mongoid 9.0.11 → 9.0.13

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +65 -0
  3. data/lib/mongoid/association/depending.rb +8 -4
  4. data/lib/mongoid/association/embedded/batchable.rb +8 -0
  5. data/lib/mongoid/association/nested/many.rb +34 -5
  6. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  7. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  8. data/lib/mongoid/association/relatable.rb +17 -0
  9. data/lib/mongoid/clients/factory.rb +2 -0
  10. data/lib/mongoid/collection_configurable.rb +8 -0
  11. data/lib/mongoid/config/encryption.rb +171 -22
  12. data/lib/mongoid/config.rb +56 -9
  13. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  14. data/lib/mongoid/contextual/memory.rb +18 -7
  15. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  16. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  17. data/lib/mongoid/encryptable.rb +73 -0
  18. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  19. data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
  20. data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
  21. data/lib/mongoid/errors.rb +3 -0
  22. data/lib/mongoid/field_readable.rb +70 -0
  23. data/lib/mongoid/indexable.rb +39 -27
  24. data/lib/mongoid/matchable.rb +6 -1
  25. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
  26. data/lib/mongoid/matcher/regex.rb +9 -13
  27. data/lib/mongoid/matcher/regexp_budget.rb +383 -0
  28. data/lib/mongoid/matcher.rb +1 -0
  29. data/lib/mongoid/persistence_context.rb +77 -0
  30. data/lib/mongoid/search_indexable.rb +19 -8
  31. data/lib/mongoid/tasks/database.rb +33 -16
  32. data/lib/mongoid/threaded.rb +37 -0
  33. data/lib/mongoid/version.rb +1 -1
  34. data/lib/mongoid/warnings.rb +4 -0
  35. data/spec/integration/app_spec.rb +7 -0
  36. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  37. data/spec/integration/atomic/modifiers_spec.rb +57 -0
  38. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  39. data/spec/integration/encryption_spec.rb +298 -0
  40. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  41. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  42. data/spec/integration/query_operator_guard_spec.rb +89 -0
  43. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  44. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  45. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  46. data/spec/mongoid/clients/factory_spec.rb +36 -0
  47. data/spec/mongoid/config/defaults_spec.rb +18 -0
  48. data/spec/mongoid/config/encryption_spec.rb +451 -0
  49. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  50. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  51. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  52. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
  53. data/spec/mongoid/criteria_spec.rb +2 -0
  54. data/spec/mongoid/encryptable_spec.rb +115 -0
  55. data/spec/mongoid/matcher/regexp_budget_spec.rb +576 -0
  56. data/spec/mongoid/search_indexable_spec.rb +34 -1
  57. data/spec/mongoid/tasks/database_spec.rb +31 -0
  58. data/spec/support/crypt/models.rb +253 -0
  59. data/spec/support/models/modifier_models.rb +32 -0
  60. metadata +17 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6976a4808058209fda8554306590eeb5fd44759d8c228d02bca544acbe5c200
4
- data.tar.gz: e0dcd20719682c2a425d9ed17c1459376b6956311f9ec439c74843a4cefb8502
3
+ metadata.gz: 030d90a4693fc5b3ccb72a7069ce6dc912089351df98fd04ead3595a414433a0
4
+ data.tar.gz: 10362c8758cb9ec1e535b9940eab11088261e7cf4831b8a07bfffb6be231e8df
5
5
  SHA512:
6
- metadata.gz: 64ee6ee13a326ed9fae0e6b6dc08fa28ba0608a75342993b873023e4f87076160f6a8997e3edd53a273da4716c92d5aabc6b49f0931a489d2074d92f8de912d7
7
- data.tar.gz: 491ecfb05581a08609bc41aedd39600f143a15f681405400c813402be95e11ed24d4be7363ac8079da9bc022d7d5b22ad3c53e534d41ee66a10977751a44b954
6
+ metadata.gz: 8e5d6bfa84e64cd80f756e55d6fe846f10b5a820c79881b26a54c305f08fb02ef98d27990d7b51b595342844b38c11e04b6e9be911cd3306748c023d226bf9a2
7
+ data.tar.gz: 5a92a5485360f68cea7bbfa48259f0441f0df723d634926230c80b78784e6f8719083b3fef9d81537223b0da8f2b96563c8d267c4bbe9a372c592b781163fe45
@@ -140,6 +140,32 @@ en:
140
140
  A collation option is only supported if the query is executed on a MongoDB server
