mongoid 9.0.10 → 9.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/Rakefile +31 -0
  4. data/lib/config/locales/en.yml +45 -0
  5. data/lib/mongoid/association/depending.rb +8 -4
  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/auto_save.rb +48 -6
  9. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  10. data/lib/mongoid/association/relatable.rb +17 -0
  11. data/lib/mongoid/collection_configurable.rb +8 -0
  12. data/lib/mongoid/config/defaults.rb +9 -5
  13. data/lib/mongoid/config/encryption.rb +36 -18
  14. data/lib/mongoid/config.rb +72 -0
  15. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  16. data/lib/mongoid/contextual/memory.rb +18 -7
  17. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  18. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  19. data/lib/mongoid/encryptable.rb +46 -0
  20. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  21. data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
  22. data/lib/mongoid/errors.rb +2 -0
  23. data/lib/mongoid/field_readable.rb +70 -0
  24. data/lib/mongoid/indexable.rb +39 -27
  25. data/lib/mongoid/matchable.rb +6 -1
  26. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
  27. data/lib/mongoid/matcher/regex.rb +9 -13
  28. data/lib/mongoid/matcher/regexp_budget.rb +383 -0
  29. data/lib/mongoid/matcher.rb +1 -0
  30. data/lib/mongoid/persistence_context.rb +35 -0
  31. data/lib/mongoid/search_indexable.rb +14 -7
  32. data/lib/mongoid/tasks/database.rb +23 -9
  33. data/lib/mongoid/threaded.rb +37 -0
  34. data/lib/mongoid/version.rb +1 -1
  35. data/lib/mongoid/warnings.rb +4 -0
  36. data/spec/integration/app_spec.rb +201 -36
  37. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  38. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  39. data/spec/integration/encryption_spec.rb +149 -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/auto_save_spec.rb +98 -0
  44. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  45. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  46. data/spec/mongoid/attributes/nested_spec.rb +250 -30
  47. data/spec/mongoid/config/defaults_spec.rb +18 -0
  48. data/spec/mongoid/config/encryption_spec.rb +228 -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 +61 -0
  55. data/spec/mongoid/errors/document_not_found_spec.rb +10 -3
  56. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  57. data/spec/mongoid/tasks/database_spec.rb +18 -0
  58. data/spec/support/crypt/models.rb +157 -0
  59. metadata +28 -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
@@ -5,6 +5,53 @@ require "spec_helper"
5
5
  require_relative './referenced/has_many_models'
6
6
  require_relative './referenced/has_one_models'
7
7
 
8
+ # Models for the MONGOID-5751 regression test: after_save callbacks must not
9
+ # fire for pre-existing, unchanged documents when autosave: true cascades a
10
+ # save from a parent to its children.
11
+ module AutoSaveMONGOID5751
12
+ class Table
13
+ include Mongoid::Document
14
+
15
+ has_many :rows, autosave: true, class_name: 'AutoSaveMONGOID5751::Row',
16
+ inverse_of: :table
17
+
18
+ class << self
19
+ attr_accessor :after_save_count
20
+ end
21
+ self.after_save_count = 0
22
+
23
+ after_save { self.class.after_save_count += 1 }
24
+ end
25
+
26
+ class Row
27
+ include Mongoid::Document
28
+
29
+ belongs_to :table, class_name: 'AutoSaveMONGOID5751::Table', inverse_of: :rows
30
+ has_many :cells, autosave: true, class_name: 'AutoSaveMONGOID5751::Cell',
31
+ inverse_of: :row
32
+
33
+ class << self
34
+ attr_accessor :after_save_count
35
+ end
36
+ self.after_save_count = 0
37
+
38
+ after_save { self.class.after_save_count += 1 }
39
+ end
40
+
41
+ class Cell
42
+ include Mongoid::Document
43
+
44
+ belongs_to :row, class_name: 'AutoSaveMONGOID5751::Row', inverse_of: :cells
45
+
46
+ class << self
47
+ attr_accessor :after_save_count
48
+ end
49
+ self.after_save_count = 0
50
+
51
+ after_save { self.class.after_save_count += 1 }
52
+ end
53
+ end
54
+
8
55
  describe Mongoid::Association::Referenced::AutoSave do
