mongoid 8.1.11 → 8.1.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +26 -0
  3. data/lib/mongoid/association/depending.rb +8 -4
  4. data/lib/mongoid/association/embedded/batchable.rb +11 -10
  5. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +61 -0
  6. data/lib/mongoid/association/nested/many.rb +38 -3
  7. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  8. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  9. data/lib/mongoid/clients/factory.rb +4 -0
  10. data/lib/mongoid/config.rb +62 -0
  11. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  12. data/lib/mongoid/contextual/memory.rb +18 -7
  13. data/lib/mongoid/criteria/queryable/mergeable.rb +12 -0
  14. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  15. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  16. data/lib/mongoid/errors.rb +1 -0
  17. data/lib/mongoid/extensions/hash.rb +27 -1
  18. data/lib/mongoid/field_readable.rb +70 -0
  19. data/lib/mongoid/matchable.rb +6 -1
  20. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +3 -5
  21. data/lib/mongoid/matcher/regex.rb +8 -9
  22. data/lib/mongoid/matcher/regexp_budget.rb +389 -0
  23. data/lib/mongoid/matcher.rb +1 -0
  24. data/lib/mongoid/threaded.rb +3 -0
  25. data/lib/mongoid/touchable.rb +1 -1
  26. data/lib/mongoid/version.rb +1 -1
  27. data/lib/mongoid/warnings.rb +1 -0
  28. data/spec/integration/app_spec.rb +8 -0
  29. data/spec/integration/associations/embeds_many_spec.rb +41 -0
  30. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  31. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  32. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  33. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  34. data/spec/integration/query_operator_guard_spec.rb +89 -0
  35. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  36. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  37. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  38. data/spec/mongoid/clients/factory_spec.rb +30 -0
  39. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  40. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  41. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  42. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +235 -0
  43. data/spec/mongoid/criteria_spec.rb +2 -0
  44. data/spec/mongoid/extensions/hash_spec.rb +236 -0
  45. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  46. data/spec/mongoid/touchable_spec.rb +12 -0
  47. data/spec/shared/CANDIDATE.md +28 -0
  48. data/spec/shared/lib/mrss/spec_organizer.rb +32 -3
  49. data/spec/shared/shlib/server.sh +1 -1
  50. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21db50efa946dce1a9e43718d0e15d37c0e4373c9d693123d21cfe13b8afb3bf
4
- data.tar.gz: 7d01ee5d5a8eb3c81a92c3055e94bf8bb90a8a911258401459b5e447c45a220c
3
+ metadata.gz: 519052cca069b6c2b2c3aa7cf6fc561b275b8df20824d3b587665ff64e9823d7
4
+ data.tar.gz: 130d16306e79e262499c1faa2320c3adb0dabb5874b1d3053a950a3026324352
5
5
  SHA512:
6
- metadata.gz: f1e10a0cb03863509acb1d150460e4f7c475092b45bb65f7619ecd8f0384d4ed6e92416565a07e6e900af26fbf26b32c4cd2c9418c4dfbe1f858da451a3e9378
7
- data.tar.gz: e2e75603f8ba8f3debead96161f81350ced4696aac125ba0b17f48a94a42cc48e867651deb1b6a60c3d57f48fa237f26ac61775f3c2be3750f62fc2b66d27661
6
+ metadata.gz: 37f2ee4e9b4f45e56c4c2552ab522b0f716548bc114d033a17eab6a84b451c4a9dc2d6c3b1e255b3e44fbbc9800ccfdb4aef5f7f25c4c4839650fd1e16c2c1cd
7
+ data.tar.gz: 57f22359f24b877b4d58ace31759e8e0958dd149a0973d1e47359196e3aa702034582991068df5158b1930c5cb773ac88ec5da45afc44bd85c329461b359fb1b
@@ -129,6 +129,32 @@ en:
129
129
  A collation option is only supported if the query is executed on a MongoDB server
130
130
  with version >= 3.4."
131
131
  resolution: "Remove the collation option from the query."
