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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0182a5e78d9fecbf490a537bd4dcf5aba640683c566b400ff7c70b189201372f'
4
- data.tar.gz: d194627c4d001e4b420652d52a8612e6f4c4209948259f3d1f4b33de4257920b
3
+ metadata.gz: 5bfb9894822f364261e1579143e0a4860a27dfe42437243938ecb2bc186a0819
4
+ data.tar.gz: f66924e8684458b2318f82bc7614f01823938e8c7cb3f19bf5246dd1098dd5b5
5
5
  SHA512:
6
- metadata.gz: b0e2447ca53e3ac80aee5a4a29dc9fe45110ded5f35a6e25569b9aca9e34a1f48060ef17a2dc9257f4d4d5ec3d07d725679a1f83e6850fa24edb32099b471ff2
7
- data.tar.gz: b41c4d87a55e8ba10c90b1998f36280021793c6c4816d54259642a29a23f39201afb5c272cf473545f0b14a47ea609e6be8b8b35e68ee5e39076c1f2ceaa64f1
6
+ metadata.gz: 16d064d36b9a6a425d2727afdb879da7a28dd25a5889de7dd6242120d69441fb666db16dec584b0e00c77aed32fcd0a5d900c7fc3ed8ddfe0fe2006b0053dbc2
7
+ data.tar.gz: 2d8c367c96724961cdd78354a8bf6d10cef2fa209cec4103741e26d43389286a173a8df301116121b2e9ad89495c7c5e268b4458dd8b22e6a2ddb4fbdd835eb0
@@ -140,6 +140,32 @@ en:
140
140
  A collation option is only supported if the query is executed on a MongoDB server
141
141
  with version >= 3.4."
142
142
  resolution: "Remove the collation option from the query."
143
+ in_memory_regexp_timeout:
144
+ message: "Evaluating this query in memory exceeded the %{limit} second
145
+ limit that applies to conditions containing a regular expression."
146
+ summary: "Mongoid evaluates some conditions in the calling thread
147
+ rather than sending them to the server: queries against an embedded
148
+ association, removing documents from an association by condition,
149
+ and Document#_matches?. A regular expression in such a condition
150
+ runs locally. Mongoid limits the total time one in-memory
151
+ evaluation may spend matching, because both the cost of a single
152
+ match and the number of matches performed grow with the condition,
153
+ and a pattern built from end-user input can be made to consume an
154
+ unbounded amount of CPU. The limit is cumulative over the whole
155
+ evaluation rather than per match. Where the Ruby in use offers no
156
+ per-Regexp timeout the limit is enforced as wall clock over the
157
+ whole in-memory evaluation, so there it can be exceeded by a large
158
+ scan even when the pattern itself is cheap."
159
+ resolution: "Check whether the pattern in this query comes from user
160
+ input; if it does, validate it before querying. If the query is
161
+ legitimate and simply large, raise
162
+ Mongoid::Config.in_memory_regexp_time_limit, or set it to nil to
163
+ remove the limit entirely. The limit in force is the smaller of
164
+ that setting and any global Regexp.timeout the application has set,
165
+ so check both. On JRuby, and on MRI before 3.2, there is no
166
+ per-Regexp timeout, so what the limit bounds there is the elapsed
167
+ time of the whole in-memory evaluation rather than the time spent
168
+ matching."
143
169
  invalid_around_callback:
144
170
  message: "An around callback must contain a yield in its definition."
145
171
  summary: "The block needs to be yielded to for around callbacks to function as intended."
@@ -515,6 +541,25 @@ en:
515
541
  environment, Mongoid cannot load its configuration."
516
542
  resolution: "Please ensure an environment is set in one of the
517
543
  listed locations. The environment must be explicitly set."
544
+ no_encryption_schema:
545
+ message: "The encryption schema of client %{client} does not cover
546
+ %{namespace}."
547
+ summary: "%{klass} declares encrypted fields, but the namespace it
548
+ resolved to, %{namespace}, is not part of the automatic encryption
549
+ schema of client %{client}. The encryption schema is built once,
550
+ when the client is created, and is keyed by namespace, so a
551
+ database name that is only known later - a callable :database
552
+ option, Model.with(database:), Mongoid.override_database - is not
553
+ in it. Routing the model to a client that has no
554
+ auto_encryption_options has the same effect. Mongoid refuses the
555
+ operation because the driver would store the declared fields in
556
+ plaintext without reporting an error."
557
+ resolution: "Give the model a namespace that is known when the
558
+ client is created, and a client configured with
559
+ auto_encryption_options. If the model has to live in a database
560
+ chosen at runtime, configure one client per database, each with
561
+ its own auto_encryption_options, and select between them with
562
+ Model.with(client:) instead of switching the database."
518
563
  no_map_reduce_output:
