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
@@ -127,10 +127,18 @@ module Mongoid
127
127
  inclusions = options[:include]
128
128
  relation_names(inclusions).each do |name|
129
129
  association = relations[name.to_s]
130
- if association && relation = send(association.name)
131
- attributes[association.name.to_s] =
132
- relation.serializable_hash(relation_options(inclusions, options, name))
133
- end
130
+ next unless association
131
+
132
+ relation =
133
+ if association.fallback?
134
+ without_autobuild { send(association.name) }
135
+ else
136
+ send(association.name)
137
+ end
138
+ next unless relation
139
+
140
+ attributes[association.name.to_s] =
141
+ relation.serializable_hash(relation_options(inclusions, options, name))
134
142
  end
135
143
  end
136
144
 
@@ -83,6 +83,12 @@ module Mongoid
83
83
  #
84
84
  # @return [ Array<Hash> ] The list of undefined indexes by model.
85
85
  def undefined_indexes(models = ::Mongoid.models)
86
+ Threaded.with_collection_management do
87
+ undefined_indexes_for(models)
88
+ end
89
+ end
90
+
91
+ def undefined_indexes_for(models)
86
92
  undefined_by_model = {}
87
93
 
88
94
  models.each do |model|
@@ -116,15 +122,17 @@ module Mongoid
116
122
  #
117
123
  # @return [ Hash{Class => Array(Hash)}] The list of indexes that were removed by model.
118
124
  def remove_undefined_indexes(models = ::Mongoid.models)
119
- undefined_indexes(models).each do |model, indexes|
120
- indexes.each do |index|
121
- key = index['key'].symbolize_keys
122
- collection = model.collection
123
- collection.indexes(session: model.send(:_session)).drop_one(key)
124
- logger.info(
125
- "MONGOID: Removed index '#{index['name']}' on collection " +
126
- "'#{collection.name}' in database '#{collection.database.name}'."
127
- )
125
+ Threaded.with_collection_management do
126
+ undefined_indexes(models).each do |model, indexes|
127
+ indexes.each do |index|
128
+ key = index['key'].symbolize_keys
129
+ collection = model.collection
130
+ collection.indexes(session: model.send(:_session)).drop_one(key)
131
+ logger.info(
132
+ "MONGOID: Removed index '#{index['name']}' on collection " +
133
+ "'#{collection.name}' in database '#{collection.database.name}'."
134
+ )
135
+ end
128
136
  end
129
137
  end
130
138
  end
@@ -172,6 +180,12 @@ module Mongoid
172
180
  #
173
181
  # @return [ Array<Class> ] The sharded models
174
182
  def shard_collections(models = ::Mongoid.models)
183
+ Threaded.with_collection_management do
184
+ shard_collections_for(models)
185
+ end
186
+ end
187
+
188
+ def shard_collections_for(models)
175
189
  models.map do |model|
176
190
  next if model.shard_config.nil?
177
191
 
@@ -32,6 +32,9 @@ module Mongoid
32
32
  hash[key] = "#{key}-stack"
33
33
  end
34
34
 
35
+ # The name of the stack tracking collection and index management.
36
+ COLLECTION_MANAGEMENT = :collection_management
37
+
35
38
  # The key for the current thread's sessions.
36
39
  SESSIONS_KEY = 'sessions'
37
40
 
@@ -42,6 +45,9 @@ module Mongoid
42
45
  # executed on documents.
43
46
  EXECUTE_CALLBACKS = 'execute-callbacks'
44
47
 
48
+ # The key for the time left in the current in-memory regexp budget.
49
+ REGEXP_BUDGET_KEY = 'regexp-budget'
50
+
45
51
  extend self
46
52
 
47
53
  # Resets the current thread- or fiber-local storage to its initial state.
@@ -165,6 +171,36 @@ module Mongoid
165
171
  !stack(name).empty?
166
172
  end
167
173
 