132
+ in_memory_regexp_timeout:
133
+ message: "Evaluating this query in memory exceeded the %{limit} second
134
+ limit that applies to conditions containing a regular expression."
135
+ summary: "Mongoid evaluates some conditions in the calling thread
136
+ rather than sending them to the server: queries against an embedded
137
+ association, removing documents from an association by condition,
138
+ and Document#_matches?. A regular expression in such a condition
139
+ runs locally. Mongoid limits the total time one in-memory
140
+ evaluation may spend matching, because both the cost of a single
141
+ match and the number of matches performed grow with the condition,
142
+ and a pattern built from end-user input can be made to consume an
143
+ unbounded amount of CPU. The limit is cumulative over the whole
144
+ evaluation rather than per match. Where the Ruby in use offers no
145
+ per-Regexp timeout the limit is enforced as wall clock over the
146
+ whole in-memory evaluation, so there it can be exceeded by a large
147
+ scan even when the pattern itself is cheap."
148
+ resolution: "Check whether the pattern in this query comes from user
149
+ input; if it does, validate it before querying. If the query is
150
+ legitimate and simply large, raise
151
+ Mongoid::Config.in_memory_regexp_time_limit, or set it to nil to
152
+ remove the limit entirely. The limit in force is the smaller of
153
+ that setting and any global Regexp.timeout the application has set,
154
+ so check both. On JRuby, and on MRI before 3.2, there is no
155
+ per-Regexp timeout, so what the limit bounds there is the elapsed
156
+ time of the whole in-memory evaluation rather than the time spent
157
+ matching."
132
158
  invalid_async_query_executor:
133
159
  message: "Invalid async_query_executor option: %{executor}."
134
160
  summary: "A invalid async query executor was specified.
@@ -11,11 +11,15 @@ module Mongoid
11
11
  included do
12
12
  class_attribute :dependents
13
13
 
14
+ # Note the leading underscore: without it, ActiveSupport's
15
+ # class_attribute would generate a helper method for this attribute
16
+ # that collides with one it generates for :dependents.
17
+ #
14
18
  # @api private
15
- class_attribute :dependents_owner
19
+ class_attribute :_dependents_owner
16
20
 
17
21
  self.dependents = []
18
- self.dependents_owner = self
22
+ self._dependents_owner = self
19
23
  end
20
24
 
21
25
  class_methods do
@@ -50,9 +54,9 @@ module Mongoid
50
54
  def self.define_dependency!(association)
51
55
  validate!(association)
52
56
  association.inverse_class.tap do |klass|
53
- if klass.dependents_owner != klass
57
+ if klass._dependents_owner != klass
54
58
  klass.dependents = []
55
- klass.dependents_owner = klass
59
+ klass._dependents_owner = klass
56
60
  end
57
61
 
58
62
  if association.dependent && !klass.dependents.include?(association)
@@ -314,18 +314,19 @@ module Mongoid
314
314
  #
315
315
  # @return [ Array<Hash> ] The documents as an array of hashes.
316
316
  def pre_process_batch_insert(docs)
317
- docs.map do |doc|
318
- next unless doc
319
- append(doc)
320
- if persistable? && !_assigning?
321
- self.path = doc.atomic_path unless path
322
- if doc.valid?(:create)
323
- doc.run_before_callbacks(:save, :create)
324
- else
325
- self.inserts_valid = false
317
+ [].tap do |results|
318
+ append_many(docs) do |doc|
319
+ if persistable? && !_assigning?
320
+ self.path = doc.atomic_path unless path
321
+ if doc.valid?(:create)
322
+ doc.run_before_callbacks(:save, :create)
323
+ else
324
+ self.inserts_valid = false
325
+ end
326
326
  end
327
+
328
+ results << doc.send(:as_attributes)
327
329
  end
328
- doc.send(:as_attributes)
329
330
  end
330
331
  end
331
332
 
@@ -411,6 +411,67 @@ module Mongoid
411
411
  execute_callback :after_add, document
412
412
  end
413
413
 
