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
@@ -17,10 +17,10 @@ class SearchIndexHelper
17
17
 
18
18
  # Wait for all of the indexes with the given names to be ready; then return
19
19
  # the list of index definitions corresponding to those names.
20
- def wait_for(*names, &condition)
20
+ def wait_for(*names, timeout: 300, &condition)
21
21
  names.flatten!
22
22
 
23
- timeboxed_wait do
23
+ timeboxed_wait(max: timeout) do
24
24
  result = collection.search_indexes
25
25
  return filter_results(result, names) if names.all? { |name| ready?(result, name, &condition) }
26
26
  end
@@ -36,6 +36,14 @@ class SearchIndexHelper
36
36
  end
37
37
  end
38
38
 
39
+ # Atlas normalizes latestDefinition by adding "fields"=>{} inside mappings
40
+ # even when no fields were declared. Strip it before comparing against specs.
41
+ def normalize_definition(defn)
42
+ return defn unless defn.is_a?(Hash) && defn['mappings'].is_a?(Hash)
43
+
44
+ defn.merge('mappings' => defn['mappings'].reject { |k, v| k.to_s == 'fields' && v == {} })
45
+ end
46
+
39
47
  private
40
48
 
41
49
  def timeboxed_wait(step: 5, max: 300)
@@ -57,7 +65,7 @@ class SearchIndexHelper
57
65
  end
58
66
 
59
67
  def filter_results(result, names)
60
- result.select { |index| names.include?(index['name']) }
68
+ names.filter_map { |name| result.find { |index| index['name'] == name } }
61
69
  end
62
70
  end
63
71
 
@@ -309,6 +317,276 @@ describe Mongoid::SearchIndexable do
309
317
  end
310
318
  end
311
319
 
