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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +45 -0
  3. data/lib/mongoid/association/depending.rb +8 -4
  4. data/lib/mongoid/association/nested/many.rb +34 -5
  5. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  6. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  7. data/lib/mongoid/association/relatable.rb +17 -0
  8. data/lib/mongoid/collection_configurable.rb +8 -0
  9. data/lib/mongoid/config/encryption.rb +36 -18
  10. data/lib/mongoid/config.rb +56 -9
  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 +4 -0
  14. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  15. data/lib/mongoid/encryptable.rb +46 -0
  16. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  17. data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
  18. data/lib/mongoid/errors.rb +2 -0
  19. data/lib/mongoid/field_readable.rb +70 -0
  20. data/lib/mongoid/indexable.rb +39 -27
  21. data/lib/mongoid/matchable.rb +6 -1
  22. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
  23. data/lib/mongoid/matcher/regex.rb +9 -13
  24. data/lib/mongoid/matcher/regexp_budget.rb +383 -0
  25. data/lib/mongoid/matcher.rb +1 -0
  26. data/lib/mongoid/persistence_context.rb +35 -0
  27. data/lib/mongoid/search_indexable.rb +14 -7
  28. data/lib/mongoid/tasks/database.rb +23 -9
  29. data/lib/mongoid/threaded.rb +37 -0
  30. data/lib/mongoid/version.rb +1 -1
  31. data/spec/integration/app_spec.rb +7 -0
  32. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  33. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  34. data/spec/integration/encryption_spec.rb +149 -0
  35. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  36. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  37. data/spec/integration/query_operator_guard_spec.rb +89 -0
  38. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  39. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  40. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  41. data/spec/mongoid/config/defaults_spec.rb +18 -0
  42. data/spec/mongoid/config/encryption_spec.rb +228 -0
  43. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  44. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  45. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  46. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
  47. data/spec/mongoid/criteria_spec.rb +2 -0
  48. data/spec/mongoid/encryptable_spec.rb +61 -0
  49. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  50. data/spec/mongoid/tasks/database_spec.rb +18 -0
  51. data/spec/support/crypt/models.rb +157 -0
  52. metadata +14 -2
@@ -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
@@ -8,11 +8,38 @@ require_relative '../association/referenced/has_and_belongs_to_many_models'
8
8
  require_relative './nested_spec_models'
9
9
 
10
10
  describe Mongoid::Attributes::Nested do
11
+ # Expects the enclosing context to define `build_with_id`, taking an id
12
+ # value and returning the document built with it in nested attributes.
13
+ shared_examples 'rejects non-scalar ids' do |klass_name|
14
+ not_found = /not found for class #{klass_name}/
15
+
16
+ context 'when the id is an operator hash' do
17
+ it 'raises a document not found error' do
18
+ expect { build_with_id({ '$ne' => nil }) }
19
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
20
+ end
21
+ end
22
+
23
+ context 'when the id is an array' do
24
+ it 'raises a document not found error' do
25
+ expect { build_with_id([ BSON::ObjectId.new ]) }
26
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
27
+ end
28
+ end
29
+
30
+ context 'when the id is a malformed object id hash' do
31
+ it 'raises a document not found error' do
32
+ expect { build_with_id({ '$oid' => 'junk' }) }
33
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
34
+ end
35
+ end
36
+ end
11
37
 
12
38
  describe ".accepts_nested_attributes_for" do
13
39
 
14
40
  context "when the autosave option is not defined" do
15
41
 
42
+
16
43
  let(:person) do
17
44
  Person.new
18
45
  end
@@ -202,6 +229,30 @@ describe Mongoid::Attributes::Nested do
202
229
  end
203
230
  end
204
231
  end
232
+
233
+ context 'when the id in the attributes is not a scalar' do
234
+ let(:person) { Person.create! }
235
+
236
+ def build_with_id(id)
237
+ person.update!(posts_attributes: { '0' => { id: id, title: 'Crafted' } })
238
+ end
239
+
240
+ context 'when the association is empty' do
241
+ include_examples 'rejects non-scalar ids', 'Post'
242
+ end
243
+
244
+ context 'when the association is not empty' do
245
+ before { person.posts.create!(title: 'Existing') }
246
+
247
+ include_examples 'rejects non-scalar ids', 'Post'
248
+ end
249
+
250
+ context 'when allow_reparenting_via_nested_attributes is true' do
251
+ config_override :allow_reparenting_via_nested_attributes, true
252
+
253
+ include_examples 'rejects non-scalar ids', 'Post'
254
+ end
255
+ end
205
256
  end
206
257
 
