diff options
Diffstat (limited to 'activemodel')
64 files changed, 868 insertions, 341 deletions
diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 8f80838a33..ad87abfa3a 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,8 +1,128 @@ +* Type cast falsy boolean symbols on boolean attribute as false. + + Fixes #35676. + + *Ryuta Kamizono* + +* Change how validation error translation strings are fetched: The new behavior + will first try the more specific keys, including doing locale fallback, then try + the less specific ones. + + For example, this is the order in which keys will now be tried for a `blank` + error on a `product`'s `title` attribute with current locale set to `en-US`: + + en-US.activerecord.errors.models.product.attributes.title.blank + en-US.activerecord.errors.models.product.blank + en-US.activerecord.errors.messages.blank + + en.activerecord.errors.models.product.attributes.title.blank + en.activerecord.errors.models.product.blank + en.activerecord.errors.messages.blank + + en-US.errors.attributes.title.blank + en-US.errors.messages.blank + + en.errors.attributes.title.blank + en.errors.messages.blank + + *Hugo Vacher* + + +## Rails 6.0.0.beta3 (March 11, 2019) ## + +* No changes. + + +## Rails 6.0.0.beta2 (February 25, 2019) ## + +* Fix date value when casting a multiparameter date hash to not convert + from Gregorian date to Julian date. + + Before: + + Day.new({"day(1i)"=>"1", "day(2i)"=>"1", "day(3i)"=>"1"}) + # => #<Day id: nil, day: "0001-01-03", created_at: nil, updated_at: nil> + + After: + + Day.new({"day(1i)"=>"1", "day(2i)"=>"1", "day(3i)"=>"1"}) + # => #<Day id: nil, day: "0001-01-01", created_at: nil, updated_at: nil> + + Fixes #28521. + + *Sayan Chakraborty* + +* Fix year value when casting a multiparameter time hash. + + When assigning a hash to a time attribute that's missing a year component + (e.g. a `time_select` with `:ignore_date` set to `true`) then the year + defaults to 1970 instead of the expected 2000. This results in the attribute + changing as a result of the save. + + Before: + ``` + event = Event.new(start_time: { 4 => 20, 5 => 30 }) + event.start_time # => 1970-01-01 20:30:00 UTC + event.save + event.reload + event.start_time # => 2000-01-01 20:30:00 UTC + ``` + + After: + ``` + event = Event.new(start_time: { 4 => 20, 5 => 30 }) + event.start_time # => 2000-01-01 20:30:00 UTC + event.save + event.reload + event.start_time # => 2000-01-01 20:30:00 UTC + ``` + + *Andrew White* + + +## Rails 6.0.0.beta1 (January 18, 2019) ## + +* Add `ActiveModel::Errors#of_kind?`. + + *bogdanvlviv*, *Rafael Mendonça França* + +* Fix numericality equality validation of `BigDecimal` and `Float` + by casting to `BigDecimal` on both ends of the validation. + + *Gannon McGibbon* + +* Add `#slice!` method to `ActiveModel::Errors`. + + *Daniel López Prat* + +* Fix numericality validator to still use value before type cast except Active Record. + + Fixes #33651, #33686. + + *Ryuta Kamizono* + +* Fix `ActiveModel::Serializers::JSON#as_json` method for timestamps. + + Before: + ``` + contact = Contact.new(created_at: Time.utc(2006, 8, 1)) + contact.as_json["created_at"] # => 2006-08-01 00:00:00 UTC + ``` + + After: + ``` + contact = Contact.new(created_at: Time.utc(2006, 8, 1)) + contact.as_json["created_at"] # => "2006-08-01T00:00:00.000Z" + ``` + + *Bogdan Gusiev* + * Allows configurable attribute name for `#has_secure_password`. This still defaults to an attribute named 'password', causing no breaking change. There is a new method `#authenticate_XXX` where XXX is the configured attribute name, making the existing `#authenticate` now an alias for this when the attribute is the default 'password'. + Example: class User < ActiveRecord::Base @@ -14,17 +134,17 @@ user.recovery_password_digest # => "$2a$04$iOfhwahFymCs5weB3BNH/uX..." user.authenticate_recovery_password('42password') # => user - *Unathi Chonco* + *Unathi Chonco* -* Add `config.active_model.i18n_full_message` in order to control whether +* Add `config.active_model.i18n_customize_full_message` in order to control whether the `full_message` error format can be overridden at the attribute or model level in the locale files. This is `false` by default. *Martin Larochelle* -* Rails 6 requires Ruby 2.4.1 or newer. +* Rails 6 requires Ruby 2.5.0 or newer. - *Jeremy Daer* + *Jeremy Daer*, *Kasper Timm Hansen* Please check [5-2-stable](https://github.com/rails/rails/blob/5-2-stable/activemodel/CHANGELOG.md) for previous changes. diff --git a/activemodel/MIT-LICENSE b/activemodel/MIT-LICENSE index 1cb3add0fc..ab7c27c209 100644 --- a/activemodel/MIT-LICENSE +++ b/activemodel/MIT-LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2004-2018 David Heinemeier Hansson +Copyright (c) 2004-2019 David Heinemeier Hansson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/activemodel/README.rdoc b/activemodel/README.rdoc index 1aaf4813ea..f9370d8de0 100644 --- a/activemodel/README.rdoc +++ b/activemodel/README.rdoc @@ -5,6 +5,8 @@ They allow for Action Pack helpers to interact with non-Active Record models, for example. Active Model also helps with building custom ORMs for use outside of the Rails framework. +You can read more about Active Model in the {Active Model Basics}[https://edgeguides.rubyonrails.org/active_model_basics.html] guide. + Prior to Rails 3.0, if a plugin or gem developer wanted to have an object interact with Action Pack helpers, it was required to either copy chunks of code from Rails, or monkey patch entire helpers to make them handle objects @@ -253,7 +255,7 @@ Active Model is released under the MIT license: API documentation is at: -* http://api.rubyonrails.org +* https://api.rubyonrails.org Bug reports for the Ruby on Rails project can be filed here: diff --git a/activemodel/activemodel.gemspec b/activemodel/activemodel.gemspec index 7be466dc4c..e681654439 100644 --- a/activemodel/activemodel.gemspec +++ b/activemodel/activemodel.gemspec @@ -9,13 +9,13 @@ Gem::Specification.new do |s| s.summary = "A toolkit for building modeling frameworks (part of Rails)." s.description = "A toolkit for building modeling frameworks like Active Record. Rich support for attributes, callbacks, validations, serialization, internationalization, and testing." - s.required_ruby_version = ">= 2.4.1" + s.required_ruby_version = ">= 2.5.0" s.license = "MIT" s.author = "David Heinemeier Hansson" s.email = "david@loudthinking.com" - s.homepage = "http://rubyonrails.org" + s.homepage = "https://rubyonrails.org" s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.rdoc", "lib/**/*"] s.require_path = "lib" @@ -25,5 +25,8 @@ Gem::Specification.new do |s| "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/activemodel/CHANGELOG.md" } + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + s.add_dependency "activesupport", version end diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb index bc10d6b4b9..c9140dc582 100644 --- a/activemodel/lib/active_model.rb +++ b/activemodel/lib/active_model.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true #-- -# Copyright (c) 2004-2018 David Heinemeier Hansson +# Copyright (c) 2004-2019 David Heinemeier Hansson # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/activemodel/lib/active_model/attribute.rb b/activemodel/lib/active_model/attribute.rb index 3f19cda07b..75f60d205e 100644 --- a/activemodel/lib/active_model/attribute.rb +++ b/activemodel/lib/active_model/attribute.rb @@ -206,6 +206,7 @@ module ActiveModel 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: diff --git a/activemodel/lib/active_model/attribute_assignment.rb b/activemodel/lib/active_model/attribute_assignment.rb index 217bf1ac01..f0e3458f51 100644 --- a/activemodel/lib/active_model/attribute_assignment.rb +++ b/activemodel/lib/active_model/attribute_assignment.rb @@ -27,7 +27,7 @@ module ActiveModel # cat.status # => 'sleeping' def assign_attributes(new_attributes) if !new_attributes.respond_to?(:stringify_keys) - raise ArgumentError, "When assigning attributes, you must pass a hash as an argument." + raise ArgumentError, "When assigning attributes, you must pass a hash as an argument, #{new_attributes.class} passed." end return if new_attributes.empty? diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index 888a431e5f..5c4670f393 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -369,7 +369,7 @@ module ActiveModel "define_method(:'#{name}') do |*args|" end - extra = (extra.map!(&:inspect) << "*args").join(", ".freeze) + extra = (extra.map!(&:inspect) << "*args").join(", ") target = if CALL_COMPILABLE_REGEXP.match?(send) "#{"self." unless include_private}#{send}(#{extra})" @@ -474,5 +474,43 @@ module ActiveModel 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. + # Evaluating many similar methods may use more memory as the instruction + # sequences are duplicated and cached (in MRI). define_method may + # be slower on dispatch, but if you're careful about the closure + # created, then define_method will consume much less memory. + # + # 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(mod, 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}'.freeze" + 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 + mod.alias_method method_name, temp_method_name + mod.undef_method temp_method_name + end + end + end end end diff --git a/activemodel/lib/active_model/attribute_set.rb b/activemodel/lib/active_model/attribute_set.rb index a890ee3932..4679b33852 100644 --- a/activemodel/lib/active_model/attribute_set.rb +++ b/activemodel/lib/active_model/attribute_set.rb @@ -37,16 +37,8 @@ module ActiveModel attributes.each_key.select { |name| self[name].initialized? } end - if defined?(JRUBY_VERSION) - # This form is significantly faster on JRuby, and this is one of our biggest hotspots. - # https://github.com/jruby/jruby/pull/2562 - def fetch_value(name, &block) - self[name].value(&block) - end - else - def fetch_value(name) - self[name].value { |n| yield n if block_given? } - end + def fetch_value(name, &block) + self[name].value(&block) end def write_from_database(name, value) diff --git a/activemodel/lib/active_model/attributes.rb b/activemodel/lib/active_model/attributes.rb index 5bf213d593..c3a446098c 100644 --- a/activemodel/lib/active_model/attributes.rb +++ b/activemodel/lib/active_model/attributes.rb @@ -29,17 +29,16 @@ module ActiveModel private def define_method_attribute=(name) - safe_name = name.unpack1("h*".freeze) - ActiveModel::AttributeMethods::AttrNames.set_name_cache safe_name, name - - generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 - def __temp__#{safe_name}=(value) - name = ::ActiveModel::AttributeMethods::AttrNames::ATTR_#{safe_name} - write_attribute(name, value) - end - alias_method #{(name + '=').inspect}, :__temp__#{safe_name}= - undef_method :__temp__#{safe_name}= - STR + ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( + generated_attribute_methods, name, writer: true, + ) do |temp_method_name, attr_name_expr| + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{temp_method_name}(value) + name = #{attr_name_expr} + write_attribute(name, value) + end + RUBY + end end NO_DEFAULT_PROVIDED = Object.new # :nodoc: @@ -97,15 +96,4 @@ module ActiveModel write_attribute(attribute_name, value) end end - - module AttributeMethods #:nodoc: - AttrNames = Module.new { - def self.set_name_cache(name, value) - const_name = "ATTR_#{name}" - unless const_defined? const_name - const_set const_name, -value - end - end - } - end end diff --git a/activemodel/lib/active_model/callbacks.rb b/activemodel/lib/active_model/callbacks.rb index fde3381df2..ea2ed7dff7 100644 --- a/activemodel/lib/active_model/callbacks.rb +++ b/activemodel/lib/active_model/callbacks.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_support/core_ext/array/extract_options" +require "active_support/core_ext/hash/keys" module ActiveModel # == Active \Model \Callbacks diff --git a/activemodel/lib/active_model/conversion.rb b/activemodel/lib/active_model/conversion.rb index cdc1282817..82713ddc81 100644 --- a/activemodel/lib/active_model/conversion.rb +++ b/activemodel/lib/active_model/conversion.rb @@ -103,7 +103,7 @@ module ActiveModel @_to_partial_path ||= begin element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name)) collection = ActiveSupport::Inflector.tableize(name) - "#{collection}/#{element}".freeze + "#{collection}/#{element}" end end end diff --git a/activemodel/lib/active_model/dirty.rb b/activemodel/lib/active_model/dirty.rb index 093052a70c..0d9e761b1e 100644 --- a/activemodel/lib/active_model/dirty.rb +++ b/activemodel/lib/active_model/dirty.rb @@ -141,7 +141,9 @@ module ActiveModel @mutations_from_database = nil end - def changes_applied # :nodoc: + # Clears dirty data and moves +changes+ to +previously_changed+ and + # +mutations_from_database+ to +mutations_before_last_save+ respectively. + def changes_applied unless defined?(@attributes) @previously_changed = changes end @@ -151,7 +153,7 @@ module ActiveModel @mutations_from_database = nil end - # Returns +true+ if any of the attributes have unsaved changes, +false+ otherwise. + # Returns +true+ if any of the attributes has unsaved changes, +false+ otherwise. # # person.changed? # => false # person.name = 'bob' diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index edc30ee64d..3a692a3e64 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -63,9 +63,9 @@ module ActiveModel MESSAGE_OPTIONS = [:message] class << self - attr_accessor :i18n_full_message # :nodoc: + attr_accessor :i18n_customize_full_message # :nodoc: end - self.i18n_full_message = false + self.i18n_customize_full_message = false attr_reader :messages, :details @@ -112,6 +112,17 @@ module ActiveModel @details.merge!(other.details) { |_, ary1, ary2| ary1 + ary2 } end + # Removes all errors except the given keys. Returns a hash containing the removed errors. + # + # person.errors.keys # => [:name, :age, :gender, :city] + # person.errors.slice!(:age, :gender) # => { :name=>["cannot be nil"], :city=>["cannot be nil"] } + # person.errors.keys # => [:age, :gender] + def slice!(*keys) + keys = keys.map(&:to_sym) + @details.slice!(*keys) + @messages.slice!(*keys) + end + # Clear the error messages. # # person.errors.full_messages # => ["name cannot be nil"] @@ -317,21 +328,42 @@ module ActiveModel # person.errors.added? :name, :blank # => true # person.errors.added? :name, "can't be blank" # => true # - # If the error message requires an option, then it returns +true+ with - # the correct option, or +false+ with an incorrect or missing option. + # If the error message 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 + # 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, message = :invalid, options = {}) + message = message.call if message.respond_to?(:call) + if message.is_a? Symbol - self.details[attribute.to_sym].map { |e| e[:error] }.include? message + details[attribute.to_sym].include? normalize_detail(message, options) + else + self[attribute].include? message + end + end + + # Returns +true+ if an error on the attribute with the given message is + # present, or +false+ otherwise. +message+ 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, message = :invalid) + message = message.call if message.respond_to?(:call) + + if message.is_a? Symbol + details[attribute.to_sym].map { |e| e[:error] }.include? message else - message = message.call if message.respond_to?(:call) - message = normalize_message(attribute, message, options) self[attribute].include? message end end @@ -379,9 +411,11 @@ module ActiveModel # * <tt>errors.format</tt> def full_message(attribute, message) return message if attribute == :base + attribute = attribute.to_s - if self.class.i18n_full_message && @base.class.respond_to?(:i18n_scope) - parts = attribute.to_s.split(".") + if self.class.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" @@ -410,8 +444,9 @@ module ActiveModel defaults << :"errors.format" defaults << "%{attribute} %{message}" - attr_name = attribute.to_s.tr(".", "_").humanize + attr_name = attribute.tr(".", "_").humanize attr_name = @base.class.human_attribute_name(attribute, default: attr_name) + I18n.t(defaults.shift, default: defaults, attribute: attr_name, @@ -444,6 +479,14 @@ module ActiveModel # * <tt>errors.messages.blank</tt> def generate_message(attribute, type = :invalid, options = {}) type = options.delete(:message) if options[:message].is_a?(Symbol) + value = (attribute != :base ? @base.send(:read_attribute_for_validation, attribute) : nil) + + options = { + model: @base.model_name.human, + attribute: @base.class.human_attribute_name(attribute), + value: value, + object: @base + }.merge!(options) if @base.class.respond_to?(:i18n_scope) i18n_scope = @base.class.i18n_scope.to_s @@ -452,6 +495,11 @@ module ActiveModel :"#{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 @@ -461,15 +509,7 @@ module ActiveModel key = defaults.shift defaults = options.delete(:message) if options[:message] - value = (attribute != :base ? @base.send(:read_attribute_for_validation, attribute) : nil) - - options = { - default: defaults, - model: @base.model_name.human, - attribute: @base.class.human_attribute_name(attribute), - value: value, - object: @base - }.merge!(options) + options[:default] = defaults I18n.translate(key, options) end diff --git a/activemodel/lib/active_model/gem_version.rb b/activemodel/lib/active_model/gem_version.rb index cef5441e4a..1626aac468 100644 --- a/activemodel/lib/active_model/gem_version.rb +++ b/activemodel/lib/active_model/gem_version.rb @@ -10,7 +10,7 @@ module ActiveModel MAJOR = 6 MINOR = 0 TINY = 0 - PRE = "alpha" + PRE = "beta3" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activemodel/lib/active_model/naming.rb b/activemodel/lib/active_model/naming.rb index dfccd03cd8..bf23fa3c05 100644 --- a/activemodel/lib/active_model/naming.rb +++ b/activemodel/lib/active_model/naming.rb @@ -111,6 +111,22 @@ module ActiveModel # 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: @@ -131,7 +147,7 @@ module ActiveModel # to_str() # # Equivalent to +to_s+. - delegate :==, :===, :<=>, :=~, :"!~", :eql?, :to_s, + delegate :==, :===, :<=>, :=~, :"!~", :eql?, :match?, :to_s, :to_str, :as_json, to: :name # Returns a new ActiveModel::Name instance. By default, the +namespace+ @@ -193,7 +209,7 @@ module ActiveModel private def _singularize(string) - ActiveSupport::Inflector.underscore(string).tr("/".freeze, "_".freeze) + ActiveSupport::Inflector.underscore(string).tr("/", "_") end end @@ -236,7 +252,7 @@ module ActiveModel # Person.model_name.plural # => "people" def model_name @_model_name ||= begin - namespace = parents.detect do |n| + namespace = module_parents.detect do |n| n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming? end ActiveModel::Name.new(self, namespace) diff --git a/activemodel/lib/active_model/railtie.rb b/activemodel/lib/active_model/railtie.rb index 0ed70bd473..eb7901c7e9 100644 --- a/activemodel/lib/active_model/railtie.rb +++ b/activemodel/lib/active_model/railtie.rb @@ -13,8 +13,8 @@ module ActiveModel ActiveModel::SecurePassword.min_cost = Rails.env.test? end - initializer "active_model.i18n_full_message" do - ActiveModel::Errors.i18n_full_message = config.active_model.delete(:i18n_full_message) || false + initializer "active_model.i18n_customize_full_message" do + ActiveModel::Errors.i18n_customize_full_message = config.active_model.delete(:i18n_customize_full_message) || false end end end diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb index 51d54f34f3..5f409326bd 100644 --- a/activemodel/lib/active_model/secure_password.rb +++ b/activemodel/lib/active_model/secure_password.rb @@ -69,6 +69,27 @@ module ActiveModel 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.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| @@ -101,21 +122,6 @@ module ActiveModel end alias_method :authenticate, :authenticate_password if attribute == :password - - 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.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 end diff --git a/activemodel/lib/active_model/serializers/json.rb b/activemodel/lib/active_model/serializers/json.rb index 25e1541d66..f77fb98c32 100644 --- a/activemodel/lib/active_model/serializers/json.rb +++ b/activemodel/lib/active_model/serializers/json.rb @@ -26,13 +26,13 @@ module ActiveModel # user = User.find(1) # user.as_json # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, - # # "created_at" => "2006/08/01", "awesome" => true} + # # "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/01", "awesome" => true } } + # # "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: @@ -40,7 +40,7 @@ module ActiveModel # user = User.find(1) # user.as_json(root: true) # # => { "user" => { "id" => 1, "name" => "Konata Izumi", "age" => 16, - # # "created_at" => "2006/08/01", "awesome" => true } } + # # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true } } # # Without any +options+, the returned Hash will include all the model's # attributes. @@ -48,7 +48,7 @@ module ActiveModel # user = User.find(1) # user.as_json # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, - # # "created_at" => "2006/08/01", "awesome" => true} + # # "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. @@ -63,14 +63,14 @@ module ActiveModel # # user.as_json(methods: :permalink) # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, - # # "created_at" => "2006/08/01", "awesome" => true, + # # "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/01", "awesome" => true, + # # "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" } ] } # @@ -81,7 +81,7 @@ module ActiveModel # only: :body } }, # only: :title } }) # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, - # # "created_at" => "2006/08/01", "awesome" => true, + # # "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" } ], @@ -93,11 +93,12 @@ module ActiveModel include_root_in_json end + hash = serializable_hash(options).as_json if root root = model_name.element if root == true - { root => serializable_hash(options) } + { root => hash } else - serializable_hash(options) + hash end end diff --git a/activemodel/lib/active_model/type/boolean.rb b/activemodel/lib/active_model/type/boolean.rb index f6c6efbc87..e64d2c793c 100644 --- a/activemodel/lib/active_model/type/boolean.rb +++ b/activemodel/lib/active_model/type/boolean.rb @@ -14,7 +14,16 @@ module ActiveModel # - Empty strings are coerced to +nil+ # - All other values will be coerced to +true+ class Boolean < Value - FALSE_VALUES = [false, 0, "0", "f", "F", "false", "FALSE", "off", "OFF"].to_set + 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 diff --git a/activemodel/lib/active_model/type/date.rb b/activemodel/lib/active_model/type/date.rb index 8ec5deedc4..c5fe926039 100644 --- a/activemodel/lib/active_model/type/date.rb +++ b/activemodel/lib/active_model/type/date.rb @@ -3,16 +3,13 @@ module ActiveModel module Type class Date < Value # :nodoc: + include Helpers::Timezone include Helpers::AcceptsMultiparameterTime.new def type :date end - def serialize(value) - cast(value) - end - def type_cast_for_schema(value) value.to_s(:db).inspect end @@ -49,7 +46,7 @@ module ActiveModel def value_from_multiparameter_assignment(*) time = super - time && time.to_date + time && new_date(time.year, time.mon, time.mday) end end end diff --git a/activemodel/lib/active_model/type/date_time.rb b/activemodel/lib/active_model/type/date_time.rb index 9641bf45ee..133410e821 100644 --- a/activemodel/lib/active_model/type/date_time.rb +++ b/activemodel/lib/active_model/type/date_time.rb @@ -3,6 +3,7 @@ module ActiveModel module Type class DateTime < Value # :nodoc: + include Helpers::Timezone include Helpers::TimeValue include Helpers::AcceptsMultiparameterTime.new( defaults: { 4 => 0, 5 => 0 } @@ -12,10 +13,6 @@ module ActiveModel :datetime end - def serialize(value) - super(cast(value)) - end - private def cast_value(value) @@ -39,9 +36,9 @@ module ActiveModel end def value_from_multiparameter_assignment(values_hash) - missing_parameter = (1..3).detect { |key| !values_hash.key?(key) } - if missing_parameter - raise ArgumentError, missing_parameter + missing_parameters = (1..3).select { |key| !values_hash.key?(key) } + if missing_parameters.any? + raise ArgumentError, "Provided hash #{values_hash} doesn't contain necessary keys: #{missing_parameters}" end super end diff --git a/activemodel/lib/active_model/type/float.rb b/activemodel/lib/active_model/type/float.rb index 9dbe32e5a6..ea1987df7c 100644 --- a/activemodel/lib/active_model/type/float.rb +++ b/activemodel/lib/active_model/type/float.rb @@ -18,8 +18,6 @@ module ActiveModel end end - alias serialize cast - private def cast_value(value) diff --git a/activemodel/lib/active_model/type/helpers.rb b/activemodel/lib/active_model/type/helpers.rb index 403f0a9e6b..20145d5f0d 100644 --- a/activemodel/lib/active_model/type/helpers.rb +++ b/activemodel/lib/active_model/type/helpers.rb @@ -4,3 +4,4 @@ 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" diff --git a/activemodel/lib/active_model/type/helpers/accepts_multiparameter_time.rb b/activemodel/lib/active_model/type/helpers/accepts_multiparameter_time.rb index ad891f841e..e15d7b013f 100644 --- a/activemodel/lib/active_model/type/helpers/accepts_multiparameter_time.rb +++ b/activemodel/lib/active_model/type/helpers/accepts_multiparameter_time.rb @@ -5,6 +5,10 @@ module ActiveModel module Helpers # :nodoc: all class AcceptsMultiparameterTime < Module def initialize(defaults: {}) + define_method(:serialize) do |value| + super(cast(value)) + end + define_method(:cast) do |value| if value.is_a?(Hash) value_from_multiparameter_assignment(value) diff --git a/activemodel/lib/active_model/type/helpers/numeric.rb b/activemodel/lib/active_model/type/helpers/numeric.rb index 16e14f9e5f..1d8171e25b 100644 --- a/activemodel/lib/active_model/type/helpers/numeric.rb +++ b/activemodel/lib/active_model/type/helpers/numeric.rb @@ -4,6 +4,10 @@ module ActiveModel module Type module Helpers # :nodoc: all module Numeric + def serialize(value) + cast(value) + end + def cast(value) value = \ case value @@ -22,15 +26,18 @@ module ActiveModel private def number_to_non_number?(old_value, new_value_before_type_cast) - old_value != nil && non_numeric_string?(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. - value.to_s !~ /\A-?\d+\.?\d*\z/ + !NUMERIC_REGEX.match?(value) end + + NUMERIC_REGEX = /\A\s*[+-]?\d/ + private_constant :NUMERIC_REGEX end end end diff --git a/activemodel/lib/active_model/type/helpers/time_value.rb b/activemodel/lib/active_model/type/helpers/time_value.rb index cb6aa67a9d..735b9a75a6 100644 --- a/activemodel/lib/active_model/type/helpers/time_value.rb +++ b/activemodel/lib/active_model/type/helpers/time_value.rb @@ -21,18 +21,6 @@ module ActiveModel value end - def is_utc? - ::Time.zone_default.nil? || ::Time.zone_default =~ "UTC" - end - - def default_timezone - if is_utc? - :utc - else - :local - end - end - def apply_seconds_precision(value) return value unless precision && value.respond_to?(:usec) number_of_insignificant_digits = 6 - precision @@ -70,7 +58,13 @@ module ActiveModel # Doesn't handle time zones. def fast_string_to_time(string) if string =~ ISO_DATETIME - microsec = ($7.to_r * 1_000_000).to_i + microsec_part = $7 + if microsec_part && microsec_part.start_with?(".") && microsec_part.length == 7 + microsec_part[0] = "" + microsec = microsec_part.to_i + else + microsec = (microsec_part.to_r * 1_000_000).to_i + end new_time $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, microsec end end diff --git a/activemodel/lib/active_model/type/helpers/timezone.rb b/activemodel/lib/active_model/type/helpers/timezone.rb new file mode 100644 index 0000000000..cf87b9715b --- /dev/null +++ b/activemodel/lib/active_model/type/helpers/timezone.rb @@ -0,0 +1,19 @@ +# 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 =~ "UTC" + end + + def default_timezone + is_utc? ? :utc : :local + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/integer.rb b/activemodel/lib/active_model/type/integer.rb index da74aaa3c5..1e1061ff60 100644 --- a/activemodel/lib/active_model/type/integer.rb +++ b/activemodel/lib/active_model/type/integer.rb @@ -18,35 +18,23 @@ module ActiveModel :integer end - def deserialize(value) - return if value.nil? - value.to_i - end - def serialize(value) - result = cast(value) - if result - ensure_in_range(result) - end - result + return if value.is_a?(::String) && non_numeric_string?(value) + ensure_in_range(super) end private attr_reader :range def cast_value(value) - case value - when true then 1 - when false then 0 - else - value.to_i rescue nil - end + value.to_i rescue nil end def ensure_in_range(value) - unless range.cover?(value) + if value && !range.cover?(value) raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{_limit} bytes" end + value end def max_value diff --git a/activemodel/lib/active_model/type/string.rb b/activemodel/lib/active_model/type/string.rb index 36f13945b1..a9c9bfadb6 100644 --- a/activemodel/lib/active_model/type/string.rb +++ b/activemodel/lib/active_model/type/string.rb @@ -16,8 +16,8 @@ module ActiveModel def cast_value(value) case value when ::String then ::String.new(value) - when true then "t".freeze - when false then "f".freeze + when true then "t" + when false then "f" else value.to_s end end diff --git a/activemodel/lib/active_model/type/time.rb b/activemodel/lib/active_model/type/time.rb index b3056b1333..61847a4ce7 100644 --- a/activemodel/lib/active_model/type/time.rb +++ b/activemodel/lib/active_model/type/time.rb @@ -3,9 +3,10 @@ module ActiveModel module Type class Time < Value # :nodoc: + include Helpers::Timezone include Helpers::TimeValue include Helpers::AcceptsMultiparameterTime.new( - defaults: { 1 => 1970, 2 => 1, 3 => 1, 4 => 0, 5 => 0 } + defaults: { 1 => 2000, 2 => 1, 3 => 1, 4 => 0, 5 => 0 } ) def type diff --git a/activemodel/lib/active_model/validations.rb b/activemodel/lib/active_model/validations.rb index 7f14d102dd..f18f9a601a 100644 --- a/activemodel/lib/active_model/validations.rb +++ b/activemodel/lib/active_model/validations.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true require "active_support/core_ext/array/extract_options" -require "active_support/core_ext/hash/keys" -require "active_support/core_ext/hash/except" module ActiveModel # == Active \Model \Validations diff --git a/activemodel/lib/active_model/validations/acceptance.rb b/activemodel/lib/active_model/validations/acceptance.rb index ea3a6b52ab..6fd54270f2 100644 --- a/activemodel/lib/active_model/validations/acceptance.rb +++ b/activemodel/lib/active_model/validations/acceptance.rb @@ -54,8 +54,9 @@ module ActiveModel def define_on(klass) attr_readers = attributes.reject { |name| klass.attribute_method?(name) } attr_writers = attributes.reject { |name| klass.attribute_method?("#{name}=") } - klass.send(:attr_reader, *attr_readers) - klass.send(:attr_writer, *attr_writers) + klass.define_attribute_methods + klass.attr_reader(*attr_readers) + klass.attr_writer(*attr_writers) end private diff --git a/activemodel/lib/active_model/validations/confirmation.rb b/activemodel/lib/active_model/validations/confirmation.rb index 1b5d5b09ab..b549755ba4 100644 --- a/activemodel/lib/active_model/validations/confirmation.rb +++ b/activemodel/lib/active_model/validations/confirmation.rb @@ -19,11 +19,11 @@ module ActiveModel private def setup!(klass) - klass.send(:attr_reader, *attributes.map do |attribute| + klass.attr_reader(*attributes.map do |attribute| :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation") end.compact) - klass.send(:attr_writer, *attributes.map do |attribute| + klass.attr_writer(*attributes.map do |attribute| :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation=") end.compact) end diff --git a/activemodel/lib/active_model/validations/length.rb b/activemodel/lib/active_model/validations/length.rb index d6c80b2c5d..02759b4ccb 100644 --- a/activemodel/lib/active_model/validations/length.rb +++ b/activemodel/lib/active_model/validations/length.rb @@ -32,7 +32,7 @@ module ActiveModel 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 nonnegative Integer, Infinity, Symbol, or Proc" + raise ArgumentError, ":#{key} must be a non-negative Integer, Infinity, Symbol, or Proc" end end end diff --git a/activemodel/lib/active_model/validations/numericality.rb b/activemodel/lib/active_model/validations/numericality.rb index 0478915be7..51e224d5cd 100644 --- a/activemodel/lib/active_model/validations/numericality.rb +++ b/activemodel/lib/active_model/validations/numericality.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "bigdecimal/util" + module ActiveModel module Validations class NumericalityValidator < EachValidator # :nodoc: @@ -9,6 +11,8 @@ module ActiveModel RESERVED_OPTIONS = CHECKS.keys + [:only_integer] + INTEGER_REGEX = /\A[+-]?\d+\z/ + def check_validity! keys = CHECKS.keys - [:odd, :even] options.slice(*keys).each do |option, value| @@ -21,8 +25,17 @@ module ActiveModel def validate_each(record, attr_name, value) came_from_user = :"#{attr_name}_came_from_user?" - if record.respond_to?(came_from_user) && record.public_send(came_from_user) - raw_value = record.read_attribute_before_type_cast(attr_name) + if record.respond_to?(came_from_user) + if record.public_send(came_from_user) + raw_value = record.read_attribute_before_type_cast(attr_name) + 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 @@ -40,11 +53,7 @@ module ActiveModel return end - if raw_value.is_a?(Numeric) - value = raw_value - else - value = parse_raw_value_as_a_number(raw_value) - end + value = parse_as_number(raw_value) options.slice(*CHECKS.keys).each do |option, option_value| case option @@ -60,6 +69,8 @@ module ActiveModel option_value = record.send(option_value) end + option_value = parse_as_number(option_value) + unless value.send(CHECKS[option], option_value) record.errors.add(attr_name, option, filtered_options(value).merge!(count: option_value)) end @@ -70,18 +81,29 @@ module ActiveModel private def is_number?(raw_value) - !parse_raw_value_as_a_number(raw_value).nil? + !parse_as_number(raw_value).nil? rescue ArgumentError, TypeError false end - def parse_raw_value_as_a_number(raw_value) - return raw_value.to_i if is_integer?(raw_value) - Kernel.Float(raw_value) if raw_value !~ /\A0[xX]/ + def parse_as_number(raw_value) + if raw_value.is_a?(Float) + raw_value.to_d + elsif raw_value.is_a?(Numeric) + raw_value + elsif is_integer?(raw_value) + raw_value.to_i + elsif !is_hexadecimal_literal?(raw_value) + Kernel.Float(raw_value).to_d + end end def is_integer?(raw_value) - /\A[+-]?\d+\z/ === raw_value.to_s + INTEGER_REGEX.match?(raw_value.to_s) + end + + def is_hexadecimal_literal?(raw_value) + /\A0[xX]/.match?(raw_value.to_s) end def filtered_options(value) diff --git a/activemodel/lib/active_model/validations/validates.rb b/activemodel/lib/active_model/validations/validates.rb index 88cca318ef..21c4ce0dfe 100644 --- a/activemodel/lib/active_model/validations/validates.rb +++ b/activemodel/lib/active_model/validations/validates.rb @@ -116,7 +116,7 @@ module ActiveModel key = "#{key.to_s.camelize}Validator" begin - validator = key.include?("::".freeze) ? key.constantize : const_get(key) + validator = key.include?("::") ? key.constantize : const_get(key) rescue NameError raise ArgumentError, "Unknown validator: '#{key}'" end diff --git a/activemodel/lib/active_model/validator.rb b/activemodel/lib/active_model/validator.rb index e17c3ca7b3..94d53b8dd1 100644 --- a/activemodel/lib/active_model/validator.rb +++ b/activemodel/lib/active_model/validator.rb @@ -90,7 +90,7 @@ module ActiveModel # class MyValidator < ActiveModel::Validator # def initialize(options={}) # super - # options[:class].send :attr_accessor, :custom_attribute + # options[:class].attr_accessor :custom_attribute # end # end class Validator diff --git a/activemodel/test/cases/attribute_assignment_test.rb b/activemodel/test/cases/attribute_assignment_test.rb index b06291f1b4..30e8419685 100644 --- a/activemodel/test/cases/attribute_assignment_test.rb +++ b/activemodel/test/cases/attribute_assignment_test.rb @@ -100,9 +100,11 @@ class AttributeAssignmentTest < ActiveModel::TestCase end test "an ArgumentError is raised if a non-hash-like object is passed" do - assert_raises(ArgumentError) 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 diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb index 0cfc6f4b6b..ebb6cc542d 100644 --- a/activemodel/test/cases/attribute_methods_test.rb +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -106,14 +106,12 @@ class AttributeMethodsTest < ActiveModel::TestCase end test "#define_attribute_method generates attribute method" do - begin - ModelWithAttributes.define_attribute_method(:foo) + 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 + 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 @@ -140,36 +138,30 @@ class AttributeMethodsTest < ActiveModel::TestCase end test "#define_attribute_method generates attribute method with invalid identifier characters" do - begin - ModelWithWeirdNamesAttributes.define_attribute_method(:'a?b') + ModelWithWeirdNamesAttributes.define_attribute_method(:'a?b') - assert_respond_to ModelWithWeirdNamesAttributes.new, :'a?b' - assert_equal "value of a?b", ModelWithWeirdNamesAttributes.new.send("a?b") - ensure - ModelWithWeirdNamesAttributes.undefine_attribute_methods - end + assert_respond_to ModelWithWeirdNamesAttributes.new, :'a?b' + assert_equal "value of a?b", ModelWithWeirdNamesAttributes.new.send("a?b") + ensure + ModelWithWeirdNamesAttributes.undefine_attribute_methods end test "#define_attribute_methods works passing multiple arguments" do - begin - ModelWithAttributes.define_attribute_methods(:foo, :baz) + 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 + 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 - begin - ModelWithAttributes.define_attribute_methods(:foo) + 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 + 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 @@ -182,38 +174,32 @@ class AttributeMethodsTest < ActiveModel::TestCase end test "#define_attribute_methods generates attribute methods with spaces in their names" do - begin - ModelWithAttributesWithSpaces.define_attribute_methods(:'foo bar') + ModelWithAttributesWithSpaces.define_attribute_methods(:'foo bar') - assert_respond_to ModelWithAttributesWithSpaces.new, :'foo bar' - assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.send(:'foo bar') - ensure - ModelWithAttributesWithSpaces.undefine_attribute_methods - end + assert_respond_to ModelWithAttributesWithSpaces.new, :'foo bar' + assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.send(:'foo bar') + ensure + ModelWithAttributesWithSpaces.undefine_attribute_methods end test "#alias_attribute works with attributes with spaces in their names" do - begin - ModelWithAttributesWithSpaces.define_attribute_methods(:'foo bar') - ModelWithAttributesWithSpaces.alias_attribute(:'foo_bar', :'foo bar') + 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 + 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 - begin - 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 + 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 diff --git a/activemodel/test/cases/attribute_set_test.rb b/activemodel/test/cases/attribute_set_test.rb index b868dba743..62feb9074e 100644 --- a/activemodel/test/cases/attribute_set_test.rb +++ b/activemodel/test/cases/attribute_set_test.rb @@ -217,7 +217,7 @@ module ActiveModel assert_equal({ foo: "1" }, attributes.to_hash) end - test "marshaling dump/load legacy materialized attribute hash" do + test "marshalling dump/load legacy materialized attribute hash" do builder = AttributeSet::Builder.new(foo: Type::String.new) attributes = builder.build_from_database(foo: "1") diff --git a/activemodel/test/cases/attribute_test.rb b/activemodel/test/cases/attribute_test.rb index ea2b0efd11..097db2e923 100644 --- a/activemodel/test/cases/attribute_test.rb +++ b/activemodel/test/cases/attribute_test.rb @@ -78,7 +78,7 @@ module ActiveModel end test "duping dups the value" do - @type.expect(:deserialize, "type cast".dup, ["a value"]) + @type.expect(:deserialize, +"type cast", ["a value"]) attribute = Attribute.from_database(nil, "a value", @type) value_from_orig = attribute.value @@ -204,7 +204,7 @@ module ActiveModel assert_not_predicate unchanged, :changed? end - test "an attribute can not be mutated if it has not been read, + 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) @@ -246,7 +246,7 @@ module ActiveModel end test "with_type preserves mutations" do - attribute = Attribute.from_database(:foo, "".dup, Type::Value.new) + attribute = Attribute.from_database(:foo, +"", Type::Value.new) attribute.value << "1" assert_equal 1, attribute.with_type(Type::Integer.new).value diff --git a/activemodel/test/cases/callbacks_test.rb b/activemodel/test/cases/callbacks_test.rb index 1ec12d8222..0711dc56ca 100644 --- a/activemodel/test/cases/callbacks_test.rb +++ b/activemodel/test/cases/callbacks_test.rb @@ -112,7 +112,7 @@ class CallbacksTest < ActiveModel::TestCase def callback1; history << "callback1"; end def callback2; history << "callback2"; end def create - run_callbacks(:create) {} + run_callbacks(:create) { } self end end diff --git a/activemodel/test/cases/dirty_test.rb b/activemodel/test/cases/dirty_test.rb index b120e68027..0edbbffa86 100644 --- a/activemodel/test/cases/dirty_test.rb +++ b/activemodel/test/cases/dirty_test.rb @@ -14,37 +14,23 @@ class DirtyTest < ActiveModel::TestCase @status = "initialized" end - def name - @name - end + attr_reader :name, :color, :size, :status def name=(val) name_will_change! @name = val end - def color - @color - end - def color=(val) color_will_change! unless val == @color @color = val end - def size - @size - end - def size=(val) attribute_will_change!(:size) unless val == @size @size = val end - def status - @status - end - def status=(val) status_will_change! unless val == @status @status = val @@ -108,7 +94,7 @@ class DirtyTest < ActiveModel::TestCase end test "attribute mutation" do - @model.instance_variable_set("@name", "Yam".dup) + @model.instance_variable_set("@name", +"Yam") assert_not_predicate @model, :name_changed? @model.name.replace("Hadad") assert_not_predicate @model, :name_changed? diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb index 41ff6443fe..947f9bf99b 100644 --- a/activemodel/test/cases/errors_test.rb +++ b/activemodel/test/cases/errors_test.rb @@ -209,6 +209,8 @@ class ErrorsTest < ActiveModel::TestCase 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 @@ -222,17 +224,103 @@ class ErrorsTest < ActiveModel::TestCase assert_not person.errors.added?(:name, "cannot be blank") end - test "added? returns false when checking for an error, but not providing message arguments" do + 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 @@ -401,6 +489,30 @@ class ErrorsTest < ActiveModel::TestCase assert_equal({ name: [{ error: :blank }, { error: :invalid }] }, person.errors.details) end + test "slice! removes all errors except the given keys" do + person = Person.new + person.errors.add(:name, "cannot be nil") + person.errors.add(:age, "cannot be nil") + person.errors.add(:gender, "cannot be nil") + person.errors.add(:city, "cannot be nil") + + person.errors.slice!(:age, "gender") + + assert_equal [:age, :gender], person.errors.keys + end + + test "slice! returns the deleted errors" do + person = Person.new + person.errors.add(:name, "cannot be nil") + person.errors.add(:age, "cannot be nil") + person.errors.add(:gender, "cannot be nil") + person.errors.add(:city, "cannot be nil") + + removed_errors = person.errors.slice!(:age, "gender") + + assert_equal({ name: ["cannot be nil"], city: ["cannot be nil"] }, removed_errors) + end + test "errors are marshalable" do errors = ActiveModel::Errors.new(Person.new) errors.add(:name, :invalid) diff --git a/activemodel/test/cases/helper.rb b/activemodel/test/cases/helper.rb index 91fb9d0a7c..a4cb472ffc 100644 --- a/activemodel/test/cases/helper.rb +++ b/activemodel/test/cases/helper.rb @@ -14,12 +14,16 @@ require "active_support/testing/method_call_assertions" class ActiveModel::TestCase < ActiveSupport::TestCase include ActiveSupport::Testing::MethodCallAssertions - # Skips the current run on Rubinius using Minitest::Assertions#skip - private def rubinius_skip(message = "") - skip message if RUBY_ENGINE == "rbx" - end - # Skips the current run on JRuby using Minitest::Assertions#skip - private def jruby_skip(message = "") - skip message if defined?(JRUBY_VERSION) - end + 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" diff --git a/activemodel/test/cases/railtie_test.rb b/activemodel/test/cases/railtie_test.rb index ab60285e2a..95ee7cace3 100644 --- a/activemodel/test/cases/railtie_test.rb +++ b/activemodel/test/cases/railtie_test.rb @@ -32,23 +32,23 @@ class RailtieTest < ActiveModel::TestCase assert_equal true, ActiveModel::SecurePassword.min_cost end - test "i18n full message defaults to false" do + test "i18n customize full message defaults to false" do @app.initialize! - assert_equal false, ActiveModel::Errors.i18n_full_message + assert_equal false, ActiveModel::Errors.i18n_customize_full_message end - test "i18n full message can be disabled" do - @app.config.active_model.i18n_full_message = false + test "i18n customize full message can be disabled" do + @app.config.active_model.i18n_customize_full_message = false @app.initialize! - assert_equal false, ActiveModel::Errors.i18n_full_message + assert_equal false, ActiveModel::Errors.i18n_customize_full_message end - test "i18n full message can be enabled" do - @app.config.active_model.i18n_full_message = true + test "i18n customize full message can be enabled" do + @app.config.active_model.i18n_customize_full_message = true @app.initialize! - assert_equal true, ActiveModel::Errors.i18n_full_message + assert_equal true, ActiveModel::Errors.i18n_customize_full_message end end diff --git a/activemodel/test/cases/secure_password_test.rb b/activemodel/test/cases/secure_password_test.rb index 9ef1148be8..0aca714bd2 100644 --- a/activemodel/test/cases/secure_password_test.rb +++ b/activemodel/test/cases/secure_password_test.rb @@ -184,6 +184,20 @@ class SecurePasswordTest < ActiveModel::TestCase 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" @@ -206,16 +220,14 @@ class SecurePasswordTest < ActiveModel::TestCase end test "Password digest cost honors bcrypt cost attribute when min_cost is false" do - begin - 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 + 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 diff --git a/activemodel/test/cases/serializers/json_serialization_test.rb b/activemodel/test/cases/serializers/json_serialization_test.rb index aae98c9fe4..84efc8de0d 100644 --- a/activemodel/test/cases/serializers/json_serialization_test.rb +++ b/activemodel/test/cases/serializers/json_serialization_test.rb @@ -26,20 +26,18 @@ class JsonSerializationTest < ActiveModel::TestCase end test "should include root in json if include_root_in_json is true" do - begin - 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 + 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 @@ -129,20 +127,22 @@ class JsonSerializationTest < ActiveModel::TestCase 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 - begin - 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.send(field), json["contact"][field] - end - ensure - Contact.include_root_in_json = original_include_root_in_json + 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.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 diff --git a/activemodel/test/cases/type/boolean_test.rb b/activemodel/test/cases/type/boolean_test.rb index 2de0f53640..7f8490b2fe 100644 --- a/activemodel/test/cases/type/boolean_test.rb +++ b/activemodel/test/cases/type/boolean_test.rb @@ -23,6 +23,13 @@ module ActiveModel 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) @@ -34,6 +41,13 @@ module ActiveModel 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 diff --git a/activemodel/test/cases/type/date_test.rb b/activemodel/test/cases/type/date_test.rb index e8cf178612..2dd1a55616 100644 --- a/activemodel/test/cases/type/date_test.rb +++ b/activemodel/test/cases/type/date_test.rb @@ -12,8 +12,22 @@ module ActiveModel assert_nil type.cast(" ") assert_nil type.cast("ABC") - date_string = ::Time.now.utc.strftime("%F") + 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 diff --git a/activemodel/test/cases/type/date_time_test.rb b/activemodel/test/cases/type/date_time_test.rb index 60f62becc2..74b47d1b4d 100644 --- a/activemodel/test/cases/type/date_time_test.rb +++ b/activemodel/test/cases/type/date_time_test.rb @@ -25,6 +25,17 @@ module ActiveModel 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:) diff --git a/activemodel/test/cases/type/decimal_test.rb b/activemodel/test/cases/type/decimal_test.rb index c0cf6ce590..be60c4f7fa 100644 --- a/activemodel/test/cases/type/decimal_test.rb +++ b/activemodel/test/cases/type/decimal_test.rb @@ -57,9 +57,12 @@ module ActiveModel def test_changed? type = Decimal.new - assert type.changed?(5.0, 5.0, "5.0wibble") + 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") end def test_scale_is_applied_before_precision_to_prevent_rounding_errors diff --git a/activemodel/test/cases/type/float_test.rb b/activemodel/test/cases/type/float_test.rb index 28318e06f8..230a8dda32 100644 --- a/activemodel/test/cases/type/float_test.rb +++ b/activemodel/test/cases/type/float_test.rb @@ -21,9 +21,12 @@ module ActiveModel def test_changing_float type = Type::Float.new - assert type.changed?(5.0, 5.0, "5wibble") + 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) end end diff --git a/activemodel/test/cases/type/integer_test.rb b/activemodel/test/cases/type/integer_test.rb index 8c5d18c9b3..6c02c01237 100644 --- a/activemodel/test/cases/type/integer_test.rb +++ b/activemodel/test/cases/type/integer_test.rb @@ -50,12 +50,31 @@ module ActiveModel 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?(5, 5, "5wibble") + 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) diff --git a/activemodel/test/cases/type/string_test.rb b/activemodel/test/cases/type/string_test.rb index 825c8bb246..9cc530e8db 100644 --- a/activemodel/test/cases/type/string_test.rb +++ b/activemodel/test/cases/type/string_test.rb @@ -12,14 +12,22 @@ module ActiveModel 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".dup + s = +"foo" assert_equal false, type.cast(s).frozen? assert_equal false, s.frozen? - f = "foo".freeze + f = -"foo" assert_equal false, type.cast(f).frozen? assert_equal true, f.frozen? end diff --git a/activemodel/test/cases/type/time_test.rb b/activemodel/test/cases/type/time_test.rb index 3fbae1a169..5c6271241d 100644 --- a/activemodel/test/cases/type/time_test.rb +++ b/activemodel/test/cases/type/time_test.rb @@ -16,6 +16,7 @@ module ActiveModel 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 diff --git a/activemodel/test/cases/validations/conditional_validation_test.rb b/activemodel/test/cases/validations/conditional_validation_test.rb index 1704db9a48..9674068aff 100644 --- a/activemodel/test/cases/validations/conditional_validation_test.rb +++ b/activemodel/test/cases/validations/conditional_validation_test.rb @@ -49,7 +49,7 @@ class ConditionalValidationTest < ActiveModel::TestCase assert_empty t.errors[:title] end - def test_unless_validation_using_array_of_true_and_felse_methods + 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? @@ -111,14 +111,14 @@ class ConditionalValidationTest < ActiveModel::TestCase assert_equal ["hoo 5"], t.errors["title"] end - def test_validation_using_conbining_if_true_and_unless_true_conditions + 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_conbining_if_true_and_unless_false_conditions + 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? diff --git a/activemodel/test/cases/validations/confirmation_validation_test.rb b/activemodel/test/cases/validations/confirmation_validation_test.rb index 8603a8ac5c..7bf15e4bee 100644 --- a/activemodel/test/cases/validations/confirmation_validation_test.rb +++ b/activemodel/test/cases/validations/confirmation_validation_test.rb @@ -66,24 +66,22 @@ class ConfirmationValidationTest < ActiveModel::TestCase end def test_title_confirmation_with_i18n_attribute - begin - @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 + @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 diff --git a/activemodel/test/cases/validations/i18n_validation_test.rb b/activemodel/test/cases/validations/i18n_validation_test.rb index 1c62e750de..eb03e837f1 100644 --- a/activemodel/test/cases/validations/i18n_validation_test.rb +++ b/activemodel/test/cases/validations/i18n_validation_test.rb @@ -13,8 +13,8 @@ class I18nValidationTest < ActiveModel::TestCase I18n.backend = I18n::Backend::Simple.new I18n.backend.store_translations("en", errors: { messages: { custom: nil } }) - @original_i18n_full_message = ActiveModel::Errors.i18n_full_message - ActiveModel::Errors.i18n_full_message = true + @original_i18n_customize_full_message = ActiveModel::Errors.i18n_customize_full_message + ActiveModel::Errors.i18n_customize_full_message = true end def teardown @@ -22,7 +22,7 @@ class I18nValidationTest < ActiveModel::TestCase I18n.load_path.replace @old_load_path I18n.backend = @old_backend I18n.backend.reload! - ActiveModel::Errors.i18n_full_message = @original_i18n_full_message + ActiveModel::Errors.i18n_customize_full_message = @original_i18n_customize_full_message end def test_full_message_encoding @@ -35,7 +35,7 @@ class I18nValidationTest < ActiveModel::TestCase def test_errors_full_messages_translates_human_attribute_name_for_model_attributes @person.errors.add(:name, "not found") - assert_called_with(Person, :human_attribute_name, [:name, default: "Name"], returns: "Person's name") do + assert_called_with(Person, :human_attribute_name, ["name", default: "Name"], returns: "Person's name") do assert_equal ["Person's name not found"], @person.errors.full_messages end end @@ -47,7 +47,7 @@ class I18nValidationTest < ActiveModel::TestCase end def test_errors_full_messages_doesnt_use_attribute_format_without_config - ActiveModel::Errors.i18n_full_message = false + ActiveModel::Errors.i18n_customize_full_message = false I18n.backend.store_translations("en", activemodel: { errors: { models: { person: { attributes: { name: { format: "%{message}" } } } } } }) @@ -58,7 +58,7 @@ class I18nValidationTest < ActiveModel::TestCase end def test_errors_full_messages_uses_attribute_format - ActiveModel::Errors.i18n_full_message = true + ActiveModel::Errors.i18n_customize_full_message = true I18n.backend.store_translations("en", activemodel: { errors: { models: { person: { attributes: { name: { format: "%{message}" } } } } } }) @@ -69,7 +69,7 @@ class I18nValidationTest < ActiveModel::TestCase end def test_errors_full_messages_uses_model_format - ActiveModel::Errors.i18n_full_message = true + ActiveModel::Errors.i18n_customize_full_message = true I18n.backend.store_translations("en", activemodel: { errors: { models: { person: { format: "%{message}" } } } }) @@ -80,7 +80,7 @@ class I18nValidationTest < ActiveModel::TestCase end def test_errors_full_messages_uses_deeply_nested_model_attributes_format - ActiveModel::Errors.i18n_full_message = true + ActiveModel::Errors.i18n_customize_full_message = true I18n.backend.store_translations("en", activemodel: { errors: { models: { 'person/contacts/addresses': { attributes: { street: { format: "%{message}" } } } } } }) @@ -91,7 +91,7 @@ class I18nValidationTest < ActiveModel::TestCase end def test_errors_full_messages_uses_deeply_nested_model_model_format - ActiveModel::Errors.i18n_full_message = true + ActiveModel::Errors.i18n_customize_full_message = true I18n.backend.store_translations("en", activemodel: { errors: { models: { 'person/contacts/addresses': { format: "%{message}" } } } }) @@ -101,6 +101,63 @@ class I18nValidationTest < ActiveModel::TestCase 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::Errors.i18n_customize_full_message = true + + I18n.backend.store_translations("en", activemodel: { + errors: { models: { 'person/contacts/addresses': { attributes: { street: { format: "%{message}" } } } } } }) + + person = Person.new + assert_equal "cannot be blank", person.errors.full_message(:'contacts[0]/addresses[0].street', "cannot be blank") + assert_equal "Contacts/addresses country cannot be blank", person.errors.full_message(:'contacts[0]/addresses[0].country', "cannot be blank") + end + + def test_errors_full_messages_with_indexed_deeply_nested_attributes_and_model_format + ActiveModel::Errors.i18n_customize_full_message = true + + I18n.backend.store_translations("en", activemodel: { + errors: { models: { 'person/contacts/addresses': { format: "%{message}" } } } }) + + person = Person.new + assert_equal "cannot be blank", person.errors.full_message(:'contacts[0]/addresses[0].street', "cannot be blank") + assert_equal "cannot be blank", person.errors.full_message(:'contacts[0]/addresses[0].country', "cannot be blank") + end + + def test_errors_full_messages_with_indexed_deeply_nested_attributes_and_i18n_attribute_name + ActiveModel::Errors.i18n_customize_full_message = true + + I18n.backend.store_translations("en", activemodel: { + attributes: { 'person/contacts/addresses': { country: "Country" } } + }) + + person = Person.new + assert_equal "Contacts/addresses 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 + + def test_errors_full_messages_with_indexed_deeply_nested_attributes_without_i18n_config + ActiveModel::Errors.i18n_customize_full_message = false + + I18n.backend.store_translations("en", activemodel: { + errors: { models: { 'person/contacts/addresses': { attributes: { street: { format: "%{message}" } } } } } }) + + person = Person.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::Errors.i18n_customize_full_message = false + + I18n.backend.store_translations("en", activemodel: { + attributes: { 'person/contacts[0]/addresses[0]': { country: "Country" } } + }) + + person = Person.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 diff --git a/activemodel/test/cases/validations/length_validation_test.rb b/activemodel/test/cases/validations/length_validation_test.rb index 774a2cde74..37e10f783c 100644 --- a/activemodel/test/cases/validations/length_validation_test.rb +++ b/activemodel/test/cases/validations/length_validation_test.rb @@ -427,7 +427,7 @@ class LengthValidationTest < ActiveModel::TestCase end def test_validates_length_of_using_proc_as_maximum_with_model_method - Topic.send(:define_method, :max_title_length, lambda { 5 }) + 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") diff --git a/activemodel/test/cases/validations/numericality_validation_test.rb b/activemodel/test/cases/validations/numericality_validation_test.rb index 01b78ae72e..16c44762cb 100644 --- a/activemodel/test/cases/validations/numericality_validation_test.rb +++ b/activemodel/test/cases/validations/numericality_validation_test.rb @@ -66,7 +66,7 @@ class NumericalityValidationTest < ActiveModel::TestCase end def test_validates_numericality_of_with_integer_only_and_proc_as_value - Topic.send(:define_method, :allow_only_integers?, lambda { false }) + Topic.define_method(:allow_only_integers?) { false } Topic.validates_numericality_of :approved, only_integer: Proc.new(&:allow_only_integers?) invalid!(NIL + BLANK + JUNK) @@ -214,23 +214,23 @@ class NumericalityValidationTest < ActiveModel::TestCase end def test_validates_numericality_with_proc - Topic.send(:define_method, :min_approved, lambda { 5 }) + Topic.define_method(:min_approved) { 5 } Topic.validates_numericality_of :approved, greater_than_or_equal_to: Proc.new(&:min_approved) invalid!([3, 4]) valid!([5, 6]) ensure - Topic.send(:remove_method, :min_approved) + Topic.remove_method :min_approved end def test_validates_numericality_with_symbol - Topic.send(:define_method, :max_approved, lambda { 5 }) + Topic.define_method(:max_approved) { 5 } Topic.validates_numericality_of :approved, less_than_or_equal_to: :max_approved invalid!([6]) valid!([4, 5]) ensure - Topic.send(:remove_method, :max_approved) + Topic.remove_method :max_approved end def test_validates_numericality_with_numeric_message @@ -262,6 +262,16 @@ class NumericalityValidationTest < ActiveModel::TestCase 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 @@ -271,6 +281,19 @@ class NumericalityValidationTest < ActiveModel::TestCase 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" } @@ -279,6 +302,13 @@ class NumericalityValidationTest < ActiveModel::TestCase assert_raise(ArgumentError) { Topic.validates_numericality_of :approved, equal_to: "foo" } end + def test_validates_numericality_equality_for_float_and_big_decimal + Topic.validates_numericality_of :approved, equal_to: BigDecimal("65.6") + + invalid!([Float("65.5"), BigDecimal("65.7")], "must be equal to 65.6") + valid!([Float("65.6"), BigDecimal("65.6")]) + end + private def invalid!(values, error = nil) diff --git a/activemodel/test/models/topic.rb b/activemodel/test/models/topic.rb index b0af00ee45..db3284f833 100644 --- a/activemodel/test/models/topic.rb +++ b/activemodel/test/models/topic.rb @@ -3,6 +3,11 @@ class Topic include ActiveModel::Validations include ActiveModel::Validations::Callbacks + include ActiveModel::AttributeMethods + include ActiveSupport::NumberHelper + + attribute_method_suffix "_before_type_cast" + define_attribute_method :price def self._validates_default_keys super | [ :message ] @@ -10,6 +15,7 @@ class Topic attr_accessor :title, :author_name, :content, :approved, :created_at attr_accessor :after_validation_performed + attr_writer :price after_validation :perform_after_validation @@ -38,4 +44,12 @@ class Topic def my_validation_with_arg(attr) errors.add attr, "is missing" unless send(attr) end + + def price + number_to_currency @price + end + + def attribute_before_type_cast(attr) + instance_variable_get(:"@#{attr}") + end end diff --git a/activemodel/test/models/user.rb b/activemodel/test/models/user.rb index bb1b187694..fc4a9e4334 100644 --- a/activemodel/test/models/user.rb +++ b/activemodel/test/models/user.rb @@ -10,4 +10,11 @@ class User 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 |