320
+ describe '.vector_search_index with flat indexingMethod' do
321
+ it 'raises ArgumentError when flat index includes hnswOptions' do
322
+ expect do
323
+ Class.new do
324
+ include Mongoid::Document
325
+
326
+ store_in collection: BSON::ObjectId.new.to_s
327
+ vector_search_index fields: [
328
+ {
329
+ type: 'vector',
330
+ path: 'embedding',
331
+ numDimensions: 3,
332
+ similarity: 'cosine',
333
+ indexingMethod: 'flat',
334
+ hnswOptions: { m: 16, efConstruction: 150 }
335
+ }
336
+ ]
337
+ end
338
+ end.to raise_error(ArgumentError, /hnswOptions is only supported with indexingMethod: hnsw/)
339
+ end
340
+
341
+ it 'does not raise when flat index has no hnswOptions' do
342
+ expect do
343
+ Class.new do
344
+ include Mongoid::Document
345
+
346
+ store_in collection: BSON::ObjectId.new.to_s
347
+ vector_search_index fields: [
348
+ {
349
+ type: 'vector',
350
+ path: 'embedding',
351
+ numDimensions: 3,
352
+ similarity: 'cosine',
353
+ indexingMethod: 'flat'
354
+ }
355
+ ]
356
+ end
357
+ end.not_to raise_error
358
+ end
359
+
360
+ it 'does not raise when hnsw index has hnswOptions' do
361
+ expect do
362
+ Class.new do
363
+ include Mongoid::Document
364
+
365
+ store_in collection: BSON::ObjectId.new.to_s
366
+ vector_search_index fields: [
367
+ {
368
+ type: 'vector',
369
+ path: 'embedding',
370
+ numDimensions: 3,
371
+ similarity: 'cosine',
372
+ indexingMethod: 'hnsw',
373
+ hnswOptions: { m: 16, efConstruction: 150 }
374
+ }
375
+ ]
376
+ end
377
+ end.not_to raise_error
378
+ end
379
+ end
380
+
381
+ describe '.vector_search pipeline construction' do
382
+ let(:model) do
383
+ Class.new do
384
+ include Mongoid::Document
385
+
386
+ store_in collection: BSON::ObjectId.new.to_s
387
+ field :embedding, type: Array
388
+ vector_search_index fields: [ { type: 'vector', path: 'embedding', numDimensions: 3, similarity: 'cosine' } ]
389
+ end
390
+ end
391
+
392
+ let(:fake_collection) { instance_double(Mongo::Collection) }
393
+ let(:fake_cursor) { double(map: []) }
394
+
395
+ before do
396
+ allow(model).to receive(:collection).and_return(fake_collection)
397
+ allow(fake_collection).to receive(:aggregate).and_return(fake_cursor)
398
+ end
399
+
400
+ it 'includes numCandidates by default' do
401
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
402
+ vs = pipeline.find { |s| s['$vectorSearch'] }
403
+ expect(vs['$vectorSearch']).to have_key('numCandidates')
404
+ fake_cursor
405
+ end
406
+
407
+ model.vector_search([ 0.1, 0.2, 0.3 ], limit: 5)
408
+ end
409
+
410
+ it 'omits numCandidates and sends exact: true when exact: true' do
411
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
412
+ vs = pipeline.find { |s| s['$vectorSearch'] }
413
+ expect(vs['$vectorSearch']).not_to have_key('numCandidates')
414
+ expect(vs['$vectorSearch']['exact']).to be true
415
+ fake_cursor
416
+ end
417
+
418
+ model.vector_search([ 0.1, 0.2, 0.3 ], exact: true)
419
+ end
420
+
421
+ it 'uses limit * 10 as the default numCandidates' do
422
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
423
+ vs = pipeline.find { |s| s['$vectorSearch'] }
424
+ expect(vs['$vectorSearch']['numCandidates']).to eq(50)
425
+ fake_cursor
426
+ end
427
+
428
+ model.vector_search([ 0.1, 0.2, 0.3 ], limit: 5)
429
+ end
430
+ end
431
+
432
+ describe '#vector_search pipeline construction' do
433
+ let(:model) do
434
+ Class.new do
435
+ include Mongoid::Document
436
+
437
+ store_in collection: BSON::ObjectId.new.to_s
438
+ field :embedding, type: Array
439
+ vector_search_index fields: [ { type: 'vector', path: 'embedding', numDimensions: 3, similarity: 'cosine' } ]
440
+ end
441
+ end
442
+
443
+ let(:fake_collection) { instance_double(Mongo::Collection) }
444
+ let(:fake_cursor) { double(map: []) }
445
+ let(:doc) { model.new(embedding: [ 0.1, 0.2, 0.3 ]) }
446
+
447
+ before do
448
+ allow(model).to receive(:collection).and_return(fake_collection)
449
+ allow(fake_collection).to receive(:aggregate).and_return(fake_cursor)
450
+ end
451
+
452
+ it 'omits numCandidates and sends exact: true when exact: true' do
453
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
454
+ vs = pipeline.find { |s| s['$vectorSearch'] }
455
+ expect(vs['$vectorSearch']).not_to have_key('numCandidates')
456
+ expect(vs['$vectorSearch']['exact']).to be true
457
+ fake_cursor
458
+ end
459
+
460
+ doc.vector_search(exact: true)
461
+ end
462
+
463
+ it 'passes limit + 1 to $vectorSearch so the post-filter never short-counts' do
464
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
465
+ vs = pipeline.find { |s| s['$vectorSearch'] }
466
+ expect(vs['$vectorSearch']['limit']).to eq 6
467
+ fake_cursor
468
+ end
469
+
470
+ doc.vector_search(limit: 5)
471
+ end
472
+
473
+ it 'does not use filter in $vectorSearch for self-exclusion' do
474
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
475
+ vs = pipeline.find { |s| s['$vectorSearch'] }
476
+ expect(vs['$vectorSearch']).not_to have_key('filter')
477
+ fake_cursor
478
+ end
479
+
480
+ doc.vector_search(limit: 5)
481
+ end
482
+
483
+ it 'adds a $match stage after $vectorSearch to exclude self' do
484
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
485
+ match = pipeline.find { |s| s['$match'] }
486
+ expect(match).to eq({ '$match' => { '_id' => { '$ne' => doc.id } } })
487
+ fake_cursor
488
+ end
489
+
490
+ doc.vector_search(limit: 5)
491
+ end
492
+
493
+ it 'adds a $limit stage after $match to cap results at the requested limit' do
494
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
495
+ match_idx = pipeline.index { |s| s['$match'] }
496
+ limit_stage = pipeline[match_idx + 1]
497
+ expect(limit_stage).to eq({ '$limit' => 5 })
498
+ fake_cursor
499
+ end
500
+
501
+ doc.vector_search(limit: 5)
502
+ end
503
+
504
+ it 'passes a user-provided filter through to $vectorSearch' do
505
+ user_filter = { 'status' => 'published' }
506
+
507
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
508
+ vs = pipeline.find { |s| s['$vectorSearch'] }
509
+ expect(vs['$vectorSearch']['filter']).to eq user_filter
510
+ fake_cursor
511
+ end
512
+
513
+ doc.vector_search(limit: 5, filter: user_filter)
514
+ end
515
+ end
516
+
517
+ describe '#auto_embed_search pipeline construction' do
518
+ let(:model) do
519
+ Class.new do
520
+ include Mongoid::Document
521
+
522
+ store_in collection: BSON::ObjectId.new.to_s
523
+ auto_embed_field :description, model: 'voyage-4'
524
+ end
525
+ end
526
+
527
+ let(:fake_collection) { instance_double(Mongo::Collection) }
528
+ let(:fake_cursor) { double(map: []) }
529
+ let(:doc) { model.new(description: 'hello world') }
530
+
531
+ before do
532
+ allow(model).to receive(:collection).and_return(fake_collection)
533
+ allow(fake_collection).to receive(:aggregate).and_return(fake_cursor)
534
+ end
535
+
536
+ it 'passes limit + 1 to $vectorSearch so the post-filter never short-counts' do
537
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
538
+ vs = pipeline.find { |s| s['$vectorSearch'] }
539
+ expect(vs['$vectorSearch']['limit']).to eq 6
540
+ fake_cursor
541
+ end
542
+
543
+ doc.auto_embed_search(limit: 5)
544
+ end
545
+
546
+ it 'does not use filter in $vectorSearch for self-exclusion' do
547
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
548
+ vs = pipeline.find { |s| s['$vectorSearch'] }
549
+ expect(vs['$vectorSearch']).not_to have_key('filter')
550
+ fake_cursor
551
+ end
552
+
553
+ doc.auto_embed_search(limit: 5)
554
+ end
555
+
556
+ it 'adds a $match stage after $vectorSearch to exclude self' do
557
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
558
+ match = pipeline.find { |s| s['$match'] }
559
+ expect(match).to eq({ '$match' => { '_id' => { '$ne' => doc.id } } })
560
+ fake_cursor
561
+ end
562
+
563
+ doc.auto_embed_search(limit: 5)
564
+ end
565
+
566
+ it 'adds a $limit stage after $match to cap results at the requested limit' do
567
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
568
+ match_idx = pipeline.index { |s| s['$match'] }
569
+ limit_stage = pipeline[match_idx + 1]
570
+ expect(limit_stage).to eq({ '$limit' => 5 })
571
+ fake_cursor
572
+ end
573
+
574
+ doc.auto_embed_search(limit: 5)
575
+ end
576
+
577
+ it 'passes a user-provided filter through to $vectorSearch' do
578
+ user_filter = { 'status' => 'published' }
579
+
580
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
581
+ vs = pipeline.find { |s| s['$vectorSearch'] }
582
+ expect(vs['$vectorSearch']['filter']).to eq user_filter
583
+ fake_cursor
584
+ end
585
+
586
+ doc.auto_embed_search(limit: 5, filter: user_filter)
587
+ end
588
+ end
589
+
312
590
  # Atlas integration tests — skipped when ATLAS_URI is not set.
