mongoid 9.1.0 → 9.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +49 -0
  3. data/lib/mongoid/association/accessors.rb +9 -1
  4. data/lib/mongoid/association/depending.rb +14 -10
  5. data/lib/mongoid/association/eager.rb +9 -0
  6. data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +88 -0
  7. data/lib/mongoid/association/eager_load/embedded_distributor.rb +151 -0
  8. data/lib/mongoid/association/eager_load/inclusion.rb +173 -0
  9. data/lib/mongoid/association/eager_load/inclusion_tree.rb +96 -0
  10. data/lib/mongoid/association/eager_load/lookup_pipeline.rb +121 -0
  11. data/lib/mongoid/association/eager_load/polymorphic_preloader.rb +61 -0
  12. data/lib/mongoid/association/eager_load/polymorphic_targets.rb +151 -0
  13. data/lib/mongoid/association/eager_loadable.rb +57 -100
  14. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
  15. data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
  16. data/lib/mongoid/association/macros.rb +13 -1
  17. data/lib/mongoid/association/nested/many.rb +34 -5
  18. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  19. data/lib/mongoid/association/nested/one.rb +6 -1
  20. data/lib/mongoid/association/options.rb +17 -0
  21. data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
  22. data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
  23. data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
  24. data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
  25. data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
  26. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  27. data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
  28. data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
  29. data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
  30. data/lib/mongoid/association/referenced/has_one.rb +1 -0
  31. data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
  32. data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
  33. data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
  34. data/lib/mongoid/association/referenced.rb +2 -0
  35. data/lib/mongoid/association/relatable.rb +40 -0
  36. data/lib/mongoid/association.rb +7 -0
  37. data/lib/mongoid/clients/factory.rb +7 -13
  38. data/lib/mongoid/collection_configurable.rb +8 -0
  39. data/lib/mongoid/config/defaults.rb +0 -1
  40. data/lib/mongoid/config/encryption.rb +36 -19
  41. data/lib/mongoid/config.rb +56 -5
  42. data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
  43. data/lib/mongoid/contextual/memory.rb +18 -7
  44. data/lib/mongoid/criteria/includable.rb +35 -11
  45. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  46. data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
  47. data/lib/mongoid/deprecable.rb +45 -2
  48. data/lib/mongoid/encryptable.rb +46 -0
  49. data/lib/mongoid/errors/config_redactor.rb +43 -0
  50. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  51. data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
  52. data/lib/mongoid/errors/no_client_database.rb +1 -1
  53. data/lib/mongoid/errors/no_client_hosts.rb +1 -1
  54. data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
  55. data/lib/mongoid/errors/readonly_association.rb +29 -0
  56. data/lib/mongoid/errors.rb +4 -0
  57. data/lib/mongoid/extensions/vector.rb +64 -0
  58. data/lib/mongoid/extensions.rb +1 -0
  59. data/lib/mongoid/field_readable.rb +69 -0
  60. data/lib/mongoid/indexable.rb +38 -26
  61. data/lib/mongoid/matchable.rb +6 -1
  62. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
  63. data/lib/mongoid/matcher/regex.rb +11 -12
  64. data/lib/mongoid/matcher/regexp_budget.rb +372 -0
  65. data/lib/mongoid/matcher.rb +1 -0
  66. data/lib/mongoid/persistable/creatable.rb +1 -1
  67. data/lib/mongoid/persistable/updatable.rb +9 -11
  68. data/lib/mongoid/persistence_context.rb +35 -0
  69. data/lib/mongoid/search_indexable.rb +64 -23
  70. data/lib/mongoid/serializable.rb +12 -4
  71. data/lib/mongoid/tasks/database.rb +23 -9
  72. data/lib/mongoid/threaded.rb +36 -0
  73. data/lib/mongoid/timestamps/timeless.rb +84 -10
  74. data/lib/mongoid/traversable.rb +2 -2
  75. data/lib/mongoid/validatable/associated.rb +18 -2
  76. data/lib/mongoid/version.rb +1 -1
  77. data/spec/integration/app_spec.rb +7 -1
  78. data/spec/integration/associations/belongs_to_spec.rb +13 -0
  79. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  80. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  81. data/spec/integration/encryption_spec.rb +151 -0
  82. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  83. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  84. data/spec/integration/query_operator_guard_spec.rb +89 -0
  85. data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
  86. data/spec/mongoid/association/eager_spec.rb +2 -2
  87. data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
  88. data/spec/mongoid/association/fallback_spec.rb +425 -0
  89. data/spec/mongoid/association/macros_spec.rb +26 -0
  90. data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
  91. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  92. data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
  93. data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
  94. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  95. data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
  96. data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
  97. data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
  98. data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
  99. data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
  100. data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
  101. data/spec/mongoid/attributes/nested_spec.rb +249 -8
  102. data/spec/mongoid/config/defaults_spec.rb +33 -0
  103. data/spec/mongoid/config/encryption_spec.rb +228 -0
  104. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  105. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  106. data/spec/mongoid/criteria/includable_spec.rb +875 -0
  107. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
  108. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
  109. data/spec/mongoid/criteria_spec.rb +3 -0
  110. data/spec/mongoid/encryptable_spec.rb +61 -0
  111. data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
  112. data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
  113. data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
  114. data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
  115. data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
  116. data/spec/mongoid/extensions/vector_spec.rb +175 -0
  117. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  118. data/spec/mongoid/search_indexable_spec.rb +295 -9
  119. data/spec/mongoid/tasks/database_spec.rb +18 -0
  120. data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
  121. data/spec/mongoid/touchable_spec.rb +25 -0
  122. data/spec/mongoid/touchable_spec_models.rb +10 -0
  123. data/spec/mongoid/validatable/associated_spec.rb +19 -0
  124. data/spec/spec_helper.rb +5 -1
  125. data/spec/support/crypt/models.rb +157 -0
  126. metadata +52 -2
