mongoid 9.1.1 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +31 -11
  3. data/lib/mongoid/association/accessors.rb +18 -4
  4. data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +10 -10
  5. data/lib/mongoid/association/eager_load/embedded_distributor.rb +6 -6
  6. data/lib/mongoid/association/eager_load/lookup_pipeline.rb +1 -1
  7. data/lib/mongoid/association/eager_loadable.rb +8 -1
  8. data/lib/mongoid/association/embedded/batchable.rb +17 -1
  9. data/lib/mongoid/association/nested/many.rb +1 -1
  10. data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
  11. data/lib/mongoid/association/relatable.rb +18 -0
  12. data/lib/mongoid/association.rb +8 -0
  13. data/lib/mongoid/atomic/modifiers.rb +100 -16
  14. data/lib/mongoid/clients/factory.rb +2 -0
  15. data/lib/mongoid/config/encryption.rb +148 -17
  16. data/lib/mongoid/document.rb +3 -0
  17. data/lib/mongoid/encryptable.rb +42 -15
  18. data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
  19. data/lib/mongoid/errors/no_encryption_schema.rb +5 -3
  20. data/lib/mongoid/errors.rb +1 -0
  21. data/lib/mongoid/fields.rb +7 -0
  22. data/lib/mongoid/persistable/creatable.rb +120 -14
  23. data/lib/mongoid/persistable.rb +15 -15
  24. data/lib/mongoid/persistence_context.rb +46 -4
  25. data/lib/mongoid/search_indexable.rb +5 -1
  26. data/lib/mongoid/tasks/database.rb +10 -7
  27. data/lib/mongoid/touchable.rb +28 -1
  28. data/lib/mongoid/version.rb +1 -1
  29. data/lib/mongoid/warnings.rb +4 -0
  30. data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
  31. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +14 -0
  32. data/spec/integration/atomic/modifiers_spec.rb +283 -0
  33. data/spec/integration/encryption_spec.rb +149 -0
  34. data/spec/mongoid/association/accessors_spec.rb +35 -0
  35. data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
  36. data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
  37. data/spec/mongoid/attributes/nested_spec.rb +29 -0
  38. data/spec/mongoid/clients/factory_spec.rb +36 -0
  39. data/spec/mongoid/config/encryption_spec.rb +223 -0
  40. data/spec/mongoid/criteria/includable_spec.rb +346 -0
  41. data/spec/mongoid/encryptable_spec.rb +54 -0
  42. data/spec/mongoid/fields_spec.rb +8 -0
  43. data/spec/mongoid/search_indexable_spec.rb +26 -0
  44. data/spec/mongoid/tasks/database_spec.rb +13 -0
  45. data/spec/mongoid/touchable_spec.rb +217 -0
  46. data/spec/mongoid/touchable_spec_models.rb +37 -0
  47. data/spec/support/crypt/models.rb +96 -0
  48. data/spec/support/models/modifier_models.rb +65 -0
  49. metadata +5 -2
@@ -20,7 +20,11 @@ module Mongoid
20
20
  #
21
21
  # @return [ Hash ] The encryption schema map.
22
22
  def encryption_schema_map(default_database, models = ::Mongoid.models)
23
- models.each_with_object({}) do |model, map|
23
+ # Subclasses share their parent's collection but are not registered in
24
+ # Mongoid.models, so they have to be added to the walk explicitly.
25
+ classes = models.flat_map { |model| [ model ] + model.descendants }.uniq
26
+
27
+ classes.each_with_object({}) do |model, map|
24
28
  next if model.embedded?
25
29
  next unless model.requires_encryption_schema?
26
30
 
@@ -34,14 +38,33 @@ module Mongoid
34
38
  next if database.respond_to?(:call)
35
39
 
36
40
  key = "#{database}.#{model.collection_name}"
37
- props = metadata_for(model).merge(properties_for(model, [ model ]))
41
+ props = metadata_for(model).merge(properties_for(model, [ model ], key))
42
+ next if props.empty?
43
+
38
44
  # The root of a collection schema describes the document, so it is
39
45
  # always an object. Saying so matters when a nested schema carries
40
46
  # encryptMetadata: mongocryptd rejects the schema otherwise.
41
- map[key] = { 'bsonType' => 'object' }.merge(props) unless props.empty?
47
+ entry = { 'bsonType' => 'object' }.merge(props)
48
+ map[key] = map.key?(key) ? merge_schema_entries(map[key], entry, key) : entry
42
49
  end