519
564
  message: "No output location was specified for the map/reduce
520
565
  operation."
@@ -618,6 +663,10 @@ en:
618
663
  can only have values set when the document is a new record."
619
664
  resolution: "Don't define '%{name}' as readonly, or do not attempt
620
665
  to update its value after the document is persisted."
666
+ readonly_association:
667
+ message: "Attempted to modify the read-only association '%{name}' on '%{klass}'."
668
+ summary: "The :%{name} association is defined with :through and is read-only."
669
+ resolution: "To modify the association, update :%{through} directly."
621
670
  readonly_document:
622
671
  message: "Attempted to persist a readonly document of class '%{klass}'."
623
672
  summary: "Documents that are marked readonly cannot be persisted."
@@ -301,7 +301,10 @@ module Mongoid
301
301
  association.inverse_class.tap do |klass|
302
302
  klass.re_define_method(name) do |reload = false|
303
303
  value = get_relation(name, association, nil, reload)
304
- value = send("build_#{name}") if value.nil? && association.autobuilding? && !without_autobuild?
304
+ next value unless value.nil? && !without_autobuild?
305
+ next association.fallback if association.fallback?
306
+ next send("build_#{name}") if association.autobuilding?
307
+
305
308
  value
306
309
  end
307
310
  end
@@ -341,6 +344,11 @@ module Mongoid
341
344
  association.inverse_class.tap do |klass|
342
345
  klass.re_define_method("#{name}=") do |object|
343
346
  without_autobuild do
347
+ if association.fallback?
348
+ klass = association.polymorphic? ? Mongoid::Document : association.relation_class
349
+ object = nil unless object.is_a?(klass)
350
+ end
351
+
344
352
  if value = get_relation(name, association, object)
345
353
  value = __build__(name, value, association) unless value.respond_to?(:substitute)
346
354
 
@@ -10,11 +10,15 @@ module Mongoid
10
10
  included do
11
11
  class_attribute :dependents
12
12
 
13
+ # Note the leading underscore: without it, ActiveSupport's
14
+ # class_attribute would generate a helper method for this attribute
15
+ # that collides with one it generates for :dependents.
16
+ #
13
17
  # @api private
14
- class_attribute :dependents_owner
18
+ class_attribute :_dependents_owner
15
19
 
16
20
  self.dependents = []
17
- self.dependents_owner = self
21
+ self._dependents_owner = self
18
22
  end
19
23
 
20
24
  class_methods do
@@ -55,9 +59,9 @@ module Mongoid
55
59
  def self.define_dependency!(association)
56
60
  validate!(association)
57
61
  association.inverse_class.tap do |klass|
58
- if klass.dependents_owner != klass
62
+ if klass._dependents_owner != klass
59
63
  klass.dependents = []
60
- klass.dependents_owner = klass
64
+ klass._dependents_owner = klass
61
65
  end
62
66
 
63
67
  klass.dependents.push(association) if association.dependent && !klass.dependents.include?(association)
@@ -96,17 +100,17 @@ module Mongoid
96
100
  private
97
101
 
98
102
  def _dependent_delete_all!(association)
99
- return unless relation = send(association.name)
103
+ return unless relation = without_autobuild { send(association.name) }
100
104
 
101
105
  if relation.respond_to?(:dependents) && relation.dependents.blank?
102
106
  relation.clear
103
107
  else
104
- ::Array.wrap(send(association.name)).each { |rel| rel.delete }
108
+ ::Array.wrap(relation).each { |rel| rel.delete }
105
109
  end
106
110
  end
107
111
 
108
112
  def _dependent_destroy!(association)
109
- return unless relation = send(association.name)
113
+ return unless relation = without_autobuild { send(association.name) }
110
114
 
111
115
  if relation.is_a?(Enumerable)
112
116
  relation.entries
@@ -117,19 +121,19 @@ module Mongoid
117
121
  end
118
122
 
119
123
  def _dependent_nullify!(association)
120
- return unless relation = send(association.name)
124
+ return unless relation = without_autobuild { send(association.name) }
121
125
 
122
126
  relation.nullify
123
127
  end
124
128
 