141
141
  with version >= 3.4."
142
142
  resolution: "Remove the collation option from the query."
143
+ in_memory_regexp_timeout:
144
+ message: "Evaluating this query in memory exceeded the %{limit} second
145
+ limit that applies to conditions containing a regular expression."
146
+ summary: "Mongoid evaluates some conditions in the calling thread
147
+ rather than sending them to the server: queries against an embedded
148
+ association, removing documents from an association by condition,
149
+ and Document#_matches?. A regular expression in such a condition
150
+ runs locally. Mongoid limits the total time one in-memory
151
+ evaluation may spend matching, because both the cost of a single
152
+ match and the number of matches performed grow with the condition,
153
+ and a pattern built from end-user input can be made to consume an
154
+ unbounded amount of CPU. The limit is cumulative over the whole
155
+ evaluation rather than per match. Where the Ruby in use offers no
156
+ per-Regexp timeout the limit is enforced as wall clock over the
157
+ whole in-memory evaluation, so there it can be exceeded by a large
158
+ scan even when the pattern itself is cheap."
159
+ resolution: "Check whether the pattern in this query comes from user
160
+ input; if it does, validate it before querying. If the query is
161
+ legitimate and simply large, raise
162
+ Mongoid::Config.in_memory_regexp_time_limit, or set it to nil to
163
+ remove the limit entirely. The limit in force is the smaller of
164
+ that setting and any global Regexp.timeout the application has set,
165
+ so check both. On JRuby, and on MRI before 3.2, there is no
166
+ per-Regexp timeout, so what the limit bounds there is the elapsed
167
+ time of the whole in-memory evaluation rather than the time spent
168
+ matching."
143
169
  invalid_around_callback:
144
170
  message: "An around callback must contain a yield in its definition."
145
171
  summary: "The block needs to be yielded to for around callbacks to function as intended."
@@ -213,6 +239,13 @@ en:
213
239
  Mongoid does not support."
214
240
  resolution: "Valid field operators are: %{valid_operators}.
215
241
  Ensure you are using one of these operators."
242
+ invalid_encryption_schema:
243
+ message: "The encryption schema for %{namespace} is invalid."
244
+ summary: "Two models writing to %{namespace} describe %{path}
245
+ differently. The encryption schema has one entry per namespace,
246
+ so the two descriptions cannot both be used."
247
+ resolution: "Make the models agree on the encryption options of the
248
+ conflicting field, or store them in different collections."
216
249
  invalid_estimated_count_criteria:
217
250
  message: "Invalid criteria for estimated_count."
218
251
  summary: "Estimated count is strictly a collection-level operation and cannot be called
@@ -515,6 +548,38 @@ en:
515
548
  environment, Mongoid cannot load its configuration."
516
549
  resolution: "Please ensure an environment is set in one of the
517
550
  listed locations. The environment must be explicitly set."
551
+ no_encryption_schema:
552
+ message: "The encryption schema of client %{client} does not cover
553
+ %{namespace}."
554
+ summary: "%{klass} declares encrypted fields, directly or through
555
+ an embedded model, but the namespace it resolved to, %{namespace},
556
+ is not part of the automatic encryption schema of client
557
+ %{client}. The encryption schema is built once, when the client
558
+ is created, and is keyed by namespace, so a database name that is
559
+ only known later - a callable :database option,
560
+ Model.with(database:), Mongoid.override_database - is not in it.
561
+ The same happens when an embedded model with encrypted fields was
562
+ not yet loaded when the client was created. Routing the model to a
563
+ client that has no auto_encryption_options has the same effect.
564
+ Mongoid refuses the operation because the driver would store the
565
+ declared fields in plaintext without reporting an error."
566
+ resolution: "Give the model a namespace that is known when the
567
+ client is created, and a client configured with
568
+ auto_encryption_options. Load all models, including embedded
569
+ ones, before the first client is created, for example by
570
+ preloading them at boot. If the model has to live in a database
571
+ chosen at runtime, configure one client per database, each with
572
+ its own auto_encryption_options, and select between them with
573
+ Model.with(client:) instead of switching the database."
574
+ no_encryption_schema_field:
575
+ message: "The encryption schema of client %{client} does not cover
576
+ the field %{field} of %{namespace}."
577
+ summary: "The namespace %{namespace} is in the automatic encryption
578
+ schema of client %{client}, but its entry does not encrypt
579
+ %{field}, which %{klass} declares as encrypted. Mongoid refuses
580
+ the operation rather than storing the field in plaintext."
581
+ resolution: "Regenerate the schema map of the client, or add
582
+ %{field} to the schema entry for %{namespace}."
518
583
  no_map_reduce_output:
