mongoid 9.1.0 → 9.1.2

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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +69 -0
  3. data/lib/mongoid/association/accessors.rb +27 -5
  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 +65 -101
  14. data/lib/mongoid/association/embedded/batchable.rb +17 -1
  15. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
  16. data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
  17. data/lib/mongoid/association/macros.rb +13 -1
  18. data/lib/mongoid/association/nested/many.rb +34 -5
  19. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  20. data/lib/mongoid/association/nested/one.rb +6 -1
  21. data/lib/mongoid/association/options.rb +17 -0
  22. data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
  23. data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
  24. data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
  25. data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
  26. data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
  27. data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
  28. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  29. data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
  30. data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
  31. data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
  32. data/lib/mongoid/association/referenced/has_one.rb +1 -0
  33. data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
  34. data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
  35. data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
  36. data/lib/mongoid/association/referenced.rb +2 -0
  37. data/lib/mongoid/association/relatable.rb +58 -0
  38. data/lib/mongoid/association.rb +15 -0
  39. data/lib/mongoid/atomic/modifiers.rb +100 -16
  40. data/lib/mongoid/clients/factory.rb +9 -13
  41. data/lib/mongoid/collection_configurable.rb +8 -0
  42. data/lib/mongoid/config/defaults.rb +0 -1
  43. data/lib/mongoid/config/encryption.rb +170 -22
  44. data/lib/mongoid/config.rb +56 -5
  45. data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
  46. data/lib/mongoid/contextual/memory.rb +18 -7
  47. data/lib/mongoid/criteria/includable.rb +35 -11
  48. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  49. data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
  50. data/lib/mongoid/deprecable.rb +45 -2
  51. data/lib/mongoid/document.rb +3 -0
  52. data/lib/mongoid/encryptable.rb +73 -0
  53. data/lib/mongoid/errors/config_redactor.rb +43 -0
  54. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  55. data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
  56. data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
  57. data/lib/mongoid/errors/no_client_database.rb +1 -1
  58. data/lib/mongoid/errors/no_client_hosts.rb +1 -1
  59. data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
  60. data/lib/mongoid/errors/readonly_association.rb +29 -0
  61. data/lib/mongoid/errors.rb +5 -0
  62. data/lib/mongoid/extensions/vector.rb +64 -0
  63. data/lib/mongoid/extensions.rb +1 -0
  64. data/lib/mongoid/field_readable.rb +69 -0
  65. data/lib/mongoid/fields.rb +7 -0
  66. data/lib/mongoid/indexable.rb +38 -26
  67. data/lib/mongoid/matchable.rb +6 -1
  68. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
  69. data/lib/mongoid/matcher/regex.rb +11 -12
  70. data/lib/mongoid/matcher/regexp_budget.rb +372 -0
  71. data/lib/mongoid/matcher.rb +1 -0
  72. data/lib/mongoid/persistable/creatable.rb +120 -14
  73. data/lib/mongoid/persistable/updatable.rb +9 -11
  74. data/lib/mongoid/persistable.rb +15 -15
  75. data/lib/mongoid/persistence_context.rb +77 -0
  76. data/lib/mongoid/search_indexable.rb +69 -24
  77. data/lib/mongoid/serializable.rb +12 -4
  78. data/lib/mongoid/tasks/database.rb +33 -16
  79. data/lib/mongoid/threaded.rb +36 -0
  80. data/lib/mongoid/timestamps/timeless.rb +84 -10
  81. data/lib/mongoid/touchable.rb +28 -1
  82. data/lib/mongoid/traversable.rb +2 -2
  83. data/lib/mongoid/validatable/associated.rb +18 -2
  84. data/lib/mongoid/version.rb +1 -1
  85. data/lib/mongoid/warnings.rb +4 -0
  86. data/spec/integration/app_spec.rb +7 -1
  87. data/spec/integration/associations/belongs_to_spec.rb +13 -0
  88. data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
  89. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +31 -2
  90. data/spec/integration/atomic/modifiers_spec.rb +283 -0
  91. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  92. data/spec/integration/encryption_spec.rb +300 -0
  93. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  94. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  95. data/spec/integration/query_operator_guard_spec.rb +89 -0
  96. data/spec/mongoid/association/accessors_spec.rb +35 -0
  97. data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
  98. data/spec/mongoid/association/eager_spec.rb +2 -2
  99. data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
  100. data/spec/mongoid/association/fallback_spec.rb +425 -0
  101. data/spec/mongoid/association/macros_spec.rb +26 -0
  102. data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
  103. data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
  104. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  105. data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
  106. data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
  107. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  108. data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
  109. data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
  110. data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
  111. data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
  112. data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
  113. data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
  114. data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
  115. data/spec/mongoid/attributes/nested_spec.rb +278 -8
  116. data/spec/mongoid/clients/factory_spec.rb +36 -0
  117. data/spec/mongoid/config/defaults_spec.rb +33 -0
  118. data/spec/mongoid/config/encryption_spec.rb +451 -0
  119. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  120. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  121. data/spec/mongoid/criteria/includable_spec.rb +1221 -0
  122. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
  123. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
  124. data/spec/mongoid/criteria_spec.rb +3 -0
  125. data/spec/mongoid/encryptable_spec.rb +115 -0
  126. data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
  127. data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
  128. data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
  129. data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
  130. data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
  131. data/spec/mongoid/extensions/vector_spec.rb +175 -0
  132. data/spec/mongoid/fields_spec.rb +8 -0
  133. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  134. data/spec/mongoid/search_indexable_spec.rb +321 -9
  135. data/spec/mongoid/tasks/database_spec.rb +31 -0
  136. data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
  137. data/spec/mongoid/touchable_spec.rb +242 -0
  138. data/spec/mongoid/touchable_spec_models.rb +47 -0
  139. data/spec/mongoid/validatable/associated_spec.rb +19 -0
  140. data/spec/spec_helper.rb +5 -1
  141. data/spec/support/crypt/models.rb +253 -0
  142. data/spec/support/models/modifier_models.rb +65 -0
  143. metadata +55 -2
