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
@@ -20,18 +20,25 @@ module Mongoid
20
20
  #
21
21
  # @return [ Hash ] The encryption schema map.
22
22
  def encryption_schema_map(default_database, models = ::Mongoid.models)
23
- visited = Set.new
24
23
  models.each_with_object({}) do |model, map|
25
- next if visited.include?(model)
26
-
27
- visited << model
28
24
  next if model.embedded?
29
- next unless model.encrypted?
25
+ next unless model.requires_encryption_schema?
30
26
 
31
27
  database = model.storage_options.fetch(:database) { default_database }
28
+ # A callable database name cannot be resolved here: the documented
29
+ # multi-tenant idiom has no correct value while the client is being
30
+ # built. Interpolating the callable would produce a key that never
31
+ # matches any namespace, so leave the model out of the map. Writes
32
+ # are refused later, by PersistenceContext, rather than silently
33
+ # going out unencrypted.
34
+ next if database.respond_to?(:call)
35
+
32
36
  key = "#{database}.#{model.collection_name}"
33
- props = metadata_for(model).merge(properties_for(model, visited))
34
- map[key] = props unless props.empty?
37
+ props = metadata_for(model).merge(properties_for(model, [ model ]))
38
+ # The root of a collection schema describes the document, so it is
39
+ # always an object. Saying so matters when a nested schema carries
40
+ # encryptMetadata: mongocryptd rejects the schema otherwise.
41
+ map[key] = { 'bsonType' => 'object' }.merge(props) unless props.empty?
35
42
  end
36
43
  end
37
44
 
@@ -100,11 +107,12 @@ module Mongoid
100
107
  # are marked as encrypted.
101
108
  #
102
109
  # @param [ Mongoid::Document ] model The model to generate the properties for.
103
- # @param [ Set<Mongoid::Document> ] visited The set of models that have already been visited.
110
+ # @param [ Array<Mongoid::Document> ] path The models the walk is already
111
+ # inside of, outermost first.
104
112
  #
105
113
  # @return [ Hash ] The encryption properties.
106
- def properties_for(model, visited)
107
- result = properties_for_fields(model).merge(properties_for_relations(model, visited))
114
+ def properties_for(model, path)
115
+ result = properties_for_fields(model).merge(properties_for_relations(model, path))
108
116
  if result.empty?
109
117
  {}
110
118
  else
@@ -141,20 +149,29 @@ module Mongoid
141
149
  # are configured to be encrypted.
142
150
  #
143
151
  # @param [ Mongoid::Document ] model The model to generate the properties for.
144
- # @param [ Set<Mongoid::Document> ] visited The set of models that have already been visited.
152
+ # @param [ Array<Mongoid::Document> ] path The models the walk is already
153
+ # inside of, outermost first.
145
154
  #
146
155
  # @return [ Hash ] The encryption properties.
147
- def properties_for_relations(model, visited)
156
+ def properties_for_relations(model, path)
148
157
  model.relations.each_with_object({}) do |(name, relation), props|
149
- next if visited.include?(relation.relation_class)
158
+ # relation_class constantizes, and a polymorphic embedded_in has no
159
+ # class to resolve, so the relation type has to be checked first.
150
160
  next unless relation.is_a?(Association::Embedded::EmbedsOne)
151
- next unless relation.relation_class.encrypted?
152
161
 
153
- visited << relation.relation_class
154
- metadata_for(
155
- relation.relation_class
156
- ).merge(
157
- properties_for(relation.relation_class, visited)
162
+ klass = relation.try_relation_class
163
+ # An association target does not have to be a Mongoid document, and
164
+ # the class it names does not have to exist.
165
+ next unless klass.respond_to?(:requires_encryption_schema?)
166
+ # Stop at a model the walk is already inside of, or a self-embedding
167
+ # model never terminates. The path covers the current branch only:
168
+ # a model embedded by two parents, or twice by one parent, has to be
169
+ # emitted at every place it appears.
170
+ next if path.include?(klass)
171
+ next unless klass.requires_encryption_schema?
172
+
173
+ metadata_for(klass).merge(
174
+ properties_for(klass, path + [ klass ])
158
175
  ).tap do |properties|
159
176
  props[name] = { 'bsonType' => 'object' }.merge(properties) unless properties.empty?
160
177
  end
@@ -130,12 +130,14 @@ module Mongoid
130
130
  validate_isolation_level!(level)
131
131
  }