@@ -179,7 +179,7 @@ module Mongoid
179
179
  # @param [ Hash ] attrs The single document attributes to process.
180
180
  def update_nested_relation(parent, id, attrs)
181
181
  first = existing.first
182
- converted = first ? convert_id(first.class, id) : id
182
+ converted = convert_id(first ? first.class : association.klass, id)
183
183
 
184
184
  if existing.where(_id: converted).exists?
185
185
  # document exists in association
@@ -191,19 +191,48 @@ module Mongoid
191
191
  end
192
192
  elsif association.embedded?
193
193
  raise Errors::DocumentNotFound.new(association.klass, id)
194
- elsif association.is_a?(Association::Referenced::HasAndBelongsToMany) || Mongoid.allow_reparenting_via_nested_attributes?
195
- Mongoid::Warnings.warn_reparenting_via_nested_attributes if Mongoid.allow_reparenting_via_nested_attributes?
194
+ elsif destroyable?(attrs)
195
+ # A destroy of a document that is not in the association is
196
+ # ignored, rather than reaching for it outside the association.
197
+ nil
198
+ elsif Mongoid.allow_reparenting_via_nested_attributes?
199
+ Mongoid::Warnings.warn_reparenting_via_nested_attributes
196
200
 
197
201
  # push existing document to association
198
202
  doc = association.klass.unscoped.find(converted)
203
+ # find returns nil instead of raising when
204
+ # Mongoid.raise_not_found_error is false; a missing document
205
+ # must still be an error here, consistent with the other
206
+ # not-found branches.
207
+ raise Errors::DocumentNotFound.new(association.klass, id) if doc.nil?
208
+
199
209
  update_document(doc, attrs)
200
- existing.push(doc) unless destroyable?(attrs)
210
+ existing.push(doc)
201
211
  else
202
- raise Errors::DocumentNotFound.new(association.klass, { _id: id, association.foreign_key => parent.id })
212
+ raise Errors::DocumentNotFound.new(association.klass, not_found_params(parent, id))
203
213
  end
204
214
 
205
215
  parent.children_may_have_changed!
206
216
  end
217
+
218
+ # The params to report when an id in the nested attributes could not
219
+ # be resolved within the association.
220
+ #
221
+ # @api private
222
+ #
223
+ # @param [ Document ] parent The parent document.
224
+ # @param [ String | BSON::ObjectId ] id of the related document.
225
+ #
226
+ # @return [ Hash | Object ] The params for the not found error.
227
+ def not_found_params(parent, id)
228
+ if association.is_a?(Association::Referenced::HasAndBelongsToMany)
229
+ # A many-to-many association has no foreign key on the child, so
230
+ # the id is only ever resolved within the association itself.
231
+ id
232
+ else
233
+ { _id: id, association.foreign_key => parent.id }
234
+ end
235
+ end
207
236
  end
208
237
  end
209
238
  end
