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
@@ -245,4 +245,153 @@ describe 'Encryption' do
245
245
  end
246
246
  end
247
247
  end
248
+
249
+ # The schema map has one entry per namespace, and it has to cover every model
250
+ # that writes there: models sharing a collection, subclasses, and subclasses
251
+ # of embedded models. Documents are read back through a client without
252
+ # automatic encryption, to see what is actually stored.
253
+ describe 'when more than one model writes to a namespace' do
254
+ let(:root_models) do
255
+ [ Crypt::Badge, Crypt::BadgeScan, Crypt::Member, Crypt::Listing, Crypt::Box, Crypt::Locker ]
256
+ end
257
+
258
+ let(:keyed_models) do
259
+ [ Crypt::Badge, Crypt::BadgeScan, Crypt::Member, Crypt::PrivateListing, Crypt::Box, Crypt::SecretContent ]
260
+ end
261
+
262
+ around do |example|
263
+ original_options = root_models.to_h { |model| [ model, model.storage_options ] }
264
+ existing_key_ids = keyed_models.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] }
265
+ keyed_models.each { |model| model.set_key_id(data_key_id) }
266
+ root_models.each do |model|
267
+ unencrypted_client[model.collection_name.to_s].drop
268
+ model.store_in(client: :encrypted)
269
+ end
270
+ # The schema map is generated when the client is built, so the client has
271
+ # to be built after the key ids above are in place.
272
+ Mongoid::Clients.clear
273
+
274
+ example.run
275
+
276
+ original_options.each { |model, options| model.storage_options = options }
277
+ existing_key_ids.each { |model, key_id| model.set_key_id(key_id) }
278
+ Mongoid::Clients.clear
279
+ end
280
+
281
+ def stored_document(model, id)
282
+ unencrypted_client[model.collection_name.to_s].find(_id: id).first
283
+ end
284
+
285
+ def expect_ciphertext(value)
286
+ expect(value).to be_a(BSON::Binary).and(have_attributes(type: :ciphertext))
287
+ end
288
+
289
+ context 'when two models are stored in one collection' do
290
+ it 'stores the fields of both models encrypted', :aggregate_failures do
291
+ badge = Crypt::Badge.create!(serial: 'SERIAL-1')
292
+ scan = Crypt::BadgeScan.create!(holder: 'HOLDER-1')
293
+
294
+ expect_ciphertext(stored_document(Crypt::Badge, badge.id)['serial'])
295
+ expect_ciphertext(stored_document(Crypt::BadgeScan, scan.id)['holder'])
296
+ end
297
+ end
298
+
299
+ context 'when a subclass adds an encrypted field to an encrypted parent' do
300
+ it 'stores the fields of the parent and of the subclass encrypted', :aggregate_failures do
301
+ employee = Crypt::Employee.create!(ssn: 'SSN-1', salary: 'SALARY-1')
302
+
303
+ stored_document(Crypt::Member, employee.id).tap do |doc|
304
+ expect_ciphertext(doc['ssn'])
305
+ expect_ciphertext(doc['salary'])
306
+ end
307
+ end
308
+
309
+ it 'stores the fields of a sibling subclass encrypted' do
310
+ contractor = Crypt::Contractor.create!(ssn: 'SSN-2', rate: 'RATE-1')
311
+
312
+ expect_ciphertext(stored_document(Crypt::Member, contractor.id)['rate'])
313
+ end
314
+
315
+ it 'reads the fields back' do
316
+ employee = Crypt::Employee.create!(ssn: 'SSN-3', salary: 'SALARY-3')
317
+
318
+ expect(Crypt::Member.find(employee.id).salary).to eq('SALARY-3')
319
+ end
320
+ end
321
+
322
+ context 'when only a subclass has encrypted fields' do
323
+ it 'stores the fields of the subclass encrypted' do
324
+ listing = Crypt::PrivateListing.create!(title: 'TITLE-1', owner_ssn: 'SSN-4')
325
+
326
+ expect_ciphertext(stored_document(Crypt::Listing, listing.id)['owner_ssn'])
327
+ end
328
+ end
329
+
330
+ context 'when a subclass of an embeds_one target adds an encrypted field' do
331
+ it 'stores the fields of the target and of the subclass encrypted', :aggregate_failures do
332
+ box = Crypt::Box.create!(item: Crypt::SealedItem.new(label: 'LABEL-1', seal: 'SEAL-1'))
333
+
334
+ stored_document(Crypt::Box, box.id)['item'].tap do |item|
335
+ expect_ciphertext(item['label'])
336
+ expect_ciphertext(item['seal'])
337
+ end
338
+ end
339
+ end
340
+
341
+ context 'when only a subclass of an embeds_one target has encrypted fields' do
342
+ it 'stores the fields of the subclass encrypted' do
343
+ locker = Crypt::Locker.create!(content: Crypt::SecretContent.new(label: 'LABEL-2', code: 'CODE-1'))
344
+
345
+ expect_ciphertext(stored_document(Crypt::Locker, locker.id)['content']['code'])
346
+ end
347
+ end
348
+
349
+ # An entry for the namespace exists, but it does not cover every encrypted
350
+ # field of the model. The driver encrypts what the entry lists and writes
351
+ # the rest in plaintext.
352
+ context 'when the schema entry for the namespace misses an encrypted field' do
353
+ let(:config) do
354
+ super().merge(
355
+ partial: {
356
+ hosts: SpecConfig.instance.addresses,
357
+ database: database_id,
358
+ options: {
359
+ auto_encryption_options: {
360
+ key_vault_client: :key_vault,
361
+ kms_providers: kms_providers,
362
+ key_vault_namespace: key_vault_namespace,
363
+ extra_options: extra_options,
364
+ schema_map: partial_schema_map
365
+ }
366
+ }
367
+ }
368
+ )
369
+ end
370
+
371
+ let(:partial_schema_map) do
372
+ {
373
+ "#{database_id}.#{Crypt::Member.collection_name}" => {
374
+ 'bsonType' => 'object',
375
+ 'encryptMetadata' => {
376
+ 'keyId' => [ BSON::Binary.new(Base64.decode64(data_key_id), :uuid) ],
377
+ 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
378
+ },
379
+ 'properties' => {
380
+ 'ssn' => { 'encrypt' => { 'bsonType' => 'string' } }
381
+ }
382
+ }
383
+ }
384
+ end
385
+
386
+ it 'does not store the field in plaintext', :aggregate_failures do
387
+ expect do
388
+ Crypt::Employee.with(client: :partial) { |klass| klass.create!(ssn: 'SSN-5', salary: 'PARTIAL-1') }
389
+ end.to raise_error(Mongoid::Errors::NoEncryptionSchema)
390
+
391
+ expect(
392
+ unencrypted_client[Crypt::Member.collection_name.to_s].find(salary: 'PARTIAL-1').to_a
393
+ ).to be_empty
394
+ end
395
+ end
396
+ end
248
397
  end
