mongoid 9.1.1 → 9.1.2

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +31 -11
  3. data/lib/mongoid/association/accessors.rb +18 -4
  4. data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +10 -10
  5. data/lib/mongoid/association/eager_load/embedded_distributor.rb +6 -6
  6. data/lib/mongoid/association/eager_load/lookup_pipeline.rb +1 -1
  7. data/lib/mongoid/association/eager_loadable.rb +8 -1
  8. data/lib/mongoid/association/embedded/batchable.rb +17 -1
  9. data/lib/mongoid/association/nested/many.rb +1 -1
  10. data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
  11. data/lib/mongoid/association/relatable.rb +18 -0
  12. data/lib/mongoid/association.rb +8 -0
  13. data/lib/mongoid/atomic/modifiers.rb +100 -16
  14. data/lib/mongoid/clients/factory.rb +2 -0
  15. data/lib/mongoid/config/encryption.rb +148 -17
  16. data/lib/mongoid/document.rb +3 -0
  17. data/lib/mongoid/encryptable.rb +42 -15
  18. data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
  19. data/lib/mongoid/errors/no_encryption_schema.rb +5 -3
  20. data/lib/mongoid/errors.rb +1 -0
  21. data/lib/mongoid/fields.rb +7 -0
  22. data/lib/mongoid/persistable/creatable.rb +120 -14
  23. data/lib/mongoid/persistable.rb +15 -15
  24. data/lib/mongoid/persistence_context.rb +46 -4
  25. data/lib/mongoid/search_indexable.rb +5 -1
  26. data/lib/mongoid/tasks/database.rb +10 -7
  27. data/lib/mongoid/touchable.rb +28 -1
  28. data/lib/mongoid/version.rb +1 -1
  29. data/lib/mongoid/warnings.rb +4 -0
  30. data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
  31. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +14 -0
  32. data/spec/integration/atomic/modifiers_spec.rb +283 -0
  33. data/spec/integration/encryption_spec.rb +149 -0
  34. data/spec/mongoid/association/accessors_spec.rb +35 -0
  35. data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
  36. data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
  37. data/spec/mongoid/attributes/nested_spec.rb +29 -0
  38. data/spec/mongoid/clients/factory_spec.rb +36 -0
  39. data/spec/mongoid/config/encryption_spec.rb +223 -0
  40. data/spec/mongoid/criteria/includable_spec.rb +346 -0
  41. data/spec/mongoid/encryptable_spec.rb +54 -0
  42. data/spec/mongoid/fields_spec.rb +8 -0
  43. data/spec/mongoid/search_indexable_spec.rb +26 -0
  44. data/spec/mongoid/tasks/database_spec.rb +13 -0
  45. data/spec/mongoid/touchable_spec.rb +217 -0
  46. data/spec/mongoid/touchable_spec_models.rb +37 -0
  47. data/spec/support/crypt/models.rb +96 -0
  48. data/spec/support/models/modifier_models.rb +65 -0
  49. metadata +5 -2
@@ -64,15 +64,18 @@ module Mongoid
64
64
  # @param [ true | false ] wait whether to wait for the indexes to be
65
65
  # built.
66
66
  def create_search_indexes(models = ::Mongoid.models, wait: true)
67
- searchable = models.select { |m| m.search_index_specs.any? }
67
+ Threaded.with_collection_management do
68
+ searchable = models.select { |m| m.search_index_specs.any? }
68
69
 
69
- # queue up the search index creation requests
70
- index_names_by_model = searchable.each_with_object({}) do |model, obj|
71
- logger.info("MONGOID: Creating search indexes on #{model}...")
72
- obj[model] = model.create_search_indexes
73
- end
70
+ # queue up the search index creation requests
71
+ index_names_by_model = searchable.each_with_object({}) do |model, obj|
72
+ logger.info("MONGOID: Creating search indexes on #{model}...")
73
+ obj[model] = model.create_search_indexes
74
+ end
74
75
 
75
- wait_for_search_indexes(index_names_by_model) if wait
76
+ wait_for_search_indexes(index_names_by_model) if wait
77
+ index_names_by_model
78
+ end
76
79
  end