519
584
  message: "No output location was specified for the map/reduce
520
585
  operation."
@@ -12,11 +12,15 @@ module Mongoid
12
12
  included do
13
13
  class_attribute :dependents
14
14
 
15
+ # Note the leading underscore: without it, ActiveSupport's
16
+ # class_attribute would generate a helper method for this attribute
17
+ # that collides with one it generates for :dependents.
18
+ #
15
19
  # @api private
16
- class_attribute :dependents_owner
20
+ class_attribute :_dependents_owner
17
21
 
18
22
  self.dependents = []
19
- self.dependents_owner = self
23
+ self._dependents_owner = self
20
24
  end
21
25
 
22
26
  class_methods do
@@ -57,9 +61,9 @@ module Mongoid
57
61
  def self.define_dependency!(association)
58
62
  validate!(association)
59
63
  association.inverse_class.tap do |klass|
60
- if klass.dependents_owner != klass
64
+ if klass._dependents_owner != klass
61
65
  klass.dependents = []
62
- klass.dependents_owner = klass
66
+ klass._dependents_owner = klass
63
67
  end
64
68
 
65
69
  if association.dependent && !klass.dependents.include?(association)
@@ -138,6 +138,14 @@ module Mongoid
138
138
  _base.collect_children.each do |child|
139
139
  child.delayed_atomic_sets.clear
140
140
  end
141
+
142
+ # If the base document is itself inserted wholesale via a $set of
143
+ # its atomic path, that $set already contains these documents, and
144
+ # a nested $set under the same path would conflict with it. The
145
+ # cleanup above has already cleared the delayed sets the assigned
146
+ # children recorded, so skipping the recording here is safe.
147
+ return if _base.settable?
148
+
141
149
  _base.delayed_atomic_sets[path] = sets
142
150
  end
143
151
  end
@@ -174,7 +174,7 @@ module Mongoid
174
174
  # @param [ Hash ] attrs The single document attributes to process.
175
175
  def update_nested_relation(parent, id, attrs)
176
176
  first = existing.first
177
- converted = first ? convert_id(first.class, id) : id
177
+ converted = convert_id(first ? first.class : association.klass, id)
178
178
 
179
179
  if existing.where(_id: converted).exists?
180
180
  # document exists in association
@@ -186,19 +186,48 @@ module Mongoid
186
186
  end
187
187
  elsif association.embedded?
188
188
  raise Errors::DocumentNotFound.new(association.klass, id)
189
- elsif association.is_a?(Association::Referenced::HasAndBelongsToMany) || Mongoid.allow_reparenting_via_nested_attributes?
190
- Mongoid::Warnings.warn_reparenting_via_nested_attributes if Mongoid.allow_reparenting_via_nested_attributes?
189
+ elsif destroyable?(attrs)
190
+ # A destroy of a document that is not in the association is
191
+ # ignored, rather than reaching for it outside the association.
192
+ nil
193
+ elsif Mongoid.allow_reparenting_via_nested_attributes?
194
+ Mongoid::Warnings.warn_reparenting_via_nested_attributes
191
195
 
192
196
  # push existing document to association
193
197
  doc = association.klass.unscoped.find(converted)
