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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +26 -0
  3. data/lib/mongoid/association/depending.rb +8 -4
  4. data/lib/mongoid/association/nested/many.rb +38 -3
  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/config.rb +62 -0
  8. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  9. data/lib/mongoid/contextual/memory.rb +18 -7
  10. data/lib/mongoid/criteria/queryable/mergeable.rb +12 -0
  11. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  12. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  13. data/lib/mongoid/errors.rb +1 -0
  14. data/lib/mongoid/field_readable.rb +70 -0
  15. data/lib/mongoid/matchable.rb +6 -1
  16. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +3 -5
  17. data/lib/mongoid/matcher/regex.rb +8 -9
  18. data/lib/mongoid/matcher/regexp_budget.rb +389 -0
  19. data/lib/mongoid/matcher.rb +1 -0
  20. data/lib/mongoid/threaded.rb +3 -0
  21. data/lib/mongoid/version.rb +1 -1
  22. data/lib/mongoid/warnings.rb +1 -0
  23. data/spec/integration/app_spec.rb +8 -0
  24. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  25. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  26. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  27. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  28. data/spec/integration/query_operator_guard_spec.rb +89 -0
  29. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  30. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  31. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  32. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  33. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  34. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  35. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +235 -0
  36. data/spec/mongoid/criteria_spec.rb +2 -0
  37. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  38. metadata +11 -2
@@ -7,6 +7,33 @@ require_relative './nested_spec_models'
7
7
 
8
8
  describe Mongoid::Attributes::Nested do
9
9
 
10
+ # Expects the enclosing context to define `build_with_id`, taking an id
11
+ # value and returning the document built with it in nested attributes.
12
+ shared_examples 'rejects non-scalar ids' do |klass_name|
13
+ not_found = /not found for class #{klass_name}/
14
+
15
+ context 'when the id is an operator hash' do
16
+ it 'raises a document not found error' do
17
+ expect { build_with_id({ '$ne' => nil }) }
18
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
19
+ end
20
+ end
21
+
22
+ context 'when the id is an array' do
23
+ it 'raises a document not found error' do
24
+ expect { build_with_id([BSON::ObjectId.new]) }
25
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
26
+ end
27
+ end
28
+
29
+ context 'when the id is a malformed object id hash' do
30
+ it 'raises a document not found error' do
31
+ expect { build_with_id({ '$oid' => 'junk' }) }
32
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
33
+ end
34
+ end
35
+ end
36
+
10
37
  describe ".accepts_nested_attributes_for" do
11
38
 
12
39
  context "when the autosave option is not defined" do
@@ -178,6 +205,30 @@ describe Mongoid::Attributes::Nested do
178
205
  end
179
206
  end
180
207
 
208
+ context 'when the id in the attributes is not a scalar' do
209
+ let(:person) { Person.create! }
210
+
211
+ def build_with_id(id)
212
+ person.update!(posts_attributes: { '0' => { id: id, title: 'Crafted' } })
213
+ end
214
+
215
+ context 'when the association is empty' do
216
+ include_examples 'rejects non-scalar ids', 'Post'
217
+ end
218
+
219
+ context 'when the association is not empty' do
220
+ before { person.posts.create!(title: 'Existing') }
221
+
222
+ include_examples 'rejects non-scalar ids', 'Post'
223
+ end
224
+
225
+ context 'when allow_reparenting_via_nested_attributes is true' do
226
+ config_override :allow_reparenting_via_nested_attributes, true
227
+
228
+ include_examples 'rejects non-scalar ids', 'Post'
229
+ end
230
+ end
231
+
181
232
  context "when the relation is a references and referenced in many" do
182
233
 
183
234
  before do
@@ -201,16 +252,100 @@ describe Mongoid::Attributes::Nested do
201
252
  )
202
253
  end
203
254
 
204
- it "sets the nested attributes" do
205
- expect(person.preferences.map(&:name)).to eq([preference.name])
255
+ context 'when allow_reparenting_via_nested_attributes is false' do
256
+ config_override :allow_reparenting_via_nested_attributes, false
257
+
258
+ it 'raises a document not found error' do
259
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
260
+ /Document\(s\) not found for class Preference/)
261
+ end
206
262
  end
207
263
 
