mongoid 9.1.0 → 9.1.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +49 -0
  3. data/lib/mongoid/association/accessors.rb +9 -1
  4. data/lib/mongoid/association/depending.rb +14 -10
  5. data/lib/mongoid/association/eager.rb +9 -0
  6. data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +88 -0
  7. data/lib/mongoid/association/eager_load/embedded_distributor.rb +151 -0
  8. data/lib/mongoid/association/eager_load/inclusion.rb +173 -0
  9. data/lib/mongoid/association/eager_load/inclusion_tree.rb +96 -0
  10. data/lib/mongoid/association/eager_load/lookup_pipeline.rb +121 -0
  11. data/lib/mongoid/association/eager_load/polymorphic_preloader.rb +61 -0
  12. data/lib/mongoid/association/eager_load/polymorphic_targets.rb +151 -0
  13. data/lib/mongoid/association/eager_loadable.rb +57 -100
  14. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
  15. data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
  16. data/lib/mongoid/association/macros.rb +13 -1
  17. data/lib/mongoid/association/nested/many.rb +34 -5
  18. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  19. data/lib/mongoid/association/nested/one.rb +6 -1
  20. data/lib/mongoid/association/options.rb +17 -0
  21. data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
  22. data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
  23. data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
  24. data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
  25. data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
  26. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  27. data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
  28. data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
  29. data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
  30. data/lib/mongoid/association/referenced/has_one.rb +1 -0
  31. data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
  32. data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
  33. data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
  34. data/lib/mongoid/association/referenced.rb +2 -0
  35. data/lib/mongoid/association/relatable.rb +40 -0
  36. data/lib/mongoid/association.rb +7 -0
  37. data/lib/mongoid/clients/factory.rb +7 -13
  38. data/lib/mongoid/collection_configurable.rb +8 -0
  39. data/lib/mongoid/config/defaults.rb +0 -1
  40. data/lib/mongoid/config/encryption.rb +36 -19
  41. data/lib/mongoid/config.rb +56 -5
  42. data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
  43. data/lib/mongoid/contextual/memory.rb +18 -7
  44. data/lib/mongoid/criteria/includable.rb +35 -11
  45. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  46. data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
  47. data/lib/mongoid/deprecable.rb +45 -2
  48. data/lib/mongoid/encryptable.rb +46 -0
  49. data/lib/mongoid/errors/config_redactor.rb +43 -0
  50. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  51. data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
  52. data/lib/mongoid/errors/no_client_database.rb +1 -1
  53. data/lib/mongoid/errors/no_client_hosts.rb +1 -1
  54. data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
  55. data/lib/mongoid/errors/readonly_association.rb +29 -0
  56. data/lib/mongoid/errors.rb +4 -0
  57. data/lib/mongoid/extensions/vector.rb +64 -0
  58. data/lib/mongoid/extensions.rb +1 -0
  59. data/lib/mongoid/field_readable.rb +69 -0
  60. data/lib/mongoid/indexable.rb +38 -26
  61. data/lib/mongoid/matchable.rb +6 -1
  62. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
  63. data/lib/mongoid/matcher/regex.rb +11 -12
  64. data/lib/mongoid/matcher/regexp_budget.rb +372 -0
  65. data/lib/mongoid/matcher.rb +1 -0
  66. data/lib/mongoid/persistable/creatable.rb +1 -1
  67. data/lib/mongoid/persistable/updatable.rb +9 -11
  68. data/lib/mongoid/persistence_context.rb +35 -0
  69. data/lib/mongoid/search_indexable.rb +64 -23
  70. data/lib/mongoid/serializable.rb +12 -4
  71. data/lib/mongoid/tasks/database.rb +23 -9
  72. data/lib/mongoid/threaded.rb +36 -0
  73. data/lib/mongoid/timestamps/timeless.rb +84 -10
  74. data/lib/mongoid/traversable.rb +2 -2
  75. data/lib/mongoid/validatable/associated.rb +18 -2
  76. data/lib/mongoid/version.rb +1 -1
  77. data/spec/integration/app_spec.rb +7 -1
  78. data/spec/integration/associations/belongs_to_spec.rb +13 -0
  79. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  80. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  81. data/spec/integration/encryption_spec.rb +151 -0
  82. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  83. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  84. data/spec/integration/query_operator_guard_spec.rb +89 -0
  85. data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
  86. data/spec/mongoid/association/eager_spec.rb +2 -2
  87. data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
  88. data/spec/mongoid/association/fallback_spec.rb +425 -0
  89. data/spec/mongoid/association/macros_spec.rb +26 -0
  90. data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
  91. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  92. data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
  93. data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
  94. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  95. data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
  96. data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
  97. data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
  98. data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
  99. data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
  100. data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
  101. data/spec/mongoid/attributes/nested_spec.rb +249 -8
  102. data/spec/mongoid/config/defaults_spec.rb +33 -0
  103. data/spec/mongoid/config/encryption_spec.rb +228 -0
  104. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  105. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  106. data/spec/mongoid/criteria/includable_spec.rb +875 -0
  107. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
  108. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
  109. data/spec/mongoid/criteria_spec.rb +3 -0
  110. data/spec/mongoid/encryptable_spec.rb +61 -0
  111. data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
  112. data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
  113. data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
  114. data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
  115. data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
  116. data/spec/mongoid/extensions/vector_spec.rb +175 -0
  117. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  118. data/spec/mongoid/search_indexable_spec.rb +295 -9
  119. data/spec/mongoid/tasks/database_spec.rb +18 -0
  120. data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
  121. data/spec/mongoid/touchable_spec.rb +25 -0
  122. data/spec/mongoid/touchable_spec_models.rb +10 -0
  123. data/spec/mongoid/validatable/associated_spec.rb +19 -0
  124. data/spec/spec_helper.rb +5 -1
  125. data/spec/support/crypt/models.rb +157 -0
  126. metadata +52 -2
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Mongoid::Association::Referenced::HasOneThrough::Eager do
6
+ before(:all) do
7
+ Object.const_set(:EgCustomer, Class.new do
8
+ include Mongoid::Document
9
+
10
+ store_in collection: 'eg_customers'
11
+ has_one :eg_franchise, class_name: 'EgFranchise', inverse_of: :eg_customer
12
+ has_one :eg_store, through: :eg_franchise, class_name: 'EgStore'
13
+ end)
14
+ Object.const_set(:EgFranchise, Class.new do
15
+ include Mongoid::Document
16
+
17
+ store_in collection: 'eg_franchises'
18
+ belongs_to :eg_customer, class_name: 'EgCustomer'
19
+ has_one :eg_store, class_name: 'EgStore', inverse_of: :eg_franchise
20
+ end)
21
+ Object.const_set(:EgStore, Class.new do
22
+ include Mongoid::Document
23
+
24
+ store_in collection: 'eg_stores'
25
+ belongs_to :eg_franchise, class_name: 'EgFranchise'
26
+ end)
27
+ end
28
+
29
+ after(:all) do
30
+ %w[EgCustomer EgFranchise EgStore].each { |c| Object.send(:remove_const, c) }
31
+ end
32
+
33
+ before { [ EgCustomer, EgFranchise, EgStore ].each(&:delete_all) }
34
+
35
+ it 'preloads the through association for multiple owners' do
36
+ c1 = EgCustomer.create!
37
+ c2 = EgCustomer.create!
38
+ f1 = EgFranchise.create!(eg_customer: c1)
39
+ f2 = EgFranchise.create!(eg_customer: c2)
40
+ s1 = EgStore.create!(eg_franchise: f1)
41
+ s2 = EgStore.create!(eg_franchise: f2)
42
+
43
+ customers = EgCustomer.includes(:eg_store).to_a
44
+ by_id = customers.index_by(&:id)
45
+
46
+ expect(by_id[c1.id].eg_store).to eq(s1)
47
+ expect(by_id[c2.id].eg_store).to eq(s2)
48
+ end
49
+
50
+ it 'sets nil on owners without a store' do
51
+ lone = EgCustomer.create!
52
+ EgFranchise.create!(eg_customer: lone) # franchise but no store
53
+
54
+ customers = EgCustomer.includes(:eg_store).to_a
55
+ loaded = customers.find { |c| c.id == lone.id }
56
+ expect(loaded.eg_store).to be_nil
57
+ end
58
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Mongoid::Association::Referenced::HasOneThrough::Proxy do
6
+ before(:all) do
7
+ Object.const_set(:PxHotStore, Class.new { include Mongoid::Document })
8
+ Object.const_set(:PxHotFranchise, Class.new do
9
+ include Mongoid::Document
10
+
11
+ has_one :px_hot_store, class_name: 'PxHotStore', inverse_of: :px_hot_franchise
12
+ end)
13
+ Object.const_set(:PxHotCustomer, Class.new do
14
+ include Mongoid::Document
15
+
16
+ has_one :px_hot_franchise, class_name: 'PxHotFranchise',
17
+ inverse_of: :px_hot_customer
18
+ has_one :px_hot_store, through: :px_hot_franchise, class_name: 'PxHotStore',
19
+ source: :px_hot_store
20
+ end)
21
+ end
22
+
23
+ after(:all) do
24
+ %w[PxHotCustomer PxHotFranchise PxHotStore].each { |c| Object.send(:remove_const, c) }
25
+ end
26
+
27
+ let(:customer) { PxHotCustomer.new }
28
+
29
+ describe 'setter' do
30
+ it 'raises ReadonlyAssociation' do
31
+ expect { customer.px_hot_store = PxHotStore.new }.to \
32
+ raise_error(Mongoid::Errors::ReadonlyAssociation)
33
+ end
34
+ end
35
+
36
+ describe '.eager_loader' do
37
+ it 'returns a HasOneThrough::Eager' do
38
+ assoc = PxHotCustomer.relations['px_hot_store']
39
+ result = described_class.eager_loader([ assoc ], [])
40
+ expect(result).to be_a(Mongoid::Association::Referenced::HasOneThrough::Eager)
41
+ end
42
+ end
43
+
44
+ describe '.embedded?' do
45
+ it 'returns false' do
46
+ expect(described_class.embedded?).to be false
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Mongoid::Association::Referenced::HasOneThrough do
6
+ # Model setup:
7
+ # HotCustomer has_one :hot_franchise
8
+ # HotCustomer has_one :hot_store, through: :hot_franchise, source: :depot
9
+ # HotFranchise has_one :depot (class HotStore)
10
+
11
+ before(:all) do
12
+ Object.const_set(:HotStore, Class.new do
13
+ include Mongoid::Document
14
+
15
+ field :hot_franchise_id, type: BSON::ObjectId
16
+ end)
17
+ Object.const_set(:HotFranchise, Class.new do
18
+ include Mongoid::Document
19
+
20
+ field :hot_customer_id, type: BSON::ObjectId
21
+ has_one :depot, class_name: 'HotStore', inverse_of: :hot_franchise
22
+ end)
23
+ Object.const_set(:HotCustomer, Class.new do
24
+ include Mongoid::Document
25
+
26
+ has_one :hot_franchise, class_name: 'HotFranchise', inverse_of: :hot_customer
27
+ has_one :hot_store, through: :hot_franchise,
28
+ class_name: 'HotStore', source: :depot
29
+ end)
30
+ end
31
+
32
+ after(:all) do
33
+ %w[HotCustomer HotFranchise HotStore].each { |c| Object.send(:remove_const, c) }
34
+ end
35
+
36
+ let(:assoc) { HotCustomer.relations['hot_store'] }
37
+
38
+ describe '#through_association' do
39
+ it 'returns the intermediate association metadata' do
40
+ expect(assoc.through_association).to eq(HotCustomer.relations['hot_franchise'])
41
+ end
42
+ end
43
+
44
+ describe '#source_association' do
45
+ it 'returns the :depot association on HotFranchise' do
46
+ expect(assoc.source_association).to eq(HotFranchise.relations['depot'])
47
+ end
48
+ end
49
+
50
+ describe '#embedded?' do
51
+ it 'returns false' do
52
+ expect(assoc.embedded?).to be false
53
+ end
54
+ end
55
+
56
+ describe 'VALID_OPTIONS' do
57
+ it 'accepts :through' do
58
+ expect { HotCustomer.has_one(:foo, through: :hot_franchise) }.not_to raise_error
59
+ end
60
+
61
+ it 'accepts :source' do
62
+ expect { HotCustomer.has_one(:bar, through: :hot_franchise, source: :depot) }.not_to raise_error
63
+ end
64
+
65
+ it 'rejects unknown options at definition time' do
66
+ expect do
67
+ HotCustomer.has_one(:baz, through: :hot_franchise, bogus_option: true)
68
+ end.to raise_error(Mongoid::Errors::InvalidRelationOption)
69
+ end
70
+ end
71
+
72
+ describe '#criteria' do
73
+ it 'returns nil when the intermediate is nil' do
74
+ customer = HotCustomer.new
75
+ allow(customer).to receive(:hot_franchise).and_return(nil)
76
+ expect(assoc.criteria(customer)).to be_nil
77
+ end
78
+
79
+ it 'delegates to the source association on the intermediate' do
80
+ franchise = HotFranchise.new
81
+ store = HotStore.new
82
+ customer = HotCustomer.new
83
+ allow(customer).to receive(:hot_franchise).and_return(franchise)
84
+ allow(franchise).to receive(:depot).and_return(store)
85
+ expect(assoc.criteria(customer)).to eq(store)
86
+ end
87
+ end
88
+
89
+ describe ':through on an embedded association' do
90
+ it 'raises InvalidRelationOption when through association is embedded' do
91
+ embedded_owner = Class.new do
92
+ include Mongoid::Document
93
+
94
+ embeds_one :address
95
+ end
96
+ embedded_owner.has_one(:city, through: :address)
97
+ expect do
98
+ embedded_owner.relations['city'].through_association # triggers lazy validation
99
+ end.to raise_error(Mongoid::Errors::InvalidRelationOption)
100
+ end
101
+ end
102
+
103
+ context 'integration', :integration do
104
+ before(:all) do
105
+ Object.const_set(:IntCustomer, Class.new do
106
+ include Mongoid::Document
107
+
108
+ store_in collection: 'int_customers'
109
+ has_one :int_franchise, class_name: 'IntFranchise', inverse_of: :int_customer
110
+ # source: :depot exercises the :source option end-to-end
111
+ has_one :int_store, through: :int_franchise, class_name: 'IntStore', source: :depot
112
+ end)
113
+
114
+ Object.const_set(:IntFranchise, Class.new do
115
+ include Mongoid::Document
116
+
117
+ store_in collection: 'int_franchises'
118
+ field :int_customer_id, type: BSON::ObjectId
119
+ belongs_to :int_customer, class_name: 'IntCustomer'
120
+ has_one :depot, class_name: 'IntStore', inverse_of: :int_franchise
121
+ end)
122
+
123
+ Object.const_set(:IntStore, Class.new do
124
+ include Mongoid::Document
125
+
126
+ store_in collection: 'int_stores'
127
+ field :int_franchise_id, type: BSON::ObjectId
128
+ belongs_to :int_franchise, class_name: 'IntFranchise'
129
+ end)
130
+ end
131
+
132
+ after(:all) do
133
+ %w[IntCustomer IntFranchise IntStore].each { |c| Object.send(:remove_const, c) }
134
+ end
135
+
136
+ before { [ IntCustomer, IntFranchise, IntStore ].each(&:delete_all) }
137
+
138
+ let!(:customer) { IntCustomer.create! }
139
+ let!(:franchise) { IntFranchise.create!(int_customer: customer) }
140
+ let!(:store) { IntStore.create!(int_franchise: franchise) }
141
+
142
+ describe 'getter' do
143
+ it 'returns the store via the franchise' do
144
+ expect(customer.int_store).to eq(store)
145
+ end
146
+
147
+ it 'returns nil when the franchise is absent' do
148
+ lone = IntCustomer.create!
149
+ expect(lone.int_store).to be_nil
150
+ end
151
+
152
+ it 'returns cached value before reload and fresh value after' do
153
+ first_store = customer.int_store # prime cache
154
+ expect(first_store).to eq(store)
155
+
156
+ new_store = IntStore.create!
157
+ franchise.depot = new_store
158
+ franchise.save!
159
+
160
+ expect(customer.int_store).to eq(store) # cached — still old
161
+ expect(customer.int_store(true)).to eq(new_store) # reloaded
162
+ end
163
+ end
164
+
165
+ describe 'setter' do
166
+ it 'raises ReadonlyAssociation' do
167
+ expect { customer.int_store = IntStore.new }.to \
168
+ raise_error(Mongoid::Errors::ReadonlyAssociation)
169
+ end
170
+ end
171
+ end
172
+
173
+ context 'with :source option resolving a differently-named association', :integration do
174
+ before(:all) do
175
+ Object.const_set(:SrcOrg, Class.new do
176
+ include Mongoid::Document
177
+
178
+ store_in collection: 'src_orgs'
179
+ has_one :src_location, class_name: 'SrcLocation', inverse_of: :src_org
180
+ has_one :src_hq, through: :src_location,
181
+ class_name: 'SrcBuilding', source: :src_main_building
182
+ end)
183
+ Object.const_set(:SrcLocation, Class.new do
184
+ include Mongoid::Document
185
+
186
+ store_in collection: 'src_locations'
187
+ belongs_to :src_org, class_name: 'SrcOrg'
188
+ has_one :src_main_building, class_name: 'SrcBuilding', inverse_of: :src_location
189
+ end)
190
+ Object.const_set(:SrcBuilding, Class.new do
191
+ include Mongoid::Document
192
+
193
+ store_in collection: 'src_buildings'
194
+ belongs_to :src_location, class_name: 'SrcLocation'
195
+ end)
196
+ end
197
+
198
+ after(:all) do
199
+ %w[SrcOrg SrcLocation SrcBuilding].each { |c| Object.send(:remove_const, c) }
200
+ end
201
+
202
+ before { [ SrcOrg, SrcLocation, SrcBuilding ].each(&:delete_all) }
203
+
204
+ it 'resolves the :source association on the intermediate' do
205
+ org = SrcOrg.create!
206
+ location = SrcLocation.create!(src_org: org)
207
+ building = SrcBuilding.create!(src_location: location)
208
+ expect(org.src_hq).to eq(building)
209
+ end
210
+ end
211
+ end
@@ -7,6 +7,33 @@ require_relative '../association/referenced/has_and_belongs_to_many_models'
7
7
  require_relative 'nested_spec_models'