132
132
 
133
- # When this flag is true, it will be possible to change the parent of a
134
- # record in a "has_many" association by passing the child record's id in the
135
- # nested attributes for another parent record.
133
+ # When this flag is true, it will be possible to add a record to a
134
+ # "has_many" or "has_and_belongs_to_many" association by passing that
135
+ # record's id in the nested attributes for another parent record, even
136
+ # when the record does not already belong to that association. For a
137
+ # "has_many" association this moves the record to the new parent.
136
138
  #
137
- # When this flag is false, attempting to change the parent of a record in a
138
- # "has-many" association via nested attributes will raise an error.
139
+ # When this flag is false, an id in nested attributes is only resolved
140
+ # within the association itself, and anything else raises an error.
139
141
  #
140
142
  # The default is `false`. Note that allowing reparenting via nested attributes
141
143
  # is a potential security risk, since it could allow a malicious user to move
@@ -294,6 +296,51 @@ module Mongoid
294
296
  # See https://jira.mongodb.org/browse/MONGOID-5892 for more details.
295
297
  option :serializable_hash_with_legacy_only, default: true
296
298
 
299
+ # When false (default), query operators are restricted when building a
300
+ # selector, so that user-supplied input reaching the query builder cannot
301
+ # make MongoDB execute arbitrary JavaScript. Two rules are enforced:
302
+ #
303
+ # - An operator at the top level of an expression must appear in
304
+ # +Criteria::Queryable::Selectable::ALLOWED_QUERY_OPERATORS+.
305
+ # - +$where+, +$function+, and +$accumulator+ are rejected at any depth,
306
+ # including inside +$expr+ and the logical operators.
307
+ #
308
+ # This applies to every query method that accepts an expression, including
309
+ # +where+, +find_by+, +and+, +or+, +nor+, +not+, +any_of+, and +none_of+.
310
+ # It also governs the string form of +where+, e.g.
311
+ # +where("this.name == 'admin'")+, which the server evaluates as +$where+.
312
+ # Applications relying on that form must set this option to true.
313
+ #
314
+ # The APIs that request JavaScript explicitly, +Criteria#for_js+ and
315
+ # +js_query+, are unaffected: there the developer has asked for it.
316
+ #
317
+ # Set to true to restore the unrestricted pass-through behavior.
318
+ #
319
+ # Note that this option is deliberately not tied to +load_defaults+: an
320
+ # application that has opted into older defaults still gets the guard, and
321
+ # must set this option explicitly to turn it off.
322
+ #
323
+ # See https://jira.mongodb.org/browse/MONGOID-5939,
324
+ # https://jira.mongodb.org/browse/MONGOID-5993,
325
+ # https://jira.mongodb.org/browse/MONGOID-5994 for details.
326
+ option :allow_unsafe_query_operators, default: false
327
+
328
+ # The maximum number of seconds that evaluating a single query in memory
329
+ # may spend executing regular expressions. Queries against an embedded
330
+ # association are evaluated in the calling thread, so a pattern built from
331
+ # user input runs locally and can otherwise consume unbounded CPU. The
332
+ # limit is cumulative over the whole query, since cost grows with the
333
+ # number of documents and conditions as well as with the pattern.
334
+ #
335
+ # Set to nil to remove the limit. On Ruby 3.2 and later the remaining
336
+ # budget is compiled into the pattern, so the limit counts only the time
337
+ # spent matching. Earlier Rubies have no per-Regexp timeout, so the query
338
+ # is bounded with Timeout instead and the limit is wall clock over the
339
+ # whole in-memory evaluation.
340
+ #
341
+ # See https://jira.mongodb.org/browse/MONGOID-5981 for details.
342
+ option :in_memory_regexp_time_limit, default: 5.0
343
+
297
344
  # Returns the Config singleton, for use in the configure DSL.
298
345
  #
299
346
  # @return [ self ] The Config singleton.
@@ -539,6 +586,10 @@ module Mongoid
539
586
  end
540
587
  end
541
588
 
589
+ # Wraps configuration options that have been deprecated so that assigning
590
+ # them emits a deprecation warning. OPTIONS is intentionally empty when no
591
+ # options are currently deprecated; it is populated as options are retired,
592
+ # at which point this module rewrites their setters to warn.
542
593
  module DeprecatedOptions
543
594
  OPTIONS = %i[]
544
595
 
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'mongoid/field_readable'
4
+
3
5
  module Mongoid
