Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
854de86
MONGOID-5057 Add ReadonlyAssociation error class
jamis May 4, 2026
cf23a93
MONGOID-5057 Add YARD docs to ReadonlyAssociation#initialize
jamis May 4, 2026
43e0ba1
MONGOID-5057 Add THROUGH_MACRO_MAPPING and :through routing in define…
jamis May 4, 2026
1832ade
MONGOID-5057 Fix ASSOCIATION_OPTIONS and add validation_default in th…
jamis May 4, 2026
485ba7b
MONGOID-5057 Implement HasOneThrough metadata, proxy, and eager loader
jamis May 4, 2026
1793887
MONGOID-5057 Add stores_foreign_key? and clarify criteria/group_by_ke…
jamis May 4, 2026
40fb663
MONGOID-5057 Add has_one :through integration tests and embedded guar…
jamis May 4, 2026
8ccdc6e
MONGOID-5057 Strengthen has_one :through integration tests (source op…
jamis May 4, 2026
f0768d4
MONGOID-5057 Implement HasManyThrough metadata, proxy, and eager loader
jamis May 4, 2026
c266612
MONGOID-5057 Clarify criteria vs resolve on HasManyThrough
jamis May 4, 2026
f858297
MONGOID-5057 Add has_many :through integration tests
jamis May 4, 2026
15378d8
MONGOID-5057 Fall back to preload in eager_load for :through associat…
jamis May 4, 2026
29ed396
MONGOID-5057 Clarify eager_load fallback warning when mixed inclusion…
jamis May 4, 2026
2e66baa
MONGOID-5057 Add eager loading integration tests for :through associa…
jamis May 4, 2026
298ca1f
MONGOID-5057 Add :source option integration test
jamis May 4, 2026
41697e1
MONGOID-5057 Fix HasManyThrough#criteria to accept base and return sc…
jamis May 4, 2026
7d7d122
Merge branch 'master' into 5057-has_many-through
jamis May 26, 2026
882e793
Add :order option, and disallow HABTM as a source for has_any :through
jamis May 26, 2026
0ff8a0e
MONGOID-5057 Address code review feedback on through associations
jamis May 26, 2026
178cbc4
MONGOID-5057 Replace em-dashes in context descriptions with ASCII hyp…
jamis May 26, 2026
ef2852f
Merge branch 'master' into 5057-has_many-through
jamis May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,10 @@ en:
can only have values set when the document is a new record."
resolution: "Don't define '%{name}' as readonly, or do not attempt
to update its value after the document is persisted."
readonly_association:
message: "Attempted to modify the read-only association '%{name}' on '%{klass}'."
summary: "The :%{name} association is defined with :through and is read-only."
resolution: "To modify the association, update :%{through} directly."
readonly_document:
message: "Attempted to persist a readonly document of class '%{klass}'."
summary: "Documents that are marked readonly cannot be persisted."
Expand Down
7 changes: 7 additions & 0 deletions lib/mongoid/association.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ module Association
belongs_to: Association::Referenced::BelongsTo
}.freeze

# Internal mapping used when :through option is present. Not exposed as
# callable macros.
THROUGH_MACRO_MAPPING = {
has_one: Association::Referenced::HasOneThrough,
has_many: Association::Referenced::HasManyThrough
}.freeze

attr_accessor :_association

included do
Expand Down
14 changes: 14 additions & 0 deletions lib/mongoid/association/eager_loadable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ def eager_load_with_lookup
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)
end

if through_inclusions.any?
names = through_inclusions.map { |a| ":#{a.name}" }.join(', ')
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.'
)
return eager_load(docs_for_lookup_fallback)
end

preload_for_lookup(criteria)
end

Expand Down
14 changes: 13 additions & 1 deletion lib/mongoid/association/macros.rb
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,19 @@ def has_one(name, options = {}, &block) # rubocop:disable Naming/PredicatePrefix
private

def define_association!(macro_name, name, options = {}, &block)
Association::MACRO_MAPPING[macro_name].new(self, name, options, &block).tap do |assoc|
klass = if options[:through]
Association::THROUGH_MACRO_MAPPING[macro_name] ||
raise(
Errors::InvalidRelationOption.new(
self, name, :through,
Association::THROUGH_MACRO_MAPPING.keys
)
)
else
Association::MACRO_MAPPING[macro_name]
end