313
591
 
314
592
  context 'Atlas integration' do
@@ -332,7 +610,7 @@ describe Mongoid::SearchIndexable do
332
610
  let(:requested_definitions) { model.search_index_specs.map { |spec| spec[:definition].with_indifferent_access } }
333
611
  let(:index_names) { model.create_search_indexes }
334
612
  let(:actual_indexes) { helper.wait_for(*index_names) }
335
- let(:actual_definitions) { actual_indexes.map { |i| i['latestDefinition'] } }
613
+ let(:actual_definitions) { actual_indexes.map { |i| helper.normalize_definition(i['latestDefinition']) } }
336
614
 
337
615
  describe '.create_search_indexes' do
338
616
  it 'creates the indexes' do
@@ -343,10 +621,10 @@ describe Mongoid::SearchIndexable do
343
621
  describe '.search_indexes' do
344
622
  before { actual_indexes } # wait for the indices to be created
345
623
 
346
- let(:queried_definitions) { model.search_indexes.map { |i| i['latestDefinition'] } }
624
+ let(:queried_definitions) { model.search_indexes.map { |i| helper.normalize_definition(i['latestDefinition']) } }
347
625
 
348
626
  it 'queries the available search indexes' do
349
- expect(queried_definitions).to eq requested_definitions
627
+ expect(queried_definitions).to match_array(requested_definitions)
350
628
  end
