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
@@ -0,0 +1,570 @@
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 '.open' do
36
+ context 'when no limit is configured' do
37
+ config_override :in_memory_regexp_time_limit, nil
38
+
39
+ it 'does not establish a budget' do
40
+ described_class.open(regexp_selector) do
41
+ expect(described_class.remaining).to be_nil
42
+ end
43
+ end
44
+ end
45
+
46
+ context 'when the configured limit is zero' do
47
+ config_override :in_memory_regexp_time_limit, 0
48
+
49
+ # Zero is a common way to spell "disabled". Taken literally it would mean
50
+ # a budget spent before the first match, so every in-memory query
51
+ # carrying a pattern would raise rather than run.
52
+ it 'does not establish a budget' do
53
+ described_class.open(regexp_selector) do
54
+ expect(described_class.remaining).to be_nil
55
+ end
56
+ end
57
+
58
+ it 'still performs matches' do
59
+ result = described_class.open(regexp_selector) do
60
+ described_class.match?('abc', cheap_pattern)
61
+ end
62
+
63
+ expect(result).to eq(0)
64
+ end
65
+ end
66
+
67
+ context 'when a limit is configured' do
68
+ config_override :in_memory_regexp_time_limit, 5.0
69
+
70
+ it 'establishes the budget for the duration of the block' do
71
+ described_class.open(regexp_selector) do
72
+ expect(described_class.remaining).to be_within(0.01).of(5.0)
73
+ end
74
+ end
75
+
76
+ it 'returns the value of the block' do
77
+ expect(described_class.open(regexp_selector) { :result }).to eq(:result)
78
+ end
79
+
80
+ it 'clears the budget when the block returns' do
81
+ described_class.open(regexp_selector) { nil }
82
+ expect(described_class.remaining).to be_nil
83
+ end
84
+
85
+ it 'clears the budget when the block raises' do
86
+ expect { described_class.open(regexp_selector) { raise 'boom' } }.to raise_error('boom')
87
+ expect(described_class.remaining).to be_nil
88
+ end
89
+
90
+ it 'joins the enclosing budget rather than starting a new one' do
91
+ described_class.open(regexp_selector) do
92
+ described_class.match?(slow_subject, slow_pattern)
93
+ spent = 5.0 - described_class.remaining
94
+ expect(spent).to be > 0
95
+
96
+ described_class.open(regexp_selector) do
97
+ expect(described_class.remaining).to be_within(0.01).of(5.0 - spent)
98
+ end
99
+ end
100
+ end
101
+
102
+ context 'when the selector carries no regular expression' do
103
+ it 'does not establish a budget' do
104
+ described_class.open('name' => 'abc') do
105
+ expect(described_class.remaining).to be_nil
106
+ end
107
+ end
108
+
109
+ it 'does not put a deadline on the block' do
110
+ # The limit bounds regular expressions, not in-memory work at large.
111
+ # On the Timeout path a scope with nothing to bound used to fail any
112
+ # slow scan -- an embedded association of any size, say -- with an
113
+ # error about regular expressions.
114
+ stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
115
+ Mongoid::Config.in_memory_regexp_time_limit = 0.2
116
+
117
+ expect do
118
+ described_class.open('name' => 'abc') { sleep 0.3 }
119
+ end.not_to raise_error
120
+ end
121
+
122
+ it 'keeps a nested scope from scanning the selector again' do
123
+ described_class.open('name' => 'abc') do
124
+ expect(Thread.current.key?(Mongoid::Threaded::REGEXP_BUDGET_KEY)).to be(true)
125
+ end
126
+ end
127
+ end
128
+
129
+ context 'when the selector carries a string $regex' do
130
+ # FieldExpression turns it into a pattern at match time, so it needs
131
+ # bounding just as much as a Regexp written out in the selector does.
132
+ it 'establishes a budget' do
133
+ described_class.open('name' => { '$regex' => 'abc' }) do
134
+ expect(described_class.remaining).to be_within(0.01).of(5.0)
135
+ end
136
+ end
137
+ end
138
+
139
+ context 'when the selector nests a regular expression' do
140
+ it 'establishes a budget' do
141
+ described_class.open('$or' => [ { 'name' => 'abc' }, { 'name' => /abc/ } ]) do
142
+ expect(described_class.remaining).to be_within(0.01).of(5.0)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ describe '.limit_for' do
150
+ context 'when a limit is configured' do
151
+ config_override :in_memory_regexp_time_limit, 5.0
152
+
153
+ it 'is the limit for a selector carrying a regular expression' do
154
+ expect(described_class.limit_for(regexp_selector)).to eq(5.0)
155
+ end
156
+
157
+ it 'is the limit for a selector carrying a string $regex' do
158
+ expect(described_class.limit_for('name' => { '$regex' => 'abc' })).to eq(5.0)
159
+ end
160
+
161
+ it 'is the limit for a selector nesting a regular expression' do
162
+ expect(described_class.limit_for('$or' => [ { 'name' => 'abc' }, { 'name' => /abc/ } ])).to eq(5.0)
163
+ end
164
+
165
+ it 'is nil for a selector carrying no regular expression' do
166
+ expect(described_class.limit_for('name' => 'abc')).to be_nil
167
+ end
168
+
169
+ it 'is nil for an empty selector' do
170
+ expect(described_class.limit_for({})).to be_nil
171
+ end
172
+ end
173
+
174
+ context 'when no limit is configured' do
175
+ config_override :in_memory_regexp_time_limit, nil
176
+
177
+ # Nothing would be bounded, so a caller that only rearranges its work to
178
+ # make room for a budget has no reason to.
179
+ it 'is nil even for a selector carrying a regular expression' do
180
+ expect(described_class.limit_for(regexp_selector)).to be_nil
181
+ end
182
+ end
183
+
184
+ context 'when the configured limit is zero' do
185
+ config_override :in_memory_regexp_time_limit, 0
186
+
187
+ it 'is nil even for a selector carrying a regular expression' do
188
+ expect(described_class.limit_for(regexp_selector)).to be_nil
189
+ end
190
+ end
191
+ end
192
+
193
+ describe '.open_with' do
194
+ config_override :in_memory_regexp_time_limit, 5.0
195
+
196
+ it 'establishes a budget for the limit it is given, not the configured one' do
197
+ described_class.open_with(2.0) do
198
+ expect(described_class.remaining).to be_within(0.01).of(2.0)
199
+ end
200
+ end
201
+
202
+ it 'returns the value of the block' do
203
+ expect(described_class.open_with(2.0) { :result }).to eq(:result)
204
+ end
205
+
206
+ it 'clears the budget when the block returns' do
207
+ described_class.open_with(2.0) { nil }
208
+ expect(described_class.remaining).to be_nil
209
+ end
210
+
211
+ context 'when given no limit' do
212
+ it 'does not establish a budget' do
213
+ described_class.open_with(nil) do
214
+ expect(described_class.remaining).to be_nil
215
+ end
216
+ end
217
+
218
+ # A caller that read the limit, found nothing to bound and arranged its
219
+ # work accordingly must not have a deadline imposed on it by a later read
220
+ # of the same setting. On the Timeout path that deadline would cover
221
+ # whatever the caller went on to do, which here is a query and a mutation
222
+ # of every match.
223
+ it 'does not put a deadline on the block' do
224
+ stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
225
+ Mongoid::Config.in_memory_regexp_time_limit = 0.2
226
+
227
+ expect do
228
+ described_class.open_with(nil) { sleep 0.3 }
229
+ end.not_to raise_error
230
+ end
231
+
232
+ it 'leaves an enclosing budget alone' do
233
+ described_class.open(regexp_selector) do
234
+ described_class.open_with(nil) do
235
+ expect(described_class.remaining).to be_within(0.01).of(5.0)
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+
242
+ describe '.match?' do
243
+ context 'when no budget is open' do
244
+ it 'performs the match' do
245
+ expect(described_class.match?('abc', cheap_pattern)).to eq(0)
246
+ end
247
+
248
+ it 'returns nil when the value does not match' do
249
+ expect(described_class.match?('xyz', cheap_pattern)).to be_nil
250
+ end
251
+
252
+ it "leaves an application's own Regexp timeout alone" do
253
+ # With nothing of ours to blame it on, translating the error would name
254
+ # a limit that is not set and advise unsetting it.
255
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
256
+
257
+ pattern = Regexp.new(catastrophic_pattern.pattern, timeout: 0.2)
258
+
259
+ expect do
260
+ described_class.match?(catastrophic_subject, pattern)
261
+ end.to raise_error(Regexp::TimeoutError)
262
+ end
263
+ end
264
+
265
+ context 'when a budget is open' do
266
+ config_override :in_memory_regexp_time_limit, 5.0
267
+
268
+ it 'performs the match' do
269
+ described_class.open(regexp_selector) do
270
+ expect(described_class.match?('abc', cheap_pattern)).to eq(0)
271
+ end
272
+ end
273
+
274
+ it 'accepts an already compiled Regexp' do
275
+ described_class.open(regexp_selector) do
276
+ expect(described_class.match?('abc', /\Aabc\z/)).to eq(0)
277
+ end
278
+ end
279
+
280
+ it 'preserves the options of the condition' do
281
+ described_class.open(regexp_selector) do
282
+ expect(described_class.match?('ABC', BSON::Regexp::Raw.new('\Aabc\z', 'i'))).to eq(0)
283
+ end
284
+ end
285
+
286
+ it 'preserves the encoding of the condition' do
287
+ # The condition is rebuilt to carry the timeout, so its source, options
288
+ # and encoding all have to survive the round trip.
289
+ described_class.open(regexp_selector) do
290
+ expect(described_class.match?('é', /\Aé+\z/u)).to eq(0)
291
+ end
292
+ end
293
+
294
+ it 'charges the elapsed time against the budget' do
295
+ described_class.open(regexp_selector) do
296
+ before = described_class.remaining
297
+ described_class.match?(slow_subject, slow_pattern)
298
+ expect(described_class.remaining).to be < before
299
+ end
300
+ end
301
+
302
+ it 'compiles a pattern once for the scope rather than once per match' do
303
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
304
+
305
+ # An already compiled condition, so that the only Regexp.new in play is
306
+ # the one baking in the timeout.
307
+ allow(Regexp).to receive(:new).and_call_original
308
+
309
+ described_class.open(regexp_selector) do
310
+ 10.times { described_class.match?('abc', /\Aabc\z/) }
311
+ end
312
+
313
+ expect(Regexp).to have_received(:new).once
314
+ end
315
+ end
316
+
317
+ context 'when the patterns are expensive to compile but cheap to run' do
318
+ config_override :in_memory_regexp_time_limit, nil
319
+
320
+ # Thousands of alternations cost far more to compile than to run against
321
+ # a subject that fails at the first character. Charging only the match
322
+ # would leave a selector full of these -- a long $or, say -- able to
323
+ # spend as much time as it liked without the budget noticing.
324
+ let(:costly_to_compile) do
325
+ Array.new(200) do |n|
326
+ BSON::Regexp::Raw.new("(?:#{(1..2_000).map { |i| "b#{n}x#{i}" }.join('|')})Z")
327
+ end
328
+ end
329
+
330
+ before do
331
+ # Building the fixtures is not free either, so it happens before the
332
+ # clock starts rather than inside the block being measured.
333
+ pattern = costly_to_compile.first.pattern
334
+ cost = Benchmark.realtime { Regexp.new(pattern) }
335
+ Mongoid::Config.in_memory_regexp_time_limit = cost * 5
336
+ end
337
+
338
+ it 'charges compilation against the budget' do
339
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
340
+
341
+ expect do
342
+ described_class.open(regexp_selector) do
343
+ costly_to_compile.each { |pattern| described_class.match?('x', pattern) }
344
+ end
345
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
346
+ end
347
+ end
348
+
349
+ context 'when the application has set a stricter Regexp timeout' do
350
+ config_override :in_memory_regexp_time_limit, 5.0
351
+
352
+ around do |example|
353
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
354
+
355
+ was = Regexp.timeout
356
+ Regexp.timeout = 0.2
357
+ begin
358
+ example.run
359
+ ensure
360
+ Regexp.timeout = was
361
+ end
362
+ end
363
+
364
+ it 'does not loosen it to the budget limit' do
365
+ # Baking the whole limit in would override the global and leave the
366
+ # application less protected than it configured itself to be.
367
+ elapsed = Benchmark.realtime do
368
+ expect do
369
+ described_class.open(regexp_selector) do
370
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
371
+ end
372
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
373
+ end
374
+
375
+ expect(elapsed).to be < 1.0
376
+ end
377
+
378
+ it 'names the limit that fired rather than the configured one' do
379
+ # Naming the configured 5.0 would state a time that was never spent,
380
+ # and send the reader after a setting that is not the one in the way.
381
+ expect do
382
+ described_class.open(regexp_selector) do
383
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
384
+ end
385
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the 0\.2 second limit/)
386
+ end
387
+ end
388
+
389
+ context 'when a global Regexp timeout fires with nothing baked' do
390
+ config_override :in_memory_regexp_time_limit, 5.0
391
+
392
+ # The counterpart to the context above, for the engine that raises
393
+ # Regexp::TimeoutError but will not take a per-Regexp timeout: JRuby. The
394
+ # pattern carries nothing there, so it reports nil and the global is the
395
+ # only thing that can have fired.
396
+ around do |example|
397
+ skip 'no Regexp timeouts at all' unless defined?(Regexp::TimeoutError)
398
+
399
+ was = Regexp.timeout
400
+ Regexp.timeout = 0.2
401
+ begin
402
+ example.run
403
+ ensure
404
+ Regexp.timeout = was
405
+ end
406
+ end
407
+
408
+ before { stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false) }
409
+
410
+ it 'names the global limit rather than the budget limit' do
411
+ expect do
412
+ described_class.open_with(5.0) do
413
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
414
+ end
415
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the 0\.2 second limit/)
416
+ end
417
+ end
418
+
419
+ context 'when the accumulated cost exceeds the limit' do
420
+ config_override :in_memory_regexp_time_limit, nil
421
+
422
+ # Calibrated rather than hard-coded: one match of the fixture costs about
423
+ # 6ms on MRI and 110ms on JRuby, so a fixed limit would be either
424
+ # unreachable on one or tripped by a single match on the other.
425
+ before do
426
+ regexp = Regexp.new(slow_pattern.pattern)
427
+ 3.times { slow_subject =~ regexp }
428
+ cost = Benchmark.realtime { slow_subject =~ regexp }
429
+ Mongoid::Config.in_memory_regexp_time_limit = cost * 5
430
+ end
431
+
432
+ it 'raises once the budget is spent' do
433
+ expect do
434
+ described_class.open(regexp_selector) do
435
+ 100.times { described_class.match?(slow_subject, slow_pattern) }
436
+ end
437
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
438
+ end
439
+
440
+ it 'names the configured limit' do
441
+ # The counterpart to the stricter-Regexp.timeout example above: where
442
+ # the budget itself is what ran out, its own limit is the one to name.
443
+ limit = Regexp.escape(Mongoid::Config.in_memory_regexp_time_limit.to_s)
444
+
445
+ expect do
446
+ described_class.open(regexp_selector) do
447
+ 100.times { described_class.match?(slow_subject, slow_pattern) }
448
+ end
449
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the #{limit} second limit/)
450
+ end
451
+
452
+ it 'does not raise for a single match under that same limit' do
453
+ expect do
454
+ described_class.open(regexp_selector) { described_class.match?(slow_subject, slow_pattern) }
455
+ end.not_to raise_error
456
+ end
457
+
458
+ it 'does not raise when no single match and no accumulation exceeds the limit' do
459
+ expect do
460
+ described_class.open(regexp_selector) do
461
+ 100.times { described_class.match?('abc', cheap_pattern) }
462
+ end
463
+ end.not_to raise_error
464
+ end
465
+ end
466
+ end
467
+
468
+ context 'when a single match runs far longer than the limit' do
469
+ config_override :in_memory_regexp_time_limit, 0.2
470
+
471
+ context 'when the Ruby in use supports per-Regexp timeouts' do
472
+ # Keyed to the capability, not the version: JRuby 10 reports Ruby 3.4
473
+ # but cannot be given a per-Regexp timeout, so it runs the Timeout path.
474
+ before do
475
+ skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT
476
+ end
477
+
478
+ it 'interrupts the match' do
479
+ expect do
480
+ described_class.open(regexp_selector) do
481
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
482
+ end
483
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
484
+ end
485
+ end
486
+
487
+ context 'when the Ruby in use has no per-Regexp timeouts' do
488
+ before do
489
+ stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
490
+ end
491
+
492
+ it 'interrupts the match with Timeout' do
493
+ expect do
494
+ described_class.open(regexp_selector) do
495
+ described_class.match?(catastrophic_subject, catastrophic_pattern)
496
+ end
497
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
498
+ end
499
+
500
+ it 'interrupts an unprotected block part way through' do
501
+ completed = false
502
+
503
+ expect do
504
+ described_class.open(regexp_selector) do
505
+ sleep 0.3
506
+ completed = true
507
+ end
508
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
509
+
510
+ expect(completed).to be(false)
511
+ end
512
+
513
+ it 'lets a protected block finish before delivering the interruption' do
514
+ # remove_all mutates association state inside its scope. Deferring the
515
+ # exception, rather than forgoing it, is what lets that scope stay
516
+ # interruptible without unbind_one being torn in half.
517
+ completed = false
518
+
519
+ expect do
520
+ described_class.open(regexp_selector) do
521
+ described_class.protect do
522
+ sleep 0.3
523
+ completed = true
524
+ end
525
+ end
526
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
527
+
528
+ expect(completed).to be(true)
529
+ end
530
+ end
531
+ end
532
+
533
+ describe Mongoid::Matcher::RegexpBudget::Budget do
534
+ describe '#compile' do
535
+ let(:budget) { described_class.new(5.0) }
536
+
537
+ it 'returns a pattern matching the condition' do
538
+ expect(budget.compile(BSON::Regexp::Raw.new('\Aabc\z'))).to match('abc')
539
+ end
540
+
541
+ it 'reuses the pattern compiled for an equal condition' do
542
+ # FieldExpression builds a fresh BSON::Regexp::Raw for every $regex it
543
+ # evaluates, so its own memo is worth nothing from one document to the
544
+ # next and the source would be compiled once per document. Raw does not
545
+ # override hash, either, so the cache cannot be keyed on the condition
546
+ # itself.
547
+ first = budget.compile(BSON::Regexp::Raw.new('abc', 'i'))
548
+ second = budget.compile(BSON::Regexp::Raw.new('abc', 'i'))
549
+
550
+ expect(second).to equal(first)
551
+ end
552
+
553
+ it 'does not confuse conditions differing only in their options' do
554
+ insensitive = budget.compile(BSON::Regexp::Raw.new('abc', 'i'))
555
+ sensitive = budget.compile(BSON::Regexp::Raw.new('abc'))
556
+
557
+ expect(insensitive).to match('ABC')
558
+ expect(sensitive).not_to match('ABC')
559
+ end
560
+
561
+ it 'reuses the pattern compiled for an equal Regexp' do
562
+ expect(budget.compile(/abc/i)).to equal(budget.compile(/abc/i))
563
+ end
564
+
565
+ it 'raises for a condition that is not a regular expression' do
566
+ expect { budget.compile('abc') }.to raise_error(ArgumentError, /Not a regular expression/)
567
+ end
568
+ end
569
+ end
570
+ end
@@ -149,6 +149,18 @@ describe Mongoid::Touchable do
149
149
  building.updated_at.should == update_time
