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
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mongoid/association/eager_load/discriminated_inclusion'
4
+ require 'mongoid/association/eager_load/inclusion'
5
+
6
+ module Mongoid
7
+ module Association
8
+ module EagerLoad
9
+ # The tree of nested inclusions an eager load asks to load. Built from the
10
+ # criteria's inclusions, it contributes each root node's stages to the
11
+ # pipeline, with each node already carrying its own children.
12
+ #
13
+ # Each root branch is built from its own copy of the inclusions, and an
14
+ # inclusion is removed as it is placed, so it lands once per branch even if
15
+ # more than one parent in that branch points at it, and a circular chain of
16
+ # inclusions can't loop forever.
17
+ #
18
+ # @api private
19
+ class InclusionTree
20
+ class << self
21
+ # Builds the tree for the criteria's inclusions.
22
+ #
23
+ # @param [ Array<Mongoid::Association::Relatable> ] inclusions The inclusions.
24
+ # @param [ LookupPipeline ] pipeline The pipeline being built.
25
+ #
26
+ # @return [ InclusionTree ] The tree.
27
+ def from(inclusions, pipeline)
28
+ new(inclusions, pipeline, inclusions.to_h { |association| [ association.name, association ] })
29
+ end
30
+
31
+ private :new
32
+ end
33
+
34
+ def initialize(inclusions, pipeline, by_name)
35
+ @inclusions = inclusions
36
+ @pipeline = pipeline
37
+ @by_name = by_name
38
+ end
39
+
40
+ # Contribute each root inclusion's stages to the pipeline. Each root carries
41
+ # its own children, so the whole tree is appended by recursion from the
42
+ # roots downward.
43
+ #
44
+ # @param [ Array<Hash> ] destination The pipeline the stages are appended to.
45
+ def contribute_to(destination)
46
+ roots.each { |root| root.contribute(destination, []) }
47
+ end
48
+
49
+ private
50
+
51
+ # A name that more than one subclass defines (with different targets) can't
52
+ # share one $lookup field, so its nodes -- each carrying its own children --
53
+ # are grouped and routed by the discriminator instead of becoming separate,
54
+ # overwriting roots.
55
+ def roots
56
+ top_level.group_by(&:name).map do |_name, associations|
57
+ nodes = associations.map { |association| node(association, @inclusions.dup) }
58
+ nodes.one? ? nodes.first : DiscriminatedInclusion.new(nodes)
59
+ end
60
+ end
61
+
62
+ # The inclusions that no other inclusion is the parent of.
63
+ def top_level
64
+ @inclusions.reject do |association|
65
+ association.parent_inclusions.any? { |name| @by_name.key?(name) }
66
+ end
67
+ end
68
+
69
+ def node(association, available)
70
+ children = take_children(association, available).map { |child| node(child, available) }
71
+ AssociationInclusion.for(association, @pipeline, children)
72
+ end
73
+
74
+ # The still-available inclusions parented to +association+, removed as they
75
+ # are taken so each lands once on this branch. A child belongs here when it
76
+ # names this association as its parent and its owner shares the target's
77
+ # class hierarchy, which tells apart children of two unrelated subclasses
78
+ # that share an association name.
79
+ def take_children(association, available)
80
+ children = available.select do |candidate|
81
+ candidate.parent_inclusions.include?(association.name) &&
82
+ same_hierarchy?(association.klass, candidate.inverse_class)
83
+ end
84
+ available.reject! { |candidate| children.include?(candidate) }
85
+ children
86
+ end
87
+
88
+ # Whether two classes belong to the same inheritance chain (one is the
89
+ # other, an ancestor of it, or a descendant of it).
90
+ def same_hierarchy?(one, other)
91
+ one <= other || other <= one
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mongoid/association/eager_load/embedded_distributor'
4
+ require 'mongoid/association/eager_load/inclusion_tree'
5
+
6
+ module Mongoid
7
+ module Association
8
+ module EagerLoad
9
+ # Builds the aggregation pipeline that eager-loads a criteria's inclusions
10
+ # with $lookup.
11
+ #
12
+ # It starts with the criteria's own match/sort/skip/limit, then lets each
13
+ # root of the inclusion tree contribute its stages. This object owns the
14
+ # stage-building helpers; how each inclusion contributes is the inclusion's
15
+ # own business (see Inclusion).
16
+ #
17
+ # For Band.eager_load(albums: :tracks) the result is roughly:
18
+ #
19
+ # [ <criteria match / sort / skip / limit>,
20
+ # { '$lookup' => { # JoinedInclusion(:albums)
21
+ # 'from' => 'albums',
22
+ # 'localField' => '_id',
23
+ # 'foreignField' => 'band_id',
24
+ # 'as' => 'albums',
25
+ # 'pipeline' => [
26
+ # { '$sort' => {
27
+ # '_id' => 1
28
+ # } },
29
+ # { '$lookup' => { # JoinedInclusion(:tracks), nested
30
+ # 'as' => 'tracks',
31
+ # 'pipeline' => [
32
+ # { '$sort' => {
33
+ # '_id' => 1
34
+ # } }
35
+ # ]
36
+ # } }
37
+ # ]
38
+ # } } ]
39
+ #
40
+ # @api private
41
+ class LookupPipeline
42
+ def initialize(criteria)
43
+ @criteria = criteria
44
+ end
45
+
46
+ # @return [ Array<Hash> ] The aggregation pipeline stages.
47
+ def stages
48
+ pipeline = @criteria.selector.to_pipeline
49
+ pipeline.concat(@criteria.options.to_pipeline_for_lookup)
50
+ InclusionTree.from(@criteria.inclusions, self).contribute_to(pipeline)
51
+ pipeline
52
+ end
53
+
54
+ # The $lookup stage for a referenced inclusion: its key fields, a
55
+ # discriminator match when the target shares its collection with sibling
56
+ # subclasses, and an order. Children are added by the inclusion itself.
57
+ #
58
+ # @param [ Mongoid::Association::Relatable ] association The inclusion.
59
+ #
60
+ # @return [ Hash ] The $lookup stage.
61
+ def lookup_stage_for(association)
62
+ local_field, foreign_field = lookup_fields(association)
63
+ sub_pipeline = []
64
+ sub_pipeline << discriminator_match(association) if association.klass.hereditary?
65
+ sub_pipeline << order(association)
66
+ { '$lookup' => {
67
+ 'from' => association.klass.collection.name,
68
+ 'localField' => local_field,
69
+ 'foreignField' => foreign_field,
70
+ 'as' => association.name.to_s,
71
+ 'pipeline' => sub_pipeline
72
+ } }
73
+ end
74
+
75
+ # Builds the stages that distribute a referenced inclusion living inside an
76
+ # embedded document onto that document.
77
+ #
78
+ # @param [ Mongoid::Association::Relatable ] association The inclusion.
79
+ # @param [ Array<Mongoid::Association::Relatable> ] chain The embedded path.
80
+ # @param [ Hash ] lookup_stage The $lookup stage for the association.
81
+ #
82
+ # @return [ Array<Hash> ] The stages to append.
83
+ def distribute(association, chain, lookup_stage)
84
+ EmbeddedDistributor.for(association: association, chain: chain, lookup_stage: lookup_stage).stages
85
+ end
86
+
87
+ private
88
+
89
+ # When the association stores the foreign key on the current document
90
+ # (belongs_to, has_and_belongs_to_many) the local field is that key; for
91
+ # the others (has_many, has_one) the key is on the related document.
92
+ def lookup_fields(association)
93
+ if association.stores_foreign_key?
94
+ [ association.foreign_key, association.primary_key ]
95
+ else
96
+ [ association.primary_key, association.foreign_key ]
97
+ end
98
+ end
99
+
100
+ def discriminator_match(association)
101
+ target = association.klass
102
+ { '$match' => {
103
+ target.discriminator_key => { '$in' => target._types }
104
+ } }
105
+ end
106
+
107
+ def order(association)
108
+ unless association.order
109
+ return { '$sort' => {
110
+ '_id' => 1
111
+ } }
112
+ end
113
+
114
+ ordering = association.order
115
+ ordering = { ordering => 1 } unless ordering.is_a?(Hash)
116
+ { '$sort' => ordering }
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mongoid/association/eager_load/polymorphic_targets'
4
+
5
+ module Mongoid
6
+ module Association
7
+ module EagerLoad
8
+ # Resolves a polymorphic belongs_to onto already-materialized root documents.
9
+ #
10
+ # A polymorphic belongs_to can't be expressed as a $lookup: its target
11
+ # collection varies per document. So once the roots are materialized, the
12
+ # foreign keys are grouped by type, PolymorphicTargets resolves the documents
13
+ # for those keys, and the result is set on each document.
14
+ #
15
+ # @api private
16
+ class PolymorphicPreloader
17
+ def initialize(association, root_class)
18
+ @association = association
19
+ @root_class = root_class
20
+ end
21
+
22
+ # Resolve and assign the polymorphic target on each of the documents.
23
+ #
24
+ # @param [ Array<Mongoid::Document> ] documents The materialized root documents.
25
+ def preload_into(documents)
26
+ targets = PolymorphicTargets.for(@association, keys_by_type(documents), @root_class)
27
+ assign(documents, targets)
28
+ end
29
+
30
+ private
31
+
32
+ # The foreign keys on the documents grouped by polymorphic type,
33
+ # e.g. { "Printer" => [ id1 ], "Scanner" => [ id2 ] }.
34
+ def keys_by_type(documents)
35
+ documents.each_with_object({}) do |document, grouped|
36
+ type, key = reference_on(document)
37
+ (grouped[type] ||= []) << key if type && key
38
+ end
39
+ end
40
+
41
+ def assign(documents, targets)
42
+ documents.each do |document|
43
+ type, key = reference_on(document)
44
+ target = targets.dig(type, key) if type && key
45
+ document.set_relation(@association.name, target)
46
+ end
47
+ end
48
+
49
+ # The [ type, key ] reference stored on the document for this association,
50
+ # or an empty pair when the document holds no such reference.
51
+ def reference_on(document)
52
+ type_field = @association.inverse_type
53
+ key_field = @association.foreign_key
54
+ return [] unless document.respond_to?(type_field) && document.respond_to?(key_field)
55
+
56
+ [ document.send(type_field), document.send(key_field) ]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Association
5
+ module EagerLoad
6
+ # The targets of a polymorphic belongs_to, indexed as
7
+ # { type => { primary_key => document } }. Each subclass reaches the types that
8
+ # live in one place (the root's database or elsewhere); .for resolves the whole
9
+ # set, routing each type to the subclass that can reach it.
10
+ #
11
+ # @api private
12
+ class PolymorphicTargets
13
+ class << self
14
+ # Resolve every polymorphic target for the foreign keys grouped by type.
15
+ # The types whose documents share the root's database are fetched together
16
+ # in one $facet; those living elsewhere are read through their own models.
17
+ #
18
+ # @param [ Mongoid::Association::Relatable ] association The polymorphic inclusion.
19
+ # @param [ Hash ] keys_by_type The foreign keys grouped by type.
20
+ # @param [ Class ] root_class The class being queried.
21
+ #
22
+ # @return [ Hash ] The targets, as { type => { primary_key => document } }.
23
+ def for(association, keys_by_type, root_class)
24
+ here, elsewhere = keys_by_type.partition do |type, _keys|
25
+ in_root_database?(association, type, root_class)
26
+ end
27
+ same_database = SameDatabaseTargets.new(association, here.to_h, root_class)
28
+ other_databases = OtherDatabaseTargets.new(association, elsewhere.to_h)
29
+ same_database.fetch.merge(other_databases.fetch)
30
+ end
31
+
32
+ private
33
+
34
+ # Whether the type's model shares the root's database (and client): exactly
35
+ # what a $lookup from the root collection can reach.
36
+ def in_root_database?(association, type, root_class)
37
+ model = association.resolver.model_for(type)
38
+ model.client_name == root_class.client_name &&
39
+ model.database_name == root_class.database_name
40
+ end
41
+ end
42
+
43
+ def initialize(association, keys_by_type)
44
+ @association = association
45
+ @keys_by_type = keys_by_type
46
+ end
47
+
48
+ # @return [ Hash ] The targets, as { type => { primary_key => document } }.
49
+ def fetch
50
+ raise NotImplementedError
51
+ end
52
+
53
+ private
54
+
55
+ def primary_key
56
+ @association.primary_key
57
+ end
58
+
59
+ def model_for(type)
60
+ @association.resolver.model_for(type)
61
+ end
62
+
63
+ # The raw documents instantiated and indexed by their primary key.
64
+ def indexed(documents, model)
65
+ documents.map { |document| Factory.from_db(model, document) }
66
+ .index_by { |document| document.send(primary_key) }
67
+ end
68
+ end
69
+
70
+ # Targets that live in the root's own database. A $lookup can reach them, so
71
+ # every type is fetched together in one $facet aggregation against the root
72
+ # collection.
73
+ #
74
+ # For { 'Printer' => [ id1 ], 'Scanner' => [ id2 ] } it runs:
75
+ #
76
+ # [
77
+ # { '$limit' => 1 }, # one input doc, so each facet branch runs once
78
+ # { '$facet' => { # run one $lookup per type within a single query
79
+ # 'Printer' => [ { '$lookup' => { 'from' => 'printers', ... } }, ... ],
80
+ # 'Scanner' => [ { '$lookup' => { 'from' => 'scanners', ... } }, ... ]
81
+ # } }
82
+ # ]
83
+ #
84
+ # @api private
85
+ class SameDatabaseTargets < PolymorphicTargets
86
+ def initialize(association, keys_by_type, root_class)
87
+ super(association, keys_by_type)
88
+ @root_class = root_class
89
+ end
90
+
91
+ # @return [ Hash ] The targets, as { type => { primary_key => document } }.
92
+ def fetch
93
+ return {} if @keys_by_type.empty?
94
+
95
+ aggregated = @root_class.collection.aggregate([ { '$limit' => 1 }, { '$facet' => facets } ]).first
96
+ aggregated.to_h do |type, branch|
97
+ # $limit => 1 makes each branch yield a single wrapper holding the matches.
98
+ [ type, indexed(branch.first['matches'], model_for(type)) ]
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def facets
105
+ @keys_by_type.to_h do |type, keys|
106
+ [ type, branch_for(model_for(type).collection.name, keys) ]
107
+ end
108
+ end
109
+
110
+ # One $facet branch: the documents in +collection_name+ whose primary key
111
+ # is among +keys+, exposed under "matches".
112
+ def branch_for(collection_name, keys)
113
+ [
114
+ { '$lookup' => {
115
+ 'from' => collection_name,
116
+ 'pipeline' => [
117
+ { '$match' => {
118
+ primary_key => { '$in' => keys.uniq }
119
+ } }
120
+ ],
121
+ 'as' => 'matches'
122
+ } },
123
+ { '$project' => {
124
+ '_id' => 0,
125
+ 'matches' => 1
126
+ } }
127
+ ]
128
+ end
129
+ end
130
+
131
+ # Targets kept in another database (or cluster), which a $lookup cannot
132
+ # reach. Each type is read directly through its own model, which connects
133
+ # with that model's client.
134
+ #
135
+ # For { 'Scanner' => [ id2 ] } it runs, on the Scanner model's own client:
136
+ # scanners.find('_id' => { '$in' => [ id2 ] })
137
+ #
138
+ # @api private
139
+ class OtherDatabaseTargets < PolymorphicTargets
140
+ # @return [ Hash ] The targets, as { type => { primary_key => document } }.
141
+ def fetch
142
+ @keys_by_type.to_h do |type, keys|
143
+ model = model_for(type)
144
+ documents = model.collection.find(primary_key => { '$in' => keys.uniq })
145
+ [ type, indexed(documents, model) ]
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'mongoid/association/eager'
4
+ require 'mongoid/association/eager_load/embedded_distributor'
5
+ require 'mongoid/association/eager_load/polymorphic_preloader'
6
+ require 'mongoid/association/eager_load/lookup_pipeline'
4
7
 
