mongoid 9.1.0 → 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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +69 -0
  3. data/lib/mongoid/association/accessors.rb +27 -5
  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 +65 -101
  14. data/lib/mongoid/association/embedded/batchable.rb +17 -1
  15. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
  16. data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
  17. data/lib/mongoid/association/macros.rb +13 -1
  18. data/lib/mongoid/association/nested/many.rb +34 -5
  19. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  20. data/lib/mongoid/association/nested/one.rb +6 -1
  21. data/lib/mongoid/association/options.rb +17 -0
  22. data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
  23. data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
  24. data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
  25. data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
  26. data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
  27. data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
  28. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  29. data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
  30. data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
  31. data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
  32. data/lib/mongoid/association/referenced/has_one.rb +1 -0
  33. data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
  34. data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
  35. data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
  36. data/lib/mongoid/association/referenced.rb +2 -0
  37. data/lib/mongoid/association/relatable.rb +58 -0
  38. data/lib/mongoid/association.rb +15 -0
  39. data/lib/mongoid/atomic/modifiers.rb +100 -16
  40. data/lib/mongoid/clients/factory.rb +9 -13
  41. data/lib/mongoid/collection_configurable.rb +8 -0
  42. data/lib/mongoid/config/defaults.rb +0 -1
  43. data/lib/mongoid/config/encryption.rb +170 -22
  44. data/lib/mongoid/config.rb +56 -5
  45. data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
  46. data/lib/mongoid/contextual/memory.rb +18 -7
  47. data/lib/mongoid/criteria/includable.rb +35 -11
  48. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  49. data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
  50. data/lib/mongoid/deprecable.rb +45 -2
  51. data/lib/mongoid/document.rb +3 -0
  52. data/lib/mongoid/encryptable.rb +73 -0
  53. data/lib/mongoid/errors/config_redactor.rb +43 -0
  54. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  55. data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
  56. data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
  57. data/lib/mongoid/errors/no_client_database.rb +1 -1
  58. data/lib/mongoid/errors/no_client_hosts.rb +1 -1
  59. data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
  60. data/lib/mongoid/errors/readonly_association.rb +29 -0
  61. data/lib/mongoid/errors.rb +5 -0
  62. data/lib/mongoid/extensions/vector.rb +64 -0
  63. data/lib/mongoid/extensions.rb +1 -0
  64. data/lib/mongoid/field_readable.rb +69 -0
  65. data/lib/mongoid/fields.rb +7 -0
  66. data/lib/mongoid/indexable.rb +38 -26
  67. data/lib/mongoid/matchable.rb +6 -1
  68. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
  69. data/lib/mongoid/matcher/regex.rb +11 -12
  70. data/lib/mongoid/matcher/regexp_budget.rb +372 -0
  71. data/lib/mongoid/matcher.rb +1 -0
  72. data/lib/mongoid/persistable/creatable.rb +120 -14
  73. data/lib/mongoid/persistable/updatable.rb +9 -11
  74. data/lib/mongoid/persistable.rb +15 -15
  75. data/lib/mongoid/persistence_context.rb +77 -0
  76. data/lib/mongoid/search_indexable.rb +69 -24
  77. data/lib/mongoid/serializable.rb +12 -4
  78. data/lib/mongoid/tasks/database.rb +33 -16
  79. data/lib/mongoid/threaded.rb +36 -0
  80. data/lib/mongoid/timestamps/timeless.rb +84 -10
  81. data/lib/mongoid/touchable.rb +28 -1
  82. data/lib/mongoid/traversable.rb +2 -2
  83. data/lib/mongoid/validatable/associated.rb +18 -2
  84. data/lib/mongoid/version.rb +1 -1
  85. data/lib/mongoid/warnings.rb +4 -0
  86. data/spec/integration/app_spec.rb +7 -1
  87. data/spec/integration/associations/belongs_to_spec.rb +13 -0
  88. data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
  89. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +31 -2
  90. data/spec/integration/atomic/modifiers_spec.rb +283 -0
  91. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  92. data/spec/integration/encryption_spec.rb +300 -0
  93. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  94. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  95. data/spec/integration/query_operator_guard_spec.rb +89 -0
  96. data/spec/mongoid/association/accessors_spec.rb +35 -0
  97. data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
  98. data/spec/mongoid/association/eager_spec.rb +2 -2
  99. data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
  100. data/spec/mongoid/association/fallback_spec.rb +425 -0
  101. data/spec/mongoid/association/macros_spec.rb +26 -0
  102. data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
  103. data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
  104. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  105. data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
  106. data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
  107. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  108. data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
  109. data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
  110. data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
  111. data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
  112. data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
  113. data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
  114. data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
  115. data/spec/mongoid/attributes/nested_spec.rb +278 -8
  116. data/spec/mongoid/clients/factory_spec.rb +36 -0
  117. data/spec/mongoid/config/defaults_spec.rb +33 -0
  118. data/spec/mongoid/config/encryption_spec.rb +451 -0
  119. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  120. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  121. data/spec/mongoid/criteria/includable_spec.rb +1221 -0
  122. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
  123. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
  124. data/spec/mongoid/criteria_spec.rb +3 -0
  125. data/spec/mongoid/encryptable_spec.rb +115 -0
  126. data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
  127. data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
  128. data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
  129. data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
  130. data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
  131. data/spec/mongoid/extensions/vector_spec.rb +175 -0
  132. data/spec/mongoid/fields_spec.rb +8 -0
  133. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  134. data/spec/mongoid/search_indexable_spec.rb +321 -9
  135. data/spec/mongoid/tasks/database_spec.rb +31 -0
  136. data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
  137. data/spec/mongoid/touchable_spec.rb +242 -0
  138. data/spec/mongoid/touchable_spec_models.rb +47 -0
  139. data/spec/mongoid/validatable/associated_spec.rb +19 -0
  140. data/spec/spec_helper.rb +5 -1
  141. data/spec/support/crypt/models.rb +253 -0
  142. data/spec/support/models/modifier_models.rb +65 -0
  143. metadata +55 -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
 