77
80
 
78
81
  # Return the list of indexes by model that exist in the database but aren't
@@ -49,7 +49,7 @@ module Mongoid
49
49
 
50
50
  begin
51
51
  touches = _gather_touch_updates(Time.current, field)
52
- _root.send(:persist_atomic_operations, '$set' => touches) if touches.present?
52
+ _root.persist_atomic_operations('$set' => touches) if touches.present?
53
53
  _run_touch_callbacks_from_root
54
54
  ensure
55
55
  _clear_touch_updates(field)
@@ -104,6 +104,33 @@ module Mongoid
104
104
  run_callbacks(:touch)
105
105
  end
106
106
 
107
+ # The root-relative paths that a touch of this document's parent chain
108
+ # writes: the (possibly aliased) +updated_at+ field of each touchable
109
+ # node in the chain, plus this document's custom touch field, if any.
110
+ #
111
+ # @api private
112
+ #
113
+ # @param [ Symbol, String, nil ] field The custom touch field.
114
+ #
115
+ # @return [ Array<String> ] The touchable paths.
116
+ def _touchable_chain_paths(field = nil)
117
+ field = database_field_name(field) if field
118
+
119
+ node = self
120
+ paths = []
121
+ loop do
122
+ if node.respond_to?(:updated_at=)
123
+ paths << node.atomic_attribute_name(node.database_field_name(:updated_at))
124
+ end
125
+ paths << node.atomic_attribute_name(field) if node.equal?(self) && field
126
+
127
+ break unless node._touchable_parent?
128
+
129
+ node = node._parent
130
+ end
131
+ paths
132
+ end
133
+
107
134
  # Indicates whether the parent exists and is touchable.
108
135
  #
109
136
  # @api private
@@ -5,5 +5,5 @@ module Mongoid
5
5
  #
6
6
  # Note that this file is automatically updated via `rake candidate:create`.
7
7
  # Manual changes to this file will be overwritten by that rake task.
8
- VERSION = '9.1.1'
8
+ VERSION = '9.1.2'
9
9
  end
@@ -36,5 +36,9 @@ module Mongoid
36
36
  warning :autosave_saves_unchanged_documents, "Autosave associations are currently configured to save documents even if they haven't changed. " \
37
37
  'This legacy behavior is deprecated. Set Mongoid.autosave_saves_unchanged_documents to false to ' \
38
38
  'skip saving unchanged documents in autosave associations.'
39
+ warning :bypass_auto_encryption,
40
+ 'auto_encryption_options includes bypass_auto_encryption: true. The driver ' \
41
+ 'will not encrypt anything for this client: fields declared with encrypt: true ' \
42
+ 'are written to the database unencrypted.'
39
43
  end
40
44
  end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ module EmbeddedDelayedAtomicSetsSpec