5
8
  module Mongoid
6
9
  module Association
@@ -27,24 +30,49 @@ module Mongoid
27
30
 
28
31
  # Load the associations for the given documents using $lookup.
29
32
  #
30
- # If any of the associated collections reside in a different cluster than
31
- # the root class, falls back to the #includes behavior and logs a warning.
33
+ # If any of the associated collections reside in a different cluster or
34
+ # database than the root class, falls back to the #includes behavior and
35
+ # logs a warning.
32
36
  #
33
37
  # @return [ Array<Mongoid::Document> ] The given documents.
34
38
  def eager_load_with_lookup
35
- offenders = cross_cluster_inclusions
39
+ offenders = inclusions_unreachable_by_lookup
36
40
  if offenders.any?
37
- root_client = klass.client_name
38
- offender_list = offenders.map { |a| "#{a.name} (#{a.klass.client_name})" }.join(', ')
41
+ offender_descriptions = offenders.map do |offender|
42
+ "#{offender.name} (client: #{offender.klass.client_name}, database: #{offender.klass.database_name})"
43
+ end
39
44
  Mongoid.logger.warn(
40
45
  'eager_load cannot use $lookup aggregation because the following associations ' \
41
- "reside in a different cluster than #{klass} (client: #{root_client}): " \
42
- "#{offender_list}. Falling back to #includes behavior."
46
+ "reside in a different cluster or database than #{klass} " \
47
+ "(client: #{klass.client_name}, database: #{klass.database_name}): " \
48
+ "#{offender_descriptions.join(', ')}. Falling back to #includes behavior."
49
+ )
50
+ return eager_load(docs_for_lookup_fallback)
51
+ end
52
+
53
+ through_inclusions = criteria.inclusions.select do |association|
54
+ association.is_a?(Association::Referenced::HasOneThrough) ||
55
+ association.is_a?(Association::Referenced::HasManyThrough)
56
+ end
57
+
58
+ if through_inclusions.any?
59
+ through_names = through_inclusions.map { |association| ":#{association.name}" }
60
+ Mongoid.logger.warn(
61
+ "#{through_names.join(', ')} are :through associations and do not support " \
62
+ '$lookup-based eager loading. All inclusions for this query will be preloaded ' \
63
+ 'using separate queries.'
43
64
  )