@@ -110,6 +118,32 @@ describe Mongoid::SearchIndexable do
110
118
  end
111
119
  end
112
120
 
121
+ describe '.wait_for_search_indexes' do
122
+ let(:model) do
123
+ Class.new do
124
+ include Mongoid::Document
125
+
126
+ store_in collection: BSON::ObjectId.new.to_s
127
+ search_index :idx, mappings: { dynamic: false }
128
+ end
129
+ end
130
+
131
+ it 'reads the index status inside collection management' do
132
+ managing = nil
133
+ search_indexes = [ { 'name' => 'idx', 'queryable' => true, 'status' => 'READY' } ]
134
+ collection = instance_double(Mongo::Collection)
135
+ allow(collection).to receive(:search_indexes) do
136
+ managing = Mongoid::Threaded.managing_collection?
137
+ search_indexes
138
+ end
139
+ allow(model).to receive(:collection).and_return(collection)
140
+
141
+ model.wait_for_search_indexes([ 'idx' ])
142
+
143
+ expect(managing).to be true
144
+ end
145
+ end
146
+
113
147
  describe '.vector_search_index' do
114
148
  let(:vector_model) do
115
149
  Class.new do
@@ -309,6 +343,276 @@ describe Mongoid::SearchIndexable do
309
343
  end
310
344
  end
311
345
 