207
258
  context 'when the relation is a has-and-belongs-to-many' do
@@ -226,16 +277,100 @@ describe Mongoid::Attributes::Nested do
226
277
  )
227
278
  end
228
279
 
229
- it "sets the nested attributes" do
230
- expect(person.preferences.map(&:name)).to eq([preference.name])
280
+ context 'when allow_reparenting_via_nested_attributes is false' do
281
+ config_override :allow_reparenting_via_nested_attributes, false
282
+
283
+ it 'raises a document not found error' do
284
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
285
+ /Document\(s\) not found for class Preference/)
286
+ end
231
287
  end
232
288
 
233
- it "updates attributes of existing document which is added to relation" do
234
- preference_name = 'updated preference'
235
- person = Person.new(
236
- preferences_attributes: { 0 => { id: preference.id, name: preference_name } }
237
- )
238
- expect(person.preferences.map(&:name)).to eq([preference_name])
289
+ context 'when allow_reparenting_via_nested_attributes is true' do
290
+ config_override :allow_reparenting_via_nested_attributes, true
291
+
292
+ it 'sets the nested attributes' do
293
+ expect(person.preferences.map(&:name)).to eq([ preference.name ])
294
+ end
295
+
296
+ it 'updates attributes of existing document which is added to relation' do
297
+ preference_name = 'updated preference'
298
+ person = Person.new(
299
+ preferences_attributes: { 0 => { id: preference.id, name: preference_name } }
300
+ )
301
+ expect(person.preferences.map(&:name)).to eq([ preference_name ])
302
+ end
303
+
304
+ context 'when the id does not correspond to an existing document' do
305
+ let(:person) do
306
+ Person.new(
307
+ preferences_attributes: { 0 => { id: BSON::ObjectId.new, name: 'Ghost' } }
308
+ )
309
+ end
310
+
311
+ context 'when raise_not_found_error is true' do
312
+ config_override :raise_not_found_error, true
313
+
314
+ it 'raises a document not found error' do
315
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
316
+ /Document\(s\) not found for class Preference/)
317
+ end
318
+ end
319
+
320
+ context 'when raise_not_found_error is false' do
321
+ config_override :raise_not_found_error, false
322
+
323
+ it 'raises a document not found error' do
324
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
325
+ /Document\(s\) not found for class Preference/)
326
+ end
327
+ end
328
+ end
329
+ end
330
+
331
+ context 'when _destroy is true for a document not in the relation' do
332
+ before do
333
+ Person.send(:undef_method, :preferences_attributes=)
334
+ Person.accepts_nested_attributes_for :preferences, allow_destroy: true
335
+ end
336
+
337
+ let(:person) do
338
+ Person.new(
339
+ preferences_attributes: { 0 => { id: preference.id, _destroy: '1' } }
340
+ )
341
+ end
342
+
343
+ it 'does not raise UnknownAttribute' do
344
+ expect { person }.not_to raise_error
345
+ end
346
+
347
+ it 'does not add the document to the relation' do
348
+ expect(person.preferences).to be_empty
349
+ end
350
+ end
351
+ end
352
+
353
+ context 'when the id in the attributes is not a scalar' do
354
+ let(:target) { Person.create! }
355
+
356
+ def build_with_id(id)
357
+ target.update!(preferences_attributes: { 0 => { id: id, name: 'Crafted' } })
358
+ end
359
+
360
+ context 'when the association is empty' do
361
+ include_examples 'rejects non-scalar ids', 'Preference'
362
+ end
363
+
364
+ context 'when the association is not empty' do
365
+ before { target.preferences.create!(name: 'Existing') }
366
+
367
+ include_examples 'rejects non-scalar ids', 'Preference'
368
+ end
369
+
370
+ context 'when allow_reparenting_via_nested_attributes is true' do
371
+ config_override :allow_reparenting_via_nested_attributes, true
372
+
373
+ include_examples 'rejects non-scalar ids', 'Preference'
239
374
  end
240
375
  end
241
376
  end
@@ -3041,7 +3176,7 @@ describe Mongoid::Attributes::Nested do
3041
3176
  { '0' =>
3042
3177
  { 'id' => BSON::ObjectId.new.to_s, 'title' => 'Rogue' } }
3043
3178
  end.to raise_error(Mongoid::Errors::DocumentNotFound,
3044
- /Document\(s\) not found for class Post/)
3179
+ /Document not found for class Post/)
3045
3180
  end
3046
3181
  end
3047
3182
  end
@@ -3076,7 +3211,7 @@ describe Mongoid::Attributes::Nested do
3076
3211
  person.posts_attributes =