@@ -689,6 +689,41 @@ describe Mongoid::Association::Accessors do
689
689
  end
690
690
  end
691
691
  end
692
+
693
+ context 'when the document has a stored field named like a referenced association' do
694
+ before(:all) do
695
+ class Musician
696
+ include Mongoid::Document
697
+
698
+ belongs_to :orchestra, optional: true
699
+ end
700
+
701
+ class Orchestra
702
+ include Mongoid::Document
703
+
704
+ has_many :musicians
705
+ end
706
+ end
707
+
708
+ after(:all) do
709
+ Object.send(:remove_const, :Orchestra)
710
+ Object.send(:remove_const, :Musician)
711
+ end
712
+
713
+ # The musicians used to be embedded, so the stored orchestra still holds
714
+ # the old array under the association's name.
715
+ let!(:orchestra_id) do
716
+ BSON::ObjectId.new.tap do |id|
717
+ Orchestra.collection.insert_one('_id' => id, 'musicians' => [ { '_id' => BSON::ObjectId.new } ])
718
+ end
719
+ end
720
+
721
+ let!(:musician) { Musician.create!(orchestra_id: orchestra_id) }
722
+
723
+ it 'loads the association by its key' do
724
+ expect(Orchestra.find(orchestra_id).musicians.to_a).to eq([ musician ])
725
+ end
726
+ end
692
727
  end
693
728
 
694
729
  context 'when setting associations to empty values' do
@@ -107,6 +107,17 @@ describe Mongoid::Association::Referenced::HasAndBelongsToMany::Buildable do
107
107
  end