8
8
 
9
9
  describe Mongoid::Attributes::Nested do
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
  context 'when the autosave option is not defined' do
12
39
  let(:person) do
@@ -188,6 +215,77 @@ describe Mongoid::Attributes::Nested do
188
215
  it 'sets the nested attributes' do
189
216
  expect(person2.posts.map(&:title)).to eq([ 'Reparented!' ])
190
217
  end
218
+
219
+ context 'when the id does not correspond to an existing document' do
220
+ let(:person2) do
221
+ Person.create!(posts_attributes: { '0' => { id: BSON::ObjectId.new, title: 'Ghost' } })
222
+ end
223
+
224
+ context 'when raise_not_found_error is true' do
225
+ config_override :raise_not_found_error, true
226
+
227
+ it 'raises a document not found error' do
228
+ expect { person2 }.to raise_error(Mongoid::Errors::DocumentNotFound,
229
+ /Document\(s\) not found for class Post/)
230
+ end
231
+ end
232
+
233
+ context 'when raise_not_found_error is false' do
234
+ config_override :raise_not_found_error, false
235
+
236
+ it 'raises a document not found error' do
237
+ expect { person2 }.to raise_error(Mongoid::Errors::DocumentNotFound,
238
+ /Document\(s\) not found for class Post/)
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ context 'when _destroy is true for a document not in the relation' do
245
+ config_override :allow_reparenting_via_nested_attributes, true
246
+
247
+ before do
248
+ Person.send(:undef_method, :posts_attributes=)
249
+ Person.accepts_nested_attributes_for :posts, allow_destroy: true
250
+ end
251
+
252
+ let(:person3) do
253
+ Person.create!(
254
+ posts_attributes: { '0' => { id: post.id, _destroy: '1' } }
255
+ )
256
+ end
257
+
258
+ it 'does not raise UnknownAttribute' do
259
+ expect { person3 }.not_to raise_error
260
+ end
261
+
262
+ it 'does not add the document to the relation' do
263
+ expect(person3.posts).to be_empty
264
+ end
265
+ end
266
+ end
267
+
268
+ context 'when the id in the attributes is not a scalar' do
269
+ let(:person) { Person.create! }
270
+
271
+ def build_with_id(id)
272
+ person.update!(posts_attributes: { '0' => { id: id, title: 'Crafted' } })
273
+ end
274
+
275
+ context 'when the association is empty' do
276
+ include_examples 'rejects non-scalar ids', 'Post'
277
+ end
278
+
279
+ context 'when the association is not empty' do
280
+ before { person.posts.create!(title: 'Existing') }
281
+
282
+ include_examples 'rejects non-scalar ids', 'Post'
283
+ end
284
+
285
+ context 'when allow_reparenting_via_nested_attributes is true' do
286
+ config_override :allow_reparenting_via_nested_attributes, true
287
+
288
+ include_examples 'rejects non-scalar ids', 'Post'
191
289
  end
