diff --git a/app/controllers/datacite_dois_controller.rb b/app/controllers/datacite_dois_controller.rb index da1fe4e66..a6a23566c 100644 --- a/app/controllers/datacite_dois_controller.rb +++ b/app/controllers/datacite_dois_controller.rb @@ -460,6 +460,9 @@ def show publisher: params[:publisher], include_other_registration_agencies: params[:include_other_registration_agencies], } + # Preload events since we are going to show details + # This optimizes the serializer which accesses part_events, citation_events, etc. + EventsPreloader.new([doi]).preload! render( json: DataciteDoiSerializer.new(doi, options).serializable_hash.to_json, diff --git a/app/models/concerns/preloaded_event_relation.rb b/app/models/concerns/preloaded_event_relation.rb new file mode 100644 index 000000000..2c41d28ac --- /dev/null +++ b/app/models/concerns/preloaded_event_relation.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +# Wrapper class to make preloaded event arrays compatible with ActiveRecord::Relation API +# This allows existing code that calls methods like `pluck`, `map`, `select` to work +# with in-memory arrays without modification. +class PreloadedEventRelation + include Enumerable + + def initialize(events) + @events = Array(events) + end + + # Delegate Enumerable methods to the underlying array + def each(&block) + @events.each(&block) + end + + # Delegate common Enumerable methods explicitly + def first + @events.first + end + + def last + @events.last + end + + def count + @events.count + end + + # Implement pluck to match ActiveRecord::Relation#pluck behavior + # Supports single or multiple column names + def pluck(*column_names) + if column_names.length == 1 + column_name = column_names.first + @events.map { |event| event.public_send(column_name) } + else + @events.map { |event| column_names.map { |col| event.public_send(col) } } + end + end + + # Delegate map to the underlying array + def map(&block) + @events.map(&block) + end + + # Delegate select to the underlying array + def select(&block) + PreloadedEventRelation.new(@events.select(&block)) + end + + # Delegate other common Enumerable methods + def compact + PreloadedEventRelation.new(@events.compact) + end + + def uniq + PreloadedEventRelation.new(@events.uniq) + end + + def sort_by(&block) + PreloadedEventRelation.new(@events.sort_by(&block)) + end + + def group_by(&block) + @events.group_by(&block) + end + + def inject(*args, &block) + @events.inject(*args, &block) + end + + def length + @events.length + end + + def empty? + @events.empty? + end + + def present? + @events.present? + end + + def blank? + @events.blank? + end + + # Allow direct access to the underlying array + def to_a + @events + end + + def to_ary + @events + end +end diff --git a/app/models/datacite_doi.rb b/app/models/datacite_doi.rb index 1e67ec85e..566f81335 100644 --- a/app/models/datacite_doi.rb +++ b/app/models/datacite_doi.rb @@ -144,19 +144,14 @@ def self.import_in_bulk(ids, options = {}) # get database records from array of database ids selected_dois = DataciteDoi.where(id: ids, type: "DataciteDoi").includes( - :client, + { client: :provider }, :media, - :view_events, - :download_events, - :citation_events, - :reference_events, - :part_events, - :part_of_events, - :version_events, - :version_of_events, :metadata ) selected_dois.find_in_batches(batch_size: batch_size) do |dois| + # Preload all events for this batch in a single query + EventsPreloader.new(dois).preload! + bulk_body = dois.map do |doi| { index: { diff --git a/app/models/doi.rb b/app/models/doi.rb index b76e5a569..0ec094a88 100644 --- a/app/models/doi.rb +++ b/app/models/doi.rb @@ -2,6 +2,7 @@ require "maremma" require "benchmark" +require_relative "concerns/preloaded_event_relation" class Doi < ApplicationRecord self.ignored_columns += [:publisher] @@ -76,6 +77,7 @@ class Doi < ApplicationRecord alias_attribute :state, :aasm_state attr_accessor :current_user + attr_accessor :preloaded_events attribute :regenerate, :boolean, default: false attribute :only_validate, :boolean, default: false @@ -100,6 +102,73 @@ class Doi < ApplicationRecord # has_many :source_events, class_name: "Event", primary_key: :doi, foreign_key: :source_doi, dependent: :destroy # has_many :target_events, class_name: "Event", primary_key: :doi, foreign_key: :target_doi, dependent: :destroy + # Override association methods to use preloaded_events when available + # Check for !nil instead of present? to handle empty arrays (preloaded but no events) + # Also filter by source_doi/target_doi to match association behavior + def view_events + if !preloaded_events.nil? + filtered_preloaded_events(:target_relation_type_id, "views", :target_doi) + else + association(:view_events).scope + end + end + + def download_events + if !preloaded_events.nil? + filtered_preloaded_events(:target_relation_type_id, "downloads", :target_doi) + else + association(:download_events).scope + end + end + + def reference_events + if !preloaded_events.nil? + filtered_preloaded_events(:source_relation_type_id, "references", :source_doi) + else + association(:reference_events).scope + end + end + + def citation_events + if !preloaded_events.nil? + filtered_preloaded_events(:target_relation_type_id, "citations", :target_doi) + else + association(:citation_events).scope + end + end + + def part_events + if !preloaded_events.nil? + filtered_preloaded_events(:source_relation_type_id, "parts", :source_doi) + else + association(:part_events).scope + end + end + + def part_of_events + if !preloaded_events.nil? + filtered_preloaded_events(:target_relation_type_id, "part_of", :target_doi) + else + association(:part_of_events).scope + end + end + + def version_events + if !preloaded_events.nil? + filtered_preloaded_events(:source_relation_type_id, "versions", :source_doi) + else + association(:version_events).scope + end + end + + def version_of_events + if !preloaded_events.nil? + filtered_preloaded_events(:target_relation_type_id, "version_of", :target_doi) + else + association(:version_of_events).scope + end + end + has_many :references, class_name: "Doi", through: :reference_events, source: :doi_for_target has_many :citations, class_name: "Doi", through: :citation_events, source: :doi_for_source has_many :parts, class_name: "Doi", through: :part_events, source: :doi_for_target @@ -2781,6 +2850,15 @@ def handle_resource_type(types) end private + def filtered_preloaded_events(relation_type_key, relation_type_value, doi_key) + PreloadedEventRelation.new( + preloaded_events.select do |e| + e.public_send(relation_type_key) == relation_type_value && + e.public_send(doi_key)&.upcase == doi.upcase + end + ) + end + def update_publisher_from_hash symbolized_publisher_hash = publisher_before_type_cast.symbolize_keys if !symbolized_publisher_hash.values.all?(nil) diff --git a/app/models/other_doi.rb b/app/models/other_doi.rb index 666ca6a80..400f11d24 100644 --- a/app/models/other_doi.rb +++ b/app/models/other_doi.rb @@ -148,19 +148,14 @@ def self.import_in_bulk(ids, options = {}) # get database records from array of database ids selected_dois = OtherDoi.where(id: ids, type: "OtherDoi").includes( - :client, + { client: :provider }, :media, - :view_events, - :download_events, - :citation_events, - :reference_events, - :part_events, - :part_of_events, - :version_events, - :version_of_events, :metadata ) selected_dois.find_in_batches(batch_size: batch_size) do |dois| + # Preload all events for this batch in a single query + EventsPreloader.new(dois).preload! + bulk_body = dois.map do |doi| { index: { diff --git a/app/models/reference_repository.rb b/app/models/reference_repository.rb index 089100fbc..85c91181c 100644 --- a/app/models/reference_repository.rb +++ b/app/models/reference_repository.rb @@ -355,6 +355,11 @@ def filter(options) "repository_type": options[:repository_type].split(",") } } end + if options[:provider_id].present? + retval << { term: { + "provider_id": { value: options[:provider_id], case_insensitive: true } + } } + end if options[:is_open] == "true" retval << { term: { "data_access.type": "open" diff --git a/app/services/events_preloader.rb b/app/services/events_preloader.rb new file mode 100644 index 000000000..313f8b723 --- /dev/null +++ b/app/services/events_preloader.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Service class to preload events for a batch of DOIs in a single query +# This dramatically reduces database queries from N*M (DOIs * Relationship Types) to 1-2 queries total +class EventsPreloader + # Maximum number of DOIs to query at once to avoid database parameter limits + CHUNK_SIZE = 1000 + + def initialize(dois) + @dois = Array(dois) + @doi_map = {} + @dois.each do |doi| + next if doi.doi.blank? + @doi_map[doi.doi.upcase] = doi + # Initialize preloaded_events array if not already set + doi.preloaded_events ||= [] + end + end + + # Preload all events for the batch of DOIs + def preload! + return if @dois.empty? + + doi_identifiers = @dois.filter_map { |doi| doi.doi&.upcase }.uniq + return if doi_identifiers.empty? + + # Fetch events in chunks to avoid database parameter limits + all_events = [] + doi_identifiers.each_slice(CHUNK_SIZE) do |chunk| + events = Event.where( + "source_doi IN (?) OR target_doi IN (?)", + chunk, chunk + ).order(updated_at: :desc).to_a + all_events.concat(events) + end + all_events.uniq! + + # Group events by DOI and assign to each Doi object + all_events.each do |event| + # Add event to source DOI's preloaded_events if it matches + if event.source_doi.present? + source_doi_obj = @doi_map[event.source_doi.upcase] + source_doi_obj.preloaded_events << event if source_doi_obj + end + + # Add event to target DOI's preloaded_events if it matches + if event.target_doi.present? + target_doi_obj = @doi_map[event.target_doi.upcase] + if target_doi_obj && target_doi_obj != source_doi_obj + target_doi_obj.preloaded_events << event + end + end + end + + # Ensure all DOIs have an array (even if empty) + @dois.each do |doi| + doi.preloaded_events ||= [] + end + + self + end +end diff --git a/spec/models/doi_related_spec.rb b/spec/models/doi_related_spec.rb index 0e59658c4..5368100b7 100644 --- a/spec/models/doi_related_spec.rb +++ b/spec/models/doi_related_spec.rb @@ -23,15 +23,38 @@ describe "N+1 safety" do describe ".import_in_bulk" do - let(:ids) { [1, 2, 3] } + let(:client) { create(:client) } + let!(:doi1) { create(:doi, client: client, type: "DataciteDoi", aasm_state: "findable") } + let!(:doi2) { create(:doi, client: client, type: "DataciteDoi", aasm_state: "findable") } + let!(:doi3) { create(:doi, client: client, type: "DataciteDoi", aasm_state: "findable") } + let(:ids) { [doi1.id, doi2.id, doi3.id] } - it "should make only one db call" do + it "should use EventsPreloader to reduce queries" do allow(DataciteDoi).to receive(:upload_to_elasticsearch) - # Test the maximum number of queries made by the method + # With EventsPreloader, we should make minimal queries + # 1 query for DOIs, 1 query for events (via EventsPreloader), plus associations expect { DataciteDoi.import_in_bulk(ids) - }.not_to exceed_query_limit(1) + }.not_to exceed_query_limit(6) # Allow some overhead for associations (client, media, metadata, allocator) + end + + it "uses EventsPreloader to preload events for each batch" do + # Arrange + allow(DataciteDoi).to receive(:upload_to_elasticsearch) # don’t actually hit ES + # Spy on EventsPreloader + preloader_double = instance_double(EventsPreloader, preload!: true) + allow(EventsPreloader).to receive(:new).and_return(preloader_double) + # Act + DataciteDoi.import_in_bulk(ids) + # Assert + # Ensure we created a preloader at least once with an array of DOIs + expect(EventsPreloader).to have_received(:new) do |dois_arg| + expect(dois_arg).to all(be_a(DataciteDoi)) + expect(dois_arg.map(&:id)).to match_array(ids) # or a subset if multiple batches + end + # And that preload! was actually invoked on that preloader + expect(preloader_double).to have_received(:preload!).at_least(:once) end end @@ -39,23 +62,35 @@ let(:client) { create(:client) } let(:doi) { create(:doi, client: client, aasm_state: "findable") } - it "should make few db call" do + it "uses preloaded_events-backed relations for event metrics" do + allow(DataciteDoi).to receive(:upload_to_elasticsearch) + # Build the relation with includes, as in production + dois = DataciteDoi.where(id: doi.id).includes({ client: :provider }, :media, :metadata) + # Preload events explicitly + preloader = EventsPreloader.new(dois.to_a) + preloader.preload! + # Sanity: events are preloaded on the instance we’ll index + expect(dois.first.preloaded_events).not_to be_nil + # When we call as_indexed_json, the overridden *_events methods should + # hit the preloaded array, not issue extra queries for events. + expect { + dois.first.as_indexed_json + }.not_to exceed_query_limit(0) # if all associations were pre-included + end + + it "should make few db calls" do allow(DataciteDoi).to receive(:upload_to_elasticsearch) dois = DataciteDoi.where(id: doi.id).includes( :client, :media, - :view_events, - :download_events, - :citation_events, - :reference_events, - :part_events, - :part_of_events, - :version_events, - :version_of_events, :metadata ) + # Preload events to avoid N+1 queries + EventsPreloader.new(dois.to_a).preload! + # Test the maximum number of queries made by the method + # With EventsPreloader, we should have fewer queries expect { dois.first.as_indexed_json }.not_to exceed_query_limit(13) @@ -308,4 +343,70 @@ expect(doi.other_relation_count).to eq(3) end end + + describe "backward compatibility with preloaded_events" do + let(:client) { create(:client) } + let(:doi) { create(:doi, client: client, aasm_state: "findable") } + let(:target_doi) { create(:doi, client: client, aasm_state: "findable") } + let(:source_doi) { create(:doi, client: client, aasm_state: "findable") } + let!(:reference_event) do + create(:event_for_crossref, { + subj_id: "https://doi.org/#{doi.doi}", + obj_id: "https://doi.org/#{target_doi.doi}", + relation_type_id: "references", + }) + end + # For citation_events, the DOI must be the target (target_doi) + # For "is-referenced-by", target_doi = subj_id, so doi needs to be subj_id + let!(:citation_event) do + create(:event_for_datacite_crossref, { + subj_id: "https://doi.org/#{doi.doi}", + obj_id: "https://doi.org/#{source_doi.doi}", + relation_type_id: "is-referenced-by", + }) + end + + it "works the same when preloaded_events is nil (fallback to database)" do + expect(doi.preloaded_events).to be_nil + expect(doi.reference_events.count).to eq(1) + expect(doi.citation_events.count).to eq(1) + expect(doi.reference_count).to eq(1) + expect(doi.citation_count).to eq(1) + end + + it "works the same when preloaded_events is set (uses in-memory data)" do + EventsPreloader.new([doi]).preload! + + expect(doi.preloaded_events).not_to be_nil + expect(doi.reference_events.count).to eq(1) + expect(doi.citation_events.count).to eq(1) + expect(doi.reference_count).to eq(1) + expect(doi.citation_count).to eq(1) + expect(doi.reference_ids).to include(target_doi.doi.downcase) + end + + it "returns same results whether preloaded or not" do + # Get results without preloading + reference_ids_without_preload = doi.reference_ids + citation_ids_without_preload = doi.citation_ids + reference_count_without_preload = doi.reference_count + citation_count_without_preload = doi.citation_count + + # Reload and preload + doi.reload + EventsPreloader.new([doi]).preload! + + # Get results with preloading + reference_ids_with_preload = doi.reference_ids + citation_ids_with_preload = doi.citation_ids + reference_count_with_preload = doi.reference_count + citation_count_with_preload = doi.citation_count + + # Should be the same + expect(reference_ids_with_preload).to match_array(reference_ids_without_preload) + expect(citation_ids_with_preload).to match_array(citation_ids_without_preload) + expect(reference_count_with_preload).to eq(reference_count_without_preload) + expect(citation_count_with_preload).to eq(citation_count_without_preload) + end + end end diff --git a/spec/models/preloaded_event_relation_spec.rb b/spec/models/preloaded_event_relation_spec.rb new file mode 100644 index 000000000..8a998f212 --- /dev/null +++ b/spec/models/preloaded_event_relation_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe PreloadedEventRelation do + let(:event1) { double("Event", id: 1, target_doi: "10.1234/TEST1", source_doi: "10.1234/TEST2", total: 10) } + let(:event2) { double("Event", id: 2, target_doi: "10.1234/TEST2", source_doi: "10.1234/TEST3", total: 20) } + let(:event3) { double("Event", id: 3, target_doi: "10.1234/TEST1", source_doi: nil, total: 30) } + let(:events) { [event1, event2, event3] } + let(:relation) { PreloadedEventRelation.new(events) } + + describe "#pluck" do + it "plucks a single column" do + expect(relation.pluck(:id)).to eq([1, 2, 3]) + expect(relation.pluck(:total)).to eq([10, 20, 30]) + end + + it "plucks multiple columns" do + result = relation.pluck(:id, :total) + expect(result).to eq([[1, 10], [2, 20], [3, 30]]) + end + + it "handles nil values" do + expect(relation.pluck(:source_doi)).to eq(["10.1234/TEST2", "10.1234/TEST3", nil]) + end + end + + describe "#map" do + it "maps over events" do + result = relation.map { |e| e.id * 2 } + expect(result).to eq([2, 4, 6]) + end + end + + describe "#select" do + it "filters events and returns PreloadedEventRelation" do + result = relation.select { |e| e.total > 15 } + expect(result).to be_a(PreloadedEventRelation) + expect(result.to_a).to eq([event2, event3]) + end + end + + describe "#compact" do + let(:events_with_nils) { [event1, nil, event2] } + let(:relation_with_nils) { PreloadedEventRelation.new(events_with_nils) } + + it "removes nil values" do + result = relation_with_nils.compact + expect(result).to be_a(PreloadedEventRelation) + expect(result.to_a).to eq([event1, event2]) + end + end + + describe "#uniq" do + let(:duplicate_events) { [event1, event1, event2] } + let(:relation_with_dups) { PreloadedEventRelation.new(duplicate_events) } + + it "removes duplicate events" do + result = relation_with_dups.uniq + expect(result).to be_a(PreloadedEventRelation) + expect(result.to_a).to eq([event1, event2]) + end + end + + describe "#sort_by" do + it "sorts events" do + result = relation.sort_by { |e| -e.total } + expect(result).to be_a(PreloadedEventRelation) + expect(result.to_a.map(&:total)).to eq([30, 20, 10]) + end + end + + describe "#group_by" do + it "groups events" do + result = relation.group_by { |e| e.target_doi } + expect(result.keys).to include("10.1234/TEST1", "10.1234/TEST2") + expect(result["10.1234/TEST1"].length).to eq(2) + end + end + + describe "#inject" do + it "reduces events" do + result = relation.inject(0) { |sum, e| sum + e.total } + expect(result).to eq(60) + end + end + + describe "#length" do + it "returns the number of events" do + expect(relation.length).to eq(3) + end + end + + describe "#empty?" do + it "returns false when events exist" do + expect(relation.empty?).to be false + end + + it "returns true when no events" do + empty_relation = PreloadedEventRelation.new([]) + expect(empty_relation.empty?).to be true + end + end + + describe "#present?" do + it "returns true when events exist" do + expect(relation.present?).to be true + end + + it "returns false when no events" do + empty_relation = PreloadedEventRelation.new([]) + expect(empty_relation.present?).to be false + end + end + + describe "#blank?" do + it "returns false when events exist" do + expect(relation.blank?).to be false + end + + it "returns true when no events" do + empty_relation = PreloadedEventRelation.new([]) + expect(empty_relation.blank?).to be true + end + end + + describe "#to_a" do + it "returns the underlying array" do + expect(relation.to_a).to eq(events) + end + end + + describe "Enumerable methods" do + it "implements each" do + collected = [] + relation.each { |e| collected << e.id } + expect(collected).to eq([1, 2, 3]) + end + + it "works with Enumerable methods" do + expect(relation.first).to eq(event1) + expect(relation.last).to eq(event3) + expect(relation.count).to eq(3) + end + end +end diff --git a/spec/requests/repositories_spec.rb b/spec/requests/repositories_spec.rb index cd5037bf5..2feb7e1f2 100644 --- a/spec/requests/repositories_spec.rb +++ b/spec/requests/repositories_spec.rb @@ -16,6 +16,8 @@ def reset_indices clear_index(DataciteDoi) clear_index(Client) clear_index(Provider) + clear_index(ReferenceRepository) + import_index(ReferenceRepository) import_index(Provider) import_index(Client) import_index(DataciteDoi) diff --git a/spec/services/events_preloader_spec.rb b/spec/services/events_preloader_spec.rb new file mode 100644 index 000000000..d92e1d262 --- /dev/null +++ b/spec/services/events_preloader_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe EventsPreloader do + let(:client) { create(:client) } + let(:doi1) { create(:doi, client: client, doi: "10.1234/TEST1", aasm_state: "findable") } + let(:doi2) { create(:doi, client: client, doi: "10.1234/TEST2", aasm_state: "findable") } + let(:doi3) { create(:doi, client: client, doi: "10.1234/TEST3", aasm_state: "findable") } + + describe "#initialize" do + it "initializes preloaded_events for each DOI" do + dois = [doi1, doi2] + EventsPreloader.new(dois) + + expect(doi1.preloaded_events).to eq([]) + expect(doi2.preloaded_events).to eq([]) + end + end + + describe "#preload!" do + context "with empty array" do + it "does not raise an error" do + preloader = EventsPreloader.new([]) + expect { preloader.preload! }.not_to raise_error + end + end + + context "with source and target events" do + let!(:reference_event) do + create(:event_for_crossref, { + subj_id: "https://doi.org/#{doi1.doi}", + obj_id: "https://doi.org/#{doi2.doi}", + relation_type_id: "references", + }) + end + # For citation_events, the DOI must be the target (target_doi) + # For "is-referenced-by", target_doi = subj_id, so doi1 needs to be subj_id + let!(:citation_event) do + create(:event_for_datacite_crossref, { + subj_id: "https://doi.org/#{doi1.doi}", + obj_id: "https://doi.org/#{doi3.doi}", + relation_type_id: "is-referenced-by", + }) + end + let!(:part_event) do + create(:event_for_datacite_parts, { + subj_id: "https://doi.org/#{doi1.doi}", + obj_id: "https://doi.org/#{doi3.doi}", + relation_type_id: "has-part", + }) + end + + it "loads all events for the DOIs" do + dois = [doi1, doi2, doi3] + EventsPreloader.new(dois).preload! + + # doi1 should have reference_event (as source), citation_event (as target), and part_event (as source) + expect(doi1.preloaded_events.length).to eq(3) + expect(doi1.preloaded_events).to include(reference_event, citation_event, part_event) + + # doi2 should have reference_event (as target) + expect(doi2.preloaded_events.length).to eq(1) + expect(doi2.preloaded_events).to include(reference_event) + + # doi3 should have citation_event (as source) and part_event (as target) + expect(doi3.preloaded_events.length).to eq(2) + expect(doi3.preloaded_events).to include(citation_event, part_event) + end + + it "makes only one database query" do + dois = [doi1, doi2, doi3] + preloader = EventsPreloader.new(dois) + + expect { + preloader.preload! + }.not_to exceed_query_limit(1) + end + end + + context "with no events" do + it "sets empty arrays for all DOIs" do + dois = [doi1, doi2] + EventsPreloader.new(dois).preload! + + expect(doi1.preloaded_events).to eq([]) + expect(doi2.preloaded_events).to eq([]) + end + end + + context "with large batch" do + it "chunks large DOI lists" do + # Create more than CHUNK_SIZE DOIs + large_batch = create_list(:doi, EventsPreloader::CHUNK_SIZE + 100, client: client, aasm_state: "findable") + + # Create events for some of them + create(:event_for_crossref, { + subj_id: "https://doi.org/#{large_batch.first.doi}", + obj_id: "https://doi.org/#{large_batch.last.doi}", + relation_type_id: "references", + }) + + expect { + EventsPreloader.new(large_batch).preload! + }.not_to raise_error + + expect(large_batch.first.preloaded_events).not_to be_nil + end + end + + context "with case-insensitive DOI matching" do + let!(:event) do + create(:event_for_crossref, { + subj_id: "https://doi.org/#{doi1.doi.downcase}", + obj_id: "https://doi.org/#{doi2.doi.downcase}", + relation_type_id: "references", + }) + end + + it "matches DOIs regardless of case" do + dois = [doi1, doi2] + EventsPreloader.new(dois).preload! + + expect(doi1.preloaded_events).to include(event) + expect(doi2.preloaded_events).to include(event) + end + end + end + + describe "integration with Doi model" do + let!(:reference_event) do + create(:event_for_crossref, { + subj_id: "https://doi.org/#{doi1.doi}", + obj_id: "https://doi.org/#{doi2.doi}", + relation_type_id: "references", + }) + end + # For citation_events, the DOI must be the target (target_doi) + # For "is-referenced-by", target_doi = subj_id, so doi1 needs to be subj_id + let!(:citation_event) do + create(:event_for_datacite_crossref, { + subj_id: "https://doi.org/#{doi1.doi}", + obj_id: "https://doi.org/#{doi3.doi}", + relation_type_id: "is-referenced-by", + }) + end + + it "allows Doi methods to use preloaded events" do + dois = [doi1, doi2, doi3] + EventsPreloader.new(dois).preload! + + # These should use preloaded_events instead of querying the database + expect(doi1.reference_events.to_a).to include(reference_event) + expect(doi1.citation_events.to_a).to include(citation_event) + expect(doi1.reference_count).to eq(1) + expect(doi1.citation_count).to eq(1) + end + + it "maintains backward compatibility when preloaded_events is nil" do + # When preloaded_events is nil, should fall back to database queries + expect(doi1.preloaded_events).to be_nil + expect(doi1.reference_events.to_a).to include(reference_event) + expect(doi1.citation_events.to_a).to include(citation_event) + end + end +end