414
+ # Returns a unique id for the document, which is either
415
+ # its _id or its object_id.
416
+ def id_of(doc)
417
+ doc._id || doc.object_id
418
+ end
419
+
420
+ # Optimized version of #append that handles multiple documents
421
+ # in a more efficient way.
422
+ #
423
+ # @param [ Array<Document> ] documents The documents to append.
424
+ #
425
+ # @return [ EmbedsMany::Proxy ] This proxy instance.
426
+ def append_many(documents, &block)
427
+ unique_set = process_incoming_docs(documents, &block)
428
+
429
+ _unscoped.concat(unique_set)
430
+ _target.push(*scope(unique_set))
431
+ update_attributes_hash
432
+
433
+ unique_set.each { |doc| execute_callback :after_add, doc }
434
+
435
+ self
436
+ end
437
+
438
+ # Processes the list of documents, building a list of those
439
+ # that are not already in the association, and preparing
440
+ # each unique document to be integrated into the association.
441
+ #
442
+ # The :before_add callback is executed for each unique document
443
+ # as part of this step.
444
+ #
445
+ # @param [ Array<Document> ] documents The incoming documents to
446
+ # process.
447
+ #
448
+ # @yield [ Document ] Optional block to call for each unique
449
+ # document.
450
+ #
451
+ # @return [ Array<Document> ] The list of unique documents that
452
+ # do not yet exist in the association.
453
+ def process_incoming_docs(documents, &block)
454
+ visited_docs = Set.new(_target.map { |doc| id_of(doc) })
455
+ next_index = _unscoped.size
456
+
457
+ documents.select do |doc|
458
+ next unless doc
459
+
460
+ id = id_of(doc)
461
+ next if visited_docs.include?(id)
462
+
463
+ execute_callback :before_add, doc
464
+
465
+ visited_docs.add(id)
466
+ integrate(doc)
467
+
468
+ doc._index = next_index
469
+ next_index += 1
470
+
471
+ block&.call(doc) || true
472
+ end
473
+ end
474
+
414
475
  # Instantiate the binding associated with this association.
415
476
  #
416
477
  # @example Create the binding.
@@ -169,7 +169,7 @@ module Mongoid
169
169
  # @param [ Hash ] attrs The single document attributes to process.
170
170
  def update_nested_relation(parent, id, attrs)
171
171
  first = existing.first
172
- converted = first ? convert_id(first.class, id) : id
172
+ converted = convert_id(first ? first.class : association.klass, id)
173
173
 
174
174
  if existing.where(_id: converted).exists?
175
175
  # document exists in association
@@ -179,11 +179,46 @@ module Mongoid
179
179
  else
180
180
  update_document(doc, attrs)
181
181
  end
182
- else
182
+ elsif association.embedded?
183
+ raise Errors::DocumentNotFound.new(association.klass, id)
184
+ elsif destroyable?(attrs)
185
+ # A destroy of a document that is not in the association is
186
+ # ignored, rather than reaching for it outside the association.
187
+ nil
188
+ elsif Mongoid.allow_reparenting_via_nested_attributes?
189
+ Mongoid::Warnings.warn_reparenting_via_nested_attributes
190
+
183
191
  # push existing document to association
184
192
  doc = association.klass.unscoped.find(converted)
193
+ # find returns nil instead of raising when
194
+ # Mongoid.raise_not_found_error is false; a missing document
195
+ # must still be an error here, consistent with the other
196
+ # not-found branches.
197
+ raise Errors::DocumentNotFound.new(association.klass, id) if doc.nil?
198
+
185
199
  update_document(doc, attrs)
186
- existing.push(doc) unless destroyable?(attrs)
200
+ existing.push(doc)
201
+ else
202
+ raise Errors::DocumentNotFound.new(association.klass, not_found_params(parent, id))
203
+ end
204
+ end
205
+
206
+ # The params to report when an id in the nested attributes could not
207
+ # be resolved within the association.
208
+ #
209
+ # @api private
210
+ #
211
+ # @param [ Document ] parent The parent document.
212
+ # @param [ String | BSON::ObjectId ] id of the related document.
213
+ #
214
+ # @return [ Hash | Object ] The params for the not found error.
215
+ def not_found_params(parent, id)
216
+ if association.is_a?(Association::Referenced::HasAndBelongsToMany)
217
+ # A many-to-many association has no foreign key on the child, so
218
+ # the id is only ever resolved within the association itself.
219
+ id
220
+ else
221
+ { _id: id, association.foreign_key => parent.id }
187
222
  end