6
+ class Section
7
+ include Mongoid::Document
8
+
9
+ field :content
10
+
11
+ embedded_in :parent, polymorphic: true
12
+ end
13
+
14
+ class Group
15
+ include Mongoid::Document
16
+
17
+ embedded_in :root
18
+ embeds_many :sections, class_name: 'EmbeddedDelayedAtomicSetsSpec::Section'
19
+ accepts_nested_attributes_for :sections
20
+ end
21
+
22
+ class Root
23
+ include Mongoid::Document
24
+
25
+ embeds_many :sections, class_name: 'EmbeddedDelayedAtomicSetsSpec::Section'
26
+ embeds_one :group, class_name: 'EmbeddedDelayedAtomicSetsSpec::Group'
27
+ accepts_nested_attributes_for :sections
28
+ accepts_nested_attributes_for :group
29
+ end
30
+ end
31
+
32
+ module NewEmbeddedChildSpec
33
+ class Member
34
+ include Mongoid::Document
35
+
36
+ field :name
37
+
38
+ embedded_in :team
39
+ end
40
+
41
+ class Team
42
+ include Mongoid::Document
43
+
44
+ embedded_in :org
45
+ embeds_many :members, class_name: 'NewEmbeddedChildSpec::Member'
46
+ accepts_nested_attributes_for :members
47
+ end
48
+
49
+ class Org
50
+ include Mongoid::Document
51
+
52
+ field :title
53
+
54
+ embeds_one :team, class_name: 'NewEmbeddedChildSpec::Team'
55
+ accepts_nested_attributes_for :team
56
+ end
57
+ end
58
+
59
+ RSpec.describe 'embedded delayed atomic sets' do
60
+ context 'with the same stored name' do
61
+ let!(:root) do
62
+ root = EmbeddedDelayedAtomicSetsSpec::Root.new
63
+ root.sections = [ EmbeddedDelayedAtomicSetsSpec::Section.new(content: 'root-original') ]
64
+ root.save!
65
+ root
66
+ end
67
+
68
+ def stored_document
69
+ EmbeddedDelayedAtomicSetsSpec::Root.collection.find(_id: root.id).first
70
+ end
71
+
72
+ def stored_contents(sections)
73
+ sections.to_a.map { |section| section['content'] }
74
+ end
75
+
76
+ def document_contents(sections)
77
+ sections.map(&:content)
78
+ end
79
+
80
+ # The persisted root has no group, so attributes= builds the new nested group
81
+ # while the root sections update is still pending.
82
+ it 'does not merge a new child into the parent association' do
83
+ reloaded = EmbeddedDelayedAtomicSetsSpec::Root.find(root.id)
84
+ reloaded.attributes = {
85
+ sections: [ { _id: reloaded.sections.first.id, content: 'root-updated' } ],
86
+ group: { sections: [ { content: 'group-new' } ] }
87
+ }
88
+ reloaded.save!
89
+
90
+ stored = stored_document
91
+ expect(stored_contents(stored['sections'])).to eq [ 'root-updated' ]
92
+ expect(stored_contents(stored.dig('group', 'sections'))).to eq [ 'group-new' ]
93
+ expect(document_contents(reloaded.sections)).to eq [ 'root-updated' ]
94
+ expect(document_contents(reloaded.group.sections)).to eq [ 'group-new' ]
95
+ end
96
+
97
+ it 'does not copy a new child into an empty parent association' do
98
+ reloaded = EmbeddedDelayedAtomicSetsSpec::Root.find(root.id)
99
+ reloaded.attributes = {
100
+ sections: [],
101
+ group: { sections: [ { content: 'group-new' } ] }
102
+ }
103
+ reloaded.save!
104
+
105
+ stored = stored_document
106
+ expect(stored).not_to have_key('sections')
107
+ expect(stored_contents(stored.dig('group', 'sections'))).to eq [ 'group-new' ]
108
+ expect(reloaded.sections).to be_empty
109
+ expect(document_contents(reloaded.group.sections)).to eq [ 'group-new' ]
110
+ end
111
+
112
+ it 'does not merge a new child when the parent association is unchanged' do
113
+ reloaded = EmbeddedDelayedAtomicSetsSpec::Root.find(root.id)
114
+ reloaded.attributes = { group: { sections: [ { content: 'group-new' } ] } }
115
+ reloaded.save!
116
+
117
+ stored = stored_document
118
+ expect(stored_contents(stored['sections'])).to eq [ 'root-original' ]
119
+ expect(stored_contents(stored.dig('group', 'sections'))).to eq [ 'group-new' ]
120
+ expect(document_contents(reloaded.sections)).to eq [ 'root-original' ]
121
+ expect(document_contents(reloaded.group.sections)).to eq [ 'group-new' ]
122
+ end
123
+
124
+ it 'updates an existing group and isolates a subsequently added group' do
125
+ root.group = EmbeddedDelayedAtomicSetsSpec::Group.new(
126
+ sections: [ EmbeddedDelayedAtomicSetsSpec::Section.new(content: 'group-original') ]
127
+ )
128
+ root.save!
129
+
130
+ reloaded = EmbeddedDelayedAtomicSetsSpec::Root.find(root.id)
131
+ reloaded.attributes = {
132
+ group: {
133
+ _id: reloaded.group.id,
134
+ sections: [ { _id: reloaded.group.sections.first.id, content: 'group-updated' } ]
135
+ }
136
+ }
137
+ reloaded.save!
138
+
139
+ stored = stored_document
140
+ expect(stored_contents(stored['sections'])).to eq [ 'root-original' ]
141
+ expect(stored_contents(stored.dig('group', 'sections'))).to eq [ 'group-updated' ]
142
+ expect(document_contents(reloaded.sections)).to eq [ 'root-original' ]
143
+ expect(document_contents(reloaded.group.sections)).to eq [ 'group-updated' ]
144
+
145
+ reloaded.group = nil
146
+ reloaded.save!
147
+ reloaded = EmbeddedDelayedAtomicSetsSpec::Root.find(root.id)
148
+ reloaded.attributes = {
149
+ sections: [ { _id: reloaded.sections.first.id, content: 'root-updated' } ],
150
+ group: { sections: [ { content: 'group-new' } ] }
151
+ }
152
+ reloaded.save!
153
+
154
+ stored = stored_document
155
+ expect(stored_contents(stored['sections'])).to eq [ 'root-updated' ]
156
+ expect(stored_contents(stored.dig('group', 'sections'))).to eq [ 'group-new' ]
157
+ expect(document_contents(reloaded.sections)).to eq [ 'root-updated' ]
158
+ expect(document_contents(reloaded.group.sections)).to eq [ 'group-new' ]
159
+ end
160
+
161
+ it 'does not merge a new child when updating the parent through nested attributes' do
162
+ reloaded = EmbeddedDelayedAtomicSetsSpec::Root.find(root.id)
163
+ reloaded.attributes = {
164
+ sections_attributes: {
165
+ '0' => { _id: reloaded.sections.first.id, content: 'root-updated' }
166
+ },
167
+ group: { sections: [ { content: 'group-new' } ] }
168
+ }
169
+ reloaded.save!
170
+
171
+ stored = stored_document
172
+ expect(stored_contents(stored['sections'])).to eq [ 'root-updated' ]
173
+ expect(stored_contents(stored.dig('group', 'sections'))).to eq [ 'group-new' ]
174
+ expect(document_contents(reloaded.sections)).to eq [ 'root-updated' ]
175
+ expect(document_contents(reloaded.group.sections)).to eq [ 'group-new' ]
176
+ end
177
+ end
178
+
179
+ context 'when assigning a new embeds_one child with nested embeds_many' do
180
+ let!(:org) { NewEmbeddedChildSpec::Org.create!(title: 't0') }
181
+
182
+ it 'does not write the nested array as a root-level field' do
183
+ reloaded = NewEmbeddedChildSpec::Org.find(org.id)
184
+ reloaded.attributes = { title: 't1', team: { members: [ { name: 'm1' } ] } }
185
+ reloaded.save!
186
+
187
+ stored = NewEmbeddedChildSpec::Org.collection.find(_id: org.id).first
188
+ expect(stored.keys).to match_array(%w[_id title team])
189
+ expect(stored.dig('team', 'members').map { |member| member['name'] }).to eq [ 'm1' ]
190
+ end
191
+
192
+ it 'does not write nested attributes at the root level' do
193
+ reloaded = NewEmbeddedChildSpec::Org.find(org.id)
194
+ reloaded.attributes = {
195
+ team_attributes: { members: [ { name: 'm1' } ] }
196
+ }
197
+ reloaded.save!
198
+
199
+ stored = NewEmbeddedChildSpec::Org.collection.find(_id: org.id).first
200
+ expect(stored.keys).to match_array(%w[_id title team])
201
+ expect(stored.dig('team', 'members').map { |member| member['name'] }).to eq [ 'm1' ]
202
+ expect(reloaded.team.members.map(&:name)).to eq [ 'm1' ]
203
+ end
204
+
205
+ it 'does not conflict when assigning to a newly built child' do
206
+ reloaded = NewEmbeddedChildSpec::Org.find(org.id)
207
+ reloaded.build_team
208
+ reloaded.team.attributes = { members: [ { name: 'm1' } ] }
209
+ reloaded.save!
210
+
211
+ stored = NewEmbeddedChildSpec::Org.collection.find(_id: org.id).first
212
+ expect(stored.dig('team', 'members').map { |member| member['name'] }).to eq [ 'm1' ]
213
+ expect(reloaded.team.members.map(&:name)).to eq [ 'm1' ]
214
+ end
215
+ end
216
+ end
@@ -60,6 +60,20 @@ module HabtmSpec
60
60
  end
