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
@@ -0,0 +1,576 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'benchmark'
4
+ require 'spec_helper'
5
+
6
+ describe Mongoid::Matcher::RegexpBudget do
7
+ # Costly but bounded on every supported Ruby: unanchored, so every branch is
8
+ # tried at every position, and with no nested quantifier there is nothing to
9
+ # backtrack exponentially. What it costs still varies a lot by engine, about
10
+ # 6ms a match on MRI against 110ms on JRuby, so anything asserting on that
11
+ # cost calibrates rather than hard-coding it.
12
+ #
13
+ # A pattern built out of nested quantifiers would be wrong here. Those are
14
+ # merely slow only where Ruby memoizes matching, which arrived in 3.2; on
15
+ # 2.7 through 3.1 they backtrack exponentially and never finish.
16
+ let(:slow_pattern) { BSON::Regexp::Raw.new("(?:#{(1..300).map { |i| "a#{i}" }.join('|')})Z") }
17
+ let(:slow_subject) { 'a' * 5_000 }
18
+
19
+ # Backtracks exponentially: the backreference disables memoization, so this
20
+ # runs orders of magnitude longer than the limit and has to be interrupted.
21
+ #
22
+ # The subject is kept short enough that the match still finishes on its own,
23
+ # in roughly 4.5 seconds against a 0.2 second limit. That way a Ruby whose
24
+ # engine does not check for interrupts mid-match fails these examples
25
+ # visibly instead of hanging the run.
26
+ let(:catastrophic_pattern) { BSON::Regexp::Raw.new('^(a+)+\1?$') }
27
+ let(:catastrophic_subject) { "#{'a' * 28}X" }
28
+
29
+ let(:cheap_pattern) { BSON::Regexp::Raw.new('\Aabc\z') }
30
+
31
+ # A budget is only opened for a selector that carries a pattern, so anything
32
+ # exercising one has to hand .open something to bound.
33
+ let(:regexp_selector) { { 'name' => /\Aabc\z/ } }
34
+
35
+ describe 'REGEXP_BUDGET_KEY' do
36
+ it 'is namespaced so a foreign thread-local cannot collide with it' do
37
+ expect(Mongoid::Threaded::REGEXP_BUDGET_KEY).to start_with('[mongoid]:')
38
+ end
39
+ end
40
+
41
+ describe '.open' do
42
+ context 'when no limit is configured' do
43
+ config_override :in_memory_regexp_time_limit, nil
44
+
45
+ it 'does not establish a budget' do
46
+ described_class.open(regexp_selector) do
47
+ expect(described_class.remaining).to be_nil
48
+ end
49
+ end
50
+ end
51
+
52
+ context 'when the configured limit is zero' do
53
+ config_override :in_memory_regexp_time_limit, 0
54
+
55
+ # Zero is a common way to spell "disabled". Taken literally it would mean
56
+ # a budget spent before the first match, so every in-memory query
57
+ # carrying a pattern would raise rather than run.
58
+ it 'does not establish a budget' do
59
+ described_class.open(regexp_selector) do
60
+ expect(described_class.remaining).to be_nil
61
+ end
62
+ end
63
+
64
+ it 'still performs matches' do
65
+ result = described_class.open(regexp_selector) do
66
+ described_class.match?('abc', cheap_pattern)
67
+ end
68
+
69
+ expect(result).to eq(0)
70
+ end
71
+ end
72
+
73
+ context 'when a limit is configured' do
74
+ config_override :in_memory_regexp_time_limit, 5.0
75
+
76
+ it 'establishes the budget for the duration of the block' do
77
+ described_class.open(regexp_selector) do
78
+ expect(described_class.remaining).to be_within(0.01).of(5.0)
79
+ end
80
+ end
81
+
82
+ it 'returns the value of the block' do
83
+ expect(described_class.open(regexp_selector) { :result }).to eq(:result)
84
+ end
85
+
86
+ it 'clears the budget when the block returns' do
87
+ described_class.open(regexp_selector) { nil }
88
+ expect(described_class.remaining).to be_nil
89
+ end
90
+
91
+ it 'clears the budget when the block raises' do
92
+ expect { described_class.open(regexp_selector) { raise 'boom' } }.to raise_error('boom')
93
+ expect(described_class.remaining).to be_nil
94
+ end
95
+
96
+ it 'joins the enclosing budget rather than starting a new one' do
97
+ described_class.open(regexp_selector) do
98
+ described_class.match?(slow_subject, slow_pattern)
99
+ spent = 5.0 - described_class.remaining
100
+ expect(spent).to be > 0
101
+
102
+ described_class.open(regexp_selector) do
103
+ expect(described_class.remaining).to be_within(0.01).of(5.0 - spent)
104
+ end
105
+ end
106
+ end
107
+
108
+ context 'when the selector carries no regular expression' do
109
+ it 'does not establish a budget' do
110
+ described_class.open('name' => 'abc') do
111
+ expect(described_class.remaining).to be_nil
112
+ end
113
+ end
114
+
115
+ it 'does not put a deadline on the block' do
116
+ # The limit bounds regular expressions, not in-memory work at large.
117
+ # On the Timeout path a scope with nothing to bound used to fail any
118
+ # slow scan -- an embedded association of any size, say -- with an
119
+ # error about regular expressions.
120
+ stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
121
+ Mongoid::Config.in_memory_regexp_time_limit = 0.2
122
+
123
+ expect do
124
+ described_class.open('name' => 'abc') { sleep 0.3 }
125
+ end.not_to raise_error
126
+ end
127
+
128
+ it 'keeps a nested scope from scanning the selector again' do
129
+ described_class.open('name' => 'abc') do
130
+ expect(Mongoid::Threaded.has?(Mongoid::Threaded::REGEXP_BUDGET_KEY)).to be(true)
131
+ end
132
+ end
133
+ end
134
+
135
+ context 'when the selector carries a string $regex' do
136
+ # FieldExpression turns it into a pattern at match time, so it needs
137
+ # bounding just as much as a Regexp written out in the selector does.
138
+ it 'establishes a budget' do
139
+ described_class.open('name' => { '$regex' => 'abc' }) do
140
+ expect(described_class.remaining).to be_within(0.01).of(5.0)
141
+ end
142
+ end
143
+ end
144
+
145
+ context 'when the selector nests a regular expression' do
146
+ it 'establishes a budget' do
147
+ described_class.open('$or' => [ { 'name' => 'abc' }, { 'name' => /abc/ } ]) do
148
+ expect(described_class.remaining).to be_within(0.01).of(5.0)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ describe '.limit_for' do
156
+ context 'when a limit is configured' do
157
+ config_override :in_memory_regexp_time_limit, 5.0
158
+
159
+ it 'is the limit for a selector carrying a regular expression' do
160
+ expect(described_class.limit_for(regexp_selector)).to eq(5.0)
161
+ end
162
+
163
+ it 'is the limit for a selector carrying a string $regex' do
164
+ expect(described_class.limit_for('name' => { '$regex' => 'abc' })).to eq(5.0)
165
+ end
166
+
167
+ it 'is the limit for a selector nesting a regular expression' do
168
+ expect(described_class.limit_for('$or' => [ { 'name' => 'abc' }, { 'name' => /abc/ } ])).to eq(5.0)
169
+ end
170
+
171
+ it 'is nil for a selector carrying no regular expression' do
172
+ expect(described_class.limit_for('name' => 'abc')).to be_nil
173
+ end
174
+
175
+ it 'is nil for an empty selector' do
176
+ expect(described_class.limit_for({})).to be_nil
177
+ end
178
+ end
179
+
180
+ context 'when no limit is configured' do
181
+ config_override :in_memory_regexp_time_limit, nil
182
+
183
+ # Nothing would be bounded, so a caller that only rearranges its work to
184
+ # make room for a budget has no reason to.
185
+ it 'is nil even for a selector carrying a regular expression' do
186
+ expect(described_class.limit_for(regexp_selector)).to be_nil
187
+ end
188
+ end
189
+
190
+ context 'when the configured limit is zero' do
191
+ config_override :in_memory_regexp_time_limit, 0
192
+
193
+ it 'is nil even for a selector carrying a regular expression' do
194
+ expect(described_class.limit_for(regexp_selector)).to be_nil
195
+ end
196
+ end
197
+ end
198
+
199
+ describe '.open_with' do
200
+ config_override :in_memory_regexp_time_limit, 5.0
201
+
202
+ it 'establishes a budget for the limit it is given, not the configured one' do
203
+ described_class.open_with(2.0) do
204
+ expect(described_class.remaining).to be_within(0.01).of(2.0)
205
+ end
206
+ end
207
+
208
+ it 'returns the value of the block' do
209
+ expect(described_class.open_with(2.0) { :result }).to eq(:result)
210
+ end
211
+
212
+ it 'clears the budget when the block returns' do
213
+ described_class.open_with(2.0) { nil }
214
+ expect(described_class.remaining).to be_nil
215
+ end
216
+
217
+ context 'when given no limit' do
218
+ it 'does not establish a budget' do
219
+ described_class.open_with(nil) do
220
+ expect(described_class.remaining).to be_nil
221
+ end
222
+ end
223
+
224
+ # A caller that read the limit, found nothing to bound and arranged its
225
+ # work accordingly must not have a deadline imposed on it by a later read
226
+ # of the same setting. On the Timeout path that deadline would cover
227
+ # whatever the caller went on to do, which here is a query and a mutation
228
+ # of every match.
229
+ it 'does not put a deadline on the block' do
230
+ stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
231
+ Mongoid::Config.in_memory_regexp_time_limit = 0.2
232
+
233
+ expect do
234
+ described_class.open_with(nil) { sleep 0.3 }
235
+ end.not_to raise_error
236
+ end
237
+
238
+ it 'leaves an enclosing budget alone' do
239
+ described_class.open(regexp_selector) do
240
+ described_class.open_with(nil) do
241
+ expect(described_class.remaining).to be_within(0.01).of(5.0)
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ describe '.match?' do
249
+ context 'when no budget is open' do
250
+ it 'performs the match' do
251
+ expect(described_class.match?('abc', cheap_pattern)).to eq(0)
252
+ end
253
+
254
+ it 'returns nil when the value does not match' do
255
+ expect(described_class.match?('xyz', cheap_pattern)).to be_nil
256
+ end
257
+
258
+ it "leaves an application's own Regexp timeout alone" do
259
+ # With nothing of ours to blame it on, translating the error would name
260
+ # a limit that is not set and advise unsetting it.
261
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
262
+
263
+ pattern = Regexp.new(catastrophic_pattern.pattern, timeout: 0.2)
264
+
265
+ expect do
266
+ described_class.match?(catastrophic_subject, pattern)
267
+ end.to raise_error(Regexp::TimeoutError)
268
+ end
269
+ end
270
+
271
+ context 'when a budget is open' do
272
+ config_override :in_memory_regexp_time_limit, 5.0
273
+
274
+ it 'performs the match' do
275
+ described_class.open(regexp_selector) do
276
+ expect(described_class.match?('abc', cheap_pattern)).to eq(0)
277
+ end
278
+ end
279
+
280
+ it 'accepts an already compiled Regexp' do
281
+ described_class.open(regexp_selector) do
282
+ expect(described_class.match?('abc', /\Aabc\z/)).to eq(0)
283
+ end
284
+ end
285
+
286
+ it 'preserves the options of the condition' do
287
+ described_class.open(regexp_selector) do
288
+ expect(described_class.match?('ABC', BSON::Regexp::Raw.new('\Aabc\z', 'i'))).to eq(0)
289
+ end
290
+ end
291
+
292
+ it 'preserves the encoding of the condition' do
293
+ # The condition is rebuilt to carry the timeout, so its source, options
294
+ # and encoding all have to survive the round trip.
295
+ described_class.open(regexp_selector) do
296
+ expect(described_class.match?('é', /\Aé+\z/u)).to eq(0)
297
+ end
298
+ end
299
+
300
+ it 'charges the elapsed time against the budget' do
301
+ described_class.open(regexp_selector) do
302
+ before = described_class.remaining
303
+ described_class.match?(slow_subject, slow_pattern)
304
+ expect(described_class.remaining).to be < before
305
+ end
306
+ end
307
+
308
+ it 'compiles a pattern once for the scope rather than once per match' do
309
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
310
+
311
+ # An already compiled condition, so that the only Regexp.new in play is
312
+ # the one baking in the timeout.
313
+ allow(Regexp).to receive(:new).and_call_original
314
+
315
+ described_class.open(regexp_selector) do
316
+ 10.times { described_class.match?('abc', /\Aabc\z/) }
317
+ end
318
+
319
+ expect(Regexp).to have_received(:new).once
320
+ end
321
+ end
322
+
323
+ context 'when the patterns are expensive to compile but cheap to run' do
324
+ config_override :in_memory_regexp_time_limit, nil
325
+
326
+ # Thousands of alternations cost far more to compile than to run against
327
+ # a subject that fails at the first character. Charging only the match
328
+ # would leave a selector full of these -- a long $or, say -- able to
329
+ # spend as much time as it liked without the budget noticing.
330
+ let(:costly_to_compile) do
331
+ Array.new(200) do |n|
332
+ BSON::Regexp::Raw.new("(?:#{(1..2_000).map { |i| "b#{n}x#{i}" }.join('|')})Z")
333
+ end
334
+ end
335
+
336
+ before do
337
+ # Building the fixtures is not free either, so it happens before the
338
+ # clock starts rather than inside the block being measured.
339
+ pattern = costly_to_compile.first.pattern
340
+ cost = Benchmark.realtime { Regexp.new(pattern) }
341
+ Mongoid::Config.in_memory_regexp_time_limit = cost * 5
342
+ end
343
+
344
+ it 'charges compilation against the budget' do
345
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
346
+
347
+ expect do
348
+ described_class.open(regexp_selector) do
349
+ costly_to_compile.each { |pattern| described_class.match?('x', pattern) }
350
+ end
351
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
352
+ end
353
+ end
354
+
355
+ context 'when the application has set a stricter Regexp timeout' do
356
+ config_override :in_memory_regexp_time_limit, 5.0
357
+
358
+ around do |example|
359
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
360
+
361
+ was = Regexp.timeout
362
+ Regexp.timeout = 0.2
363
+ begin
364
+ example.run
365
+ ensure
366
+ Regexp.timeout = was
367
+ end
368
+ end
369
+
370
+ it 'does not loosen it to the budget limit' do
371
+ # Baking the whole limit in would override the global and leave the
372
+ # application less protected than it configured itself to be.
373
+ elapsed = Benchmark.realtime do
374
+ expect do
375
+ described_class.open(regexp_selector) do
376
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
377
+ end
378
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
379
+ end
380
+
381
+ expect(elapsed).to be < 1.0
382
+ end
383
+
384
+ it 'names the limit that fired rather than the configured one' do
385
+ # Naming the configured 5.0 would state a time that was never spent,
386
+ # and send the reader after a setting that is not the one in the way.
387
+ expect do
388
+ described_class.open(regexp_selector) do
389
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
390
+ end
391
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the 0\.2 second limit/)
392
+ end
393
+ end
394
+
395
+ context 'when a global Regexp timeout fires with nothing baked' do
396
+ config_override :in_memory_regexp_time_limit, 5.0
397
+
398
+ # The counterpart to the context above, for the engine that raises
399
+ # Regexp::TimeoutError but will not take a per-Regexp timeout: JRuby. The
400
+ # pattern carries nothing there, so it reports nil and the global is the
401
+ # only thing that can have fired.
402
+ around do |example|
403
+ skip 'no Regexp timeouts at all' unless defined?(Regexp::TimeoutError)
404
+
405
+ was = Regexp.timeout
406
+ Regexp.timeout = 0.2
407
+ begin
408
+ example.run
409
+ ensure
410
+ Regexp.timeout = was
411
+ end
412
+ end
413
+
414
+ before { stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false) }
415
+
416
+ it 'names the global limit rather than the budget limit' do
417
+ expect do
418
+ described_class.open_with(5.0) do
419
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
420
+ end
421
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the 0\.2 second limit/)
422
+ end
423
+ end
424
+
425
+ context 'when the accumulated cost exceeds the limit' do
426
+ config_override :in_memory_regexp_time_limit, nil
427
+
428
+ # Calibrated rather than hard-coded: one match of the fixture costs about
429
+ # 6ms on MRI and 110ms on JRuby, so a fixed limit would be either
430
+ # unreachable on one or tripped by a single match on the other.
431
+ before do
432
+ regexp = Regexp.new(slow_pattern.pattern)
433
+ 3.times { slow_subject =~ regexp }
434
+ cost = Benchmark.realtime { slow_subject =~ regexp }
435
+ Mongoid::Config.in_memory_regexp_time_limit = cost * 5
436
+ end
437
+
438
+ it 'raises once the budget is spent' do
439
+ expect do
440
+ described_class.open(regexp_selector) do
441
+ 100.times { described_class.match?(slow_subject, slow_pattern) }
442
+ end
443
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
444
+ end
445
+
446
+ it 'names the configured limit' do
447
+ # The counterpart to the stricter-Regexp.timeout example above: where
448
+ # the budget itself is what ran out, its own limit is the one to name.
449
+ limit = Regexp.escape(Mongoid::Config.in_memory_regexp_time_limit.to_s)
450
+
451
+ expect do
452
+ described_class.open(regexp_selector) do
453
+ 100.times { described_class.match?(slow_subject, slow_pattern) }
454
+ end
455
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the #{limit} second limit/)
456
+ end
457
+
458
+ it 'does not raise for a single match under that same limit' do
459
+ expect do
460
+ described_class.open(regexp_selector) { described_class.match?(slow_subject, slow_pattern) }
461
+ end.not_to raise_error
462
+ end
463
+
464
+ it 'does not raise when no single match and no accumulation exceeds the limit' do
465
+ expect do
466
+ described_class.open(regexp_selector) do
467
+ 100.times { described_class.match?('abc', cheap_pattern) }
468
+ end
469
+ end.not_to raise_error
470
+ end
471
+ end
472
+ end
473
+
474
+ context 'when a single match runs far longer than the limit' do
475
+ config_override :in_memory_regexp_time_limit, 0.2
476
+
477
+ context 'when the Ruby in use supports per-Regexp timeouts' do
478
+ # Keyed to the capability, not the version: JRuby 10 reports Ruby 3.4
479
+ # but cannot be given a per-Regexp timeout, so it runs the Timeout path.
480
+ before do
481
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
482
+ end
483
+
484
+ it 'interrupts the match' do
485
+ expect do
486
+ described_class.open(regexp_selector) do
487
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
488
+ end
489
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
490
+ end
491
+ end
492
+
493
+ context 'when the Ruby in use has no per-Regexp timeouts' do
494
+ before do
495
+ stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
496
+ end
497
+
498
+ it 'interrupts the match with Timeout' do
499
+ expect do
500
+ described_class.open(regexp_selector) do
501
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
502
+ end
503
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
504
+ end
505
+
506
+ it 'interrupts an unprotected block part way through' do
507
+ completed = false
508
+
509
+ expect do
510
+ described_class.open(regexp_selector) do
511
+ sleep 0.3
512
+ completed = true
513
+ end
514
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
515
+
516
+ expect(completed).to be(false)
517
+ end
518
+
519
+ it 'lets a protected block finish before delivering the interruption' do
520
+ # remove_all mutates association state inside its scope. Deferring the
521
+ # exception, rather than forgoing it, is what lets that scope stay
522
+ # interruptible without unbind_one being torn in half.
523
+ completed = false
524
+
525
+ expect do
526
+ described_class.open(regexp_selector) do
527
+ described_class.protect do
528
+ sleep 0.3
529
+ completed = true
530
+ end
531
+ end
532
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
533
+
534
+ expect(completed).to be(true)
535
+ end
536
+ end
537
+ end
538
+
539
+ describe Mongoid::Matcher::RegexpBudget::Budget do
540
+ describe '#compile' do
541
+ let(:budget) { described_class.new(5.0) }
542
+
543
+ it 'returns a pattern matching the condition' do
544
+ expect(budget.compile(BSON::Regexp::Raw.new('\Aabc\z'))).to match('abc')
545
+ end
546
+
547
+ it 'reuses the pattern compiled for an equal condition' do
548
+ # FieldExpression builds a fresh BSON::Regexp::Raw for every $regex it
549
+ # evaluates, so its own memo is worth nothing from one document to the
550
+ # next and the source would be compiled once per document. Raw does not
551
+ # override hash, either, so the cache cannot be keyed on the condition
552
+ # itself.
553
+ first = budget.compile(BSON::Regexp::Raw.new('abc', 'i'))
554
+ second = budget.compile(BSON::Regexp::Raw.new('abc', 'i'))
555
+
556
+ expect(second).to equal(first)
557
+ end
558
+
559
+ it 'does not confuse conditions differing only in their options' do
560
+ insensitive = budget.compile(BSON::Regexp::Raw.new('abc', 'i'))
561
+ sensitive = budget.compile(BSON::Regexp::Raw.new('abc'))
562
+
563
+ expect(insensitive).to match('ABC')
564
+ expect(sensitive).not_to match('ABC')
565
+ end
566
+
567
+ it 'reuses the pattern compiled for an equal Regexp' do
568
+ expect(budget.compile(/abc/i)).to equal(budget.compile(/abc/i))
569
+ end
570
+
571
+ it 'raises for a condition that is not a regular expression' do
572
+ expect { budget.compile('abc') }.to raise_error(ArgumentError, /Not a regular expression/)
573
+ end
574
+ end
575
+ end
576
+ end
@@ -62,7 +62,7 @@ class SearchIndexHelper
62
62
  end