@@ -55,12 +55,26 @@ module Mongoid
55
55
  # @example Convert the id.
56
56
  # builder.convert_id(Person, "4d371b444835d98b8b000010")
57
57
  #
58
+ # Ids arriving from a form are always scalars. A Hash or an Array
59
+ # here means the parameters were crafted, and letting one through
60
+ # would turn the id into a query operator, so they are rejected.
61
+ #
58
62
  # @param [ Class ] klass The class we're trying to convert for.
59
63
  # @param [ String ] id The id, usually coming from the form.
60
64
  #
61
65
  # @return [ BSON::ObjectId | String | Object ] The converted id.
66
+ #
67
+ # @raise [ Errors::DocumentNotFound ] if the id is not a scalar, or
68
+ # cannot be converted to the type the class uses for its ids.
69
+ # The BSON::Error rescue is defensive: a value that reaches
70
+ # BSON::ObjectId.mongoize and raises there must not surface as an
71
+ # unhandled BSON error in the caller.
62
72
  def convert_id(klass, id)
73
+ raise Errors::DocumentNotFound.new(klass, id) if id.is_a?(::Hash) || id.is_a?(::Array)
74
+
63
75
  klass.using_object_ids? ? BSON::ObjectId.mongoize(id) : id
76
+ rescue BSON::Error
77
+ raise Errors::DocumentNotFound.new(klass, id)
64
78
  end
65
79
 
66
80
  private
@@ -26,7 +26,12 @@ module Mongoid
26
26
  def build(parent)
27
27
  return if reject?(parent, attributes)
28
28
 
29
- @existing = parent.send(association.name)
29
+ @existing =
30
+ if association.fallback?
31
+ parent.send(:without_autobuild) { parent.send(association.name) }
32
+ else
33
+ parent.send(association.name)
34
+ end
30
35
  if update?
31
36
  delete_id(attributes)
32
37
  existing.assign_attributes(attributes)
@@ -39,6 +39,23 @@ module Mongoid
39
39
  !!@options[:autobuild]
40
40
  end
41
41
 
42
+ # Invokes the :fallback Proc and returns the null object that stands in
43
+ # for the association when its actual value is nil. The Proc is invoked
44
+ # on every access; identity is the user's responsibility (return a fresh
45
+ # instance, a shared constant, or whatever the Proc chooses).
46
+ #
47
+ # @return [ Object | nil ] The Proc's return value, or nil if not set.
48
+ def fallback
49
+ @options[:fallback]&.call
50
+ end
51
+
52
+ # Whether the association has a :fallback (null object) option set.
53
+ #
54
+ # @return [ true | false ]
55
+ def fallback?
56
+ !@options[:fallback].nil?
57
+ end
58
+
42
59
  # Is the association cyclic.
43
60
  #
44
61
  # @return [ true | false ] Whether the association is cyclic.
@@ -24,10 +24,15 @@ module Mongoid
24
24
  # association is not polymorphic, all documents are retrieved in
25
25
  # a single query. If the association is polymorphic, one query is
26
26
  # issued per association target class.
27
+ #
28
+ # The polymorphic *_type field is resolved through the
29
+ # association's resolver to keep this path consistent with the
30
+ # non-eager accessor and to avoid loading arbitrary constants
31
+ # named in user-controlled BSON data.
27
32
  def each_loaded_document(&block)
28
33
  if @association.polymorphic?
29
34
  keys_by_type_from_docs.each do |type, keys|
30
- each_loaded_document_of_class(Object.const_get(type), keys, &block)
35
+ each_loaded_document_of_class(@association.resolver.model_for(type), keys, &block)
31
36
  end
32
37
  else
33
38
  super
@@ -22,6 +22,7 @@ module Mongoid
22
22
  autosave
23
23
  counter_cache
24
24
  dependent
25
+ fallback
25
26
  foreign_key
26
27
  index
27
28
  polymorphic
@@ -110,7 +110,7 @@ module Mongoid
110
110
  end
111
111
  end
112
112
 
113
- if (record = __send__(name)) && !current.nil?
113
+ if (record = without_autobuild { __send__(name) }) && !current.nil?
114
114
  record[cache_column] = (record[cache_column] || 0) + 1
115
115
  record.class.with(record.persistence_context) do |_class|
116
116
  _class.increment_counter(cache_column, current) if record.persisted?
@@ -120,7 +120,7 @@ module Mongoid
120
120
  end
121
121
 
122
122
  klass.after_create do