9
56
 
10
57
  describe ".auto_save" do
@@ -399,6 +446,57 @@ describe Mongoid::Association::Referenced::AutoSave do
399
446
  expect(harvest.reload.season).to eq('Fall')
400
447
  end
401
448
  end
449
+
450
+ # Regression test for MONGOID-5751: after_save must not fire for
451
+ # pre-existing, unchanged documents that are merely loaded into memory
452
+ # as a side-effect of the autosave traversal.
453
+ context 'when a parent with existing children has a new child added' do
454
+ before do
455
+ # Persist a table with 3 pre-existing rows, each with 3 cells.
456
+ table = AutoSaveMONGOID5751::Table.create!
457
+ 3.times do
458
+ row = table.rows.create!
459
+ 3.times { row.cells.create! }
460
+ end
461
+
462
+ # Reset counters so only the saves triggered by the call below are
463
+ # measured.
464
+ AutoSaveMONGOID5751::Table.after_save_count = 0
465
+ AutoSaveMONGOID5751::Row.after_save_count = 0
466
+ AutoSaveMONGOID5751::Cell.after_save_count = 0
467
+
468
+ # Reload the table fresh from the database, then append exactly one
469
+ # new row (with one new cell) and persist.
470
+ reloaded = AutoSaveMONGOID5751::Table.find(table.id)
471
+ new_row = reloaded.rows.build
472
+ new_row.cells.build
473
+ reloaded.save!
474
+ end
475
+
476
+ it 'fires after_save once for the parent table' do
477
+ expect(AutoSaveMONGOID5751::Table.after_save_count).to eq(1)
478
+ end
479
+
480
+ it 'fires after_save only for the newly added cell' do
481
+ expect(AutoSaveMONGOID5751::Cell.after_save_count).to eq(1)
482
+ end
483
+
484
+ context 'when autosave_saves_unchanged_documents is true' do
485
+ config_override :autosave_saves_unchanged_documents, true
486
+
487
+ it 'fires after_save for all new and pre-existing rows' do
488
+ expect(AutoSaveMONGOID5751::Row.after_save_count).to eq(4)
489
+ end
490
+ end
491
+
492
+ context 'when autosave_saves_unchanged_documents is false' do
493
+ config_override :autosave_saves_unchanged_documents, false
494
+
495
+ it 'fires after_save only for the newly added row, not for pre-existing rows' do
496
+ expect(AutoSaveMONGOID5751::Row.after_save_count).to eq(1)
497
+ end
498
+ end
499
+ end
402
500
  end
403
501
  end
404
502
  end
@@ -2267,6 +2267,13 @@ describe Mongoid::Association::Referenced::HasAndBelongsToMany::Proxy do
2267
2267
  end
2268
2268
  end
2269
2269
 
2270
+ # Whether the conditions carry a pattern decides which branch of remove_all
2271
+ # runs, so anything that has to behave the same on both is exercised twice.
2272
+ conditions_variants = [
2273
+ [ 'no regular expression', nil ],
2274
+ [ 'a regular expression', { name: /Test/ } ]
2275
+ ].freeze
2276
+
2270
2277
  %i[ delete_all destroy_all ].each do |method|
2271
2278
  describe "\##{method}" do
2272
2279
  context 'when the relation is not polymorphic' do
@@ -2338,6 +2345,47 @@ describe Mongoid::Association::Referenced::HasAndBelongsToMany::Proxy do
2338
2345
  expect(person.preferences).to eq([])
2339
2346
  end
2340
2347
  end
