Skip to content

Instantly share code, notes, and snippets.

@sgoedecke
Created March 8, 2022 01:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sgoedecke/30fe59f6b102148d45684438f9fa6fb9 to your computer and use it in GitHub Desktop.
Save sgoedecke/30fe59f6b102148d45684438f9fa6fb9 to your computer and use it in GitHub Desktop.
# frozen_string_literal: true
module ActiveModel
module Validations
# == \Active \Model Absence Validator
class AbsenceValidator < EachValidator # :nodoc:
def validate_each(record, attr_name, value)
record.errors.add(attr_name, :present, **options) if value.present?
end
end
module HelperMethods
# Validates that the specified attributes are blank (as defined by
# Object#present?). Happens by default on save.
#
# class Person < ActiveRecord::Base
# validates_absence_of :first_name
# end
#
# The first_name attribute must be in the object and it must be blank.
#
# Configuration options:
# * <tt>:message</tt> - A custom error message (default is: "must be blank").
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
# See ActiveModel::Validations::ClassMethods#validates for more information.
def validates_absence_of(*attr_names)
validates_with AbsenceValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
require "models/custom_reader"
class AbsenceValidationTest < ActiveModel::TestCase
teardown do
Topic.clear_validators!
Person.clear_validators!
CustomReader.clear_validators!
end
def test_validates_absence_of
Topic.validates_absence_of(:title, :content)
t = Topic.new
t.title = "foo"
t.content = "bar"
assert_predicate t, :invalid?
assert_equal ["must be blank"], t.errors[:title]
assert_equal ["must be blank"], t.errors[:content]
t.title = ""
t.content = "something"
assert_predicate t, :invalid?
assert_equal ["must be blank"], t.errors[:content]
assert_equal [], t.errors[:title]
t.content = ""
assert_predicate t, :valid?
end
def test_validates_absence_of_with_array_arguments
Topic.validates_absence_of %w(title content)
t = Topic.new
t.title = "foo"
t.content = "bar"
assert_predicate t, :invalid?
assert_equal ["must be blank"], t.errors[:title]
assert_equal ["must be blank"], t.errors[:content]
end
def test_validates_absence_of_with_custom_error_using_quotes
Person.validates_absence_of :karma, message: "This string contains 'single' and \"double\" quotes"
p = Person.new
p.karma = "good"
assert_predicate p, :invalid?
assert_equal "This string contains 'single' and \"double\" quotes", p.errors[:karma].last
end
def test_validates_absence_of_for_ruby_class
Person.validates_absence_of :karma
p = Person.new
p.karma = "good"
assert_predicate p, :invalid?
assert_equal ["must be blank"], p.errors[:karma]
p.karma = nil
assert_predicate p, :valid?
end
def test_validates_absence_of_for_ruby_class_with_custom_reader
CustomReader.validates_absence_of :karma
p = CustomReader.new
p[:karma] = "excellent"
assert_predicate p, :invalid?
assert_equal ["must be blank"], p.errors[:karma]
p[:karma] = ""
assert_predicate p, :valid?
end
end
# frozen_string_literal: true
module ActiveModel
module Validations
class AcceptanceValidator < EachValidator # :nodoc:
def initialize(options)
super({ allow_nil: true, accept: ["1", true] }.merge!(options))
setup!(options[:class])
end
def validate_each(record, attribute, value)
unless acceptable_option?(value)
record.errors.add(attribute, :accepted, **options.except(:accept, :allow_nil))
end
end
private
def setup!(klass)
define_attributes = LazilyDefineAttributes.new(attributes)
klass.include(define_attributes) unless klass.included_modules.include?(define_attributes)
end
def acceptable_option?(value)
Array(options[:accept]).include?(value)
end
class LazilyDefineAttributes < Module
def initialize(attributes)
@attributes = attributes.map(&:to_s)
end
def included(klass)
@lock = Mutex.new
mod = self
define_method(:respond_to_missing?) do |method_name, include_private = false|
mod.define_on(klass)
super(method_name, include_private) || mod.matches?(method_name)
end
define_method(:method_missing) do |method_name, *args, &block|
mod.define_on(klass)
if mod.matches?(method_name)
send(method_name, *args, &block)
else
super(method_name, *args, &block)
end
end
end
def matches?(method_name)
attr_name = method_name.to_s.chomp("=")
attributes.any? { |name| name == attr_name }
end
def define_on(klass)
@lock&.synchronize do
return unless @lock
attr_readers = attributes.reject { |name| klass.attribute_method?(name) }
attr_writers = attributes.reject { |name| klass.attribute_method?("#{name}=") }
attr_reader(*attr_readers)
attr_writer(*attr_writers)
remove_method :respond_to_missing?
remove_method :method_missing
@lock = nil
end
end
def ==(other)
self.class == other.class && attributes == other.attributes
end
protected
attr_reader :attributes
end
end
module HelperMethods
# Encapsulates the pattern of wanting to validate the acceptance of a
# terms of service check box (or similar agreement).
#
# class Person < ActiveRecord::Base
# validates_acceptance_of :terms_of_service
# validates_acceptance_of :eula, message: 'must be abided'
# end
#
# If the database column does not exist, the +terms_of_service+ attribute
# is entirely virtual. This check is performed only if +terms_of_service+
# is not +nil+ and by default on save.
#
# Configuration options:
# * <tt>:message</tt> - A custom error message (default is: "must be
# accepted").
# * <tt>:accept</tt> - Specifies a value that is considered accepted.
# Also accepts an array of possible values. The default value is
# an array ["1", true], which makes it easy to relate to an HTML
# checkbox. This should be set to, or include, +true+ if you are validating
# a database column, since the attribute is typecast from "1" to +true+
# before validation.
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
# See ActiveModel::Validations::ClassMethods#validates for more information.
def validates_acceptance_of(*attr_names)
validates_with AcceptanceValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/reply"
require "models/person"
class AcceptanceValidationTest < ActiveModel::TestCase
teardown do
self.class.send(:remove_const, :TestClass)
end
def test_terms_of_service_agreement_no_acceptance
klass = define_test_class(Topic)
klass.validates_acceptance_of(:terms_of_service)
t = klass.new("title" => "We should not be confirmed")
assert_predicate t, :valid?
end
def test_terms_of_service_agreement
klass = define_test_class(Topic)
klass.validates_acceptance_of(:terms_of_service)
t = klass.new("title" => "We should be confirmed", "terms_of_service" => "")
assert_predicate t, :invalid?
assert_equal ["must be accepted"], t.errors[:terms_of_service]
t.terms_of_service = "1"
assert_predicate t, :valid?
end
def test_eula
klass = define_test_class(Topic)
klass.validates_acceptance_of(:eula, message: "must be abided")
t = klass.new("title" => "We should be confirmed", "eula" => "")
assert_predicate t, :invalid?
assert_equal ["must be abided"], t.errors[:eula]
t.eula = "1"
assert_predicate t, :valid?
end
def test_terms_of_service_agreement_with_accept_value
klass = define_test_class(Topic)
klass.validates_acceptance_of(:terms_of_service, accept: "I agree.")
t = klass.new("title" => "We should be confirmed", "terms_of_service" => "")
assert_predicate t, :invalid?
assert_equal ["must be accepted"], t.errors[:terms_of_service]
t.terms_of_service = "I agree."
assert_predicate t, :valid?
end
def test_terms_of_service_agreement_with_multiple_accept_values
klass = define_test_class(Topic)
klass.validates_acceptance_of(:terms_of_service, accept: [1, "I concur."])
t = klass.new("title" => "We should be confirmed", "terms_of_service" => "")
assert_predicate t, :invalid?
assert_equal ["must be accepted"], t.errors[:terms_of_service]
t.terms_of_service = 1
assert_predicate t, :valid?
t.terms_of_service = "I concur."
assert_predicate t, :valid?
end
def test_validates_acceptance_of_for_ruby_class
klass = define_test_class(Person)
klass.validates_acceptance_of :karma
p = klass.new
p.karma = ""
assert_predicate p, :invalid?
assert_equal ["must be accepted"], p.errors[:karma]
p.karma = "1"
assert_predicate p, :valid?
end
def test_validates_acceptance_of_true
klass = define_test_class(Topic)
klass.validates_acceptance_of(:terms_of_service)
assert_predicate klass.new(terms_of_service: true), :valid?
end
def test_lazy_attribute_module_included_only_once
klass = define_test_class(Topic)
assert_difference -> { klass.ancestors.count }, 2 do
2.times do
klass.validates_acceptance_of(:something_to_accept)
assert klass.new.respond_to?(:something_to_accept)
end
2.times do
klass.validates_acceptance_of(:something_else_to_accept)
assert klass.new.respond_to?(:something_else_to_accept)
end
end
end
def test_lazy_attributes_module_included_again_if_needed
klass = define_test_class(Topic)
assert_difference -> { klass.ancestors.count }, 1 do
klass.validates_acceptance_of(:something_to_accept)
end
topic = klass.new
topic.something_to_accept
assert_difference -> { klass.ancestors.count }, 1 do
klass.validates_acceptance_of(:something_else_to_accept)
end
assert topic.respond_to?(:something_else_to_accept)
end
def test_lazy_attributes_respond_to?
klass = define_test_class(Topic)
klass.validates_acceptance_of(:terms_of_service)
topic = klass.new
threads = []
2.times do
threads << Thread.new do
assert topic.respond_to?(:terms_of_service)
end
end
threads.each(&:join)
end
private
def define_test_class(parent)
self.class.const_set(:TestClass, Class.new(parent))
end
end
# frozen_string_literal: true
module ActiveModel
module Type
module Helpers # :nodoc: all
class AcceptsMultiparameterTime < Module
module InstanceMethods
def serialize(value)
super(cast(value))
end
def cast(value)
if value.is_a?(Hash)
value_from_multiparameter_assignment(value)
else
super(value)
end
end
def assert_valid_value(value)
if value.is_a?(Hash)
value_from_multiparameter_assignment(value)
else
super(value)
end
end
def value_constructed_by_mass_assignment?(value)
value.is_a?(Hash)
end
end
def initialize(defaults: {})
include InstanceMethods
define_method(:value_from_multiparameter_assignment) do |values_hash|
defaults.each do |k, v|
values_hash[k] ||= v
end
return unless values_hash[1] && values_hash[2] && values_hash[3]
values = values_hash.sort.map!(&:last)
::Time.public_send(default_timezone, *values)
end
private :value_from_multiparameter_assignment
end
end
end
end
end
# frozen_string_literal: true
require "active_support/core_ext/enumerable"
require "active_support/core_ext/hash/indifferent_access"
module ActiveModel
module Access # :nodoc:
def slice(*methods)
methods.flatten.index_with { |method| public_send(method) }.with_indifferent_access
end
def values_at(*methods)
methods.flatten.map! { |method| public_send(method) }
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_support/core_ext/hash/indifferent_access"
class AccessTest < ActiveModel::TestCase
class Point
include ActiveModel::Access
def initialize(*vector)
@vector = vector
end
def x
@vector[0]
end
def y
@vector[1]
end
def z
@vector[2]
end
end
setup do
@point = Point.new(123, 456, 789)
end
test "slice" do
expected = { z: @point.z, x: @point.x }.with_indifferent_access
actual = @point.slice(:z, :x)
assert_equal expected.keys, actual.keys
expected.each do |key, value|
assert_equal value, actual[key.to_s]
assert_equal value, actual[key.to_sym]
end
end
test "slice with array" do
expected = { z: @point.z, x: @point.x }.with_indifferent_access
assert_equal expected, @point.slice([:z, :x])
end
test "values_at" do
assert_equal [@point.x, @point.z], @point.values_at(:x, :z)
assert_equal [@point.z, @point.x], @point.values_at(:z, :x)
end
test "values_at with array" do
assert_equal [@point.x, @point.z], @point.values_at([:x, :z])
assert_equal [@point.z, @point.x], @point.values_at([:z, :x])
end
end
# frozen_string_literal: true
class Account
include ActiveModel::ForbiddenAttributesProtection
public :sanitize_for_mass_assignment
end
# frozen_string_literal: true
#--
# Copyright (c) 2004-2022 David Heinemeier Hansson
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#++
require "active_support"
require "active_support/rails"
require "active_model/version"
module ActiveModel
extend ActiveSupport::Autoload
autoload :Access
autoload :API
autoload :Attribute
autoload :Attributes
autoload :AttributeAssignment
autoload :AttributeMethods
autoload :BlockValidator, "active_model/validator"
autoload :Callbacks
autoload :Conversion
autoload :Dirty
autoload :EachValidator, "active_model/validator"
autoload :ForbiddenAttributesProtection
autoload :Lint
autoload :Model
autoload :Name, "active_model/naming"
autoload :Naming
autoload :SecurePassword
autoload :Serialization
autoload :Translation
autoload :Type
autoload :Validations
autoload :Validator
eager_autoload do
autoload :Errors
autoload :Error
autoload :RangeError, "active_model/errors"
autoload :StrictValidationFailed, "active_model/errors"
autoload :UnknownAttributeError, "active_model/errors"
end
module Serializers
extend ActiveSupport::Autoload
eager_autoload do
autoload :JSON
end
end
def self.eager_load!
super
ActiveModel::Serializers.eager_load!
end
end
ActiveSupport.on_load(:i18n) do
I18n.load_path << File.expand_path("active_model/locale/en.yml", __dir__)
end
# frozen_string_literal: true
module ActiveModel
# == Active \Model \API
#
# Includes the required interface for an object to interact with
# Action Pack and Action View, using different Active Model modules.
# It includes model name introspections, conversions, translations, and
# validations. Besides that, it allows you to initialize the object with a
# hash of attributes, pretty much like Active Record does.
#
# A minimal implementation could be:
#
# class Person
# include ActiveModel::API
# attr_accessor :name, :age
# end
#
# person = Person.new(name: 'bob', age: '18')
# person.name # => "bob"
# person.age # => "18"
#
# Note that, by default, <tt>ActiveModel::API</tt> implements <tt>persisted?</tt>
# to return +false+, which is the most common case. You may want to override
# it in your class to simulate a different scenario:
#
# class Person
# include ActiveModel::API
# attr_accessor :id, :name
#
# def persisted?
# self.id.present?
# end
# end
#
# person = Person.new(id: 1, name: 'bob')
# person.persisted? # => true
#
# Also, if for some reason you need to run code on <tt>initialize</tt>, make
# sure you call +super+ if you want the attributes hash initialization to
# happen.
#
# class Person
# include ActiveModel::API
# attr_accessor :id, :name, :omg
#
# def initialize(attributes={})
# super
# @omg ||= true
# end
# end
#
# person = Person.new(id: 1, name: 'bob')
# person.omg # => true
#
# For more detailed information on other functionalities available, please
# refer to the specific modules included in <tt>ActiveModel::API</tt>
# (see below).
module API
extend ActiveSupport::Concern
include ActiveModel::AttributeAssignment
include ActiveModel::Validations
include ActiveModel::Conversion
included do
extend ActiveModel::Naming
extend ActiveModel::Translation
end
# Initializes a new model with the given +params+.
#
# class Person
# include ActiveModel::API
# attr_accessor :name, :age
# end
#
# person = Person.new(name: 'bob', age: '18')
# person.name # => "bob"
# person.age # => "18"
def initialize(attributes = {})
assign_attributes(attributes) if attributes
super()
end
# Indicates if the model is persisted. Default is +false+.
#
# class Person
# include ActiveModel::API
# attr_accessor :id, :name
# end
#
# person = Person.new(id: 1, name: 'bob')
# person.persisted? # => false
def persisted?
false
end
end
end
# frozen_string_literal: true
require "cases/helper"
class APITest < ActiveModel::TestCase
include ActiveModel::Lint::Tests
module DefaultValue
def self.included(klass)
klass.class_eval { attr_accessor :hello }
end
def initialize(*args)
@attr ||= "default value"
super
end
end
class BasicModel
include DefaultValue
include ActiveModel::API
attr_accessor :attr
end
class BasicModelWithReversedMixins
include ActiveModel::API
include DefaultValue
attr_accessor :attr
end
class SimpleModel
include ActiveModel::API
attr_accessor :attr
end
def setup
@model = BasicModel.new
end
def test_initialize_with_params
object = BasicModel.new(attr: "value")
assert_equal "value", object.attr
end
def test_initialize_with_params_and_mixins_reversed
object = BasicModelWithReversedMixins.new(attr: "value")
assert_equal "value", object.attr
end
def test_initialize_with_nil_or_empty_hash_params_does_not_explode
assert_nothing_raised do
BasicModel.new()
BasicModel.new(nil)
BasicModel.new({})
SimpleModel.new(attr: "value")
end
end
def test_persisted_is_always_false
object = BasicModel.new(attr: "value")
assert_not object.persisted?
end
def test_mixin_inclusion_chain
object = BasicModel.new
assert_equal "default value", object.attr
end
def test_mixin_initializer_when_args_exist
object = BasicModel.new(hello: "world")
assert_equal "world", object.hello
end
def test_mixin_initializer_when_args_dont_exist
assert_raises(ActiveModel::UnknownAttributeError) do
SimpleModel.new(hello: "world")
end
end
end
# frozen_string_literal: true
require "active_support/core_ext/object/duplicable"
module ActiveModel
class Attribute # :nodoc:
class << self
def from_database(name, value_before_type_cast, type, value = nil)
FromDatabase.new(name, value_before_type_cast, type, nil, value)
end
def from_user(name, value_before_type_cast, type, original_attribute = nil)
FromUser.new(name, value_before_type_cast, type, original_attribute)
end
def with_cast_value(name, value_before_type_cast, type)
WithCastValue.new(name, value_before_type_cast, type)
end
def null(name)
Null.new(name)
end
def uninitialized(name, type)
Uninitialized.new(name, type)
end
end
attr_reader :name, :value_before_type_cast, :type
# This method should not be called directly.
# Use #from_database or #from_user
def initialize(name, value_before_type_cast, type, original_attribute = nil, value = nil)
@name = name
@value_before_type_cast = value_before_type_cast
@type = type
@original_attribute = original_attribute
@value = value unless value.nil?
end
def value
# `defined?` is cheaper than `||=` when we get back falsy values
@value = type_cast(value_before_type_cast) unless defined?(@value)
@value
end
def original_value
if assigned?
original_attribute.original_value
else
type_cast(value_before_type_cast)
end
end
def value_for_database
type.serialize(value)
end
def serializable?(&block)
type.serializable?(value, &block)
end
def changed?
changed_from_assignment? || changed_in_place?
end
def changed_in_place?
has_been_read? && type.changed_in_place?(original_value_for_database, value)
end
def forgetting_assignment
with_value_from_database(value_for_database)
end
def with_value_from_user(value)
type.assert_valid_value(value)
self.class.from_user(name, value, type, original_attribute || self)
end
def with_value_from_database(value)
self.class.from_database(name, value, type)
end
def with_cast_value(value)
self.class.with_cast_value(name, value, type)
end
def with_type(type)
if changed_in_place?
with_value_from_user(value).with_type(type)
else
self.class.new(name, value_before_type_cast, type, original_attribute)
end
end
def type_cast(*)
raise NotImplementedError
end
def initialized?
true
end
def came_from_user?
false
end
def has_been_read?
defined?(@value)
end
def ==(other)
self.class == other.class &&
name == other.name &&
value_before_type_cast == other.value_before_type_cast &&
type == other.type
end
alias eql? ==
def hash
[self.class, name, value_before_type_cast, type].hash
end
def init_with(coder)
@name = coder["name"]
@value_before_type_cast = coder["value_before_type_cast"]
@type = coder["type"]
@original_attribute = coder["original_attribute"]
@value = coder["value"] if coder.map.key?("value")
end
def encode_with(coder)
coder["name"] = name
coder["value_before_type_cast"] = value_before_type_cast unless value_before_type_cast.nil?
coder["type"] = type if type
coder["original_attribute"] = original_attribute if original_attribute
coder["value"] = value if defined?(@value)
end
def original_value_for_database
if assigned?
original_attribute.original_value_for_database
else
_original_value_for_database
end
end
private
attr_reader :original_attribute
alias :assigned? :original_attribute
def initialize_dup(other)
if defined?(@value) && @value.duplicable?
@value = @value.dup
end
end
def changed_from_assignment?
assigned? && type.changed?(original_value, value, value_before_type_cast)
end
def _original_value_for_database
type.serialize(original_value)
end
class FromDatabase < Attribute # :nodoc:
def type_cast(value)
type.deserialize(value)
end
private
def _original_value_for_database
value_before_type_cast
end
end
class FromUser < Attribute # :nodoc:
def type_cast(value)
type.cast(value)
end
def came_from_user?
!type.value_constructed_by_mass_assignment?(value_before_type_cast)
end
end
class WithCastValue < Attribute # :nodoc:
def type_cast(value)
value
end
def changed_in_place?
false
end
end
class Null < Attribute # :nodoc:
def initialize(name)
super(name, nil, Type.default_value)
end
def type_cast(*)
nil
end
def with_type(type)
self.class.with_cast_value(name, nil, type)
end
def with_value_from_database(value)
raise ActiveModel::MissingAttributeError, "can't write unknown attribute `#{name}`"
end
alias_method :with_value_from_user, :with_value_from_database
alias_method :with_cast_value, :with_value_from_database
end
class Uninitialized < Attribute # :nodoc:
UNINITIALIZED_ORIGINAL_VALUE = Object.new
def initialize(name, type)
super(name, nil, type)
end
def value
if block_given?
yield name
end
end
def original_value
UNINITIALIZED_ORIGINAL_VALUE
end
def value_for_database
end
def initialized?
false
end
def forgetting_assignment
dup
end
def with_type(type)
self.class.new(name, type)
end
end
private_constant :FromDatabase, :FromUser, :Null, :Uninitialized, :WithCastValue
end
end
# frozen_string_literal: true
require "active_support/core_ext/hash/keys"
module ActiveModel
module AttributeAssignment
include ActiveModel::ForbiddenAttributesProtection
# Allows you to set all the attributes by passing in a hash of attributes with
# keys matching the attribute names.
#
# If the passed hash responds to <tt>permitted?</tt> method and the return value
# of this method is +false+ an <tt>ActiveModel::ForbiddenAttributesError</tt>
# exception is raised.
#
# class Cat
# include ActiveModel::AttributeAssignment
# attr_accessor :name, :status
# end
#
# cat = Cat.new
# cat.assign_attributes(name: "Gorby", status: "yawning")
# cat.name # => 'Gorby'
# cat.status # => 'yawning'
# cat.assign_attributes(status: "sleeping")
# cat.name # => 'Gorby'
# cat.status # => 'sleeping'
def assign_attributes(new_attributes)
unless new_attributes.respond_to?(:each_pair)
raise ArgumentError, "When assigning attributes, you must pass a hash as an argument, #{new_attributes.class} passed."
end
return if new_attributes.empty?
_assign_attributes(sanitize_for_mass_assignment(new_attributes))
end
alias attributes= assign_attributes
private
def _assign_attributes(attributes)
attributes.each do |k, v|
_assign_attribute(k, v)
end
end
def _assign_attribute(k, v)
setter = :"#{k}="
if respond_to?(setter)
public_send(setter, v)
else
raise UnknownAttributeError.new(self, k.to_s)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_support/core_ext/hash/indifferent_access"
require "active_support/hash_with_indifferent_access"
class AttributeAssignmentTest < ActiveModel::TestCase
class Model
include ActiveModel::AttributeAssignment
attr_accessor :name, :description
def initialize(attributes = {})
assign_attributes(attributes)
end
def broken_attribute=(value)
raise ErrorFromAttributeWriter
end
private
attr_writer :metadata
end
class ErrorFromAttributeWriter < StandardError
end
class ProtectedParams
attr_accessor :permitted
alias :permitted? :permitted
delegate :keys, :key?, :has_key?, :empty?, to: :@parameters
def initialize(attributes)
@parameters = attributes.with_indifferent_access
@permitted = false
end
def permit!
@permitted = true
self
end
def [](key)
@parameters[key]
end
def to_h
@parameters
end
def each_pair(&block)
@parameters.each_pair(&block)
end
def dup
super.tap do |duplicate|
duplicate.instance_variable_set :@permitted, permitted?
end
end
end
test "simple assignment" do
model = Model.new
model.assign_attributes(name: "hello", description: "world")
assert_equal "hello", model.name
assert_equal "world", model.description
end
test "simple assignment alias" do
model = Model.new
model.attributes = { name: "hello", description: "world" }
assert_equal "hello", model.name
assert_equal "world", model.description
end
test "assign non-existing attribute" do
model = Model.new
error = assert_raises(ActiveModel::UnknownAttributeError) do
model.assign_attributes(hz: 1)
end
assert_equal model, error.record
assert_equal "hz", error.attribute
end
test "assign private attribute" do
model = Model.new
assert_raises(ActiveModel::UnknownAttributeError) do
model.assign_attributes(metadata: { a: 1 })
end
end
test "does not swallow errors raised in an attribute writer" do
assert_raises(ErrorFromAttributeWriter) do
Model.new(broken_attribute: 1)
end
end
test "an ArgumentError is raised if a non-hash-like object is passed" do
err = assert_raises(ArgumentError) do
Model.new(1)
end
assert_equal("When assigning attributes, you must pass a hash as an argument, Integer passed.", err.message)
end
test "forbidden attributes cannot be used for mass assignment" do
params = ProtectedParams.new(name: "Guille", description: "m")
assert_raises(ActiveModel::ForbiddenAttributesError) do
Model.new(params)
end
end
test "permitted attributes can be used for mass assignment" do
params = ProtectedParams.new(name: "Guille", description: "desc")
params.permit!
model = Model.new(params)
assert_equal "Guille", model.name
assert_equal "desc", model.description
end
test "regular hash should still be used for mass assignment" do
model = Model.new(name: "Guille", description: "m")
assert_equal "Guille", model.name
assert_equal "m", model.description
end
test "assigning no attributes should not raise, even if the hash is un-permitted" do
model = Model.new
assert_nil model.assign_attributes(ProtectedParams.new({}))
end
end
# frozen_string_literal: true
require "concurrent/map"
module ActiveModel
# Raised when an attribute is not defined.
#
# class User < ActiveRecord::Base
# has_many :pets
# end
#
# user = User.first
# user.pets.select(:id).first.user_id
# # => ActiveModel::MissingAttributeError: missing attribute: user_id
class MissingAttributeError < NoMethodError
end
# == Active \Model \Attribute \Methods
#
# Provides a way to add prefixes and suffixes to your methods as
# well as handling the creation of <tt>ActiveRecord::Base</tt>-like
# class methods such as +table_name+.
#
# The requirements to implement <tt>ActiveModel::AttributeMethods</tt> are to:
#
# * <tt>include ActiveModel::AttributeMethods</tt> in your class.
# * Call each of its methods you want to add, such as +attribute_method_suffix+
# or +attribute_method_prefix+.
# * Call +define_attribute_methods+ after the other methods are called.
# * Define the various generic +_attribute+ methods that you have declared.
# * Define an +attributes+ method which returns a hash with each
# attribute name in your model as hash key and the attribute value as hash value.
# Hash keys must be strings.
#
# A minimal implementation could be:
#
# class Person
# include ActiveModel::AttributeMethods
#
# attribute_method_affix prefix: 'reset_', suffix: '_to_default!'
# attribute_method_suffix '_contrived?'
# attribute_method_prefix 'clear_'
# define_attribute_methods :name
#
# attr_accessor :name
#
# def attributes
# { 'name' => @name }
# end
#
# private
#
# def attribute_contrived?(attr)
# true
# end
#
# def clear_attribute(attr)
# send("#{attr}=", nil)
# end
#
# def reset_attribute_to_default!(attr)
# send("#{attr}=", 'Default Name')
# end
# end
module AttributeMethods
extend ActiveSupport::Concern
NAME_COMPILABLE_REGEXP = /\A[a-zA-Z_]\w*[!?=]?\z/
CALL_COMPILABLE_REGEXP = /\A[a-zA-Z_]\w*[!?]?\z/
FORWARD_PARAMETERS = "*args"
included do
class_attribute :attribute_aliases, instance_writer: false, default: {}
class_attribute :attribute_method_patterns, instance_writer: false, default: [ ClassMethods::AttributeMethodPattern.new ]
end
module ClassMethods
# Declares a method available for all attributes with the given prefix.
# Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method.
#
# #{prefix}#{attr}(*args, &block)
#
# to
#
# #{prefix}attribute(#{attr}, *args, &block)
#
# An instance method <tt>#{prefix}attribute</tt> must exist and accept
# at least the +attr+ argument.
#
# class Person
# include ActiveModel::AttributeMethods
#
# attr_accessor :name
# attribute_method_prefix 'clear_'
# define_attribute_methods :name
#
# private
#
# def clear_attribute(attr)
# send("#{attr}=", nil)
# end
# end
#
# person = Person.new
# person.name = 'Bob'
# person.name # => "Bob"
# person.clear_name
# person.name # => nil
def attribute_method_prefix(*prefixes, parameters: nil)
self.attribute_method_patterns += prefixes.map! { |prefix| AttributeMethodPattern.new(prefix: prefix, parameters: parameters) }
undefine_attribute_methods
end
# Declares a method available for all attributes with the given suffix.
# Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method.
#
# #{attr}#{suffix}(*args, &block)
#
# to
#
# attribute#{suffix}(#{attr}, *args, &block)
#
# An <tt>attribute#{suffix}</tt> instance method must exist and accept at
# least the +attr+ argument.
#
# class Person
# include ActiveModel::AttributeMethods
#
# attr_accessor :name
# attribute_method_suffix '_short?'
# define_attribute_methods :name
#
# private
#
# def attribute_short?(attr)
# send(attr).length < 5
# end
# end
#
# person = Person.new
# person.name = 'Bob'
# person.name # => "Bob"
# person.name_short? # => true
def attribute_method_suffix(*suffixes, parameters: nil)
self.attribute_method_patterns += suffixes.map! { |suffix| AttributeMethodPattern.new(suffix: suffix, parameters: parameters) }
undefine_attribute_methods
end
# Declares a method available for all attributes with the given prefix
# and suffix. Uses +method_missing+ and <tt>respond_to?</tt> to rewrite
# the method.
#
# #{prefix}#{attr}#{suffix}(*args, &block)
#
# to
#
# #{prefix}attribute#{suffix}(#{attr}, *args, &block)
#
# An <tt>#{prefix}attribute#{suffix}</tt> instance method must exist and
# accept at least the +attr+ argument.
#
# class Person
# include ActiveModel::AttributeMethods
#
# attr_accessor :name
# attribute_method_affix prefix: 'reset_', suffix: '_to_default!'
# define_attribute_methods :name
#
# private
#
# def reset_attribute_to_default!(attr)
# send("#{attr}=", 'Default Name')
# end
# end
#
# person = Person.new
# person.name # => 'Gem'
# person.reset_name_to_default!
# person.name # => 'Default Name'
def attribute_method_affix(*affixes)
self.attribute_method_patterns += affixes.map! { |affix| AttributeMethodPattern.new(**affix) }
undefine_attribute_methods
end
# Allows you to make aliases for attributes.
#
# class Person
# include ActiveModel::AttributeMethods
#
# attr_accessor :name
# attribute_method_suffix '_short?'
# define_attribute_methods :name
#
# alias_attribute :nickname, :name
#
# private
#
# def attribute_short?(attr)
# send(attr).length < 5
# end
# end
#
# person = Person.new
# person.name = 'Bob'
# person.name # => "Bob"
# person.nickname # => "Bob"
# person.name_short? # => true
# person.nickname_short? # => true
def alias_attribute(new_name, old_name)
self.attribute_aliases = attribute_aliases.merge(new_name.to_s => old_name.to_s)
ActiveSupport::CodeGenerator.batch(self, __FILE__, __LINE__) do |code_generator|
attribute_method_patterns.each do |pattern|
method_name = pattern.method_name(new_name).to_s
target_name = pattern.method_name(old_name).to_s
parameters = pattern.parameters
mangled_name = target_name
unless NAME_COMPILABLE_REGEXP.match?(target_name)
mangled_name = "__temp__#{target_name.unpack1("h*")}"
end
code_generator.define_cached_method(method_name, as: mangled_name, namespace: :alias_attribute) do |batch|
body = if CALL_COMPILABLE_REGEXP.match?(target_name)
"self.#{target_name}(#{parameters || ''})"
else
call_args = [":'#{target_name}'"]
call_args << parameters if parameters
"send(#{call_args.join(", ")})"
end
modifier = pattern.parameters == FORWARD_PARAMETERS ? "ruby2_keywords " : ""
batch <<
"#{modifier}def #{mangled_name}(#{parameters || ''})" <<
body <<
"end"
end
end
end
end
# Is +new_name+ an alias?
def attribute_alias?(new_name)
attribute_aliases.key? new_name.to_s
end
# Returns the original name for the alias +name+
def attribute_alias(name)
attribute_aliases[name.to_s]
end
# Declares the attributes that should be prefixed and suffixed by
# <tt>ActiveModel::AttributeMethods</tt>.
#
# To use, pass attribute names (as strings or symbols). Be sure to declare
# +define_attribute_methods+ after you define any prefix, suffix, or affix
# methods, or they will not hook in.
#
# class Person
# include ActiveModel::AttributeMethods
#
# attr_accessor :name, :age, :address
# attribute_method_prefix 'clear_'
#
# # Call to define_attribute_methods must appear after the
# # attribute_method_prefix, attribute_method_suffix or
# # attribute_method_affix declarations.
# define_attribute_methods :name, :age, :address
#
# private
#
# def clear_attribute(attr)
# send("#{attr}=", nil)
# end
# end
def define_attribute_methods(*attr_names)
ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |owner|
attr_names.flatten.each { |attr_name| define_attribute_method(attr_name, _owner: owner) }
end
end
# Declares an attribute that should be prefixed and suffixed by
# <tt>ActiveModel::AttributeMethods</tt>.
#
# To use, pass an attribute name (as string or symbol). Be sure to declare
# +define_attribute_method+ after you define any prefix, suffix or affix
# method, or they will not hook in.
#
# class Person
# include ActiveModel::AttributeMethods
#
# attr_accessor :name
# attribute_method_suffix '_short?'
#
# # Call to define_attribute_method must appear after the
# # attribute_method_prefix, attribute_method_suffix or
# # attribute_method_affix declarations.
# define_attribute_method :name
#
# private
#
# def attribute_short?(attr)
# send(attr).length < 5
# end
# end
#
# person = Person.new
# person.name = 'Bob'
# person.name # => "Bob"
# person.name_short? # => true
def define_attribute_method(attr_name, _owner: generated_attribute_methods)
ActiveSupport::CodeGenerator.batch(_owner, __FILE__, __LINE__) do |owner|
attribute_method_patterns.each do |pattern|
method_name = pattern.method_name(attr_name)
unless instance_method_already_implemented?(method_name)
generate_method = "define_method_#{pattern.proxy_target}"
if respond_to?(generate_method, true)
send(generate_method, attr_name.to_s, owner: owner)
else
define_proxy_call(owner, method_name, pattern.proxy_target, pattern.parameters, attr_name.to_s, namespace: :active_model_proxy)
end
end
end
attribute_method_patterns_cache.clear
end
end
# Removes all the previously dynamically defined methods from the class.
#
# class Person
# include ActiveModel::AttributeMethods
#
# attr_accessor :name
# attribute_method_suffix '_short?'
# define_attribute_method :name
#
# private
#
# def attribute_short?(attr)
# send(attr).length < 5
# end
# end
#
# person = Person.new
# person.name = 'Bob'
# person.name_short? # => true
#
# Person.undefine_attribute_methods
#
# person.name_short? # => NoMethodError
def undefine_attribute_methods
generated_attribute_methods.module_eval do
undef_method(*instance_methods)
end
attribute_method_patterns_cache.clear
end
private
def generated_attribute_methods
@generated_attribute_methods ||= Module.new.tap { |mod| include mod }
end
def instance_method_already_implemented?(method_name)
generated_attribute_methods.method_defined?(method_name)
end
# The methods +method_missing+ and +respond_to?+ of this module are
# invoked often in a typical rails, both of which invoke the method
# +matched_attribute_method+. The latter method iterates through an
# array doing regular expression matches, which results in a lot of
# object creations. Most of the time it returns a +nil+ match. As the
# match result is always the same given a +method_name+, this cache is
# used to alleviate the GC, which ultimately also speeds up the app
# significantly (in our case our test suite finishes 10% faster with
# this cache).
def attribute_method_patterns_cache
@attribute_method_patterns_cache ||= Concurrent::Map.new(initial_capacity: 4)
end
def attribute_method_patterns_matching(method_name)
attribute_method_patterns_cache.compute_if_absent(method_name) do
attribute_method_patterns.filter_map { |pattern| pattern.match(method_name) }
end
end
# Define a method `name` in `mod` that dispatches to `send`
# using the given `extra` args. This falls back on `send`
# if the called name cannot be compiled.
def define_proxy_call(code_generator, name, proxy_target, parameters, *call_args, namespace:)
mangled_name = name
unless NAME_COMPILABLE_REGEXP.match?(name)
mangled_name = "__temp__#{name.unpack1("h*")}"
end
code_generator.define_cached_method(name, as: mangled_name, namespace: namespace) do |batch|
call_args.map!(&:inspect)
call_args << parameters if parameters
body = if CALL_COMPILABLE_REGEXP.match?(proxy_target)
"self.#{proxy_target}(#{call_args.join(", ")})"
else
call_args.unshift(":'#{proxy_target}'")
"send(#{call_args.join(", ")})"
end
modifier = parameters == FORWARD_PARAMETERS ? "ruby2_keywords " : ""
batch <<
"#{modifier}def #{mangled_name}(#{parameters || ''})" <<
body <<
"end"
end
end
class AttributeMethodPattern # :nodoc:
attr_reader :prefix, :suffix, :proxy_target, :parameters
AttributeMethod = Struct.new(:proxy_target, :attr_name)
def initialize(prefix: "", suffix: "", parameters: nil)
@prefix = prefix
@suffix = suffix
@parameters = parameters.nil? ? FORWARD_PARAMETERS : parameters
@regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
@proxy_target = "#{@prefix}attribute#{@suffix}"
@method_name = "#{prefix}%s#{suffix}"
end
def match(method_name)
if @regex =~ method_name
AttributeMethod.new(proxy_target, $1)
end
end
def method_name(attr_name)
@method_name % attr_name
end
end
end
# Allows access to the object attributes, which are held in the hash
# returned by <tt>attributes</tt>, as though they were first-class
# methods. So a +Person+ class with a +name+ attribute can for example use
# <tt>Person#name</tt> and <tt>Person#name=</tt> and never directly use
# the attributes hash -- except for multiple assignments with
# <tt>ActiveRecord::Base#attributes=</tt>.
#
# It's also possible to instantiate related objects, so a <tt>Client</tt>
# class belonging to the +clients+ table with a +master_id+ foreign key
# can instantiate master through <tt>Client#master</tt>.
def method_missing(method, *args, &block)
if respond_to_without_attributes?(method, true)
super
else
match = matched_attribute_method(method.to_s)
match ? attribute_missing(match, *args, &block) : super
end
end
ruby2_keywords(:method_missing)
# +attribute_missing+ is like +method_missing+, but for attributes. When
# +method_missing+ is called we check to see if there is a matching
# attribute method. If so, we tell +attribute_missing+ to dispatch the
# attribute. This method can be overloaded to customize the behavior.
def attribute_missing(match, *args, &block)
__send__(match.proxy_target, match.attr_name, *args, &block)
end
# A +Person+ instance with a +name+ attribute can ask
# <tt>person.respond_to?(:name)</tt>, <tt>person.respond_to?(:name=)</tt>,
# and <tt>person.respond_to?(:name?)</tt> which will all return +true+.
alias :respond_to_without_attributes? :respond_to?
def respond_to?(method, include_private_methods = false)
if super
true
elsif !include_private_methods && super(method, true)
# If we're here then we haven't found among non-private methods
# but found among all methods. Which means that the given method is private.
false
else
!matched_attribute_method(method.to_s).nil?
end
end
private
def attribute_method?(attr_name)
respond_to_without_attributes?(:attributes) && attributes.include?(attr_name)
end
# Returns a struct representing the matching attribute method.
# The struct's attributes are prefix, base and suffix.
def matched_attribute_method(method_name)
matches = self.class.send(:attribute_method_patterns_matching, method_name)
matches.detect { |match| attribute_method?(match.attr_name) }
end
def missing_attribute(attr_name, stack)
raise ActiveModel::MissingAttributeError, "missing attribute: #{attr_name}", stack
end
def _read_attribute(attr)
__send__(attr)
end
module AttrNames # :nodoc:
DEF_SAFE_NAME = /\A[a-zA-Z_]\w*\z/
# We want to generate the methods via module_eval rather than
# define_method, because define_method is slower on dispatch.
#
# But sometimes the database might return columns with
# characters that are not allowed in normal method names (like
# 'my_column(omg)'. So to work around this we first define with
# the __temp__ identifier, and then use alias method to rename
# it to what we want.
#
# We are also defining a constant to hold the frozen string of
# the attribute name. Using a constant means that we do not have
# to allocate an object on each call to the attribute method.
# Making it frozen means that it doesn't get duped when used to
# key the @attributes in read_attribute.
def self.define_attribute_accessor_method(owner, attr_name, writer: false)
method_name = "#{attr_name}#{'=' if writer}"
if attr_name.ascii_only? && DEF_SAFE_NAME.match?(attr_name)
yield method_name, "'#{attr_name}'"
else
safe_name = attr_name.unpack1("h*")
const_name = "ATTR_#{safe_name}"
const_set(const_name, attr_name) unless const_defined?(const_name)
temp_method_name = "__temp__#{safe_name}#{'=' if writer}"
attr_name_expr = "::ActiveModel::AttributeMethods::AttrNames::#{const_name}"
yield temp_method_name, attr_name_expr
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
class ModelWithAttributes
include ActiveModel::AttributeMethods
class << self
define_method(:bar) do
"original bar"
end
end
def attributes
{ foo: "value of foo", baz: "value of baz" }
end
private
def attribute(name)
attributes[name.to_sym]
end
end
class ModelWithAttributes2
include ActiveModel::AttributeMethods
attr_accessor :attributes
attribute_method_suffix "_test", "_kw"
private
def attribute(name)
attributes[name.to_s]
end
def attribute_test(name, attrs = {})
attrs[name] = attribute(name)
end
def attribute_kw(name, kw: 1)
attribute(name)
end
def private_method
"<3 <3"
end
protected
def protected_method
"O_o O_o"
end
end
class ModelWithAttributesWithSpaces
include ActiveModel::AttributeMethods
def attributes
{ 'foo bar': "value of foo bar" }
end
private
def attribute(name)
attributes[name.to_sym]
end
end
class ModelWithWeirdNamesAttributes
include ActiveModel::AttributeMethods
class << self
define_method(:'c?d') do
"original c?d"
end
end
def attributes
{ 'a?b': "value of a?b" }
end
private
def attribute(name)
attributes[name.to_sym]
end
end
class ModelWithRubyKeywordNamedAttributes
include ActiveModel::AttributeMethods
def attributes
{ begin: "value of begin", end: "value of end" }
end
private
def attribute(name)
attributes[name.to_sym]
end
end
class ModelWithoutAttributesMethod
include ActiveModel::AttributeMethods
end
class AttributeMethodsTest < ActiveModel::TestCase
test "method missing works correctly even if attributes method is not defined" do
assert_raises(NoMethodError) { ModelWithoutAttributesMethod.new.foo }
end
test "unrelated classes should not share attribute method matchers" do
assert_not_equal ModelWithAttributes.public_send(:attribute_method_patterns),
ModelWithAttributes2.public_send(:attribute_method_patterns)
end
test "#define_attribute_method generates attribute method" do
ModelWithAttributes.define_attribute_method(:foo)
assert_respond_to ModelWithAttributes.new, :foo
assert_equal "value of foo", ModelWithAttributes.new.foo
ensure
ModelWithAttributes.undefine_attribute_methods
end
test "#define_attribute_method does not generate attribute method if already defined in attribute module" do
klass = Class.new(ModelWithAttributes)
klass.send(:generated_attribute_methods).module_eval do
def foo
"<3"
end
end
klass.define_attribute_method(:foo)
assert_equal "<3", klass.new.foo
end
test "#define_attribute_method generates a method that is already defined on the host" do
klass = Class.new(ModelWithAttributes) do
def foo
super
end
end
klass.define_attribute_method(:foo)
assert_equal "value of foo", klass.new.foo
end
test "#define_attribute_method generates attribute method with invalid identifier characters" do
ModelWithWeirdNamesAttributes.define_attribute_method(:'a?b')
assert_respond_to ModelWithWeirdNamesAttributes.new, :'a?b'
assert_equal "value of a?b", ModelWithWeirdNamesAttributes.new.public_send("a?b")
ensure
ModelWithWeirdNamesAttributes.undefine_attribute_methods
end
test "#define_attribute_methods works passing multiple arguments" do
ModelWithAttributes.define_attribute_methods(:foo, :baz)
assert_equal "value of foo", ModelWithAttributes.new.foo
assert_equal "value of baz", ModelWithAttributes.new.baz
ensure
ModelWithAttributes.undefine_attribute_methods
end
test "#define_attribute_methods generates attribute methods" do
ModelWithAttributes.define_attribute_methods(:foo)
assert_respond_to ModelWithAttributes.new, :foo
assert_equal "value of foo", ModelWithAttributes.new.foo
ensure
ModelWithAttributes.undefine_attribute_methods
end
test "#alias_attribute generates attribute_aliases lookup hash" do
klass = Class.new(ModelWithAttributes) do
define_attribute_methods :foo
alias_attribute :bar, :foo
end
assert_equal({ "bar" => "foo" }, klass.attribute_aliases)
end
test "#define_attribute_methods generates attribute methods with spaces in their names" do
ModelWithAttributesWithSpaces.define_attribute_methods(:'foo bar')
assert_respond_to ModelWithAttributesWithSpaces.new, :'foo bar'
assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.public_send(:'foo bar')
ensure
ModelWithAttributesWithSpaces.undefine_attribute_methods
end
test "#alias_attribute works with attributes with spaces in their names" do
ModelWithAttributesWithSpaces.define_attribute_methods(:'foo bar')
ModelWithAttributesWithSpaces.alias_attribute(:'foo_bar', :'foo bar')
assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.foo_bar
ensure
ModelWithAttributesWithSpaces.undefine_attribute_methods
end
test "#alias_attribute works with attributes named as a ruby keyword" do
ModelWithRubyKeywordNamedAttributes.define_attribute_methods([:begin, :end])
ModelWithRubyKeywordNamedAttributes.alias_attribute(:from, :begin)
ModelWithRubyKeywordNamedAttributes.alias_attribute(:to, :end)
assert_equal "value of begin", ModelWithRubyKeywordNamedAttributes.new.from
assert_equal "value of end", ModelWithRubyKeywordNamedAttributes.new.to
ensure
ModelWithRubyKeywordNamedAttributes.undefine_attribute_methods
end
test "#undefine_attribute_methods removes attribute methods" do
ModelWithAttributes.define_attribute_methods(:foo)
ModelWithAttributes.undefine_attribute_methods
assert_not_respond_to ModelWithAttributes.new, :foo
assert_raises(NoMethodError) { ModelWithAttributes.new.foo }
end
test "accessing a suffixed attribute" do
m = ModelWithAttributes2.new
m.attributes = { "foo" => "bar" }
attrs = {}
assert_equal "bar", m.foo
assert_equal "bar", m.foo_kw(kw: 2)
assert_equal "bar", m.foo_test(attrs)
assert_equal "bar", attrs["foo"]
end
test "defined attribute doesn't expand positional hash argument" do
ModelWithAttributes2.define_attribute_methods(:foo)
m = ModelWithAttributes2.new
m.attributes = { "foo" => "bar" }
attrs = {}
assert_equal "bar", m.foo
assert_equal "bar", m.foo_kw(kw: 2)
assert_equal "bar", m.foo_test(attrs)
assert_equal "bar", attrs["foo"]
ensure
ModelWithAttributes2.undefine_attribute_methods
end
test "should not interfere with method_missing if the attr has a private/protected method" do
m = ModelWithAttributes2.new
m.attributes = { "private_method" => "<3", "protected_method" => "O_o" }
# dispatches to the *method*, not the attribute
assert_equal "<3 <3", m.send(:private_method)
assert_equal "O_o O_o", m.send(:protected_method)
# sees that a method is already defined, so doesn't intervene
assert_raises(NoMethodError) { m.private_method }
assert_raises(NoMethodError) { m.protected_method }
end
class ClassWithProtected
protected
def protected_method
end
end
test "should not interfere with respond_to? if the attribute has a private/protected method" do
m = ModelWithAttributes2.new
m.attributes = { "private_method" => "<3", "protected_method" => "O_o" }
assert_not_respond_to m, :private_method
assert m.respond_to?(:private_method, true)
c = ClassWithProtected.new
# This is messed up, but it's how Ruby works at the moment. Apparently it will be changed
# in the future.
assert_equal c.respond_to?(:protected_method), m.respond_to?(:protected_method)
assert m.respond_to?(:protected_method, true)
end
test "should use attribute_missing to dispatch a missing attribute" do
m = ModelWithAttributes2.new
m.attributes = { "foo" => "bar" }
def m.attribute_missing(match, *args, &block)
match
end
match = m.foo_test
assert_equal "foo", match.attr_name
assert_equal "attribute_test", match.proxy_target
end
end
# frozen_string_literal: true
require "active_support/core_ext/hash/indifferent_access"
require "active_support/core_ext/object/duplicable"
module ActiveModel
class AttributeMutationTracker # :nodoc:
OPTION_NOT_GIVEN = Object.new
def initialize(attributes)
@attributes = attributes
end
def changed_attribute_names
attr_names.select { |attr_name| changed?(attr_name) }
end
def changed_values
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
if changed?(attr_name)
result[attr_name] = original_value(attr_name)
end
end
end
def changes
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
if change = change_to_attribute(attr_name)
result.merge!(attr_name => change)
end
end
end
def change_to_attribute(attr_name)
if changed?(attr_name)
[original_value(attr_name), fetch_value(attr_name)]
end
end
def any_changes?
attr_names.any? { |attr| changed?(attr) }
end
def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN)
attribute_changed?(attr_name) &&
(OPTION_NOT_GIVEN == from || original_value(attr_name) == from) &&
(OPTION_NOT_GIVEN == to || fetch_value(attr_name) == to)
end
def changed_in_place?(attr_name)
attributes[attr_name].changed_in_place?
end
def forget_change(attr_name)
attributes[attr_name] = attributes[attr_name].forgetting_assignment
forced_changes.delete(attr_name)
end
def original_value(attr_name)
attributes[attr_name].original_value
end
def force_change(attr_name)
forced_changes[attr_name] = fetch_value(attr_name)
end
private
attr_reader :attributes
def forced_changes
@forced_changes ||= {}
end
def attr_names
attributes.keys
end
def attribute_changed?(attr_name)
forced_changes.include?(attr_name) || !!attributes[attr_name].changed?
end
def fetch_value(attr_name)
attributes.fetch_value(attr_name)
end
end
class ForcedMutationTracker < AttributeMutationTracker # :nodoc:
def initialize(attributes)
super
@finalized_changes = nil
end
def changed_in_place?(attr_name)
false
end
def change_to_attribute(attr_name)
if finalized_changes&.include?(attr_name)
finalized_changes[attr_name].dup
else
super
end
end
def forget_change(attr_name)
forced_changes.delete(attr_name)
end
def original_value(attr_name)
if changed?(attr_name)
forced_changes[attr_name]
else
fetch_value(attr_name)
end
end
def force_change(attr_name)
forced_changes[attr_name] = clone_value(attr_name) unless attribute_changed?(attr_name)
end
def finalize_changes
@finalized_changes = changes
end
private
attr_reader :finalized_changes
def attr_names
forced_changes.keys
end
def attribute_changed?(attr_name)
forced_changes.include?(attr_name)
end
def fetch_value(attr_name)
attributes.send(:_read_attribute, attr_name)
end
def clone_value(attr_name)
value = fetch_value(attr_name)
value.duplicable? ? value.clone : value
rescue TypeError, NoMethodError
value
end
end
class NullMutationTracker # :nodoc:
include Singleton
def changed_attribute_names
[]
end
def changed_values
{}
end
def changes
{}
end
def change_to_attribute(attr_name)
end
def any_changes?
false
end
def changed?(attr_name, **)
false
end
def changed_in_place?(attr_name)
false
end
def original_value(attr_name)
end
end
end
# frozen_string_literal: true
require "active_support/core_ext/enumerable"
require "active_support/core_ext/object/deep_dup"
require "active_model/attribute_set/builder"
require "active_model/attribute_set/yaml_encoder"
module ActiveModel
class AttributeSet # :nodoc:
delegate :each_value, :fetch, :except, to: :attributes
def initialize(attributes)
@attributes = attributes
end
def [](name)
@attributes[name] || default_attribute(name)
end
def []=(name, value)
@attributes[name] = value
end
def values_before_type_cast
attributes.transform_values(&:value_before_type_cast)
end
def values_for_database
attributes.transform_values(&:value_for_database)
end
def to_hash
keys.index_with { |name| self[name].value }
end
alias :to_h :to_hash
def key?(name)
attributes.key?(name) && self[name].initialized?
end
def keys
attributes.each_key.select { |name| self[name].initialized? }
end
def fetch_value(name, &block)
self[name].value(&block)
end
def write_from_database(name, value)
@attributes[name] = self[name].with_value_from_database(value)
end
def write_from_user(name, value)
raise FrozenError, "can't modify frozen attributes" if frozen?
@attributes[name] = self[name].with_value_from_user(value)
value
end
def write_cast_value(name, value)
@attributes[name] = self[name].with_cast_value(value)
end
def freeze
attributes.freeze
super
end
def deep_dup
AttributeSet.new(attributes.deep_dup)
end
def initialize_dup(_)
@attributes = @attributes.dup
super
end
def initialize_clone(_)
@attributes = @attributes.clone
super
end
def reset(key)
if key?(key)
write_from_database(key, nil)
end
end
def accessed
attributes.each_key.select { |name| self[name].has_been_read? }
end
def map(&block)
new_attributes = attributes.transform_values(&block)
AttributeSet.new(new_attributes)
end
def ==(other)
attributes == other.attributes
end
protected
attr_reader :attributes
private
def default_attribute(name)
Attribute.null(name)
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_model/attribute_set"
module ActiveModel
class AttributeSetTest < ActiveModel::TestCase
test "building a new set from raw attributes" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new)
attributes = builder.build_from_database(foo: "1.1", bar: "2.2")
assert_equal 1, attributes[:foo].value
assert_equal 2.2, attributes[:bar].value
assert_equal :foo, attributes[:foo].name
assert_equal :bar, attributes[:bar].name
end
test "building with custom types" do
builder = AttributeSet::Builder.new(foo: Type::Float.new)
attributes = builder.build_from_database({ foo: "3.3", bar: "4.4" }, { bar: Type::Integer.new })
assert_equal 3.3, attributes[:foo].value
assert_equal 4, attributes[:bar].value
end
test "[] returns a null object" do
builder = AttributeSet::Builder.new(foo: Type::Float.new)
attributes = builder.build_from_database(foo: "3.3")
assert_equal "3.3", attributes[:foo].value_before_type_cast
assert_nil attributes[:bar].value_before_type_cast
assert_equal :bar, attributes[:bar].name
end
test "duping creates a new hash, but does not dup the attributes" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new)
attributes = builder.build_from_database(foo: 1, bar: "foo")
# Ensure the type cast value is cached
attributes[:foo].value
attributes[:bar].value
duped = attributes.dup
duped.write_from_database(:foo, 2)
duped[:bar].value << "bar"
assert_equal 1, attributes[:foo].value
assert_equal 2, duped[:foo].value
assert_equal "foobar", attributes[:bar].value
assert_equal "foobar", duped[:bar].value
end
test "deep_duping creates a new hash and dups each attribute" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new)
attributes = builder.build_from_database(foo: 1, bar: "foo")
# Ensure the type cast value is cached
attributes[:foo].value
attributes[:bar].value
duped = attributes.deep_dup
duped.write_from_database(:foo, 2)
duped[:bar].value << "bar"
assert_equal 1, attributes[:foo].value
assert_equal 2, duped[:foo].value
assert_equal "foo", attributes[:bar].value
assert_equal "foobar", duped[:bar].value
end
test "freezing cloned set does not freeze original" do
attributes = AttributeSet.new({})
clone = attributes.clone
clone.freeze
assert_predicate clone, :frozen?
assert_not_predicate attributes, :frozen?
end
test "to_hash returns a hash of the type cast values" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new)
attributes = builder.build_from_database(foo: "1.1", bar: "2.2")
assert_equal({ foo: 1, bar: 2.2 }, attributes.to_hash)
assert_equal({ foo: 1, bar: 2.2 }, attributes.to_h)
end
test "to_hash maintains order" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new)
attributes = builder.build_from_database(foo: "2.2", bar: "3.3")
attributes[:bar]
hash = attributes.to_h
assert_equal [[:foo, 2], [:bar, 3.3]], hash.to_a
end
test "values_before_type_cast" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new)
attributes = builder.build_from_database(foo: "1.1", bar: "2.2")
assert_equal({ foo: "1.1", bar: "2.2" }, attributes.values_before_type_cast)
end
test "known columns are built with uninitialized attributes" do
attributes = attributes_with_uninitialized_key
assert_predicate attributes[:foo], :initialized?
assert_not_predicate attributes[:bar], :initialized?
end
test "uninitialized attributes are not included in the attributes hash" do
attributes = attributes_with_uninitialized_key
assert_equal({ foo: 1 }, attributes.to_hash)
end
test "uninitialized attributes are not included in keys" do
attributes = attributes_with_uninitialized_key
assert_equal [:foo], attributes.keys
end
test "uninitialized attributes return false for key?" do
attributes = attributes_with_uninitialized_key
assert attributes.key?(:foo)
assert_not attributes.key?(:bar)
end
test "unknown attributes return false for key?" do
attributes = attributes_with_uninitialized_key
assert_not attributes.key?(:wibble)
end
test "fetch_value returns the value for the given initialized attribute" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new)
attributes = builder.build_from_database(foo: "1.1", bar: "2.2")
assert_equal 1, attributes.fetch_value(:foo)
assert_equal 2.2, attributes.fetch_value(:bar)
end
test "fetch_value returns nil for unknown attributes" do
attributes = attributes_with_uninitialized_key
assert_nil attributes.fetch_value(:wibble) { "hello" }
end
test "fetch_value returns nil for unknown attributes when types has a default" do
types = Hash.new(Type::Value.new)
builder = AttributeSet::Builder.new(types)
attributes = builder.build_from_database
assert_nil attributes.fetch_value(:wibble) { "hello" }
end
test "fetch_value uses the given block for uninitialized attributes" do
attributes = attributes_with_uninitialized_key
value = attributes.fetch_value(:bar) { |n| n.to_s + "!" }
assert_equal "bar!", value
end
test "fetch_value returns nil for uninitialized attributes if no block is given" do
attributes = attributes_with_uninitialized_key
assert_nil attributes.fetch_value(:bar)
end
test "the primary_key is always initialized" do
defaults = { foo: Attribute.from_user(:foo, nil, nil) }
builder = AttributeSet::Builder.new({ foo: Type::Integer.new }, defaults)
attributes = builder.build_from_database
assert attributes.key?(:foo)
assert_equal [:foo], attributes.keys
assert_predicate attributes[:foo], :initialized?
end
class MyType
def cast(value)
return if value.nil?
value + " from user"
end
def deserialize(value)
return if value.nil?
value + " from database"
end
def assert_valid_value(*)
end
end
test "write_from_database sets the attribute with database typecasting" do
builder = AttributeSet::Builder.new(foo: MyType.new)
attributes = builder.build_from_database
assert_nil attributes.fetch_value(:foo)
attributes.write_from_database(:foo, "value")
assert_equal "value from database", attributes.fetch_value(:foo)
end
test "write_from_user sets the attribute with user typecasting" do
builder = AttributeSet::Builder.new(foo: MyType.new)
attributes = builder.build_from_database
assert_nil attributes.fetch_value(:foo)
attributes.write_from_user(:foo, "value")
assert_equal "value from user", attributes.fetch_value(:foo)
end
class MySerializedType < ::ActiveModel::Type::Value
def serialize(value)
value + " serialized"
end
end
test "values_for_database" do
builder = AttributeSet::Builder.new(foo: MySerializedType.new)
attributes = builder.build_from_database
attributes.write_from_user(:foo, "value")
assert_equal({ foo: "value serialized" }, attributes.values_for_database)
end
test "freezing doesn't prevent the set from materializing" do
builder = AttributeSet::Builder.new(foo: Type::String.new)
attributes = builder.build_from_database(foo: "1")
attributes.freeze
assert_equal({ foo: "1" }, attributes.to_hash)
end
test "marshalling dump/load materialized attribute hash" do
builder = AttributeSet::Builder.new(foo: Type::String.new)
def builder.build_from_database(values = {}, additional_types = {})
attributes = LazyAttributeHash.new(types, values, additional_types, default_attributes)
AttributeSet.new(attributes)
end
attributes = builder.build_from_database(foo: "1")
data = Marshal.dump(attributes)
attributes = Marshal.load(data)
assert_equal({ foo: "1" }, attributes.to_hash)
end
test "#accessed_attributes returns only attributes which have been read" do
builder = AttributeSet::Builder.new(foo: Type::Value.new, bar: Type::Value.new)
attributes = builder.build_from_database(foo: "1", bar: "2")
assert_equal [], attributes.accessed
attributes.fetch_value(:foo)
assert_equal [:foo], attributes.accessed
end
test "#map returns a new attribute set with the changes applied" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new)
attributes = builder.build_from_database(foo: "1", bar: "2")
new_attributes = attributes.map do |attr|
attr.with_cast_value(attr.value + 1)
end
assert_equal 2, new_attributes.fetch_value(:foo)
assert_equal 3, new_attributes.fetch_value(:bar)
end
test "comparison for equality is correctly implemented" do
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new)
attributes = builder.build_from_database(foo: "1", bar: "2")
attributes2 = builder.build_from_database(foo: "1", bar: "2")
attributes3 = builder.build_from_database(foo: "2", bar: "2")
assert_equal attributes, attributes2
assert_not_equal attributes2, attributes3
end
private
def attributes_with_uninitialized_key
builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new)
builder.build_from_database(foo: "1.1")
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
class AttributeTest < ActiveModel::TestCase
setup do
@type = Minitest::Mock.new
end
teardown do
assert @type.verify
end
test "from_database + read type casts from database" do
@type.expect(:deserialize, "type cast from database", ["a value"])
attribute = Attribute.from_database(nil, "a value", @type)
type_cast_value = attribute.value
assert_equal "type cast from database", type_cast_value
end
test "from_user + read type casts from user" do
@type.expect(:cast, "type cast from user", ["a value"])
attribute = Attribute.from_user(nil, "a value", @type)
type_cast_value = attribute.value
assert_equal "type cast from user", type_cast_value
end
test "reading memoizes the value" do
@type.expect(:deserialize, "from the database", ["whatever"])
attribute = Attribute.from_database(nil, "whatever", @type)
type_cast_value = attribute.value
second_read = attribute.value
assert_equal "from the database", type_cast_value
assert_same type_cast_value, second_read
end
test "reading memoizes falsy values" do
@type.expect(:deserialize, false, ["whatever"])
attribute = Attribute.from_database(nil, "whatever", @type)
attribute.value
attribute.value
end
test "read_before_typecast returns the given value" do
attribute = Attribute.from_database(nil, "raw value", @type)
raw_value = attribute.value_before_type_cast
assert_equal "raw value", raw_value
end
test "from_database + read_for_database type casts to and from database" do
@type.expect(:deserialize, "read from database", ["whatever"])
@type.expect(:serialize, "ready for database", ["read from database"])
attribute = Attribute.from_database(nil, "whatever", @type)
serialize = attribute.value_for_database
assert_equal "ready for database", serialize
end
test "from_user + read_for_database type casts from the user to the database" do
@type.expect(:cast, "read from user", ["whatever"])
@type.expect(:serialize, "ready for database", ["read from user"])
attribute = Attribute.from_user(nil, "whatever", @type)
serialize = attribute.value_for_database
assert_equal "ready for database", serialize
end
test "duping dups the value" do
@type.expect(:deserialize, +"type cast", ["a value"])
attribute = Attribute.from_database(nil, "a value", @type)
value_from_orig = attribute.value
value_from_clone = attribute.dup.value
value_from_orig << " foo"
assert_equal "type cast foo", value_from_orig
assert_equal "type cast", value_from_clone
end
test "duping does not dup the value if it is not dupable" do
@type.expect(:deserialize, false, ["a value"])
attribute = Attribute.from_database(nil, "a value", @type)
assert_same attribute.value, attribute.dup.value
end
test "duping does not eagerly type cast if we have not yet type cast" do
attribute = Attribute.from_database(nil, "a value", @type)
attribute.dup
end
class MyType
def cast(value)
value + " from user"
end
def deserialize(value)
value + " from database"
end
def assert_valid_value(*)
end
end
test "with_value_from_user returns a new attribute with the value from the user" do
old = Attribute.from_database(nil, "old", MyType.new)
new = old.with_value_from_user("new")
assert_equal "old from database", old.value
assert_equal "new from user", new.value
end
test "with_value_from_database returns a new attribute with the value from the database" do
old = Attribute.from_user(nil, "old", MyType.new)
new = old.with_value_from_database("new")
assert_equal "old from user", old.value
assert_equal "new from database", new.value
end
test "uninitialized attributes yield their name if a block is given to value" do
block = proc { |name| name.to_s + "!" }
foo = Attribute.uninitialized(:foo, nil)
bar = Attribute.uninitialized(:bar, nil)
assert_equal "foo!", foo.value(&block)
assert_equal "bar!", bar.value(&block)
end
test "uninitialized attributes have no value" do
assert_nil Attribute.uninitialized(:foo, nil).value
end
test "attributes equal other attributes with the same constructor arguments" do
first = Attribute.from_database(:foo, 1, Type::Integer.new)
second = Attribute.from_database(:foo, 1, Type::Integer.new)
assert_equal first, second
end
test "attributes do not equal attributes with different names" do
first = Attribute.from_database(:foo, 1, Type::Integer.new)
second = Attribute.from_database(:bar, 1, Type::Integer.new)
assert_not_equal first, second
end
test "attributes do not equal attributes with different types" do
first = Attribute.from_database(:foo, 1, Type::Integer.new)
second = Attribute.from_database(:foo, 1, Type::Float.new)
assert_not_equal first, second
end
test "attributes do not equal attributes with different values" do
first = Attribute.from_database(:foo, 1, Type::Integer.new)
second = Attribute.from_database(:foo, 2, Type::Integer.new)
assert_not_equal first, second
end
test "attributes do not equal attributes of other classes" do
first = Attribute.from_database(:foo, 1, Type::Integer.new)
second = Attribute.from_user(:foo, 1, Type::Integer.new)
assert_not_equal first, second
end
test "an attribute has not been read by default" do
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
assert_not_predicate attribute, :has_been_read?
end
test "an attribute has been read when its value is calculated" do
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
attribute.value
assert_predicate attribute, :has_been_read?
end
test "an attribute is not changed if it hasn't been assigned or mutated" do
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
assert_not_predicate attribute, :changed?
end
test "an attribute is changed if it's been assigned a new value" do
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
changed = attribute.with_value_from_user(2)
assert_predicate changed, :changed?
end
test "an attribute is not changed if it's assigned the same value" do
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
unchanged = attribute.with_value_from_user(1)
assert_not_predicate unchanged, :changed?
end
test "an attribute cannot be mutated if it has not been read,
and skips expensive calculations" do
type_which_raises_from_all_methods = Object.new
attribute = Attribute.from_database(:foo, "bar", type_which_raises_from_all_methods)
assert_not_predicate attribute, :changed_in_place?
end
test "an attribute is changed if it has been mutated" do
attribute = Attribute.from_database(:foo, "bar", Type::String.new)
attribute.value << "!"
assert_predicate attribute, :changed_in_place?
assert_predicate attribute, :changed?
end
test "an attribute can forget its changes" do
attribute = Attribute.from_database(:foo, "bar", Type::String.new)
changed = attribute.with_value_from_user("foo")
forgotten = changed.forgetting_assignment
assert changed.changed? # Check to avoid a false positive
assert_not_predicate forgotten, :changed?
end
test "with_value_from_user validates the value" do
type = Type::Value.new
type.define_singleton_method(:assert_valid_value) do |value|
if value == 1
raise ArgumentError
end
end
attribute = Attribute.from_database(:foo, 1, type)
assert_equal 1, attribute.value
assert_equal 2, attribute.with_value_from_user(2).value
assert_raises ArgumentError do
attribute.with_value_from_user(1)
end
end
test "with_type preserves mutations" do
attribute = Attribute.from_database(:foo, +"", Type::Value.new)
attribute.value << "1"
assert_equal 1, attribute.with_type(Type::Integer.new).value
end
end
end
# frozen_string_literal: true
require "active_model/attribute_set"
require "active_model/attribute/user_provided_default"
module ActiveModel
# The Attributes module allows models to define attributes beyond simple Ruby
# readers and writers. Similar to Active Record attributes, which are
# typically inferred from the database schema, Active Model Attributes are
# aware of data types, can have default values, and can handle casting and
# serialization.
#
# To use Attributes, include the module in your model class and define your
# attributes using the +attribute+ macro. It accepts a name, a type, a default
# value, and any other options supported by the attribute type.
#
# ==== Examples
#
# class Person
# include ActiveModel::Attributes
#
# attribute :name, :string
# attribute :active, :boolean, default: true
# end
#
# person = Person.new
# person.name = "Volmer"
#
# person.name # => "Volmer"
# person.active # => true
module Attributes
extend ActiveSupport::Concern
include ActiveModel::AttributeMethods
included do
attribute_method_suffix "=", parameters: "value"
class_attribute :attribute_types, :_default_attributes, instance_accessor: false
self.attribute_types = Hash.new(Type.default_value)
self._default_attributes = AttributeSet.new({})
end
module ClassMethods
# Defines a model attribute. In addition to the attribute name, a cast
# type and default value may be specified, as well as any options
# supported by the given cast type.
#
# class Person
# include ActiveModel::Attributes
#
# attribute :name, :string
# attribute :active, :boolean, default: true
# end
#
# person = Person.new
# person.name = "Volmer"
#
# person.name # => "Volmer"
# person.active # => true
def attribute(name, cast_type = nil, default: NO_DEFAULT_PROVIDED, **options)
name = name.to_s
cast_type = Type.lookup(cast_type, **options) if Symbol === cast_type
cast_type ||= attribute_types[name]
self.attribute_types = attribute_types.merge(name => cast_type)
define_default_attribute(name, default, cast_type)
define_attribute_method(name)
end
# Returns an array of attribute names as strings.
#
# class Person
# include ActiveModel::Attributes
#
# attribute :name, :string
# attribute :age, :integer
# end
#
# Person.attribute_names # => ["name", "age"]
def attribute_names
attribute_types.keys
end
private
def define_method_attribute=(name, owner:)
ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method(
owner, name, writer: true,
) do |temp_method_name, attr_name_expr|
owner.define_cached_method("#{name}=", as: temp_method_name, namespace: :active_model) do |batch|
batch <<
"def #{temp_method_name}(value)" <<
" _write_attribute(#{attr_name_expr}, value)" <<
"end"
end
end
end
NO_DEFAULT_PROVIDED = Object.new # :nodoc:
private_constant :NO_DEFAULT_PROVIDED
def define_default_attribute(name, value, type)
self._default_attributes = _default_attributes.deep_dup
if value == NO_DEFAULT_PROVIDED
default_attribute = _default_attributes[name].with_type(type)
else
default_attribute = Attribute::UserProvidedDefault.new(
name,
value,
type,
_default_attributes.fetch(name.to_s) { nil },
)
end
_default_attributes[name] = default_attribute
end
end
def initialize(*) # :nodoc:
@attributes = self.class._default_attributes.deep_dup
super
end
def initialize_dup(other) # :nodoc:
@attributes = @attributes.deep_dup
super
end
# Returns a hash of all the attributes with their names as keys and the
# values of the attributes as values.
#
# class Person
# include ActiveModel::Attributes
#
# attribute :name, :string
# attribute :age, :integer
# end
#
# person = Person.new
# person.name = "Francesco"
# person.age = 22
#
# person.attributes # => { "name" => "Francesco", "age" => 22}
def attributes
@attributes.to_hash
end
# Returns an array of attribute names as strings.
#
# class Person
# include ActiveModel::Attributes
#
# attribute :name, :string
# attribute :age, :integer
# end
#
# person = Person.new
# person.attribute_names # => ["name", "age"]
def attribute_names
@attributes.keys
end
def freeze # :nodoc:
@attributes = @attributes.clone.freeze unless frozen?
super
end
private
def _write_attribute(attr_name, value)
@attributes.write_from_user(attr_name, value)
end
alias :attribute= :_write_attribute
def attribute(attr_name)
@attributes.fetch_value(attr_name)
end
end
end
# frozen_string_literal: true
require "cases/helper"
class AttributesDirtyTest < ActiveModel::TestCase
class DirtyModel
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Dirty
attribute :name, :string
attribute :color, :string
attribute :size, :integer
def save
changes_applied
end
end
setup do
@model = DirtyModel.new
end
test "setting attribute will result in change" do
assert_not_predicate @model, :changed?
assert_not_predicate @model, :name_changed?
@model.name = "Ringo"
assert_predicate @model, :changed?
assert_predicate @model, :name_changed?
end
test "list of changed attribute keys" do
assert_equal [], @model.changed
@model.name = "Paul"
assert_equal ["name"], @model.changed
end
test "changes to attribute values" do
assert_not @model.changes["name"]
@model.name = "John"
assert_equal [nil, "John"], @model.changes["name"]
end
test "checking if an attribute has changed to a particular value" do
@model.name = "Ringo"
assert @model.name_changed?(from: nil, to: "Ringo")
assert_not @model.name_changed?(from: "Pete", to: "Ringo")
assert @model.name_changed?(to: "Ringo")
assert_not @model.name_changed?(to: "Pete")
assert @model.name_changed?(from: nil)
assert_not @model.name_changed?(from: "Pete")
end
test "changes accessible through both strings and symbols" do
@model.name = "David"
assert_not_nil @model.changes[:name]
assert_not_nil @model.changes["name"]
end
test "be consistent with symbols arguments after the changes are applied" do
@model.name = "David"
assert @model.attribute_changed?(:name)
@model.save
@model.name = "Rafael"
assert @model.attribute_changed?(:name)
end
test "attribute mutation" do
@model.name = "Yam"
@model.save
assert_not_predicate @model, :name_changed?
@model.name.replace("Hadad")
assert_predicate @model, :name_changed?
end
test "resetting attribute" do
@model.name = "Bob"
@model.restore_name!
assert_nil @model.name
assert_not_predicate @model, :name_changed?
end
test "setting color to same value should not result in change being recorded" do
@model.color = "red"
assert_predicate @model, :color_changed?
@model.save
assert_not_predicate @model, :color_changed?
assert_not_predicate @model, :changed?
@model.color = "red"
assert_not_predicate @model, :color_changed?
assert_not_predicate @model, :changed?
end
test "saving should reset model's changed status" do
@model.name = "Alf"
assert_predicate @model, :changed?
@model.save
assert_not_predicate @model, :changed?
assert_not_predicate @model, :name_changed?
end
test "saving should preserve previous changes" do
@model.name = "Jericho Cane"
@model.save
assert_equal [nil, "Jericho Cane"], @model.previous_changes["name"]
end
test "setting new attributes should not affect previous changes" do
@model.name = "Jericho Cane"
@model.save
@model.name = "DudeFella ManGuy"
assert_equal [nil, "Jericho Cane"], @model.name_previous_change
end
test "saving should preserve model's previous changed status" do
@model.name = "Jericho Cane"
@model.save
assert_predicate @model, :name_previously_changed?
end
test "previous value is preserved when changed after save" do
assert_equal({}, @model.changed_attributes)
@model.name = "Paul"
assert_equal({ "name" => nil }, @model.changed_attributes)
@model.save
@model.name = "John"
assert_equal({ "name" => "Paul" }, @model.changed_attributes)
end
test "changing the same attribute multiple times retains the correct original value" do
@model.name = "Otto"
@model.save
@model.name = "DudeFella ManGuy"
@model.name = "Mr. Manfredgensonton"
assert_equal ["Otto", "Mr. Manfredgensonton"], @model.name_change
assert_equal @model.name_was, "Otto"
end
test "using attribute_will_change! with a symbol" do
@model.size = 1
assert_predicate @model, :size_changed?
end
test "clear_changes_information should reset all changes" do
@model.name = "Dmitry"
@model.name_changed?
@model.save
@model.name = "Bob"
assert_equal [nil, "Dmitry"], @model.previous_changes["name"]
assert_equal "Dmitry", @model.changed_attributes["name"]
@model.clear_changes_information
assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.previous_changes
assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.changed_attributes
end
test "restore_attributes should restore all previous data" do
@model.name = "Dmitry"
@model.color = "Red"
@model.save
@model.name = "Bob"
@model.color = "White"
@model.restore_attributes
assert_not_predicate @model, :changed?
assert_equal "Dmitry", @model.name
assert_equal "Red", @model.color
end
test "restore_attributes can restore only some attributes" do
@model.name = "Dmitry"
@model.color = "Red"
@model.save
@model.name = "Bob"
@model.color = "White"
@model.restore_attributes(["name"])
assert_predicate @model, :changed?
assert_equal "Dmitry", @model.name
assert_equal "White", @model.color
end
test "changing the attribute reports a change only when the cast value changes" do
@model.size = "2.3"
@model.save
@model.size = "2.1"
assert_equal false, @model.changed?
@model.size = "5.1"
assert_equal true, @model.changed?
assert_equal true, @model.size_changed?
assert_equal({ "size" => [2, 5] }, @model.changes)
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
class AttributesTest < ActiveModel::TestCase
class ModelForAttributesTest
include ActiveModel::Model
include ActiveModel::Attributes
attribute :integer_field, :integer
attribute :string_field, :string
attribute :decimal_field, :decimal
attribute :string_with_default, :string, default: "default string"
attribute :date_field, :date, default: -> { Date.new(2016, 1, 1) }
attribute :boolean_field, :boolean
end
class ChildModelForAttributesTest < ModelForAttributesTest
end
class GrandchildModelForAttributesTest < ChildModelForAttributesTest
attribute :integer_field, :string
attribute :string_field, default: "default string"
end
class ModelWithGeneratedAttributeMethods
include ActiveModel::Attributes
attribute :foo
end
class ModelWithProxiedAttributeMethods
include ActiveModel::AttributeMethods
attribute_method_suffix "="
define_attribute_method(:foo)
def attribute=(_, _)
end
end
test "models that proxy attributes do not conflict with models with generated methods" do
ModelWithGeneratedAttributeMethods.new
model = ModelWithProxiedAttributeMethods.new
assert_nothing_raised do
model.foo = "foo"
end
end
test "properties assignment" do
data = ModelForAttributesTest.new(
integer_field: "2.3",
string_field: "Rails FTW",
decimal_field: "12.3",
boolean_field: "0"
)
assert_equal 2, data.integer_field
assert_equal "Rails FTW", data.string_field
assert_equal BigDecimal("12.3"), data.decimal_field
assert_equal "default string", data.string_with_default
assert_equal Date.new(2016, 1, 1), data.date_field
assert_equal false, data.boolean_field
data.integer_field = 10
data.string_with_default = nil
data.boolean_field = "1"
assert_equal 10, data.integer_field
assert_nil data.string_with_default
assert_equal true, data.boolean_field
end
test "reading attributes" do
data = ModelForAttributesTest.new(
integer_field: 1.1,
string_field: 1.1,
decimal_field: 1.1,
boolean_field: 1.1
)
expected_attributes = {
integer_field: 1,
string_field: "1.1",
decimal_field: BigDecimal("1.1"),
string_with_default: "default string",
date_field: Date.new(2016, 1, 1),
boolean_field: true
}.stringify_keys
assert_equal expected_attributes, data.attributes
end
test "reading attribute names" do
names = [
"integer_field",
"string_field",
"decimal_field",
"string_with_default",
"date_field",
"boolean_field"
]
assert_equal names, ModelForAttributesTest.attribute_names
assert_equal names, ModelForAttributesTest.new.attribute_names
end
test "nonexistent attribute" do
assert_raise ActiveModel::UnknownAttributeError do
ModelForAttributesTest.new(nonexistent: "nonexistent")
end
end
test "children inherit attributes" do
data = ChildModelForAttributesTest.new(integer_field: "4.4")
assert_equal 4, data.integer_field
end
test "children can override parents" do
klass = GrandchildModelForAttributesTest
assert_instance_of Type::String, klass.attribute_types["integer_field"]
assert_instance_of Type::String, klass.attribute_types["string_field"]
data = GrandchildModelForAttributesTest.new(integer_field: "4.4")
assert_equal "4.4", data.integer_field
assert_equal "default string", data.string_field
end
test "attributes with proc defaults can be marshalled" do
data = ModelForAttributesTest.new
attributes = data.instance_variable_get(:@attributes)
round_tripped = Marshal.load(Marshal.dump(data))
new_attributes = round_tripped.instance_variable_get(:@attributes)
assert_equal attributes, new_attributes
end
test "attributes can be dup-ed" do
data = ModelForAttributesTest.new
data.integer_field = 1
duped = data.dup
assert_equal 1, data.integer_field
assert_equal 1, duped.integer_field
duped.integer_field = 2
assert_equal 1, data.integer_field
assert_equal 2, duped.integer_field
end
test "can't modify attributes if frozen" do
data = ModelForAttributesTest.new
data.freeze
assert data.frozen?
assert_raise(FrozenError) { data.integer_field = 1 }
end
test "attributes can be frozen again" do
data = ModelForAttributesTest.new
data.freeze
assert_nothing_raised { data.freeze }
end
test "unknown type error is raised" do
assert_raise(ArgumentError) do
ModelForAttributesTest.attribute :foo, :unknown
end
end
end
end
# frozen_string_literal: true
require "active_model/type/integer"
module ActiveModel
module Type
# Attribute type for integers that can be serialized to an unlimited number
# of bytes. This type is registered under the +:big_integer+ key.
#
# class Person
# include ActiveModel::Attributes
#
# attribute :id, :big_integer
# end
#
# person = Person.new
# person.id = "18_000_000_000"
#
# person.id # => 18000000000
#
# All casting and serialization are performed in the same way as the
# standard ActiveModel::Type::Integer type.
class BigInteger < Integer
private
def max_value
::Float::INFINITY
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class BigIntegerTest < ActiveModel::TestCase
def test_type_cast_big_integer
type = Type::BigInteger.new
assert_equal 1, type.cast(1)
assert_equal 1, type.cast("1")
end
def test_small_values
type = Type::BigInteger.new
assert_equal(-9999999999999999999999999999999, type.serialize(-9999999999999999999999999999999))
end
def test_large_values
type = Type::BigInteger.new
assert_equal 9999999999999999999999999999999, type.serialize(9999999999999999999999999999999)
end
end
end
end
# frozen_string_literal: true
module ActiveModel
module Type
# Attribute type for representation of binary data. This type is registered
# under the +:binary+ key.
#
# Non-string values are coerced to strings using their +to_s+ method.
class Binary < Value
def type
:binary
end
def binary?
true
end
def cast(value)
if value.is_a?(Data)
value.to_s
else
super
end
end
def serialize(value)
return if value.nil?
Data.new(super)
end
def changed_in_place?(raw_old_value, value)
old_value = deserialize(raw_old_value)
old_value != value
end
class Data # :nodoc:
def initialize(value)
@value = value.to_s
end
def to_s
@value
end
alias_method :to_str, :to_s
def hex
@value.unpack1("H*")
end
def ==(other)
other == to_s || super
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class BinaryTest < ActiveModel::TestCase
def test_type_cast_binary
type = Type::Binary.new
assert_nil type.cast(nil)
assert_equal "1", type.cast("1")
assert_equal 1, type.cast(1)
end
end
end
end
# frozen_string_literal: true
module Blog
def self.use_relative_model_naming?
true
end
class Post
extend ActiveModel::Naming
end
end
# frozen_string_literal: true
module ActiveModel
module Type
# A class that behaves like a boolean type, including rules for coercion of
# user input.
#
# - <tt>"false"</tt>, <tt>"f"</tt>, <tt>"0"</tt>, +0+ or any other value in
# +FALSE_VALUES+ will be coerced to +false+.
# - Empty strings are coerced to +nil+.
# - All other values will be coerced to +true+.
class Boolean < Value
FALSE_VALUES = [
false, 0,
"0", :"0",
"f", :f,
"F", :F,
"false", :false,
"FALSE", :FALSE,
"off", :off,
"OFF", :OFF,
].to_set.freeze
def type # :nodoc:
:boolean
end
def serialize(value) # :nodoc:
cast(value)
end
private
def cast_value(value)
if value == ""
nil
else
!FALSE_VALUES.include?(value)
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class BooleanTest < ActiveModel::TestCase
def test_type_cast_boolean
type = Type::Boolean.new
assert_predicate type.cast(""), :nil?
assert_predicate type.cast(nil), :nil?
assert type.cast(true)
assert type.cast(1)
assert type.cast("1")
assert type.cast("t")
assert type.cast("T")
assert type.cast("true")
assert type.cast("TRUE")
assert type.cast("on")
assert type.cast("ON")
assert type.cast(" ")
assert type.cast("\u3000\r\n")
assert type.cast("\u0000")
assert type.cast("SOMETHING RANDOM")
assert type.cast(:"1")
assert type.cast(:t)
assert type.cast(:T)
assert type.cast(:true)
assert type.cast(:TRUE)
assert type.cast(:on)
assert type.cast(:ON)
# explicitly check for false vs nil
assert_equal false, type.cast(false)
assert_equal false, type.cast(0)
assert_equal false, type.cast("0")
assert_equal false, type.cast("f")
assert_equal false, type.cast("F")
assert_equal false, type.cast("false")
assert_equal false, type.cast("FALSE")
assert_equal false, type.cast("off")
assert_equal false, type.cast("OFF")
assert_equal false, type.cast(:"0")
assert_equal false, type.cast(:f)
assert_equal false, type.cast(:F)
assert_equal false, type.cast(:false)
assert_equal false, type.cast(:FALSE)
assert_equal false, type.cast(:off)
assert_equal false, type.cast(:OFF)
end
end
end
end
# frozen_string_literal: true
require "active_model/attribute"
module ActiveModel
class AttributeSet # :nodoc:
class Builder # :nodoc:
attr_reader :types, :default_attributes
def initialize(types, default_attributes = {})
@types = types
@default_attributes = default_attributes
end
def build_from_database(values = {}, additional_types = {})
LazyAttributeSet.new(values, types, additional_types, default_attributes)
end
end
end
class LazyAttributeSet < AttributeSet # :nodoc:
def initialize(values, types, additional_types, default_attributes, attributes = {})
super(attributes)
@values = values
@types = types
@additional_types = additional_types
@default_attributes = default_attributes
@casted_values = {}
@materialized = false
end
def key?(name)
(values.key?(name) || types.key?(name) || @attributes.key?(name)) && self[name].initialized?
end
def keys
keys = values.keys | types.keys | @attributes.keys
keys.keep_if { |name| self[name].initialized? }
end
def fetch_value(name, &block)
if attr = @attributes[name]
return attr.value(&block)
end
@casted_values.fetch(name) do
value_present = true
value = values.fetch(name) { value_present = false }
if value_present
type = additional_types.fetch(name, types[name])
@casted_values[name] = type.deserialize(value)
else
attr = default_attribute(name, value_present, value)
attr.value(&block)
end
end
end
protected
def attributes
unless @materialized
values.each_key { |key| self[key] }
types.each_key { |key| self[key] }
@materialized = true
end
@attributes
end
private
attr_reader :values, :types, :additional_types, :default_attributes
def default_attribute(
name,
value_present = true,
value = values.fetch(name) { value_present = false }
)
type = additional_types.fetch(name, types[name])
if value_present
@attributes[name] = Attribute.from_database(name, value, type, @casted_values[name])
elsif types.key?(name)
if attr = default_attributes[name]
@attributes[name] = attr.dup
else
@attributes[name] = Attribute.uninitialized(name, type)
end
else
Attribute.null(name)
end
end
end
class LazyAttributeHash # :nodoc:
delegate :transform_values, :each_value, :fetch, :except, to: :materialize
def initialize(types, values, additional_types, default_attributes, delegate_hash = {})
@types = types
@values = values
@additional_types = additional_types
@materialized = false
@delegate_hash = delegate_hash
@default_attributes = default_attributes
end
def key?(key)
delegate_hash.key?(key) || values.key?(key) || types.key?(key)
end
def [](key)
delegate_hash[key] || assign_default_value(key)
end
def []=(key, value)
delegate_hash[key] = value
end
def deep_dup
dup.tap do |copy|
copy.instance_variable_set(:@delegate_hash, delegate_hash.transform_values(&:dup))
end
end
def initialize_dup(_)
@delegate_hash = Hash[delegate_hash]
super
end
def each_key(&block)
keys = types.keys | values.keys | delegate_hash.keys
keys.each(&block)
end
def ==(other)
if other.is_a?(LazyAttributeHash)
materialize == other.materialize
else
materialize == other
end
end
def marshal_dump
[@types, @values, @additional_types, @default_attributes, @delegate_hash]
end
def marshal_load(values)
initialize(*values)
end
protected
def materialize
unless @materialized
values.each_key { |key| self[key] }
types.each_key { |key| self[key] }
unless frozen?
@materialized = true
end
end
delegate_hash
end
private
attr_reader :types, :values, :additional_types, :delegate_hash, :default_attributes
def assign_default_value(name)
type = additional_types.fetch(name, types[name])
value_present = true
value = values.fetch(name) { value_present = false }
if value_present
delegate_hash[name] = Attribute.from_database(name, value, type)
elsif types.key?(name)
attr = default_attributes[name]
if attr
delegate_hash[name] = attr.dup
else
delegate_hash[name] = Attribute.uninitialized(name, type)
end
end
end
end
end
# frozen_string_literal: true
module ActiveModel
module Validations
# == Active \Model \Validation \Callbacks
#
# Provides an interface for any class to have +before_validation+ and
# +after_validation+ callbacks.
#
# First, include ActiveModel::Validations::Callbacks from the class you are
# creating:
#
# class MyModel
# include ActiveModel::Validations::Callbacks
#
# before_validation :do_stuff_before_validation
# after_validation :do_stuff_after_validation
# end
#
# Like other <tt>before_*</tt> callbacks if +before_validation+ throws
# +:abort+ then <tt>valid?</tt> will not be called.
module Callbacks
extend ActiveSupport::Concern
included do
include ActiveSupport::Callbacks
define_callbacks :validation,
skip_after_callbacks_if_terminated: true,
scope: [:kind, :name]
end
module ClassMethods
# Defines a callback that will get called right before validation.
#
# class Person
# include ActiveModel::Validations
# include ActiveModel::Validations::Callbacks
#
# attr_accessor :name
#
# validates_length_of :name, maximum: 6
#
# before_validation :remove_whitespaces
#
# private
#
# def remove_whitespaces
# name.strip!
# end
# end
#
# person = Person.new
# person.name = ' bob '
# person.valid? # => true
# person.name # => "bob"
def before_validation(*args, &block)
options = args.extract_options!
set_options_for_callback(options)
set_callback(:validation, :before, *args, options, &block)
end
# Defines a callback that will get called right after validation.
#
# class Person
# include ActiveModel::Validations
# include ActiveModel::Validations::Callbacks
#
# attr_accessor :name, :status
#
# validates_presence_of :name
#
# after_validation :set_status
#
# private
#
# def set_status
# self.status = errors.empty?
# end
# end
#
# person = Person.new
# person.name = ''
# person.valid? # => false
# person.status # => false
# person.name = 'bob'
# person.valid? # => true
# person.status # => true
def after_validation(*args, &block)
options = args.extract_options!
options = options.dup
options[:prepend] = true
set_options_for_callback(options)
set_callback(:validation, :after, *args, options, &block)
end
private
def set_options_for_callback(options)
if options.key?(:on)
options[:on] = Array(options[:on])
options[:if] = [
->(o) {
!(options[:on] & Array(o.validation_context)).empty?
},
*options[:if]
]
end
end
end
private
# Override run_validations! to include callbacks.
def run_validations!
_run_validation_callbacks { super }
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
class Dog
include ActiveModel::Validations
include ActiveModel::Validations::Callbacks
attr_accessor :name, :history
def initialize
@history = []
end
end
class DogWithMethodCallbacks < Dog
before_validation :set_before_validation_marker
after_validation :set_after_validation_marker
def set_before_validation_marker; history << "before_validation_marker"; end
def set_after_validation_marker; history << "after_validation_marker" ; end
end
class DogValidatorsAreProc < Dog
before_validation { history << "before_validation_marker" }
after_validation { history << "after_validation_marker" }
end
class DogWithTwoValidators < Dog
before_validation { history << "before_validation_marker1" }
before_validation { history << "before_validation_marker2" }
end
class DogBeforeValidatorReturningFalse < Dog
before_validation { false }
before_validation { history << "before_validation_marker2" }
end
class DogBeforeValidatorThrowingAbort < Dog
before_validation { throw :abort }
before_validation { history << "before_validation_marker2" }
end
class DogAfterValidatorReturningFalse < Dog
after_validation { false }
after_validation { history << "after_validation_marker" }
end
class DogWithMissingName < Dog
before_validation { history << "before_validation_marker" }
validates_presence_of :name
end
class DogValidatorWithOnCondition < Dog
before_validation :set_before_validation_marker, on: :create
after_validation :set_after_validation_marker, on: :create
def set_before_validation_marker; history << "before_validation_marker"; end
def set_after_validation_marker; history << "after_validation_marker" ; end
end
class DogValidatorWithOnMultipleCondition < Dog
before_validation :set_before_validation_marker_on_context_a, on: :context_a
before_validation :set_before_validation_marker_on_context_b, on: :context_b
after_validation :set_after_validation_marker_on_context_a, on: :context_a
after_validation :set_after_validation_marker_on_context_b, on: :context_b
def set_before_validation_marker_on_context_a; history << "before_validation_marker on context_a"; end
def set_before_validation_marker_on_context_b; history << "before_validation_marker on context_b"; end
def set_after_validation_marker_on_context_a; history << "after_validation_marker on context_a" ; end
def set_after_validation_marker_on_context_b; history << "after_validation_marker on context_b" ; end
end
class DogValidatorWithIfCondition < Dog
before_validation :set_before_validation_marker1, if: -> { true }
before_validation :set_before_validation_marker2, if: -> { false }
after_validation :set_after_validation_marker1, if: -> { true }
after_validation :set_after_validation_marker2, if: -> { false }
def set_before_validation_marker1; history << "before_validation_marker1"; end
def set_before_validation_marker2; history << "before_validation_marker2" ; end
def set_after_validation_marker1; history << "after_validation_marker1"; end
def set_after_validation_marker2; history << "after_validation_marker2" ; end
end
class CallbacksWithMethodNamesShouldBeCalled < ActiveModel::TestCase
def test_if_condition_is_respected_for_before_validation
d = DogValidatorWithIfCondition.new
d.valid?
assert_equal ["before_validation_marker1", "after_validation_marker1"], d.history
end
def test_on_condition_is_respected_for_validation_with_matching_context
d = DogValidatorWithOnCondition.new
d.valid?(:create)
assert_equal ["before_validation_marker", "after_validation_marker"], d.history
end
def test_on_condition_is_respected_for_validation_without_matching_context
d = DogValidatorWithOnCondition.new
d.valid?(:save)
assert_equal [], d.history
end
def test_on_condition_is_respected_for_validation_without_context
d = DogValidatorWithOnCondition.new
d.valid?
assert_equal [], d.history
end
def test_on_multiple_condition_is_respected_for_validation_with_matching_context
d = DogValidatorWithOnMultipleCondition.new
d.valid?(:context_a)
assert_equal ["before_validation_marker on context_a", "after_validation_marker on context_a"], d.history
d = DogValidatorWithOnMultipleCondition.new
d.valid?(:context_b)
assert_equal ["before_validation_marker on context_b", "after_validation_marker on context_b"], d.history
d = DogValidatorWithOnMultipleCondition.new
d.valid?([:context_a, :context_b])
assert_equal([
"before_validation_marker on context_a",
"before_validation_marker on context_b",
"after_validation_marker on context_a",
"after_validation_marker on context_b"
], d.history)
end
def test_on_multiple_condition_is_respected_for_validation_without_matching_context
d = DogValidatorWithOnMultipleCondition.new
d.valid?(:save)
assert_equal [], d.history
end
def test_on_multiple_condition_is_respected_for_validation_without_context
d = DogValidatorWithOnMultipleCondition.new
d.valid?
assert_equal [], d.history
end
def test_before_validation_and_after_validation_callbacks_should_be_called
d = DogWithMethodCallbacks.new
d.valid?
assert_equal ["before_validation_marker", "after_validation_marker"], d.history
end
def test_before_validation_and_after_validation_callbacks_should_be_called_with_proc
d = DogValidatorsAreProc.new
d.valid?
assert_equal ["before_validation_marker", "after_validation_marker"], d.history
end
def test_before_validation_and_after_validation_callbacks_should_be_called_in_declared_order
d = DogWithTwoValidators.new
d.valid?
assert_equal ["before_validation_marker1", "before_validation_marker2"], d.history
end
def test_further_callbacks_should_not_be_called_if_before_validation_throws_abort
d = DogBeforeValidatorThrowingAbort.new
output = d.valid?
assert_equal [], d.history
assert_equal false, output
end
def test_further_callbacks_should_be_called_if_before_validation_returns_false
d = DogBeforeValidatorReturningFalse.new
output = d.valid?
assert_equal ["before_validation_marker2"], d.history
assert_equal true, output
end
def test_further_callbacks_should_be_called_if_after_validation_returns_false
d = DogAfterValidatorReturningFalse.new
d.valid?
assert_equal ["after_validation_marker"], d.history
end
def test_validation_test_should_be_done
d = DogWithMissingName.new
output = d.valid?
assert_equal ["before_validation_marker"], d.history
assert_equal false, output
end
def test_before_validation_does_not_mutate_the_if_options_array
opts = []
Class.new(Dog) do
before_validation(if: opts, on: :create) { }
end
assert_empty opts
end
def test_after_validation_does_not_mutate_the_if_options_array
opts = []
Class.new(Dog) do
after_validation(if: opts, on: :create) { }
end
assert_empty opts
end
end
# frozen_string_literal: true
require "active_support/core_ext/range"
module ActiveModel
module Validations
module Clusivity # :nodoc:
ERROR_MESSAGE = "An object with the method #include? or a proc, lambda or symbol is required, " \
"and must be supplied as the :in (or :within) option of the configuration hash"
def check_validity!
unless delimiter.respond_to?(:include?) || delimiter.respond_to?(:call) || delimiter.respond_to?(:to_sym)
raise ArgumentError, ERROR_MESSAGE
end
end
private
def include?(record, value)
members = if delimiter.respond_to?(:call)
delimiter.call(record)
elsif delimiter.respond_to?(:to_sym)
record.send(delimiter)
else
delimiter
end
if value.is_a?(Array)
value.all? { |v| members.public_send(inclusion_method(members), v) }
else
members.public_send(inclusion_method(members), value)
end
end
def delimiter
@delimiter ||= options[:in] || options[:within]
end
# After Ruby 2.2, <tt>Range#include?</tt> on non-number-or-time-ish ranges checks all
# possible values in the range for equality, which is slower but more accurate.
# <tt>Range#cover?</tt> uses the previous logic of comparing a value with the range
# endpoints, which is fast but is only accurate on Numeric, Time, Date,
# or DateTime ranges.
def inclusion_method(enumerable)
if enumerable.is_a? Range
case enumerable.first
when Numeric, Time, DateTime, Date
:cover?
else
:include?
end
else
:include?
end
end
end
end
end
# frozen_string_literal: true
module ActiveModel
module Validations
module Comparability # :nodoc:
COMPARE_CHECKS = { greater_than: :>, greater_than_or_equal_to: :>=,
equal_to: :==, less_than: :<, less_than_or_equal_to: :<=,
other_than: :!= }.freeze
def option_value(record, option_value)
case option_value
when Proc
option_value.call(record)
when Symbol
record.send(option_value)
else
option_value
end
end
def error_options(value, option_value)
options.except(*COMPARE_CHECKS.keys).merge!(
count: option_value,
value: value
)
end
end
end
end
# frozen_string_literal: true
require "active_model/validations/comparability"
module ActiveModel
module Validations
class ComparisonValidator < EachValidator # :nodoc:
include Comparability
def check_validity!
unless (options.keys & COMPARE_CHECKS.keys).any?
raise ArgumentError, "Expected one of :greater_than, :greater_than_or_equal_to, "\
":equal_to, :less_than, :less_than_or_equal_to, or :other_than option to be supplied."
end
end
def validate_each(record, attr_name, value)
options.slice(*COMPARE_CHECKS.keys).each do |option, raw_option_value|
option_value = option_value(record, raw_option_value)
if value.nil? || value.blank?
return record.errors.add(attr_name, :blank, **error_options(value, option_value))
end
unless value.public_send(COMPARE_CHECKS[option], option_value)
record.errors.add(attr_name, option, **error_options(value, option_value))
end
rescue ArgumentError => e
record.errors.add(attr_name, e.message)
end
end
end
module HelperMethods
# Validates the value of a specified attribute fulfills all
# defined comparisons with another value, proc, or attribute.
#
# class Person < ActiveRecord::Base
# validates_comparison_of :value, greater_than: 'the sum of its parts'
# end
#
# Configuration options:
# * <tt>:message</tt> - A custom error message (default is: "failed comparison").
# * <tt>:greater_than</tt> - Specifies the value must be greater than the
# supplied value.
# * <tt>:greater_than_or_equal_to</tt> - Specifies the value must be
# greater than or equal to the supplied value.
# * <tt>:equal_to</tt> - Specifies the value must be equal to the supplied
# value.
# * <tt>:less_than</tt> - Specifies the value must be less than the
# supplied value.
# * <tt>:less_than_or_equal_to</tt> - Specifies the value must be less
# than or equal to the supplied value.
# * <tt>:other_than</tt> - Specifies the value must not be equal to the
# supplied value.
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+ .
# See ActiveModel::Validations::ClassMethods#validates for more information.
#
# The validator requires at least one of the following checks to be supplied.
# Each will accept a proc, value, or a symbol which corresponds to a method:
#
# * <tt>:greater_than</tt>
# * <tt>:greater_than_or_equal_to</tt>
# * <tt>:equal_to</tt>
# * <tt>:less_than</tt>
# * <tt>:less_than_or_equal_to</tt>
# * <tt>:other_than</tt>
#
# For example:
#
# class Person < ActiveRecord::Base
# validates_comparison_of :birth_date, less_than_or_equal_to: -> { Date.today }
# validates_comparison_of :preferred_name, other_than: :given_name, allow_nil: true
# end
def validates_comparison_of(*attr_names)
validates_with ComparisonValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
class ComparisonValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
def test_validates_comparison_with_greater_than_using_numeric
Topic.validates_comparison_of :approved, greater_than: 10
assert_invalid_values([-12, 10], "must be greater than 10")
assert_valid_values([11])
end
def test_validates_comparison_with_greater_than_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, greater_than: date_value
assert_invalid_values([
Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
Date.parse("2020-08-02"),
DateTime.new(2020, 8, 1, 12, 34)], "must be greater than 2020-08-02")
assert_valid_values([Date.parse("2020-08-03"), DateTime.new(2020, 8, 2, 12, 34)])
end
def test_validates_comparison_with_greater_than_using_time
time_value = Time.at(1596285240)
Topic.validates_comparison_of :approved, greater_than: time_value
assert_invalid_values([
Time.at(1596285240),
Time.at(1593714600)], "must be greater than #{time_value}")
assert_valid_values([Time.at(1596371640), Time.at(1596393000)])
end
def test_validates_comparison_with_greater_than_using_string
Topic.validates_comparison_of :approved, greater_than: "cat"
assert_invalid_values(["ant", "cat"], "must be greater than cat")
assert_valid_values(["dog", "whale"])
end
def test_validates_comparison_with_greater_than_or_equal_to_using_numeric
Topic.validates_comparison_of :approved, greater_than_or_equal_to: 10
assert_invalid_values([-12, 5], "must be greater than or equal to 10")
assert_valid_values([11, 10])
end
def test_validates_comparison_with_greater_than_or_equal_to_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, greater_than_or_equal_to: date_value
assert_invalid_values([
Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
DateTime.new(2020, 8, 1, 12, 34)], "must be greater than or equal to 2020-08-02")
assert_valid_values([Date.parse("2020-08-03"), DateTime.new(2020, 8, 2, 12, 34), Date.parse("2020-08-02")])
end
def test_validates_comparison_with_greater_than_or_equal_to_using_time
time_value = Time.at(1596285240)
Topic.validates_comparison_of :approved, greater_than_or_equal_to: time_value
assert_invalid_values([
Time.at(1564662840),
Time.at(1596285230)], "must be greater than or equal to #{time_value}")
assert_valid_values([Time.at(1596285240), Time.at(1596285241)])
end
def test_validates_comparison_with_greater_than_or_equal_to_using_string
Topic.validates_comparison_of :approved, greater_than_or_equal_to: "cat"
assert_invalid_values(["ant"], "must be greater than or equal to cat")
assert_valid_values(["cat", "dog", "whale"])
end
def test_validates_comparison_with_equal_to_using_numeric
Topic.validates_comparison_of :approved, equal_to: 10
assert_invalid_values([-12, 5, 11], "must be equal to 10")
assert_valid_values([10])
end
def test_validates_comparison_with_equal_to_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, equal_to: date_value
assert_invalid_values([
Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
DateTime.new(2020, 8, 1, 12, 34),
Date.parse("2020-08-03"),
DateTime.new(2020, 8, 2, 12, 34)], "must be equal to 2020-08-02")
assert_valid_values([Date.parse("2020-08-02"), DateTime.new(2020, 8, 2, 0, 0)])
end
def test_validates_comparison_with_equal_to_using_time
time_value = Time.at(1596285240)
Topic.validates_comparison_of :approved, equal_to: time_value
assert_invalid_values([
Time.at(1564662840),
Time.at(1596285230)], "must be equal to #{time_value}")
assert_valid_values([Time.at(1596285240)])
end
def test_validates_comparison_with_equal_to_using_string
Topic.validates_comparison_of :approved, equal_to: "cat"
assert_invalid_values(["dog", "whale"], "must be equal to cat")
assert_valid_values(["cat"])
end
def test_validates_comparison_with_less_than_using_numeric
Topic.validates_comparison_of :approved, less_than: 10
assert_invalid_values([11, 10], "must be less than 10")
assert_valid_values([-12, -5, 5])
end
def test_validates_comparison_with_less_than_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, less_than: date_value
assert_invalid_values([
Date.parse("2020-08-02"),
Date.parse("2020-08-03"),
DateTime.new(2020, 8, 2, 12, 34)], "must be less than 2020-08-02")
assert_valid_values([Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
DateTime.new(2020, 8, 1, 12, 34)])
end
def test_validates_comparison_with_less_than_using_time
time_value = Time.at(1596285240)
Topic.validates_comparison_of :approved, less_than: time_value
assert_invalid_values([
Time.at(1596371640),
Time.at(1596393000)], "must be less than #{time_value}")
assert_valid_values([Time.at(1596285239), Time.at(1593714600)])
end
def test_validates_comparison_with_less_than_using_string
Topic.validates_comparison_of :approved, less_than: "dog"
assert_invalid_values(["whale"], "must be less than dog")
assert_valid_values(["ant", "cat"])
end
def test_validates_comparison_with_less_than_or_equal_to_using_numeric
Topic.validates_comparison_of :approved, less_than_or_equal_to: 10
assert_invalid_values([12], "must be less than or equal to 10")
assert_valid_values([-11, 5, 10])
end
def test_validates_comparison_with_less_than_or_equal_to_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, less_than_or_equal_to: date_value
assert_invalid_values([
Date.parse("2020-08-03"),
DateTime.new(2020, 8, 2, 12, 34)], "must be less than or equal to 2020-08-02")
assert_valid_values([Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
Date.parse("2020-08-02"),
DateTime.new(2020, 8, 1, 12, 34)])
end
def test_validates_comparison_with_less_than_or_equal_to_using_time
time_value = Time.at(1596285240)
Topic.validates_comparison_of :approved, less_than_or_equal_to: time_value
assert_invalid_values([
Time.at(1598963640),
Time.at(1596285241)], "must be less than or equal to #{time_value}")
assert_valid_values([Time.at(1596285240), Time.at(1596285230)])
end
def test_validates_comparison_with_less_than_or_equal_to_using_string
Topic.validates_comparison_of :approved, less_than_or_equal_to: "dog"
assert_invalid_values(["whale"], "must be less than or equal to dog")
assert_valid_values(["ant", "cat", "dog"])
end
def test_validates_comparison_with_other_than_using_numeric
Topic.validates_comparison_of :approved, other_than: 10
assert_invalid_values([10], "must be other than 10")
assert_valid_values([-12, 5, 11])
end
def test_validates_comparison_with_other_than_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, other_than: date_value
assert_invalid_values([Date.parse("2020-08-02"), DateTime.new(2020, 8, 2, 0, 0)], "must be other than 2020-08-02")
assert_valid_values([
Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
DateTime.new(2020, 8, 1, 12, 34),
Date.parse("2020-08-03"),
DateTime.new(2020, 8, 2, 12, 34)])
end
def test_validates_comparison_with_other_than_using_time
time_value = Time.at(1596285240)
Topic.validates_comparison_of :approved, other_than: time_value
assert_invalid_values([Time.at(1596285240)], "must be other than #{time_value}")
assert_valid_values([Time.at(1564662840), Time.at(1596285230)])
end
def test_validates_comparison_with_other_than_using_string
Topic.validates_comparison_of :approved, other_than: "whale"
assert_invalid_values(["whale"], "must be other than whale")
assert_valid_values(["ant", "cat", "dog"])
end
def test_validates_comparison_with_proc
Topic.define_method(:requested) { Date.new(2020, 8, 1) }
Topic.validates_comparison_of :approved, greater_than_or_equal_to: Proc.new(&:requested)
assert_invalid_values([Date.new(2020, 7, 1), Date.new(2019, 7, 1), DateTime.new(2020, 7, 1, 22, 34)], "must be greater than or equal to 2020-08-01")
assert_valid_values([Date.new(2020, 8, 2), DateTime.new(2021, 8, 1)])
ensure
Topic.remove_method :requested
end
def test_validates_comparison_with_method
Topic.define_method(:requested) { Date.new(2020, 8, 1) }
Topic.validates_comparison_of :approved, greater_than_or_equal_to: :requested
assert_invalid_values([Date.new(2020, 7, 1), Date.new(2019, 7, 1), DateTime.new(2020, 7, 1, 22, 34)], "must be greater than or equal to 2020-08-01")
assert_valid_values([Date.new(2020, 8, 2), DateTime.new(2021, 8, 1)])
ensure
Topic.remove_method :requested
end
def test_validates_comparison_with_custom_compare
custom = Struct.new(:amount) {
include Comparable
def <=>(other)
amount % 100 <=> other.amount % 100
end
}
Topic.validates_comparison_of :approved, greater_than_or_equal_to: custom.new(1150)
assert_invalid_values([custom.new(530), custom.new(2325)])
assert_valid_values([custom.new(575), custom.new(250), custom.new(1999)])
end
def test_validates_comparison_with_blank_allowed
Topic.validates_comparison_of :approved, greater_than: "cat", allow_blank: true
assert_invalid_values(["ant"])
assert_valid_values([nil, ""])
end
def test_validates_comparison_with_nil_allowed
Topic.validates_comparison_of :approved, less_than: 100, allow_nil: true
assert_invalid_values([200])
assert_valid_values([nil, 50])
end
def test_validates_comparison_of_incomparables
Topic.validates_comparison_of :approved, less_than: "cat"
assert_invalid_values([12], "comparison of Integer with String failed")
assert_invalid_values([nil])
assert_valid_values([])
end
def test_validates_comparison_of_multiple_values
Topic.validates_comparison_of :approved, other_than: 17, greater_than: 13
assert_invalid_values([12, nil, 17])
assert_valid_values([15])
end
def test_validates_comparison_of_no_options
error = assert_raises(ArgumentError) do
Topic.validates_comparison_of(:approved)
end
assert_equal "Expected one of :greater_than, :greater_than_or_equal_to, :equal_to," \
" :less_than, :less_than_or_equal_to, or :other_than option to be supplied.", error.message
end
private
def assert_invalid_values(values, error = nil)
with_each_topic_approved_value(values) do |topic, value|
assert topic.invalid?, "#{value.inspect} failed comparison"
assert topic.errors[:approved].any?, "FAILED for #{value.inspect}"
assert_equal error, topic.errors[:approved].first if error
end
end
def assert_valid_values(values)
with_each_topic_approved_value(values) do |topic, value|
assert topic.valid?, "#{value.inspect} failed comparison with validation error: #{topic.errors[:approved].first}"
end
end
def with_each_topic_approved_value(values)
topic = Topic.new(title: "comparison test", content: "whatever")
values.each do |value|
topic.approved = value
yield topic, value
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
class ConditionalValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
def test_if_validation_using_method_true
# When the method returns true
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: :condition_is_true)
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
def test_if_validation_using_array_of_true_methods
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: [:condition_is_true, :condition_is_true])
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
def test_unless_validation_using_array_of_false_methods
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", unless: [:condition_is_false, :condition_is_false])
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
def test_unless_validation_using_method_true
# When the method returns true
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", unless: :condition_is_true)
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :valid?
assert_empty t.errors[:title]
end
def test_if_validation_using_array_of_true_and_false_methods
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: [:condition_is_true, :condition_is_false])
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :valid?
assert_empty t.errors[:title]
end
def test_unless_validation_using_array_of_true_and_false_methods
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", unless: [:condition_is_true, :condition_is_false])
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :valid?
assert_empty t.errors[:title]
end
def test_if_validation_using_method_false
# When the method returns false
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: :condition_is_false)
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :valid?
assert_empty t.errors[:title]
end
def test_unless_validation_using_method_false
# When the method returns false
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", unless: :condition_is_false)
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
def test_if_validation_using_block_true
# When the block returns true
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}",
if: Proc.new { |r| r.content.size > 4 })
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
def test_unless_validation_using_block_true
# When the block returns true
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}",
unless: Proc.new { |r| r.content.size > 4 })
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :valid?
assert_empty t.errors[:title]
end
def test_if_validation_using_block_false
# When the block returns false
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}",
if: Proc.new { |r| r.title != "uhohuhoh" })
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :valid?
assert_empty t.errors[:title]
end
def test_unless_validation_using_block_false
# When the block returns false
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}",
unless: Proc.new { |r| r.title != "uhohuhoh" })
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
def test_validation_using_combining_if_true_and_unless_true_conditions
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: :condition_is_true, unless: :condition_is_true)
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :valid?
assert_empty t.errors[:title]
end
def test_validation_using_combining_if_true_and_unless_false_conditions
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: :condition_is_true, unless: :condition_is_false)
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
end
# frozen_string_literal: true
module ActiveModel
module Validations
class ConfirmationValidator < EachValidator # :nodoc:
def initialize(options)
super({ case_sensitive: true }.merge!(options))
setup!(options[:class])
end
def validate_each(record, attribute, value)
unless (confirmed = record.public_send("#{attribute}_confirmation")).nil?
unless confirmation_value_equal?(record, attribute, value, confirmed)
human_attribute_name = record.class.human_attribute_name(attribute)
record.errors.add(:"#{attribute}_confirmation", :confirmation, **options.except(:case_sensitive).merge!(attribute: human_attribute_name))
end
end
end
private
def setup!(klass)
klass.attr_reader(*attributes.filter_map do |attribute|
:"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation")
end)
klass.attr_writer(*attributes.filter_map do |attribute|
:"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation=")
end)
end
def confirmation_value_equal?(record, attribute, value, confirmed)
if !options[:case_sensitive] && value.is_a?(String)
value.casecmp(confirmed) == 0
else
value == confirmed
end
end
end
module HelperMethods
# Encapsulates the pattern of wanting to validate a password or email
# address field with a confirmation.
#
# Model:
# class Person < ActiveRecord::Base
# validates_confirmation_of :user_name, :password
# validates_confirmation_of :email_address,
# message: 'should match confirmation'
# end
#
# View:
# <%= password_field "person", "password" %>
# <%= password_field "person", "password_confirmation" %>
#
# The added +password_confirmation+ attribute is virtual; it exists only
# as an in-memory attribute for validating the password. To achieve this,
# the validation adds accessors to the model for the confirmation
# attribute.
#
# NOTE: This check is performed only if +password_confirmation+ is not
# +nil+. To require confirmation, make sure to add a presence check for
# the confirmation attribute:
#
# validates_presence_of :password_confirmation, if: :password_changed?
#
# Configuration options:
# * <tt>:message</tt> - A custom error message (default is: "doesn't match
# <tt>%{translated_attribute_name}</tt>").
# * <tt>:case_sensitive</tt> - Looks for an exact match. Ignored by
# non-text columns (+true+ by default).
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
# See ActiveModel::Validations::ClassMethods#validates for more information.
def validates_confirmation_of(*attr_names)
validates_with ConfirmationValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
class ConfirmationValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
def test_no_title_confirmation
Topic.validates_confirmation_of(:title)
t = Topic.new(author_name: "Plutarch")
assert_predicate t, :valid?
t.title_confirmation = "Parallel Lives"
assert_predicate t, :invalid?
t.title_confirmation = nil
t.title = "Parallel Lives"
assert_predicate t, :valid?
t.title_confirmation = "Parallel Lives"
assert_predicate t, :valid?
end
def test_title_confirmation
Topic.validates_confirmation_of(:title)
t = Topic.new("title" => "We should be confirmed", "title_confirmation" => "")
assert_predicate t, :invalid?
t.title_confirmation = "We should be confirmed"
assert_predicate t, :valid?
end
def test_validates_confirmation_of_with_boolean_attribute
Topic.validates_confirmation_of(:approved)
t = Topic.new(approved: true, approved_confirmation: nil)
assert_predicate t, :valid?
t.approved_confirmation = false
assert_predicate t, :invalid?
t.approved_confirmation = true
assert_predicate t, :valid?
end
def test_validates_confirmation_of_for_ruby_class
Person.validates_confirmation_of :karma
p = Person.new
p.karma_confirmation = "None"
assert_predicate p, :invalid?
assert_equal ["doesn't match Karma"], p.errors[:karma_confirmation]
p.karma = "None"
assert_predicate p, :valid?
ensure
Person.clear_validators!
end
def test_title_confirmation_with_i18n_attribute
@old_load_path, @old_backend = I18n.load_path.dup, I18n.backend
I18n.load_path.clear
I18n.backend = I18n::Backend::Simple.new
I18n.backend.store_translations("en",
errors: { messages: { confirmation: "doesn't match %{attribute}" } },
activemodel: { attributes: { topic: { title: "Test Title" } } })
Topic.validates_confirmation_of(:title)
t = Topic.new("title" => "We should be confirmed", "title_confirmation" => "")
assert_predicate t, :invalid?
assert_equal ["doesn't match Test Title"], t.errors[:title_confirmation]
ensure
I18n.load_path.replace @old_load_path
I18n.backend = @old_backend
I18n.backend.reload!
end
test "does not override confirmation reader if present" do
klass = Class.new do
include ActiveModel::Validations
def title_confirmation
"expected title"
end
validates_confirmation_of :title
end
assert_equal "expected title", klass.new.title_confirmation,
"confirmation validation should not override the reader"
end
test "does not override confirmation writer if present" do
klass = Class.new do
include ActiveModel::Validations
def title_confirmation=(value)
@title_confirmation = "expected title"
end
validates_confirmation_of :title
end
model = klass.new
model.title_confirmation = "new title"
assert_equal "expected title", model.title_confirmation,
"confirmation validation should not override the writer"
end
def test_title_confirmation_with_case_sensitive_option_true
Topic.validates_confirmation_of(:title, case_sensitive: true)
t = Topic.new(title: "title", title_confirmation: "Title")
assert_predicate t, :invalid?
end
def test_title_confirmation_with_case_sensitive_option_false
Topic.validates_confirmation_of(:title, case_sensitive: false)
t = Topic.new(title: "title", title_confirmation: "Title")
assert_predicate t, :valid?
end
end
# frozen_string_literal: true
class Contact
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
include ActiveModel::Serializers::JSON
attr_accessor :id, :name, :age, :created_at, :awesome, :preferences
attr_accessor :address, :friends, :contact
def social
%w(twitter github)
end
def network
{ git: :github }
end
def initialize(options = {})
options.each { |name, value| public_send("#{name}=", value) }
end
def pseudonyms
nil
end
def persisted?
id
end
def attributes=(hash)
hash.each do |k, v|
instance_variable_set("@#{k}", v)
end
end
def attributes
instance_values.except("address", "friends", "contact")
end
end
# frozen_string_literal: true
module ActiveModel
# == Active \Model \Conversion
#
# Handles default conversions: to_model, to_key, to_param, and to_partial_path.
#
# Let's take for example this non-persisted object.
#
# class ContactMessage
# include ActiveModel::Conversion
#
# # ContactMessage are never persisted in the DB
# def persisted?
# false
# end
# end
#
# cm = ContactMessage.new
# cm.to_model == cm # => true
# cm.to_key # => nil
# cm.to_param # => nil
# cm.to_partial_path # => "contact_messages/contact_message"
module Conversion
extend ActiveSupport::Concern
# If your object is already designed to implement all of the \Active \Model
# you can use the default <tt>:to_model</tt> implementation, which simply
# returns +self+.
#
# class Person
# include ActiveModel::Conversion
# end
#
# person = Person.new
# person.to_model == person # => true
#
# If your model does not act like an \Active \Model object, then you should
# define <tt>:to_model</tt> yourself returning a proxy object that wraps
# your object with \Active \Model compliant methods.
def to_model
self
end
# Returns an Array of all key attributes if any of the attributes is set, whether or not
# the object is persisted. Returns +nil+ if there are no key attributes.
#
# class Person
# include ActiveModel::Conversion
# attr_accessor :id
#
# def initialize(id)
# @id = id
# end
# end
#
# person = Person.new(1)
# person.to_key # => [1]
def to_key
key = respond_to?(:id) && id
key ? [key] : nil
end
# Returns a +string+ representing the object's key suitable for use in URLs,
# or +nil+ if <tt>persisted?</tt> is +false+.
#
# class Person
# include ActiveModel::Conversion
# attr_accessor :id
#
# def initialize(id)
# @id = id
# end
#
# def persisted?
# true
# end
# end
#
# person = Person.new(1)
# person.to_param # => "1"
def to_param
(persisted? && key = to_key) ? key.join("-") : nil
end
# Returns a +string+ identifying the path associated with the object.
# ActionPack uses this to find a suitable partial to represent the object.
#
# class Person
# include ActiveModel::Conversion
# end
#
# person = Person.new
# person.to_partial_path # => "people/person"
def to_partial_path
self.class._to_partial_path
end
module ClassMethods # :nodoc:
# Provide a class level cache for #to_partial_path. This is an
# internal method and should not be accessed directly.
def _to_partial_path # :nodoc:
@_to_partial_path ||= begin
element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name))
collection = ActiveSupport::Inflector.tableize(name)
"#{collection}/#{element}"
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/contact"
require "models/helicopter"
class ConversionTest < ActiveModel::TestCase
test "to_model default implementation returns self" do
contact = Contact.new
assert_equal contact, contact.to_model
end
test "to_key default implementation returns nil for new records" do
assert_nil Contact.new.to_key
end
test "to_key default implementation returns the id in an array for persisted records" do
assert_equal [1], Contact.new(id: 1).to_key
end
test "to_param default implementation returns nil for new records" do
assert_nil Contact.new.to_param
end
test "to_param default implementation returns a string of ids for persisted records" do
assert_equal "1", Contact.new(id: 1).to_param
end
test "to_param returns the string joined by '-'" do
assert_equal "abc-xyz", Contact.new(id: ["abc", "xyz"]).to_param
end
test "to_param returns nil if to_key is nil" do
klass = Class.new(Contact) do
def persisted?
true
end
end
assert_nil klass.new.to_param
end
test "to_partial_path default implementation returns a string giving a relative path" do
assert_equal "contacts/contact", Contact.new.to_partial_path
assert_equal "helicopters/helicopter", Helicopter.new.to_partial_path,
"ActiveModel::Conversion#to_partial_path caching should be class-specific"
end
test "to_partial_path handles namespaced models" do
assert_equal "helicopter/comanches/comanche", Helicopter::Comanche.new.to_partial_path
end
end
# frozen_string_literal: true
class CustomReader
include ActiveModel::Validations
def initialize(data = {})
@data = data
end
def []=(key, value)
@data[key] = value
end
def read_attribute_for_validation(key)
@data[key]
end
end
# frozen_string_literal: true
module ActiveModel
module Type
# Attribute type for date representation. It is registered under the
# +:date+ key.
#
# class Person
# include ActiveModel::Attributes
#
# attribute :birthday, :date
# end
#
# person = Person.new
# person.birthday = "1989-07-13"
#
# person.birthday.class # => Date
# person.birthday.year # => 1989
# person.birthday.month # => 7
# person.birthday.day # => 13
#
# String values are parsed using the ISO 8601 date format. Any other values
# are cast using their +to_date+ method, if it exists.
class Date < Value
include Helpers::Timezone
include Helpers::AcceptsMultiparameterTime.new
def type
:date
end
def type_cast_for_schema(value)
value.to_fs(:db).inspect
end
private
def cast_value(value)
if value.is_a?(::String)
return if value.empty?
fast_string_to_date(value) || fallback_string_to_date(value)
elsif value.respond_to?(:to_date)
value.to_date
else
value
end
end
ISO_DATE = /\A(\d{4})-(\d\d)-(\d\d)\z/
def fast_string_to_date(string)
if string =~ ISO_DATE
new_date $1.to_i, $2.to_i, $3.to_i
end
end
def fallback_string_to_date(string)
new_date(*::Date._parse(string, false).values_at(:year, :mon, :mday))
end
def new_date(year, mon, mday)
unless year.nil? || (year == 0 && mon == 0 && mday == 0)
::Date.new(year, mon, mday) rescue nil
end
end
def value_from_multiparameter_assignment(*)
time = super
time && new_date(time.year, time.mon, time.mday)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class DateTest < ActiveModel::TestCase
def test_type_cast_date
type = Type::Date.new
assert_nil type.cast(nil)
assert_nil type.cast("")
assert_nil type.cast(" ")
assert_nil type.cast("ABC")
now = ::Time.now.utc
values_hash = { 1 => now.year, 2 => now.mon, 3 => now.mday }
date_string = now.strftime("%F")
assert_equal date_string, type.cast(date_string).strftime("%F")
assert_equal date_string, type.cast(values_hash).strftime("%F")
end
def test_returns_correct_year
type = Type::Date.new
time = ::Time.utc(1, 1, 1)
date = ::Date.new(time.year, time.mon, time.mday)
values_hash_for_multiparameter_assignment = { 1 => 1, 2 => 1, 3 => 1 }
assert_equal date, type.cast(values_hash_for_multiparameter_assignment)
end
end
end
end
# frozen_string_literal: true
module ActiveModel
module Type
# Attribute type to represent dates and times. It is registered under the
# +:datetime+ key.
#
# class Event
# include ActiveModel::Attributes
#
# attribute :start, :datetime
# end
#
# event = Event.new
# event.start = "Wed, 04 Sep 2013 03:00:00 EAT"
#
# event.start.class # => Time
# event.start.year # => 2013
# event.start.month # => 9
# event.start.day # => 4
# event.start.hour # => 3
# event.start.min # => 0
# event.start.sec # => 0
# event.start.zone # => "EAT"
#
# String values are parsed using the ISO 8601 datetime format. Partial
# time-only formats are also accepted.
#
# event.start = "06:07:08+09:00"
# event.start.utc # => 1999-12-31 21:07:08 UTC
#
# The degree of sub-second precision can be customized when declaring an
# attribute:
#
# class Event
# include ActiveModel::Attributes
#
# attribute :start, :datetime, precision: 4
# end
class DateTime < Value
include Helpers::Timezone
include Helpers::TimeValue
include Helpers::AcceptsMultiparameterTime.new(
defaults: { 4 => 0, 5 => 0 }
)
def type
:datetime
end
private
def cast_value(value)
return apply_seconds_precision(value) unless value.is_a?(::String)
return if value.empty?
fast_string_to_time(value) || fallback_string_to_time(value)
end
# '0.123456' -> 123456
# '1.123456' -> 123456
def microseconds(time)
time[:sec_fraction] ? (time[:sec_fraction] * 1_000_000).to_i : 0
end
def fallback_string_to_time(string)
time_hash = ::Date._parse(string)
time_hash[:sec_fraction] = microseconds(time_hash)
new_time(*time_hash.values_at(:year, :mon, :mday, :hour, :min, :sec, :sec_fraction, :offset))
end
def value_from_multiparameter_assignment(values_hash)
missing_parameters = [1, 2, 3].delete_if { |key| values_hash.key?(key) }
unless missing_parameters.empty?
raise ArgumentError, "Provided hash #{values_hash} doesn't contain necessary keys: #{missing_parameters}"
end
super
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class DateTimeTest < ActiveModel::TestCase
def test_type_cast_datetime_and_timestamp
type = Type::DateTime.new
assert_nil type.cast(nil)
assert_nil type.cast("")
assert_nil type.cast(" ")
assert_nil type.cast("ABC")
datetime_string = ::Time.now.utc.strftime("%FT%T")
assert_equal datetime_string, type.cast(datetime_string).strftime("%FT%T")
end
def test_string_to_time_with_timezone
["UTC", "US/Eastern"].each do |zone|
with_timezone_config default: zone do
type = Type::DateTime.new
assert_equal ::Time.utc(2013, 9, 4, 0, 0, 0), type.cast("Wed, 04 Sep 2013 03:00:00 EAT")
end
end
end
def test_hash_to_time
type = Type::DateTime.new
assert_equal ::Time.utc(2018, 10, 15, 0, 0, 0), type.cast(1 => 2018, 2 => 10, 3 => 15)
end
def test_hash_with_wrong_keys
type = Type::DateTime.new
error = assert_raises(ArgumentError) { type.cast(a: 1) }
assert_equal "Provided hash {:a=>1} doesn't contain necessary keys: [1, 2, 3]", error.message
end
private
def with_timezone_config(default:)
old_zone_default = ::Time.zone_default
::Time.zone_default = ::Time.find_zone(default)
yield
ensure
::Time.zone_default = old_zone_default
end
end
end
end
# frozen_string_literal: true
require "bigdecimal/util"
module ActiveModel
module Type
# Attribute type for decimal, high-precision floating point numeric
# representation. It is registered under the +:decimal+ key.
#
# class BagOfCoffee
# include ActiveModel::Attributes
#
# attribute :weight, :decimal
# end
#
# bag = BagOfCoffee.new
# bag.weight = "0.0001"
#
# bag.weight # => 0.1e-3
#
# Numeric instances are converted to BigDecimal instances. Any other objects
# are cast using their +to_d+ method, if it exists. If it does not exist,
# the object is converted to a string using +to_s+, which is then coerced to
# a BigDecimal using +to_d+.
#
# Decimal precision defaults to 18, and can be customized when declaring an
# attribute:
#
# class BagOfCoffee
# include ActiveModel::Attributes
#
# attribute :weight, :decimal, precision: 24
# end
class Decimal < Value
include Helpers::Numeric
BIGDECIMAL_PRECISION = 18
def type
:decimal
end
def type_cast_for_schema(value)
value.to_s.inspect
end
private
def cast_value(value)
casted_value = \
case value
when ::Float
convert_float_to_big_decimal(value)
when ::Numeric
BigDecimal(value, precision || BIGDECIMAL_PRECISION)
when ::String
begin
value.to_d
rescue ArgumentError
BigDecimal(0)
end
else
if value.respond_to?(:to_d)
value.to_d
else
cast_value(value.to_s)
end
end
apply_scale(casted_value)
end
def convert_float_to_big_decimal(value)
if precision
BigDecimal(apply_scale(value), float_precision)
else
value.to_d
end
end
def float_precision
if precision.to_i > ::Float::DIG + 1
::Float::DIG + 1
else
precision.to_i
end
end
def apply_scale(value)
if scale
value.round(scale)
else
value
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class DecimalTest < ActiveModel::TestCase
def test_type_cast_decimal
type = Decimal.new
assert_equal BigDecimal("0"), type.cast(BigDecimal("0"))
assert_equal BigDecimal("123"), type.cast(123.0)
assert_equal BigDecimal("1"), type.cast(:"1")
end
def test_type_cast_decimal_from_invalid_string
type = Decimal.new
assert_nil type.cast("")
assert_equal BigDecimal("1"), type.cast("1ignore")
assert_equal BigDecimal("0"), type.cast("bad1")
assert_equal BigDecimal("0"), type.cast("bad")
end
def test_type_cast_decimal_from_float_with_large_precision
type = Decimal.new(precision: ::Float::DIG + 2)
assert_equal BigDecimal("123.0"), type.cast(123.0)
end
def test_type_cast_from_float_with_unspecified_precision
type = Decimal.new
assert_equal 22.68.to_d, type.cast(22.68)
end
def test_type_cast_decimal_from_rational_with_precision
type = Decimal.new(precision: 2)
assert_equal BigDecimal("0.33"), type.cast(Rational(1, 3))
assert_equal BigDecimal("0.67"), type.cast(Rational(2, 3))
end
def test_type_cast_decimal_from_rational_with_precision_and_scale
type = Decimal.new(precision: 4, scale: 2)
assert_equal BigDecimal("0.33"), type.cast(Rational(1, 3))
assert_equal BigDecimal("0.67"), type.cast(Rational(2, 3))
end
def test_type_cast_decimal_from_rational_without_precision_defaults_to_18_36
type = Decimal.new
assert_equal BigDecimal("0.333333333333333333E0"), type.cast(Rational(1, 3))
assert_equal BigDecimal("0.666666666666666667E0"), type.cast(Rational(2, 3))
end
def test_type_cast_decimal_from_object_responding_to_d
value = Object.new
def value.to_d
BigDecimal("1")
end
type = Decimal.new
assert_equal BigDecimal("1"), type.cast(value)
end
def test_changed?
type = Decimal.new
assert type.changed?(0.0, 0, "wibble")
assert type.changed?(5.0, 0, "wibble")
assert_not type.changed?(5.0, 5.0, "5.0wibble")
assert_not type.changed?(5.0, 5.0, "5.0")
assert_not type.changed?(-5.0, -5.0, "-5.0")
assert_not type.changed?(5.0, 5.0, "0.5e+1")
assert_not type.changed?(BigDecimal("0.0") / 0, BigDecimal("0.0") / 0, BigDecimal("0.0") / 0)
assert type.changed?(BigDecimal("0.0") / 0, 0.0 / 0.0, 0.0 / 0.0)
end
def test_scale_is_applied_before_precision_to_prevent_rounding_errors
type = Decimal.new(precision: 5, scale: 3)
assert_equal BigDecimal("1.250"), type.cast(1.250473853637869)
assert_equal BigDecimal("1.250"), type.cast("1.250473853637869")
end
end
end
end
# frozen_string_literal: true
require "active_model/attribute_mutation_tracker"
module ActiveModel
# == Active \Model \Dirty
#
# Provides a way to track changes in your object in the same way as
# Active Record does.
#
# The requirements for implementing ActiveModel::Dirty are:
#
# * <tt>include ActiveModel::Dirty</tt> in your object.
# * Call <tt>define_attribute_methods</tt> passing each method you want to
# track.
# * Call <tt>[attr_name]_will_change!</tt> before each change to the tracked
# attribute.
# * Call <tt>changes_applied</tt> after the changes are persisted.
# * Call <tt>clear_changes_information</tt> when you want to reset the changes
# information.
# * Call <tt>restore_attributes</tt> when you want to restore previous data.
#
# A minimal implementation could be:
#
# class Person
# include ActiveModel::Dirty
#
# define_attribute_methods :name
#
# def initialize
# @name = nil
# end
#
# def name
# @name
# end
#
# def name=(val)
# name_will_change! unless val == @name
# @name = val
# end
#
# def save
# # do persistence work
#
# changes_applied
# end
#
# def reload!
# # get the values from the persistence layer
#
# clear_changes_information
# end
#
# def rollback!
# restore_attributes
# end
# end
#
# A newly instantiated +Person+ object is unchanged:
#
# person = Person.new
# person.changed? # => false
#
# Change the name:
#
# person.name = 'Bob'
# person.changed? # => true
# person.name_changed? # => true
# person.name_changed?(from: nil, to: "Bob") # => true
# person.name_was # => nil
# person.name_change # => [nil, "Bob"]
# person.name = 'Bill'
# person.name_change # => [nil, "Bill"]
#
# Save the changes:
#
# person.save
# person.changed? # => false
# person.name_changed? # => false
#
# Reset the changes:
#
# person.previous_changes # => {"name" => [nil, "Bill"]}
# person.name_previously_changed? # => true
# person.name_previously_changed?(from: nil, to: "Bill") # => true
# person.name_previous_change # => [nil, "Bill"]
# person.name_previously_was # => nil
# person.reload!
# person.previous_changes # => {}
#
# Rollback the changes:
#
# person.name = "Uncle Bob"
# person.rollback!
# person.name # => "Bill"
# person.name_changed? # => false
#
# Assigning the same value leaves the attribute unchanged:
#
# person.name = 'Bill'
# person.name_changed? # => false
# person.name_change # => nil
#
# Which attributes have changed?
#
# person.name = 'Bob'
# person.changed # => ["name"]
# person.changes # => {"name" => ["Bill", "Bob"]}
#
# If an attribute is modified in-place then make use of
# <tt>[attribute_name]_will_change!</tt> to mark that the attribute is changing.
# Otherwise \Active \Model can't track changes to in-place attributes. Note
# that Active Record can detect in-place modifications automatically. You do
# not need to call <tt>[attribute_name]_will_change!</tt> on Active Record models.
#
# person.name_will_change!
# person.name_change # => ["Bill", "Bill"]
# person.name << 'y'
# person.name_change # => ["Bill", "Billy"]
module Dirty
extend ActiveSupport::Concern
include ActiveModel::AttributeMethods
included do
attribute_method_suffix "_previously_changed?", "_changed?", parameters: "**options"
attribute_method_suffix "_change", "_will_change!", "_was", parameters: false
attribute_method_suffix "_previous_change", "_previously_was", parameters: false
attribute_method_affix prefix: "restore_", suffix: "!", parameters: false
attribute_method_affix prefix: "clear_", suffix: "_change", parameters: false
end
def initialize_dup(other) # :nodoc:
super
if self.class.respond_to?(:_default_attributes)
@attributes = self.class._default_attributes.map do |attr|
attr.with_value_from_user(@attributes.fetch_value(attr.name))
end
end
@mutations_from_database = nil
end
def as_json(options = {}) # :nodoc:
options[:except] = [*options[:except], "mutations_from_database", "mutations_before_last_save"]
super(options)
end
# Clears dirty data and moves +changes+ to +previous_changes+ and
# +mutations_from_database+ to +mutations_before_last_save+ respectively.
def changes_applied
unless defined?(@attributes)
mutations_from_database.finalize_changes
end
@mutations_before_last_save = mutations_from_database
forget_attribute_assignments
@mutations_from_database = nil
end
# Returns +true+ if any of the attributes has unsaved changes, +false+ otherwise.
#
# person.changed? # => false
# person.name = 'bob'
# person.changed? # => true
def changed?
mutations_from_database.any_changes?
end
# Returns an array with the name of the attributes with unsaved changes.
#
# person.changed # => []
# person.name = 'bob'
# person.changed # => ["name"]
def changed
mutations_from_database.changed_attribute_names
end
# Dispatch target for <tt>*_changed?</tt> attribute methods.
def attribute_changed?(attr_name, **options) # :nodoc:
mutations_from_database.changed?(attr_name.to_s, **options)
end
# Dispatch target for <tt>*_was</tt> attribute methods.
def attribute_was(attr_name) # :nodoc:
mutations_from_database.original_value(attr_name.to_s)
end
# Dispatch target for <tt>*_previously_changed?</tt> attribute methods.
def attribute_previously_changed?(attr_name, **options) # :nodoc:
mutations_before_last_save.changed?(attr_name.to_s, **options)
end
# Dispatch target for <tt>*_previously_was</tt> attribute methods.
def attribute_previously_was(attr_name) # :nodoc:
mutations_before_last_save.original_value(attr_name.to_s)
end
# Restore all previous data of the provided attributes.
def restore_attributes(attr_names = changed)
attr_names.each { |attr_name| restore_attribute!(attr_name) }
end
# Clears all dirty data: current changes and previous changes.
def clear_changes_information
@mutations_before_last_save = nil
forget_attribute_assignments
@mutations_from_database = nil
end
def clear_attribute_changes(attr_names)
attr_names.each do |attr_name|
clear_attribute_change(attr_name)
end
end
# Returns a hash of the attributes with unsaved changes indicating their original
# values like <tt>attr => original value</tt>.
#
# person.name # => "bob"
# person.name = 'robert'
# person.changed_attributes # => {"name" => "bob"}
def changed_attributes
mutations_from_database.changed_values
end
# Returns a hash of changed attributes indicating their original
# and new values like <tt>attr => [original value, new value]</tt>.
#
# person.changes # => {}
# person.name = 'bob'
# person.changes # => { "name" => ["bill", "bob"] }
def changes
mutations_from_database.changes
end
# Returns a hash of attributes that were changed before the model was saved.
#
# person.name # => "bob"
# person.name = 'robert'
# person.save
# person.previous_changes # => {"name" => ["bob", "robert"]}
def previous_changes
mutations_before_last_save.changes
end
def attribute_changed_in_place?(attr_name) # :nodoc:
mutations_from_database.changed_in_place?(attr_name.to_s)
end
private
def clear_attribute_change(attr_name)
mutations_from_database.forget_change(attr_name.to_s)
end
def mutations_from_database
@mutations_from_database ||= if defined?(@attributes)
ActiveModel::AttributeMutationTracker.new(@attributes)
else
ActiveModel::ForcedMutationTracker.new(self)
end
end
def forget_attribute_assignments
@attributes = @attributes.map(&:forgetting_assignment) if defined?(@attributes)
end
def mutations_before_last_save
@mutations_before_last_save ||= ActiveModel::NullMutationTracker.instance
end
# Dispatch target for <tt>*_change</tt> attribute methods.
def attribute_change(attr_name)
mutations_from_database.change_to_attribute(attr_name.to_s)
end
# Dispatch target for <tt>*_previous_change</tt> attribute methods.
def attribute_previous_change(attr_name)
mutations_before_last_save.change_to_attribute(attr_name.to_s)
end
# Dispatch target for <tt>*_will_change!</tt> attribute methods.
def attribute_will_change!(attr_name)
mutations_from_database.force_change(attr_name.to_s)
end
# Dispatch target for <tt>restore_*!</tt> attribute methods.
def restore_attribute!(attr_name)
attr_name = attr_name.to_s
if attribute_changed?(attr_name)
__send__("#{attr_name}=", attribute_was(attr_name))
clear_attribute_change(attr_name)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_support/json"
class DirtyTest < ActiveModel::TestCase
class DirtyModel
include ActiveModel::Dirty
define_attribute_methods :name, :color, :size, :status
def initialize
@name = nil
@color = nil
@size = nil
@status = "initialized"
end
attr_reader :name, :color, :size, :status
def name=(val)
name_will_change!
@name = val
end
def color=(val)
color_will_change! unless val == @color
@color = val
end
def size=(val)
attribute_will_change!(:size) unless val == @size
@size = val
end
def status=(val)
status_will_change! unless val == @status
@status = val
end
def save
changes_applied
end
end
setup do
@model = DirtyModel.new
end
test "setting attribute will result in change" do
assert_not_predicate @model, :changed?
assert_not_predicate @model, :name_changed?
@model.name = "Ringo"
assert_predicate @model, :changed?
assert_predicate @model, :name_changed?
end
test "list of changed attribute keys" do
assert_equal [], @model.changed
@model.name = "Paul"
assert_equal ["name"], @model.changed
end
test "changes to attribute values" do
assert_not @model.changes["name"]
@model.name = "John"
assert_equal [nil, "John"], @model.changes["name"]
end
test "checking if an attribute has changed to a particular value" do
@model.name = "Ringo"
assert @model.name_changed?(from: nil, to: "Ringo")
assert_not @model.name_changed?(from: "Pete", to: "Ringo")
assert @model.name_changed?(to: "Ringo")
assert_not @model.name_changed?(to: "Pete")
assert @model.name_changed?(from: nil)
assert_not @model.name_changed?(from: "Pete")
end
test "changes accessible through both strings and symbols" do
@model.name = "David"
assert_not_nil @model.changes[:name]
assert_not_nil @model.changes["name"]
end
test "be consistent with symbols arguments after the changes are applied" do
@model.name = "David"
assert @model.attribute_changed?(:name)
@model.save
@model.name = "Rafael"
assert @model.attribute_changed?(:name)
end
test "attribute mutation" do
@model.instance_variable_set("@name", +"Yam")
assert_not_predicate @model, :name_changed?
@model.name.replace("Hadad")
assert_not_predicate @model, :name_changed?
@model.name_will_change!
@model.name.replace("Baal")
assert_predicate @model, :name_changed?
end
test "resetting attribute" do
@model.name = "Bob"
@model.restore_name!
assert_nil @model.name
assert_not_predicate @model, :name_changed?
end
test "setting color to same value should not result in change being recorded" do
@model.color = "red"
assert_predicate @model, :color_changed?
@model.save
assert_not_predicate @model, :color_changed?
assert_not_predicate @model, :changed?
@model.color = "red"
assert_not_predicate @model, :color_changed?
assert_not_predicate @model, :changed?
end
test "saving should reset model's changed status" do
@model.name = "Alf"
assert_predicate @model, :changed?
@model.save
assert_not_predicate @model, :changed?
assert_not_predicate @model, :name_changed?
end
test "saving should preserve previous changes" do
@model.name = "Jericho Cane"
@model.status = "waiting"
@model.save
assert_equal [nil, "Jericho Cane"], @model.previous_changes["name"]
assert_equal ["initialized", "waiting"], @model.previous_changes["status"]
end
test "setting new attributes should not affect previous changes" do
@model.name = "Jericho Cane"
@model.status = "waiting"
@model.save
@model.name = "DudeFella ManGuy"
@model.status = "finished"
assert_equal [nil, "Jericho Cane"], @model.name_previous_change
assert_equal ["initialized", "waiting"], @model.previous_changes["status"]
end
test "saving should preserve model's previous changed status" do
@model.name = "Jericho Cane"
@model.save
assert_predicate @model, :name_previously_changed?
end
test "checking if an attribute was previously changed to a particular value" do
@model.name = "Ringo"
@model.save
assert @model.name_previously_changed?(from: nil, to: "Ringo")
assert_not @model.name_previously_changed?(from: "Pete", to: "Ringo")
assert @model.name_previously_changed?(to: "Ringo")
assert_not @model.name_previously_changed?(to: "Pete")
assert @model.name_previously_changed?(from: nil)
assert_not @model.name_previously_changed?(from: "Pete")
end
test "previous value is preserved when changed after save" do
assert_equal({}, @model.changed_attributes)
@model.name = "Paul"
@model.status = "waiting"
assert_equal({ "name" => nil, "status" => "initialized" }, @model.changed_attributes)
@model.save
@model.name = "John"
@model.status = "finished"
assert_equal({ "name" => "Paul", "status" => "waiting" }, @model.changed_attributes)
end
test "changing the same attribute multiple times retains the correct original value" do
@model.name = "Otto"
@model.status = "waiting"
@model.save
@model.name = "DudeFella ManGuy"
@model.name = "Mr. Manfredgensonton"
@model.status = "processing"
@model.status = "finished"
assert_equal ["Otto", "Mr. Manfredgensonton"], @model.name_change
assert_equal ["waiting", "finished"], @model.status_change
assert_equal @model.name_was, "Otto"
end
test "using attribute_will_change! with a symbol" do
@model.size = 1
assert_predicate @model, :size_changed?
end
test "clear_changes_information should reset all changes" do
@model.name = "Dmitry"
@model.name_changed?
@model.save
@model.name = "Bob"
assert_equal [nil, "Dmitry"], @model.previous_changes["name"]
assert_equal "Dmitry", @model.changed_attributes["name"]
@model.clear_changes_information
assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.previous_changes
assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.changed_attributes
end
test "restore_attributes should restore all previous data" do
@model.name = "Dmitry"
@model.color = "Red"
@model.save
@model.name = "Bob"
@model.color = "White"
@model.restore_attributes
assert_not_predicate @model, :changed?
assert_equal "Dmitry", @model.name
assert_equal "Red", @model.color
end
test "restore_attributes can restore only some attributes" do
@model.name = "Dmitry"
@model.color = "Red"
@model.save
@model.name = "Bob"
@model.color = "White"
@model.restore_attributes(["name"])
assert_predicate @model, :changed?
assert_equal "Dmitry", @model.name
assert_equal "White", @model.color
end
test "model can be dup-ed without Attributes" do
assert @model.dup
end
test "to_json should work on model" do
@model.name = "Dmitry"
assert_equal "{\"name\":\"Dmitry\",\"color\":null,\"size\":null,\"status\":\"initialized\"}", @model.to_json
end
test "to_json should work on model with :except string option" do
@model.name = "Dmitry"
assert_equal "{\"color\":null,\"size\":null,\"status\":\"initialized\"}", @model.to_json(except: "name")
end
test "to_json should work on model with :except array option" do
@model.name = "Dmitry"
assert_equal "{\"color\":null,\"size\":null,\"status\":\"initialized\"}", @model.to_json(except: ["name"])
end
test "to_json should work on model after save" do
@model.name = "Dmitry"
@model.save
assert_equal "{\"name\":\"Dmitry\",\"color\":null,\"size\":null,\"status\":\"initialized\"}", @model.to_json
end
end
# frozen_string_literal: true
require "validators/email_validator"
module Namespace
class EmailValidator < ::EmailValidator
end
end
# frozen_string_literal: true
require "active_support/core_ext/class/attribute"
module ActiveModel
# == Active \Model \Error
#
# Represents one single error
class Error
CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict]
MESSAGE_OPTIONS = [:message]
class_attribute :i18n_customize_full_message, default: false
def self.full_message(attribute, message, base) # :nodoc:
return message if attribute == :base
base_class = base.class
attribute = attribute.to_s
if i18n_customize_full_message && base_class.respond_to?(:i18n_scope)
attribute = attribute.remove(/\[\d+\]/)
parts = attribute.split(".")
attribute_name = parts.pop
namespace = parts.join("/") unless parts.empty?
attributes_scope = "#{base_class.i18n_scope}.errors.models"
if namespace
defaults = base_class.lookup_ancestors.map do |klass|
[
:"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.attributes.#{attribute_name}.format",
:"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.format",
]
end
else
defaults = base_class.lookup_ancestors.map do |klass|
[
:"#{attributes_scope}.#{klass.model_name.i18n_key}.attributes.#{attribute_name}.format",
:"#{attributes_scope}.#{klass.model_name.i18n_key}.format",
]
end
end
defaults.flatten!
else
defaults = []
end
defaults << :"errors.format"
defaults << "%{attribute} %{message}"
attr_name = attribute.tr(".", "_").humanize
attr_name = base_class.human_attribute_name(attribute, {
default: attr_name,
base: base,
})
I18n.t(defaults.shift,
default: defaults,
attribute: attr_name,
message: message)
end
def self.generate_message(attribute, type, base, options) # :nodoc:
type = options.delete(:message) if options[:message].is_a?(Symbol)
value = (attribute != :base ? base.read_attribute_for_validation(attribute) : nil)
options = {
model: base.model_name.human,
attribute: base.class.human_attribute_name(attribute, { base: base }),
value: value,
object: base
}.merge!(options)
if base.class.respond_to?(:i18n_scope)
i18n_scope = base.class.i18n_scope.to_s
attribute = attribute.to_s.remove(/\[\d+\]/)
defaults = base.class.lookup_ancestors.flat_map do |klass|
[ :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}",
:"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.#{type}" ]
end
defaults << :"#{i18n_scope}.errors.messages.#{type}"
catch(:exception) do
translation = I18n.translate(defaults.first, **options.merge(default: defaults.drop(1), throw: true))
return translation unless translation.nil?
end unless options[:message]
else
defaults = []
end
defaults << :"errors.attributes.#{attribute}.#{type}"
defaults << :"errors.messages.#{type}"
key = defaults.shift
defaults = options.delete(:message) if options[:message]
options[:default] = defaults
I18n.translate(key, **options)
end
def initialize(base, attribute, type = :invalid, **options)
@base = base
@attribute = attribute
@raw_type = type
@type = type || :invalid
@options = options
end
def initialize_dup(other) # :nodoc:
@attribute = @attribute.dup
@raw_type = @raw_type.dup
@type = @type.dup
@options = @options.deep_dup
end
# The object which the error belongs to
attr_reader :base
# The attribute of +base+ which the error belongs to
attr_reader :attribute
# The type of error, defaults to +:invalid+ unless specified
attr_reader :type
# The raw value provided as the second parameter when calling +errors#add+
attr_reader :raw_type
# The options provided when calling +errors#add+
attr_reader :options
# Returns the error message.
#
# error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
# error.message
# # => "is too short (minimum is 5 characters)"
def message
case raw_type
when Symbol
self.class.generate_message(attribute, raw_type, @base, options.except(*CALLBACKS_OPTIONS))
else
raw_type
end
end
# Returns the error details.
#
# error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
# error.details
# # => { error: :too_short, count: 5 }
def details
{ error: raw_type }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS))
end
alias_method :detail, :details
# Returns the full error message.
#
# error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
# error.full_message
# # => "Name is too short (minimum is 5 characters)"
def full_message
self.class.full_message(attribute, message, @base)
end
# See if error matches provided +attribute+, +type+, and +options+.
#
# Omitted params are not checked for a match.
def match?(attribute, type = nil, **options)
if @attribute != attribute || (type && @type != type)
return false
end
options.each do |key, value|
if @options[key] != value
return false
end
end
true
end
# See if error matches provided +attribute+, +type+, and +options+ exactly.
#
# All params must be equal to Error's own attributes to be considered a
# strict match.
def strict_match?(attribute, type, **options)
return false unless match?(attribute, type)
options == @options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS)
end
def ==(other) # :nodoc:
other.is_a?(self.class) && attributes_for_hash == other.attributes_for_hash
end
alias eql? ==
def hash # :nodoc:
attributes_for_hash.hash
end
def inspect # :nodoc:
"#<#{self.class.name} attribute=#{@attribute}, type=#{@type}, options=#{@options.inspect}>"
end
protected
def attributes_for_hash
[@base, @attribute, @raw_type, @options.except(*CALLBACKS_OPTIONS)]
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_model/error"
class ErrorTest < ActiveModel::TestCase
class Person
extend ActiveModel::Naming
def initialize
@errors = ActiveModel::Errors.new(self)
end
attr_accessor :name, :age
attr_reader :errors
def read_attribute_for_validation(attr)
send(attr)
end
def self.human_attribute_name(attr, options = {})
attr
end
def self.lookup_ancestors
[self]
end
end
class Manager < Person
def read_attribute_for_validation(attr)
try(attr)
end
def self.i18n_scope
:activemodel
end
end
def test_initialize
base = Person.new
error = ActiveModel::Error.new(base, :name, :too_long, foo: :bar)
assert_equal base, error.base
assert_equal :name, error.attribute
assert_equal :too_long, error.type
assert_equal({ foo: :bar }, error.options)
end
test "initialize without type" do
error = ActiveModel::Error.new(Person.new, :name)
assert_equal :invalid, error.type
assert_equal({}, error.options)
end
test "initialize without type but with options" do
options = { message: "bar" }
error = ActiveModel::Error.new(Person.new, :name, **options)
assert_equal(options, error.options)
end
# match?
test "match? handles mixed condition" do
subject = ActiveModel::Error.new(Person.new, :mineral, :not_enough, count: 2)
assert_not subject.match?(:mineral, :too_coarse)
assert subject.match?(:mineral, :not_enough)
assert subject.match?(:mineral, :not_enough, count: 2)
assert_not subject.match?(:mineral, :not_enough, count: 1)
end
test "match? handles attribute match" do
subject = ActiveModel::Error.new(Person.new, :mineral, :not_enough, count: 2)
assert_not subject.match?(:foo)
assert subject.match?(:mineral)
end
test "match? handles error type match" do
subject = ActiveModel::Error.new(Person.new, :mineral, :not_enough, count: 2)
assert_not subject.match?(:mineral, :too_coarse)
assert subject.match?(:mineral, :not_enough)
end
test "match? handles extra options match" do
subject = ActiveModel::Error.new(Person.new, :mineral, :not_enough, count: 2)
assert_not subject.match?(:mineral, :not_enough, count: 1)
assert subject.match?(:mineral, :not_enough, count: 2)
end
# message
test "message with type as a symbol" do
error = ActiveModel::Error.new(Person.new, :name, :blank)
assert_equal "can't be blank", error.message
end
test "message with custom interpolation" do
subject = ActiveModel::Error.new(Person.new, :name, :inclusion, message: "custom message %{value}", value: "name")
assert_equal "custom message name", subject.message
end
test "message returns plural interpolation" do
subject = ActiveModel::Error.new(Person.new, :name, :too_long, count: 10)
assert_equal "is too long (maximum is 10 characters)", subject.message
end
test "message returns singular interpolation" do
subject = ActiveModel::Error.new(Person.new, :name, :too_long, count: 1)
assert_equal "is too long (maximum is 1 character)", subject.message
end
test "message returns count interpolation" do
subject = ActiveModel::Error.new(Person.new, :name, :too_long, message: "custom message %{count}", count: 10)
assert_equal "custom message 10", subject.message
end
test "message handles lambda in messages and option values, and i18n interpolation" do
subject = ActiveModel::Error.new(Person.new, :name, :invalid,
foo: "foo",
bar: "bar",
baz: Proc.new { "baz" },
message: Proc.new { |model, options|
"%{attribute} %{foo} #{options[:bar]} %{baz}"
}
)
assert_equal "name foo bar baz", subject.message
end
test "generate_message works without i18n_scope" do
person = Person.new
error = ActiveModel::Error.new(person, :name, :blank)
assert_not_respond_to Person, :i18n_scope
assert_nothing_raised {
error.message
}
end
test "message with type as custom message" do
error = ActiveModel::Error.new(Person.new, :name, message: "cannot be blank")
assert_equal "cannot be blank", error.message
end
test "message with options[:message] as custom message" do
error = ActiveModel::Error.new(Person.new, :name, :blank, message: "cannot be blank")
assert_equal "cannot be blank", error.message
end
test "message renders lazily using current locale" do
error = nil
I18n.backend.store_translations(:pl, errors: { messages: { invalid: "jest nieprawidłowe" } })
I18n.with_locale(:en) { error = ActiveModel::Error.new(Person.new, :name, :invalid) }
I18n.with_locale(:pl) {
assert_equal "jest nieprawidłowe", error.message
}
end
test "message with type as a symbol and indexed attribute can lookup without index in attribute key" do
I18n.backend.store_translations(:en, activemodel: { errors: { models: { 'error_test/manager': {
attributes: { reports: { name: { presence: "must be present" } } } } } } })
error = ActiveModel::Error.new(Manager.new, :'reports[123].name', :presence)
assert_equal "must be present", error.message
end
test "message uses current locale" do
I18n.backend.store_translations(:en, errors: { messages: { inadequate: "Inadequate %{attribute} found!" } })
error = ActiveModel::Error.new(Person.new, :name, :inadequate)
assert_equal "Inadequate name found!", error.message
end
# full_message
test "full_message returns the given message when attribute is :base" do
error = ActiveModel::Error.new(Person.new, :base, message: "press the button")
assert_equal "press the button", error.full_message
end
test "full_message returns the given message with the attribute name included" do
error = ActiveModel::Error.new(Person.new, :name, :blank)
assert_equal "name can't be blank", error.full_message
end
test "full_message uses default format" do
error = ActiveModel::Error.new(Person.new, :name, message: "can't be blank")
# Use a locale without errors.format
I18n.with_locale(:unknown) {
assert_equal "name can't be blank", error.full_message
}
end
test "equality by base attribute, type and options" do
person = Person.new
e1 = ActiveModel::Error.new(person, :name, foo: :bar)
e2 = ActiveModel::Error.new(person, :name, foo: :bar)
e2.instance_variable_set(:@_humanized_attribute, "Name")
assert_equal(e1, e2)
end
test "inequality" do
person = Person.new
error = ActiveModel::Error.new(person, :name, foo: :bar)
assert error != ActiveModel::Error.new(person, :name, foo: :baz)
assert error != ActiveModel::Error.new(person, :name)
assert error != ActiveModel::Error.new(person, :title, foo: :bar)
assert error != ActiveModel::Error.new(Person.new, :name, foo: :bar)
end
test "comparing against different class would not raise error" do
person = Person.new
error = ActiveModel::Error.new(person, :name, foo: :bar)
assert_not_equal error, person
end
# details
test "details which ignores callback and message options" do
person = Person.new
error = ActiveModel::Error.new(
person,
:name,
:too_short,
foo: :bar,
if: :foo,
unless: :bar,
on: :baz,
allow_nil: false,
allow_blank: false,
strict: true,
message: "message"
)
assert_equal(
error.details,
{ error: :too_short, foo: :bar }
)
end
test "details which has no raw_type" do
person = Person.new
error = ActiveModel::Error.new(person, :name, foo: :bar)
assert_equal(error.details, { error: :invalid, foo: :bar })
end
end
# frozen_string_literal: true
require "active_support/core_ext/array/conversions"
require "active_support/core_ext/string/inflections"
require "active_support/core_ext/object/deep_dup"
require "active_support/core_ext/string/filters"
require "active_model/error"
require "active_model/nested_error"
require "forwardable"
module ActiveModel
# == Active \Model \Errors
#
# Provides error related functionalities you can include in your object
# for handling error messages and interacting with Action View helpers.
#
# A minimal implementation could be:
#
# class Person
# # Required dependency for ActiveModel::Errors
# extend ActiveModel::Naming
#
# def initialize
# @errors = ActiveModel::Errors.new(self)
# end
#
# attr_accessor :name
# attr_reader :errors
#
# def validate!
# errors.add(:name, :blank, message: "cannot be nil") if name.nil?
# end
#
# # The following methods are needed to be minimally implemented
#
# def read_attribute_for_validation(attr)
# send(attr)
# end
#
# def self.human_attribute_name(attr, options = {})
# attr
# end
#
# def self.lookup_ancestors
# [self]
# end
# end
#
# The last three methods are required in your object for +Errors+ to be
# able to generate error messages correctly and also handle multiple
# languages. Of course, if you extend your object with ActiveModel::Translation
# you will not need to implement the last two. Likewise, using
# ActiveModel::Validations will handle the validation related methods
# for you.
#
# The above allows you to do:
#
# person = Person.new
# person.validate! # => ["cannot be nil"]
# person.errors.full_messages # => ["name cannot be nil"]
# # etc..
class Errors
include Enumerable
extend Forwardable
# :method: each
#
# :call-seq: each(&block)
#
# Iterates through each error object.
#
# person.errors.add(:name, :too_short, count: 2)
# person.errors.each do |error|
# # Will yield <#ActiveModel::Error attribute=name, type=too_short,
# options={:count=>3}>
# end
def_delegators :@errors, :each, :clear, :empty?, :size, :uniq!
# The actual array of +Error+ objects
# This method is aliased to <tt>objects</tt>.
attr_reader :errors
alias :objects :errors
# Pass in the instance of the object that is using the errors object.
#
# class Person
# def initialize
# @errors = ActiveModel::Errors.new(self)
# end
# end
def initialize(base)
@base = base
@errors = []
end
def initialize_dup(other) # :nodoc:
@errors = other.errors.deep_dup
super
end
# Copies the errors from <tt>other</tt>.
# For copying errors but keep <tt>@base</tt> as is.
#
# ==== Parameters
#
# * +other+ - The ActiveModel::Errors instance.
#
# ==== Examples
#
# person.errors.copy!(other)
#
def copy!(other) # :nodoc:
@errors = other.errors.deep_dup
@errors.each { |error|
error.instance_variable_set(:@base, @base)
}
end
# Imports one error.
# Imported errors are wrapped as a NestedError,
# providing access to original error object.
# If attribute or type needs to be overridden, use +override_options+.
#
# ==== Options
#
# * +:attribute+ - Override the attribute the error belongs to.
# * +:type+ - Override type of the error.
def import(error, override_options = {})
[:attribute, :type].each do |key|
if override_options.key?(key)
override_options[key] = override_options[key].to_sym
end
end
@errors.append(NestedError.new(@base, error, override_options))
end
# Merges the errors from <tt>other</tt>,
# each Error wrapped as NestedError.
#
# ==== Parameters
#
# * +other+ - The ActiveModel::Errors instance.
#
# ==== Examples
#
# person.errors.merge!(other)
#
def merge!(other)
return errors if equal?(other)
other.errors.each { |error|
import(error)
}
end
# Search for errors matching +attribute+, +type+, or +options+.
#
# Only supplied params will be matched.
#
# person.errors.where(:name) # => all name errors.
# person.errors.where(:name, :too_short) # => all name errors being too short
# person.errors.where(:name, :too_short, minimum: 2) # => all name errors being too short and minimum is 2
def where(attribute, type = nil, **options)
attribute, type, options = normalize_arguments(attribute, type, **options)
@errors.select { |error|
error.match?(attribute, type, **options)
}
end
# Returns +true+ if the error messages include an error for the given key
# +attribute+, +false+ otherwise.
#
# person.errors.messages # => {:name=>["cannot be nil"]}
# person.errors.include?(:name) # => true
# person.errors.include?(:age) # => false
def include?(attribute)
@errors.any? { |error|
error.match?(attribute.to_sym)
}
end
alias :has_key? :include?
alias :key? :include?
# Delete messages for +key+. Returns the deleted messages.
#
# person.errors[:name] # => ["cannot be nil"]
# person.errors.delete(:name) # => ["cannot be nil"]
# person.errors[:name] # => []
def delete(attribute, type = nil, **options)
attribute, type, options = normalize_arguments(attribute, type, **options)
matches = where(attribute, type, **options)
matches.each do |error|
@errors.delete(error)
end
matches.map(&:message).presence
end
# When passed a symbol or a name of a method, returns an array of errors
# for the method.
#
# person.errors[:name] # => ["cannot be nil"]
# person.errors['name'] # => ["cannot be nil"]
def [](attribute)
messages_for(attribute)
end
# Returns all error attribute names
#
# person.errors.messages # => {:name=>["cannot be nil", "must be specified"]}
# person.errors.attribute_names # => [:name]
def attribute_names
@errors.map(&:attribute).uniq.freeze
end
# Returns a Hash that can be used as the JSON representation for this
# object. You can pass the <tt>:full_messages</tt> option. This determines
# if the json object should contain full messages or not (false by default).
#
# person.errors.as_json # => {:name=>["cannot be nil"]}
# person.errors.as_json(full_messages: true) # => {:name=>["name cannot be nil"]}
def as_json(options = nil)
to_hash(options && options[:full_messages])
end
# Returns a Hash of attributes with their error messages. If +full_messages+
# is +true+, it will contain full messages (see +full_message+).
#
# person.errors.to_hash # => {:name=>["cannot be nil"]}
# person.errors.to_hash(true) # => {:name=>["name cannot be nil"]}
def to_hash(full_messages = false)
message_method = full_messages ? :full_message : :message
group_by_attribute.transform_values do |errors|
errors.map(&message_method)
end
end
undef :to_h
EMPTY_ARRAY = [].freeze # :nodoc:
# Returns a Hash of attributes with an array of their error messages.
def messages
hash = to_hash
hash.default = EMPTY_ARRAY
hash.freeze
hash
end
# Returns a Hash of attributes with an array of their error details.
def details
hash = group_by_attribute.transform_values do |errors|
errors.map(&:details)
end
hash.default = EMPTY_ARRAY
hash.freeze
hash
end
# Returns a Hash of attributes with an array of their Error objects.
#
# person.errors.group_by_attribute
# # => {:name=>[<#ActiveModel::Error>, <#ActiveModel::Error>]}
def group_by_attribute
@errors.group_by(&:attribute)
end
# Adds a new error of +type+ on +attribute+.
# More than one error can be added to the same +attribute+.
# If no +type+ is supplied, <tt>:invalid</tt> is assumed.
#
# person.errors.add(:name)
# # Adds <#ActiveModel::Error attribute=name, type=invalid>
# person.errors.add(:name, :not_implemented, message: "must be implemented")
# # Adds <#ActiveModel::Error attribute=name, type=not_implemented,
# options={:message=>"must be implemented"}>
#
# person.errors.messages
# # => {:name=>["is invalid", "must be implemented"]}
#
# If +type+ is a string, it will be used as error message.
#
# If +type+ is a symbol, it will be translated using the appropriate
# scope (see +generate_message+).
#
# person.errors.add(:name, :blank)
# person.errors.messages
# # => {:name=>["can't be blank"]}
#
# person.errors.add(:name, :too_long, { count: 25 })
# person.errors.messages
# # => ["is too long (maximum is 25 characters)"]
#
# If +type+ is a proc, it will be called, allowing for things like
# <tt>Time.now</tt> to be used within an error.
#
# If the <tt>:strict</tt> option is set to +true+, it will raise
# ActiveModel::StrictValidationFailed instead of adding the error.
# <tt>:strict</tt> option can also be set to any other exception.
#
# person.errors.add(:name, :invalid, strict: true)
# # => ActiveModel::StrictValidationFailed: Name is invalid
# person.errors.add(:name, :invalid, strict: NameIsInvalid)
# # => NameIsInvalid: Name is invalid
#
# person.errors.messages # => {}
#
# +attribute+ should be set to <tt>:base</tt> if the error is not
# directly associated with a single attribute.
#
# person.errors.add(:base, :name_or_email_blank,
# message: "either name or email must be present")
# person.errors.messages
# # => {:base=>["either name or email must be present"]}
# person.errors.details
# # => {:base=>[{error: :name_or_email_blank}]}
def add(attribute, type = :invalid, **options)
attribute, type, options = normalize_arguments(attribute, type, **options)
error = Error.new(@base, attribute, type, **options)
if exception = options[:strict]
exception = ActiveModel::StrictValidationFailed if exception == true
raise exception, error.full_message
end
@errors.append(error)
error
end
# Returns +true+ if an error matches provided +attribute+ and +type+,
# or +false+ otherwise. +type+ is treated the same as for +add+.
#
# person.errors.add :name, :blank
# person.errors.added? :name, :blank # => true
# person.errors.added? :name, "can't be blank" # => true
#
# If the error requires options, then it returns +true+ with
# the correct options, or +false+ with incorrect or missing options.
#
# person.errors.add :name, :too_long, { count: 25 }
# person.errors.added? :name, :too_long, count: 25 # => true
# person.errors.added? :name, "is too long (maximum is 25 characters)" # => true
# person.errors.added? :name, :too_long, count: 24 # => false
# person.errors.added? :name, :too_long # => false
# person.errors.added? :name, "is too long" # => false
def added?(attribute, type = :invalid, options = {})
attribute, type, options = normalize_arguments(attribute, type, **options)
if type.is_a? Symbol
@errors.any? { |error|
error.strict_match?(attribute, type, **options)
}
else
messages_for(attribute).include?(type)
end
end
# Returns +true+ if an error on the attribute with the given type is
# present, or +false+ otherwise. +type+ is treated the same as for +add+.
#
# person.errors.add :age
# person.errors.add :name, :too_long, { count: 25 }
# person.errors.of_kind? :age # => true
# person.errors.of_kind? :name # => false
# person.errors.of_kind? :name, :too_long # => true
# person.errors.of_kind? :name, "is too long (maximum is 25 characters)" # => true
# person.errors.of_kind? :name, :not_too_long # => false
# person.errors.of_kind? :name, "is too long" # => false
def of_kind?(attribute, type = :invalid)
attribute, type = normalize_arguments(attribute, type)
if type.is_a? Symbol
!where(attribute, type).empty?
else
messages_for(attribute).include?(type)
end
end
# Returns all the full error messages in an array.
#
# class Person
# validates_presence_of :name, :address, :email
# validates_length_of :name, in: 5..30
# end
#
# person = Person.create(address: '123 First St.')
# person.errors.full_messages
# # => ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Email can't be blank"]
def full_messages
@errors.map(&:full_message)
end
alias :to_a :full_messages
# Returns all the full error messages for a given attribute in an array.
#
# class Person
# validates_presence_of :name, :email
# validates_length_of :name, in: 5..30
# end
#
# person = Person.create()
# person.errors.full_messages_for(:name)
# # => ["Name is too short (minimum is 5 characters)", "Name can't be blank"]
def full_messages_for(attribute)
where(attribute).map(&:full_message).freeze
end
# Returns all the error messages for a given attribute in an array.
#
# class Person
# validates_presence_of :name, :email
# validates_length_of :name, in: 5..30
# end
#
# person = Person.create()
# person.errors.messages_for(:name)
# # => ["is too short (minimum is 5 characters)", "can't be blank"]
def messages_for(attribute)
where(attribute).map(&:message)
end
# Returns a full message for a given attribute.
#
# person.errors.full_message(:name, 'is invalid') # => "Name is invalid"
def full_message(attribute, message)
Error.full_message(attribute, message, @base)
end
# Translates an error message in its default scope
# (<tt>activemodel.errors.messages</tt>).
#
# Error messages are first looked up in <tt>activemodel.errors.models.MODEL.attributes.ATTRIBUTE.MESSAGE</tt>,
# if it's not there, it's looked up in <tt>activemodel.errors.models.MODEL.MESSAGE</tt> and if
# that is not there also, it returns the translation of the default message
# (e.g. <tt>activemodel.errors.messages.MESSAGE</tt>). The translated model
# name, translated attribute name, and the value are available for
# interpolation.
#
# When using inheritance in your models, it will check all the inherited
# models too, but only if the model itself hasn't been found. Say you have
# <tt>class Admin < User; end</tt> and you wanted the translation for
# the <tt>:blank</tt> error message for the <tt>title</tt> attribute,
# it looks for these translations:
#
# * <tt>activemodel.errors.models.admin.attributes.title.blank</tt>
# * <tt>activemodel.errors.models.admin.blank</tt>
# * <tt>activemodel.errors.models.user.attributes.title.blank</tt>
# * <tt>activemodel.errors.models.user.blank</tt>
# * any default you provided through the +options+ hash (in the <tt>activemodel.errors</tt> scope)
# * <tt>activemodel.errors.messages.blank</tt>
# * <tt>errors.attributes.title.blank</tt>
# * <tt>errors.messages.blank</tt>
def generate_message(attribute, type = :invalid, options = {})
Error.generate_message(attribute, type, @base, options)
end
def inspect # :nodoc:
inspection = @errors.inspect
"#<#{self.class.name} #{inspection}>"
end
private
def normalize_arguments(attribute, type, **options)
# Evaluate proc first
if type.respond_to?(:call)
type = type.call(@base, options)
end
[attribute.to_sym, type, options]
end
end
# Raised when a validation cannot be corrected by end users and are considered
# exceptional.
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name
#
# validates_presence_of :name, strict: true
# end
#
# person = Person.new
# person.name = nil
# person.valid?
# # => ActiveModel::StrictValidationFailed: Name can't be blank
class StrictValidationFailed < StandardError
end
# Raised when attribute values are out of range.
class RangeError < ::RangeError
end
# Raised when unknown attributes are supplied via mass assignment.
#
# class Person
# include ActiveModel::AttributeAssignment
# include ActiveModel::Validations
# end
#
# person = Person.new
# person.assign_attributes(name: 'Gorby')
# # => ActiveModel::UnknownAttributeError: unknown attribute 'name' for Person.
class UnknownAttributeError < NoMethodError
attr_reader :record, :attribute
def initialize(record, attribute)
@record = record
@attribute = attribute
super("unknown attribute '#{attribute}' for #{@record.class}.")
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "yaml"
class ErrorsTest < ActiveModel::TestCase
class Person
extend ActiveModel::Naming
def initialize
@errors = ActiveModel::Errors.new(self)
end
attr_accessor :name, :age, :gender, :city
attr_reader :errors
def validate!
errors.add(:name, :blank, message: "cannot be nil") if name == nil
end
def read_attribute_for_validation(attr)
send(attr)
end
def self.human_attribute_name(attr, options = {})
attr
end
def self.lookup_ancestors
[self]
end
end
def test_delete
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :blank)
errors.delete("name")
assert_empty errors[:name]
end
def test_include?
errors = ActiveModel::Errors.new(Person.new)
errors.add(:foo, "omg")
assert_includes errors, :foo, "errors should include :foo"
assert_includes errors, "foo", "errors should include 'foo' as :foo"
end
def test_each_when_arity_is_negative
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :blank)
errors.add(:gender, :blank)
assert_equal([:name, :gender], errors.map(&:attribute))
end
def test_any?
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name)
assert errors.any?, "any? should return true"
assert errors.any? { |_| true }, "any? should return true"
end
def test_first
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :blank)
error = errors.first
assert_kind_of ActiveModel::Error, error
end
def test_dup
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name)
errors_dup = errors.dup
assert_not_same errors_dup.errors, errors.errors
end
def test_has_key?
errors = ActiveModel::Errors.new(Person.new)
errors.add(:foo, "omg")
assert_equal true, errors.has_key?(:foo), "errors should have key :foo"
assert_equal true, errors.has_key?("foo"), "errors should have key 'foo' as :foo"
end
def test_has_no_key
errors = ActiveModel::Errors.new(Person.new)
assert_equal false, errors.has_key?(:name), "errors should not have key :name"
end
def test_key?
errors = ActiveModel::Errors.new(Person.new)
errors.add(:foo, "omg")
assert_equal true, errors.key?(:foo), "errors should have key :foo"
assert_equal true, errors.key?("foo"), "errors should have key 'foo' as :foo"
end
def test_no_key
errors = ActiveModel::Errors.new(Person.new)
assert_equal false, errors.key?(:name), "errors should not have key :name"
end
test "clear errors" do
person = Person.new
person.validate!
assert_equal 1, person.errors.count
person.errors.clear
assert_empty person.errors
end
test "error access is indifferent" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, "omg")
assert_equal ["omg"], errors["name"]
end
test "attribute_names returns the error attributes" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:foo, "omg")
errors.add(:baz, "zomg")
assert_equal [:foo, :baz], errors.attribute_names
end
test "attribute_names only returns unique attribute names" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:foo, "omg")
errors.add(:foo, "zomg")
assert_equal [:foo], errors.attribute_names
end
test "attribute_names returns an empty array after try to get a message only" do
errors = ActiveModel::Errors.new(Person.new)
errors.messages[:foo]
errors.messages[:baz]
assert_equal [], errors.attribute_names
end
test "detecting whether there are errors with empty?, blank?, include?" do
person = Person.new
person.errors[:foo]
assert_empty person.errors
assert_predicate person.errors, :blank?
assert_not_includes person.errors, :foo
person.errors.add(:foo, "New error")
assert_not_empty person.errors
assert_not_predicate person.errors, :blank?
assert_includes person.errors, :foo
end
test "include? does not add a key to messages hash" do
person = Person.new
person.errors.include?(:foo)
assert_not person.errors.messages.key?(:foo)
end
test "adding errors using conditionals with Person#validate!" do
person = Person.new
person.validate!
assert_equal ["name cannot be nil"], person.errors.full_messages
assert_equal ["cannot be nil"], person.errors[:name]
end
test "add creates an error object and returns it" do
person = Person.new
error = person.errors.add(:name, :blank)
assert_equal :name, error.attribute
assert_equal :blank, error.type
assert_equal error, person.errors.objects.first
end
test "add, with type as symbol" do
person = Person.new
person.errors.add(:name, :blank)
assert_equal :blank, person.errors.objects.first.type
assert_equal ["can't be blank"], person.errors[:name]
end
test "add, with type as String" do
msg = "custom msg"
person = Person.new
person.errors.add(:name, msg)
assert_equal [msg], person.errors[:name]
end
test "add, with type as nil" do
person = Person.new
person.errors.add(:name)
assert_equal :invalid, person.errors.objects.first.type
assert_equal ["is invalid"], person.errors[:name]
end
test "add, with type as Proc, which evaluates to String" do
msg = "custom msg"
type = Proc.new { msg }
person = Person.new
person.errors.add(:name, type)
assert_equal [msg], person.errors[:name]
end
test "add, type being Proc, which evaluates to Symbol" do
type = Proc.new { :blank }
person = Person.new
person.errors.add(:name, type)
assert_equal :blank, person.errors.objects.first.type
assert_equal ["can't be blank"], person.errors[:name]
end
test "add an error message on a specific attribute with a defined type" do
person = Person.new
person.errors.add(:name, :blank, message: "cannot be blank")
assert_equal ["cannot be blank"], person.errors[:name]
end
test "initialize options[:message] as Proc, which evaluates to String" do
msg = "custom msg"
type = Proc.new { msg }
person = Person.new
person.errors.add(:name, :blank, message: type)
assert_equal :blank, person.errors.objects.first.type
assert_equal [msg], person.errors[:name]
end
test "add, with options[:message] as Proc, which evaluates to String, where type is nil" do
msg = "custom msg"
type = Proc.new { msg }
person = Person.new
person.errors.add(:name, message: type)
assert_equal :invalid, person.errors.objects.first.type
assert_equal [msg], person.errors[:name]
end
test "added? when attribute was added through a collection" do
person = Person.new
person.errors.add(:"family_members.name", :too_long, count: 25)
assert person.errors.added?(:"family_members.name", :too_long, count: 25)
assert_not person.errors.added?(:"family_members.name", :too_long)
assert_not person.errors.added?(:"family_members.name", :too_long, name: "hello")
end
test "added? ignores callback option" do
person = Person.new
person.errors.add(:name, :too_long, if: -> { true })
assert person.errors.added?(:name, :too_long)
end
test "added? ignores message option" do
person = Person.new
person.errors.add(:name, :too_long, message: proc { "foo" })
assert person.errors.added?(:name, :too_long)
end
test "added? detects indifferent if a specific error was added to the object" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert person.errors.added?(:name, "cannot be blank")
assert person.errors.added?("name", "cannot be blank")
end
test "added? handles symbol message" do
person = Person.new
person.errors.add(:name, :blank)
assert person.errors.added?(:name, :blank)
end
test "added? returns true when string attribute is used with a symbol message" do
person = Person.new
person.errors.add(:name, :blank)
assert person.errors.added?("name", :blank)
end
test "added? handles proc messages" do
person = Person.new
message = Proc.new { "cannot be blank" }
person.errors.add(:name, message)
assert person.errors.added?(:name, message)
end
test "added? defaults message to :invalid" do
person = Person.new
person.errors.add(:name)
assert person.errors.added?(:name)
end
test "added? matches the given message when several errors are present for the same attribute" do
person = Person.new
person.errors.add(:name, "cannot be blank")
person.errors.add(:name, "is invalid")
assert person.errors.added?(:name, "cannot be blank")
assert person.errors.added?(:name, "is invalid")
assert_not person.errors.added?(:name, "incorrect")
end
test "added? returns false when no errors are present" do
person = Person.new
assert_not person.errors.added?(:name)
end
test "added? returns false when checking a nonexisting error and other errors are present for the given attribute" do
person = Person.new
person.errors.add(:name, "is invalid")
assert_not person.errors.added?(:name, "cannot be blank")
end
test "added? returns false when checking for an error, but not providing message argument" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert_not person.errors.added?(:name)
end
test "added? returns false when checking for an error with an incorrect or missing option" do
person = Person.new
person.errors.add :name, :too_long, count: 25
assert person.errors.added? :name, :too_long, count: 25
assert person.errors.added? :name, "is too long (maximum is 25 characters)"
assert_not person.errors.added? :name, :too_long, count: 24
assert_not person.errors.added? :name, :too_long
assert_not person.errors.added? :name, "is too long"
end
test "added? returns false when checking for an error by symbol and a different error with same message is present" do
I18n.backend.store_translations("en", errors: { attributes: { name: { wrong: "is wrong", used: "is wrong" } } })
person = Person.new
person.errors.add(:name, :wrong)
assert_not person.errors.added?(:name, :used)
assert person.errors.added?(:name, :wrong)
end
test "of_kind? returns false when checking for an error, but not providing message argument" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert_not person.errors.of_kind?(:name)
end
test "of_kind? returns false when checking a nonexisting error and other errors are present for the given attribute" do
person = Person.new
person.errors.add(:name, "is invalid")
assert_not person.errors.of_kind?(:name, "cannot be blank")
end
test "of_kind? returns false when no errors are present" do
person = Person.new
assert_not person.errors.of_kind?(:name)
end
test "of_kind? matches the given message when several errors are present for the same attribute" do
person = Person.new
person.errors.add(:name, "cannot be blank")
person.errors.add(:name, "is invalid")
assert person.errors.of_kind?(:name, "cannot be blank")
assert person.errors.of_kind?(:name, "is invalid")
assert_not person.errors.of_kind?(:name, "incorrect")
end
test "of_kind? defaults message to :invalid" do
person = Person.new
person.errors.add(:name)
assert person.errors.of_kind?(:name)
end
test "of_kind? handles proc messages" do
person = Person.new
message = Proc.new { "cannot be blank" }
person.errors.add(:name, message)
assert person.errors.of_kind?(:name, message)
end
test "of_kind? returns true when string attribute is used with a symbol message" do
person = Person.new
person.errors.add(:name, :blank)
assert person.errors.of_kind?("name", :blank)
end
test "of_kind? handles symbol message" do
person = Person.new
person.errors.add(:name, :blank)
assert person.errors.of_kind?(:name, :blank)
end
test "of_kind? detects indifferent if a specific error was added to the object" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert person.errors.of_kind?(:name, "cannot be blank")
assert person.errors.of_kind?("name", "cannot be blank")
end
test "of_kind? ignores options" do
person = Person.new
person.errors.add :name, :too_long, count: 25
assert person.errors.of_kind? :name, :too_long
assert person.errors.of_kind? :name, "is too long (maximum is 25 characters)"
end
test "of_kind? returns false when checking for an error by symbol and a different error with same message is present" do
I18n.backend.store_translations("en", errors: { attributes: { name: { wrong: "is wrong", used: "is wrong" } } })
person = Person.new
person.errors.add(:name, :wrong)
assert_not person.errors.of_kind?(:name, :used)
assert person.errors.of_kind?(:name, :wrong)
end
test "size calculates the number of error messages" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert_equal 1, person.errors.size
end
test "count calculates the number of error messages" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert_equal 1, person.errors.count
end
test "to_a returns the list of errors with complete messages containing the attribute names" do
person = Person.new
person.errors.add(:name, "cannot be blank")
person.errors.add(:name, "cannot be nil")
assert_equal ["name cannot be blank", "name cannot be nil"], person.errors.to_a
end
test "to_hash returns the error messages hash" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert_equal({ name: ["cannot be blank"] }, person.errors.to_hash)
end
test "to_hash returns a hash without default proc" do
person = Person.new
assert_nil person.errors.to_hash.default_proc
end
test "as_json returns a hash without default proc" do
person = Person.new
assert_nil person.errors.as_json.default_proc
end
test "messages returns empty frozen array when when accessed with non-existent attribute" do
errors = ActiveModel::Errors.new(Person.new)
assert_equal [], errors.messages[:foo]
assert_raises(FrozenError) { errors.messages[:foo] << "foo" }
assert_raises(FrozenError) { errors.messages[:foo].clear }
end
test "full_messages doesn't require the base object to respond to `:errors" do
model = Class.new do
def initialize
@errors = ActiveModel::Errors.new(self)
@errors.add(:name, "bar")
end
def self.human_attribute_name(attr, options = {})
"foo"
end
def call
error_wrapper = Struct.new(:model_errors)
error_wrapper.new(@errors)
end
end
assert_equal(["foo bar"], model.new.call.model_errors.full_messages)
end
test "full_messages creates a list of error messages with the attribute name included" do
person = Person.new
person.errors.add(:name, "cannot be blank")
person.errors.add(:name, "cannot be nil")
assert_equal ["name cannot be blank", "name cannot be nil"], person.errors.full_messages
end
test "full_messages_for contains all the error messages for the given attribute indifferent" do
person = Person.new
person.errors.add(:name, "cannot be blank")
person.errors.add(:name, "cannot be nil")
assert_equal ["name cannot be blank", "name cannot be nil"], person.errors.full_messages_for(:name)
end
test "full_messages_for does not contain error messages from other attributes" do
person = Person.new
person.errors.add(:name, "cannot be blank")
person.errors.add(:email, "cannot be blank")
assert_equal ["name cannot be blank"], person.errors.full_messages_for(:name)
assert_equal ["name cannot be blank"], person.errors.full_messages_for("name")
end
test "full_messages_for returns an empty list in case there are no errors for the given attribute" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert_equal [], person.errors.full_messages_for(:email)
end
test "full_message returns the given message when attribute is :base" do
person = Person.new
assert_equal "press the button", person.errors.full_message(:base, "press the button")
end
test "full_message returns the given message with the attribute name included" do
person = Person.new
assert_equal "name cannot be blank", person.errors.full_message(:name, "cannot be blank")
assert_equal "name_test cannot be blank", person.errors.full_message(:name_test, "cannot be blank")
end
test "as_json creates a json formatted representation of the errors hash" do
person = Person.new
person.validate!
assert_equal({ name: ["cannot be nil"] }, person.errors.as_json)
end
test "as_json with :full_messages option creates a json formatted representation of the errors containing complete messages" do
person = Person.new
person.validate!
assert_equal({ name: ["name cannot be nil"] }, person.errors.as_json(full_messages: true))
end
test "generate_message works without i18n_scope" do
person = Person.new
assert_not_respond_to Person, :i18n_scope
assert_nothing_raised {
person.errors.generate_message(:name, :blank)
}
end
test "details returns added error detail" do
person = Person.new
person.errors.add(:name, :invalid)
assert_equal({ name: [{ error: :invalid }] }, person.errors.details)
end
test "details returns added error detail with custom option" do
person = Person.new
person.errors.add(:name, :greater_than, count: 5)
assert_equal({ name: [{ error: :greater_than, count: 5 }] }, person.errors.details)
end
test "details do not include message option" do
person = Person.new
person.errors.add(:name, :invalid, message: "is bad")
assert_equal({ name: [{ error: :invalid }] }, person.errors.details)
end
test "details retains original type as error" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, "cannot be nil")
errors.add("foo", "bar")
errors.add(:baz, nil)
errors.add(:age, :invalid, count: 3, message: "%{count} is too low")
assert_equal(
{
name: [{ error: "cannot be nil" }],
foo: [{ error: "bar" }],
baz: [{ error: nil }],
age: [{ error: :invalid, count: 3 }]
},
errors.details
)
end
test "group_by_attribute" do
person = Person.new
error = person.errors.add(:name, :invalid, message: "is bad")
hash = person.errors.group_by_attribute
assert_equal({ name: [error] }, hash)
end
test "dup duplicates details" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :invalid)
errors_dup = errors.dup
errors_dup.add(:name, :taken)
assert_not_equal errors_dup.details, errors.details
end
test "delete returns nil when no errors were deleted" do
errors = ActiveModel::Errors.new(Person.new)
assert_nil(errors.delete(:name))
end
test "delete removes details on given attribute" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :invalid)
errors.delete(:name)
assert_not errors.added?(:name)
end
test "delete returns the deleted messages" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :invalid)
assert_equal ["is invalid"], errors.delete(:name)
end
test "clear removes details" do
person = Person.new
person.errors.add(:name, :invalid)
assert_equal 1, person.errors.details.count
person.errors.clear
assert_empty person.errors.details
end
test "details returns empty array when accessed with non-existent attribute" do
errors = ActiveModel::Errors.new(Person.new)
assert_equal [], errors.details[:foo]
assert_raises(FrozenError) { errors.details[:foo] << "foo" }
assert_raises(FrozenError) { errors.details[:foo].clear }
end
test "copy errors" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :invalid)
person = Person.new
person.errors.copy!(errors)
assert person.errors.added?(:name, :invalid)
person.errors.each do |error|
assert_same person, error.base
end
end
test "merge errors" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :invalid)
person = Person.new
person.errors.add(:name, :blank)
person.errors.merge!(errors)
assert(person.errors.added?(:name, :invalid))
assert(person.errors.added?(:name, :blank))
end
test "merge does not import errors when merging with self" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :invalid)
errors_before_merge = errors.dup
errors.merge!(errors)
assert_equal errors.errors, errors_before_merge.errors
end
test "errors are marshalable" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:name, :invalid)
serialized = Marshal.load(Marshal.dump(errors))
assert_equal Person, serialized.instance_variable_get(:@base).class
assert_equal errors.messages, serialized.messages
assert_equal errors.details, serialized.details
end
test "errors are compatible with YAML dumped from Rails 6.x" do
yaml = <<~CODE
--- !ruby/object:ActiveModel::Errors
base: &1 !ruby/object:ErrorsTest::Person
errors: !ruby/object:ActiveModel::Errors
base: *1
errors: []
errors:
- !ruby/object:ActiveModel::Error
base: *1
attribute: :name
type: :invalid
raw_type: :invalid
options: {}
CODE
errors = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(yaml) : YAML.load(yaml)
assert_equal({ name: ["is invalid"] }, errors.messages)
assert_equal({ name: [{ error: :invalid }] }, errors.details)
errors.clear
assert_equal({}, errors.messages)
assert_equal({}, errors.details)
end
test "inspect" do
errors = ActiveModel::Errors.new(Person.new)
errors.add(:base)
assert_equal(%(#<ActiveModel::Errors [#{errors.first.inspect}]>), errors.inspect)
end
end
# frozen_string_literal: true
require "active_model/validations/clusivity"
module ActiveModel
module Validations
class ExclusionValidator < EachValidator # :nodoc:
include Clusivity
def validate_each(record, attribute, value)
if include?(record, value)
record.errors.add(attribute, :exclusion, **options.except(:in, :within).merge!(value: value))
end
end
end
module HelperMethods
# Validates that the value of the specified attribute is not in a
# particular enumerable object.
#
# class Person < ActiveRecord::Base
# validates_exclusion_of :username, in: %w( admin superuser ), message: "You don't belong here"
# validates_exclusion_of :age, in: 30..60, message: 'This site is only for under 30 and over 60'
# validates_exclusion_of :format, in: %w( mov avi ), message: "extension %{value} is not allowed"
# validates_exclusion_of :password, in: ->(person) { [person.username, person.first_name] },
# message: 'should not be the same as your username or first name'
# validates_exclusion_of :karma, in: :reserved_karmas
# end
#
# Configuration options:
# * <tt>:in</tt> - An enumerable object of items that the value shouldn't
# be part of. This can be supplied as a proc, lambda, or symbol which returns an
# enumerable. If the enumerable is a numerical, time, or datetime range the test
# is performed with <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. When
# using a proc or lambda the instance under validation is passed as an argument.
# * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt>
# <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>.
# * <tt>:message</tt> - Specifies a custom error message (default is: "is
# reserved").
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
# See ActiveModel::Validations::ClassMethods#validates for more information.
def validates_exclusion_of(*attr_names)
validates_with ExclusionValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_support/core_ext/numeric/time"
require "models/topic"
require "models/person"
class ExclusionValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
def test_validates_exclusion_of
Topic.validates_exclusion_of(:title, in: %w( abe monkey ))
assert_predicate Topic.new("title" => "something", "content" => "abc"), :valid?
assert_predicate Topic.new("title" => "monkey", "content" => "abc"), :invalid?
end
def test_validates_exclusion_of_with_formatted_message
Topic.validates_exclusion_of(:title, in: %w( abe monkey ), message: "option %{value} is restricted")
assert Topic.new("title" => "something", "content" => "abc")
t = Topic.new("title" => "monkey")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["option monkey is restricted"], t.errors[:title]
end
def test_validates_exclusion_of_with_within_option
Topic.validates_exclusion_of(:title, within: %w( abe monkey ))
assert Topic.new("title" => "something", "content" => "abc")
t = Topic.new("title" => "monkey")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
end
def test_validates_exclusion_of_for_ruby_class
Person.validates_exclusion_of :karma, in: %w( abe monkey )
p = Person.new
p.karma = "abe"
assert_predicate p, :invalid?
assert_equal ["is reserved"], p.errors[:karma]
p.karma = "Lifo"
assert_predicate p, :valid?
ensure
Person.clear_validators!
end
def test_validates_exclusion_of_with_lambda
Topic.validates_exclusion_of :title, in: lambda { |topic| topic.author_name == "sikachu" ? %w( monkey elephant ) : %w( abe wasabi ) }
t = Topic.new
t.title = "elephant"
t.author_name = "sikachu"
assert_predicate t, :invalid?
t.title = "wasabi"
assert_predicate t, :valid?
end
def test_validates_exclusion_of_with_range
Topic.validates_exclusion_of :content, in: ("a".."g")
assert_predicate Topic.new(content: "g"), :invalid?
assert_predicate Topic.new(content: "h"), :valid?
end
def test_validates_exclusion_of_with_time_range
Topic.validates_exclusion_of :created_at, in: 6.days.ago..2.days.ago
assert_predicate Topic.new(created_at: 5.days.ago), :invalid?
assert_predicate Topic.new(created_at: 3.days.ago), :invalid?
assert_predicate Topic.new(created_at: 7.days.ago), :valid?
assert_predicate Topic.new(created_at: 1.day.ago), :valid?
end
def test_validates_inclusion_of_with_symbol
Person.validates_exclusion_of :karma, in: :reserved_karmas
p = Person.new
p.karma = "abe"
def p.reserved_karmas
%w(abe)
end
assert_predicate p, :invalid?
assert_equal ["is reserved"], p.errors[:karma]
p = Person.new
p.karma = "abe"
def p.reserved_karmas
%w()
end
assert_predicate p, :valid?
ensure
Person.clear_validators!
end
end
# frozen_string_literal: true
require "active_support/core_ext/object/try"
module ActiveModel
module Type
# Attribute type for floating point numeric values. It is registered under
# the +:float+ key.
#
# class BagOfCoffee
# include ActiveModel::Attributes
#
# attribute :weight, :float
# end
#
# bag = BagOfCoffee.new
# bag.weight = "0.25"
#
# bag.weight # => 0.25
#
# Values are coerced to their float representation using their +to_f+
# methods. However, the following strings which represent floating point
# constants are cast accordingly:
#
# - <tt>"Infinity"</tt> is cast to <tt>Float::INFINITY</tt>.
# - <tt>"-Infinity"</tt> is cast to <tt>-Float::INFINITY</tt>.
# - <tt>"NaN"</tt> is cast to <tt>Float::NAN</tt>.
class Float < Value
include Helpers::Numeric
def type
:float
end
def type_cast_for_schema(value)
return "::Float::NAN" if value.try(:nan?)
case value
when ::Float::INFINITY then "::Float::INFINITY"
when -::Float::INFINITY then "-::Float::INFINITY"
else super
end
end
private
def cast_value(value)
case value
when ::Float then value
when "Infinity" then ::Float::INFINITY
when "-Infinity" then -::Float::INFINITY
when "NaN" then ::Float::NAN
else value.to_f
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class FloatTest < ActiveModel::TestCase
def test_type_cast_float
type = Type::Float.new
assert_equal 1.0, type.cast("1")
end
def test_type_cast_float_from_invalid_string
type = Type::Float.new
assert_nil type.cast("")
assert_equal 1.0, type.cast("1ignore")
assert_equal 0.0, type.cast("bad1")
assert_equal 0.0, type.cast("bad")
end
def test_changing_float
type = Type::Float.new
assert type.changed?(0.0, 0, "wibble")
assert type.changed?(5.0, 0, "wibble")
assert_not type.changed?(5.0, 5.0, "5wibble")
assert_not type.changed?(5.0, 5.0, "5")
assert_not type.changed?(5.0, 5.0, "5.0")
assert_not type.changed?(500.0, 500.0, "0.5E+4")
assert_not type.changed?(nil, nil, nil)
assert_not type.changed?(0.0 / 0.0, 0.0 / 0.0, 0.0 / 0.0)
assert type.changed?(0.0 / 0.0, BigDecimal("0.0") / 0, BigDecimal("0.0") / 0)
end
end
end
end
# frozen_string_literal: true
module ActiveModel
# Raised when forbidden attributes are used for mass assignment.
#
# class Person < ActiveRecord::Base
# end
#
# params = ActionController::Parameters.new(name: 'Bob')
# Person.new(params)
# # => ActiveModel::ForbiddenAttributesError
#
# params.permit!
# Person.new(params)
# # => #<Person id: nil, name: "Bob">
class ForbiddenAttributesError < StandardError
end
module ForbiddenAttributesProtection # :nodoc:
private
def sanitize_for_mass_assignment(attributes)
if attributes.respond_to?(:permitted?)
raise ActiveModel::ForbiddenAttributesError if !attributes.permitted?
attributes.to_h
else
attributes
end
end
alias :sanitize_forbidden_attributes :sanitize_for_mass_assignment
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_support/core_ext/hash/indifferent_access"
require "models/account"
class ProtectedParams
attr_accessor :permitted
alias :permitted? :permitted
delegate :keys, :key?, :has_key?, :empty?, to: :@parameters
def initialize(attributes)
@parameters = attributes
@permitted = false
end
def permit!
@permitted = true
self
end
def to_h
@parameters
end
end
class ActiveModelMassUpdateProtectionTest < ActiveSupport::TestCase
test "forbidden attributes cannot be used for mass updating" do
params = ProtectedParams.new("a" => "b")
assert_raises(ActiveModel::ForbiddenAttributesError) do
Account.new.sanitize_for_mass_assignment(params)
end
end
test "permitted attributes can be used for mass updating" do
params = ProtectedParams.new("a" => "b").permit!
assert_equal({ "a" => "b" }, Account.new.sanitize_for_mass_assignment(params))
end
test "regular attributes should still be allowed" do
assert_equal({ a: "b" }, Account.new.sanitize_for_mass_assignment(a: "b"))
end
end
# frozen_string_literal: true
module ActiveModel
module Validations
class FormatValidator < EachValidator # :nodoc:
def validate_each(record, attribute, value)
if options[:with]
regexp = option_call(record, :with)
record_error(record, attribute, :with, value) unless regexp.match?(value.to_s)
elsif options[:without]
regexp = option_call(record, :without)
record_error(record, attribute, :without, value) if regexp.match?(value.to_s)
end
end
def check_validity!
unless options.include?(:with) ^ options.include?(:without) # ^ == xor, or "exclusive or"
raise ArgumentError, "Either :with or :without must be supplied (but not both)"
end
check_options_validity :with
check_options_validity :without
end
private
def option_call(record, name)
option = options[name]
option.respond_to?(:call) ? option.call(record) : option
end
def record_error(record, attribute, name, value)
record.errors.add(attribute, :invalid, **options.except(name).merge!(value: value))
end
def check_options_validity(name)
if option = options[name]
if option.is_a?(Regexp)
if options[:multiline] != true && regexp_using_multiline_anchors?(option)
raise ArgumentError, "The provided regular expression is using multiline anchors (^ or $), " \
"which may present a security risk. Did you mean to use \\A and \\z, or forgot to add the " \
":multiline => true option?"
end
elsif !option.respond_to?(:call)
raise ArgumentError, "A regular expression or a proc or lambda must be supplied as :#{name}"
end
end
end
def regexp_using_multiline_anchors?(regexp)
source = regexp.source
source.start_with?("^") || (source.end_with?("$") && !source.end_with?("\\$"))
end
end
module HelperMethods
# Validates whether the value of the specified attribute is of the correct
# form, going by the regular expression provided. You can require that the
# attribute matches the regular expression:
#
# class Person < ActiveRecord::Base
# validates_format_of :email, with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, on: :create
# end
#
# Alternatively, you can require that the specified attribute does _not_
# match the regular expression:
#
# class Person < ActiveRecord::Base
# validates_format_of :email, without: /NOSPAM/
# end
#
# You can also provide a proc or lambda which will determine the regular
# expression that will be used to validate the attribute.
#
# class Person < ActiveRecord::Base
# # Admin can have number as a first letter in their screen name
# validates_format_of :screen_name,
# with: ->(person) { person.admin? ? /\A[a-z0-9][a-z0-9_\-]*\z/i : /\A[a-z][a-z0-9_\-]*\z/i }
# end
#
# Note: use <tt>\A</tt> and <tt>\z</tt> to match the start and end of the
# string, <tt>^</tt> and <tt>$</tt> match the start/end of a line.
#
# Due to frequent misuse of <tt>^</tt> and <tt>$</tt>, you need to pass
# the <tt>multiline: true</tt> option in case you use any of these two
# anchors in the provided regular expression. In most cases, you should be
# using <tt>\A</tt> and <tt>\z</tt>.
#
# You must pass either <tt>:with</tt> or <tt>:without</tt> as an option.
# In addition, both must be a regular expression or a proc or lambda, or
# else an exception will be raised.
#
# Configuration options:
# * <tt>:message</tt> - A custom error message (default is: "is invalid").
# * <tt>:with</tt> - Regular expression that if the attribute matches will
# result in a successful validation. This can be provided as a proc or
# lambda returning regular expression which will be called at runtime.
# * <tt>:without</tt> - Regular expression that if the attribute does not
# match will result in a successful validation. This can be provided as
# a proc or lambda returning regular expression which will be called at
# runtime.
# * <tt>:multiline</tt> - Set to true if your regular expression contains
# anchors that match the beginning or end of lines as opposed to the
# beginning or end of the string. These anchors are <tt>^</tt> and <tt>$</tt>.
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
# See ActiveModel::Validations::ClassMethods#validates for more information.
def validates_format_of(*attr_names)
validates_with FormatValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
class FormatValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
def test_validate_format
Topic.validates_format_of(:title, :content, with: /\AValidation\smacros \w+!\z/, message: "is bad data")
t = Topic.new("title" => "i'm incorrect", "content" => "Validation macros rule!")
assert t.invalid?, "Shouldn't be valid"
assert_equal ["is bad data"], t.errors[:title]
assert_empty t.errors[:content]
t.title = "Validation macros rule!"
assert_predicate t, :valid?
assert_empty t.errors[:title]
assert_raise(ArgumentError) { Topic.validates_format_of(:title, :content) }
end
def test_validate_format_with_allow_blank
Topic.validates_format_of(:title, with: /\AValidation\smacros \w+!\z/, allow_blank: true)
assert_predicate Topic.new("title" => "Shouldn't be valid"), :invalid?
assert_predicate Topic.new("title" => ""), :valid?
assert_predicate Topic.new("title" => nil), :valid?
assert_predicate Topic.new("title" => "Validation macros rule!"), :valid?
end
# testing ticket #3142
def test_validate_format_numeric
Topic.validates_format_of(:title, :content, with: /\A[1-9][0-9]*\z/, message: "is bad data")
t = Topic.new("title" => "72x", "content" => "6789")
assert t.invalid?, "Shouldn't be valid"
assert_equal ["is bad data"], t.errors[:title]
assert_empty t.errors[:content]
t.title = "-11"
assert t.invalid?, "Shouldn't be valid"
t.title = "03"
assert t.invalid?, "Shouldn't be valid"
t.title = "z44"
assert t.invalid?, "Shouldn't be valid"
t.title = "5v7"
assert t.invalid?, "Shouldn't be valid"
t.title = "1"
assert_predicate t, :valid?
assert_empty t.errors[:title]
end
def test_validate_format_with_formatted_message
Topic.validates_format_of(:title, with: /\AValid Title\z/, message: "can't be %{value}")
t = Topic.new(title: "Invalid title")
assert_predicate t, :invalid?
assert_equal ["can't be Invalid title"], t.errors[:title]
end
def test_validate_format_of_with_multiline_regexp_should_raise_error
assert_raise(ArgumentError) { Topic.validates_format_of(:title, with: /^Valid Title$/) }
end
def test_validate_format_of_with_multiline_regexp_and_option
assert_nothing_raised do
Topic.validates_format_of(:title, with: /^Valid Title$/, multiline: true)
end
end
def test_validate_format_with_not_option
Topic.validates_format_of(:title, without: /foo/, message: "should not contain foo")
t = Topic.new
t.title = "foobar"
t.valid?
assert_equal ["should not contain foo"], t.errors[:title]
t.title = "something else"
t.valid?
assert_equal [], t.errors[:title]
end
def test_validate_format_of_without_any_regexp_should_raise_error
assert_raise(ArgumentError) { Topic.validates_format_of(:title) }
end
def test_validates_format_of_with_both_regexps_should_raise_error
assert_raise(ArgumentError) { Topic.validates_format_of(:title, with: /this/, without: /that/) }
end
def test_validates_format_of_when_with_isnt_a_regexp_should_raise_error
assert_raise(ArgumentError) { Topic.validates_format_of(:title, with: "clearly not a regexp") }
end
def test_validates_format_of_when_not_isnt_a_regexp_should_raise_error
assert_raise(ArgumentError) { Topic.validates_format_of(:title, without: "clearly not a regexp") }
end
def test_validates_format_of_with_lambda
Topic.validates_format_of :content, with: lambda { |topic| topic.title == "digit" ? /\A\d+\z/ : /\A\S+\z/ }
t = Topic.new
t.title = "digit"
t.content = "Pixies"
assert_predicate t, :invalid?
t.content = "1234"
assert_predicate t, :valid?
end
def test_validates_format_of_without_lambda
Topic.validates_format_of :content, without: lambda { |topic| topic.title == "characters" ? /\A\d+\z/ : /\A\S+\z/ }
t = Topic.new
t.title = "characters"
t.content = "1234"
assert_predicate t, :invalid?
t.content = "Pixies"
assert_predicate t, :valid?
end
def test_validates_format_of_for_ruby_class
Person.validates_format_of :karma, with: /\A\d+\z/
p = Person.new
p.karma = "Pixies"
assert_predicate p, :invalid?
assert_equal ["is invalid"], p.errors[:karma]
p.karma = "1234"
assert_predicate p, :valid?
ensure
Person.clear_validators!
end
end
# frozen_string_literal: true
module ActiveModel
# Returns the version of the currently loaded \Active \Model as a <tt>Gem::Version</tt>
def self.gem_version
Gem::Version.new VERSION::STRING
end
module VERSION
MAJOR = 7
MINOR = 1
TINY = 0
PRE = "alpha"
STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
end
end
# frozen_string_literal: true
class Helicopter
include ActiveModel::Conversion
end
class Helicopter::Comanche
include ActiveModel::Conversion
end
# frozen_string_literal: true
require "active_model"
# Show backtraces for deprecated behavior for quicker cleanup.
ActiveSupport::Deprecation.debug = true
# Disable available locale checks to avoid warnings running the test suite.
I18n.enforce_available_locales = false
require "active_support/testing/autorun"
require "active_support/testing/method_call_assertions"
require "active_support/core_ext/integer/time"
class ActiveModel::TestCase < ActiveSupport::TestCase
include ActiveSupport::Testing::MethodCallAssertions
private
# Skips the current run on Rubinius using Minitest::Assertions#skip
def rubinius_skip(message = "")
skip message if RUBY_ENGINE == "rbx"
end
# Skips the current run on JRuby using Minitest::Assertions#skip
def jruby_skip(message = "")
skip message if defined?(JRUBY_VERSION)
end
end
require_relative "../../../tools/test_common"
# frozen_string_literal: true
module ActiveModel
module Validations
module HelperMethods # :nodoc:
private
def _merge_attributes(attr_names)
options = attr_names.extract_options!.symbolize_keys
attr_names.flatten!
options[:attributes] = attr_names
options
end
end
end
end
# frozen_string_literal: true
require "active_model/type/helpers/accepts_multiparameter_time"
require "active_model/type/helpers/numeric"
require "active_model/type/helpers/mutable"
require "active_model/type/helpers/time_value"
require "active_model/type/helpers/timezone"
# frozen_string_literal: true
require "cases/helper"
require "models/person"
class I18nGenerateMessageValidationTest < ActiveModel::TestCase
def setup
Person.clear_validators!
@person = Person.new
end
# validates_inclusion_of: generate_message(attr_name, :inclusion, message: custom_message, value: value)
def test_generate_message_inclusion_with_default_message
assert_equal "is not included in the list", @person.errors.generate_message(:title, :inclusion, value: "title")
end
def test_generate_message_inclusion_with_custom_message
assert_equal "custom message title", @person.errors.generate_message(:title, :inclusion, message: "custom message %{value}", value: "title")
end
# validates_exclusion_of: generate_message(attr_name, :exclusion, message: custom_message, value: value)
def test_generate_message_exclusion_with_default_message
assert_equal "is reserved", @person.errors.generate_message(:title, :exclusion, value: "title")
end
def test_generate_message_exclusion_with_custom_message
assert_equal "custom message title", @person.errors.generate_message(:title, :exclusion, message: "custom message %{value}", value: "title")
end
# validates_format_of: generate_message(attr_name, :invalid, message: custom_message, value: value)
def test_generate_message_invalid_with_default_message
assert_equal "is invalid", @person.errors.generate_message(:title, :invalid, value: "title")
end
def test_generate_message_invalid_with_custom_message
assert_equal "custom message title", @person.errors.generate_message(:title, :invalid, message: "custom message %{value}", value: "title")
end
# validates_confirmation_of: generate_message(attr_name, :confirmation, message: custom_message)
def test_generate_message_confirmation_with_default_message
assert_equal "doesn't match Title", @person.errors.generate_message(:title, :confirmation)
end
def test_generate_message_confirmation_with_custom_message
assert_equal "custom message", @person.errors.generate_message(:title, :confirmation, message: "custom message")
end
# validates_acceptance_of: generate_message(attr_name, :accepted, message: custom_message)
def test_generate_message_accepted_with_default_message
assert_equal "must be accepted", @person.errors.generate_message(:title, :accepted)
end
def test_generate_message_accepted_with_custom_message
assert_equal "custom message", @person.errors.generate_message(:title, :accepted, message: "custom message")
end
# add_on_empty: generate_message(attr, :empty, message: custom_message)
def test_generate_message_empty_with_default_message
assert_equal "can't be empty", @person.errors.generate_message(:title, :empty)
end
def test_generate_message_empty_with_custom_message
assert_equal "custom message", @person.errors.generate_message(:title, :empty, message: "custom message")
end
# validates_presence_of: generate_message(attr, :blank, message: custom_message)
def test_generate_message_blank_with_default_message
assert_equal "can't be blank", @person.errors.generate_message(:title, :blank)
end
def test_generate_message_blank_with_custom_message
assert_equal "custom message", @person.errors.generate_message(:title, :blank, message: "custom message")
end
# validates_length_of: generate_message(attr, :too_long, message: custom_message, count: option_value.end)
def test_generate_message_too_long_with_default_message_plural
assert_equal "is too long (maximum is 10 characters)", @person.errors.generate_message(:title, :too_long, count: 10)
end
def test_generate_message_too_long_with_default_message_singular
assert_equal "is too long (maximum is 1 character)", @person.errors.generate_message(:title, :too_long, count: 1)
end
def test_generate_message_too_long_with_custom_message
assert_equal "custom message 10", @person.errors.generate_message(:title, :too_long, message: "custom message %{count}", count: 10)
end
# validates_length_of: generate_message(attr, :too_short, default: custom_message, count: option_value.begin)
def test_generate_message_too_short_with_default_message_plural
assert_equal "is too short (minimum is 10 characters)", @person.errors.generate_message(:title, :too_short, count: 10)
end
def test_generate_message_too_short_with_default_message_singular
assert_equal "is too short (minimum is 1 character)", @person.errors.generate_message(:title, :too_short, count: 1)
end
def test_generate_message_too_short_with_custom_message
assert_equal "custom message 10", @person.errors.generate_message(:title, :too_short, message: "custom message %{count}", count: 10)
end
# validates_length_of: generate_message(attr, :wrong_length, message: custom_message, count: option_value)
def test_generate_message_wrong_length_with_default_message_plural
assert_equal "is the wrong length (should be 10 characters)", @person.errors.generate_message(:title, :wrong_length, count: 10)
end
def test_generate_message_wrong_length_with_default_message_singular
assert_equal "is the wrong length (should be 1 character)", @person.errors.generate_message(:title, :wrong_length, count: 1)
end
def test_generate_message_wrong_length_with_custom_message
assert_equal "custom message 10", @person.errors.generate_message(:title, :wrong_length, message: "custom message %{count}", count: 10)
end
# validates_numericality_of: generate_message(attr_name, :not_a_number, value: raw_value, message: custom_message)
def test_generate_message_not_a_number_with_default_message
assert_equal "is not a number", @person.errors.generate_message(:title, :not_a_number, value: "title")
end
def test_generate_message_not_a_number_with_custom_message
assert_equal "custom message title", @person.errors.generate_message(:title, :not_a_number, message: "custom message %{value}", value: "title")
end
# validates_numericality_of: generate_message(attr_name, option, value: raw_value, default: custom_message)
def test_generate_message_greater_than_with_default_message
assert_equal "must be greater than 10", @person.errors.generate_message(:title, :greater_than, value: "title", count: 10)
end
def test_generate_message_greater_than_or_equal_to_with_default_message
assert_equal "must be greater than or equal to 10", @person.errors.generate_message(:title, :greater_than_or_equal_to, value: "title", count: 10)
end
def test_generate_message_equal_to_with_default_message
assert_equal "must be equal to 10", @person.errors.generate_message(:title, :equal_to, value: "title", count: 10)
end
def test_generate_message_less_than_with_default_message
assert_equal "must be less than 10", @person.errors.generate_message(:title, :less_than, value: "title", count: 10)
end
def test_generate_message_less_than_or_equal_to_with_default_message
assert_equal "must be less than or equal to 10", @person.errors.generate_message(:title, :less_than_or_equal_to, value: "title", count: 10)
end
def test_generate_message_odd_with_default_message
assert_equal "must be odd", @person.errors.generate_message(:title, :odd, value: "title", count: 10)
end
def test_generate_message_even_with_default_message
assert_equal "must be even", @person.errors.generate_message(:title, :even, value: "title", count: 10)
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/person"
class I18nValidationTest < ActiveModel::TestCase
def setup
Person.clear_validators!
@person = person_class.new
@old_load_path, @old_backend = I18n.load_path.dup, I18n.backend
I18n.load_path.clear
I18n.backend = I18n::Backend::Simple.new
I18n.backend.store_translations("en", errors: { messages: { custom: nil } })
@original_i18n_customize_full_message = ActiveModel::Error.i18n_customize_full_message
ActiveModel::Error.i18n_customize_full_message = true
end
def teardown
person_class.clear_validators!
self.class.send(:remove_const, :Person)
@person_stub = nil
I18n.load_path.replace @old_load_path
I18n.backend = @old_backend
I18n.backend.reload!
ActiveModel::Error.i18n_customize_full_message = @original_i18n_customize_full_message
end
def test_full_message_encoding
I18n.backend.store_translations("en", errors: {
messages: { too_short: "猫舌" } })
person_class.validates_length_of :title, within: 3..5
@person.valid?
assert_equal ["Title 猫舌"], @person.errors.full_messages
end
def test_errors_full_messages_translates_human_attribute_name_for_model_attributes
@person.errors.add(:name, "not found")
assert_called_with(person_class, :human_attribute_name, ["name", default: "Name", base: @person], returns: "Person's name") do
assert_equal ["Person's name not found"], @person.errors.full_messages
end
end
def test_errors_full_messages_uses_format
I18n.backend.store_translations("en", errors: { format: "Field %{attribute} %{message}" })
@person.errors.add("name", "empty")
assert_equal ["Field Name empty"], @person.errors.full_messages
end
def test_errors_full_messages_doesnt_use_attribute_format_without_config
ActiveModel::Error.i18n_customize_full_message = false
I18n.backend.store_translations("en", activemodel: {
errors: { models: { person: { attributes: { name: { format: "%{message}" } } } } } })
person = person_class.new
assert_equal "Name cannot be blank", person.errors.full_message(:name, "cannot be blank")
assert_equal "Name test cannot be blank", person.errors.full_message(:name_test, "cannot be blank")
end
def test_errors_full_messages_on_nested_error_uses_attribute_format
ActiveModel::Error.i18n_customize_full_message = true
I18n.backend.store_translations("en", activemodel: {
errors: { models: { person: { attributes: { gender: "Gender" } } } },
attributes: { "person/contacts": { gender: "Gender" } }
})
person = person_class.new
error = ActiveModel::Error.new(person, :gender, "can't be blank")
person.errors.import(error, attribute: "person[0].contacts.gender")
assert_equal ["Gender can't be blank"], person.errors.full_messages
end
def test_errors_full_messages_uses_attribute_format
ActiveModel::Error.i18n_customize_full_message = true
I18n.backend.store_translations("en", activemodel: {
errors: { models: { person: { attributes: { name: { format: "%{message}" } } } } } })
person = person_class.new
assert_equal "cannot be blank", person.errors.full_message(:name, "cannot be blank")
assert_equal "Name test cannot be blank", person.errors.full_message(:name_test, "cannot be blank")
end
def test_errors_full_messages_uses_model_format
ActiveModel::Error.i18n_customize_full_message = true
I18n.backend.store_translations("en", activemodel: {
errors: { models: { person: { format: "%{message}" } } } })
person = person_class.new
assert_equal "cannot be blank", person.errors.full_message(:name, "cannot be blank")
assert_equal "cannot be blank", person.errors.full_message(:name_test, "cannot be blank")
end
def test_errors_full_messages_uses_deeply_nested_model_attributes_format
ActiveModel::Error.i18n_customize_full_message = true
I18n.backend.store_translations("en", activemodel: {
errors: { models: { 'person/contacts/addresses': { attributes: { street: { format: "%{message}" } } } } } })
person = person_class.new
assert_equal "cannot be blank", person.errors.full_message(:'contacts/addresses.street', "cannot be blank")
assert_equal "Contacts/addresses country cannot be blank", person.errors.full_message(:'contacts/addresses.country', "cannot be blank")
end
def test_errors_full_messages_uses_deeply_nested_model_model_format
ActiveModel::Error.i18n_customize_full_message = true
I18n.backend.store_translations("en", activemodel: {
errors: { models: { 'person/contacts/addresses': { format: "%{message}" } } } })
person = person_class.new
assert_equal "cannot be blank", person.errors.full_message(:'contacts/addresses.street', "cannot be blank")
assert_equal "cannot be blank", person.errors.full_message(:'contacts/addresses.country', "cannot be blank")
end
def test_errors_full_messages_with_indexed_deeply_nested_attributes_and_attributes_format
ActiveModel::Error.i18n_customize_full_message = true
I18n.backend.store_translations("en", activemodel: {
errors: { models: { 'person/contacts/addresses': { attributes: { street: { format: "%{message}" } } } } } })
person = person_class.new
assert_equal "cannot be blank", person.errors.full_message(:'contacts[0]/addresses[123].street', "cannot be blank")
assert_equal "Contacts/addresses country cannot be blank", person.errors.full_message(:'contacts[0]/addresses[123].country', "cannot be blank")
end
def test_errors_full_messages_with_indexed_deeply_nested_attributes_and_model_format
ActiveModel::Error.i18n_customize_full_message = true
I18n.backend.store_translations("en", activemodel: {
errors: { models: { 'person/contacts/addresses': { format: "%{message}" } } } })
person = person_class.new
assert_equal "cannot be blank", person.errors.full_message(:'contacts[0]/addresses[123].street', "cannot be blank")
assert_equal "cannot be blank", person.errors.full_message(:'contacts[0]/addresses[123].country', "cannot be blank")
end
def test_errors_full_messages_with_indexed_deeply_nested_attributes_and_i18n_attribute_name
ActiveModel::Error.i18n_customize_full_message = true
I18n.backend.store_translations("en", activemodel: {
attributes: { 'person/contacts/addresses': { country: "Country" } }
})
person = person_class.new
assert_equal "Contacts/addresses street cannot be blank", person.errors.full_message(:'contacts[0]/addresses[123].street', "cannot be blank")
assert_equal "Country cannot be blank", person.errors.full_message(:'contacts[0]/addresses[123].country', "cannot be blank")
end
def test_errors_full_messages_with_indexed_deeply_nested_attributes_without_i18n_config
ActiveModel::Error.i18n_customize_full_message = false
I18n.backend.store_translations("en", activemodel: {
errors: { models: { 'person/contacts/addresses': { attributes: { street: { format: "%{message}" } } } } } })
person = person_class.new
assert_equal "Contacts[0]/addresses[0] street cannot be blank", person.errors.full_message(:'contacts[0]/addresses[0].street', "cannot be blank")
assert_equal "Contacts[0]/addresses[0] country cannot be blank", person.errors.full_message(:'contacts[0]/addresses[0].country', "cannot be blank")
end
def test_errors_full_messages_with_i18n_attribute_name_without_i18n_config
ActiveModel::Error.i18n_customize_full_message = false
I18n.backend.store_translations("en", activemodel: {
attributes: { 'person/contacts[0]/addresses[0]': { country: "Country" } }
})
person = person_class.new
assert_equal "Contacts[0]/addresses[0] street cannot be blank", person.errors.full_message(:'contacts[0]/addresses[0].street', "cannot be blank")
assert_equal "Country cannot be blank", person.errors.full_message(:'contacts[0]/addresses[0].country', "cannot be blank")
end
# ActiveModel::Validations
# A set of common cases for ActiveModel::Validations message generation that
# are used to generate tests to keep things DRY
#
COMMON_CASES = [
# [ case, validation_options, generate_message_options]
[ "given no options", {}, {}],
[ "given custom message", { message: "custom" }, { message: "custom" }],
[ "given if condition", { if: lambda { true } }, {}],
[ "given unless condition", { unless: lambda { false } }, {}],
[ "given option that is not reserved", { format: "jpg" }, { format: "jpg" }]
]
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_confirmation_of on generated message #{name}" do
person_class.validates_confirmation_of :title, validation_options
@person.title_confirmation = "foo"
call = [:title_confirmation, :confirmation, @person, generate_message_options.merge(attribute: "Title")]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_acceptance_of on generated message #{name}" do
person_class.validates_acceptance_of :title, validation_options.merge(allow_nil: false)
call = [:title, :accepted, @person, generate_message_options]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_presence_of on generated message #{name}" do
person_class.validates_presence_of :title, validation_options
call = [:title, :blank, @person, generate_message_options]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_length_of for :within on generated message when too short #{name}" do
person_class.validates_length_of :title, validation_options.merge(within: 3..5)
call = [:title, :too_short, @person, generate_message_options.merge(count: 3)]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_length_of for :too_long generated message #{name}" do
person_class.validates_length_of :title, validation_options.merge(within: 3..5)
@person.title = "this title is too long"
call = [:title, :too_long, @person, generate_message_options.merge(count: 5)]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_length_of for :is on generated message #{name}" do
person_class.validates_length_of :title, validation_options.merge(is: 5)
call = [:title, :wrong_length, @person, generate_message_options.merge(count: 5)]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_format_of on generated message #{name}" do
person_class.validates_format_of :title, validation_options.merge(with: /\A[1-9][0-9]*\z/)
@person.title = "72x"
call = [:title, :invalid, @person, generate_message_options.merge(value: "72x")]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_inclusion_of on generated message #{name}" do
person_class.validates_inclusion_of :title, validation_options.merge(in: %w(a b c))
@person.title = "z"
call = [:title, :inclusion, @person, generate_message_options.merge(value: "z")]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_inclusion_of using :within on generated message #{name}" do
person_class.validates_inclusion_of :title, validation_options.merge(within: %w(a b c))
@person.title = "z"
call = [:title, :inclusion, @person, generate_message_options.merge(value: "z")]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_exclusion_of generated message #{name}" do
person_class.validates_exclusion_of :title, validation_options.merge(in: %w(a b c))
@person.title = "a"
call = [:title, :exclusion, @person, generate_message_options.merge(value: "a")]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_exclusion_of using :within generated message #{name}" do
person_class.validates_exclusion_of :title, validation_options.merge(within: %w(a b c))
@person.title = "a"
call = [:title, :exclusion, @person, generate_message_options.merge(value: "a")]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_numericality_of generated message #{name}" do
person_class.validates_numericality_of :title, validation_options
@person.title = "a"
call = [:title, :not_a_number, @person, generate_message_options.merge(value: "a")]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_numericality_of for :only_integer on generated message #{name}" do
person_class.validates_numericality_of :title, validation_options.merge(only_integer: true)
@person.title = "0.0"
call = [:title, :not_an_integer, @person, generate_message_options.merge(value: "0.0")]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_numericality_of for :odd on generated message #{name}" do
person_class.validates_numericality_of :title, validation_options.merge(only_integer: true, odd: true)
@person.title = 0
call = [:title, :odd, @person, generate_message_options.merge(value: 0)]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
COMMON_CASES.each do |name, validation_options, generate_message_options|
test "validates_numericality_of for :less_than on generated message #{name}" do
person_class.validates_numericality_of :title, validation_options.merge(only_integer: true, less_than: 0)
@person.title = 1
call = [:title, :less_than, @person, generate_message_options.merge(value: 1, count: 0)]
assert_called_with(ActiveModel::Error, :generate_message, call) do
@person.valid?
@person.errors.messages
end
end
end
# To make things DRY this macro is created to define 3 tests for every validation case.
def self.set_expectations_for_validation(validation, error_type, &block_that_sets_validation)
if error_type == :confirmation
attribute = :title_confirmation
else
attribute = :title
end
test "#{validation} finds custom model key translation when #{error_type}" do
I18n.backend.store_translations "en", activemodel: { errors: { models: { person: { attributes: { attribute => { error_type => "custom message" } } } } } }
I18n.backend.store_translations "en", errors: { messages: { error_type => "global message" } }
yield(@person, {})
@person.valid?
assert_equal ["custom message"], @person.errors[attribute]
end
test "#{validation} finds custom model key translation with interpolation when #{error_type}" do
I18n.backend.store_translations "en", activemodel: { errors: { models: { person: { attributes: { attribute => { error_type => "custom message with %{extra}" } } } } } }
I18n.backend.store_translations "en", errors: { messages: { error_type => "global message" } }
yield(@person, { extra: "extra information" })
@person.valid?
assert_equal ["custom message with extra information"], @person.errors[attribute]
end
test "#{validation} finds global default key translation when #{error_type}" do
I18n.backend.store_translations "en", errors: { messages: { error_type => "global message" } }
yield(@person, {})
@person.valid?
assert_equal ["global message"], @person.errors[attribute]
end
end
set_expectations_for_validation "validates_confirmation_of", :confirmation do |person, options_to_merge|
person.class.validates_confirmation_of :title, options_to_merge
person.title_confirmation = "foo"
end
set_expectations_for_validation "validates_acceptance_of", :accepted do |person, options_to_merge|
person.class.validates_acceptance_of :title, options_to_merge.merge(allow_nil: false)
end
set_expectations_for_validation "validates_presence_of", :blank do |person, options_to_merge|
person.class.validates_presence_of :title, options_to_merge
end
set_expectations_for_validation "validates_length_of", :too_short do |person, options_to_merge|
person.class.validates_length_of :title, options_to_merge.merge(within: 3..5)
end
set_expectations_for_validation "validates_length_of", :too_long do |person, options_to_merge|
person.class.validates_length_of :title, options_to_merge.merge(within: 3..5)
person.title = "too long"
end
set_expectations_for_validation "validates_length_of", :wrong_length do |person, options_to_merge|
person.class.validates_length_of :title, options_to_merge.merge(is: 5)
end
set_expectations_for_validation "validates_format_of", :invalid do |person, options_to_merge|
person.class.validates_format_of :title, options_to_merge.merge(with: /\A[1-9][0-9]*\z/)
end
set_expectations_for_validation "validates_inclusion_of", :inclusion do |person, options_to_merge|
person.class.validates_inclusion_of :title, options_to_merge.merge(in: %w(a b c))
end
set_expectations_for_validation "validates_exclusion_of", :exclusion do |person, options_to_merge|
person.class.validates_exclusion_of :title, options_to_merge.merge(in: %w(a b c))
person.title = "a"
end
set_expectations_for_validation "validates_numericality_of", :not_a_number do |person, options_to_merge|
person.class.validates_numericality_of :title, options_to_merge
person.title = "a"
end
set_expectations_for_validation "validates_numericality_of", :not_an_integer do |person, options_to_merge|
person.class.validates_numericality_of :title, options_to_merge.merge(only_integer: true)
person.title = "1.0"
end
set_expectations_for_validation "validates_numericality_of", :odd do |person, options_to_merge|
person.class.validates_numericality_of :title, options_to_merge.merge(only_integer: true, odd: true)
person.title = 0
end
set_expectations_for_validation "validates_numericality_of", :less_than do |person, options_to_merge|
person.class.validates_numericality_of :title, options_to_merge.merge(only_integer: true, less_than: 0)
person.title = 1
end
def test_validations_with_message_symbol_must_translate
I18n.backend.store_translations "en", errors: { messages: { custom_error: "I am a custom error" } }
person_class.validates_presence_of :title, message: :custom_error
@person.title = nil
@person.valid?
assert_equal ["I am a custom error"], @person.errors[:title]
end
def test_validates_with_message_symbol_must_translate_per_attribute
I18n.backend.store_translations "en", activemodel: { errors: { models: { person: { attributes: { title: { custom_error: "I am a custom error" } } } } } }
person_class.validates_presence_of :title, message: :custom_error
@person.title = nil
@person.valid?
assert_equal ["I am a custom error"], @person.errors[:title]
end
def test_validates_with_message_symbol_must_translate_per_model
I18n.backend.store_translations "en", activemodel: { errors: { models: { person: { custom_error: "I am a custom error" } } } }
person_class.validates_presence_of :title, message: :custom_error
@person.title = nil
@person.valid?
assert_equal ["I am a custom error"], @person.errors[:title]
end
def test_validates_with_message_string
person_class.validates_presence_of :title, message: "I am a custom error"
@person.title = nil
@person.valid?
assert_equal ["I am a custom error"], @person.errors[:title]
end
def person_class
@person_stub ||= self.class.const_set(:Person, Class.new(Person))
end
end
# frozen_string_literal: true
module ActiveModel
module Type
# Attribute type to represent immutable strings. It casts incoming values to
# frozen strings.
#
# class Person
# include ActiveModel::Attributes
#
# attribute :name, :immutable_string
# end
#
# person = Person.new
# person.name = 1
#
# person.name # => "1"
# person.name.frozen? # => true
#
# Values are coerced to strings using their +to_s+ method. Boolean values
# are treated differently, however: +true+ will be cast to <tt>"t"</tt> and
# +false+ will be cast to <tt>"f"</tt>. These strings can be customized when
# declaring an attribute:
#
# class Person
# include ActiveModel::Attributes
#
# attribute :active, :immutable_string, true: "aye", false: "nay"
# end
#
# person = Person.new
# person.active = true
#
# person.active # => "aye"
class ImmutableString < Value
def initialize(**args)
@true = -(args.delete(:true)&.to_s || "t")
@false = -(args.delete(:false)&.to_s || "f")
super
end
def type
:string
end
def serialize(value)
case value
when ::Numeric, ::Symbol, ActiveSupport::Duration then value.to_s
when true then @true
when false then @false
else super
end
end
private
def cast_value(value)
case value
when true then @true
when false then @false
else value.to_s.freeze
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class ImmutableStringTest < ActiveModel::TestCase
test "cast strings are frozen" do
s = "foo"
type = Type::ImmutableString.new
assert_equal true, type.cast(s).frozen?
end
test "immutable strings are not duped coming out" do
s = "foo"
type = Type::ImmutableString.new
assert_same s, type.cast(s)
assert_same s, type.deserialize(s)
end
end
end
end
# frozen_string_literal: true
require "active_model/validations/clusivity"
module ActiveModel
module Validations
class InclusionValidator < EachValidator # :nodoc:
include Clusivity
def validate_each(record, attribute, value)
unless include?(record, value)
record.errors.add(attribute, :inclusion, **options.except(:in, :within).merge!(value: value))
end
end
end
module HelperMethods
# Validates whether the value of the specified attribute is available in a
# particular enumerable object.
#
# class Person < ActiveRecord::Base
# validates_inclusion_of :role, in: %w( admin contributor )
# validates_inclusion_of :age, in: 0..99
# validates_inclusion_of :format, in: %w( jpg gif png ), message: "extension %{value} is not included in the list"
# validates_inclusion_of :states, in: ->(person) { STATES[person.country] }
# validates_inclusion_of :karma, in: :available_karmas
# end
#
# Configuration options:
# * <tt>:in</tt> - An enumerable object of available items. This can be
# supplied as a proc, lambda, or symbol which returns an enumerable. If the
# enumerable is a numerical, time, or datetime range the test is performed
# with <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. When using
# a proc or lambda the instance under validation is passed as an argument.
# * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt>
# * <tt>:message</tt> - Specifies a custom error message (default is: "is
# not included in the list").
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
# See ActiveModel::Validations::ClassMethods#validates for more information.
def validates_inclusion_of(*attr_names)
validates_with InclusionValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
class InclusionValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
def test_validates_inclusion_of_range
Topic.validates_inclusion_of(:title, in: "aaa".."bbb")
assert_predicate Topic.new("title" => "bbc", "content" => "abc"), :invalid?
assert_predicate Topic.new("title" => "aa", "content" => "abc"), :invalid?
assert_predicate Topic.new("title" => "aaab", "content" => "abc"), :invalid?
assert_predicate Topic.new("title" => "aaa", "content" => "abc"), :valid?
assert_predicate Topic.new("title" => "abc", "content" => "abc"), :valid?
assert_predicate Topic.new("title" => "bbb", "content" => "abc"), :valid?
end
def test_validates_inclusion_of_time_range
range_begin = 1.year.ago
range_end = Time.now
Topic.validates_inclusion_of(:created_at, in: range_begin..range_end)
assert_predicate Topic.new(title: "aaa", created_at: 2.years.ago), :invalid?
assert_predicate Topic.new(title: "aaa", created_at: 3.months.ago), :valid?
assert_predicate Topic.new(title: "aaa", created_at: 37.weeks.from_now), :invalid?
assert_predicate Topic.new(title: "aaa", created_at: range_begin), :valid?
assert_predicate Topic.new(title: "aaa", created_at: range_end), :valid?
end
def test_validates_inclusion_of_date_range
range_begin = 1.year.until(Date.today)
range_end = Date.today
Topic.validates_inclusion_of(:created_at, in: range_begin..range_end)
assert_predicate Topic.new(title: "aaa", created_at: 2.years.until(Date.today)), :invalid?
assert_predicate Topic.new(title: "aaa", created_at: 3.months.until(Date.today)), :valid?
assert_predicate Topic.new(title: "aaa", created_at: 37.weeks.since(Date.today)), :invalid?
assert_predicate Topic.new(title: "aaa", created_at: 1.year.until(Date.today)), :valid?
assert_predicate Topic.new(title: "aaa", created_at: Date.today), :valid?
assert_predicate Topic.new(title: "aaa", created_at: range_begin), :valid?
assert_predicate Topic.new(title: "aaa", created_at: range_end), :valid?
end
def test_validates_inclusion_of_date_time_range
range_begin = 1.year.until(DateTime.current)
range_end = DateTime.current
Topic.validates_inclusion_of(:created_at, in: range_begin..range_end)
assert_predicate Topic.new(title: "aaa", created_at: 2.years.until(DateTime.current)), :invalid?
assert_predicate Topic.new(title: "aaa", created_at: 3.months.until(DateTime.current)), :valid?
assert_predicate Topic.new(title: "aaa", created_at: 37.weeks.since(DateTime.current)), :invalid?
assert_predicate Topic.new(title: "aaa", created_at: range_begin), :valid?
assert_predicate Topic.new(title: "aaa", created_at: range_end), :valid?
end
def test_validates_inclusion_of
Topic.validates_inclusion_of(:title, in: %w( a b c d e f g ))
assert_predicate Topic.new("title" => "a!", "content" => "abc"), :invalid?
assert_predicate Topic.new("title" => "a b", "content" => "abc"), :invalid?
assert_predicate Topic.new("title" => nil, "content" => "def"), :invalid?
t = Topic.new("title" => "a", "content" => "I know you are but what am I?")
assert_predicate t, :valid?
t.title = "uhoh"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is not included in the list"], t.errors[:title]
assert_raise(ArgumentError) { Topic.validates_inclusion_of(:title, in: nil) }
assert_raise(ArgumentError) { Topic.validates_inclusion_of(:title, in: 0) }
assert_nothing_raised { Topic.validates_inclusion_of(:title, in: "hi!") }
assert_nothing_raised { Topic.validates_inclusion_of(:title, in: {}) }
assert_nothing_raised { Topic.validates_inclusion_of(:title, in: []) }
end
def test_validates_inclusion_of_with_allow_nil
Topic.validates_inclusion_of(:title, in: %w( a b c d e f g ), allow_nil: true)
assert_predicate Topic.new("title" => "a!", "content" => "abc"), :invalid?
assert_predicate Topic.new("title" => "", "content" => "abc"), :invalid?
assert_predicate Topic.new("title" => nil, "content" => "abc"), :valid?
end
def test_validates_inclusion_of_with_formatted_message
Topic.validates_inclusion_of(:title, in: %w( a b c d e f g ), message: "option %{value} is not in the list")
assert_predicate Topic.new("title" => "a", "content" => "abc"), :valid?
t = Topic.new("title" => "uhoh", "content" => "abc")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["option uhoh is not in the list"], t.errors[:title]
end
def test_validates_inclusion_of_with_within_option
Topic.validates_inclusion_of(:title, within: %w( a b c d e f g ))
assert_predicate Topic.new("title" => "a", "content" => "abc"), :valid?
t = Topic.new("title" => "uhoh", "content" => "abc")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
end
def test_validates_inclusion_of_for_ruby_class
Person.validates_inclusion_of :karma, in: %w( abe monkey )
p = Person.new
p.karma = "Lifo"
assert_predicate p, :invalid?
assert_equal ["is not included in the list"], p.errors[:karma]
p.karma = "monkey"
assert_predicate p, :valid?
ensure
Person.clear_validators!
end
def test_validates_inclusion_of_with_lambda
Topic.validates_inclusion_of :title, in: lambda { |topic| topic.author_name == "sikachu" ? %w( monkey elephant ) : %w( abe wasabi ) }
t = Topic.new
t.title = "wasabi"
t.author_name = "sikachu"
assert_predicate t, :invalid?
t.title = "elephant"
assert_predicate t, :valid?
end
def test_validates_inclusion_of_with_symbol
Person.validates_inclusion_of :karma, in: :available_karmas
p = Person.new
p.karma = "Lifo"
def p.available_karmas
%w()
end
assert_predicate p, :invalid?
assert_equal ["is not included in the list"], p.errors[:karma]
p = Person.new
p.karma = "Lifo"
def p.available_karmas
%w(Lifo)
end
assert_predicate p, :valid?
ensure
Person.clear_validators!
end
def test_validates_inclusion_of_with_array_value
Person.validates_inclusion_of :karma, in: %w( abe monkey )
p = Person.new
p.karma = %w(Lifo monkey)
assert p.invalid?
assert_equal ["is not included in the list"], p.errors[:karma]
p = Person.new
p.karma = %w(abe monkey)
assert p.valid?
ensure
Person.clear_validators!
end
end
# frozen_string_literal: true
module ActiveModel
module Type
# Attribute type for integer representation. This type is registered under
# the +:integer+ key.
#
# class Person
# include ActiveModel::Attributes
#
# attribute :age, :integer
# end
#
# person = Person.new
# person.age = "18"
#
# person.age # => 18
#
# Values are cast using their +to_i+ method, if it exists. If it does not
# exist, or if it raises an error, the value will be cast to +nil+:
#
# person.age = :not_an_integer
# person.age # => nil (because Symbol does not define #to_i)
#
# Serialization also works under the same principle. Non-numeric strings are
# serialized as +nil+, for example.
#
# Serialization also validates that the integer can be stored using a
# limited number of bytes. If it cannot, an ActiveModel::RangeError will be
# raised. The default limit is 4 bytes, and can be customized when declaring
# an attribute:
#
# class Person
# include ActiveModel::Attributes
#
# attribute :age, :integer, limit: 6
# end
class Integer < Value
include Helpers::Numeric
# Column storage size in bytes.
# 4 bytes means an integer as opposed to smallint etc.
DEFAULT_LIMIT = 4
def initialize(**)
super
@range = min_value...max_value
end
def type
:integer
end
def deserialize(value)
return if value.blank?
value.to_i
end
def serialize(value)
return if value.is_a?(::String) && non_numeric_string?(value)
ensure_in_range(super)
end
def serializable?(value)
cast_value = cast(value)
in_range?(cast_value) || begin
yield cast_value if block_given?
false
end
end
private
attr_reader :range
def in_range?(value)
!value || range.member?(value)
end
def cast_value(value)
value.to_i rescue nil
end
def ensure_in_range(value)
unless in_range?(value)
raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{_limit} bytes"
end
value
end
def max_value
1 << (_limit * 8 - 1) # 8 bits per byte with one bit for sign
end
def min_value
-max_value
end
def _limit
limit || DEFAULT_LIMIT
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_support/core_ext/numeric/time"
module ActiveModel
module Type
class IntegerTest < ActiveModel::TestCase
test "simple values" do
type = Type::Integer.new
assert_nil type.cast("")
assert_equal 1, type.cast(1)
assert_equal 1, type.cast("1")
assert_equal 1, type.cast("1ignore")
assert_equal 0, type.cast("bad1")
assert_equal 0, type.cast("bad")
assert_equal 1, type.cast(1.7)
assert_equal 0, type.cast(false)
assert_equal 1, type.cast(true)
assert_nil type.cast(nil)
end
test "random objects cast to nil" do
type = Type::Integer.new
assert_nil type.cast([1, 2])
assert_nil type.cast(1 => 2)
assert_nil type.cast(1..2)
end
test "casting objects without to_i" do
type = Type::Integer.new
assert_nil type.cast(::Object.new)
end
test "casting nan and infinity" do
type = Type::Integer.new
assert_nil type.cast(::Float::NAN)
assert_nil type.cast(1.0 / 0.0)
end
test "casting booleans for database" do
type = Type::Integer.new
assert_equal 1, type.serialize(true)
assert_equal 0, type.serialize(false)
end
test "casting duration" do
type = Type::Integer.new
assert_equal 1800, type.cast(30.minutes)
assert_equal 7200, type.cast(2.hours)
end
test "casting string for database" do
type = Type::Integer.new
assert_nil type.serialize("wibble")
assert_equal 5, type.serialize("5wibble")
assert_equal 5, type.serialize(" +5")
assert_equal(-5, type.serialize(" -5"))
end
test "casting empty string" do
type = Type::Integer.new
assert_nil type.cast("")
assert_nil type.serialize("")
assert_nil type.deserialize("")
end
test "changed?" do
type = Type::Integer.new
assert type.changed?(0, 0, "wibble")
assert type.changed?(5, 0, "wibble")
assert_not type.changed?(5, 5, "5wibble")
assert_not type.changed?(5, 5, "5")
assert_not type.changed?(5, 5, "5.0")
assert_not type.changed?(5, 5, "+5")
assert_not type.changed?(5, 5, "+5.0")
assert_not type.changed?(-5, -5, "-5")
assert_not type.changed?(-5, -5, "-5.0")
assert_not type.changed?(nil, nil, nil)
end
test "values below int min value are out of range" do
assert_raises(ActiveModel::RangeError) do
Integer.new.serialize(-2147483649)
end
end
test "values above int max value are out of range" do
assert_raises(ActiveModel::RangeError) do
Integer.new.serialize(2147483648)
end
end
test "very small numbers are out of range" do
assert_raises(ActiveModel::RangeError) do
Integer.new.serialize(-9999999999999999999999999999999)
end
end
test "very large numbers are out of range" do
assert_raises(ActiveModel::RangeError) do
Integer.new.serialize(9999999999999999999999999999999)
end
end
test "normal numbers are in range" do
type = Integer.new
assert_equal(0, type.serialize(0))
assert_equal(-1, type.serialize(-1))
assert_equal(1, type.serialize(1))
end
test "int max value is in range" do
assert_equal(2147483647, Integer.new.serialize(2147483647))
end
test "int min value is in range" do
assert_equal(-2147483648, Integer.new.serialize(-2147483648))
end
test "columns with a larger limit have larger ranges" do
type = Integer.new(limit: 8)
assert_equal(9223372036854775807, type.serialize(9223372036854775807))
assert_equal(-9223372036854775808, type.serialize(-9223372036854775808))
assert_raises(ActiveModel::RangeError) do
type.serialize(-9999999999999999999999999999999)
end
assert_raises(ActiveModel::RangeError) do
type.serialize(9999999999999999999999999999999)
end
end
end
end
end
# frozen_string_literal: true
require "active_support/json"
module ActiveModel
module Serializers
# == Active \Model \JSON \Serializer
module JSON
extend ActiveSupport::Concern
include ActiveModel::Serialization
included do
extend ActiveModel::Naming
class_attribute :include_root_in_json, instance_writer: false, default: false
end
# Returns a hash representing the model. Some configuration can be
# passed through +options+.
#
# The option <tt>include_root_in_json</tt> controls the top-level behavior
# of +as_json+. If +true+, +as_json+ will emit a single root node named
# after the object's type. The default value for <tt>include_root_in_json</tt>
# option is +false+.
#
# user = User.find(1)
# user.as_json
# # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# # "created_at" => "2006-08-01T17:27:133.000Z", "awesome" => true}
#
# ActiveRecord::Base.include_root_in_json = true
#
# user.as_json
# # => { "user" => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true } }
#
# This behavior can also be achieved by setting the <tt>:root</tt> option
# to +true+ as in:
#
# user = User.find(1)
# user.as_json(root: true)
# # => { "user" => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true } }
#
# If you prefer, <tt>:root</tt> may also be set to a custom string key instead as in:
#
# user = User.find(1)
# user.as_json(root: "author")
# # => { "author" => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true } }
#
# Without any +options+, the returned Hash will include all the model's
# attributes.
#
# user = User.find(1)
# user.as_json
# # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true}
#
# The <tt>:only</tt> and <tt>:except</tt> options can be used to limit
# the attributes included, and work similar to the +attributes+ method.
#
# user.as_json(only: [:id, :name])
# # => { "id" => 1, "name" => "Konata Izumi" }
#
# user.as_json(except: [:id, :created_at, :age])
# # => { "name" => "Konata Izumi", "awesome" => true }
#
# To include the result of some method calls on the model use <tt>:methods</tt>:
#
# user.as_json(methods: :permalink)
# # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
# # "permalink" => "1-konata-izumi" }
#
# To include associations use <tt>:include</tt>:
#
# user.as_json(include: :posts)
# # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
# # "posts" => [ { "id" => 1, "author_id" => 1, "title" => "Welcome to the weblog" },
# # { "id" => 2, "author_id" => 1, "title" => "So I was thinking" } ] }
#
# Second level and higher order associations work as well:
#
# user.as_json(include: { posts: {
# include: { comments: {
# only: :body } },
# only: :title } })
# # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
# # "posts" => [ { "comments" => [ { "body" => "1st post!" }, { "body" => "Second!" } ],
# # "title" => "Welcome to the weblog" },
# # { "comments" => [ { "body" => "Don't think too hard" } ],
# # "title" => "So I was thinking" } ] }
def as_json(options = nil)
root = if options && options.key?(:root)
options[:root]
else
include_root_in_json
end
hash = serializable_hash(options).as_json
if root
root = model_name.element if root == true
{ root => hash }
else
hash
end
end
# Sets the model +attributes+ from a JSON string. Returns +self+.
#
# class Person
# include ActiveModel::Serializers::JSON
#
# attr_accessor :name, :age, :awesome
#
# def attributes=(hash)
# hash.each do |key, value|
# send("#{key}=", value)
# end
# end
#
# def attributes
# instance_values
# end
# end
#
# json = { name: 'bob', age: 22, awesome:true }.to_json
# person = Person.new
# person.from_json(json) # => #<Person:0x007fec5e7a0088 @age=22, @awesome=true, @name="bob">
# person.name # => "bob"
# person.age # => 22
# person.awesome # => true
#
# The default value for +include_root+ is +false+. You can change it to
# +true+ if the given JSON string includes a single root node.
#
# json = { person: { name: 'bob', age: 22, awesome:true } }.to_json
# person = Person.new
# person.from_json(json, true) # => #<Person:0x007fec5e7a0088 @age=22, @awesome=true, @name="bob">
# person.name # => "bob"
# person.age # => 22
# person.awesome # => true
def from_json(json, include_root = include_root_in_json)
hash = ActiveSupport::JSON.decode(json)
hash = hash.values.first if include_root
self.attributes = hash
self
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/contact"
require "active_support/core_ext/object/instance_variables"
class JsonSerializationTest < ActiveModel::TestCase
def setup
@contact = Contact.new
@contact.name = "Konata Izumi"
@contact.age = 16
@contact.created_at = Time.utc(2006, 8, 1)
@contact.awesome = true
@contact.preferences = { "shows" => "anime" }
end
test "should not include root in json (class method)" do
json = @contact.to_json
assert_no_match %r{^\{"contact":\{}, json
assert_match %r{"name":"Konata Izumi"}, json
assert_match %r{"age":16}, json
assert_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
assert_match %r{"awesome":true}, json
assert_match %r{"preferences":\{"shows":"anime"\}}, json
end
test "should include root in json if include_root_in_json is true" do
original_include_root_in_json = Contact.include_root_in_json
Contact.include_root_in_json = true
json = @contact.to_json
assert_match %r{^\{"contact":\{}, json
assert_match %r{"name":"Konata Izumi"}, json
assert_match %r{"age":16}, json
assert_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
assert_match %r{"awesome":true}, json
assert_match %r{"preferences":\{"shows":"anime"\}}, json
ensure
Contact.include_root_in_json = original_include_root_in_json
end
test "should include root in json (option) even if the default is set to false" do
json = @contact.to_json(root: true)
assert_match %r{^\{"contact":\{}, json
end
test "should not include root in json (option)" do
json = @contact.to_json(root: false)
assert_no_match %r{^\{"contact":\{}, json
end
test "should include custom root in json" do
json = @contact.to_json(root: "json_contact")
assert_match %r{^\{"json_contact":\{}, json
assert_match %r{"name":"Konata Izumi"}, json
assert_match %r{"age":16}, json
assert_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
assert_match %r{"awesome":true}, json
assert_match %r{"preferences":\{"shows":"anime"\}}, json
end
test "should encode all encodable attributes" do
json = @contact.to_json
assert_match %r{"name":"Konata Izumi"}, json
assert_match %r{"age":16}, json
assert_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
assert_match %r{"awesome":true}, json
assert_match %r{"preferences":\{"shows":"anime"\}}, json
end
test "should allow attribute filtering with only" do
json = @contact.to_json(only: [:name, :age])
assert_match %r{"name":"Konata Izumi"}, json
assert_match %r{"age":16}, json
assert_no_match %r{"awesome":true}, json
assert_not_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
assert_no_match %r{"preferences":\{"shows":"anime"\}}, json
end
test "should allow attribute filtering with except" do
json = @contact.to_json(except: [:name, :age])
assert_no_match %r{"name":"Konata Izumi"}, json
assert_no_match %r{"age":16}, json
assert_match %r{"awesome":true}, json
assert_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
assert_match %r{"preferences":\{"shows":"anime"\}}, json
end
test "methods are called on object" do
# Define methods on fixture.
def @contact.label; "Has cheezburger"; end
def @contact.favorite_quote; "Constraints are liberating"; end
# Single method.
assert_match %r{"label":"Has cheezburger"}, @contact.to_json(only: :name, methods: :label)
# Both methods.
methods_json = @contact.to_json(only: :name, methods: [:label, :favorite_quote])
assert_match %r{"label":"Has cheezburger"}, methods_json
assert_match %r{"favorite_quote":"Constraints are liberating"}, methods_json
end
test "should return Hash for errors" do
contact = Contact.new
contact.errors.add :name, "can't be blank"
contact.errors.add :name, "is too short (minimum is 2 characters)"
contact.errors.add :age, "must be 16 or over"
hash = {}
hash[:name] = ["can't be blank", "is too short (minimum is 2 characters)"]
hash[:age] = ["must be 16 or over"]
assert_equal hash.to_json, contact.errors.to_json
end
test "serializable_hash should not modify options passed in argument" do
options = { except: :name }
@contact.serializable_hash(options)
assert_nil options[:only]
assert_equal :name, options[:except]
end
test "as_json should serialize timestamps" do
assert_equal "2006-08-01T00:00:00.000Z", @contact.as_json["created_at"]
end
test "as_json should return a hash if include_root_in_json is true" do
original_include_root_in_json = Contact.include_root_in_json
Contact.include_root_in_json = true
json = @contact.as_json
assert_kind_of Hash, json
assert_kind_of Hash, json["contact"]
%w(name age created_at awesome preferences).each do |field|
assert_equal @contact.public_send(field).as_json, json["contact"][field]
end
ensure
Contact.include_root_in_json = original_include_root_in_json
end
test "from_json should work without a root (class attribute)" do
json = @contact.to_json
result = Contact.new.from_json(json)
assert_equal result.name, @contact.name
assert_equal result.age, @contact.age
assert_equal Time.parse(result.created_at), @contact.created_at
assert_equal result.awesome, @contact.awesome
assert_equal result.preferences, @contact.preferences
end
test "from_json should work without a root (method parameter)" do
json = @contact.to_json
result = Contact.new.from_json(json, false)
assert_equal result.name, @contact.name
assert_equal result.age, @contact.age
assert_equal Time.parse(result.created_at), @contact.created_at
assert_equal result.awesome, @contact.awesome
assert_equal result.preferences, @contact.preferences
end
test "from_json should work with a root (method parameter)" do
json = @contact.to_json(root: :true)
result = Contact.new.from_json(json, true)
assert_equal result.name, @contact.name
assert_equal result.age, @contact.age
assert_equal Time.parse(result.created_at), @contact.created_at
assert_equal result.awesome, @contact.awesome
assert_equal result.preferences, @contact.preferences
end
test "custom as_json should be honored when generating json" do
def @contact.as_json(options); { name: name, created_at: created_at }; end
json = @contact.to_json
assert_match %r{"name":"Konata Izumi"}, json
assert_match %r{"created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))}}, json
assert_no_match %r{"awesome":}, json
assert_no_match %r{"preferences":}, json
end
test "custom as_json options should be extensible" do
def @contact.as_json(options = {}); super(options.merge(only: [:name])); end
json = @contact.to_json
assert_match %r{"name":"Konata Izumi"}, json
assert_no_match %r{"created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))}}, json
assert_no_match %r{"awesome":}, json
assert_no_match %r{"preferences":}, json
end
test "Class.model_name should be json encodable" do
assert_match %r{"Contact"}, Contact.model_name.to_json
end
end
# frozen_string_literal: true
module ActiveModel
module Validations
class LengthValidator < EachValidator # :nodoc:
MESSAGES = { is: :wrong_length, minimum: :too_short, maximum: :too_long }.freeze
CHECKS = { is: :==, minimum: :>=, maximum: :<= }.freeze
RESERVED_OPTIONS = [:minimum, :maximum, :within, :is, :too_short, :too_long]
def initialize(options)
if range = (options.delete(:in) || options.delete(:within))
raise ArgumentError, ":in and :within must be a Range" unless range.is_a?(Range)
options[:minimum], options[:maximum] = range.min, range.max
end
if options[:allow_blank] == false && options[:minimum].nil? && options[:is].nil?
options[:minimum] = 1
end
super
end
def check_validity!
keys = CHECKS.keys & options.keys
if keys.empty?
raise ArgumentError, "Range unspecified. Specify the :in, :within, :maximum, :minimum, or :is option."
end
keys.each do |key|
value = options[key]
unless (value.is_a?(Integer) && value >= 0) || value == Float::INFINITY || value.is_a?(Symbol) || value.is_a?(Proc)
raise ArgumentError, ":#{key} must be a non-negative Integer, Infinity, Symbol, or Proc"
end
end
end
def validate_each(record, attribute, value)
value_length = value.respond_to?(:length) ? value.length : value.to_s.length
errors_options = options.except(*RESERVED_OPTIONS)
CHECKS.each do |key, validity_check|
next unless check_value = options[key]
if !value.nil? || skip_nil_check?(key)
case check_value
when Proc
check_value = check_value.call(record)
when Symbol
check_value = record.send(check_value)
end
next if value_length.public_send(validity_check, check_value)
end
errors_options[:count] = check_value
default_message = options[MESSAGES[key]]
errors_options[:message] ||= default_message if default_message
record.errors.add(attribute, MESSAGES[key], **errors_options)
end
end
private
def skip_nil_check?(key)
key == :maximum && options[:allow_nil].nil? && options[:allow_blank].nil?
end
end
module HelperMethods
# Validates that the specified attributes match the length restrictions
# supplied. Only one constraint option can be used at a time apart from
# +:minimum+ and +:maximum+ that can be combined together:
#
# class Person < ActiveRecord::Base
# validates_length_of :first_name, maximum: 30
# validates_length_of :last_name, maximum: 30, message: "less than 30 if you don't mind"
# validates_length_of :fax, in: 7..32, allow_nil: true
# validates_length_of :phone, in: 7..32, allow_blank: true
# validates_length_of :user_name, within: 6..20, too_long: 'pick a shorter name', too_short: 'pick a longer name'
# validates_length_of :zip_code, minimum: 5, too_short: 'please enter at least 5 characters'
# validates_length_of :smurf_leader, is: 4, message: "papa is spelled with 4 characters... don't play me."
# validates_length_of :words_in_essay, minimum: 100, too_short: 'Your essay must be at least 100 words.'
#
# private
#
# def words_in_essay
# essay.scan(/\w+/)
# end
# end
#
# Constraint options:
#
# * <tt>:minimum</tt> - The minimum size of the attribute.
# * <tt>:maximum</tt> - The maximum size of the attribute. Allows +nil+ by
# default if not used with +:minimum+.
# * <tt>:is</tt> - The exact size of the attribute.
# * <tt>:within</tt> - A range specifying the minimum and maximum size of
# the attribute.
# * <tt>:in</tt> - A synonym (or alias) for <tt>:within</tt>.
#
# Other options:
#
# * <tt>:allow_nil</tt> - Attribute may be +nil+; skip validation.
# * <tt>:allow_blank</tt> - Attribute may be blank; skip validation.
# * <tt>:too_long</tt> - The error message if the attribute goes over the
# maximum (default is: "is too long (maximum is %{count} characters)").
# * <tt>:too_short</tt> - The error message if the attribute goes under the
# minimum (default is: "is too short (minimum is %{count} characters)").
# * <tt>:wrong_length</tt> - The error message if using the <tt>:is</tt>
# method and the attribute is the wrong size (default is: "is the wrong
# length (should be %{count} characters)").
# * <tt>:message</tt> - The error message to use for a <tt>:minimum</tt>,
# <tt>:maximum</tt>, or <tt>:is</tt> violation. An alias of the appropriate
# <tt>too_long</tt>/<tt>too_short</tt>/<tt>wrong_length</tt> message.
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, and +:strict+.
# See ActiveModel::Validations::ClassMethods#validates for more information.
def validates_length_of(*attr_names)
validates_with LengthValidator, _merge_attributes(attr_names)
end
alias_method :validates_size_of, :validates_length_of
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
class LengthValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
def test_validates_length_of_with_allow_nil
Topic.validates_length_of(:title, is: 5, allow_nil: true)
assert_predicate Topic.new("title" => "ab"), :invalid?
assert_predicate Topic.new("title" => ""), :invalid?
assert_predicate Topic.new("title" => nil), :valid?
assert_predicate Topic.new("title" => "abcde"), :valid?
end
def test_validates_length_of_with_allow_blank
Topic.validates_length_of(:title, is: 5, allow_blank: true)
assert_predicate Topic.new("title" => "ab"), :invalid?
assert_predicate Topic.new("title" => ""), :valid?
assert_predicate Topic.new("title" => nil), :valid?
assert_predicate Topic.new("title" => "abcde"), :valid?
end
def test_validates_length_of_using_minimum
Topic.validates_length_of :title, minimum: 5
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = "not"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too short (minimum is 5 characters)"], t.errors[:title]
t.title = ""
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too short (minimum is 5 characters)"], t.errors[:title]
t.title = nil
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too short (minimum is 5 characters)"], t.errors["title"]
end
def test_validates_length_of_using_maximum_should_allow_nil
Topic.validates_length_of :title, maximum: 10
t = Topic.new
assert_predicate t, :valid?
end
def test_optionally_validates_length_of_using_minimum
Topic.validates_length_of :title, minimum: 5, allow_nil: true
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = nil
assert_predicate t, :valid?
end
def test_validates_length_of_using_maximum
Topic.validates_length_of :title, maximum: 5
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = "notvalid"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too long (maximum is 5 characters)"], t.errors[:title]
t.title = ""
assert_predicate t, :valid?
end
def test_optionally_validates_length_of_using_maximum
Topic.validates_length_of :title, maximum: 5, allow_nil: true
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = nil
assert_predicate t, :valid?
end
def test_validates_length_of_using_within
Topic.validates_length_of(:title, :content, within: 3..5)
t = Topic.new("title" => "a!", "content" => "I'm ooooooooh so very long")
assert_predicate t, :invalid?
assert_equal ["is too short (minimum is 3 characters)"], t.errors[:title]
assert_equal ["is too long (maximum is 5 characters)"], t.errors[:content]
t.title = nil
t.content = nil
assert_predicate t, :invalid?
assert_equal ["is too short (minimum is 3 characters)"], t.errors[:title]
assert_equal ["is too short (minimum is 3 characters)"], t.errors[:content]
t.title = "abe"
t.content = "mad"
assert_predicate t, :valid?
end
def test_validates_length_of_using_within_with_exclusive_range
Topic.validates_length_of(:title, within: 4...10)
t = Topic.new("title" => "9 chars!!")
assert_predicate t, :valid?
t.title = "Now I'm 10"
assert_predicate t, :invalid?
assert_equal ["is too long (maximum is 9 characters)"], t.errors[:title]
t.title = "Four"
assert_predicate t, :valid?
end
def test_optionally_validates_length_of_using_within
Topic.validates_length_of :title, :content, within: 3..5, allow_nil: true
t = Topic.new("title" => "abc", "content" => "abcd")
assert_predicate t, :valid?
t.title = nil
assert_predicate t, :valid?
end
def test_validates_length_of_using_is
Topic.validates_length_of :title, is: 5
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = "notvalid"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is the wrong length (should be 5 characters)"], t.errors[:title]
t.title = ""
assert_predicate t, :invalid?
t.title = nil
assert_predicate t, :invalid?
end
def test_optionally_validates_length_of_using_is
Topic.validates_length_of :title, is: 5, allow_nil: true
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = nil
assert_predicate t, :valid?
end
def test_validates_length_of_using_bignum
bigmin = 2**30
bigmax = 2**32
bigrange = bigmin...bigmax
assert_nothing_raised do
Topic.validates_length_of :title, is: bigmin + 5
Topic.validates_length_of :title, within: bigrange
Topic.validates_length_of :title, in: bigrange
Topic.validates_length_of :title, minimum: bigmin
Topic.validates_length_of :title, maximum: bigmax
end
end
def test_validates_length_of_nasty_params
assert_raise(ArgumentError) { Topic.validates_length_of(:title, is: -6) }
assert_raise(ArgumentError) { Topic.validates_length_of(:title, within: 6) }
assert_raise(ArgumentError) { Topic.validates_length_of(:title, minimum: "a") }
assert_raise(ArgumentError) { Topic.validates_length_of(:title, maximum: "a") }
assert_raise(ArgumentError) { Topic.validates_length_of(:title, within: "a") }
assert_raise(ArgumentError) { Topic.validates_length_of(:title, is: "a") }
end
def test_validates_length_of_custom_errors_for_minimum_with_message
Topic.validates_length_of(:title, minimum: 5, message: "boo %{count}")
t = Topic.new("title" => "uhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["boo 5"], t.errors[:title]
end
def test_validates_length_of_custom_errors_for_minimum_with_too_short
Topic.validates_length_of(:title, minimum: 5, too_short: "hoo %{count}")
t = Topic.new("title" => "uhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors[:title]
end
def test_validates_length_of_custom_errors_for_maximum_with_message
Topic.validates_length_of(:title, maximum: 5, message: "boo %{count}")
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["boo 5"], t.errors[:title]
end
def test_validates_length_of_custom_errors_for_in
Topic.validates_length_of(:title, in: 10..20, message: "hoo %{count}")
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 10"], t.errors["title"]
t = Topic.new("title" => "uhohuhohuhohuhohuhohuhohuhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 20"], t.errors["title"]
end
def test_validates_length_of_custom_errors_for_maximum_with_too_long
Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}")
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
def test_validates_length_of_custom_errors_for_both_too_short_and_too_long
Topic.validates_length_of :title, minimum: 3, maximum: 5, too_short: "too short", too_long: "too long"
t = Topic.new(title: "a")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["too short"], t.errors["title"]
t = Topic.new(title: "aaaaaa")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["too long"], t.errors["title"]
end
def test_validates_length_of_custom_errors_for_is_with_message
Topic.validates_length_of(:title, is: 5, message: "boo %{count}")
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["boo 5"], t.errors["title"]
end
def test_validates_length_of_custom_errors_for_is_with_wrong_length
Topic.validates_length_of(:title, is: 5, wrong_length: "hoo %{count}")
t = Topic.new("title" => "uhohuhoh", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["hoo 5"], t.errors["title"]
end
def test_validates_length_of_using_minimum_utf8
Topic.validates_length_of :title, minimum: 5
t = Topic.new("title" => "一二三四五", "content" => "whatever")
assert_predicate t, :valid?
t.title = "一二三四"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too short (minimum is 5 characters)"], t.errors["title"]
end
def test_validates_length_of_using_maximum_utf8
Topic.validates_length_of :title, maximum: 5
t = Topic.new("title" => "一二三四五", "content" => "whatever")
assert_predicate t, :valid?
t.title = "一二34五六"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too long (maximum is 5 characters)"], t.errors["title"]
end
def test_validates_length_of_using_within_utf8
Topic.validates_length_of(:title, :content, within: 3..5)
t = Topic.new("title" => "一二", "content" => "12三四五六七")
assert_predicate t, :invalid?
assert_equal ["is too short (minimum is 3 characters)"], t.errors[:title]
assert_equal ["is too long (maximum is 5 characters)"], t.errors[:content]
t.title = "一二三"
t.content = "12三"
assert_predicate t, :valid?
end
def test_optionally_validates_length_of_using_within_utf8
Topic.validates_length_of :title, within: 3..5, allow_nil: true
t = Topic.new(title: "一二三四五")
assert t.valid?, t.errors.inspect
t = Topic.new(title: "一二三")
assert t.valid?, t.errors.inspect
t.title = nil
assert t.valid?, t.errors.inspect
end
def test_validates_length_of_using_is_utf8
Topic.validates_length_of :title, is: 5
t = Topic.new("title" => "一二345", "content" => "whatever")
assert_predicate t, :valid?
t.title = "一二345六"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is the wrong length (should be 5 characters)"], t.errors["title"]
end
def test_validates_length_of_for_integer
Topic.validates_length_of(:approved, is: 4)
t = Topic.new("title" => "uhohuhoh", "content" => "whatever", approved: 1)
assert_predicate t, :invalid?
assert_predicate t.errors[:approved], :any?
t = Topic.new("title" => "uhohuhoh", "content" => "whatever", approved: 1234)
assert_predicate t, :valid?
end
def test_validates_length_of_for_ruby_class
Person.validates_length_of :karma, minimum: 5
p = Person.new
p.karma = "Pix"
assert_predicate p, :invalid?
assert_equal ["is too short (minimum is 5 characters)"], p.errors[:karma]
p.karma = "The Smiths"
assert_predicate p, :valid?
ensure
Person.clear_validators!
end
def test_validates_length_of_for_infinite_maxima
Topic.validates_length_of(:title, within: 5..Float::INFINITY)
t = Topic.new("title" => "1234")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
t.title = "12345"
assert_predicate t, :valid?
Topic.validates_length_of(:author_name, maximum: Float::INFINITY)
assert_predicate t, :valid?
t.author_name = "A very long author name that should still be valid." * 100
assert_predicate t, :valid?
end
def test_validates_length_of_using_maximum_should_not_allow_nil_when_nil_not_allowed
Topic.validates_length_of :title, maximum: 10, allow_nil: false
t = Topic.new
assert_predicate t, :invalid?
end
def test_validates_length_of_using_maximum_should_not_allow_nil_and_empty_string_when_blank_not_allowed
Topic.validates_length_of :title, maximum: 10, allow_blank: false
t = Topic.new
assert_predicate t, :invalid?
t.title = ""
assert_predicate t, :invalid?
end
def test_validates_length_of_using_both_minimum_and_maximum_should_not_allow_nil
Topic.validates_length_of :title, minimum: 5, maximum: 10
t = Topic.new
assert_predicate t, :invalid?
end
def test_validates_length_of_using_minimum_0_should_not_allow_nil
Topic.validates_length_of :title, minimum: 0
t = Topic.new
assert_predicate t, :invalid?
t.title = ""
assert_predicate t, :valid?
end
def test_validates_length_of_using_is_0_should_not_allow_nil
Topic.validates_length_of :title, is: 0
t = Topic.new
assert_predicate t, :invalid?
t.title = ""
assert_predicate t, :valid?
end
def test_validates_with_diff_in_option
Topic.validates_length_of(:title, is: 5)
Topic.validates_length_of(:title, is: 5, if: Proc.new { false })
assert_predicate Topic.new("title" => "david"), :valid?
assert_predicate Topic.new("title" => "david2"), :invalid?
end
def test_validates_length_of_using_symbol_as_maximum
Topic.validates_length_of :title, maximum: :five
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = "notvalid"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too long (maximum is 5 characters)"], t.errors[:title]
t.title = ""
assert_predicate t, :valid?
end
def test_validates_length_of_using_proc_as_maximum
Topic.validates_length_of :title, maximum: ->(model) { 5 }
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = "notvalid"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too long (maximum is 5 characters)"], t.errors[:title]
t.title = ""
assert_predicate t, :valid?
end
def test_validates_length_of_using_proc_as_maximum_with_model_method
Topic.define_method(:max_title_length) { 5 }
Topic.validates_length_of :title, maximum: Proc.new(&:max_title_length)
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :valid?
t.title = "notvalid"
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["is too long (maximum is 5 characters)"], t.errors[:title]
t.title = ""
assert_predicate t, :valid?
end
end
# frozen_string_literal: true
module ActiveModel
module Lint
# == Active \Model \Lint \Tests
#
# You can test whether an object is compliant with the Active \Model API by
# including <tt>ActiveModel::Lint::Tests</tt> in your TestCase. It will
# include tests that tell you whether your object is fully compliant,
# or if not, which aspects of the API are not implemented.
#
# Note an object is not required to implement all APIs in order to work
# with Action Pack. This module only intends to provide guidance in case
# you want all features out of the box.
#
# These tests do not attempt to determine the semantic correctness of the
# returned values. For instance, you could implement <tt>valid?</tt> to
# always return +true+, and the tests would pass. It is up to you to ensure
# that the values are semantically meaningful.
#
# Objects you pass in are expected to return a compliant object from a call
# to <tt>to_model</tt>. It is perfectly fine for <tt>to_model</tt> to return
# +self+.
module Tests
# Passes if the object's model responds to <tt>to_key</tt> and if calling
# this method returns +nil+ when the object is not persisted.
# Fails otherwise.
#
# <tt>to_key</tt> returns an Enumerable of all (primary) key attributes
# of the model, and is used to a generate unique DOM id for the object.
def test_to_key
assert_respond_to model, :to_key
def model.persisted?() false end
assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false"
end
# Passes if the object's model responds to <tt>to_param</tt> and if
# calling this method returns +nil+ when the object is not persisted.
# Fails otherwise.
#
# <tt>to_param</tt> is used to represent the object's key in URLs.
# Implementers can decide to either raise an exception or provide a
# default in case the record uses a composite primary key. There are no
# tests for this behavior in lint because it doesn't make sense to force
# any of the possible implementation strategies on the implementer.
def test_to_param
assert_respond_to model, :to_param
def model.to_key() [1] end
def model.persisted?() false end
assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false"
end
# Passes if the object's model responds to <tt>to_partial_path</tt> and if
# calling this method returns a string. Fails otherwise.
#
# <tt>to_partial_path</tt> is used for looking up partials. For example,
# a BlogPost model might return "blog_posts/blog_post".
def test_to_partial_path
assert_respond_to model, :to_partial_path
assert_kind_of String, model.to_partial_path
end
# Passes if the object's model responds to <tt>persisted?</tt> and if
# calling this method returns either +true+ or +false+. Fails otherwise.
#
# <tt>persisted?</tt> is used when calculating the URL for an object.
# If the object is not persisted, a form for that object, for instance,
# will route to the create action. If it is persisted, a form for the
# object will route to the update action.
def test_persisted?
assert_respond_to model, :persisted?
assert_boolean model.persisted?, "persisted?"
end
# Passes if the object's model responds to <tt>model_name</tt> both as
# an instance method and as a class method, and if calling this method
# returns a string with some convenience methods: <tt>:human</tt>,
# <tt>:singular</tt> and <tt>:plural</tt>.
#
# Check ActiveModel::Naming for more information.
def test_model_naming
assert_respond_to model.class, :model_name
model_name = model.class.model_name
assert_respond_to model_name, :to_str
assert_respond_to model_name.human, :to_str
assert_respond_to model_name.singular, :to_str
assert_respond_to model_name.plural, :to_str
assert_respond_to model, :model_name
assert_equal model.model_name, model.class.model_name
end
# Passes if the object's model responds to <tt>errors</tt> and if calling
# <tt>[](attribute)</tt> on the result of this method returns an array.
# Fails otherwise.
#
# <tt>errors[attribute]</tt> is used to retrieve the errors of a model
# for a given attribute. If errors are present, the method should return
# an array of strings that are the errors for the attribute in question.
# If localization is used, the strings should be localized for the current
# locale. If no error is present, the method should return an empty array.
def test_errors_aref
assert_respond_to model, :errors
assert_equal [], model.errors[:hello], "errors#[] should return an empty Array"
end
private
def model
assert_respond_to @model, :to_model
@model.to_model
end
def assert_boolean(result, name)
assert result == true || result == false, "#{name} should be a boolean"
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
class LintTest < ActiveModel::TestCase
include ActiveModel::Lint::Tests
class CompliantModel
extend ActiveModel::Naming
include ActiveModel::Conversion
def persisted?() false end
def errors
Hash.new([])
end
end
def setup
@model = CompliantModel.new
end
end
# frozen_string_literal: true
module ActiveModel
# == Active \Model \Basic \Model
#
# Allows implementing models similar to ActiveRecord::Base.
# Includes ActiveModel::API for the required interface for an
# object to interact with Action Pack and Action View, but can be
# extended with other functionalities.
#
# A minimal implementation could be:
#
# class Person
# include ActiveModel::Model
# attr_accessor :name, :age
# end
#
# person = Person.new(name: 'bob', age: '18')
# person.name # => "bob"
# person.age # => "18"
#
# If for some reason you need to run code on <tt>initialize</tt>, make
# sure you call +super+ if you want the attributes hash initialization to
# happen.
#
# class Person
# include ActiveModel::Model
# attr_accessor :id, :name, :omg
#
# def initialize(attributes={})
# super
# @omg ||= true
# end
# end
#
# person = Person.new(id: 1, name: 'bob')
# person.omg # => true
#
# For more detailed information on other functionalities available, please
# refer to the specific modules included in <tt>ActiveModel::Model</tt>
# (see below).
module Model
extend ActiveSupport::Concern
include ActiveModel::API
include ActiveModel::Access
##
# :method: slice
#
# :call-seq: slice(*methods)
#
# Returns a hash of the given methods with their names as keys and returned
# values as values.
#
#--
# Implemented by ActiveModel::Access#slice.
##
# :method: values_at
#
# :call-seq: values_at(*methods)
#
# Returns an array of the values returned by the given methods.
#
#--
# Implemented by ActiveModel::Access#values_at.
end
end
# frozen_string_literal: true
require "cases/helper"
class ModelTest < ActiveModel::TestCase
include ActiveModel::Lint::Tests
module DefaultValue
def self.included(klass)
klass.class_eval { attr_accessor :hello }
end
def initialize(*args)
@attr ||= "default value"
super
end
end
class BasicModel
include DefaultValue
include ActiveModel::Model
attr_accessor :attr
end
class BasicModelWithReversedMixins
include ActiveModel::Model
include DefaultValue
attr_accessor :attr
end
class SimpleModel
include ActiveModel::Model
attr_accessor :attr
end
def setup
@model = BasicModel.new
end
def test_initialize_with_params
object = BasicModel.new(attr: "value")
assert_equal "value", object.attr
end
def test_initialize_with_params_and_mixins_reversed
object = BasicModelWithReversedMixins.new(attr: "value")
assert_equal "value", object.attr
end
def test_initialize_with_nil_or_empty_hash_params_does_not_explode
assert_nothing_raised do
BasicModel.new()
BasicModel.new(nil)
BasicModel.new({})
SimpleModel.new(attr: "value")
end
end
def test_persisted_is_always_false
object = BasicModel.new(attr: "value")
assert_not object.persisted?
end
def test_mixin_inclusion_chain
object = BasicModel.new
assert_equal "default value", object.attr
end
def test_mixin_initializer_when_args_exist
object = BasicModel.new(hello: "world")
assert_equal "world", object.hello
end
def test_mixin_initializer_when_args_dont_exist
assert_raises(ActiveModel::UnknownAttributeError) do
SimpleModel.new(hello: "world")
end
end
end
# frozen_string_literal: true
module ActiveModel
module Type
module Helpers # :nodoc: all
module Mutable
def cast(value)
deserialize(serialize(value))
end
# +raw_old_value+ will be the `_before_type_cast` version of the
# value (likely a string). +new_value+ will be the current, type
# cast value.
def changed_in_place?(raw_old_value, new_value)
raw_old_value != serialize(new_value)
end
end
end
end
end
# frozen_string_literal: true
require "active_support/core_ext/hash/except"
require "active_support/core_ext/module/introspection"
require "active_support/core_ext/module/redefine_method"
require "active_support/core_ext/module/delegation"
module ActiveModel
class Name
include Comparable
attr_accessor :singular, :plural, :element, :collection,
:singular_route_key, :route_key, :param_key, :i18n_key,
:name
alias_method :cache_key, :collection
##
# :method: ==
#
# :call-seq:
# ==(other)
#
# Equivalent to <tt>String#==</tt>. Returns +true+ if the class name and
# +other+ are equal, otherwise +false+.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name == 'BlogPost' # => true
# BlogPost.model_name == 'Blog Post' # => false
##
# :method: ===
#
# :call-seq:
# ===(other)
#
# Equivalent to <tt>#==</tt>.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name === 'BlogPost' # => true
# BlogPost.model_name === 'Blog Post' # => false
##
# :method: <=>
#
# :call-seq:
# <=>(other)
#
# Equivalent to <tt>String#<=></tt>.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name <=> 'BlogPost' # => 0
# BlogPost.model_name <=> 'Blog' # => 1
# BlogPost.model_name <=> 'BlogPosts' # => -1
##
# :method: =~
#
# :call-seq:
# =~(regexp)
#
# Equivalent to <tt>String#=~</tt>. Match the class name against the given
# regexp. Returns the position where the match starts or +nil+ if there is
# no match.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name =~ /Post/ # => 4
# BlogPost.model_name =~ /\d/ # => nil
##
# :method: !~
#
# :call-seq:
# !~(regexp)
#
# Equivalent to <tt>String#!~</tt>. Match the class name against the given
# regexp. Returns +true+ if there is no match, otherwise +false+.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name !~ /Post/ # => false
# BlogPost.model_name !~ /\d/ # => true
##
# :method: eql?
#
# :call-seq:
# eql?(other)
#
# Equivalent to <tt>String#eql?</tt>. Returns +true+ if the class name and
# +other+ have the same length and content, otherwise +false+.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name.eql?('BlogPost') # => true
# BlogPost.model_name.eql?('Blog Post') # => false
##
# :method: match?
#
# :call-seq:
# match?(regexp)
#
# Equivalent to <tt>String#match?</tt>. Match the class name against the
# given regexp. Returns +true+ if there is a match, otherwise +false+.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name.match?(/Post/) # => true
# BlogPost.model_name.match?(/\d/) # => false
##
# :method: to_s
#
# :call-seq:
# to_s()
#
# Returns the class name.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name.to_s # => "BlogPost"
##
# :method: to_str
#
# :call-seq:
# to_str()
#
# Equivalent to +to_s+.
delegate :==, :===, :<=>, :=~, :"!~", :eql?, :match?, :to_s,
:to_str, :as_json, to: :name
# Returns a new ActiveModel::Name instance. By default, the +namespace+
# and +name+ option will take the namespace and name of the given class
# respectively.
# Use +locale+ argument for singularize and pluralize model name.
#
# module Foo
# class Bar
# end
# end
#
# ActiveModel::Name.new(Foo::Bar).to_s
# # => "Foo::Bar"
def initialize(klass, namespace = nil, name = nil, locale = :en)
@name = name || klass.name
raise ArgumentError, "Class name cannot be blank. You need to supply a name argument when anonymous class given" if @name.blank?
@unnamespaced = @name.delete_prefix("#{namespace.name}::") if namespace
@klass = klass
@singular = _singularize(@name)
@plural = ActiveSupport::Inflector.pluralize(@singular, locale)
@uncountable = @plural == @singular
@element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(@name))
@human = ActiveSupport::Inflector.humanize(@element)
@collection = ActiveSupport::Inflector.tableize(@name)
@param_key = (namespace ? _singularize(@unnamespaced) : @singular)
@i18n_key = @name.underscore.to_sym
@route_key = (namespace ? ActiveSupport::Inflector.pluralize(@param_key, locale) : @plural.dup)
@singular_route_key = ActiveSupport::Inflector.singularize(@route_key, locale)
@route_key << "_index" if @uncountable
end
# Transform the model name into a more human format, using I18n. By default,
# it will underscore then humanize the class name.
#
# class BlogPost
# extend ActiveModel::Naming
# end
#
# BlogPost.model_name.human # => "Blog post"
#
# Specify +options+ with additional translating options.
def human(options = {})
return @human if i18n_keys.empty? || i18n_scope.empty?
key, *defaults = i18n_keys
defaults << options[:default] if options[:default]
defaults << MISSING_TRANSLATION
translation = I18n.translate(key, scope: i18n_scope, count: 1, **options, default: defaults)
translation = @human if translation == MISSING_TRANSLATION
translation
end
def uncountable?
@uncountable
end
private
MISSING_TRANSLATION = Object.new # :nodoc:
def _singularize(string)
ActiveSupport::Inflector.underscore(string).tr("/", "_")
end
def i18n_keys
@i18n_keys ||= if @klass.respond_to?(:lookup_ancestors)
@klass.lookup_ancestors.map { |klass| klass.model_name.i18n_key }
else
[]
end
end
def i18n_scope
@i18n_scope ||= @klass.respond_to?(:i18n_scope) ? [@klass.i18n_scope, :models] : []
end
end
# == Active \Model \Naming
#
# Creates a +model_name+ method on your object.
#
# To implement, just extend ActiveModel::Naming in your object:
#
# class BookCover
# extend ActiveModel::Naming
# end
#
# BookCover.model_name.name # => "BookCover"
# BookCover.model_name.human # => "Book cover"
#
# BookCover.model_name.i18n_key # => :book_cover
# BookModule::BookCover.model_name.i18n_key # => :"book_module/book_cover"
#
# Providing the functionality that ActiveModel::Naming provides in your object
# is required to pass the \Active \Model Lint test. So either extending the
# provided method below, or rolling your own is required.
module Naming
def self.extended(base) # :nodoc:
base.silence_redefinition_of_method :model_name
base.delegate :model_name, to: :class
end
# Returns an ActiveModel::Name object for module. It can be
# used to retrieve all kinds of naming-related information
# (See ActiveModel::Name for more information).
#
# class Person
# extend ActiveModel::Naming
# end
#
# Person.model_name.name # => "Person"
# Person.model_name.class # => ActiveModel::Name
# Person.model_name.singular # => "person"
# Person.model_name.plural # => "people"
def model_name
@_model_name ||= begin
namespace = module_parents.detect do |n|
n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming?
end
ActiveModel::Name.new(self, namespace)
end
end
# Returns the plural class name of a record or class.
#
# ActiveModel::Naming.plural(post) # => "posts"
# ActiveModel::Naming.plural(Highrise::Person) # => "highrise_people"
def self.plural(record_or_class)
model_name_from_record_or_class(record_or_class).plural
end
# Returns the singular class name of a record or class.
#
# ActiveModel::Naming.singular(post) # => "post"
# ActiveModel::Naming.singular(Highrise::Person) # => "highrise_person"
def self.singular(record_or_class)
model_name_from_record_or_class(record_or_class).singular
end
# Identifies whether the class name of a record or class is uncountable.
#
# ActiveModel::Naming.uncountable?(Sheep) # => true
# ActiveModel::Naming.uncountable?(Post) # => false
def self.uncountable?(record_or_class)
model_name_from_record_or_class(record_or_class).uncountable?
end
# Returns string to use while generating route names. It differs for
# namespaced models regarding whether it's inside isolated engine.
#
# # For isolated engine:
# ActiveModel::Naming.singular_route_key(Blog::Post) # => "post"
#
# # For shared engine:
# ActiveModel::Naming.singular_route_key(Blog::Post) # => "blog_post"
def self.singular_route_key(record_or_class)
model_name_from_record_or_class(record_or_class).singular_route_key
end
# Returns string to use while generating route names. It differs for
# namespaced models regarding whether it's inside isolated engine.
#
# # For isolated engine:
# ActiveModel::Naming.route_key(Blog::Post) # => "posts"
#
# # For shared engine:
# ActiveModel::Naming.route_key(Blog::Post) # => "blog_posts"
#
# The route key also considers if the noun is uncountable and, in
# such cases, automatically appends _index.
def self.route_key(record_or_class)
model_name_from_record_or_class(record_or_class).route_key
end
# Returns string to use for params names. It differs for
# namespaced models regarding whether it's inside isolated engine.
#
# # For isolated engine:
# ActiveModel::Naming.param_key(Blog::Post) # => "post"
#
# # For shared engine:
# ActiveModel::Naming.param_key(Blog::Post) # => "blog_post"
def self.param_key(record_or_class)
model_name_from_record_or_class(record_or_class).param_key
end
def self.model_name_from_record_or_class(record_or_class) # :nodoc:
if record_or_class.respond_to?(:to_model)
record_or_class.to_model.model_name
else
record_or_class.model_name
end
end
private_class_method :model_name_from_record_or_class
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/contact"
require "models/sheep"
require "models/track_back"
require "models/blog_post"
class NamingTest < ActiveModel::TestCase
def setup
@model_name = ActiveModel::Name.new(Post::TrackBack)
end
def test_singular
assert_equal "post_track_back", @model_name.singular
end
def test_plural
assert_equal "post_track_backs", @model_name.plural
end
def test_element
assert_equal "track_back", @model_name.element
end
def test_collection
assert_equal "post/track_backs", @model_name.collection
end
def test_human
assert_equal "Track back", @model_name.human
end
def test_route_key
assert_equal "post_track_backs", @model_name.route_key
end
def test_param_key
assert_equal "post_track_back", @model_name.param_key
end
def test_i18n_key
assert_equal :"post/track_back", @model_name.i18n_key
end
def test_uncountable
assert_equal false, @model_name.uncountable?
end
end
class NamingWithNamespacedModelInIsolatedNamespaceTest < ActiveModel::TestCase
def setup
@model_name = ActiveModel::Name.new(Blog::Post, Blog)
end
def test_singular
assert_equal "blog_post", @model_name.singular
end
def test_plural
assert_equal "blog_posts", @model_name.plural
end
def test_element
assert_equal "post", @model_name.element
end
def test_collection
assert_equal "blog/posts", @model_name.collection
end
def test_human
assert_equal "Post", @model_name.human
end
def test_route_key
assert_equal "posts", @model_name.route_key
end
def test_param_key
assert_equal "post", @model_name.param_key
end
def test_i18n_key
assert_equal :"blog/post", @model_name.i18n_key
end
end
class NamingWithNamespacedModelInSharedNamespaceTest < ActiveModel::TestCase
def setup
@model_name = ActiveModel::Name.new(Blog::Post)
end
def test_singular
assert_equal "blog_post", @model_name.singular
end
def test_plural
assert_equal "blog_posts", @model_name.plural
end
def test_element
assert_equal "post", @model_name.element
end
def test_collection
assert_equal "blog/posts", @model_name.collection
end
def test_human
assert_equal "Post", @model_name.human
end
def test_route_key
assert_equal "blog_posts", @model_name.route_key
end
def test_param_key
assert_equal "blog_post", @model_name.param_key
end
def test_i18n_key
assert_equal :"blog/post", @model_name.i18n_key
end
end
class NamingWithSuppliedModelNameTest < ActiveModel::TestCase
def setup
@model_name = ActiveModel::Name.new(Blog::Post, nil, "Article")
end
def test_singular
assert_equal "article", @model_name.singular
end
def test_plural
assert_equal "articles", @model_name.plural
end
def test_element
assert_equal "article", @model_name.element
end
def test_collection
assert_equal "articles", @model_name.collection
end
def test_human
assert_equal "Article", @model_name.human
end
def test_route_key
assert_equal "articles", @model_name.route_key
end
def test_param_key
assert_equal "article", @model_name.param_key
end
def test_i18n_key
assert_equal :"article", @model_name.i18n_key
end
end
class NamingWithSuppliedLocaleTest < ActiveModel::TestCase
def setup
ActiveSupport::Inflector.inflections(:cs) do |inflect|
inflect.plural(/(e)l$/i, '\1lé')
end
@model_name = ActiveModel::Name.new(Blog::Post, nil, "Uzivatel", :cs)
end
def test_singular
assert_equal "uzivatel", @model_name.singular
end
def test_plural
assert_equal "uzivatelé", @model_name.plural
end
end
class NamingUsingRelativeModelNameTest < ActiveModel::TestCase
def setup
@model_name = Blog::Post.model_name
end
def test_singular
assert_equal "blog_post", @model_name.singular
end
def test_plural
assert_equal "blog_posts", @model_name.plural
end
def test_element
assert_equal "post", @model_name.element
end
def test_collection
assert_equal "blog/posts", @model_name.collection
end
def test_human
assert_equal "Post", @model_name.human
end
def test_route_key
assert_equal "posts", @model_name.route_key
end
def test_param_key
assert_equal "post", @model_name.param_key
end
def test_i18n_key
assert_equal :"blog/post", @model_name.i18n_key
end
end
class NamingHelpersTest < ActiveModel::TestCase
def setup
@klass = Contact
@record = @klass.new
@singular = "contact"
@plural = "contacts"
@uncountable = Sheep
@singular_route_key = "contact"
@route_key = "contacts"
@param_key = "contact"
end
def test_to_model_called_on_record
assert_equal "post_named_track_backs", plural(Post::TrackBack.new)
end
def test_singular
assert_equal @singular, singular(@record)
end
def test_singular_for_class
assert_equal @singular, singular(@klass)
end
def test_plural
assert_equal @plural, plural(@record)
end
def test_plural_for_class
assert_equal @plural, plural(@klass)
end
def test_route_key
assert_equal @route_key, route_key(@record)
assert_equal @singular_route_key, singular_route_key(@record)
end
def test_route_key_for_class
assert_equal @route_key, route_key(@klass)
assert_equal @singular_route_key, singular_route_key(@klass)
end
def test_param_key
assert_equal @param_key, param_key(@record)
end
def test_param_key_for_class
assert_equal @param_key, param_key(@klass)
end
def test_uncountable
assert uncountable?(@uncountable), "Expected 'sheep' to be uncountable"
assert_not uncountable?(@klass), "Expected 'contact' to be countable"
end
def test_uncountable_route_key
assert_equal "sheep", singular_route_key(@uncountable)
assert_equal "sheep_index", route_key(@uncountable)
end
private
def method_missing(method, *args)
ActiveModel::Naming.public_send(method, *args)
end
end
class NameWithAnonymousClassTest < ActiveModel::TestCase
def test_anonymous_class_without_name_argument
assert_raises(ArgumentError) do
ActiveModel::Name.new(Class.new)
end
end
def test_anonymous_class_with_name_argument
model_name = ActiveModel::Name.new(Class.new, nil, "Anonymous")
assert_equal "Anonymous", model_name
end
end
class NamingMethodDelegationTest < ActiveModel::TestCase
def test_model_name
assert_equal Blog::Post.model_name, Blog::Post.new.model_name
end
end
class OverridingAccessorsTest < ActiveModel::TestCase
def test_overriding_accessors_keys
model_name = ActiveModel::Name.new(Post::TrackBack).tap do |name|
name.singular = :singular
name.plural = :plural
name.element = :element
name.collection = :collection
name.singular_route_key = :singular_route_key
name.route_key = :route_key
name.param_key = :param_key
name.i18n_key = :i18n_key
name.name = :name
end
assert_equal :singular, model_name.singular
assert_equal :plural, model_name.plural
assert_equal :element, model_name.element
assert_equal :collection, model_name.collection
assert_equal :singular_route_key, model_name.singular_route_key
assert_equal :route_key, model_name.route_key
assert_equal :param_key, model_name.param_key
assert_equal :i18n_key, model_name.i18n_key
assert_equal :name, model_name.name
end
end
# frozen_string_literal: true
require "active_model/error"
require "forwardable"
module ActiveModel
class NestedError < Error
def initialize(base, inner_error, override_options = {})
@base = base
@inner_error = inner_error
@attribute = override_options.fetch(:attribute) { inner_error.attribute }
@type = override_options.fetch(:type) { inner_error.type }
@raw_type = inner_error.raw_type
@options = inner_error.options
end
attr_reader :inner_error
extend Forwardable
def_delegators :@inner_error, :message
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_model/nested_error"
require "models/topic"
require "models/reply"
class NestedErrorTest < ActiveModel::TestCase
def test_initialize
topic = Topic.new
inner_error = ActiveModel::Error.new(topic, :title, :not_enough, count: 2)
reply = Reply.new
error = ActiveModel::NestedError.new(reply, inner_error)
assert_equal reply, error.base
assert_equal inner_error.attribute, error.attribute
assert_equal inner_error.type, error.type
assert_equal(inner_error.options, error.options)
end
test "initialize with overriding attribute and type" do
topic = Topic.new
inner_error = ActiveModel::Error.new(topic, :title, :not_enough, count: 2)
reply = Reply.new
error = ActiveModel::NestedError.new(reply, inner_error, attribute: :parent, type: :foo)
assert_equal reply, error.base
assert_equal :parent, error.attribute
assert_equal :foo, error.type
assert_equal(inner_error.options, error.options)
end
def test_message
topic = Topic.new(author_name: "Bruce")
inner_error = ActiveModel::Error.new(topic, :title, :not_enough, message: Proc.new { |model, options|
"not good enough for #{model.author_name}"
})
reply = Reply.new(author_name: "Mark")
error = ActiveModel::NestedError.new(reply, inner_error)
assert_equal "not good enough for Bruce", error.message
end
def test_full_message
topic = Topic.new(author_name: "Bruce")
inner_error = ActiveModel::Error.new(topic, :title, :not_enough, message: Proc.new { |model, options|
"not good enough for #{model.author_name}"
})
reply = Reply.new(author_name: "Mark")
error = ActiveModel::NestedError.new(reply, inner_error)
assert_equal "Title not good enough for Bruce", error.full_message
end
end
# frozen_string_literal: true
module ActiveModel
module Type
module Helpers # :nodoc: all
module Numeric
def serialize(value)
cast(value)
end
def cast(value)
# Checks whether the value is numeric. Spaceship operator
# will return nil if value is not numeric.
value = if value <=> 0
value
else
case value
when true then 1
when false then 0
else value.presence
end
end
super(value)
end
def changed?(old_value, _new_value, new_value_before_type_cast) # :nodoc:
(super || number_to_non_number?(old_value, new_value_before_type_cast)) &&
!equal_nan?(old_value, new_value_before_type_cast)
end
private
def equal_nan?(old_value, new_value)
(old_value.is_a?(::Float) || old_value.is_a?(BigDecimal)) &&
old_value.nan? &&
old_value.instance_of?(new_value.class) &&
new_value.nan?
end
def number_to_non_number?(old_value, new_value_before_type_cast)
old_value != nil && non_numeric_string?(new_value_before_type_cast.to_s)
end
def non_numeric_string?(value)
# 'wibble'.to_i will give zero, we want to make sure
# that we aren't marking int zero to string zero as
# changed.
!NUMERIC_REGEX.match?(value)
end
NUMERIC_REGEX = /\A\s*[+-]?\d/
private_constant :NUMERIC_REGEX
end
end
end
end
# frozen_string_literal: true
require "active_model/validations/comparability"
require "bigdecimal/util"
module ActiveModel
module Validations
class NumericalityValidator < EachValidator # :nodoc:
include Comparability
RANGE_CHECKS = { in: :in? }
NUMBER_CHECKS = { odd: :odd?, even: :even? }
RESERVED_OPTIONS = COMPARE_CHECKS.keys + NUMBER_CHECKS.keys + RANGE_CHECKS.keys + [:only_integer, :only_numeric]
INTEGER_REGEX = /\A[+-]?\d+\z/
HEXADECIMAL_REGEX = /\A[+-]?0[xX]/
def check_validity!
options.slice(*COMPARE_CHECKS.keys).each do |option, value|
unless value.is_a?(Numeric) || value.is_a?(Proc) || value.is_a?(Symbol)
raise ArgumentError, ":#{option} must be a number, a symbol or a proc"
end
end
options.slice(*RANGE_CHECKS.keys).each do |option, value|
unless value.is_a?(Range)
raise ArgumentError, ":#{option} must be a range"
end
end
end
def validate_each(record, attr_name, value, precision: Float::DIG, scale: nil)
unless is_number?(value, precision, scale)
record.errors.add(attr_name, :not_a_number, **filtered_options(value))
return
end
if allow_only_integer?(record) && !is_integer?(value)
record.errors.add(attr_name, :not_an_integer, **filtered_options(value))
return
end
value = parse_as_number(value, precision, scale)
options.slice(*RESERVED_OPTIONS).each do |option, option_value|
if NUMBER_CHECKS.include?(option)
unless value.to_i.public_send(NUMBER_CHECKS[option])
record.errors.add(attr_name, option, **filtered_options(value))
end
elsif RANGE_CHECKS.include?(option)
unless value.public_send(RANGE_CHECKS[option], option_value)
record.errors.add(attr_name, option, **filtered_options(value).merge!(count: option_value))
end
elsif COMPARE_CHECKS.include?(option)
option_value = option_as_number(record, option_value, precision, scale)
unless value.public_send(COMPARE_CHECKS[option], option_value)
record.errors.add(attr_name, option, **filtered_options(value).merge!(count: option_value))
end
end
end
end
private
def option_as_number(record, option_value, precision, scale)
parse_as_number(option_value(record, option_value), precision, scale)
end
def parse_as_number(raw_value, precision, scale)
if raw_value.is_a?(Float)
parse_float(raw_value, precision, scale)
elsif raw_value.is_a?(BigDecimal)
round(raw_value, scale)
elsif raw_value.is_a?(Numeric)
raw_value
elsif is_integer?(raw_value)
raw_value.to_i
elsif !is_hexadecimal_literal?(raw_value)
parse_float(Kernel.Float(raw_value), precision, scale)
end
end
def parse_float(raw_value, precision, scale)
round(raw_value, scale).to_d(precision)
end
def round(raw_value, scale)
scale ? raw_value.round(scale) : raw_value
end
def is_number?(raw_value, precision, scale)
if options[:only_numeric] && !raw_value.is_a?(Numeric)
return false
end
!parse_as_number(raw_value, precision, scale).nil?
rescue ArgumentError, TypeError
false
end
def is_integer?(raw_value)
INTEGER_REGEX.match?(raw_value.to_s)
end
def is_hexadecimal_literal?(raw_value)
HEXADECIMAL_REGEX.match?(raw_value.to_s)
end
def filtered_options(value)
filtered = options.except(*RESERVED_OPTIONS)
filtered[:value] = value
filtered
end
def allow_only_integer?(record)
case options[:only_integer]
when Symbol
record.send(options[:only_integer])
when Proc
options[:only_integer].call(record)
else
options[:only_integer]
end
end
def prepare_value_for_validation(value, record, attr_name)
return value if record_attribute_changed_in_place?(record, attr_name)
came_from_user = :"#{attr_name}_came_from_user?"
if record.respond_to?(came_from_user)
if record.public_send(came_from_user)
raw_value = record.public_send(:"#{attr_name}_before_type_cast")
elsif record.respond_to?(:read_attribute)
raw_value = record.read_attribute(attr_name)
end
else
before_type_cast = :"#{attr_name}_before_type_cast"
if record.respond_to?(before_type_cast)
raw_value = record.public_send(before_type_cast)
end
end
raw_value || value
end
def record_attribute_changed_in_place?(record, attr_name)
record.respond_to?(:attribute_changed_in_place?) &&
record.attribute_changed_in_place?(attr_name.to_s)
end
end
module HelperMethods
# Validates whether the value of the specified attribute is numeric by
# trying to convert it to a float with Kernel.Float (if <tt>only_integer</tt>
# is +false+) or applying it to the regular expression <tt>/\A[\+\-]?\d+\z/</tt>
# (if <tt>only_integer</tt> is set to +true+). Precision of Kernel.Float values
# are guaranteed up to 15 digits.
#
# class Person < ActiveRecord::Base
# validates_numericality_of :value, on: :create
# end
#
# Configuration options:
# * <tt>:message</tt> - A custom error message (default is: "is not a number").
# * <tt>:only_integer</tt> - Specifies whether the value has to be an
# integer (default is +false+).
# * <tt>:only_numeric</tt> - Specifies whether the value has to be an
# instance of Numeric (default is +false+). The default behavior is to
# attempt parsing the value if it is a String.
# * <tt>:allow_nil</tt> - Skip validation if attribute is +nil+ (default is
# +false+). Notice that for Integer and Float columns empty strings are
# converted to +nil+.
# * <tt>:greater_than</tt> - Specifies the value must be greater than the
# supplied value.
# * <tt>:greater_than_or_equal_to</tt> - Specifies the value must be
# greater than or equal the supplied value.
# * <tt>:equal_to</tt> - Specifies the value must be equal to the supplied
# value.
# * <tt>:less_than</tt> - Specifies the value must be less than the
# supplied value.
# * <tt>:less_than_or_equal_to</tt> - Specifies the value must be less
# than or equal the supplied value.
# * <tt>:other_than</tt> - Specifies the value must be other than the
# supplied value.
# * <tt>:odd</tt> - Specifies the value must be an odd number.
# * <tt>:even</tt> - Specifies the value must be an even number.
# * <tt>:in</tt> - Check that the value is within a range.
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+ .
# See ActiveModel::Validations::ClassMethods#validates for more information.
#
# The following checks can also be supplied with a proc or a symbol which
# corresponds to a method:
#
# * <tt>:greater_than</tt>
# * <tt>:greater_than_or_equal_to</tt>
# * <tt>:equal_to</tt>
# * <tt>:less_than</tt>
# * <tt>:less_than_or_equal_to</tt>
# * <tt>:only_integer</tt>
# * <tt>:other_than</tt>
#
# For example:
#
# class Person < ActiveRecord::Base
# validates_numericality_of :width, less_than: ->(person) { person.height }
# validates_numericality_of :width, greater_than: :minimum_weight
# end
def validates_numericality_of(*attr_names)
validates_with NumericalityValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
require "bigdecimal"
require "active_support/core_ext/big_decimal"
require "active_support/core_ext/object/inclusion"
class NumericalityValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
NIL = [nil]
BLANK = ["", " ", " \t \r \n"]
BIGDECIMAL_STRINGS = %w(12345678901234567890.1234567890) # 30 significant digits
FLOAT_STRINGS = %w(0.0 +0.0 -0.0 10.0 10.5 -10.5 -0.0001 -090.1 90.1e1 -90.1e5 -90.1e-5 90e-5)
INTEGER_STRINGS = %w(0 +0 -0 10 +10 -10 0090 -090)
NUMERIC_FLOATS = [0.0, 10.0, 10.5, -10.5, -0.0001]
NUMERIC_INTEGERS = [0, 10, -10]
FLOATS = NUMERIC_FLOATS + FLOAT_STRINGS
INTEGERS = NUMERIC_INTEGERS + INTEGER_STRINGS
BIGDECIMAL = BIGDECIMAL_STRINGS.collect! { |bd| BigDecimal(bd) }
JUNK = ["not a number", "42 not a number", "0xdeadbeef", "-0xdeadbeef", "+0xdeadbeef", "0xinvalidhex", "0Xdeadbeef", "00-1", "--3", "+-3", "+3-1", "-+019.0", "12.12.13.12", "123\nnot a number"]
INFINITY = [1.0 / 0.0]
def test_default_validates_numericality_of
Topic.validates_numericality_of :approved
assert_invalid_values(NIL + BLANK + JUNK)
assert_valid_values(FLOATS + INTEGERS + BIGDECIMAL + INFINITY)
end
def test_validates_numericality_of_with_nil_allowed
Topic.validates_numericality_of :approved, allow_nil: true
assert_invalid_values(JUNK + BLANK)
assert_valid_values(NIL + FLOATS + INTEGERS + BIGDECIMAL + INFINITY)
end
def test_validates_numericality_of_with_blank_allowed
Topic.validates_numericality_of :approved, allow_blank: true
assert_invalid_values(JUNK)
assert_valid_values(NIL + BLANK + FLOATS + INTEGERS + BIGDECIMAL + INFINITY)
end
def test_validates_numericality_of_with_integer_only
Topic.validates_numericality_of :approved, only_integer: true
assert_invalid_values(NIL + BLANK + JUNK + FLOATS + BIGDECIMAL + INFINITY)
assert_valid_values(INTEGERS)
end
def test_validates_numericality_of_with_integer_only_and_nil_allowed
Topic.validates_numericality_of :approved, only_integer: true, allow_nil: true
assert_invalid_values(JUNK + BLANK + FLOATS + BIGDECIMAL + INFINITY)
assert_valid_values(NIL + INTEGERS)
end
def test_validates_numericality_of_with_integer_only_and_symbol_as_value
Topic.validates_numericality_of :approved, only_integer: :condition_is_false
assert_invalid_values(NIL + BLANK + JUNK)
assert_valid_values(FLOATS + INTEGERS + BIGDECIMAL + INFINITY)
end
def test_validates_numericality_of_with_integer_only_and_proc_as_value
Topic.define_method(:allow_only_integers?) { false }
Topic.validates_numericality_of :approved, only_integer: Proc.new(&:allow_only_integers?)
assert_invalid_values(NIL + BLANK + JUNK)
assert_valid_values(FLOATS + INTEGERS + BIGDECIMAL + INFINITY)
end
def test_validates_numericality_of_with_numeric_only
Topic.validates_numericality_of :approved, only_numeric: true
assert_invalid_values(NIL + BLANK + JUNK + FLOAT_STRINGS + INTEGER_STRINGS)
assert_valid_values(NUMERIC_FLOATS + NUMERIC_INTEGERS + BIGDECIMAL + INFINITY)
end
def test_validates_numericality_of_with_numeric_only_and_nil_allowed
Topic.validates_numericality_of :approved, only_numeric: true, allow_nil: true
assert_invalid_values(JUNK + BLANK + FLOAT_STRINGS + INTEGER_STRINGS)
assert_valid_values(NIL + NUMERIC_FLOATS + NUMERIC_INTEGERS + BIGDECIMAL + INFINITY)
end
def test_validates_numericality_with_greater_than
Topic.validates_numericality_of :approved, greater_than: 10
assert_invalid_values([-10, 10], "must be greater than 10")
assert_valid_values([11])
end
def test_validates_numericality_with_greater_than_using_differing_numeric_types
Topic.validates_numericality_of :approved, greater_than: BigDecimal("97.18")
assert_invalid_values([-97.18, BigDecimal("97.18"), BigDecimal("-97.18")], "must be greater than 97.18")
assert_valid_values([97.19, 98, BigDecimal("98"), BigDecimal("97.19")])
end
def test_validates_numericality_with_greater_than_using_string_value
Topic.validates_numericality_of :approved, greater_than: 10
assert_invalid_values(["-10", "9", "9.9", "10"], "must be greater than 10")
assert_valid_values(["10.1", "11"])
end
def test_validates_numericality_with_greater_than_or_equal
Topic.validates_numericality_of :approved, greater_than_or_equal_to: 10
assert_invalid_values([-9, 9], "must be greater than or equal to 10")
assert_valid_values([10])
end
def test_validates_numericality_with_greater_than_or_equal_using_differing_numeric_types
Topic.validates_numericality_of :approved, greater_than_or_equal_to: BigDecimal("97.18")
assert_invalid_values([-97.18, 97.17, 97, BigDecimal("97.17"), BigDecimal("-97.18")], "must be greater than or equal to 97.18")
assert_valid_values([97.18, 98, BigDecimal("97.19")])
end
def test_validates_numericality_with_greater_than_or_equal_using_string_value
Topic.validates_numericality_of :approved, greater_than_or_equal_to: 10
assert_invalid_values(["-10", "9", "9.9"], "must be greater than or equal to 10")
assert_valid_values(["10", "10.1", "11"])
end
def test_validates_numericality_with_equal_to
Topic.validates_numericality_of :approved, equal_to: 10
assert_invalid_values([-10, 11] + INFINITY, "must be equal to 10")
assert_valid_values([10])
end
def test_validates_numericality_with_equal_to_using_differing_numeric_types
Topic.validates_numericality_of :approved, equal_to: BigDecimal("97.18")
assert_invalid_values([-97.18], "must be equal to 97.18")
assert_valid_values([BigDecimal("97.18")])
end
def test_validates_numericality_with_equal_to_using_string_value
Topic.validates_numericality_of :approved, equal_to: 10
assert_invalid_values(["-10", "9", "9.9", "10.1", "11"], "must be equal to 10")
assert_valid_values(["10"])
end
def test_validates_numericality_with_less_than
Topic.validates_numericality_of :approved, less_than: 10
assert_invalid_values([10], "must be less than 10")
assert_valid_values([-9, 9])
end
def test_validates_numericality_with_less_than_using_differing_numeric_types
Topic.validates_numericality_of :approved, less_than: BigDecimal("97.18")
assert_invalid_values([97.18, BigDecimal("97.18")], "must be less than 97.18")
assert_valid_values([-97.0, 97.0, -97, 97, BigDecimal("-97"), BigDecimal("97")])
end
def test_validates_numericality_with_less_than_using_string_value
Topic.validates_numericality_of :approved, less_than: 10
assert_invalid_values(["10", "10.1", "11"], "must be less than 10")
assert_valid_values(["-10", "9", "9.9"])
end
def test_validates_numericality_with_less_than_or_equal_to
Topic.validates_numericality_of :approved, less_than_or_equal_to: 10
assert_invalid_values([11], "must be less than or equal to 10")
assert_valid_values([-10, 10])
end
def test_validates_numericality_with_less_than_or_equal_to_using_differing_numeric_types
Topic.validates_numericality_of :approved, less_than_or_equal_to: BigDecimal("97.18")
assert_invalid_values([97.19, 98], "must be less than or equal to 97.18")
assert_valid_values([-97.18, BigDecimal("-97.18"), BigDecimal("97.18")])
end
def test_validates_numericality_with_less_than_or_equal_using_string_value
Topic.validates_numericality_of :approved, less_than_or_equal_to: 10
assert_invalid_values(["10.1", "11"], "must be less than or equal to 10")
assert_valid_values(["-10", "9", "9.9", "10"])
end
def test_validates_numericality_with_odd
Topic.validates_numericality_of :approved, odd: true
assert_invalid_values([-2, 2], "must be odd")
assert_valid_values([-1, 1])
end
def test_validates_numericality_with_even
Topic.validates_numericality_of :approved, even: true
assert_invalid_values([-1, 1], "must be even")
assert_valid_values([-2, 2])
end
def test_validates_numericality_with_greater_than_less_than_and_even
Topic.validates_numericality_of :approved, greater_than: 1, less_than: 4, even: true
assert_invalid_values([1, 3, 4])
assert_valid_values([2])
end
def test_validates_numericality_with_other_than
Topic.validates_numericality_of :approved, other_than: 0
assert_invalid_values([0, 0.0])
assert_valid_values([-1, 42])
end
def test_validates_numericality_with_in
Topic.validates_numericality_of :approved, in: 1..3
assert_invalid_values([0, 4])
assert_valid_values([1, 2, 3])
end
def test_validates_numericality_with_other_than_using_string_value
Topic.validates_numericality_of :approved, other_than: 0
assert_invalid_values(["0", "0.0"])
assert_valid_values(["-1", "1.1", "42"])
end
def test_validates_numericality_with_proc
Topic.define_method(:min_approved) { 5 }
Topic.validates_numericality_of :approved, greater_than_or_equal_to: Proc.new(&:min_approved)
assert_invalid_values([3, 4], "must be greater than or equal to 5")
assert_valid_values([5, 6])
ensure
Topic.remove_method :min_approved
end
def test_validates_numericality_with_symbol
Topic.define_method(:max_approved) { 5 }
Topic.validates_numericality_of :approved, less_than_or_equal_to: :max_approved
assert_invalid_values([6], "must be less than or equal to 5")
assert_valid_values([4, 5])
ensure
Topic.remove_method :max_approved
end
def test_validates_numericality_with_numeric_message
Topic.validates_numericality_of :approved, less_than: 4, message: "smaller than %{count}"
topic = Topic.new("title" => "numeric test", "approved" => 10)
assert_not_predicate topic, :valid?
assert_equal ["smaller than 4"], topic.errors[:approved]
Topic.validates_numericality_of :approved, greater_than: 4, message: "greater than %{count}"
topic = Topic.new("title" => "numeric test", "approved" => 1)
assert_not_predicate topic, :valid?
assert_equal ["greater than 4"], topic.errors[:approved]
end
def test_validates_numericality_of_for_ruby_class
Person.validates_numericality_of :karma, allow_nil: false
p = Person.new
p.karma = "Pix"
assert_predicate p, :invalid?
assert_equal ["is not a number"], p.errors[:karma]
p.karma = "1234"
assert_predicate p, :valid?
ensure
Person.clear_validators!
end
def test_validates_numericality_using_value_before_type_cast_if_possible
Topic.validates_numericality_of :price
topic = Topic.new(price: 50)
assert_equal "$50.00", topic.price
assert_equal 50, topic.price_before_type_cast
assert_predicate topic, :valid?
end
def test_validates_numericality_with_exponent_number
base = 10_000_000_000_000_000
Topic.validates_numericality_of :approved, less_than_or_equal_to: base
topic = Topic.new
topic.approved = (base + 1).to_s
assert_predicate topic, :invalid?
end
def test_validates_numericality_with_object_acting_as_numeric
klass = Class.new do
def to_f
123.54
end
end
Topic.validates_numericality_of :price
topic = Topic.new(price: klass.new)
assert_predicate topic, :valid?
end
def test_validates_numericality_with_invalid_args
assert_raise(ArgumentError) { Topic.validates_numericality_of :approved, greater_than_or_equal_to: "foo" }
assert_raise(ArgumentError) { Topic.validates_numericality_of :approved, less_than_or_equal_to: "foo" }
assert_raise(ArgumentError) { Topic.validates_numericality_of :approved, greater_than: "foo" }
assert_raise(ArgumentError) { Topic.validates_numericality_of :approved, less_than: "foo" }
assert_raise(ArgumentError) { Topic.validates_numericality_of :approved, equal_to: "foo" }
assert_raise(ArgumentError) { Topic.validates_numericality_of :approved, in: "foo" }
end
def test_validates_numericality_equality_for_float_and_big_decimal
Topic.validates_numericality_of :approved, equal_to: BigDecimal("65.6")
assert_invalid_values([Float("65.5"), BigDecimal("65.7")], "must be equal to 65.6")
assert_valid_values([Float("65.6"), BigDecimal("65.6")])
end
private
def assert_invalid_values(values, error = nil)
with_each_topic_approved_value(values) do |topic, value|
assert topic.invalid?, "#{value.inspect} not rejected as a number"
assert topic.errors[:approved].any?, "FAILED for #{value.inspect}"
assert_equal error, topic.errors[:approved].first if error
end
end
def assert_valid_values(values)
with_each_topic_approved_value(values) do |topic, value|
assert topic.valid?, "#{value.inspect} not accepted as a number with validation error: #{topic.errors[:approved].first}"
end
end
def with_each_topic_approved_value(values)
topic = Topic.new(title: "numeric test", content: "whatever")
values.each do |value|
topic.approved = value
yield topic, value
end
end
end
# frozen_string_literal: true
class Person
include ActiveModel::Validations
extend ActiveModel::Translation
attr_accessor :title, :karma, :salary, :gender
def condition_is_true
true
end
def condition_is_false
false
end
end
class Person::Gender
extend ActiveModel::Translation
end
class Child < Person
end
# frozen_string_literal: true
class PersonWithValidator
include ActiveModel::Validations
class PresenceValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
record.errors.add(attribute, message: "Local validator#{options[:custom]}") if value.blank?
end
end
class LikeValidator < ActiveModel::EachValidator
def initialize(options)
@with = options[:with]
super
end
def validate_each(record, attribute, value)
unless value[@with]
record.errors.add attribute, "does not appear to be like #{@with}"
end
end
end
attr_accessor :title, :karma
end
# frozen_string_literal: true
module ActiveModel
module Validations
class PresenceValidator < EachValidator # :nodoc:
def validate_each(record, attr_name, value)
record.errors.add(attr_name, :blank, **options) if value.blank?
end
end
module HelperMethods
# Validates that the specified attributes are not blank (as defined by
# Object#blank?). Happens by default on save.
#
# class Person < ActiveRecord::Base
# validates_presence_of :first_name
# end
#
# The first_name attribute must be in the object and it cannot be blank.
#
# If you want to validate the presence of a boolean field (where the real
# values are +true+ and +false+), you will want to use
# <tt>validates_inclusion_of :field_name, in: [true, false]</tt>.
#
# This is due to the way Object#blank? handles boolean values:
# <tt>false.blank? # => true</tt>.
#
# Configuration options:
# * <tt>:message</tt> - A custom error message (default is: "can't be blank").
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
# See ActiveModel::Validations::ClassMethods#validates for more information.
def validates_presence_of(*attr_names)
validates_with PresenceValidator, _merge_attributes(attr_names)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
require "models/custom_reader"
class PresenceValidationTest < ActiveModel::TestCase
teardown do
Topic.clear_validators!
Person.clear_validators!
CustomReader.clear_validators!
end
def test_validate_presences
Topic.validates_presence_of(:title, :content)
t = Topic.new
assert_predicate t, :invalid?
assert_equal ["can't be blank"], t.errors[:title]
assert_equal ["can't be blank"], t.errors[:content]
t.title = "something"
t.content = " "
assert_predicate t, :invalid?
assert_equal ["can't be blank"], t.errors[:content]
t.content = "like stuff"
assert_predicate t, :valid?
end
def test_accepts_array_arguments
Topic.validates_presence_of %w(title content)
t = Topic.new
assert_predicate t, :invalid?
assert_equal ["can't be blank"], t.errors[:title]
assert_equal ["can't be blank"], t.errors[:content]
end
def test_validates_acceptance_of_with_custom_error_using_quotes
Person.validates_presence_of :karma, message: "This string contains 'single' and \"double\" quotes"
p = Person.new
assert_predicate p, :invalid?
assert_equal "This string contains 'single' and \"double\" quotes", p.errors[:karma].last
end
def test_validates_presence_of_for_ruby_class
Person.validates_presence_of :karma
p = Person.new
assert_predicate p, :invalid?
assert_equal ["can't be blank"], p.errors[:karma]
p.karma = "Cold"
assert_predicate p, :valid?
end
def test_validates_presence_of_for_ruby_class_with_custom_reader
CustomReader.validates_presence_of :karma
p = CustomReader.new
assert_predicate p, :invalid?
assert_equal ["can't be blank"], p.errors[:karma]
p[:karma] = "Cold"
assert_predicate p, :valid?
end
def test_validates_presence_of_with_allow_nil_option
Topic.validates_presence_of(:title, allow_nil: true)
t = Topic.new(title: "something")
assert t.valid?, t.errors.full_messages
t.title = ""
assert_predicate t, :invalid?
assert_equal ["can't be blank"], t.errors[:title]
t.title = " "
assert t.invalid?, t.errors.full_messages
assert_equal ["can't be blank"], t.errors[:title]
t.title = nil
assert t.valid?, t.errors.full_messages
end
def test_validates_presence_of_with_allow_blank_option
Topic.validates_presence_of(:title, allow_blank: true)
t = Topic.new(title: "something")
assert t.valid?, t.errors.full_messages
t.title = ""
assert t.valid?, t.errors.full_messages
t.title = " "
assert t.valid?, t.errors.full_messages
t.title = nil
assert t.valid?, t.errors.full_messages
end
end
# frozen_string_literal: true
require "active_model"
require "rails"
module ActiveModel
class Railtie < Rails::Railtie # :nodoc:
config.eager_load_namespaces << ActiveModel
config.active_model = ActiveSupport::OrderedOptions.new
initializer "active_model.secure_password" do
ActiveModel::SecurePassword.min_cost = Rails.env.test?
end
initializer "active_model.i18n_customize_full_message" do
ActiveModel::Error.i18n_customize_full_message = config.active_model.delete(:i18n_customize_full_message) || false
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_support/testing/isolation"
class RailtieTest < ActiveModel::TestCase
include ActiveSupport::Testing::Isolation
def setup
require "active_model/railtie"
# Set a fake logger to avoid creating the log directory automatically
fake_logger = Logger.new(nil)
@app ||= Class.new(::Rails::Application) do
config.eager_load = false
config.logger = fake_logger
end
end
test "secure password min_cost is false in the development environment" do
Rails.env = "development"
@app.initialize!
assert_equal false, ActiveModel::SecurePassword.min_cost
end
test "secure password min_cost is true in the test environment" do
Rails.env = "test"
@app.initialize!
assert_equal true, ActiveModel::SecurePassword.min_cost
end
test "i18n customize full message defaults to false" do
@app.initialize!
assert_equal false, ActiveModel::Error.i18n_customize_full_message
end
test "i18n customize full message can be disabled" do
@app.config.active_model.i18n_customize_full_message = false
@app.initialize!
assert_equal false, ActiveModel::Error.i18n_customize_full_message
end
test "i18n customize full message can be enabled" do
@app.config.active_model.i18n_customize_full_message = true
@app.initialize!
assert_equal true, ActiveModel::Error.i18n_customize_full_message
end
end
# frozen_string_literal: true
module ActiveModel
module Type
class Registry # :nodoc:
def initialize
@registrations = {}
end
def initialize_copy(other)
@registrations = @registrations.dup
super
end
def register(type_name, klass = nil, &block)
unless block_given?
block = proc { |_, *args| klass.new(*args) }
block.ruby2_keywords if block.respond_to?(:ruby2_keywords)
end
registrations[type_name] = block
end
def lookup(symbol, *args)
registration = registrations[symbol]
if registration
registration.call(symbol, *args)
else
raise ArgumentError, "Unknown type #{symbol.inspect}"
end
end
ruby2_keywords(:lookup)
private
attr_reader :registrations
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class RegistryTest < ActiveModel::TestCase
test "a class can be registered for a symbol" do
registry = Type::Registry.new
registry.register(:foo, ::String)
registry.register(:bar, ::Array)
assert_equal "", registry.lookup(:foo)
assert_equal [], registry.lookup(:bar)
assert_equal [:a, :a], registry.lookup(:bar, 2, :a) # Array.new(2, :a)
assert_equal [{}, {}], registry.lookup(:bar, 2, {}) # Array.new(2, {})
end
test "a block can be registered" do
registry = Type::Registry.new
registry.register(:foo) do |type, *args|
[type, args, "block for foo"]
end
registry.register(:bar) do |type, *args|
[type, args, "block for bar"]
end
registry.register(:baz) do |type, **kwargs|
[type, kwargs, "block for baz"]
end
assert_equal [:foo, [1], "block for foo"], registry.lookup(:foo, 1)
assert_equal [:foo, [2], "block for foo"], registry.lookup(:foo, 2)
assert_equal [:bar, [1, 2, 3], "block for bar"], registry.lookup(:bar, 1, 2, 3)
assert_equal [:baz, { kw: 1 }, "block for baz"], registry.lookup(:baz, kw: 1)
end
test "a reasonable error is given when no type is found" do
registry = Type::Registry.new
e = assert_raises(ArgumentError) do
registry.lookup(:foo)
end
assert_equal "Unknown type :foo", e.message
end
end
end
end
# frozen_string_literal: true
require "models/topic"
class Reply < Topic
validate :errors_on_empty_content
validate :title_is_wrong_create, on: :create
validate :check_empty_title
validate :check_content_mismatch, on: :create
validate :check_wrong_update, on: :update
def check_empty_title
errors.add(:title, "is Empty") unless title && title.size > 0
end
def errors_on_empty_content
errors.add(:content, "is Empty") unless content && content.size > 0
end
def check_content_mismatch
if title && content && content == "Mismatch"
errors.add(:title, "is Content Mismatch")
end
end
def title_is_wrong_create
errors.add(:title, "is Wrong Create") if title && title == "Wrong Create"
end
def check_wrong_update
errors.add(:title, "is Wrong Update") if title && title == "Wrong Update"
end
end
# frozen_string_literal: true
module ActiveModel
module SecurePassword
extend ActiveSupport::Concern
# BCrypt hash function can handle maximum 72 bytes, and if we pass
# password of length more than 72 bytes it ignores extra characters.
# Hence need to put a restriction on password length.
MAX_PASSWORD_LENGTH_ALLOWED = 72
class << self
attr_accessor :min_cost # :nodoc:
end
self.min_cost = false
module ClassMethods
# Adds methods to set and authenticate against a BCrypt password.
# This mechanism requires you to have a +XXX_digest+ attribute.
# Where +XXX+ is the attribute name of your desired password.
#
# The following validations are added automatically:
# * Password must be present on creation
# * Password length should be less than or equal to 72 bytes
# * Confirmation of password (using a +XXX_confirmation+ attribute)
#
# If confirmation validation is not needed, simply leave out the
# value for +XXX_confirmation+ (i.e. don't provide a form field for
# it). When this attribute has a +nil+ value, the validation will not be
# triggered.
#
# For further customizability, it is possible to suppress the default
# validations by passing <tt>validations: false</tt> as an argument.
#
# Add bcrypt (~> 3.1.7) to Gemfile to use #has_secure_password:
#
# gem 'bcrypt', '~> 3.1.7'
#
# Example using Active Record (which automatically includes ActiveModel::SecurePassword):
#
# # Schema: User(name:string, password_digest:string, recovery_password_digest:string)
# class User < ActiveRecord::Base
# has_secure_password
# has_secure_password :recovery_password, validations: false
# end
#
# user = User.new(name: 'david', password: '', password_confirmation: 'nomatch')
# user.save # => false, password required
# user.password = 'mUc3m00RsqyRe'
# user.save # => false, confirmation doesn't match
# user.password_confirmation = 'mUc3m00RsqyRe'
# user.save # => true
# user.recovery_password = "42password"
# user.recovery_password_digest # => "$2a$04$iOfhwahFymCs5weB3BNH/uXkTG65HR.qpW.bNhEjFP3ftli3o5DQC"
# user.save # => true
# user.authenticate('notright') # => false
# user.authenticate('mUc3m00RsqyRe') # => user
# user.authenticate_recovery_password('42password') # => user
# User.find_by(name: 'david')&.authenticate('notright') # => false
# User.find_by(name: 'david')&.authenticate('mUc3m00RsqyRe') # => user
def has_secure_password(attribute = :password, validations: true)
# Load bcrypt gem only when has_secure_password is used.
# This is to avoid ActiveModel (and by extension the entire framework)
# being dependent on a binary library.
begin
require "bcrypt"
rescue LoadError
$stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install"
raise
end
include InstanceMethodsOnActivation.new(attribute)
if validations
include ActiveModel::Validations
# This ensures the model has a password by checking whether the password_digest
# is present, so that this works with both new and existing records. However,
# when there is an error, the message is added to the password attribute instead
# so that the error message will make sense to the end-user.
validate do |record|
record.errors.add(attribute, :blank) unless record.public_send("#{attribute}_digest").present?
end
validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
validates_confirmation_of attribute, allow_blank: true
end
end
end
class InstanceMethodsOnActivation < Module
def initialize(attribute)
attr_reader attribute
define_method("#{attribute}=") do |unencrypted_password|
if unencrypted_password.nil?
instance_variable_set("@#{attribute}", nil)
self.public_send("#{attribute}_digest=", nil)
elsif !unencrypted_password.empty?
instance_variable_set("@#{attribute}", unencrypted_password)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
self.public_send("#{attribute}_digest=", BCrypt::Password.create(unencrypted_password, cost: cost))
end
end
define_method("#{attribute}_confirmation=") do |unencrypted_password|
instance_variable_set("@#{attribute}_confirmation", unencrypted_password)
end
# Returns +self+ if the password is correct, otherwise +false+.
#
# class User < ActiveRecord::Base
# has_secure_password validations: false
# end
#
# user = User.new(name: 'david', password: 'mUc3m00RsqyRe')
# user.save
# user.authenticate_password('notright') # => false
# user.authenticate_password('mUc3m00RsqyRe') # => user
define_method("authenticate_#{attribute}") do |unencrypted_password|
attribute_digest = public_send("#{attribute}_digest")
attribute_digest.present? && BCrypt::Password.new(attribute_digest).is_password?(unencrypted_password) && self
end
alias_method :authenticate, :authenticate_password if attribute == :password
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/user"
require "models/visitor"
class SecurePasswordTest < ActiveModel::TestCase
setup do
# Used only to speed up tests
@original_min_cost = ActiveModel::SecurePassword.min_cost
ActiveModel::SecurePassword.min_cost = true
@user = User.new
@visitor = Visitor.new
# Simulate loading an existing user from the DB
@existing_user = User.new
@existing_user.password_digest = BCrypt::Password.create("password", cost: BCrypt::Engine::MIN_COST)
end
teardown do
ActiveModel::SecurePassword.min_cost = @original_min_cost
end
test "automatically include ActiveModel::Validations when validations are enabled" do
assert_respond_to @user, :valid?
end
test "don't include ActiveModel::Validations when validations are disabled" do
assert_not_respond_to @visitor, :valid?
end
test "create a new user with validations and valid password/confirmation" do
@user.password = "password"
@user.password_confirmation = "password"
assert @user.valid?(:create), "user should be valid"
@user.password = "a" * 72
@user.password_confirmation = "a" * 72
assert @user.valid?(:create), "user should be valid"
end
test "create a new user with validation and a spaces only password" do
@user.password = " " * 72
assert @user.valid?(:create), "user should be valid"
end
test "create a new user with validation and a blank password" do
@user.password = ""
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
assert_equal ["can't be blank"], @user.errors[:password]
end
test "create a new user with validation and a nil password" do
@user.password = nil
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
assert_equal ["can't be blank"], @user.errors[:password]
end
test "create a new user with validation and password length greater than 72" do
@user.password = "a" * 73
@user.password_confirmation = "a" * 73
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
assert_equal ["is too long (maximum is 72 characters)"], @user.errors[:password]
end
test "create a new user with validation and a blank password confirmation" do
@user.password = "password"
@user.password_confirmation = ""
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
assert_equal ["doesn't match Password"], @user.errors[:password_confirmation]
end
test "create a new user with validation and a nil password confirmation" do
@user.password = "password"
@user.password_confirmation = nil
assert @user.valid?(:create), "user should be valid"
end
test "create a new user with validation and an incorrect password confirmation" do
@user.password = "password"
@user.password_confirmation = "something else"
assert_not @user.valid?(:create), "user should be invalid"
assert_equal 1, @user.errors.count
assert_equal ["doesn't match Password"], @user.errors[:password_confirmation]
end
test "resetting password to nil clears the password cache" do
@user.password = "password"
@user.password = nil
assert_nil @user.password
end
test "update an existing user with validation and no change in password" do
assert @existing_user.valid?(:update), "user should be valid"
end
test "update an existing user with validations and valid password/confirmation" do
@existing_user.password = "password"
@existing_user.password_confirmation = "password"
assert @existing_user.valid?(:update), "user should be valid"
@existing_user.password = "a" * 72
@existing_user.password_confirmation = "a" * 72
assert @existing_user.valid?(:update), "user should be valid"
end
test "updating an existing user with validation and a blank password" do
@existing_user.password = ""
assert @existing_user.valid?(:update), "user should be valid"
end
test "updating an existing user with validation and a spaces only password" do
@user.password = " " * 72
assert @user.valid?(:update), "user should be valid"
end
test "updating an existing user with validation and a blank password and password_confirmation" do
@existing_user.password = ""
@existing_user.password_confirmation = ""
assert @existing_user.valid?(:update), "user should be valid"
end
test "updating an existing user with validation and a nil password" do
@existing_user.password = nil
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
assert_equal ["can't be blank"], @existing_user.errors[:password]
end
test "updating an existing user with validation and password length greater than 72" do
@existing_user.password = "a" * 73
@existing_user.password_confirmation = "a" * 73
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
assert_equal ["is too long (maximum is 72 characters)"], @existing_user.errors[:password]
end
test "updating an existing user with validation and a blank password confirmation" do
@existing_user.password = "password"
@existing_user.password_confirmation = ""
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
assert_equal ["doesn't match Password"], @existing_user.errors[:password_confirmation]
end
test "updating an existing user with validation and a nil password confirmation" do
@existing_user.password = "password"
@existing_user.password_confirmation = nil
assert @existing_user.valid?(:update), "user should be valid"
end
test "updating an existing user with validation and an incorrect password confirmation" do
@existing_user.password = "password"
@existing_user.password_confirmation = "something else"
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
assert_equal ["doesn't match Password"], @existing_user.errors[:password_confirmation]
end
test "updating an existing user with validation and a blank password digest" do
@existing_user.password_digest = ""
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
assert_equal ["can't be blank"], @existing_user.errors[:password]
end
test "updating an existing user with validation and a nil password digest" do
@existing_user.password_digest = nil
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
assert_equal ["can't be blank"], @existing_user.errors[:password]
end
test "setting a blank password should not change an existing password" do
@existing_user.password = ""
assert @existing_user.password_digest == "password"
end
test "setting a nil password should clear an existing password" do
@existing_user.password = nil
assert_nil @existing_user.password_digest
end
test "override secure password attribute" do
assert_nil @user.password_called
@user.password = "secret"
assert_equal "secret", @user.password
assert_equal 1, @user.password_called
@user.password = "terces"
assert_equal "terces", @user.password
assert_equal 2, @user.password_called
end
test "authenticate" do
@user.password = "secret"
@user.recovery_password = "42password"
assert_equal false, @user.authenticate("wrong")
assert_equal @user, @user.authenticate("secret")
assert_equal false, @user.authenticate_password("wrong")
assert_equal @user, @user.authenticate_password("secret")
assert_equal false, @user.authenticate_recovery_password("wrong")
assert_equal @user, @user.authenticate_recovery_password("42password")
end
test "authenticate should return false and not raise when password digest is blank" do
@user.password_digest = " "
assert_equal false, @user.authenticate(" ")
end
test "Password digest cost defaults to bcrypt default cost when min_cost is false" do
ActiveModel::SecurePassword.min_cost = false
@user.password = "secret"
assert_equal BCrypt::Engine::DEFAULT_COST, @user.password_digest.cost
end
test "Password digest cost honors bcrypt cost attribute when min_cost is false" do
original_bcrypt_cost = BCrypt::Engine.cost
ActiveModel::SecurePassword.min_cost = false
BCrypt::Engine.cost = 5
@user.password = "secret"
assert_equal BCrypt::Engine.cost, @user.password_digest.cost
ensure
BCrypt::Engine.cost = original_bcrypt_cost
end
test "Password digest cost can be set to bcrypt min cost to speed up tests" do
ActiveModel::SecurePassword.min_cost = true
@user.password = "secret"
assert_equal BCrypt::Engine::MIN_COST, @user.password_digest.cost
end
end
# frozen_string_literal: true
require "active_support/core_ext/enumerable"
module ActiveModel
# == Active \Model \Serialization
#
# Provides a basic serialization to a serializable_hash for your objects.
#
# A minimal implementation could be:
#
# class Person
# include ActiveModel::Serialization
#
# attr_accessor :name
#
# def attributes
# {'name' => nil}
# end
# end
#
# Which would provide you with:
#
# person = Person.new
# person.serializable_hash # => {"name"=>nil}
# person.name = "Bob"
# person.serializable_hash # => {"name"=>"Bob"}
#
# An +attributes+ hash must be defined and should contain any attributes you
# need to be serialized. Attributes must be strings, not symbols.
# When called, serializable hash will use instance methods that match the name
# of the attributes hash's keys. In order to override this behavior, take a look
# at the private method +read_attribute_for_serialization+.
#
# ActiveModel::Serializers::JSON module automatically includes
# the <tt>ActiveModel::Serialization</tt> module, so there is no need to
# explicitly include <tt>ActiveModel::Serialization</tt>.
#
# A minimal implementation including JSON would be:
#
# class Person
# include ActiveModel::Serializers::JSON
#
# attr_accessor :name
#
# def attributes
# {'name' => nil}
# end
# end
#
# Which would provide you with:
#
# person = Person.new
# person.serializable_hash # => {"name"=>nil}
# person.as_json # => {"name"=>nil}
# person.to_json # => "{\"name\":null}"
#
# person.name = "Bob"
# person.serializable_hash # => {"name"=>"Bob"}
# person.as_json # => {"name"=>"Bob"}
# person.to_json # => "{\"name\":\"Bob\"}"
#
# Valid options are <tt>:only</tt>, <tt>:except</tt>, <tt>:methods</tt> and
# <tt>:include</tt>. The following are all valid examples:
#
# person.serializable_hash(only: 'name')
# person.serializable_hash(include: :address)
# person.serializable_hash(include: { address: { only: 'city' }})
module Serialization
# Returns a serialized hash of your object.
#
# class Person
# include ActiveModel::Serialization
#
# attr_accessor :name, :age
#
# def attributes
# {'name' => nil, 'age' => nil}
# end
#
# def capitalized_name
# name.capitalize
# end
# end
#
# person = Person.new
# person.name = 'bob'
# person.age = 22
# person.serializable_hash # => {"name"=>"bob", "age"=>22}
# person.serializable_hash(only: :name) # => {"name"=>"bob"}
# person.serializable_hash(except: :name) # => {"age"=>22}
# person.serializable_hash(methods: :capitalized_name)
# # => {"name"=>"bob", "age"=>22, "capitalized_name"=>"Bob"}
#
# Example with <tt>:include</tt> option
#
# class User
# include ActiveModel::Serializers::JSON
# attr_accessor :name, :notes # Emulate has_many :notes
# def attributes
# {'name' => nil}
# end
# end
#
# class Note
# include ActiveModel::Serializers::JSON
# attr_accessor :title, :text
# def attributes
# {'title' => nil, 'text' => nil}
# end
# end
#
# note = Note.new
# note.title = 'Battle of Austerlitz'
# note.text = 'Some text here'
#
# user = User.new
# user.name = 'Napoleon'
# user.notes = [note]
#
# user.serializable_hash
# # => {"name" => "Napoleon"}
# user.serializable_hash(include: { notes: { only: 'title' }})
# # => {"name" => "Napoleon", "notes" => [{"title"=>"Battle of Austerlitz"}]}
def serializable_hash(options = nil)
attribute_names = self.attribute_names
return serializable_attributes(attribute_names) if options.blank?
if only = options[:only]
attribute_names &= Array(only).map(&:to_s)
elsif except = options[:except]
attribute_names -= Array(except).map(&:to_s)
end
hash = serializable_attributes(attribute_names)
Array(options[:methods]).each { |m| hash[m.to_s] = send(m) }
serializable_add_includes(options) do |association, records, opts|
hash[association.to_s] = if records.respond_to?(:to_ary)
records.to_ary.map { |a| a.serializable_hash(opts) }
else
records.serializable_hash(opts)
end
end
hash
end
# Returns an array of attribute names as strings
def attribute_names # :nodoc:
attributes.keys
end
private
# Hook method defining how an attribute value should be retrieved for
# serialization. By default this is assumed to be an instance named after
# the attribute. Override this method in subclasses should you need to
# retrieve the value for a given attribute differently:
#
# class MyClass
# include ActiveModel::Serialization
#
# def initialize(data = {})
# @data = data
# end
#
# def read_attribute_for_serialization(key)
# @data[key]
# end
# end
alias :read_attribute_for_serialization :send
def serializable_attributes(attribute_names)
attribute_names.index_with { |n| read_attribute_for_serialization(n) }
end
# Add associations specified via the <tt>:include</tt> option.
#
# Expects a block that takes as arguments:
# +association+ - name of the association
# +records+ - the association record(s) to be serialized
# +opts+ - options for the association records
def serializable_add_includes(options = {}) # :nodoc:
return unless includes = options[:include]
unless includes.is_a?(Hash)
includes = Hash[Array(includes).flat_map { |n| n.is_a?(Hash) ? n.to_a : [[n, {}]] }]
end
includes.each do |association, opts|
if records = send(association)
yield association, records, opts
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "active_support/core_ext/object/instance_variables"
class SerializationTest < ActiveModel::TestCase
class User
include ActiveModel::Serialization
attr_accessor :name, :email, :gender, :address, :friends
def initialize(name, email, gender)
@name, @email, @gender = name, email, gender
@friends = []
end
def attributes
instance_values.except("address", "friends")
end
def method_missing(method_name, *args)
if method_name == :bar
"i_am_bar"
else
super
end
end
def foo
"i_am_foo"
end
end
class Address
include ActiveModel::Serialization
attr_accessor :street, :city, :state, :zip
def attributes
instance_values
end
end
setup do
@user = User.new("David", "david@example.com", "male")
@user.address = Address.new
@user.address.street = "123 Lane"
@user.address.city = "Springfield"
@user.address.state = "CA"
@user.address.zip = 11111
@user.friends = [User.new("Joe", "joe@example.com", "male"),
User.new("Sue", "sue@example.com", "female")]
end
def test_method_serializable_hash_should_work
expected = { "name" => "David", "gender" => "male", "email" => "david@example.com" }
assert_equal expected, @user.serializable_hash
end
def test_method_serializable_hash_should_work_with_only_option
expected = { "name" => "David" }
assert_equal expected, @user.serializable_hash(only: [:name])
end
def test_method_serializable_hash_should_work_with_except_option
expected = { "gender" => "male", "email" => "david@example.com" }
assert_equal expected, @user.serializable_hash(except: [:name])
end
def test_method_serializable_hash_should_work_with_methods_option
expected = { "name" => "David", "gender" => "male", "foo" => "i_am_foo", "bar" => "i_am_bar", "email" => "david@example.com" }
assert_equal expected, @user.serializable_hash(methods: [:foo, :bar])
end
def test_method_serializable_hash_should_work_with_only_and_methods
expected = { "foo" => "i_am_foo", "bar" => "i_am_bar" }
assert_equal expected, @user.serializable_hash(only: [], methods: [:foo, :bar])
end
def test_method_serializable_hash_should_work_with_except_and_methods
expected = { "gender" => "male", "foo" => "i_am_foo", "bar" => "i_am_bar" }
assert_equal expected, @user.serializable_hash(except: [:name, :email], methods: [:foo, :bar])
end
def test_should_raise_NoMethodError_for_non_existing_method
assert_raise(NoMethodError) { @user.serializable_hash(methods: [:nada]) }
end
def test_should_use_read_attribute_for_serialization
def @user.read_attribute_for_serialization(n)
"Jon"
end
expected = { "name" => "Jon" }
assert_equal expected, @user.serializable_hash(only: :name)
end
def test_include_option_with_singular_association
expected = { "name" => "David", "gender" => "male", "email" => "david@example.com",
"address" => { "street" => "123 Lane", "city" => "Springfield", "state" => "CA", "zip" => 11111 } }
assert_equal expected, @user.serializable_hash(include: :address)
end
def test_include_option_with_plural_association
expected = { "email" => "david@example.com", "gender" => "male", "name" => "David",
"friends" => [{ "name" => "Joe", "email" => "joe@example.com", "gender" => "male" },
{ "name" => "Sue", "email" => "sue@example.com", "gender" => "female" }] }
assert_equal expected, @user.serializable_hash(include: :friends)
end
def test_include_option_with_empty_association
@user.friends = []
expected = { "email" => "david@example.com", "gender" => "male", "name" => "David", "friends" => [] }
assert_equal expected, @user.serializable_hash(include: :friends)
end
class FriendList
def initialize(friends)
@friends = friends
end
def to_ary
@friends
end
end
def test_include_option_with_ary
@user.friends = FriendList.new(@user.friends)
expected = { "email" => "david@example.com", "gender" => "male", "name" => "David",
"friends" => [{ "name" => "Joe", "email" => "joe@example.com", "gender" => "male" },
{ "name" => "Sue", "email" => "sue@example.com", "gender" => "female" }] }
assert_equal expected, @user.serializable_hash(include: :friends)
end
def test_multiple_includes
expected = { "email" => "david@example.com", "gender" => "male", "name" => "David",
"address" => { "street" => "123 Lane", "city" => "Springfield", "state" => "CA", "zip" => 11111 },
"friends" => [{ "name" => "Joe", "email" => "joe@example.com", "gender" => "male" },
{ "name" => "Sue", "email" => "sue@example.com", "gender" => "female" }] }
assert_equal expected, @user.serializable_hash(include: [:address, :friends])
end
def test_include_with_options
expected = { "email" => "david@example.com", "gender" => "male", "name" => "David",
"address" => { "street" => "123 Lane" } }
assert_equal expected, @user.serializable_hash(include: { address: { only: "street" } })
end
def test_nested_include
@user.friends.first.friends = [@user]
expected = { "email" => "david@example.com", "gender" => "male", "name" => "David",
"friends" => [{ "name" => "Joe", "email" => "joe@example.com", "gender" => "male",
"friends" => [{ "email" => "david@example.com", "gender" => "male", "name" => "David" }] },
{ "name" => "Sue", "email" => "sue@example.com", "gender" => "female", "friends" => [] }] }
assert_equal expected, @user.serializable_hash(include: { friends: { include: :friends } })
end
def test_only_include
expected = { "name" => "David", "friends" => [{ "name" => "Joe" }, { "name" => "Sue" }] }
assert_equal expected, @user.serializable_hash(only: :name, include: { friends: { only: :name } })
end
def test_except_include
expected = { "name" => "David", "email" => "david@example.com",
"friends" => [{ "name" => "Joe", "email" => "joe@example.com" },
{ "name" => "Sue", "email" => "sue@example.com" }] }
assert_equal expected, @user.serializable_hash(except: :gender, include: { friends: { except: :gender } })
end
def test_multiple_includes_with_options
expected = { "email" => "david@example.com", "gender" => "male", "name" => "David",
"address" => { "street" => "123 Lane" },
"friends" => [{ "name" => "Joe", "email" => "joe@example.com", "gender" => "male" },
{ "name" => "Sue", "email" => "sue@example.com", "gender" => "female" }] }
assert_equal expected, @user.serializable_hash(include: [{ address: { only: "street" } }, :friends])
end
def test_all_includes_with_options
expected = { "email" => "david@example.com", "gender" => "male", "name" => "David",
"address" => { "street" => "123 Lane" },
"friends" => [{ "name" => "Joe" }, { "name" => "Sue" }] }
assert_equal expected, @user.serializable_hash(include: [address: { only: "street" }, friends: { only: "name" }])
end
end
# frozen_string_literal: true
class Sheep
extend ActiveModel::Naming
end
# frozen_string_literal: true
require "active_model/type/immutable_string"
module ActiveModel
module Type
# Attribute type for strings. It is registered under the +:string+ key.
#
# This class is a specialization of ActiveModel::Type::ImmutableString. It
# performs coercion in the same way, and can be configured in the same way.
# However, it accounts for mutable strings, so dirty tracking can properly
# check if a string has changed.
class String < ImmutableString
def changed_in_place?(raw_old_value, new_value)
if new_value.is_a?(::String)
raw_old_value != new_value
end
end
def to_immutable_string
ImmutableString.new(
true: @true,
false: @false,
limit: limit,
precision: precision,
scale: scale,
)
end
private
def cast_value(value)
case value
when ::String then ::String.new(value)
when true then @true
when false then @false
else value.to_s
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class StringTest < ActiveModel::TestCase
test "type casting" do
type = Type::String.new
assert_equal "t", type.cast(true)
assert_equal "f", type.cast(false)
assert_equal "123", type.cast(123)
end
test "type casting for database" do
type = Type::String.new
object, array, hash = Object.new, [true], { a: :b }
assert_equal object, type.serialize(object)
assert_equal array, type.serialize(array)
assert_equal hash, type.serialize(hash)
end
test "cast strings are mutable" do
type = Type::String.new
s = +"foo"
assert_equal false, type.cast(s).frozen?
assert_equal false, s.frozen?
f = -"foo"
assert_equal false, type.cast(f).frozen?
assert_equal true, f.frozen?
end
test "values are duped coming out" do
type = Type::String.new
s = "foo"
assert_not_same s, type.cast(s)
assert_equal s, type.cast(s)
assert_not_same s, type.deserialize(s)
assert_equal s, type.deserialize(s)
end
end
end
end
# frozen_string_literal: true
module ActiveModel
module Type
# Attribute type for time representation. It is registered under the
# +:time+ key.
#
# class Event
# include ActiveModel::Attributes
#
# attribute :start, :time
# end
#
# event = Event.new
# event.start = "2022-02-18T13:15:00-05:00"
#
# event.start.class # => Time
# event.start.year # => 2022
# event.start.month # => 2
# event.start.day # => 18
# event.start.hour # => 13
# event.start.min # => 15
# event.start.sec # => 0
# event.start.zone # => "EST"
#
# String values are parsed using the ISO 8601 datetime format. Partial
# time-only formats are also accepted.
#
# event.start = "06:07:08+09:00"
# event.start.utc # => 1999-12-31 21:07:08 UTC
#
# The degree of sub-second precision can be customized when declaring an
# attribute:
#
# class Event
# include ActiveModel::Attributes
#
# attribute :start, :time, precision: 4
# end
class Time < Value
include Helpers::Timezone
include Helpers::TimeValue
include Helpers::AcceptsMultiparameterTime.new(
defaults: { 1 => 2000, 2 => 1, 3 => 1, 4 => 0, 5 => 0 }
)
def type
:time
end
def user_input_in_time_zone(value)
return unless value.present?
case value
when ::String
value = "2000-01-01 #{value}"
time_hash = ::Date._parse(value)
return if time_hash[:hour].nil?
when ::Time
value = value.change(year: 2000, day: 1, month: 1)
end
super(value)
end
private
def cast_value(value)
return apply_seconds_precision(value) unless value.is_a?(::String)
return if value.empty?
dummy_time_value = value.sub(/\A\d{4}-\d\d-\d\d(?:T|\s)|/, "2000-01-01 ")
fast_string_to_time(dummy_time_value) || begin
time_hash = ::Date._parse(dummy_time_value)
return if time_hash[:hour].nil?
new_time(*time_hash.values_at(:year, :mon, :mday, :hour, :min, :sec, :sec_fraction, :offset))
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class TimeTest < ActiveModel::TestCase
def test_type_cast_time
type = Type::Time.new
assert_nil type.cast(nil)
assert_nil type.cast("")
assert_nil type.cast("ABC")
time_string = ::Time.now.utc.strftime("%T")
assert_equal time_string, type.cast(time_string).strftime("%T")
assert_equal ::Time.utc(2000, 1, 1, 16, 45, 54), type.cast("2015-06-13T19:45:54+03:00")
assert_equal ::Time.utc(1999, 12, 31, 21, 7, 8), type.cast("06:07:08+09:00")
assert_equal ::Time.utc(2000, 1, 1, 16, 45, 54), type.cast(4 => 16, 5 => 45, 6 => 54)
end
def test_user_input_in_time_zone
::Time.use_zone("Pacific Time (US & Canada)") do
type = Type::Time.new
assert_nil type.user_input_in_time_zone(nil)
assert_nil type.user_input_in_time_zone("")
assert_nil type.user_input_in_time_zone("ABC")
offset = ::Time.zone.formatted_offset
time_string = "2015-02-09T19:45:54#{offset}"
assert_equal 19, type.user_input_in_time_zone(time_string).hour
assert_equal offset, type.user_input_in_time_zone(time_string).formatted_offset
end
end
end
end
end
# frozen_string_literal: true
require "active_support/core_ext/string/zones"
require "active_support/core_ext/time/zones"
module ActiveModel
module Type
module Helpers # :nodoc: all
module TimeValue
def serialize(value)
value = apply_seconds_precision(value)
if value.acts_like?(:time)
if is_utc?
value = value.getutc if !value.utc?
else
value = value.getlocal
end
end
value
end
def apply_seconds_precision(value)
return value unless precision && value.respond_to?(:nsec)
number_of_insignificant_digits = 9 - precision
round_power = 10**number_of_insignificant_digits
rounded_off_nsec = value.nsec % round_power
if rounded_off_nsec > 0
value.change(nsec: value.nsec - rounded_off_nsec)
else
value
end
end
def type_cast_for_schema(value)
value.to_fs(:db).inspect
end
def user_input_in_time_zone(value)
value.in_time_zone
end
private
def new_time(year, mon, mday, hour, min, sec, microsec, offset = nil)
# Treat 0000-00-00 00:00:00 as nil.
return if year.nil? || (year == 0 && mon == 0 && mday == 0)
if offset
time = ::Time.utc(year, mon, mday, hour, min, sec, microsec) rescue nil
return unless time
time -= offset unless offset == 0
is_utc? ? time : time.getlocal
elsif is_utc?
::Time.utc(year, mon, mday, hour, min, sec, microsec) rescue nil
else
::Time.local(year, mon, mday, hour, min, sec, microsec) rescue nil
end
end
ISO_DATETIME = /
\A
(\d{4})-(\d\d)-(\d\d)(?:T|\s) # 2020-06-20T
(\d\d):(\d\d):(\d\d)(?:\.(\d{1,6})\d*)? # 10:20:30.123456
(?:(Z(?=\z)|[+-]\d\d)(?::?(\d\d))?)? # +09:00
\z
/x
def fast_string_to_time(string)
return unless ISO_DATETIME =~ string
usec = $7.to_i
usec_len = $7&.length
if usec_len&.< 6
usec *= 10**(6 - usec_len)
end
if $8
offset = $8 == "Z" ? 0 : $8.to_i * 3600 + $9.to_i * 60
end
new_time($1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, usec, offset)
end
end
end
end
end
# frozen_string_literal: true
require "active_support/core_ext/time/zones"
module ActiveModel
module Type
module Helpers # :nodoc: all
module Timezone
def is_utc?
::Time.zone_default.nil? || ::Time.zone_default.match?("UTC")
end
def default_timezone
is_utc? ? :utc : :local
end
end
end
end
end
# frozen_string_literal: true
class Topic
include ActiveModel::Validations
include ActiveModel::Validations::Callbacks
include ActiveModel::AttributeMethods
include ActiveSupport::NumberHelper
attribute_method_suffix "_before_type_cast", parameters: false
define_attribute_method :price
def self._validates_default_keys
super | [ :message ]
end
attr_accessor :title, :author_name, :content, :approved, :created_at
attr_accessor :after_validation_performed
attr_writer :price
after_validation :perform_after_validation
def initialize(attributes = {})
attributes.each do |key, value|
public_send "#{key}=", value
end
end
def condition_is_true
true
end
def condition_is_false
false
end
def perform_after_validation
self.after_validation_performed = true
end
def my_validation
errors.add :title, "is missing" unless title
end
def my_validation_with_arg(attr)
errors.add attr, "is missing" unless public_send(attr)
end
def price
number_to_currency @price
end
def attribute_before_type_cast(attr)
instance_variable_get(:"@#{attr}")
end
private
def five
5
end
end
# frozen_string_literal: true
class Post
class TrackBack
def to_model
NamedTrackBack.new
end
end
class NamedTrackBack
extend ActiveModel::Naming
end
end
# frozen_string_literal: true
module ActiveModel
# == Active \Model \Translation
#
# Provides integration between your object and the Rails internationalization
# (i18n) framework.
#
# A minimal implementation could be:
#
# class TranslatedPerson
# extend ActiveModel::Translation
# end
#
# TranslatedPerson.human_attribute_name('my_attribute')
# # => "My attribute"
#
# This also provides the required class methods for hooking into the
# Rails internationalization API, including being able to define a
# class-based +i18n_scope+ and +lookup_ancestors+ to find translations in
# parent classes.
module Translation
include ActiveModel::Naming
# Returns the +i18n_scope+ for the class. Override if you want custom lookup.
def i18n_scope
:activemodel
end
# When localizing a string, it goes through the lookup returned by this
# method, which is used in ActiveModel::Name#human,
# ActiveModel::Errors#full_messages and
# ActiveModel::Translation#human_attribute_name.
def lookup_ancestors
ancestors.select { |x| x.respond_to?(:model_name) }
end
MISSING_TRANSLATION = Object.new # :nodoc:
# Transforms attribute names into a more human format, such as "First name"
# instead of "first_name".
#
# Person.human_attribute_name("first_name") # => "First name"
#
# Specify +options+ with additional translating options.
def human_attribute_name(attribute, options = {})
attribute = attribute.to_s
if attribute.include?(".")
namespace, _, attribute = attribute.rpartition(".")
namespace.tr!(".", "/")
defaults = lookup_ancestors.map do |klass|
:"#{i18n_scope}.attributes.#{klass.model_name.i18n_key}/#{namespace}.#{attribute}"
end
defaults << :"#{i18n_scope}.attributes.#{namespace}.#{attribute}"
else
defaults = lookup_ancestors.map do |klass|
:"#{i18n_scope}.attributes.#{klass.model_name.i18n_key}.#{attribute}"
end
end
defaults << :"attributes.#{attribute}"
defaults << options[:default] if options[:default]
defaults << MISSING_TRANSLATION
translation = I18n.translate(defaults.shift, count: 1, **options, default: defaults)
translation = attribute.humanize if translation == MISSING_TRANSLATION
translation
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/person"
class ActiveModelI18nTests < ActiveModel::TestCase
def setup
I18n.backend = I18n::Backend::Simple.new
end
def teardown
I18n.backend.reload!
end
def test_translated_model_attributes
I18n.backend.store_translations "en", activemodel: { attributes: { person: { name: "person name attribute" } } }
assert_equal "person name attribute", Person.human_attribute_name("name")
end
def test_translated_model_attributes_with_default
I18n.backend.store_translations "en", attributes: { name: "name default attribute" }
assert_equal "name default attribute", Person.human_attribute_name("name")
end
def test_translated_model_attributes_using_default_option
assert_equal "name default attribute", Person.human_attribute_name("name", default: "name default attribute")
end
def test_translated_model_attributes_using_default_option_as_symbol
I18n.backend.store_translations "en", default_name: "name default attribute"
assert_equal "name default attribute", Person.human_attribute_name("name", default: :default_name)
end
def test_translated_model_attributes_falling_back_to_default
assert_equal "Name", Person.human_attribute_name("name")
end
def test_translated_model_attributes_using_default_option_as_symbol_and_falling_back_to_default
assert_equal "Name", Person.human_attribute_name("name", default: :default_name)
end
def test_translated_model_attributes_with_symbols
I18n.backend.store_translations "en", activemodel: { attributes: { person: { name: "person name attribute" } } }
assert_equal "person name attribute", Person.human_attribute_name(:name)
end
def test_translated_model_attributes_with_ancestor
I18n.backend.store_translations "en", activemodel: { attributes: { child: { name: "child name attribute" } } }
assert_equal "child name attribute", Child.human_attribute_name("name")
end
def test_translated_model_attributes_with_ancestors_fallback
I18n.backend.store_translations "en", activemodel: { attributes: { person: { name: "person name attribute" } } }
assert_equal "person name attribute", Child.human_attribute_name("name")
end
def test_translated_model_attributes_with_attribute_matching_namespaced_model_name
I18n.backend.store_translations "en", activemodel: { attributes: {
person: { gender: "person gender" },
"person/gender": { attribute: "person gender attribute" }
} }
assert_equal "person gender", Person.human_attribute_name("gender")
assert_equal "person gender attribute", Person::Gender.human_attribute_name("attribute")
end
def test_translated_deeply_nested_model_attributes
I18n.backend.store_translations "en", activemodel: { attributes: { "person/contacts/addresses": { street: "Deeply Nested Address Street" } } }
assert_equal "Deeply Nested Address Street", Person.human_attribute_name("contacts.addresses.street")
end
def test_translated_nested_model_attributes
I18n.backend.store_translations "en", activemodel: { attributes: { "person/addresses": { street: "Person Address Street" } } }
assert_equal "Person Address Street", Person.human_attribute_name("addresses.street")
end
def test_translated_nested_model_attributes_with_namespace_fallback
I18n.backend.store_translations "en", activemodel: { attributes: { addresses: { street: "Cool Address Street" } } }
assert_equal "Cool Address Street", Person.human_attribute_name("addresses.street")
end
def test_translated_model_names
I18n.backend.store_translations "en", activemodel: { models: { person: "person model" } }
assert_equal "person model", Person.model_name.human
end
def test_translated_model_when_missing_translation
assert_equal "Person", Person.model_name.human
end
def test_translated_model_with_namespace
I18n.backend.store_translations "en", activemodel: { models: { 'person/gender': "gender model" } }
assert_equal "gender model", Person::Gender.model_name.human
end
def test_translated_subclass_model
I18n.backend.store_translations "en", activemodel: { models: { child: "child model" } }
assert_equal "child model", Child.model_name.human
end
def test_translated_subclass_model_when_ancestor_translation
I18n.backend.store_translations "en", activemodel: { models: { person: "person model" } }
assert_equal "person model", Child.model_name.human
end
def test_translated_subclass_model_when_missing_translation
assert_equal "Child", Child.model_name.human
end
def test_translated_model_with_default_value_when_missing_translation
assert_equal "dude", Person.model_name.human(default: "dude")
end
def test_translated_model_with_default_key_when_missing_both_translations
assert_equal "Person", Person.model_name.human(default: :this_key_does_not_exist)
end
def test_human_does_not_modify_options
options = { default: "person model" }
Person.model_name.human(options)
assert_equal({ default: "person model" }, options)
end
def test_human_attribute_name_does_not_modify_options
options = { default: "Cool gender" }
Person.human_attribute_name("gender", options)
assert_equal({ default: "Cool gender" }, options)
end
end
# frozen_string_literal: true
require "active_model/type/helpers"
require "active_model/type/value"
require "active_model/type/big_integer"
require "active_model/type/binary"
require "active_model/type/boolean"
require "active_model/type/date"
require "active_model/type/date_time"
require "active_model/type/decimal"
require "active_model/type/float"
require "active_model/type/immutable_string"
require "active_model/type/integer"
require "active_model/type/string"
require "active_model/type/time"
require "active_model/type/registry"
module ActiveModel
module Type
@registry = Registry.new
class << self
attr_accessor :registry # :nodoc:
# Add a new type to the registry, allowing it to be referenced as a
# symbol by {attribute}[rdoc-ref:Attributes::ClassMethods#attribute].
def register(type_name, klass = nil, &block)
registry.register(type_name, klass, &block)
end
def lookup(...) # :nodoc:
registry.lookup(...)
end
def default_value # :nodoc:
@default_value ||= Value.new
end
end
register(:big_integer, Type::BigInteger)
register(:binary, Type::Binary)
register(:boolean, Type::Boolean)
register(:date, Type::Date)
register(:datetime, Type::DateTime)
register(:decimal, Type::Decimal)
register(:float, Type::Float)
register(:immutable_string, Type::ImmutableString)
register(:integer, Type::Integer)
register(:string, Type::String)
register(:time, Type::Time)
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
class TypeTest < ActiveModel::TestCase
setup do
@old_registry = ActiveModel::Type.registry
ActiveModel::Type.registry = @old_registry.dup
end
teardown do
ActiveModel::Type.registry = @old_registry
end
test "registering a new type" do
type = Struct.new(:args)
ActiveModel::Type.register(:foo, type)
assert_equal type.new(:arg), ActiveModel::Type.lookup(:foo, :arg)
assert_equal type.new({}), ActiveModel::Type.lookup(:foo, {})
end
end
end
# frozen_string_literal: true
class User
extend ActiveModel::Callbacks
include ActiveModel::SecurePassword
define_model_callbacks :create
has_secure_password
has_secure_password :recovery_password, validations: false
attr_accessor :password_digest, :recovery_password_digest
attr_accessor :password_called
def password=(unencrypted_password)
self.password_called ||= 0
self.password_called += 1
super
end
end
# frozen_string_literal: true
require "active_model/attribute"
module ActiveModel
class Attribute # :nodoc:
class UserProvidedDefault < FromUser # :nodoc:
def initialize(name, value, type, database_default)
@user_provided_value = value
super(name, value, type, database_default)
end
def value_before_type_cast
if user_provided_value.is_a?(Proc)
@memoized_value_before_type_cast ||= user_provided_value.call
else
@user_provided_value
end
end
def with_type(type)
self.class.new(name, user_provided_value, type, original_attribute)
end
def marshal_dump
result = [
name,
value_before_type_cast,
type,
original_attribute,
]
result << value if defined?(@value)
result
end
def marshal_load(values)
name, user_provided_value, type, original_attribute, value = values
@name = name
@user_provided_value = user_provided_value
@type = type
@original_attribute = original_attribute
if values.length == 5
@value = value
end
end
private
attr_reader :user_provided_value
end
end
end
# frozen_string_literal: true
require "active_support/core_ext/hash/slice"
module ActiveModel
module Validations
module ClassMethods
# This method is a shortcut to all default validators and any custom
# validator classes ending in 'Validator'. Note that Rails default
# validators can be overridden inside specific classes by creating
# custom validator classes in their place such as PresenceValidator.
#
# Examples of using the default rails validators:
#
# validates :username, absence: true
# validates :terms, acceptance: true
# validates :password, confirmation: true
# validates :username, exclusion: { in: %w(admin superuser) }
# validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, on: :create }
# validates :age, inclusion: { in: 0..9 }
# validates :first_name, length: { maximum: 30 }
# validates :age, numericality: true
# validates :username, presence: true
#
# The power of the +validates+ method comes when using custom validators
# and default validators in one call for a given attribute.
#
# class EmailValidator < ActiveModel::EachValidator
# def validate_each(record, attribute, value)
# record.errors.add attribute, (options[:message] || "is not an email") unless
# /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i.match?(value)
# end
# end
#
# class Person
# include ActiveModel::Validations
# attr_accessor :name, :email
#
# validates :name, presence: true, length: { maximum: 100 }
# validates :email, presence: true, email: true
# end
#
# Validator classes may also exist within the class being validated
# allowing custom modules of validators to be included as needed.
#
# class Film
# include ActiveModel::Validations
#
# class TitleValidator < ActiveModel::EachValidator
# def validate_each(record, attribute, value)
# record.errors.add attribute, "must start with 'the'" unless /\Athe/i.match?(value)
# end
# end
#
# validates :name, title: true
# end
#
# Additionally validator classes may be in another namespace and still
# used within any class.
#
# validates :name, :'film/title' => true
#
# The validators hash can also handle regular expressions, ranges, arrays
# and strings in shortcut form.
#
# validates :email, format: /@/
# validates :role, inclusion: %w(admin contributor)
# validates :password, length: 6..20
#
# When using shortcut form, ranges and arrays are passed to your
# validator's initializer as <tt>options[:in]</tt> while other types
# including regular expressions and strings are passed as <tt>options[:with]</tt>.
#
# There is also a list of options that could be used along with validators:
#
# * <tt>:on</tt> - Specifies the contexts where this validation is active.
# Runs in all validation contexts by default +nil+. You can pass a symbol
# or an array of symbols. (e.g. <tt>on: :create</tt> or
# <tt>on: :custom_validation_context</tt> or
# <tt>on: [:create, :custom_validation_context]</tt>)
# * <tt>:if</tt> - Specifies a method, proc, or string to call to determine
# if the validation should occur (e.g. <tt>if: :allow_validation</tt>,
# or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>). The method,
# proc or string should return or evaluate to a +true+ or +false+ value.
# * <tt>:unless</tt> - Specifies a method, proc, or string to call to determine
# if the validation should not occur (e.g. <tt>unless: :skip_validation</tt>,
# or <tt>unless: Proc.new { |user| user.signup_step <= 2 }</tt>). The
# method, proc, or string should return or evaluate to a +true+ or
# +false+ value.
# * <tt>:allow_nil</tt> - Skip validation if the attribute is +nil+.
# * <tt>:allow_blank</tt> - Skip validation if the attribute is blank.
# * <tt>:strict</tt> - If the <tt>:strict</tt> option is set to true
# will raise ActiveModel::StrictValidationFailed instead of adding the error.
# <tt>:strict</tt> option can also be set to any other exception.
#
# Example:
#
# validates :password, presence: true, confirmation: true, if: :password_required?
# validates :token, length: 24, strict: TokenLengthException
#
#
# Finally, the options +:if+, +:unless+, +:on+, +:allow_blank+, +:allow_nil+, +:strict+
# and +:message+ can be given to one specific validator, as a hash:
#
# validates :password, presence: { if: :password_required?, message: 'is forgotten.' }, confirmation: true
def validates(*attributes)
defaults = attributes.extract_options!.dup
validations = defaults.slice!(*_validates_default_keys)
raise ArgumentError, "You need to supply at least one attribute" if attributes.empty?
raise ArgumentError, "You need to supply at least one validation" if validations.empty?
defaults[:attributes] = attributes
validations.each do |key, options|
key = "#{key.to_s.camelize}Validator"
begin
validator = key.include?("::") ? key.constantize : const_get(key)
rescue NameError
raise ArgumentError, "Unknown validator: '#{key}'"
end
next unless options
validates_with(validator, defaults.merge(_parse_validates_options(options)))
end
end
# This method is used to define validations that cannot be corrected by end
# users and are considered exceptional. So each validator defined with bang
# or <tt>:strict</tt> option set to <tt>true</tt> will always raise
# <tt>ActiveModel::StrictValidationFailed</tt> instead of adding error
# when validation fails. See <tt>validates</tt> for more information about
# the validation itself.
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name
# validates! :name, presence: true
# end
#
# person = Person.new
# person.name = ''
# person.valid?
# # => ActiveModel::StrictValidationFailed: Name can't be blank
def validates!(*attributes)
options = attributes.extract_options!
options[:strict] = true
validates(*(attributes << options))
end
private
# When creating custom validators, it might be useful to be able to specify
# additional default keys. This can be done by overwriting this method.
def _validates_default_keys
[:if, :unless, :on, :allow_blank, :allow_nil, :strict]
end
def _parse_validates_options(options)
case options
when TrueClass
{}
when Hash
options
when Range, Array
{ in: options }
else
{ with: options }
end
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/person"
require "models/topic"
require "models/person_with_validator"
require "validators/namespace/email_validator"
class ValidatesTest < ActiveModel::TestCase
setup :reset_callbacks
teardown :reset_callbacks
def reset_callbacks
Person.clear_validators!
Topic.clear_validators!
PersonWithValidator.clear_validators!
end
def test_validates_with_messages_empty
Person.validates :title, presence: { message: "" }
person = Person.new
assert_not person.valid?, "person should not be valid."
end
def test_validates_with_built_in_validation
Person.validates :title, numericality: true
person = Person.new
person.valid?
assert_equal ["is not a number"], person.errors[:title]
end
def test_validates_with_attribute_specified_as_string
Person.validates "title", numericality: true
person = Person.new
person.valid?
assert_equal ["is not a number"], person.errors[:title]
person = Person.new
person.title = 123
assert_predicate person, :valid?
end
def test_validates_with_built_in_validation_and_options
Person.validates :salary, numericality: { message: "my custom message" }
person = Person.new
person.valid?
assert_equal ["my custom message"], person.errors[:salary]
end
def test_validates_with_validator_class
Person.validates :karma, email: true
person = Person.new
person.valid?
assert_equal ["is not an email"], person.errors[:karma]
end
def test_validates_with_namespaced_validator_class
Person.validates :karma, 'namespace/email': true
person = Person.new
person.valid?
assert_equal ["is not an email"], person.errors[:karma]
end
def test_validates_with_if_as_local_conditions
Person.validates :karma, presence: true, email: { if: :condition_is_false }
person = Person.new
person.valid?
assert_equal ["can't be blank"], person.errors[:karma]
end
def test_validates_with_if_as_shared_conditions
Person.validates :karma, presence: true, email: true, if: :condition_is_false
person = Person.new
assert_predicate person, :valid?
end
def test_validates_with_unless_as_local_conditions
Person.validates :karma, presence: true, email: { unless: :condition_is_true }
person = Person.new
person.valid?
assert_equal ["can't be blank"], person.errors[:karma]
end
def test_validates_with_unless_shared_conditions
Person.validates :karma, presence: true, email: true, unless: :condition_is_true
person = Person.new
assert_predicate person, :valid?
end
def test_validates_with_allow_nil_shared_conditions
Person.validates :karma, length: { minimum: 20 }, email: true, allow_nil: true
person = Person.new
assert_predicate person, :valid?
end
def test_validates_with_regexp
Person.validates :karma, format: /positive|negative/
person = Person.new
assert_predicate person, :invalid?
assert_equal ["is invalid"], person.errors[:karma]
person.karma = "positive"
assert_predicate person, :valid?
end
def test_validates_with_array
Person.validates :gender, inclusion: %w(m f)
person = Person.new
assert_predicate person, :invalid?
assert_equal ["is not included in the list"], person.errors[:gender]
person.gender = "m"
assert_predicate person, :valid?
end
def test_validates_with_range
Person.validates :karma, length: 6..20
person = Person.new
assert_predicate person, :invalid?
assert_equal ["is too short (minimum is 6 characters)"], person.errors[:karma]
person.karma = "something"
assert_predicate person, :valid?
end
def test_validates_with_validator_class_and_options
Person.validates :karma, email: { message: "my custom message" }
person = Person.new
person.valid?
assert_equal ["my custom message"], person.errors[:karma]
end
def test_validates_with_unknown_validator
assert_raise(ArgumentError) { Person.validates :karma, unknown: true }
end
def test_validates_with_disabled_unknown_validator
assert_raise(ArgumentError) { Person.validates :karma, unknown: false }
end
def test_validates_with_included_validator
PersonWithValidator.validates :title, presence: true
person = PersonWithValidator.new
person.valid?
assert_equal ["Local validator"], person.errors[:title]
end
def test_validates_with_included_validator_and_options
PersonWithValidator.validates :title, presence: { custom: " please" }
person = PersonWithValidator.new
person.valid?
assert_equal ["Local validator please"], person.errors[:title]
end
def test_validates_with_included_validator_and_wildcard_shortcut
# Shortcut for PersonWithValidator.validates :title, like: { with: "Mr." }
PersonWithValidator.validates :title, like: "Mr."
person = PersonWithValidator.new
person.title = "Ms. Pacman"
person.valid?
assert_equal ["does not appear to be like Mr."], person.errors[:title]
end
def test_defining_extra_default_keys_for_validates
Topic.validates :title, confirmation: true, message: "Y U NO CONFIRM"
topic = Topic.new
topic.title = "What's happening"
topic.title_confirmation = "Not this"
assert_not_predicate topic, :valid?
assert_equal ["Y U NO CONFIRM"], topic.errors[:title_confirmation]
end
end
# frozen_string_literal: true
require "active_support/core_ext/array/extract_options"
module ActiveModel
# == Active \Model \Validations
#
# Provides a full validation framework to your objects.
#
# A minimal implementation could be:
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :first_name, :last_name
#
# validates_each :first_name, :last_name do |record, attr, value|
# record.errors.add attr, "starts with z." if value.start_with?("z")
# end
# end
#
# Which provides you with the full standard validation stack that you
# know from Active Record:
#
# person = Person.new
# person.valid? # => true
# person.invalid? # => false
#
# person.first_name = 'zoolander'
# person.valid? # => false
# person.invalid? # => true
# person.errors.messages # => {first_name:["starts with z."]}
#
# Note that <tt>ActiveModel::Validations</tt> automatically adds an +errors+
# method to your instances initialized with a new ActiveModel::Errors
# object, so there is no need for you to do this manually.
module Validations
extend ActiveSupport::Concern
included do
extend ActiveModel::Naming
extend ActiveModel::Callbacks
extend ActiveModel::Translation
extend HelperMethods
include HelperMethods
attr_accessor :validation_context
private :validation_context=
define_callbacks :validate, scope: :name
class_attribute :_validators, instance_writer: false, default: Hash.new { |h, k| h[k] = [] }
end
module ClassMethods
# Validates each attribute against a block.
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :first_name, :last_name
#
# validates_each :first_name, :last_name, allow_blank: true do |record, attr, value|
# record.errors.add attr, "starts with z." if value.start_with?("z")
# end
# end
#
# Options:
# * <tt>:on</tt> - Specifies the contexts where this validation is active.
# Runs in all validation contexts by default +nil+. You can pass a symbol
# or an array of symbols. (e.g. <tt>on: :create</tt> or
# <tt>on: :custom_validation_context</tt> or
# <tt>on: [:create, :custom_validation_context]</tt>)
# * <tt>:allow_nil</tt> - Skip validation if attribute is +nil+.
# * <tt>:allow_blank</tt> - Skip validation if attribute is blank.
# * <tt>:if</tt> - Specifies a method, proc, or string to call to determine
# if the validation should occur (e.g. <tt>if: :allow_validation</tt>,
# or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>). The method,
# proc or string should return or evaluate to a +true+ or +false+ value.
# * <tt>:unless</tt> - Specifies a method, proc, or string to call to
# determine if the validation should not occur (e.g. <tt>unless: :skip_validation</tt>,
# or <tt>unless: Proc.new { |user| user.signup_step <= 2 }</tt>). The
# method, proc, or string should return or evaluate to a +true+ or +false+
# value.
def validates_each(*attr_names, &block)
validates_with BlockValidator, _merge_attributes(attr_names), &block
end
VALID_OPTIONS_FOR_VALIDATE = [:on, :if, :unless, :prepend].freeze # :nodoc:
# Adds a validation method or block to the class. This is useful when
# overriding the +validate+ instance method becomes too unwieldy and
# you're looking for more descriptive declaration of your validations.
#
# This can be done with a symbol pointing to a method:
#
# class Comment
# include ActiveModel::Validations
#
# validate :must_be_friends
#
# def must_be_friends
# errors.add(:base, 'Must be friends to leave a comment') unless commenter.friend_of?(commentee)
# end
# end
#
# With a block which is passed with the current record to be validated:
#
# class Comment
# include ActiveModel::Validations
#
# validate do |comment|
# comment.must_be_friends
# end
#
# def must_be_friends
# errors.add(:base, 'Must be friends to leave a comment') unless commenter.friend_of?(commentee)
# end
# end
#
# Or with a block where +self+ points to the current record to be validated:
#
# class Comment
# include ActiveModel::Validations
#
# validate do
# errors.add(:base, 'Must be friends to leave a comment') unless commenter.friend_of?(commentee)
# end
# end
#
# Note that the return value of validation methods is not relevant.
# It's not possible to halt the validate callback chain.
#
# Options:
# * <tt>:on</tt> - Specifies the contexts where this validation is active.
# Runs in all validation contexts by default +nil+. You can pass a symbol
# or an array of symbols. (e.g. <tt>on: :create</tt> or
# <tt>on: :custom_validation_context</tt> or
# <tt>on: [:create, :custom_validation_context]</tt>)
# * <tt>:if</tt> - Specifies a method, proc, or string to call to determine
# if the validation should occur (e.g. <tt>if: :allow_validation</tt>,
# or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>). The method,
# proc or string should return or evaluate to a +true+ or +false+ value.
# * <tt>:unless</tt> - Specifies a method, proc, or string to call to
# determine if the validation should not occur (e.g. <tt>unless: :skip_validation</tt>,
# or <tt>unless: Proc.new { |user| user.signup_step <= 2 }</tt>). The
# method, proc, or string should return or evaluate to a +true+ or +false+
# value.
#
# NOTE: Calling +validate+ multiple times on the same method will overwrite previous definitions.
#
def validate(*args, &block)
options = args.extract_options!
if args.all?(Symbol)
options.each_key do |k|
unless VALID_OPTIONS_FOR_VALIDATE.include?(k)
raise ArgumentError.new("Unknown key: #{k.inspect}. Valid keys are: #{VALID_OPTIONS_FOR_VALIDATE.map(&:inspect).join(', ')}. Perhaps you meant to call `validates` instead of `validate`?")
end
end
end
if options.key?(:on)
options = options.dup
options[:on] = Array(options[:on])
options[:if] = [
->(o) { !(options[:on] & Array(o.validation_context)).empty? },
*options[:if]
]
end
set_callback(:validate, *args, options, &block)
end
# List all validators that are being used to validate the model using
# +validates_with+ method.
#
# class Person
# include ActiveModel::Validations
#
# validates_with MyValidator
# validates_with OtherValidator, on: :create
# validates_with StrictValidator, strict: true
# end
#
# Person.validators
# # => [
# # #<MyValidator:0x007fbff403e808 @options={}>,
# # #<OtherValidator:0x007fbff403d930 @options={on: :create}>,
# # #<StrictValidator:0x007fbff3204a30 @options={strict:true}>
# # ]
def validators
_validators.values.flatten.uniq
end
# Clears all of the validators and validations.
#
# Note that this will clear anything that is being used to validate
# the model for both the +validates_with+ and +validate+ methods.
# It clears the validators that are created with an invocation of
# +validates_with+ and the callbacks that are set by an invocation
# of +validate+.
#
# class Person
# include ActiveModel::Validations
#
# validates_with MyValidator
# validates_with OtherValidator, on: :create
# validates_with StrictValidator, strict: true
# validate :cannot_be_robot
#
# def cannot_be_robot
# errors.add(:base, 'A person cannot be a robot') if person_is_robot
# end
# end
#
# Person.validators
# # => [
# # #<MyValidator:0x007fbff403e808 @options={}>,
# # #<OtherValidator:0x007fbff403d930 @options={on: :create}>,
# # #<StrictValidator:0x007fbff3204a30 @options={strict:true}>
# # ]
#
# If one runs <tt>Person.clear_validators!</tt> and then checks to see what
# validators this class has, you would obtain:
#
# Person.validators # => []
#
# Also, the callback set by <tt>validate :cannot_be_robot</tt> will be erased
# so that:
#
# Person._validate_callbacks.empty? # => true
#
def clear_validators!
reset_callbacks(:validate)
_validators.clear
end
# List all validators that are being used to validate a specific attribute.
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name, :age
#
# validates_presence_of :name
# validates_inclusion_of :age, in: 0..99
# end
#
# Person.validators_on(:name)
# # => [
# # #<ActiveModel::Validations::PresenceValidator:0x007fe604914e60 @attributes=[:name], @options={}>,
# # ]
def validators_on(*attributes)
attributes.flat_map do |attribute|
_validators[attribute.to_sym]
end
end
# Returns +true+ if +attribute+ is an attribute method, +false+ otherwise.
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name
# end
#
# User.attribute_method?(:name) # => true
# User.attribute_method?(:age) # => false
def attribute_method?(attribute)
method_defined?(attribute)
end
# Copy validators on inheritance.
def inherited(base) # :nodoc:
dup = _validators.dup
base._validators = dup.each { |k, v| dup[k] = v.dup }
super
end
end
# Clean the +Errors+ object if instance is duped.
def initialize_dup(other) # :nodoc:
@errors = nil
super
end
# Returns the +Errors+ object that holds all information about attribute
# error messages.
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name
# validates_presence_of :name
# end
#
# person = Person.new
# person.valid? # => false
# person.errors # => #<ActiveModel::Errors:0x007fe603816640 @messages={name:["can't be blank"]}>
def errors
@errors ||= Errors.new(self)
end
# Runs all the specified validations and returns +true+ if no errors were
# added otherwise +false+.
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name
# validates_presence_of :name
# end
#
# person = Person.new
# person.name = ''
# person.valid? # => false
# person.name = 'david'
# person.valid? # => true
#
# Context can optionally be supplied to define which callbacks to test
# against (the context is defined on the validations using <tt>:on</tt>).
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name
# validates_presence_of :name, on: :new
# end
#
# person = Person.new
# person.valid? # => true
# person.valid?(:new) # => false
def valid?(context = nil)
current_context, self.validation_context = validation_context, context
errors.clear
run_validations!
ensure
self.validation_context = current_context
end
alias_method :validate, :valid?
# Performs the opposite of <tt>valid?</tt>. Returns +true+ if errors were
# added, +false+ otherwise.
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name
# validates_presence_of :name
# end
#
# person = Person.new
# person.name = ''
# person.invalid? # => true
# person.name = 'david'
# person.invalid? # => false
#
# Context can optionally be supplied to define which callbacks to test
# against (the context is defined on the validations using <tt>:on</tt>).
#
# class Person
# include ActiveModel::Validations
#
# attr_accessor :name
# validates_presence_of :name, on: :new
# end
#
# person = Person.new
# person.invalid? # => false
# person.invalid?(:new) # => true
def invalid?(context = nil)
!valid?(context)
end
# Runs all the validations within the specified context. Returns +true+ if
# no errors are found, raises +ValidationError+ otherwise.
#
# Validations with no <tt>:on</tt> option will run no matter the context. Validations with
# some <tt>:on</tt> option will only run in the specified context.
def validate!(context = nil)
valid?(context) || raise_validation_error
end
# Hook method defining how an attribute value should be retrieved. By default
# this is assumed to be an instance named after the attribute. Override this
# method in subclasses should you need to retrieve the value for a given
# attribute differently:
#
# class MyClass
# include ActiveModel::Validations
#
# def initialize(data = {})
# @data = data
# end
#
# def read_attribute_for_validation(key)
# @data[key]
# end
# end
alias :read_attribute_for_validation :send
private
def run_validations!
_run_validate_callbacks
errors.empty?
end
def raise_validation_error # :doc:
raise(ValidationError.new(self))
end
end
# = Active Model ValidationError
#
# Raised by <tt>validate!</tt> when the model is invalid. Use the
# +model+ method to retrieve the record which did not validate.
#
# begin
# complex_operation_that_internally_calls_validate!
# rescue ActiveModel::ValidationError => invalid
# puts invalid.model.errors
# end
class ValidationError < StandardError
attr_reader :model
def initialize(model)
@model = model
errors = @model.errors.full_messages.join(", ")
super(I18n.t(:"#{@model.class.i18n_scope}.errors.messages.model_invalid", errors: errors, default: :"errors.messages.model_invalid"))
end
end
end
Dir[File.expand_path("validations/*.rb", __dir__)].each { |file| require file }
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
class ValidationsContextTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
ERROR_MESSAGE = "Validation error from validator"
ANOTHER_ERROR_MESSAGE = "Another validation error from validator"
class ValidatorThatAddsErrors < ActiveModel::Validator
def validate(record)
record.errors.add(:base, ERROR_MESSAGE)
end
end
class AnotherValidatorThatAddsErrors < ActiveModel::Validator
def validate(record)
record.errors.add(:base, ANOTHER_ERROR_MESSAGE)
end
end
test "with a class that adds errors on create and validating a new model with no arguments" do
Topic.validates_with(ValidatorThatAddsErrors, on: :create)
topic = Topic.new
assert topic.valid?, "Validation doesn't run on valid? if 'on' is set to create"
end
test "with a class that adds errors on update and validating a new model" do
Topic.validates_with(ValidatorThatAddsErrors, on: :update)
topic = Topic.new
assert topic.valid?(:create), "Validation doesn't run on create if 'on' is set to update"
end
test "with a class that adds errors on create and validating a new model" do
Topic.validates_with(ValidatorThatAddsErrors, on: :create)
topic = Topic.new
assert topic.invalid?(:create), "Validation does run on create if 'on' is set to create"
assert_includes topic.errors[:base], ERROR_MESSAGE
end
test "with a class that adds errors on multiple contexts and validating a new model" do
Topic.validates_with(ValidatorThatAddsErrors, on: [:context1, :context2])
topic = Topic.new
assert topic.valid?, "Validation ran with no context given when 'on' is set to context1 and context2"
assert topic.invalid?(:context1), "Validation did not run on context1 when 'on' is set to context1 and context2"
assert_includes topic.errors[:base], ERROR_MESSAGE
assert topic.invalid?(:context2), "Validation did not run on context2 when 'on' is set to context1 and context2"
assert_includes topic.errors[:base], ERROR_MESSAGE
end
test "with a class that validating a model for a multiple contexts" do
Topic.validates_with(ValidatorThatAddsErrors, on: :context1)
Topic.validates_with(AnotherValidatorThatAddsErrors, on: :context2)
topic = Topic.new
assert topic.valid?, "Validation ran with no context given when 'on' is set to context1 and context2"
assert topic.invalid?([:context1, :context2]), "Validation did not run on context1 when 'on' is set to context1 and context2"
assert_includes topic.errors[:base], ERROR_MESSAGE
assert_includes topic.errors[:base], ANOTHER_ERROR_MESSAGE
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/reply"
require "models/custom_reader"
require "active_support/json"
require "active_support/xml_mini"
class ValidationsTest < ActiveModel::TestCase
class CustomStrictValidationException < StandardError; end
def teardown
Topic.clear_validators!
end
def test_single_field_validation
r = Reply.new
r.title = "There's no content!"
assert r.invalid?, "A reply without content should be invalid"
assert r.after_validation_performed, "after_validation callback should be called"
r.content = "Messa content!"
assert r.valid?, "A reply with content should be valid"
assert r.after_validation_performed, "after_validation callback should be called"
end
def test_single_attr_validation_and_error_msg
r = Reply.new
r.title = "There's no content!"
assert_predicate r, :invalid?
assert r.errors[:content].any?, "A reply without content should mark that attribute as invalid"
assert_equal ["is Empty"], r.errors["content"], "A reply without content should contain an error"
assert_equal 1, r.errors.count
end
def test_double_attr_validation_and_error_msg
r = Reply.new
assert_predicate r, :invalid?
assert r.errors[:title].any?, "A reply without title should mark that attribute as invalid"
assert_equal ["is Empty"], r.errors["title"], "A reply without title should contain an error"
assert r.errors[:content].any?, "A reply without content should mark that attribute as invalid"
assert_equal ["is Empty"], r.errors["content"], "A reply without content should contain an error"
assert_equal 2, r.errors.count
end
def test_multiple_errors_per_attr_iteration_with_full_error_composition
r = Reply.new
r.title = ""
r.content = ""
r.valid?
errors = r.errors.to_a
assert_equal "Content is Empty", errors[0]
assert_equal "Title is Empty", errors[1]
assert_equal 2, r.errors.count
end
def test_errors_on_nested_attributes_expands_name
t = Topic.new
t.errors.add("replies.name", "can't be blank")
assert_equal ["Replies name can't be blank"], t.errors.full_messages
end
def test_errors_on_base
r = Reply.new
r.content = "Mismatch"
r.valid?
r.errors.add(:base, "Reply is not dignifying")
errors = r.errors.to_a.inject([]) { |result, error| result + [error] }
assert_equal ["Reply is not dignifying"], r.errors[:base]
assert_includes errors, "Title is Empty"
assert_includes errors, "Reply is not dignifying"
assert_equal 2, r.errors.count
end
def test_errors_on_base_with_symbol_message
r = Reply.new
r.content = "Mismatch"
r.valid?
r.errors.add(:base, :invalid)
errors = r.errors.to_a.inject([]) { |result, error| result + [error] }
assert_equal ["is invalid"], r.errors[:base]
assert_includes errors, "Title is Empty"
assert_includes errors, "is invalid"
assert_equal 2, r.errors.count
end
def test_errors_empty_after_errors_on_check
t = Topic.new
assert_empty t.errors[:id]
assert_empty t.errors
end
def test_validates_each
hits = 0
Topic.validates_each(:title, :content, [:title, :content]) do |record, attr|
record.errors.add attr, "gotcha"
hits += 1
end
t = Topic.new("title" => "valid", "content" => "whatever")
assert_predicate t, :invalid?
assert_equal 4, hits
assert_equal %w(gotcha gotcha), t.errors[:title]
assert_equal %w(gotcha gotcha), t.errors[:content]
end
def test_validates_each_custom_reader
hits = 0
CustomReader.validates_each(:title, :content, [:title, :content]) do |record, attr|
record.errors.add attr, "gotcha"
hits += 1
end
t = CustomReader.new("title" => "valid", "content" => "whatever")
assert_predicate t, :invalid?
assert_equal 4, hits
assert_equal %w(gotcha gotcha), t.errors[:title]
assert_equal %w(gotcha gotcha), t.errors[:content]
ensure
CustomReader.clear_validators!
end
def test_validate_block
Topic.validate { errors.add("title", "will never be valid") }
t = Topic.new("title" => "Title", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["will never be valid"], t.errors["title"]
end
def test_validate_block_with_params
Topic.validate { |topic| topic.errors.add("title", "will never be valid") }
t = Topic.new("title" => "Title", "content" => "whatever")
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
assert_equal ["will never be valid"], t.errors["title"]
end
def test_validates_with_array_condition_does_not_mutate_the_array
opts = []
Topic.validate(if: opts, on: :create) { }
assert_empty opts
end
def test_invalid_validator
Topic.validate :i_dont_exist
assert_raises(NoMethodError) do
t = Topic.new
t.valid?
end
end
def test_invalid_options_to_validate
error = assert_raises(ArgumentError) do
# A common mistake -- we meant to call 'validates'
Topic.validate :title, presence: true
end
message = "Unknown key: :presence. Valid keys are: :on, :if, :unless, :prepend. Perhaps you meant to call `validates` instead of `validate`?"
assert_equal message, error.message
end
def test_callback_options_to_validate
klass = Class.new(Topic) do
attr_reader :call_sequence
def initialize(*)
super
@call_sequence = []
end
private
def validator_a
@call_sequence << :a
end
def validator_b
@call_sequence << :b
end
def validator_c
@call_sequence << :c
end
end
assert_nothing_raised do
klass.validate :validator_a, if: -> { true }
klass.validate :validator_b, prepend: true
klass.validate :validator_c, unless: -> { true }
end
t = klass.new
assert_predicate t, :valid?
assert_equal [:b, :a], t.call_sequence
end
def test_errors_to_json
Topic.validates_presence_of %w(title content)
t = Topic.new
assert_predicate t, :invalid?
hash = {}
hash[:title] = ["can't be blank"]
hash[:content] = ["can't be blank"]
assert_equal t.errors.to_json, hash.to_json
end
def test_validation_order
Topic.validates_presence_of :title
Topic.validates_length_of :title, minimum: 2
t = Topic.new("title" => "")
assert_predicate t, :invalid?
assert_equal "can't be blank", t.errors["title"].first
Topic.validates_presence_of :title, :author_name
Topic.validate { errors.add("author_email_address", "will never be valid") }
Topic.validates_length_of :title, :content, minimum: 2
t = Topic.new title: ""
assert_predicate t, :invalid?
assert_equal :title, key = t.errors.attribute_names[0]
assert_equal "can't be blank", t.errors[key][0]
assert_equal "is too short (minimum is 2 characters)", t.errors[key][1]
assert_equal :author_name, key = t.errors.attribute_names[1]
assert_equal "can't be blank", t.errors[key][0]
assert_equal :author_email_address, key = t.errors.attribute_names[2]
assert_equal "will never be valid", t.errors[key][0]
assert_equal :content, key = t.errors.attribute_names[3]
assert_equal "is too short (minimum is 2 characters)", t.errors[key][0]
end
def test_validation_with_if_and_on
Topic.validates_presence_of :title, if: Proc.new { |x| x.author_name = "bad"; true }, on: :update
t = Topic.new(title: "")
# If block should not fire
assert_predicate t, :valid?
assert_predicate t.author_name, :nil?
# If block should fire
assert t.invalid?(:update)
assert t.author_name == "bad"
end
def test_invalid_should_be_the_opposite_of_valid
Topic.validates_presence_of :title
t = Topic.new
assert_predicate t, :invalid?
assert_predicate t.errors[:title], :any?
t.title = "Things are going to change"
assert_not_predicate t, :invalid?
end
def test_validation_with_message_as_proc
Topic.validates_presence_of(:title, message: proc { "no blanks here".upcase })
t = Topic.new
assert_predicate t, :invalid?
assert_equal ["NO BLANKS HERE"], t.errors[:title]
end
def test_list_of_validators_for_model
Topic.validates_presence_of :title
Topic.validates_length_of :title, minimum: 2
assert_equal 2, Topic.validators.count
assert_equal [:presence, :length], Topic.validators.map(&:kind)
end
def test_list_of_validators_on_an_attribute
Topic.validates_presence_of :title, :content
Topic.validates_length_of :title, minimum: 2
assert_equal 2, Topic.validators_on(:title).count
assert_equal [:presence, :length], Topic.validators_on(:title).map(&:kind)
assert_equal 1, Topic.validators_on(:content).count
assert_equal [:presence], Topic.validators_on(:content).map(&:kind)
end
def test_accessing_instance_of_validator_on_an_attribute
Topic.validates_length_of :title, minimum: 10
assert_equal 10, Topic.validators_on(:title).first.options[:minimum]
end
def test_list_of_validators_on_multiple_attributes
Topic.validates :title, length: { minimum: 10 }
Topic.validates :author_name, presence: true, format: /a/
validators = Topic.validators_on(:title, :author_name)
assert_equal [
ActiveModel::Validations::FormatValidator,
ActiveModel::Validations::LengthValidator,
ActiveModel::Validations::PresenceValidator
], validators.map(&:class).sort_by(&:to_s)
end
def test_list_of_validators_will_be_empty_when_empty
Topic.validates :title, length: { minimum: 10 }
assert_equal [], Topic.validators_on(:author_name)
end
def test_validations_on_the_instance_level
Topic.validates :title, :author_name, presence: true
Topic.validates :content, length: { minimum: 10 }
topic = Topic.new
assert_predicate topic, :invalid?
assert_equal 3, topic.errors.size
topic.title = "Some Title"
topic.author_name = "Some Author"
topic.content = "Some Content Whose Length is more than 10."
assert_predicate topic, :valid?
end
def test_validate
Topic.validate do
validates_presence_of :title, :author_name
validates_length_of :content, minimum: 10
end
topic = Topic.new
assert_empty topic.errors
topic.validate
assert_not_empty topic.errors
end
def test_validate_with_bang
Topic.validates :title, presence: true
assert_raise(ActiveModel::ValidationError) do
Topic.new.validate!
end
end
def test_validate_with_bang_and_context
Topic.validates :title, presence: true, on: :context
assert_raise(ActiveModel::ValidationError) do
Topic.new.validate!(:context)
end
t = Topic.new(title: "Valid title")
assert t.validate!(:context)
end
def test_strict_validation_in_validates
Topic.validates :title, strict: true, presence: true
assert_raises ActiveModel::StrictValidationFailed do
Topic.new.valid?
end
end
def test_strict_validation_not_fails
Topic.validates :title, strict: true, presence: true
assert_predicate Topic.new(title: "hello"), :valid?
end
def test_strict_validation_particular_validator
Topic.validates :title, presence: { strict: true }
assert_raises ActiveModel::StrictValidationFailed do
Topic.new.valid?
end
end
def test_strict_validation_in_custom_validator_helper
Topic.validates_presence_of :title, strict: true
assert_raises ActiveModel::StrictValidationFailed do
Topic.new.valid?
end
end
def test_strict_validation_custom_exception
Topic.validates_presence_of :title, strict: CustomStrictValidationException
assert_raises CustomStrictValidationException do
Topic.new.valid?
end
end
def test_validates_with_bang
Topic.validates! :title, presence: true
assert_raises ActiveModel::StrictValidationFailed do
Topic.new.valid?
end
end
def test_validates_with_false_hash_value
Topic.validates :title, presence: false
assert_predicate Topic.new, :valid?
end
def test_strict_validation_error_message
Topic.validates :title, strict: true, presence: true
exception = assert_raises(ActiveModel::StrictValidationFailed) do
Topic.new.valid?
end
assert_equal "Title can't be blank", exception.message
end
def test_does_not_modify_options_argument
options = { presence: true }
Topic.validates :title, options
assert_equal({ presence: true }, options)
end
def test_dup_validity_is_independent
Topic.validates_presence_of :title
topic = Topic.new("title" => "Literature")
topic.valid?
duped = topic.dup
duped.title = nil
assert_predicate duped, :invalid?
topic.title = nil
duped.title = "Mathematics"
assert_predicate topic, :invalid?
assert_predicate duped, :valid?
end
def test_validation_with_message_as_proc_that_takes_a_record_as_a_parameter
Topic.validates_presence_of(:title, message: proc { |record| "You have failed me for the last time, #{record.author_name}." })
t = Topic.new(author_name: "Admiral")
assert_predicate t, :invalid?
assert_equal ["You have failed me for the last time, Admiral."], t.errors[:title]
end
def test_validation_with_message_as_proc_that_takes_record_and_data_as_a_parameters
Topic.validates_presence_of(:title, message: proc { |record, data| "#{data[:attribute]} is missing. You have failed me for the last time, #{record.author_name}." })
t = Topic.new(author_name: "Admiral")
assert_predicate t, :invalid?
assert_equal ["Title is missing. You have failed me for the last time, Admiral."], t.errors[:title]
end
end
# frozen_string_literal: true
require "active_support/core_ext/module/anonymous"
module ActiveModel
# == Active \Model \Validator
#
# A simple base class that can be used along with
# ActiveModel::Validations::ClassMethods.validates_with
#
# class Person
# include ActiveModel::Validations
# validates_with MyValidator
# end
#
# class MyValidator < ActiveModel::Validator
# def validate(record)
# if some_complex_logic
# record.errors.add(:base, "This record is invalid")
# end
# end
#
# private
# def some_complex_logic
# # ...
# end
# end
#
# Any class that inherits from ActiveModel::Validator must implement a method
# called +validate+ which accepts a +record+.
#
# class Person
# include ActiveModel::Validations
# validates_with MyValidator
# end
#
# class MyValidator < ActiveModel::Validator
# def validate(record)
# record # => The person instance being validated
# options # => Any non-standard options passed to validates_with
# end
# end
#
# To cause a validation error, you must add to the +record+'s errors directly
# from within the validators message.
#
# class MyValidator < ActiveModel::Validator
# def validate(record)
# record.errors.add :base, "This is some custom error message"
# record.errors.add :first_name, "This is some complex validation"
# # etc...
# end
# end
#
# To add behavior to the initialize method, use the following signature:
#
# class MyValidator < ActiveModel::Validator
# def initialize(options)
# super
# @my_custom_field = options[:field_name] || :first_name
# end
# end
#
# Note that the validator is initialized only once for the whole application
# life cycle, and not on each validation run.
#
# The easiest way to add custom validators for validating individual attributes
# is with the convenient ActiveModel::EachValidator.
#
# class TitleValidator < ActiveModel::EachValidator
# def validate_each(record, attribute, value)
# record.errors.add attribute, 'must be Mr., Mrs., or Dr.' unless %w(Mr. Mrs. Dr.).include?(value)
# end
# end
#
# This can now be used in combination with the +validates+ method
# (see ActiveModel::Validations::ClassMethods#validates for more on this).
#
# class Person
# include ActiveModel::Validations
# attr_accessor :title
#
# validates :title, presence: true, title: true
# end
#
# It can be useful to access the class that is using that validator when there are prerequisites such
# as an +attr_accessor+ being present. This class is accessible via <tt>options[:class]</tt> in the constructor.
# To set up your validator override the constructor.
#
# class MyValidator < ActiveModel::Validator
# def initialize(options={})
# super
# options[:class].attr_accessor :custom_attribute
# end
# end
class Validator
attr_reader :options
# Returns the kind of the validator.
#
# PresenceValidator.kind # => :presence
# AcceptanceValidator.kind # => :acceptance
def self.kind
@kind ||= name.split("::").last.underscore.chomp("_validator").to_sym unless anonymous?
end
# Accepts options that will be made available through the +options+ reader.
def initialize(options = {})
@options = options.except(:class).freeze
end
# Returns the kind for this validator.
#
# PresenceValidator.new(attributes: [:username]).kind # => :presence
# AcceptanceValidator.new(attributes: [:terms]).kind # => :acceptance
def kind
self.class.kind
end
# Override this method in subclasses with validation logic, adding errors
# to the records +errors+ array where necessary.
def validate(record)
raise NotImplementedError, "Subclasses must implement a validate(record) method."
end
end
# +EachValidator+ is a validator which iterates through the attributes given
# in the options hash invoking the <tt>validate_each</tt> method passing in the
# record, attribute, and value.
#
# All \Active \Model validations are built on top of this validator.
class EachValidator < Validator
attr_reader :attributes
# Returns a new validator instance. All options will be available via the
# +options+ reader, however the <tt>:attributes</tt> option will be removed
# and instead be made available through the +attributes+ reader.
def initialize(options)
@attributes = Array(options.delete(:attributes))
raise ArgumentError, ":attributes cannot be blank" if @attributes.empty?
super
check_validity!
end
# Performs validation on the supplied record. By default this will call
# +validate_each+ to determine validity therefore subclasses should
# override +validate_each+ with validation logic.
def validate(record)
attributes.each do |attribute|
value = record.read_attribute_for_validation(attribute)
next if (value.nil? && options[:allow_nil]) || (value.blank? && options[:allow_blank])
value = prepare_value_for_validation(value, record, attribute)
validate_each(record, attribute, value)
end
end
# Override this method in subclasses with the validation logic, adding
# errors to the records +errors+ array where necessary.
def validate_each(record, attribute, value)
raise NotImplementedError, "Subclasses must implement a validate_each(record, attribute, value) method"
end
# Hook method that gets called by the initializer allowing verification
# that the arguments supplied are valid. You could for example raise an
# +ArgumentError+ when invalid options are supplied.
def check_validity!
end
private
def prepare_value_for_validation(value, record, attr_name)
value
end
end
# +BlockValidator+ is a special +EachValidator+ which receives a block on initialization
# and call this block for each attribute being validated. +validates_each+ uses this validator.
class BlockValidator < EachValidator # :nodoc:
def initialize(options, &block)
@block = block
super
end
private
def validate_each(record, attribute, value)
@block.call(record, attribute, value)
end
end
end
# frozen_string_literal: true
module ActiveModel
module Type
# The base class for all attribute types. This class also serves as the
# default type for attributes that do not specify a type.
class Value
attr_reader :precision, :scale, :limit
# Initializes a type with three basic configuration settings: precision,
# limit, and scale. The Value base class does not define behavior for
# these settings. It uses them for equality comparison and hash key
# generation only.
def initialize(precision: nil, limit: nil, scale: nil)
@precision = precision
@scale = scale
@limit = limit
end
# Returns true if this type can convert +value+ to a type that is usable
# by the database. For example a boolean type can return +true+ if the
# value parameter is a Ruby boolean, but may return +false+ if the value
# parameter is some other object.
def serializable?(value)
true
end
# Returns the unique type name as a Symbol. Subclasses should override
# this method.
def type
end
# Converts a value from database input to the appropriate ruby type. The
# return value of this method will be returned from
# ActiveRecord::AttributeMethods::Read#read_attribute. The default
# implementation just calls Value#cast.
#
# +value+ The raw input, as provided from the database.
def deserialize(value)
cast(value)
end
# Type casts a value from user input (e.g. from a setter). This value may
# be a string from the form builder, or a ruby object passed to a setter.
# There is currently no way to differentiate between which source it came
# from.
#
# The return value of this method will be returned from
# ActiveRecord::AttributeMethods::Read#read_attribute. See also:
# Value#cast_value.
#
# +value+ The raw input, as provided to the attribute setter.
def cast(value)
cast_value(value) unless value.nil?
end
# Casts a value from the ruby type to a type that the database knows how
# to understand. The returned value from this method should be a
# +String+, +Numeric+, +Date+, +Time+, +Symbol+, +true+, +false+, or
# +nil+.
def serialize(value)
value
end
# Type casts a value for schema dumping. This method is private, as we are
# hoping to remove it entirely.
def type_cast_for_schema(value) # :nodoc:
value.inspect
end
# These predicates are not documented, as I need to look further into
# their use, and see if they can be removed entirely.
def binary? # :nodoc:
false
end
# Determines whether a value has changed for dirty checking. +old_value+
# and +new_value+ will always be type-cast. Types should not need to
# override this method.
def changed?(old_value, new_value, _new_value_before_type_cast)
old_value != new_value
end
# Determines whether the mutable value has been modified since it was
# read. Returns +false+ by default. If your type returns an object
# which could be mutated, you should override this method. You will need
# to either:
#
# - pass +new_value+ to Value#serialize and compare it to
# +raw_old_value+
#
# or
#
# - pass +raw_old_value+ to Value#deserialize and compare it to
# +new_value+
#
# +raw_old_value+ The original value, before being passed to
# +deserialize+.
#
# +new_value+ The current value, after type casting.
def changed_in_place?(raw_old_value, new_value)
false
end
def value_constructed_by_mass_assignment?(_value) # :nodoc:
false
end
def force_equality?(_value) # :nodoc:
false
end
def map(value) # :nodoc:
yield value
end
def ==(other)
self.class == other.class &&
precision == other.precision &&
scale == other.scale &&
limit == other.limit
end
alias eql? ==
def hash
[self.class, precision, scale, limit].hash
end
def assert_valid_value(_)
end
private
# Convenience method for types which do not need separate type casting
# behavior for user and database inputs. Called by Value#cast for
# values except +nil+.
def cast_value(value) # :doc:
value
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
module ActiveModel
module Type
class ValueTest < ActiveModel::TestCase
def test_type_equality
assert_equal Type::Value.new, Type::Value.new
assert_not_equal Type::Value.new, Type::Integer.new
assert_not_equal Type::Value.new(precision: 1), Type::Value.new(precision: 2)
end
end
end
end
# frozen_string_literal: true
require_relative "gem_version"
module ActiveModel
# Returns the version of the currently loaded \Active \Model as a <tt>Gem::Version</tt>
def self.version
gem_version
end
end
# frozen_string_literal: true
class Visitor
extend ActiveModel::Callbacks
include ActiveModel::SecurePassword
define_model_callbacks :create
has_secure_password(validations: false)
attr_accessor :password_digest
attr_reader :password_confirmation
end
# frozen_string_literal: true
require "active_support/core_ext/array/extract_options"
module ActiveModel
module Validations
class WithValidator < EachValidator # :nodoc:
def validate_each(record, attr, val)
method_name = options[:with]
if record.method(method_name).arity == 0
record.send method_name
else
record.send method_name, attr
end
end
end
module ClassMethods
# Passes the record off to the class or classes specified and allows them
# to add errors based on more complex conditions.
#
# class Person
# include ActiveModel::Validations
# validates_with MyValidator
# end
#
# class MyValidator < ActiveModel::Validator
# def validate(record)
# if some_complex_logic
# record.errors.add :base, 'This record is invalid'
# end
# end
#
# private
# def some_complex_logic
# # ...
# end
# end
#
# You may also pass it multiple classes, like so:
#
# class Person
# include ActiveModel::Validations
# validates_with MyValidator, MyOtherValidator, on: :create
# end
#
# Configuration options:
# * <tt>:on</tt> - Specifies the contexts where this validation is active.
# Runs in all validation contexts by default +nil+. You can pass a symbol
# or an array of symbols. (e.g. <tt>on: :create</tt> or
# <tt>on: :custom_validation_context</tt> or
# <tt>on: [:create, :custom_validation_context]</tt>)
# * <tt>:if</tt> - Specifies a method, proc, or string to call to determine
# if the validation should occur (e.g. <tt>if: :allow_validation</tt>,
# or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>).
# The method, proc, or string should return or evaluate to a +true+ or
# +false+ value.
# * <tt>:unless</tt> - Specifies a method, proc, or string to call to
# determine if the validation should not occur
# (e.g. <tt>unless: :skip_validation</tt>, or
# <tt>unless: Proc.new { |user| user.signup_step <= 2 }</tt>).
# The method, proc, or string should return or evaluate to a +true+ or
# +false+ value.
# * <tt>:strict</tt> - Specifies whether validation should be strict.
# See <tt>ActiveModel::Validations#validates!</tt> for more information.
#
# If you pass any additional configuration options, they will be passed
# to the class and available as +options+:
#
# class Person
# include ActiveModel::Validations
# validates_with MyValidator, my_custom_key: 'my custom value'
# end
#
# class MyValidator < ActiveModel::Validator
# def validate(record)
# options[:my_custom_key] # => "my custom value"
# end
# end
def validates_with(*args, &block)
options = args.extract_options!
options[:class] = self
args.each do |klass|
validator = klass.new(options, &block)
if validator.respond_to?(:attributes) && !validator.attributes.empty?
validator.attributes.each do |attribute|
_validators[attribute.to_sym] << validator
end
else
_validators[nil] << validator
end
validate(validator, options)
end
end
end
# Passes the record off to the class or classes specified and allows them
# to add errors based on more complex conditions.
#
# class Person
# include ActiveModel::Validations
#
# validate :instance_validations
#
# def instance_validations
# validates_with MyValidator
# end
# end
#
# Please consult the class method documentation for more information on
# creating your own validator.
#
# You may also pass it multiple classes, like so:
#
# class Person
# include ActiveModel::Validations
#
# validate :instance_validations, on: :create
#
# def instance_validations
# validates_with MyValidator, MyOtherValidator
# end
# end
#
# Standard configuration options (<tt>:on</tt>, <tt>:if</tt> and
# <tt>:unless</tt>), which are available on the class version of
# +validates_with+, should instead be placed on the +validates+ method
# as these are applied and tested in the callback.
#
# If you pass any additional configuration options, they will be passed
# to the class and available as +options+, please refer to the
# class version of this method for more information.
def validates_with(*args, &block)
options = args.extract_options!
options[:class] = self.class
args.each do |klass|
validator = klass.new(options, &block)
validator.validate(self)
end
end
end
end
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
class ValidatesWithTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
ERROR_MESSAGE = "Validation error from validator"
OTHER_ERROR_MESSAGE = "Validation error from other validator"
class ValidatorThatAddsErrors < ActiveModel::Validator
def validate(record)
record.errors.add(:base, message: ERROR_MESSAGE)
end
end
class OtherValidatorThatAddsErrors < ActiveModel::Validator
def validate(record)
record.errors.add(:base, message: OTHER_ERROR_MESSAGE)
end
end
class ValidatorThatDoesNotAddErrors < ActiveModel::Validator
def validate(record)
end
end
class ValidatorThatValidatesOptions < ActiveModel::Validator
def validate(record)
if options[:field] == :first_name
record.errors.add(:base, message: ERROR_MESSAGE)
end
end
end
class ValidatorPerEachAttribute < ActiveModel::EachValidator
def validate_each(record, attribute, value)
record.errors.add(attribute, message: "Value is #{value}")
end
end
class ValidatorCheckValidity < ActiveModel::EachValidator
def check_validity!
raise "boom!"
end
end
test "validation with class that adds errors" do
Topic.validates_with(ValidatorThatAddsErrors)
topic = Topic.new
assert topic.invalid?, "A class that adds errors causes the record to be invalid"
assert_includes topic.errors[:base], ERROR_MESSAGE
end
test "with a class that returns valid" do
Topic.validates_with(ValidatorThatDoesNotAddErrors)
topic = Topic.new
assert topic.valid?, "A class that does not add errors does not cause the record to be invalid"
end
test "with multiple classes" do
Topic.validates_with(ValidatorThatAddsErrors, OtherValidatorThatAddsErrors)
topic = Topic.new
assert_predicate topic, :invalid?
assert_includes topic.errors[:base], ERROR_MESSAGE
assert_includes topic.errors[:base], OTHER_ERROR_MESSAGE
end
test "passes all configuration options to the validator class" do
topic = Topic.new
validator = Minitest::Mock.new
validator.expect(:new, validator, [{ foo: :bar, if: :condition_is_true, class: Topic }])
validator.expect(:validate, nil, [topic])
validator.expect(:is_a?, false, [String]) # Call run by ActiveSupport::Callbacks::Callback.build
Topic.validates_with(validator, if: :condition_is_true, foo: :bar)
assert_predicate topic, :valid?
validator.verify
end
test "validates_with with options" do
Topic.validates_with(ValidatorThatValidatesOptions, field: :first_name)
topic = Topic.new
assert_predicate topic, :invalid?
assert_includes topic.errors[:base], ERROR_MESSAGE
end
test "validates_with each validator" do
Topic.validates_with(ValidatorPerEachAttribute, attributes: [:title, :content])
topic = Topic.new title: "Title", content: "Content"
assert_predicate topic, :invalid?
assert_equal ["Value is Title"], topic.errors[:title]
assert_equal ["Value is Content"], topic.errors[:content]
end
test "each validator checks validity" do
assert_raise RuntimeError do
Topic.validates_with(ValidatorCheckValidity, attributes: [:title])
end
end
test "each validator expects attributes to be given" do
assert_raise ArgumentError do
Topic.validates_with(ValidatorPerEachAttribute)
end
end
test "each validator skip nil values if :allow_nil is set to true" do
Topic.validates_with(ValidatorPerEachAttribute, attributes: [:title, :content], allow_nil: true)
topic = Topic.new content: ""
assert_predicate topic, :invalid?
assert_empty topic.errors[:title]
assert_equal ["Value is "], topic.errors[:content]
end
test "each validator skip blank values if :allow_blank is set to true" do
Topic.validates_with(ValidatorPerEachAttribute, attributes: [:title, :content], allow_blank: true)
topic = Topic.new content: ""
assert_predicate topic, :valid?
assert_empty topic.errors[:title]
assert_empty topic.errors[:content]
end
test "validates_with can validate with an instance method" do
Topic.validates :title, with: :my_validation
topic = Topic.new title: "foo"
assert_predicate topic, :valid?
assert_empty topic.errors[:title]
topic = Topic.new
assert_not_predicate topic, :valid?
assert_equal ["is missing"], topic.errors[:title]
end
test "optionally pass in the attribute being validated when validating with an instance method" do
Topic.validates :title, :content, with: :my_validation_with_arg
topic = Topic.new title: "foo"
assert_not_predicate topic, :valid?
assert_empty topic.errors[:title]
assert_equal ["is missing"], topic.errors[:content]
end
end
# frozen_string_literal: true
module ActiveModel
class AttributeSet
# Attempts to do more intelligent YAML dumping of an
# ActiveModel::AttributeSet to reduce the size of the resulting string
class YAMLEncoder # :nodoc:
def initialize(default_types)
@default_types = default_types
end
def encode(attribute_set, coder)
coder["concise_attributes"] = attribute_set.each_value.map do |attr|
if attr.type.equal?(default_types[attr.name])
attr.with_type(nil)
else
attr
end
end
end
def decode(coder)
if coder["attributes"]
coder["attributes"]
else
attributes_hash = Hash[coder["concise_attributes"].map do |attr|
if attr.type.nil?
attr = attr.with_type(default_types[attr.name])
end
[attr.name, attr]
end]
AttributeSet.new(attributes_hash)
end
end
private
attr_reader :default_types
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment