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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +69 -0
  3. data/lib/mongoid/association/accessors.rb +27 -5
  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 +65 -101
  14. data/lib/mongoid/association/embedded/batchable.rb +17 -1
  15. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
  16. data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
  17. data/lib/mongoid/association/macros.rb +13 -1
  18. data/lib/mongoid/association/nested/many.rb +34 -5
  19. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  20. data/lib/mongoid/association/nested/one.rb +6 -1
  21. data/lib/mongoid/association/options.rb +17 -0
  22. data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
  23. data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
  24. data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
  25. data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
  26. data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
  27. data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
  28. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  29. data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
  30. data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
  31. data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
  32. data/lib/mongoid/association/referenced/has_one.rb +1 -0
  33. data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
  34. data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
  35. data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
  36. data/lib/mongoid/association/referenced.rb +2 -0
  37. data/lib/mongoid/association/relatable.rb +58 -0
  38. data/lib/mongoid/association.rb +15 -0
  39. data/lib/mongoid/atomic/modifiers.rb +100 -16
  40. data/lib/mongoid/clients/factory.rb +9 -13
  41. data/lib/mongoid/collection_configurable.rb +8 -0
  42. data/lib/mongoid/config/defaults.rb +0 -1
  43. data/lib/mongoid/config/encryption.rb +170 -22
  44. data/lib/mongoid/config.rb +56 -5
  45. data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
  46. data/lib/mongoid/contextual/memory.rb +18 -7
  47. data/lib/mongoid/criteria/includable.rb +35 -11
  48. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  49. data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
  50. data/lib/mongoid/deprecable.rb +45 -2
  51. data/lib/mongoid/document.rb +3 -0
  52. data/lib/mongoid/encryptable.rb +73 -0
  53. data/lib/mongoid/errors/config_redactor.rb +43 -0
  54. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  55. data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
  56. data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
  57. data/lib/mongoid/errors/no_client_database.rb +1 -1
  58. data/lib/mongoid/errors/no_client_hosts.rb +1 -1
  59. data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
  60. data/lib/mongoid/errors/readonly_association.rb +29 -0
  61. data/lib/mongoid/errors.rb +5 -0
  62. data/lib/mongoid/extensions/vector.rb +64 -0
  63. data/lib/mongoid/extensions.rb +1 -0
  64. data/lib/mongoid/field_readable.rb +69 -0
  65. data/lib/mongoid/fields.rb +7 -0
  66. data/lib/mongoid/indexable.rb +38 -26
  67. data/lib/mongoid/matchable.rb +6 -1
  68. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
  69. data/lib/mongoid/matcher/regex.rb +11 -12
  70. data/lib/mongoid/matcher/regexp_budget.rb +372 -0
  71. data/lib/mongoid/matcher.rb +1 -0
  72. data/lib/mongoid/persistable/creatable.rb +120 -14
  73. data/lib/mongoid/persistable/updatable.rb +9 -11
  74. data/lib/mongoid/persistable.rb +15 -15
  75. data/lib/mongoid/persistence_context.rb +77 -0
  76. data/lib/mongoid/search_indexable.rb +69 -24
  77. data/lib/mongoid/serializable.rb +12 -4
  78. data/lib/mongoid/tasks/database.rb +33 -16
  79. data/lib/mongoid/threaded.rb +36 -0
  80. data/lib/mongoid/timestamps/timeless.rb +84 -10
  81. data/lib/mongoid/touchable.rb +28 -1
  82. data/lib/mongoid/traversable.rb +2 -2
  83. data/lib/mongoid/validatable/associated.rb +18 -2
  84. data/lib/mongoid/version.rb +1 -1
  85. data/lib/mongoid/warnings.rb +4 -0
  86. data/spec/integration/app_spec.rb +7 -1
  87. data/spec/integration/associations/belongs_to_spec.rb +13 -0
  88. data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
  89. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +31 -2
  90. data/spec/integration/atomic/modifiers_spec.rb +283 -0
  91. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  92. data/spec/integration/encryption_spec.rb +300 -0
  93. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  94. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  95. data/spec/integration/query_operator_guard_spec.rb +89 -0
  96. data/spec/mongoid/association/accessors_spec.rb +35 -0
  97. data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
  98. data/spec/mongoid/association/eager_spec.rb +2 -2
  99. data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
  100. data/spec/mongoid/association/fallback_spec.rb +425 -0
  101. data/spec/mongoid/association/macros_spec.rb +26 -0
  102. data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
  103. data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
  104. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  105. data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
  106. data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
  107. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  108. data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
  109. data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
  110. data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
  111. data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
  112. data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
  113. data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
  114. data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
  115. data/spec/mongoid/attributes/nested_spec.rb +278 -8
  116. data/spec/mongoid/clients/factory_spec.rb +36 -0
  117. data/spec/mongoid/config/defaults_spec.rb +33 -0
  118. data/spec/mongoid/config/encryption_spec.rb +451 -0
  119. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  120. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  121. data/spec/mongoid/criteria/includable_spec.rb +1221 -0
  122. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
  123. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
  124. data/spec/mongoid/criteria_spec.rb +3 -0
  125. data/spec/mongoid/encryptable_spec.rb +115 -0
  126. data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
  127. data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
  128. data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
  129. data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
  130. data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
  131. data/spec/mongoid/extensions/vector_spec.rb +175 -0
  132. data/spec/mongoid/fields_spec.rb +8 -0
  133. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  134. data/spec/mongoid/search_indexable_spec.rb +321 -9
  135. data/spec/mongoid/tasks/database_spec.rb +31 -0
  136. data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
  137. data/spec/mongoid/touchable_spec.rb +242 -0
  138. data/spec/mongoid/touchable_spec_models.rb +47 -0
  139. data/spec/mongoid/validatable/associated_spec.rb +19 -0
  140. data/spec/spec_helper.rb +5 -1
  141. data/spec/support/crypt/models.rb +253 -0
  142. data/spec/support/models/modifier_models.rb +65 -0
  143. metadata +55 -2