2348
+
2349
+ context 'when the base holds the foreign keys' do
2350
+ # unbind_one pulls ids out of the base's foreign key array and marks
2351
+ # the base dirty, so which documents get unbound is visible here.
2352
+ # Only what the association already held is unbound, and that has to
2353
+ # hold whether or not the conditions carry a regular expression:
2354
+ # scanning under a regexp budget loads the association first, and
2355
+ # that load must not turn into an unbind of its own. See the note in
2356
+ # HasMany::Proxy#remove_all_bounded.
2357
+ let!(:person) do
2358
+ Person.create!.tap do |base|
2359
+ base.preferences.create!(name: 'Testing')
2360
+ base.preferences.create!(name: 'Test')
2361
+ end
2362
+ end
2363
+
2364
+ let(:ids) { person.preference_ids.dup }
2365
+
2366
+ conditions_variants.each do |description, conditions|
2367
+ context "when the conditions carry #{description}" do
2368
+ context 'when the association is already in memory' do
2369
+ it 'unbinds the deleted documents' do
2370
+ person.preferences.send(method, conditions)
2371
+ expect(person.preference_ids).to eq([])
2372
+ expect(person).to be_changed
2373
+ end
2374
+ end
2375
+
2376
+ context 'when the association has not been loaded' do
2377
+ let(:cold) { Person.find(person._id) }
2378
+
2379
+ it 'leaves the foreign keys alone' do
2380
+ expect(ids.length).to eq(2)
2381
+ cold.preferences.send(method, conditions)
2382
+ expect(cold.preference_ids).to eq(ids)
2383
+ expect(cold).not_to be_changed
2384
+ end
2385
+ end
2386
+ end
2387
+ end
2388
+ end
2341
2389
  end
2342
2390
  end
2343
2391
  end
@@ -1832,6 +1832,151 @@ describe Mongoid::Association::Referenced::HasMany::Proxy do
1832
1832
  expect(person.posts).to eq([])
1833
1833
  end
1834
1834
  end