174
+ # Execute the block as collection or index management.
175
+ #
176
+ # Creating, dropping and inspecting collections and indexes sends no
177
+ # document data, so these operations are exempt from the encryption schema
178
+ # check that PersistenceContext applies to reads and writes. Without the
179
+ # exemption, tasks such as db:mongoid:create_collections would need an
180
+ # encryption-capable client to run.
181
+ #
182
+ # @example Create a collection.
183
+ # Threaded.with_collection_management { model.create_collection }
184
+ #
185
+ # @return [ Object ] The result of the block.
186
+ def with_collection_management
187
+ begin_execution(COLLECTION_MANAGEMENT)
188
+ yield
189
+ ensure
190
+ exit_execution(COLLECTION_MANAGEMENT)
191
+ end
192
+
193
+ # Is collection or index management being executed?
194
+ #
195
+ # @example Is a collection being managed?
196
+ # Threaded.managing_collection?
197
+ #
198
+ # @return [ true | false ] Whether collection or index management is in
199
+ # progress on the current thread.
200
+ def managing_collection?
201
+ executing?(COLLECTION_MANAGEMENT)
202
+ end
203
+
168
204
  # Exit from a named thread local stack.
169
205
  #
170
206
  # @example Exit from the stack.
@@ -22,13 +22,20 @@ module Mongoid
22
22
  true
23
23
  end
24
24
 
25
- # Begin an execution that should skip timestamping.
25
+ # Skip timestamping for the duration of the given block, or (in the
26
+ # deprecated, block-less form) for the next persistence operation.
26
27
  #
27
- # @example Save a document but don't timestamp.
28
+ # @example Save a document but don't timestamp (block form).
29
+ # person.timeless { person.save }
30
+ #
31
+ # @example Save a document but don't timestamp (deprecated chained form).
28
32
  # person.timeless.save
29
33
  #
30
- # @return [ Document ] The document this was called on.
31
- def timeless
34
+ # @return [ Object | Document ] The return value of the block, or (in the
35
+ # block-less form) the document this was called on.
36
+ def timeless(&block)
37
+ return Timeless.with_timeless(&block) if block
38
+
32
39
  self.class.timeless
33
40
  self
34
41
  end
@@ -47,6 +54,9 @@ module Mongoid
47
54
  # The key to use to store the timeless table
48
55
  TIMELESS_TABLE_KEY = '[mongoid]:timeless'
49
56
 
57
+ # The key to use to store the block-based timeless flag.
58
+ TIMELESS_FLAG_KEY = '[mongoid]:timeless-flag'
59
+
50
60
  # Returns the in-memory thread cache of classes
51
61
  # for which to skip timestamping.
52
62
  #
@@ -58,16 +68,78 @@ module Mongoid
58
68
  end
59
69
 
60
70
  def_delegators :timeless_table, :[]=, :[]
71
+
72
+ # Skip timestamping for the duration of the given block, on the
73
+ # current thread or fiber. This applies to every document persisted
74
+ # while the block is executing, regardless of class, including
75
+ # cascaded embedded children at any nesting depth.
76
+ #
77
+ # @example Skip timestamping for a block.
78
+ # Mongoid::Timestamps::Timeless.with_timeless do
79
+ # person.save
80
+ # end
81
+ #
82
+ # @return [ Object ] The return value of the block.
83
+ def with_timeless
84
+ # Only the outermost block owns the flag: if we are already inside a
85
+ # timeless scope, we leave the suppression in place when this block
86
+ # ends. This avoids tracking a nesting depth that could drift out of
87
+ # sync.
88
+ already_timeless = suppressing_timestamps?
89
+ set_suppressing_timestamps(true) unless already_timeless
90
+ yield
91
+ ensure
92
+ set_suppressing_timestamps(false) unless already_timeless
93
+ end
94
+
95
+ # Whether a block-based timeless scope is currently active on this
96
+ # thread/fiber.
97
+ #
98
+ # @return [ true | false ] Whether timestamps are being suppressed.
99
+ #
100
+ # @api private
101
+ def suppressing_timestamps?
102
+ !!Threaded.get(TIMELESS_FLAG_KEY) { false }
103
+ end
104
+
105
+ # Set whether a block-based timeless scope is active on this
106
+ # thread/fiber.
107
+ #
108
+ # @param [ true | false ] value Whether to suppress timestamps.
109
+ #
110
+ # @api private
111
+ def set_suppressing_timestamps(value)
112
+ Threaded.set(TIMELESS_FLAG_KEY, value)
113
+ end
61
114
  end