351
629
  end
352
630
 
@@ -390,11 +668,19 @@ describe Mongoid::SearchIndexable do
390
668
  let(:vector_helper) { SearchIndexHelper.new(vector_model) }
391
669
 
392
670
  # Three orthogonal unit vectors as a minimal, predictable dataset.
393
- let!(:doc_a) { vector_model.create!(embedding: [ 1.0, 0.0, 0.0 ]) }
394
- let!(:doc_b) { vector_model.create!(embedding: [ 0.0, 1.0, 0.0 ]) }
395
- let!(:doc_c) { vector_model.create!(embedding: [ 0.0, 0.0, 1.0 ]) }
671
+ let(:doc_a) { vector_model.create!(embedding: [ 1.0, 0.0, 0.0 ]) }
672
+ let(:doc_b) { vector_model.create!(embedding: [ 0.0, 1.0, 0.0 ]) }
673
+ let(:doc_c) { vector_model.create!(embedding: [ 0.0, 0.0, 1.0 ]) }
396
674
 
397
675
  before do
676
+ vector_helper # force collection to be dropped, created
677
+
678
+ # once the collection has been recreated, populate it with documents
679
+ doc_a
680
+ doc_b
681
+ doc_c
682
+
683
+ # prepare the search indexes
398
684
  names = vector_model.create_search_indexes
399
685
  vector_helper.wait_for(*names)
400
686
  end
@@ -69,6 +69,24 @@ describe Mongoid::Tasks::Database do
69
69
  end
70
70
 
71
71
  describe '.create_collections' do
72
+ # Creating a collection sends no document data, so it must not require a
73
+ # client that can encrypt, even for a model that declares encrypted
74
+ # fields. Otherwise this task cannot run without KMS credentials.
75
+ context 'when a model declares encrypted fields' do
76
+ before do
77
+ require 'support/crypt/models'
78
+ end
79
+
80
+ after do
81
+ Mongoid.default_client[Crypt::Patient.collection_name].drop
82
+ end
83
+
84
+ it 'creates the collection using a client without automatic encryption' do
85
+ expect { Mongoid::Tasks::Database.create_collections([ Crypt::Patient ]) }
86
+ .not_to raise_error
87
+ end
88
+ end
89
+
72
90
  context 'collection_options are specified' do