63
63
 
64
64
  # rubocop:disable RSpec/MultipleMemoizedHelpers
65
- describe Mongoid::SearchIndexable do
65
+ describe Mongoid::SearchIndexable do # rubocop:disable RSpec/MultipleDescribes
66
66
  before do
67
67
  skip "#{described_class} requires at Atlas environment (set ATLAS_URI)" if ENV['ATLAS_URI'].nil?
68
68
  end
@@ -145,3 +145,36 @@ describe Mongoid::SearchIndexable do
145
145
  end
146
146
  end
147
147
  # rubocop:enable RSpec/MultipleMemoizedHelpers
148
+
149
+ # A unit test that does not need an Atlas connection.
150
+ describe Mongoid::SearchIndexable, '.wait_for_search_indexes' do
151
+ # Mongo::Collection#search_indexes was added in driver 2.19; the
152
+ # driver-min and driver-oldstable CI jobs pin the driver to 2.18-stable.
153
+ min_driver_version '2.19'
154
+
155
+ context 'when an index is ready' do
156
+ let(:model) do
157
+ Class.new do
158
+ include Mongoid::Document
159
+
160
+ store_in collection: BSON::ObjectId.new.to_s
161
+ search_index :idx, mappings: { dynamic: false }
162
+ end
163
+ end
164
+
165
+ it 'reads the index status inside collection management' do
166
+ managing = nil
167
+ search_indexes = [ { 'name' => 'idx', 'queryable' => true, 'status' => 'READY' } ]
168
+ collection = instance_double(Mongo::Collection)
169
+ allow(collection).to receive(:search_indexes) do
170
+ managing = Mongoid::Threaded.managing_collection?
171
+ search_indexes
172
+ end
173
+ allow(model).to receive(:collection).and_return(collection)
174
+
175
+ model.wait_for_search_indexes([ 'idx' ])
176
+
177
+ expect(managing).to be true
178
+ end
179
+ end
180
+ end
@@ -73,6 +73,24 @@ describe Mongoid::Tasks::Database do
73
73
  end