198
+ # find returns nil instead of raising when
199
+ # Mongoid.raise_not_found_error is false; a missing document
200
+ # must still be an error here, consistent with the other
201
+ # not-found branches.
202
+ raise Errors::DocumentNotFound.new(association.klass, id) if doc.nil?
203
+
194
204
  update_document(doc, attrs)
195
- existing.push(doc) unless destroyable?(attrs)
205
+ existing.push(doc)
196
206
  else
197
- raise Errors::DocumentNotFound.new(association.klass, { _id: id, association.foreign_key => parent.id })
207
+ raise Errors::DocumentNotFound.new(association.klass, not_found_params(parent, id))
198
208
  end
199
209
 
200
210
  parent.children_may_have_changed!
201
211
  end
212
+
213
+ # The params to report when an id in the nested attributes could not
214
+ # be resolved within the association.
215
+ #
216
+ # @api private
217
+ #
218
+ # @param [ Document ] parent The parent document.
219
+ # @param [ String | BSON::ObjectId ] id of the related document.
220
+ #
221
+ # @return [ Hash | Object ] The params for the not found error.
222
+ def not_found_params(parent, id)
223
+ if association.is_a?(Association::Referenced::HasAndBelongsToMany)
224
+ # A many-to-many association has no foreign key on the child, so
225
+ # the id is only ever resolved within the association itself.
226
+ id
227
+ else
228
+ { _id: id, association.foreign_key => parent.id }
229
+ end
230
+ end
202
231
  end
203
232
  end
204
233
  end
@@ -58,12 +58,26 @@ module Mongoid
58
58
  # @example Convert the id.
59
59
  # builder.convert_id(Person, "4d371b444835d98b8b000010")
60
60
  #
61
+ # Ids arriving from a form are always scalars. A Hash or an Array
62
+ # here means the parameters were crafted, and letting one through
63
+ # would turn the id into a query operator, so they are rejected.
64
+ #
61
65
  # @param [ Class ] klass The class we're trying to convert for.
62
66
  # @param [ String ] id The id, usually coming from the form.
63
67
  #
64
68
  # @return [ BSON::ObjectId | String | Object ] The converted id.
69
+ #
70
+ # @raise [ Errors::DocumentNotFound ] if the id is not a scalar, or
71
+ # cannot be converted to the type the class uses for its ids.
72
+ # The BSON::Error rescue is defensive: a value that reaches
73
+ # BSON::ObjectId.mongoize and raises there must not surface as an
74
+ # unhandled BSON error in the caller.
65
75
  def convert_id(klass, id)
76
+ raise Errors::DocumentNotFound.new(klass, id) if id.is_a?(::Hash) || id.is_a?(::Array)
77
+
66
78
  klass.using_object_ids? ? BSON::ObjectId.mongoize(id) : id
79
+ rescue BSON::Error
80
+ raise Errors::DocumentNotFound.new(klass, id)
67
81
  end
68
82
 
69
83
  private
@@ -534,12 +534,111 @@ module Mongoid
534
534
  # @return [ Integer ] The number of documents deleted.
535
535
  def remove_all(conditions = nil, method = :delete_all)
536
536
  selector = conditions || {}
537
- removed = klass.send(method, selector.merge!(criteria.selector))
537
+ selector.merge!(criteria.selector)
538
+
539
+ # Scanning before the delete means scanning only what is already in
540
+ # memory, so it is only done where there is a pattern whose cost has
541
+ # to be bounded. Everywhere else the delete comes first and the scan
542
+ # sees the survivors, which is what this association has always
543
+ # done.
544
+ #
545
+ # The limit is read once and carried into whichever branch is taken.
546
+ # Letting the branch below ask again would read it a second time,
547
+ # across a server round trip, and a limit that had become positive
548
+ # in the meantime would put a deadline on the delete_if -- a query,
549
+ # and an unbind of every match -- which is the arrangement this
550
+ # branch exists to avoid.
551
+ limit = Matcher::RegexpBudget.limit_for(selector)
552
+ return remove_all_bounded(selector, method, limit) if limit
553
+
554
+ removed = klass.send(method, selector)
555
+
556
+ # No scope around this. The scan runs after the delete, so the
557
+ # association this loads comes back holding only what the delete did
558
+ # not match, and there is no pattern here to bound in any case.
559
+ # Opening a scope would save _matches? from deciding once per
560
+ # document -- 0.48us against 1.74us for the match itself -- but a
561
+ # scope with nothing to bound suppresses that decision for
562
+ # everything nested inside it, and loading the association runs
563
+ # find callbacks. A pattern in a selector one of those evaluates
564
+ # would go unguarded, which costs more than the saving is worth on a
565
+ # path that has just made two round trips.
538
566
  _target.delete_if do |doc|