61
61
 
62
62
  describe 'has_and_belongs_to_many associations' do
63
+ context 'when the stored foreign-key array is empty' do
64
+ it 'can reset and enumerate the association proxy' do
65
+ item = HabtmSpec::Item.create!(color_ids: [])
66
+ colors = item.reload.colors
67
+
68
+ expect_query(0) do
69
+ colors.reset
70
+ expect(colors.empty?).to be true
71
+ expect(colors.size).to be 0
72
+ expect(colors.to_a).to eq []
73
+ end
74
+ end
75
+ end
76
+
63
77
  context 'when an anonymous class defines a has_and_belongs_to_many association' do
64
78
  let(:klass) do
65
79
  Class.new do
@@ -134,4 +134,287 @@ describe Mongoid::Atomic::Modifiers do
134
134
  expect(library.books[2].foreword).not_to be_nil
135
135
  end
136
136
  end
137
+
138
+ context 'when adding to a set on an existing embedded document and pushing a new one' do
139
+ let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
140
+ let(:roster) do
141
+ ModifierModels::Roster.create!(players: [
142
+ ModifierModels::Player.new(badges: [ badges[0] ]),
143
+ ModifierModels::Player.new(badges: [ badges[0] ])
144
+ ])
145
+ end
146
+
147
+ before do
148
+ roster.reload
149
+ roster.players.build
150
+ player = roster.players[0]
151
+ player.badge_ids = player.badge_ids + [ badges[1].id ]
152
+ roster.save!
153
+ roster.reload
154
+ end
155
+
156
+ it 'applies both operations' do
157
+ expect(roster.players.size).to eq(3)
158
+ expect(roster.players[0].badge_ids).to eq([ badges[0].id, badges[1].id ])
159
+ expect(roster.players[1].badge_ids).to eq([ badges[0].id ])
160
+ end
161
+ end
162
+
163
+ context 'when pulling all from an existing embedded document and pushing a new one' do
164
+ let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
165
+ let(:roster) do
166
+ ModifierModels::Roster.create!(players: [
167
+ ModifierModels::Player.new(badges: badges),
168
+ ModifierModels::Player.new
169
+ ])
170
+ end
171
+
172
+ before do
173
+ roster.reload
174
+ roster.players.build
175
+ player = roster.players[0]
176
+ player.badge_ids = player.badge_ids.drop(1)
177
+ roster.save!
178
+ roster.reload
179
+ end
180
+
181
+ it 'applies both operations' do
182
+ expect(roster.players.size).to eq(3)
183
+ expect(roster.players[0].badge_ids).to eq([ badges[1].id ])
184
+ end
185
+ end
186
+
187
+ context 'when unsetting a field on an existing embedded document and pushing a new one' do
188
+ let(:roster) do
189
+ ModifierModels::Roster.create!(players: [
190
+ ModifierModels::Player.new(medal: ModifierModels::Medal.new),
191
+ ModifierModels::Player.new
192
+ ])
193
+ end
194
+
195
+ before do
196
+ roster.reload
197
+ roster.players.build
198
+ roster.players[0].assign_attributes(medal: nil)
199
+ roster.save!
200
+ roster.reload
201
+ end
202
+
203
+ it 'applies both operations' do
204
+ expect(roster.players.size).to eq(3)
205
+ expect(roster.players[0].medal).to be_nil
206
+ end
207
+ end
208
+
209
+ context 'when unsetting an embedded field and pulling a sibling' do
210
+ let(:roster) do
211
+ ModifierModels::Roster.create!(players: [
212
+ ModifierModels::Player.new(medal: ModifierModels::Medal.new),
213
+ ModifierModels::Player.new(medal: ModifierModels::Medal.new)
214
+ ])
215
+ end
216
+
217
+ before do
218
+ roster.reload
219
+ roster.add_atomic_pull(roster.players[0])
220
+ roster.players[1].assign_attributes(medal: nil)
221
+ end
222
+
223
+ it 'fails without changing stored data' do
224
+ # $pull shifts array elements, so these operations cannot be safely
225
+ # applied in separate writes; MongoDB rejects them in a single write.
226
+ expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
227
+
228
+ roster.reload
229
+ expect(roster.players.size).to eq(2)
230
+ expect(roster.players[0].medal).not_to be_nil
231
+ expect(roster.players[1].medal).not_to be_nil
232
+ end
233
+ end
234
+
235
+ context 'when changing foreign keys of a remaining nested document after pulling its sibling' do
236
+ let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
237
+ let(:roster) do
238
+ ModifierModels::Roster.create!(players: [
239
+ ModifierModels::Player.new(badges: [ badges[0] ]),
240
+ ModifierModels::Player.new(
241
+ badges: [ badges[0] ],
242
+ awards: [
243
+ ModifierModels::Award.new(name: 'first', badges: [ badges[0] ]),
244
+ ModifierModels::Award.new(name: 'second', badges: [ badges[0] ])
245
+ ]
246
+ )
247
+ ])
248
+ end
249
+
250
+ before do
251
+ roster.reload
252
+ first_player, second_player = roster.players.to_a
253
+ first_award, second_award = second_player.awards.to_a
254
+ # The change on the first player comes before the nested $pull on the
255
+ # same root. The change on the second award uses its position after
256
+ # the $pull.
257
+ change.call(first_player)
258
+ roster.players_attributes = [
259
+ { id: second_player.id, awards_attributes: [ { id: first_award.id, _destroy: true } ] }
260
+ ]
261
+ change.call(second_award)
262
+ end
263
+
264
+ context 'when adding to the foreign keys' do
265
+ let(:change) do
266
+ ->(doc) { doc.badge_ids = doc.badge_ids + [ badges[1].id ] }
267
+ end
268
+
269
+ it 'fails without changing stored data' do
270
+ # The $addToSet for the second award would hit the first award if it
271
+ # were applied before the $pull, so it stays in the same update.
272
+ expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
273
+
274
+ roster.reload
275
+ expect(roster.players[0].badge_ids).to eq([ badges[0].id ])
276
+ expect(roster.players[1].awards.map(&:name)).to eq(%w[first second])
277
+ expect(roster.players[1].awards.map(&:badge_ids)).to eq([ [ badges[0].id ], [ badges[0].id ] ])
278
+ end
279
+ end
280
+
281
+ context 'when removing from the foreign keys' do
282
+ let(:change) do
283
+ ->(doc) { doc.badge_ids = [] }
284
+ end
285
+
286
+ it 'applies the change to the remaining nested document' do
287
+ roster.save!
288
+
289
+ roster.reload
290
+ expect(roster.players[0].badge_ids).to eq([])
291
+ expect(roster.players[1].awards.map(&:name)).to eq([ 'second' ])
292
+ expect(roster.players[1].awards[0].badge_ids).to eq([])
293
+ end
294
+ end
295
+ end
296
+
297
+ context 'when renaming a remaining nested document after pulling a preceding sibling and its sibling' do
298
+ let(:roster) do
299
+ ModifierModels::Roster.create!(players: [
300
+ ModifierModels::Player.new(nickname: 'a'),
301
+ ModifierModels::Player.new(nickname: 'b'),
302
+ ModifierModels::Player.new(
303
+ nickname: 'c',
304
+ awards: [
305
+ ModifierModels::Award.new(name: 'first'),
306
+ ModifierModels::Award.new(name: 'second')
307
+ ]
308
+ )
309
+ ])
310
+ end
311
+
312
+ before do
313
+ roster.reload
314
+ first_player, second_player, third_player = roster.players.to_a
315
+ first_award, second_award = third_player.awards.to_a
316
+ roster.players_attributes = [
317
+ { id: first_player.id, _destroy: true },
318
+ { id: second_player.id, nickname: 'B' },
319
+ { id: third_player.id, awards_attributes: [
320
+ { id: first_award.id, _destroy: true },
321
+ { id: second_award.id, name: 'SECOND' }
322
+ ] }
323
+ ]
324
+ end
325
+
326
+ it 'fails without changing stored data' do
327
+ # Both $pulls stay in the main update. The rename of the second award
328
+ # would hit the first award if it were applied before the nested $pull.
329
+ expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
330
+
331
+ roster.reload
332
+ expect(roster.players.map(&:nickname)).to eq(%w[a b c])
333
+ expect(roster.players[2].awards.map(&:name)).to eq(%w[first second])
334
+ end
335
+ end
336
+
337
+ context 'when adding a nested document and pulling a sibling' do
338
+ let(:roster) do
339
+ ModifierModels::Roster.create!(players: [
340
+ ModifierModels::Player.new(nickname: 'a'),
341
+ ModifierModels::Player.new(
342
+ nickname: 'b',
343
+ awards: [ ModifierModels::Award.new(name: 'first') ]
344
+ )
345
+ ])
346
+ end
347
+
348
+ before do
349
+ roster.reload
350
+ first_player, second_player = roster.players.to_a
351
+ second_player.awards.build(name: 'second')
352
+ roster.players_attributes = [ { id: first_player.id, _destroy: true } ]
353
+ end
354
+
355
+ it 'applies both operations' do
356
+ roster.save!
357
+
358
+ roster.reload
359
+ expect(roster.players.map(&:nickname)).to eq([ 'b' ])
360
+ expect(roster.players[0].awards.map(&:name)).to eq(%w[first second])
361
+ end
362
+ end
363
+
364
+ context 'when assigning embeds_many attributes on a new embedded document' do
365
+ let(:cabinet) do
366
+ ModifierModels::Cabinet.create!
367
+ end
368
+
369
+ before do
370
+ cabinet.reload
371
+ end
372
+
373
+ context 'when the nested documents are assigned on the built document' do
374
+ before do
375
+ cabinet.build_drawer(matcher: 'all')
376
+ cabinet.drawer.assign_attributes(items: [ { name: 'a' } ])
377
+ end
378
+
379
+ it 'saves without conflicting $set paths' do
380
+ expect { cabinet.save! }.not_to raise_error
381
+
382
+ cabinet.reload
383
+ expect(cabinet.drawer.matcher).to eq('all')
384
+ expect(cabinet.drawer.items.map(&:name)).to eq([ 'a' ])
385
+ end
386
+ end
387
+
388
+ context 'when the nested documents are assigned via the parent' do
389
+ before do
390
+ cabinet.assign_attributes(drawer: { matcher: 'all', items: [ { name: 'a' } ] })
391
+ end
392
+
393
+ it 'saves without conflicting $set paths' do
394
+ expect { cabinet.save! }.not_to raise_error
395
+
396
+ cabinet.reload
397
+ expect(cabinet.drawer.matcher).to eq('all')
398
+ expect(cabinet.drawer.items.map(&:name)).to eq([ 'a' ])
399
+ end
400
+ end
401
+
402
+ context 'when an assigned child has its own embedded collection' do
403
+ let(:item) { ModifierModels::Item.new(name: 'a') }
404
+
405
+ before do
406
+ item.assign_attributes(widgets: [ { label: 'w' } ])
407
+ cabinet.build_drawer(matcher: 'all')
408
+ cabinet.drawer.assign_attributes(items: [ item ])
409
+ end
410
+
411
+ it 'does not write the child collection to the root document' do
412
+ expect { cabinet.save! }.not_to raise_error
413
+
414
+ stored = ModifierModels::Cabinet.collection.find(_id: cabinet.id).first
415
+ expect(stored.keys).to contain_exactly('_id', 'drawer')
416
+ expect(stored['drawer']['items'].first['widgets'].first['label']).to eq('w')
417
+ end
418
+ end
419
+ end
137
420
  end