klass.new(self, name, options, &block).tap do |assoc|
assoc.setup!
self.relations = relations.merge(name => assoc)
if assoc.embedded? && assoc.respond_to?(:store_as) && assoc.store_as != name
Expand Down
2 changes: 2 additions & 0 deletions lib/mongoid/association/referenced.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
require 'mongoid/association/referenced/has_many'
require 'mongoid/association/referenced/has_and_belongs_to_many'
require 'mongoid/association/referenced/has_one'
require 'mongoid/association/referenced/has_one_through'
require 'mongoid/association/referenced/has_many_through'
187 changes: 187 additions & 0 deletions lib/mongoid/association/referenced/has_many_through.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# frozen_string_literal: true

require 'mongoid/association/referenced/has_many_through/proxy'
require 'mongoid/association/referenced/has_many_through/eager'

module Mongoid
module Association
module Referenced
# Metadata class for has_many :through associations.
class HasManyThrough
include Relatable

# The options available for this type of association, in addition to the
# common ones.
#
# @return [ Array<Symbol> ] The extra valid options.
ASSOCIATION_OPTIONS = %i[order source through].freeze

# The complete list of valid options for this association, including
# the shared ones.
#
# @return [ Array<Symbol> ] The valid options.
VALID_OPTIONS = (ASSOCIATION_OPTIONS + SHARED_OPTIONS).freeze

# The list of association complements.
#
# @return [ Array ]
def relation_complements
[].freeze
end

# Setup instance methods on the owner class.
#
# @return [ self ]
def setup!
setup_instance_methods!
self
end

# Is this association embedded?
#
# @return [ false ]
def embedded?
false
end

# The proxy class for this association type.
#
# @return [ Class ]
def relation
Proxy
end

# Through associations never store a foreign key on the owner document.
#
# @return [ false ]
def stores_foreign_key?
false
end

# The intermediate association metadata on the owner class.
# Resolved lazily to allow forward references.
#
# @return [ Mongoid::Association::Relatable ]
def through_association
@through_association ||= begin
assoc = @owner_class.relations[@options[:through].to_s] ||
raise(
Errors::InvalidRelationOption.new(
@owner_class, name, :through, @options[:through]
)
)
if assoc.embedded?
raise(
Errors::InvalidRelationOption.new(
@owner_class, name, :through,
'through association must be a referenced association, not embedded'
)
)
end
assoc
end
end

# The source association metadata on the intermediate class.
# Resolved lazily to allow forward references.
#
# @return [ Mongoid::Association::Relatable ]
def source_association
@source_association ||= begin
source_name = (@options[:source] || name.to_s.singularize).to_s
assoc = through_association.klass.relations[source_name] ||
raise(
Errors::InvalidRelationOption.new(
@owner_class, name, :source, source_name
)
)
if assoc.is_a?(Referenced::HasAndBelongsToMany)
raise(
Errors::InvalidRelationOption.new(
@owner_class, name, :source,
'has_and_belongs_to_many is not supported as a :through source'
)
)
end
assoc
end
end

# Return a Criteria scoped to the target documents reachable from base
# via the through association. Performs two queries: one against the
# intermediate collection, one against the source collection.
#
# @param [ Document ] base The owner document.
#
# @return [ Mongoid::Criteria ]
def criteria(base)
through_crit = through_association.criteria(base)

crit = if source_association.stores_foreign_key?
# FK is on the intermediate (e.g. appointment.patient_id -> belongs_to :patient)
target_pk = source_association.primary_key # '_id' on Patient
source_fk = source_association.foreign_key # 'patient_id' on Appointment
source_association.klass.where(
target_pk => { '$in' => through_crit.pluck(source_fk) }
)
else
# FK is on the source (e.g. reader.book_id -> has_many :readers on Book)
source_pk = source_association.primary_key # '_id' on intermediate (Book)
source_fk = source_association.foreign_key # 'book_id' on Reader
source_association.klass.where(
source_fk => { '$in' => through_crit.pluck(source_pk) }
)
end
Comment thread
jamis marked this conversation as resolved.
order ? crit.order_by(order) : crit
end
Comment thread
jamis marked this conversation as resolved.

# The default for validating the association object.
#
# @return [ false ]
def validation_default
false
end

private

def setup_instance_methods!
define_through_getter!
define_through_ids_getter!
define_readonly_setter!
define_existence_check!
self
end