192
290
  end
193
291
  end
@@ -214,16 +312,100 @@ describe Mongoid::Attributes::Nested do
214
312
  )
215
313
  end
216
314
 
217
- it 'sets the nested attributes' do
218
- expect(person.preferences.map(&:name)).to eq([ preference.name ])
315
+ context 'when allow_reparenting_via_nested_attributes is false' do
316
+ config_override :allow_reparenting_via_nested_attributes, false
317
+
318
+ it 'raises a document not found error' do
319
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
320
+ /Document\(s\) not found for class Preference/)
321
+ end
219
322
  end
220
323
 
221
- it 'updates attributes of existing document which is added to relation' do
222
- preference_name = 'updated preference'
223
- person = Person.new(
224
- preferences_attributes: { 0 => { id: preference.id, name: preference_name } }
225
- )
226
- expect(person.preferences.map(&:name)).to eq([ preference_name ])
324
+ context 'when allow_reparenting_via_nested_attributes is true' do
325
+ config_override :allow_reparenting_via_nested_attributes, true
326
+
327
+ it 'sets the nested attributes' do
328
+ expect(person.preferences.map(&:name)).to eq([ preference.name ])
329
+ end
330
+
331
+ it 'updates attributes of existing document which is added to relation' do
332
+ preference_name = 'updated preference'
333
+ person = Person.new(
334
+ preferences_attributes: { 0 => { id: preference.id, name: preference_name } }
335
+ )
336
+ expect(person.preferences.map(&:name)).to eq([ preference_name ])
337
+ end
338
+
339
+ context 'when the id does not correspond to an existing document' do
340
+ let(:person) do
341
+ Person.new(
342
+ preferences_attributes: { 0 => { id: BSON::ObjectId.new, name: 'Ghost' } }
343
+ )
344
+ end
345
+
346
+ context 'when raise_not_found_error is true' do
347
+ config_override :raise_not_found_error, true
348
+
349
+ it 'raises a document not found error' do
350
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
351
+ /Document\(s\) not found for class Preference/)
352
+ end
353
+ end
354
+
355
+ context 'when raise_not_found_error is false' do
356
+ config_override :raise_not_found_error, false
357
+
358
+ it 'raises a document not found error' do
359
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
360
+ /Document\(s\) not found for class Preference/)
361
+ end
362
+ end
363
+ end
364
+ end
365
+
366
+ context 'when _destroy is true for a document not in the relation' do
367
+ before do
368
+ Person.send(:undef_method, :preferences_attributes=)
369
+ Person.accepts_nested_attributes_for :preferences, allow_destroy: true
370
+ end
371
+
372
+ let(:person) do
373
+ Person.new(
374
+ preferences_attributes: { 0 => { id: preference.id, _destroy: '1' } }
375
+ )
376
+ end
377
+
378
+ it 'does not raise UnknownAttribute' do
379
+ expect { person }.not_to raise_error
380
+ end
381
+
382
+ it 'does not add the document to the relation' do
383
+ expect(person.preferences).to be_empty
384
+ end
385
+ end
386
+ end
387
+
388
+ context 'when the id in the attributes is not a scalar' do
389
+ let(:target) { Person.create! }
390
+
391
+ def build_with_id(id)
392
+ target.update!(preferences_attributes: { 0 => { id: id, name: 'Crafted' } })
393
+ end
394
+
395
+ context 'when the association is empty' do
396
+ include_examples 'rejects non-scalar ids', 'Preference'
397
+ end
398
+
399
+ context 'when the association is not empty' do
400
+ before { target.preferences.create!(name: 'Existing') }
401
+
402
+ include_examples 'rejects non-scalar ids', 'Preference'
403
+ end
404
+
405
+ context 'when allow_reparenting_via_nested_attributes is true' do
406
+ config_override :allow_reparenting_via_nested_attributes, true
407
+
408
+ include_examples 'rejects non-scalar ids', 'Preference'
227
409
  end