4
6
  module Contextual
5
7
  module Aggregable
6
8
  # Contains behavior for aggregating values in memory.
7
9
  module Memory
10
+ include FieldReadable
11
+
8
12
  # Get all the aggregate values for the provided field.
9
13
  # Provided for interface consistency with Aggregable::Mongo.
10
14
  #
@@ -28,7 +32,7 @@ module Mongoid
28
32
  #
29
33
  # @return [ Numeric ] The average.
30
34
  def avg(field)
31
- total = count { |doc| !doc.send(field).nil? }
35
+ total = count { |doc| !read_field_value(doc, field).nil? }
32
36
  return nil unless total > 0
33
37
 
34
38
  total = total.to_f if total.is_a?(Integer)
@@ -114,7 +118,7 @@ module Mongoid
114
118
  def aggregate_by(field, method)
115
119
  return nil unless any?
116
120
 
117
- map { |doc| doc.public_send(field) }.compact.public_send(method)
121
+ map { |doc| read_field_value(doc, field) }.compact.public_send(method)
118
122
  end
119
123
  end
120
124
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'mongoid/contextual/aggregable/memory'
4
4
  require 'mongoid/association/eager_loadable'
5
+ require 'mongoid/field_readable'
5
6
 
6
7
  module Mongoid
7
8
  module Contextual
@@ -15,6 +16,7 @@ module Mongoid
15
16
  include Association::EagerLoadable
16
17
  include Queryable
17
18
  include Positional
19
+ include FieldReadable
18
20
 
19
21
  # @attribute [r] root The root document.
20
22
  # @attribute [r] path The atomic path.
@@ -174,10 +176,20 @@ module Mongoid
174
176
  # @param [ Criteria ] criteria The criteria.
175
177
  def initialize(criteria)
176
178
  @criteria, @klass = criteria, criteria.klass
177
- @documents = criteria.documents.select do |doc|
178
- @root ||= doc._root
179
- @collection ||= root.collection
180
- doc._matches?(criteria.selector)
179
+
180
+ # Resolving the collection can build a Mongo::Client the first time it
181
+ # runs, so it happens before the budget opens. Where the budget is
182
+ # enforced with Timeout the exception is asynchronous, and landing in
183
+ # the middle of that would leave a half-built client behind.
184
+ if (first = criteria.documents.first)
185
+ @root = first._root
186
+ @collection = @root.collection
187
+ end
188
+
189
+ # One regexp budget covers the whole scan, so that the limit bounds the
190
+ # query rather than each document individually.
191
+ @documents = Matcher::RegexpBudget.open(criteria.selector) do
192
+ criteria.documents.select { |doc| doc._matches?(criteria.selector) }
181
193
  end
182
194
  apply_sorting
183
195
  apply_options
@@ -739,10 +751,9 @@ module Mongoid
739
751
  # If this is a localized field, and there are remaining, get the
740
752
  # _translations hash so that we can get the specified translation in
741
753
  # the remaining
742
- document.send("#{segment}_translations") if field&.localized?
754
+ document.public_send("#{segment}_translations") if field&.localized?
743
755
  end
744
- meth = klass.aliased_associations[segment] || segment
745
- res.nil? ? document.try(meth) : res
756
+ res.nil? ? read_field_value(document, segment) : res
746
757
  elsif document.is_a?(Hash)
747
758
  # TODO: Remove the indifferent access when implementing MONGOID-5410.
748
759
  if document.key?(segment.to_s)
@@ -44,11 +44,13 @@ module Mongoid
44
44
  clone
45
45
  end
46
46
 
47
- # Returns whether to use $lookup aggregation for eager loading.
47
+ # Returns whether to use $lookup aggregation for eager loading. Only when
48
+ # eager_load was requested and there is something to load: an empty
49
+ # inclusion list (e.g. eager_load([])) falls back to the normal path.
48
50
  #
49
51
  # @return [ true | false ] Whether to use $lookup.
50
52
  def use_lookup?
51
- !!@use_lookup
53
+ !!@use_lookup && inclusions.any?
52
54
  end
53
55
 
54
56
  # Get a list of criteria that are to be executed for eager loading.
@@ -96,20 +98,42 @@ module Mongoid
96
98
  # The names of the association(s) to eager load.
97
99
  def extract_includes_list(_parent_class, parent, is_eager_load = false, *relations_list)
98
100
  relations_list.flatten.each do |relation_object|
