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
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'benchmark'
4
+ require 'spec_helper'
5
+
6
+ describe 'in-memory regexp time limit' do
7
+ # See the comment in spec/mongoid/matcher/regexp_budget_spec.rb: costly to
8
+ # match but bounded on every supported Ruby. A pattern relying on nested
9
+ # quantifiers would hang before Ruby 3.2, where the examples that have no
10
+ # interrupt available could not stop it.
11
+ let(:slow_pattern) { "(?:#{(1..300).map { |i| "a#{i}" }.join('|')})Z" }
12
+ let(:slow_street) { 'a' * 5_000 }
13
+
14
+ let(:person) do
15
+ # Address derives its _id from the street, so the streets have to differ or
16
+ # the embedded association collapses to a single document.
17
+ Person.new(addresses: Array.new(30) { |i| Address.new(street: "#{slow_street}#{i}") })
18
+ end
19
+
20
+ let(:one_address) { Person.new(addresses: [ Address.new(street: slow_street) ]) }
21
+
22
+ # What one match of the fixture costs on the machine running the suite. It
23
+ # varies by more than an order of magnitude between implementations - around
24
+ # 6ms on MRI, 110ms on JRuby - so the examples below calibrate against a
25
+ # measurement instead of hard-coding a threshold that only holds on one.
26
+ let(:single_match_cost) do
27
+ regexp = Regexp.new(slow_pattern)
28
+ 3.times { slow_street =~ regexp }
29
+ Benchmark.realtime { slow_street =~ regexp }
30
+ end
31
+
32
+ # Comfortably more than one match, comfortably less than twenty. The examples
33
+ # only need the ordering to hold, so the margin either side is wide.
34
+ let(:calibrated_limit) { single_match_cost * 5 }
35
+
36
+ context 'when the limit is disabled' do
37
+ config_override :in_memory_regexp_time_limit, nil
38
+
39
+ it 'evaluates the query without a bound' do
40
+ expect(person.addresses.where(street: { '$regex' => slow_pattern }).to_a).to eq([])
41
+ end
42
+ end
43
+
44
+ context 'when a limit is configured' do
45
+ config_override :in_memory_regexp_time_limit, nil
46
+
47
+ before { Mongoid::Config.in_memory_regexp_time_limit = calibrated_limit }
48
+
49
+ it 'leaves ordinary queries alone' do
50
+ person = Person.new(addresses: [ Address.new(street: 'Clarkson') ])
51
+ expect(person.addresses.where(street: { '$regex' => '\AClark' }).to_a.size).to eq(1)
52
+ end
53
+
54
+ it 'does not raise for a single document' do
55
+ # The counterpart to the example below, and what makes it meaningful: one
56
+ # document stays under the limit, so a scan that raises can only have got
57
+ # there by accumulating cost across documents.
58
+ expect do
59
+ one_address.addresses.where(street: { '$regex' => slow_pattern }).to_a
60
+ end.not_to raise_error
61
+ end
62
+
63
+ it 'raises for a scan over many documents under that same limit' do
64
+ expect do
65
+ person.addresses.where(street: { '$regex' => slow_pattern }).to_a
66
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
67
+ end
68
+
69
+ it 'raises when $in multiplies the cost within a single document' do
70
+ conditions = Array.new(20) { BSON::Regexp::Raw.new(slow_pattern) }
71
+
72
+ expect do
73
+ one_address.addresses.where(street: { '$in' => conditions }).to_a
74
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
75
+ end
76
+
77
+ it 'raises through the public _matches? API' do
78
+ # 20 conditions rather than one, because a single match is deliberately
79
+ # kept well under the limit by the calibration above.
80
+ conditions = Array.new(20) { BSON::Regexp::Raw.new(slow_pattern) }
81
+
82
+ expect do
83
+ person.addresses.first._matches?('street' => { '$in' => conditions })
84
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
85
+ end
86
+ end
87
+
88
+ context 'when the pattern is pathological' do
89
+ config_override :in_memory_regexp_time_limit, 0.2
90
+
91
+ # The ticket's Case 1, verbatim. It backtracks exponentially and is the
92
+ # reason the guard exists, so every entry point that evaluates a selector
93
+ # in memory has to bound it. The subject is kept short enough that the
94
+ # match still finishes on its own in a few seconds, so an entry point that
95
+ # fails to bound it shows up as a failing example rather than a hung run.
96
+ let(:evil_pattern) { '^(a+)+\1?$' }
97
+ let(:evil_subject) { "#{'a' * 28}X" }
98
+
99
+ it 'bounds a scan over an embedded association' do
100
+ person = Person.new(addresses: [ Address.new(street: evil_subject) ])
101
+
102
+ expect do
103
+ person.addresses.where(street: { '$regex' => evil_pattern }).to_a
104
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
105
+ end
106
+
107
+ it 'bounds the public _matches? API' do
108
+ expect do
109
+ Address.new(street: evil_subject)._matches?('street' => { '$regex' => evil_pattern })
110
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
111
+ end
112
+
113
+ it 'bounds removal from a referenced association' do
114
+ owner = Person.create!
115
+ owner.posts.create!(title: evil_subject)
116
+
117
+ expect do
118
+ owner.posts.delete_all(title: { '$regex' => evil_pattern })
119
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
120
+ end
121
+
122
+ it 'deletes nothing when removal is bounded' do
123
+ # The scan runs before the delete, so the error means what a caller would
124
+ # take it to mean: the documents are still there.
125
+ owner = Person.create!
126
+ owner.posts.create!(title: evil_subject)
127
+
128
+ expect do
129
+ owner.posts.delete_all(title: { '$regex' => evil_pattern })
130
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
131
+
132
+ expect(Post.count).to eq(1)
133
+ expect(owner.reload.posts.size).to eq(1)
134
+ end
135
+ end
136
+
137
+ context 'when the association being removed from has not been loaded' do
138
+ config_override :in_memory_regexp_time_limit, 0.2
139
+
140
+ let!(:owner) do
141
+ person = Person.create!
142
+ person.posts.create!(title: 'Testing')
143
+ Person.find(person._id)
144
+ end
145
+
146
+ # The query that loads the association, which removal has to run before it
147
+ # has anything to match against.
148
+ let(:load_query) { owner.posts._target._unloaded }
149
+
150
+ # A stall standing in for a slow network. It is longer than the limit, and
151
+ # none of it is time spent on regular expressions.
152
+ before do
153
+ allow(load_query).to receive(:each).and_wrap_original do |original, *args, &block|
154
+ sleep(0.3)
155
+ original.call(*args, &block)
156
+ end
157
+ end
158
+
159
+ it 'does not load it, so no query can be counted against the limit' do
160
+ # On the Timeout path a deadline covering the fetch failed a slow query
161
+ # with an error about regular expressions, and the asynchronous exception
162
+ # could land inside the driver's socket read, leaving the connection with
163
+ # unconsumed bytes. Nothing is fetched now: the scan sees only what the
164
+ # association already holds.
165
+ stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
166
+
167
+ expect do
168
+ owner.posts.delete_all(title: { '$regex' => '\Azzz' })
169
+ end.not_to raise_error
170
+
171
+ expect(load_query).not_to have_received(:each)
172
+ end
173
+
174
+ it 'runs no pattern against documents it never loaded' do
175
+ # The counterpart to the referenced-association examples below, where the
176
+ # documents are in memory and the pattern is run against every one of
177
+ # them. Here there is nothing to run it against, and the server applies
178
+ # the same selector for the delete, so nothing is missed by not looking.
179
+ expect(Mongoid::Matcher::RegexpBudget).not_to receive(:match?)
180
+
181
+ owner.posts.delete_all(title: { '$regex' => '\Azzz' })
182
+ end
183
+
184
+ it 'still removes the documents that match' do
185
+ expect(owner.posts.delete_all(title: { '$regex' => '\ATest' })).to eq(1)
186
+ expect(Post.count).to eq(0)
187
+ expect(owner.posts.size).to eq(0)
188
+ end
189
+ end
190
+
191
+ context 'when removing documents from a referenced association' do
192
+ config_override :in_memory_regexp_time_limit, nil
193
+
194
+ let!(:owner) { Person.create! }
195
+
196
+ before do
197
+ 30.times { owner.posts.create!(title: slow_street) }
198
+ Mongoid::Config.in_memory_regexp_time_limit = calibrated_limit
199
+ end
200
+
201
+ it 'raises rather than scanning every loaded document' do
202
+ expect do
203
+ owner.posts.delete_all(title: { '$regex' => slow_pattern })
204
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
205
+ end
206
+
207
+ it 'leaves the documents in place' do
208
+ expect do
209
+ owner.posts.delete_all(title: { '$regex' => slow_pattern })
210
+ end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
211
+
212
+ expect(Post.count).to eq(30)
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'query operator injection guard' do
6
+ let(:js) { 'this.name == "admin"' }
7
+
8
+ let(:nested_function) do
9
+ { '$expr' => { '$function' => { 'body' => 'function() { return true; }', 'args' => [], 'lang' => 'js' } } }
10
+ end
11
+
12
+ let(:band) { Band.create!(name: 'Depeche Mode') }
13
+
14
+ context 'with default configuration' do
15
+ it 'rejects unsafe operators without any opt-out' do
16
+ expect(Mongoid.allow_unsafe_query_operators).to be false
17
+ end
18
+
19
+ context 'when querying with where' do
20
+ it 'rejects a top-level $where' do
21
+ expect { Band.where('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
22
+ end
23
+
24
+ it 'rejects a $function nested in $expr' do
25
+ expect { Band.where(nested_function).first }.to raise_error(Mongoid::Errors::InvalidQuery)
26
+ end
27
+
28
+ it 'permits an ordinary query' do
29
+ expect(Band.where(name: band.name).first).to eq(band)
30
+ end
31
+ end
32
+
33
+ context 'when querying with find_by' do
34
+ it 'rejects a top-level $where' do
35
+ expect { Band.find_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
36
+ end
37
+
38
+ it 'rejects a $function nested in $expr' do
39
+ expect { Band.find_by(nested_function) }.to raise_error(Mongoid::Errors::InvalidQuery)
40
+ end
41
+ end
42
+
43
+ context 'when querying with find_or_create_by' do
44
+ it 'rejects a top-level $where' do
45
+ expect { Band.find_or_create_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
46
+ end
47
+ end
48
+
49
+ context 'when querying with find_or_initialize_by' do
50
+ it 'rejects a top-level $where' do
51
+ expect { Band.find_or_initialize_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
52
+ end
53
+ end
54
+
55
+ context 'when querying an association with find_or_create_by' do
56
+ it 'rejects a top-level $where' do
57
+ expect do
58
+ band.records.find_or_create_by('$where' => js)
59
+ end.to raise_error(Mongoid::Errors::InvalidQuery)
60
+ end
61
+ end
62
+
63
+ context 'when querying with a logical operator' do
64
+ it 'rejects a $where smuggled through or' do
65
+ expect { Band.or('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
66
+ end
67
+
68
+ it 'rejects a $where smuggled through any_of' do
69
+ expect { Band.any_of('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
70
+ end
71
+ end
72
+ end
73
+
74
+ context 'when allow_unsafe_query_operators is true' do
75
+ config_override :allow_unsafe_query_operators, true
76
+
77
+ it 'permits a top-level $where' do
78
+ expect(Band.where('$where' => "this.name == '#{band.name}'").first).to eq(band)
79
+ end
80
+
81
+ it 'permits a $function nested in $expr' do
82
+ expect { Band.where(nested_function).first }.not_to raise_error
83
+ end
84
+
85
+ it 'permits a $where smuggled through or' do
86
+ expect { Band.or('$where' => js).first }.not_to raise_error
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Mongoid::Association::EagerLoadable do
6
+ context 'with through associations' do
7
+ before(:all) do
8
+ Object.const_set(:ELPhysician, Class.new do
9
+ include Mongoid::Document
10
+
11
+ store_in collection: 'el_physicians'
12
+ has_many :el_appointments, class_name: 'ELAppointment', inverse_of: :el_physician
13
+ has_many :el_patients, through: :el_appointments,
14
+ class_name: 'ELPatient', source: :el_patient
15
+ end)
16
+ Object.const_set(:ELAppointment, Class.new do
17
+ include Mongoid::Document
18
+
19
+ store_in collection: 'el_appointments'
20
+ belongs_to :el_physician, class_name: 'ELPhysician'
21
+ belongs_to :el_patient, class_name: 'ELPatient'
22
+ end)
23
+ Object.const_set(:ELPatient, Class.new do
24
+ include Mongoid::Document
25
+
26
+ store_in collection: 'el_patients'
27
+ end)
28
+ end
29
+
30
+ after(:all) do
31
+ %w[ELPhysician ELAppointment ELPatient].each { |c| Object.send(:remove_const, c) }
32
+ end
33
+
34
+ before { [ ELPhysician, ELAppointment, ELPatient ].each(&:delete_all) }
35
+
36
+ it 'logs a warning and falls back to preload when eager_load is used with a through association' do
37
+ physician = ELPhysician.create!
38
+ patient = ELPatient.create!
39
+ ELAppointment.create!(el_physician: physician, el_patient: patient)
40
+
41
+ expect(Mongoid.logger).to receive(:warn).with(a_string_including('through'))
42
+
43
+ docs = ELPhysician.eager_load(:el_patients).to_a
44
+ expect(docs.first.el_patients.to_a).to include(patient)
45
+ end
46
+ end
47
+ end
@@ -679,9 +679,9 @@ describe Mongoid::Association::EagerLoadable do
679
679
  expect(docs.first.posts).to eq([ post ])
680
680
  end
681
681
 
682
- it 'mentions the offending association and client in the warning' do
682
+ it 'mentions the offending association, client, and database in the warning' do
683
683
  expect(Mongoid.logger).to receive(:warn).with(
684
- /posts \(other_cluster\)/
684
+ /posts \(client: other_cluster, database: /
685
685
  )
686
686
  context.eager_load_with_lookup
687
687
  end
@@ -4524,6 +4524,39 @@ describe Mongoid::Association::Embedded::EmbedsMany::Proxy do
4524
4524
  expect(reloaded_band).not_to have_key(:labels)
4525
4525
  end
4526
4526
  end
4527
+
4528
+ context 'when set via raw attribute write in a before_save callback' do
4529
+ after { Band.reset_callbacks(:save) }
4530
+
4531
+ let(:reloaded_band) { Band.collection.find(_id: band._id).first }
4532
+
4533
+ context 'on a new record' do
4534
+ before { Band.before_save { self[:labels] ||= [] } }
4535
+
4536
+ let(:band) { Band.create! }
4537
+
4538
+ it 'persists the empty list' do
4539
+ expect(reloaded_band).to have_key(:labels)
4540
+ expect(reloaded_band[:labels]).to eq []
4541
+ end
4542
+ end
4543
+
4544
+ context 'on an existing record' do
4545
+ # Reload the band from MongoDB so the labels proxy has not been
4546
+ # initialized, which is the scenario that triggers the bug.
4547
+ let(:band) { Band.find(Band.create!._id) }
4548
+
4549
+ before do
4550
+ Band.before_save { self[:labels] ||= [] }
4551
+ band.save!
4552
+ end
4553
+
4554
+ it 'persists the empty list' do
4555
+ expect(reloaded_band).to have_key(:labels)
4556
+ expect(reloaded_band[:labels]).to eq []
4557
+ end
4558
+ end
4559
+ end
4527
4560
  end
4528
4561
 
4529
4562
  context 'when using assign_attributes with an already populated array' do