1835
+
1836
+ context 'when a document is appended with an id that is already loaded' do
1837
+ # Iterating the association moves an appended document out of _added
1838
+ # without adding it to _loaded, so the scan for matches and the
1839
+ # removal that follows see different instances for the same id.
1840
+ let(:person) { Person.create! }
1841
+ let!(:post) { person.posts.create!(title: 'Testing') }
1842
+ let(:reloaded) { Person.find(person._id) }
1843
+
1844
+ before do
1845
+ reloaded.posts.to_a
1846
+ reloaded.posts._target.push(
1847
+ Post.new(_id: post._id, title: 'Testing', person_id: reloaded._id)
1848
+ )
1849
+ end
1850
+
1851
+ shared_examples 'removes the document once' do
1852
+ it 'sets the association locally' do
1853
+ reloaded.posts.send(method, conditions)
1854
+ expect(reloaded.posts).to eq([])
1855
+ end
1856
+
1857
+ it 'deletes the documents from the database' do
1858
+ reloaded.posts.send(method, conditions)
1859
+ expect(Post.count).to eq(0)
1860
+ end
1861
+ end
1862
+
1863
+ context 'when the conditions carry no regular expression' do
1864
+ let(:conditions) { { title: 'Testing' } }
1865
+
1866
+ it_behaves_like 'removes the document once'
1867
+ end
1868
+
1869
+ context 'when the conditions carry a regular expression' do
1870
+ let(:conditions) { { title: /Testing/ } }
1871
+
1872
+ it_behaves_like 'removes the document once'
1873
+ end
1874
+ end
1875
+
1876
+ context 'when an appended document disagrees with the loaded one' do
1877
+ # Only the appended instance matches, so the removal has to drop that
1878
+ # id from both _loaded and _added. Removing by id and stopping at the
1879
+ # first hash that has it would take the loaded instance instead and
1880
+ # leave the unbound one behind.
1881
+ let(:person) { Person.create! }
1882
+ let!(:post) { person.posts.create!(title: 'Other') }
1883
+ let(:reloaded) { Person.find(person._id) }
1884
+
1885
+ before do
1886
+ reloaded.posts.to_a
1887
+ reloaded.posts._target.push(
1888
+ Post.new(_id: post._id, title: 'Testing', person_id: reloaded._id)
1889
+ )
1890
+ end
1891
+
1892
+ it 'deletes nothing from the database' do
1893
+ expect(reloaded.posts.send(method, { title: /Testing/ })).to eq(0)
1894
+ expect(Post.where(title: 'Other').count).to eq(1)
1895
+ end
1896
+
1897
+ it 'leaves no unbound document in the association' do
1898
+ reloaded.posts.send(method, { title: /Testing/ })
1899
+ expect(reloaded.posts._target.in_memory.map(&:person_id)).not_to include(nil)
1900
+ end
1901
+ end
1902
+
1903
+ context 'when a nested selector is evaluated while the association loads' do
1904
+ # Loading the association runs find callbacks, and application code in
1905
+ # one of those can evaluate a selector of its own. A scope opened
1906
+ # around the load would mark it as having nothing to bound, and that
1907
+ # suppresses the decision for the nested selector too, leaving any
1908
+ # pattern in it unguarded.
1909
+ config_override :in_memory_regexp_time_limit, 5.0
1910
+
1911
+ let(:person) { Person.create! }
1912
+ let(:reloaded) { Person.find(person._id) }
1913
+
1914
+ before do
1915
+ person.posts.create!(title: 'Testing')
1916
+ person.posts.create!(title: 'Keep')
1917
+ reloaded
1918
+ end
1919
+
1920
+ it 'leaves the nested scope free to establish its own budget' do
1921
+ seen = []
1922
+ allow(Mongoid::Factory).to receive(:from_db).and_wrap_original do |original, *args|
1923
+ Mongoid::Matcher::RegexpBudget.open('title' => /Testing/) do
1924
+ seen << Mongoid::Matcher::RegexpBudget.remaining
1925
+ end
1926
+ original.call(*args)
1927
+ end
1928
+
1929
+ reloaded.posts.send(method, { title: 'Testing' })
1930
+
1931
+ expect(seen).not_to be_empty
1932
+ expect(seen).not_to include(nil)
1933
+ end
1934
+ end
1935
+
1936
+ if method == :delete_all
1937
+ context 'when the association has not been loaded' do
1938
+ let(:person) { Person.create! }
1939
+ let(:reloaded) { Person.find(person._id) }
1940
+ let(:subscriber) { Mrss::EventSubscriber.new }
1941
+
1942
+ before do
1943
+ person.posts.create!(title: 'Testing')
1944
+ person.posts.create!(title: 'Test')
1945
+ reloaded
1946
+ end
1947
+
1948
+ def commands_for(conditions)
1949
+ Person.collection.client.subscribe(Mongo::Monitoring::COMMAND, subscriber)
1950
+ reloaded.posts.delete_all(conditions)
1951
+ subscriber.started_events.map(&:command_name)
1952
+ ensure
1953
+ Person.collection.client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber)
1954
+ end
1955
+
1956
+ context 'when the conditions carry no regular expression' do
1957
+ # Nothing to bound, so the scan for matches stays where it was,
1958
+ # after the delete, where the association it loads comes back
1959
+ # empty. Moving it ahead of the delete would transfer the whole
1960
+ # association for an operation that need send nothing over the
1961
+ # wire.
1962
+ it 'deletes before loading the association' do
1963
+ expect(commands_for(nil)).to eq(%w[ delete find ])
1964
+ end
1965
+ end
1966
+
1967
+ context 'when the conditions carry a regular expression' do
1968
+ # The scan has to run under a budget, and before anything is
1969
+ # deleted, so that a budget which runs out leaves the database
1970
+ # untouched. What it scans is what is already in memory: loading
1971
+ # would trade the matching cost the budget exists to bound for an
1972
+ # unbounded amount of memory, since /.*/ is both cheap to supply
1973
+ # and matches every document in the association.
1974
+ it 'deletes without loading the association' do
1975
+ expect(commands_for({ title: /Test/ })).to eq(%w[ delete ])
1976
+ end
1977
+ end
1978
+ end
1979
+ end
1835
1980
  end
1836
1981
 
1837
1982
  context 'when the association is polymorphic' do