228
410
  end
229
411
  end
@@ -4552,4 +4734,63 @@ describe Mongoid::Attributes::Nested do
4552
4734
  end
4553
4735
  end
4554
4736
  end
4737
+
4738
+ context 'when an id in nested attributes belongs to another parent' do
4739
+ let(:victim) { Person.create! }
4740
+ let(:attacker) { Person.create! }
4741
+
4742
+ context 'when the association is a has-many' do
4743
+ before do
4744
+ Person.send(:undef_method, :posts_attributes=)
4745
+ Person.accepts_nested_attributes_for :posts
4746
+ end
4747
+
4748
+ let!(:victim_post) { victim.posts.create!(title: 'Private') }
4749
+
4750
+ it 'does not update the other parent\'s document' do
4751
+ expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
4752
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
4753
+ expect(victim_post.reload.title).to eq('Private')
4754
+ end
4755
+
4756
+ it 'does not add the other parent\'s document to the association' do
4757
+ expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
4758
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
4759
+ expect(attacker.reload.posts).to be_empty
4760
+ end
4761
+
4762
+ it 'does not update via a direct association assignment' do
4763
+ expect { attacker.update!(posts: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
4764
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
4765
+ expect(victim_post.reload.title).to eq('Private')
4766
+ end
4767
+ end
4768
+
4769
+ context 'when the association is a has-and-belongs-to-many' do
4770
+ before do
4771
+ Person.send(:undef_method, :preferences_attributes=)
4772
+ Person.accepts_nested_attributes_for :preferences
4773
+ end
4774
+
4775
+ let!(:victim_preference) { victim.preferences.create!(name: 'Private') }
4776
+
4777
+ it 'does not update the other parent\'s document' do
4778
+ expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
4779
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
4780
+ expect(victim_preference.reload.name).to eq('Private')
4781
+ end
4782
+
4783
+ it 'does not add the other parent\'s document to the association' do
4784
+ expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
4785
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
4786
+ expect(attacker.reload.preferences).to be_empty
4787
+ end
4788
+
4789
+ it 'does not update via a direct association assignment' do
4790
+ expect { attacker.update!(preferences: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
4791
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
4792
+ expect(victim_preference.reload.name).to eq('Private')
4793
+ end
4794
+ end
4795
+ end
4555
4796
  end
@@ -38,6 +38,24 @@ describe Mongoid::Config::Defaults do
38
38
  end
39
39
  end
40
40
 
41
+ shared_examples 'uses settings for 9.0' do
42
+ it 'uses settings for 9.0' do
43
+ expect(Mongoid.autosave_saves_unchanged_documents).to be true
44
+ end
45
+ end
46
+
47
+ shared_examples 'does not use settings for 9.0' do
48
+ it 'does not use settings for 9.0' do
49
+ expect(Mongoid.autosave_saves_unchanged_documents).to be false
50
+ end
51
+ end
52
+
53
+ shared_examples 'never allows reparenting via nested attributes' do
54
+ it 'leaves allow_reparenting_via_nested_attributes false' do
55
+ expect(Mongoid.allow_reparenting_via_nested_attributes).to be false
56
+ end
57
+ end
58
+
41
59
  context 'when giving a valid version' do
42
60
  before do
43
61
  config.load_defaults(version)
@@ -52,6 +70,8 @@ describe Mongoid::Config::Defaults do
52
70
 
53
71
  it_behaves_like 'uses settings for 8.0'
54
72
  it_behaves_like 'uses settings for 8.1'
73
+ it_behaves_like 'uses settings for 9.0'
74
+ it_behaves_like 'never allows reparenting via nested attributes'
55
75
  end
56
76
 
57
77
  context 'when the given version is 8.1' do
@@ -59,6 +79,8 @@ describe Mongoid::Config::Defaults do
59
79
 
60
80
  it_behaves_like 'does not use settings for 8.0'
61
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'
62
84
  end
63
85
 
64
86
  context 'when the given version is 9.0' do
@@ -66,6 +88,17 @@ describe Mongoid::Config::Defaults do
66
88
 
67
89
  it_behaves_like 'does not use settings for 8.0'
68
90
  it_behaves_like 'does not use settings for 8.1'
91
+ it_behaves_like 'uses settings for 9.0'
92
+ it_behaves_like 'never allows reparenting via nested attributes'
93
+ end
94
+
95
+ context 'when the given version is 9.1' do
96
+ let(:version) { 9.1 }
97
+
98
+ it_behaves_like 'does not use settings for 8.0'
99
+ it_behaves_like 'does not use settings for 8.1'
100
+ it_behaves_like 'does not use settings for 9.0'
101
+ it_behaves_like 'never allows reparenting via nested attributes'
69
102
  end
70
103
  end
71
104