208
- it "updates attributes of existing document which is added to relation" do
209
- preference_name = 'updated preference'
210
- person = Person.new(
211
- preferences_attributes: { 0 => { id: preference.id, name: preference_name } }
212
- )
213
- expect(person.preferences.map(&:name)).to eq([preference_name])
264
+ context 'when allow_reparenting_via_nested_attributes is true' do
265
+ config_override :allow_reparenting_via_nested_attributes, true
266
+
267
+ it 'sets the nested attributes' do
268
+ expect(person.preferences.map(&:name)).to eq([preference.name])
269
+ end
270
+
271
+ it 'updates attributes of existing document which is added to relation' do
272
+ preference_name = 'updated preference'
273
+ person = Person.new(
274
+ preferences_attributes: { 0 => { id: preference.id, name: preference_name } }
275
+ )
276
+ expect(person.preferences.map(&:name)).to eq([preference_name])
277
+ end
278
+
279
+ context 'when the id does not correspond to an existing document' do
280
+ let(:person) do
281
+ Person.new(
282
+ preferences_attributes: { 0 => { id: BSON::ObjectId.new, name: 'Ghost' } }
283
+ )
284
+ end
285
+
286
+ context 'when raise_not_found_error is true' do
287
+ config_override :raise_not_found_error, true
288
+
289
+ it 'raises a document not found error' do
290
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
291
+ /Document\(s\) not found for class Preference/)
292
+ end
293
+ end
294
+
295
+ context 'when raise_not_found_error is false' do
296
+ config_override :raise_not_found_error, false
297
+
298
+ it 'raises a document not found error' do
299
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
300
+ /Document\(s\) not found for class Preference/)
301
+ end
302
+ end
303
+ end
304
+ end
305
+
306
+ context 'when _destroy is true for a document not in the relation' do
307
+ before do
308
+ Person.send(:undef_method, :preferences_attributes=)
309
+ Person.accepts_nested_attributes_for :preferences, allow_destroy: true
310
+ end
311
+
312
+ let(:person) do
313
+ Person.new(
314
+ preferences_attributes: { 0 => { id: preference.id, _destroy: '1' } }
315
+ )
316
+ end
317
+
318
+ it 'does not raise UnknownAttribute' do
319
+ expect { person }.not_to raise_error
320
+ end
321
+
322
+ it 'does not add the document to the relation' do
323
+ expect(person.preferences).to be_empty
324
+ end
325
+ end
326
+ end
327
+
328
+ context 'when the id in the attributes is not a scalar' do
329
+ let(:target) { Person.create! }
330
+
331
+ def build_with_id(id)
332
+ target.update!(preferences_attributes: { 0 => { id: id, name: 'Crafted' } })
333
+ end
334
+
335
+ context 'when the association is empty' do
336
+ include_examples 'rejects non-scalar ids', 'Preference'
337
+ end
338
+
339
+ context 'when the association is not empty' do
340
+ before { target.preferences.create!(name: 'Existing') }
341
+
342
+ include_examples 'rejects non-scalar ids', 'Preference'
343
+ end
344
+
345
+ context 'when allow_reparenting_via_nested_attributes is true' do
346
+ config_override :allow_reparenting_via_nested_attributes, true
347
+
348
+ include_examples 'rejects non-scalar ids', 'Preference'
214
349
  end
215
350
  end
216
351
  end
@@ -3015,7 +3150,7 @@ describe Mongoid::Attributes::Nested do
3015
3150
  { "0" =>
3016
3151
  { "id" => BSON::ObjectId.new.to_s, "title" => "Rogue" }
3017
3152
  }
3018
- }.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Post with id\(s\)/)
3153
+ }.to raise_error(Mongoid::Errors::DocumentNotFound, /Document not found for class Post/)
3019
3154
  end
3020
3155
  end
3021
3156
  end
@@ -3049,7 +3184,7 @@ describe Mongoid::Attributes::Nested do
3049
3184
  expect {
3050
3185
  person.posts_attributes =
3051
3186
  { "foo" => { "id" => "test", "title" => "Test" } }
3052
- }.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Post with id\(s\)/)
3187
+ }.to raise_error(Mongoid::Errors::DocumentNotFound, /Document not found for class Post/)
3053
3188
  end
3054
3189
  end
3055
3190
  end