108
108
  end
109
109
 
110
+ context 'when provided an empty array' do
111
+ let(:object) do
112
+ []
113
+ end
114
+
115
+ it 'returns an empty criteria' do
116
+ expect(documents).to be_a(Mongoid::Criteria)
117
+ expect(documents.empty_and_chainable?).to be true
118
+ end
119
+ end
120
+
110
121
  context 'when provided a object' do
111
122
  context 'when the object is not nil' do
112
123
  let(:object) do
@@ -74,6 +74,78 @@ describe Mongoid::Atomic::Modifiers do
74
74
  end
75
75
  end
76
76
  end
77
+
78
+ context 'when a conflicting modification exists' do
79
+ context 'when the conflicting modification is a set' do
80
+ let(:sets) do
81
+ { 'addresses.0.street' => 'Bond' }
82
+ end
83
+
84
+ let(:adds) do
85
+ { 'addresses.0.tags' => %w[tag] }
86
+ end
87
+
88
+ before do
89
+ modifiers.set(sets)
90
+ modifiers.add_to_set(adds)
91
+ end
92
+
93
+ it 'adds the add to set modifiers to the conflicts hash' do
94
+ expect(modifiers).to eq(
95
+ { '$set' => { 'addresses.0.street' => 'Bond' },
96
+ conflicts: { '$addToSet' =>
97
+ { 'addresses.0.tags' => { '$each' => %w[tag] } } } }
98
+ )
99
+ end
100
+ end
101
+
102
+ context 'when the conflicting modification is a push' do
103
+ let(:pushes) do
104
+ { 'addresses' => { 'street' => 'Oxford St' } }
105
+ end
106
+
107
+ let(:adds) do
108
+ { 'addresses.0.tags' => %w[tag] }
109
+ end
110
+
111
+ before do
112
+ modifiers.push(pushes)
113
+ modifiers.add_to_set(adds)
114
+ end
115
+
116
+ it 'adds the add to set modifiers to the conflicts hash' do
117
+ expect(modifiers).to eq(
118
+ { '$push' => { 'addresses' => { '$each' => [ { 'street' => 'Oxford St' } ] } },
119
+ conflicts: { '$addToSet' =>
120
+ { 'addresses.0.tags' => { '$each' => %w[tag] } } } }
121
+ )
122
+ end
123
+ end
124
+
125
+ context 'when the conflicting modification is a pull' do
126
+ let(:pulls) do
127
+ { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } }
128
+ end
129
+
130
+ let(:adds) do
131
+ { 'addresses.0.tags' => %w[tag] }
132
+ end
133
+
134
+ before do
135
+ modifiers.pull(pulls)
136
+ modifiers.add_to_set(adds)
137
+ end
138
+
139
+ it 'does not split the operations, leaving MongoDB to reject the update' do
140
+ # A $pull shifts array elements, so applying the $addToSet in a
141
+ # separate write afterwards could target a different element.
142
+ expect(modifiers).to eq(
143
+ { '$pull' => { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } },
144
+ '$addToSet' => { 'addresses.0.tags' => { '$each' => %w[tag] } } }
145
+ )
146
+ end
147
+ end
148
+ end
77
149
  end
78
150
 
79
151
  describe '#pull' do
@@ -125,6 +197,98 @@ describe Mongoid::Atomic::Modifiers do
125
197
  end
126
198
  end
127
199
  end