539
567
  doc._matches?(selector).tap do |b|
540
568
  unbind_one(doc) if b
541
569
  end
542
570
  end
571
+
572
+ removed
573
+ end
574
+
575
+ # Deletes all related documents matching a selector that carries a
576
+ # regular expression, with the whole scan under one regexp budget.
577
+ #
578
+ # The scan runs before anything is deleted, so a budget that runs out
579
+ # leaves both the database and the association untouched rather than
580
+ # reporting a failure for a delete that has already happened. It also
581
+ # means the budget is closed by the time the association is mutated,
582
+ # so where the budget is enforced with Timeout there is no window for
583
+ # the exception to land in the middle of an unbind.
584
+ #
585
+ # @param [ Hash ] selector The selector to delete with.
586
+ # @param [ Symbol ] method The deletion method to call.
587
+ # @param [ Float ] limit The seconds the scan may spend, as read by
588
+ # the caller when it chose this path.
589
+ #
590
+ # @return [ Integer ] The number of documents deleted.
591
+ def remove_all_bounded(selector, method, limit)
592
+ # Only what the association already holds. Iterating it instead
593
+ # would load the whole association before the delete, and for a
594
+ # broad pattern that is every document the association has: /.*/ is
595
+ # both the cheapest pattern an attacker can supply and the one that
596
+ # matches everything, so bounding the cost of the matching would
597
+ # have been paid for with an unbounded amount of memory. Nothing is
598
+ # missed by not loading, because the server evaluates the same
599
+ # selector for the delete itself.
600
+ #
601
+ # It also means the scan runs no query, so no deadline of ours can
602
+ # land on one. Where the budget is enforced with Timeout, covering
603
+ # a query would report a slow network as a regexp timeout and could
604
+ # deliver the asynchronous exception inside the driver's socket
605
+ # read.
606
+ documents = _target.in_memory
607
+
608
+ matching = Matcher::RegexpBudget.open_with(limit) do
609
+ documents.select { |doc| doc._matches?(selector) }
610
+ end
611
+
612
+ removed = klass.send(method, selector)
613
+
614
+ # The ids are already known to be in memory, so the loaded and added
615
+ # documents can be dropped directly. Enumerable#delete would look
616
+ # each one up by id and stop at the first hash that has it, which
617
+ # removes the wrong instance when both hashes hold one for the same
618
+ # id. Enumerable#delete_if drops it from both, as this does, but
619
+ # only after load_all!, which is the load this scan exists to avoid.
620
+ #
621
+ # Which documents get unbound has always depended on which ones
622
+ # happened to be in memory, and scanning only what is in memory
623
+ # keeps that. It matters for has_and_belongs_to_many, whose
624
+ # unbind_one pulls the id out of the foreign key array on _base and
625
+ # marks it dirty; the scan used to run after the delete, against an
626
+ # association that a cold proxy had just reloaded as holding only
627
+ # the survivors, so nothing matched and nothing was unbound.
628
+ #
629
+ # TODO: unbinding should not depend on what was in memory. Every
630
+ # matched document leaves the association, so every one of them
631
+ # should be unbound, which would also stop _base's foreign key
632
+ # array from keeping ids that no longer resolve. That is a
633
+ # behaviour change for has_and_belongs_to_many and belongs in a
634
+ # ticket of its own, with a release note; it should not ride along
635
+ # on this one.
636
+ matching.each do |doc|
637
+ unbind_one(doc)
638
+ _target._loaded.delete(doc._id)
639
+ _target._added.delete(doc._id)
640
+ end
641
+
543
642
  removed
544
643
  end
545
644
 