@@ -4997,4 +5132,63 @@ describe Mongoid::Attributes::Nested do
4997
5132
  end
4998
5133
  end
4999
5134
  end
5135
+
5136
+ context 'when an id in nested attributes belongs to another parent' do
5137
+ let(:victim) { Person.create! }
5138
+ let(:attacker) { Person.create! }
5139
+
5140
+ context 'when the association is a has-many' do
5141
+ before do
5142
+ Person.send(:undef_method, :posts_attributes=)
5143
+ Person.accepts_nested_attributes_for :posts
5144
+ end
5145
+
5146
+ let!(:victim_post) { victim.posts.create!(title: 'Private') }
5147
+
5148
+ it 'does not update the other parent\'s document' do
5149
+ expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5150
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5151
+ expect(victim_post.reload.title).to eq('Private')
5152
+ end
5153
+
5154
+ it 'does not add the other parent\'s document to the association' do
5155
+ expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5156
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5157
+ expect(attacker.reload.posts).to be_empty
5158
+ end
5159
+
5160
+ it 'does not update via a direct association assignment' do
5161
+ expect { attacker.update!(posts: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5162
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5163
+ expect(victim_post.reload.title).to eq('Private')
5164
+ end
5165
+ end
5166
+
5167
+ context 'when the association is a has-and-belongs-to-many' do
5168
+ before do
5169
+ Person.send(:undef_method, :preferences_attributes=)
5170
+ Person.accepts_nested_attributes_for :preferences
5171
+ end
5172
+
5173
+ let!(:victim_preference) { victim.preferences.create!(name: 'Private') }
5174
+
5175
+ it 'does not update the other parent\'s document' do
5176
+ expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5177
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5178
+ expect(victim_preference.reload.name).to eq('Private')
5179
+ end
5180
+
5181
+ it 'does not add the other parent\'s document to the association' do
5182
+ expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5183
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5184
+ expect(attacker.reload.preferences).to be_empty
5185
+ end
5186
+
5187
+ it 'does not update via a direct association assignment' do
5188
+ expect { attacker.update!(preferences: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5189
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5190
+ expect(victim_preference.reload.name).to eq('Private')
5191
+ end
5192
+ end
5193
+ end
5000
5194
  end
@@ -570,4 +570,95 @@ describe Mongoid::Contextual::Aggregable::Memory do
570
570
  end
571
571
  end
572
572
  end
573
+
574
+ context 'when the field name is a method name' do
575
+ let!(:depeche) do
576
+ Band.create!(name: 'Depeche Mode', likes: 1000)
577
+ end
578
+
579
+ let(:criteria) do
580
+ Band.all.tap do |crit|
581
+ crit.documents = [ depeche ]
582
+ end
583
+ end
584
+
585
+ it 'does not call the method for sum' do
586
+ expect(depeche).not_to receive(:delete)
587
+ expect(context.sum(:delete)).to eq(0)
588
+ end
589
+
590
+ it 'does not call the method for min' do
591
+ expect(depeche).not_to receive(:delete)
592
+ expect(context.min(:delete)).to be_nil
593
+ end
594
+
595
+ it 'does not call the method for max' do
596
+ expect(depeche).not_to receive(:delete)
597
+ expect(context.max(:delete)).to be_nil
598
+ end
599
+
600
+ it 'does not call the method for avg' do
601
+ expect(depeche).not_to receive(:delete)
602
+ expect(context.avg(:delete)).to be_nil
603
+ end
604
+
605
+ it 'does not call the method for aggregates' do
606
+ expect(depeche).not_to receive(:delete)
607
+ expect(context.aggregates(:delete))
608
+ .to eq('count' => 0, 'avg' => nil, 'max' => nil, 'min' => nil, 'sum' => 0)
609
+ end
610
+
611
+ it 'does not call a private method for avg' do
612
+ expect(depeche).not_to receive(:exit)
613
+ expect(context.avg(:exit)).to be_nil
614
+ end
615
+ end
616
+
617
+ context 'when the field is not defined' do
618
+ let!(:depeche) do
619
+ Band.create!(name: 'Depeche Mode', likes: 1000)
620
+ end
621
+
622
+ let(:criteria) do
623
+ Band.all.tap do |crit|
624
+ crit.documents = [ depeche ]
625
+ end
626
+ end
627
+
628
+ it 'returns zero for sum' do
629
+ expect(context.sum(:not_a_field)).to eq(0)
630
+ end
631
+
632
+ it 'returns nil for min' do
633
+ expect(context.min(:not_a_field)).to be_nil
634
+ end
635
+
636
+ it 'returns nil for max' do
637
+ expect(context.max(:not_a_field)).to be_nil
638
+ end
639
+
640
+ it 'returns nil for avg' do
641
+ expect(context.avg(:not_a_field)).to be_nil
642
+ end
643
+ end
644
+
645
+ context 'when the field is the foreign key of a many to many association' do
646
+ let!(:preference) do
647
+ Preference.create!(name: 'nature')
648
+ end
649
+
650
+ let!(:person) do
651
+ Person.new(preferences: [ preference ])
652
+ end
653
+
654
+ let(:criteria) do
655
+ Person.all.tap do |crit|
656
+ crit.documents = [ person ]
657
+ end
658
+ end
659
+
660
+ it 'aggregates the stored ids' do
661
+ expect(context.max(:preference_ids)).to eq([ preference.id ])
662
+ end
663
+ end
573
664
  end
@@ -653,6 +653,27 @@ describe Mongoid::Contextual::Memory do
653
653
  end
654
654
  end
655
655
  end
656
+
657
+ context 'when the field name is a method name' do
658
+ let!(:person) do
659
+ Person.create!(ssn: 'secret-ssn')
660
+ end
661
+
662
+ let!(:address) do
663
+ person.addresses.create!(street: 'hobrecht')
664
+ end
665
+
666
+ let(:criteria) do
667
+ Address.all.tap do |crit|
668
+ crit.documents = [ address ]
669
+ end
670
+ end
671
+
672
+ it 'does not call the method' do
673
+ expect(address).not_to receive(:destroy)
674
+ expect(context.distinct(:destroy)).to eq([ nil ])
675
+ end
676
+ end
656
677
  end
657
678
 
658
679
  describe "#each" do
@@ -1996,6 +2017,116 @@ describe Mongoid::Contextual::Memory do
1996
2017
  end
1997
2018
  end
1998
2019
  end
2020
+
2021
+ context 'when plucking a dynamic attribute' do
2022
+ let(:criteria) do
2023
+ Band.all.tap do |crit|
2024
+ crit.documents = [ Band.create!(name: 'Depeche Mode', mood: 'dark') ]
2025
+ end
2026
+ end
2027
+
2028
+ it 'returns the value from the attributes' do
2029
+ expect(context.pluck(:mood)).to eq([ 'dark' ])
2030
+ end
2031
+ end
2032
+
2033
+ context 'when the field name is a method name' do
2034
+ let!(:person) do
2035
+ Person.create!(ssn: 'secret-ssn')
2036
+ end
2037
+
2038
+ let!(:address) do
2039
+ person.addresses.create!(street: 'hobrecht')
2040
+ end
2041
+
2042
+ let(:criteria) do
2043
+ Address.all.tap do |crit|
2044
+ crit.documents = [ address ]
2045
+ end
2046
+ end
2047
+
2048
+ it 'does not call the method' do
2049
+ expect(address).not_to receive(:destroy)
2050
+ expect(context.pluck(:destroy)).to eq([ nil ])
2051
+ end
2052
+
2053
+ it 'does not return the attributes hash' do
2054
+ expect(context.pluck(:attributes)).to eq([ nil ])
2055
+ end
2056
+
2057
+ it 'does not traverse to the parent document' do
2058
+ expect(context.pluck('_root.ssn')).to eq([ nil ])
2059
+ end
2060
+
2061
+ it 'does not destroy the parent document' do
2062
+ expect(context.pluck('_root.destroy')).to eq([ nil ])
2063
+ expect(Person.where(_id: person.id).count).to eq(1)
2064
+ end
2065
+ end
2066
+
2067
+ context 'when plucking a belongs_to association' do
2068
+ let!(:band) do
2069
+ Band.create!(name: 'Depeche Mode')
2070
+ end
2071
+
2072
+ let!(:artist) do
2073
+ Artist.create!(band: band)
2074
+ end
2075
+
2076
+ let(:criteria) do
2077
+ Artist.all.tap do |crit|
2078
+ crit.documents = [ artist ]
2079
+ end
2080
+ end
2081
+
2082
+ it 'traverses to the associated document' do
2083
+ expect(context.pluck('band.name')).to eq([ 'Depeche Mode' ])
2084
+ end
2085
+
2086
+ it 'returns the document for the association name' do
2087
+ expect(context.pluck(:band)).to eq([ band ])
2088
+ end
2089
+
2090
+ it 'returns the id for the foreign key' do
2091
+ expect(context.pluck(:band_id)).to eq([ band.id ])
2092
+ end
2093
+ end
2094
+
2095
+ context 'when plucking the foreign key of a many to many association' do
2096
+ let!(:preference) do
2097
+ Preference.create!(name: 'nature')
2098
+ end
2099
+
2100
+ let!(:person) do
2101
+ Person.create!(preferences: [ preference ])
2102
+ end
2103
+
2104
+ let(:criteria) do
2105
+ Person.all.tap do |crit|
2106
+ crit.documents = [ person ]
2107
+ end
2108
+ end
2109
+
2110
+ it 'returns the stored ids' do
2111
+ expect(context.pluck(:preference_ids)).to eq([ [ preference.id ] ])
2112
+ end
2113
+ end
2114
+
2115
+ context 'when the field path has an empty segment' do
2116
+ let!(:band) do
2117
+ Band.create!(name: 'Depeche Mode', label: Label.new(name: 'Mute'))
2118
+ end
2119
+
2120
+ let(:criteria) do
2121
+ Band.all.tap do |crit|
2122
+ crit.documents = [ band ]
2123
+ end
2124
+ end
2125
+
2126
+ it 'returns nil' do
2127
+ expect(context.pluck('label..name')).to eq([ nil ])
2128
+ end
2129
+ end
1999
2130
  end
2000
2131
 
2001
2132
  describe "#pick" do
@@ -2056,6 +2187,27 @@ describe Mongoid::Contextual::Memory do
2056
2187
  expect(picked).to be_nil
2057
2188
  end
2058
2189
  end
2190
+
2191
+ context 'when the field name is a method name' do
2192
+ let!(:person) do
2193
+ Person.create!(ssn: 'secret-ssn')
2194
+ end
2195
+
2196
+ let!(:address) do
2197
+ person.addresses.create!(street: 'hobrecht')
2198
+ end
2199
+
2200
+ let(:criteria) do
2201
+ Address.all.tap do |crit|
2202
+ crit.documents = [ address ]
2203
+ end
2204
+ end
2205
+
2206
+ it 'does not call the method' do
2207
+ expect(address).not_to receive(:destroy)
2208
+ expect(context.pick(:destroy)).to be_nil
2209
+ end
2210
+ end
2059
2211
  end
2060
2212
 
2061
2213
  describe "#tally" do
@@ -2510,6 +2662,31 @@ describe Mongoid::Contextual::Memory do
2510
2662
  )
2511
2663
  end
2512
2664
  end
2665
+
2666
+ context 'when the field name is a method name' do
2667
+ let!(:person) do
2668
+ Person.create!(ssn: 'secret-ssn')
2669
+ end
2670
+
2671
+ let!(:address) do
2672
+ person.addresses.create!(street: 'hobrecht')
2673
+ end
2674
+
2675
+ let(:criteria) do
2676
+ Address.all.tap do |crit|
2677
+ crit.documents = [ address ]
2678
+ end
2679
+ end
2680
+
2681
+ it 'does not call the method' do
2682
+ expect(address).not_to receive(:destroy)
2683
+ expect(context.tally(:destroy)).to eq(nil => 1)
2684
+ end
2685
+
2686
+ it 'does not disclose the parent document fields' do
2687
+ expect(context.tally('_root.ssn')).to eq(nil => 1)
2688
+ end
2689
+ end
2513
2690
  end
2514
2691
 
2515
2692
  describe '#inc' do
@@ -1829,6 +1829,8 @@ describe Mongoid::Criteria::Queryable::Selectable do
1829
1829
  end
1830
1830
 
1831
1831
  context 'when the following criteria uses string were form' do
1832
+ # String criteria compile to $where, which requires the opt-in.
1833
+ config_override :allow_unsafe_query_operators, true
1832
1834
 
1833
1835
  let(:selection) do
1834
1836
  query.not.where('hello world')