43
50
  end
44
51
 
52
+ # Collect the paths of the fields encrypted by the schema generated for
53
+ # a single model.
54
+ #
55
+ # @param [ Mongoid::Document ] model The model.
56
+ #
57
+ # @return [ Array<Array<String>> ] The property paths, outermost first.
58
+ #
59
+ # @api private
60
+ def encrypted_paths_for(model)
61
+ return model.instance_variable_get(:@encrypted_paths_for) if
62
+ model.instance_variable_defined?(:@encrypted_paths_for)
63
+
64
+ properties = properties_for(model, [ model ])['properties'] || {}
65
+ model.instance_variable_set(:@encrypted_paths_for, collect_encrypted_paths(properties, []))
66
+ end
67
+
45
68
  private
46
69
 
47
70
  # The algorithm to use for the deterministic encryption.
@@ -109,10 +132,12 @@ module Mongoid
109
132
  # @param [ Mongoid::Document ] model The model to generate the properties for.
110
133
  # @param [ Array<Mongoid::Document> ] path The models the walk is already
111
134
  # inside of, outermost first.
135
+ # @param [ String | nil ] namespace The namespace the schema is built
136
+ # for, used for error reporting.
112
137
  #
113
138
  # @return [ Hash ] The encryption properties.
114
- def properties_for(model, path)
115
- result = properties_for_fields(model).merge(properties_for_relations(model, path))
139
+ def properties_for(model, path, namespace = nil)
140
+ result = properties_for_fields(model).merge(properties_for_relations(model, path, namespace))
116
141
  if result.empty?
117
142
  {}
118
143
  else
@@ -151,9 +176,11 @@ module Mongoid
151
176
  # @param [ Mongoid::Document ] model The model to generate the properties for.
152
177
  # @param [ Array<Mongoid::Document> ] path The models the walk is already
153
178
  # inside of, outermost first.
179
+ # @param [ String | nil ] namespace The namespace the schema is built
180
+ # for, used for error reporting.
154
181
  #
155
182
  # @return [ Hash ] The encryption properties.
156
- def properties_for_relations(model, path)
183
+ def properties_for_relations(model, path, namespace = nil)
157
184
  model.relations.each_with_object({}) do |(name, relation), props|
158
185
  # relation_class constantizes, and a polymorphic embedded_in has no
159
186
  # class to resolve, so the relation type has to be checked first.
@@ -163,21 +190,125 @@ module Mongoid
163
190
  # An association target does not have to be a Mongoid document, and
164
191
  # the class it names does not have to exist.
165
192
  next unless klass.respond_to?(:requires_encryption_schema?)
166
- # Stop at a model the walk is already inside of, or a self-embedding
167
- # model never terminates. The path covers the current branch only:
168
- # a model embedded by two parents, or twice by one parent, has to be
169
- # emitted at every place it appears.
170
- next if path.include?(klass)
171
- next unless klass.requires_encryption_schema?
172
-
173
- metadata_for(klass).merge(
174
- properties_for(klass, path + [ klass ])
175
- ).tap do |properties|
176
- props[name] = { 'bsonType' => 'object' }.merge(properties) unless properties.empty?
193
+
194
+ # Any subclass of the declared class can be assigned to the
195
+ # association, so its encrypted fields have to be mapped here too.
196
+ properties = ([ klass ] + klass.descendants).uniq.inject({}) do |merged, candidate|
197
+ # Stop at a model the walk is already inside of, or a self-embedding
198
+ # model never terminates. The path covers the current branch only:
199
+ # a model embedded by two parents, or twice by one parent, has to be
200
+ # emitted at every place it appears.
201
+ next merged if path.include?(candidate)
202
+ next merged unless candidate.respond_to?(:requires_encryption_schema?)
203
+ next merged unless candidate.requires_encryption_schema?
204
+
205
+ merge_schema_entries(
206
+ merged,
207
+ metadata_for(candidate).merge(
208
+ properties_for(candidate, path + [ candidate ], namespace)
209
+ ),
210
+ namespace
211
+ )
177
212
  end
213
+ props[name] = { 'bsonType' => 'object' }.merge(properties) unless properties.empty?
178
214
  end
179
215
  end
180
216
 