188
223
  end
189
224
  end
@@ -53,12 +53,26 @@ module Mongoid
53
53
  # @example Convert the id.
54
54
  # builder.convert_id(Person, "4d371b444835d98b8b000010")
55
55
  #
56
+ # Ids arriving from a form are always scalars. A Hash or an Array
57
+ # here means the parameters were crafted, and letting one through
58
+ # would turn the id into a query operator, so they are rejected.
59
+ #
56
60
  # @param [ Class ] klass The class we're trying to convert for.
57
61
  # @param [ String ] id The id, usually coming from the form.
58
62
  #
59
63
  # @return [ BSON::ObjectId | String | Object ] The converted id.
64
+ #
65
+ # @raise [ Errors::DocumentNotFound ] if the id is not a scalar, or
66
+ # cannot be converted to the type the class uses for its ids.
67
+ # The BSON::Error rescue is defensive: a value that reaches
68
+ # BSON::ObjectId.mongoize and raises there must not surface as an
69
+ # unhandled BSON error in the caller.
60
70
  def convert_id(klass, id)
71
+ raise Errors::DocumentNotFound.new(klass, id) if id.is_a?(::Hash) || id.is_a?(::Array)
72
+
61
73
  klass.using_object_ids? ? BSON::ObjectId.mongoize(id) : id
74
+ rescue BSON::Error
75
+ raise Errors::DocumentNotFound.new(klass, id)
62
76
  end
63
77
  end
64
78
  end
@@ -486,12 +486,111 @@ module Mongoid
486
486
  # @return [ Integer ] The number of documents deleted.
487
487
  def remove_all(conditions = nil, method = :delete_all)
488
488
  selector = conditions || {}
489
- removed = klass.send(method, selector.merge!(criteria.selector))
489
+ selector.merge!(criteria.selector)
490
+
491
+ # Scanning before the delete means scanning only what is already in
492
+ # memory, so it is only done where there is a pattern whose cost has
493
+ # to be bounded. Everywhere else the delete comes first and the scan
494
+ # sees the survivors, which is what this association has always
495
+ # done.
496
+ #
497
+ # The limit is read once and carried into whichever branch is taken.
498
+ # Letting the branch below ask again would read it a second time,
499
+ # across a server round trip, and a limit that had become positive
500
+ # in the meantime would put a deadline on the delete_if -- a query,
501
+ # and an unbind of every match -- which is the arrangement this
502
+ # branch exists to avoid.
503
+ limit = Matcher::RegexpBudget.limit_for(selector)
504
+ return remove_all_bounded(selector, method, limit) if limit
505
+
506
+ removed = klass.send(method, selector)
507
+
508
+ # No scope around this. The scan runs after the delete, so the
509
+ # association this loads comes back holding only what the delete did
510
+ # not match, and there is no pattern here to bound in any case.
511
+ # Opening a scope would save _matches? from deciding once per
512
+ # document -- 0.48us against 1.74us for the match itself -- but a
513
+ # scope with nothing to bound suppresses that decision for
514
+ # everything nested inside it, and loading the association runs
515
+ # find callbacks. A pattern in a selector one of those evaluates
516
+ # would go unguarded, which costs more than the saving is worth on a
517
+ # path that has just made two round trips.
490
518
  _target.delete_if do |doc|
491
519
  doc._matches?(selector).tap do |b|
492
520
  unbind_one(doc) if b
493
521
  end
494
522
  end