123
- if record = __send__(name)
123
+ if record = without_autobuild { __send__(name) }
124
124
  record[cache_column] = (record[cache_column] || 0) + 1
125
125
 
126
126
  if record.persisted?
@@ -133,7 +133,7 @@ module Mongoid
133
133
  end
134
134
 
135
135
  klass.before_destroy do
136
- if record = __send__(name)
136
+ if record = without_autobuild { __send__(name) }
137
137
  record[cache_column] = (record[cache_column] || 0) - 1 unless record.frozen?
138
138
 
139
139
  if record.persisted?
@@ -271,6 +271,11 @@ module Mongoid
271
271
  crit = relation_class.criteria
272
272
  crit = if id_list
273
273
  crit = crit.apply_scope(scope)
274
+ # A nil in the stored key array would match every document
275
+ # whose primary key is null or absent, returning documents
276
+ # that were never associated. Associating a target whose
277
+ # custom primary key is unset stores such a nil.
278
+ id_list = id_list.compact if id_list.is_a?(Array)
274
279
  crit.all_of(primary_key => { '$in' => id_list })
275
280
  else
276
281
  crit.none
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'mongoid/field_readable'
3
4
  require 'mongoid/pluckable'
4
5
 
5
6
  module Mongoid
@@ -12,6 +13,7 @@ module Mongoid
12
13
  class Enumerable
13
14
  extend Forwardable
14
15
  include ::Enumerable
16
+ include FieldReadable
15
17
  include Pluckable
16
18
 
17
19
  # The three main instance variables are collections of documents.
@@ -590,7 +592,7 @@ module Mongoid
590
592
  #
591
593
  # @return [ Array<Numeric> ] The non-nil field values.
592
594
  def field_values_for(field)
593
- map { |doc| doc.public_send(field) }.compact
595
+ map { |doc| read_field_value(doc, field) }.compact
594
596
  end
595
597
 
596
598
  def set_base(document)
@@ -538,12 +538,111 @@ module Mongoid
538
538
  # @return [ Integer ] The number of documents deleted.
539
539
  def remove_all(conditions = nil, method = :delete_all)
540
540
  selector = conditions || {}
541
- removed = klass.send(method, selector.merge!(criteria.selector))
541
+ selector.merge!(criteria.selector)
542
+
543
+ # Scanning before the delete means scanning only what is already in
544
+ # memory, so it is only done where there is a pattern whose cost has
545
+ # to be bounded. Everywhere else the delete comes first and the scan
546
+ # sees the survivors, which is what this association has always
547
+ # done.
548
+ #
549
+ # The limit is read once and carried into whichever branch is taken.
550
+ # Letting the branch below ask again would read it a second time,
551
+ # across a server round trip, and a limit that had become positive
552
+ # in the meantime would put a deadline on the delete_if -- a query,
553
+ # and an unbind of every match -- which is the arrangement this
554
+ # branch exists to avoid.
555
+ limit = Matcher::RegexpBudget.limit_for(selector)
556
+ return remove_all_bounded(selector, method, limit) if limit
557
+
558
+ removed = klass.send(method, selector)
559
+
560
+ # No scope around this. The scan runs after the delete, so the
561
+ # association this loads comes back holding only what the delete did
562
+ # not match, and there is no pattern here to bound in any case.
563
+ # Opening a scope would save _matches? from deciding once per
564
+ # document -- 0.48us against 1.74us for the match itself -- but a
565
+ # scope with nothing to bound suppresses that decision for
566
+ # everything nested inside it, and loading the association runs
567
+ # find callbacks. A pattern in a selector one of those evaluates
568
+ # would go unguarded, which costs more than the saving is worth on a
569
+ # path that has just made two round trips.
542
570
  _target.delete_if do |doc|
543
571
  doc._matches?(selector).tap do |b|
544
572
  unbind_one(doc) if b
545
573
  end
546
574
  end