73
91
  let(:models) do
74
92
  [ DatabaseSpec::Measurement ]
@@ -12,7 +12,7 @@ describe Mongoid::Timestamps::Timeless do
12
12
  before_save :lay_timeless_egg
13
13
 
14
14
  def lay_timeless_egg
15
- Egg.timeless.create!
15
+ Egg.timeless { Egg.create! }
16
16
  end
17
17
  end
18
18
 
@@ -30,7 +30,7 @@ describe Mongoid::Timestamps::Timeless do
30
30
  context 'when timeless is used on one instance and then not used on another instance' do
31
31
  let!(:first_instance) do
32
32
  egg = Egg.create!
33
- egg.timeless.save!
33
+ egg.timeless { egg.save! }
34
34
  egg
35
35
  end
36
36
 
@@ -60,7 +60,7 @@ describe Mongoid::Timestamps::Timeless do
60
60
 
61
61
  context 'when the root executes timeless' do
62
62
  let!(:chicken) do
63
- Chicken.timeless.create!
63
+ Chicken.timeless { Chicken.create! }
64
64
  end
65
65
 
66
66
  it 'creates the parent with a timestamp' do
@@ -80,7 +80,7 @@ describe Mongoid::Timestamps::Timeless do
80
80
  end
81
81
 
82
82
  before do
83
- document.timeless.save!
83
+ document.timeless { document.save! }
84
84
  end
85
85
 
86
86
  it 'does not set the created timestamp' do
@@ -108,7 +108,7 @@ describe Mongoid::Timestamps::Timeless do
108
108
 
109
109
  context 'when used on the class' do
110
110
  let!(:document) do
111
- Dokument.timeless.create!
111
+ Dokument.timeless { Dokument.create! }
112
112
  end
113
113
 
114
114
  it 'does not set the created timestamp' do
@@ -135,4 +135,196 @@ describe Mongoid::Timestamps::Timeless do
135
135
  end
136
136
  end
137
137
  end