@@ -39,13 +39,79 @@ module Mongoid
39
39
  { atomic_insert_modifier => { atomic_position => as_attributes } }
40
40
  end
41
41
 
42
+ # Determine whether merging the touch updates into the insert
43
+ # operations would produce an update whose operators target both a
44
+ # path and one of its ancestors or descendants. MongoDB rejects such
45
+ # an update with error 40. This can happen for embeds_many inserts,
46
+ # where the operations target the array (via $push) but the touches
47
+ # include a path inside that array (e.g. items.0.updated_at) when a
48
+ # sibling has a pending touch.
49
+ #
50
+ # @api private
51
+ #
52
+ # @param [ Hash ] operations The atomic insert operations.
53
+ # @param [ Hash ] touches The touch updates to merge.
54
+ #
55
+ # @return [ true | false ] Whether any touch path conflicts with a
56
+ # path the insert operations target.
57
+ def conflicting_touch_paths?(operations, touches)
58
+ targeted = operations.each_value.flat_map do |doc|
59
+ doc.respond_to?(:keys) ? doc.keys.map(&:to_s) : []
60
+ end
61
+
62
+ touches.each_key.any? do |touch_path|
63
+ path = touch_path.to_s
64
+ targeted.any? do |target|
65
+ path == target || path.start_with?("#{target}.") ||
66
+ target.start_with?("#{path}.")
67
+ end
68
+ end
69
+ end
70
+
71
+ # Rewrite the indices in the touch updates to the positional operator,
72
+ # as the final update will do to the merged operations. The touches of
73
+ # the parent chain target the inserted element, so rewriting them is
74
+ # safe, and merging the rewritten keys into the operations lets the
75
+ # final update reuse them as-is. A pending touch on a different element
76
+ # of the same array, however, would be rewritten to the same positional
77
+ # path and silently touch the wrong document; in that case this method
78
+ # returns nil and the caller persists the touch updates in their own
79
+ # round-trip after the insert instead of merging them.
80
+ #
81
+ # @api private
82
+ #
83
+ # @param [ Hash ] selector The update selector.
84
+ # @param [ Document ] parent The parent document being touched.
85
+ # @param [ Symbol, String, nil ] field The parent's custom touch field.
86
+ # @param [ Hash ] touches The touch updates to rewrite.
87
+ #
88
+ # @return [ Hash | nil ] The rewritten touch updates, or nil if any
89
+ # touch would be misdirected by the rewriting.
90
+ def rewritten_touch_updates(selector, parent, field, touches)
91
+ rewritten = positionally(selector, { '$set' => touches })['$set']
92
+
93
+ chain_paths = parent._touchable_chain_paths(field)
94
+ touches.each_key do |key|
95
+ path = key.to_s
96
+ next if rewritten.key?(path)
97
+
98
+ return nil unless chain_paths.include?(path)
99
+ end
100
+ rewritten
101
+ end
102
+
42
103
  # Insert the embedded document.