99
- if relation_object.is_a?(Hash)
100
- relation_object.each do |relation, _includes|
101
- association = _parent_class.reflect_on_association(relation)
102
- raise_eager_error(is_eager_load, _klass, relation) unless association
101
+ # Normalize a bare association name to a hash with no nested
102
+ # inclusions, so both forms share one resolution path below.
103
+ relations = relation_object.is_a?(Hash) ? relation_object : { relation_object => nil }
104
+
105
+ relations.each do |relation, nested|
106
+ associations = resolve_inclusion_associations(_parent_class, relation, is_eager_load)
107
+ raise_eager_error(is_eager_load, _parent_class, relation) if associations.empty?
108
+
109
+ associations.each do |association|
103
110
  add_inclusion(association, parent)
104
- extract_includes_list(association.klass, association.name, is_eager_load, _includes)
111
+ extract_includes_list(association.klass, association.name, is_eager_load, nested) if nested
105
112
  end
106
- else
107
- association = _parent_class.reflect_on_association(relation_object)
108
- raise_eager_error(is_eager_load, _parent_class, relation_object) unless association
109
- add_inclusion(association, parent)
110
113
  end
111
114
  end
112
115
  end
116
+
117
+ # Resolve the association(s) matching the given relation name. For the
118
+ # regular #includes path, only the parent class is consulted. For the
119
+ # #eager_load ($lookup) path, its subclasses are consulted as well, so
120
+ # associations defined only on a subclass can also be eager-loaded when
121
+ # querying through the superclass.
122
+ #
123
+ # @param [ Class ] parent_class The class to start the lookup from.
124
+ # @param [ Symbol | String ] relation The association name.
125
+ # @param [ Boolean ] is_eager_load Whether to consider subclasses.
126
+ #
127
+ # @return [ Array<Mongoid::Association::Relatable> ] Matching associations.
128
+ def resolve_inclusion_associations(parent_class, relation, is_eager_load)
129
+ if association = parent_class.reflect_on_association(relation)
130
+ return [ association ]
131
+ end
132
+
133
+ return [] unless is_eager_load
134
+
135
+ parent_class.descendants.filter_map { |sub| sub.reflect_on_association(relation) }
136
+ end
113
137
  end
114
138
 
115
139
  def raise_eager_error(is_eager_load, klass, relation)
@@ -320,6 +320,10 @@ module Mongoid
320
320
  end
321
321
  end
322
322
  end
323
+ # Every query method that takes a user-supplied expression normalizes
324
+ # it here, so this is where the operator guard is enforced. See
325
+ # Selectable#_mongoid_validate_operators! for what it does not cover.
326
+ _mongoid_validate_operators!(result)
323
327
  result
324
328
  end
325
329
 
@@ -491,16 +491,16 @@ module Mongoid
491
491
  !!negating
492
492
  end
493
493
 
494
- # Negate the arguments, or the next selection if no arguments are given.
495
- #
496
- # @example Negate the next selection.
497
- # selectable.not.in(field: [ 1, 2 ])
494
+ # Negate the arguments.
498
495
  #
499
496
  # @example Add the $not criterion.
500
- # selectable.not(name: /Bob/)
497
+ # Person.not(name: /Bob/)
501
498
  #
502
499
  # @example Execute a $not in a where query.
503
- # selectable.where(:field.not => /Bob/)
500
+ # Person.where(:name.not => /Bob/)
501
+ #
502
+ # @example Negate a Criteria instance
503
+ # Person.not(Person.where(...))
504
504
  #
505
505
  # @param [ [ Hash | Criteria ]... ] *criteria The key/value pair
506
506
  # matches or Criteria objects to negate.
@@ -508,6 +508,14 @@ module Mongoid
508
508
  # @return [ Selectable ] The new selectable.
509
509
  def not(*criteria)
510
510
  if criteria.empty?
511
+ # @deprecated
512
+ Mongoid.deprecation_warning(
513
+ :not_sans_arguments,
514
+ 'Calling `#not` without arguments is deprecated, and will be ' \
515
+ 'removed in the next major version. Instead, pass the Hash or ' \
516
+ 'Criteria instance to negate.',
517
+ caller_locations
518
+ )
511
519
  dup.tap { |query| query.negating = !query.negating }
512
520
  else
513
521
  criteria.compact.inject(clone) do |c, new_s|