346
+ describe '.vector_search_index with flat indexingMethod' do
347
+ it 'raises ArgumentError when flat index includes hnswOptions' do
348
+ expect do
349
+ Class.new do
350
+ include Mongoid::Document
351
+
352
+ store_in collection: BSON::ObjectId.new.to_s
353
+ vector_search_index fields: [
354
+ {
355
+ type: 'vector',
356
+ path: 'embedding',
357
+ numDimensions: 3,
358
+ similarity: 'cosine',
359
+ indexingMethod: 'flat',
360
+ hnswOptions: { m: 16, efConstruction: 150 }
361
+ }
362
+ ]
363
+ end
364
+ end.to raise_error(ArgumentError, /hnswOptions is only supported with indexingMethod: hnsw/)
365
+ end
366
+
367
+ it 'does not raise when flat index has no hnswOptions' do
368
+ expect do
369
+ Class.new do
370
+ include Mongoid::Document
371
+
372
+ store_in collection: BSON::ObjectId.new.to_s
373
+ vector_search_index fields: [
374
+ {
375
+ type: 'vector',
376
+ path: 'embedding',
377
+ numDimensions: 3,
378
+ similarity: 'cosine',
379
+ indexingMethod: 'flat'
380
+ }
381
+ ]
382
+ end
383
+ end.not_to raise_error
384
+ end
385
+
386
+ it 'does not raise when hnsw index has hnswOptions' do
387
+ expect do
388
+ Class.new do
389
+ include Mongoid::Document
390
+
391
+ store_in collection: BSON::ObjectId.new.to_s
392
+ vector_search_index fields: [
393
+ {
394
+ type: 'vector',
395
+ path: 'embedding',
396
+ numDimensions: 3,
397
+ similarity: 'cosine',
398
+ indexingMethod: 'hnsw',
399
+ hnswOptions: { m: 16, efConstruction: 150 }
400
+ }
401
+ ]
402
+ end
403
+ end.not_to raise_error
404
+ end
405
+ end
406
+
407
+ describe '.vector_search pipeline construction' do
408
+ let(:model) do
409
+ Class.new do
410
+ include Mongoid::Document
411
+
412
+ store_in collection: BSON::ObjectId.new.to_s
413
+ field :embedding, type: Array
414
+ vector_search_index fields: [ { type: 'vector', path: 'embedding', numDimensions: 3, similarity: 'cosine' } ]
415
+ end
416
+ end
417
+
418
+ let(:fake_collection) { instance_double(Mongo::Collection) }
419
+ let(:fake_cursor) { double(map: []) }
420
+
421
+ before do
422
+ allow(model).to receive(:collection).and_return(fake_collection)
423
+ allow(fake_collection).to receive(:aggregate).and_return(fake_cursor)
424
+ end
425
+
426
+ it 'includes numCandidates by default' do
427
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
428
+ vs = pipeline.find { |s| s['$vectorSearch'] }
429
+ expect(vs['$vectorSearch']).to have_key('numCandidates')
430
+ fake_cursor
431
+ end
432
+
433
+ model.vector_search([ 0.1, 0.2, 0.3 ], limit: 5)
434
+ end
435
+
436
+ it 'omits numCandidates and sends exact: true when exact: true' do
437
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
438
+ vs = pipeline.find { |s| s['$vectorSearch'] }
439
+ expect(vs['$vectorSearch']).not_to have_key('numCandidates')
440
+ expect(vs['$vectorSearch']['exact']).to be true
441
+ fake_cursor
442
+ end
443
+
444
+ model.vector_search([ 0.1, 0.2, 0.3 ], exact: true)
445
+ end
446
+
447
+ it 'uses limit * 10 as the default numCandidates' do
448
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
449
+ vs = pipeline.find { |s| s['$vectorSearch'] }
450
+ expect(vs['$vectorSearch']['numCandidates']).to eq(50)
451
+ fake_cursor
452
+ end
453
+
454
+ model.vector_search([ 0.1, 0.2, 0.3 ], limit: 5)
455
+ end
456
+ end
457
+
458
+ describe '#vector_search pipeline construction' do
459
+ let(:model) do
460
+ Class.new do
461
+ include Mongoid::Document
462
+
463
+ store_in collection: BSON::ObjectId.new.to_s
464
+ field :embedding, type: Array
465
+ vector_search_index fields: [ { type: 'vector', path: 'embedding', numDimensions: 3, similarity: 'cosine' } ]
466
+ end
467
+ end
468
+
469
+ let(:fake_collection) { instance_double(Mongo::Collection) }
470
+ let(:fake_cursor) { double(map: []) }
471
+ let(:doc) { model.new(embedding: [ 0.1, 0.2, 0.3 ]) }
472
+
473
+ before do
474
+ allow(model).to receive(:collection).and_return(fake_collection)
475
+ allow(fake_collection).to receive(:aggregate).and_return(fake_cursor)
476
+ end
477
+
478
+ it 'omits numCandidates and sends exact: true when exact: true' do
479
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
480
+ vs = pipeline.find { |s| s['$vectorSearch'] }
481
+ expect(vs['$vectorSearch']).not_to have_key('numCandidates')
482
+ expect(vs['$vectorSearch']['exact']).to be true
483
+ fake_cursor
484
+ end
485
+
486
+ doc.vector_search(exact: true)
487
+ end
488
+
489
+ it 'passes limit + 1 to $vectorSearch so the post-filter never short-counts' do
490
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
491
+ vs = pipeline.find { |s| s['$vectorSearch'] }
492
+ expect(vs['$vectorSearch']['limit']).to eq 6
493
+ fake_cursor
494
+ end
495
+
496
+ doc.vector_search(limit: 5)
497
+ end
498
+
499
+ it 'does not use filter in $vectorSearch for self-exclusion' do
500
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
501
+ vs = pipeline.find { |s| s['$vectorSearch'] }
502
+ expect(vs['$vectorSearch']).not_to have_key('filter')
503
+ fake_cursor
504
+ end
505
+
506
+ doc.vector_search(limit: 5)
507
+ end
508
+
509
+ it 'adds a $match stage after $vectorSearch to exclude self' do
510
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
511
+ match = pipeline.find { |s| s['$match'] }
512
+ expect(match).to eq({ '$match' => { '_id' => { '$ne' => doc.id } } })
513
+ fake_cursor
514
+ end
515
+
516
+ doc.vector_search(limit: 5)
517
+ end
518
+
519
+ it 'adds a $limit stage after $match to cap results at the requested limit' do
520
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
521
+ match_idx = pipeline.index { |s| s['$match'] }
522
+ limit_stage = pipeline[match_idx + 1]
523
+ expect(limit_stage).to eq({ '$limit' => 5 })
524
+ fake_cursor
525
+ end
526
+
527
+ doc.vector_search(limit: 5)
528
+ end
529
+
530
+ it 'passes a user-provided filter through to $vectorSearch' do
531
+ user_filter = { 'status' => 'published' }
532
+
533
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
534
+ vs = pipeline.find { |s| s['$vectorSearch'] }
535
+ expect(vs['$vectorSearch']['filter']).to eq user_filter
536
+ fake_cursor
537
+ end
538
+
539
+ doc.vector_search(limit: 5, filter: user_filter)
540
+ end
541
+ end
542
+
543
+ describe '#auto_embed_search pipeline construction' do
544
+ let(:model) do
545
+ Class.new do
546
+ include Mongoid::Document
547
+
548
+ store_in collection: BSON::ObjectId.new.to_s
549
+ auto_embed_field :description, model: 'voyage-4'
550
+ end
551
+ end
552
+
553
+ let(:fake_collection) { instance_double(Mongo::Collection) }
554
+ let(:fake_cursor) { double(map: []) }
555
+ let(:doc) { model.new(description: 'hello world') }
556
+
557
+ before do
558
+ allow(model).to receive(:collection).and_return(fake_collection)
559
+ allow(fake_collection).to receive(:aggregate).and_return(fake_cursor)
560
+ end
561
+
562
+ it 'passes limit + 1 to $vectorSearch so the post-filter never short-counts' do
563
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
564
+ vs = pipeline.find { |s| s['$vectorSearch'] }
565
+ expect(vs['$vectorSearch']['limit']).to eq 6
566
+ fake_cursor
567
+ end
568
+
569
+ doc.auto_embed_search(limit: 5)
570
+ end
571
+
572
+ it 'does not use filter in $vectorSearch for self-exclusion' do
573
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
574
+ vs = pipeline.find { |s| s['$vectorSearch'] }
575
+ expect(vs['$vectorSearch']).not_to have_key('filter')
576
+ fake_cursor
577
+ end
578
+
579
+ doc.auto_embed_search(limit: 5)
580
+ end
581
+
582
+ it 'adds a $match stage after $vectorSearch to exclude self' do
583
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
584
+ match = pipeline.find { |s| s['$match'] }
585
+ expect(match).to eq({ '$match' => { '_id' => { '$ne' => doc.id } } })
586
+ fake_cursor
587
+ end
588
+
589
+ doc.auto_embed_search(limit: 5)
590
+ end
591
+
592
+ it 'adds a $limit stage after $match to cap results at the requested limit' do
593
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
594
+ match_idx = pipeline.index { |s| s['$match'] }
595
+ limit_stage = pipeline[match_idx + 1]
596
+ expect(limit_stage).to eq({ '$limit' => 5 })
597
+ fake_cursor
598
+ end
599
+
600
+ doc.auto_embed_search(limit: 5)
601
+ end
602
+
603
+ it 'passes a user-provided filter through to $vectorSearch' do
604
+ user_filter = { 'status' => 'published' }
605
+
606
+ expect(fake_collection).to receive(:aggregate) do |pipeline|
607
+ vs = pipeline.find { |s| s['$vectorSearch'] }
608
+ expect(vs['$vectorSearch']['filter']).to eq user_filter
609
+ fake_cursor
610
+ end
611
+
612
+ doc.auto_embed_search(limit: 5, filter: user_filter)
613
+ end
614
+ end
615
+
312
616
  # Atlas integration tests — skipped when ATLAS_URI is not set.