3077
3212
  { 'foo' => { 'id' => 'test', 'title' => 'Test' } }
3078
3213
  end.to raise_error(Mongoid::Errors::DocumentNotFound,
3079
- /Document\(s\) not found for class Post/)
3214
+ /Document not found for class Post/)
3080
3215
  end
3081
3216
  end
3082
3217
  end
@@ -5025,4 +5160,63 @@ describe Mongoid::Attributes::Nested do
5025
5160
  end
5026
5161
  end
5027
5162
  end
5163
+
5164
+ context 'when an id in nested attributes belongs to another parent' do
5165
+ let(:victim) { Person.create! }
5166
+ let(:attacker) { Person.create! }
5167
+
5168
+ context 'when the association is a has-many' do
5169
+ before do
5170
+ Person.send(:undef_method, :posts_attributes=)
5171
+ Person.accepts_nested_attributes_for :posts
5172
+ end
5173
+
5174
+ let!(:victim_post) { victim.posts.create!(title: 'Private') }
5175
+
5176
+ it 'does not update the other parent\'s document' do
5177
+ expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5178
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5179
+ expect(victim_post.reload.title).to eq('Private')
5180
+ end
5181
+
5182
+ it 'does not add the other parent\'s document to the association' do
5183
+ expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5184
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5185
+ expect(attacker.reload.posts).to be_empty
5186
+ end
5187
+
5188
+ it 'does not update via a direct association assignment' do
5189
+ expect { attacker.update!(posts: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5190
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5191
+ expect(victim_post.reload.title).to eq('Private')
5192
+ end
5193
+ end
5194
+
5195
+ context 'when the association is a has-and-belongs-to-many' do
5196
+ before do
5197
+ Person.send(:undef_method, :preferences_attributes=)
5198
+ Person.accepts_nested_attributes_for :preferences
5199
+ end
5200
+
5201
+ let!(:victim_preference) { victim.preferences.create!(name: 'Private') }
5202
+
5203
+ it 'does not update the other parent\'s document' do
5204
+ expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5205
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5206
+ expect(victim_preference.reload.name).to eq('Private')
5207
+ end
5208
+
5209
+ it 'does not add the other parent\'s document to the association' do
5210
+ expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5211
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5212
+ expect(attacker.reload.preferences).to be_empty
5213
+ end
5214
+
5215
+ it 'does not update via a direct association assignment' do
5216
+ expect { attacker.update!(preferences: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5217
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5218
+ expect(victim_preference.reload.name).to eq('Private')
5219
+ end
5220
+ end
5221
+ end
5028
5222
  end
@@ -41,6 +41,18 @@ describe Mongoid::Config::Defaults do
41
41
  end
42
42
  end
43
43
 
44
+ shared_examples 'uses settings for 9.0' do
45
+ it 'uses settings for 9.0' do
46
+ expect(Mongoid.autosave_saves_unchanged_documents).to be true
47
+ end
48
+ end
49
+
50
+ shared_examples 'never allows reparenting via nested attributes' do
51
+ it 'leaves allow_reparenting_via_nested_attributes false' do
52
+ expect(Mongoid.allow_reparenting_via_nested_attributes).to be false
53
+ end
54
+ end
55
+
44
56
  context "when giving a valid version" do
45
57
 
46
58
  before do
@@ -57,6 +69,8 @@ describe Mongoid::Config::Defaults do
57
69
 
58
70
  it_behaves_like "uses settings for 8.0"
59
71
  it_behaves_like "uses settings for 8.1"
72
+ it_behaves_like 'uses settings for 9.0'
73
+ it_behaves_like 'never allows reparenting via nested attributes'
60
74
  end
61
75
 
62
76
  context "when the given version is 8.1" do
@@ -65,6 +79,8 @@ describe Mongoid::Config::Defaults do
65
79
 
66
80
  it_behaves_like "does not use settings for 8.0"
67
81
  it_behaves_like "uses settings for 8.1"
82
+ it_behaves_like 'uses settings for 9.0'
83
+ it_behaves_like 'never allows reparenting via nested attributes'
68
84
  end
69
85
 
70
86
  context "when the given version is 9.0" do
@@ -73,6 +89,8 @@ describe Mongoid::Config::Defaults do
73
89
 
74
90
  it_behaves_like "does not use settings for 8.0"
75
91
  it_behaves_like "does not use settings for 8.1"
92
+ it_behaves_like 'uses settings for 9.0'
93
+ it_behaves_like 'never allows reparenting via nested attributes'
76
94
  end
77
95
  end
78
96