217
+ # Walk the schema properties and collect the path of every encrypted
218
+ # leaf.
219
+ #
220
+ # @param [ Hash ] properties The properties to walk.
221
+ # @param [ Array<String> ] path The properties the walk is already
222
+ # inside of.
223
+ #
224
+ # @return [ Array<Array<String>> ] The property paths.
225
+ def collect_encrypted_paths(properties, path)
226
+ properties.each_with_object([]) do |(name, schema), paths|
227
+ if schema.key?('encrypt')
228
+ paths << (path + [ name ])
229
+ elsif schema['properties']
230
+ paths.concat(collect_encrypted_paths(schema['properties'], path + [ name ]))
231
+ end
232
+ end
233
+ end
234
+
235
+ # Merge two schema objects covering the same model or namespace.
236
+ #
237
+ # Every model writing to one namespace contributes an entry to the
238
+ # schema map. Those entries have to agree on everything they have in
239
+ # common, or the schema would silently encrypt a field differently for
240
+ # one model than for another.
241
+ #
242
+ # @param [ Hash ] left The first schema.
243
+ # @param [ Hash ] right The second schema.
244
+ # @param [ String | nil ] namespace The namespace the schema belongs to.
245
+ #
246
+ # @return [ Hash ] The merged schema.
247
+ def merge_schema_entries(left, right, namespace = nil)
248
+ left.merge(right) do |key, left_value, right_value|
249
+ case key
250
+ when 'bsonType'
251
+ left_value
252
+ when 'encryptMetadata'
253
+ merge_encrypt_metadata(left_value, right_value, namespace)
254
+ when 'properties'
255
+ merge_properties(left_value, right_value, namespace)
256
+ else
257
+ raise_invalid_encryption_schema(namespace, key)
258
+ end
259
+ end
260
+ end
261
+
262
+ # Merge two encryptMetadata objects.
263
+ #
264
+ # @param [ Hash ] left The first encryptMetadata object.
265
+ # @param [ Hash ] right The second encryptMetadata object.
266
+ # @param [ String | nil ] namespace The namespace the schema belongs to.
267
+ #
268
+ # @return [ Hash ] The merged encryptMetadata object.
269
+ def merge_encrypt_metadata(left, right, namespace)
270
+ left.merge(right) do |key, left_value, right_value|
271
+ unless left_value == right_value
272
+ raise_invalid_encryption_schema(namespace, "encryptMetadata.#{key}")
273
+ end
274
+
275
+ left_value
276
+ end
277
+ end
278
+
279
+ # Merge two properties objects.
280
+ #
281
+ # @param [ Hash ] left The first properties object.
282
+ # @param [ Hash ] right The second properties object.
283
+ # @param [ String | nil ] namespace The namespace the schema belongs to.
284
+ #
285
+ # @return [ Hash ] The merged properties object.
286
+ def merge_properties(left, right, namespace)
287
+ left.merge(right) do |name, left_value, right_value|
288
+ if left_value.key?('encrypt') && right_value.key?('encrypt')
289
+ unless left_value['encrypt'] == right_value['encrypt']
290
+ raise_invalid_encryption_schema(namespace, "properties.#{name}")
291
+ end
292
+
293
+ left_value
294
+ elsif left_value.key?('encrypt') || right_value.key?('encrypt')
295
+ raise_invalid_encryption_schema(namespace, "properties.#{name}")
296
+ else
297
+ merge_schema_entries(left_value, right_value, namespace)
298
+ end
299
+ end
300
+ end
301
+
302
+ # Raise an error for a schema conflicting with another one.
303
+ #
304
+ # @param [ String | nil ] namespace The namespace the schema belongs to.
305
+ # @param [ String ] path The conflicting path in the schema.
306
+ #
307
+ # @raise [ Errors::InvalidEncryptionSchema ] Always raised.
308
+ def raise_invalid_encryption_schema(namespace, path)
309
+ raise Errors::InvalidEncryptionSchema.new(namespace, path)
310
+ end
311
+
181
312
  # Get the BSON type identifier for the provided field according to the
182
313
  # https://www.mongodb.com/docs/manual/reference/bson-types/#std-label-bson-types
183
314
  #
@@ -410,6 +410,8 @@ module Mongoid
410
410
  def instantiate_document(attrs = nil, selected_fields = nil, options = {}, &block)
411
411
  execute_callbacks = options.fetch(:execute_callbacks, Threaded.execute_callbacks?)
412
412
  attributes = attrs.to_h
413
+ preloaded_fields = attributes.keys.select { |key| key.start_with?(Association::PRELOADED_PREFIX) }
414
+ preloaded = attributes.extract!(*preloaded_fields)
413
415
 