@@ -768,6 +776,12 @@ module Mongoid
768
776
  # only ever specify one criterion to #where.
769
777
  @criterion = criterion
770
778
  if criterion.is_a?(String)
779
+ unless Mongoid.allow_unsafe_query_operators?
780
+ raise Errors::InvalidQuery,
781
+ "String criteria are not allowed because they compile to the '$where' operator, " \
782
+ 'which is not allowed in a query expression. Set Mongoid.allow_unsafe_query_operators = true ' \
783
+ 'to permit all operators.'
784
+ end
771
785
  query.js_query(criterion)
772
786
  else
773
787
  query.expr_query(criterion)
@@ -775,6 +789,19 @@ module Mongoid
775
789
  end
776
790
  end
777
791
 
792
+ # Operators permitted at the top level of a query expression without
793
+ # opt-in. Excludes $where (JS execution) and other operators not needed
794
+ # for ordinary application queries.
795
+ ALLOWED_QUERY_OPERATORS = %w[
796
+ $and $or $nor $not $text $comment $expr $jsonSchema $alwaysFalse $alwaysTrue
797
+ ].freeze
798
+
799
+ # Operators that execute server-side JavaScript. These are rejected at
800
+ # any depth, not just at the top level: the allowlist above permits
801
+ # $expr and the logical operators, and their values are arbitrary
802
+ # nested expressions that can carry $function or $where.
803
+ JAVASCRIPT_QUERY_OPERATORS = %w[$where $function $accumulator].freeze
804
+
778
805
  # Adds the specified expression to the query.
779
806
  #
780
807
  # Criterion must be a hash in one of the following forms:
@@ -798,12 +825,13 @@ module Mongoid
798
825
  raise Errors::InvalidQuery, "Expression must be a Hash: #{Errors::InvalidQuery.truncate_expr(criterion)}"
799
826
  end
800
827
 
828
+ # The operator guard is applied by _mongoid_expand_keys, which every
829
+ # query method that accepts a user-supplied expression passes through.
801
830
  normalized = _mongoid_expand_keys(criterion)
802
831
  clone.tap do |query|
803
832
  normalized.each do |field, value|
804
833
  field_s = field.to_s
805
834
  if field_s.start_with?('$')
806
- # Query expression-level operator, like $and or $where
807
835
  query.add_operator_expression(field_s, value)
808
836
  else
809
837
  query.add_field_expression(field, value)
@@ -837,6 +865,94 @@ module Mongoid
837
865
 
838
866
  private
839
867
 