200
+
201
+ context 'when a conflicting modification exists' do
202
+ # A $pull shifts the positions of the remaining elements, so it stays in
203
+ # the main update. MongoDB rejects the update if the paths overlap.
204
+ context 'when the conflicting modification is a set' do
205
+ let(:sets) do
206
+ { 'addresses.0.street' => 'Bond' }
207
+ end
208
+
209
+ let(:pulls) do
210
+ { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } }
211
+ end
212
+
213
+ before do
214
+ modifiers.set(sets)
215
+ modifiers.pull(pulls)
216
+ end
217
+
218
+ it 'keeps the pull modifiers in the main update' do
219
+ expect(modifiers).to eq(
220
+ { '$set' => { 'addresses.0.street' => 'Bond' },
221
+ '$pull' => { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } } }
222
+ )
223
+ end
224
+ end
225
+
226
+ context 'when the conflicting modification is a push' do
227
+ let(:pushes) do
228
+ { 'addresses' => { 'street' => 'Oxford St' } }
229
+ end
230
+
231
+ let(:pulls) do
232
+ { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } }
233
+ end
234
+
235
+ before do
236
+ modifiers.push(pushes)
237
+ modifiers.pull(pulls)
238
+ end
239
+
240
+ it 'keeps the pull modifiers in the main update' do
241
+ expect(modifiers).to eq(
242
+ { '$push' => { 'addresses' => { '$each' => [ { 'street' => 'Oxford St' } ] } },
243
+ '$pull' => { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } } }
244
+ )
245
+ end
246
+ end
247
+
248
+ context 'when the conflicting modification is an add to set' do
249
+ let(:adds) do
250
+ { 'addresses.0.tags' => %w[tag] }
251
+ end
252
+
253
+ let(:pulls) do
254
+ { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } }
255
+ end
256
+
257
+ before do
258
+ modifiers.add_to_set(adds)
259
+ modifiers.pull(pulls)
260
+ end
261
+
262
+ it 'keeps the pull modifiers in the main update' do
263
+ expect(modifiers).to eq(
264
+ { '$addToSet' => { 'addresses.0.tags' => { '$each' => %w[tag] } },
265
+ '$pull' => { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } } }
266
+ )
267
+ end
268
+ end
269
+
270
+ context 'when the conflicting modification is a pull all' do
271
+ let(:pull_alls) do
272
+ { 'addresses' => [ { '_id' => 'one' } ] }
273
+ end
274
+
275
+ let(:pulls) do
276
+ { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } }
277
+ end
278
+
279
+ before do
280
+ modifiers.pull_all(pull_alls)
281
+ modifiers.pull(pulls)
282
+ end
283
+
284
+ it 'keeps the pull modifiers in the main update' do
285
+ expect(modifiers).to eq(
286
+ { '$pullAll' => { 'addresses' => [ { '_id' => 'one' } ] },
287
+ '$pull' => { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } } }
288
+ )
289
+ end
290
+ end
291
+ end
128
292
  end
129
293
 
130
294
  describe '#pull_all' do
@@ -183,6 +347,54 @@ describe Mongoid::Atomic::Modifiers do
183
347
  end
184
348
  end
185
349
  end
350
+
351
+ context 'when a conflicting modification exists' do
352
+ context 'when the conflicting modification is a pull' do
353
+ let(:pulls) do
354
+ { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } }
355
+ end
356
+
357
+ let(:pull_alls) do
358
+ { 'addresses' => [ { '_id' => 'one' } ] }
359
+ end
360
+
361
+ before do
362
+ modifiers.pull(pulls)
363
+ modifiers.pull_all(pull_alls)
364
+ end
365
+
366
+ it 'adds the pull all modifiers to the conflicts hash' do
367
+ expect(modifiers).to eq(
368
+ { '$pull' => { 'addresses' => { '_id' => { '$in' => [ 'one' ] } } },
369
+ conflicts: { '$pullAll' =>
370
+ { 'addresses' => [ { '_id' => 'one' } ] } } }
371
+ )
372
+ end
373
+ end
374
+
375
+ context 'when the conflicting modification is a push' do
376
+ let(:pushes) do
377
+ { 'addresses' => { 'street' => 'Oxford St' } }
378
+ end
379
+
380
+ let(:pull_alls) do
381
+ { 'addresses' => [ { '_id' => 'one' } ] }
382
+ end
383
+
384
+ before do
385
+ modifiers.push(pushes)
386
+ modifiers.pull_all(pull_alls)
387
+ end
388
+
389
+ it 'adds the pull all modifiers to the conflicts hash' do
390
+ expect(modifiers).to eq(
391
+ { '$push' => { 'addresses' => { '$each' => [ { 'street' => 'Oxford St' } ] } },
392
+ conflicts: { '$pullAll' =>
393
+ { 'addresses' => [ { '_id' => 'one' } ] } } }
394
+ )
395
+ end
396
+ end
397
+ end
186
398
  end
187
399
 
188
400
  describe '#push' do
@@ -321,6 +533,56 @@ describe Mongoid::Atomic::Modifiers do
321
533
  )