43
104
  #
44
105
  # When the parent association is touchable (which is the default for
45
- # +embedded_in+), the touch timestamp updates are merged into the
46
- # same +update_one+ call that performs the insert. This avoids a
47
- # second round-trip that the +after_save+ touch callback would
48
- # otherwise issue.
106
+ # +embedded_in+), the touch updates are merged into the same
107
+ # +update_one+ call that performs the insert. This avoids a second
108
+ # round-trip that the +after_save+ touch callback would otherwise
109
+ # issue. The merge is skipped when a touch path would conflict with a
110
+ # path the insert targets (see +conflicting_touch_paths?+) or when
111
+ # rewriting the touch indices to the positional operator would
112
+ # misdirect a touch (see +rewritten_touch_updates+); such touches are
113
+ # persisted in their own round-trip right after the insert, so their
114
+ # persistence does not depend on the callback chain running.
49
115
  #
50
116
  # @api private
51
117
  #
@@ -62,19 +128,59 @@ module Mongoid
62
128
  selector = _parent.atomic_selector
63
129
  operations = atomic_inserts
64
130
 
65
- if _touchable_parent?
66
- touches = _parent._gather_touch_updates(Time.current)
67
- if touches.present?
68
- operations['$set'] = touches
69
- Threaded.begin_touch_merged(self)
70
- end
131
+ deferred_touches = merge_touch_updates(selector, operations)
132
+
133
+ begin
134
+ _root.collection.find(selector).update_one(
135
+ positionally(selector, operations),
136
+ session: _session
137
+ )
138
+ _root.persist_atomic_operations('$set' => deferred_touches) if deferred_touches
139
+ rescue StandardError
140
+ # If the insert failed, the after_save callback will not run to
141
+ # consume and clear the merged-touch flag, so clear it here.
142
+ Threaded.exit_touch_merged(self)
143
+ raise
71
144
  end
145
+ end
146
+ end
147
+
148
+ # Merge the parent chain's pending touch updates into the insert
149
+ # operations when doing so would not produce a conflicting update
150
+ # (see +conflicting_touch_paths?+ and +rewritten_touch_updates+).
151
+ # Either way, marks the touch as merged so the after_save callback
152
+ # does not persist the updates a second time. Returns the touch
153
+ # updates that could not be merged; they are persisted in their own
154
+ # round-trip right after the insert, rather than relying on the
155
+ # after_save callback, so that an aborted callback chain cannot
156
+ # silently drop them.
157
+ #
158
+ # @api private
159
+ #
160
+ # @param [ Hash ] selector The update selector.
161
+ # @param [ Hash ] operations The atomic insert operations, modified
162
+ # in place when the touches are merged.
163
+ #
164
+ # @return [ Hash | nil ] The deferred touch updates, if any.
165
+ def merge_touch_updates(selector, operations)
166
+ return nil unless _touchable_parent?
72
167
 
73
- _root.collection.find(selector).update_one(
74
- positionally(selector, operations),
75
- session: _session
76
- )
168
+ field = _association&.inverse_association&.touch_field
169
+ touches = _parent._gather_touch_updates(Time.current, field)
170
+ return nil if touches.blank?
171
+
172
+ if conflicting_touch_paths?(operations, touches)
173
+ deferred_touches = touches
174
+ else
175
+ rewritten = rewritten_touch_updates(selector, _parent, field, touches)
176
+ if rewritten
177
+ operations['$set'] = (operations['$set'] || {}).merge(rewritten)
178
+ else
179
+ deferred_touches = touches
180
+ end
77
181
  end