62
115
 
63
116
  module ClassMethods
64
- # Begin an execution that should skip timestamping.
117
+ # Skip timestamping for the duration of the given block, or (in the
118
+ # deprecated, block-less form) for the next persistence operation.
119
+ #
120
+ # @example Create a document but don't timestamp (block form).
121
+ # Person.timeless { Person.create(title: "Sir") }
65
122
  #
66
- # @example Create a document but don't timestamp.
123
+ # @example Create a document but don't timestamp (deprecated form).
67
124
  # Person.timeless.create(:title => "Sir")
68
125
  #
69
- # @return [ Class ] The class this was called on.
70
- def timeless
126
+ # @return [ Object | Class ] The return value of the block, or (in the
127
+ # block-less form) the class this was called on.
128
+ def timeless(&block)
129
+ return Timeless.with_timeless(&block) if block
130
+
131
+ locations = caller_locations
132
+
133
+ # if this is called from the #timeless instance method, look up one
134
+ # level higher for the call site
135
+ locations.shift if locations[0].path == __FILE__
136
+
137
+ Mongoid.deprecation_warning(
138
+ :timeless_sans_block,
139
+ 'Calling #timeless without a block is deprecated; pass a block ' \
140
+ 'instead, e.g. `record.timeless { record.save }`.',
141
+ locations
142
+ )
71
143
  counter = 0
72
144
  counter += 1 if self < Mongoid::Timestamps::Created
73
145
  counter += 1 if self < Mongoid::Timestamps::Updated
@@ -109,12 +181,14 @@ module Mongoid
109
181
  Timeless[name] = (counter == 0) ? nil : counter
110
182
  end
111
183
 
112
- # Returns whether the current class should skip timestamping.
184
+ # Returns whether the current class should skip timestamping. This is
185
+ # true when either a block-based timeless scope is active on the
186
+ # current thread/fiber, or the deprecated per-class counter is set.
113
187
  #
114
188
  # @return [ true | false ] Whether the current class should
115
189
  # skip timestamping.
116
190
  def timeless?
117
- !!Timeless[name]
191
+ Timeless.suppressing_timestamps? || !!Timeless[name]
118
192
  end
119
193
  end
120
194
  end
@@ -211,7 +211,7 @@ module Mongoid
211
211
  # @api private
212
212
  def self.add_discriminator_mapping(value, klass = self)
213
213
  self.discriminator_mapping ||= {}
214
- self.discriminator_mapping[value] = klass
214
+ discriminator_mapping[value] = klass
215
215
  superclass.add_discriminator_mapping(value, klass) if hereditary?
216
216
  end
217
217
 
@@ -225,7 +225,7 @@ module Mongoid
225
225
  #
226
226
  # @api private
227
227
  def self.get_discriminator_mapping(value)
228
- self.discriminator_mapping[value] if self.discriminator_mapping
228
+ discriminator_mapping[value] if discriminator_mapping
229
229
  end
230
230
  end
231
231
 
@@ -67,12 +67,13 @@ module Mongoid
67
67
  valid = document.validating do
68
68
  # Now, treating the target as an array, look at each element
69
69
  # and see if it is valid, but only if it has already been
70
- # persisted, or changed, and hasn't been flagged for destroy.
70
+ # persisted, or changed, and hasn't been flagged for destroy
71
+ # or already destroyed.
71
72
  #
72
73
  # use map.all? instead of just all?, because all? will do short-circuit
73
74
  # evaluation and terminate on the first failed validation.
74
75
  list.map do |value|
75
- if value && !value.flagged_for_destroy? && (!value.persisted? || value.changed?)
76
+ if needs_validation?(value)
76
77
  value.validated? || value.valid?
77
78
  else
78
79
  true
@@ -120,6 +121,21 @@ module Mongoid
120
121
  def get_target_documents_for_other(target)
121
122
  Array.wrap(target)
122
123
  end
