lib/validated_object.rb
# typed: true
# frozen_string_literal: true
require 'active_model'
require 'sorbet-runtime'
require 'validated_object/version'
require 'validated_object/simplified_api'
module ValidatedObject
# @abstract Subclass and add `attr_accessor` and validations
# to create custom validating objects.
#
# Uses {http://api.rubyonrails.org/classes/ActiveModel/Validations/ClassMethods.html#method-i-validates ActiveModel::Validations}
# to create self-validating Plain Old Ruby objects. This is especially
# useful when importing data from one system into another. This class also
# creates very readable error messages.
#
# @example Writing a self-validating object
# class Dog < ValidatedObject::Base
# attr_accessor :name, :birthday
#
# validates :name, presence: true
# validates :birthday, type: Date, allow_nil: true
# end
#
# @example Instantiating and automatically validating
# # The dog1 instance validates itself at the end of instantiation.
# # Here, it succeeds and so doesn't raise an exception.
# dog1 = Dog.new name: 'Spot'
#
# # We can also explicitly test for validity
# dog1.valid? # => true
#
# dog1.birthday = Date.new(2015, 1, 23)
# dog1.valid? # => true
#
# @example Making an instance invalid
# dog1.birthday = '2015-01-23'
# dog1.valid? # => false
# dog1.check_validations! # => ArgumentError: Birthday is class String, not Date
#
# @see ValidatedObject::Base::TypeValidator
# @see http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/ ActiveModel: Make Any Ruby Object Feel Like ActiveRecord, Yehuda Katz
# @see http://www.rubyinside.com/rails-3-0s-activemodel-how-to-give-ruby-classes-some-activerecord-magic-2937.html Rails 3.0′s ActiveModel: How To Give Ruby Classes Some ActiveRecord Magic, Peter Cooper
class Base
include ActiveModel::Validations
include SimplifiedApi
extend T::Sig
SymbolHash = T.type_alias { T::Hash[Symbol, T.untyped] }
EMPTY_HASH = T.let({}.freeze, SymbolHash)
# A private class definition, not intended to
# be used directly. Implements a pseudo-boolean class
# enabling validations like this:
#
# validates :enabled, type: Boolean
class Boolean
end
# Instantiate and validate a new object.
# @example
# maru = Dog.new(birthday: Date.today, name: 'Maru')
#
# @raise [ArgumentError] if the object is not valid at the
# end of initialization or `attributes` is not a Hash.
sig { params(attributes: SymbolHash).void }
def initialize(attributes = EMPTY_HASH)
set_instance_variables from_hash: attributes
check_validations!
nil
end
def validated_attr(attribute_name, **validation_options)
attr_reader attribute_name
validates attribute_name, validation_options
end
# Run any validations and raise an error if invalid.
#
# @raise [ArgumentError] if any validations fail.
# @return [ValidatedObject::Base] the receiver
sig { returns(ValidatedObject::Base) }
def check_validations!
raise ArgumentError, errors.full_messages.join('; ') if invalid?
self
end
# A custom validator which ensures an object is an instance of a class
# or a subclass. It supports a pseudo-boolean class for convenient
# validation. (Ruby doesn't have a built-in Boolean.)
#
# Automatically used in a `type` validation:
#
# @example Ensure that weight is a number
# class Dog < ValidatedObject::Base
# attr_accessor :weight, :neutered
# validates :weight, type: Numeric # Typed and required
# validates :neutered, type: Boolean, allow_nil: true # Typed but optional
# end
class TypeValidator < ActiveModel::EachValidator
extend T::Sig
# @return [nil]
sig do
params(
record: T.untyped,
attribute: T.untyped,
value: T.untyped
)
.void
end
def validate_each(record, attribute, value)
validation_options = T.let(options, SymbolHash)
expected_class = validation_options[:with]
return if pseudo_boolean?(expected_class, value) ||
expected_class?(expected_class, value)
save_error(record, attribute, value, validation_options)
end
private
sig { params(expected_class: T.untyped, value: T.untyped).returns(T.untyped) }
def pseudo_boolean?(expected_class, value)
expected_class == Boolean && boolean?(value)
end
sig { params(expected_class: T.untyped, value: T.untyped).returns(T.untyped) }
def expected_class?(expected_class, value)
value.is_a?(expected_class)
end
sig { params(value: T.untyped).returns(T.untyped) }
def boolean?(value)
value.is_a?(TrueClass) || value.is_a?(FalseClass)
end
sig do
params(
record: T.untyped,
attribute: T.untyped,
value: T.untyped,
validation_options: SymbolHash
)
.void
end
def save_error(record, attribute, value, validation_options)
record.errors.add attribute,
validation_options[:message] || "is a #{value.class}, not a #{validation_options[:with]}"
end
end
private
sig { params(from_hash: SymbolHash).void }
def set_instance_variables(from_hash:)
from_hash.each do |variable_name, variable_value|
# Test for the attribute reader
send variable_name.to_sym
# Set value in a way that succeeds even if attr is read-only
instance_variable_set "@#{variable_name}".to_sym, variable_value
end
end
end
end