313
617
 
314
618
  context 'Atlas integration' do
@@ -332,7 +636,7 @@ describe Mongoid::SearchIndexable do
332
636
  let(:requested_definitions) { model.search_index_specs.map { |spec| spec[:definition].with_indifferent_access } }
333
637
  let(:index_names) { model.create_search_indexes }
334
638
  let(:actual_indexes) { helper.wait_for(*index_names) }
335
- let(:actual_definitions) { actual_indexes.map { |i| i['latestDefinition'] } }
639
+ let(:actual_definitions) { actual_indexes.map { |i| helper.normalize_definition(i['latestDefinition']) } }
336
640
 
337
641
  describe '.create_search_indexes' do
338
642
  it 'creates the indexes' do
@@ -343,10 +647,10 @@ describe Mongoid::SearchIndexable do
343
647
  describe '.search_indexes' do
344
648
  before { actual_indexes } # wait for the indices to be created
345
649
 
346
- let(:queried_definitions) { model.search_indexes.map { |i| i['latestDefinition'] } }
650
+ let(:queried_definitions) { model.search_indexes.map { |i| helper.normalize_definition(i['latestDefinition']) } }
347
651
 
348
652
  it 'queries the available search indexes' do
349
- expect(queried_definitions).to eq requested_definitions
653
+ expect(queried_definitions).to match_array(requested_definitions)
350
654
  end
