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
|
@@ -1850,6 +1850,151 @@ describe Mongoid::Association::Referenced::HasMany::Proxy do
|
|
|
1850
1850
|
expect(person.posts).to eq([])
|
|
1851
1851
|
end
|
|
1852
1852
|
end
|
|
1853
|
+
|
|
1854
|
+
context 'when a document is appended with an id that is already loaded' do
|
|
1855
|
+
# Iterating the association moves an appended document out of _added
|
|
1856
|
+
# without adding it to _loaded, so the scan for matches and the
|
|
1857
|
+
# removal that follows see different instances for the same id.
|
|
1858
|
+
let(:person) { Person.create! }
|
|
1859
|
+
let!(:post) { person.posts.create!(title: 'Testing') }
|
|
1860
|
+
let(:reloaded) { Person.find(person._id) }
|
|
1861
|
+
|
|
1862
|
+
before do
|
|
1863
|
+
reloaded.posts.to_a
|
|
1864
|
+
reloaded.posts._target.push(
|
|
1865
|
+
Post.new(_id: post._id, title: 'Testing', person_id: reloaded._id)
|
|
1866
|
+
)
|
|
1867
|
+
end
|
|
1868
|
+
|
|
1869
|
+
shared_examples 'removes the document once' do
|
|
1870
|
+
it 'sets the association locally' do
|
|
1871
|
+
reloaded.posts.send(method, conditions)
|
|
1872
|
+
expect(reloaded.posts).to eq([])
|
|
1873
|
+
end
|
|
1874
|
+
|
|
1875
|
+
it 'deletes the documents from the database' do
|
|
1876
|
+
reloaded.posts.send(method, conditions)
|
|
1877
|
+
expect(Post.count).to eq(0)
|
|
1878
|
+
end
|
|
1879
|
+
end
|
|
1880
|
+
|
|
1881
|
+
context 'when the conditions carry no regular expression' do
|
|
1882
|
+
let(:conditions) { { title: 'Testing' } }
|
|
1883
|
+
|
|
1884
|
+
it_behaves_like 'removes the document once'
|
|
1885
|
+
end
|
|
1886
|
+
|
|
1887
|
+
context 'when the conditions carry a regular expression' do
|
|
1888
|
+
let(:conditions) { { title: /Testing/ } }
|
|
1889
|
+
|
|
1890
|
+
it_behaves_like 'removes the document once'
|
|
1891
|
+
end
|
|
1892
|
+
end
|
|
1893
|
+
|
|
1894
|
+
context 'when an appended document disagrees with the loaded one' do
|
|
1895
|
+
# Only the appended instance matches, so the removal has to drop that
|
|
1896
|
+
# id from both _loaded and _added. Removing by id and stopping at the
|
|
1897
|
+
# first hash that has it would take the loaded instance instead and
|
|
1898
|
+
# leave the unbound one behind.
|
|
1899
|
+
let(:person) { Person.create! }
|
|
1900
|
+
let!(:post) { person.posts.create!(title: 'Other') }
|
|
1901
|
+
let(:reloaded) { Person.find(person._id) }
|
|
1902
|
+
|
|
1903
|
+
before do
|
|
1904
|
+
reloaded.posts.to_a
|
|
1905
|
+
reloaded.posts._target.push(
|
|
1906
|
+
Post.new(_id: post._id, title: 'Testing', person_id: reloaded._id)
|
|
1907
|
+
)
|
|
1908
|
+
end
|
|
1909
|
+
|
|
1910
|
+
it 'deletes nothing from the database' do
|
|
1911
|
+
expect(reloaded.posts.send(method, { title: /Testing/ })).to eq(0)
|
|
1912
|
+
expect(Post.where(title: 'Other').count).to eq(1)
|
|
1913
|
+
end
|
|
1914
|
+
|
|
1915
|
+
it 'leaves no unbound document in the association' do
|
|
1916
|
+
reloaded.posts.send(method, { title: /Testing/ })
|
|
1917
|
+
expect(reloaded.posts._target.in_memory.map(&:person_id)).not_to include(nil)
|
|
1918
|
+
end
|
|
1919
|
+
end
|
|
1920
|
+
|
|
1921
|
+
context 'when a nested selector is evaluated while the association loads' do
|
|
1922
|
+
# Loading the association runs find callbacks, and application code in
|
|
1923
|
+
# one of those can evaluate a selector of its own. A scope opened
|
|
1924
|
+
# around the load would mark it as having nothing to bound, and that
|
|
1925
|
+
# suppresses the decision for the nested selector too, leaving any
|
|
1926
|
+
# pattern in it unguarded.
|
|
1927
|
+
config_override :in_memory_regexp_time_limit, 5.0
|
|
1928
|
+
|
|
1929
|
+
let(:person) { Person.create! }
|
|
1930
|
+
let(:reloaded) { Person.find(person._id) }
|
|
1931
|
+
|
|
1932
|
+
before do
|
|
1933
|
+
person.posts.create!(title: 'Testing')
|
|
1934
|
+
person.posts.create!(title: 'Keep')
|
|
1935
|
+
reloaded
|
|
1936
|
+
end
|
|
1937
|
+
|
|
1938
|
+
it 'leaves the nested scope free to establish its own budget' do
|
|
1939
|
+
seen = []
|
|
1940
|
+
allow(Mongoid::Factory).to receive(:from_db).and_wrap_original do |original, *args|
|
|
1941
|
+
Mongoid::Matcher::RegexpBudget.open('title' => /Testing/) do
|
|
1942
|
+
seen << Mongoid::Matcher::RegexpBudget.remaining
|
|
1943
|
+
end
|
|
1944
|
+
original.call(*args)
|
|
1945
|
+
end
|
|
1946
|
+
|
|
1947
|
+
reloaded.posts.send(method, { title: 'Testing' })
|
|
1948
|
+
|
|
1949
|
+
expect(seen).not_to be_empty
|
|
1950
|
+
expect(seen).not_to include(nil)
|
|
1951
|
+
end
|
|
1952
|
+
end
|
|
1953
|
+
|
|
1954
|
+
if method == :delete_all
|
|
1955
|
+
context 'when the association has not been loaded' do
|
|
1956
|
+
let(:person) { Person.create! }
|
|
1957
|
+
let(:reloaded) { Person.find(person._id) }
|
|
1958
|
+
let(:subscriber) { Mrss::EventSubscriber.new }
|
|
1959
|
+
|
|
1960
|
+
before do
|
|
1961
|
+
person.posts.create!(title: 'Testing')
|
|
1962
|
+
person.posts.create!(title: 'Test')
|
|
1963
|
+
reloaded
|
|
1964
|
+
end
|
|
1965
|
+
|
|
1966
|
+
def commands_for(conditions)
|
|
1967
|
+
Person.collection.client.subscribe(Mongo::Monitoring::COMMAND, subscriber)
|
|
1968
|
+
reloaded.posts.delete_all(conditions)
|
|
1969
|
+
subscriber.started_events.map(&:command_name)
|
|
1970
|
+
ensure
|
|
1971
|
+
Person.collection.client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber)
|
|
1972
|
+
end
|
|
1973
|
+
|
|
1974
|
+
context 'when the conditions carry no regular expression' do
|
|
1975
|
+
# Nothing to bound, so the scan for matches stays where it was,
|
|
1976
|
+
# after the delete, where the association it loads comes back
|
|
1977
|
+
# empty. Moving it ahead of the delete would transfer the whole
|
|
1978
|
+
# association for an operation that need send nothing over the
|
|
1979
|
+
# wire.
|
|
1980
|
+
it 'deletes before loading the association' do
|
|
1981
|
+
expect(commands_for(nil)).to eq(%w[ delete find ])
|
|
1982
|
+
end
|
|
1983
|
+
end
|
|
1984
|
+
|
|
1985
|
+
context 'when the conditions carry a regular expression' do
|
|
1986
|
+
# The scan has to run under a budget, and before anything is
|
|
1987
|
+
# deleted, so that a budget which runs out leaves the database
|
|
1988
|
+
# untouched. What it scans is what is already in memory: loading
|
|
1989
|
+
# would trade the matching cost the budget exists to bound for an
|
|
1990
|
+
# unbounded amount of memory, since /.*/ is both cheap to supply
|
|
1991
|
+
# and matches every document in the association.
|
|
1992
|
+
it 'deletes without loading the association' do
|
|
1993
|
+
expect(commands_for({ title: /Test/ })).to eq(%w[ delete ])
|
|
1994
|
+
end
|
|
1995
|
+
end
|
|
1996
|
+
end
|
|
1997
|
+
end
|
|
1853
1998
|
end
|
|
1854
1999
|
|
|
1855
2000
|
context 'when the association is polymorphic' do
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe Mongoid::Association::Referenced::HasManyThrough::Eager do
|
|
6
|
+
before(:all) do
|
|
7
|
+
Object.const_set(:EgPhysician, Class.new do
|
|
8
|
+
include Mongoid::Document
|
|
9
|
+
|
|
10
|
+
store_in collection: 'eg_physicians'
|
|
11
|
+
has_many :eg_appointments, class_name: 'EgAppointment', inverse_of: :eg_physician
|
|
12
|
+
has_many :eg_patients, through: :eg_appointments,
|
|
13
|
+
class_name: 'EgPatient', source: :eg_patient
|
|
14
|
+
end)
|
|
15
|
+
Object.const_set(:EgAppointment, Class.new do
|
|
16
|
+
include Mongoid::Document
|
|
17
|
+
|
|
18
|
+
store_in collection: 'eg_appointments'
|
|
19
|
+
belongs_to :eg_physician, class_name: 'EgPhysician'
|
|
20
|
+
belongs_to :eg_patient, class_name: 'EgPatient'
|
|
21
|
+
end)
|
|
22
|
+
Object.const_set(:EgPatient, Class.new do
|
|
23
|
+
include Mongoid::Document
|
|
24
|
+
|
|
25
|
+
store_in collection: 'eg_patients'
|
|
26
|
+
end)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
after(:all) do
|
|
30
|
+
%w[EgPhysician EgAppointment EgPatient].each { |c| Object.send(:remove_const, c) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
before { [ EgPhysician, EgAppointment, EgPatient ].each(&:delete_all) }
|
|
34
|
+
|
|
35
|
+
it 'preloads patients for multiple physicians' do
|
|
36
|
+
p1 = EgPhysician.create!
|
|
37
|
+
p2 = EgPhysician.create!
|
|
38
|
+
pat1 = EgPatient.create!
|
|
39
|
+
pat2 = EgPatient.create!
|
|
40
|
+
pat3 = EgPatient.create!
|
|
41
|
+
EgAppointment.create!(eg_physician: p1, eg_patient: pat1)
|
|
42
|
+
EgAppointment.create!(eg_physician: p1, eg_patient: pat2)
|
|
43
|
+
EgAppointment.create!(eg_physician: p2, eg_patient: pat3)
|
|
44
|
+
|
|
45
|
+
physicians = EgPhysician.includes(:eg_patients).to_a
|
|
46
|
+
by_id = physicians.index_by(&:id)
|
|
47
|
+
|
|
48
|
+
expect(by_id[p1.id].eg_patients.to_a).to contain_exactly(pat1, pat2)
|
|
49
|
+
expect(by_id[p2.id].eg_patients.to_a).to contain_exactly(pat3)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'sets [] on physicians with no appointments' do
|
|
53
|
+
lone = EgPhysician.create!
|
|
54
|
+
physicians = EgPhysician.includes(:eg_patients).to_a
|
|
55
|
+
loaded = physicians.find { |p| p.id == lone.id }
|
|
56
|
+
expect(loaded.eg_patients.to_a).to eq([])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe Mongoid::Association::Referenced::HasManyThrough::Proxy do
|
|
6
|
+
before(:all) do
|
|
7
|
+
Object.const_set(:PxAppointment, Class.new do
|
|
8
|
+
include Mongoid::Document
|
|
9
|
+
|
|
10
|
+
field :physician_id, type: BSON::ObjectId
|
|
11
|
+
field :patient_id, type: BSON::ObjectId
|
|
12
|
+
belongs_to :px_physician, class_name: 'PxPhysician'
|
|
13
|
+
belongs_to :px_patient, class_name: 'PxPatient'
|
|
14
|
+
end)
|
|
15
|
+
Object.const_set(:PxPatient, Class.new { include Mongoid::Document })
|
|
16
|
+
Object.const_set(:PxPhysician, Class.new do
|
|
17
|
+
include Mongoid::Document
|
|
18
|
+
|
|
19
|
+
has_many :px_appointments, class_name: 'PxAppointment', inverse_of: :px_physician
|
|
20
|
+
has_many :px_patients, through: :px_appointments, class_name: 'PxPatient',
|
|
21
|
+
source: :px_patient
|
|
22
|
+
end)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
after(:all) do
|
|
26
|
+
%w[PxPhysician PxAppointment PxPatient].each { |c| Object.send(:remove_const, c) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
let(:physician) { PxPhysician.new }
|
|
30
|
+
|
|
31
|
+
describe 'mutation methods' do
|
|
32
|
+
%i[<< push concat build create create! delete delete_all
|
|
33
|
+
destroy_all clear nullify substitute].each do |method|
|
|
34
|
+
it "raises ReadonlyAssociation on ##{method}" do
|
|
35
|
+
expect { physician.px_patients.public_send(method) }.to \
|
|
36
|
+
raise_error(Mongoid::Errors::ReadonlyAssociation)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe 'read methods' do
|
|
42
|
+
it 'responds to #each' do
|
|
43
|
+
expect(physician.px_patients).to respond_to(:each)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'responds to #to_a' do
|
|
47
|
+
expect(physician.px_patients).to respond_to(:to_a)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'responds to #count' do
|
|
51
|
+
expect(physician.px_patients).to respond_to(:count)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '.eager_loader' do
|
|
56
|
+
it 'returns a HasManyThrough::Eager' do
|
|
57
|
+
assoc = PxPhysician.relations['px_patients']
|
|
58
|
+
result = described_class.eager_loader([ assoc ], [])
|
|
59
|
+
expect(result).to be_a(Mongoid::Association::Referenced::HasManyThrough::Eager)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe Mongoid::Association::Referenced::HasManyThrough do
|
|
6
|
+
before(:all) do
|
|
7
|
+
Object.const_set(:Appointment, Class.new do
|
|
8
|
+
include Mongoid::Document
|
|
9
|
+
|
|
10
|
+
field :physician_id, type: BSON::ObjectId
|
|
11
|
+
field :patient_id, type: BSON::ObjectId
|
|
12
|
+
belongs_to :physician
|
|
13
|
+
belongs_to :patient
|
|
14
|
+
end)
|
|
15
|
+
|
|
16
|
+
Object.const_set(:Patient, Class.new do
|
|
17
|
+
include Mongoid::Document
|
|
18
|
+
end)
|
|
19
|
+
|
|
20
|
+
Object.const_set(:Physician, Class.new do
|
|
21
|
+
include Mongoid::Document
|
|
22
|
+
|
|
23
|
+
has_many :appointments
|
|
24
|
+
has_many :patients, through: :appointments
|
|
25
|
+
end)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
after(:all) do
|
|
29
|
+
%w[Physician Appointment Patient].each { |c| Object.send(:remove_const, c) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
let(:assoc) { Physician.relations['patients'] }
|
|
33
|
+
|
|
34
|
+
describe '#through_association' do
|
|
35
|
+
it 'returns the appointments metadata' do
|
|
36
|
+
expect(assoc.through_association).to eq(Physician.relations['appointments'])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe '#source_association' do
|
|
41
|
+
it 'returns the patient belongs_to on Appointment' do
|
|
42
|
+
expect(assoc.source_association).to eq(Appointment.relations['patient'])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
context 'when the source association is has_and_belongs_to_many' do
|
|
46
|
+
before(:all) do
|
|
47
|
+
Object.const_set(:HabtmTag, Class.new { include Mongoid::Document })
|
|
48
|
+
Object.const_set(:HabtmPost, Class.new do
|
|
49
|
+
include Mongoid::Document
|
|
50
|
+
|
|
51
|
+
has_and_belongs_to_many :habtm_tags, class_name: 'HabtmTag'
|
|
52
|
+
end)
|
|
53
|
+
Object.const_set(:HabtmBlog, Class.new do
|
|
54
|
+
include Mongoid::Document
|
|
55
|
+
|
|
56
|
+
has_many :habtm_posts, class_name: 'HabtmPost', inverse_of: :habtm_blog
|
|
57
|
+
has_many :habtm_tags, through: :habtm_posts, class_name: 'HabtmTag'
|
|
58
|
+
end)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
after(:all) do
|
|
62
|
+
%w[HabtmBlog HabtmPost HabtmTag].each { |c| Object.send(:remove_const, c) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'raises InvalidRelationOption' do
|
|
66
|
+
blog_assoc = HabtmBlog.relations['habtm_tags']
|
|
67
|
+
expect { blog_assoc.source_association }.to \
|
|
68
|
+
raise_error(Mongoid::Errors::InvalidRelationOption)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe '#embedded?' do
|
|
74
|
+
it 'returns false' do
|
|
75
|
+
expect(assoc.embedded?).to be false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe 'VALID_OPTIONS' do
|
|
80
|
+
it 'rejects unknown options' do
|
|
81
|
+
expect do
|
|
82
|
+
Physician.has_many(:foos, through: :appointments, bad_opt: true)
|
|
83
|
+
end.to raise_error(Mongoid::Errors::InvalidRelationOption)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'accepts :order' do
|
|
87
|
+
expect do
|
|
88
|
+
Physician.has_many(:ordered_patients, through: :appointments,
|
|
89
|
+
class_name: 'Patient', source: :patient,
|
|
90
|
+
order: { _id: 1 })
|
|
91
|
+
end.not_to raise_error
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe '#criteria' do
|
|
96
|
+
it 'returns a scoped Criteria for the source class' do
|
|
97
|
+
physician = Physician.new
|
|
98
|
+
appointments_crit = instance_double(Mongoid::Criteria, pluck: [])
|
|
99
|
+
allow(assoc.through_association).to receive(:criteria).with(physician).and_return(appointments_crit)
|
|
100
|
+
crit = assoc.criteria(physician)
|
|
101
|
+
expect(crit).to be_a(Mongoid::Criteria)
|
|
102
|
+
expect(crit.klass).to eq(Patient)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
context 'integration - belongs_to source (join table)', :integration do
|
|
107
|
+
before(:all) do
|
|
108
|
+
Object.const_set(:JtPhysician, Class.new do
|
|
109
|
+
include Mongoid::Document
|
|
110
|
+
|
|
111
|
+
store_in collection: 'jt_physicians'
|
|
112
|
+
has_many :jt_appointments, class_name: 'JtAppointment', inverse_of: :jt_physician
|
|
113
|
+
has_many :jt_patients, through: :jt_appointments,
|
|
114
|
+
class_name: 'JtPatient', source: :jt_patient
|
|
115
|
+
end)
|
|
116
|
+
Object.const_set(:JtAppointment, Class.new do
|
|
117
|
+
include Mongoid::Document
|
|
118
|
+
|
|
119
|
+
store_in collection: 'jt_appointments'
|
|
120
|
+
belongs_to :jt_physician, class_name: 'JtPhysician'
|
|
121
|
+
belongs_to :jt_patient, class_name: 'JtPatient'
|
|
122
|
+
end)
|
|
123
|
+
Object.const_set(:JtPatient, Class.new do
|
|
124
|
+
include Mongoid::Document
|
|
125
|
+
|
|
126
|
+
store_in collection: 'jt_patients'
|
|
127
|
+
end)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
after(:all) do
|
|
131
|
+
%w[JtPhysician JtAppointment JtPatient].each { |c| Object.send(:remove_const, c) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
before { [ JtPhysician, JtAppointment, JtPatient ].each(&:delete_all) }
|
|
135
|
+
|
|
136
|
+
let!(:physician) { JtPhysician.create! }
|
|
137
|
+
let!(:patient1) { JtPatient.create! }
|
|
138
|
+
let!(:patient2) { JtPatient.create! }
|
|
139
|
+
let!(:_appt1) { JtAppointment.create!(jt_physician: physician, jt_patient: patient1) }
|
|
140
|
+
let!(:_appt2) { JtAppointment.create!(jt_physician: physician, jt_patient: patient2) }
|
|
141
|
+
|
|
142
|
+
it 'returns all patients through appointments' do
|
|
143
|
+
expect(physician.jt_patients.to_a).to contain_exactly(patient1, patient2)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'does not include patients of other physicians' do
|
|
147
|
+
other = JtPhysician.create!
|
|
148
|
+
other_patient = JtPatient.create!
|
|
149
|
+
JtAppointment.create!(jt_physician: other, jt_patient: other_patient)
|
|
150
|
+
expect(physician.jt_patients.to_a).not_to include(other_patient)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'returns an empty result when no appointments exist' do
|
|
154
|
+
lone = JtPhysician.create!
|
|
155
|
+
expect(lone.jt_patients.to_a).to eq([])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'reloads on demand' do
|
|
159
|
+
physician.jt_patients.to_a # prime cache
|
|
160
|
+
new_patient = JtPatient.create!
|
|
161
|
+
JtAppointment.create!(jt_physician: physician, jt_patient: new_patient)
|
|
162
|
+
expect(physician.jt_patients(true).to_a).to include(new_patient)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'raises ReadonlyAssociation on <<' do
|
|
166
|
+
expect { physician.jt_patients << JtPatient.new }.to \
|
|
167
|
+
raise_error(Mongoid::Errors::ReadonlyAssociation)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'exposes a singular_ids getter' do
|
|
171
|
+
ids = physician.jt_patient_ids
|
|
172
|
+
expect(ids).to contain_exactly(patient1.id, patient2.id)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
context 'integration - has_many source', :integration do
|
|
177
|
+
before(:all) do
|
|
178
|
+
Object.const_set(:HmAuthor, Class.new do
|
|
179
|
+
include Mongoid::Document
|
|
180
|
+
|
|
181
|
+
store_in collection: 'hm_authors'
|
|
182
|
+
has_many :hm_books, class_name: 'HmBook', inverse_of: :hm_author
|
|
183
|
+
has_many :hm_readers, through: :hm_books,
|
|
184
|
+
class_name: 'HmReader', source: :hm_readers
|
|
185
|
+
end)
|
|
186
|
+
Object.const_set(:HmBook, Class.new do
|
|
187
|
+
include Mongoid::Document
|
|
188
|
+
|
|
189
|
+
store_in collection: 'hm_books'
|
|
190
|
+
belongs_to :hm_author, class_name: 'HmAuthor'
|
|
191
|
+
has_many :hm_readers, class_name: 'HmReader', inverse_of: :hm_book
|
|
192
|
+
end)
|
|
193
|
+
Object.const_set(:HmReader, Class.new do
|
|
194
|
+
include Mongoid::Document
|
|
195
|
+
|
|
196
|
+
store_in collection: 'hm_readers'
|
|
197
|
+
belongs_to :hm_book, class_name: 'HmBook'
|
|
198
|
+
end)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
after(:all) do
|
|
202
|
+
%w[HmAuthor HmBook HmReader].each { |c| Object.send(:remove_const, c) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
before { [ HmAuthor, HmBook, HmReader ].each(&:delete_all) }
|
|
206
|
+
|
|
207
|
+
let!(:author) { HmAuthor.create! }
|
|
208
|
+
let!(:book1) { HmBook.create!(hm_author: author) }
|
|
209
|
+
let!(:book2) { HmBook.create!(hm_author: author) }
|
|
210
|
+
let!(:r1) { HmReader.create!(hm_book: book1) }
|
|
211
|
+
let!(:r2) { HmReader.create!(hm_book: book1) }
|
|
212
|
+
let!(:r3) { HmReader.create!(hm_book: book2) }
|
|
213
|
+
|
|
214
|
+
it 'returns readers across all books' do
|
|
215
|
+
expect(author.hm_readers.to_a).to contain_exactly(r1, r2, r3)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
context 'integration - :order option', :integration do
|
|
220
|
+
before(:all) do
|
|
221
|
+
Object.const_set(:OrdPhysician, Class.new do
|
|
222
|
+
include Mongoid::Document
|
|
223
|
+
|
|
224
|
+
store_in collection: 'ord_physicians'
|
|
225
|
+
has_many :ord_appointments, class_name: 'OrdAppointment', inverse_of: :ord_physician
|
|
226
|
+
has_many :ord_patients, through: :ord_appointments,
|
|
227
|
+
class_name: 'OrdPatient', source: :ord_patient,
|
|
228
|
+
order: { name: 1 }
|
|
229
|
+
end)
|
|
230
|
+
Object.const_set(:OrdAppointment, Class.new do
|
|
231
|
+
include Mongoid::Document
|
|
232
|
+
|
|
233
|
+
store_in collection: 'ord_appointments'
|
|
234
|
+
belongs_to :ord_physician, class_name: 'OrdPhysician'
|
|
235
|
+
belongs_to :ord_patient, class_name: 'OrdPatient'
|
|
236
|
+
end)
|
|
237
|
+
Object.const_set(:OrdPatient, Class.new do
|
|
238
|
+
include Mongoid::Document
|
|
239
|
+
|
|
240
|
+
store_in collection: 'ord_patients'
|
|
241
|
+
field :name, type: String
|
|
242
|
+
end)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
after(:all) do
|
|
246
|
+
%w[OrdPhysician OrdAppointment OrdPatient].each { |c| Object.send(:remove_const, c) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
before { [ OrdPhysician, OrdAppointment, OrdPatient ].each(&:delete_all) }
|
|
250
|
+
|
|
251
|
+
it 'returns patients in the declared order' do
|
|
252
|
+
physician = OrdPhysician.create!
|
|
253
|
+
charlie = OrdPatient.create!(name: 'Charlie')
|
|
254
|
+
alice = OrdPatient.create!(name: 'Alice')
|
|
255
|
+
bob = OrdPatient.create!(name: 'Bob')
|
|
256
|
+
[ charlie, alice, bob ].each do |p|
|
|
257
|
+
OrdAppointment.create!(ord_physician: physician, ord_patient: p)
|
|
258
|
+
end
|
|
259
|
+
expect(physician.ord_patients.to_a).to eq([ alice, bob, charlie ])
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
context 'integration - intermediates missing the plucked key', :integration do
|
|
264
|
+
# An intermediate document with no value for the plucked key contributes a
|
|
265
|
+
# nil to the $in array, and a nil in an $in matches every document whose
|
|
266
|
+
# queried field is null or absent. With a custom primary key those are real
|
|
267
|
+
# documents, potentially belonging to another owner.
|
|
268
|
+
|
|
269
|
+
context 'when the foreign key is on the intermediate' do
|
|
270
|
+
before(:all) do
|
|
271
|
+
Object.const_set(:NkClinic, Class.new do
|
|
272
|
+
include Mongoid::Document
|
|
273
|
+
|
|
274
|
+
store_in collection: 'nk_clinics'
|
|
275
|
+
has_many :nk_visits, class_name: 'NkVisit', inverse_of: :nk_clinic
|
|
276
|
+
has_many :nk_patients, through: :nk_visits,
|
|
277
|
+
class_name: 'NkPatient', source: :nk_patient
|
|
278
|
+
end)
|
|
279
|
+
Object.const_set(:NkVisit, Class.new do
|
|
280
|
+
include Mongoid::Document
|
|
281
|
+
|
|
282
|
+
store_in collection: 'nk_visits'
|
|
283
|
+
belongs_to :nk_clinic, class_name: 'NkClinic'
|
|
284
|
+
belongs_to :nk_patient, class_name: 'NkPatient',
|
|
285
|
+
primary_key: :ssn, optional: true
|
|
286
|
+
end)
|
|
287
|
+
Object.const_set(:NkPatient, Class.new do
|
|
288
|
+
include Mongoid::Document
|
|
289
|
+
|
|
290
|
+
store_in collection: 'nk_patients'
|
|
291
|
+
field :ssn, type: String
|
|
292
|
+
field :name, type: String
|
|
293
|
+
end)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
after(:all) do
|
|
297
|
+
%w[NkClinic NkVisit NkPatient].each { |c| Object.send(:remove_const, c) }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
before { [ NkClinic, NkVisit, NkPatient ].each(&:delete_all) }
|
|
301
|
+
|
|
302
|
+
let!(:clinic) { NkClinic.create! }
|
|
303
|
+
let!(:patient) { NkPatient.create!(ssn: 'SSN-1', name: 'own patient') }
|
|
304
|
+
# Not reachable from any clinic: it has no ssn at all.
|
|
305
|
+
let!(:keyless_patient) { NkPatient.create!(name: 'unrelated patient') }
|
|
306
|
+
let!(:_visit) { NkVisit.create!(nk_clinic: clinic, nk_patient: patient) }
|
|
307
|
+
# An intermediate that never got a patient assigned.
|
|
308
|
+
let!(:_keyless_visit) { NkVisit.create!(nk_clinic: clinic) }
|
|
309
|
+
|
|
310
|
+
it 'does not return documents whose primary key is absent' do
|
|
311
|
+
expect(clinic.nk_patients.to_a).to eq([ patient ])
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it 'does not put nil in the membership query' do
|
|
315
|
+
expect(clinic.nk_patients.criteria.selector['ssn']['$in']).not_to include(nil)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it 'agrees with the eager-loaded result' do
|
|
319
|
+
eager = NkClinic.includes(:nk_patients).find(clinic.id).nk_patients.to_a
|
|
320
|
+
expect(clinic.nk_patients.to_a).to eq(eager)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
context 'when no intermediate carries a usable key' do
|
|
324
|
+
before do
|
|
325
|
+
NkVisit.delete_all
|
|
326
|
+
NkVisit.create!(nk_clinic: clinic)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
it 'returns nothing rather than everything' do
|
|
330
|
+
expect(clinic.nk_patients.to_a).to eq([])
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
context 'when the foreign key is on the source' do
|
|
336
|
+
before(:all) do
|
|
337
|
+
Object.const_set(:NkLibrary, Class.new do
|
|
338
|
+
include Mongoid::Document
|
|
339
|
+
|
|
340
|
+
store_in collection: 'nk_libraries'
|
|
341
|
+
has_many :nk_books, class_name: 'NkBook', inverse_of: :nk_library
|
|
342
|
+
has_many :nk_readers, through: :nk_books,
|
|
343
|
+
class_name: 'NkReader', source: :nk_readers
|
|
344
|
+
end)
|
|
345
|
+
Object.const_set(:NkBook, Class.new do
|
|
346
|
+
include Mongoid::Document
|
|
347
|
+
|
|
348
|
+
store_in collection: 'nk_books'
|
|
349
|
+
field :isbn, type: String
|
|
350
|
+
belongs_to :nk_library, class_name: 'NkLibrary'
|
|
351
|
+
has_many :nk_readers, class_name: 'NkReader', inverse_of: :nk_book,
|
|
352
|
+
primary_key: :isbn
|
|
353
|
+
end)
|
|
354
|
+
Object.const_set(:NkReader, Class.new do
|
|
355
|
+
include Mongoid::Document
|
|
356
|
+
|
|
357
|
+
store_in collection: 'nk_readers'
|
|
358
|
+
field :name, type: String
|
|
359
|
+
belongs_to :nk_book, class_name: 'NkBook',
|
|
360
|
+
primary_key: :isbn, optional: true
|
|
361
|
+
end)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
after(:all) do
|
|
365
|
+
%w[NkLibrary NkBook NkReader].each { |c| Object.send(:remove_const, c) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
before { [ NkLibrary, NkBook, NkReader ].each(&:delete_all) }
|
|
369
|
+
|
|
370
|
+
let!(:library) { NkLibrary.create! }
|
|
371
|
+
let!(:book) { NkBook.create!(nk_library: library, isbn: 'ISBN-1') }
|
|
372
|
+
let!(:reader) { NkReader.create!(nk_book: book, name: 'own reader') }
|
|
373
|
+
# Orphaned, as dependent: :nullify would leave them.
|
|
374
|
+
let!(:orphan) { NkReader.create!(name: 'orphaned reader') }
|
|
375
|
+
# A book that never got an isbn assigned.
|
|
376
|
+
let!(:_keyless_book) { NkBook.create!(nk_library: library) }
|
|
377
|
+
|
|
378
|
+
it 'does not return documents whose foreign key is absent' do
|
|
379
|
+
expect(library.nk_readers.to_a).to eq([ reader ])
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
it 'does not put nil in the membership query' do
|
|
383
|
+
expect(library.nk_readers.criteria.selector['nk_book_id']['$in']).not_to include(nil)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it 'agrees with the eager-loaded result' do
|
|
387
|
+
eager = NkLibrary.includes(:nk_readers).find(library.id).nk_readers.to_a
|
|
388
|
+
expect(library.nk_readers.to_a).to eq(eager)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it 'does not leak documents belonging to another owner' do
|
|
392
|
+
attacker = NkLibrary.create!
|
|
393
|
+
NkBook.create!(nk_library: attacker)
|
|
394
|
+
expect(attacker.nk_readers.to_a).to eq([])
|
|
395
|
+
expect(attacker.nk_readers.to_a).not_to include(orphan)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|