124
+
125
+ # Returns true if the given value should be validated as part of
126
+ # an associated validation. Destroyed and flagged-for-destroy
127
+ # documents are skipped, as are persisted documents that haven't
128
+ # changed.
129
+ #
130
+ # @param [ Mongoid::Document | nil ] value The value to check.
131
+ #
132
+ # @return [ true | false ] Whether the value needs validation.
133
+ def needs_validation?(value)
134
+ value &&
135
+ !value.flagged_for_destroy? &&
136
+ !value.destroyed? &&
137
+ (!value.persisted? || value.changed?)
138
+ end
123
139
  end
124
140
  end
125
141
  end
@@ -5,5 +5,5 @@ module Mongoid
5
5
  #
6
6
  # Note that this file is automatically updated via `rake candidate:create`.
7
7
  # Manual changes to this file will be overwritten by that rake task.
8
- VERSION = '9.1.0'
8
+ VERSION = '9.1.1'
9
9
  end
@@ -435,7 +435,13 @@ describe 'Mongoid application tests' do
435
435
  end
436
436
  gemfile_lines << "gem 'mongoid', path: '#{File.expand_path(BASE)}'\n"
437
437
 
438
- # Rails 6.0 and 6.1 need logger gem on Ruby 2.7+ due to stdlib changes
438
+ # json 3.0 removed the quirks_mode and max_nesting keywords that
439
+ # ActiveSupport::JSON (every 7.x) still passes to JSON.generate and
440
+ # JSON.parse, so a freshly resolved bundle picks a 3.x json and the app
441
+ # fails to encode any response with ArgumentError: unknown keyword:
442
+ # quirks_mode. Cap json at 2.x, which still accepts those keywords.
443
+ gemfile_lines << "gem 'json', '< 3'\n" # Rails 6.0 and 6.1 need logger gem on Ruby 2.7+ due to stdlib changes
444
+
439
445
  gemfile_lines << "gem 'logger'\n" if rails_version && rails_version.to_f < 7.0 && RUBY_VERSION >= '2.7'
440
446
 
441
447
  if rails_version
@@ -99,6 +99,19 @@ describe 'belongs_to associations' do
99
99
  expect(team_manager.reload.unit_type).to eq 'group'
100
100
  expect(team_manager.unit).to eq team
101
101
  end
102
+
103
+ it 'eager-loads the corresponding unit when unit_type is a different alias' do
104
+ dept_manager.update unit_type: 'sandbox_dept'
105
+ team_manager.update unit_type: 'group'
106
+
107
+ managers = SandboxManager.where(:_id.in => [ dept_manager.id, team_manager.id ])
108
+ .includes(:unit)
109
+ .to_a
110
+ units_by_id = managers.to_h { |m| [ m.id, m.ivar(:unit) ] }
111
+
112
+ expect(units_by_id[dept_manager.id]).to eq department
113
+ expect(units_by_id[team_manager.id]).to eq team
114
+ end
102
115
  end
103
116
 
104
117
  context 'when the association uses the default resolver' do
@@ -90,8 +90,23 @@ describe 'has_and_belongs_to_many associations' do
90
90
  })
91
91
  end
92
92
 
93
- it 'does not raise on save' do
94
- expect { image_block.save! }.not_to raise_error
93
+ # The nested attributes are processed before the attachment_ids
94
+ # assignment is applied, so at that point the id is not yet in the
95
+ # association and resolving it requires a collection-wide lookup.
96
+ context 'when allow_reparenting_via_nested_attributes is false' do
97
+ config_override :allow_reparenting_via_nested_attributes, false
98
+
99
+ it 'raises a document not found error' do
100
+ expect { image_block.save! }.to raise_error(Mongoid::Errors::DocumentNotFound)
101
+ end
102
+ end
103
+
104
+ context 'when allow_reparenting_via_nested_attributes is true' do
105
+ config_override :allow_reparenting_via_nested_attributes, true
106
+
107
+ it 'does not raise on save' do
108
+ expect { image_block.save! }.not_to raise_error
109
+ end
95
110
  end
96
111
  end
97
112
 
@@ -253,10 +253,20 @@ describe 'Dots and Dollars' do
253
253
  DADMUser.where('$_amount': 0).first
254
254
  end
255
255
 
256
- it 'raise an error' do
256
+ it 'raises an error' do
257
257
  expect do
258
258
  queried
259
- end.to raise_error(Mongo::Error::OperationFailure)
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,139 @@ 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
97
248
  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