322
534
  end
323
535
  end
536
+
537
+ context 'when the conflicting modification is an add to set' do
538
+ let(:adds) do
539
+ { 'addresses.0.tags' => %w[tag] }
540
+ end
541
+
542
+ let(:pushes) do
543
+ { 'addresses' => { 'street' => 'Oxford St' } }
544
+ end
545
+
546
+ before do
547
+ modifiers.add_to_set(adds)
548
+ modifiers.push(pushes)
549
+ end
550
+
551
+ it 'adds the push modifiers to the conflicts hash' do
552
+ expect(modifiers).to eq(
553
+ { '$addToSet' => { 'addresses.0.tags' => { '$each' => %w[tag] } },
554
+ conflicts: { '$push' =>
555
+ { 'addresses' => { '$each' => [
556
+ { 'street' => 'Oxford St' }
557
+ ] } } } }
558
+ )
559
+ end
560
+ end
561
+
562
+ context 'when the conflicting modification is an unset' do
563
+ let(:unsets) do
564
+ [ 'addresses.0.street' ]
565
+ end
566
+
567
+ let(:pushes) do
568
+ { 'addresses' => { 'street' => 'Oxford St' } }
569
+ end
570
+
571
+ before do
572
+ modifiers.unset(unsets)
573
+ modifiers.push(pushes)
574
+ end
575
+
576
+ it 'adds the push modifiers to the conflicts hash' do
577
+ expect(modifiers).to eq(
578
+ { '$unset' => { 'addresses.0.street' => true },
579
+ conflicts: { '$push' =>
580
+ { 'addresses' => { '$each' => [
581
+ { 'street' => 'Oxford St' }
582
+ ] } } } }
583
+ )
584
+ end
585
+ end
324
586
  end
325
587
 
326
588
  context 'when there is another operation on a sibling of a key which is nested' do
@@ -419,6 +681,30 @@ describe Mongoid::Atomic::Modifiers do
419
681
  )
420
682
  end
421
683
  end
684
+
685
+ context 'when the conflicting modification is an add to set' do
686
+ let(:adds) do
687
+ { 'addresses.0.tags' => %w[tag] }
688
+ end
689
+
690
+ let(:sets) do
691
+ { 'addresses.0.title' => 'Sir' }
692
+ end
693
+
694
+ before do
695
+ modifiers.add_to_set(adds)
696
+ modifiers.set(sets)
697
+ end
698
+
699
+ it 'adds the set modifiers to the conflicts hash' do
700
+ expect(modifiers).to eq(
701
+ { '$addToSet' =>
702
+ { 'addresses.0.tags' => { '$each' => %w[tag] } },
703
+ conflicts:
704
+ { '$set' => { 'addresses.0.title' => 'Sir' } } }
705
+ )
706
+ end
707
+ end
422
708
  end
423
709
  end
424
710
 
@@ -488,6 +774,67 @@ describe Mongoid::Atomic::Modifiers do
488
774
  end
489
775
  end
490
776
  end
777
+
778
+ context 'when the unsets conflict with a prior push' do
779
+ # A $push appends to the array and does not shift existing element
780
+ # positions, so the $unset can be applied safely in a separate write.
781
+ let(:pushes) { { 'books' => { 'title' => '1984' } } }
782
+
783
+ let(:unsets) { [ 'books.0.title' ] }
784
+
785
+ before do
786
+ modifiers.push(pushes)
787
+ modifiers.unset(unsets)
788
+ end
789
+
790
+ it 'adds the unset modifiers to the conflicts hash' do
791
+ expect(modifiers).to eq(
792
+ { '$push' => { 'books' => { '$each' => [ { 'title' => '1984' } ] } },
793
+ conflicts: { '$unset' => { 'books.0.title' => true } } }
794
+ )
795
+ end
796
+ end
797
+
798
+ context 'when the unsets conflict with a prior add to set' do
799
+ let(:adds) { { 'books.0.tags' => %w[tag] } }
800
+
801
+ let(:unsets) { [ 'books.0.title' ] }
802
+
803
+ before do
804
+ modifiers.add_to_set(adds)
805
+ modifiers.unset(unsets)
806
+ end
807
+
808
+ it 'adds the unset modifiers to the conflicts hash' do
809
+ expect(modifiers).to eq(
810
+ { '$addToSet' => { 'books.0.tags' => { '$each' => %w[tag] } },
811
+ conflicts: { '$unset' => { 'books.0.title' => true } } }
812
+ )
813
+ end
814
+ end
815
+
816
+ context 'when there is a pending pull on the same field' do
817
+ # A $pull shifts array elements, so routing the $unset into a separate
818
+ # write could make it target a different element. The operations are
819
+ # left together so MongoDB rejects the update (code 40) atomically.
820
+ let(:pulls) do
821
+ { 'books' => { '_id' => { '$in' => [ 'one' ] } } }
822
+ end
823
+
824
+ let(:unsets) { [ 'books.0.title' ] }
825
+
826
+ before do
827
+ modifiers.pull(pulls)
828
+ modifiers.unset(unsets)
829
+ end
830
+
831
+ it 'does not split the operations' do
832
+ expect(modifiers).to eq(
833
+ { '$pull' => { 'books' => { '_id' => { '$in' => [ 'one' ] } } },
834
+ '$unset' => { 'books.0.title' => true } }
835
+ )
836
+ end
837
+ end
491
838
  end