414
416
  doc = allocate
415
417
  doc.__selected_fields = selected_fields
@@ -417,6 +419,7 @@ module Mongoid
417
419
  doc.instance_variable_set(:@attributes_before_type_cast, attributes.dup)
418
420
 
419
421
  doc._handle_callbacks_after_instantiation(execute_callbacks, &block)
422
+ doc.load_preloaded_associations(preloaded)
420
423
 
421
424
  doc.remember_storage_options!
422
425
  doc
@@ -43,21 +43,29 @@ module Mongoid
43
43
  # group has no encrypted field of its own, but its collection still needs
44
44
  # a schema, otherwise the embedded fields are written in plaintext.
45
45
  #
46
- # The answer is memoized, since this runs on the persistence path.
47
- # Declaring encryption on a model after it has already been persisted is
48
- # not supported.
46
+ # This runs on the persistence path, so the answer is memoized. A true
47
+ # answer is always memoized. A false answer is memoized only when every
48
+ # embeds_one target in the walk resolved to a class; if one could not be
49
+ # resolved yet, the answer is recomputed next time, so a class loaded
50
+ # later is still detected. Declaring encryption on a model after it has
51
+ # already been persisted is not supported.
49
52
  #
50
53
  # @return [ true | false ] Whether the model needs an encryption schema.
51
54
  #
52
55
  # @api private
53
56
  def requires_encryption_schema?
54
57
  return @requires_encryption_schema if defined?(@requires_encryption_schema)
58
+ return @requires_encryption_schema = true if encrypted?
55
59
 
56
- @requires_encryption_schema = encrypted? || embeds_encrypted?([ self ])
60
+ found, complete = embedded_encryption_scan([ self ])
61
+ @requires_encryption_schema = found if found || complete
62
+ found
57
63
  end
58
64
 
59
- # Whether any model reachable through this model's embeds_one relations
60
- # declares encryption.
65
+ # Walks the models reachable through this model's embeds_one relations,
66
+ # looking for one that declares encryption. Subclasses of an
67
+ # association target are included: any of them can be assigned to the
68
+ # association.
61
69
  #
62
70
  # embeds_many is not considered: libmongocrypt cannot express per-field
63
71
  # encryption under array items, so those fields are never mapped.
@@ -65,21 +73,40 @@ module Mongoid
65
73
  # @param [ Array<Class> ] path The models the walk is already inside of.
66
74
  # A model embedding itself terminates here.
67
75
  #
68
- # @return [ true | false ] Whether an embedded model is encrypted.
76
+ # @return [ Array<true | false> ] A pair: whether an embedded model is
77
+ # encrypted, and whether every embeds_one target the walk needed
78
+ # resolved to a class. The second value is only meaningful when the
79
+ # first is false.
69
80
  #
70
81
  # @api private
71
- def embeds_encrypted?(path)
72
- relations.each_value.any? do |relation|
73
- next false unless relation.is_a?(Association::Embedded::EmbedsOne)
82
+ def embedded_encryption_scan(path)
83
+ complete = true
84
+
85
+ relations.each_value do |relation|
86
+ next unless relation.is_a?(Association::Embedded::EmbedsOne)
74
87
 
75
88
  klass = relation.try_relation_class
76
- # An association target does not have to be a Mongoid document, and
77
- # the class it names does not have to exist.
78
- next false unless klass.respond_to?(:encrypted?)
79
- next false if path.include?(klass)
89
+ # The class an association names does not have to exist yet.
90
+ if klass.nil?
91
+ complete = false
92
+ next
93
+ end
94
+
95
+ # An association target does not have to be a Mongoid document.
96
+ next unless klass.respond_to?(:encrypted?)
97
+
98
+ ([ klass ] + klass.descendants).uniq.each do |candidate|
99
+ next if path.include?(candidate)
100
+ return [ true, true ] if candidate.encrypted?
80
101
 
81
- klass.encrypted? || klass.embeds_encrypted?(path + [ klass ])
102
+ found, nested_complete = candidate.embedded_encryption_scan(path + [ candidate ])
103
+ return [ true, true ] if found
104
+
105
+ complete &&= nested_complete
106
+ end
82
107
  end
108
+
109
+ [ false, complete ]
83
110
  end
84
111
 
85
112
  # Override the key_id for the model.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Errors
