mongoid 8.1.12 → 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.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +26 -0
- data/lib/mongoid/association/depending.rb +8 -4
- data/lib/mongoid/association/nested/many.rb +38 -3
- data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
- data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
- data/lib/mongoid/config.rb +62 -0
- data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
- data/lib/mongoid/contextual/memory.rb +18 -7
- data/lib/mongoid/criteria/queryable/mergeable.rb +12 -0
- data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
- data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
- data/lib/mongoid/errors.rb +1 -0
- data/lib/mongoid/field_readable.rb +70 -0
- data/lib/mongoid/matchable.rb +6 -1
- data/lib/mongoid/matcher/eq_impl_with_regexp.rb +3 -5
- data/lib/mongoid/matcher/regex.rb +8 -9
- data/lib/mongoid/matcher/regexp_budget.rb +389 -0
- data/lib/mongoid/matcher.rb +1 -0
- data/lib/mongoid/threaded.rb +3 -0
- data/lib/mongoid/version.rb +1 -1
- data/lib/mongoid/warnings.rb +1 -0
- data/spec/integration/app_spec.rb +8 -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/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/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 +235 -0
- data/spec/mongoid/criteria_spec.rb +2 -0
- data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
- metadata +11 -2
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'benchmark'
|
|
4
|
+
require 'spec_helper'
|
|
5
|
+
|
|
6
|
+
describe 'in-memory regexp time limit' do
|
|
7
|
+
# See the comment in spec/mongoid/matcher/regexp_budget_spec.rb: costly to
|
|
8
|
+
# match but bounded on every supported Ruby. A pattern relying on nested
|
|
9
|
+
# quantifiers would hang before Ruby 3.2, where the examples that have no
|
|
10
|
+
# interrupt available could not stop it.
|
|
11
|
+
let(:slow_pattern) { "(?:#{(1..300).map { |i| "a#{i}" }.join('|')})Z" }
|
|
12
|
+
let(:slow_street) { 'a' * 5_000 }
|
|
13
|
+
|
|
14
|
+
let(:person) do
|
|
15
|
+
# Address derives its _id from the street, so the streets have to differ or
|
|
16
|
+
# the embedded association collapses to a single document.
|
|
17
|
+
Person.new(addresses: Array.new(30) { |i| Address.new(street: "#{slow_street}#{i}") })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
let(:one_address) { Person.new(addresses: [ Address.new(street: slow_street) ]) }
|
|
21
|
+
|
|
22
|
+
# What one match of the fixture costs on the machine running the suite. It
|
|
23
|
+
# varies by more than an order of magnitude between implementations - around
|
|
24
|
+
# 6ms on MRI, 110ms on JRuby - so the examples below calibrate against a
|
|
25
|
+
# measurement instead of hard-coding a threshold that only holds on one.
|
|
26
|
+
let(:single_match_cost) do
|
|
27
|
+
regexp = Regexp.new(slow_pattern)
|
|
28
|
+
3.times { slow_street =~ regexp }
|
|
29
|
+
Benchmark.realtime { slow_street =~ regexp }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Comfortably more than one match, comfortably less than twenty. The examples
|
|
33
|
+
# only need the ordering to hold, so the margin either side is wide.
|
|
34
|
+
let(:calibrated_limit) { single_match_cost * 5 }
|
|
35
|
+
|
|
36
|
+
context 'when the limit is disabled' do
|
|
37
|
+
config_override :in_memory_regexp_time_limit, nil
|
|
38
|
+
|
|
39
|
+
it 'evaluates the query without a bound' do
|
|
40
|
+
expect(person.addresses.where(street: { '$regex' => slow_pattern }).to_a).to eq([])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
context 'when a limit is configured' do
|
|
45
|
+
config_override :in_memory_regexp_time_limit, nil
|
|
46
|
+
|
|
47
|
+
before { Mongoid::Config.in_memory_regexp_time_limit = calibrated_limit }
|
|
48
|
+
|
|
49
|
+
it 'leaves ordinary queries alone' do
|
|
50
|
+
person = Person.new(addresses: [ Address.new(street: 'Clarkson') ])
|
|
51
|
+
expect(person.addresses.where(street: { '$regex' => '\AClark' }).to_a.size).to eq(1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'does not raise for a single document' do
|
|
55
|
+
# The counterpart to the example below, and what makes it meaningful: one
|
|
56
|
+
# document stays under the limit, so a scan that raises can only have got
|
|
57
|
+
# there by accumulating cost across documents.
|
|
58
|
+
expect do
|
|
59
|
+
one_address.addresses.where(street: { '$regex' => slow_pattern }).to_a
|
|
60
|
+
end.not_to raise_error
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'raises for a scan over many documents under that same limit' do
|
|
64
|
+
expect do
|
|
65
|
+
person.addresses.where(street: { '$regex' => slow_pattern }).to_a
|
|
66
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'raises when $in multiplies the cost within a single document' do
|
|
70
|
+
conditions = Array.new(20) { BSON::Regexp::Raw.new(slow_pattern) }
|
|
71
|
+
|
|
72
|
+
expect do
|
|
73
|
+
one_address.addresses.where(street: { '$in' => conditions }).to_a
|
|
74
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'raises through the public _matches? API' do
|
|
78
|
+
# 20 conditions rather than one, because a single match is deliberately
|
|
79
|
+
# kept well under the limit by the calibration above.
|
|
80
|
+
conditions = Array.new(20) { BSON::Regexp::Raw.new(slow_pattern) }
|
|
81
|
+
|
|
82
|
+
expect do
|
|
83
|
+
person.addresses.first._matches?('street' => { '$in' => conditions })
|
|
84
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
context 'when the pattern is pathological' do
|
|
89
|
+
config_override :in_memory_regexp_time_limit, 0.2
|
|
90
|
+
|
|
91
|
+
# The ticket's Case 1, verbatim. It backtracks exponentially and is the
|
|
92
|
+
# reason the guard exists, so every entry point that evaluates a selector
|
|
93
|
+
# in memory has to bound it. The subject is kept short enough that the
|
|
94
|
+
# match still finishes on its own in a few seconds, so an entry point that
|
|
95
|
+
# fails to bound it shows up as a failing example rather than a hung run.
|
|
96
|
+
let(:evil_pattern) { '^(a+)+\1?$' }
|
|
97
|
+
let(:evil_subject) { "#{'a' * 28}X" }
|
|
98
|
+
|
|
99
|
+
it 'bounds a scan over an embedded association' do
|
|
100
|
+
person = Person.new(addresses: [ Address.new(street: evil_subject) ])
|
|
101
|
+
|
|
102
|
+
expect do
|
|
103
|
+
person.addresses.where(street: { '$regex' => evil_pattern }).to_a
|
|
104
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'bounds the public _matches? API' do
|
|
108
|
+
expect do
|
|
109
|
+
Address.new(street: evil_subject)._matches?('street' => { '$regex' => evil_pattern })
|
|
110
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'bounds removal from a referenced association' do
|
|
114
|
+
owner = Person.create!
|
|
115
|
+
owner.posts.create!(title: evil_subject)
|
|
116
|
+
|
|
117
|
+
expect do
|
|
118
|
+
owner.posts.delete_all(title: { '$regex' => evil_pattern })
|
|
119
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'deletes nothing when removal is bounded' do
|
|
123
|
+
# The scan runs before the delete, so the error means what a caller would
|
|
124
|
+
# take it to mean: the documents are still there.
|
|
125
|
+
owner = Person.create!
|
|
126
|
+
owner.posts.create!(title: evil_subject)
|
|
127
|
+
|
|
128
|
+
expect do
|
|
129
|
+
owner.posts.delete_all(title: { '$regex' => evil_pattern })
|
|
130
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
131
|
+
|
|
132
|
+
expect(Post.count).to eq(1)
|
|
133
|
+
expect(owner.reload.posts.size).to eq(1)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
context 'when the association being removed from has not been loaded' do
|
|
138
|
+
config_override :in_memory_regexp_time_limit, 0.2
|
|
139
|
+
|
|
140
|
+
let!(:owner) do
|
|
141
|
+
person = Person.create!
|
|
142
|
+
person.posts.create!(title: 'Testing')
|
|
143
|
+
Person.find(person._id)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The query that loads the association, which removal has to run before it
|
|
147
|
+
# has anything to match against.
|
|
148
|
+
let(:load_query) { owner.posts._target._unloaded }
|
|
149
|
+
|
|
150
|
+
# A stall standing in for a slow network. It is longer than the limit, and
|
|
151
|
+
# none of it is time spent on regular expressions.
|
|
152
|
+
before do
|
|
153
|
+
allow(load_query).to receive(:each).and_wrap_original do |original, *args, &block|
|
|
154
|
+
sleep(0.3)
|
|
155
|
+
original.call(*args, &block)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'does not load it, so no query can be counted against the limit' do
|
|
160
|
+
# On the Timeout path a deadline covering the fetch failed a slow query
|
|
161
|
+
# with an error about regular expressions, and the asynchronous exception
|
|
162
|
+
# could land inside the driver's socket read, leaving the connection with
|
|
163
|
+
# unconsumed bytes. Nothing is fetched now: the scan sees only what the
|
|
164
|
+
# association already holds.
|
|
165
|
+
stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
|
|
166
|
+
|
|
167
|
+
expect do
|
|
168
|
+
owner.posts.delete_all(title: { '$regex' => '\Azzz' })
|
|
169
|
+
end.not_to raise_error
|
|
170
|
+
|
|
171
|
+
expect(load_query).not_to have_received(:each)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'runs no pattern against documents it never loaded' do
|
|
175
|
+
# The counterpart to the referenced-association examples below, where the
|
|
176
|
+
# documents are in memory and the pattern is run against every one of
|
|
177
|
+
# them. Here there is nothing to run it against, and the server applies
|
|
178
|
+
# the same selector for the delete, so nothing is missed by not looking.
|
|
179
|
+
expect(Mongoid::Matcher::RegexpBudget).not_to receive(:match?)
|
|
180
|
+
|
|
181
|
+
owner.posts.delete_all(title: { '$regex' => '\Azzz' })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'still removes the documents that match' do
|
|
185
|
+
expect(owner.posts.delete_all(title: { '$regex' => '\ATest' })).to eq(1)
|
|
186
|
+
expect(Post.count).to eq(0)
|
|
187
|
+
expect(owner.posts.size).to eq(0)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
context 'when removing documents from a referenced association' do
|
|
192
|
+
config_override :in_memory_regexp_time_limit, nil
|
|
193
|
+
|
|
194
|
+
let!(:owner) { Person.create! }
|
|
195
|
+
|
|
196
|
+
before do
|
|
197
|
+
30.times { owner.posts.create!(title: slow_street) }
|
|
198
|
+
Mongoid::Config.in_memory_regexp_time_limit = calibrated_limit
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'raises rather than scanning every loaded document' do
|
|
202
|
+
expect do
|
|
203
|
+
owner.posts.delete_all(title: { '$regex' => slow_pattern })
|
|
204
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'leaves the documents in place' do
|
|
208
|
+
expect do
|
|
209
|
+
owner.posts.delete_all(title: { '$regex' => slow_pattern })
|
|
210
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
211
|
+
|
|
212
|
+
expect(Post.count).to eq(30)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'query operator injection guard' do
|
|
6
|
+
let(:js) { 'this.name == "admin"' }
|
|
7
|
+
|
|
8
|
+
let(:nested_function) do
|
|
9
|
+
{ '$expr' => { '$function' => { 'body' => 'function() { return true; }', 'args' => [], 'lang' => 'js' } } }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
let(:band) { Band.create!(name: 'Depeche Mode') }
|
|
13
|
+
|
|
14
|
+
context 'with default configuration' do
|
|
15
|
+
it 'rejects unsafe operators without any opt-out' do
|
|
16
|
+
expect(Mongoid.allow_unsafe_query_operators).to be false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
context 'when querying with where' do
|
|
20
|
+
it 'rejects a top-level $where' do
|
|
21
|
+
expect { Band.where('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'rejects a $function nested in $expr' do
|
|
25
|
+
expect { Band.where(nested_function).first }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'permits an ordinary query' do
|
|
29
|
+
expect(Band.where(name: band.name).first).to eq(band)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context 'when querying with find_by' do
|
|
34
|
+
it 'rejects a top-level $where' do
|
|
35
|
+
expect { Band.find_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'rejects a $function nested in $expr' do
|
|
39
|
+
expect { Band.find_by(nested_function) }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
context 'when querying with find_or_create_by' do
|
|
44
|
+
it 'rejects a top-level $where' do
|
|
45
|
+
expect { Band.find_or_create_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
context 'when querying with find_or_initialize_by' do
|
|
50
|
+
it 'rejects a top-level $where' do
|
|
51
|
+
expect { Band.find_or_initialize_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
context 'when querying an association with find_or_create_by' do
|
|
56
|
+
it 'rejects a top-level $where' do
|
|
57
|
+
expect do
|
|
58
|
+
band.records.find_or_create_by('$where' => js)
|
|
59
|
+
end.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
context 'when querying with a logical operator' do
|
|
64
|
+
it 'rejects a $where smuggled through or' do
|
|
65
|
+
expect { Band.or('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'rejects a $where smuggled through any_of' do
|
|
69
|
+
expect { Band.any_of('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'when allow_unsafe_query_operators is true' do
|
|
75
|
+
config_override :allow_unsafe_query_operators, true
|
|
76
|
+
|
|
77
|
+
it 'permits a top-level $where' do
|
|
78
|
+
expect(Band.where('$where' => "this.name == '#{band.name}'").first).to eq(band)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'permits a $function nested in $expr' do
|
|
82
|
+
expect { Band.where(nested_function).first }.not_to raise_error
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'permits a $where smuggled through or' do
|
|
86
|
+
expect { Band.or('$where' => js).first }.not_to raise_error
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -2266,6 +2266,13 @@ describe Mongoid::Association::Referenced::HasAndBelongsToMany::Proxy do
|
|
|
2266
2266
|
end
|
|
2267
2267
|
end
|
|
2268
2268
|
|
|
2269
|
+
# Whether the conditions carry a pattern decides which branch of remove_all
|
|
2270
|
+
# runs, so anything that has to behave the same on both is exercised twice.
|
|
2271
|
+
conditions_variants = [
|
|
2272
|
+
[ 'no regular expression', nil ],
|
|
2273
|
+
[ 'a regular expression', { name: /Test/ } ]
|
|
2274
|
+
].freeze
|
|
2275
|
+
|
|
2269
2276
|
%i[ delete_all destroy_all ].each do |method|
|
|
2270
2277
|
describe "\##{method}" do
|
|
2271
2278
|
context 'when the relation is not polymorphic' do
|
|
@@ -2337,6 +2344,47 @@ describe Mongoid::Association::Referenced::HasAndBelongsToMany::Proxy do
|
|
|
2337
2344
|
expect(person.preferences).to eq([])
|
|
2338
2345
|
end
|
|
2339
2346
|
end
|
|
2347
|
+
|
|
2348
|
+
context 'when the base holds the foreign keys' do
|
|
2349
|
+
# unbind_one pulls ids out of the base's foreign key array and marks
|
|
2350
|
+
# the base dirty, so which documents get unbound is visible here.
|
|
2351
|
+
# Only what the association already held is unbound, and that has to
|
|
2352
|
+
# hold whether or not the conditions carry a regular expression:
|
|
2353
|
+
# scanning under a regexp budget loads the association first, and
|
|
2354
|
+
# that load must not turn into an unbind of its own. See the note in
|
|
2355
|
+
# HasMany::Proxy#remove_all_bounded.
|
|
2356
|
+
let!(:person) do
|
|
2357
|
+
Person.create!.tap do |base|
|
|
2358
|
+
base.preferences.create!(name: 'Testing')
|
|
2359
|
+
base.preferences.create!(name: 'Test')
|
|
2360
|
+
end
|
|
2361
|
+
end
|
|
2362
|
+
|
|
2363
|
+
let(:ids) { person.preference_ids.dup }
|
|
2364
|
+
|
|
2365
|
+
conditions_variants.each do |description, conditions|
|
|
2366
|
+
context "when the conditions carry #{description}" do
|
|
2367
|
+
context 'when the association is already in memory' do
|
|
2368
|
+
it 'unbinds the deleted documents' do
|
|
2369
|
+
person.preferences.send(method, conditions)
|
|
2370
|
+
expect(person.preference_ids).to eq([])
|
|
2371
|
+
expect(person).to be_changed
|
|
2372
|
+
end
|
|
2373
|
+
end
|
|
2374
|
+
|
|
2375
|
+
context 'when the association has not been loaded' do
|
|
2376
|
+
let(:cold) { Person.find(person._id) }
|
|
2377
|
+
|
|
2378
|
+
it 'leaves the foreign keys alone' do
|
|
2379
|
+
expect(ids.length).to eq(2)
|
|
2380
|
+
cold.preferences.send(method, conditions)
|
|
2381
|
+
expect(cold.preference_ids).to eq(ids)
|
|
2382
|
+
expect(cold).not_to be_changed
|
|
2383
|
+
end
|
|
2384
|
+
end
|
|
2385
|
+
end
|
|
2386
|
+
end
|
|
2387
|
+
end
|
|
2340
2388
|
end
|
|
2341
2389
|
end
|
|
2342
2390
|
end
|
|
@@ -2280,6 +2280,151 @@ describe Mongoid::Association::Referenced::HasMany::Proxy do
|
|
|
2280
2280
|
expect(person.posts).to eq([])
|
|
2281
2281
|
end
|
|
2282
2282
|
end
|
|
2283
|
+
|
|
2284
|
+
context 'when a document is appended with an id that is already loaded' do
|
|
2285
|
+
# Iterating the association moves an appended document out of _added
|
|
2286
|
+
# without adding it to _loaded, so the scan for matches and the
|
|
2287
|
+
# removal that follows see different instances for the same id.
|
|
2288
|
+
let(:person) { Person.create! }
|
|
2289
|
+
let!(:post) { person.posts.create!(title: 'Testing') }
|
|
2290
|
+
let(:reloaded) { Person.find(person._id) }
|
|
2291
|
+
|
|
2292
|
+
before do
|
|
2293
|
+
reloaded.posts.to_a
|
|
2294
|
+
reloaded.posts._target.push(
|
|
2295
|
+
Post.new(_id: post._id, title: 'Testing', person_id: reloaded._id)
|
|
2296
|
+
)
|
|
2297
|
+
end
|
|
2298
|
+
|
|
2299
|
+
shared_examples 'removes the document once' do
|
|
2300
|
+
it 'sets the association locally' do
|
|
2301
|
+
reloaded.posts.send(method, conditions)
|
|
2302
|
+
expect(reloaded.posts).to eq([])
|
|
2303
|
+
end
|
|
2304
|
+
|
|
2305
|
+
it 'deletes the documents from the database' do
|
|
2306
|
+
reloaded.posts.send(method, conditions)
|
|
2307
|
+
expect(Post.count).to eq(0)
|
|
2308
|
+
end
|
|
2309
|
+
end
|
|
2310
|
+
|
|
2311
|
+
context 'when the conditions carry no regular expression' do
|
|
2312
|
+
let(:conditions) { { title: 'Testing' } }
|
|
2313
|
+
|
|
2314
|
+
it_behaves_like 'removes the document once'
|
|
2315
|
+
end
|
|
2316
|
+
|
|
2317
|
+
context 'when the conditions carry a regular expression' do
|
|
2318
|
+
let(:conditions) { { title: /Testing/ } }
|
|
2319
|
+
|
|
2320
|
+
it_behaves_like 'removes the document once'
|
|
2321
|
+
end
|
|
2322
|
+
end
|
|
2323
|
+
|
|
2324
|
+
context 'when an appended document disagrees with the loaded one' do
|
|
2325
|
+
# Only the appended instance matches, so the removal has to drop that
|
|
2326
|
+
# id from both _loaded and _added. Removing by id and stopping at the
|
|
2327
|
+
# first hash that has it would take the loaded instance instead and
|
|
2328
|
+
# leave the unbound one behind.
|
|
2329
|
+
let(:person) { Person.create! }
|
|
2330
|
+
let!(:post) { person.posts.create!(title: 'Other') }
|
|
2331
|
+
let(:reloaded) { Person.find(person._id) }
|
|
2332
|
+
|
|
2333
|
+
before do
|
|
2334
|
+
reloaded.posts.to_a
|
|
2335
|
+
reloaded.posts._target.push(
|
|
2336
|
+
Post.new(_id: post._id, title: 'Testing', person_id: reloaded._id)
|
|
2337
|
+
)
|
|
2338
|
+
end
|
|
2339
|
+
|
|
2340
|
+
it 'deletes nothing from the database' do
|
|
2341
|
+
expect(reloaded.posts.send(method, { title: /Testing/ })).to eq(0)
|
|
2342
|
+
expect(Post.where(title: 'Other').count).to eq(1)
|
|
2343
|
+
end
|
|
2344
|
+
|
|
2345
|
+
it 'leaves no unbound document in the association' do
|
|
2346
|
+
reloaded.posts.send(method, { title: /Testing/ })
|
|
2347
|
+
expect(reloaded.posts._target.in_memory.map(&:person_id)).not_to include(nil)
|
|
2348
|
+
end
|
|
2349
|
+
end
|
|
2350
|
+
|
|
2351
|
+
context 'when a nested selector is evaluated while the association loads' do
|
|
2352
|
+
# Loading the association runs find callbacks, and application code in
|
|
2353
|
+
# one of those can evaluate a selector of its own. A scope opened
|
|
2354
|
+
# around the load would mark it as having nothing to bound, and that
|
|
2355
|
+
# suppresses the decision for the nested selector too, leaving any
|
|
2356
|
+
# pattern in it unguarded.
|
|
2357
|
+
config_override :in_memory_regexp_time_limit, 5.0
|
|
2358
|
+
|
|
2359
|
+
let(:person) { Person.create! }
|
|
2360
|
+
let(:reloaded) { Person.find(person._id) }
|
|
2361
|
+
|
|
2362
|
+
before do
|
|
2363
|
+
person.posts.create!(title: 'Testing')
|
|
2364
|
+
person.posts.create!(title: 'Keep')
|
|
2365
|
+
reloaded
|
|
2366
|
+
end
|
|
2367
|
+
|
|
2368
|
+
it 'leaves the nested scope free to establish its own budget' do
|
|
2369
|
+
seen = []
|
|
2370
|
+
allow(Mongoid::Factory).to receive(:from_db).and_wrap_original do |original, *args|
|
|
2371
|
+
Mongoid::Matcher::RegexpBudget.open('title' => /Testing/) do
|
|
2372
|
+
seen << Mongoid::Matcher::RegexpBudget.remaining
|
|
2373
|
+
end
|
|
2374
|
+
original.call(*args)
|
|
2375
|
+
end
|
|
2376
|
+
|
|
2377
|
+
reloaded.posts.send(method, { title: 'Testing' })
|
|
2378
|
+
|
|
2379
|
+
expect(seen).not_to be_empty
|
|
2380
|
+
expect(seen).not_to include(nil)
|
|
2381
|
+
end
|
|
2382
|
+
end
|
|
2383
|
+
|
|
2384
|
+
if method == :delete_all
|
|
2385
|
+
context 'when the association has not been loaded' do
|
|
2386
|
+
let(:person) { Person.create! }
|
|
2387
|
+
let(:reloaded) { Person.find(person._id) }
|
|
2388
|
+
let(:subscriber) { Mrss::EventSubscriber.new }
|
|
2389
|
+
|
|
2390
|
+
before do
|
|
2391
|
+
person.posts.create!(title: 'Testing')
|
|
2392
|
+
person.posts.create!(title: 'Test')
|
|
2393
|
+
reloaded
|
|
2394
|
+
end
|
|
2395
|
+
|
|
2396
|
+
def commands_for(conditions)
|
|
2397
|
+
Person.collection.client.subscribe(Mongo::Monitoring::COMMAND, subscriber)
|
|
2398
|
+
reloaded.posts.delete_all(conditions)
|
|
2399
|
+
subscriber.started_events.map(&:command_name)
|
|
2400
|
+
ensure
|
|
2401
|
+
Person.collection.client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber)
|
|
2402
|
+
end
|
|
2403
|
+
|
|
2404
|
+
context 'when the conditions carry no regular expression' do
|
|
2405
|
+
# Nothing to bound, so the scan for matches stays where it was,
|
|
2406
|
+
# after the delete, where the association it loads comes back
|
|
2407
|
+
# empty. Moving it ahead of the delete would transfer the whole
|
|
2408
|
+
# association for an operation that need send nothing over the
|
|
2409
|
+
# wire.
|
|
2410
|
+
it 'deletes before loading the association' do
|
|
2411
|
+
expect(commands_for(nil)).to eq(%w[ delete find ])
|
|
2412
|
+
end
|
|
2413
|
+
end
|
|
2414
|
+
|
|
2415
|
+
context 'when the conditions carry a regular expression' do
|
|
2416
|
+
# The scan has to run under a budget, and before anything is
|
|
2417
|
+
# deleted, so that a budget which runs out leaves the database
|
|
2418
|
+
# untouched. What it scans is what is already in memory: loading
|
|
2419
|
+
# would trade the matching cost the budget exists to bound for an
|
|
2420
|
+
# unbounded amount of memory, since /.*/ is both cheap to supply
|
|
2421
|
+
# and matches every document in the association.
|
|
2422
|
+
it 'deletes without loading the association' do
|
|
2423
|
+
expect(commands_for({ title: /Test/ })).to eq(%w[ delete ])
|
|
2424
|
+
end
|
|
2425
|
+
end
|
|
2426
|
+
end
|
|
2427
|
+
end
|
|
2283
2428
|
end
|
|
2284
2429
|
|
|
2285
2430
|
context "when the association is polymorphic" do
|