868
+ # Enforces the operator rules governed by the
869
+ # +allow_unsafe_query_operators+ configuration option against a
870
+ # normalized query expression.
871
+ #
872
+ # Two rules apply, and both are skipped when the option is true:
873
+ #
874
+ # - An operator at the top level of the expression must appear in
875
+ # ALLOWED_QUERY_OPERATORS.
876
+ # - An operator in JAVASCRIPT_QUERY_OPERATORS is rejected at any depth.
877
+ #
878
+ # This is called from #_mongoid_expand_keys rather than from the
879
+ # individual query methods, because that is the one point every query
880
+ # method taking a user-supplied expression passes through on its way to
881
+ # the selector.
882
+ #
883
+ # It deliberately does not cover the APIs that ask for JavaScript
884
+ # outright, such as #js_query and Criteria#for_js: there the developer
885
+ # has chosen server-side JavaScript, so there is nothing to guard
886
+ # against. The same goes for the low-level Storable methods
887
+ # (#add_field_expression, #add_operator_expression), which write to the
888
+ # selector directly.
889
+ #
890
+ # @param [ Hash ] expr A normalized query expression.
891
+ #
892
+ # @raise [ Errors::InvalidQuery ] If a disallowed operator is present.
893
+ #
894
+ # @api private
895
+ def _mongoid_validate_operators!(expr)
896
+ return if Mongoid.allow_unsafe_query_operators?
897
+
898
+ expr.each_key do |field|
899
+ field_s = field.to_s
900
+ next unless field_s.start_with?('$')
901
+ next if ALLOWED_QUERY_OPERATORS.include?(field_s)
902
+
903
+ raise Errors::InvalidQuery,
904
+ "Operator '#{field_s}' is not allowed in a query expression. " \
905
+ 'Set Mongoid.allow_unsafe_query_operators = true to permit all operators.'
906
+ end
907
+
908
+ _mongoid_validate_no_javascript!(expr)
909
+ end
910
+
911
+ # Walks a query expression looking for operators that execute
912
+ # server-side JavaScript, descending through both hashes and arrays so
913
+ # that nested forms such as {'$expr' => {'$function' => ...}} and
914
+ # {'$or' => [ {'$where' => ...} ]} are caught.
915
+ #
916
+ # The walk does not distinguish operator position from value position,
917
+ # so it also rejects queries where a JavaScript operator name appears as
918
+ # data rather than as an operator. Server 5.0+ permits $-prefixed field
919
+ # names in stored documents, which makes this reachable:
920
+ #
921
+ # Doc.where(payload: { '$eq' => { '$function' => 'abc' } })
922
+ #
923
+ # Here the $eq marks its argument as a literal value to compare, so the
924
+ # server never evaluates it, but the guard raises anyway. The only
925
+ # workaround today is the global allow_unsafe_query_operators flag.
926
+ #
927
+ # TODO: discuss whether to track operator position (skipping the subtree
928
+ # under $eq, $ne, $in, $nin, and $elemMatch values) in a future
929
+ # iteration. It removes the false positive but adds exactly the kind of
930
+ # state that a real bypass could hide in, so it was left out for now.
931
+ #
932
+ # @param [ Object ] object A fragment of a query expression.
933
+ #
934
+ # @raise [ Errors::InvalidQuery ] If a JavaScript operator is present.
935
+ #
936
+ # @api private
937
+ def _mongoid_validate_no_javascript!(object)
938
+ case object
939
+ when Hash
940
+ object.each do |key, value|
941
+ key_s = key.to_s
942
+ if JAVASCRIPT_QUERY_OPERATORS.include?(key_s)
943
+ raise Errors::InvalidQuery,
944
+ "Operator '#{key_s}' executes server-side JavaScript and is not allowed " \
945
+ 'anywhere in a query expression. Set Mongoid.allow_unsafe_query_operators = true ' \
946
+ 'to permit all operators.'
947
+ end
948
+
949
+ _mongoid_validate_no_javascript!(value)
950
+ end
951
+ when Array
952
+ object.each { |value| _mongoid_validate_no_javascript!(value) }
953
+ end
954
+ end
955
+
840
956
  # Force the values of the criterion to be evolved.
841
957
  #
842
958
  # @api private
@@ -7,6 +7,29 @@ module Mongoid
7
7
  #
8
8
  # @api private
9
9
  module Deprecable
10
+ # A Mongoid::Deprecation instance to use for reporting deprecations
11
+ def deprecator
12
+ @deprecator ||= Mongoid::Deprecation.new
13
+ end
14
+
15
+ # Resets all deprecation warnings. For use in tests.
16
+ def reset_deprecation_warnings!
17
+ DEPRECATION_WARNING_MUTEX.synchronize { @deprecation_warnings = {} }
18
+ end
19
+
20
+ # Emits a warning using the current deprecator. If the given warning (as
21
+ # identified by `id`) has already been issued previously, this is a no-op.
22
+ #
23
+ # @param [ Symbol ] id The unique identifier for this warning.
24
+ # @param [ String ] warning The warning message to emit.
25
+ # @param [ Array<Thread::Backtrace::Location> | nil ] callstack The backtrace at the call site.
26
+ def deprecation_warning(id, warning, callstack = nil)
27
+ site = callstack&.first
28
+ deprecation_warning_guard(id, site ? "#{site.path}:#{site.lineno}" : nil) do
29
+ deprecator.warn(warning, callstack)
30
+ end
31
+ end
32
+
10
33
  # Declares method(s) as deprecated.
11
34
  #
12
35
  # @example Deprecate a method.
@@ -25,8 +48,28 @@ module Mongoid
25
48
  # @param [ [ Symbol | Hash<Symbol, [ Symbol | String ]> ]... ] *method_descriptors
26
49
  # The methods to deprecate, with optional replacement instructions.
27
50
  def deprecate(target_module, *method_descriptors)