523
+
524
+ removed
525
+ end
526
+
527
+ # Deletes all related documents matching a selector that carries a
528
+ # regular expression, with the whole scan under one regexp budget.
529
+ #
530
+ # The scan runs before anything is deleted, so a budget that runs out
531
+ # leaves both the database and the association untouched rather than
532
+ # reporting a failure for a delete that has already happened. It also
533
+ # means the budget is closed by the time the association is mutated,
534
+ # so where the budget is enforced with Timeout there is no window for
535
+ # the exception to land in the middle of an unbind.
536
+ #
537
+ # @param [ Hash ] selector The selector to delete with.
538
+ # @param [ Symbol ] method The deletion method to call.
539
+ # @param [ Float ] limit The seconds the scan may spend, as read by
540
+ # the caller when it chose this path.
541
+ #
542
+ # @return [ Integer ] The number of documents deleted.
543
+ def remove_all_bounded(selector, method, limit)
544
+ # Only what the association already holds. Iterating it instead
545
+ # would load the whole association before the delete, and for a
546
+ # broad pattern that is every document the association has: /.*/ is
547
+ # both the cheapest pattern an attacker can supply and the one that
548
+ # matches everything, so bounding the cost of the matching would
549
+ # have been paid for with an unbounded amount of memory. Nothing is
550
+ # missed by not loading, because the server evaluates the same
551
+ # selector for the delete itself.
552
+ #
553
+ # It also means the scan runs no query, so no deadline of ours can
554
+ # land on one. Where the budget is enforced with Timeout, covering
555
+ # a query would report a slow network as a regexp timeout and could
556
+ # deliver the asynchronous exception inside the driver's socket
557
+ # read.
558
+ documents = _target.in_memory
559
+
560
+ matching = Matcher::RegexpBudget.open_with(limit) do
561
+ documents.select { |doc| doc._matches?(selector) }
562
+ end
563
+
564
+ removed = klass.send(method, selector)
565
+
566
+ # The ids are already known to be in memory, so the loaded and added
567
+ # documents can be dropped directly. Enumerable#delete would look
568
+ # each one up by id and stop at the first hash that has it, which
569
+ # removes the wrong instance when both hashes hold one for the same
570
+ # id. Enumerable#delete_if drops it from both, as this does, but
571
+ # only after load_all!, which is the load this scan exists to avoid.
572
+ #
573
+ # Which documents get unbound has always depended on which ones
574
+ # happened to be in memory, and scanning only what is in memory
575
+ # keeps that. It matters for has_and_belongs_to_many, whose
576
+ # unbind_one pulls the id out of the foreign key array on _base and
577
+ # marks it dirty; the scan used to run after the delete, against an
578
+ # association that a cold proxy had just reloaded as holding only
579
+ # the survivors, so nothing matched and nothing was unbound.
580
+ #
581
+ # TODO: unbinding should not depend on what was in memory. Every
582
+ # matched document leaves the association, so every one of them
583
+ # should be unbound, which would also stop _base's foreign key
584
+ # array from keeping ids that no longer resolve. That is a
585
+ # behaviour change for has_and_belongs_to_many and belongs in a
586
+ # ticket of its own, with a release note; it should not ride along
587
+ # on this one.
588
+ matching.each do |doc|
589
+ unbind_one(doc)
590
+ _target._loaded.delete(doc._id)
591
+ _target._added.delete(doc._id)
592
+ end
593
+
495
594
  removed
496
595
  end
497
596
 
@@ -95,6 +95,10 @@ module Mongoid
95
95
  [MONGOID_WRAPPING_LIBRARY] + options[:wrapping_libraries]
96
96
  else
97
97
  [MONGOID_WRAPPING_LIBRARY]
98
+ end.tap do |wrap|
99
+ if defined?(::Rails) && ::Rails.respond_to?(:version)
100
+ wrap << { name: 'Rails', version: ::Rails.version }
101
+ end
98
102
  end
99
103
  options[:wrapping_libraries] = wrap_lib
100
104
  end
@@ -158,6 +158,23 @@ module Mongoid
158
158
  # to `:global_thread_pool`.
159
159
  option :global_executor_concurrency, default: nil
160
160
 