182
+ Threaded.begin_touch_merged(self)
183
+ deferred_touches
78
184
  end
79
185
 
80
186
  # Insert the root document.
@@ -103,7 +103,7 @@ module Mongoid
103
103
 
104
104
  process_flagged_destroys
105
105
  update_children = cascadable_children(:update)
106
- process_touch_option(options, update_children) do
106
+ process_touch_option(options) do
107
107
  run_all_callbacks_for_update(update_children) do
108
108
  result = yield(self)
109
109
  self.previously_new_record = false
@@ -160,24 +160,22 @@ module Mongoid
160
160
  end
161
161
  end
162
162
 
163
- # If there is a touch option and it is false, this method will call the
164
- # timeless method so that the updated_at attribute is not updated. It
165
- # will call the timeless method on all of the cascadable children as
166
- # well. Note that timeless is cleared in the before_update callback.
163
+ # If there is a touch option and it is false, this method suppresses
164
+ # timestamping for the duration of the update using a block-based
165
+ # timeless scope, which covers this document and every cascaded child
166
+ # (at any nesting depth), and also suppresses touch callbacks.
167
167
  #
168
168
  # @param [ Hash ] options The options.
169
- # @param [ Array<Document> ] children The children that the :update
170
- # callbacks will be executed on.
171
169
  #
172
170
  # @option options [ true | false ] :touch Whether or not the updated_at
173
171
  # attribute will be updated with the current time.
174
- def process_touch_option(options, children, &block)
172
+ def process_touch_option(options, &block)
175
173
  if options.fetch(:touch, true)
176
174
  yield
177
175
  else
178
- timeless
179
- children.each(&:timeless)
180
- suppress_touch_callbacks(&block)
176
+ Mongoid::Timestamps::Timeless.with_timeless do
177
+ suppress_touch_callbacks(&block)
178
+ end
181
179
  end
182
180
  end
183
181
 
@@ -142,6 +142,21 @@ module Mongoid
142
142
  raise Errors::Callback.new(self.class, method)
143
143
  end
144
144
 
145
+ # Persist the atomic operations.
146
+ #
147
+ # @api private
148
+ #
149
+ # @example Persist the atomic operations.
150
+ # persist_atomic_operations(ops)
151
+ #
152
+ # @param [ Hash ] operations The atomic operations.
153
+ def persist_atomic_operations(operations)
154
+ return unless persisted? && operations && !operations.empty?
155
+
156
+ selector = atomic_selector
157
+ _root.collection.find(selector).update_one(positionally(selector, operations), session: _session)
158
+ end
159
+
145
160
  private
146
161
 
147
162
  # Are we executing an atomically block on the current document?
@@ -300,20 +315,5 @@ module Mongoid
300
315
  persist_atomic_operations(operation)
301
316
  end
302
317
  end
303
-
304
- # Persist the atomic operations.
305
- #
306
- # @api private
307
- #
308
- # @example Persist the atomic operations.
309
- # persist_atomic_operations(ops)
310
- #
311
- # @param [ Hash ] operations The atomic operations.
312
- def persist_atomic_operations(operations)
313
- return unless persisted? && operations && !operations.empty?
314
-
315
- selector = atomic_selector
316
- _root.collection.find(selector).update_one(positionally(selector, operations), session: _session)
317
- end
318
318
  end
319
319
  end
@@ -125,6 +125,8 @@ module Mongoid
125
125
 
126
126
  client = client.with(options) unless options.empty?
127
127
 
128
+ verify_encryption_schema!(client)
129
+
128
130
  client
129
131
  end
130
132
  end
@@ -199,6 +201,81 @@ module Mongoid
199
201
  name.respond_to?(:call) ? name.call.to_sym : name.to_sym
200
202
  end
201
203
 