575
+
576
+ removed
577
+ end
578
+
579
+ # Deletes all related documents matching a selector that carries a
580
+ # regular expression, with the whole scan under one regexp budget.
581
+ #
582
+ # The scan runs before anything is deleted, so a budget that runs out
583
+ # leaves both the database and the association untouched rather than
584
+ # reporting a failure for a delete that has already happened. It also
585
+ # means the budget is closed by the time the association is mutated,
586
+ # so where the budget is enforced with Timeout there is no window for
587
+ # the exception to land in the middle of an unbind.
588
+ #
589
+ # @param [ Hash ] selector The selector to delete with.
590
+ # @param [ Symbol ] method The deletion method to call.
591
+ # @param [ Float ] limit The seconds the scan may spend, as read by
592
+ # the caller when it chose this path.
593
+ #
594
+ # @return [ Integer ] The number of documents deleted.
595
+ def remove_all_bounded(selector, method, limit)
596
+ # Only what the association already holds. Iterating it instead
597
+ # would load the whole association before the delete, and for a
598
+ # broad pattern that is every document the association has: /.*/ is
599
+ # both the cheapest pattern an attacker can supply and the one that
600
+ # matches everything, so bounding the cost of the matching would
601
+ # have been paid for with an unbounded amount of memory. Nothing is
602
+ # missed by not loading, because the server evaluates the same
603
+ # selector for the delete itself.
604
+ #
605
+ # It also means the scan runs no query, so no deadline of ours can
606
+ # land on one. Where the budget is enforced with Timeout, covering
607
+ # a query would report a slow network as a regexp timeout and could
608
+ # deliver the asynchronous exception inside the driver's socket
609
+ # read.
610
+ documents = _target.in_memory
611
+
612
+ matching = Matcher::RegexpBudget.open_with(limit) do
613
+ documents.select { |doc| doc._matches?(selector) }
614
+ end
615
+
616
+ removed = klass.send(method, selector)
617
+
618
+ # The ids are already known to be in memory, so the loaded and added
619
+ # documents can be dropped directly. Enumerable#delete would look
620
+ # each one up by id and stop at the first hash that has it, which
621
+ # removes the wrong instance when both hashes hold one for the same
622
+ # id. Enumerable#delete_if drops it from both, as this does, but
623
+ # only after load_all!, which is the load this scan exists to avoid.
624
+ #
625
+ # Which documents get unbound has always depended on which ones
626
+ # happened to be in memory, and scanning only what is in memory
627
+ # keeps that. It matters for has_and_belongs_to_many, whose
628
+ # unbind_one pulls the id out of the foreign key array on _base and
629
+ # marks it dirty; the scan used to run after the delete, against an
630
+ # association that a cold proxy had just reloaded as holding only
631
+ # the survivors, so nothing matched and nothing was unbound.
632
+ #
633
+ # TODO: unbinding should not depend on what was in memory. Every
634
+ # matched document leaves the association, so every one of them
635
+ # should be unbound, which would also stop _base's foreign key
636
+ # array from keeping ids that no longer resolve. That is a
637
+ # behaviour change for has_and_belongs_to_many and belongs in a
638
+ # ticket of its own, with a release note; it should not ride along
639
+ # on this one.
640
+ matching.each do |doc|
641
+ unbind_one(doc)
642
+ _target._loaded.delete(doc._id)
643
+ _target._added.delete(doc._id)
644
+ end
645
+
547
646
  removed
548
647
  end