44
65
  return eager_load(docs_for_lookup_fallback)
45
66
  end
46
67
 
47
- preload_for_lookup(criteria)
68
+ documents = preload_for_lookup(criteria)
69
+ # A polymorphic belongs_to cannot be expressed as a $lookup: its target
70
+ # collection varies per document. It is resolved after the roots are
71
+ # materialized, each inclusion by its own preloader.
72
+ criteria.inclusions.select(&:polymorphic?).each do |association|
73
+ EagerLoad::PolymorphicPreloader.new(association, klass).preload_into(documents)
74
+ end
75
+ documents
48
76
  end
49
77
 
50
78
  # Load the associations for the given documents. This will be done
@@ -82,39 +110,16 @@ module Mongoid
82
110
  end
83
111
  end
84
112
 
85
- # Load the associations for the given documents. This will be done
86
- # recursively to load the associations of the given documents'
87
- # associated documents.
113
+ # Materialize the root documents with their inclusions eager-loaded by a
114
+ # single $lookup aggregation. The pipeline is built by LookupPipeline; the
115
+ # polymorphic inclusions it leaves out are resolved by the caller.
88
116
  #
89
- # @param [ Array<Mongoid::Association::Relatable> ] associations
90
- # The associations to load.
91
- # @param [ Array<Mongoid::Document> ] docs The documents.
117
+ # @param [ Mongoid::Criteria ] criteria The criteria to load.
118
+ #
119
+ # @return [ Array<Mongoid::Document> ] The materialized root documents.
92
120
  def preload_for_lookup(criteria)
