diff --git a/lib/prism/orm/type_casting.rb b/lib/prism/orm/type_casting.rb new file mode 100644 index 0000000..4dcc4f7 --- /dev/null +++ b/lib/prism/orm/type_casting.rb @@ -0,0 +1,91 @@ +require 'time' + +module Prism + module Orm + # ActiveModel::Type-lite: coerces a raw value (almost always a String, + # since every value that ever crosses an HTTP request - query string, + # form body, JSON string fields typed as numbers-in-quotes - arrives as + # one) into the Ruby type an attribute is declared to hold. + # + # Kept deliberately separate from Store/Migration's column *names*: + # a migration's `t.integer :views` has never done anything with the + # type beyond documenting intent (TableDefinition#column discards it), + # and this module does not change that. Casting here is opt-in per + # attribute via `Attributes.attribute` - it does not infer behavior + # from a migration's declared column type. + module TypeCasting + class InvalidCast < StandardError; end + + TRUE_STRINGS = %w[1 t true y yes on].freeze + FALSE_STRINGS = %w[0 f false n no off].freeze + + module_function + + def cast(type, value) + return nil if value.nil? + + case type + when :string, :text then cast_string(value) + when :integer then cast_integer(value) + when :float then cast_float(value) + when :boolean then cast_boolean(value) + when :datetime then cast_datetime(value) + else raise ArgumentError, "unknown attribute type #{type.inspect}" + end + end + + def cast_string(value) + value.is_a?(String) ? value : value.to_s + end + + # Blank input ("" left in an optional numeric form field) casts to + # nil rather than 0 - the two mean different things (unknown vs. + # zero) and collapsing them would silently corrupt any numericality + # validation or aggregate downstream. Anything non-blank that is not + # a real integer is a boundary violation (malformed request/API + # misuse), not a value worth guessing at, so it raises loudly - the + # same posture this ORM already takes for StaleObjectError/ + # UniqueConstraintViolation rather than swallowing the problem. + def cast_integer(value) + return value if value.is_a?(Integer) + return value.to_i if value.is_a?(Numeric) + str = value.to_s.strip + return nil if str.empty? + Integer(str, 10) + rescue ArgumentError + raise InvalidCast, "#{value.inspect} is not a valid :integer" + end + + def cast_float(value) + return value if value.is_a?(Float) + return value.to_f if value.is_a?(Numeric) + str = value.to_s.strip + return nil if str.empty? + Float(str) + rescue ArgumentError + raise InvalidCast, "#{value.inspect} is not a valid :float" + end + + # true/false pass through untouched (including from a previously-cast + # attribute re-read through the getter). Recognized string/integer + # forms map case-insensitively; anything else raises rather than + # defaulting to Ruby's "anything non-nil/false is truthy", which + # would silently turn a typo'd param value into `true`. + def cast_boolean(value) + return value if value == true || value == false + token = value.is_a?(String) ? value.strip.downcase : value.to_s + return true if TRUE_STRINGS.include?(token) + return false if FALSE_STRINGS.include?(token) + raise InvalidCast, "#{value.inspect} is not a valid :boolean" + end + + def cast_datetime(value) + return value if value.is_a?(Time) + return value.to_time if value.is_a?(Date) || value.is_a?(DateTime) + Time.parse(value.to_s) + rescue ArgumentError + raise InvalidCast, "#{value.inspect} is not a valid :datetime" + end + end + end +end diff --git a/lib/prism/orm/validatable.rb b/lib/prism/orm/validatable.rb index 32c3325..3513a6f 100644 --- a/lib/prism/orm/validatable.rb +++ b/lib/prism/orm/validatable.rb @@ -10,11 +10,34 @@ module Prism @validators ||= [] end + def custom_validators + @custom_validators ||= [] + end + def validates(*attributes, **rules) attributes.each do |attribute| rules.each { |kind, config| validators << { attribute: attribute, kind: kind, config: config } } end end + + def validates_numericality_of(*attributes, **options) + validates(*attributes, numericality: options) + end + + def validates_inclusion_of(*attributes, **options) + validates(*attributes, inclusion: options) + end + + def validates_exclusion_of(*attributes, **options) + validates(*attributes, exclusion: options) + end + + # Registers a custom validation as either an instance method name + # (validate :check_something) or a block run via instance_exec. + # Either form is expected to call errors.add itself. + def validate(method_name = nil, &block) + custom_validators << (method_name || block) + end end def errors @@ -23,7 +46,10 @@ module Prism def valid? errors.clear + return false unless run_callbacks(:before_validation) self.class.validators.each { |validator| run_validator(validator) } + self.class.custom_validators.each { |validator| run_custom_validator(validator) } + run_callbacks(:after_validation) errors.empty? end @@ -39,40 +65,107 @@ module Prism when :uniqueness then validate_uniqueness(validator[:attribute], validator[:config]) when :format then validate_format(validator[:attribute], validator[:config]) when :length then validate_length(validator[:attribute], validator[:config]) + when :numericality then validate_numericality(validator[:attribute], validator[:config]) + when :inclusion then validate_inclusion(validator[:attribute], validator[:config]) + when :exclusion then validate_exclusion(validator[:attribute], validator[:config]) else raise ArgumentError, "unknown validator #{validator[:kind]}" end end + def run_custom_validator(validator) + validator.is_a?(Proc) ? instance_exec(&validator) : send(validator) + end + def validate_presence(attribute, config) return unless config - value = self[attribute] + value = public_send(attribute) return unless value.nil? || (value.respond_to?(:empty?) && value.empty?) errors.add(attribute, message_for(config, 'must be present')) end def validate_uniqueness(attribute, config) return unless config - value = self[attribute] + value = public_send(attribute) return if value.nil? - conflict = self.class.where(attribute => value).any? { |record| !persisted? || record.id != id } + scope = config.is_a?(Hash) ? Array(config[:scope]) : [] + candidates = scope.inject(self.class.where(attribute => value)) do |relation, scope_attribute| + relation.where(scope_attribute => public_send(scope_attribute)) + end + conflict = candidates.any? { |record| !persisted? || record.id != id } errors.add(attribute, message_for(config, 'must be unique')) if conflict end def validate_format(attribute, config) pattern = config.is_a?(Hash) ? config[:with] : config return unless pattern - value = self[attribute] + value = public_send(attribute) return if value.nil? return if value.to_s.match?(pattern) errors.add(attribute, message_for(config, 'is invalid')) end def validate_length(attribute, config) - value = self[attribute].to_s + value = public_send(attribute).to_s errors.add(attribute, "is too short (minimum #{config[:minimum]})") if config[:minimum] && value.length < config[:minimum] errors.add(attribute, "is too long (maximum #{config[:maximum]})") if config[:maximum] && value.length > config[:maximum] end + NUMERICALITY_CHECKS = { + greater_than: ->(n, v) { n > v }, + greater_than_or_equal_to: ->(n, v) { n >= v }, + less_than: ->(n, v) { n < v }, + less_than_or_equal_to: ->(n, v) { n <= v }, + equal_to: ->(n, v) { n == v }, + other_than: ->(n, v) { n != v } + }.freeze + + def validate_numericality(attribute, config) + config = config.is_a?(Hash) ? config : {} + value = public_send(attribute) + return if value.nil? && config[:allow_nil] + + number = coerce_number(value) + return errors.add(attribute, message_for(config, 'is not a number')) if number.nil? + + if config[:only_integer] && number != number.to_i + return errors.add(attribute, message_for(config, 'must be an integer')) + end + + NUMERICALITY_CHECKS.each do |key, check| + next unless config.key?(key) + next if check.call(number, config[key]) + errors.add(attribute, message_for(config, "must be #{key.to_s.tr('_', ' ')} #{config[key]}")) + end + + errors.add(attribute, message_for(config, 'must be odd')) if config[:odd] && number.to_i.even? + errors.add(attribute, message_for(config, 'must be even')) if config[:even] && number.to_i.odd? + end + + def coerce_number(value) + return value if value.is_a?(Numeric) + return nil if value.nil? + + Float(value.to_s) + rescue ArgumentError, TypeError + nil + end + + def validate_inclusion(attribute, config) + config = config.is_a?(Hash) ? config : { in: config } + value = public_send(attribute) + return if value.nil? && config[:allow_nil] + set = config[:in] || config[:within] + errors.add(attribute, message_for(config, 'is not included in the list')) unless set.include?(value) + end + + def validate_exclusion(attribute, config) + config = config.is_a?(Hash) ? config : { in: config } + value = public_send(attribute) + return if value.nil? && config[:allow_nil] + set = config[:in] || config[:within] + errors.add(attribute, message_for(config, 'is reserved')) if set.include?(value) + end + def message_for(config, default) config.is_a?(Hash) && config[:message] ? config[:message] : default end