From c082add4810196b03eefaf68bba2552179ba9f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Wed, 13 May 2026 11:01:15 -0300 Subject: [PATCH 01/12] Support eager_load for associations defined only on a subclass --- lib/mongoid/association/eager_loadable.rb | 41 ++--- lib/mongoid/criteria/includable.rb | 40 +++-- spec/mongoid/criteria/includable_spec.rb | 183 ++++++++++++++++++++++ 3 files changed, 230 insertions(+), 34 deletions(-) diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index d46d4c3f44..0fa5b66597 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -104,31 +104,22 @@ def preload(associations, docs) # The associations to load. # @param [ Array ] docs The documents. def preload_for_lookup(criteria) - assoc_map = criteria.inclusions.group_by(&:inverse_class_name) + inclusions = criteria.inclusions + assoc_map = inclusions.group_by(&:inverse_class_name) # match first pipeline = criteria.selector.to_pipeline # then sort, skip, limit pipeline.concat(criteria.options.to_pipeline_for_lookup) - # account for single-collection inheritance - root_class = klass.root_class - - if assoc_map[klass.to_s] - assoc_map[klass.to_s].each do |assoc| - # Create a copy of the mapping for each top-level association to avoid mutation issues - pipeline << create_pipeline(assoc, assoc_map.dup) - end - end - - if klass != root_class && assoc_map[root_class.to_s] - assoc_map[root_class.to_s].each do |assoc| - # Create a copy of the mapping for each top-level association to avoid mutation issues - pipeline << create_pipeline(assoc, assoc_map.dup) - end + # Emit a $lookup for each top-level inclusion, in declaration order. + # Nested inclusions are emitted inside their parent's $lookup + # sub-pipeline by create_pipeline. + inclusions.select { |assoc| assoc.parent_inclusions.empty? }.each do |assoc| + pipeline << create_pipeline(assoc, assoc_map.dup) end - Eager.new(criteria.inclusions, [], true, pipeline).run + Eager.new(inclusions, [], true, pipeline).run end private @@ -193,14 +184,14 @@ def create_pipeline(current_assoc, mapping) pipeline_stages << { '$sort' => { '_id' => 1 } } end - # Add nested lookups for child associations - # Child associations don't need the embedded_path prefix since they're referenced from the looked-up document - # Remove this class from the mapping to prevent infinite loops with circular references - class_name = current_assoc.klass.to_s - if child_assocs = mapping.delete(class_name) - child_assocs.each do |child| - pipeline_stages << create_pipeline(child, mapping) - end + # Add nested lookups for child associations declared on the looked-up + # class or any of its subclasses, so subclass-only nested associations + # are reached too. Deleting each class from the mapping prevents + # infinite loops with circular references. + [ current_assoc.klass, *current_assoc.klass.descendants ].each do |child_class| + next unless child_assocs = mapping.delete(child_class.to_s) + + child_assocs.each { |child| pipeline_stages << create_pipeline(child, mapping) } end # Always add pipeline since we always have at least $sort diff --git a/lib/mongoid/criteria/includable.rb b/lib/mongoid/criteria/includable.rb index c5cbe5c408..f75a97f204 100644 --- a/lib/mongoid/criteria/includable.rb +++ b/lib/mongoid/criteria/includable.rb @@ -96,20 +96,42 @@ def add_inclusion(association, parent = nil) # The names of the association(s) to eager load. def extract_includes_list(_parent_class, parent, is_eager_load = false, *relations_list) relations_list.flatten.each do |relation_object| - if relation_object.is_a?(Hash) - relation_object.each do |relation, _includes| - association = _parent_class.reflect_on_association(relation) - raise_eager_error(is_eager_load, _klass, relation) unless association + # Normalize a bare association name to a hash with no nested + # inclusions, so both forms share one resolution path below. + relations = relation_object.is_a?(Hash) ? relation_object : { relation_object => nil } + + relations.each do |relation, nested| + associations = resolve_inclusion_associations(_parent_class, relation, is_eager_load) + raise_eager_error(is_eager_load, _parent_class, relation) if associations.empty? + + associations.each do |association| add_inclusion(association, parent) - extract_includes_list(association.klass, association.name, is_eager_load, _includes) + extract_includes_list(association.klass, association.name, is_eager_load, nested) if nested end - else - association = _parent_class.reflect_on_association(relation_object) - raise_eager_error(is_eager_load, _parent_class, relation_object) unless association - add_inclusion(association, parent) end end end + + # Resolve the association(s) matching the given relation name. For the + # regular #includes path, only the parent class is consulted. For the + # #eager_load ($lookup) path, its subclasses are consulted as well, so + # associations defined only on a subclass can also be eager-loaded when + # querying through the superclass. + # + # @param [ Class ] parent_class The class to start the lookup from. + # @param [ Symbol | String ] relation The association name. + # @param [ Boolean ] is_eager_load Whether to consider subclasses. + # + # @return [ Array ] Matching associations. + def resolve_inclusion_associations(parent_class, relation, is_eager_load) + if association = parent_class.reflect_on_association(relation) + return [ association ] + end + + return [] unless is_eager_load + + parent_class.descendants.filter_map { |sub| sub.reflect_on_association(relation) } + end end def raise_eager_error(is_eager_load, klass, relation) diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index 66f7476a3f..02e820379f 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -1414,6 +1414,189 @@ class C # 4.4 lookup does not support the lookup pipeline as it is currently written min_server_version '5.0' it_behaves_like 'eager loading', :eager_load + + context 'when the association is defined on a subclass of the queried class' do + before(:all) do + class Machine + include Mongoid::Document + end + + class Peripheral + include Mongoid::Document + end + + class Webcam < Peripheral + end + + class Mouse < Peripheral + belongs_to :machine + end + end + + after(:all) do + Object.send(:remove_const, :Mouse) + Object.send(:remove_const, :Webcam) + Object.send(:remove_const, :Peripheral) + Object.send(:remove_const, :Machine) + end + + let!(:machine) { Machine.create! } + let!(:webcam) { Webcam.create! } + let!(:mouse) { Mouse.create!(machine: machine) } + + it 'eager-loads it through the queried superclass' do + expect_query(1) do + loaded = Peripheral.eager_load(:machine).detect { |doc| doc.is_a?(Mouse) } + expect(loaded.machine).to eq(machine) + end + end + end + + context 'when the association is defined on a subclass of an associated class' do + before(:all) do + class Brand + include Mongoid::Document + end + + class Machine + include Mongoid::Document + end + + class Laptop < Machine + belongs_to :brand + end + + class Peripheral + include Mongoid::Document + end + + class Mouse < Peripheral + belongs_to :machine + end + end + + after(:all) do + Object.send(:remove_const, :Mouse) + Object.send(:remove_const, :Peripheral) + Object.send(:remove_const, :Laptop) + Object.send(:remove_const, :Machine) + Object.send(:remove_const, :Brand) + end + + let!(:brand) { Brand.create! } + let!(:laptop) { Laptop.create!(brand: brand) } + let!(:mouse) { Mouse.create!(machine: laptop) } + + it 'eager-loads it through the parent association' do + expect_query(1) do + loaded = Peripheral.eager_load(machine: :brand).first + expect(loaded.machine).to eq(laptop) + expect(loaded.machine.brand).to eq(brand) + end + end + end + + context 'when a has_many is defined only on a subclass' do + before(:all) do + class Gadget + include Mongoid::Document + end + + class Speaker < Gadget + has_many :cables + end + + class Cable + include Mongoid::Document + + belongs_to :speaker + end + end + + after(:all) do + Object.send(:remove_const, :Cable) + Object.send(:remove_const, :Speaker) + Object.send(:remove_const, :Gadget) + end + + let!(:speaker) { Speaker.create! } + let!(:cable) { Cable.create!(speaker: speaker) } + + it 'eager-loads it through the queried superclass' do + expect_query(1) do + loaded = Gadget.eager_load(:cables).first + expect(loaded.cables).to eq([ cable ]) + end + end + end + + context 'when a has_one is defined only on a subclass' do + before(:all) do + class Gadget + include Mongoid::Document + end + + class Speaker < Gadget + has_one :cable + end + + class Cable + include Mongoid::Document + + belongs_to :speaker + end + end + + after(:all) do + Object.send(:remove_const, :Cable) + Object.send(:remove_const, :Speaker) + Object.send(:remove_const, :Gadget) + end + + let!(:speaker) { Speaker.create! } + let!(:cable) { Cable.create!(speaker: speaker) } + + it 'eager-loads it through the queried superclass' do + expect_query(1) do + loaded = Gadget.eager_load(:cable).first + expect(loaded.cable).to eq(cable) + end + end + end + + context 'when a has_and_belongs_to_many is defined only on a subclass' do + before(:all) do + class Cable + include Mongoid::Document + + has_and_belongs_to_many :speakers + end + + class Gadget + include Mongoid::Document + end + + class Speaker < Gadget + has_and_belongs_to_many :cables + end + end + + after(:all) do + Object.send(:remove_const, :Speaker) + Object.send(:remove_const, :Gadget) + Object.send(:remove_const, :Cable) + end + + let!(:cable) { Cable.create! } + let!(:speaker) { Speaker.create!(cables: [ cable ]) } + + it 'eager-loads it through the queried superclass' do + expect_query(1) do + loaded = Gadget.eager_load(:cables).first + expect(loaded.cables).to eq([ cable ]) + end + end + end end describe '#inclusions' do From d171b96ee7538b4760f9bac186f43784d4cd0479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Sat, 13 Jun 2026 20:25:04 -0300 Subject: [PATCH 02/12] Support eager_load for belongs_to inside embedded documents --- lib/mongoid/association/eager_loadable.rb | 148 +++++++- spec/mongoid/criteria/includable_spec.rb | 438 ++++++++++++++++++++++ 2 files changed, 569 insertions(+), 17 deletions(-) diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index 0fa5b66597..2a71e3a812 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -106,22 +106,136 @@ def preload(associations, docs) def preload_for_lookup(criteria) inclusions = criteria.inclusions assoc_map = inclusions.group_by(&:inverse_class_name) + inclusions_by_name = {} + inclusions.each { |a| inclusions_by_name[a.name] = a } # match first pipeline = criteria.selector.to_pipeline # then sort, skip, limit pipeline.concat(criteria.options.to_pipeline_for_lookup) - # Emit a $lookup for each top-level inclusion, in declaration order. - # Nested inclusions are emitted inside their parent's $lookup - # sub-pipeline by create_pipeline. - inclusions.select { |assoc| assoc.parent_inclusions.empty? }.each do |assoc| - pipeline << create_pipeline(assoc, assoc_map.dup) + # Walk every inclusion in declaration order and let each one decide + # what to emit: a referenced inclusion emits a $lookup (prefixed with + # the embedded ancestor path when it lives inside an embedded doc), an + # embedded inclusion is a passthrough, and an inclusion nested under a + # referenced parent is emitted inside that parent's sub-pipeline by + # create_pipeline. + inclusions.each do |assoc| + add_inclusion_to_pipeline(pipeline, assoc, inclusions_by_name, assoc_map) end Eager.new(inclusions, [], true, pipeline).run end + def add_inclusion_to_pipeline(pipeline, assoc, inclusions_by_name, assoc_map) + # An embedded inclusion rides inside its document; it needs no $lookup. + return if assoc.embedded? + + # A referenced parent already nests this inclusion in its sub-pipeline. + parent = inclusions_by_name[assoc.parent_inclusions.first] + return if parent && !parent.embedded? + + chains = embedded_ancestor_chains(assoc, inclusions_by_name) + return pipeline << create_pipeline(assoc, assoc_map.dup) if chains.empty? + + # The same inclusion can sit under several embedded parents (e.g. two + # embeds_one of the same class); graft a fresh $lookup onto each path. + chains.each do |chain| + graft_embedded_lookup(pipeline, create_pipeline(assoc, assoc_map.dup), chain, assoc) + end + end + + def graft_embedded_lookup(pipeline, stage, chain, association) + lookup = stage['$lookup'] + path = chain.map(&:store_as).join('.') + name = association.name.to_s + # The $lookup can't write inside an embedded array, so its matches land in + # a temporary top-level field that graft_value distributes and then drops. + tmp_field = "__el_#{path.tr('.', '_')}_#{name}" + graft = { + name: name, + tmp: tmp_field, + local: lookup['localField'], + foreign: lookup['foreignField'], + match_operator: association.is_a?(Mongoid::Association::Referenced::HasAndBelongsToMany) ? '$in' : '$eq' + } + lookup['localField'] = "#{path}.#{lookup['localField']}" + lookup['as'] = tmp_field + pipeline << stage + + root = chain.first.store_as + pipeline << { '$set' => { root => graft_value(chain, "$#{root}", graft) } } + pipeline << { '$unset' => tmp_field } + end + + # An embeds_many is rebuilt with $map so each element keeps its own matches + # instead of collapsing onto the first; an embeds_one is merged in place. + def graft_value(chain, node, graft) + head, *rest = chain + many = head.is_a?(Association::Embedded::EmbedsMany) + element = many ? "$$#{head.store_as}" : node + child = + if rest.empty? + { graft[:name] => correlated_matches(graft, element) } + else + next_segment = rest.first.store_as + { next_segment => graft_value(rest, "#{element}.#{next_segment}", graft) } + end + merged = { '$mergeObjects' => [ element, child ] } + many ? { '$map' => { 'input' => node, 'as' => head.store_as, 'in' => merged } } : merged + end + + # A has_and_belongs_to_many holds an array of foreign keys, so a match + # belongs to the element when its key is contained in that array ($in); + # every other association points at a single key ($eq). + def correlated_matches(graft, element) + { '$filter' => { + 'input' => "$#{graft[:tmp]}", + 'as' => 'match', + 'cond' => { graft[:match_operator] => [ "$$match.#{graft[:foreign]}", "#{element}.#{graft[:local]}" ] } + } } + end + + # An embedded child rides inside its parent's $lookup, so instead of its own + # $lookup its referenced descendants are grafted onto the embedded path. + def nest_embedded_inclusion(embedded_assoc, pipeline_stages, mapping, chain = [ embedded_assoc ]) + child_inclusions_of(embedded_assoc, mapping).each do |child| + mapping[child.inverse_class_name] -= [ child ] + if child.embedded? + nest_embedded_inclusion(child, pipeline_stages, mapping, chain + [ child ]) + else + stage = create_pipeline(child, mapping) + graft_embedded_lookup(pipeline_stages, stage, chain, child) + end + end + end + + # Matched by parent_inclusions (the real parent-child link), not by class, so + # a sibling branch isn't pulled in when an association points at a superclass + # of the queried subclass. + def child_inclusions_of(parent, mapping) + mapping.values.flatten.select do |child| + child.parent_inclusions.include?(parent.name) + end + end + + def embedded_ancestor_chains(assoc, inclusions_by_name) + assoc.parent_inclusions.filter_map do |parent_name| + parent = inclusions_by_name[parent_name] + embedded_chain_up_to(parent, inclusions_by_name) if parent&.embedded? + end + end + + def embedded_chain_up_to(embedded_assoc, inclusions_by_name) + chain = [ embedded_assoc ] + current = embedded_assoc + while (ancestor = inclusions_by_name[current.parent_inclusions.first]) && ancestor.embedded? + chain.unshift(ancestor) + current = ancestor + end + chain + end + private # Returns the materialized documents to use when falling back from @@ -163,15 +277,12 @@ def create_pipeline(current_assoc, mapping) foreign_field = current_assoc.foreign_key end - # Build the 'as' field with embedded path prefix if needed - as_field = current_assoc.name.to_s - stage = { '$lookup' => { 'from' => current_assoc.klass.collection.name, 'localField' => local_field, 'foreignField' => foreign_field, - 'as' => as_field + 'as' => current_assoc.name.to_s } } @@ -184,14 +295,17 @@ def create_pipeline(current_assoc, mapping) pipeline_stages << { '$sort' => { '_id' => 1 } } end - # Add nested lookups for child associations declared on the looked-up - # class or any of its subclasses, so subclass-only nested associations - # are reached too. Deleting each class from the mapping prevents - # infinite loops with circular references. - [ current_assoc.klass, *current_assoc.klass.descendants ].each do |child_class| - next unless child_assocs = mapping.delete(child_class.to_s) - - child_assocs.each { |child| pipeline_stages << create_pipeline(child, mapping) } + # Nest each child inclusion, dropping it from the mapping as it is consumed + # to prevent loops with circular references. An embedded child emits no + # $lookup of its own (it rides inside this document); its referenced + # children are grafted onto the embedded path by nest_embedded_inclusion. + child_inclusions_of(current_assoc, mapping).each do |child| + mapping[child.inverse_class_name] -= [ child ] + if child.embedded? + nest_embedded_inclusion(child, pipeline_stages, mapping) + else + pipeline_stages << create_pipeline(child, mapping) + end end # Always add pipeline since we always have at least $sort diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index 02e820379f..4185e8e509 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -1597,6 +1597,444 @@ class Speaker < Gadget end end end + + context 'when a belongs_to lives inside an embedded document' do + before(:all) do + class Device + include Mongoid::Document + end + + class Port + include Mongoid::Document + + embedded_in :computer + belongs_to :device + end + + class Computer + include Mongoid::Document + + embeds_one :port + end + end + + after(:all) do + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Device) + end + + let!(:device) { Device.create! } + let!(:computer) { Computer.create!(port: Port.new(device: device)) } + + it 'eager-loads it through the embedded document' do + expect_query(1) do + loaded = Computer.eager_load(port: :device).first + expect(loaded.port.device).to eq(device) + end + end + end + + context 'when two embeds_one associations of the same class are eager-loaded' do + before(:all) do + class Device + include Mongoid::Document + end + + class Port + include Mongoid::Document + + embedded_in :computer + belongs_to :device + end + + class Computer + include Mongoid::Document + + embeds_one :left_port, class_name: 'Port' + embeds_one :right_port, class_name: 'Port' + end + end + + after(:all) do + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Device) + end + + let!(:device1) { Device.create! } + let!(:device2) { Device.create! } + let!(:computer) do + Computer.create!( + left_port: Port.new(device: device1), + right_port: Port.new(device: device2) + ) + end + + it 'eager-loads the belongs_to in both embedded documents' do + expect_query(1) do + loaded = Computer.eager_load(left_port: :device, right_port: :device).first + expect(loaded.left_port.device).to eq(device1) + expect(loaded.right_port.device).to eq(device2) + end + end + end + + context 'when a belongs_to lives inside a doubly embedded document' do + before(:all) do + class Device + include Mongoid::Document + end + + class Port + include Mongoid::Document + + embedded_in :panel + belongs_to :device + end + + class Panel + include Mongoid::Document + + embedded_in :computer + embeds_one :port + end + + class Computer + include Mongoid::Document + + embeds_one :panel + end + end + + after(:all) do + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Panel) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Device) + end + + let!(:device) { Device.create! } + let!(:computer) { Computer.create!(panel: Panel.new(port: Port.new(device: device))) } + + it 'eager-loads it through both embedded documents' do + expect_query(1) do + loaded = Computer.eager_load(panel: { port: :device }).first + expect(loaded.panel.port.device).to eq(device) + end + end + end + + context 'when a belongs_to is defined only on a subclass of an embedded document' do + before(:all) do + class Device + include Mongoid::Document + end + + class Port + include Mongoid::Document + + embedded_in :computer + end + + class UsbPort < Port + belongs_to :device + end + + class Computer + include Mongoid::Document + + embeds_one :port + end + end + + after(:all) do + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :UsbPort) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Device) + end + + let!(:device) { Device.create! } + let!(:computer) { Computer.create!(port: UsbPort.new(device: device)) } + + it 'eager-loads it through the embedded subclass' do + expect_query(1) do + loaded = Computer.eager_load(port: :device).first + expect(loaded.port.device).to eq(device) + end + end + end + + context 'when a belongs_to lives inside an embeds_many' do + before(:all) do + class Device + include Mongoid::Document + end + + class Port + include Mongoid::Document + + embedded_in :computer + belongs_to :device + end + + class Computer + include Mongoid::Document + + embeds_many :ports + end + end + + after(:all) do + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Device) + end + + let!(:device1) { Device.create! } + let!(:device2) { Device.create! } + let!(:computer) { Computer.create!(ports: [ Port.new(device: device1), Port.new(device: device2) ]) } + + it 'eager-loads each embedded belongs_to without extra queries' do + expect_query(1) do + loaded = Computer.eager_load(ports: :device).first + expect(loaded.ports.map(&:device)).to eq([ device1, device2 ]) + end + end + end + + context 'when a has_and_belongs_to_many lives inside an embedded document' do + before(:all) do + class Device + include Mongoid::Document + end + + class Port + include Mongoid::Document + + embedded_in :computer + has_and_belongs_to_many :devices + end + + class Computer + include Mongoid::Document + + embeds_one :port + end + end + + after(:all) do + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Device) + end + + let!(:device1) { Device.create! } + let!(:device2) { Device.create! } + let!(:computer) { Computer.create!(port: Port.new(devices: [ device1, device2 ])) } + + it 'eager-loads the devices referenced by the embedded port' do + expect_query(1) do + loaded = Computer.eager_load(port: :devices).first + expect(loaded.port.devices).to eq([ device1, device2 ]) + end + end + end + + context 'when an embedded belongs_to targets a superclass of the queried subclass' do + before(:all) do + class Device + include Mongoid::Document + end + + class Computer < Device + embeds_one :port + end + + class Port + include Mongoid::Document + + embedded_in :computer + belongs_to :device + end + end + + after(:all) do + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Device) + end + + let!(:device) { Device.create! } + let!(:computer) { Computer.create!(port: Port.new(device: device)) } + + it 'eager-loads the embedded belongs_to through the queried subclass' do + expect_query(1) do + loaded = Computer.eager_load(port: :device).first + expect(loaded.port.device).to eq(device) + end + end + end + + context 'when an embedded association is nested under a referenced one' do + before(:all) do + class Device + include Mongoid::Document + end + + class Port + include Mongoid::Document + + embedded_in :computer + belongs_to :device + end + + class Computer + include Mongoid::Document + + embeds_one :port + end + + class Desk + include Mongoid::Document + + belongs_to :computer + end + end + + after(:all) do + Object.send(:remove_const, :Desk) + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Device) + end + + let!(:device) { Device.create! } + let!(:computer) { Computer.create!(port: Port.new(device: device)) } + let!(:desk) { Desk.create!(computer: computer) } + + it 'eager-loads the embedded association together with its referenced child' do + expect_query(1) do + loaded = Desk.eager_load(computer: { port: :device }).first + expect(loaded.computer.port.device).to eq(device) + end + end + end + + context 'when an embedded association is nested two levels under a referenced one' do + before(:all) do + class Device + include Mongoid::Document + end + + class Pin + include Mongoid::Document + + embedded_in :port + belongs_to :device + end + + class Port + include Mongoid::Document + + embedded_in :computer + embeds_one :pin + end + + class Computer + include Mongoid::Document + + embeds_one :port + end + + class Desk + include Mongoid::Document + + belongs_to :computer + end + end + + after(:all) do + Object.send(:remove_const, :Desk) + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Pin) + Object.send(:remove_const, :Device) + end + + let!(:device) { Device.create! } + let!(:computer) { Computer.create!(port: Port.new(pin: Pin.new(device: device))) } + let!(:desk) { Desk.create!(computer: computer) } + + it 'eager-loads the doubly embedded association together with its referenced child' do + expect_query(1) do + loaded = Desk.eager_load(computer: { port: { pin: :device } }).first + expect(loaded.computer.port.pin.device).to eq(device) + end + end + end + + context 'when an embeds_many is nested inside another embeds_many under a referenced one' do + before(:all) do + class Device + include Mongoid::Document + end + + class Port + include Mongoid::Document + + embedded_in :card + belongs_to :device + end + + class Card + include Mongoid::Document + + embedded_in :computer + embeds_many :ports + end + + class Computer + include Mongoid::Document + + embeds_many :cards + end + + class Desk + include Mongoid::Document + + belongs_to :computer + end + end + + after(:all) do + Object.send(:remove_const, :Desk) + Object.send(:remove_const, :Computer) + Object.send(:remove_const, :Card) + Object.send(:remove_const, :Port) + Object.send(:remove_const, :Device) + end + + let!(:devices) { Array.new(4) { Device.create! } } + let!(:computer) do + Computer.create!( + cards: [ + Card.new(ports: [ Port.new(device: devices[0]), Port.new(device: devices[1]) ]), + Card.new(ports: [ Port.new(device: devices[2]), Port.new(device: devices[3]) ]) + ] + ) + end + let!(:desk) { Desk.create!(computer: computer) } + + it 'eager-loads each deeply embedded belongs_to correlated to its own element' do + expect_query(1) do + loaded = Desk.eager_load(computer: { cards: { ports: :device } }).first + got = loaded.computer.cards.flat_map { |card| card.ports.map(&:device) } + expect(got).to eq(devices) + end + end + end end describe '#inclusions' do From f5cd6964843b0702c280adab8d2bb2bd1cc5440d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Sun, 14 Jun 2026 01:44:05 -0300 Subject: [PATCH 03/12] Support eager_load for polymorphic belongs_to associations A polymorphic belongs_to has no single target collection, so it cannot be expressed as a $lookup. After the root documents are loaded, the polymorphic targets are fetched with a single aggregation whose $facet has one branch per distinct type, then assigned back to each document by type and key. --- lib/mongoid/association/eager_loadable.rb | 89 ++++++++++++++++++++++- spec/mongoid/criteria/includable_spec.rb | 37 ++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index 2a71e3a812..0224b3d066 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -58,7 +58,13 @@ def eager_load_with_lookup return eager_load(docs_for_lookup_fallback) end - preload_for_lookup(criteria) + docs = preload_for_lookup(criteria) + # A polymorphic belongs_to cannot be expressed as a $lookup because its + # target collection varies per document. Once the root documents are + # materialized, a single aggregation fetches every polymorphic target. + polymorphic_inclusions = criteria.inclusions.select(&:polymorphic?) + preload_polymorphic(polymorphic_inclusions, docs) if polymorphic_inclusions.any? + docs end # Load the associations for the given documents. This will be done @@ -129,6 +135,7 @@ def preload_for_lookup(criteria) def add_inclusion_to_pipeline(pipeline, assoc, inclusions_by_name, assoc_map) # An embedded inclusion rides inside its document; it needs no $lookup. + return if assoc.polymorphic? return if assoc.embedded? # A referenced parent already nests this inclusion in its sub-pipeline. @@ -236,6 +243,80 @@ def embedded_chain_up_to(embedded_assoc, inclusions_by_name) chain end + # Preload polymorphic belongs_to inclusions onto the already-materialized + # root documents. The target collection differs per *_type, so this cannot + # be a $lookup; each inclusion is resolved with a single aggregation whose + # $facet has one branch per distinct type. + def preload_polymorphic(inclusions, docs) + inclusions.each do |assoc| + keys_by_type = polymorphic_keys_by_type(assoc, docs) + targets = fetch_polymorphic_targets(assoc, keys_by_type) + assign_polymorphic_targets(assoc, docs, targets) + end + end + + # Group the foreign keys found on the documents by their polymorphic type, + # e.g. { "Printer" => [ id1 ], "Scanner" => [ id2 ] }. + def polymorphic_keys_by_type(assoc, docs) + docs.each_with_object({}) do |doc, keys_by_type| + type, key = polymorphic_reference(assoc, doc) + (keys_by_type[type] ||= []) << key if type + end + end + + # Fetch every target in one aggregation: a $facet with a $lookup branch per + # type. Returns the targets as { type => { primary_key => document } }. + def fetch_polymorphic_targets(assoc, keys_by_type) + return {} if keys_by_type.empty? + + primary_key = assoc.primary_key + facets = keys_by_type.to_h do |type, keys| + collection = assoc.resolver.model_for(type).collection.name + [ type, polymorphic_facet_branch(collection, primary_key, keys) ] + end + + aggregated = klass.collection.aggregate([ { '$limit' => 1 }, { '$facet' => facets } ]).first + aggregated.to_h do |type, branch| + model = assoc.resolver.model_for(type) + # $limit => 1 makes each branch yield a single wrapper holding the matches. + targets = branch.first['matches'].map { |doc| Factory.from_db(model, doc) } + [ type, targets.index_by { |doc| doc.send(primary_key) } ] + end + end + + # One $facet branch: look up the documents in +collection+ whose primary key + # is among +keys+, exposed under "matches". + def polymorphic_facet_branch(collection, primary_key, keys) + [ + { '$lookup' => { + 'from' => collection, + 'pipeline' => [ { '$match' => { primary_key => { '$in' => keys.uniq } } } ], + 'as' => 'matches' + } }, + { '$project' => { '_id' => 0, 'matches' => 1 } } + ] + end + + # Set the eager-loaded target on each document, matched by its type and key. + def assign_polymorphic_targets(assoc, docs, targets) + docs.each do |doc| + type, key = polymorphic_reference(assoc, doc) + doc.set_relation(assoc.name, type && targets.dig(type, key)) + end + end + + # The [ type, key ] polymorphic reference stored on the document for this + # association, or nil when the document holds no reference. + def polymorphic_reference(assoc, doc) + type_field = assoc.inverse_type + key_field = assoc.foreign_key + return unless doc.respond_to?(type_field) && doc.respond_to?(key_field) + + type = doc.send(type_field) + key = doc.send(key_field) + [ type, key ] if type && key + end + private # Returns the materialized documents to use when falling back from @@ -253,7 +334,11 @@ def docs_for_lookup_fallback # @return [ Array ] The offending inclusions. def cross_cluster_inclusions root_client = klass.client_name - criteria.inclusions.reject { |assoc| assoc.klass.client_name == root_client } + # Polymorphic associations have no single resolvable klass and are not + # loaded via $lookup, so they are never cross-cluster offenders. + criteria.inclusions.reject do |assoc| + assoc.polymorphic? || assoc.klass.client_name == root_client + end end public diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index 4185e8e509..745e21a4c6 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -2035,6 +2035,43 @@ class Desk end end end + + context 'when a polymorphic belongs_to is eager-loaded' do + before(:all) do + class Printer + include Mongoid::Document + end + + class Scanner + include Mongoid::Document + end + + class Cartridge + include Mongoid::Document + + belongs_to :device, polymorphic: true + end + end + + after(:all) do + Object.send(:remove_const, :Cartridge) + Object.send(:remove_const, :Scanner) + Object.send(:remove_const, :Printer) + end + + let!(:printer) { Printer.create! } + let!(:scanner) { Scanner.create! } + let!(:printer_cartridge) { Cartridge.create!(device: printer) } + let!(:scanner_cartridge) { Cartridge.create!(device: scanner) } + + it 'eager-loads polymorphic targets of different types in a single extra query' do + expect_query(2) do + loaded = Cartridge.eager_load(:device).to_a.index_by(&:id) + expect(loaded[printer_cartridge.id].device).to eq(printer) + expect(loaded[scanner_cartridge.id].device).to eq(scanner) + end + end + end end describe '#inclusions' do From 7294e30deb718d824df8d49c07e558da19fd022f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Mon, 15 Jun 2026 22:37:29 -0300 Subject: [PATCH 04/12] Fix eager_load crash with an empty inclusion list --- lib/mongoid/criteria/includable.rb | 6 ++++-- spec/mongoid/criteria/includable_spec.rb | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/mongoid/criteria/includable.rb b/lib/mongoid/criteria/includable.rb index f75a97f204..3b55b84840 100644 --- a/lib/mongoid/criteria/includable.rb +++ b/lib/mongoid/criteria/includable.rb @@ -44,11 +44,13 @@ def eager_load(*relations) clone end - # Returns whether to use $lookup aggregation for eager loading. + # Returns whether to use $lookup aggregation for eager loading. Only when + # eager_load was requested and there is something to load: an empty + # inclusion list (e.g. eager_load([])) falls back to the normal path. # # @return [ true | false ] Whether to use $lookup. def use_lookup? - !!@use_lookup + !!@use_lookup && inclusions.any? end # Get a list of criteria that are to be executed for eager loading. diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index 745e21a4c6..a4f659262f 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -1415,6 +1415,14 @@ class C min_server_version '5.0' it_behaves_like 'eager loading', :eager_load + context 'when the inclusion list is empty' do + let!(:band) { Band.create! } + + it 'returns the documents without eager loading' do + expect(Band.eager_load([]).first).to eq(band) + end + end + context 'when the association is defined on a subclass of the queried class' do before(:all) do class Machine From 478ecb5e18a371b1b5bc705d0aafdfc5d1316b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Mon, 15 Jun 2026 22:58:19 -0300 Subject: [PATCH 05/12] Restrict eager_load lookup to the target subclass --- lib/mongoid/association/eager_loadable.rb | 7 ++++ spec/mongoid/criteria/includable_spec.rb | 40 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index 0224b3d066..43fcb065ff 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -371,6 +371,13 @@ def create_pipeline(current_assoc, mapping) } } + # A subclass shares its collection with sibling subclasses, so restrict the + # lookup to the target's own discriminators, like a normal query would. + if current_assoc.klass.hereditary? + target = current_assoc.klass + pipeline_stages << { '$match' => { target.discriminator_key => { '$in' => target._types } } } + end + # Add ordering if defined on the association, or default to _id for consistent order if current_assoc.order sort_spec = current_assoc.order.is_a?(Hash) ? current_assoc.order : { current_assoc.order => 1 } diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index a4f659262f..2b0316b64c 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -2080,6 +2080,46 @@ class Cartridge end end end + + context 'when a has_many targets a class sharing its collection with sibling subclasses' do + before(:all) do + class Part + include Mongoid::Document + end + + class Chip < Part + belongs_to :board, class_name: 'Board', optional: true + end + + class Cable < Part + belongs_to :board, class_name: 'Board', optional: true + end + + class Board + include Mongoid::Document + + has_many :chips, class_name: 'Chip', inverse_of: :board + end + end + + after(:all) do + Object.send(:remove_const, :Board) + Object.send(:remove_const, :Cable) + Object.send(:remove_const, :Chip) + Object.send(:remove_const, :Part) + end + + let!(:board) { Board.create! } + let!(:chip) { Chip.create!(board: board) } + let!(:cable) { Cable.create!(board: board) } + + it 'eager-loads only the chips, not the sibling subclass' do + expect_query(1) do + loaded = Board.eager_load(:chips).first + expect(loaded.chips.to_a).to eq([ chip ]) + end + end + end end describe '#inclusions' do From 3e7b52e271c8f1a71781c5eb95fe51ec0c3c0885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Wed, 17 Jun 2026 22:05:35 -0300 Subject: [PATCH 06/12] Document the embedded eager-load grafting helpers --- lib/mongoid/association/eager_loadable.rb | 100 +++++++++++++++++++--- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index 43fcb065ff..bd275c997e 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -133,6 +133,19 @@ def preload_for_lookup(criteria) Eager.new(inclusions, [], true, pipeline).run end + # Eager loading turns each requested association into aggregation stages, but + # not every association is fetched the same way. This is where a single + # inclusion's strategy is settled: a plain reference becomes a top-level + # $lookup, an embedded one travels inside its own document, a polymorphic one + # is resolved after the roots are loaded, and one nested under a referenced + # parent is loaded within that parent. Only the top-level cases add stages here. + # + # @param [ Array ] pipeline The aggregation pipeline being built. + # @param [ Mongoid::Association::Relatable ] assoc The inclusion to add. + # @param [ Hash ] inclusions_by_name + # The inclusions indexed by association name. + # @param [ Hash> ] assoc_map + # The inclusions grouped by inverse class name, used to nest children. def add_inclusion_to_pipeline(pipeline, assoc, inclusions_by_name, assoc_map) # An embedded inclusion rides inside its document; it needs no $lookup. return if assoc.polymorphic? @@ -152,6 +165,18 @@ def add_inclusion_to_pipeline(pipeline, assoc, inclusions_by_name, assoc_map) end end + # Handles eager loading a reference that lives on an embedded document rather + # than on a top-level one. MongoDB's $lookup can only attach its results to a + # top-level field, never inside an embedded array, so the matches are first + # collected at the top level and then moved onto the embedded documents they + # belong to, following +chain+ down to where the reference is declared. + # + # @param [ Array ] pipeline The aggregation pipeline being built. + # @param [ Hash ] stage The $lookup stage produced for +association+. + # @param [ Array ] chain The embedded + # ancestors from the root document down to +association+'s owner. + # @param [ Mongoid::Association::Relatable ] association The referenced + # inclusion to graft onto the embedded path. def graft_embedded_lookup(pipeline, stage, chain, association) lookup = stage['$lookup'] path = chain.map(&:store_as).join('.') @@ -175,8 +200,19 @@ def graft_embedded_lookup(pipeline, stage, chain, association) pipeline << { '$unset' => tmp_field } end - # An embeds_many is rebuilt with $map so each element keeps its own matches - # instead of collapsing onto the first; an embeds_one is merged in place. + # Once the looked-up documents sit in a temporary top-level field, each + # embedded document along the path has to receive the matches that are its + # own. This expresses that hand-off. An embedded collection (embeds_many) + # keeps a per-element result so matches don't collapse onto the first + # element; a single embedded document (embeds_one) receives its match in place. + # + # @param [ Array ] chain The remaining + # embedded ancestors to descend into. + # @param [ String ] node The aggregation expression for the current embedded + # node (e.g. "$ports" or "$$port"). + # @param [ Hash ] graft The grafting parameters built by graft_embedded_lookup. + # + # @return [ Hash ] The aggregation expression for the enclosing $set stage. def graft_value(chain, node, graft) head, *rest = chain many = head.is_a?(Association::Embedded::EmbedsMany) @@ -192,9 +228,15 @@ def graft_value(chain, node, graft) many ? { '$map' => { 'input' => node, 'as' => head.store_as, 'in' => merged } } : merged end - # A has_and_belongs_to_many holds an array of foreign keys, so a match - # belongs to the element when its key is contained in that array ($in); - # every other association points at a single key ($eq). + # From the pool of looked-up documents, keeps only the ones that belong to a + # particular embedded document, by matching keys. A has_and_belongs_to_many + # holds an array of foreign keys, so a document belongs when its key is one + # of them ($in); every other association points at a single key ($eq). + # + # @param [ Hash ] graft The grafting parameters built by graft_embedded_lookup. + # @param [ String ] element The aggregation expression for the embedded element. + # + # @return [ Hash ] The $filter expression. def correlated_matches(graft, element) { '$filter' => { 'input' => "$#{graft[:tmp]}", @@ -203,8 +245,18 @@ def correlated_matches(graft, element) } } end - # An embedded child rides inside its parent's $lookup, so instead of its own - # $lookup its referenced descendants are grafted onto the embedded path. + # Handles references reached through an embedded document, e.g. a Computer + # that embeds Ports where each Port references a Device. The embedded + # document has no collection of its own to be looked up from (it already + # travels inside its parent), so its references are attached onto the + # embedded path instead of getting their own top-level $lookup. + # + # @param [ Mongoid::Association::Relatable ] embedded_assoc The embedded inclusion. + # @param [ Array ] pipeline_stages The sub-pipeline being built. + # @param [ Hash> ] mapping The + # inclusions grouped by inverse class name, drained as children are consumed. + # @param [ Array ] chain The embedded path + # accumulated so far, from the outermost embedded ancestor inward. def nest_embedded_inclusion(embedded_assoc, pipeline_stages, mapping, chain = [ embedded_assoc ]) child_inclusions_of(embedded_assoc, mapping).each do |child| mapping[child.inverse_class_name] -= [ child ] @@ -217,15 +269,33 @@ def nest_embedded_inclusion(embedded_assoc, pipeline_stages, mapping, chain = [ end end - # Matched by parent_inclusions (the real parent-child link), not by class, so - # a sibling branch isn't pulled in when an association points at a superclass - # of the queried subclass. + # Nested inclusions (e.g. include(a: :b)) form a tree. This gives the ones + # that hang directly under +parent+, matched by the actual parent-child link + # rather than by class, so a sibling branch isn't pulled in when an + # association happens to point at a superclass of the queried subclass. + # + # @param [ Mongoid::Association::Relatable ] parent The parent inclusion. + # @param [ Hash> ] mapping + # The inclusions grouped by inverse class name. + # + # @return [ Array ] The child inclusions. def child_inclusions_of(parent, mapping) mapping.values.flatten.select do |child| child.parent_inclusions.include?(parent.name) end end + # A reference can be reached through one or more embedded documents. This + # gives the embedded path leading down to it, one per embedded parent, since + # the same association can be embedded in more than one place (e.g. two + # embeds_one of the same class). + # + # @param [ Mongoid::Association::Relatable ] assoc The inclusion. + # @param [ Hash ] inclusions_by_name + # The inclusions indexed by association name. + # + # @return [ Array> ] One chain of + # embedded ancestors per embedded parent, each ordered from the root inward. def embedded_ancestor_chains(assoc, inclusions_by_name) assoc.parent_inclusions.filter_map do |parent_name| parent = inclusions_by_name[parent_name] @@ -233,6 +303,16 @@ def embedded_ancestor_chains(assoc, inclusions_by_name) end end + # The path of embedded documents from the root down to a given embedded + # association, found by climbing through its embedded ancestors. + # + # @param [ Mongoid::Association::Relatable ] embedded_assoc The embedded + # inclusion to start from. + # @param [ Hash ] inclusions_by_name + # The inclusions indexed by association name. + # + # @return [ Array ] The chain of embedded + # ancestors, outermost first. def embedded_chain_up_to(embedded_assoc, inclusions_by_name) chain = [ embedded_assoc ] current = embedded_assoc From 829d5870729a7e4a062b0cb1af631c91f8c87eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Wed, 17 Jun 2026 23:13:33 -0300 Subject: [PATCH 07/12] Fetch cross-database polymorphic targets without $lookup A $lookup only reaches collections in the root's own database, so eager loading a polymorphic belongs_to whose target is stored in another database (or cluster) silently returned nil for that target: the generated $facet referenced a collection that was not there. Split the types by location: the ones sharing the root's database are still fetched together in a single $facet, while a type kept elsewhere is read directly through its own model, which connects with that model's client. --- lib/mongoid/association/eager_loadable.rb | 59 ++++++++++++++++++++++- spec/mongoid/criteria/includable_spec.rb | 48 ++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index bd275c997e..ef3cf2c142 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -344,9 +344,48 @@ def polymorphic_keys_by_type(assoc, docs) end end - # Fetch every target in one aggregation: a $facet with a $lookup branch per - # type. Returns the targets as { type => { primary_key => document } }. + # Resolve the targets of a polymorphic inclusion, as + # { type => { primary_key => document } }. + # + # A $lookup only reaches collections in the root's own database, so the types + # are split by where they live: those in the root's database are fetched + # together in a single $facet aggregation, while a type kept in another + # database (or cluster) is read straight from its own model, which connects + # through that model's client. + # + # @param [ Mongoid::Association::Relatable ] assoc The polymorphic inclusion. + # @param [ Hash ] keys_by_type The foreign keys grouped by type. + # + # @return [ Hash ] The targets, as { type => { primary_key => document } }. def fetch_polymorphic_targets(assoc, keys_by_type) + local, remote = keys_by_type.partition do |type, _keys| + same_database_as_root?(assoc.resolver.model_for(type)) + end + + targets = fetch_targets_via_facet(assoc, local.to_h) + remote.each { |type, keys| targets[type] = fetch_targets_for_type(assoc, type, keys) } + targets + end + + # Whether +model+ keeps its documents in the same database (and client) as + # the root: exactly what a $lookup from the root collection is able to reach. + # + # @param [ Class ] model The target model. + # + # @return [ true | false ] Whether it shares the root's database. + def same_database_as_root?(model) + model.client_name == klass.client_name && + model.database_name == klass.database_name + end + + # The single-query path, for the types that live in the root's database: one + # $facet aggregation with a $lookup branch per type. + # + # @param [ Mongoid::Association::Relatable ] assoc The polymorphic inclusion. + # @param [ Hash ] keys_by_type The foreign keys grouped by type. + # + # @return [ Hash ] The targets, as { type => { primary_key => document } }. + def fetch_targets_via_facet(assoc, keys_by_type) return {} if keys_by_type.empty? primary_key = assoc.primary_key @@ -364,6 +403,22 @@ def fetch_polymorphic_targets(assoc, keys_by_type) end end + # The direct-query path, for a type kept outside the root's database that a + # $lookup could not reach: its own collection is read through its model. + # + # @param [ Mongoid::Association::Relatable ] assoc The polymorphic inclusion. + # @param [ String ] type The polymorphic type to fetch. + # @param [ Array ] keys The foreign keys for this type. + # + # @return [ Hash ] The targets for this type, as { primary_key => document }. + def fetch_targets_for_type(assoc, type, keys) + model = assoc.resolver.model_for(type) + model.collection + .find(assoc.primary_key => { '$in' => keys.uniq }) + .map { |doc| Factory.from_db(model, doc) } + .index_by { |doc| doc.send(assoc.primary_key) } + end + # One $facet branch: look up the documents in +collection+ whose primary key # is among +keys+, exposed under "matches". def polymorphic_facet_branch(collection, primary_key, keys) diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index 2b0316b64c..f52baa7caf 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -2081,6 +2081,54 @@ class Cartridge end end + context 'when a polymorphic target is stored in another database' do + before(:all) do + class LocalDevice + include Mongoid::Document + end + + class RemoteDevice + include Mongoid::Document + + store_in database: 'secondary' + end + + class Accessory + include Mongoid::Document + + belongs_to :device, polymorphic: true + end + end + + after(:all) do + Object.send(:remove_const, :Accessory) + Object.send(:remove_const, :RemoteDevice) + Object.send(:remove_const, :LocalDevice) + end + + # RemoteDevice lives in its own database, which the global cleanup hook + # (default database only) does not touch, so drop it here. + after { RemoteDevice.collection.drop } + + let!(:local_device) { LocalDevice.create! } + let!(:remote_device) { RemoteDevice.create! } + let!(:local_accessory) { Accessory.create!(device: local_device) } + let!(:remote_accessory) { Accessory.create!(device: remote_device) } + + it 'eager-loads the target stored in the other database' do + # One query materializes the roots, one $facet fetches the same-database + # target, and one direct query fetches the target in the other database. + loaded = expect_query(3) do + Accessory.eager_load(:device).to_a.index_by(&:id) + end + + expect_no_queries do + expect(loaded[local_accessory.id].device).to eq(local_device) + expect(loaded[remote_accessory.id].device).to eq(remote_device) + end + end + end + context 'when a has_many targets a class sharing its collection with sibling subclasses' do before(:all) do class Part From 109e3a9cdf7ae865eeb439c7a62d74e0544794b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Thu, 18 Jun 2026 19:17:01 -0300 Subject: [PATCH 08/12] Refactor the aggregation eager-load into a dedicated subsystem --- lib/mongoid/association/eager.rb | 29 +- .../eager_load/embedded_distributor.rb | 138 ++++++ .../association/eager_load/inclusion.rb | 120 +++++ .../association/eager_load/inclusion_tree.rb | 70 +++ .../association/eager_load/lookup_pipeline.rb | 119 +++++ .../eager_load/polymorphic_preloader.rb | 59 +++ .../eager_load/polymorphic_targets.rb | 138 ++++++ lib/mongoid/association/eager_loadable.rb | 466 ++---------------- lib/mongoid/association/relatable.rb | 9 + 9 files changed, 689 insertions(+), 459 deletions(-) create mode 100644 lib/mongoid/association/eager_load/embedded_distributor.rb create mode 100644 lib/mongoid/association/eager_load/inclusion.rb create mode 100644 lib/mongoid/association/eager_load/inclusion_tree.rb create mode 100644 lib/mongoid/association/eager_load/lookup_pipeline.rb create mode 100644 lib/mongoid/association/eager_load/polymorphic_preloader.rb create mode 100644 lib/mongoid/association/eager_load/polymorphic_targets.rb diff --git a/lib/mongoid/association/eager.rb b/lib/mongoid/association/eager.rb index 4ddea6622e..5e5a38c638 100644 --- a/lib/mongoid/association/eager.rb +++ b/lib/mongoid/association/eager.rb @@ -12,18 +12,12 @@ class Eager # @param [ Array ] associations # Associations to eager load # @param [ Array ] docs Documents to preload the associations - # @param [ Boolean ] use_lookup Whether to use $lookup aggregation - # for eager loading. This is used in Criteria#eager_load. - # @param [ Array ] pipeline The aggregation pipeline to use - # when using $lookup for eager loading. # # @return [ Base ] The eager load preloader - def initialize(associations, docs, use_lookup = false, pipeline = []) + def initialize(associations, docs) @associations = associations @docs = docs @grouped_docs = {} - @use_lookup = use_lookup - @pipeline = pipeline end # Run the preloader. @@ -35,12 +29,6 @@ def initialize(associations, docs, use_lookup = false, pipeline = []) def run @loaded = [] - if @use_lookup - preload_with_lookup - @loaded = @docs - return @loaded.flatten - end - while shift_association preload @loaded << @docs.collect { |d| d.send(@association.name) if d.respond_to?(@association.name) } @@ -60,21 +48,6 @@ def preload raise NotImplementedError end - # Preload the current association using $lookup aggregation. - # This method executes the aggregation pipeline - # and instantiates the documents. - # @example Preload the current association using $lookup. - # loader.preload_with_lookup - def preload_with_lookup - # For $lookup aggregation, execute pipeline and instantiate documents - owner_class = @associations.first.owner_class - aggregated_docs = owner_class.collection.aggregate(@pipeline) - aggregated_docs.each do |doc| - parsed_doc = Factory.from_db(owner_class, doc) - @docs << parsed_doc - end - end - # Retrieves the documents referenced by the association, and # yields each one sequentially to the provided block. If the # association is not polymorphic, all documents are retrieved in diff --git a/lib/mongoid/association/eager_load/embedded_distributor.rb b/lib/mongoid/association/eager_load/embedded_distributor.rb new file mode 100644 index 0000000000..5f50330b7d --- /dev/null +++ b/lib/mongoid/association/eager_load/embedded_distributor.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +module Mongoid + module Association + # Objects that carry out the aggregation-based eager load triggered by + # Criteria#eager_load. + module EagerLoad + # Distributes the results of a $lookup onto the embedded documents they + # belong to. + # + # A $lookup overwrites the single field it writes to, and it can't distribute + # its matches across the elements of an embedded array. So when the reference + # being eager-loaded lives on an embedded document, the matches are first + # collected in a temporary top-level field and then distributed down the + # embedded path onto each embedded document, merging into it (so the rest of + # the document is kept) and correlating by key. The temporary field is then + # dropped. + # + # For Computer.eager_load(port: :device) (Port belongs_to :device) it emits: + # + # { '$lookup' => { # devices can't be written into + # 'from' => 'devices', # the embedded port, so they are + # 'localField' => 'port.device_id', # collected in a temp top-level + # 'foreignField' => '_id', # field instead + # 'as' => '__eager_load_port_device' + # } }, + # { '$set' => { + # 'port' => { '$mergeObjects' => [ # merge a 'device' key onto the port + # '$port', + # { 'device' => { '$filter' => { ... } } } # this port's matches + # ] } + # } }, + # { '$unset' => '__eager_load_port_device' } # drop the temp field + class EmbeddedDistributor + # @param [ Mongoid::Association::Relatable ] association The referenced + # inclusion being eager-loaded from within an embedded document. + # @param [ Array ] chain The embedded + # ancestors, from the root document inward, down to the association's owner. + # @param [ Hash ] lookup_stage The $lookup stage built for the association. + # + # @return [ EmbeddedDistributor ] The distributor. + class << self + def for(association:, chain:, lookup_stage:) + lookup = lookup_stage['$lookup'] + new(association, chain, lookup_stage, lookup['localField'], lookup['foreignField']) + end + + private :new + end + + def initialize(association, chain, lookup_stage, local_field, foreign_field) + @association = association + @chain = chain + @lookup_stage = lookup_stage + @local_field = local_field + @foreign_field = foreign_field + end + + # The stages that run the $lookup into a temporary field and then + # distribute its matches onto the embedded documents along the path. + # + # @return [ Array ] The stages to append to the pipeline. + def stages + redirect_lookup_to_temporary_field + [ + @lookup_stage, + { '$set' => { + root => distributed_value(@chain, "$#{root}") + } }, + { '$unset' => temporary_field } + ] + end + + private + + # The $lookup runs at the top level, so it reads the local field by its + # full embedded path and writes the matches into the temporary field. + def redirect_lookup_to_temporary_field + lookup = @lookup_stage['$lookup'] + lookup['localField'] = "#{path}.#{@local_field}" + lookup['as'] = temporary_field + end + + def path + @chain.map(&:store_as).join('.') + end + + def root + @chain.first.store_as + end + + def temporary_field + "__eager_load_#{path.tr('.', '_')}_#{@association.name}" + end + + # An embedded collection (embeds_many) is rebuilt with $map so each element + # keeps its own matches instead of collapsing onto the first; a single + # embedded document (embeds_one) receives its matches in place. + def distributed_value(chain, node) + head, *rest = chain + many = head.many? + element = many ? "$$#{head.store_as}" : node + child = + if rest.empty? + { @association.name.to_s => correlated_matches(element) } + else + segment = rest.first.store_as + { segment => distributed_value(rest, "#{element}.#{segment}") } + end + merged = { '$mergeObjects' => [ element, child ] } + return merged unless many + + { '$map' => { + 'input' => node, + 'as' => head.store_as, + 'in' => merged + } } + end + + # The matches that belong to a single embedded element. + def correlated_matches(element) + { '$filter' => { + 'input' => "$#{temporary_field}", + 'as' => 'match', + 'cond' => { match_operator => [ "$$match.#{@foreign_field}", "#{element}.#{@local_field}" ] } + } } + end + + # A has_and_belongs_to_many holds an array of foreign keys, so a match + # belongs when its key is among them ($in); every other association points + # at a single key ($eq). + def match_operator + @association.many_to_many? ? '$in' : '$eq' + end + end + end + end +end diff --git a/lib/mongoid/association/eager_load/inclusion.rb b/lib/mongoid/association/eager_load/inclusion.rb new file mode 100644 index 0000000000..822735f821 --- /dev/null +++ b/lib/mongoid/association/eager_load/inclusion.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Mongoid + module Association + module EagerLoad + # An inclusion of an eager load, in the role it plays while the pipeline is + # built. Each kind knows how it contributes; the LookupPipeline holds the + # stage-building helpers they lean on. A node carries its own children, so + # the pipeline is built by recursion from the roots downward. + class Inclusion + class << self + # Builds the right kind of inclusion for the association. Each subclass + # decides whether it handles it (.for?); exactly one does. + # + # @param [ Mongoid::Association::Relatable ] association The inclusion. + # @param [ LookupPipeline ] pipeline The pipeline being built. + # @param [ Array ] children The inclusions nested under it. + # + # @return [ Inclusion ] The matching kind of inclusion. + def for(association, pipeline, children) + subclasses.find { |kind| kind.for?(association) }.new(association, pipeline, children) + end + + # Whether this kind handles the given association. + # + # @return [ true | false ] Whether it handles it. + def for?(association) + raise NotImplementedError + end + end + + def initialize(association, pipeline, children) + @association = association + @pipeline = pipeline + @children = children + end + + # Add this inclusion's stages to the destination. + # + # @param [ Array ] destination The pipeline (or sub-pipeline) the + # stages are appended to. + # @param [ Array ] chain The embedded path + # accumulated from the ancestors above this inclusion (empty at the top). + def contribute(destination, chain) + raise NotImplementedError + end + end + + # A referenced inclusion: contributes a $lookup whose sub-pipeline holds its + # own children. When it lives inside an embedded document (a non-empty + # chain), the $lookup is distributed onto that embedded path instead of + # standing at the top level. + # + # For a has_many :albums it contributes: + # + # { '$lookup' => { + # 'from' => 'albums', + # 'localField' => '_id', # the band's _id... + # 'foreignField' => 'band_id', # ...matched against each album's band_id + # 'as' => 'albums', # matches are written to this field + # 'pipeline' => [ + # { '$sort' => { + # '_id' => 1 + # } }, + # + # ] + # } } + class JoinedInclusion < Inclusion + class << self + # The default kind: a referenced, non-polymorphic association, i.e. the + # one no sibling kind claims. + def for?(association) + (superclass.subclasses - [ self ]).none? { |kind| kind.for?(association) } + end + end + + def contribute(destination, chain) + stage = @pipeline.lookup_stage_for(@association) + @children.each { |child| child.contribute(stage['$lookup']['pipeline'], []) } + + if chain.empty? + destination << stage + else + destination.concat(@pipeline.distribute(@association, chain, stage)) + end + end + end + + # An embedded inclusion: it rides inside its own document, so it adds no + # stage of its own. Its children contribute to the same destination, with + # this document appended to their embedded path. + # + # For Computer.eager_load(port: :device) the :port inclusion emits nothing; + # it hands the path [ :port ] to :device, which EmbeddedDistributor then + # turns into stages. + class EmbeddedInclusion < Inclusion + class << self + def for?(association) + association.embedded? + end + end + + def contribute(destination, chain) + @children.each { |child| child.contribute(destination, chain + [ @association ]) } + end + end + + # A polymorphic inclusion: its target collection varies per document, so it + # can't be a $lookup. It adds nothing here; PolymorphicPreloader resolves it + # after the roots are materialized. + class DeferredInclusion < Inclusion + def self.for?(association) + association.polymorphic? + end + + def contribute(destination, chain); end + end + end + end +end diff --git a/lib/mongoid/association/eager_load/inclusion_tree.rb b/lib/mongoid/association/eager_load/inclusion_tree.rb new file mode 100644 index 0000000000..f1bcc6f875 --- /dev/null +++ b/lib/mongoid/association/eager_load/inclusion_tree.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'mongoid/association/eager_load/inclusion' + +module Mongoid + module Association + module EagerLoad + # The tree of nested inclusions an eager load asks to load. Built from the + # criteria's inclusions, it contributes each root node's stages to the + # pipeline, with each node already carrying its own children. + # + # Each root branch is built from its own copy of the inclusions, and an + # inclusion is removed as it is placed, so it lands once per branch even if + # more than one parent in that branch points at it, and a circular chain of + # inclusions can't loop forever. + class InclusionTree + class << self + def from(inclusions, pipeline) + new(inclusions, pipeline, inclusions.to_h { |association| [ association.name, association ] }) + end + + private :new + end + + def initialize(inclusions, pipeline, by_name) + @inclusions = inclusions + @pipeline = pipeline + @by_name = by_name + end + + # Contribute each root inclusion's stages to the pipeline. Each root carries + # its own children, so the whole tree is appended by recursion from the + # roots downward. + # + # @param [ Array ] destination The pipeline the stages are appended to. + def contribute_to(destination) + roots.each { |root| root.contribute(destination, []) } + end + + private + + def roots + top_level.map { |association| node(association, @inclusions.dup) } + end + + # The inclusions that no other inclusion is the parent of. + def top_level + @inclusions.reject do |association| + association.parent_inclusions.any? { |name| @by_name.key?(name) } + end + end + + def node(association, available) + children = take_children(association, available).map { |child| node(child, available) } + Inclusion.for(association, @pipeline, children) + end + + # The still-available inclusions parented to +association+, removed as they + # are taken so each lands once on this branch. Matched by the real + # parent-child link, not by class, so a sibling branch isn't pulled in when + # an association points at a superclass of the queried subclass. + def take_children(association, available) + children = available.select { |candidate| candidate.parent_inclusions.include?(association.name) } + available.reject! { |candidate| children.include?(candidate) } + children + end + end + end + end +end diff --git a/lib/mongoid/association/eager_load/lookup_pipeline.rb b/lib/mongoid/association/eager_load/lookup_pipeline.rb new file mode 100644 index 0000000000..afdc92c0a5 --- /dev/null +++ b/lib/mongoid/association/eager_load/lookup_pipeline.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'mongoid/association/eager_load/embedded_distributor' +require 'mongoid/association/eager_load/inclusion_tree' + +module Mongoid + module Association + module EagerLoad + # Builds the aggregation pipeline that eager-loads a criteria's inclusions + # with $lookup. + # + # It starts with the criteria's own match/sort/skip/limit, then lets each + # root of the inclusion tree contribute its stages. This object owns the + # stage-building helpers; how each inclusion contributes is the inclusion's + # own business (see Inclusion). + # + # For Band.eager_load(albums: :tracks) the result is roughly: + # + # [ , + # { '$lookup' => { # JoinedInclusion(:albums) + # 'from' => 'albums', + # 'localField' => '_id', + # 'foreignField' => 'band_id', + # 'as' => 'albums', + # 'pipeline' => [ + # { '$sort' => { + # '_id' => 1 + # } }, + # { '$lookup' => { # JoinedInclusion(:tracks), nested + # 'as' => 'tracks', + # 'pipeline' => [ + # { '$sort' => { + # '_id' => 1 + # } } + # ] + # } } + # ] + # } } ] + class LookupPipeline + def initialize(criteria) + @criteria = criteria + end + + # @return [ Array ] The aggregation pipeline stages. + def stages + pipeline = @criteria.selector.to_pipeline + pipeline.concat(@criteria.options.to_pipeline_for_lookup) + InclusionTree.from(@criteria.inclusions, self).contribute_to(pipeline) + pipeline + end + + # The $lookup stage for a referenced inclusion: its key fields, a + # discriminator match when the target shares its collection with sibling + # subclasses, and an order. Children are added by the inclusion itself. + # + # @param [ Mongoid::Association::Relatable ] association The inclusion. + # + # @return [ Hash ] The $lookup stage. + def lookup_stage_for(association) + local_field, foreign_field = lookup_fields(association) + sub_pipeline = [] + sub_pipeline << discriminator_match(association) if association.klass.hereditary? + sub_pipeline << order(association) + { '$lookup' => { + 'from' => association.klass.collection.name, + 'localField' => local_field, + 'foreignField' => foreign_field, + 'as' => association.name.to_s, + 'pipeline' => sub_pipeline + } } + end + + # Builds the stages that distribute a referenced inclusion living inside an + # embedded document onto that document. + # + # @param [ Mongoid::Association::Relatable ] association The inclusion. + # @param [ Array ] chain The embedded path. + # @param [ Hash ] lookup_stage The $lookup stage for the association. + # + # @return [ Array ] The stages to append. + def distribute(association, chain, lookup_stage) + EmbeddedDistributor.for(association: association, chain: chain, lookup_stage: lookup_stage).stages + end + + private + + # When the association stores the foreign key on the current document + # (belongs_to, has_and_belongs_to_many) the local field is that key; for + # the others (has_many, has_one) the key is on the related document. + def lookup_fields(association) + if association.stores_foreign_key? + [ association.foreign_key, association.primary_key ] + else + [ association.primary_key, association.foreign_key ] + end + end + + def discriminator_match(association) + target = association.klass + { '$match' => { + target.discriminator_key => { '$in' => target._types } + } } + end + + def order(association) + unless association.order + return { '$sort' => { + '_id' => 1 + } } + end + + ordering = association.order + ordering = { ordering => 1 } unless ordering.is_a?(Hash) + { '$sort' => ordering } + end + end + end + end +end diff --git a/lib/mongoid/association/eager_load/polymorphic_preloader.rb b/lib/mongoid/association/eager_load/polymorphic_preloader.rb new file mode 100644 index 0000000000..779b2bbc32 --- /dev/null +++ b/lib/mongoid/association/eager_load/polymorphic_preloader.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'mongoid/association/eager_load/polymorphic_targets' + +module Mongoid + module Association + module EagerLoad + # Resolves a polymorphic belongs_to onto already-materialized root documents. + # + # A polymorphic belongs_to can't be expressed as a $lookup: its target + # collection varies per document. So once the roots are materialized, the + # foreign keys are grouped by type, PolymorphicTargets resolves the documents + # for those keys, and the result is set on each document. + class PolymorphicPreloader + def initialize(association, root_class) + @association = association + @root_class = root_class + end + + # Resolve and assign the polymorphic target on each of the documents. + # + # @param [ Array ] documents The materialized root documents. + def preload_into(documents) + targets = PolymorphicTargets.for(@association, keys_by_type(documents), @root_class) + assign(documents, targets) + end + + private + + # The foreign keys on the documents grouped by polymorphic type, + # e.g. { "Printer" => [ id1 ], "Scanner" => [ id2 ] }. + def keys_by_type(documents) + documents.each_with_object({}) do |document, grouped| + type, key = reference_on(document) + (grouped[type] ||= []) << key if type && key + end + end + + def assign(documents, targets) + documents.each do |document| + type, key = reference_on(document) + target = targets.dig(type, key) if type && key + document.set_relation(@association.name, target) + end + end + + # The [ type, key ] reference stored on the document for this association, + # or an empty pair when the document holds no such reference. + def reference_on(document) + type_field = @association.inverse_type + key_field = @association.foreign_key + return [] unless document.respond_to?(type_field) && document.respond_to?(key_field) + + [ document.send(type_field), document.send(key_field) ] + end + end + end + end +end diff --git a/lib/mongoid/association/eager_load/polymorphic_targets.rb b/lib/mongoid/association/eager_load/polymorphic_targets.rb new file mode 100644 index 0000000000..db722fcf0d --- /dev/null +++ b/lib/mongoid/association/eager_load/polymorphic_targets.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +module Mongoid + module Association + module EagerLoad + # The targets of a polymorphic belongs_to, indexed as + # { type => { primary_key => document } }. Each subclass reaches the types that + # live in one place (the root's database or elsewhere); .for resolves the whole + # set, routing each type to the subclass that can reach it. + class PolymorphicTargets + class << self + # Resolve every polymorphic target for the foreign keys grouped by type. + # The types whose documents share the root's database are fetched together + # in one $facet; those living elsewhere are read through their own models. + # Returns them indexed as { type => { primary_key => document } }. + def for(association, keys_by_type, root_class) + here, elsewhere = keys_by_type.partition do |type, _keys| + in_root_database?(association, type, root_class) + end + same_database = SameDatabaseTargets.new(association, here.to_h, root_class) + other_databases = OtherDatabaseTargets.new(association, elsewhere.to_h) + same_database.fetch.merge(other_databases.fetch) + end + + private + + # Whether the type's model shares the root's database (and client): exactly + # what a $lookup from the root collection can reach. + def in_root_database?(association, type, root_class) + model = association.resolver.model_for(type) + model.client_name == root_class.client_name && + model.database_name == root_class.database_name + end + end + + def initialize(association, keys_by_type) + @association = association + @keys_by_type = keys_by_type + end + + # @return [ Hash ] The targets, as { type => { primary_key => document } }. + def fetch + raise NotImplementedError + end + + private + + def primary_key + @association.primary_key + end + + def model_for(type) + @association.resolver.model_for(type) + end + + # The raw documents instantiated and indexed by their primary key. + def indexed(documents, model) + documents.map { |document| Factory.from_db(model, document) } + .index_by { |document| document.send(primary_key) } + end + end + + # Targets that live in the root's own database. A $lookup can reach them, so + # every type is fetched together in one $facet aggregation against the root + # collection. + # + # For { 'Printer' => [ id1 ], 'Scanner' => [ id2 ] } it runs: + # + # [ + # { '$limit' => 1 }, # one input doc, so each facet branch runs once + # { '$facet' => { # run one $lookup per type within a single query + # 'Printer' => [ { '$lookup' => { 'from' => 'printers', ... } }, ... ], + # 'Scanner' => [ { '$lookup' => { 'from' => 'scanners', ... } }, ... ] + # } } + # ] + class SameDatabaseTargets < PolymorphicTargets + def initialize(association, keys_by_type, root_class) + super(association, keys_by_type) + @root_class = root_class + end + + def fetch + return {} if @keys_by_type.empty? + + aggregated = @root_class.collection.aggregate([ { '$limit' => 1 }, { '$facet' => facets } ]).first + aggregated.to_h do |type, branch| + # $limit => 1 makes each branch yield a single wrapper holding the matches. + [ type, indexed(branch.first['matches'], model_for(type)) ] + end + end + + private + + def facets + @keys_by_type.to_h do |type, keys| + [ type, branch_for(model_for(type).collection.name, keys) ] + end + end + + # One $facet branch: the documents in +collection_name+ whose primary key + # is among +keys+, exposed under "matches". + def branch_for(collection_name, keys) + [ + { '$lookup' => { + 'from' => collection_name, + 'pipeline' => [ + { '$match' => { + primary_key => { '$in' => keys.uniq } + } } + ], + 'as' => 'matches' + } }, + { '$project' => { + '_id' => 0, + 'matches' => 1 + } } + ] + end + end + + # Targets kept in another database (or cluster), which a $lookup cannot + # reach. Each type is read directly through its own model, which connects + # with that model's client. + # + # For { 'Scanner' => [ id2 ] } it runs, on the Scanner model's own client: + # scanners.find('_id' => { '$in' => [ id2 ] }) + class OtherDatabaseTargets < PolymorphicTargets + def fetch + @keys_by_type.to_h do |type, keys| + model = model_for(type) + documents = model.collection.find(primary_key => { '$in' => keys.uniq }) + [ type, indexed(documents, model) ] + end + end + end + end + end +end diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index ef3cf2c142..03f5164311 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require 'mongoid/association/eager' +require 'mongoid/association/eager_load/embedded_distributor' +require 'mongoid/association/eager_load/polymorphic_preloader' +require 'mongoid/association/eager_load/lookup_pipeline' module Mongoid module Association @@ -34,37 +37,40 @@ def eager_load(docs) def eager_load_with_lookup offenders = cross_cluster_inclusions if offenders.any? - root_client = klass.client_name - offender_list = offenders.map { |a| "#{a.name} (#{a.klass.client_name})" }.join(', ') + offender_descriptions = offenders.map do |offender| + "#{offender.name} (#{offender.klass.client_name})" + end Mongoid.logger.warn( 'eager_load cannot use $lookup aggregation because the following associations ' \ - "reside in a different cluster than #{klass} (client: #{root_client}): " \ - "#{offender_list}. Falling back to #includes behavior." + "reside in a different cluster than #{klass} (client: #{klass.client_name}): " \ + "#{offender_descriptions.join(', ')}. Falling back to #includes behavior." ) return eager_load(docs_for_lookup_fallback) end - through_inclusions = criteria.inclusions.select do |assoc| - assoc.is_a?(Association::Referenced::HasOneThrough) || - assoc.is_a?(Association::Referenced::HasManyThrough) + through_inclusions = criteria.inclusions.select do |association| + association.is_a?(Association::Referenced::HasOneThrough) || + association.is_a?(Association::Referenced::HasManyThrough) end if through_inclusions.any? - names = through_inclusions.map { |a| ":#{a.name}" }.join(', ') + through_names = through_inclusions.map { |association| ":#{association.name}" } Mongoid.logger.warn( - "#{names} are :through associations and do not support $lookup-based eager " \ - 'loading. All inclusions for this query will be preloaded using separate queries.' + "#{through_names.join(', ')} are :through associations and do not support " \ + '$lookup-based eager loading. All inclusions for this query will be preloaded ' \ + 'using separate queries.' ) return eager_load(docs_for_lookup_fallback) end - docs = preload_for_lookup(criteria) - # A polymorphic belongs_to cannot be expressed as a $lookup because its - # target collection varies per document. Once the root documents are - # materialized, a single aggregation fetches every polymorphic target. - polymorphic_inclusions = criteria.inclusions.select(&:polymorphic?) - preload_polymorphic(polymorphic_inclusions, docs) if polymorphic_inclusions.any? - docs + documents = preload_for_lookup(criteria) + # A polymorphic belongs_to cannot be expressed as a $lookup: its target + # collection varies per document. It is resolved after the roots are + # materialized, each inclusion by its own preloader. + criteria.inclusions.select(&:polymorphic?).each do |association| + EagerLoad::PolymorphicPreloader.new(association, klass).preload_into(documents) + end + documents end # Load the associations for the given documents. This will be done @@ -102,354 +108,17 @@ def preload(associations, docs) end end - # Load the associations for the given documents. This will be done - # recursively to load the associations of the given documents' - # associated documents. - # - # @param [ Array ] associations - # The associations to load. - # @param [ Array ] docs The documents. - def preload_for_lookup(criteria) - inclusions = criteria.inclusions - assoc_map = inclusions.group_by(&:inverse_class_name) - inclusions_by_name = {} - inclusions.each { |a| inclusions_by_name[a.name] = a } - - # match first - pipeline = criteria.selector.to_pipeline - # then sort, skip, limit - pipeline.concat(criteria.options.to_pipeline_for_lookup) - - # Walk every inclusion in declaration order and let each one decide - # what to emit: a referenced inclusion emits a $lookup (prefixed with - # the embedded ancestor path when it lives inside an embedded doc), an - # embedded inclusion is a passthrough, and an inclusion nested under a - # referenced parent is emitted inside that parent's sub-pipeline by - # create_pipeline. - inclusions.each do |assoc| - add_inclusion_to_pipeline(pipeline, assoc, inclusions_by_name, assoc_map) - end - - Eager.new(inclusions, [], true, pipeline).run - end - - # Eager loading turns each requested association into aggregation stages, but - # not every association is fetched the same way. This is where a single - # inclusion's strategy is settled: a plain reference becomes a top-level - # $lookup, an embedded one travels inside its own document, a polymorphic one - # is resolved after the roots are loaded, and one nested under a referenced - # parent is loaded within that parent. Only the top-level cases add stages here. - # - # @param [ Array ] pipeline The aggregation pipeline being built. - # @param [ Mongoid::Association::Relatable ] assoc The inclusion to add. - # @param [ Hash ] inclusions_by_name - # The inclusions indexed by association name. - # @param [ Hash> ] assoc_map - # The inclusions grouped by inverse class name, used to nest children. - def add_inclusion_to_pipeline(pipeline, assoc, inclusions_by_name, assoc_map) - # An embedded inclusion rides inside its document; it needs no $lookup. - return if assoc.polymorphic? - return if assoc.embedded? - - # A referenced parent already nests this inclusion in its sub-pipeline. - parent = inclusions_by_name[assoc.parent_inclusions.first] - return if parent && !parent.embedded? - - chains = embedded_ancestor_chains(assoc, inclusions_by_name) - return pipeline << create_pipeline(assoc, assoc_map.dup) if chains.empty? - - # The same inclusion can sit under several embedded parents (e.g. two - # embeds_one of the same class); graft a fresh $lookup onto each path. - chains.each do |chain| - graft_embedded_lookup(pipeline, create_pipeline(assoc, assoc_map.dup), chain, assoc) - end - end - - # Handles eager loading a reference that lives on an embedded document rather - # than on a top-level one. MongoDB's $lookup can only attach its results to a - # top-level field, never inside an embedded array, so the matches are first - # collected at the top level and then moved onto the embedded documents they - # belong to, following +chain+ down to where the reference is declared. - # - # @param [ Array ] pipeline The aggregation pipeline being built. - # @param [ Hash ] stage The $lookup stage produced for +association+. - # @param [ Array ] chain The embedded - # ancestors from the root document down to +association+'s owner. - # @param [ Mongoid::Association::Relatable ] association The referenced - # inclusion to graft onto the embedded path. - def graft_embedded_lookup(pipeline, stage, chain, association) - lookup = stage['$lookup'] - path = chain.map(&:store_as).join('.') - name = association.name.to_s - # The $lookup can't write inside an embedded array, so its matches land in - # a temporary top-level field that graft_value distributes and then drops. - tmp_field = "__el_#{path.tr('.', '_')}_#{name}" - graft = { - name: name, - tmp: tmp_field, - local: lookup['localField'], - foreign: lookup['foreignField'], - match_operator: association.is_a?(Mongoid::Association::Referenced::HasAndBelongsToMany) ? '$in' : '$eq' - } - lookup['localField'] = "#{path}.#{lookup['localField']}" - lookup['as'] = tmp_field - pipeline << stage - - root = chain.first.store_as - pipeline << { '$set' => { root => graft_value(chain, "$#{root}", graft) } } - pipeline << { '$unset' => tmp_field } - end - - # Once the looked-up documents sit in a temporary top-level field, each - # embedded document along the path has to receive the matches that are its - # own. This expresses that hand-off. An embedded collection (embeds_many) - # keeps a per-element result so matches don't collapse onto the first - # element; a single embedded document (embeds_one) receives its match in place. - # - # @param [ Array ] chain The remaining - # embedded ancestors to descend into. - # @param [ String ] node The aggregation expression for the current embedded - # node (e.g. "$ports" or "$$port"). - # @param [ Hash ] graft The grafting parameters built by graft_embedded_lookup. - # - # @return [ Hash ] The aggregation expression for the enclosing $set stage. - def graft_value(chain, node, graft) - head, *rest = chain - many = head.is_a?(Association::Embedded::EmbedsMany) - element = many ? "$$#{head.store_as}" : node - child = - if rest.empty? - { graft[:name] => correlated_matches(graft, element) } - else - next_segment = rest.first.store_as - { next_segment => graft_value(rest, "#{element}.#{next_segment}", graft) } - end - merged = { '$mergeObjects' => [ element, child ] } - many ? { '$map' => { 'input' => node, 'as' => head.store_as, 'in' => merged } } : merged - end - - # From the pool of looked-up documents, keeps only the ones that belong to a - # particular embedded document, by matching keys. A has_and_belongs_to_many - # holds an array of foreign keys, so a document belongs when its key is one - # of them ($in); every other association points at a single key ($eq). - # - # @param [ Hash ] graft The grafting parameters built by graft_embedded_lookup. - # @param [ String ] element The aggregation expression for the embedded element. - # - # @return [ Hash ] The $filter expression. - def correlated_matches(graft, element) - { '$filter' => { - 'input' => "$#{graft[:tmp]}", - 'as' => 'match', - 'cond' => { graft[:match_operator] => [ "$$match.#{graft[:foreign]}", "#{element}.#{graft[:local]}" ] } - } } - end - - # Handles references reached through an embedded document, e.g. a Computer - # that embeds Ports where each Port references a Device. The embedded - # document has no collection of its own to be looked up from (it already - # travels inside its parent), so its references are attached onto the - # embedded path instead of getting their own top-level $lookup. - # - # @param [ Mongoid::Association::Relatable ] embedded_assoc The embedded inclusion. - # @param [ Array ] pipeline_stages The sub-pipeline being built. - # @param [ Hash> ] mapping The - # inclusions grouped by inverse class name, drained as children are consumed. - # @param [ Array ] chain The embedded path - # accumulated so far, from the outermost embedded ancestor inward. - def nest_embedded_inclusion(embedded_assoc, pipeline_stages, mapping, chain = [ embedded_assoc ]) - child_inclusions_of(embedded_assoc, mapping).each do |child| - mapping[child.inverse_class_name] -= [ child ] - if child.embedded? - nest_embedded_inclusion(child, pipeline_stages, mapping, chain + [ child ]) - else - stage = create_pipeline(child, mapping) - graft_embedded_lookup(pipeline_stages, stage, chain, child) - end - end - end - - # Nested inclusions (e.g. include(a: :b)) form a tree. This gives the ones - # that hang directly under +parent+, matched by the actual parent-child link - # rather than by class, so a sibling branch isn't pulled in when an - # association happens to point at a superclass of the queried subclass. + # Materialize the root documents with their inclusions eager-loaded by a + # single $lookup aggregation. The pipeline is built by LookupPipeline; the + # polymorphic inclusions it leaves out are resolved by the caller. # - # @param [ Mongoid::Association::Relatable ] parent The parent inclusion. - # @param [ Hash> ] mapping - # The inclusions grouped by inverse class name. + # @param [ Mongoid::Criteria ] criteria The criteria to load. # - # @return [ Array ] The child inclusions. - def child_inclusions_of(parent, mapping) - mapping.values.flatten.select do |child| - child.parent_inclusions.include?(parent.name) - end - end - - # A reference can be reached through one or more embedded documents. This - # gives the embedded path leading down to it, one per embedded parent, since - # the same association can be embedded in more than one place (e.g. two - # embeds_one of the same class). - # - # @param [ Mongoid::Association::Relatable ] assoc The inclusion. - # @param [ Hash ] inclusions_by_name - # The inclusions indexed by association name. - # - # @return [ Array> ] One chain of - # embedded ancestors per embedded parent, each ordered from the root inward. - def embedded_ancestor_chains(assoc, inclusions_by_name) - assoc.parent_inclusions.filter_map do |parent_name| - parent = inclusions_by_name[parent_name] - embedded_chain_up_to(parent, inclusions_by_name) if parent&.embedded? - end - end - - # The path of embedded documents from the root down to a given embedded - # association, found by climbing through its embedded ancestors. - # - # @param [ Mongoid::Association::Relatable ] embedded_assoc The embedded - # inclusion to start from. - # @param [ Hash ] inclusions_by_name - # The inclusions indexed by association name. - # - # @return [ Array ] The chain of embedded - # ancestors, outermost first. - def embedded_chain_up_to(embedded_assoc, inclusions_by_name) - chain = [ embedded_assoc ] - current = embedded_assoc - while (ancestor = inclusions_by_name[current.parent_inclusions.first]) && ancestor.embedded? - chain.unshift(ancestor) - current = ancestor - end - chain - end - - # Preload polymorphic belongs_to inclusions onto the already-materialized - # root documents. The target collection differs per *_type, so this cannot - # be a $lookup; each inclusion is resolved with a single aggregation whose - # $facet has one branch per distinct type. - def preload_polymorphic(inclusions, docs) - inclusions.each do |assoc| - keys_by_type = polymorphic_keys_by_type(assoc, docs) - targets = fetch_polymorphic_targets(assoc, keys_by_type) - assign_polymorphic_targets(assoc, docs, targets) - end - end - - # Group the foreign keys found on the documents by their polymorphic type, - # e.g. { "Printer" => [ id1 ], "Scanner" => [ id2 ] }. - def polymorphic_keys_by_type(assoc, docs) - docs.each_with_object({}) do |doc, keys_by_type| - type, key = polymorphic_reference(assoc, doc) - (keys_by_type[type] ||= []) << key if type - end - end - - # Resolve the targets of a polymorphic inclusion, as - # { type => { primary_key => document } }. - # - # A $lookup only reaches collections in the root's own database, so the types - # are split by where they live: those in the root's database are fetched - # together in a single $facet aggregation, while a type kept in another - # database (or cluster) is read straight from its own model, which connects - # through that model's client. - # - # @param [ Mongoid::Association::Relatable ] assoc The polymorphic inclusion. - # @param [ Hash ] keys_by_type The foreign keys grouped by type. - # - # @return [ Hash ] The targets, as { type => { primary_key => document } }. - def fetch_polymorphic_targets(assoc, keys_by_type) - local, remote = keys_by_type.partition do |type, _keys| - same_database_as_root?(assoc.resolver.model_for(type)) - end - - targets = fetch_targets_via_facet(assoc, local.to_h) - remote.each { |type, keys| targets[type] = fetch_targets_for_type(assoc, type, keys) } - targets - end - - # Whether +model+ keeps its documents in the same database (and client) as - # the root: exactly what a $lookup from the root collection is able to reach. - # - # @param [ Class ] model The target model. - # - # @return [ true | false ] Whether it shares the root's database. - def same_database_as_root?(model) - model.client_name == klass.client_name && - model.database_name == klass.database_name - end - - # The single-query path, for the types that live in the root's database: one - # $facet aggregation with a $lookup branch per type. - # - # @param [ Mongoid::Association::Relatable ] assoc The polymorphic inclusion. - # @param [ Hash ] keys_by_type The foreign keys grouped by type. - # - # @return [ Hash ] The targets, as { type => { primary_key => document } }. - def fetch_targets_via_facet(assoc, keys_by_type) - return {} if keys_by_type.empty? - - primary_key = assoc.primary_key - facets = keys_by_type.to_h do |type, keys| - collection = assoc.resolver.model_for(type).collection.name - [ type, polymorphic_facet_branch(collection, primary_key, keys) ] - end - - aggregated = klass.collection.aggregate([ { '$limit' => 1 }, { '$facet' => facets } ]).first - aggregated.to_h do |type, branch| - model = assoc.resolver.model_for(type) - # $limit => 1 makes each branch yield a single wrapper holding the matches. - targets = branch.first['matches'].map { |doc| Factory.from_db(model, doc) } - [ type, targets.index_by { |doc| doc.send(primary_key) } ] - end - end - - # The direct-query path, for a type kept outside the root's database that a - # $lookup could not reach: its own collection is read through its model. - # - # @param [ Mongoid::Association::Relatable ] assoc The polymorphic inclusion. - # @param [ String ] type The polymorphic type to fetch. - # @param [ Array ] keys The foreign keys for this type. - # - # @return [ Hash ] The targets for this type, as { primary_key => document }. - def fetch_targets_for_type(assoc, type, keys) - model = assoc.resolver.model_for(type) - model.collection - .find(assoc.primary_key => { '$in' => keys.uniq }) - .map { |doc| Factory.from_db(model, doc) } - .index_by { |doc| doc.send(assoc.primary_key) } - end - - # One $facet branch: look up the documents in +collection+ whose primary key - # is among +keys+, exposed under "matches". - def polymorphic_facet_branch(collection, primary_key, keys) - [ - { '$lookup' => { - 'from' => collection, - 'pipeline' => [ { '$match' => { primary_key => { '$in' => keys.uniq } } } ], - 'as' => 'matches' - } }, - { '$project' => { '_id' => 0, 'matches' => 1 } } - ] - end - - # Set the eager-loaded target on each document, matched by its type and key. - def assign_polymorphic_targets(assoc, docs, targets) - docs.each do |doc| - type, key = polymorphic_reference(assoc, doc) - doc.set_relation(assoc.name, type && targets.dig(type, key)) - end - end - - # The [ type, key ] polymorphic reference stored on the document for this - # association, or nil when the document holds no reference. - def polymorphic_reference(assoc, doc) - type_field = assoc.inverse_type - key_field = assoc.foreign_key - return unless doc.respond_to?(type_field) && doc.respond_to?(key_field) - - type = doc.send(type_field) - key = doc.send(key_field) - [ type, key ] if type && key + # @return [ Array ] The materialized root documents. + def preload_for_lookup(criteria) + pipeline = EagerLoad::LookupPipeline.new(criteria).stages + owner = criteria.inclusions.first.owner_class + owner.collection.aggregate(pipeline).map { |document| Factory.from_db(owner, document) } end private @@ -468,77 +137,12 @@ def docs_for_lookup_fallback # # @return [ Array ] The offending inclusions. def cross_cluster_inclusions - root_client = klass.client_name + root_client_name = klass.client_name # Polymorphic associations have no single resolvable klass and are not # loaded via $lookup, so they are never cross-cluster offenders. - criteria.inclusions.reject do |assoc| - assoc.polymorphic? || assoc.klass.client_name == root_client - end - end - - public - - def switch_local_and_foreign_fields?(association) - association.is_a?(Mongoid::Association::Referenced::BelongsTo) || - association.is_a?(Mongoid::Association::Referenced::HasAndBelongsToMany) - end - - def create_pipeline(current_assoc, mapping) - # Build nested pipeline for children and ordering - pipeline_stages = [] - - # For belongs_to and has_and_belongs_to_many, the foreign key is on the current document - # For has_many/has_one, the foreign key is on the related document - if switch_local_and_foreign_fields?(current_assoc) - local_field = current_assoc.foreign_key - foreign_field = current_assoc.primary_key - else - local_field = current_assoc.primary_key - foreign_field = current_assoc.foreign_key + criteria.inclusions.reject do |association| + association.polymorphic? || association.klass.client_name == root_client_name end - - stage = { - '$lookup' => { - 'from' => current_assoc.klass.collection.name, - 'localField' => local_field, - 'foreignField' => foreign_field, - 'as' => current_assoc.name.to_s - } - } - - # A subclass shares its collection with sibling subclasses, so restrict the - # lookup to the target's own discriminators, like a normal query would. - if current_assoc.klass.hereditary? - target = current_assoc.klass - pipeline_stages << { '$match' => { target.discriminator_key => { '$in' => target._types } } } - end - - # Add ordering if defined on the association, or default to _id for consistent order - if current_assoc.order - sort_spec = current_assoc.order.is_a?(Hash) ? current_assoc.order : { current_assoc.order => 1 } - pipeline_stages << { '$sort' => sort_spec } - else - # Default to sorting by _id to maintain insertion order consistency - pipeline_stages << { '$sort' => { '_id' => 1 } } - end - - # Nest each child inclusion, dropping it from the mapping as it is consumed - # to prevent loops with circular references. An embedded child emits no - # $lookup of its own (it rides inside this document); its referenced - # children are grafted onto the embedded path by nest_embedded_inclusion. - child_inclusions_of(current_assoc, mapping).each do |child| - mapping[child.inverse_class_name] -= [ child ] - if child.embedded? - nest_embedded_inclusion(child, pipeline_stages, mapping) - else - pipeline_stages << create_pipeline(child, mapping) - end - end - - # Always add pipeline since we always have at least $sort - stage['$lookup']['pipeline'] = pipeline_stages - - stage end end end diff --git a/lib/mongoid/association/relatable.rb b/lib/mongoid/association/relatable.rb index 97b3a18df6..011822eb28 100644 --- a/lib/mongoid/association/relatable.rb +++ b/lib/mongoid/association/relatable.rb @@ -342,6 +342,15 @@ def in_to? [ Referenced::BelongsTo, Embedded::EmbeddedIn ].any? { |a| is_a?(a) } end + # Is this association a many-to-many association? Such an association stores + # its foreign keys as an array on the document itself. + # + # @return [ true | false ] true if it is a has_and_belongs_to_many + # association, false if not. + def many_to_many? + is_a?(Referenced::HasAndBelongsToMany) + end + private # Gets the model classes with inverse associations of this model. This is used to determine From f0ea19c3c164ea1e9f013900d37ffcd980faf7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Tue, 23 Jun 2026 14:15:53 -0300 Subject: [PATCH 09/12] Fall back to #includes for eager loads targeting another database A $lookup joins only within the same client and database, but the fallback check compared only the client. An inclusion whose target uses store_in(database:) -- same client, different database -- silently returned empty results instead of falling back. Compare the database as well. --- lib/mongoid/association/eager_loadable.rb | 30 +++++++++++------ spec/mongoid/association/eager_spec.rb | 4 +-- spec/mongoid/criteria/includable_spec.rb | 41 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index 03f5164311..62c591f9f6 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -30,19 +30,21 @@ def eager_load(docs) # Load the associations for the given documents using $lookup. # - # If any of the associated collections reside in a different cluster than - # the root class, falls back to the #includes behavior and logs a warning. + # If any of the associated collections reside in a different cluster or + # database than the root class, falls back to the #includes behavior and + # logs a warning. # # @return [ Array ] The given documents. def eager_load_with_lookup - offenders = cross_cluster_inclusions + offenders = inclusions_unreachable_by_lookup if offenders.any? offender_descriptions = offenders.map do |offender| - "#{offender.name} (#{offender.klass.client_name})" + "#{offender.name} (client: #{offender.klass.client_name}, database: #{offender.klass.database_name})" end Mongoid.logger.warn( 'eager_load cannot use $lookup aggregation because the following associations ' \ - "reside in a different cluster than #{klass} (client: #{klass.client_name}): " \ + "reside in a different cluster or database than #{klass} " \ + "(client: #{klass.client_name}, database: #{klass.database_name}): " \ "#{offender_descriptions.join(', ')}. Falling back to #includes behavior." ) return eager_load(docs_for_lookup_fallback) @@ -132,18 +134,24 @@ def docs_for_lookup_fallback raise NotImplementedError, "#{self.class} must implement #docs_for_lookup_fallback" end - # Returns the inclusions whose target class resides in a different cluster - # than the root class. + # Returns the inclusions whose target class can't be reached by a $lookup + # from the root class, which joins only within the same client and database. # # @return [ Array ] The offending inclusions. - def cross_cluster_inclusions - root_client_name = klass.client_name + def inclusions_unreachable_by_lookup # Polymorphic associations have no single resolvable klass and are not - # loaded via $lookup, so they are never cross-cluster offenders. + # loaded via $lookup, so they are never offenders. criteria.inclusions.reject do |association| - association.polymorphic? || association.klass.client_name == root_client_name + association.polymorphic? || reachable_by_lookup?(association.klass) end end + + # Whether a $lookup from a query on the root class can reach the model: it + # must live in the same client and database. + def reachable_by_lookup?(model) + model.client_name == klass.client_name && + model.database_name == klass.database_name + end end end end diff --git a/spec/mongoid/association/eager_spec.rb b/spec/mongoid/association/eager_spec.rb index c4dafc4b0c..2ea09771a7 100644 --- a/spec/mongoid/association/eager_spec.rb +++ b/spec/mongoid/association/eager_spec.rb @@ -679,9 +679,9 @@ class Voucher expect(docs.first.posts).to eq([ post ]) end - it 'mentions the offending association and client in the warning' do + it 'mentions the offending association, client, and database in the warning' do expect(Mongoid.logger).to receive(:warn).with( - /posts \(other_cluster\)/ + /posts \(client: other_cluster, database: / ) context.eager_load_with_lookup end diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index f52baa7caf..8d9025a415 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -2129,6 +2129,47 @@ class Accessory end end + context 'when a non-polymorphic target is stored in another database' do + before(:all) do + class RemoteServer + include Mongoid::Document + + store_in database: 'secondary' + end + + class Workstation + include Mongoid::Document + + belongs_to :server, class_name: 'RemoteServer', optional: true + end + end + + after(:all) do + Object.send(:remove_const, :Workstation) + Object.send(:remove_const, :RemoteServer) + end + + # RemoteServer lives in its own database, which the global cleanup hook + # (default database only) does not touch, so drop it here. + after { RemoteServer.collection.drop } + + let!(:server) { RemoteServer.create! } + let!(:workstation) { Workstation.create!(server: server) } + + it 'eager-loads the target stored in the other database' do + # A $lookup cannot reach another database, so this falls back to + # separate-query loading: one query materializes the roots and one fetches + # the targets, instead of silently returning nothing. + loaded = expect_query(2) do + Workstation.eager_load(:server).to_a + end + + expect_no_queries do + expect(loaded.first.server).to eq(server) + end + end + end + context 'when a has_many targets a class sharing its collection with sibling subclasses' do before(:all) do class Part From ca813ea55e50ab68c33e511f9683e70c68ea67e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Tue, 23 Jun 2026 14:18:13 -0300 Subject: [PATCH 10/12] Keep an absent embeds_one absent when eager-loading Distributing a referenced association nested in an embeds_one used $mergeObjects, which synthesized the embedded document even when it was missing -- an absent port became { device: [...] }. Guard the merge so a missing embeds_one stays absent instead of being fabricated from its matches. --- .../association/eager_load/embedded_distributor.rb | 13 ++++++++++++- spec/mongoid/criteria/includable_spec.rb | 10 ++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/mongoid/association/eager_load/embedded_distributor.rb b/lib/mongoid/association/eager_load/embedded_distributor.rb index 5f50330b7d..802a8ad7f7 100644 --- a/lib/mongoid/association/eager_load/embedded_distributor.rb +++ b/lib/mongoid/association/eager_load/embedded_distributor.rb @@ -108,7 +108,7 @@ def distributed_value(chain, node) { segment => distributed_value(rest, "#{element}.#{segment}") } end merged = { '$mergeObjects' => [ element, child ] } - return merged unless many + return merge_into_present(node, merged) unless many { '$map' => { 'input' => node, @@ -117,6 +117,17 @@ def distributed_value(chain, node) } } end + # Merge the matches into a single embedded document only when it exists, so + # an absent embeds_one stays absent instead of being synthesized from its + # matches alone. + def merge_into_present(node, merged) + { '$cond' => { + 'if' => { '$ifNull' => [ node, false ] }, + 'then' => merged, + 'else' => node + } } + end + # The matches that belong to a single embedded element. def correlated_matches(element) { '$filter' => { diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index 8d9025a415..34bb54902d 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -1641,6 +1641,16 @@ class Computer expect(loaded.port.device).to eq(device) end end + + it 'leaves the embedded document absent instead of synthesizing it' do + without_port = Computer.create! + loaded = expect_query(1) do + Computer.eager_load(port: :device).to_a.index_by(&:id) + end + expect_no_queries do + expect(loaded[without_port.id].port).to be_nil + end + end end context 'when two embeds_one associations of the same class are eager-loaded' do From abb8f2da7724f39e02143c5d1a191053c62e9485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Tue, 23 Jun 2026 14:32:13 -0300 Subject: [PATCH 11/12] Route a same-named association on several subclasses by discriminator When more than one subclass defines an association with the same name but a different target, eager_load emitted one $lookup per subclass all writing to the same field, so the last overwrote the rest and a sibling's matches silently disappeared. Look each subclass's inclusion, with its own nested children, into its own temporary field and route every document to its own by the discriminator. --- .../eager_load/discriminated_inclusion.rb | 86 +++++++++++++++++++ .../association/eager_load/inclusion.rb | 52 ++++++----- .../association/eager_load/inclusion_tree.rb | 30 +++++-- spec/mongoid/criteria/includable_spec.rb | 70 +++++++++++++++ 4 files changed, 212 insertions(+), 26 deletions(-) create mode 100644 lib/mongoid/association/eager_load/discriminated_inclusion.rb diff --git a/lib/mongoid/association/eager_load/discriminated_inclusion.rb b/lib/mongoid/association/eager_load/discriminated_inclusion.rb new file mode 100644 index 0000000000..b7a9c7e88c --- /dev/null +++ b/lib/mongoid/association/eager_load/discriminated_inclusion.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'mongoid/association/eager_load/inclusion' + +module Mongoid + module Association + module EagerLoad + # Loads an inclusion that more than one subclass defines under the same name + # but pointing at different targets. A single $lookup can't serve them: they + # would all write to the same field and overwrite one another. So each + # subclass's inclusion is contributed into its own temporary field, carrying + # its own nested children, and a $set then routes every document to the field + # for its own type, by the discriminator. + # + # For Machine.eager_load(:widgets), where Lathe#widgets => Cog and + # Press#widgets => Belt, it emits: + # + # { '$lookup' => { 'from' => 'cogs', ..., 'as' => '__eager_load_widgets_Lathe' } }, + # { '$lookup' => { 'from' => 'belts', ..., 'as' => '__eager_load_widgets_Press' } }, + # { '$set' => { + # 'widgets' => { '$switch' => { 'branches' => [ # route each document to its + # { 'case' => { '$eq' => [ '$_type', 'Lathe' ] }, 'then' => '$__eager_load_widgets_Lathe' }, # own type's matches + # { 'case' => { '$eq' => [ '$_type', 'Press' ] }, 'then' => '$__eager_load_widgets_Press' } + # ], 'default' => [] } } + # } }, + # { '$unset' => [ '__eager_load_widgets_Lathe', '__eager_load_widgets_Press' ] } + class DiscriminatedInclusion < Inclusion + def initialize(nodes) + super() + @nodes = nodes + end + + # Append each subclass's lookup (into its own temporary field), the routing + # $set, and the cleanup $unset. + # + # @param [ Array ] destination The pipeline the stages are appended to. + def contribute(destination, _chain) + fields = @nodes.map { |node| [ node, contribute_into_temporary(destination, node) ] } + destination << route_by_type(fields) + destination << { '$unset' => fields.map { |_node, field| field } } + end + + private + + # Let the node build its own $lookup (with its nested children) and redirect + # it to write into a temporary field instead of the shared association name. + def contribute_into_temporary(destination, node) + field = temporary_field(node) + captured = [] + node.contribute(captured, []) + captured.first['$lookup']['as'] = field + destination.concat(captured) + field + end + + def temporary_field(node) + "__eager_load_#{node.association.name}_#{owner(node).discriminator_value}" + end + + # The $set that fills the association on each document from the temporary + # field matching its own type. + def route_by_type(fields) + branches = fields.map do |node, field| + { + 'case' => { '$eq' => [ "$#{discriminator_key}", owner(node).discriminator_value ] }, + 'then' => "$#{field}" + } + end + { '$set' => { name => { '$switch' => { 'branches' => branches, 'default' => [] } } } } + end + + def owner(node) + node.association.inverse_class + end + + def name + @nodes.first.association.name.to_s + end + + def discriminator_key + owner(@nodes.first).discriminator_key + end + end + end + end +end diff --git a/lib/mongoid/association/eager_load/inclusion.rb b/lib/mongoid/association/eager_load/inclusion.rb index 822735f821..eba5f4be71 100644 --- a/lib/mongoid/association/eager_load/inclusion.rb +++ b/lib/mongoid/association/eager_load/inclusion.rb @@ -3,11 +3,27 @@ module Mongoid module Association module EagerLoad - # An inclusion of an eager load, in the role it plays while the pipeline is - # built. Each kind knows how it contributes; the LookupPipeline holds the - # stage-building helpers they lean on. A node carries its own children, so - # the pipeline is built by recursion from the roots downward. + # Something an eager load contributes to the pipeline, in the role it plays + # while the pipeline is built. A root is asked to contribute and the whole + # tree follows by recursion. AssociationInclusion stands for a single + # association; DiscriminatedInclusion stands for a name several subclasses + # share. class Inclusion + # Add this inclusion's stages to the destination. + # + # @param [ Array ] destination The pipeline (or sub-pipeline) the + # stages are appended to. + # @param [ Array ] chain The embedded path + # accumulated from the ancestors above this inclusion (empty at the top). + def contribute(destination, chain) + raise NotImplementedError + end + end + + # An inclusion that stands for a single association. The LookupPipeline holds + # the stage-building helpers the kinds lean on, and a node carries its own + # children, so the pipeline is built by recursion from the roots downward. + class AssociationInclusion < Inclusion class << self # Builds the right kind of inclusion for the association. Each subclass # decides whether it handles it (.for?); exactly one does. @@ -16,7 +32,7 @@ class << self # @param [ LookupPipeline ] pipeline The pipeline being built. # @param [ Array ] children The inclusions nested under it. # - # @return [ Inclusion ] The matching kind of inclusion. + # @return [ AssociationInclusion ] The matching kind of inclusion. def for(association, pipeline, children) subclasses.find { |kind| kind.for?(association) }.new(association, pipeline, children) end @@ -29,21 +45,15 @@ def for?(association) end end + # @return [ Mongoid::Association::Relatable ] The association this stands for. + attr_reader :association + def initialize(association, pipeline, children) + super() @association = association @pipeline = pipeline @children = children end - - # Add this inclusion's stages to the destination. - # - # @param [ Array ] destination The pipeline (or sub-pipeline) the - # stages are appended to. - # @param [ Array ] chain The embedded path - # accumulated from the ancestors above this inclusion (empty at the top). - def contribute(destination, chain) - raise NotImplementedError - end end # A referenced inclusion: contributes a $lookup whose sub-pipeline holds its @@ -65,7 +75,7 @@ def contribute(destination, chain) # # ] # } } - class JoinedInclusion < Inclusion + class JoinedInclusion < AssociationInclusion class << self # The default kind: a referenced, non-polymorphic association, i.e. the # one no sibling kind claims. @@ -93,7 +103,7 @@ def contribute(destination, chain) # For Computer.eager_load(port: :device) the :port inclusion emits nothing; # it hands the path [ :port ] to :device, which EmbeddedDistributor then # turns into stages. - class EmbeddedInclusion < Inclusion + class EmbeddedInclusion < AssociationInclusion class << self def for?(association) association.embedded? @@ -108,9 +118,11 @@ def contribute(destination, chain) # A polymorphic inclusion: its target collection varies per document, so it # can't be a $lookup. It adds nothing here; PolymorphicPreloader resolves it # after the roots are materialized. - class DeferredInclusion < Inclusion - def self.for?(association) - association.polymorphic? + class DeferredInclusion < AssociationInclusion + class << self + def for?(association) + association.polymorphic? + end end def contribute(destination, chain); end diff --git a/lib/mongoid/association/eager_load/inclusion_tree.rb b/lib/mongoid/association/eager_load/inclusion_tree.rb index f1bcc6f875..92e4b6bbad 100644 --- a/lib/mongoid/association/eager_load/inclusion_tree.rb +++ b/lib/mongoid/association/eager_load/inclusion_tree.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'mongoid/association/eager_load/discriminated_inclusion' require 'mongoid/association/eager_load/inclusion' module Mongoid @@ -39,8 +40,15 @@ def contribute_to(destination) private + # A name that more than one subclass defines (with different targets) can't + # share one $lookup field, so its nodes -- each carrying its own children -- + # are grouped and routed by the discriminator instead of becoming separate, + # overwriting roots. def roots - top_level.map { |association| node(association, @inclusions.dup) } + top_level.group_by(&:name).map do |_name, associations| + nodes = associations.map { |association| node(association, @inclusions.dup) } + nodes.one? ? nodes.first : DiscriminatedInclusion.new(nodes) + end end # The inclusions that no other inclusion is the parent of. @@ -52,18 +60,28 @@ def top_level def node(association, available) children = take_children(association, available).map { |child| node(child, available) } - Inclusion.for(association, @pipeline, children) + AssociationInclusion.for(association, @pipeline, children) end # The still-available inclusions parented to +association+, removed as they - # are taken so each lands once on this branch. Matched by the real - # parent-child link, not by class, so a sibling branch isn't pulled in when - # an association points at a superclass of the queried subclass. + # are taken so each lands once on this branch. A child belongs here when it + # names this association as its parent and its owner shares the target's + # class hierarchy, which tells apart children of two unrelated subclasses + # that share an association name. def take_children(association, available) - children = available.select { |candidate| candidate.parent_inclusions.include?(association.name) } + children = available.select do |candidate| + candidate.parent_inclusions.include?(association.name) && + same_hierarchy?(association.klass, candidate.inverse_class) + end available.reject! { |candidate| children.include?(candidate) } children end + + # Whether two classes belong to the same inheritance chain (one is the + # other, an ancestor of it, or a descendant of it). + def same_hierarchy?(one, other) + one <= other || other <= one + end end end end diff --git a/spec/mongoid/criteria/includable_spec.rb b/spec/mongoid/criteria/includable_spec.rb index 34bb54902d..31404350bd 100644 --- a/spec/mongoid/criteria/includable_spec.rb +++ b/spec/mongoid/criteria/includable_spec.rb @@ -2180,6 +2180,76 @@ class Workstation end end + context 'when a has_many of the same name is defined on more than one subclass' do + before(:all) do + class Supplier + include Mongoid::Document + end + + class Cog + include Mongoid::Document + + belongs_to :machine, optional: true + belongs_to :supplier, optional: true + end + + class Belt + include Mongoid::Document + + belongs_to :machine, optional: true + belongs_to :supplier, optional: true + end + + class Machine + include Mongoid::Document + end + + class Lathe < Machine + has_many :widgets, class_name: 'Cog', inverse_of: :machine + end + + class Press < Machine + has_many :widgets, class_name: 'Belt', inverse_of: :machine + end + end + + after(:all) do + Object.send(:remove_const, :Press) + Object.send(:remove_const, :Lathe) + Object.send(:remove_const, :Machine) + Object.send(:remove_const, :Belt) + Object.send(:remove_const, :Cog) + Object.send(:remove_const, :Supplier) + end + + let!(:cog_supplier) { Supplier.create! } + let!(:belt_supplier) { Supplier.create! } + let!(:lathe) { Lathe.create! } + let!(:press) { Press.create! } + let!(:cog) { Cog.create!(machine: lathe, supplier: cog_supplier) } + let!(:belt) { Belt.create!(machine: press, supplier: belt_supplier) } + + it 'loads each subclass association without one overwriting the other' do + loaded = expect_query(1) do + Machine.eager_load(:widgets).to_a.index_by(&:id) + end + expect_no_queries do + expect(loaded[lathe.id].widgets).to eq([ cog ]) + expect(loaded[press.id].widgets).to eq([ belt ]) + end + end + + it 'eager-loads inclusions nested under each subclass association' do + loaded = expect_query(1) do + Machine.eager_load(widgets: :supplier).to_a.index_by(&:id) + end + expect_no_queries do + expect(loaded[lathe.id].widgets.first.supplier).to eq(cog_supplier) + expect(loaded[press.id].widgets.first.supplier).to eq(belt_supplier) + end + end + end + context 'when a has_many targets a class sharing its collection with sibling subclasses' do before(:all) do class Part From 2fbc9a8a81c32e210979f77c1fcbb70c99f81927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=C3=A1n=20P=C3=A9rez?= Date: Wed, 24 Jun 2026 18:29:03 -0300 Subject: [PATCH 12/12] Mark eager-load internals @api private and restore Eager's public API The new eager-load classes are implementation detail, so mark them @api private, and add YARD to the public methods that lacked it. Removing parameters from Eager#initialize changed its public API. Keep the use_lookup/pipeline parameters and the $lookup branch in #run, and have preload_for_lookup delegate to a new Eager.run class method that wraps new(...).run. Update the eager_spec warning expectation for the new cluster/database message. --- lib/mongoid/association/eager.rb | 38 ++++++++++++++++- .../eager_load/discriminated_inclusion.rb | 2 + .../eager_load/embedded_distributor.rb | 2 + .../association/eager_load/inclusion.rb | 41 +++++++++++++++++++ .../association/eager_load/inclusion_tree.rb | 8 ++++ .../association/eager_load/lookup_pipeline.rb | 2 + .../eager_load/polymorphic_preloader.rb | 2 + .../eager_load/polymorphic_targets.rb | 15 ++++++- lib/mongoid/association/eager_loadable.rb | 3 +- 9 files changed, 109 insertions(+), 4 deletions(-) diff --git a/lib/mongoid/association/eager.rb b/lib/mongoid/association/eager.rb index 5e5a38c638..5e32477229 100644 --- a/lib/mongoid/association/eager.rb +++ b/lib/mongoid/association/eager.rb @@ -4,6 +4,15 @@ module Mongoid module Association # Base class for eager load preload functions. class Eager + # Build a preloader for the given arguments and run it. + # + # @param (see #initialize) + # + # @return [ Array ] The list of documents given. + def self.run(associations, docs, use_lookup = false, pipeline = []) + new(associations, docs, use_lookup, pipeline).run + end + # Instantiate the eager load class. # # @example Create the new belongs to eager load preloader. @@ -12,12 +21,18 @@ class Eager # @param [ Array ] associations # Associations to eager load # @param [ Array ] docs Documents to preload the associations + # @param [ Boolean ] use_lookup Whether to use $lookup aggregation + # for eager loading. This is used in Criteria#eager_load. + # @param [ Array ] pipeline The aggregation pipeline to use + # when using $lookup for eager loading. # # @return [ Base ] The eager load preloader - def initialize(associations, docs) + def initialize(associations, docs, use_lookup = false, pipeline = []) @associations = associations @docs = docs @grouped_docs = {} + @use_lookup = use_lookup + @pipeline = pipeline end # Run the preloader. @@ -29,6 +44,12 @@ def initialize(associations, docs) def run @loaded = [] + if @use_lookup + preload_with_lookup + @loaded = @docs + return @loaded.flatten + end + while shift_association preload @loaded << @docs.collect { |d| d.send(@association.name) if d.respond_to?(@association.name) } @@ -48,6 +69,21 @@ def preload raise NotImplementedError end + # Preload the current association using $lookup aggregation. + # This method executes the aggregation pipeline + # and instantiates the documents. + # @example Preload the current association using $lookup. + # loader.preload_with_lookup + def preload_with_lookup + # For $lookup aggregation, execute pipeline and instantiate documents + owner_class = @associations.first.owner_class + aggregated_docs = owner_class.collection.aggregate(@pipeline) + aggregated_docs.each do |doc| + parsed_doc = Factory.from_db(owner_class, doc) + @docs << parsed_doc + end + end + # Retrieves the documents referenced by the association, and # yields each one sequentially to the provided block. If the # association is not polymorphic, all documents are retrieved in diff --git a/lib/mongoid/association/eager_load/discriminated_inclusion.rb b/lib/mongoid/association/eager_load/discriminated_inclusion.rb index b7a9c7e88c..106ae54653 100644 --- a/lib/mongoid/association/eager_load/discriminated_inclusion.rb +++ b/lib/mongoid/association/eager_load/discriminated_inclusion.rb @@ -24,6 +24,8 @@ module EagerLoad # ], 'default' => [] } } # } }, # { '$unset' => [ '__eager_load_widgets_Lathe', '__eager_load_widgets_Press' ] } + # + # @api private class DiscriminatedInclusion < Inclusion def initialize(nodes) super() diff --git a/lib/mongoid/association/eager_load/embedded_distributor.rb b/lib/mongoid/association/eager_load/embedded_distributor.rb index 802a8ad7f7..ad4b6a784d 100644 --- a/lib/mongoid/association/eager_load/embedded_distributor.rb +++ b/lib/mongoid/association/eager_load/embedded_distributor.rb @@ -31,6 +31,8 @@ module EagerLoad # ] } # } }, # { '$unset' => '__eager_load_port_device' } # drop the temp field + # + # @api private class EmbeddedDistributor # @param [ Mongoid::Association::Relatable ] association The referenced # inclusion being eager-loaded from within an embedded document. diff --git a/lib/mongoid/association/eager_load/inclusion.rb b/lib/mongoid/association/eager_load/inclusion.rb index eba5f4be71..47cb03a264 100644 --- a/lib/mongoid/association/eager_load/inclusion.rb +++ b/lib/mongoid/association/eager_load/inclusion.rb @@ -8,6 +8,8 @@ module EagerLoad # tree follows by recursion. AssociationInclusion stands for a single # association; DiscriminatedInclusion stands for a name several subclasses # share. + # + # @api private class Inclusion # Add this inclusion's stages to the destination. # @@ -23,6 +25,8 @@ def contribute(destination, chain) # An inclusion that stands for a single association. The LookupPipeline holds # the stage-building helpers the kinds lean on, and a node carries its own # children, so the pipeline is built by recursion from the roots downward. + # + # @api private class AssociationInclusion < Inclusion class << self # Builds the right kind of inclusion for the association. Each subclass @@ -39,6 +43,8 @@ def for(association, pipeline, children) # Whether this kind handles the given association. # + # @param [ Mongoid::Association::Relatable ] association The inclusion. + # # @return [ true | false ] Whether it handles it. def for?(association) raise NotImplementedError @@ -75,15 +81,28 @@ def initialize(association, pipeline, children) # # ] # } } + # + # @api private class JoinedInclusion < AssociationInclusion class << self # The default kind: a referenced, non-polymorphic association, i.e. the # one no sibling kind claims. + # + # @param [ Mongoid::Association::Relatable ] association The inclusion. + # + # @return [ true | false ] Whether it handles it. def for?(association) (superclass.subclasses - [ self ]).none? { |kind| kind.for?(association) } end end + # Append the $lookup, with the children in its sub-pipeline, to the + # destination; or distribute it onto the embedded path when nested in one. + # + # @param [ Array ] destination The pipeline (or sub-pipeline) the + # stages are appended to. + # @param [ Array ] chain The embedded path + # accumulated from the ancestors above this inclusion (empty at the top). def contribute(destination, chain) stage = @pipeline.lookup_stage_for(@association) @children.each { |child| child.contribute(stage['$lookup']['pipeline'], []) } @@ -103,13 +122,25 @@ def contribute(destination, chain) # For Computer.eager_load(port: :device) the :port inclusion emits nothing; # it hands the path [ :port ] to :device, which EmbeddedDistributor then # turns into stages. + # + # @api private class EmbeddedInclusion < AssociationInclusion class << self + # @param [ Mongoid::Association::Relatable ] association The inclusion. + # + # @return [ true | false ] Whether the association is embedded. def for?(association) association.embedded? end end + # Add no stage of its own; hand this document down the embedded path so the + # children distribute onto it. + # + # @param [ Array ] destination The pipeline (or sub-pipeline) the + # stages are appended to. + # @param [ Array ] chain The embedded path + # accumulated from the ancestors above this inclusion (empty at the top). def contribute(destination, chain) @children.each { |child| child.contribute(destination, chain + [ @association ]) } end @@ -118,13 +149,23 @@ def contribute(destination, chain) # A polymorphic inclusion: its target collection varies per document, so it # can't be a $lookup. It adds nothing here; PolymorphicPreloader resolves it # after the roots are materialized. + # + # @api private class DeferredInclusion < AssociationInclusion class << self + # @param [ Mongoid::Association::Relatable ] association The inclusion. + # + # @return [ true | false ] Whether the association is polymorphic. def for?(association) association.polymorphic? end end + # Add nothing; PolymorphicPreloader resolves the association after the + # roots are materialized. + # + # @param [ Array ] destination The pipeline (unused). + # @param [ Array ] chain The embedded path (unused). def contribute(destination, chain); end end end diff --git a/lib/mongoid/association/eager_load/inclusion_tree.rb b/lib/mongoid/association/eager_load/inclusion_tree.rb index 92e4b6bbad..3a25ff770c 100644 --- a/lib/mongoid/association/eager_load/inclusion_tree.rb +++ b/lib/mongoid/association/eager_load/inclusion_tree.rb @@ -14,8 +14,16 @@ module EagerLoad # inclusion is removed as it is placed, so it lands once per branch even if # more than one parent in that branch points at it, and a circular chain of # inclusions can't loop forever. + # + # @api private class InclusionTree class << self + # Builds the tree for the criteria's inclusions. + # + # @param [ Array ] inclusions The inclusions. + # @param [ LookupPipeline ] pipeline The pipeline being built. + # + # @return [ InclusionTree ] The tree. def from(inclusions, pipeline) new(inclusions, pipeline, inclusions.to_h { |association| [ association.name, association ] }) end diff --git a/lib/mongoid/association/eager_load/lookup_pipeline.rb b/lib/mongoid/association/eager_load/lookup_pipeline.rb index afdc92c0a5..04a4e0760b 100644 --- a/lib/mongoid/association/eager_load/lookup_pipeline.rb +++ b/lib/mongoid/association/eager_load/lookup_pipeline.rb @@ -36,6 +36,8 @@ module EagerLoad # } } # ] # } } ] + # + # @api private class LookupPipeline def initialize(criteria) @criteria = criteria diff --git a/lib/mongoid/association/eager_load/polymorphic_preloader.rb b/lib/mongoid/association/eager_load/polymorphic_preloader.rb index 779b2bbc32..d7da8dbfe6 100644 --- a/lib/mongoid/association/eager_load/polymorphic_preloader.rb +++ b/lib/mongoid/association/eager_load/polymorphic_preloader.rb @@ -11,6 +11,8 @@ module EagerLoad # collection varies per document. So once the roots are materialized, the # foreign keys are grouped by type, PolymorphicTargets resolves the documents # for those keys, and the result is set on each document. + # + # @api private class PolymorphicPreloader def initialize(association, root_class) @association = association diff --git a/lib/mongoid/association/eager_load/polymorphic_targets.rb b/lib/mongoid/association/eager_load/polymorphic_targets.rb index db722fcf0d..9459cd02e2 100644 --- a/lib/mongoid/association/eager_load/polymorphic_targets.rb +++ b/lib/mongoid/association/eager_load/polymorphic_targets.rb @@ -7,12 +7,19 @@ module EagerLoad # { type => { primary_key => document } }. Each subclass reaches the types that # live in one place (the root's database or elsewhere); .for resolves the whole # set, routing each type to the subclass that can reach it. + # + # @api private class PolymorphicTargets class << self # Resolve every polymorphic target for the foreign keys grouped by type. # The types whose documents share the root's database are fetched together # in one $facet; those living elsewhere are read through their own models. - # Returns them indexed as { type => { primary_key => document } }. + # + # @param [ Mongoid::Association::Relatable ] association The polymorphic inclusion. + # @param [ Hash ] keys_by_type The foreign keys grouped by type. + # @param [ Class ] root_class The class being queried. + # + # @return [ Hash ] The targets, as { type => { primary_key => document } }. def for(association, keys_by_type, root_class) here, elsewhere = keys_by_type.partition do |type, _keys| in_root_database?(association, type, root_class) @@ -73,12 +80,15 @@ def indexed(documents, model) # 'Scanner' => [ { '$lookup' => { 'from' => 'scanners', ... } }, ... ] # } } # ] + # + # @api private class SameDatabaseTargets < PolymorphicTargets def initialize(association, keys_by_type, root_class) super(association, keys_by_type) @root_class = root_class end + # @return [ Hash ] The targets, as { type => { primary_key => document } }. def fetch return {} if @keys_by_type.empty? @@ -124,7 +134,10 @@ def branch_for(collection_name, keys) # # For { 'Scanner' => [ id2 ] } it runs, on the Scanner model's own client: # scanners.find('_id' => { '$in' => [ id2 ] }) + # + # @api private class OtherDatabaseTargets < PolymorphicTargets + # @return [ Hash ] The targets, as { type => { primary_key => document } }. def fetch @keys_by_type.to_h do |type, keys| model = model_for(type) diff --git a/lib/mongoid/association/eager_loadable.rb b/lib/mongoid/association/eager_loadable.rb index 62c591f9f6..0dd1bc1535 100644 --- a/lib/mongoid/association/eager_loadable.rb +++ b/lib/mongoid/association/eager_loadable.rb @@ -119,8 +119,7 @@ def preload(associations, docs) # @return [ Array ] The materialized root documents. def preload_for_lookup(criteria) pipeline = EagerLoad::LookupPipeline.new(criteria).stages - owner = criteria.inclusions.first.owner_class - owner.collection.aggregate(pipeline).map { |document| Factory.from_db(owner, document) } + Eager.run(criteria.inclusions, [], true, pipeline) end private