150
150
  end
151
151
  end
152
+
153
+ context 'when not updating an additional field' do
154
+ it 'does not call set_field_atomic_updates' do
155
+ # Regression test for MONGOID-5833
156
+ entrance
157
+ update_time
158
+ expect(entrance).not_to receive(:set_field_atomic_updates)
159
+ entrance.touch
160
+
161
+ entrance.updated_at.should == update_time
162
+ end
163
+ end
152
164
  end
153
165
 
154
166
  context "when the document is referenced" do
@@ -0,0 +1,28 @@
1
+ # Candidate Tasks
2
+
3
+ When using the `candidate` rake tasks, you must make sure:
4
+
5
+ 1. You are using at least `git` version 2.49.0.
6
+ 2. You have the `gh` CLI tool installed.
7
+ 3. You are logged into `gh` with an account that has collaborator access to the repository.
8
+ 4. You have run `gh repo set-default` from the root of your local checkout to set the default repository to the canonical MongoDB repo.
9
+ 5. The `origin` remote for your local checkout is set to your own fork.
10
+ 6. The `upstream` remote for your local checkout is set to the canonical
11
+ MongoDB repo.
12
+
13
+ Once configured, you can use the following commands:
14
+
15
+ 1. `rake candidate:prs` - This will list all pull requests that will be included in the next release. Any with `[?]` are unlabelled (or are not labelled with a recognized label). Otherwise, `[b]` means `bug`, `[f]` means `feature`, and `[x]` means `bcbreak`.
16
+ 2. `rake candidate:preview` - This will generate and display the release notes for the next release, based on the associated pull requests.
17
+ 3. `rake candidate:create` - This will create a new PR against the default repository, using the generated release notes as the description. The new PR will be given the `release-candidate` label.
18
+
19
+ Then, after the release candidate PR is approved and merged, the release process will automatically bundle, sign, and release the new version.
20
+
21
+ Once you've merged the PR, you can switch to the "Actions" tab for the repository on GitHub and look for the "Release" workflow (might be named differently), which should have triggered automatically. You can monitor the progress of the release there. If there are any problems, the workflow is generally safe to re-run after you've addressed them.
22
+
23
+ Things to do after the release succeeds:
24
+
25
+ 1. Copy the release notes from the PR and create a new release announcement on the forums (https://www.mongodb.com/community/forums/c/announcements/driver-releases/110).
26
+ 2. If the release was not automatically announced in #ruby, copy a link to the GitHub release or MongoDB forum post there.
27
+ 3. Close the release in Jira.
28
+
@@ -25,19 +25,19 @@ module Mrss
25
25
  end
26
26
 
27
27
  def initialize(root: nil, classifiers:, priority_order:,
28
- spec_root: nil, rspec_json_path: nil, rspec_all_json_path: nil,
29
- randomize: false
28
+ spec_root: nil, rspec_json_path: nil, rspec_all_json_path: nil, rspec_xml_path: nil, randomize: false
30
29
  )
31
30
  @spec_root = spec_root || File.join(root, 'spec')
32
31
  @classifiers = classifiers
33
32
  @priority_order = priority_order
34
33
  @rspec_json_path = rspec_json_path || File.join(root, 'tmp/rspec.json')
35
34
  @rspec_all_json_path = rspec_all_json_path || File.join(root, 'tmp/rspec-all.json')
35
+ @rspec_xml_path = rspec_xml_path || File.join(root, 'tmp/rspec.xml')
36
36
  @randomize = !!randomize
37
37
  end
38
38
 
39
39
  attr_reader :spec_root, :classifiers, :priority_order
40
- attr_reader :rspec_json_path, :rspec_all_json_path
40
+ attr_reader :rspec_json_path, :rspec_all_json_path, :rspec_xml_path
41
41
 
42
42
  def randomize?
43
43
  @randomize
@@ -47,6 +47,25 @@ module Mrss
47
47
  @seed ||= (rand * 100_000).to_i
48
48
  end
49
49
 
50
+ # Remove all XML files from tmp directory before running tests
51
+ def cleanup_xml_files
52
+ xml_pattern = File.join(File.dirname(rspec_xml_path), '*.xml')
53
+ Dir.glob(xml_pattern).each do |xml_file|
54
+ FileUtils.rm_f(xml_file)
55
+ end
56
+ end
57
+
58
+ # Move the XML file to a timestamped version for evergreen upload
59
+ def archive_xml_file(category)
60
+ return unless File.exist?(rspec_xml_path)
61
+
62
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%3N')
63
+ archived_path = rspec_xml_path.sub(/\.xml$/, "-#{category}-#{timestamp}.xml")
64
+
65
+ FileUtils.mv(rspec_xml_path, archived_path)
66
+ puts "Archived XML results to #{archived_path}"
67
+ end
68
+
50
69
  def buckets
51
70
  @buckets ||= {}.tap do |buckets|
52
71
  Find.find(spec_root) do |path|
@@ -96,6 +115,8 @@ module Mrss
96
115
 
97
116
  def run_buckets(*buckets)
98
117
  FileUtils.rm_f(rspec_all_json_path)
118
+ # Clean up all XML files before starting test runs
119
+ cleanup_xml_files
99
120
 
100
121
  buckets.each do |bucket|
101
122
  if bucket && !self.buckets[bucket]
@@ -131,7 +152,12 @@ module Mrss
131
152
  def run_files(category, paths)
132
153
  puts "Running #{category.to_s.gsub('_', ' ')} tests"
133
154
  FileUtils.rm_f(rspec_json_path)
155
+ FileUtils.rm_f(rspec_xml_path) # Clean up XML file before running this bucket
156
+
134
157
  cmd = %w(rspec) + paths
158
+ # Add junit formatter for XML output
159
+ cmd += ['--format', 'Rfc::Riff', '--format', 'RspecJunitFormatter', '--out', rspec_xml_path]
160
+
135
161
  if randomize?
136
162
  cmd += %W(--order rand:#{seed})
137
163
  end
@@ -147,6 +173,9 @@ module Mrss
147
173
  FileUtils.cp(rspec_json_path, rspec_all_json_path)
148
174
  end
149
175
  end
176
+
177
+ # Archive XML file after running this bucket
178
+ archive_xml_file(category)
150
179
  end
151
180
 
152
181
  true