161
+ # When this flag is true, it will be possible to add a record to a
162
+ # "has_many" or "has_and_belongs_to_many" association by passing that
163
+ # record's id in the nested attributes for another parent record, even
164
+ # when the record does not already belong to that association. For a
165
+ # "has_many" association this moves the record to the new parent.
166
+ #
167
+ # When this flag is false, an id in nested attributes is only resolved
168
+ # within the association itself, and anything else raises an error.
169
+ #
170
+ # The default is `false`. Note that allowing reparenting via nested attributes
171
+ # is a potential security risk, since it could allow a malicious user to move
172
+ # records that they do not own to a parent record that they do own.
173
+ #
174
+ # This option will be removed in Mongoid 10, and the only behavior will be
175
+ # as if this option were set to false.
176
+ option :allow_reparenting_via_nested_attributes, default: false
177
+
161
178
  # When this flag is false, a document will become read-only only once the
162
179
  # #readonly! method is called, and an error will be raised on attempting
163
180
  # to save or update such documents, instead of just on delete. When this
@@ -198,6 +215,51 @@ module Mongoid
198
215
  # See https://jira.mongodb.org/browse/MONGOID-5658 for more details.
199
216
  option :around_callbacks_for_embeds, default: true
200
217
 
218
+ # The maximum number of seconds that evaluating a single query in memory
219
+ # may spend executing regular expressions. Queries against an embedded
220
+ # association are evaluated in the calling thread, so a pattern built from
221
+ # user input runs locally and can otherwise consume unbounded CPU. The
222
+ # limit is cumulative over the whole query, since cost grows with the
223
+ # number of documents and conditions as well as with the pattern.
224
+ #
225
+ # Set to nil to remove the limit. On Ruby 3.2 and later the remaining
226
+ # budget is compiled into the pattern, so the limit counts only the time
227
+ # spent matching. Earlier Rubies have no per-Regexp timeout, so the query
228
+ # is bounded with Timeout instead and the limit is wall clock over the
229
+ # whole in-memory evaluation.
230
+ #
231
+ # See https://jira.mongodb.org/browse/MONGOID-5981 for details.
232
+ option :in_memory_regexp_time_limit, default: 5.0
233
+
234
+ # When false (default), query operators are restricted when building a
235
+ # selector, so that user-supplied input reaching the query builder cannot
236
+ # make MongoDB execute arbitrary JavaScript. Two rules are enforced:
237
+ #
238
+ # - An operator at the top level of an expression must appear in
239
+ # +Criteria::Queryable::Selectable::ALLOWED_QUERY_OPERATORS+.
240
+ # - +$where+, +$function+, and +$accumulator+ are rejected at any depth,
241
+ # including inside +$expr+ and the logical operators.
242
+ #
243
+ # This applies to every query method that accepts an expression, including
244
+ # +where+, +find_by+, +and+, +or+, +nor+, +not+, +any_of+, and +none_of+.
245
+ # It also governs the string form of +where+, e.g.
246
+ # +where("this.name == 'admin'")+, which the server evaluates as +$where+.
247
+ # Applications relying on that form must set this option to true.
248
+ #
249
+ # The APIs that request JavaScript explicitly, +Criteria#for_js+ and
250
+ # +js_query+, are unaffected: there the developer has asked for it.
251
+ #
252
+ # Set to true to restore the unrestricted pass-through behavior.
253
+ #
254
+ # Note that this option is deliberately not tied to +load_defaults+: an
255
+ # application that has opted into older defaults still gets the guard, and
256
+ # must set this option explicitly to turn it off.
257
+ #
258
+ # See https://jira.mongodb.org/browse/MONGOID-5939,
259
+ # https://jira.mongodb.org/browse/MONGOID-5993,
260
+ # https://jira.mongodb.org/browse/MONGOID-5994 for details.
261
+ option :allow_unsafe_query_operators, default: false
262
+
201
263
  # Returns the Config singleton, for use in the configure DSL.
202
264
  #
203
265
  # @return [ self ] The Config singleton.
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'mongoid/field_readable'
4
+
3
5
  module Mongoid
4
6
  module Contextual
5
7
  module Aggregable
6
8
  # Contains behavior for aggregating values in memory.
7
9
  module Memory
10
+ include FieldReadable
8
11
 
9
12
  # Get all the aggregate values for the provided field.
10
13
  # Provided for interface consistency with Aggregable::Mongo.
@@ -29,7 +32,7 @@ module Mongoid
29
32
  #
30
33
  # @return [ Numeric ] The average.