138
+
139
+ describe '#timeless with a block' do
140
+ before(:all) do
141
+ class TimelessOther
142
+ include Mongoid::Document
143
+ include Mongoid::Timestamps
144
+ end
145
+ end
146
+
147
+ after(:all) do
148
+ Object.send(:remove_const, :TimelessOther)
149
+ end
150
+
151
+ context 'when called on an instance' do
152
+ let(:document) { Dokument.new }
153
+
154
+ it 'executes the block and persists the document' do
155
+ document.timeless { document.save! }
156
+ expect(document).to be_persisted
157
+ end
158
+
159
+ it 'does not set the created timestamp' do
160
+ document.timeless { document.save! }
161
+ expect(document.created_at).to be_nil
162
+ end
163
+
164
+ it 'does not set the updated timestamp' do
165
+ document.timeless { document.save! }
166
+ expect(document.updated_at).to be_nil
167
+ end
168
+
169
+ it 'returns the value of the block' do
170
+ expect(document.timeless { 42 }).to eq(42)
171
+ end
172
+
173
+ it 'resumes timestamping after the block' do
174
+ document.timeless { document.save! }
175
+ document.update_attribute(:title, 'Sir')
176
+ expect(document.updated_at).not_to be_nil
177
+ end
178
+
179
+ it 'is not timeless outside the block' do
180
+ document.timeless { document.save! }
181
+ expect(document).not_to be_timeless
182
+ end
183
+
184
+ it 'restores state even when the block raises' do
185
+ expect do
186
+ document.timeless { raise 'boom' }
187
+ end.to raise_error('boom')
188
+ expect(document).not_to be_timeless
189
+ end
190
+ end
191
+
192
+ context 'when called on the class' do
193
+ it 'does not set timestamps for documents created in the block' do
194
+ document = Dokument.timeless { Dokument.create! }
195
+ expect(document.created_at).to be_nil
196
+ expect(document.updated_at).to be_nil
197
+ end
198
+ end
199
+
200
+ context 'when nested' do
201
+ let(:document) { Dokument.new }
202
+
203
+ it 'remains timeless until the outermost block exits' do
204
+ Dokument.timeless do
205
+ Dokument.timeless { document.save! }
206
+ # inner block has exited, but we are still inside the outer block
207
+ expect(document).to be_timeless
208
+ end
209
+ expect(document).not_to be_timeless
210
+ expect(document.created_at).to be_nil
211
+ end
212
+ end
213
+
214
+ context 'when other documents are persisted in the block' do
215
+ it 'suppresses timestamps globally on the thread for the block duration' do
216
+ other = nil
217
+ Dokument.timeless { other = TimelessOther.create! }
218
+ expect(other.created_at).to be_nil
219
+ end
220
+ end
221
+ end
222
+
223
+ # Regression for MONGOID-5782: saving a parent timeless must not bump the
224
+ # updated_at of embedded children, at any nesting depth, when the
225
+ # associations cascade callbacks.
226
+ describe 'MONGOID-5782 cascaded embedded timestamps' do
227
+ before(:all) do
228
+ class TimelessBaz
229
+ include Mongoid::Document
230
+ include Mongoid::Timestamps
231
+
232
+ embedded_in :timeless_bar
233
+ field :val, type: String
234
+ end
235
+
236
+ class TimelessBar
237
+ include Mongoid::Document
238
+ include Mongoid::Timestamps
239
+
240
+ embedded_in :timeless_foo
241
+ embeds_many :timeless_bazs, cascade_callbacks: true
242
+ field :val, type: String
243
+ end
244
+
245
+ class TimelessFoo
246
+ include Mongoid::Document
247
+ include Mongoid::Timestamps
248
+
249
+ embeds_many :timeless_bars, cascade_callbacks: true
250
+ field :val, type: String
251
+ end
252
+ end
253
+
254
+ after(:all) do
255
+ Object.send(:remove_const, :TimelessBaz)
256
+ Object.send(:remove_const, :TimelessBar)
257
+ Object.send(:remove_const, :TimelessFoo)
258
+ end
259
+
260
+ let!(:start_time) { Timecop.freeze(Time.at(Time.now.to_i)) }
261
+
262
+ let!(:foo) do
263
+ TimelessFoo.create!(timeless_bars: [ { val: 'a', timeless_bazs: [ { val: 'x' } ] } ])
264
+ end
265
+
266
+ let(:bar) { foo.timeless_bars.first }
267
+ let(:baz) { bar.timeless_bazs.first }
268
+
269
+ after do
270
+ Timecop.return
271
+ end
272
+
273
+ it 'does not bump the embedded child updated_at' do
274
+ original = bar.updated_at
275
+ bar.val = 'b'
276
+ Timecop.freeze(Time.at(Time.now.to_i) + 2)
277
+ foo.timeless { foo.save! }
278
+ # the change was actually persisted (the block ran)...
279
+ expect(foo.reload.timeless_bars.first.val).to eq('b')
280
+ # ...but the timestamp was suppressed.
281
+ expect(bar.updated_at).to eq(original)
282
+ end
283
+
284
+ it 'does not bump the nested embedded child updated_at' do
285
+ original = baz.updated_at
286
+ baz.val = 'y'
287
+ Timecop.freeze(Time.at(Time.now.to_i) + 2)
288
+ foo.timeless { foo.save! }
289
+ expect(foo.reload.timeless_bars.first.timeless_bazs.first.val).to eq('y')
290
+ expect(baz.updated_at).to eq(original)
291
+ end
292
+
293
+ it 'still preserves the parent updated_at' do
294
+ original = foo.updated_at
295
+ foo.val = 'c'
296
+ Timecop.freeze(Time.at(Time.now.to_i) + 2)
297
+ foo.timeless { foo.save! }
298
+ expect(TimelessFoo.find(foo.id).val).to eq('c')
299
+ expect(foo.updated_at).to eq(original)
300
+ end
301
+ end
302
+
303
+ describe 'deprecation of the block-less form' do
304
+ before { Mongoid.reset_deprecation_warnings! }
305
+
306
+ let(:document) { Dokument.new }
307
+
308
+ it 'warns when called on an instance without a block' do
309
+ expect(Mongoid.logger).to receive(:warn).with(/timeless/)
310
+ document.timeless.save!
311
+ end
312
+
313
+ it 'warns when called on the class without a block' do
314
+ expect(Mongoid.logger).to receive(:warn).with(/timeless/)
315
+ Dokument.timeless.create!
316
+ end
317
+
318
+ it 'does not warn when called with a block' do
319
+ expect(Mongoid.logger).not_to receive(:warn)
320
+ document.timeless { document.save! }
321
+ end
322
+
323
+ it 'does not warn from the internal touch: false path' do
324
+ document.save!
325
+ document.title = 'changed'
326
+ expect(Mongoid.logger).not_to receive(:warn).with(/timeless/)
327
+ document.save!(touch: false)
328
+ end
329
+ end
138
330
  end
