diff options
Diffstat (limited to 'activemodel/lib/active_model')
55 files changed, 1756 insertions, 711 deletions
diff --git a/activemodel/lib/active_model/attribute_assignment.rb b/activemodel/lib/active_model/attribute_assignment.rb new file mode 100644 index 0000000000..62014cd1cd --- /dev/null +++ b/activemodel/lib/active_model/attribute_assignment.rb @@ -0,0 +1,52 @@ +require 'active_support/core_ext/hash/keys' + +module ActiveModel + module AttributeAssignment + include ActiveModel::ForbiddenAttributesProtection + + # Allows you to set all the attributes by passing in a hash of attributes with + # keys matching the attribute names. + # + # If the passed hash responds to <tt>permitted?</tt> method and the return value + # of this method is +false+ an <tt>ActiveModel::ForbiddenAttributesError</tt> + # exception is raised. + # + # class Cat + # include ActiveModel::AttributeAssignment + # attr_accessor :name, :status + # end + # + # cat = Cat.new + # cat.assign_attributes(name: "Gorby", status: "yawning") + # cat.name # => 'Gorby' + # cat.status => 'yawning' + # cat.assign_attributes(status: "sleeping") + # cat.name # => 'Gorby' + # cat.status => 'sleeping' + def assign_attributes(new_attributes) + if !new_attributes.respond_to?(:stringify_keys) + raise ArgumentError, "When assigning attributes, you must pass a hash as an argument." + end + return if new_attributes.nil? || new_attributes.empty? + + attributes = new_attributes.stringify_keys + _assign_attributes(sanitize_for_mass_assignment(attributes)) + end + + private + + def _assign_attributes(attributes) + attributes.each do |k, v| + _assign_attribute(k, v) + end + end + + def _assign_attribute(k, v) + if respond_to?("#{k}=") + public_send("#{k}=", v) + else + raise UnknownAttributeError.new(self, k) + end + end + end +end diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index f336c759d2..cc6285f932 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -1,4 +1,4 @@ -require 'thread_safe' +require 'concurrent/map' require 'mutex_m' module ActiveModel @@ -14,20 +14,22 @@ module ActiveModel class MissingAttributeError < NoMethodError end - # == Active \Model Attribute Methods + # == Active \Model \Attribute \Methods # - # <tt>ActiveModel::AttributeMethods</tt> provides a way to add prefixes and - # suffixes to your methods as well as handling the creation of - # <tt>ActiveRecord::Base</tt>-like class methods such as +table_name+. + # Provides a way to add prefixes and suffixes to your methods as + # well as handling the creation of <tt>ActiveRecord::Base</tt>-like + # class methods such as +table_name+. # # The requirements to implement <tt>ActiveModel::AttributeMethods</tt> are to: # # * <tt>include ActiveModel::AttributeMethods</tt> in your class. - # * Call each of its method you want to add, such as +attribute_method_suffix+ + # * Call each of its methods you want to add, such as +attribute_method_suffix+ # or +attribute_method_prefix+. # * Call +define_attribute_methods+ after the other methods are called. # * Define the various generic +_attribute+ methods that you have declared. - # * Define an +attributes+ method, see below. + # * Define an +attributes+ method which returns a hash with each + # attribute name in your model as hash key and the attribute value as hash value. + # Hash keys must be strings. # # A minimal implementation could be: # @@ -42,7 +44,7 @@ module ActiveModel # attr_accessor :name # # def attributes - # {'name' => @name} + # { 'name' => @name } # end # # private @@ -59,13 +61,6 @@ module ActiveModel # send("#{attr}=", 'Default Name') # end # end - # - # Note that whenever you include <tt>ActiveModel::AttributeMethods</tt> in - # your class, it requires you to implement an +attributes+ method which - # returns a hash with each attribute name in your model as hash key and the - # attribute value as hash value. - # - # Hash keys must be strings. module AttributeMethods extend ActiveSupport::Concern @@ -173,14 +168,14 @@ module ActiveModel # private # # def reset_attribute_to_default!(attr) - # ... + # send("#{attr}=", 'Default Name') # end # end # # person = Person.new # person.name # => 'Gem' # person.reset_name_to_default! - # person.name # => 'Gemma' + # person.name # => 'Default Name' def attribute_method_affix(*affixes) self.attribute_method_matchers += affixes.map! { |affix| AttributeMethodMatcher.new prefix: affix[:prefix], suffix: affix[:suffix] } undefine_attribute_methods @@ -230,9 +225,9 @@ module ActiveModel end # Declares the attributes that should be prefixed and suffixed by - # ActiveModel::AttributeMethods. + # <tt>ActiveModel::AttributeMethods</tt>. # - # To use, pass attribute names (as strings or symbols), be sure to declare + # To use, pass attribute names (as strings or symbols). Be sure to declare # +define_attribute_methods+ after you define any prefix, suffix or affix # methods, or they will not hook in. # @@ -244,13 +239,13 @@ module ActiveModel # # # Call to define_attribute_methods must appear after the # # attribute_method_prefix, attribute_method_suffix or - # # attribute_method_affix declares. + # # attribute_method_affix declarations. # define_attribute_methods :name, :age, :address # # private # # def clear_attribute(attr) - # ... + # send("#{attr}=", nil) # end # end def define_attribute_methods(*attr_names) @@ -258,9 +253,9 @@ module ActiveModel end # Declares an attribute that should be prefixed and suffixed by - # ActiveModel::AttributeMethods. + # <tt>ActiveModel::AttributeMethods</tt>. # - # To use, pass an attribute name (as string or symbol), be sure to declare + # To use, pass an attribute name (as string or symbol). Be sure to declare # +define_attribute_method+ after you define any prefix, suffix or affix # method, or they will not hook in. # @@ -272,7 +267,7 @@ module ActiveModel # # # Call to define_attribute_method must appear after the # # attribute_method_prefix, attribute_method_suffix or - # # attribute_method_affix declares. + # # attribute_method_affix declarations. # define_attribute_method :name # # private @@ -347,30 +342,28 @@ module ActiveModel private # The methods +method_missing+ and +respond_to?+ of this module are # invoked often in a typical rails, both of which invoke the method - # +match_attribute_method?+. The latter method iterates through an + # +matched_attribute_method+. The latter method iterates through an # array doing regular expression matches, which results in a lot of - # object creations. Most of the times it returns a +nil+ match. As the + # object creations. Most of the time it returns a +nil+ match. As the # match result is always the same given a +method_name+, this cache is # used to alleviate the GC, which ultimately also speeds up the app # significantly (in our case our test suite finishes 10% faster with # this cache). def attribute_method_matchers_cache #:nodoc: - @attribute_method_matchers_cache ||= ThreadSafe::Cache.new(initial_capacity: 4) + @attribute_method_matchers_cache ||= Concurrent::Map.new(initial_capacity: 4) end - def attribute_method_matcher(method_name) #:nodoc: + def attribute_method_matchers_matching(method_name) #:nodoc: attribute_method_matchers_cache.compute_if_absent(method_name) do # 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) - match = nil - matchers.detect { |method| match = method.match(method_name) } - match + matchers.map { |method| method.match(method_name) }.compact end end # Define a method `name` in `mod` that dispatches to `send` - # using the given `extra` args. This fallbacks `define_method` + # using the given `extra` args. This falls back on `define_method` # and `send` if the given names cannot be compiled. def define_proxy_call(include_private, mod, name, send, *extra) #:nodoc: defn = if name =~ NAME_COMPILABLE_REGEXP @@ -379,7 +372,7 @@ module ActiveModel "define_method(:'#{name}') do |*args|" end - extra = (extra.map!(&:inspect) << "*args").join(", ") + extra = (extra.map!(&:inspect) << "*args").join(", ".freeze) target = if send =~ CALL_COMPILABLE_REGEXP "#{"self." unless include_private}#{send}(#{extra})" @@ -426,7 +419,7 @@ module ActiveModel # returned by <tt>attributes</tt>, as though they were first-class # methods. So a +Person+ class with a +name+ attribute can for example use # <tt>Person#name</tt> and <tt>Person#name=</tt> and never directly use - # the attributes hash -- except for multiple assigns with + # the attributes hash -- except for multiple assignments with # <tt>ActiveRecord::Base#attributes=</tt>. # # It's also possible to instantiate related objects, so a <tt>Client</tt> @@ -436,7 +429,7 @@ module ActiveModel if respond_to_without_attributes?(method, true) super else - match = match_attribute_method?(method.to_s) + match = matched_attribute_method(method.to_s) match ? attribute_missing(match, *args, &block) : super end end @@ -461,7 +454,7 @@ module ActiveModel # but found among all methods. Which means that the given method is private. false else - !match_attribute_method?(method.to_s).nil? + !matched_attribute_method(method.to_s).nil? end end @@ -473,9 +466,9 @@ module ActiveModel private # Returns a struct representing the matching attribute method. # The struct's attributes are prefix, base and suffix. - def match_attribute_method?(method_name) - match = self.class.send(:attribute_method_matcher, method_name) - match if match && attribute_method?(match.attr_name) + def matched_attribute_method(method_name) + matches = self.class.send(:attribute_method_matchers_matching, method_name) + matches.detect { |match| attribute_method?(match.attr_name) } end def missing_attribute(attr_name, stack) diff --git a/activemodel/lib/active_model/callbacks.rb b/activemodel/lib/active_model/callbacks.rb index 377aa6ee27..0d6a3dc52d 100644 --- a/activemodel/lib/active_model/callbacks.rb +++ b/activemodel/lib/active_model/callbacks.rb @@ -6,7 +6,7 @@ module ActiveModel # Provides an interface for any class to have Active Record like callbacks. # # Like the Active Record methods, the callback chain is aborted as soon as - # one of the methods in the chain returns +false+. + # one of the methods throws +:abort+. # # First, extend ActiveModel::Callbacks from the class you are creating: # @@ -30,7 +30,7 @@ module ActiveModel # end # # Then in your class, you can use the +before_create+, +after_create+ and - # +around_create+ methods, just as you would in an Active Record module. + # +around_create+ methods, just as you would in an Active Record model. # # before_create :action_before_create # @@ -49,7 +49,7 @@ module ActiveModel # puts 'block successfully called.' # end # - # You can choose not to have all three callbacks by passing a hash to the + # You can choose to have only specific callbacks by passing a hash to the # +define_model_callbacks+ method. # # define_model_callbacks :create, only: [:after, :before] @@ -97,10 +97,13 @@ module ActiveModel # # obj is the MyModel instance that the callback is being called on # end # end + # + # NOTE: +method_name+ passed to `define_model_callbacks` must not end with + # `!`, `?` or `=`. def define_model_callbacks(*callbacks) options = callbacks.extract_options! options = { - terminator: ->(_,result) { result == false }, + terminator: deprecated_false_terminator, skip_after_callbacks_if_terminated: true, scope: [:kind, :name], only: [:before, :around, :after] diff --git a/activemodel/lib/active_model/conversion.rb b/activemodel/lib/active_model/conversion.rb index 21e4eb3c86..9de6ea65be 100644 --- a/activemodel/lib/active_model/conversion.rb +++ b/activemodel/lib/active_model/conversion.rb @@ -1,5 +1,5 @@ module ActiveModel - # == Active \Model Conversion + # == Active \Model \Conversion # # Handles default conversions: to_model, to_key, to_param, and to_partial_path. # @@ -22,7 +22,7 @@ module ActiveModel module Conversion extend ActiveSupport::Concern - # If your object is already designed to implement all of the Active Model + # If your object is already designed to implement all of the \Active \Model # you can use the default <tt>:to_model</tt> implementation, which simply # returns +self+. # @@ -33,20 +33,22 @@ module ActiveModel # person = Person.new # person.to_model == person # => true # - # If your model does not act like an Active Model object, then you should + # If your model does not act like an \Active \Model object, then you should # define <tt>:to_model</tt> yourself returning a proxy object that wraps - # your object with Active Model compliant methods. + # your object with \Active \Model compliant methods. def to_model self end - # Returns an Enumerable of all key attributes if any is set, regardless if - # the object is persisted or not. If there no key attributes, returns +nil+. + # Returns an Array of all key attributes if any is set, regardless if + # the object is persisted or not. Returns +nil+ if there are no key attributes. # - # class Person < ActiveRecord::Base + # class Person + # include ActiveModel::Conversion + # attr_accessor :id # end # - # person = Person.create + # person = Person.create(id: 1) # person.to_key # => [1] def to_key key = respond_to?(:id) && id @@ -56,13 +58,18 @@ module ActiveModel # Returns a +string+ representing the object's key suitable for use in URLs, # or +nil+ if <tt>persisted?</tt> is +false+. # - # class Person < ActiveRecord::Base + # class Person + # include ActiveModel::Conversion + # attr_accessor :id + # def persisted? + # true + # end # end # - # person = Person.create + # person = Person.create(id: 1) # person.to_param # => "1" def to_param - persisted? ? to_key.join('-') : nil + (persisted? && key = to_key) ? key.join('-') : nil end # Returns a +string+ identifying the path associated with the object. @@ -83,8 +90,8 @@ module ActiveModel # internal method and should not be accessed directly. def _to_partial_path #:nodoc: @_to_partial_path ||= begin - element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(self)) - collection = ActiveSupport::Inflector.tableize(self) + element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name)) + collection = ActiveSupport::Inflector.tableize(name) "#{collection}/#{element}".freeze end end diff --git a/activemodel/lib/active_model/dirty.rb b/activemodel/lib/active_model/dirty.rb index c5f1b3f11a..6e2e5afd1b 100644 --- a/activemodel/lib/active_model/dirty.rb +++ b/activemodel/lib/active_model/dirty.rb @@ -12,11 +12,12 @@ module ActiveModel # * <tt>include ActiveModel::Dirty</tt> in your object. # * Call <tt>define_attribute_methods</tt> passing each method you want to # track. - # * Call <tt>attr_name_will_change!</tt> before each change to the tracked + # * Call <tt>[attr_name]_will_change!</tt> before each change to the tracked # attribute. # * Call <tt>changes_applied</tt> after the changes are persisted. - # * Call <tt>reset_changes</tt> when you want to reset the changes + # * Call <tt>clear_changes_information</tt> when you want to reset the changes # information. + # * Call <tt>restore_attributes</tt> when you want to restore previous data. # # A minimal implementation could be: # @@ -25,6 +26,10 @@ module ActiveModel # # define_attribute_methods :name # + # def initialize(name) + # @name = name + # end + # # def name # @name # end @@ -36,24 +41,32 @@ module ActiveModel # # def save # # do persistence work + # # changes_applied # end # # def reload! - # reset_changes + # # get the values from the persistence layer + # + # clear_changes_information + # end + # + # def rollback! + # restore_attributes # end # end # - # A newly instantiated object is unchanged: + # A newly instantiated +Person+ object is unchanged: # - # person = Person.find_by(name: 'Uncle Bob') - # person.changed? # => false + # person = Person.new("Uncle Bob") + # person.changed? # => false # # Change the name: # # person.name = 'Bob' # person.changed? # => true # person.name_changed? # => true + # person.name_changed?(from: "Uncle Bob", to: "Bob") # => true # person.name_was # => "Uncle Bob" # person.name_change # => ["Uncle Bob", "Bob"] # person.name = 'Bill' @@ -62,45 +75,57 @@ module ActiveModel # Save the changes: # # person.save - # person.changed? # => false - # person.name_changed? # => false + # person.changed? # => false + # person.name_changed? # => false # # Reset the changes: # - # person.previous_changes # => {"name" => ["Uncle Bob", "Bill"]} + # person.previous_changes # => {"name" => ["Uncle Bob", "Bill"]} + # person.name_previously_changed? # => true + # person.name_previous_change # => ["Uncle Bob", "Bill"] # person.reload! - # person.previous_changes # => {} + # person.previous_changes # => {} + # + # Rollback the changes: + # + # person.name = "Uncle Bob" + # person.rollback! + # person.name # => "Bill" + # person.name_changed? # => false # # Assigning the same value leaves the attribute unchanged: # # person.name = 'Bill' - # person.name_changed? # => false - # person.name_change # => nil + # person.name_changed? # => false + # person.name_change # => nil # # Which attributes have changed? # # person.name = 'Bob' - # person.changed # => ["name"] - # person.changes # => {"name" => ["Bill", "Bob"]} + # person.changed # => ["name"] + # person.changes # => {"name" => ["Bill", "Bob"]} # - # If an attribute is modified in-place then make use of <tt>[attribute_name]_will_change!</tt> - # to mark that the attribute is changing. Otherwise ActiveModel can't track - # changes to in-place attributes. + # If an attribute is modified in-place then make use of + # <tt>[attribute_name]_will_change!</tt> to mark that the attribute is changing. + # Otherwise \Active \Model can't track changes to in-place attributes. Note + # that Active Record can detect in-place modifications automatically. You do + # not need to call <tt>[attribute_name]_will_change!</tt> on Active Record models. # # person.name_will_change! - # person.name_change # => ["Bill", "Bill"] + # person.name_change # => ["Bill", "Bill"] # person.name << 'y' - # person.name_change # => ["Bill", "Billy"] + # person.name_change # => ["Bill", "Billy"] module Dirty extend ActiveSupport::Concern include ActiveModel::AttributeMethods included do attribute_method_suffix '_changed?', '_change', '_will_change!', '_was' - attribute_method_affix prefix: 'reset_', suffix: '!' + attribute_method_suffix '_previously_changed?', '_previous_change' + attribute_method_affix prefix: 'restore_', suffix: '!' end - # Returns +true+ if any attribute have unsaved changes, +false+ otherwise. + # Returns +true+ if any of the attributes have unsaved changes, +false+ otherwise. # # person.changed? # => false # person.name = 'bob' @@ -135,7 +160,7 @@ module ActiveModel # person.save # person.previous_changes # => {"name" => ["bob", "robert"]} def previous_changes - @previously_changed ||= {} + @previously_changed ||= ActiveSupport::HashWithIndifferentAccess.new end # Returns a hash of the attributes with unsaved changes indicating their original @@ -148,36 +173,66 @@ module ActiveModel @changed_attributes ||= ActiveSupport::HashWithIndifferentAccess.new end - # Handle <tt>*_changed?</tt> for +method_missing+. - def attribute_changed?(attr) - changed_attributes.include?(attr) + # Handles <tt>*_changed?</tt> for +method_missing+. + def attribute_changed?(attr, options = {}) #:nodoc: + result = changes_include?(attr) + result &&= options[:to] == __send__(attr) if options.key?(:to) + result &&= options[:from] == changed_attributes[attr] if options.key?(:from) + result end - # Handle <tt>*_was</tt> for +method_missing+. - def attribute_was(attr) + # Handles <tt>*_was</tt> for +method_missing+. + def attribute_was(attr) # :nodoc: attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr) end + # Handles <tt>*_previously_changed?</tt> for +method_missing+. + def attribute_previously_changed?(attr, options = {}) #:nodoc: + previous_changes_include?(attr) + end + + # Restore all previous data of the provided attributes. + def restore_attributes(attributes = changed) + attributes.each { |attr| restore_attribute! attr } + end + private + # Returns +true+ if attr_name is changed, +false+ otherwise. + def changes_include?(attr_name) + attributes_changed_by_setter.include?(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 + # Removes current changes and makes them accessible through +previous_changes+. - def changes_applied + def changes_applied # :doc: @previously_changed = changes - @changed_attributes = {} + @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new end - # Removes all dirty data: current changes and previous changes - def reset_changes - @previously_changed = {} - @changed_attributes = {} + # Clears all dirty data: current changes and previous changes. + def clear_changes_information # :doc: + @previously_changed = ActiveSupport::HashWithIndifferentAccess.new + @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new end - # Handle <tt>*_change</tt> for +method_missing+. + # Handles <tt>*_change</tt> for +method_missing+. def attribute_change(attr) [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr) end - # Handle <tt>*_will_change!</tt> for +method_missing+. + # Handles <tt>*_previous_change</tt> for +method_missing+. + def attribute_previous_change(attr) + previous_changes[attr] if attribute_previously_changed?(attr) + end + + # Handles <tt>*_will_change!</tt> for +method_missing+. def attribute_will_change!(attr) return if attribute_changed?(attr) @@ -187,15 +242,29 @@ module ActiveModel rescue TypeError, NoMethodError end - changed_attributes[attr] = value + set_attribute_was(attr, value) end - # Handle <tt>reset_*!</tt> for +method_missing+. - def reset_attribute!(attr) + # Handles <tt>restore_*!</tt> for +method_missing+. + def restore_attribute!(attr) if attribute_changed?(attr) __send__("#{attr}=", changed_attributes[attr]) - changed_attributes.delete(attr) + clear_attribute_changes([attr]) end end + + # This is necessary because `changed_attributes` might be overridden in + # other implementations (e.g. in `ActiveRecord`) + alias_method :attributes_changed_by_setter, :changed_attributes # :nodoc: + + # Force an attribute to have a particular "before" value + def set_attribute_was(attr, old_value) + attributes_changed_by_setter[attr] = old_value + end + + # Remove changes information for the provided attributes. + def clear_attribute_changes(attributes) # :doc: + attributes_changed_by_setter.except!(*attributes) + end end end diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index cf7551e4f4..ef6141a51d 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -1,13 +1,13 @@ -# -*- coding: utf-8 -*- - require 'active_support/core_ext/array/conversions' require 'active_support/core_ext/string/inflections' +require 'active_support/core_ext/object/deep_dup' +require 'active_support/core_ext/string/filters' module ActiveModel # == Active \Model \Errors # # Provides a modified +Hash+ that you can include in your object - # for handling error messages and interacting with Action Pack helpers. + # for handling error messages and interacting with Action View helpers. # # A minimal implementation could be: # @@ -23,7 +23,7 @@ module ActiveModel # attr_reader :errors # # def validate! - # errors.add(:name, "can not be nil") if name == nil + # errors.add(:name, :blank, message: "cannot be nil") if name.nil? # end # # # The following methods are needed to be minimally implemented @@ -32,34 +32,35 @@ module ActiveModel # send(attr) # end # - # def Person.human_attribute_name(attr, options = {}) + # def self.human_attribute_name(attr, options = {}) # attr # end # - # def Person.lookup_ancestors + # def self.lookup_ancestors # [self] # end # end # - # The last three methods are required in your object for Errors to be + # The last three methods are required in your object for +Errors+ to be # able to generate error messages correctly and also handle multiple - # languages. Of course, if you extend your object with ActiveModel::Translation + # languages. Of course, if you extend your object with <tt>ActiveModel::Translation</tt> # you will not need to implement the last two. Likewise, using - # ActiveModel::Validations will handle the validation related methods + # <tt>ActiveModel::Validations</tt> will handle the validation related methods # for you. # # The above allows you to do: # # person = Person.new - # person.validate! # => ["can not be nil"] - # person.errors.full_messages # => ["name can not be nil"] + # person.validate! # => ["cannot be nil"] + # person.errors.full_messages # => ["name cannot be nil"] # # etc.. class Errors include Enumerable CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict] + MESSAGE_OPTIONS = [:message] - attr_reader :messages + attr_reader :messages, :details # Pass in the instance of the object that is using the errors object. # @@ -70,69 +71,97 @@ module ActiveModel # end def initialize(base) @base = base - @messages = {} + @messages = Hash.new { |messages, attribute| messages[attribute] = [] } + @details = Hash.new { |details, attribute| details[attribute] = [] } end def initialize_dup(other) # :nodoc: @messages = other.messages.dup + @details = other.details.deep_dup super end + # Copies the errors from <tt>other</tt>. + # + # other - The ActiveModel::Errors instance. + # + # Examples + # + # person.errors.copy!(other) + def copy!(other) # :nodoc: + @messages = other.messages.dup + @details = other.details.dup + end + # Clear the error messages. # - # person.errors.full_messages # => ["name can not be nil"] + # person.errors.full_messages # => ["name cannot be nil"] # person.errors.clear # person.errors.full_messages # => [] def clear messages.clear + details.clear end # Returns +true+ if the error messages include an error for the given key # +attribute+, +false+ otherwise. # - # person.errors.messages # => {:name=>["can not be nil"]} + # person.errors.messages # => {:name=>["cannot be nil"]} # person.errors.include?(:name) # => true # person.errors.include?(:age) # => false def include?(attribute) - (v = messages[attribute]) && v.any? + messages[attribute].present? end - # aliases include? alias :has_key? :include? + alias :key? :include? # Get messages for +key+. # - # person.errors.messages # => {:name=>["can not be nil"]} - # person.errors.get(:name) # => ["can not be nil"] - # person.errors.get(:age) # => nil + # person.errors.messages # => {:name=>["cannot be nil"]} + # person.errors.get(:name) # => ["cannot be nil"] + # person.errors.get(:age) # => [] def get(key) + ActiveSupport::Deprecation.warn(<<-MESSAGE.squish) + ActiveModel::Errors#get is deprecated and will be removed in Rails 5.1. + + To achieve the same use model.errors[:#{key}]. + MESSAGE + messages[key] end # Set messages for +key+ to +value+. # - # person.errors.get(:name) # => ["can not be nil"] + # person.errors[:name] # => ["cannot be nil"] # person.errors.set(:name, ["can't be nil"]) - # person.errors.get(:name) # => ["can't be nil"] + # person.errors[:name] # => ["can't be nil"] def set(key, value) + ActiveSupport::Deprecation.warn(<<-MESSAGE.squish) + ActiveModel::Errors#set is deprecated and will be removed in Rails 5.1. + + Use model.errors.add(:#{key}, #{value.inspect}) instead. + MESSAGE + messages[key] = value end # Delete messages for +key+. Returns the deleted messages. # - # person.errors.get(:name) # => ["can not be nil"] - # person.errors.delete(:name) # => ["can not be nil"] - # person.errors.get(:name) # => nil + # person.errors[:name] # => ["cannot be nil"] + # person.errors.delete(:name) # => ["cannot be nil"] + # person.errors[:name] # => [] def delete(key) + details.delete(key) messages.delete(key) end # When passed a symbol or a name of a method, returns an array of errors # for the method. # - # person.errors[:name] # => ["can not be nil"] - # person.errors['name'] # => ["can not be nil"] + # person.errors[:name] # => ["cannot be nil"] + # person.errors['name'] # => ["cannot be nil"] def [](attribute) - get(attribute.to_sym) || set(attribute.to_sym, []) + messages[attribute.to_sym] end # Adds to the supplied attribute the supplied error message. @@ -140,89 +169,76 @@ module ActiveModel # person.errors[:name] = "must be set" # person.errors[:name] # => ['must be set'] def []=(attribute, error) - self[attribute] << error + ActiveSupport::Deprecation.warn(<<-MESSAGE.squish) + ActiveModel::Errors#[]= is deprecated and will be removed in Rails 5.1. + + Use model.errors.add(:#{attribute}, #{error.inspect}) instead. + MESSAGE + + messages[attribute.to_sym] << error end # Iterates through each error key, value pair in the error messages hash. # Yields the attribute and the error for that attribute. If the attribute # has more than one error message, yields once for each error message. # - # person.errors.add(:name, "can't be blank") + # person.errors.add(:name, :blank, message: "can't be blank") # person.errors.each do |attribute, error| # # Will yield :name and "can't be blank" # end # - # person.errors.add(:name, "must be specified") + # person.errors.add(:name, :not_specified, message: "must be specified") # person.errors.each do |attribute, error| # # Will yield :name and "can't be blank" # # then yield :name and "must be specified" # end def each messages.each_key do |attribute| - self[attribute].each { |error| yield attribute, error } + messages[attribute].each { |error| yield attribute, error } end end # Returns the number of error messages. # - # person.errors.add(:name, "can't be blank") + # person.errors.add(:name, :blank, message: "can't be blank") # person.errors.size # => 1 - # person.errors.add(:name, "must be specified") + # person.errors.add(:name, :not_specified, message: "must be specified") # person.errors.size # => 2 def size values.flatten.size end + alias :count :size # Returns all message values. # - # person.errors.messages # => {:name=>["can not be nil", "must be specified"]} - # person.errors.values # => [["can not be nil", "must be specified"]] + # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]} + # person.errors.values # => [["cannot be nil", "must be specified"]] def values messages.values end # Returns all message keys. # - # person.errors.messages # => {:name=>["can not be nil", "must be specified"]} + # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]} # person.errors.keys # => [:name] def keys messages.keys end - # Returns an array of error messages, with the attribute name included. - # - # person.errors.add(:name, "can't be blank") - # person.errors.add(:name, "must be specified") - # person.errors.to_a # => ["name can't be blank", "name must be specified"] - def to_a - full_messages - end - - # Returns the number of error messages. - # - # person.errors.add(:name, "can't be blank") - # person.errors.count # => 1 - # person.errors.add(:name, "must be specified") - # person.errors.count # => 2 - def count - to_a.size - end - # Returns +true+ if no errors are found, +false+ otherwise. # If the error message is a string it can be empty. # - # person.errors.full_messages # => ["name can not be nil"] + # person.errors.full_messages # => ["name cannot be nil"] # person.errors.empty? # => false def empty? - all? { |k, v| v && v.empty? && !v.is_a?(String) } + size.zero? end - # aliases empty? - alias_method :blank?, :empty? + alias :blank? :empty? # Returns an xml formatted representation of the Errors hash. # - # person.errors.add(:name, "can't be blank") - # person.errors.add(:name, "must be specified") + # person.errors.add(:name, :blank, message: "can't be blank") + # person.errors.add(:name, :not_specified, message: "must be specified") # person.errors.to_xml # # => # # <?xml version=\"1.0\" encoding=\"UTF-8\"?> @@ -238,8 +254,8 @@ module ActiveModel # object. You can pass the <tt>:full_messages</tt> option. This determines # if the json object should contain full messages or not (false by default). # - # person.errors.as_json # => {:name=>["can not be nil"]} - # person.errors.as_json(full_messages: true) # => {:name=>["name can not be nil"]} + # person.errors.as_json # => {:name=>["cannot be nil"]} + # person.errors.as_json(full_messages: true) # => {:name=>["name cannot be nil"]} def as_json(options=nil) to_hash(options && options[:full_messages]) end @@ -247,31 +263,32 @@ module ActiveModel # Returns a Hash of attributes with their error messages. If +full_messages+ # is +true+, it will contain full messages (see +full_message+). # - # person.errors.to_hash # => {:name=>["can not be nil"]} - # person.errors.to_hash(true) # => {:name=>["name can not be nil"]} + # person.errors.to_hash # => {:name=>["cannot be nil"]} + # person.errors.to_hash(true) # => {:name=>["name cannot be nil"]} def to_hash(full_messages = false) if full_messages - messages = {} - self.messages.each do |attribute, array| + self.messages.each_with_object({}) do |(attribute, array), messages| messages[attribute] = array.map { |message| full_message(attribute, message) } end - messages else self.messages.dup end end - # Adds +message+ to the error messages on +attribute+. More than one error - # can be added to the same +attribute+. If no +message+ is supplied, - # <tt>:invalid</tt> is assumed. + # Adds +message+ to the error messages and used validator type to +details+ on +attribute+. + # More than one error can be added to the same +attribute+. + # If no +message+ is supplied, <tt>:invalid</tt> is assumed. # # person.errors.add(:name) # # => ["is invalid"] - # person.errors.add(:name, 'must be implemented') + # person.errors.add(:name, :not_implemented, message: "must be implemented") # # => ["is invalid", "must be implemented"] # # person.errors.messages - # # => {:name=>["must be implemented", "is invalid"]} + # # => {:name=>["is invalid", "must be implemented"]} + # + # person.errors.details + # # => {:name=>[{error: :not_implemented}, {error: :invalid}]} # # If +message+ is a symbol, it will be translated using the appropriate # scope (see +generate_message+). @@ -279,24 +296,37 @@ module ActiveModel # If +message+ is a proc, it will be called, allowing for things like # <tt>Time.now</tt> to be used within an error. # - # If the <tt>:strict</tt> option is set to true will raise + # If the <tt>:strict</tt> option is set to +true+, it will raise # ActiveModel::StrictValidationFailed instead of adding the error. # <tt>:strict</tt> option can also be set to any other exception. # - # person.errors.add(:name, nil, strict: true) + # person.errors.add(:name, :invalid, strict: true) # # => ActiveModel::StrictValidationFailed: name is invalid - # person.errors.add(:name, nil, strict: NameIsInvalid) + # person.errors.add(:name, :invalid, strict: NameIsInvalid) # # => NameIsInvalid: name is invalid # # person.errors.messages # => {} + # + # +attribute+ should be set to <tt>:base</tt> if the error is not + # directly associated with a single attribute. + # + # person.errors.add(:base, :name_or_email_blank, + # message: "either name or email must be present") + # person.errors.messages + # # => {:base=>["either name or email must be present"]} + # person.errors.details + # # => {:base=>[{error: :name_or_email_blank}]} def add(attribute, message = :invalid, options = {}) + message = message.call if message.respond_to?(:call) + detail = normalize_detail(attribute, message, options) message = normalize_message(attribute, message, options) if exception = options[:strict] exception = ActiveModel::StrictValidationFailed if exception == true raise exception, full_message(attribute, message) end - self[attribute] << message + details[attribute.to_sym] << detail + messages[attribute.to_sym] << message end # Will add an error message to each of the attributes in +attributes+ @@ -306,6 +336,14 @@ module ActiveModel # person.errors.messages # # => {:name=>["can't be empty"]} def add_on_empty(attributes, options = {}) + ActiveSupport::Deprecation.warn(<<-MESSAGE.squish) + ActiveModel::Errors#add_on_empty is deprecated and will be removed in Rails 5.1 + + To achieve the same use: + + errors.add(attribute, :empty, options) if value.nil? || value.empty? + MESSAGE + Array(attributes).each do |attribute| value = @base.send(:read_attribute_for_validation, attribute) is_empty = value.respond_to?(:empty?) ? value.empty? : false @@ -320,6 +358,14 @@ module ActiveModel # person.errors.messages # # => {:name=>["can't be blank"]} def add_on_blank(attributes, options = {}) + ActiveSupport::Deprecation.warn(<<-MESSAGE.squish) + ActiveModel::Errors#add_on_blank is deprecated and will be removed in Rails 5.1 + + To achieve the same use: + + errors.add(attribute, :empty, options) if value.blank? + MESSAGE + Array(attributes).each do |attribute| value = @base.send(:read_attribute_for_validation, attribute) add(attribute, :blank, options) if value.blank? @@ -332,6 +378,7 @@ module ActiveModel # person.errors.add :name, :blank # person.errors.added? :name, :blank # => true def added?(attribute, message = :invalid, options = {}) + message = message.call if message.respond_to?(:call) message = normalize_message(attribute, message, options) self[attribute].include? message end @@ -349,6 +396,7 @@ module ActiveModel def full_messages map { |attribute, message| full_message(attribute, message) } end + alias :to_a :full_messages # Returns all the full error messages for a given attribute in an array. # @@ -361,7 +409,7 @@ module ActiveModel # person.errors.full_messages_for(:name) # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank"] def full_messages_for(attribute) - (get(attribute) || []).map { |message| full_message(attribute, message) } + messages[attribute].map { |message| full_message(attribute, message) } end # Returns a full message for a given attribute. @@ -381,8 +429,8 @@ module ActiveModel # Translates an error message in its default scope # (<tt>activemodel.errors.messages</tt>). # - # Error messages are first looked up in <tt>models.MODEL.attributes.ATTRIBUTE.MESSAGE</tt>, - # if it's not there, it's looked up in <tt>models.MODEL.MESSAGE</tt> and if + # Error messages are first looked up in <tt>activemodel.errors.models.MODEL.attributes.ATTRIBUTE.MESSAGE</tt>, + # if it's not there, it's looked up in <tt>activemodel.errors.models.MODEL.MESSAGE</tt> and if # that is not there also, it returns the translation of the default message # (e.g. <tt>activemodel.errors.messages.MESSAGE</tt>). The translated model # name, translated attribute name and the value are available for @@ -414,7 +462,6 @@ module ActiveModel defaults = [] end - defaults << options.delete(:message) defaults << :"#{@base.class.i18n_scope}.errors.messages.#{type}" if @base.class.respond_to?(:i18n_scope) defaults << :"errors.attributes.#{attribute}.#{type}" defaults << :"errors.messages.#{type}" @@ -423,11 +470,12 @@ module ActiveModel defaults.flatten! 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.class.model_name.human, + model: @base.model_name.human, attribute: @base.class.human_attribute_name(attribute), value: value }.merge!(options) @@ -440,12 +488,14 @@ module ActiveModel case message when Symbol generate_message(attribute, message, options.except(*CALLBACKS_OPTIONS)) - when Proc - message.call else message end end + + def normalize_detail(attribute, message, options) + { error: message }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS)) + end end # Raised when a validation cannot be corrected by end users and are considered @@ -465,4 +515,15 @@ module ActiveModel # # => ActiveModel::StrictValidationFailed: Name can't be blank class StrictValidationFailed < StandardError end + + # Raised when unknown attributes are supplied via mass assignment. + class UnknownAttributeError < NoMethodError + attr_reader :record, :attribute + + def initialize(record, attribute) + @record = record + @attribute = attribute + super("unknown attribute '#{attribute}' for #{@record.class}.") + end + end end diff --git a/activemodel/lib/active_model/forbidden_attributes_protection.rb b/activemodel/lib/active_model/forbidden_attributes_protection.rb index 7468f95548..d2c6a89cc2 100644 --- a/activemodel/lib/active_model/forbidden_attributes_protection.rb +++ b/activemodel/lib/active_model/forbidden_attributes_protection.rb @@ -17,11 +17,13 @@ module ActiveModel module ForbiddenAttributesProtection # :nodoc: protected def sanitize_for_mass_assignment(attributes) - if attributes.respond_to?(:permitted?) && !attributes.permitted? - raise ActiveModel::ForbiddenAttributesError + if attributes.respond_to?(:permitted?) + raise ActiveModel::ForbiddenAttributesError if !attributes.permitted? + attributes.to_h else attributes end end + alias :sanitize_forbidden_attributes :sanitize_for_mass_assignment end end diff --git a/activemodel/lib/active_model/gem_version.rb b/activemodel/lib/active_model/gem_version.rb new file mode 100644 index 0000000000..d74a3912d6 --- /dev/null +++ b/activemodel/lib/active_model/gem_version.rb @@ -0,0 +1,15 @@ +module ActiveModel + # Returns the version of the currently loaded \Active \Model as a <tt>Gem::Version</tt> + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 5 + MINOR = 0 + TINY = 0 + PRE = "beta1" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/activemodel/lib/active_model/lint.rb b/activemodel/lib/active_model/lint.rb index 46b446dc08..010eaeb170 100644 --- a/activemodel/lib/active_model/lint.rb +++ b/activemodel/lib/active_model/lint.rb @@ -13,7 +13,7 @@ module ActiveModel # # These tests do not attempt to determine the semantic correctness of the # returned values. For instance, you could implement <tt>valid?</tt> to - # always return true, and the tests would pass. It is up to you to ensure + # always return +true+, and the tests would pass. It is up to you to ensure # that the values are semantically meaningful. # # Objects you pass in are expected to return a compliant object from a call @@ -21,28 +21,27 @@ module ActiveModel # +self+. module Tests - # == Responds to <tt>to_key</tt> + # Passes if the object's model responds to <tt>to_key</tt> and if calling + # this method returns +nil+ when the object is not persisted. + # Fails otherwise. # - # Returns an Enumerable of all (primary) key attributes - # or nil if <tt>model.persisted?</tt> is false. This is used by - # <tt>dom_id</tt> to generate unique ids for the object. + # <tt>to_key</tt> returns an Enumerable of all (primary) key attributes + # of the model, and is used to a generate unique DOM id for the object. def test_to_key assert model.respond_to?(:to_key), "The model should respond to to_key" def model.persisted?() false end assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false" end - # == Responds to <tt>to_param</tt> - # - # Returns a string representing the object's key suitable for use in URLs - # or +nil+ if <tt>model.persisted?</tt> is +false+. + # Passes if the object's model responds to <tt>to_param</tt> and if + # calling this method returns +nil+ when the object is not persisted. + # Fails otherwise. # + # <tt>to_param</tt> is used to represent the object's key in URLs. # Implementers can decide to either raise an exception or provide a # default in case the record uses a composite primary key. There are no # tests for this behavior in lint because it doesn't make sense to force # any of the possible implementation strategies on the implementer. - # However, if the resource is not persisted?, then <tt>to_param</tt> - # should always return +nil+. def test_to_param assert model.respond_to?(:to_param), "The model should respond to to_param" def model.to_key() [1] end @@ -50,47 +49,55 @@ module ActiveModel assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false" end - # == Responds to <tt>to_partial_path</tt> + # Passes if the object's model responds to <tt>to_partial_path</tt> and if + # calling this method returns a string. Fails otherwise. # - # Returns a string giving a relative path. This is used for looking up - # partials. For example, a BlogPost model might return "blog_posts/blog_post" + # <tt>to_partial_path</tt> is used for looking up partials. For example, + # a BlogPost model might return "blog_posts/blog_post". def test_to_partial_path assert model.respond_to?(:to_partial_path), "The model should respond to to_partial_path" assert_kind_of String, model.to_partial_path end - # == Responds to <tt>persisted?</tt> + # Passes if the object's model responds to <tt>persisted?</tt> and if + # calling this method returns either +true+ or +false+. Fails otherwise. # - # Returns a boolean that specifies whether the object has been persisted - # yet. This is used when calculating the URL for an object. If the object - # is not persisted, a form for that object, for instance, will route to - # the create action. If it is persisted, a form for the object will routes - # to the update action. + # <tt>persisted?</tt> is used when calculating the URL for an object. + # If the object is not persisted, a form for that object, for instance, + # will route to the create action. If it is persisted, a form for the + # object will route to the update action. def test_persisted? assert model.respond_to?(:persisted?), "The model should respond to persisted?" assert_boolean model.persisted?, "persisted?" end - # == \Naming + # Passes if the object's model responds to <tt>model_name</tt> both as + # an instance method and as a class method, and if calling this method + # returns a string with some convenience methods: <tt>:human</tt>, + # <tt>:singular</tt> and <tt>:plural</tt>. # - # Model.model_name must return a string with some convenience methods: - # <tt>:human</tt>, <tt>:singular</tt> and <tt>:plural</tt>. Check - # ActiveModel::Naming for more information. + # Check ActiveModel::Naming for more information. def test_model_naming - assert model.class.respond_to?(:model_name), "The model should respond to model_name" + assert model.class.respond_to?(:model_name), "The model class should respond to model_name" model_name = model.class.model_name assert model_name.respond_to?(:to_str) assert model_name.human.respond_to?(:to_str) assert model_name.singular.respond_to?(:to_str) assert model_name.plural.respond_to?(:to_str) + + assert model.respond_to?(:model_name), "The model instance should respond to model_name" + assert_equal model.model_name, model.class.model_name end - # == \Errors Testing + # Passes if the object's model responds to <tt>errors</tt> and if calling + # <tt>[](attribute)</tt> on the result of this method returns an array. + # Fails otherwise. # - # Returns an object that implements [](attribute) defined which returns an - # Array of Strings that are the errors for the attribute in question. - # If localization is used, the Strings should be localized for the current - # locale. If no error is present, this method should return an empty Array. + # <tt>errors[attribute]</tt> is used to retrieve the errors of a model + # for a given attribute. If errors are present, the method should return + # an array of strings that are the errors for the attribute in question. + # If localization is used, the strings should be localized for the current + # locale. If no error is present, the method should return an empty array. def test_errors_aref assert model.respond_to?(:errors), "The model should respond to errors" assert model.errors[:hello].is_a?(Array), "errors#[] should return an Array" diff --git a/activemodel/lib/active_model/locale/en.yml b/activemodel/lib/active_model/locale/en.yml index 540e8132d3..061e35dd1e 100644 --- a/activemodel/lib/active_model/locale/en.yml +++ b/activemodel/lib/active_model/locale/en.yml @@ -6,6 +6,7 @@ en: # The values :model, :attribute and :value are always available for interpolation # The value :count is available when applicable. Can be used for pluralization. messages: + model_invalid: "Validation failed: %{errors}" inclusion: "is not included in the list" exclusion: "is reserved" invalid: "is invalid" @@ -14,9 +15,15 @@ en: empty: "can't be empty" blank: "can't be blank" present: "must be blank" - too_long: "is too long (maximum is %{count} characters)" - too_short: "is too short (minimum is %{count} characters)" - wrong_length: "is the wrong length (should be %{count} characters)" + too_long: + one: "is too long (maximum is 1 character)" + other: "is too long (maximum is %{count} characters)" + too_short: + one: "is too short (minimum is 1 character)" + other: "is too short (minimum is %{count} characters)" + wrong_length: + one: "is the wrong length (should be 1 character)" + other: "is the wrong length (should be %{count} characters)" not_a_number: "is not a number" not_an_integer: "must be an integer" greater_than: "must be greater than %{count}" diff --git a/activemodel/lib/active_model/model.rb b/activemodel/lib/active_model/model.rb index f048dda5c6..dac8d549a7 100644 --- a/activemodel/lib/active_model/model.rb +++ b/activemodel/lib/active_model/model.rb @@ -1,6 +1,6 @@ module ActiveModel - # == Active \Model Basic \Model + # == Active \Model \Basic \Model # # Includes the required interface for an object to interact with # <tt>ActionPack</tt>, using different <tt>ActiveModel</tt> modules. @@ -16,8 +16,8 @@ module ActiveModel # end # # person = Person.new(name: 'bob', age: '18') - # person.name # => 'bob' - # person.age # => 18 + # person.name # => "bob" + # person.age # => "18" # # Note that, by default, <tt>ActiveModel::Model</tt> implements <tt>persisted?</tt> # to return +false+, which is the most common case. You may want to override @@ -56,13 +56,14 @@ module ActiveModel # refer to the specific modules included in <tt>ActiveModel::Model</tt> # (see below). module Model - def self.included(base) #:nodoc: - base.class_eval do - extend ActiveModel::Naming - extend ActiveModel::Translation - include ActiveModel::Validations - include ActiveModel::Conversion - end + extend ActiveSupport::Concern + include ActiveModel::AttributeAssignment + include ActiveModel::Validations + include ActiveModel::Conversion + + included do + extend ActiveModel::Naming + extend ActiveModel::Translation end # Initializes a new model with the given +params+. @@ -74,11 +75,9 @@ module ActiveModel # # person = Person.new(name: 'bob', age: '18') # person.name # => "bob" - # person.age # => 18 - def initialize(params={}) - params.each do |attr, value| - self.public_send("#{attr}=", value) - end if params + # person.age # => "18" + def initialize(attributes={}) + assign_attributes(attributes) if attributes super() end diff --git a/activemodel/lib/active_model/naming.rb b/activemodel/lib/active_model/naming.rb index 198efc5088..d86ef6224e 100644 --- a/activemodel/lib/active_model/naming.rb +++ b/activemodel/lib/active_model/naming.rb @@ -1,5 +1,7 @@ require 'active_support/core_ext/hash/except' require 'active_support/core_ext/module/introspection' +require 'active_support/core_ext/module/remove_method' +require 'active_support/core_ext/module/delegation' module ActiveModel class Name @@ -129,7 +131,7 @@ module ActiveModel # # Equivalent to +to_s+. delegate :==, :===, :<=>, :=~, :"!~", :eql?, :to_s, - :to_str, to: :name + :to_str, :as_json, to: :name # Returns a new ActiveModel::Name instance. By default, the +namespace+ # and +name+ option will take the namespace and name of the given class @@ -162,7 +164,7 @@ module ActiveModel @route_key << "_index" if @plural == @singular end - # Transform the model name into a more humane format, using I18n. By default, + # Transform the model name into a more human format, using I18n. By default, # it will underscore then humanize the class name. # # class BlogPost @@ -189,8 +191,8 @@ module ActiveModel private - def _singularize(string, replacement='_') - ActiveSupport::Inflector.underscore(string).tr('/', replacement) + def _singularize(string) + ActiveSupport::Inflector.underscore(string).tr('/'.freeze, '_'.freeze) end end @@ -204,24 +206,30 @@ module ActiveModel # extend ActiveModel::Naming # end # - # BookCover.model_name # => "BookCover" + # BookCover.model_name.name # => "BookCover" # BookCover.model_name.human # => "Book cover" # # BookCover.model_name.i18n_key # => :book_cover # BookModule::BookCover.model_name.i18n_key # => :"book_module/book_cover" # # Providing the functionality that ActiveModel::Naming provides in your object - # is required to pass the Active Model Lint test. So either extending the + # is required to pass the \Active \Model Lint test. So either extending the # provided method below, or rolling your own is required. module Naming + def self.extended(base) #:nodoc: + base.remove_possible_method :model_name + base.delegate :model_name, to: :class + end + # Returns an ActiveModel::Name object for module. It can be # used to retrieve all kinds of naming-related information # (See ActiveModel::Name for more information). # - # class Person < ActiveModel::Model + # class Person + # extend ActiveModel::Naming # end # - # Person.model_name # => Person + # Person.model_name.name # => "Person" # Person.model_name.class # => ActiveModel::Name # Person.model_name.singular # => "person" # Person.model_name.plural # => "people" @@ -262,10 +270,10 @@ module ActiveModel # namespaced models regarding whether it's inside isolated engine. # # # For isolated engine: - # ActiveModel::Naming.singular_route_key(Blog::Post) #=> "post" + # ActiveModel::Naming.singular_route_key(Blog::Post) # => "post" # # # For shared engine: - # ActiveModel::Naming.singular_route_key(Blog::Post) #=> "blog_post" + # ActiveModel::Naming.singular_route_key(Blog::Post) # => "blog_post" def self.singular_route_key(record_or_class) model_name_from_record_or_class(record_or_class).singular_route_key end @@ -274,10 +282,10 @@ module ActiveModel # namespaced models regarding whether it's inside isolated engine. # # # For isolated engine: - # ActiveModel::Naming.route_key(Blog::Post) #=> "posts" + # ActiveModel::Naming.route_key(Blog::Post) # => "posts" # # # For shared engine: - # ActiveModel::Naming.route_key(Blog::Post) #=> "blog_posts" + # ActiveModel::Naming.route_key(Blog::Post) # => "blog_posts" # # The route key also considers if the noun is uncountable and, in # such cases, automatically appends _index. @@ -289,21 +297,19 @@ module ActiveModel # namespaced models regarding whether it's inside isolated engine. # # # For isolated engine: - # ActiveModel::Naming.param_key(Blog::Post) #=> "post" + # ActiveModel::Naming.param_key(Blog::Post) # => "post" # # # For shared engine: - # ActiveModel::Naming.param_key(Blog::Post) #=> "blog_post" + # ActiveModel::Naming.param_key(Blog::Post) # => "blog_post" def self.param_key(record_or_class) model_name_from_record_or_class(record_or_class).param_key end def self.model_name_from_record_or_class(record_or_class) #:nodoc: - if record_or_class.respond_to?(:model_name) - record_or_class.model_name - elsif record_or_class.respond_to?(:to_model) - record_or_class.to_model.class.model_name + if record_or_class.respond_to?(:to_model) + record_or_class.to_model.model_name else - record_or_class.class.model_name + record_or_class.model_name end end private_class_method :model_name_from_record_or_class diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb index 7e694b5c50..89da74efa8 100644 --- a/activemodel/lib/active_model/secure_password.rb +++ b/activemodel/lib/active_model/secure_password.rb @@ -2,6 +2,11 @@ module ActiveModel module SecurePassword extend ActiveSupport::Concern + # BCrypt hash function can handle maximum 72 characters, and if we pass + # password of length more than 72 characters it ignores extra characters. + # Hence need to put a restriction on password length. + MAX_PASSWORD_LENGTH_ALLOWED = 72 + class << self attr_accessor :min_cost # :nodoc: end @@ -9,20 +14,24 @@ 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 +password_digest+ attribute. + # + # The following validations are added automatically: + # * Password must be present on creation + # * Password length should be less than or equal to 72 characters + # * Confirmation of password (using a +password_confirmation+ attribute) # - # Validations for presence of password on create, confirmation of password - # (using a +password_confirmation+ attribute) are automatically added. If - # you wish to turn off validations, pass <tt>validations: false</tt> as an - # argument. You can add more validations by hand if need be. + # If password confirmation validation is not needed, simply leave out the + # value for +password_confirmation+ (i.e. don't provide a form field for + # it). When this attribute has a +nil+ value, the validation will not be + # triggered. # - # If you don't need the confirmation validation, just don't set any - # value to the password_confirmation attribute and the validation - # will not be triggered. + # For further customizability, it is possible to suppress the default + # validations by passing <tt>validations: false</tt> as an argument. # - # You need to add bcrypt-ruby (~> 3.1.2) to Gemfile to use #has_secure_password: + # Add bcrypt (~> 3.1.7) to Gemfile to use #has_secure_password: # - # gem 'bcrypt-ruby', '~> 3.1.2' + # gem 'bcrypt', '~> 3.1.7' # # Example using Active Record (which automatically includes ActiveModel::SecurePassword): # @@ -42,32 +51,31 @@ module ActiveModel # User.find_by(name: 'david').try(:authenticate, 'notright') # => false # User.find_by(name: 'david').try(:authenticate, 'mUc3m00RsqyRe') # => user def has_secure_password(options = {}) - # Load bcrypt-ruby only when has_secure_password is used. + # Load bcrypt gem only when has_secure_password is used. # This is to avoid ActiveModel (and by extension the entire framework) # being dependent on a binary library. begin require 'bcrypt' rescue LoadError - $stderr.puts "You don't have bcrypt-ruby installed in your application. Please add it to your Gemfile and run bundle install" + $stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install" raise end - attr_reader :password - include InstanceMethodsOnActivation if options.fetch(:validations, true) - validates_confirmation_of :password, if: :should_confirm_password? - validates_presence_of :password, on: :create - validates_presence_of :password_confirmation, if: :should_confirm_password? - - before_create { raise "Password digest missing on new record" if password_digest.blank? } - end + include ActiveModel::Validations - if respond_to?(:attributes_protected_by_default) - def self.attributes_protected_by_default #:nodoc: - super + ['password_digest'] + # This ensures the model has a password by checking whether the password_digest + # is present, so that this works with both new and existing records. However, + # when there is an error, the message is added to the password attribute instead + # so that the error message will make sense to the end-user. + validate do |record| + record.errors.add(:password, :blank) unless record.password_digest.present? end + + validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED + validates_confirmation_of :password, allow_blank: true end end end @@ -84,11 +92,13 @@ module ActiveModel # user.authenticate('notright') # => false # user.authenticate('mUc3m00RsqyRe') # => user def authenticate(unencrypted_password) - BCrypt::Password.new(password_digest) == unencrypted_password && self + BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self end + attr_reader :password + # Encrypts the password into the +password_digest+ attribute, only if the - # new password is not blank. + # new password is not empty. # # class User < ActiveRecord::Base # has_secure_password validations: false @@ -100,7 +110,9 @@ module ActiveModel # user.password = 'mUc3m00RsqyRe' # user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4." def password=(unencrypted_password) - unless unencrypted_password.blank? + 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) @@ -110,12 +122,6 @@ module ActiveModel def password_confirmation=(unencrypted_password) @password_confirmation = unencrypted_password end - - private - - def should_confirm_password? - password_confirmation && password.present? - end end end end diff --git a/activemodel/lib/active_model/serialization.rb b/activemodel/lib/active_model/serialization.rb index fdb06aebb9..70e10fa06d 100644 --- a/activemodel/lib/active_model/serialization.rb +++ b/activemodel/lib/active_model/serialization.rb @@ -4,7 +4,7 @@ require 'active_support/core_ext/hash/slice' module ActiveModel # == Active \Model \Serialization # - # Provides a basic serialization to a serializable_hash for your object. + # Provides a basic serialization to a serializable_hash for your objects. # # A minimal implementation could be: # @@ -25,22 +25,20 @@ module ActiveModel # person.name = "Bob" # person.serializable_hash # => {"name"=>"Bob"} # - # You need to declare an attributes hash which contains the attributes you - # want to serialize. Attributes must be strings, not symbols. When called, - # serializable hash will use instance methods that match the name of the - # attributes hash's keys. In order to override this behavior, take a look at - # the private method +read_attribute_for_serialization+. + # An +attributes+ hash must be defined and should contain any attributes you + # need to be serialized. Attributes must be strings, not symbols. + # When called, serializable hash will use instance methods that match the name + # of the attributes hash's keys. In order to override this behavior, take a look + # at the private method +read_attribute_for_serialization+. # - # Most of the time though, you will want to include the JSON or XML - # serializations. Both of these modules automatically include the - # <tt>ActiveModel::Serialization</tt> module, so there is no need to - # explicitly include it. + # ActiveModel::Serializers::JSON module automatically includes + # the <tt>ActiveModel::Serialization</tt> module, so there is no need to + # explicitly include <tt>ActiveModel::Serialization</tt>. # - # A minimal implementation including XML and JSON would be: + # A minimal implementation including JSON would be: # # class Person # include ActiveModel::Serializers::JSON - # include ActiveModel::Serializers::Xml # # attr_accessor :name # @@ -55,13 +53,11 @@ module ActiveModel # person.serializable_hash # => {"name"=>nil} # person.as_json # => {"name"=>nil} # person.to_json # => "{\"name\":null}" - # person.to_xml # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<serial-person... # # person.name = "Bob" # person.serializable_hash # => {"name"=>"Bob"} # person.as_json # => {"name"=>"Bob"} # person.to_json # => "{\"name\":\"Bob\"}" - # person.to_xml # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<serial-person... # # Valid options are <tt>:only</tt>, <tt>:except</tt>, <tt>:methods</tt> and # <tt>:include</tt>. The following are all valid examples: @@ -94,6 +90,37 @@ module ActiveModel # person.serializable_hash(except: :name) # => {"age"=>22} # person.serializable_hash(methods: :capitalized_name) # # => {"name"=>"bob", "age"=>22, "capitalized_name"=>"Bob"} + # + # Example with <tt>:include</tt> option + # + # class User + # include ActiveModel::Serializers::JSON + # attr_accessor :name, :notes # Emulate has_many :notes + # def attributes + # {'name' => nil} + # end + # end + # + # class Note + # include ActiveModel::Serializers::JSON + # attr_accessor :title, :text + # def attributes + # {'title' => nil, 'text' => nil} + # end + # end + # + # note = Note.new + # note.title = 'Battle of Austerlitz' + # note.text = 'Some text here' + # + # user = User.new + # user.name = 'Napoleon' + # user.notes = [note] + # + # user.serializable_hash + # # => {"name" => "Napoleon"} + # user.serializable_hash(include: { notes: { only: 'title' }}) + # # => {"name" => "Napoleon", "notes" => [{"title"=>"Battle of Austerlitz"}]} def serializable_hash(options = nil) options ||= {} @@ -107,7 +134,7 @@ module ActiveModel hash = {} attribute_names.each { |n| hash[n] = read_attribute_for_serialization(n) } - Array(options[:methods]).each { |m| hash[m.to_s] = send(m) if respond_to?(m) } + Array(options[:methods]).each { |m| hash[m.to_s] = send(m) } serializable_add_includes(options) do |association, records, opts| hash[association.to_s] = if records.respond_to?(:to_ary) @@ -128,7 +155,7 @@ module ActiveModel # retrieve the value for a given attribute differently: # # class MyClass - # include ActiveModel::Validations + # include ActiveModel::Serialization # # def initialize(data = {}) # @data = data diff --git a/activemodel/lib/active_model/serializers/json.rb b/activemodel/lib/active_model/serializers/json.rb index 05e2e089e5..b66dbf1afe 100644 --- a/activemodel/lib/active_model/serializers/json.rb +++ b/activemodel/lib/active_model/serializers/json.rb @@ -2,7 +2,7 @@ require 'active_support/json' module ActiveModel module Serializers - # == Active Model JSON Serializer + # == Active \Model \JSON \Serializer module JSON extend ActiveSupport::Concern include ActiveModel::Serialization @@ -93,7 +93,7 @@ module ActiveModel end if root - root = self.class.model_name.element if root == true + root = model_name.element if root == true { root => serializable_hash(options) } else serializable_hash(options) @@ -130,10 +130,10 @@ module ActiveModel # # json = { person: { name: 'bob', age: 22, awesome:true } }.to_json # person = Person.new - # person.from_json(json) # => #<Person:0x007fec5e7a0088 @age=22, @awesome=true, @name="bob"> - # person.name # => "bob" - # person.age # => 22 - # person.awesome # => true + # person.from_json(json, true) # => #<Person:0x007fec5e7a0088 @age=22, @awesome=true, @name="bob"> + # person.name # => "bob" + # person.age # => 22 + # person.awesome # => true def from_json(json, include_root=include_root_in_json) hash = ActiveSupport::JSON.decode(json) hash = hash.values.first if include_root diff --git a/activemodel/lib/active_model/serializers/xml.rb b/activemodel/lib/active_model/serializers/xml.rb deleted file mode 100644 index 2864c2ba11..0000000000 --- a/activemodel/lib/active_model/serializers/xml.rb +++ /dev/null @@ -1,238 +0,0 @@ -require 'active_support/core_ext/class/attribute_accessors' -require 'active_support/core_ext/array/conversions' -require 'active_support/core_ext/hash/conversions' -require 'active_support/core_ext/hash/slice' -require 'active_support/core_ext/time/acts_like' - -module ActiveModel - module Serializers - # == Active Model XML Serializer - module Xml - extend ActiveSupport::Concern - include ActiveModel::Serialization - - included do - extend ActiveModel::Naming - end - - class Serializer #:nodoc: - class Attribute #:nodoc: - attr_reader :name, :value, :type - - def initialize(name, serializable, value) - @name, @serializable = name, serializable - - if value.acts_like?(:time) && value.respond_to?(:in_time_zone) - value = value.in_time_zone - end - - @value = value - @type = compute_type - end - - def decorations - decorations = {} - decorations[:encoding] = 'base64' if type == :binary - decorations[:type] = (type == :string) ? nil : type - decorations[:nil] = true if value.nil? - decorations - end - - protected - - def compute_type - return if value.nil? - type = ActiveSupport::XmlMini::TYPE_NAMES[value.class.name] - type ||= :string if value.respond_to?(:to_str) - type ||= :yaml - type - end - end - - class MethodAttribute < Attribute #:nodoc: - end - - attr_reader :options - - def initialize(serializable, options = nil) - @serializable = serializable - @options = options ? options.dup : {} - end - - def serializable_hash - @serializable.serializable_hash(@options.except(:include)) - end - - def serializable_collection - methods = Array(options[:methods]).map(&:to_s) - serializable_hash.map do |name, value| - name = name.to_s - if methods.include?(name) - self.class::MethodAttribute.new(name, @serializable, value) - else - self.class::Attribute.new(name, @serializable, value) - end - end - end - - def serialize - require 'builder' unless defined? ::Builder - - options[:indent] ||= 2 - options[:builder] ||= ::Builder::XmlMarkup.new(indent: options[:indent]) - - @builder = options[:builder] - @builder.instruct! unless options[:skip_instruct] - - root = (options[:root] || @serializable.class.model_name.element).to_s - root = ActiveSupport::XmlMini.rename_key(root, options) - - args = [root] - args << { xmlns: options[:namespace] } if options[:namespace] - args << { type: options[:type] } if options[:type] && !options[:skip_types] - - @builder.tag!(*args) do - add_attributes_and_methods - add_includes - add_extra_behavior - add_procs - yield @builder if block_given? - end - end - - private - - def add_extra_behavior - end - - def add_attributes_and_methods - serializable_collection.each do |attribute| - key = ActiveSupport::XmlMini.rename_key(attribute.name, options) - ActiveSupport::XmlMini.to_tag(key, attribute.value, - options.merge(attribute.decorations)) - end - end - - def add_includes - @serializable.send(:serializable_add_includes, options) do |association, records, opts| - add_associations(association, records, opts) - end - end - - # TODO: This can likely be cleaned up to simple use ActiveSupport::XmlMini.to_tag as well. - def add_associations(association, records, opts) - merged_options = opts.merge(options.slice(:builder, :indent)) - merged_options[:skip_instruct] = true - - [:skip_types, :dasherize, :camelize].each do |key| - merged_options[key] = options[key] if merged_options[key].nil? && !options[key].nil? - end - - if records.respond_to?(:to_ary) - records = records.to_ary - - tag = ActiveSupport::XmlMini.rename_key(association.to_s, options) - type = options[:skip_types] ? { } : { type: "array" } - association_name = association.to_s.singularize - merged_options[:root] = association_name - - if records.empty? - @builder.tag!(tag, type) - else - @builder.tag!(tag, type) do - records.each do |record| - if options[:skip_types] - record_type = {} - else - record_class = (record.class.to_s.underscore == association_name) ? nil : record.class.name - record_type = { type: record_class } - end - - record.to_xml merged_options.merge(record_type) - end - end - end - else - merged_options[:root] = association.to_s - - unless records.class.to_s.underscore == association.to_s - merged_options[:type] = records.class.name - end - - records.to_xml merged_options - end - end - - def add_procs - if procs = options.delete(:procs) - Array(procs).each do |proc| - if proc.arity == 1 - proc.call(options) - else - proc.call(options, @serializable) - end - end - end - end - end - - # Returns XML representing the model. Configuration can be - # passed through +options+. - # - # Without any +options+, the returned XML string will include all the - # model's attributes. - # - # user = User.find(1) - # user.to_xml - # - # <?xml version="1.0" encoding="UTF-8"?> - # <user> - # <id type="integer">1</id> - # <name>David</name> - # <age type="integer">16</age> - # <created-at type="dateTime">2011-01-30T22:29:23Z</created-at> - # </user> - # - # The <tt>:only</tt> and <tt>:except</tt> options can be used to limit the - # attributes included, and work similar to the +attributes+ method. - # - # To include the result of some method calls on the model use <tt>:methods</tt>. - # - # To include associations use <tt>:include</tt>. - # - # For further documentation, see <tt>ActiveRecord::Serialization#to_xml</tt> - def to_xml(options = {}, &block) - Serializer.new(self, options).serialize(&block) - end - - # Sets the model +attributes+ from an XML string. Returns +self+. - # - # class Person - # include ActiveModel::Serializers::Xml - # - # attr_accessor :name, :age, :awesome - # - # def attributes=(hash) - # hash.each do |key, value| - # instance_variable_set("@#{key}", value) - # end - # end - # - # def attributes - # instance_values - # end - # end - # - # xml = { name: 'bob', age: 22, awesome:true }.to_xml - # person = Person.new - # person.from_xml(xml) # => #<Person:0x007fec5e3b3c40 @age=22, @awesome=true, @name="bob"> - # person.name # => "bob" - # person.age # => 22 - # person.awesome # => true - def from_xml(xml) - self.attributes = Hash.from_xml(xml).values.first - self - end - end - end -end diff --git a/activemodel/lib/active_model/type.rb b/activemodel/lib/active_model/type.rb new file mode 100644 index 0000000000..bec851594f --- /dev/null +++ b/activemodel/lib/active_model/type.rb @@ -0,0 +1,59 @@ +require 'active_model/type/helpers' +require 'active_model/type/value' + +require 'active_model/type/big_integer' +require 'active_model/type/binary' +require 'active_model/type/boolean' +require 'active_model/type/date' +require 'active_model/type/date_time' +require 'active_model/type/decimal' +require 'active_model/type/decimal_without_scale' +require 'active_model/type/float' +require 'active_model/type/immutable_string' +require 'active_model/type/integer' +require 'active_model/type/string' +require 'active_model/type/text' +require 'active_model/type/time' +require 'active_model/type/unsigned_integer' + +require 'active_model/type/registry' + +module ActiveModel + module Type + @registry = Registry.new + + class << self + attr_accessor :registry # :nodoc: + delegate :add_modifier, to: :registry + + # Add a new type to the registry, allowing it to be referenced as a + # symbol by ActiveModel::Attributes::ClassMethods#attribute. If your + # type is only meant to be used with a specific database adapter, you can + # do so by passing +adapter: :postgresql+. If your type has the same + # name as a native type for the current adapter, an exception will be + # raised unless you specify an +:override+ option. +override: true+ will + # cause your type to be used instead of the native type. +override: + # false+ will cause the native type to be used over yours if one exists. + def register(type_name, klass = nil, **options, &block) + registry.register(type_name, klass, **options, &block) + end + + def lookup(*args, **kwargs) # :nodoc: + registry.lookup(*args, **kwargs) + end + end + + register(:big_integer, Type::BigInteger) + register(:binary, Type::Binary) + register(:boolean, Type::Boolean) + register(:date, Type::Date) + register(:date_time, Type::DateTime) + register(:decimal, Type::Decimal) + register(:float, Type::Float) + register(:immutable_string, Type::ImmutableString) + register(:integer, Type::Integer) + register(:string, Type::String) + register(:text, Type::Text) + register(:time, Type::Time) + end +end diff --git a/activemodel/lib/active_model/type/big_integer.rb b/activemodel/lib/active_model/type/big_integer.rb new file mode 100644 index 0000000000..4168cbfce7 --- /dev/null +++ b/activemodel/lib/active_model/type/big_integer.rb @@ -0,0 +1,13 @@ +require 'active_model/type/integer' + +module ActiveModel + module Type + class BigInteger < Integer # :nodoc: + private + + def max_value + ::Float::INFINITY + end + end + end +end diff --git a/activemodel/lib/active_model/type/binary.rb b/activemodel/lib/active_model/type/binary.rb new file mode 100644 index 0000000000..a0cc45b4c3 --- /dev/null +++ b/activemodel/lib/active_model/type/binary.rb @@ -0,0 +1,50 @@ +module ActiveModel + module Type + class Binary < Value # :nodoc: + def type + :binary + end + + def binary? + true + end + + def cast(value) + if value.is_a?(Data) + value.to_s + else + super + end + end + + def serialize(value) + return if value.nil? + Data.new(super) + end + + def changed_in_place?(raw_old_value, value) + old_value = deserialize(raw_old_value) + old_value != value + end + + class Data # :nodoc: + def initialize(value) + @value = value.to_s + end + + def to_s + @value + end + alias_method :to_str, :to_s + + def hex + @value.unpack('H*')[0] + end + + def ==(other) + other == to_s || super + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/boolean.rb b/activemodel/lib/active_model/type/boolean.rb new file mode 100644 index 0000000000..c1bce98c87 --- /dev/null +++ b/activemodel/lib/active_model/type/boolean.rb @@ -0,0 +1,21 @@ +module ActiveModel + module Type + class Boolean < Value # :nodoc: + FALSE_VALUES = [false, 0, '0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].to_set + + def type + :boolean + end + + private + + def cast_value(value) + if value == '' + nil + else + !FALSE_VALUES.include?(value) + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/date.rb b/activemodel/lib/active_model/type/date.rb new file mode 100644 index 0000000000..f74243a22c --- /dev/null +++ b/activemodel/lib/active_model/type/date.rb @@ -0,0 +1,50 @@ +module ActiveModel + module Type + class Date < Value # :nodoc: + include Helpers::AcceptsMultiparameterTime.new + + def type + :date + end + + def type_cast_for_schema(value) + "'#{value.to_s(:db)}'" + end + + private + + def cast_value(value) + if value.is_a?(::String) + return if value.empty? + fast_string_to_date(value) || fallback_string_to_date(value) + elsif value.respond_to?(:to_date) + value.to_date + else + value + end + end + + ISO_DATE = /\A(\d{4})-(\d\d)-(\d\d)\z/ + def fast_string_to_date(string) + if string =~ ISO_DATE + new_date $1.to_i, $2.to_i, $3.to_i + end + end + + def fallback_string_to_date(string) + new_date(*::Date._parse(string, false).values_at(:year, :mon, :mday)) + end + + def new_date(year, mon, mday) + if year && year != 0 + ::Date.new(year, mon, mday) rescue nil + end + end + + def value_from_multiparameter_assignment(*) + time = super + time && time.to_date + end + end + end +end diff --git a/activemodel/lib/active_model/type/date_time.rb b/activemodel/lib/active_model/type/date_time.rb new file mode 100644 index 0000000000..2f2df4320f --- /dev/null +++ b/activemodel/lib/active_model/type/date_time.rb @@ -0,0 +1,44 @@ +module ActiveModel + module Type + class DateTime < Value # :nodoc: + include Helpers::TimeValue + include Helpers::AcceptsMultiparameterTime.new( + defaults: { 4 => 0, 5 => 0 } + ) + + def type + :datetime + end + + private + + def cast_value(value) + return apply_seconds_precision(value) unless value.is_a?(::String) + return if value.empty? + + fast_string_to_time(value) || fallback_string_to_time(value) + end + + # '0.123456' -> 123456 + # '1.123456' -> 123456 + def microseconds(time) + time[:sec_fraction] ? (time[:sec_fraction] * 1_000_000).to_i : 0 + end + + def fallback_string_to_time(string) + time_hash = ::Date._parse(string) + time_hash[:sec_fraction] = microseconds(time_hash) + + new_time(*time_hash.values_at(:year, :mon, :mday, :hour, :min, :sec, :sec_fraction, :offset)) + end + + def value_from_multiparameter_assignment(values_hash) + missing_parameter = (1..3).detect { |key| !values_hash.key?(key) } + if missing_parameter + raise ArgumentError, missing_parameter + end + super + end + end + end +end diff --git a/activemodel/lib/active_model/type/decimal.rb b/activemodel/lib/active_model/type/decimal.rb new file mode 100644 index 0000000000..d19d8baada --- /dev/null +++ b/activemodel/lib/active_model/type/decimal.rb @@ -0,0 +1,52 @@ +require "bigdecimal/util" + +module ActiveModel + module Type + class Decimal < Value # :nodoc: + include Helpers::Numeric + + def type + :decimal + end + + def type_cast_for_schema(value) + value.to_s.inspect + end + + private + + def cast_value(value) + casted_value = case value + when ::Float + convert_float_to_big_decimal(value) + when ::Numeric, ::String + BigDecimal(value, precision.to_i) + else + if value.respond_to?(:to_d) + value.to_d + else + cast_value(value.to_s) + end + end + + scale ? casted_value.round(scale) : casted_value + end + + def convert_float_to_big_decimal(value) + if precision + BigDecimal(value, float_precision) + else + value.to_d + end + end + + def float_precision + if precision.to_i > ::Float::DIG + 1 + ::Float::DIG + 1 + else + precision.to_i + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/decimal_without_scale.rb b/activemodel/lib/active_model/type/decimal_without_scale.rb new file mode 100644 index 0000000000..129baa0c10 --- /dev/null +++ b/activemodel/lib/active_model/type/decimal_without_scale.rb @@ -0,0 +1,11 @@ +require 'active_model/type/big_integer' + +module ActiveModel + module Type + class DecimalWithoutScale < BigInteger # :nodoc: + def type + :decimal + end + end + end +end diff --git a/activemodel/lib/active_model/type/float.rb b/activemodel/lib/active_model/type/float.rb new file mode 100644 index 0000000000..0f925bc7e1 --- /dev/null +++ b/activemodel/lib/active_model/type/float.rb @@ -0,0 +1,25 @@ +module ActiveModel + module Type + class Float < Value # :nodoc: + include Helpers::Numeric + + def type + :float + end + + alias serialize cast + + private + + def cast_value(value) + case value + when ::Float then value + when "Infinity" then ::Float::INFINITY + when "-Infinity" then -::Float::INFINITY + when "NaN" then ::Float::NAN + else value.to_f + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/helpers.rb b/activemodel/lib/active_model/type/helpers.rb new file mode 100644 index 0000000000..a805a359ab --- /dev/null +++ b/activemodel/lib/active_model/type/helpers.rb @@ -0,0 +1,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' diff --git a/activemodel/lib/active_model/type/helpers/accepts_multiparameter_time.rb b/activemodel/lib/active_model/type/helpers/accepts_multiparameter_time.rb new file mode 100644 index 0000000000..facea12704 --- /dev/null +++ b/activemodel/lib/active_model/type/helpers/accepts_multiparameter_time.rb @@ -0,0 +1,35 @@ +module ActiveModel + module Type + module Helpers + class AcceptsMultiparameterTime < Module # :nodoc: + def initialize(defaults: {}) + define_method(:cast) do |value| + if value.is_a?(Hash) + value_from_multiparameter_assignment(value) + else + super(value) + end + end + + define_method(:assert_valid_value) do |value| + if value.is_a?(Hash) + value_from_multiparameter_assignment(value) + else + super(value) + end + end + + define_method(:value_from_multiparameter_assignment) do |values_hash| + defaults.each do |k, v| + values_hash[k] ||= v + end + return unless values_hash[1] && values_hash[2] && values_hash[3] + values = values_hash.sort.map(&:last) + ::Time.send(default_timezone, *values) + end + private :value_from_multiparameter_assignment + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/helpers/mutable.rb b/activemodel/lib/active_model/type/helpers/mutable.rb new file mode 100644 index 0000000000..4dddbe4e5e --- /dev/null +++ b/activemodel/lib/active_model/type/helpers/mutable.rb @@ -0,0 +1,18 @@ +module ActiveModel + module Type + module Helpers + module Mutable # :nodoc: + def cast(value) + deserialize(serialize(value)) + end + + # +raw_old_value+ will be the `_before_type_cast` version of the + # value (likely a string). +new_value+ will be the current, type + # cast value. + def changed_in_place?(raw_old_value, new_value) + raw_old_value != serialize(new_value) + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/helpers/numeric.rb b/activemodel/lib/active_model/type/helpers/numeric.rb new file mode 100644 index 0000000000..c883010506 --- /dev/null +++ b/activemodel/lib/active_model/type/helpers/numeric.rb @@ -0,0 +1,34 @@ +module ActiveModel + module Type + module Helpers + module Numeric # :nodoc: + def cast(value) + value = case value + when true then 1 + when false then 0 + when ::String then value.presence + else value + end + super(value) + end + + def changed?(old_value, _new_value, new_value_before_type_cast) # :nodoc: + super || number_to_non_number?(old_value, new_value_before_type_cast) + end + + private + + def number_to_non_number?(old_value, new_value_before_type_cast) + old_value != nil && non_numeric_string?(new_value_before_type_cast) + 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/ + end + end + 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 new file mode 100644 index 0000000000..63993c0d93 --- /dev/null +++ b/activemodel/lib/active_model/type/helpers/time_value.rb @@ -0,0 +1,77 @@ +require "active_support/core_ext/time/zones" + +module ActiveModel + module Type + module Helpers + module TimeValue # :nodoc: + def serialize(value) + value = apply_seconds_precision(value) + + if value.acts_like?(:time) + zone_conversion_method = is_utc? ? :getutc : :getlocal + + if value.respond_to?(zone_conversion_method) + value = value.send(zone_conversion_method) + end + end + + 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 + round_power = 10 ** number_of_insignificant_digits + value.change(usec: value.usec / round_power * round_power) + end + + def type_cast_for_schema(value) + "'#{value.to_s(:db)}'" + end + + def user_input_in_time_zone(value) + value.in_time_zone + end + + private + + def new_time(year, mon, mday, hour, min, sec, microsec, offset = nil) + # Treat 0000-00-00 00:00:00 as nil. + return if year.nil? || (year == 0 && mon == 0 && mday == 0) + + if offset + time = ::Time.utc(year, mon, mday, hour, min, sec, microsec) rescue nil + return unless time + + time -= offset + is_utc? ? time : time.getlocal + else + ::Time.public_send(default_timezone, year, mon, mday, hour, min, sec, microsec) rescue nil + end + end + + ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(\.\d+)?\z/ + + # Doesn't handle time zones. + def fast_string_to_time(string) + if string =~ ISO_DATETIME + microsec = ($7.to_r * 1_000_000).to_i + new_time $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, microsec + end + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/immutable_string.rb b/activemodel/lib/active_model/type/immutable_string.rb new file mode 100644 index 0000000000..20b8ca0cc4 --- /dev/null +++ b/activemodel/lib/active_model/type/immutable_string.rb @@ -0,0 +1,29 @@ +module ActiveModel + module Type + class ImmutableString < Value # :nodoc: + def type + :string + end + + def serialize(value) + case value + when ::Numeric, ActiveSupport::Duration then value.to_s + when true then "t" + when false then "f" + else super + end + end + + private + + def cast_value(value) + result = case value + when true then "t" + when false then "f" + else value.to_s + end + result.freeze + end + end + end +end diff --git a/activemodel/lib/active_model/type/integer.rb b/activemodel/lib/active_model/type/integer.rb new file mode 100644 index 0000000000..2f73ede009 --- /dev/null +++ b/activemodel/lib/active_model/type/integer.rb @@ -0,0 +1,66 @@ +module ActiveModel + module Type + class Integer < Value # :nodoc: + include Helpers::Numeric + + # Column storage size in bytes. + # 4 bytes means a MySQL int or Postgres integer as opposed to smallint etc. + DEFAULT_LIMIT = 4 + + def initialize(*) + super + @range = min_value...max_value + end + + def type + :integer + end + + def deserialize(value) + return if value.nil? + value.to_i + end + + def serialize(value) + result = cast(value) + if result + ensure_in_range(result) + end + result + end + + protected + + attr_reader :range + + private + + def cast_value(value) + case value + when true then 1 + when false then 0 + else + value.to_i rescue nil + end + end + + def ensure_in_range(value) + unless range.cover?(value) + raise RangeError, "#{value} is out of range for #{self.class} with limit #{_limit}" + end + end + + def max_value + 1 << (_limit * 8 - 1) # 8 bits per byte with one bit for sign + end + + def min_value + -max_value + end + + def _limit + self.limit || DEFAULT_LIMIT + end + end + end +end diff --git a/activemodel/lib/active_model/type/registry.rb b/activemodel/lib/active_model/type/registry.rb new file mode 100644 index 0000000000..adc88eb624 --- /dev/null +++ b/activemodel/lib/active_model/type/registry.rb @@ -0,0 +1,64 @@ +module ActiveModel + # :stopdoc: + module Type + class Registry + def initialize + @registrations = [] + end + + def register(type_name, klass = nil, **options, &block) + block ||= proc { |_, *args| klass.new(*args) } + registrations << registration_klass.new(type_name, block, **options) + end + + def lookup(symbol, *args) + registration = find_registration(symbol, *args) + + if registration + registration.call(self, symbol, *args) + else + raise ArgumentError, "Unknown type #{symbol.inspect}" + end + end + + protected + + attr_reader :registrations + + private + + def registration_klass + Registration + end + + def find_registration(symbol, *args) + registrations.find { |r| r.matches?(symbol, *args) } + end + end + + class Registration + # Options must be taken because of https://bugs.ruby-lang.org/issues/10856 + def initialize(name, block, **) + @name = name + @block = block + end + + def call(_registry, *args, **kwargs) + if kwargs.any? # https://bugs.ruby-lang.org/issues/10856 + block.call(*args, **kwargs) + else + block.call(*args) + end + end + + def matches?(type_name, *args, **kwargs) + type_name == name + end + + protected + + attr_reader :name, :block + end + end + # :startdoc: +end diff --git a/activemodel/lib/active_model/type/string.rb b/activemodel/lib/active_model/type/string.rb new file mode 100644 index 0000000000..8a91410998 --- /dev/null +++ b/activemodel/lib/active_model/type/string.rb @@ -0,0 +1,19 @@ +require "active_model/type/immutable_string" + +module ActiveModel + module Type + class String < ImmutableString # :nodoc: + def changed_in_place?(raw_old_value, new_value) + if new_value.is_a?(::String) + raw_old_value != new_value + end + end + + private + + def cast_value(value) + ::String.new(super) + end + end + end +end diff --git a/activemodel/lib/active_model/type/text.rb b/activemodel/lib/active_model/type/text.rb new file mode 100644 index 0000000000..1ad04daba4 --- /dev/null +++ b/activemodel/lib/active_model/type/text.rb @@ -0,0 +1,11 @@ +require 'active_model/type/string' + +module ActiveModel + module Type + class Text < String # :nodoc: + def type + :text + end + end + end +end diff --git a/activemodel/lib/active_model/type/time.rb b/activemodel/lib/active_model/type/time.rb new file mode 100644 index 0000000000..fe09f63a87 --- /dev/null +++ b/activemodel/lib/active_model/type/time.rb @@ -0,0 +1,46 @@ +module ActiveModel + module Type + class Time < Value # :nodoc: + include Helpers::TimeValue + include Helpers::AcceptsMultiparameterTime.new( + defaults: { 1 => 1970, 2 => 1, 3 => 1, 4 => 0, 5 => 0 } + ) + + def type + :time + end + + def user_input_in_time_zone(value) + return unless value.present? + + case value + when ::String + value = "2000-01-01 #{value}" + when ::Time + value = value.change(year: 2000, day: 1, month: 1) + end + + super(value) + end + + private + + def cast_value(value) + return value unless value.is_a?(::String) + return if value.empty? + + if value =~ /^2000-01-01/ + dummy_time_value = value + else + dummy_time_value = "2000-01-01 #{value}" + end + + fast_string_to_time(dummy_time_value) || begin + time_hash = ::Date._parse(dummy_time_value) + return if time_hash[:hour].nil? + new_time(*time_hash.values_at(:year, :mon, :mday, :hour, :min, :sec, :sec_fraction)) + end + end + end + end +end diff --git a/activemodel/lib/active_model/type/unsigned_integer.rb b/activemodel/lib/active_model/type/unsigned_integer.rb new file mode 100644 index 0000000000..3f49f9f5f7 --- /dev/null +++ b/activemodel/lib/active_model/type/unsigned_integer.rb @@ -0,0 +1,15 @@ +module ActiveModel + module Type + class UnsignedInteger < Integer # :nodoc: + private + + def max_value + super * 2 + end + + def min_value + 0 + end + end + end +end diff --git a/activemodel/lib/active_model/type/value.rb b/activemodel/lib/active_model/type/value.rb new file mode 100644 index 0000000000..9d1f267b41 --- /dev/null +++ b/activemodel/lib/active_model/type/value.rb @@ -0,0 +1,112 @@ +module ActiveModel + module Type + class Value + attr_reader :precision, :scale, :limit + + def initialize(precision: nil, limit: nil, scale: nil) + @precision = precision + @scale = scale + @limit = limit + end + + def type # :nodoc: + end + + # Converts a value from database input to the appropriate ruby type. The + # return value of this method will be returned from + # ActiveRecord::AttributeMethods::Read#read_attribute. The default + # implementation just calls Value#cast. + # + # +value+ The raw input, as provided from the database. + def deserialize(value) + cast(value) + end + + # Type casts a value from user input (e.g. from a setter). This value may + # be a string from the form builder, or a ruby object passed to a setter. + # There is currently no way to differentiate between which source it came + # from. + # + # The return value of this method will be returned from + # ActiveRecord::AttributeMethods::Read#read_attribute. See also: + # Value#cast_value. + # + # +value+ The raw input, as provided to the attribute setter. + def cast(value) + cast_value(value) unless value.nil? + end + + # Casts a value from the ruby type to a type that the database knows how + # to understand. The returned value from this method should be a + # +String+, +Numeric+, +Date+, +Time+, +Symbol+, +true+, +false+, or + # +nil+. + def serialize(value) + value + end + + # Type casts a value for schema dumping. This method is private, as we are + # hoping to remove it entirely. + def type_cast_for_schema(value) # :nodoc: + value.inspect + end + + # These predicates are not documented, as I need to look further into + # their use, and see if they can be removed entirely. + def binary? # :nodoc: + false + end + + # Determines whether a value has changed for dirty checking. +old_value+ + # and +new_value+ will always be type-cast. Types should not need to + # override this method. + def changed?(old_value, new_value, _new_value_before_type_cast) + old_value != new_value + end + + # Determines whether the mutable value has been modified since it was + # read. Returns +false+ by default. If your type returns an object + # which could be mutated, you should override this method. You will need + # to either: + # + # - pass +new_value+ to Value#serialize and compare it to + # +raw_old_value+ + # + # or + # + # - pass +raw_old_value+ to Value#deserialize and compare it to + # +new_value+ + # + # +raw_old_value+ The original value, before being passed to + # +deserialize+. + # + # +new_value+ The current value, after type casting. + def changed_in_place?(raw_old_value, new_value) + false + end + + def ==(other) + self.class == other.class && + precision == other.precision && + scale == other.scale && + limit == other.limit + end + alias eql? == + + def hash + [self.class, precision, scale, limit].hash + end + + def assert_valid_value(*) + end + + private + + # Convenience method for types which do not need separate type casting + # behavior for user and database inputs. Called by Value#cast for + # values except +nil+. + def cast_value(value) # :doc: + value + end + end + end +end diff --git a/activemodel/lib/active_model/validations.rb b/activemodel/lib/active_model/validations.rb index 31c2245265..f23c920d87 100644 --- a/activemodel/lib/active_model/validations.rb +++ b/activemodel/lib/active_model/validations.rb @@ -4,7 +4,7 @@ require 'active_support/core_ext/hash/except' module ActiveModel - # == Active \Model Validations + # == Active \Model \Validations # # Provides a full validation framework to your objects. # @@ -39,6 +39,7 @@ module ActiveModel extend ActiveSupport::Concern included do + extend ActiveModel::Naming extend ActiveModel::Callbacks extend ActiveModel::Translation @@ -66,8 +67,11 @@ module ActiveModel # end # # Options: - # * <tt>:on</tt> - Specifies the context where this validation is active - # (e.g. <tt>on: :create</tt> or <tt>on: :custom_validation_context</tt>) + # * <tt>:on</tt> - Specifies the contexts where this validation is active. + # Runs in all validation contexts by default (nil). You can pass a symbol + # or an array of symbols. (e.g. <tt>on: :create</tt> or + # <tt>on: :custom_validation_context</tt> or + # <tt>on: [:create, :custom_validation_context]</tt>) # * <tt>:allow_nil</tt> - Skip validation if attribute is +nil+. # * <tt>:allow_blank</tt> - Skip validation if attribute is blank. # * <tt>:if</tt> - Specifies a method, proc or string to call to determine @@ -83,6 +87,8 @@ module ActiveModel validates_with BlockValidator, _merge_attributes(attr_names), &block end + VALID_OPTIONS_FOR_VALIDATE = [:on, :if, :unless, :prepend].freeze # :nodoc: + # Adds a validation method or block to the class. This is useful when # overriding the +validate+ instance method becomes too unwieldy and # you're looking for more descriptive declaration of your validations. @@ -123,11 +129,15 @@ module ActiveModel # end # end # + # Note that the return value of validation methods is not relevant. + # It's not possible to halt the validate callback chain. + # # Options: - # * <tt>:on</tt> - Specifies the context where this validation is active - # (e.g. <tt>on: :create</tt> or <tt>on: :custom_validation_context</tt>) - # * <tt>:allow_nil</tt> - Skip validation if attribute is +nil+. - # * <tt>:allow_blank</tt> - Skip validation if attribute is blank. + # * <tt>:on</tt> - Specifies the contexts where this validation is active. + # Runs in all validation contexts by default (nil). You can pass a symbol + # or an array of symbols. (e.g. <tt>on: :create</tt> or + # <tt>on: :custom_validation_context</tt> or + # <tt>on: [:create, :custom_validation_context]</tt>) # * <tt>:if</tt> - Specifies a method, proc or string to call to determine # if the validation should occur (e.g. <tt>if: :allow_validation</tt>, # or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>). The method, @@ -139,13 +149,23 @@ module ActiveModel # value. def validate(*args, &block) options = args.extract_options! + + if args.all? { |arg| arg.is_a?(Symbol) } + options.each_key do |k| + unless VALID_OPTIONS_FOR_VALIDATE.include?(k) + raise ArgumentError.new("Unknown key: #{k.inspect}. Valid keys are: #{VALID_OPTIONS_FOR_VALIDATE.map(&:inspect).join(', ')}. Perhaps you meant to call `validates` instead of `validate`?") + end + end + end + if options.key?(:on) options = options.dup options[:if] = Array(options[:if]) - options[:if].unshift lambda { |o| - o.validation_context == options[:on] + options[:if].unshift ->(o) { + !(Array(options[:on]) & Array(o.validation_context)).empty? } end + args << options set_callback(:validate, *args, &block) end @@ -199,12 +219,12 @@ module ActiveModel # # #<StrictValidator:0x007fbff3204a30 @options={strict:true}> # # ] # - # If one runs Person.clear_validators! and then checks to see what + # If one runs <tt>Person.clear_validators!</tt> and then checks to see what # validators this class has, you would obtain: # # Person.validators # => [] # - # Also, the callback set by +validate :cannot_be_robot+ will be erased + # Also, the callback set by <tt>validate :cannot_be_robot</tt> will be erased # so that: # # Person._validate_callbacks.empty? # => true @@ -283,6 +303,8 @@ module ActiveModel # Runs all the specified validations and returns +true+ if no errors were # added otherwise +false+. # + # Aliased as validate. + # # class Person # include ActiveModel::Validations # @@ -317,6 +339,8 @@ module ActiveModel self.validation_context = current_context end + alias_method :validate, :valid? + # Performs the opposite of <tt>valid?</tt>. Returns +true+ if errors were # added, +false+ otherwise. # @@ -350,6 +374,15 @@ module ActiveModel !valid?(context) end + # Runs all the validations within the specified context. Returns +true+ if + # no errors are found, raises +ValidationError+ otherwise. + # + # Validations with no <tt>:on</tt> option will run no matter the context. Validations with + # some <tt>:on</tt> option will only run in the specified context. + def validate!(context = nil) + valid?(context) || raise_validation_error + end + # Hook method defining how an attribute value should be retrieved. By default # this is assumed to be an instance named after the attribute. Override this # method in subclasses should you need to retrieve the value for a given @@ -371,9 +404,33 @@ module ActiveModel protected def run_validations! #:nodoc: - run_callbacks :validate + _run_validate_callbacks errors.empty? end + + def raise_validation_error + raise(ValidationError.new(self)) + end + end + + # = Active Model ValidationError + # + # Raised by <tt>validate!</tt> when the model is invalid. Use the + # +model+ method to retrieve the record which did not validate. + # + # begin + # complex_operation_that_internally_calls_validate! + # rescue ActiveModel::ValidationError => invalid + # puts invalid.model.errors + # end + class ValidationError < StandardError + attr_reader :model + + def initialize(model) + @model = model + errors = @model.errors.full_messages.join(", ") + super(I18n.t(:"#{@model.class.i18n_scope}.errors.messages.model_invalid", errors: errors, default: :"errors.messages.model_invalid")) + end end end diff --git a/activemodel/lib/active_model/validations/absence.rb b/activemodel/lib/active_model/validations/absence.rb index 1a1863370b..75bf655578 100644 --- a/activemodel/lib/active_model/validations/absence.rb +++ b/activemodel/lib/active_model/validations/absence.rb @@ -1,6 +1,6 @@ module ActiveModel module Validations - # == Active Model Absence Validator + # == \Active \Model Absence Validator class AbsenceValidator < EachValidator #:nodoc: def validate_each(record, attr_name, value) record.errors.add(attr_name, :present, options) if value.present? @@ -21,7 +21,7 @@ module ActiveModel # * <tt>:message</tt> - A custom error message (default is: "must be blank"). # # There is also a list of default options supported by every validator: - # +:if+, +:unless+, +:on+ and +:strict+. + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. # See <tt>ActiveModel::Validation#validates</tt> for more information def validates_absence_of(*attr_names) validates_with AbsenceValidator, _merge_attributes(attr_names) diff --git a/activemodel/lib/active_model/validations/acceptance.rb b/activemodel/lib/active_model/validations/acceptance.rb index 139de16326..c5c0cd4636 100644 --- a/activemodel/lib/active_model/validations/acceptance.rb +++ b/activemodel/lib/active_model/validations/acceptance.rb @@ -3,22 +3,73 @@ module ActiveModel module Validations class AcceptanceValidator < EachValidator # :nodoc: def initialize(options) - super({ allow_nil: true, accept: "1" }.merge!(options)) + super({ allow_nil: true, accept: ["1", true] }.merge!(options)) setup!(options[:class]) end def validate_each(record, attribute, value) - unless value == options[:accept] + unless acceptable_option?(value) record.errors.add(attribute, :accepted, options.except(:accept, :allow_nil)) end end private + def setup!(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.include(LazilyDefineAttributes.new(AttributeDefinition.new(attributes))) + end + + def acceptable_option?(value) + Array(options[:accept]).include?(value) + end + + class LazilyDefineAttributes < Module + def initialize(attribute_definition) + define_method(:respond_to_missing?) do |method_name, include_private=false| + super(method_name, include_private) || attribute_definition.matches?(method_name) + end + + define_method(:method_missing) do |method_name, *args, &block| + if attribute_definition.matches?(method_name) + attribute_definition.define_on(self.class) + send(method_name, *args, &block) + else + super(method_name, *args, &block) + end + end + end + end + + class AttributeDefinition + def initialize(attributes) + @attributes = attributes.map(&:to_s) + end + + def matches?(method_name) + attr_name = convert_to_reader_name(method_name) + attributes.include?(attr_name) + end + + 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) + end + + protected + + attr_reader :attributes + + private + + def convert_to_reader_name(method_name) + attr_name = method_name.to_s + if attr_name.end_with?("=") + attr_name = attr_name[0..-2] + end + attr_name + end end end @@ -38,17 +89,16 @@ module ActiveModel # Configuration options: # * <tt>:message</tt> - A custom error message (default is: "must be # accepted"). - # * <tt>:allow_nil</tt> - Skip validation if attribute is +nil+ (default - # is +true+). - # * <tt>:accept</tt> - Specifies value that is considered accepted. - # The default value is a string "1", which makes it easy to relate to - # an HTML checkbox. This should be set to +true+ if you are validating + # * <tt>:accept</tt> - Specifies a value that is considered accepted. + # Also accepts an array of possible values. The default value is + # an array ["1", true], which makes it easy to relate to an HTML + # checkbox. This should be set to, or include, +true+ if you are validating # a database column, since the attribute is typecast from "1" to +true+ # before validation. # # There is also a list of default options supported by every validator: - # +:if+, +:unless+, +:on+ and +:strict+. - # See <tt>ActiveModel::Validation#validates</tt> for more information + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information. def validates_acceptance_of(*attr_names) validates_with AcceptanceValidator, _merge_attributes(attr_names) end diff --git a/activemodel/lib/active_model/validations/callbacks.rb b/activemodel/lib/active_model/validations/callbacks.rb index fde53b9f89..52111e5442 100644 --- a/activemodel/lib/active_model/validations/callbacks.rb +++ b/activemodel/lib/active_model/validations/callbacks.rb @@ -1,6 +1,6 @@ module ActiveModel module Validations - # == Active \Model Validation Callbacks + # == Active \Model \Validation \Callbacks # # Provides an interface for any class to have +before_validation+ and # +after_validation+ callbacks. @@ -15,15 +15,15 @@ module ActiveModel # after_validation :do_stuff_after_validation # end # - # Like other <tt>before_*</tt> callbacks if +before_validation+ returns - # +false+ then <tt>valid?</tt> will not be called. + # Like other <tt>before_*</tt> callbacks if +before_validation+ throws + # +:abort+ then <tt>valid?</tt> will not be called. module Callbacks extend ActiveSupport::Concern included do include ActiveSupport::Callbacks define_callbacks :validation, - terminator: ->(_,result) { result == false }, + terminator: deprecated_false_terminator, skip_after_callbacks_if_terminated: true, scope: [:kind, :name] end @@ -58,7 +58,7 @@ module ActiveModel if options.is_a?(Hash) && options[:on] options[:if] = Array(options[:if]) options[:on] = Array(options[:on]) - options[:if].unshift lambda { |o| + options[:if].unshift ->(o) { options[:on].include? o.validation_context } end @@ -98,7 +98,9 @@ module ActiveModel options[:if] = Array(options[:if]) if options[:on] options[:on] = Array(options[:on]) - options[:if].unshift("#{options[:on]}.include? self.validation_context") + options[:if].unshift ->(o) { + options[:on].include? o.validation_context + } end set_callback(:validation, :after, *(args << options), &block) end @@ -108,7 +110,7 @@ module ActiveModel # Overwrite run validations to include callbacks. def run_validations! #:nodoc: - run_callbacks(:validation) { super } + _run_validation_callbacks { super } end end end diff --git a/activemodel/lib/active_model/validations/clusivity.rb b/activemodel/lib/active_model/validations/clusivity.rb index fd6cc1edb4..bad9e4f9a9 100644 --- a/activemodel/lib/active_model/validations/clusivity.rb +++ b/activemodel/lib/active_model/validations/clusivity.rb @@ -35,10 +35,13 @@ module ActiveModel # <tt>Range#cover?</tt> uses the previous logic of comparing a value with the range # endpoints, which is fast but is only accurate on Numeric, Time, or DateTime ranges. def inclusion_method(enumerable) - return :include? unless enumerable.is_a?(Range) - case enumerable.first - when Numeric, Time, DateTime - :cover? + if enumerable.is_a? Range + case enumerable.first + when Numeric, Time, DateTime + :cover? + else + :include? + end else :include? end diff --git a/activemodel/lib/active_model/validations/confirmation.rb b/activemodel/lib/active_model/validations/confirmation.rb index b0542661af..8f8ade90bb 100644 --- a/activemodel/lib/active_model/validations/confirmation.rb +++ b/activemodel/lib/active_model/validations/confirmation.rb @@ -3,14 +3,16 @@ module ActiveModel module Validations class ConfirmationValidator < EachValidator # :nodoc: def initialize(options) - super + super({ case_sensitive: true }.merge!(options)) setup!(options[:class]) end def validate_each(record, attribute, value) - if (confirmed = record.send("#{attribute}_confirmation")) && (value != confirmed) - human_attribute_name = record.class.human_attribute_name(attribute) - record.errors.add(:"#{attribute}_confirmation", :confirmation, options.merge(attribute: human_attribute_name)) + if (confirmed = record.send("#{attribute}_confirmation")) + unless confirmation_value_equal?(record, attribute, value, confirmed) + human_attribute_name = record.class.human_attribute_name(attribute) + record.errors.add(:"#{attribute}_confirmation", :confirmation, options.except(:case_sensitive).merge!(attribute: human_attribute_name)) + end end end @@ -24,6 +26,14 @@ module ActiveModel :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation=") end.compact) end + + def confirmation_value_equal?(record, attribute, value, confirmed) + if !options[:case_sensitive] && value.is_a?(String) + value.casecmp(confirmed) == 0 + else + value == confirmed + end + end end module HelperMethods @@ -54,10 +64,12 @@ module ActiveModel # # Configuration options: # * <tt>:message</tt> - A custom error message (default is: "doesn't match - # confirmation"). + # <tt>%{translated_attribute_name}</tt>"). + # * <tt>:case_sensitive</tt> - Looks for an exact match. Ignored by + # non-text columns (+true+ by default). # # There is also a list of default options supported by every validator: - # +:if+, +:unless+, +:on+ and +:strict+. + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. # See <tt>ActiveModel::Validation#validates</tt> for more information def validates_confirmation_of(*attr_names) validates_with ConfirmationValidator, _merge_attributes(attr_names) diff --git a/activemodel/lib/active_model/validations/exclusion.rb b/activemodel/lib/active_model/validations/exclusion.rb index 48bf5cd802..6f4276cc2a 100644 --- a/activemodel/lib/active_model/validations/exclusion.rb +++ b/activemodel/lib/active_model/validations/exclusion.rb @@ -29,18 +29,16 @@ module ActiveModel # Configuration options: # * <tt>:in</tt> - An enumerable object of items that the value shouldn't # be part of. This can be supplied as a proc, lambda or symbol which returns an - # enumerable. If the enumerable is a range the test is performed with + # enumerable. If the enumerable is a numerical, time or datetime range the test + # is performed with <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. When + # using a proc or lambda the instance under validation is passed as an argument. # * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt> # <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. # * <tt>:message</tt> - Specifies a custom error message (default is: "is # reserved"). - # * <tt>:allow_nil</tt> - If set to true, skips this validation if the - # attribute is +nil+ (default is +false+). - # * <tt>:allow_blank</tt> - If set to true, skips this validation if the - # attribute is blank(default is +false+). # # There is also a list of default options supported by every validator: - # +:if+, +:unless+, +:on+ and +:strict+. + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. # See <tt>ActiveModel::Validation#validates</tt> for more information def validates_exclusion_of(*attr_names) validates_with ExclusionValidator, _merge_attributes(attr_names) diff --git a/activemodel/lib/active_model/validations/format.rb b/activemodel/lib/active_model/validations/format.rb index be7cae588f..46a2e54fba 100644 --- a/activemodel/lib/active_model/validations/format.rb +++ b/activemodel/lib/active_model/validations/format.rb @@ -17,8 +17,8 @@ module ActiveModel raise ArgumentError, "Either :with or :without must be supplied (but not both)" end - check_options_validity(options, :with) - check_options_validity(options, :without) + check_options_validity :with + check_options_validity :without end private @@ -32,27 +32,29 @@ module ActiveModel record.errors.add(attribute, :invalid, options.except(name).merge!(value: value)) end - def regexp_using_multiline_anchors?(regexp) - regexp.source.start_with?("^") || - (regexp.source.end_with?("$") && !regexp.source.end_with?("\\$")) + def check_options_validity(name) + if option = options[name] + if option.is_a?(Regexp) + if options[:multiline] != true && regexp_using_multiline_anchors?(option) + raise ArgumentError, "The provided regular expression is using multiline anchors (^ or $), " \ + "which may present a security risk. Did you mean to use \\A and \\z, or forgot to add the " \ + ":multiline => true option?" + end + elsif !option.respond_to?(:call) + raise ArgumentError, "A regular expression or a proc or lambda must be supplied as :#{name}" + end + end end - def check_options_validity(options, name) - option = options[name] - if option && !option.is_a?(Regexp) && !option.respond_to?(:call) - raise ArgumentError, "A regular expression or a proc or lambda must be supplied as :#{name}" - elsif option && option.is_a?(Regexp) && - regexp_using_multiline_anchors?(option) && options[:multiline] != true - raise ArgumentError, "The provided regular expression is using multiline anchors (^ or $), " \ - "which may present a security risk. Did you mean to use \\A and \\z, or forgot to add the " \ - ":multiline => true option?" - end + def regexp_using_multiline_anchors?(regexp) + source = regexp.source + source.start_with?("^") || (source.end_with?("$") && !source.end_with?("\\$")) end end module HelperMethods # Validates whether the value of the specified attribute is of the correct - # form, going by the regular expression provided.You can require that the + # form, going by the regular expression provided. You can require that the # attribute matches the regular expression: # # class Person < ActiveRecord::Base @@ -75,7 +77,7 @@ module ActiveModel # with: ->(person) { person.admin? ? /\A[a-z0-9][a-z0-9_\-]*\z/i : /\A[a-z][a-z0-9_\-]*\z/i } # end # - # Note: use <tt>\A</tt> and <tt>\Z</tt> to match the start and end of the + # Note: use <tt>\A</tt> and <tt>\z</tt> to match the start and end of the # string, <tt>^</tt> and <tt>$</tt> match the start/end of a line. # # Due to frequent misuse of <tt>^</tt> and <tt>$</tt>, you need to pass @@ -89,10 +91,6 @@ module ActiveModel # # Configuration options: # * <tt>:message</tt> - A custom error message (default is: "is invalid"). - # * <tt>:allow_nil</tt> - If set to true, skips this validation if the - # attribute is +nil+ (default is +false+). - # * <tt>:allow_blank</tt> - If set to true, skips this validation if the - # attribute is blank (default is +false+). # * <tt>:with</tt> - Regular expression that if the attribute matches will # result in a successful validation. This can be provided as a proc or # lambda returning regular expression which will be called at runtime. @@ -105,7 +103,7 @@ module ActiveModel # beginning or end of the string. These anchors are <tt>^</tt> and <tt>$</tt>. # # There is also a list of default options supported by every validator: - # +:if+, +:unless+, +:on+ and +:strict+. + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. # See <tt>ActiveModel::Validation#validates</tt> for more information def validates_format_of(*attr_names) validates_with FormatValidator, _merge_attributes(attr_names) diff --git a/activemodel/lib/active_model/validations/helper_methods.rb b/activemodel/lib/active_model/validations/helper_methods.rb new file mode 100644 index 0000000000..2176115334 --- /dev/null +++ b/activemodel/lib/active_model/validations/helper_methods.rb @@ -0,0 +1,13 @@ +module ActiveModel + module Validations + module HelperMethods # :nodoc: + private + def _merge_attributes(attr_names) + options = attr_names.extract_options!.symbolize_keys + attr_names.flatten! + options[:attributes] = attr_names + options + end + end + end +end diff --git a/activemodel/lib/active_model/validations/inclusion.rb b/activemodel/lib/active_model/validations/inclusion.rb index 24337614c5..03e0ef56d8 100644 --- a/activemodel/lib/active_model/validations/inclusion.rb +++ b/activemodel/lib/active_model/validations/inclusion.rb @@ -28,18 +28,15 @@ module ActiveModel # Configuration options: # * <tt>:in</tt> - An enumerable object of available items. This can be # supplied as a proc, lambda or symbol which returns an enumerable. If the - # enumerable is a numerical range the test is performed with <tt>Range#cover?</tt>, - # otherwise with <tt>include?</tt>. + # enumerable is a numerical, time or datetime range the test is performed + # with <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. When using + # a proc or lambda the instance under validation is passed as an argument. # * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt> # * <tt>:message</tt> - Specifies a custom error message (default is: "is # not included in the list"). - # * <tt>:allow_nil</tt> - If set to +true+, skips this validation if the - # attribute is +nil+ (default is +false+). - # * <tt>:allow_blank</tt> - If set to +true+, skips this validation if the - # attribute is blank (default is +false+). # # There is also a list of default options supported by every validator: - # +:if+, +:unless+, +:on+ and +:strict+. + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. # See <tt>ActiveModel::Validation#validates</tt> for more information def validates_inclusion_of(*attr_names) validates_with InclusionValidator, _merge_attributes(attr_names) diff --git a/activemodel/lib/active_model/validations/length.rb b/activemodel/lib/active_model/validations/length.rb index ddfd8a342e..910cca2f49 100644 --- a/activemodel/lib/active_model/validations/length.rb +++ b/activemodel/lib/active_model/validations/length.rb @@ -1,6 +1,6 @@ -module ActiveModel +require "active_support/core_ext/string/strip" - # == Active \Model Length \Validator +module ActiveModel module Validations class LengthValidator < EachValidator # :nodoc: MESSAGES = { is: :wrong_length, minimum: :too_short, maximum: :too_long }.freeze @@ -18,6 +18,27 @@ module ActiveModel options[:minimum] = 1 end + if options[:tokenizer] + ActiveSupport::Deprecation.warn(<<-EOS.strip_heredoc) + The `:tokenizer` option is deprecated, and will be removed in Rails 5.1. + You can achieve the same functionality by defining an instance method + with the value that you want to validate the length of. For example, + + validates_length_of :essay, minimum: 100, + tokenizer: ->(str) { str.scan(/\w+/) } + + should be written as + + validates_length_of :words_in_essay, minimum: 100 + + private + + def words_in_essay + essay.scan(/\w+/) + end + EOS + end + super end @@ -38,7 +59,7 @@ module ActiveModel end def validate_each(record, attribute, value) - value = tokenize(value) + value = tokenize(record, value) value_length = value.respond_to?(:length) ? value.length : value.to_s.length errors_options = options.except(*RESERVED_OPTIONS) @@ -59,10 +80,14 @@ module ActiveModel end private - - def tokenize(value) - if options[:tokenizer] && value.kind_of?(String) - options[:tokenizer].call(value) + def tokenize(record, value) + tokenizer = options[:tokenizer] + if tokenizer && value.kind_of?(String) + if tokenizer.kind_of?(Proc) + tokenizer.call(value) + elsif record.respond_to?(tokenizer) + record.send(tokenizer, value) + end end || value end @@ -73,8 +98,9 @@ module ActiveModel module HelperMethods - # Validates that the specified attribute matches the length restrictions - # supplied. Only one option can be used at a time: + # Validates that the specified attributes match the length restrictions + # supplied. Only one constraint option can be used at a time apart from + # +:minimum+ and +:maximum+ that can be combined together: # # class Person < ActiveRecord::Base # validates_length_of :first_name, maximum: 30 @@ -84,18 +110,27 @@ module ActiveModel # validates_length_of :user_name, within: 6..20, too_long: 'pick a shorter name', too_short: 'pick a longer name' # validates_length_of :zip_code, minimum: 5, too_short: 'please enter at least 5 characters' # validates_length_of :smurf_leader, is: 4, message: "papa is spelled with 4 characters... don't play me." - # validates_length_of :essay, minimum: 100, too_short: 'Your essay must be at least 100 words.', - # tokenizer: ->(str) { str.scan(/\w+/) } + # validates_length_of :words_in_essay, minimum: 100, too_short: 'Your essay must be at least 100 words.' + # + # private + # + # def words_in_essay + # essay.scan(/\w+/) + # end # end # - # Configuration options: + # Constraint options: + # # * <tt>:minimum</tt> - The minimum size of the attribute. # * <tt>:maximum</tt> - The maximum size of the attribute. Allows +nil+ by - # default if not used with :minimum. + # default if not used with +:minimum+. # * <tt>:is</tt> - The exact size of the attribute. # * <tt>:within</tt> - A range specifying the minimum and maximum size of # the attribute. # * <tt>:in</tt> - A synonym (or alias) for <tt>:within</tt>. + # + # Other options: + # # * <tt>:allow_nil</tt> - Attribute may be +nil+; skip validation. # * <tt>:allow_blank</tt> - Attribute may be blank; skip validation. # * <tt>:too_long</tt> - The error message if the attribute goes over the @@ -108,10 +143,6 @@ module ActiveModel # * <tt>:message</tt> - The error message to use for a <tt>:minimum</tt>, # <tt>:maximum</tt>, or <tt>:is</tt> violation. An alias of the appropriate # <tt>too_long</tt>/<tt>too_short</tt>/<tt>wrong_length</tt> message. - # * <tt>:tokenizer</tt> - Specifies how to split up the attribute string. - # (e.g. <tt>tokenizer: ->(str) { str.scan(/\w+/) }</tt> to count words - # as in above example). Defaults to <tt>->(value) { value.split(//) }</tt> - # which counts individual characters. # # There is also a list of default options supported by every validator: # +:if+, +:unless+, +:on+ and +:strict+. diff --git a/activemodel/lib/active_model/validations/numericality.rb b/activemodel/lib/active_model/validations/numericality.rb index c6abe45f4a..ad7012df48 100644 --- a/activemodel/lib/active_model/validations/numericality.rb +++ b/activemodel/lib/active_model/validations/numericality.rb @@ -11,29 +11,36 @@ module ActiveModel def check_validity! keys = CHECKS.keys - [:odd, :even] options.slice(*keys).each do |option, value| - next if value.is_a?(Numeric) || value.is_a?(Proc) || value.is_a?(Symbol) - raise ArgumentError, ":#{option} must be a number, a symbol or a proc" + unless value.is_a?(Numeric) || value.is_a?(Proc) || value.is_a?(Symbol) + raise ArgumentError, ":#{option} must be a number, a symbol or a proc" + end end end def validate_each(record, attr_name, value) before_type_cast = :"#{attr_name}_before_type_cast" - raw_value = record.send(before_type_cast) if record.respond_to?(before_type_cast) + raw_value = record.send(before_type_cast) if record.respond_to?(before_type_cast) && record.send(before_type_cast) != value raw_value ||= value + if record_attribute_changed_in_place?(record, attr_name) + raw_value = value + end + return if options[:allow_nil] && raw_value.nil? - unless value = parse_raw_value_as_a_number(raw_value) + unless is_number?(raw_value) record.errors.add(attr_name, :not_a_number, filtered_options(raw_value)) return end - if options[:only_integer] - unless value = parse_raw_value_as_an_integer(raw_value) - record.errors.add(attr_name, :not_an_integer, filtered_options(raw_value)) - return - end + if allow_only_integer?(record) && !is_integer?(raw_value) + record.errors.add(attr_name, :not_an_integer, filtered_options(raw_value)) + return + end + + unless raw_value.is_a?(Numeric) + value = parse_raw_value_as_a_number(raw_value) end options.slice(*CHECKS.keys).each do |option, option_value| @@ -43,11 +50,15 @@ module ActiveModel record.errors.add(attr_name, option, filtered_options(value)) end else - option_value = option_value.call(record) if option_value.is_a?(Proc) - option_value = record.send(option_value) if option_value.is_a?(Symbol) + case option_value + when Proc + option_value = option_value.call(record) + when Symbol + option_value = record.send(option_value) + end unless value.send(CHECKS[option], option_value) - record.errors.add(attr_name, option, filtered_options(value).merge(count: option_value)) + record.errors.add(attr_name, option, filtered_options(value).merge!(count: option_value)) end end end @@ -55,25 +66,42 @@ module ActiveModel protected + def is_number?(raw_value) + !parse_raw_value_as_a_number(raw_value).nil? + rescue ArgumentError, TypeError + false + end + def parse_raw_value_as_a_number(raw_value) - case raw_value - when /\A0[xX]/ - nil - else - begin - Kernel.Float(raw_value) - rescue ArgumentError, TypeError - nil - end - end + Kernel.Float(raw_value) if raw_value !~ /\A0[xX]/ end - def parse_raw_value_as_an_integer(raw_value) - raw_value.to_i if raw_value.to_s =~ /\A[+-]?\d+\Z/ + def is_integer?(raw_value) + /\A[+-]?\d+\z/ === raw_value.to_s end def filtered_options(value) - options.except(*RESERVED_OPTIONS).merge!(value: value) + filtered = options.except(*RESERVED_OPTIONS) + filtered[:value] = value + filtered + end + + def allow_only_integer?(record) + case options[:only_integer] + when Symbol + record.send(options[:only_integer]) + when Proc + options[:only_integer].call(record) + else + options[:only_integer] + end + end + + private + + def record_attribute_changed_in_place?(record, attr_name) + record.respond_to?(:attribute_changed_in_place?) && + record.attribute_changed_in_place?(attr_name.to_s) end end @@ -110,7 +138,7 @@ module ActiveModel # * <tt>:even</tt> - Specifies the value must be an even number. # # There is also a list of default options supported by every validator: - # +:if+, +:unless+, +:on+ and +:strict+ . + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+ . # See <tt>ActiveModel::Validation#validates</tt> for more information # # The following checks can also be supplied with a proc or a symbol which @@ -121,6 +149,7 @@ module ActiveModel # * <tt>:equal_to</tt> # * <tt>:less_than</tt> # * <tt>:less_than_or_equal_to</tt> + # * <tt>:only_integer</tt> # # For example: # diff --git a/activemodel/lib/active_model/validations/presence.rb b/activemodel/lib/active_model/validations/presence.rb index ab8c8359fc..5d593274eb 100644 --- a/activemodel/lib/active_model/validations/presence.rb +++ b/activemodel/lib/active_model/validations/presence.rb @@ -29,7 +29,7 @@ module ActiveModel # * <tt>:message</tt> - A custom error message (default is: "can't be blank"). # # There is also a list of default options supported by every validator: - # +:if+, +:unless+, +:on+ and +:strict+. + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. # See <tt>ActiveModel::Validation#validates</tt> for more information def validates_presence_of(*attr_names) validates_with PresenceValidator, _merge_attributes(attr_names) diff --git a/activemodel/lib/active_model/validations/validates.rb b/activemodel/lib/active_model/validations/validates.rb index 9a1ff2ad39..1da4df21e7 100644 --- a/activemodel/lib/active_model/validations/validates.rb +++ b/activemodel/lib/active_model/validations/validates.rb @@ -13,7 +13,7 @@ module ActiveModel # validates :terms, acceptance: true # validates :password, confirmation: true # validates :username, exclusion: { in: %w(admin superuser) } - # validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i, on: :create } + # validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, on: :create } # validates :age, inclusion: { in: 0..9 } # validates :first_name, length: { maximum: 30 } # validates :age, numericality: true @@ -71,9 +71,11 @@ module ActiveModel # # There is also a list of options that could be used along with validators: # - # * <tt>:on</tt> - Specifies when this validation is active. Runs in all - # validation contexts by default (+nil+), other options are <tt>:create</tt> - # and <tt>:update</tt>. + # * <tt>:on</tt> - Specifies the contexts where this validation is active. + # Runs in all validation contexts by default (nil). You can pass a symbol + # or an array of symbols. (e.g. <tt>on: :create</tt> or + # <tt>on: :custom_validation_context</tt> or + # <tt>on: [:create, :custom_validation_context]</tt>) # * <tt>:if</tt> - Specifies a method, proc or string to call to determine # if the validation should occur (e.g. <tt>if: :allow_validation</tt>, # or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>). The method, @@ -83,7 +85,9 @@ module ActiveModel # or <tt>unless: Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a +true+ or # +false+ value. - # * <tt>:strict</tt> - if the <tt>:strict</tt> option is set to true + # * <tt>:allow_nil</tt> - Skip validation if the attribute is +nil+. + # * <tt>:allow_blank</tt> - Skip validation if the attribute is blank. + # * <tt>:strict</tt> - If the <tt>:strict</tt> option is set to true # will raise ActiveModel::StrictValidationFailed instead of adding the error. # <tt>:strict</tt> option can also be set to any other exception. # @@ -111,7 +115,7 @@ module ActiveModel key = "#{key.to_s.camelize}Validator" begin - validator = key.include?('::') ? key.constantize : const_get(key) + validator = key.include?('::'.freeze) ? key.constantize : const_get(key) rescue NameError raise ArgumentError, "Unknown validator: '#{key}'" end diff --git a/activemodel/lib/active_model/validations/with.rb b/activemodel/lib/active_model/validations/with.rb index 16bd6670d1..6de01b3392 100644 --- a/activemodel/lib/active_model/validations/with.rb +++ b/activemodel/lib/active_model/validations/with.rb @@ -1,15 +1,5 @@ module ActiveModel module Validations - module HelperMethods - private - def _merge_attributes(attr_names) - options = attr_names.extract_options!.symbolize_keys - attr_names.flatten! - options[:attributes] = attr_names - options - end - end - class WithValidator < EachValidator # :nodoc: def validate_each(record, attr, val) method_name = options[:with] @@ -52,8 +42,11 @@ module ActiveModel # end # # Configuration options: - # * <tt>:on</tt> - Specifies when this validation is active - # (<tt>:create</tt> or <tt>:update</tt>. + # * <tt>:on</tt> - Specifies the contexts where this validation is active. + # Runs in all validation contexts by default (nil). You can pass a symbol + # or an array of symbols. (e.g. <tt>on: :create</tt> or + # <tt>on: :custom_validation_context</tt> or + # <tt>on: [:create, :custom_validation_context]</tt>) # * <tt>:if</tt> - Specifies a method, proc or string to call to determine # if the validation should occur (e.g. <tt>if: :allow_validation</tt>, # or <tt>if: Proc.new { |user| user.signup_step > 2 }</tt>). @@ -139,6 +132,8 @@ module ActiveModel # class version of this method for more information. def validates_with(*args, &block) options = args.extract_options! + options[:class] = self.class + args.each do |klass| validator = klass.new(options, &block) validator.validate(self) diff --git a/activemodel/lib/active_model/validator.rb b/activemodel/lib/active_model/validator.rb index 690856aee1..1d2888a818 100644 --- a/activemodel/lib/active_model/validator.rb +++ b/activemodel/lib/active_model/validator.rb @@ -15,7 +15,7 @@ module ActiveModel # class MyValidator < ActiveModel::Validator # def validate(record) # if some_complex_logic - # record.errors[:base] = "This record is invalid" + # record.errors.add(:base, "This record is invalid") # end # end # @@ -61,7 +61,7 @@ module ActiveModel # end # # Note that the validator is initialized only once for the whole application - # lifecycle, and not on each validation run. + # life cycle, and not on each validation run. # # The easiest way to add custom validators for validating individual attributes # is with the convenient <tt>ActiveModel::EachValidator</tt>. @@ -79,11 +79,11 @@ module ActiveModel # include ActiveModel::Validations # attr_accessor :title # - # validates :title, presence: true + # validates :title, presence: true, title: true # end # # It can be useful to access the class that is using that validator when there are prerequisites such - # as an +attr_accessor+ being present. This class is accessable via +options[:class]+ in the constructor. + # as an +attr_accessor+ being present. This class is accessible via +options[:class]+ in the constructor. # To setup your validator override the constructor. # # class MyValidator < ActiveModel::Validator @@ -106,10 +106,9 @@ module ActiveModel # Accepts options that will be made available through the +options+ reader. def initialize(options = {}) @options = options.except(:class).freeze - deprecated_setup(options) end - # Return the kind for this validator. + # Returns the kind for this validator. # # PresenceValidator.new.kind # => :presence # UniquenessValidator.new.kind # => :uniqueness @@ -122,28 +121,13 @@ module ActiveModel def validate(record) raise NotImplementedError, "Subclasses must implement a validate(record) method." end - - private - def deprecated_setup(options) # TODO: remove me in 4.2. - return unless respond_to?(:setup) - ActiveSupport::Deprecation.warn "The `Validator#setup` instance method is deprecated and will be removed on Rails 4.2. Do your setup in the constructor instead: - -class MyValidator < ActiveModel::Validator - def initialize(options={}) - super - options[:class].send :attr_accessor, :custom_attribute - end -end -" - setup(options[:class]) - end end # +EachValidator+ is a validator which iterates through the attributes given # in the options hash invoking the <tt>validate_each</tt> method passing in the # record, attribute and value. # - # All Active Model validations are built on top of this validator. + # All \Active \Model validations are built on top of this validator. class EachValidator < Validator #:nodoc: attr_reader :attributes @@ -179,6 +163,10 @@ end # +ArgumentError+ when invalid options are supplied. def check_validity! end + + def should_validate?(record) # :nodoc: + !record.persisted? || record.changed? || record.marked_for_destruction? + end end # +BlockValidator+ is a special +EachValidator+ which receives a block on initialization diff --git a/activemodel/lib/active_model/version.rb b/activemodel/lib/active_model/version.rb index 86340bba37..6da3b4117b 100644 --- a/activemodel/lib/active_model/version.rb +++ b/activemodel/lib/active_model/version.rb @@ -1,11 +1,8 @@ +require_relative 'gem_version' + module ActiveModel - # Returns the version of the currently loaded ActiveModel as a Gem::Version + # Returns the version of the currently loaded \Active \Model as a <tt>Gem::Version</tt> def self.version - Gem::Version.new "4.1.0.beta" - end - - module VERSION #:nodoc: - MAJOR, MINOR, TINY, PRE = ActiveModel.version.segments - STRING = ActiveModel.version.to_s + gem_version end end |