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.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +49 -0
- data/lib/mongoid/association/accessors.rb +9 -1
- 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 +57 -100
- 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.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 +40 -0
- data/lib/mongoid/association.rb +7 -0
- data/lib/mongoid/clients/factory.rb +7 -13
- data/lib/mongoid/collection_configurable.rb +8 -0
- data/lib/mongoid/config/defaults.rb +0 -1
- data/lib/mongoid/config/encryption.rb +36 -19
- 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/encryptable.rb +46 -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/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 +28 -0
- data/lib/mongoid/errors/readonly_association.rb +29 -0
- data/lib/mongoid/errors.rb +4 -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/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 +1 -1
- data/lib/mongoid/persistable/updatable.rb +9 -11
- data/lib/mongoid/persistence_context.rb +35 -0
- data/lib/mongoid/search_indexable.rb +64 -23
- data/lib/mongoid/serializable.rb +12 -4
- data/lib/mongoid/tasks/database.rb +23 -9
- data/lib/mongoid/threaded.rb +36 -0
- data/lib/mongoid/timestamps/timeless.rb +84 -10
- data/lib/mongoid/traversable.rb +2 -2
- data/lib/mongoid/validatable/associated.rb +18 -2
- data/lib/mongoid/version.rb +1 -1
- data/spec/integration/app_spec.rb +7 -1
- data/spec/integration/associations/belongs_to_spec.rb +13 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
- data/spec/integration/dots_and_dollars_spec.rb +12 -2
- data/spec/integration/encryption_spec.rb +151 -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/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/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/attributes/nested_spec.rb +249 -8
- data/spec/mongoid/config/defaults_spec.rb +33 -0
- data/spec/mongoid/config/encryption_spec.rb +228 -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 +875 -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 +61 -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/matcher/regexp_budget_spec.rb +570 -0
- data/spec/mongoid/search_indexable_spec.rb +295 -9
- data/spec/mongoid/tasks/database_spec.rb +18 -0
- data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
- data/spec/mongoid/touchable_spec.rb +25 -0
- data/spec/mongoid/touchable_spec_models.rb +10 -0
- data/spec/mongoid/validatable/associated_spec.rb +19 -0
- data/spec/spec_helper.rb +5 -1
- data/spec/support/crypt/models.rb +157 -0
- 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.
|
|
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
|
|
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
|
|
394
|
-
let
|
|
395
|
-
let
|
|
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
|
|