204
+ # Refuse to use a client that would store a model's encrypted fields in
205
+ # plaintext.
206
+ #
207
+ # The automatic encryption schema is keyed by namespace and is built once,
208
+ # when the client is created. The driver looks the target namespace up in
209
+ # that schema, and when it is absent it asks the server for a schema
210
+ # instead; a collection without a validator then yields no encryption at
211
+ # all, and no error. Checking the namespace here is what turns that silent
212
+ # downgrade into a failure. When the namespace is present, its entry is
213
+ # checked against the fields the model declares as encrypted too: a
214
+ # hand-written schema can cover the namespace and still leave out a field.
215
+ #
216
+ # @param [ Mongo::Client ] client The client this context resolved to.
217
+ #
218
+ # @raise [ Errors::NoEncryptionSchema ] if the model needs an encryption
219
+ # schema and the client's schema does not cover the target namespace, or
220
+ # the namespace entry does not cover every encrypted field of the model.
221
+ def verify_encryption_schema!(client)
222
+ klass = @object.is_a?(Class) ? @object : @object.class
223
+ # A model whose encrypted fields all live on embedded models declares no
224
+ # encryption of its own, and still needs a schema for its collection.
225
+ return unless klass.respond_to?(:requires_encryption_schema?) && klass.requires_encryption_schema?
226
+ # An embedded document is persisted as part of its parent, so the
227
+ # parent's context is what governs the namespace.
228
+ return if klass.embedded?
229
+ # Collection and index management sends no document data, so it does not
230
+ # need an encryption-capable client.
231
+ return if Threaded.managing_collection?
232
+
233
+ schema_map = client.options.dig(:auto_encryption_options, :schema_map)
234
+ namespace = "#{client.database.name}.#{collection_name}"
235
+ entry = schema_map && schema_map[namespace]
236
+ raise Errors::NoEncryptionSchema.new(klass, namespace, client_name) unless entry
237
+
238
+ # The schema map may have been written by hand, with symbol keys rather
239
+ # than strings; both are tolerated at every level.
240
+ Mongoid.config.encrypted_paths_for(klass).each do |path|
241
+ next if encryption_schema_has_path?(entry, path)
242
+
243
+ raise Errors::NoEncryptionSchema.new(
244
+ klass, namespace, client_name, path.join('.')
245
+ )
246
+ end
247
+ end
248
+
249
+ # Whether a schema entry encrypts the property at the given path.
250
+ #
251
+ # @param [ Hash ] entry The namespace schema entry.
252
+ # @param [ Array<String> ] path The property path, outermost first.
253
+ #
254
+ # @return [ true | false ] Whether the leaf is marked as encrypted.
255
+ def encryption_schema_has_path?(entry, path)
256
+ node = entry
257
+ path.each do |name|
258
+ properties = schema_value(node, 'properties')
259
+ return false unless properties.is_a?(Hash)
260
+
261
+ node = schema_value(properties, name)
262
+ return false unless node.is_a?(Hash)
263
+ end
264
+ !schema_value(node, 'encrypt').nil?
265
+ end
266
+
267
+ # Read a schema key that may be written as a string or a symbol.
268
+ #
269
+ # @param [ Hash ] hash The schema object.
270
+ # @param [ String ] key The key to read.
271
+ #
272
+ # @return [ Object ] The value, or nil when the key is absent.
273
+ def schema_value(hash, key)
274
+ return hash[key] if hash.key?(key)
275
+
276
+ hash[key.to_sym]
277
+ end
278
+
202
279
  def client_options
203
280
  @client_options ||= begin
204
281
  opts = options.select do |k, _v|
@@ -69,6 +69,9 @@ module Mongoid
69
69
  # @param [ Integer ] limit The maximum number of results (default: 10).
70
70
  # @param [ Integer | nil ] num_candidates The number of candidates to
71
71
  # consider during the ANN search; defaults to limit * 10.
72
+ # @param [ true | false ] exact Use exact nearest-neighbor (ENN) search
73
+ # instead of ANN (default: false). When true, numCandidates is omitted.
74
+ # Required when using a flat vector search index.
72
75
  # @param [ Hash | nil ] filter An optional MongoDB filter to pre-filter
73
76
  # candidates before scoring.
74
77
  # @param [ Array ] pipeline Additional aggregation stages to append after
@@ -76,7 +79,7 @@ module Mongoid
76
79
  #
77
80
  # @return [ Array<Mongoid::Document> ] matching documents, each with
78
81
  # a populated +vector_search_score+ attribute.
79
- def vector_search(index: nil, path: nil, limit: 10, num_candidates: nil, filter: nil, pipeline: [])
82
+ def vector_search(index: nil, path: nil, limit: 10, num_candidates: nil, exact: false, filter: nil, pipeline: []) # rubocop:disable Metrics/ParameterLists
80
83
  _index, resolved_path = self.class.send(:resolve_vector_index, index, path)