def define_through_getter!
assoc = self
assoc_name = name
@owner_class.re_define_method(assoc_name) do |reload = false|
if reload || !instance_variable_defined?("@_#{assoc_name}")
set_relation(assoc_name, HasManyThrough::Proxy.new(self, assoc))
end
instance_variable_get("@_#{assoc_name}")
end
end

def define_through_ids_getter!
assoc_name = name
ids_method = :"#{assoc_name.to_s.singularize}_ids"
@owner_class.re_define_method(ids_method) do
send(assoc_name).pluck(:_id)
end
end

def define_readonly_setter!
assoc = self
@owner_class.re_define_method(:"#{name}=") do |_object|
raise Mongoid::Errors::ReadonlyAssociation.new(self.class, assoc)
end
end

def default_primary_key
PRIMARY_KEY_DEFAULT
end
end
end
end
end
99 changes: 99 additions & 0 deletions lib/mongoid/association/referenced/has_many_through/eager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

module Mongoid
module Association
module Referenced
class HasManyThrough
# Two-query eager preloader for has_many :through associations.
class Eager < Association::Eager
private

def preload
@docs.each { |d| set_relation(d, []) }

through_assoc = @association.through_association
source_assoc = @association.source_association

owner_pk = through_assoc.primary_key
through_fk = through_assoc.foreign_key

owner_ids = @docs.filter_map { |d| d.public_send(owner_pk) }.uniq
return if owner_ids.empty?

# Step 1: load all intermediate records
intermediates = through_assoc.klass
.where(through_fk => { '$in' => owner_ids })
.to_a

# Step 2: map owner FK values to arrays of target docs
targets_by_owner_fk = build_targets_map(intermediates, through_fk, source_assoc)

# Step 3: set relation on each owner doc
@docs.each do |doc|
key_val = doc.public_send(owner_pk)
set_relation(doc, targets_by_owner_fk[key_val] || [])
end
end

# Build a Hash mapping each owner FK value to an array of target docs.
# Uses two different strategies depending on where the FK lives.
def build_targets_map(intermediates, through_fk, source_assoc)
if source_assoc.stores_foreign_key?
fk_on_intermediate_targets_map(intermediates, through_fk, source_assoc)
else
fk_on_source_targets_map(intermediates, through_fk, source_assoc)
end
end

# FK is on the intermediate (e.g. appointment.patient_id -> belongs_to :patient).
def fk_on_intermediate_targets_map(intermediates, through_fk, source_assoc)
source_fk_vals = intermediates.filter_map { |i| i.public_send(source_assoc.foreign_key) }.uniq
targets = source_assoc.klass.where(
source_assoc.primary_key => { '$in' => source_fk_vals }
).to_a
targets_by_pk = targets.group_by { |t| t.public_send(source_assoc.primary_key) }

result = Hash.new { |h, k| h[k] = [] }
intermediates.each do |i|
owner_fk_val = i.public_send(through_fk)
matched = targets_by_pk[i.public_send(source_assoc.foreign_key)] || []
result[owner_fk_val].concat(matched)
end
result
end

# FK is on the source (e.g. reader.book_id -> has_many :readers on Book).
def fk_on_source_targets_map(intermediates, through_fk, source_assoc)
source_pk = source_assoc.primary_key
intermediate_pks = intermediates.filter_map { |i| i.public_send(source_pk) }.uniq
targets = source_assoc.klass.where(
source_assoc.foreign_key => { '$in' => intermediate_pks }
).to_a
targets_by_source_fk = targets.group_by { |t| t.public_send(source_assoc.foreign_key) }

result = Hash.new { |h, k| h[k] = [] }
intermediates.each do |i|
owner_fk_val = i.public_send(through_fk)
matched = targets_by_source_fk[i.public_send(source_pk)] || []
result[owner_fk_val].concat(matched)
end
Comment thread
jamis marked this conversation as resolved.
result
end

def set_relation(doc, element)
return if doc.blank?

proxy = HasManyThrough::Proxy.new(doc, @association, preloaded: element)
doc.set_relation(@association.name, proxy)
end
Comment thread
jamis marked this conversation as resolved.

# Required by base class contract. Not called from preload since this
# class manages its own two-query traversal directly.
def group_by_key
@association.through_association.primary_key
end
end
end
end
end
end
Loading
Loading