mongoid 9.1.0 → 9.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +69 -0
- data/lib/mongoid/association/accessors.rb +27 -5
- data/lib/mongoid/association/depending.rb +14 -10
- data/lib/mongoid/association/eager.rb +9 -0
- data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +88 -0
- data/lib/mongoid/association/eager_load/embedded_distributor.rb +151 -0
- data/lib/mongoid/association/eager_load/inclusion.rb +173 -0
- data/lib/mongoid/association/eager_load/inclusion_tree.rb +96 -0
- data/lib/mongoid/association/eager_load/lookup_pipeline.rb +121 -0
- data/lib/mongoid/association/eager_load/polymorphic_preloader.rb +61 -0
- data/lib/mongoid/association/eager_load/polymorphic_targets.rb +151 -0
- data/lib/mongoid/association/eager_loadable.rb +65 -101
- data/lib/mongoid/association/embedded/batchable.rb +17 -1
- data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
- data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
- data/lib/mongoid/association/macros.rb +13 -1
- data/lib/mongoid/association/nested/many.rb +34 -5
- data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
- data/lib/mongoid/association/nested/one.rb +6 -1
- data/lib/mongoid/association/options.rb +17 -0
- data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
- data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
- data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
- data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
- data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
- data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
- data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
- data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
- data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
- data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
- data/lib/mongoid/association/referenced/has_one.rb +1 -0
- data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
- data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
- data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
- data/lib/mongoid/association/referenced.rb +2 -0
- data/lib/mongoid/association/relatable.rb +58 -0
- data/lib/mongoid/association.rb +15 -0
- data/lib/mongoid/atomic/modifiers.rb +100 -16
- data/lib/mongoid/clients/factory.rb +9 -13
- data/lib/mongoid/collection_configurable.rb +8 -0
- data/lib/mongoid/config/defaults.rb +0 -1
- data/lib/mongoid/config/encryption.rb +170 -22
- data/lib/mongoid/config.rb +56 -5
- data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
- data/lib/mongoid/contextual/memory.rb +18 -7
- data/lib/mongoid/criteria/includable.rb +35 -11
- data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
- data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
- data/lib/mongoid/deprecable.rb +45 -2
- data/lib/mongoid/document.rb +3 -0
- data/lib/mongoid/encryptable.rb +73 -0
- data/lib/mongoid/errors/config_redactor.rb +43 -0
- data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
- data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
- data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
- data/lib/mongoid/errors/no_client_database.rb +1 -1
- data/lib/mongoid/errors/no_client_hosts.rb +1 -1
- data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
- data/lib/mongoid/errors/readonly_association.rb +29 -0
- data/lib/mongoid/errors.rb +5 -0
- data/lib/mongoid/extensions/vector.rb +64 -0
- data/lib/mongoid/extensions.rb +1 -0
- data/lib/mongoid/field_readable.rb +69 -0
- data/lib/mongoid/fields.rb +7 -0
- data/lib/mongoid/indexable.rb +38 -26
- data/lib/mongoid/matchable.rb +6 -1
- data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
- data/lib/mongoid/matcher/regex.rb +11 -12
- data/lib/mongoid/matcher/regexp_budget.rb +372 -0
- data/lib/mongoid/matcher.rb +1 -0
- data/lib/mongoid/persistable/creatable.rb +120 -14
- data/lib/mongoid/persistable/updatable.rb +9 -11
- data/lib/mongoid/persistable.rb +15 -15
- data/lib/mongoid/persistence_context.rb +77 -0
- data/lib/mongoid/search_indexable.rb +69 -24
- data/lib/mongoid/serializable.rb +12 -4
- data/lib/mongoid/tasks/database.rb +33 -16
- data/lib/mongoid/threaded.rb +36 -0
- data/lib/mongoid/timestamps/timeless.rb +84 -10
- data/lib/mongoid/touchable.rb +28 -1
- data/lib/mongoid/traversable.rb +2 -2
- data/lib/mongoid/validatable/associated.rb +18 -2
- data/lib/mongoid/version.rb +1 -1
- data/lib/mongoid/warnings.rb +4 -0
- data/spec/integration/app_spec.rb +7 -1
- data/spec/integration/associations/belongs_to_spec.rb +13 -0
- data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +31 -2
- data/spec/integration/atomic/modifiers_spec.rb +283 -0
- data/spec/integration/dots_and_dollars_spec.rb +12 -2
- data/spec/integration/encryption_spec.rb +300 -0
- data/spec/integration/matcher_operator_data/regex.yml +21 -0
- data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
- data/spec/integration/query_operator_guard_spec.rb +89 -0
- data/spec/mongoid/association/accessors_spec.rb +35 -0
- data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
- data/spec/mongoid/association/eager_spec.rb +2 -2
- data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
- data/spec/mongoid/association/fallback_spec.rb +425 -0
- data/spec/mongoid/association/macros_spec.rb +26 -0
- data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
- data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
- data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
- data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
- data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
- data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
- data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
- data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
- data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
- data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
- data/spec/mongoid/attributes/nested_spec.rb +278 -8
- data/spec/mongoid/clients/factory_spec.rb +36 -0
- data/spec/mongoid/config/defaults_spec.rb +33 -0
- data/spec/mongoid/config/encryption_spec.rb +451 -0
- data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
- data/spec/mongoid/contextual/memory_spec.rb +177 -0
- data/spec/mongoid/criteria/includable_spec.rb +1221 -0
- data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
- data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
- data/spec/mongoid/criteria_spec.rb +3 -0
- data/spec/mongoid/encryptable_spec.rb +115 -0
- data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
- data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
- data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
- data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
- data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
- data/spec/mongoid/extensions/vector_spec.rb +175 -0
- data/spec/mongoid/fields_spec.rb +8 -0
- data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
- data/spec/mongoid/search_indexable_spec.rb +321 -9
- data/spec/mongoid/tasks/database_spec.rb +31 -0
- data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
- data/spec/mongoid/touchable_spec.rb +242 -0
- data/spec/mongoid/touchable_spec_models.rb +47 -0
- data/spec/mongoid/validatable/associated_spec.rb +19 -0
- data/spec/spec_helper.rb +5 -1
- data/spec/support/crypt/models.rb +253 -0
- data/spec/support/models/modifier_models.rb +65 -0
- metadata +55 -2
|
@@ -134,4 +134,287 @@ describe Mongoid::Atomic::Modifiers do
|
|
|
134
134
|
expect(library.books[2].foreword).not_to be_nil
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
|
+
|
|
138
|
+
context 'when adding to a set on an existing embedded document and pushing a new one' do
|
|
139
|
+
let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
|
|
140
|
+
let(:roster) do
|
|
141
|
+
ModifierModels::Roster.create!(players: [
|
|
142
|
+
ModifierModels::Player.new(badges: [ badges[0] ]),
|
|
143
|
+
ModifierModels::Player.new(badges: [ badges[0] ])
|
|
144
|
+
])
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
before do
|
|
148
|
+
roster.reload
|
|
149
|
+
roster.players.build
|
|
150
|
+
player = roster.players[0]
|
|
151
|
+
player.badge_ids = player.badge_ids + [ badges[1].id ]
|
|
152
|
+
roster.save!
|
|
153
|
+
roster.reload
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'applies both operations' do
|
|
157
|
+
expect(roster.players.size).to eq(3)
|
|
158
|
+
expect(roster.players[0].badge_ids).to eq([ badges[0].id, badges[1].id ])
|
|
159
|
+
expect(roster.players[1].badge_ids).to eq([ badges[0].id ])
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
context 'when pulling all from an existing embedded document and pushing a new one' do
|
|
164
|
+
let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
|
|
165
|
+
let(:roster) do
|
|
166
|
+
ModifierModels::Roster.create!(players: [
|
|
167
|
+
ModifierModels::Player.new(badges: badges),
|
|
168
|
+
ModifierModels::Player.new
|
|
169
|
+
])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
before do
|
|
173
|
+
roster.reload
|
|
174
|
+
roster.players.build
|
|
175
|
+
player = roster.players[0]
|
|
176
|
+
player.badge_ids = player.badge_ids.drop(1)
|
|
177
|
+
roster.save!
|
|
178
|
+
roster.reload
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'applies both operations' do
|
|
182
|
+
expect(roster.players.size).to eq(3)
|
|
183
|
+
expect(roster.players[0].badge_ids).to eq([ badges[1].id ])
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
context 'when unsetting a field on an existing embedded document and pushing a new one' do
|
|
188
|
+
let(:roster) do
|
|
189
|
+
ModifierModels::Roster.create!(players: [
|
|
190
|
+
ModifierModels::Player.new(medal: ModifierModels::Medal.new),
|
|
191
|
+
ModifierModels::Player.new
|
|
192
|
+
])
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
before do
|
|
196
|
+
roster.reload
|
|
197
|
+
roster.players.build
|
|
198
|
+
roster.players[0].assign_attributes(medal: nil)
|
|
199
|
+
roster.save!
|
|
200
|
+
roster.reload
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'applies both operations' do
|
|
204
|
+
expect(roster.players.size).to eq(3)
|
|
205
|
+
expect(roster.players[0].medal).to be_nil
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
context 'when unsetting an embedded field and pulling a sibling' do
|
|
210
|
+
let(:roster) do
|
|
211
|
+
ModifierModels::Roster.create!(players: [
|
|
212
|
+
ModifierModels::Player.new(medal: ModifierModels::Medal.new),
|
|
213
|
+
ModifierModels::Player.new(medal: ModifierModels::Medal.new)
|
|
214
|
+
])
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
before do
|
|
218
|
+
roster.reload
|
|
219
|
+
roster.add_atomic_pull(roster.players[0])
|
|
220
|
+
roster.players[1].assign_attributes(medal: nil)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it 'fails without changing stored data' do
|
|
224
|
+
# $pull shifts array elements, so these operations cannot be safely
|
|
225
|
+
# applied in separate writes; MongoDB rejects them in a single write.
|
|
226
|
+
expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
|
|
227
|
+
|
|
228
|
+
roster.reload
|
|
229
|
+
expect(roster.players.size).to eq(2)
|
|
230
|
+
expect(roster.players[0].medal).not_to be_nil
|
|
231
|
+
expect(roster.players[1].medal).not_to be_nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
context 'when changing foreign keys of a remaining nested document after pulling its sibling' do
|
|
236
|
+
let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
|
|
237
|
+
let(:roster) do
|
|
238
|
+
ModifierModels::Roster.create!(players: [
|
|
239
|
+
ModifierModels::Player.new(badges: [ badges[0] ]),
|
|
240
|
+
ModifierModels::Player.new(
|
|
241
|
+
badges: [ badges[0] ],
|
|
242
|
+
awards: [
|
|
243
|
+
ModifierModels::Award.new(name: 'first', badges: [ badges[0] ]),
|
|
244
|
+
ModifierModels::Award.new(name: 'second', badges: [ badges[0] ])
|
|
245
|
+
]
|
|
246
|
+
)
|
|
247
|
+
])
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
before do
|
|
251
|
+
roster.reload
|
|
252
|
+
first_player, second_player = roster.players.to_a
|
|
253
|
+
first_award, second_award = second_player.awards.to_a
|
|
254
|
+
# The change on the first player comes before the nested $pull on the
|
|
255
|
+
# same root. The change on the second award uses its position after
|
|
256
|
+
# the $pull.
|
|
257
|
+
change.call(first_player)
|
|
258
|
+
roster.players_attributes = [
|
|
259
|
+
{ id: second_player.id, awards_attributes: [ { id: first_award.id, _destroy: true } ] }
|
|
260
|
+
]
|
|
261
|
+
change.call(second_award)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
context 'when adding to the foreign keys' do
|
|
265
|
+
let(:change) do
|
|
266
|
+
->(doc) { doc.badge_ids = doc.badge_ids + [ badges[1].id ] }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it 'fails without changing stored data' do
|
|
270
|
+
# The $addToSet for the second award would hit the first award if it
|
|
271
|
+
# were applied before the $pull, so it stays in the same update.
|
|
272
|
+
expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
|
|
273
|
+
|
|
274
|
+
roster.reload
|
|
275
|
+
expect(roster.players[0].badge_ids).to eq([ badges[0].id ])
|
|
276
|
+
expect(roster.players[1].awards.map(&:name)).to eq(%w[first second])
|
|
277
|
+
expect(roster.players[1].awards.map(&:badge_ids)).to eq([ [ badges[0].id ], [ badges[0].id ] ])
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
context 'when removing from the foreign keys' do
|
|
282
|
+
let(:change) do
|
|
283
|
+
->(doc) { doc.badge_ids = [] }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'applies the change to the remaining nested document' do
|
|
287
|
+
roster.save!
|
|
288
|
+
|
|
289
|
+
roster.reload
|
|
290
|
+
expect(roster.players[0].badge_ids).to eq([])
|
|
291
|
+
expect(roster.players[1].awards.map(&:name)).to eq([ 'second' ])
|
|
292
|
+
expect(roster.players[1].awards[0].badge_ids).to eq([])
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
context 'when renaming a remaining nested document after pulling a preceding sibling and its sibling' do
|
|
298
|
+
let(:roster) do
|
|
299
|
+
ModifierModels::Roster.create!(players: [
|
|
300
|
+
ModifierModels::Player.new(nickname: 'a'),
|
|
301
|
+
ModifierModels::Player.new(nickname: 'b'),
|
|
302
|
+
ModifierModels::Player.new(
|
|
303
|
+
nickname: 'c',
|
|
304
|
+
awards: [
|
|
305
|
+
ModifierModels::Award.new(name: 'first'),
|
|
306
|
+
ModifierModels::Award.new(name: 'second')
|
|
307
|
+
]
|
|
308
|
+
)
|
|
309
|
+
])
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
before do
|
|
313
|
+
roster.reload
|
|
314
|
+
first_player, second_player, third_player = roster.players.to_a
|
|
315
|
+
first_award, second_award = third_player.awards.to_a
|
|
316
|
+
roster.players_attributes = [
|
|
317
|
+
{ id: first_player.id, _destroy: true },
|
|
318
|
+
{ id: second_player.id, nickname: 'B' },
|
|
319
|
+
{ id: third_player.id, awards_attributes: [
|
|
320
|
+
{ id: first_award.id, _destroy: true },
|
|
321
|
+
{ id: second_award.id, name: 'SECOND' }
|
|
322
|
+
] }
|
|
323
|
+
]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it 'fails without changing stored data' do
|
|
327
|
+
# Both $pulls stay in the main update. The rename of the second award
|
|
328
|
+
# would hit the first award if it were applied before the nested $pull.
|
|
329
|
+
expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
|
|
330
|
+
|
|
331
|
+
roster.reload
|
|
332
|
+
expect(roster.players.map(&:nickname)).to eq(%w[a b c])
|
|
333
|
+
expect(roster.players[2].awards.map(&:name)).to eq(%w[first second])
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
context 'when adding a nested document and pulling a sibling' do
|
|
338
|
+
let(:roster) do
|
|
339
|
+
ModifierModels::Roster.create!(players: [
|
|
340
|
+
ModifierModels::Player.new(nickname: 'a'),
|
|
341
|
+
ModifierModels::Player.new(
|
|
342
|
+
nickname: 'b',
|
|
343
|
+
awards: [ ModifierModels::Award.new(name: 'first') ]
|
|
344
|
+
)
|
|
345
|
+
])
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
before do
|
|
349
|
+
roster.reload
|
|
350
|
+
first_player, second_player = roster.players.to_a
|
|
351
|
+
second_player.awards.build(name: 'second')
|
|
352
|
+
roster.players_attributes = [ { id: first_player.id, _destroy: true } ]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it 'applies both operations' do
|
|
356
|
+
roster.save!
|
|
357
|
+
|
|
358
|
+
roster.reload
|
|
359
|
+
expect(roster.players.map(&:nickname)).to eq([ 'b' ])
|
|
360
|
+
expect(roster.players[0].awards.map(&:name)).to eq(%w[first second])
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
context 'when assigning embeds_many attributes on a new embedded document' do
|
|
365
|
+
let(:cabinet) do
|
|
366
|
+
ModifierModels::Cabinet.create!
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
before do
|
|
370
|
+
cabinet.reload
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
context 'when the nested documents are assigned on the built document' do
|
|
374
|
+
before do
|
|
375
|
+
cabinet.build_drawer(matcher: 'all')
|
|
376
|
+
cabinet.drawer.assign_attributes(items: [ { name: 'a' } ])
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
it 'saves without conflicting $set paths' do
|
|
380
|
+
expect { cabinet.save! }.not_to raise_error
|
|
381
|
+
|
|
382
|
+
cabinet.reload
|
|
383
|
+
expect(cabinet.drawer.matcher).to eq('all')
|
|
384
|
+
expect(cabinet.drawer.items.map(&:name)).to eq([ 'a' ])
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
context 'when the nested documents are assigned via the parent' do
|
|
389
|
+
before do
|
|
390
|
+
cabinet.assign_attributes(drawer: { matcher: 'all', items: [ { name: 'a' } ] })
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
it 'saves without conflicting $set paths' do
|
|
394
|
+
expect { cabinet.save! }.not_to raise_error
|
|
395
|
+
|
|
396
|
+
cabinet.reload
|
|
397
|
+
expect(cabinet.drawer.matcher).to eq('all')
|
|
398
|
+
expect(cabinet.drawer.items.map(&:name)).to eq([ 'a' ])
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
context 'when an assigned child has its own embedded collection' do
|
|
403
|
+
let(:item) { ModifierModels::Item.new(name: 'a') }
|
|
404
|
+
|
|
405
|
+
before do
|
|
406
|
+
item.assign_attributes(widgets: [ { label: 'w' } ])
|
|
407
|
+
cabinet.build_drawer(matcher: 'all')
|
|
408
|
+
cabinet.drawer.assign_attributes(items: [ item ])
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it 'does not write the child collection to the root document' do
|
|
412
|
+
expect { cabinet.save! }.not_to raise_error
|
|
413
|
+
|
|
414
|
+
stored = ModifierModels::Cabinet.collection.find(_id: cabinet.id).first
|
|
415
|
+
expect(stored.keys).to contain_exactly('_id', 'drawer')
|
|
416
|
+
expect(stored['drawer']['items'].first['widgets'].first['label']).to eq('w')
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
137
420
|
end
|
|
@@ -253,10 +253,20 @@ describe 'Dots and Dollars' do
|
|
|
253
253
|
DADMUser.where('$_amount': 0).first
|
|
254
254
|
end
|
|
255
255
|
|
|
256
|
-
it '
|
|
256
|
+
it 'raises an error' do
|
|
257
257
|
expect do
|
|
258
258
|
queried
|
|
259
|
-
end.to raise_error(
|
|
259
|
+
end.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
context 'when allow_unsafe_query_operators is true' do
|
|
263
|
+
config_override :allow_unsafe_query_operators, true
|
|
264
|
+
|
|
265
|
+
it 'raises an error from the server' do
|
|
266
|
+
expect do
|
|
267
|
+
queried
|
|
268
|
+
end.to raise_error(Mongo::Error::OperationFailure)
|
|
269
|
+
end
|
|
260
270
|
end
|
|
261
271
|
end
|
|
262
272
|
end
|
|
@@ -33,19 +33,24 @@ describe 'Encryption' do
|
|
|
33
33
|
around do |example|
|
|
34
34
|
Mongoid.default_client[Crypt::Patient.collection_name].drop
|
|
35
35
|
Mongoid.default_client[Crypt::Car.collection_name].drop
|
|
36
|
+
Mongoid.default_client[Crypt::Folder.collection_name].drop
|
|
36
37
|
existing_key_id = Crypt::Patient.encrypt_metadata[:key_id]
|
|
37
38
|
Crypt::Patient.set_key_id(data_key_id)
|
|
38
39
|
Crypt::Car.set_key_id(data_key_id)
|
|
40
|
+
Crypt::Note.set_key_id(data_key_id)
|
|
39
41
|
Mongoid::Config.send(:clients=, config)
|
|
40
42
|
Mongoid::Clients.with_name(:key_vault)[key_vault_collection].drop
|
|
41
43
|
Crypt::Patient.store_in(client: :encrypted)
|
|
42
44
|
Crypt::Car.store_in(client: :encrypted, database: Crypt::Car.storage_options[:database])
|
|
45
|
+
Crypt::Folder.store_in(client: :encrypted)
|
|
43
46
|
|
|
44
47
|
example.run
|
|
45
48
|
|
|
46
49
|
Crypt::Patient.reset_storage_options!
|
|
50
|
+
Crypt::Folder.reset_storage_options!
|
|
47
51
|
Crypt::Patient.set_key_id(existing_key_id)
|
|
48
52
|
Crypt::Car.set_key_id(existing_key_id)
|
|
53
|
+
Crypt::Note.set_key_id(existing_key_id)
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
it 'encrypts and decrypts fields' do
|
|
@@ -85,6 +90,17 @@ describe 'Encryption' do
|
|
|
85
90
|
end
|
|
86
91
|
end
|
|
87
92
|
|
|
93
|
+
# Nothing about the parent document says it needs a schema: it has no
|
|
94
|
+
# encrypted field of its own and no encrypt_with. Its collection still needs
|
|
95
|
+
# one, or the embedded field goes out in the clear.
|
|
96
|
+
it 'stores an embedded field encrypted when the parent has no encrypted field' do
|
|
97
|
+
folder = Crypt::Folder.create!(note: Crypt::Note.new(text: 'SECRET'))
|
|
98
|
+
unencrypted_client[Crypt::Folder.collection.name].find(_id: folder.id).first.tap do |doc|
|
|
99
|
+
expect(doc['note']['text']).to be_a(BSON::Binary)
|
|
100
|
+
expect(doc['note']['text'].type).to eq(:ciphertext)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
88
104
|
it 'stores data encrypted in the non-default database' do
|
|
89
105
|
car = Crypt::Car.create!(vin: 'VA1234')
|
|
90
106
|
unencrypted_client
|
|
@@ -94,4 +110,288 @@ describe 'Encryption' do
|
|
|
94
110
|
expect(doc[:vin].type).to eq(:ciphertext)
|
|
95
111
|
end
|
|
96
112
|
end
|
|
113
|
+
|
|
114
|
+
# The encryption schema map is keyed by namespace and built once, when the
|
|
115
|
+
# client is constructed. Whenever the namespace an encrypted model actually
|
|
116
|
+
# writes to is not in that map, the driver encrypts nothing and reports no
|
|
117
|
+
# error, so the field lands in the clear. Mongoid fails closed instead.
|
|
118
|
+
describe 'when the target namespace is not in the encryption schema map' do
|
|
119
|
+
let(:scratch_databases) do
|
|
120
|
+
%w[vehicles_dynamic vehicles_tenant_b vehicles_tenant_c tenant_a]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
around do |example|
|
|
124
|
+
original_options = {
|
|
125
|
+
Crypt::DynamicCar => Crypt::DynamicCar.storage_options,
|
|
126
|
+
Crypt::TenantCar => Crypt::TenantCar.storage_options
|
|
127
|
+
}
|
|
128
|
+
existing_key_ids = original_options.keys.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] }
|
|
129
|
+
original_options.each_key do |model|
|
|
130
|
+
model.set_key_id(data_key_id)
|
|
131
|
+
model.store_in(client: :encrypted)
|
|
132
|
+
end
|
|
133
|
+
clean_scratch_data
|
|
134
|
+
# The schema map is generated when the client is built, so the client has
|
|
135
|
+
# to be built after the storage options above are in place.
|
|
136
|
+
Mongoid::Clients.clear
|
|
137
|
+
|
|
138
|
+
example.run
|
|
139
|
+
|
|
140
|
+
clean_scratch_data
|
|
141
|
+
original_options.each { |model, options| model.storage_options = options }
|
|
142
|
+
existing_key_ids.each { |model, key_id| model.set_key_id(key_id) }
|
|
143
|
+
Mongoid::Clients.clear
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def clean_scratch_data
|
|
147
|
+
scratch_databases.each { |database| unencrypted_client.use(database).database.drop }
|
|
148
|
+
# Crypt::Car writes into the shared 'vehicles' database, which other
|
|
149
|
+
# examples rely on, so remove only the documents these examples create.
|
|
150
|
+
unencrypted_client
|
|
151
|
+
.use(Crypt::Car.storage_options[:database])[Crypt::Car.collection_name.to_s]
|
|
152
|
+
.delete_many(vin: { '$in' => %w[CLIENT-1 CLIENT-2] })
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Reads with a client that has no automatic encryption, to see what really
|
|
156
|
+
# landed on disk.
|
|
157
|
+
def documents_with_plaintext_vin(database, collection, vin)
|
|
158
|
+
unencrypted_client.use(database)[collection].find(vin: vin).to_a
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
context 'when the database name is a callable' do
|
|
162
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
163
|
+
expect { Crypt::DynamicCar.create!(vin: 'DYNAMIC-1') }
|
|
164
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
165
|
+
|
|
166
|
+
expect(
|
|
167
|
+
documents_with_plaintext_vin('vehicles_dynamic', Crypt::DynamicCar.collection_name.to_s, 'DYNAMIC-1')
|
|
168
|
+
).to be_empty
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
context 'when the database name is a callable resolved per tenant' do
|
|
173
|
+
around do |example|
|
|
174
|
+
Thread.current[:tenant_database] = 'tenant_a'
|
|
175
|
+
example.run
|
|
176
|
+
Thread.current[:tenant_database] = nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
180
|
+
expect { Crypt::TenantCar.create!(vin: 'TENANT-1') }
|
|
181
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
182
|
+
|
|
183
|
+
expect(
|
|
184
|
+
documents_with_plaintext_vin('tenant_a', Crypt::TenantCar.collection_name.to_s, 'TENANT-1')
|
|
185
|
+
).to be_empty
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
context 'when the database is overridden for the block' do
|
|
190
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
191
|
+
expect { Crypt::Car.with(database: 'vehicles_tenant_b') { |car| car.create!(vin: 'BLOCK-1') } }
|
|
192
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
193
|
+
|
|
194
|
+
expect(
|
|
195
|
+
documents_with_plaintext_vin('vehicles_tenant_b', Crypt::Car.collection_name.to_s, 'BLOCK-1')
|
|
196
|
+
).to be_empty
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
context 'when the database is overridden globally' do
|
|
201
|
+
persistence_context_override :database, 'vehicles_tenant_c'
|
|
202
|
+
|
|
203
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
204
|
+
expect { Crypt::Car.create!(vin: 'GLOBAL-1') }
|
|
205
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
206
|
+
|
|
207
|
+
expect(
|
|
208
|
+
documents_with_plaintext_vin('vehicles_tenant_c', Crypt::Car.collection_name.to_s, 'GLOBAL-1')
|
|
209
|
+
).to be_empty
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
context 'when the model is routed to a client without automatic encryption' do
|
|
214
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
215
|
+
expect { Crypt::Car.with(client: :default) { |car| car.create!(vin: 'CLIENT-1') } }
|
|
216
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
217
|
+
|
|
218
|
+
expect(
|
|
219
|
+
documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-1')
|
|
220
|
+
).to be_empty
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
context 'when the parent has no encrypted field of its own' do
|
|
225
|
+
it 'does not store the embedded field in plaintext', :aggregate_failures do
|
|
226
|
+
expect { Crypt::Folder.with(client: :default) { |folder| folder.create!(note: Crypt::Note.new(text: 'EMBEDDED-1')) } }
|
|
227
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
228
|
+
|
|
229
|
+
expect(
|
|
230
|
+
unencrypted_client[Crypt::Folder.collection_name.to_s].find('note.text' => 'EMBEDDED-1').to_a
|
|
231
|
+
).to be_empty
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
context 'when the client is overridden globally to one without automatic encryption' do
|
|
236
|
+
persistence_context_override :client, :default
|
|
237
|
+
|
|
238
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
239
|
+
expect { Crypt::Car.create!(vin: 'CLIENT-2') }
|
|
240
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
241
|
+
|
|
242
|
+
expect(
|
|
243
|
+
documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-2')
|
|
244
|
+
).to be_empty
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The schema map has one entry per namespace, and it has to cover every model
|
|
250
|
+
# that writes there: models sharing a collection, subclasses, and subclasses
|
|
251
|
+
# of embedded models. Documents are read back through a client without
|
|
252
|
+
# automatic encryption, to see what is actually stored.
|
|
253
|
+
describe 'when more than one model writes to a namespace' do
|
|
254
|
+
let(:root_models) do
|
|
255
|
+
[ Crypt::Badge, Crypt::BadgeScan, Crypt::Member, Crypt::Listing, Crypt::Box, Crypt::Locker ]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
let(:keyed_models) do
|
|
259
|
+
[ Crypt::Badge, Crypt::BadgeScan, Crypt::Member, Crypt::PrivateListing, Crypt::Box, Crypt::SecretContent ]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
around do |example|
|
|
263
|
+
original_options = root_models.to_h { |model| [ model, model.storage_options ] }
|
|
264
|
+
existing_key_ids = keyed_models.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] }
|
|
265
|
+
keyed_models.each { |model| model.set_key_id(data_key_id) }
|
|
266
|
+
root_models.each do |model|
|
|
267
|
+
unencrypted_client[model.collection_name.to_s].drop
|
|
268
|
+
model.store_in(client: :encrypted)
|
|
269
|
+
end
|
|
270
|
+
# The schema map is generated when the client is built, so the client has
|
|
271
|
+
# to be built after the key ids above are in place.
|
|
272
|
+
Mongoid::Clients.clear
|
|
273
|
+
|
|
274
|
+
example.run
|
|
275
|
+
|
|
276
|
+
original_options.each { |model, options| model.storage_options = options }
|
|
277
|
+
existing_key_ids.each { |model, key_id| model.set_key_id(key_id) }
|
|
278
|
+
Mongoid::Clients.clear
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def stored_document(model, id)
|
|
282
|
+
unencrypted_client[model.collection_name.to_s].find(_id: id).first
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def expect_ciphertext(value)
|
|
286
|
+
expect(value).to be_a(BSON::Binary).and(have_attributes(type: :ciphertext))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
context 'when two models are stored in one collection' do
|
|
290
|
+
it 'stores the fields of both models encrypted', :aggregate_failures do
|
|
291
|
+
badge = Crypt::Badge.create!(serial: 'SERIAL-1')
|
|
292
|
+
scan = Crypt::BadgeScan.create!(holder: 'HOLDER-1')
|
|
293
|
+
|
|
294
|
+
expect_ciphertext(stored_document(Crypt::Badge, badge.id)['serial'])
|
|
295
|
+
expect_ciphertext(stored_document(Crypt::BadgeScan, scan.id)['holder'])
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
context 'when a subclass adds an encrypted field to an encrypted parent' do
|
|
300
|
+
it 'stores the fields of the parent and of the subclass encrypted', :aggregate_failures do
|
|
301
|
+
employee = Crypt::Employee.create!(ssn: 'SSN-1', salary: 'SALARY-1')
|
|
302
|
+
|
|
303
|
+
stored_document(Crypt::Member, employee.id).tap do |doc|
|
|
304
|
+
expect_ciphertext(doc['ssn'])
|
|
305
|
+
expect_ciphertext(doc['salary'])
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it 'stores the fields of a sibling subclass encrypted' do
|
|
310
|
+
contractor = Crypt::Contractor.create!(ssn: 'SSN-2', rate: 'RATE-1')
|
|
311
|
+
|
|
312
|
+
expect_ciphertext(stored_document(Crypt::Member, contractor.id)['rate'])
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'reads the fields back' do
|
|
316
|
+
employee = Crypt::Employee.create!(ssn: 'SSN-3', salary: 'SALARY-3')
|
|
317
|
+
|
|
318
|
+
expect(Crypt::Member.find(employee.id).salary).to eq('SALARY-3')
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
context 'when only a subclass has encrypted fields' do
|
|
323
|
+
it 'stores the fields of the subclass encrypted' do
|
|
324
|
+
listing = Crypt::PrivateListing.create!(title: 'TITLE-1', owner_ssn: 'SSN-4')
|
|
325
|
+
|
|
326
|
+
expect_ciphertext(stored_document(Crypt::Listing, listing.id)['owner_ssn'])
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
context 'when a subclass of an embeds_one target adds an encrypted field' do
|
|
331
|
+
it 'stores the fields of the target and of the subclass encrypted', :aggregate_failures do
|
|
332
|
+
box = Crypt::Box.create!(item: Crypt::SealedItem.new(label: 'LABEL-1', seal: 'SEAL-1'))
|
|
333
|
+
|
|
334
|
+
stored_document(Crypt::Box, box.id)['item'].tap do |item|
|
|
335
|
+
expect_ciphertext(item['label'])
|
|
336
|
+
expect_ciphertext(item['seal'])
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
context 'when only a subclass of an embeds_one target has encrypted fields' do
|
|
342
|
+
it 'stores the fields of the subclass encrypted' do
|
|
343
|
+
locker = Crypt::Locker.create!(content: Crypt::SecretContent.new(label: 'LABEL-2', code: 'CODE-1'))
|
|
344
|
+
|
|
345
|
+
expect_ciphertext(stored_document(Crypt::Locker, locker.id)['content']['code'])
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# An entry for the namespace exists, but it does not cover every encrypted
|
|
350
|
+
# field of the model. The driver encrypts what the entry lists and writes
|
|
351
|
+
# the rest in plaintext.
|
|
352
|
+
context 'when the schema entry for the namespace misses an encrypted field' do
|
|
353
|
+
let(:config) do
|
|
354
|
+
super().merge(
|
|
355
|
+
partial: {
|
|
356
|
+
hosts: SpecConfig.instance.addresses,
|
|
357
|
+
database: database_id,
|
|
358
|
+
options: {
|
|
359
|
+
auto_encryption_options: {
|
|
360
|
+
key_vault_client: :key_vault,
|
|
361
|
+
kms_providers: kms_providers,
|
|
362
|
+
key_vault_namespace: key_vault_namespace,
|
|
363
|
+
extra_options: extra_options,
|
|
364
|
+
schema_map: partial_schema_map
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
let(:partial_schema_map) do
|
|
372
|
+
{
|
|
373
|
+
"#{database_id}.#{Crypt::Member.collection_name}" => {
|
|
374
|
+
'bsonType' => 'object',
|
|
375
|
+
'encryptMetadata' => {
|
|
376
|
+
'keyId' => [ BSON::Binary.new(Base64.decode64(data_key_id), :uuid) ],
|
|
377
|
+
'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
|
|
378
|
+
},
|
|
379
|
+
'properties' => {
|
|
380
|
+
'ssn' => { 'encrypt' => { 'bsonType' => 'string' } }
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
387
|
+
expect do
|
|
388
|
+
Crypt::Employee.with(client: :partial) { |klass| klass.create!(ssn: 'SSN-5', salary: 'PARTIAL-1') }
|
|
389
|
+
end.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
390
|
+
|
|
391
|
+
expect(
|
|
392
|
+
unencrypted_client[Crypt::Member.collection_name.to_s].find(salary: 'PARTIAL-1').to_a
|
|
393
|
+
).to be_empty
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
97
397
|
end
|
|
@@ -135,6 +135,27 @@
|
|
|
135
135
|
$regex: bar
|
|
136
136
|
matches: false
|
|
137
137
|
|
|
138
|
+
# Object#=~ is gone as of Ruby 3.2, so an element that cannot be matched
|
|
139
|
+
# against has to be skipped rather than passed to =~.
|
|
140
|
+
- name: array field value - element that cannot be matched against
|
|
141
|
+
document:
|
|
142
|
+
title:
|
|
143
|
+
- 42
|
|
144
|
+
- foo
|
|
145
|
+
query:
|
|
146
|
+
title:
|
|
147
|
+
$regex: foo
|
|
148
|
+
matches: true
|
|
149
|
+
|
|
150
|
+
- name: array field value - no element can be matched against
|
|
151
|
+
document:
|
|
152
|
+
title:
|
|
153
|
+
- 42
|
|
154
|
+
query:
|
|
155
|
+
title:
|
|
156
|
+
$regex: foo
|
|
157
|
+
matches: false
|
|
158
|
+
|
|
138
159
|
- name: true field value
|
|
139
160
|
document:
|
|
140
161
|
title: true
|