74
74
 
75
75
  describe '.create_collections' do
76
+ # Creating a collection sends no document data, so it must not require a
77
+ # client that can encrypt, even for a model that declares encrypted
78
+ # fields. Otherwise this task cannot run without KMS credentials.
79
+ context 'when a model declares encrypted fields' do
80
+ before do
81
+ require 'support/crypt/models'
82
+ end
83
+
84
+ after do
85
+ Mongoid.default_client[Crypt::Patient.collection_name].drop
86
+ end
87
+
88
+ it 'creates the collection using a client without automatic encryption' do
89
+ expect { Mongoid::Tasks::Database.create_collections([ Crypt::Patient ]) }
90
+ .not_to raise_error
91
+ end
92
+ end
93
+
76
94
  context 'collection_options are specified' do
77
95
  let(:models) do
78
96
  [DatabaseSpec::Measurement]
@@ -247,6 +265,19 @@ describe Mongoid::Tasks::Database do
247
265
  described_class.create_search_indexes([searchable_model_spy], wait: false)
248
266
  end
249
267
  end
268
+
269
+ context 'when waiting for the indexes' do
270
+ it 'runs the wait inside collection management' do
271
+ managing = nil
272
+ allow(described_class).to receive(:wait_for_search_indexes) do
273
+ managing = Mongoid::Threaded.managing_collection?
274
+ end
275
+
276
+ described_class.create_search_indexes([ searchable_model_spy ], wait: true)
277
+
278
+ expect(managing).to be true
279
+ end
280
+ end
250
281
  end
251
282
 
252
283
  describe '.remove_search_indexes' do