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.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +45 -0
- data/lib/mongoid/association/depending.rb +8 -4
- data/lib/mongoid/association/nested/many.rb +34 -5
- data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
- data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
- data/lib/mongoid/association/relatable.rb +17 -0
- data/lib/mongoid/collection_configurable.rb +8 -0
- data/lib/mongoid/config/encryption.rb +36 -18
- data/lib/mongoid/config.rb +56 -9
- data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
- data/lib/mongoid/contextual/memory.rb +18 -7
- data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
- data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
- data/lib/mongoid/encryptable.rb +46 -0
- data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
- data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
- data/lib/mongoid/errors.rb +2 -0
- data/lib/mongoid/field_readable.rb +70 -0
- data/lib/mongoid/indexable.rb +39 -27
- data/lib/mongoid/matchable.rb +6 -1
- data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
- data/lib/mongoid/matcher/regex.rb +9 -13
- data/lib/mongoid/matcher/regexp_budget.rb +383 -0
- data/lib/mongoid/matcher.rb +1 -0
- data/lib/mongoid/persistence_context.rb +35 -0
- data/lib/mongoid/search_indexable.rb +14 -7
- data/lib/mongoid/tasks/database.rb +23 -9
- data/lib/mongoid/threaded.rb +37 -0
- data/lib/mongoid/version.rb +1 -1
- data/spec/integration/app_spec.rb +7 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
- data/spec/integration/dots_and_dollars_spec.rb +12 -2
- data/spec/integration/encryption_spec.rb +149 -0
- data/spec/integration/matcher_operator_data/regex.yml +21 -0
- data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
- data/spec/integration/query_operator_guard_spec.rb +89 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
- data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
- data/spec/mongoid/attributes/nested_spec.rb +204 -10
- data/spec/mongoid/config/defaults_spec.rb +18 -0
- data/spec/mongoid/config/encryption_spec.rb +228 -0
- data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
- data/spec/mongoid/contextual/memory_spec.rb +177 -0
- data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
- data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
- data/spec/mongoid/criteria_spec.rb +2 -0
- data/spec/mongoid/encryptable_spec.rb +61 -0
- data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
- data/spec/mongoid/tasks/database_spec.rb +18 -0
- data/spec/support/crypt/models.rb +157 -0
- metadata +14 -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(Mongoid::Threaded.has?(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
|
|
@@ -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]
|