125
129
  def _dependent_restrict_with_exception!(association)
126
- if (relation = send(association.name)) && !relation.blank?
130
+ if (relation = without_autobuild { send(association.name) }) && !relation.blank?
127
131
  raise Errors::DeleteRestriction.new(relation, association.name)
128
132
  end
129
133
  end
130
134
 
131
135
  def _dependent_restrict_with_error!(association)
132
- return unless (relation = send(association.name)) && !relation.blank?
136
+ return unless (relation = without_autobuild { send(association.name) }) && !relation.blank?
133
137
 
134
138
  errors.add(association.name, :destroy_restrict_with_error_dependencies_exist)
135
139
  throw(:abort, false)
@@ -4,6 +4,15 @@ module Mongoid
4
4
  module Association
5
5
  # Base class for eager load preload functions.
6
6
  class Eager
7
+ # Build a preloader for the given arguments and run it.
8
+ #
9
+ # @param (see #initialize)
10
+ #
11
+ # @return [ Array ] The list of documents given.
12
+ def self.run(associations, docs, use_lookup = false, pipeline = [])
13
+ new(associations, docs, use_lookup, pipeline).run
14
+ end
15
+
7
16
  # Instantiate the eager load class.
8
17
  #
9
18
  # @example Create the new belongs to eager load preloader.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mongoid/association/eager_load/inclusion'
