feat(orm): extend type_casting with Prism and Orm
This commit is contained in:
1 parent
b41a513317
commit
64c209f156
2 files changed
+189
-5
No files matched your search
@@ -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
|
||||||
@@ -10,11 +10,34 @@ module Prism
|
|||||||
@validators ||= []
|
@validators ||= []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def custom_validators
|
||||||
|
@custom_validators ||= []
|
||||||
|
end
|
||||||
|
|
||||||
def validates(*attributes, **rules)
|
def validates(*attributes, **rules)
|
||||||
attributes.each do |attribute|
|
attributes.each do |attribute|
|
||||||
rules.each { |kind, config| validators << { attribute: attribute, kind: kind, config: config } }
|
rules.each { |kind, config| validators << { attribute: attribute, kind: kind, config: config } }
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
def errors
|
def errors
|
||||||
@@ -23,7 +46,10 @@ module Prism
|
|||||||
|
|
||||||
def valid?
|
def valid?
|
||||||
errors.clear
|
errors.clear
|
||||||
|
return false unless run_callbacks(:before_validation)
|
||||||
self.class.validators.each { |validator| run_validator(validator) }
|
self.class.validators.each { |validator| run_validator(validator) }
|
||||||
|
self.class.custom_validators.each { |validator| run_custom_validator(validator) }
|
||||||
|
run_callbacks(:after_validation)
|
||||||
errors.empty?
|
errors.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -39,40 +65,107 @@ module Prism
|
|||||||
when :uniqueness then validate_uniqueness(validator[:attribute], validator[:config])
|
when :uniqueness then validate_uniqueness(validator[:attribute], validator[:config])
|
||||||
when :format then validate_format(validator[:attribute], validator[:config])
|
when :format then validate_format(validator[:attribute], validator[:config])
|
||||||
when :length then validate_length(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]}"
|
else raise ArgumentError, "unknown validator #{validator[:kind]}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def run_custom_validator(validator)
|
||||||
|
validator.is_a?(Proc) ? instance_exec(&validator) : send(validator)
|
||||||
|
end
|
||||||
|
|
||||||
def validate_presence(attribute, config)
|
def validate_presence(attribute, config)
|
||||||
return unless config
|
return unless config
|
||||||
value = self[attribute]
|
value = public_send(attribute)
|
||||||
return unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
return unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
||||||
errors.add(attribute, message_for(config, 'must be present'))
|
errors.add(attribute, message_for(config, 'must be present'))
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_uniqueness(attribute, config)
|
def validate_uniqueness(attribute, config)
|
||||||
return unless config
|
return unless config
|
||||||
value = self[attribute]
|
value = public_send(attribute)
|
||||||
return if value.nil?
|
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
|
errors.add(attribute, message_for(config, 'must be unique')) if conflict
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_format(attribute, config)
|
def validate_format(attribute, config)
|
||||||
pattern = config.is_a?(Hash) ? config[:with] : config
|
pattern = config.is_a?(Hash) ? config[:with] : config
|
||||||
return unless pattern
|
return unless pattern
|
||||||
value = self[attribute]
|
value = public_send(attribute)
|
||||||
return if value.nil?
|
return if value.nil?
|
||||||
return if value.to_s.match?(pattern)
|
return if value.to_s.match?(pattern)
|
||||||
errors.add(attribute, message_for(config, 'is invalid'))
|
errors.add(attribute, message_for(config, 'is invalid'))
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_length(attribute, config)
|
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 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]
|
errors.add(attribute, "is too long (maximum #{config[:maximum]})") if config[:maximum] && value.length > config[:maximum]
|
||||||
end
|
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)
|
def message_for(config, default)
|
||||||
config.is_a?(Hash) && config[:message] ? config[:message] : default
|
config.is_a?(Hash) && config[:message] ? config[:message] : default
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in new issue
Block a user