492
839
  end
493
840
  end
@@ -290,6 +290,35 @@ describe Mongoid::Attributes::Nested do
290
290
  end
291
291
  end
292
292
 
293
+ context 'when a has-many association uses a custom primary key' do
294
+ # A throwaway class, so that accepts_nested_attributes_for does not
295
+ # leave autosave callbacks and nested attributes on a shared model.
296
+ let(:company_class) do
297
+ stub_const('NestedSpecCompany', Class.new do
298
+ include Mongoid::Document
299
+
300
+ store_in collection: 'hmm_companies'
301
+ field :p, type: Integer
302
+ has_many :emails, primary_key: :p, foreign_key: :f, class_name: 'HmmEmail', inverse_of: nil
303
+ accepts_nested_attributes_for :emails
304
+ end)
305
+ end
306
+
307
+ after do
308
+ Mongoid.deregister_model(company_class)
309
+ end
310
+
311
+ it 'reports the parent primary key value in the error params' do
312
+ company = company_class.create!(p: 42)
313
+
314
+ expect do
315
+ company.emails_attributes = { '0' => { 'id' => 'missing', 'f' => 42 } }
316
+ end.to raise_error(Mongoid::Errors::DocumentNotFound) { |error|
317
+ expect(error.params).to eq({ _id: 'missing', 'f' => 42 })
318
+ }
319
+ end
320
+ end
321
+
293
322
  context 'when the relation is a has-and-belongs-to-many' do
294
323
  before do
295
324
  Person.send(:undef_method, :preferences_attributes=)
@@ -331,6 +331,42 @@ describe Mongoid::Clients::Factory do
331
331
  end
332
332
  end
333
333
  end
334
+
335
+ context 'when bypass_auto_encryption is set' do
336
+ it 'warns that encryption is bypassed' do
337
+ expect(Mongoid::Warnings).to receive(:warn_bypass_auto_encryption)
338
+
339
+ described_class.send(
340
+ :build_auto_encryption_options,
341
+ { auto_encryption_options: { bypass_auto_encryption: true, schema_map: {} } },
342
+ database_id
343
+ )
344
+ end
345
+ end
346
+
347
+ context 'when bypass_auto_encryption is not set' do
348
+ it 'does not warn' do
349
+ expect(Mongoid::Warnings).not_to receive(:warn_bypass_auto_encryption)
350
+
351
+ described_class.send(
352
+ :build_auto_encryption_options,
353
+ { auto_encryption_options: { schema_map: {} } },
354
+ database_id
355
+ )
356
+ end
357
+ end
358
+
359
+ context 'when bypass_auto_encryption is false' do
360
+ it 'does not warn' do
361
+ expect(Mongoid::Warnings).not_to receive(:warn_bypass_auto_encryption)
362
+
363
+ described_class.send(
364
+ :build_auto_encryption_options,
365
+ { auto_encryption_options: { bypass_auto_encryption: false, schema_map: {} } },
366
+ database_id
367
+ )
368
+ end
369
+ end
334
370
  end
335
371
 
336
372
  context 'when no name is provided' do