4
+
5
+ module Mongoid
6
+ module Association
7
+ module EagerLoad
8
+ # Loads an inclusion that more than one subclass defines under the same name
9
+ # but pointing at different targets. A single $lookup can't serve them: they
10
+ # would all write to the same field and overwrite one another. So each
11
+ # subclass's inclusion is contributed into its own temporary field, carrying
12
+ # its own nested children, and a $set then routes every document to the field
13
+ # for its own type, by the discriminator.
14
+ #
15
+ # For Machine.eager_load(:widgets), where Lathe#widgets => Cog and
16
+ # Press#widgets => Belt, it emits:
17
+ #
18
+ # { '$lookup' => { 'from' => 'cogs', ..., 'as' => '__eager_load_widgets_Lathe' } },
19
+ # { '$lookup' => { 'from' => 'belts', ..., 'as' => '__eager_load_widgets_Press' } },
20
+ # { '$set' => {
21
+ # 'widgets' => { '$switch' => { 'branches' => [ # route each document to its
22
+ # { 'case' => { '$eq' => [ '$_type', 'Lathe' ] }, 'then' => '$__eager_load_widgets_Lathe' }, # own type's matches
23
+ # { 'case' => { '$eq' => [ '$_type', 'Press' ] }, 'then' => '$__eager_load_widgets_Press' }
24
+ # ], 'default' => [] } }
25
+ # } },
26
+ # { '$unset' => [ '__eager_load_widgets_Lathe', '__eager_load_widgets_Press' ] }
27
+ #
28
+ # @api private
29
+ class DiscriminatedInclusion < Inclusion
30
+ def initialize(nodes)
31
+ super()
32
+ @nodes = nodes
33
+ end
34
+
35
+ # Append each subclass's lookup (into its own temporary field), the routing
36
+ # $set, and the cleanup $unset.
37
+ #
38
+ # @param [ Array<Hash> ] destination The pipeline the stages are appended to.
39
+ def contribute(destination, _chain)
40
+ fields = @nodes.map { |node| [ node, contribute_into_temporary(destination, node) ] }
41
+ destination << route_by_type(fields)
42
+ destination << { '$unset' => fields.map { |_node, field| field } }
43
+ end
44
+
45
+ private
46
+
47
+ # Let the node build its own $lookup (with its nested children) and redirect
48
+ # it to write into a temporary field instead of the shared association name.
49
+ def contribute_into_temporary(destination, node)
50
+ field = temporary_field(node)
51
+ captured = []
52
+ node.contribute(captured, [])
53
+ captured.first['$lookup']['as'] = field
54
+ destination.concat(captured)
55
+ field
56
+ end
57
+
58
+ def temporary_field(node)
59
+ "__eager_load_#{node.association.name}_#{owner(node).discriminator_value}"
60
+ end
61
+
62
+ # The $set that fills the association on each document from the temporary
63
+ # field matching its own type.
64
+ def route_by_type(fields)
65
+ branches = fields.map do |node, field|
66
+ {
67
+ 'case' => { '$eq' => [ "$#{discriminator_key}", owner(node).discriminator_value ] },
68
+ 'then' => "$#{field}"
69
+ }
70
+ end
71
+ { '$set' => { name => { '$switch' => { 'branches' => branches, 'default' => [] } } } }
72
+ end
73
+
74
+ def owner(node)
75
+ node.association.inverse_class
76
+ end
77
+
78
+ def name
79
+ @nodes.first.association.name.to_s
80
+ end
81
+
82
+ def discriminator_key
83
+ owner(@nodes.first).discriminator_key
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Association
5
+ # Objects that carry out the aggregation-based eager load triggered by
6
+ # Criteria#eager_load.
7
+ module EagerLoad
8
+ # Distributes the results of a $lookup onto the embedded documents they
9
+ # belong to.
10
+ #
11
+ # A $lookup overwrites the single field it writes to, and it can't distribute
12
+ # its matches across the elements of an embedded array. So when the reference
13
+ # being eager-loaded lives on an embedded document, the matches are first
14
+ # collected in a temporary top-level field and then distributed down the
15
+ # embedded path onto each embedded document, merging into it (so the rest of
16
+ # the document is kept) and correlating by key. The temporary field is then
17
+ # dropped.
18
+ #
19
+ # For Computer.eager_load(port: :device) (Port belongs_to :device) it emits:
20
+ #
21
+ # { '$lookup' => { # devices can't be written into
22
+ # 'from' => 'devices', # the embedded port, so they are
23
+ # 'localField' => 'port.device_id', # collected in a temp top-level
24
+ # 'foreignField' => '_id', # field instead
25
+ # 'as' => '__eager_load_port_device'
26
+ # } },
27
+ # { '$set' => {
28
+ # 'port' => { '$mergeObjects' => [ # merge a 'device' key onto the port
29
+ # '$port',
30
+ # { 'device' => { '$filter' => { ... } } } # this port's matches
31
+ # ] }
32
+ # } },
33
+ # { '$unset' => '__eager_load_port_device' } # drop the temp field
34
+ #
35
+ # @api private
36
+ class EmbeddedDistributor
37
+ # @param [ Mongoid::Association::Relatable ] association The referenced
38
+ # inclusion being eager-loaded from within an embedded document.
39
+ # @param [ Array<Mongoid::Association::Relatable> ] chain The embedded
40
+ # ancestors, from the root document inward, down to the association's owner.
41
+ # @param [ Hash ] lookup_stage The $lookup stage built for the association.
42
+ #
43
+ # @return [ EmbeddedDistributor ] The distributor.
44
+ class << self
45
+ def for(association:, chain:, lookup_stage:)
46
+ lookup = lookup_stage['$lookup']
47
+ new(association, chain, lookup_stage, lookup['localField'], lookup['foreignField'])
48
+ end
49
+
50
+ private :new
51
+ end
52
+
53
+ def initialize(association, chain, lookup_stage, local_field, foreign_field)
54
+ @association = association
55
+ @chain = chain
56
+ @lookup_stage = lookup_stage
57
+ @local_field = local_field
58
+ @foreign_field = foreign_field
59
+ end
60
+
61
+ # The stages that run the $lookup into a temporary field and then
62
+ # distribute its matches onto the embedded documents along the path.
63
+ #
64
+ # @return [ Array<Hash> ] The stages to append to the pipeline.
65
+ def stages
66
+ redirect_lookup_to_temporary_field
67
+ [
68
+ @lookup_stage,
69
+ { '$set' => {
70
+ root => distributed_value(@chain, "$#{root}")
71
+ } },
72
+ { '$unset' => temporary_field }
73
+ ]
74
+ end
75
+
76
+ private
77
+
78
+ # The $lookup runs at the top level, so it reads the local field by its
79
+ # full embedded path and writes the matches into the temporary field.
80
+ def redirect_lookup_to_temporary_field
81
+ lookup = @lookup_stage['$lookup']
82
+ lookup['localField'] = "#{path}.#{@local_field}"
83
+ lookup['as'] = temporary_field
84
+ end
85
+
86
+ def path
87
+ @chain.map(&:store_as).join('.')
88
+ end
89
+
90
+ def root
91
+ @chain.first.store_as
92
+ end
93
+
94
+ def temporary_field
95
+ "__eager_load_#{path.tr('.', '_')}_#{@association.name}"
96
+ end
97
+
98
+ # An embedded collection (embeds_many) is rebuilt with $map so each element
99
+ # keeps its own matches instead of collapsing onto the first; a single
100
+ # embedded document (embeds_one) receives its matches in place.
101
+ def distributed_value(chain, node)
102
+ head, *rest = chain
103
+ many = head.many?
104
+ element = many ? "$$#{head.store_as}" : node
105
+ child =
106
+ if rest.empty?
107
+ { @association.name.to_s => correlated_matches(element) }
108
+ else
109
+ segment = rest.first.store_as
110
+ { segment => distributed_value(rest, "#{element}.#{segment}") }
111
+ end
112
+ merged = { '$mergeObjects' => [ element, child ] }
113
+ return merge_into_present(node, merged) unless many
114
+
115
+ { '$map' => {
116
+ 'input' => node,
117
+ 'as' => head.store_as,
118
+ 'in' => merged
119
+ } }
120
+ end
121
+
122
+ # Merge the matches into a single embedded document only when it exists, so
123
+ # an absent embeds_one stays absent instead of being synthesized from its
124
+ # matches alone.
125
+ def merge_into_present(node, merged)
126
+ { '$cond' => {
127
+ 'if' => { '$ifNull' => [ node, false ] },
128
+ 'then' => merged,
129
+ 'else' => node
130
+ } }
131
+ end
132
+
133
+ # The matches that belong to a single embedded element.
134
+ def correlated_matches(element)
135
+ { '$filter' => {
136
+ 'input' => "$#{temporary_field}",
137
+ 'as' => 'match',
138
+ 'cond' => { match_operator => [ "$$match.#{@foreign_field}", "#{element}.#{@local_field}" ] }
139
+ } }
140
+ end
141
+
142
+ # A has_and_belongs_to_many holds an array of foreign keys, so a match
143
+ # belongs when its key is among them ($in); every other association points
144
+ # at a single key ($eq).
145
+ def match_operator
146
+ @association.many_to_many? ? '$in' : '$eq'
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Association
5
+ module EagerLoad
6
+ # Something an eager load contributes to the pipeline, in the role it plays
7
+ # while the pipeline is built. A root is asked to contribute and the whole
8
+ # tree follows by recursion. AssociationInclusion stands for a single
9
+ # association; DiscriminatedInclusion stands for a name several subclasses
10
+ # share.
11
+ #
12
+ # @api private
13
+ class Inclusion
14
+ # Add this inclusion's stages to the destination.
15
+ #
16
+ # @param [ Array<Hash> ] destination The pipeline (or sub-pipeline) the
17
+ # stages are appended to.
18
+ # @param [ Array<Mongoid::Association::Relatable> ] chain The embedded path
19
+ # accumulated from the ancestors above this inclusion (empty at the top).
20
+ def contribute(destination, chain)
21
+ raise NotImplementedError
22
+ end
23
+ end
24
+
25
+ # An inclusion that stands for a single association. The LookupPipeline holds
26
+ # the stage-building helpers the kinds lean on, and a node carries its own
27
+ # children, so the pipeline is built by recursion from the roots downward.
28
+ #
29
+ # @api private
30
+ class AssociationInclusion < Inclusion
31
+ class << self
32
+ # Builds the right kind of inclusion for the association. Each subclass
33
+ # decides whether it handles it (.for?); exactly one does.
34
+ #
35
+ # @param [ Mongoid::Association::Relatable ] association The inclusion.
36
+ # @param [ LookupPipeline ] pipeline The pipeline being built.
37
+ # @param [ Array<Inclusion> ] children The inclusions nested under it.
38
+ #
39
+ # @return [ AssociationInclusion ] The matching kind of inclusion.
40
+ def for(association, pipeline, children)
41
+ subclasses.find { |kind| kind.for?(association) }.new(association, pipeline, children)
42
+ end
43
+
44
+ # Whether this kind handles the given association.
45
+ #
46
+ # @param [ Mongoid::Association::Relatable ] association The inclusion.
47
+ #
48
+ # @return [ true | false ] Whether it handles it.
49
+ def for?(association)
50
+ raise NotImplementedError
51
+ end
52
+ end
53
+
54
+ # @return [ Mongoid::Association::Relatable ] The association this stands for.
55
+ attr_reader :association
56
+
57
+ def initialize(association, pipeline, children)
58
+ super()
59
+ @association = association
60
+ @pipeline = pipeline
61
+ @children = children
62
+ end
63
+ end
64
+
65
+ # A referenced inclusion: contributes a $lookup whose sub-pipeline holds its
66
+ # own children. When it lives inside an embedded document (a non-empty
67
+ # chain), the $lookup is distributed onto that embedded path instead of
68
+ # standing at the top level.
69
+ #
70
+ # For a has_many :albums it contributes:
71
+ #
72
+ # { '$lookup' => {
73
+ # 'from' => 'albums',
74
+ # 'localField' => '_id', # the band's _id...
75
+ # 'foreignField' => 'band_id', # ...matched against each album's band_id
76
+ # 'as' => 'albums', # matches are written to this field
77
+ # 'pipeline' => [
78
+ # { '$sort' => {
79
+ # '_id' => 1
80
+ # } },
81
+ # <children>
82
+ # ]
83
+ # } }
84
+ #
85
+ # @api private
86
+ class JoinedInclusion < AssociationInclusion
87
+ class << self
88
+ # The default kind: a referenced, non-polymorphic association, i.e. the
89
+ # one no sibling kind claims.
90
+ #
91
+ # @param [ Mongoid::Association::Relatable ] association The inclusion.
92
+ #
93
+ # @return [ true | false ] Whether it handles it.
94
+ def for?(association)
95
+ (superclass.subclasses - [ self ]).none? { |kind| kind.for?(association) }
96
+ end
97
+ end
98
+
99
+ # Append the $lookup, with the children in its sub-pipeline, to the
100
+ # destination; or distribute it onto the embedded path when nested in one.
101
+ #
102
+ # @param [ Array<Hash> ] destination The pipeline (or sub-pipeline) the
103
+ # stages are appended to.
104
+ # @param [ Array<Mongoid::Association::Relatable> ] chain The embedded path
105
+ # accumulated from the ancestors above this inclusion (empty at the top).
106
+ def contribute(destination, chain)
107
+ stage = @pipeline.lookup_stage_for(@association)
108
+ @children.each { |child| child.contribute(stage['$lookup']['pipeline'], []) }
109
+
110
+ if chain.empty?
111
+ destination << stage
112
+ else
113
+ destination.concat(@pipeline.distribute(@association, chain, stage))
114
+ end
115
+ end
116
+ end
117
+
118
+ # An embedded inclusion: it rides inside its own document, so it adds no
119
+ # stage of its own. Its children contribute to the same destination, with
120
+ # this document appended to their embedded path.
121
+ #
122
+ # For Computer.eager_load(port: :device) the :port inclusion emits nothing;
123
+ # it hands the path [ :port ] to :device, which EmbeddedDistributor then
124
+ # turns into stages.
125
+ #
126
+ # @api private
127
+ class EmbeddedInclusion < AssociationInclusion
128
+ class << self
129
+ # @param [ Mongoid::Association::Relatable ] association The inclusion.
130
+ #
131
+ # @return [ true | false ] Whether the association is embedded.
132
+ def for?(association)
133
+ association.embedded?
134
+ end
135
+ end
136
+
137
+ # Add no stage of its own; hand this document down the embedded path so the
138
+ # children distribute onto it.
139
+ #
140
+ # @param [ Array<Hash> ] destination The pipeline (or sub-pipeline) the
141
+ # stages are appended to.
142
+ # @param [ Array<Mongoid::Association::Relatable> ] chain The embedded path
143
+ # accumulated from the ancestors above this inclusion (empty at the top).
144
+ def contribute(destination, chain)
145
+ @children.each { |child| child.contribute(destination, chain + [ @association ]) }
146
+ end
147
+ end
148
+
149
+ # A polymorphic inclusion: its target collection varies per document, so it
150
+ # can't be a $lookup. It adds nothing here; PolymorphicPreloader resolves it
151
+ # after the roots are materialized.
152
+ #
153
+ # @api private
154
+ class DeferredInclusion < AssociationInclusion
155
+ class << self
156
+ # @param [ Mongoid::Association::Relatable ] association The inclusion.
157
+ #
158
+ # @return [ true | false ] Whether the association is polymorphic.
159
+ def for?(association)
160
+ association.polymorphic?
161
+ end
162
+ end
163
+
164
+ # Add nothing; PolymorphicPreloader resolves the association after the
165
+ # roots are materialized.
166
+ #
167
+ # @param [ Array<Hash> ] destination The pipeline (unused).
168
+ # @param [ Array<Mongoid::Association::Relatable> ] chain The embedded path (unused).
169
+ def contribute(destination, chain); end
170
+ end
171
+ end
172
+ end
173
+ end