@@ -1511,5 +1511,30 @@ describe Mongoid::Touchable do
1511
1511
  expect(building.updated_at).to eq(original_building_updated_at)
1512
1512
  end
1513
1513
  end
1514
+
1515
+ context 'when creating an embeds_one document with touch: true (regression for #6128)' do
1516
+ let(:building) do
1517
+ TouchableSpec::Embedded::Building.create!(title: 'Tower')
1518
+ end
1519
+
1520
+ it 'persists the embedded document fields after create_<assoc>' do
1521
+ building.create_lobby(name: 'Main Lobby')
1522
+
1523
+ reloaded = TouchableSpec::Embedded::Building.find(building.id)
1524
+ expect(reloaded.lobby).not_to be_nil
1525
+ expect(reloaded.lobby.name).to eq('Main Lobby')
1526
+ end
1527
+
1528
+ it 'updates the parent updated_at after create_<assoc>' do
1529
+ original_updated_at = building.updated_at
1530
+
1531
+ Timecop.travel(Time.now + 10) do
1532
+ building.create_lobby(name: 'Main Lobby')
1533
+ end
1534
+
1535
+ building.reload
1536
+ expect(building.updated_at).to be > original_updated_at
1537
+ end
1538
+ end
1514
1539
  end
1515
1540
  end
@@ -10,6 +10,16 @@ module TouchableSpec
10
10
 
11
11
  embeds_many :entrances, class_name: 'TouchableSpec::Embedded::Entrance'
12
12
  embeds_many :floors, class_name: 'TouchableSpec::Embedded::Floor'
13
+ embeds_one :lobby, class_name: 'TouchableSpec::Embedded::Lobby'
14
+ end
15
+
16
+ class Lobby
17
+ include Mongoid::Document
18
+ include Mongoid::Timestamps
19
+
20
+ field :name, type: String
21
+
22
+ embedded_in :building, touch: true, class_name: 'TouchableSpec::Embedded::Building'
13
23
  end
14
24
 
15
25
  class Entrance
@@ -82,6 +82,25 @@ describe Mongoid::Validatable::AssociatedValidator do
82
82
  expect(user).to be_valid
83
83
  end
84
84
  end
85
+
86
+ context 'when a child document has been destroyed' do
87
+ let(:user) do
88
+ User.new(name: 'test')
89
+ end
90
+
91
+ let(:description) do
92
+ Description.new
93
+ end
94
+
95
+ before do
96
+ user.descriptions << description
97
+ description.destroyed = true
98
+ end
99
+
100
+ it 'does not run validation on the destroyed child' do
101
+ expect(user).to be_valid
102
+ end
103
+ end
85
104
  end
86
105
  end
87
106