mongoid 9.0.11 → 9.0.12

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +45 -0
  3. data/lib/mongoid/association/depending.rb +8 -4
  4. data/lib/mongoid/association/nested/many.rb +34 -5
  5. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  6. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  7. data/lib/mongoid/association/relatable.rb +17 -0
  8. data/lib/mongoid/collection_configurable.rb +8 -0
  9. data/lib/mongoid/config/encryption.rb +36 -18
  10. data/lib/mongoid/config.rb +56 -9
  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 +4 -0
  14. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  15. data/lib/mongoid/encryptable.rb +46 -0
  16. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  17. data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
  18. data/lib/mongoid/errors.rb +2 -0
  19. data/lib/mongoid/field_readable.rb +70 -0
  20. data/lib/mongoid/indexable.rb +39 -27
  21. data/lib/mongoid/matchable.rb +6 -1
  22. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
  23. data/lib/mongoid/matcher/regex.rb +9 -13
  24. data/lib/mongoid/matcher/regexp_budget.rb +383 -0
  25. data/lib/mongoid/matcher.rb +1 -0
  26. data/lib/mongoid/persistence_context.rb +35 -0
  27. data/lib/mongoid/search_indexable.rb +14 -7
  28. data/lib/mongoid/tasks/database.rb +23 -9
  29. data/lib/mongoid/threaded.rb +37 -0
  30. data/lib/mongoid/version.rb +1 -1
  31. data/spec/integration/app_spec.rb +7 -0
  32. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  33. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  34. data/spec/integration/encryption_spec.rb +149 -0
  35. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  36. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  37. data/spec/integration/query_operator_guard_spec.rb +89 -0
  38. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  39. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  40. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  41. data/spec/mongoid/config/defaults_spec.rb +18 -0
  42. data/spec/mongoid/config/encryption_spec.rb +228 -0
  43. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  44. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  45. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  46. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
  47. data/spec/mongoid/criteria_spec.rb +2 -0
  48. data/spec/mongoid/encryptable_spec.rb +61 -0
  49. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  50. data/spec/mongoid/tasks/database_spec.rb +18 -0
  51. data/spec/support/crypt/models.rb +157 -0
  52. metadata +14 -2
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module Mongoid
6
+ module Matcher
7
+ # Bounds the time spent executing regular expressions while evaluating a
8
+ # single in-memory match operation.
9
+ #
10
+ # A query condition can carry an application-supplied pattern, and the
11
+ # in-memory matcher compiles and runs that pattern in the caller's thread.
12
+ # Both the cost of one match and the number of matches performed are under
13
+ # the control of whoever supplied the condition, so the limit is cumulative
14
+ # over an entire operation rather than per match.
15
+ #
16
+ # The budget is held in thread- or fiber-local storage, so concurrent
17
+ # queries are accounted for independently.
18
+ #
19
+ # @api private
20
+ module RegexpBudget
21
+ # Whether a per-Regexp timeout can be relied on to reach Regexp.new.
22
+ #
23
+ # MRI added them in 3.2. JRuby 10.0.6 defines Regexp::TimeoutError,
24
+ # reports Ruby 3.4, and does honour a timeout that reaches it, but its
25
+ # Regexp.new accepts the keyword only for the first couple of calls
26
+ # through a given call site and raises ArgumentError from then on.
27
+ # Because that breakage is per call site, no load-time probe can predict
28
+ # it: a probe at its own call site reports a capability that the call in
29
+ # Budget#compile does not have. So non-MRI engines are excluded outright
30
+ # and use the Timeout fallback, which does interrupt a Joni match already
31
+ # under way. Worth revisiting if JRuby fixes the keyword handling.
32
+ PER_REGEXP_TIMEOUT =
33
+ if RUBY_ENGINE == 'ruby' && defined?(::Regexp::TimeoutError)
34
+ true
35
+ else
36
+ false
37
+ end
38
+
39
+ # The exception raised by a per-Regexp timeout. Tied to the constant
40
+ # rather than to the probe, so that a timeout set some other way (an
41
+ # application assigning Regexp.timeout, say) is still translated. On
42
+ # Rubies with no such constant, a class that is never raised stands in.
43
+ TIMEOUT_ERROR = defined?(::Regexp::TimeoutError) ? ::Regexp::TimeoutError : Class.new(StandardError)
44
+
45
+ # Raised by Timeout on Rubies without per-Regexp timeouts, and converted
46
+ # immediately. It is private to this module so that an application's own
47
+ # Timeout, firing inside our block, is never mistaken for ours.
48
+ class TimedOut < StandardError; end
49
+
50
+ # The marker stored in thread-local storage when a scope is open but has
51
+ # no budget to enforce. Threaded.has? reports a nil-valued variable as
52
+ # absent, so an open-but-unbounded scope has to store a real marker; a
53
+ # nested call checks Threaded.has? and joins the enclosing scope rather
54
+ # than deciding again for itself.
55
+ NO_BUDGET = Object.new
56
+
57
+ # The state of one open budget: what is left of the limit, and the
58
+ # patterns compiled under it.
59
+ #
60
+ # @api private
61
+ class Budget
62
+ # @return [ Float ] The limit this budget started with.
63
+ attr_reader :limit
64
+
65
+ # @return [ Float ] The seconds left before the budget is spent.
66
+ attr_reader :remaining
67
+
68
+ # @param [ Float ] limit The seconds this budget may spend.
69
+ def initialize(limit)
70
+ @limit = limit
71
+ @remaining = limit
72
+ @cache = {}
73
+ # A per-Regexp timeout bounds one match; the cumulative budget bounds
74
+ # the operation. So the timeout is fixed for the life of the scope
75
+ # rather than following the drawdown, which is what lets a pattern be
76
+ # compiled once instead of once per match. It means a match that
77
+ # starts with almost nothing left can still run for a whole limit, so
78
+ # an operation can overshoot by at most one limit -- bounded, which is
79
+ # the point, and far cheaper than recompiling.
80
+ #
81
+ # An application that has set a stricter global Regexp.timeout keeps
82
+ # it: baking in a larger value would leave it less protected than it
83
+ # asked to be. A timeout set on one individual pattern is a different
84
+ # matter, and is not preserved -- Budget#compile rebuilds the pattern
85
+ # from its source and flags, neither of which carries one, so this
86
+ # value takes its place. Only trusted code can supply such a pattern:
87
+ # a condition decoded from JSON or BSON arrives as a string or a
88
+ # BSON::Regexp::Raw, with no timeout of its own.
89
+ @timeout = [ limit, ::Regexp.timeout ].compact.min if PER_REGEXP_TIMEOUT
90
+ end
91
+
92
+ # Draws the elapsed time down from the budget.
93
+ #
94
+ # @param [ Float ] elapsed The seconds to charge.
95
+ def charge(elapsed)
96
+ @remaining -= elapsed
97
+ end
98
+
99
+ # @return [ true | false ] Whether the budget is spent.
100
+ def exhausted?
101
+ @remaining <= 0
102
+ end
103
+
104
+ # Returns the condition as a Regexp which, where the Ruby in use
105
+ # supports it, gives up once its timeout is spent.
106
+ #
107
+ # The condition is taken uncompiled so that the cache can answer before
108
+ # any compiling happens. A BSON::Regexp::Raw memoizes its own compile,
109
+ # but FieldExpression builds a fresh one for every $regex it evaluates,
110
+ # so that memo is worth nothing across documents and the source would
111
+ # otherwise be compiled once per document.
112
+ #
113
+ # @param [ Regexp | BSON::Regexp::Raw ] condition The condition.
114
+ #
115
+ # @return [ Regexp ] The compiled pattern.
116
+ def compile(condition)
117
+ @cache[cache_key(condition)] ||= bake(RegexpBudget.coerce(condition))
118
+ end
119
+
120
+ private
121
+
122
+ # BSON::Regexp::Raw aliases eql? to == but leaves hash alone, so two
123
+ # equal instances hash differently and cannot key the cache. What they
124
+ # are equal by can. Anything else keys on itself and is left to coerce
125
+ # to reject.
126
+ def cache_key(condition)
127
+ case condition
128
+ when BSON::Regexp::Raw then [ condition.pattern, condition.options ]
129
+ else condition
130
+ end
131
+ end
132
+
133
+ # Rebuilds the pattern with the budget's timeout, where the Ruby in use
134
+ # has them.
135
+ def bake(regexp)
136
+ return regexp unless PER_REGEXP_TIMEOUT
137
+
138
+ ::Regexp.new(regexp.source, regexp.options, timeout: @timeout)
139
+ end
140
+ end
141
+
142
+ class << self
143
+ # Opens a budget scope for the duration of the block.
144
+ #
145
+ # Nested calls join the enclosing budget instead of starting a new one,
146
+ # which is what lets a scan over many documents share a single limit.
147
+ # It also keeps the recursion in Expression.matches? (through
148
+ # $elemMatch, $and, $or and $nor) from resetting the budget.
149
+ #
150
+ # No budget is opened for a selector that carries no regular
151
+ # expression. There would be nothing for it to bound, and on the
152
+ # Timeout path it would put a deadline on in-memory work that has
153
+ # nothing to do with regular expressions.
154
+ #
155
+ # The scope covers everything nested inside the block, a selector other
156
+ # than this one included: a nested call joins the scope rather than
157
+ # deciding for itself, which is what keeps a scan from walking the
158
+ # selector once per document. Where the scope has nothing to bound, that
159
+ # means nested selectors are not bounded either -- so do not open one
160
+ # around work that can run application code. Loading documents runs find
161
+ # callbacks, and a query in one of those brings its own selector.
162
+ #
163
+ # Where a selector does carry one, the Timeout path still measures the
164
+ # whole scope rather than the matching alone, so a long scan can trip
165
+ # the limit with a cheap pattern. That imprecision is accepted: the
166
+ # alternative is a Timeout around each individual match, which was
167
+ # measured at about nine seconds per million matches, and the limit
168
+ # exists to bound a scan of exactly that size. The error message is
169
+ # worded to hold either way, and Rubies with per-Regexp timeouts --
170
+ # every supported MRI from 3.2 on -- do not take this path at all.
171
+ #
172
+ # Code inside the block that mutates state should be wrapped in
173
+ # .protect, since on Rubies without a per-Regexp timeout the budget is
174
+ # enforced with an asynchronous exception that can land anywhere.
175
+ #
176
+ # @param [ Hash ] selector The selector about to be evaluated.
177
+ #
178
+ # @return [ Object ] The value of the block.
179
+ def open(selector, &block)
180
+ # The key is present (holding NO_BUDGET) where an enclosing scope
181
+ # found nothing to bound, so that a nested call does not scan the
182
+ # selector again. Deciding before this check, in a default argument
183
+ # say, would walk the selector once per document on a scan.
184
+ return yield if Threaded.has?(Threaded::REGEXP_BUDGET_KEY)
185
+
186
+ open_with(limit_for(selector), &block)
187
+ end
188
+
189
+ # Opens a budget scope for a limit the caller has already decided on.
190
+ #
191
+ # A caller that rearranges its work around the decision -- loading
192
+ # documents up front so that nothing is mutated before the scan
193
+ # finishes, say -- has to make it before it can act on it, and must not
194
+ # then make it a second time. Asking .limit_for and letting .open ask
195
+ # again reads the configured limit twice, and the two reads can differ:
196
+ # a limit that becomes positive in between would establish a budget in
197
+ # the branch that was chosen for not needing one, and on the Timeout
198
+ # path that arms a deadline over work the branch never made
199
+ # interruptible.
200
+ #
201
+ # See .open for what the scope does and does not bound, and for the
202
+ # note about mutating state inside it.
203
+ #
204
+ # @param [ Float | nil ] limit The seconds the scope may spend, or nil
205
+ # for a scope with nothing to bound.
206
+ #
207
+ # @return [ Object ] The value of the block.
208
+ def open_with(limit, &block)
209
+ return yield if Threaded.has?(Threaded::REGEXP_BUDGET_KEY)
210
+
211
+ budget = Budget.new(limit) if limit&.positive?
212
+
213
+ begin
214
+ # Set inside the begin so that an asynchronous exception from an
215
+ # enclosing timeout cannot leave the key behind on a pooled thread.
216
+ Threaded.set(Threaded::REGEXP_BUDGET_KEY, budget || NO_BUDGET)
217
+
218
+ if budget.nil? || PER_REGEXP_TIMEOUT
219
+ yield
220
+ else
221
+ begin
222
+ Timeout.timeout(budget.limit, TimedOut, &block)
223
+ rescue TimedOut
224
+ raise timeout_error(budget)
225
+ end
226
+ end
227
+ ensure
228
+ Threaded.delete(Threaded::REGEXP_BUDGET_KEY)
229
+ end
230
+ end
231
+
232
+ # The limit a scope evaluating this selector would be bounded by.
233
+ #
234
+ # A caller that has to rearrange its work to make the scan
235
+ # interruptible -- loading documents up front so that nothing is
236
+ # mutated before the scan finishes, say -- can ask this first and skip
237
+ # the rearrangement, and whatever it costs, when there is no pattern to
238
+ # bound. It then passes what it got to .open_with, so that the decision
239
+ # it acted on is the one the scope is opened with. Callers with nothing
240
+ # to rearrange should just call .open, which asks this itself.
241
+ #
242
+ # @param [ Hash ] selector The selector about to be evaluated.
243
+ #
244
+ # @return [ Float | nil ] The limit, or nil where there is nothing to
245
+ # bound.
246
+ def limit_for(selector)
247
+ # nil.to_f is 0.0, so an unset limit and a limit of zero or less are
248
+ # the same thing here: no limit. Zero is a common way to spell
249
+ # "disabled", and taking it literally would mean a budget that is
250
+ # spent before the first match and a query that can never run.
251
+ limit = Mongoid::Config.in_memory_regexp_time_limit.to_f
252
+ return nil unless limit.positive?
253
+
254
+ limit if contains_regexp?(selector)
255
+ end
256
+
257
+ # Matches a value against a regular expression condition, charging the
258
+ # time it takes against the open budget.
259
+ #
260
+ # @param [ Object ] value The value to match.
261
+ # @param [ Regexp | BSON::Regexp::Raw ] condition The condition.
262
+ #
263
+ # @raise [ Errors::InMemoryRegexpTimeout ] if the budget is exhausted.
264
+ #
265
+ # @return [ Integer | nil ] The offset of the match, or nil.
266
+ def match?(value, condition)
267
+ budget = current
268
+ return value =~ coerce(condition) unless budget
269
+
270
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
271
+ pattern = nil
272
+ begin
273
+ raise timeout_error(budget) if budget.exhausted?
274
+
275
+ # Compiling is charged too. It is not free, and for a pattern with
276
+ # very many branches it costs far more than running the pattern
277
+ # does, so leaving it out would leave a way to spend unbounded time
278
+ # without the budget ever noticing.
279
+ pattern = budget.compile(condition)
280
+ value =~ pattern
281
+ rescue TIMEOUT_ERROR
282
+ # Name the limit that actually fired, which is not always the
283
+ # budget's. A baked pattern carries it: the smaller of the budget's
284
+ # limit and any global Regexp.timeout the application has set.
285
+ # Where nothing was baked -- an engine that raises this error but
286
+ # will not take a per-Regexp timeout, which is JRuby -- the global
287
+ # is the only thing that can have fired, and the pattern reports
288
+ # nil. Naming the budget's limit in either case would state a time
289
+ # that was never spent and send the reader after a setting that is
290
+ # not the one in the way.
291
+ #
292
+ # Both readers arrived together with Regexp::TimeoutError, so every
293
+ # engine that can reach this rescue at all has them.
294
+ raise timeout_error(budget, pattern&.timeout || ::Regexp.timeout || budget.limit)
295
+ ensure
296
+ budget.charge(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
297
+ end
298
+ end
299
+
300
+ # Runs the block without letting a scope timeout tear it in half.
301
+ #
302
+ # Where the budget is enforced with Timeout, the exception is raised
303
+ # asynchronously and can arrive at any point. Wrapping a mutation in
304
+ # this holds the exception back until the block has finished, so the
305
+ # interruption is deferred rather than given up.
306
+ #
307
+ # @return [ Object ] The value of the block.
308
+ def protect(&block)
309
+ Thread.handle_interrupt(TimedOut => :never, &block)
310
+ end
311
+
312
+ # The time left in the open budget, or nil when no budget is open.
313
+ #
314
+ # @return [ Float | nil ] The remaining seconds.
315
+ def remaining
316
+ current&.remaining
317
+ end
318
+
319
+ # Returns the condition as a Regexp, without a timeout.
320
+ #
321
+ # @param [ Regexp | BSON::Regexp::Raw ] condition The condition.
322
+ #
323
+ # @return [ Regexp ] The pattern.
324
+ def coerce(condition)
325
+ case condition
326
+ when ::Regexp then condition
327
+ when BSON::Regexp::Raw then condition.compile
328
+ else raise ArgumentError, "Not a regular expression: #{condition.inspect}"
329
+ end
330
+ end
331
+
332
+ private
333
+
334
+ # The budget for the open scope, if there is one.
335
+ #
336
+ # A scope with nothing to bound leaves NO_BUDGET in storage, which reads
337
+ # as no budget.
338
+ #
339
+ # @return [ Budget | nil ] The open budget.
340
+ def current
341
+ budget = Threaded.get(Threaded::REGEXP_BUDGET_KEY)
342
+ budget unless budget.equal?(NO_BUDGET)
343
+ end
344
+
345
+ # Whether evaluating the selector could run a regular expression.
346
+ #
347
+ # A string under $regex counts: FieldExpression turns it into a pattern
348
+ # at match time.
349
+ def contains_regexp?(object)
350
+ case object
351
+ when ::Regexp, BSON::Regexp::Raw
352
+ true
353
+ when Hash
354
+ object.any? do |k, v|
355
+ k.to_s == '$regex' || contains_regexp?(v)
356
+ end
357
+ when Array
358
+ object.any? { |v| contains_regexp?(v) }
359
+ else
360
+ false
361
+ end
362
+ end
363
+
364
+ # Builds the error with the scope timeout held back.
365
+ #
366
+ # Composing the message goes through I18n, which reads locale files the
367
+ # first time it runs. An asynchronous TimedOut landing in the middle of
368
+ # that is caught by I18n and reraised as a locale-loading failure, so
369
+ # the real error never surfaces.
370
+ #
371
+ # @param [ Budget ] budget The open budget.
372
+ # @param [ Float ] limit The limit that was exceeded. Defaults to the
373
+ # budget's own, which is the right one to name everywhere the budget
374
+ # itself ran out.
375
+ def timeout_error(budget, limit = budget.limit)
376
+ protect do
377
+ Errors::InMemoryRegexpTimeout.new(limit)
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
@@ -142,6 +142,7 @@ require 'mongoid/matcher/nor'
142
142
  require 'mongoid/matcher/not'
143
143
  require 'mongoid/matcher/or'
144
144
  require 'mongoid/matcher/regex'
145
+ require 'mongoid/matcher/regexp_budget'
145
146
  require 'mongoid/matcher/size'
146
147
  require 'mongoid/matcher/type'
147
148
  require 'mongoid/matcher/expression_operator'
@@ -126,6 +126,8 @@ module Mongoid
126
126
 
127
127
  client = client.with(options) unless options.empty?
128
128
 
129
+ verify_encryption_schema!(client)
130
+
129
131
  client
130
132
  end
131
133
  end
@@ -199,6 +201,39 @@ module Mongoid
199
201
  name.respond_to?(:call) ? name.call.to_sym : name.to_sym
200
202
  end
201
203
 
204
+ # Refuse to use a client that would store a model's encrypted fields in
205
+ # plaintext.
206
+ #
207
+ # The automatic encryption schema is keyed by namespace and is built once,
208
+ # when the client is created. The driver looks the target namespace up in
209
+ # that schema, and when it is absent it asks the server for a schema
210
+ # instead; a collection without a validator then yields no encryption at
211
+ # all, and no error. Checking the namespace here is what turns that silent
212
+ # downgrade into a failure.
213
+ #
214
+ # @param [ Mongo::Client ] client The client this context resolved to.
215
+ #
216
+ # @raise [ Errors::NoEncryptionSchema ] if the model needs an encryption
217
+ # schema and the client's schema does not cover the target namespace.
218
+ def verify_encryption_schema!(client)
219
+ klass = @object.is_a?(Class) ? @object : @object.class
220
+ # A model whose encrypted fields all live on embedded models declares no
221
+ # encryption of its own, and still needs a schema for its collection.
222
+ return unless klass.respond_to?(:requires_encryption_schema?) && klass.requires_encryption_schema?
223
+ # An embedded document is persisted as part of its parent, so the
224
+ # parent's context is what governs the namespace.
225
+ return if klass.embedded?
226
+ # Collection and index management sends no document data, so it does not
227
+ # need an encryption-capable client.
228
+ return if Threaded.managing_collection?
229
+
230
+ schema_map = client.options.dig(:auto_encryption_options, :schema_map)
231
+ namespace = "#{client.database.name}.#{collection_name}"
232
+ return if schema_map&.key?(namespace)
233
+
234
+ raise Errors::NoEncryptionSchema.new(klass, namespace, client_name)
235
+ end
236
+
202
237
  def client_options
203
238
  @client_options ||= begin
204
239
  opts = options.select do |k, v|
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rubocop:todo all
2
3
 
3
4
  module Mongoid
4
5
  # Encapsulates behavior around managing search indexes. This feature
@@ -65,7 +66,9 @@ module Mongoid
65
66
  def create_search_indexes
66
67
  return if search_index_specs.empty?
67
68
 
68
- collection.search_indexes.create_many(search_index_specs)
69
+ Threaded.with_collection_management do
70
+ collection.search_indexes.create_many(search_index_specs)
71
+ end
69
72
  end
70
73
 
71
74
  # Waits for the named search indexes to be created.
@@ -96,7 +99,9 @@ module Mongoid
96
99
  # @option options [ Hash ] :aggregate The options hash to pass to the
97
100
  # aggregate command (optional)
98
101
  def search_indexes(options = {})
99
- collection.search_indexes(options)
102
+ Threaded.with_collection_management do
103
+ collection.search_indexes(options)
104
+ end
100
105
  end
101
106
 
102
107
  # Removes the search index specified by the given name or id. Either
@@ -105,12 +110,14 @@ module Mongoid
105
110
  # @param [ String | nil ] name the name of the index to remove
106
111
  # @param [ String | nil ] id the id of the index to remove
107
112
  def remove_search_index(name: nil, id: nil)
108
- logger.info(
109
- "MONGOID: Removing search index '#{name || id}' " \
110
- "on collection '#{collection.name}'."
111
- )
113
+ Threaded.with_collection_management do
114
+ logger.info(
115
+ "MONGOID: Removing search index '#{name || id}' " \
116
+ "on collection '#{collection.name}'."
117
+ )
112
118
 
113
- collection.search_indexes.drop_one(name: name, id: id)
119
+ collection.search_indexes.drop_one(name: name, id: id)
120
+ end
114
121
  end
115
122
 
116
123
  # Request the removal of all registered search indexes. Note
@@ -84,6 +84,12 @@ module Mongoid
84
84
  #
85
85
  # @return [ Array<Hash> ] The list of undefined indexes by model.
86
86
  def undefined_indexes(models = ::Mongoid.models)
87
+ Threaded.with_collection_management do
88
+ undefined_indexes_for(models)
89
+ end
90
+ end
91
+
92
+ def undefined_indexes_for(models)
87
93
  undefined_by_model = {}
88
94
 
89
95
  models.each do |model|
@@ -116,15 +122,17 @@ module Mongoid
116
122
  #
117
123
  # @return [ Hash{Class => Array(Hash)}] The list of indexes that were removed by model.
118
124
  def remove_undefined_indexes(models = ::Mongoid.models)
119
- undefined_indexes(models).each do |model, indexes|
120
- indexes.each do |index|
121
- key = index['key'].symbolize_keys
122
- collection = model.collection
123
- collection.indexes(session: model.send(:_session)).drop_one(key)
124
- logger.info(
125
- "MONGOID: Removed index '#{index['name']}' on collection " +
126
- "'#{collection.name}' in database '#{collection.database.name}'."
127
- )
125
+ Threaded.with_collection_management do
126
+ undefined_indexes(models).each do |model, indexes|
127
+ indexes.each do |index|
128
+ key = index['key'].symbolize_keys
129
+ collection = model.collection
130
+ collection.indexes(session: model.send(:_session)).drop_one(key)
131
+ logger.info(
132
+ "MONGOID: Removed index '#{index['name']}' on collection " +
133
+ "'#{collection.name}' in database '#{collection.database.name}'."
134
+ )
135
+ end
128
136
  end
129
137
  end
130
138
  end
@@ -170,6 +178,12 @@ module Mongoid
170
178
  #
171
179
  # @return [ Array<Class> ] The sharded models
172
180
  def shard_collections(models = ::Mongoid.models)
181
+ Threaded.with_collection_management do
182
+ shard_collections_for(models)
183
+ end
184
+ end
185
+
186
+ def shard_collections_for(models)
173
187
  models.map do |model|
174
188
  next if model.shard_config.nil?
175
189
 
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rubocop:todo all
2
3
 
3
4
  require 'mongoid/threaded/lifecycle'
4
5
 
@@ -25,6 +26,9 @@ module Mongoid
25
26
  hash[key] = "[mongoid]:#{key}-stack"
26
27
  end
27
28
 
29
+ # The name of the stack tracking collection and index management.
30
+ COLLECTION_MANAGEMENT = :collection_management
31
+
28
32
  # The key for the current thread's sessions.
29
33
  SESSIONS_KEY = '[mongoid]:sessions'
30
34
 
@@ -35,6 +39,9 @@ module Mongoid
35
39
  # executed on documents.
36
40
  EXECUTE_CALLBACKS = '[mongoid]:execute-callbacks'
37
41
 
42
+ # The key for the time left in the current in-memory regexp budget.
43
+ REGEXP_BUDGET_KEY = 'regexp-budget'
44
+
38
45
  extend self
39
46
 
40
47
  # Queries the thread-local variable with the given name. If a block is
@@ -152,6 +159,36 @@ module Mongoid
152
159
  !stack(name).empty?
153
160
  end
154
161
 
162
+ # Execute the block as collection or index management.
163
+ #
164
+ # Creating, dropping and inspecting collections and indexes sends no
165
+ # document data, so these operations are exempt from the encryption schema
166
+ # check that PersistenceContext applies to reads and writes. Without the
167
+ # exemption, tasks such as db:mongoid:create_collections would need an
168
+ # encryption-capable client to run.
169
+ #
170
+ # @example Create a collection.
171
+ # Threaded.with_collection_management { model.create_collection }
172
+ #
173
+ # @return [ Object ] The result of the block.
174
+ def with_collection_management
175
+ begin_execution(COLLECTION_MANAGEMENT)
176
+ yield
177
+ ensure
178
+ exit_execution(COLLECTION_MANAGEMENT)
179
+ end
180
+
181
+ # Is collection or index management being executed?
182
+ #
183
+ # @example Is a collection being managed?
184
+ # Threaded.managing_collection?
185
+ #
186
+ # @return [ true | false ] Whether collection or index management is in
187
+ # progress on the current thread.
188
+ def managing_collection?
189
+ executing?(COLLECTION_MANAGEMENT)
190
+ end
191
+
155
192
  # Exit from a named thread local stack.
156
193
  #
157
194
  # @example Exit from the stack.
@@ -5,5 +5,5 @@ module Mongoid
5
5
  #
6
6
  # Note that this file is automatically updated via `rake candidate:create`.
7
7
  # Manual changes to this file will be overwritten by that rake task.
8
- VERSION = '9.0.11'
8
+ VERSION = '9.0.12'
9
9
  end
@@ -459,6 +459,13 @@ describe 'Mongoid application tests' do
459
459
  end
460
460
  gemfile_lines << "gem 'mongoid', path: '#{File.expand_path(BASE)}'\n"
461
461
 
462
+ # json 3.0 removed the quirks_mode and max_nesting keywords that
463
+ # ActiveSupport::JSON (every 7.x) still passes to JSON.generate and
464
+ # JSON.parse, so a freshly resolved bundle picks a 3.x json and the app
465
+ # fails to encode any response with ArgumentError: unknown keyword:
466
+ # quirks_mode. Cap json at 2.x, which still accepts those keywords.
467
+ gemfile_lines << "gem 'json', '< 3'\n"
468
+
462
469
  # Rails 6.0 and 6.1 need logger gem on Ruby 2.7+ due to stdlib changes
463
470
  if rails_version && rails_version.to_f < 7.0 && RUBY_VERSION >= '2.7'
464
471
  gemfile_lines << "gem 'logger'\n"
@@ -87,8 +87,23 @@ describe 'has_and_belongs_to_many associations' do
87
87
  })