31
34
  def avg(field)
32
- total = count { |doc| !doc.send(field).nil? }
35
+ total = count { |doc| !read_field_value(doc, field).nil? }
33
36
  return nil unless total > 0
34
37
 
35
38
  total = total.to_f if total.is_a?(Integer)
@@ -114,7 +117,7 @@ module Mongoid
114
117
  def aggregate_by(field, method)
115
118
  return nil unless any?
116
119
 
117
- map { |doc| doc.public_send(field) }.compact.public_send(method)
120
+ map { |doc| read_field_value(doc, field) }.compact.public_send(method)
118
121
  end
119
122
  end
120
123
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "mongoid/contextual/aggregable/memory"
4
4
  require "mongoid/association/eager_loadable"
5
+ require "mongoid/field_readable"
5
6
 
6
7
  module Mongoid
7
8
  module Contextual
@@ -11,6 +12,7 @@ module Mongoid
11
12
  include Association::EagerLoadable
12
13
  include Queryable
13
14
  include Positional
15
+ include FieldReadable
14
16
 
15
17
  # @attribute [r] root The root document.
16
18
  # @attribute [r] path The atomic path.
@@ -170,10 +172,20 @@ module Mongoid
170
172
  # @param [ Criteria ] criteria The criteria.
171
173
  def initialize(criteria)
172
174
  @criteria, @klass = criteria, criteria.klass
173
- @documents = criteria.documents.select do |doc|
174
- @root ||= doc._root
175
- @collection ||= root.collection
176
- doc._matches?(criteria.selector)
175
+
176
+ # Resolving the collection can build a Mongo::Client the first time it
177
+ # runs, so it happens before the budget opens. Where the budget is
178
+ # enforced with Timeout the exception is asynchronous, and landing in
179
+ # the middle of that would leave a half-built client behind.
180
+ if (first = criteria.documents.first)
181
+ @root = first._root
182
+ @collection = @root.collection
183
+ end
184
+
185
+ # One regexp budget covers the whole scan, so that the limit bounds the
186
+ # query rather than each document individually.
187
+ @documents = Matcher::RegexpBudget.open(criteria.selector) do
188
+ criteria.documents.select { |doc| doc._matches?(criteria.selector) }
177
189
  end
178
190
  apply_sorting
179
191
  apply_options
@@ -730,11 +742,10 @@ module Mongoid
730
742
  # _translations hash so that we can get the specified translation in
731
743
  # the remaining
732
744
  if field&.localized?
733
- document.send("#{segment}_translations")
745
+ document.public_send("#{segment}_translations")
734
746
  end
735
747
  end
736
- meth = klass.aliased_associations[segment] || segment
737
- res.nil? ? document.try(meth) : res
748
+ res.nil? ? read_field_value(document, segment) : res
738
749
  elsif document.is_a?(Hash)
739
750
  # TODO: Remove the indifferent access when implementing MONGOID-5410.
740
751
  document.key?(segment.to_s) ?
@@ -325,6 +325,10 @@ module Mongoid
325
325
  end
326
326
  end
327
327
  end
328
+ # Every query method that takes a user-supplied expression normalizes
329
+ # it here, so this is where the operator guard is enforced. See
330
+ # Selectable#_mongoid_validate_operators! for what it does not cover.
331
+ _mongoid_validate_operators!(result)
328
332
  result
329
333
  end
330
334
 
@@ -343,6 +347,14 @@ module Mongoid
343
347
  if criterion.is_a?(Selectable)
344
348
  criterion = criterion.selector
345
349
  end
350
+
351
+ # The __override__ path writes to the selector directly, so it
352
+ # bypasses _mongoid_expand_keys where the operator guard normally
353
+ # runs. Enforce the guard here so chained override methods
354
+ # (elem_match, exists, eq, gt, in, etc.) cannot smuggle $where or
355
+ # other JavaScript operators under strict mode.
356
+ _mongoid_validate_operators!(criterion)
357
+
346
358
  selection(criterion) do |selector, field, value|
347
359
  expression = prepare(field, operator, value)
348
360
  existing = selector[field]