mongoid 9.1.0 → 9.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +49 -0
  3. data/lib/mongoid/association/accessors.rb +9 -1
  4. data/lib/mongoid/association/depending.rb +14 -10
  5. data/lib/mongoid/association/eager.rb +9 -0
  6. data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +88 -0
  7. data/lib/mongoid/association/eager_load/embedded_distributor.rb +151 -0
  8. data/lib/mongoid/association/eager_load/inclusion.rb +173 -0
  9. data/lib/mongoid/association/eager_load/inclusion_tree.rb +96 -0
  10. data/lib/mongoid/association/eager_load/lookup_pipeline.rb +121 -0
  11. data/lib/mongoid/association/eager_load/polymorphic_preloader.rb +61 -0
  12. data/lib/mongoid/association/eager_load/polymorphic_targets.rb +151 -0
  13. data/lib/mongoid/association/eager_loadable.rb +57 -100
  14. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
  15. data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
  16. data/lib/mongoid/association/macros.rb +13 -1
  17. data/lib/mongoid/association/nested/many.rb +34 -5
  18. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  19. data/lib/mongoid/association/nested/one.rb +6 -1
  20. data/lib/mongoid/association/options.rb +17 -0
  21. data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
  22. data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
  23. data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
  24. data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
  25. data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
  26. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  27. data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
  28. data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
  29. data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
  30. data/lib/mongoid/association/referenced/has_one.rb +1 -0
  31. data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
  32. data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
  33. data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
  34. data/lib/mongoid/association/referenced.rb +2 -0
  35. data/lib/mongoid/association/relatable.rb +40 -0
  36. data/lib/mongoid/association.rb +7 -0
  37. data/lib/mongoid/clients/factory.rb +7 -13
  38. data/lib/mongoid/collection_configurable.rb +8 -0
  39. data/lib/mongoid/config/defaults.rb +0 -1
  40. data/lib/mongoid/config/encryption.rb +36 -19
  41. data/lib/mongoid/config.rb +56 -5
  42. data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
  43. data/lib/mongoid/contextual/memory.rb +18 -7
  44. data/lib/mongoid/criteria/includable.rb +35 -11
  45. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  46. data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
  47. data/lib/mongoid/deprecable.rb +45 -2
  48. data/lib/mongoid/encryptable.rb +46 -0
  49. data/lib/mongoid/errors/config_redactor.rb +43 -0
  50. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  51. data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
  52. data/lib/mongoid/errors/no_client_database.rb +1 -1
  53. data/lib/mongoid/errors/no_client_hosts.rb +1 -1
  54. data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
  55. data/lib/mongoid/errors/readonly_association.rb +29 -0
  56. data/lib/mongoid/errors.rb +4 -0
  57. data/lib/mongoid/extensions/vector.rb +64 -0
  58. data/lib/mongoid/extensions.rb +1 -0
  59. data/lib/mongoid/field_readable.rb +69 -0
  60. data/lib/mongoid/indexable.rb +38 -26
  61. data/lib/mongoid/matchable.rb +6 -1
  62. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
  63. data/lib/mongoid/matcher/regex.rb +11 -12
  64. data/lib/mongoid/matcher/regexp_budget.rb +372 -0
  65. data/lib/mongoid/matcher.rb +1 -0
  66. data/lib/mongoid/persistable/creatable.rb +1 -1
  67. data/lib/mongoid/persistable/updatable.rb +9 -11
  68. data/lib/mongoid/persistence_context.rb +35 -0
  69. data/lib/mongoid/search_indexable.rb +64 -23
  70. data/lib/mongoid/serializable.rb +12 -4
  71. data/lib/mongoid/tasks/database.rb +23 -9
  72. data/lib/mongoid/threaded.rb +36 -0
  73. data/lib/mongoid/timestamps/timeless.rb +84 -10
  74. data/lib/mongoid/traversable.rb +2 -2
  75. data/lib/mongoid/validatable/associated.rb +18 -2
  76. data/lib/mongoid/version.rb +1 -1
  77. data/spec/integration/app_spec.rb +7 -1
  78. data/spec/integration/associations/belongs_to_spec.rb +13 -0
  79. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  80. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  81. data/spec/integration/encryption_spec.rb +151 -0
  82. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  83. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  84. data/spec/integration/query_operator_guard_spec.rb +89 -0
  85. data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
  86. data/spec/mongoid/association/eager_spec.rb +2 -2
  87. data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
  88. data/spec/mongoid/association/fallback_spec.rb +425 -0
  89. data/spec/mongoid/association/macros_spec.rb +26 -0
  90. data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
  91. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  92. data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
  93. data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
  94. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  95. data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
  96. data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
  97. data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
  98. data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
  99. data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
  100. data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
  101. data/spec/mongoid/attributes/nested_spec.rb +249 -8
  102. data/spec/mongoid/config/defaults_spec.rb +33 -0
  103. data/spec/mongoid/config/encryption_spec.rb +228 -0
  104. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  105. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  106. data/spec/mongoid/criteria/includable_spec.rb +875 -0
  107. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
  108. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
  109. data/spec/mongoid/criteria_spec.rb +3 -0
  110. data/spec/mongoid/encryptable_spec.rb +61 -0
  111. data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
  112. data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
  113. data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
  114. data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
  115. data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
  116. data/spec/mongoid/extensions/vector_spec.rb +175 -0
  117. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  118. data/spec/mongoid/search_indexable_spec.rb +295 -9
  119. data/spec/mongoid/tasks/database_spec.rb +18 -0
  120. data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
  121. data/spec/mongoid/touchable_spec.rb +25 -0
  122. data/spec/mongoid/touchable_spec_models.rb +10 -0
  123. data/spec/mongoid/validatable/associated_spec.rb +19 -0
  124. data/spec/spec_helper.rb +5 -1
  125. data/spec/support/crypt/models.rb +157 -0
  126. metadata +52 -2
@@ -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