88
88
  end
89
89
 
90
- it 'does not raise on save' do
91
- expect { image_block.save! }.not_to raise_error
90
+ # The nested attributes are processed before the attachment_ids
91
+ # assignment is applied, so at that point the id is not yet in the
92
+ # association and resolving it requires a collection-wide lookup.
93
+ context 'when allow_reparenting_via_nested_attributes is false' do
94
+ config_override :allow_reparenting_via_nested_attributes, false
95
+
96
+ it 'raises a document not found error' do
97
+ expect { image_block.save! }.to raise_error(Mongoid::Errors::DocumentNotFound)
98
+ end
99
+ end
100
+
101
+ context 'when allow_reparenting_via_nested_attributes is true' do
102
+ config_override :allow_reparenting_via_nested_attributes, true
103
+
104
+ it 'does not raise on save' do
105
+ expect { image_block.save! }.not_to raise_error
106
+ end
92
107
  end
93
108
  end
94
109
 
@@ -268,10 +268,20 @@ describe "Dots and Dollars" do
268
268
  DADMUser.where("$_amount": 0).first
269
269
  end
270
270
 
271
- it "raise an error" do
271
+ it 'raises an error' do
272
272
  expect do
273
273
  queried
274
- end.to raise_error(Mongo::Error::OperationFailure)
274
+ end.to raise_error(Mongoid::Errors::InvalidQuery)
275
+ end
276
+
277
+ context 'when allow_unsafe_query_operators is true' do
278
+ config_override :allow_unsafe_query_operators, true
279
+
280
+ it 'raises an error from the server' do
281
+ expect do
282
+ queried
283
+ end.to raise_error(Mongo::Error::OperationFailure)
284
+ end
275
285
  end
276
286
  end
277
287
  end