From 8aa2e906866980f3bf55e2c0b0d1fbeb6fa0695a Mon Sep 17 00:00:00 2001 From: johnnyshields Date: Sun, 23 Apr 2023 05:20:01 +0900 Subject: [PATCH] Better handling of type casting --- lib/mongoid/attributes.rb | 29 +++++++++ lib/mongoid/config.rb | 8 +++ lib/mongoid/config/encryption.rb | 2 +- .../criteria/queryable/extensions/array.rb | 2 +- lib/mongoid/criteria/queryable/selector.rb | 28 +++++---- lib/mongoid/errors/invalid_type_assignment.rb | 24 +++++++ lib/mongoid/extensions.rb | 2 +- lib/mongoid/extensions/array.rb | 2 + lib/mongoid/extensions/big_decimal.rb | 10 ++- lib/mongoid/extensions/binary.rb | 1 + lib/mongoid/extensions/boolean.rb | 2 + lib/mongoid/extensions/date.rb | 21 ++++--- lib/mongoid/extensions/float.rb | 6 +- lib/mongoid/extensions/hash.rb | 2 + lib/mongoid/extensions/integer.rb | 15 +++-- lib/mongoid/extensions/range.rb | 1 + lib/mongoid/extensions/raw_value.rb | 62 +++++++++++++++++-- lib/mongoid/extensions/regexp.rb | 20 +++--- lib/mongoid/extensions/set.rb | 1 + lib/mongoid/extensions/string.rb | 4 +- .../{ => extensions}/stringified_symbol.rb | 20 +++--- lib/mongoid/extensions/symbol.rb | 4 +- lib/mongoid/extensions/time.rb | 12 ++-- lib/mongoid/fields/localized.rb | 2 +- lib/mongoid/fields/standard.rb | 50 ++++++++++++++- 25 files changed, 260 insertions(+), 70 deletions(-) create mode 100644 lib/mongoid/errors/invalid_type_assignment.rb rename lib/mongoid/{ => extensions}/stringified_symbol.rb (83%) diff --git a/lib/mongoid/attributes.rb b/lib/mongoid/attributes.rb index a439244a2..8b75d16c8 100644 --- a/lib/mongoid/attributes.rb +++ b/lib/mongoid/attributes.rb @@ -169,6 +169,8 @@ def write_attribute(name, value) if attribute_writable?(field_name) _assigning do + # TODO: remove this + # validate_attribute_value(field_name, value) localized = fields[field_name].try(:localized?) attributes_before_type_cast[name.to_s] = value typed_value = typed_value_for(field_name, value) @@ -352,6 +354,33 @@ def unalias_attribute(name) end end + private + + # Validates an attribute value as being assignable to the specified field. + # + # For now, only Hash and Array fields are validated, and the value is + # being checked to be of an appropriate type (i.e. either Hash or Array, + # respectively, or nil). + # + # This method takes the name of the field as stored in the document + # in the database, not (necessarily) the Ruby method name used to read/write + # the said field. + # + # @param [ String, Symbol ] field_name The name of the field. + # @param [ Object ] value The value to be validated. + # TODO: remove this + # def validate_attribute_value(field_name, value) + # return if value.nil? + # field = fields[field_name] + # return unless field + # validatable_types = [ Hash, Array ] + # if validatable_types.include?(field.type) + # unless value.is_a?(field.type) + # raise Mongoid::Errors::InvalidAttributeAssignment.new(field.type, value.class) + # end + # end + # end + def lookup_attribute_presence(name, value) if localized_fields.key?(name) && value value = localized_fields[name].send(:lookup, value) diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index faefc33a6..eaff42486 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -69,6 +69,14 @@ module Config # existing method. option :scope_overwrite_exception, default: false + # Indicates whether or not to raise an error when attempting + # to assign an incompatible type to a field. + option :raise_invalid_type_assignment_error, default: false + + # Indicates whether uncastable values from the database should + # be returned wrapped by Mongoid::RawValue class. + option :wrap_uncastable_database_values, default: false + # Return stored times as UTC. option :use_utc, default: false diff --git a/lib/mongoid/config/encryption.rb b/lib/mongoid/config/encryption.rb index edb58ee10..7949a2cba 100644 --- a/lib/mongoid/config/encryption.rb +++ b/lib/mongoid/config/encryption.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'mongoid/extensions/boolean' -require 'mongoid/stringified_symbol' +require 'mongoid/extensions/stringified_symbol' module Mongoid module Config diff --git a/lib/mongoid/criteria/queryable/extensions/array.rb b/lib/mongoid/criteria/queryable/extensions/array.rb index b8b79dcc9..03e301c31 100644 --- a/lib/mongoid/criteria/queryable/extensions/array.rb +++ b/lib/mongoid/criteria/queryable/extensions/array.rb @@ -136,7 +136,7 @@ def evolve(object) when ::Array, ::Set object.map { |obj| obj.class.evolve(obj) } else - object + Mongoid::RawValue(object, 'Array') end end end diff --git a/lib/mongoid/criteria/queryable/selector.rb b/lib/mongoid/criteria/queryable/selector.rb index 0c7903c4b..7a41caca9 100644 --- a/lib/mongoid/criteria/queryable/selector.rb +++ b/lib/mongoid/criteria/queryable/selector.rb @@ -147,18 +147,24 @@ def evolve_multi(specs) # # @return [ Object ] The serialized object. def evolve(serializer, value) - case value - when Mongoid::RawValue - value.raw_value - when Hash - evolve_hash(serializer, value) - when Array - evolve_array(serializer, value) - when Range - value.__evolve_range__(serializer: serializer) - else - (serializer || value.class).evolve(value) + _value = case value + when Mongoid::RawValue + value.raw_value + when Hash + evolve_hash(serializer, value) + when Array + evolve_array(serializer, value) + when Range + value.__evolve_range__(serializer: serializer) + else + (serializer || value.class).evolve(value) + end + + while _value.is_a?(Mongoid::RawValue) do + _value = _value.raw_value end + + _value end # Evolve a single key selection with array values. diff --git a/lib/mongoid/errors/invalid_type_assignment.rb b/lib/mongoid/errors/invalid_type_assignment.rb new file mode 100644 index 000000000..5ccbcc4ec --- /dev/null +++ b/lib/mongoid/errors/invalid_type_assignment.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mongoid + module Errors + + # This exception is raised when attempting to assign a field value + # which cannot be cast to field type. + class InvalidAttributeAssignment < MongoidError + + # Create the new invalid attribute assignment error. + # + # @example Create the new invalid date error. + # InvalidAttributeAssignment.new('Integer', 'String') + # + # @param [ String | Class ] field_type The type of the field that was + # attempted to be assigned. + # @param [ String | Class ] value_class The class of the value that was + # attempted to be assigned. + def initialize(field_type, value_class) + super(compose_message('invalid_attribute_assignment', { field_type: field_type.to_s, value_class: value_class.to_s })) + end + end + end +end diff --git a/lib/mongoid/extensions.rb b/lib/mongoid/extensions.rb index 8e18df3a1..efe7bc91a 100644 --- a/lib/mongoid/extensions.rb +++ b/lib/mongoid/extensions.rb @@ -20,7 +20,7 @@ require 'mongoid/extensions/regexp' require 'mongoid/extensions/set' require 'mongoid/extensions/string' -require 'mongoid/stringified_symbol' +require 'mongoid/extensions/stringified_symbol' require 'mongoid/extensions/symbol' require 'mongoid/extensions/time' require 'mongoid/extensions/time_with_zone' diff --git a/lib/mongoid/extensions/array.rb b/lib/mongoid/extensions/array.rb index 62855fd9e..78e9b140e 100644 --- a/lib/mongoid/extensions/array.rb +++ b/lib/mongoid/extensions/array.rb @@ -153,6 +153,8 @@ def mongoize(object) case object when ::Array, ::Set object.map(&:mongoize) + else + Mongoid::RawValue(object, 'Array') end end diff --git a/lib/mongoid/extensions/big_decimal.rb b/lib/mongoid/extensions/big_decimal.rb index e10a82302..ca778f4de 100644 --- a/lib/mongoid/extensions/big_decimal.rb +++ b/lib/mongoid/extensions/big_decimal.rb @@ -74,13 +74,17 @@ def mongoize(object) BSON::Decimal128.new(object) elsif object.numeric? BSON::Decimal128.new(object.to_s) - elsif !object.is_a?(String) - object.try(:to_d) + elsif !object.is_a?(String) && object.respond_to?(:to_d) + object.to_d + else + Mongoid::RawValue(object, 'BigDecimal') end elsif object.is_a?(BSON::Decimal128) || object.numeric? object.to_s - elsif !object.is_a?(String) + elsif !object.is_a?(String) && object.respond_to?(:to_d) object.try(:to_d)&.to_s + else + Mongoid::RawValue(object, 'BigDecimal') end end end diff --git a/lib/mongoid/extensions/binary.rb b/lib/mongoid/extensions/binary.rb index f0de35a53..01e49050b 100644 --- a/lib/mongoid/extensions/binary.rb +++ b/lib/mongoid/extensions/binary.rb @@ -33,6 +33,7 @@ def mongoize(object) case object when BSON::Binary then object when String, Symbol then BSON::Binary.new(object.to_s) + else Mongoid::RawValue(object, 'BSON::Binary') end end alias_method :demongoize, :mongoize diff --git a/lib/mongoid/extensions/boolean.rb b/lib/mongoid/extensions/boolean.rb index c84c5ae20..7b290b099 100644 --- a/lib/mongoid/extensions/boolean.rb +++ b/lib/mongoid/extensions/boolean.rb @@ -23,6 +23,8 @@ def mongoize(object) true elsif object.to_s&.match?(FALSY_VALUES) false + else + Mongoid::RawValue(object, 'Boolean') end end alias_method :demongoize, :mongoize diff --git a/lib/mongoid/extensions/date.rb b/lib/mongoid/extensions/date.rb index 22f36367e..a41ac5105 100644 --- a/lib/mongoid/extensions/date.rb +++ b/lib/mongoid/extensions/date.rb @@ -68,19 +68,22 @@ def demongoize(object) def mongoize(object) return if object.blank? - begin - time = if object.is_a?(String) - # https://jira.mongodb.org/browse/MONGOID-4460 - ::Time.parse(object) - else - object.__mongoize_time__ - end + time = begin + if object.is_a?(String) + # https://jira.mongodb.org/browse/MONGOID-4460 + ::Time.parse(object) + else + object.__mongoize_time__ + end rescue ArgumentError nil end - return unless time.acts_like?(:time) - ::Time.utc(time.year, time.month, time.day) + if time&.acts_like?(:time) + return ::Time.utc(time.year, time.month, time.day) + end + + Mongoid::RawValue(object, 'Date') end end end diff --git a/lib/mongoid/extensions/float.rb b/lib/mongoid/extensions/float.rb index 6a6672128..bdbf4d29c 100644 --- a/lib/mongoid/extensions/float.rb +++ b/lib/mongoid/extensions/float.rb @@ -40,10 +40,10 @@ module ClassMethods def mongoize(object) return if object.blank? - if object.is_a?(String) - object.to_f if object.numeric? + if (object.is_a?(String) && object.numeric?) || object.respond_to?(:to_f) + object.to_f else - object.try(:to_f) + Mongoid::RawValue(object, 'Float') end end alias_method :demongoize, :mongoize diff --git a/lib/mongoid/extensions/hash.rb b/lib/mongoid/extensions/hash.rb index c546d80f8..87d65422f 100644 --- a/lib/mongoid/extensions/hash.rb +++ b/lib/mongoid/extensions/hash.rb @@ -230,6 +230,8 @@ def mongoize(object) object.dup.transform_values!(&:mongoize) when Hash BSON::Document.new(object.transform_values(&:mongoize)) + else + Mongoid::RawValue(object, 'Hash') end end diff --git a/lib/mongoid/extensions/integer.rb b/lib/mongoid/extensions/integer.rb index 9b6f2b2fa..e7221d287 100644 --- a/lib/mongoid/extensions/integer.rb +++ b/lib/mongoid/extensions/integer.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true +# rubocop:todo all module Mongoid module Extensions - - # Adds type-casting behavior to Integer class. module Integer # Converts the integer into a time as the number of seconds since the epoch. @@ -48,17 +47,17 @@ module ClassMethods def mongoize(object) return if object.blank? - if object.is_a?(String) - object.to_i if object.numeric? + if (object.is_a?(String) && object.numeric?) || object.respond_to?(:to_i) + object.to_i else - object.try(:to_i) + Mongoid::RawValue(object, 'Integer') end end - alias_method :demongoize, :mongoize + alias :demongoize :mongoize end end end end -Integer.include Mongoid::Extensions::Integer -Integer.extend(Mongoid::Extensions::Integer::ClassMethods) +::Integer.__send__(:include, Mongoid::Extensions::Integer) +::Integer.extend(Mongoid::Extensions::Integer::ClassMethods) diff --git a/lib/mongoid/extensions/range.rb b/lib/mongoid/extensions/range.rb index dd2fa7061..b5971dee3 100644 --- a/lib/mongoid/extensions/range.rb +++ b/lib/mongoid/extensions/range.rb @@ -79,6 +79,7 @@ def mongoize(object) case object when Hash then __mongoize_hash__(object) when Range then __mongoize_range__(object) + else Mongoid::RawValue(object, 'Range') end end diff --git a/lib/mongoid/extensions/raw_value.rb b/lib/mongoid/extensions/raw_value.rb index e96174a08..5fcec6b5a 100644 --- a/lib/mongoid/extensions/raw_value.rb +++ b/lib/mongoid/extensions/raw_value.rb @@ -1,25 +1,45 @@ # frozen_string_literal: true -# Wrapper class used when a value cannot be casted in evolve method. +# Wrapper class used when a value cannot be casted by the +# mongoize, demongoize, and evolve methods. module Mongoid - # Instantiates a new Mongoid::RawValue object. Used as a syntax shortcut. + # Instantiates a new Mongoid::RawValue object. Used as a + # syntax shortcut. # # @example Create a Mongoid::RawValue object. # Mongoid::RawValue("Beagle") # + # @param [ Object ] raw_value The underlying raw object. + # @param [ String ] cast_class_name The name of the class + # to which the raw value is intended to be cast. + # # @return [ Mongoid::RawValue ] The object. - def RawValue(*args) # rubocop:disable Naming/MethodName - RawValue.new(*args) + def RawValue(raw_value, cast_class_name = nil) # rubocop:disable Naming/MethodName + return if raw_value.nil? + + RawValue.new(raw_value, cast_class_name) end # Represents a value which cannot be type-casted between Ruby and MongoDB. class RawValue - attr_reader :raw_value + attr_reader :raw_value, + :cast_class_name - def initialize(raw_value) + # Instantiates a new Mongoid::RawValue object. + # + # @example Create a Mongoid::RawValue object. + # Mongoid::RawValue.new("Beagle", "String") + # + # @param [ Object ] raw_value The underlying raw object. + # @param [ String ] cast_class_name The name of the class + # to which the raw value is intended to be cast. + # + # @return [ Mongoid::RawValue ] The object. + def initialize(raw_value, cast_class_name = nil) @raw_value = raw_value + @cast_class_name = cast_class_name end # Returns a string containing a human-readable representation of @@ -29,5 +49,35 @@ def initialize(raw_value) def inspect "RawValue: #{raw_value.inspect}" end + + # Raises a Mongoid::Errors::InvalidValue error. + def raise_error! + raise Mongoid::Errors::InvalidValue.new(raw_value.class.name, cast_class_name) + end + + # Logs a warning that a value cannot be cast. + def warn + Mongoid.logger.warn("Cannot cast #{raw_value.class.name} to #{cast_class_name}; returning nil") + end + + # Delegate all missing methods to the raw value. + # + # @param [ String, Symbol ] method_name The name of the method. + # @param [ Array ] args The arguments passed to the method. + # + # @return [ Object ] The method response. + ruby2_keywords def method_missing(method_name, *args, &block) + raw_value.send(method_name, *args, &block) + end + + # Delegate all missing methods to the raw value. + # + # @param [ String, Symbol ] method_name The name of the method. + # @param [ true | false ] include_private Whether to check private methods. + # + # @return [ true | false ] Whether the raw value object responds to the method. + def respond_to_missing?(method_name, include_private = false) + raw_value.respond_to?(method_name, include_private) + end end end diff --git a/lib/mongoid/extensions/regexp.rb b/lib/mongoid/extensions/regexp.rb index e3c9df0cf..4bd33b47c 100644 --- a/lib/mongoid/extensions/regexp.rb +++ b/lib/mongoid/extensions/regexp.rb @@ -20,13 +20,19 @@ module ClassMethods def mongoize(object) return if object.nil? - case object - when String then ::Regexp.new(object) - when ::Regexp then object - when BSON::Regexp::Raw then object.compile - end - rescue RegexpError - nil + value = begin + case object + when String then ::Regexp.new(object) + when ::Regexp then object + when BSON::Regexp::Raw then object.compile + end + rescue RegexpError + nil + end + + return value if value + + Mongoid::RawValue(object, 'Regexp') end alias_method :demongoize, :mongoize end diff --git a/lib/mongoid/extensions/set.rb b/lib/mongoid/extensions/set.rb index 13219c360..a8732aa3a 100644 --- a/lib/mongoid/extensions/set.rb +++ b/lib/mongoid/extensions/set.rb @@ -49,6 +49,7 @@ def mongoize(object) case object when ::Set then ::Array.mongoize(object.to_a).uniq when ::Array then ::Array.mongoize(object).uniq + else Mongoid::RawValue(object, 'Set') end end end diff --git a/lib/mongoid/extensions/string.rb b/lib/mongoid/extensions/string.rb index d35849824..9f6d195a7 100644 --- a/lib/mongoid/extensions/string.rb +++ b/lib/mongoid/extensions/string.rb @@ -161,7 +161,9 @@ module ClassMethods # # @return [ String ] The object mongoized. def mongoize(object) - object.try(:to_s) + return if object.nil? + return object.to_s if object.respond_to?(:to_s) + Mongoid::RawValue.new(object, 'String') end alias_method :demongoize, :mongoize end diff --git a/lib/mongoid/stringified_symbol.rb b/lib/mongoid/extensions/stringified_symbol.rb similarity index 83% rename from lib/mongoid/stringified_symbol.rb rename to lib/mongoid/extensions/stringified_symbol.rb index 3d7b5ba98..80ba60eae 100644 --- a/lib/mongoid/stringified_symbol.rb +++ b/lib/mongoid/extensions/stringified_symbol.rb @@ -19,11 +19,11 @@ class << self # # @api private def demongoize(object) - if object.nil? - object - else - object.to_s.to_sym - end + return if object.nil? + + return object.to_s.to_sym if object.respond_to?(:to_s) + + Mongoid::RawValue.new(object, 'Symbol') end # Turn the object from the Ruby type into the type @@ -38,11 +38,11 @@ def demongoize(object) # # @api private def mongoize(object) - if object.nil? - object - else - object.to_s - end + return if object.nil? + + return object.to_s if object.respond_to?(:to_s) + + Mongoid::RawValue.new(object, 'String') end # Turn the object from the Ruby type into the type diff --git a/lib/mongoid/extensions/symbol.rb b/lib/mongoid/extensions/symbol.rb index e0da88777..5a7ec4da7 100644 --- a/lib/mongoid/extensions/symbol.rb +++ b/lib/mongoid/extensions/symbol.rb @@ -28,7 +28,9 @@ module ClassMethods # # @return [ Symbol | nil ] The object mongoized or nil. def mongoize(object) - object.try(:to_sym) + return if object.nil? + return object.to_sym if object.respond_to?(:to_sym) + Mongoid::RawValue.new(object, 'Symbol') end alias_method :demongoize, :mongoize end diff --git a/lib/mongoid/extensions/time.rb b/lib/mongoid/extensions/time.rb index 1fd34bb09..0d7c6257d 100644 --- a/lib/mongoid/extensions/time.rb +++ b/lib/mongoid/extensions/time.rb @@ -84,13 +84,13 @@ def demongoize(object) def mongoize(object) return if object.blank? - begin - time = object.__mongoize_time__ - rescue ArgumentError - return - end + time = begin + object.__mongoize_time__ + rescue ArgumentError + nil + end - return unless time.acts_like?(:time) + return Mongoid::RawValue.new(object, 'Time') unless time&.acts_like?(:time) if object.respond_to?(:sec_fraction) ::Time.at(time.to_i, object.sec_fraction * (10**6)).utc diff --git a/lib/mongoid/fields/localized.rb b/lib/mongoid/fields/localized.rb index 5ea89445c..290dccba5 100644 --- a/lib/mongoid/fields/localized.rb +++ b/lib/mongoid/fields/localized.rb @@ -56,7 +56,7 @@ def localize_present? # # @return [ Hash ] The locale with string translation. def mongoize(object) - { ::I18n.locale.to_s => type.mongoize(object) } + { ::I18n.locale.to_s => super(object) } end private diff --git a/lib/mongoid/fields/standard.rb b/lib/mongoid/fields/standard.rb index 238a4916b..0e2db1008 100644 --- a/lib/mongoid/fields/standard.rb +++ b/lib/mongoid/fields/standard.rb @@ -13,7 +13,32 @@ class Standard # Set readers for the instance variables. attr_accessor :default_val, :label, :name, :options - def_delegators :type, :demongoize, :evolve, :mongoize + # If type.mongoize returns Mongoid::RawValue, + # handle according to field or global strict setting + def mongoize(object) + value = type.mongoize(object) + if value.is_a?(Mongoid::RawValue) + case strict + when :error then value.raise_error! + when :warn then value.warn and return nil + when :suppress then return nil + # when :defer, assign the Mongoid::RawValue and fail when trying to persist. + end + end + value + end + + # If type.demongoize returns Mongoid::RawValue, + # return the inner value according to Mongoid.wrap_uncastable_values_from_database + def demongoize(object) + value = type.demongoize(object) + if value.is_a?(Mongoid::RawValue) && !Mongoid.wrap_uncastable_values_from_database + return value.raw_value + end + value + end + + def_delegators :type, :evolve # Adds the atomic changes for this type of resizable field. # @@ -111,6 +136,29 @@ def localize_present? false end + # Whether or not the field raises an error if a non-castable + # type is assignment. + # + # @example Get the type. + # field.type + # + # @return [ :error | :warn | :suppress ] The value. True means raise + # an error. False means handle as nil. + def strict + return @strict if defined?(@strict) + if options.key?(:strict) + @strict = case options[:strict] + when true, :error then :error + when false, :suppress then :suppress + when :warn then :warn + end + end + # TODO: add default. Array/Hash should be strict. + # Also consider global + # Don't memoize default? + # Support warn option + end + # Get the metadata for the field if its a foreign key. # # @example Get the metadata.