351
655
  end
352
656
 
@@ -390,11 +694,19 @@ describe Mongoid::SearchIndexable do
390
694
  let(:vector_helper) { SearchIndexHelper.new(vector_model) }
391
695
 
392
696
  # 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 ]) }
697
+ let(:doc_a) { vector_model.create!(embedding: [ 1.0, 0.0, 0.0 ]) }
698
+ let(:doc_b) { vector_model.create!(embedding: [ 0.0, 1.0, 0.0 ]) }
699
+ let(:doc_c) { vector_model.create!(embedding: [ 0.0, 0.0, 1.0 ]) }
396
700
 
397
701
  before do
702
+ vector_helper # force collection to be dropped, created
703
+
704
+ # once the collection has been recreated, populate it with documents
705
+ doc_a
706
+ doc_b
707
+ doc_c
708
+
709
+ # prepare the search indexes
398
710
  names = vector_model.create_search_indexes
399
711
  vector_helper.wait_for(*names)
400
712
  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 ]
@@ -236,6 +254,19 @@ describe Mongoid::Tasks::Database do
236
254
  described_class.create_search_indexes([ searchable_model_spy ], wait: false)
237
255
  end
238
256
  end
257
+
258
+ context 'when waiting for the indexes' do
259
+ it 'runs the wait inside collection management' do
260
+ managing = nil
261
+ allow(described_class).to receive(:wait_for_search_indexes) do
262
+ managing = Mongoid::Threaded.managing_collection?
263
+ end
264
+
265
+ described_class.create_search_indexes([ searchable_model_spy ], wait: true)
266
+
267
+ expect(managing).to be true
268
+ end
269
+ end
239
270
  end
240
271
 
241
272
  describe '.remove_search_indexes' do
@@ -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