@@ -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
@@ -410,6 +410,8 @@ module Mongoid
410
410
  def instantiate_document(attrs = nil, selected_fields = nil, options = {}, &block)
411
411
  execute_callbacks = options.fetch(:execute_callbacks, Threaded.execute_callbacks?)
412
412
  attributes = attrs.to_h
413
+ preloaded_fields = attributes.keys.select { |key| key.start_with?(Association::PRELOADED_PREFIX) }
414
+ preloaded = attributes.extract!(*preloaded_fields)
413
415
 
414
416
  doc = allocate
415
417
  doc.__selected_fields = selected_fields
@@ -417,6 +419,7 @@ module Mongoid
417
419
  doc.instance_variable_set(:@attributes_before_type_cast, attributes.dup)
418
420
 
419
421
  doc._handle_callbacks_after_instantiation(execute_callbacks, &block)
422
+ doc.load_preloaded_associations(preloaded)
420
423
 
421
424
  doc.remember_storage_options!
422
425
  doc
@@ -36,6 +36,79 @@ 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
+ # This runs on the persistence path, so the answer is memoized. A true
47
+ # answer is always memoized. A false answer is memoized only when every
48
+ # embeds_one target in the walk resolved to a class; if one could not be
49
+ # resolved yet, the answer is recomputed next time, so a class loaded
50
+ # later is still detected. Declaring encryption on a model after it has
51
+ # already been persisted is not supported.
52
+ #
53
+ # @return [ true | false ] Whether the model needs an encryption schema.
54
+ #
55
+ # @api private
56
+ def requires_encryption_schema?
57
+ return @requires_encryption_schema if defined?(@requires_encryption_schema)
58
+ return @requires_encryption_schema = true if encrypted?
59
+
60
+ found, complete = embedded_encryption_scan([ self ])
61
+ @requires_encryption_schema = found if found || complete
62
+ found
63
+ end
64
+
65
+ # Walks the models reachable through this model's embeds_one relations,
66
+ # looking for one that declares encryption. Subclasses of an
67
+ # association target are included: any of them can be assigned to the
68
+ # association.
69
+ #
70
+ # embeds_many is not considered: libmongocrypt cannot express per-field
71
+ # encryption under array items, so those fields are never mapped.
72
+ #
73
+ # @param [ Array<Class> ] path The models the walk is already inside of.
74
+ # A model embedding itself terminates here.
75
+ #
76
+ # @return [ Array<true | false> ] A pair: whether an embedded model is
77
+ # encrypted, and whether every embeds_one target the walk needed
78
+ # resolved to a class. The second value is only meaningful when the
79
+ # first is false.
80
+ #
81
+ # @api private
82
+ def embedded_encryption_scan(path)
83
+ complete = true
84
+
85
+ relations.each_value do |relation|
86
+ next unless relation.is_a?(Association::Embedded::EmbedsOne)
87
+
88
+ klass = relation.try_relation_class
89
+ # The class an association names does not have to exist yet.
90
+ if klass.nil?
91
+ complete = false
92
+ next
93
+ end
94
+
95
+ # An association target does not have to be a Mongoid document.
96
+ next unless klass.respond_to?(:encrypted?)
97
+
98
+ ([ klass ] + klass.descendants).uniq.each do |candidate|
99
+ next if path.include?(candidate)
100
+ return [ true, true ] if candidate.encrypted?
101
+
102
+ found, nested_complete = candidate.embedded_encryption_scan(path + [ candidate ])
103
+ return [ true, true ] if found
104
+
105
+ complete &&= nested_complete
106
+ end
107
+ end
108
+
109
+ [ false, complete ]
110
+ end
111
+
39
112
  # Override the key_id for the model.
