Created
March 8, 2022 01:22
-
-
Save sgoedecke/30fe59f6b102148d45684438f9fa6fb9 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
class Account | |
include ActiveModel::ForbiddenAttributesProtection | |
public :sanitize_for_mass_assignment | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
module Blog | |
def self.use_relative_model_naming? | |
true | |
end | |
class Post | |
extend ActiveModel::Naming | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
require "validators/email_validator" | |
module Namespace | |
class EmailValidator < ::EmailValidator | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
class Helicopter | |
include ActiveModel::Conversion | |
end | |
class Helicopter::Comanche | |
include ActiveModel::Conversion | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
class Sheep | |
extend ActiveModel::Naming | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
class Post | |
class TrackBack | |
def to_model | |
NamedTrackBack.new | |
end | |
end | |
class NamedTrackBack | |
extend ActiveModel::Naming | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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