81
84
  query_vector = public_send(resolved_path)
82
85
 
@@ -85,17 +88,19 @@ module Mongoid
85
88
  "#{resolved_path} is nil on this document; cannot perform vector search"
86
89
  end
87
90
 
88
- self_filter = { '_id' => { '$ne' => _id } }
89
- combined_filter = filter ? { '$and' => [ self_filter, filter ] } : self_filter
91
+ self_exclusion = { '$match' => { '_id' => { '$ne' => _id } } }
92
+ post_pipeline = [ self_exclusion, { '$limit' => limit }, *Array(pipeline) ]
93
+ effective_candidates = num_candidates || (limit * 10)
90
94
 
91
95
  self.class.vector_search(
92
96
  query_vector,
93
97
  index: index,
94
98
  path: path,
95
- limit: limit,
96
- num_candidates: num_candidates,
97
- filter: combined_filter,
98
- pipeline: pipeline
99
+ limit: limit + 1,
100
+ num_candidates: effective_candidates,
101
+ exact: exact,
102
+ filter: filter,
103
+ pipeline: post_pipeline
99
104
  )
100
105
  end
101
106
 
@@ -129,19 +134,20 @@ module Mongoid
129
134
  "#{resolved_path} is nil on this document; cannot perform auto-embed search"
130
135
  end
131
136
 
132
- self_filter = { '_id' => { '$ne' => _id } }
133
- combined_filter = filter ? { '$and' => [ self_filter, filter ] } : self_filter
137
+ self_exclusion = { '$match' => { '_id' => { '$ne' => _id } } }
138
+ post_pipeline = [ self_exclusion, { '$limit' => limit }, *Array(pipeline) ]
139
+ effective_candidates = num_candidates || (limit * 10)
134
140
 
135
141
  self.class.auto_embed_search(
136
142
  text,
137
143
  index: index,
138
144
  path: path,
139
- limit: limit,
140
- num_candidates: num_candidates,
141
- filter: combined_filter,
145
+ limit: limit + 1,
146
+ num_candidates: effective_candidates,
147
+ filter: filter,
142
148
  exact: exact,
143
149
  model: model,
144
- pipeline: pipeline
150
+ pipeline: post_pipeline
145
151
  )
146
152
  end
147
153
 
@@ -155,7 +161,9 @@ module Mongoid
155
161
  def create_search_indexes
156
162
  return if search_index_specs.empty?
157
163
 
158
- collection.search_indexes.create_many(search_index_specs)
164
+ Threaded.with_collection_management do
165
+ collection.search_indexes.create_many(search_index_specs)
166
+ end
159
167
  end
160
168
 
161
169
  # Waits for the named search indexes to be created.
@@ -186,7 +194,9 @@ module Mongoid
186
194
  # @option options [ Hash ] :aggregate The options hash to pass to the
187
195
  # aggregate command (optional)
188
196
  def search_indexes(options = {})
189
- collection.search_indexes(options)
197
+ Threaded.with_collection_management do
198
+ collection.search_indexes(options)
199
+ end
190
200
  end
191
201
 
192
202
  # Removes the search index specified by the given name or id. Either
@@ -195,12 +205,14 @@ module Mongoid
195
205
  # @param [ String | nil ] name the name of the index to remove
196
206
  # @param [ String | nil ] id the id of the index to remove
197
207
  def remove_search_index(name: nil, id: nil)
198
- logger.info(
199
- "MONGOID: Removing search index '#{name || id}' " \
200
- "on collection '#{collection.name}'."
201
- )
208
+ Threaded.with_collection_management do
209
+ logger.info(
210
+ "MONGOID: Removing search index '#{name || id}' " \
211
+ "on collection '#{collection.name}'."
212
+ )
202
213
 
203
- collection.search_indexes.drop_one(name: name, id: id)
214
+ collection.search_indexes.drop_one(name: name, id: id)
215
+ end
204
216
  end
205
217
 
206
218
  # Request the removal of all registered search indexes. Note
