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.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +69 -0
- data/lib/mongoid/association/accessors.rb +27 -5
- data/lib/mongoid/association/depending.rb +14 -10
- data/lib/mongoid/association/eager.rb +9 -0
- data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +88 -0
- data/lib/mongoid/association/eager_load/embedded_distributor.rb +151 -0
- data/lib/mongoid/association/eager_load/inclusion.rb +173 -0
- data/lib/mongoid/association/eager_load/inclusion_tree.rb +96 -0
- data/lib/mongoid/association/eager_load/lookup_pipeline.rb +121 -0
- data/lib/mongoid/association/eager_load/polymorphic_preloader.rb +61 -0
- data/lib/mongoid/association/eager_load/polymorphic_targets.rb +151 -0
- data/lib/mongoid/association/eager_loadable.rb +65 -101
- data/lib/mongoid/association/embedded/batchable.rb +17 -1
- data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
- data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
- data/lib/mongoid/association/macros.rb +13 -1
- data/lib/mongoid/association/nested/many.rb +34 -5
- data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
- data/lib/mongoid/association/nested/one.rb +6 -1
- data/lib/mongoid/association/options.rb +17 -0
- data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
- data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
- data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
- data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
- data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
- data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
- data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
- data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
- data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
- data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
- data/lib/mongoid/association/referenced/has_one.rb +1 -0
- data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
- data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
- data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
- data/lib/mongoid/association/referenced.rb +2 -0
- data/lib/mongoid/association/relatable.rb +58 -0
- data/lib/mongoid/association.rb +15 -0
- data/lib/mongoid/atomic/modifiers.rb +100 -16
- data/lib/mongoid/clients/factory.rb +9 -13
- data/lib/mongoid/collection_configurable.rb +8 -0
- data/lib/mongoid/config/defaults.rb +0 -1
- data/lib/mongoid/config/encryption.rb +170 -22
- data/lib/mongoid/config.rb +56 -5
- data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
- data/lib/mongoid/contextual/memory.rb +18 -7
- data/lib/mongoid/criteria/includable.rb +35 -11
- data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
- data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
- data/lib/mongoid/deprecable.rb +45 -2
- data/lib/mongoid/document.rb +3 -0
- data/lib/mongoid/encryptable.rb +73 -0
- data/lib/mongoid/errors/config_redactor.rb +43 -0
- data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
- data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
- data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
- data/lib/mongoid/errors/no_client_database.rb +1 -1
- data/lib/mongoid/errors/no_client_hosts.rb +1 -1
- data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
- data/lib/mongoid/errors/readonly_association.rb +29 -0
- data/lib/mongoid/errors.rb +5 -0
- data/lib/mongoid/extensions/vector.rb +64 -0
- data/lib/mongoid/extensions.rb +1 -0
- data/lib/mongoid/field_readable.rb +69 -0
- data/lib/mongoid/fields.rb +7 -0
- data/lib/mongoid/indexable.rb +38 -26
- data/lib/mongoid/matchable.rb +6 -1
- data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
- data/lib/mongoid/matcher/regex.rb +11 -12
- data/lib/mongoid/matcher/regexp_budget.rb +372 -0
- data/lib/mongoid/matcher.rb +1 -0
- data/lib/mongoid/persistable/creatable.rb +120 -14
- data/lib/mongoid/persistable/updatable.rb +9 -11
- data/lib/mongoid/persistable.rb +15 -15
- data/lib/mongoid/persistence_context.rb +77 -0
- data/lib/mongoid/search_indexable.rb +69 -24
- data/lib/mongoid/serializable.rb +12 -4
- data/lib/mongoid/tasks/database.rb +33 -16
- data/lib/mongoid/threaded.rb +36 -0
- data/lib/mongoid/timestamps/timeless.rb +84 -10
- data/lib/mongoid/touchable.rb +28 -1
- data/lib/mongoid/traversable.rb +2 -2
- data/lib/mongoid/validatable/associated.rb +18 -2
- data/lib/mongoid/version.rb +1 -1
- data/lib/mongoid/warnings.rb +4 -0
- data/spec/integration/app_spec.rb +7 -1
- data/spec/integration/associations/belongs_to_spec.rb +13 -0
- data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +31 -2
- data/spec/integration/atomic/modifiers_spec.rb +283 -0
- data/spec/integration/dots_and_dollars_spec.rb +12 -2
- data/spec/integration/encryption_spec.rb +300 -0
- data/spec/integration/matcher_operator_data/regex.yml +21 -0
- data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
- data/spec/integration/query_operator_guard_spec.rb +89 -0
- data/spec/mongoid/association/accessors_spec.rb +35 -0
- data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
- data/spec/mongoid/association/eager_spec.rb +2 -2
- data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
- data/spec/mongoid/association/fallback_spec.rb +425 -0
- data/spec/mongoid/association/macros_spec.rb +26 -0
- data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
- data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
- data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
- data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
- data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
- data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
- data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
- data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
- data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
- data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
- data/spec/mongoid/attributes/nested_spec.rb +278 -8
- data/spec/mongoid/clients/factory_spec.rb +36 -0
- data/spec/mongoid/config/defaults_spec.rb +33 -0
- data/spec/mongoid/config/encryption_spec.rb +451 -0
- data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
- data/spec/mongoid/contextual/memory_spec.rb +177 -0
- data/spec/mongoid/criteria/includable_spec.rb +1221 -0
- data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
- data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
- data/spec/mongoid/criteria_spec.rb +3 -0
- data/spec/mongoid/encryptable_spec.rb +115 -0
- data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
- data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
- data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
- data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
- data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
- data/spec/mongoid/extensions/vector_spec.rb +175 -0
- data/spec/mongoid/fields_spec.rb +8 -0
- data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
- data/spec/mongoid/search_indexable_spec.rb +321 -9
- data/spec/mongoid/tasks/database_spec.rb +31 -0
- data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
- data/spec/mongoid/touchable_spec.rb +242 -0
- data/spec/mongoid/touchable_spec_models.rb +47 -0
- data/spec/mongoid/validatable/associated_spec.rb +19 -0
- data/spec/spec_helper.rb +5 -1
- data/spec/support/crypt/models.rb +253 -0
- data/spec/support/models/modifier_models.rb +65 -0
- 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.
|
|
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
|
|
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
|
|
394
|
-
let
|
|
395
|
-
let
|
|
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
|