diff options
Diffstat (limited to 'activemodel')
71 files changed, 1219 insertions, 505 deletions
diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 6b557a7cb1..ad87abfa3a 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,6 +1,150 @@ -* Rails 6 requires Ruby 2.4.1 or newer. +* Type cast falsy boolean symbols on boolean attribute as false. - *Jeremy Daer* + 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 + has_secure_password :recovery_password, validations: false + end + + user = User.new() + user.recovery_password = "42password" + user.recovery_password_digest # => "$2a$04$iOfhwahFymCs5weB3BNH/uX..." + user.authenticate_recovery_password('42password') # => user + + *Unathi Chonco* + +* 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.5.0 or newer. + + *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..afeee03d4f 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -355,7 +355,7 @@ module ActiveModel # Must try to match prefixes/suffixes first, or else the matcher with no prefix/suffix # will match every time. matchers = attribute_method_matchers.partition(&:plain?).reverse.flatten(1) - matchers.map { |method| method.match(method_name) }.compact + matchers.map { |matcher| matcher.match(method_name) }.compact end end @@ -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_mutation_tracker.rb b/activemodel/lib/active_model/attribute_mutation_tracker.rb index 6abf37bd44..d8cd48a53b 100644 --- a/activemodel/lib/active_model/attribute_mutation_tracker.rb +++ b/activemodel/lib/active_model/attribute_mutation_tracker.rb @@ -1,14 +1,15 @@ # 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) + def initialize(attributes, forced_changes = Set.new) @attributes = attributes - @forced_changes = Set.new + @forced_changes = forced_changes end def changed_attribute_names @@ -18,24 +19,22 @@ module ActiveModel def changed_values attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| if changed?(attr_name) - result[attr_name] = attributes[attr_name].original_value + result[attr_name] = original_value(attr_name) end end end def changes attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| - change = change_to_attribute(attr_name) - if change + if change = change_to_attribute(attr_name) result.merge!(attr_name => change) end end end def change_to_attribute(attr_name) - attr_name = attr_name.to_s if changed?(attr_name) - [attributes[attr_name].original_value, attributes.fetch_value(attr_name)] + [original_value(attr_name), fetch_value(attr_name)] end end @@ -44,29 +43,26 @@ module ActiveModel end def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN) - attr_name = attr_name.to_s - forced_changes.include?(attr_name) || - attributes[attr_name].changed? && - (OPTION_NOT_GIVEN == from || attributes[attr_name].original_value == from) && - (OPTION_NOT_GIVEN == to || attributes[attr_name].value == to) + 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.to_s].changed_in_place? + attributes[attr_name].changed_in_place? end def forget_change(attr_name) - attr_name = attr_name.to_s attributes[attr_name] = attributes[attr_name].forgetting_assignment forced_changes.delete(attr_name) end def original_value(attr_name) - attributes[attr_name.to_s].original_value + attributes[attr_name].original_value end def force_change(attr_name) - forced_changes << attr_name.to_s + forced_changes << attr_name end private @@ -75,45 +71,108 @@ module ActiveModel 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, forced_changes = {}) + 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(*) + def changed_attribute_names [] end - def changed_values(*) + def changed_values {} end - def changes(*) + def changes {} end def change_to_attribute(attr_name) end - def any_changes?(*) + def any_changes? false end - def changed?(*) + def changed?(attr_name, **) false end - def changed_in_place?(*) + def changed_in_place?(attr_name) false end - def forget_change(*) - end - - def original_value(*) - end - - def force_change(*) + def original_value(attr_name) 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 7d44f7f2a3..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.dup.freeze - end - end - } - end end diff --git a/activemodel/lib/active_model/callbacks.rb b/activemodel/lib/active_model/callbacks.rb index 8fa9680cb1..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 @@ -127,26 +128,28 @@ module ActiveModel private def _define_before_model_callback(klass, callback) - klass.define_singleton_method("before_#{callback}") do |*args, &block| - set_callback(:"#{callback}", :before, *args, &block) + klass.define_singleton_method("before_#{callback}") do |*args, **options, &block| + options.assert_valid_keys(:if, :unless, :prepend) + set_callback(:"#{callback}", :before, *args, options, &block) end end def _define_around_model_callback(klass, callback) - klass.define_singleton_method("around_#{callback}") do |*args, &block| - set_callback(:"#{callback}", :around, *args, &block) + klass.define_singleton_method("around_#{callback}") do |*args, **options, &block| + options.assert_valid_keys(:if, :unless, :prepend) + set_callback(:"#{callback}", :around, *args, options, &block) end end def _define_after_model_callback(klass, callback) - klass.define_singleton_method("after_#{callback}") do |*args, &block| - options = args.extract_options! + klass.define_singleton_method("after_#{callback}") do |*args, **options, &block| + options.assert_valid_keys(:if, :unless, :prepend) options[:prepend] = true conditional = ActiveSupport::Callbacks::Conditionals::Value.new { |v| v != false } options[:if] = Array(options[:if]) << conditional - set_callback(:"#{callback}", :after, *(args << options), &block) + set_callback(:"#{callback}", :after, *args, options, &block) end end end 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 eaf8dfb223..ab2c9d04ae 100644 --- a/activemodel/lib/active_model/dirty.rb +++ b/activemodel/lib/active_model/dirty.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "active_support/hash_with_indifferent_access" -require "active_support/core_ext/object/duplicable" require "active_model/attribute_mutation_tracker" module ActiveModel @@ -122,9 +120,6 @@ module ActiveModel extend ActiveSupport::Concern include ActiveModel::AttributeMethods - OPTION_NOT_GIVEN = Object.new # :nodoc: - private_constant :OPTION_NOT_GIVEN - included do attribute_method_suffix "_changed?", "_change", "_will_change!", "_was" attribute_method_suffix "_previously_changed?", "_previous_change" @@ -141,23 +136,24 @@ 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 + mutations_from_database.finalize_changes end @mutations_before_last_save = mutations_from_database - @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new forget_attribute_assignments @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' # person.changed? # => true def changed? - changed_attributes.present? + mutations_from_database.any_changes? end # Returns an array with the name of the attributes with unsaved changes. @@ -166,42 +162,37 @@ module ActiveModel # person.name = 'bob' # person.changed # => ["name"] def changed - changed_attributes.keys + mutations_from_database.changed_attribute_names end # Handles <tt>*_changed?</tt> for +method_missing+. - def attribute_changed?(attr, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN) # :nodoc: - !!changes_include?(attr) && - (to == OPTION_NOT_GIVEN || to == _read_attribute(attr)) && - (from == OPTION_NOT_GIVEN || from == changed_attributes[attr]) + def attribute_changed?(attr_name, **options) # :nodoc: + mutations_from_database.changed?(attr_name.to_s, options) end # Handles <tt>*_was</tt> for +method_missing+. - def attribute_was(attr) # :nodoc: - attribute_changed?(attr) ? changed_attributes[attr] : _read_attribute(attr) + def attribute_was(attr_name) # :nodoc: + mutations_from_database.original_value(attr_name.to_s) end # Handles <tt>*_previously_changed?</tt> for +method_missing+. - def attribute_previously_changed?(attr) #:nodoc: - previous_changes_include?(attr) + def attribute_previously_changed?(attr_name) # :nodoc: + mutations_before_last_save.changed?(attr_name.to_s) end # Restore all previous data of the provided attributes. - def restore_attributes(attributes = changed) - attributes.each { |attr| restore_attribute! attr } + 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 - @previously_changed = ActiveSupport::HashWithIndifferentAccess.new @mutations_before_last_save = nil - @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new forget_attribute_assignments @mutations_from_database = nil end def clear_attribute_changes(attr_names) - attributes_changed_by_setter.except!(*attr_names) attr_names.each do |attr_name| clear_attribute_change(attr_name) end @@ -214,13 +205,7 @@ module ActiveModel # person.name = 'robert' # person.changed_attributes # => {"name" => "bob"} def changed_attributes - # This should only be set by methods which will call changed_attributes - # multiple times when it is known that the computed value cannot change. - if defined?(@cached_changed_attributes) - @cached_changed_attributes - else - attributes_changed_by_setter.reverse_merge(mutations_from_database.changed_values).freeze - end + mutations_from_database.changed_values end # Returns a hash of changed attributes indicating their original @@ -230,9 +215,7 @@ module ActiveModel # person.name = 'bob' # person.changes # => { "name" => ["bill", "bob"] } def changes - cache_changed_attributes do - ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }] - end + mutations_from_database.changes end # Returns a hash of attributes that were changed before the model was saved. @@ -242,27 +225,23 @@ module ActiveModel # person.save # person.previous_changes # => {"name" => ["bob", "robert"]} def previous_changes - @previously_changed ||= ActiveSupport::HashWithIndifferentAccess.new - @previously_changed.merge(mutations_before_last_save.changes) + mutations_before_last_save.changes end def attribute_changed_in_place?(attr_name) # :nodoc: - mutations_from_database.changed_in_place?(attr_name) + 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) + mutations_from_database.forget_change(attr_name.to_s) end def mutations_from_database - unless defined?(@mutations_from_database) - @mutations_from_database = nil - end @mutations_from_database ||= if defined?(@attributes) ActiveModel::AttributeMutationTracker.new(@attributes) else - NullMutationTracker.instance + ActiveModel::ForcedMutationTracker.new(self) end end @@ -274,68 +253,28 @@ module ActiveModel @mutations_before_last_save ||= ActiveModel::NullMutationTracker.instance end - def cache_changed_attributes - @cached_changed_attributes = changed_attributes - yield - ensure - clear_changed_attributes_cache - end - - def clear_changed_attributes_cache - remove_instance_variable(:@cached_changed_attributes) if defined?(@cached_changed_attributes) - end - - # Returns +true+ if attr_name is changed, +false+ otherwise. - def changes_include?(attr_name) - attributes_changed_by_setter.include?(attr_name) || mutations_from_database.changed?(attr_name) - end - alias attribute_changed_by_setter? changes_include? - - # Returns +true+ if attr_name were changed before the model was saved, - # +false+ otherwise. - def previous_changes_include?(attr_name) - previous_changes.include?(attr_name) - end - # Handles <tt>*_change</tt> for +method_missing+. - def attribute_change(attr) - [changed_attributes[attr], _read_attribute(attr)] if attribute_changed?(attr) + def attribute_change(attr_name) + mutations_from_database.change_to_attribute(attr_name.to_s) end # Handles <tt>*_previous_change</tt> for +method_missing+. - def attribute_previous_change(attr) - previous_changes[attr] if attribute_previously_changed?(attr) + def attribute_previous_change(attr_name) + mutations_before_last_save.change_to_attribute(attr_name.to_s) end # Handles <tt>*_will_change!</tt> for +method_missing+. - def attribute_will_change!(attr) - unless attribute_changed?(attr) - begin - value = _read_attribute(attr) - value = value.duplicable? ? value.clone : value - rescue TypeError, NoMethodError - end - - set_attribute_was(attr, value) - end - mutations_from_database.force_change(attr) + def attribute_will_change!(attr_name) + mutations_from_database.force_change(attr_name.to_s) end # Handles <tt>restore_*!</tt> for +method_missing+. - def restore_attribute!(attr) - if attribute_changed?(attr) - __send__("#{attr}=", changed_attributes[attr]) - clear_attribute_changes([attr]) + 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 - - def attributes_changed_by_setter - @attributes_changed_by_setter ||= ActiveSupport::HashWithIndifferentAccess.new - end - - # Force an attribute to have a particular "before" value - def set_attribute_was(attr, old_value) - attributes_changed_by_setter[attr] = old_value - end end end diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index 275e3f1313..3a692a3e64 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -62,6 +62,11 @@ module ActiveModel CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict] MESSAGE_OPTIONS = [:message] + class << self + attr_accessor :i18n_customize_full_message # :nodoc: + end + self.i18n_customize_full_message = false + attr_reader :messages, :details # Pass in the instance of the object that is using the errors object. @@ -107,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"] @@ -312,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].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 @@ -364,12 +401,54 @@ module ActiveModel # Returns a full message for a given attribute. # # person.errors.full_message(:name, 'is invalid') # => "Name is invalid" + # + # The `"%{attribute} %{message}"` error format can be overridden with either + # + # * <tt>activemodel.errors.models.person/contacts/addresses.attributes.street.format</tt> + # * <tt>activemodel.errors.models.person/contacts/addresses.format</tt> + # * <tt>activemodel.errors.models.person.attributes.name.format</tt> + # * <tt>activemodel.errors.models.person.format</tt> + # * <tt>errors.format</tt> def full_message(attribute, message) return message if attribute == :base - attr_name = attribute.to_s.tr(".", "_").humanize + attribute = attribute.to_s + + 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" + + 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) - I18n.t(:"errors.format", - default: "%{attribute} %{message}", + + I18n.t(defaults.shift, + default: defaults, attribute: attr_name, message: message) end @@ -400,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 @@ -408,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 @@ -417,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 a9cdabba00..eb7901c7e9 100644 --- a/activemodel/lib/active_model/railtie.rb +++ b/activemodel/lib/active_model/railtie.rb @@ -7,8 +7,14 @@ 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::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 86f051f5ce..5f409326bd 100644 --- a/activemodel/lib/active_model/secure_password.rb +++ b/activemodel/lib/active_model/secure_password.rb @@ -16,15 +16,16 @@ module ActiveModel module ClassMethods # Adds methods to set and authenticate against a BCrypt password. - # This mechanism requires you to have a +password_digest+ attribute. + # 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 +password_confirmation+ attribute) + # * Confirmation of password (using a +XXX_confirmation+ attribute) # - # If password confirmation validation is not needed, simply leave out the - # value for +password_confirmation+ (i.e. don't provide a form field for + # 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. # @@ -37,9 +38,10 @@ module ActiveModel # # Example using Active Record (which automatically includes ActiveModel::SecurePassword): # - # # Schema: User(name:string, password_digest:string) + # # 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') @@ -48,11 +50,15 @@ module ActiveModel # 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').try(:authenticate, 'notright') # => false # User.find_by(name: 'david').try(:authenticate, 'mUc3m00RsqyRe') # => user - def has_secure_password(options = {}) + 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. @@ -63,9 +69,9 @@ module ActiveModel raise end - include InstanceMethodsOnActivation + include InstanceMethodsOnActivation.new(attribute) - if options.fetch(:validations, true) + if validations include ActiveModel::Validations # This ensures the model has a password by checking whether the password_digest @@ -73,56 +79,49 @@ module ActiveModel # 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(:password, :blank) unless record.password_digest.present? + record.errors.add(attribute, :blank) unless record.send("#{attribute}_digest").present? end - validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED - validates_confirmation_of :password, allow_blank: true + validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED + validates_confirmation_of attribute, allow_blank: true end end end - module InstanceMethodsOnActivation - # 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('notright') # => false - # user.authenticate('mUc3m00RsqyRe') # => user - def authenticate(unencrypted_password) - BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self - end + class InstanceMethodsOnActivation < Module + def initialize(attribute) + attr_reader attribute + + define_method("#{attribute}=") do |unencrypted_password| + if unencrypted_password.nil? + self.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.send("#{attribute}_digest=", BCrypt::Password.create(unencrypted_password, cost: cost)) + end + end - attr_reader :password + define_method("#{attribute}_confirmation=") do |unencrypted_password| + instance_variable_set("@#{attribute}_confirmation", unencrypted_password) + end - # Encrypts the password into the +password_digest+ attribute, only if the - # new password is not empty. - # - # class User < ActiveRecord::Base - # has_secure_password validations: false - # end - # - # user = User.new - # user.password = nil - # user.password_digest # => nil - # user.password = 'mUc3m00RsqyRe' - # user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4." - def password=(unencrypted_password) - if unencrypted_password.nil? - self.password_digest = nil - elsif !unencrypted_password.empty? - @password = unencrypted_password - cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost - self.password_digest = BCrypt::Password.create(unencrypted_password, cost: cost) + # 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 = send("#{attribute}_digest") + BCrypt::Password.new(attribute_digest).is_password?(unencrypted_password) && self end - end - def password_confirmation=(unencrypted_password) - @password_confirmation = unencrypted_password + alias_method :authenticate, :authenticate_password if attribute == :password 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 bcdbab0343..e64d2c793c 100644 --- a/activemodel/lib/active_model/type/boolean.rb +++ b/activemodel/lib/active_model/type/boolean.rb @@ -14,12 +14,25 @@ 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 end + def serialize(value) # :nodoc: + cast(value) + end + private def cast_value(value) 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 250c4021c6..735b9a75a6 100644 --- a/activemodel/lib/active_model/type/helpers/time_value.rb +++ b/activemodel/lib/active_model/type/helpers/time_value.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "active_support/core_ext/string/zones" require "active_support/core_ext/time/zones" module ActiveModel @@ -20,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 @@ -69,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 c094ee0013..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 @@ -18,6 +19,8 @@ module ActiveModel 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 diff --git a/activemodel/lib/active_model/type/value.rb b/activemodel/lib/active_model/type/value.rb index a8ea6a2c22..b6914dd63c 100644 --- a/activemodel/lib/active_model/type/value.rb +++ b/activemodel/lib/active_model/type/value.rb @@ -90,6 +90,10 @@ module ActiveModel false end + def force_equality?(_value) # :nodoc: + false + end + def map(value) # :nodoc: yield value end 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/inclusion.rb b/activemodel/lib/active_model/validations/inclusion.rb index 3104e7e329..9c12dc14c5 100644 --- a/activemodel/lib/active_model/validations/inclusion.rb +++ b/activemodel/lib/active_model/validations/inclusion.rb @@ -19,7 +19,7 @@ module ActiveModel # particular enumerable object. # # class Person < ActiveRecord::Base - # validates_inclusion_of :gender, in: %w( m f ) + # 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] } 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 31750ba78e..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| @@ -19,9 +23,20 @@ module ActiveModel end def validate_each(record, attr_name, value) - before_type_cast = :"#{attr_name}_before_type_cast" + came_from_user = :"#{attr_name}_came_from_user?" - raw_value = record.send(before_type_cast) if record.respond_to?(before_type_cast) && record.send(before_type_cast) != value + 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 if record_attribute_changed_in_place?(record, attr_name) @@ -38,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 @@ -58,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 @@ -68,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 e28e7e9219..21c4ce0dfe 100644 --- a/activemodel/lib/active_model/validations/validates.rb +++ b/activemodel/lib/active_model/validations/validates.rb @@ -63,7 +63,7 @@ module ActiveModel # and strings in shortcut form. # # validates :email, format: /@/ - # validates :gender, inclusion: %w(male female) + # validates :role, inclusion: %(admin contributor) # validates :password, length: 6..20 # # When using shortcut form, ranges and arrays are passed to your @@ -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/attributes_dirty_test.rb b/activemodel/test/cases/attributes_dirty_test.rb index c991176389..f9693a23cd 100644 --- a/activemodel/test/cases/attributes_dirty_test.rb +++ b/activemodel/test/cases/attributes_dirty_test.rb @@ -39,7 +39,7 @@ class AttributesDirtyTest < ActiveModel::TestCase end test "changes to attribute values" do - assert !@model.changes["name"] + assert_not @model.changes["name"] @model.name = "John" assert_equal [nil, "John"], @model.changes["name"] end 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 f769eb0da1..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 @@ -78,7 +64,7 @@ class DirtyTest < ActiveModel::TestCase end test "changes to attribute values" do - assert !@model.changes["name"] + assert_not @model.changes["name"] @model.name = "John" assert_equal [nil, "John"], @model.changes["name"] end @@ -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 cb6a8c43d5..947f9bf99b 100644 --- a/activemodel/test/cases/errors_test.rb +++ b/activemodel/test/cases/errors_test.rb @@ -185,6 +185,12 @@ class ErrorsTest < ActiveModel::TestCase 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" } @@ -203,30 +209,118 @@ 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 person = Person.new - assert !person.errors.added?(:name) + 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 !person.errors.added?(:name, "cannot be blank") + 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 !person.errors.added?(:name) + 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 !person.errors.added?(:name, :used) + 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 @@ -395,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/naming_test.rb b/activemodel/test/cases/naming_test.rb index 009f1f47af..4693da434c 100644 --- a/activemodel/test/cases/naming_test.rb +++ b/activemodel/test/cases/naming_test.rb @@ -248,7 +248,7 @@ class NamingHelpersTest < ActiveModel::TestCase def test_uncountable assert uncountable?(@uncountable), "Expected 'sheep' to be uncountable" - assert !uncountable?(@klass), "Expected 'contact' to be countable" + assert_not uncountable?(@klass), "Expected 'contact' to be countable" end def test_uncountable_route_key diff --git a/activemodel/test/cases/railtie_test.rb b/activemodel/test/cases/railtie_test.rb index ff5022e960..95ee7cace3 100644 --- a/activemodel/test/cases/railtie_test.rb +++ b/activemodel/test/cases/railtie_test.rb @@ -31,4 +31,24 @@ class RailtieTest < ActiveModel::TestCase assert_equal true, ActiveModel::SecurePassword.min_cost end + + test "i18n customize full message defaults to false" do + @app.initialize! + + assert_equal false, ActiveModel::Errors.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::Errors.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::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 d19e81a119..0aca714bd2 100644 --- a/activemodel/test/cases/secure_password_test.rb +++ b/activemodel/test/cases/secure_password_test.rb @@ -49,14 +49,14 @@ class SecurePasswordTest < ActiveModel::TestCase test "create a new user with validation and a blank password" do @user.password = "" - assert !@user.valid?(:create), "user should be invalid" + 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 !@user.valid?(:create), "user should be invalid" + 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 @@ -64,7 +64,7 @@ class SecurePasswordTest < ActiveModel::TestCase test "create a new user with validation and password length greater than 72" do @user.password = "a" * 73 @user.password_confirmation = "a" * 73 - assert !@user.valid?(:create), "user should be invalid" + 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 @@ -72,7 +72,7 @@ class SecurePasswordTest < ActiveModel::TestCase test "create a new user with validation and a blank password confirmation" do @user.password = "password" @user.password_confirmation = "" - assert !@user.valid?(:create), "user should be invalid" + 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 @@ -86,7 +86,7 @@ class SecurePasswordTest < ActiveModel::TestCase test "create a new user with validation and an incorrect password confirmation" do @user.password = "password" @user.password_confirmation = "something else" - assert !@user.valid?(:create), "user should be invalid" + 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 @@ -125,7 +125,7 @@ class SecurePasswordTest < ActiveModel::TestCase test "updating an existing user with validation and a nil password" do @existing_user.password = nil - assert !@existing_user.valid?(:update), "user should be invalid" + 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 @@ -133,7 +133,7 @@ class SecurePasswordTest < ActiveModel::TestCase 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 !@existing_user.valid?(:update), "user should be invalid" + 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 @@ -141,7 +141,7 @@ class SecurePasswordTest < ActiveModel::TestCase test "updating an existing user with validation and a blank password confirmation" do @existing_user.password = "password" @existing_user.password_confirmation = "" - assert !@existing_user.valid?(:update), "user should be invalid" + 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 @@ -155,21 +155,21 @@ class SecurePasswordTest < ActiveModel::TestCase test "updating an existing user with validation and an incorrect password confirmation" do @existing_user.password = "password" @existing_user.password_confirmation = "something else" - assert !@existing_user.valid?(:update), "user should be invalid" + 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 !@existing_user.valid?(:update), "user should be invalid" + 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 !@existing_user.valid?(:update), "user should be invalid" + 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 @@ -184,11 +184,32 @@ 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" + + 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 !@user.authenticate("wrong") - assert @user.authenticate("secret") + assert_equal false, @user.authenticate_recovery_password("wrong") + assert_equal @user, @user.authenticate_recovery_password("42password") end test "Password digest cost defaults to bcrypt default cost when min_cost is false" do @@ -199,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 f7102d1e97..5c6271241d 100644 --- a/activemodel/test/cases/type/time_test.rb +++ b/activemodel/test/cases/type/time_test.rb @@ -16,6 +16,22 @@ 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 + ::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 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 9cfe189d0e..eb03e837f1 100644 --- a/activemodel/test/cases/validations/i18n_validation_test.rb +++ b/activemodel/test/cases/validations/i18n_validation_test.rb @@ -12,6 +12,9 @@ class I18nValidationTest < ActiveModel::TestCase 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::Errors.i18n_customize_full_message + ActiveModel::Errors.i18n_customize_full_message = true end def teardown @@ -19,6 +22,7 @@ class I18nValidationTest < ActiveModel::TestCase I18n.load_path.replace @old_load_path I18n.backend = @old_backend I18n.backend.reload! + ActiveModel::Errors.i18n_customize_full_message = @original_i18n_customize_full_message end def test_full_message_encoding @@ -31,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 @@ -42,6 +46,118 @@ class I18nValidationTest < ActiveModel::TestCase assert_equal ["Field Name empty"], @person.errors.full_messages end + def test_errors_full_messages_doesnt_use_attribute_format_without_config + ActiveModel::Errors.i18n_customize_full_message = false + + I18n.backend.store_translations("en", activemodel: { + errors: { models: { person: { attributes: { name: { format: "%{message}" } } } } } }) + + 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 + + def test_errors_full_messages_uses_attribute_format + ActiveModel::Errors.i18n_customize_full_message = true + + I18n.backend.store_translations("en", activemodel: { + errors: { models: { person: { attributes: { name: { format: "%{message}" } } } } } }) + + person = Person.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::Errors.i18n_customize_full_message = true + + I18n.backend.store_translations("en", activemodel: { + errors: { models: { person: { format: "%{message}" } } } }) + + person = Person.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::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/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::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/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::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/cases/validations/validates_test.rb b/activemodel/test/cases/validations/validates_test.rb index 80c347703a..ae5a875c24 100644 --- a/activemodel/test/cases/validations/validates_test.rb +++ b/activemodel/test/cases/validations/validates_test.rb @@ -19,7 +19,7 @@ class ValidatesTest < ActiveModel::TestCase def test_validates_with_messages_empty Person.validates :title, presence: { message: "" } person = Person.new - assert !person.valid?, "person should not be valid." + assert_not person.valid?, "person should not be valid." end def test_validates_with_built_in_validation 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 e98fd8a0a1..fc4a9e4334 100644 --- a/activemodel/test/models/user.rb +++ b/activemodel/test/models/user.rb @@ -7,6 +7,14 @@ class User define_model_callbacks :create has_secure_password + has_secure_password :recovery_password, validations: false - attr_accessor :password_digest + 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 diff --git a/activemodel/test/models/visitor.rb b/activemodel/test/models/visitor.rb index 9da004ffcc..96bf3ef10a 100644 --- a/activemodel/test/models/visitor.rb +++ b/activemodel/test/models/visitor.rb @@ -8,5 +8,6 @@ class Visitor has_secure_password(validations: false) - attr_accessor :password_digest, :password_confirmation + attr_accessor :password_digest + attr_reader :password_confirmation end |