549
648
 
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Association
5
+ module Referenced
6
+ class HasManyThrough
7
+ # Two-query eager preloader for has_many :through associations.
8
+ class Eager < Association::Eager
9
+ private
10
+
11
+ def preload
12
+ @docs.each { |d| set_relation(d, []) }
13
+
14
+ through_assoc = @association.through_association
15
+ source_assoc = @association.source_association
16
+
17
+ owner_pk = through_assoc.primary_key
18
+ through_fk = through_assoc.foreign_key
19
+
20
+ owner_ids = @docs.filter_map { |d| d.public_send(owner_pk) }.uniq
21
+ return if owner_ids.empty?
22
+
23
+ # Step 1: load all intermediate records
24
+ intermediates = through_assoc.klass
25
+ .where(through_fk => { '$in' => owner_ids })
26
+ .to_a
27
+
28
+ # Step 2: map owner FK values to arrays of target docs
29
+ targets_by_owner_fk = build_targets_map(intermediates, through_fk, source_assoc)
30
+
31
+ # Step 3: set relation on each owner doc
32
+ @docs.each do |doc|
33
+ key_val = doc.public_send(owner_pk)
34
+ set_relation(doc, targets_by_owner_fk[key_val] || [])
35
+ end
36
+ end
37
+
38
+ # Build a Hash mapping each owner FK value to an array of target docs.
39
+ # Uses two different strategies depending on where the FK lives.
40
+ def build_targets_map(intermediates, through_fk, source_assoc)
41
+ if source_assoc.stores_foreign_key?
42
+ fk_on_intermediate_targets_map(intermediates, through_fk, source_assoc)
43
+ else
44
+ fk_on_source_targets_map(intermediates, through_fk, source_assoc)
45
+ end
46
+ end
47
+
48
+ # FK is on the intermediate (e.g. appointment.patient_id -> belongs_to :patient).
49
+ def fk_on_intermediate_targets_map(intermediates, through_fk, source_assoc)
50
+ source_fk_vals = intermediates.filter_map { |i| i.public_send(source_assoc.foreign_key) }.uniq
51
+ targets = source_assoc.klass.where(
52
+ source_assoc.primary_key => { '$in' => source_fk_vals }
53
+ ).to_a
54
+ targets_by_pk = targets.group_by { |t| t.public_send(source_assoc.primary_key) }
55
+
56
+ result = Hash.new { |h, k| h[k] = [] }
57
+ intermediates.each do |i|
58
+ owner_fk_val = i.public_send(through_fk)
59
+ matched = targets_by_pk[i.public_send(source_assoc.foreign_key)] || []
60
+ result[owner_fk_val].concat(matched)
61
+ end
62
+ result
63
+ end
64
+
65
+ # FK is on the source (e.g. reader.book_id -> has_many :readers on Book).
66
+ def fk_on_source_targets_map(intermediates, through_fk, source_assoc)
67
+ source_pk = source_assoc.primary_key
68
+ intermediate_pks = intermediates.filter_map { |i| i.public_send(source_pk) }.uniq
69
+ targets = source_assoc.klass.where(
70
+ source_assoc.foreign_key => { '$in' => intermediate_pks }
71
+ ).to_a
72
+ targets_by_source_fk = targets.group_by { |t| t.public_send(source_assoc.foreign_key) }
73
+
74
+ result = Hash.new { |h, k| h[k] = [] }
75
+ intermediates.each do |i|
76
+ owner_fk_val = i.public_send(through_fk)
77
+ matched = targets_by_source_fk[i.public_send(source_pk)] || []
78
+ result[owner_fk_val].concat(matched)
79
+ end
80
+ result
81
+ end
82
+
83
+ def set_relation(doc, element)
84
+ return if doc.blank?
85
+
86
+ proxy = HasManyThrough::Proxy.new(doc, @association, preloaded: element)
87
+ doc.set_relation(@association.name, proxy)
88
+ end
89
+
90
+ # Required by base class contract. Not called from preload since this
91
+ # class manages its own two-query traversal directly.
92
+ def group_by_key
93
+ @association.through_association.primary_key
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Association
5
+ module Referenced
6
+ class HasManyThrough
7
+ # Read-only proxy for has_many :through associations. Wraps the lazy
8
+ # Criteria returned by the association and raises ReadonlyAssociation on
9
+ # any write attempt.
10
+ class Proxy
11
+ extend Forwardable
12
+ include Enumerable
13
+
14
+ module ClassMethods
15
+ def eager_loader(association, docs)
16
+ Eager.new(association, docs)
17
+ end
18
+
19
+ def embedded?
20
+ false
21
+ end
22
+ end
23
+
24
+ extend ClassMethods
25
+
26
+ # Enumerable methods use the preloaded array when available; query
27
+ # methods always delegate to criteria so callers get a Criteria back.
28
+ def_delegators :_source,
29
+ :each, :to_a, :first, :last, :count, :size, :length,
30
+ :exists?, :any?, :none?, :empty?
31
+
32
+ def_delegators :criteria,
33
+ :where, :pluck, :limit, :skip, :order_by, :only, :without,
34
+ :sum, :avg, :min, :max
35
+
36
+ READONLY_METHODS = %i[
37
+ << push concat substitute build new create create!
38
+ delete delete_one delete_all destroy_all clear nullify
39
+ ].freeze
40
+
41
+ def initialize(base, association, preloaded: nil)
42
+ @_base = base
43
+ @_association = association
44
+ @preloaded = preloaded
45
+ end
46
+
47
+ def criteria
48
+ @criteria ||= @_association.criteria(@_base)
49
+ end
50
+
51
+ private
52
+
53
+ def _source
54
+ @preloaded || criteria
55
+ end
56
+
57
+ public
58
+
59
+ READONLY_METHODS.each do |meth|
60
+ define_method(meth) do |*|
61
+ raise Mongoid::Errors::ReadonlyAssociation.new(@_base.class, @_association)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end