93
- assoc_map = criteria.inclusions.group_by(&:inverse_class_name)
94
-
95
- # match first
96
- pipeline = criteria.selector.to_pipeline
97
- # then sort, skip, limit
98
- pipeline.concat(criteria.options.to_pipeline_for_lookup)
99
-
100
- # account for single-collection inheritance
101
- root_class = klass.root_class
102
-
103
- if assoc_map[klass.to_s]
104
- assoc_map[klass.to_s].each do |assoc|
105
- # Create a copy of the mapping for each top-level association to avoid mutation issues
106
- pipeline << create_pipeline(assoc, assoc_map.dup)
107
- end
108
- end
109
-
110
- if klass != root_class && assoc_map[root_class.to_s]
111
- assoc_map[root_class.to_s].each do |assoc|
112
- # Create a copy of the mapping for each top-level association to avoid mutation issues
113
- pipeline << create_pipeline(assoc, assoc_map.dup)
114
- end
115
- end
116
-
117
- Eager.new(criteria.inclusions, [], true, pipeline).run
121
+ pipeline = EagerLoad::LookupPipeline.new(criteria).stages
122
+ Eager.run(criteria.inclusions, [], true, pipeline)
118
123
  end
119
124
 
120
125
  private
@@ -128,71 +133,23 @@ module Mongoid
128
133
  raise NotImplementedError, "#{self.class} must implement #docs_for_lookup_fallback"
