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.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +69 -0
- data/lib/mongoid/association/accessors.rb +27 -5
- data/lib/mongoid/association/depending.rb +14 -10
- data/lib/mongoid/association/eager.rb +9 -0
- data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +88 -0
- data/lib/mongoid/association/eager_load/embedded_distributor.rb +151 -0
- data/lib/mongoid/association/eager_load/inclusion.rb +173 -0
- data/lib/mongoid/association/eager_load/inclusion_tree.rb +96 -0
- data/lib/mongoid/association/eager_load/lookup_pipeline.rb +121 -0
- data/lib/mongoid/association/eager_load/polymorphic_preloader.rb +61 -0
- data/lib/mongoid/association/eager_load/polymorphic_targets.rb +151 -0
- data/lib/mongoid/association/eager_loadable.rb +65 -101
- data/lib/mongoid/association/embedded/batchable.rb +17 -1
- data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
- data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
- data/lib/mongoid/association/macros.rb +13 -1
- data/lib/mongoid/association/nested/many.rb +34 -5
- data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
- data/lib/mongoid/association/nested/one.rb +6 -1
- data/lib/mongoid/association/options.rb +17 -0
- data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
- data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
- data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
- data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
- data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
- data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
- data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
- data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
- data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
- data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
- data/lib/mongoid/association/referenced/has_one.rb +1 -0
- data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
- data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
- data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
- data/lib/mongoid/association/referenced.rb +2 -0
- data/lib/mongoid/association/relatable.rb +58 -0
- data/lib/mongoid/association.rb +15 -0
- data/lib/mongoid/atomic/modifiers.rb +100 -16
- data/lib/mongoid/clients/factory.rb +9 -13
- data/lib/mongoid/collection_configurable.rb +8 -0
- data/lib/mongoid/config/defaults.rb +0 -1
- data/lib/mongoid/config/encryption.rb +170 -22
- data/lib/mongoid/config.rb +56 -5
- data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
- data/lib/mongoid/contextual/memory.rb +18 -7
- data/lib/mongoid/criteria/includable.rb +35 -11
- data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
- data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
- data/lib/mongoid/deprecable.rb +45 -2
- data/lib/mongoid/document.rb +3 -0
- data/lib/mongoid/encryptable.rb +73 -0
- data/lib/mongoid/errors/config_redactor.rb +43 -0
- data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
- data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
- data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
- data/lib/mongoid/errors/no_client_database.rb +1 -1
- data/lib/mongoid/errors/no_client_hosts.rb +1 -1
- data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
- data/lib/mongoid/errors/readonly_association.rb +29 -0
- data/lib/mongoid/errors.rb +5 -0
- data/lib/mongoid/extensions/vector.rb +64 -0
- data/lib/mongoid/extensions.rb +1 -0
- data/lib/mongoid/field_readable.rb +69 -0
- data/lib/mongoid/fields.rb +7 -0
- data/lib/mongoid/indexable.rb +38 -26
- data/lib/mongoid/matchable.rb +6 -1
- data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
- data/lib/mongoid/matcher/regex.rb +11 -12
- data/lib/mongoid/matcher/regexp_budget.rb +372 -0
- data/lib/mongoid/matcher.rb +1 -0
- data/lib/mongoid/persistable/creatable.rb +120 -14
- data/lib/mongoid/persistable/updatable.rb +9 -11
- data/lib/mongoid/persistable.rb +15 -15
- data/lib/mongoid/persistence_context.rb +77 -0
- data/lib/mongoid/search_indexable.rb +69 -24
- data/lib/mongoid/serializable.rb +12 -4
- data/lib/mongoid/tasks/database.rb +33 -16
- data/lib/mongoid/threaded.rb +36 -0
- data/lib/mongoid/timestamps/timeless.rb +84 -10
- data/lib/mongoid/touchable.rb +28 -1
- data/lib/mongoid/traversable.rb +2 -2
- data/lib/mongoid/validatable/associated.rb +18 -2
- data/lib/mongoid/version.rb +1 -1
- data/lib/mongoid/warnings.rb +4 -0
- data/spec/integration/app_spec.rb +7 -1
- data/spec/integration/associations/belongs_to_spec.rb +13 -0
- data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +31 -2
- data/spec/integration/atomic/modifiers_spec.rb +283 -0
- data/spec/integration/dots_and_dollars_spec.rb +12 -2
- data/spec/integration/encryption_spec.rb +300 -0
- data/spec/integration/matcher_operator_data/regex.yml +21 -0
- data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
- data/spec/integration/query_operator_guard_spec.rb +89 -0
- data/spec/mongoid/association/accessors_spec.rb +35 -0
- data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
- data/spec/mongoid/association/eager_spec.rb +2 -2
- data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
- data/spec/mongoid/association/fallback_spec.rb +425 -0
- data/spec/mongoid/association/macros_spec.rb +26 -0
- data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
- data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
- data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
- data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
- data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
- data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
- data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
- data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
- data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
- data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
- data/spec/mongoid/attributes/nested_spec.rb +278 -8
- data/spec/mongoid/clients/factory_spec.rb +36 -0
- data/spec/mongoid/config/defaults_spec.rb +33 -0
- data/spec/mongoid/config/encryption_spec.rb +451 -0
- data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
- data/spec/mongoid/contextual/memory_spec.rb +177 -0
- data/spec/mongoid/criteria/includable_spec.rb +1221 -0
- data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
- data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
- data/spec/mongoid/criteria_spec.rb +3 -0
- data/spec/mongoid/encryptable_spec.rb +115 -0
- data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
- data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
- data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
- data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
- data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
- data/spec/mongoid/extensions/vector_spec.rb +175 -0
- data/spec/mongoid/fields_spec.rb +8 -0
- data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
- data/spec/mongoid/search_indexable_spec.rb +321 -9
- data/spec/mongoid/tasks/database_spec.rb +31 -0
- data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
- data/spec/mongoid/touchable_spec.rb +242 -0
- data/spec/mongoid/touchable_spec_models.rb +47 -0
- data/spec/mongoid/validatable/associated_spec.rb +19 -0
- data/spec/spec_helper.rb +5 -1
- data/spec/support/crypt/models.rb +253 -0
- data/spec/support/models/modifier_models.rb +65 -0
- 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
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
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,
|
|
172
|
+
def process_touch_option(options, &block)
|
|
175
173
|
if options.fetch(:touch, true)
|
|
176
174
|
yield
|
|
177
175
|
else
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
Mongoid::Timestamps::Timeless.with_timeless do
|
|
177
|
+
suppress_touch_callbacks(&block)
|
|
178
|
+
end
|
|
181
179
|
end
|
|
182
180
|
end
|
|
183
181
|
|
data/lib/mongoid/persistable.rb
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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:
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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:
|
|
141
|
-
filter:
|
|
145
|
+
limit: limit + 1,
|
|
146
|
+
num_candidates: effective_candidates,
|
|
147
|
+
filter: filter,
|
|
142
148
|
exact: exact,
|
|
143
149
|
model: model,
|
|
144
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
data/lib/mongoid/serializable.rb
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
67
|
+
Threaded.with_collection_management do
|
|
68
|
+
searchable = models.select { |m| m.search_index_specs.any? }
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|