@@ -253,6 +265,15 @@ module Mongoid
253
265
  # vector_search_index :my_vector_index, { fields: [...] }
254
266
  # end
255
267
  #
268
+ # @example Create a flat vector search index.
269
+ # class Person
270
+ # include Mongoid::Document
271
+ # vector_search_index fields: [
272
+ # { type: 'vector', path: 'embedding', numDimensions: 1536,
273
+ # similarity: 'cosine', indexingMethod: 'flat' }
274
+ # ]
275
+ # end
276
+ #
256
277
  # @param [ Symbol | String | Hash ] name_or_defn Either the name of the index to
257
278
  # define, or the index definition.
258
279
  # @param [ Hash ] defn The vector search index definition.
@@ -260,6 +281,8 @@ module Mongoid
260
281
  name = name_or_defn
261
282
  name, defn = nil, name if name.is_a?(Hash)
262
283
 
284
+ validate_vector_index_definition!(defn)
285
+
263
286
  spec = { type: 'vectorSearch', definition: defn }.tap { |s| s[:name] = name.to_s if name }
264
287
  search_index_specs.push(spec)
265
288
 
@@ -290,22 +313,25 @@ module Mongoid
290
313
  # consider during the ANN search; defaults to limit * 10.
291
314
  # @param [ Hash | nil ] filter An optional MongoDB filter to pre-filter
292
315
  # candidates before scoring.
316
+ # @param [ true | false ] exact Use exact nearest-neighbor (ENN) search
317
+ # instead of ANN (default: false). When true, numCandidates is omitted.
318
+ # Required when using a flat vector search index.
293
319
  # @param [ Array ] pipeline Additional aggregation stages to append after
294
320
  # the vector search and score projection.
295
321
  #
296
322
  # @return [ Array<Mongoid::Document> ] matching documents, each with
297
323
  # a populated +vector_search_score+ attribute.
298
- def vector_search(vector, index: nil, path: nil, limit: 10, num_candidates: nil, filter: nil, pipeline: []) # rubocop:disable Metrics/ParameterLists
324
+ def vector_search(vector, index: nil, path: nil, limit: 10, num_candidates: nil, exact: false, filter: nil, pipeline: []) # rubocop:disable Metrics/ParameterLists
299
325
  resolved_index, resolved_path = resolve_vector_index(index, path)
300
- num_candidates ||= limit * 10
301
326
 
302
327
  vs_options = {
303
328
  'index' => resolved_index,
304
329
  'path' => resolved_path,
305
330
  'queryVector' => vector,
306
- 'numCandidates' => num_candidates,
307
331
  'limit' => limit
308
332
  }
333
+ vs_options['numCandidates'] = num_candidates || (limit * 10) unless exact
334
+ vs_options['exact'] = true if exact
309
335
  vs_options['filter'] = filter if filter
310
336
 