129
134
  end
130
135
 
131
- # Returns the inclusions whose target class resides in a different cluster
132
- # than the root class.
136
+ # Returns the inclusions whose target class can't be reached by a $lookup
137
+ # from the root class, which joins only within the same client and database.
133
138
  #
134
139
  # @return [ Array<Mongoid::Association::Relatable> ] The offending inclusions.
135
- def cross_cluster_inclusions
136
- root_client = klass.client_name
137
- criteria.inclusions.reject { |assoc| assoc.klass.client_name == root_client }
138
- end
139
-
140
- public
141
-
142
- def switch_local_and_foreign_fields?(association)
143
- association.is_a?(Mongoid::Association::Referenced::BelongsTo) ||
144
- association.is_a?(Mongoid::Association::Referenced::HasAndBelongsToMany)
145
- end
146
-
147
- def create_pipeline(current_assoc, mapping)
148
- # Build nested pipeline for children and ordering
149
- pipeline_stages = []
150
-
151
- # For belongs_to and has_and_belongs_to_many, the foreign key is on the current document
152
- # For has_many/has_one, the foreign key is on the related document
153
- if switch_local_and_foreign_fields?(current_assoc)
154
- local_field = current_assoc.foreign_key
155
- foreign_field = current_assoc.primary_key
156
- else
157
- local_field = current_assoc.primary_key
158
- foreign_field = current_assoc.foreign_key
140
+ def inclusions_unreachable_by_lookup
141
+ # Polymorphic associations have no single resolvable klass and are not
142
+ # loaded via $lookup, so they are never offenders.
143
+ criteria.inclusions.reject do |association|
144
+ association.polymorphic? || reachable_by_lookup?(association.klass)
159
145
  end