28
- @_deprecator ||= Mongoid::Deprecation.new
29
- @_deprecator.deprecate_methods(target_module, *method_descriptors)
51
+ deprecator.deprecate_methods(target_module, *method_descriptors)
52
+ end
53
+
54
+ private
55
+
56
+ # The Mutex instance used to guard the deprecation warning flags.
57
+ DEPRECATION_WARNING_MUTEX = Mutex.new
58
+
59
+ # Wraps access to the warnings Hash in a synchronize block. If the given
60
+ # id+callsite has not been warned already, the method will yield to a block and then
61
+ # flag the id. Otherwise, it returns immediately.
62
+ def deprecation_warning_guard(id, callsite)
63
+ DEPRECATION_WARNING_MUTEX.synchronize do
64
+ @deprecation_warnings ||= {}
65
+
66
+ key = "#{id}:#{callsite}"
67
+ return if @deprecation_warnings.key?(key)
68
+
69
+ yield
70
+
71
+ @deprecation_warnings[key] = true
72
+ end
30
73
  end
31
74
  end
32
75
  end
@@ -36,6 +36,52 @@ module Mongoid
36
36
  !encrypt_metadata.empty? || fields.any? { |_, field| field.is_a?(Mongoid::Fields::Encrypted) }
37
37
  end
38
38
 
39
+ # Whether an encryption schema has to be generated for this model.
40
+ #
41
+ # True when the model declares encryption itself, and also when any model
42
+ # reachable through its embeds_one relations does. A model in the second
43
+ # group has no encrypted field of its own, but its collection still needs
44
+ # a schema, otherwise the embedded fields are written in plaintext.
45
+ #
46
+ # The answer is memoized, since this runs on the persistence path.
47
+ # Declaring encryption on a model after it has already been persisted is
48
+ # not supported.
49
+ #
50
+ # @return [ true | false ] Whether the model needs an encryption schema.
51
+ #
52
+ # @api private
53
+ def requires_encryption_schema?
54
+ return @requires_encryption_schema if defined?(@requires_encryption_schema)
55
+
56
+ @requires_encryption_schema = encrypted? || embeds_encrypted?([ self ])
57
+ end
58
+
59
+ # Whether any model reachable through this model's embeds_one relations
60
+ # declares encryption.
61
+ #
62
+ # embeds_many is not considered: libmongocrypt cannot express per-field
63
+ # encryption under array items, so those fields are never mapped.
64
+ #
65
+ # @param [ Array<Class> ] path The models the walk is already inside of.
66
+ # A model embedding itself terminates here.
67
+ #
68
+ # @return [ true | false ] Whether an embedded model is encrypted.
69
+ #
70
+ # @api private
71
+ def embeds_encrypted?(path)
72
+ relations.each_value.any? do |relation|
73
+ next false unless relation.is_a?(Association::Embedded::EmbedsOne)
74
+
75
+ klass = relation.try_relation_class
76
+ # An association target does not have to be a Mongoid document, and
77
+ # the class it names does not have to exist.
78
+ next false unless klass.respond_to?(:encrypted?)
79
+ next false if path.include?(klass)
80
+
81
+ klass.encrypted? || klass.embeds_encrypted?(path + [ klass ])
82
+ end
83
+ end
84
+
39
85
  # Override the key_id for the model.
40
86
  #
41
87
  # This method is solely for testing purposes and should not be used in
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Errors
5
+ # Redacts credentials from a client configuration hash before it is
6
+ # interpolated into an exception message.
7
+ module ConfigRedactor
8
+ extend self
9
+
10
+ REDACTED = '[REDACTED]'
11
+
12
+ # Top-level keys whose values should be replaced wholesale.
13
+ SENSITIVE_KEYS = %w[password auto_encryption_options].freeze
14
+
15
+ # Match the userinfo portion of a MongoDB connection string.
16
+ URI_USERINFO = %r{\A(mongodb(?:\+srv)?://)[^@/]+@}.freeze
17
+
18
+ # Return a copy of the given config hash with sensitive values redacted.
19
+ # Recurses into nested hashes so that, e.g., `:options =>
20
+ # { :auto_encryption_options => ... }` is also covered. Non-hash inputs
21
+ # are returned unchanged.
22
+ def redact(config)
23
+ return config unless config.is_a?(Hash)
24
+
25
+ config.each_with_object({}) do |(key, value), result|
26
+ result[key] = redact_value(key, value)
27
+ end
28
+ end
29
+
30
+ def redact_value(key, value)
31
+ if SENSITIVE_KEYS.include?(key.to_s)
32
+ REDACTED
33
+ elsif key.to_s == 'uri' && value.is_a?(String)
34
+ value.sub(URI_USERINFO, "\\1#{REDACTED}@")
35
+ elsif value.is_a?(Hash)
36
+ redact(value)
37
+ else
38
+ value
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end