311
337
  agg_pipeline = [
@@ -374,13 +400,32 @@ module Mongoid
374
400
 
375
401
  private
376
402
 
403
+ # Validates the vector index definition, raising ArgumentError for
404
+ # combinations that MongoDB does not support.
405
+ #
406
+ # @param [ Hash ] defn The vector search index definition.
407
+ def validate_vector_index_definition!(defn)
408
+ fields = defn[:fields] || defn['fields'] || []
409
+ fields.each do |field|
410
+ method = field[:indexingMethod] || field['indexingMethod']
411
+ next unless method.to_s == 'flat'
412
+ next unless field[:hnswOptions] || field['hnswOptions']
413
+
414
+ raise ArgumentError, 'hnswOptions is only supported with indexingMethod: hnsw'
415
+ end
416
+ end
417
+
377
418
  # Retrieves the index records for the indexes with the given names.
378
419
  #
379
420
  # @param [ Array<String> ] names the index names to query
380
421
  #
381
422
  # @return [ Array<Hash> ] the raw index documents
382
423
  def get_indexes(names)
383
- collection.search_indexes.select { |i| names.include?(i['name']) }
424
+ # Reading index metadata sends no document data, so it is exempt from
425
+ # the encryption schema check, like the other index operations here.
426
+ Threaded.with_collection_management do
427
+ collection.search_indexes.select { |i| names.include?(i['name']) }
428
+ end
384
429
  end
385
430
 
386
431
  # Resolves the index name and vector path from the declared specs,
@@ -127,10 +127,18 @@ module Mongoid
127
127
  inclusions = options[:include]
128
128
  relation_names(inclusions).each do |name|
129
129
  association = relations[name.to_s]
130
- if association && relation = send(association.name)
131
- attributes[association.name.to_s] =
132
- relation.serializable_hash(relation_options(inclusions, options, name))
133
- end
130
+ next unless association
131
+
132
+ relation =
133
+ if association.fallback?
134
+ without_autobuild { send(association.name) }
135
+ else
136
+ send(association.name)
137
+ end
138
+ next unless relation
139
+
140
+ attributes[association.name.to_s] =
141
+ relation.serializable_hash(relation_options(inclusions, options, name))
134
142
  end
135
143
  end
136
144
 
@@ -64,15 +64,18 @@ module Mongoid
64
64
  # @param [ true | false ] wait whether to wait for the indexes to be
65
65
  # built.
66
66
  def create_search_indexes(models = ::Mongoid.models, wait: true)
67
- searchable = models.select { |m| m.search_index_specs.any? }
67
+ Threaded.with_collection_management do
68
+ searchable = models.select { |m| m.search_index_specs.any? }
68
69
 
69
- # queue up the search index creation requests
70
- index_names_by_model = searchable.each_with_object({}) do |model, obj|
71
- logger.info("MONGOID: Creating search indexes on #{model}...")
72
- obj[model] = model.create_search_indexes
73
- end
70
+ # queue up the search index creation requests
71
+ index_names_by_model = searchable.each_with_object({}) do |model, obj|
72
+ logger.info("MONGOID: Creating search indexes on #{model}...")
73
+ obj[model] = model.create_search_indexes
74
+ end
74
75
 
75
- wait_for_search_indexes(index_names_by_model) if wait
76
+ wait_for_search_indexes(index_names_by_model) if wait
77
+ index_names_by_model
78
+ end
76
79
  end
77
80
 
78
81
  # Return the list of indexes by model that exist in the database but aren't
@@ -83,6 +86,12 @@ module Mongoid
83
86
  #
84
87
  # @return [ Array<Hash> ] The list of undefined indexes by model.
85
88
  def undefined_indexes(models = ::Mongoid.models)
89
+ Threaded.with_collection_management do
90
+ undefined_indexes_for(models)
91
+ end
92
+ end
93
+
94
+ def undefined_indexes_for(models)
86
95
  undefined_by_model = {}
87
96
 
88
97
  models.each do |model|
@@ -116,15 +125,17 @@ module Mongoid
116
125
  #
117
126
  # @return [ Hash{Class => Array(Hash)}] The list of indexes that were removed by model.
118
127
  def remove_undefined_indexes(models = ::Mongoid.models)
119
- undefined_indexes(models).each do |model, indexes|
120
- indexes.each do |index|
121
- key = index['key'].symbolize_keys
122
- collection = model.collection
123
- collection.indexes(session: model.send(:_session)).drop_one(key)
124
- logger.info(
125
- "MONGOID: Removed index '#{index['name']}' on collection " +
126
- "'#{collection.name}' in database '#{collection.database.name}'."
127
- )
128
+ Threaded.with_collection_management do
129
+ undefined_indexes(models).each do |model, indexes|
130
+ indexes.each do |index|
131
+ key = index['key'].symbolize_keys
132
+ collection = model.collection
133
+ collection.indexes(session: model.send(:_session)).drop_one(key)
134
+ logger.info(
135
+ "MONGOID: Removed index '#{index['name']}' on collection " +
136
+ "'#{collection.name}' in database '#{collection.database.name}'."
137
+ )
138
+ end
128
139
  end
129
140
  end
130
141
  end
@@ -172,6 +183,12 @@ module Mongoid
172
183
  #
173
184
  # @return [ Array<Class> ] The sharded models
174
185
  def shard_collections(models = ::Mongoid.models)
186
+ Threaded.with_collection_management do
187
+ shard_collections_for(models)
188
+ end
189
+ end
190
+
191
+ def shard_collections_for(models)
175
192
  models.map do |model|
176
193
  next if model.shard_config.nil?
177
194