146
+ end
160
147
 
161
- # Build the 'as' field with embedded path prefix if needed
162
- as_field = current_assoc.name.to_s
163
-
164
- stage = {
165
- '$lookup' => {
166
- 'from' => current_assoc.klass.collection.name,
167
- 'localField' => local_field,
168
- 'foreignField' => foreign_field,
169
- 'as' => as_field
170
- }
171
- }
172
-
173
- # Add ordering if defined on the association, or default to _id for consistent order
174
- if current_assoc.order
175
- sort_spec = current_assoc.order.is_a?(Hash) ? current_assoc.order : { current_assoc.order => 1 }
176
- pipeline_stages << { '$sort' => sort_spec }
177
- else
178
- # Default to sorting by _id to maintain insertion order consistency
179
- pipeline_stages << { '$sort' => { '_id' => 1 } }
180
- end
181
-
182
- # Add nested lookups for child associations
183
- # Child associations don't need the embedded_path prefix since they're referenced from the looked-up document
184
- # Remove this class from the mapping to prevent infinite loops with circular references
185
- class_name = current_assoc.klass.to_s
186
- if child_assocs = mapping.delete(class_name)
187
- child_assocs.each do |child|
188
- pipeline_stages << create_pipeline(child, mapping)
189
- end
190
- end
191
-
192
- # Always add pipeline since we always have at least $sort
193
- stage['$lookup']['pipeline'] = pipeline_stages
194
-
195
- stage
148
+ # Whether a $lookup from a query on the root class can reach the model: it
149
+ # must live in the same client and database.
150
+ def reachable_by_lookup?(model)
151
+ model.client_name == klass.client_name &&
152
+ model.database_name == klass.database_name
196
153
  end
197
154
  end
198
155
  end
@@ -601,7 +601,12 @@ module Mongoid
601
601
  # @api private
602
602
  def update_attributes_hash
603
603
  if _target.empty?
604
- _base.attributes.delete(_association.store_as)
604
+ # Only remove the key if it is absent or nil. If it is already
605
+ # an empty array, the caller explicitly set it to [] (e.g. via
606
+ # a raw write_attribute call), and that intent must be preserved
607
+ # so the empty array is persisted to the database.
608
+ stored = _association.store_as
609
+ _base.attributes.delete(stored) unless _base.attributes[stored] == []
605
610
  else
606
611
  _base.attributes.merge!(_association.store_as => _target.map(&:attributes))
607
612
  end
@@ -21,6 +21,7 @@ module Mongoid
21
21
  as
22
22
  cascade_callbacks
23
23
  cyclic
24
+ fallback
24
25
  store_as
25
26
  ]
26
27
 
@@ -218,7 +218,19 @@ module Mongoid
218
218
  private
219
219
 
220
220
  def define_association!(macro_name, name, options = {}, &block)
221
- Association::MACRO_MAPPING[macro_name].new(self, name, options, &block).tap do |assoc|
221
+ klass = if options[:through]
222
+ Association::THROUGH_MACRO_MAPPING[macro_name] ||
223
+ raise(
224
+ Errors::InvalidRelationOption.new(
225
+ self, name, :through,
226
+ Association::THROUGH_MACRO_MAPPING.keys
227
+ )
228
+ )
229
+ else
230
+ Association::MACRO_MAPPING[macro_name]
231
+ end
232
+
233
+ klass.new(self, name, options, &block).tap do |assoc|
222
234
  assoc.setup!
223
235
  self.relations = relations.merge(name => assoc)
224
236
  if assoc.embedded? && assoc.respond_to?(:store_as) && assoc.store_as != name