feat(orm): extend type_casting with Prism and Orm

This commit is contained in:
dualfroz committed 2026-09-08 20:31:24 +02:00
1 parent b41a513317
commit 64c209f156
2 files changed
+189 -5

No files matched your search

+91
View File
@@ -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
+98 -5
View File
@@ -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