@@ -172,6 +172,23 @@ module Mongoid
172
172
  end
173
173
  alias :klass :relation_class
174
174
 
175
+ # The class of the association target, or nil when the named class is
176
+ # not defined.
177
+ #
178
+ # An association may name a class that never gets defined. The
179
+ # association is then unusable, but its owner still has to be. Callers
180
+ # that walk every association of every model, rather than following the
181
+ # one the application asked for, use this instead of relation_class.
182
+ #
183
+ # @return [ Class | nil ] The association objects' class.
184
+ #
185
+ # @api private
186
+ def try_relation_class
187
+ relation_class
188
+ rescue NameError
189
+ nil
190
+ end
191
+
175
192
  # The class name of the object owning this association.
176
193
  #
177
194
  # @return [ String ] The owning objects' class name.
@@ -91,6 +91,8 @@ module Mongoid
91
91
  return nil unless opts[:auto_encryption_options]
92
92
 
93
93
  opts[:auto_encryption_options].dup.tap do |auto_encryption_options|
94
+ Mongoid::Warnings.warn_bypass_auto_encryption if auto_encryption_options[:bypass_auto_encryption]
95
+
94
96
  if auto_encryption_options.key?(:schema_map)
95
97
  default_logger.warn(
96
98
  'The :schema_map is configured in the :auto_encryption_options for the client;' +
@@ -22,6 +22,14 @@ module Mongoid
22
22
  # @raise [ Errors::CreateCollectionFailure ] If collection creation failed.
23
23
  # @raise [ Errors::DropCollectionFailure ] If an attempt to drop collection failed.
24
24
  def create_collection(force: false)
25
+ Threaded.with_collection_management do
26
+ perform_create_collection(force: force)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def perform_create_collection(force:)
25
33
  if collection_name.empty?
26
34
  # This is most probably an anonymous class, we ignore them.
27
35
  return
@@ -21,20 +21,51 @@ module Mongoid
21
21
  #
22
22
  # @return [ Hash ] The encryption schema map.
23
23
  def encryption_schema_map(default_database, models = ::Mongoid.models)
24
- visited = Set.new
25
- models.each_with_object({}) do |model, map|
26
- next if visited.include?(model)
27
- visited << model
24
+ # Subclasses share their parent's collection but are not registered in
25
+ # Mongoid.models, so they have to be added to the walk explicitly.
26
+ classes = models.flat_map { |model| [ model ] + model.descendants }.uniq
27
+
28
+ classes.each_with_object({}) do |model, map|
28
29
  next if model.embedded?
29
- next unless model.encrypted?
30
+ next unless model.requires_encryption_schema?
30
31
 
31
32
  database = model.storage_options.fetch(:database) { default_database }
33
+ # A callable database name cannot be resolved here: the documented
34
+ # multi-tenant idiom has no correct value while the client is being
35
+ # built. Interpolating the callable would produce a key that never
36
+ # matches any namespace, so leave the model out of the map. Writes
37
+ # are refused later, by PersistenceContext, rather than silently
38
+ # going out unencrypted.
39
+ next if database.respond_to?(:call)
40
+
32
41
  key = "#{database}.#{model.collection_name}"
33
- props = metadata_for(model).merge(properties_for(model, visited))
34
- map[key] = props unless props.empty?
42
+ props = metadata_for(model).merge(properties_for(model, [ model ], key))
43
+ next if props.empty?
44
+
45
+ # The root of a collection schema describes the document, so it is
46
+ # always an object. Saying so matters when a nested schema carries
47
+ # encryptMetadata: mongocryptd rejects the schema otherwise.
48
+ entry = { 'bsonType' => 'object' }.merge(props)
49
+ map[key] = map.key?(key) ? merge_schema_entries(map[key], entry, key) : entry
35
50
  end
36
51
  end
37
52
 
53
+ # Collect the paths of the fields encrypted by the schema generated for
54
+ # a single model.
55
+ #
56
+ # @param [ Mongoid::Document ] model The model.
57
+ #
58
+ # @return [ Array<Array<String>> ] The property paths, outermost first.
59
+ #
60
+ # @api private
61
+ def encrypted_paths_for(model)
62
+ return model.instance_variable_get(:@encrypted_paths_for) if
63
+ model.instance_variable_defined?(:@encrypted_paths_for)
64
+
65
+ properties = properties_for(model, [ model ])['properties'] || {}
66
+ model.instance_variable_set(:@encrypted_paths_for, collect_encrypted_paths(properties, []))
67
+ end
68
+
38
69
  private
39
70
 
40
71
  # The algorithm to use for the deterministic encryption.
@@ -100,11 +131,14 @@ module Mongoid
100
131
  # are marked as encrypted.
101
132
  #
102
133
  # @param [ Mongoid::Document ] model The model to generate the properties for.
103
- # @param [ Set<Mongoid::Document> ] visited The set of models that have already been visited.
134
+ # @param [ Array<Mongoid::Document> ] path The models the walk is already
135
+ # inside of, outermost first.
136
+ # @param [ String | nil ] namespace The namespace the schema is built
137
+ # for, used for error reporting.
104
138
  #
105
139
  # @return [ Hash ] The encryption properties.
106
- def properties_for(model, visited)
107
- result = properties_for_fields(model).merge(properties_for_relations(model, visited))
140
+ def properties_for(model, path, namespace = nil)
141
+ result = properties_for_fields(model).merge(properties_for_relations(model, path, namespace))
108
142
  if result.empty?
109
143
  {}
110
144
  else
@@ -141,26 +175,141 @@ module Mongoid
141
175
  # are configured to be encrypted.
142
176
  #
143
177
  # @param [ Mongoid::Document ] model The model to generate the properties for.
144
- # @param [ Set<Mongoid::Document> ] visited The set of models that have already been visited.
178
+ # @param [ Array<Mongoid::Document> ] path The models the walk is already
179
+ # inside of, outermost first.
180
+ # @param [ String | nil ] namespace The namespace the schema is built
181
+ # for, used for error reporting.
145
182
  #
146
183
  # @return [ Hash ] The encryption properties.
147
- def properties_for_relations(model, visited)
184
+ def properties_for_relations(model, path, namespace = nil)
148
185
  model.relations.each_with_object({}) do |(name, relation), props|
149
- next if visited.include?(relation.relation_class)
186
+ # relation_class constantizes, and a polymorphic embedded_in has no
187
+ # class to resolve, so the relation type has to be checked first.
150
188
  next unless relation.is_a?(Association::Embedded::EmbedsOne)
151
- next unless relation.relation_class.encrypted?
152
-
153
- visited << relation.relation_class
154
- metadata_for(
155
- relation.relation_class
156
- ).merge(
157
- properties_for(relation.relation_class, visited)
158
- ).tap do |properties|
159
- props[name] = { 'bsonType' => 'object' }.merge(properties) unless properties.empty?
189
+
190
+ klass = relation.try_relation_class
191
+ # An association target does not have to be a Mongoid document, and
192
+ # the class it names does not have to exist.
193
+ next unless klass.respond_to?(:requires_encryption_schema?)
194
+
195
+ # Any subclass of the declared class can be assigned to the
196
+ # association, so its encrypted fields have to be mapped here too.
197
+ properties = ([ klass ] + klass.descendants).uniq.inject({}) do |merged, candidate|
198
+ # Stop at a model the walk is already inside of, or a self-embedding
199
+ # model never terminates. The path covers the current branch only:
200
+ # a model embedded by two parents, or twice by one parent, has to be
201
+ # emitted at every place it appears.
202
+ next merged if path.include?(candidate)
203
+ next merged unless candidate.respond_to?(:requires_encryption_schema?)
204
+ next merged unless candidate.requires_encryption_schema?
205
+
206
+ merge_schema_entries(
207
+ merged,
208
+ metadata_for(candidate).merge(
209
+ properties_for(candidate, path + [ candidate ], namespace)
210
+ ),
211
+ namespace
212
+ )
160
213
  end
214
+ props[name] = { 'bsonType' => 'object' }.merge(properties) unless properties.empty?
161
215
  end
162
216
  end
163
217
 
218
+ # Walk the schema properties and collect the path of every encrypted
219
+ # leaf.
220
+ #
221
+ # @param [ Hash ] properties The properties to walk.
222
+ # @param [ Array<String> ] path The properties the walk is already
223
+ # inside of.
224
+ #
225
+ # @return [ Array<Array<String>> ] The property paths.
226
+ def collect_encrypted_paths(properties, path)
227
+ properties.each_with_object([]) do |(name, schema), paths|
228
+ if schema.key?('encrypt')
229
+ paths << (path + [ name ])
230
+ elsif schema['properties']
231
+ paths.concat(collect_encrypted_paths(schema['properties'], path + [ name ]))
232
+ end
233
+ end
234
+ end
235
+
236
+ # Merge two schema objects covering the same model or namespace.
237
+ #
238
+ # Every model writing to one namespace contributes an entry to the
239
+ # schema map. Those entries have to agree on everything they have in
240
+ # common, or the schema would silently encrypt a field differently for
241
+ # one model than for another.
242
+ #
243
+ # @param [ Hash ] left The first schema.
244
+ # @param [ Hash ] right The second schema.
245
+ # @param [ String | nil ] namespace The namespace the schema belongs to.
246
+ #
247
+ # @return [ Hash ] The merged schema.
248
+ def merge_schema_entries(left, right, namespace = nil)
249
+ left.merge(right) do |key, left_value, right_value|
250
+ case key
251
+ when 'bsonType'
252
+ left_value
253
+ when 'encryptMetadata'
254
+ merge_encrypt_metadata(left_value, right_value, namespace)
255
+ when 'properties'
256
+ merge_properties(left_value, right_value, namespace)
257
+ else
258
+ raise_invalid_encryption_schema(namespace, key)
259
+ end
260
+ end
261
+ end
262
+
263
+ # Merge two encryptMetadata objects.
264
+ #
265
+ # @param [ Hash ] left The first encryptMetadata object.
266
+ # @param [ Hash ] right The second encryptMetadata object.
267
+ # @param [ String | nil ] namespace The namespace the schema belongs to.
268
+ #
269
+ # @return [ Hash ] The merged encryptMetadata object.
270
+ def merge_encrypt_metadata(left, right, namespace)
271
+ left.merge(right) do |key, left_value, right_value|
272
+ unless left_value == right_value
273
+ raise_invalid_encryption_schema(namespace, "encryptMetadata.#{key}")
274
+ end
275
+
276
+ left_value
277
+ end
278
+ end
279
+
280
+ # Merge two properties objects.
281
+ #
282
+ # @param [ Hash ] left The first properties object.
283
+ # @param [ Hash ] right The second properties object.
284
+ # @param [ String | nil ] namespace The namespace the schema belongs to.
285
+ #
286
+ # @return [ Hash ] The merged properties object.
287
+ def merge_properties(left, right, namespace)
288
+ left.merge(right) do |name, left_value, right_value|
289
+ if left_value.key?('encrypt') && right_value.key?('encrypt')
290
+ unless left_value['encrypt'] == right_value['encrypt']
291
+ raise_invalid_encryption_schema(namespace, "properties.#{name}")
292
+ end
293
+
294
+ left_value
295
+ elsif left_value.key?('encrypt') || right_value.key?('encrypt')
296
+ raise_invalid_encryption_schema(namespace, "properties.#{name}")
297
+ else
298
+ merge_schema_entries(left_value, right_value, namespace)
299
+ end
300
+ end
301
+ end
302
+
303
+ # Raise an error for a schema conflicting with another one.
304
+ #
305
+ # @param [ String | nil ] namespace The namespace the schema belongs to.
306
+ # @param [ String ] path The conflicting path in the schema.
307
+ #
308
+ # @raise [ Errors::InvalidEncryptionSchema ] Always raised.
309
+ def raise_invalid_encryption_schema(namespace, path)
310
+ raise Errors::InvalidEncryptionSchema.new(namespace, path)
311
+ end
312
+
164
313
  # Get the BSON type identifier for the provided field according to the
165
314
  # https://www.mongodb.com/docs/manual/reference/bson-types/#std-label-bson-types
166
315
  #