5
+ # This error is raised when two models writing to the same namespace
6
+ # describe a field, or the collection metadata, differently. The schema map
7
+ # has one entry per namespace, so the two descriptions cannot both be
8
+ # honored.
9
+ class InvalidEncryptionSchema < MongoidError
10
+ # Create the new error.
11
+ #
12
+ # @example Create the error.
13
+ # InvalidEncryptionSchema.new('music.bands', 'properties.code')
14
+ #
15
+ # @param [ String | nil ] namespace The namespace being described.
16
+ # @param [ String ] path The conflicting path in the schema.
17
+ def initialize(namespace, path)
18
+ super(
19
+ compose_message(
20
+ 'invalid_encryption_schema',
21
+ { namespace: namespace || 'the collection', path: path }
22
+ )
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -15,11 +15,13 @@ module Mongoid
15
15
  # @param [ Class ] klass The model class.
16
16
  # @param [ String ] namespace The namespace the model resolved to.
17
17
  # @param [ String | Symbol ] client The name of the client in use.
18
- def initialize(klass, namespace, client)
18
+ # @param [ String | nil ] field The encrypted field missing from the
19
+ # schema entry, if the namespace itself is covered.
20
+ def initialize(klass, namespace, client, field = nil)
19
21
  super(
20
22
  compose_message(
21
- 'no_encryption_schema',
22
- { klass: klass, namespace: namespace, client: client }
23
+ field ? 'no_encryption_schema_field' : 'no_encryption_schema',
24
+ { klass: klass, namespace: namespace, client: client, field: field }
23
25
  )
24
26
  )
25
27
  end
@@ -20,6 +20,7 @@ require 'mongoid/errors/invalid_collection'
20
20
  require 'mongoid/errors/invalid_config_file'
21
21
  require 'mongoid/errors/invalid_config_option'
22
22
  require 'mongoid/errors/invalid_dependent_strategy'
23
+ require 'mongoid/errors/invalid_encryption_schema'
23
24
  require 'mongoid/errors/invalid_field'
24
25
  require 'mongoid/errors/invalid_field_option'
25
26
  require 'mongoid/errors/invalid_field_type'
@@ -412,6 +412,13 @@ module Mongoid
412
412
  return '' unless name.present?
413
413
 
414
414
  key = name.to_s
415
+
416
+ # A key without a "." refers to an immediate field, so it resolves to
417
+ # its alias (or itself) with no splitting. Short-circuiting here avoids
418
+ # the split and dup on every read, which is a hot path. This mirrors the
419
+ # same optimization already applied to the matcher.
420
+ return aliased_fields[key]&.dup || key unless key.include?('.')
421
+
415
422
  segment, remaining = key.split('.', 2)
416
423
 
417
424
  # Don't get the alias for the field when a belongs_to association
@@ -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'] = (operations['$set'] || {}).merge(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.
@@ -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
@@ -209,12 +209,15 @@ module Mongoid
209
209
  # that schema, and when it is absent it asks the server for a schema
210
210
  # instead; a collection without a validator then yields no encryption at
211
211
  # all, and no error. Checking the namespace here is what turns that silent
212
- # downgrade into a failure.
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.
213
215
  #
214
216
  # @param [ Mongo::Client ] client The client this context resolved to.
215
217
  #
216
218
  # @raise [ Errors::NoEncryptionSchema ] if the model needs an encryption
217
- # schema and the client's schema does not cover the target namespace.
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.
218
221
  def verify_encryption_schema!(client)
219
222
  klass = @object.is_a?(Class) ? @object : @object.class
220
223
  # A model whose encrypted fields all live on embedded models declares no
@@ -229,9 +232,48 @@ module Mongoid
229
232
 
230
233
  schema_map = client.options.dig(:auto_encryption_options, :schema_map)
231
234
  namespace = "#{client.database.name}.#{collection_name}"
232
- return if schema_map&.key?(namespace)
235
+ entry = schema_map && schema_map[namespace]
236
+ raise Errors::NoEncryptionSchema.new(klass, namespace, client_name) unless entry
233
237
 
234
- raise Errors::NoEncryptionSchema.new(klass, namespace, client_name)
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]
235
277
  end
236
278
 
237
279
  def client_options
@@ -421,7 +421,11 @@ module Mongoid
421
421
  #
422
422
  # @return [ Array<Hash> ] the raw index documents
423
423
  def get_indexes(names)
424
- 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
425
429
  end
426
430
 
427
431
  # Resolves the index name and vector path from the declared specs,