40
113
  #
41
114
  # 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
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Errors
5
+ # This error is raised when evaluating a query in memory exceeds
6
+ # Mongoid::Config.in_memory_regexp_time_limit.
7
+ #
8
+ # What the limit bounds depends on the Ruby in use. Where per-Regexp
9
+ # timeouts are available it is the time spent executing regular
10
+ # expressions; elsewhere it is the elapsed time of the whole in-memory
11
+ # evaluation. The message is worded to hold either way.
12
+ class InMemoryRegexpTimeout < MongoidError
13
+ # Create the new error.
14
+ #
15
+ # @example Create the new in-memory regexp timeout error.
16
+ # InMemoryRegexpTimeout.new(5.0)
17
+ #
18
+ # @param [ Float ] limit The limit that was exceeded, in seconds. Not
19
+ # always the configured one: a global Regexp.timeout stricter than the
20
+ # configuration takes its place.
21
+ def initialize(limit)
22
+ super(compose_message('in_memory_regexp_timeout', limit: limit))
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Errors
5
+ # This error is raised when two models writing to the same namespace
6
+ # describe a field, or the collection metadata, differently. The schema map
7
+ # has one entry per namespace, so the two descriptions cannot both be
8
+ # honored.
9
+ class InvalidEncryptionSchema < MongoidError
10
+ # Create the new error.
11
+ #
12
+ # @example Create the error.
13
+ # InvalidEncryptionSchema.new('music.bands', 'properties.code')
14
+ #
15
+ # @param [ String | nil ] namespace The namespace being described.
16
+ # @param [ String ] path The conflicting path in the schema.
17
+ def initialize(namespace, path)
18
+ super(
19
+ compose_message(
20
+ 'invalid_encryption_schema',
21
+ { namespace: namespace || 'the collection', path: path }
22
+ )
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -16,7 +16,7 @@ module Mongoid
16
16
  super(
17
17
  compose_message(
18
18
  'mixed_client_configuration',
19
- { name: name, config: config }
19
+ { name: name, config: ConfigRedactor.redact(config) }
20
20
  )
21
21
  )
22
22
  end
@@ -15,7 +15,7 @@ module Mongoid
15
15
  super(
16
16
  compose_message(
17
17
  'no_client_database',
18
- { name: name, config: config }
18
+ { name: name, config: ConfigRedactor.redact(config) }
19
19
  )
20
20
  )
21
21
  end
@@ -15,7 +15,7 @@ module Mongoid
15
15
  super(
16
16
  compose_message(
17
17
  'no_client_hosts',
18
- { name: name, config: config }
18
+ { name: name, config: ConfigRedactor.redact(config) }
19
19
  )
20
20
  )
21
21
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Errors
5
+ # This error is raised when a model declares encrypted fields, but the
6
+ # namespace it is about to be persisted to is not covered by the automatic
7
+ # encryption schema of the client in use. Without a schema the driver has
8
+ # nothing to encrypt with, and the fields would be stored in plaintext.
9
+ class NoEncryptionSchema < MongoidError
10
+ # Create the new error.
11
+ #
12
+ # @example Create the error.
13
+ # NoEncryptionSchema.new(Band, 'music.bands', :default)
14
+ #
15
+ # @param [ Class ] klass The model class.
16
+ # @param [ String ] namespace The namespace the model resolved to.
17
+ # @param [ String | Symbol ] client The name of the client in use.
18
+ # @param [ String | nil ] field The encrypted field missing from the
19
+ # schema entry, if the namespace itself is covered.
20
+ def initialize(klass, namespace, client, field = nil)
21
+ super(
22
+ compose_message(
23
+ field ? 'no_encryption_schema_field' : 'no_encryption_schema',
24
+ { klass: klass, namespace: namespace, client: client, field: field }
25
+ )
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Errors
5
+ # Raised when attempting to write to a :through association, which is
6
+ # read-only.
7
+ class ReadonlyAssociation < MongoidError
8
+ # Instantiate the exception.
9
+ #
10
+ # @example Create the error.
11
+ # ReadonlyAssociation.new(Physician, association)
12
+ #
13
+ # @param [ Class ] klass The owner class.
14
+ # @param [ Mongoid::Association::Relatable ] association The through association.
15
+ def initialize(klass, association)
16
+ super(
17
+ compose_message(
18
+ 'readonly_association',
19
+ {
20
+ klass: klass,
21
+ name: association.name,
22
+ through: association.options[:through]
23
+ }
24
+ )
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'mongoid/errors/mongoid_error'
4
+ require 'mongoid/errors/config_redactor'
4
5
  require 'mongoid/errors/ambiguous_relationship'
5
6
  require 'mongoid/errors/attribute_not_loaded'
6
7
  require 'mongoid/errors/callback'
@@ -11,6 +12,7 @@ require 'mongoid/errors/document_not_found'
11
12
  require 'mongoid/errors/empty_config_file'
12
13
  require 'mongoid/errors/immutable_attribute'
13
14
  require 'mongoid/errors/in_memory_collation_not_supported'
15
+ require 'mongoid/errors/in_memory_regexp_timeout'
14
16
  require 'mongoid/errors/invalid_auto_encryption_configuration'
15
17
  require 'mongoid/errors/invalid_around_callback'
16
18
  require 'mongoid/errors/invalid_async_query_executor'
@@ -18,6 +20,7 @@ require 'mongoid/errors/invalid_collection'
18
20
  require 'mongoid/errors/invalid_config_file'
19
21
  require 'mongoid/errors/invalid_config_option'
20
22
  require 'mongoid/errors/invalid_dependent_strategy'
23
+ require 'mongoid/errors/invalid_encryption_schema'
21
24
  require 'mongoid/errors/invalid_field'
22
25
  require 'mongoid/errors/invalid_field_option'
23
26
  require 'mongoid/errors/invalid_field_type'
@@ -51,6 +54,7 @@ require 'mongoid/errors/mixed_client_configuration'
51
54
  require 'mongoid/errors/nested_attributes_metadata_not_found'
52
55
  require 'mongoid/errors/no_default_client'
53
56
  require 'mongoid/errors/no_environment'
57
+ require 'mongoid/errors/no_encryption_schema'
54
58
  require 'mongoid/errors/no_map_reduce_output'
55
59
  require 'mongoid/errors/no_metadata'
56
60
  require 'mongoid/errors/no_parent'
@@ -60,6 +64,7 @@ require 'mongoid/errors/no_client_database'
60
64
  require 'mongoid/errors/no_client_hosts'
61
65
  require 'mongoid/errors/readonly_attribute'
62
66
  require 'mongoid/errors/readonly_document'
67
+ require 'mongoid/errors/readonly_association'
63
68
  require 'mongoid/errors/rollback'
64
69
  require 'mongoid/errors/sessions_not_supported'
65
70
  require 'mongoid/errors/scope_overwrite'