diff options
Diffstat (limited to 'activemodel/lib/active_model')
23 files changed, 350 insertions, 194 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..087d11f708 --- /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.blank? + + 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 ea07c5c039..96be551264 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -353,14 +353,12 @@ module ActiveModel @attribute_method_matchers_cache ||= ThreadSafe::Cache.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 @@ -469,8 +467,8 @@ module ActiveModel # 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) + 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 b3d70dc515..2cf39b68fb 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: # @@ -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] @@ -103,7 +103,6 @@ module ActiveModel def define_model_callbacks(*callbacks) options = callbacks.extract_options! options = { - terminator: ->(_,result) { result == false }, 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 9c9b6f4a77..9de6ea65be 100644 --- a/activemodel/lib/active_model/conversion.rb +++ b/activemodel/lib/active_model/conversion.rb @@ -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,9 +33,9 @@ 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 diff --git a/activemodel/lib/active_model/dirty.rb b/activemodel/lib/active_model/dirty.rb index ae185694ca..c03e5fac79 100644 --- a/activemodel/lib/active_model/dirty.rb +++ b/activemodel/lib/active_model/dirty.rb @@ -1,6 +1,5 @@ require 'active_support/hash_with_indifferent_access' require 'active_support/core_ext/object/duplicable' -require 'active_support/core_ext/string/filters' module ActiveModel # == Active \Model \Dirty @@ -13,7 +12,7 @@ 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>clear_changes_information</tt> when you want to reset the changes @@ -53,10 +52,10 @@ module ActiveModel # 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 + # person.changed? # => false # # Change the name: # @@ -72,8 +71,8 @@ module ActiveModel # Save the changes: # # person.save - # person.changed? # => false - # person.name_changed? # => false + # person.changed? # => false + # person.name_changed? # => false # # Reset the changes: # @@ -85,42 +84,41 @@ module ActiveModel # # person.name = "Uncle Bob" # person.rollback! - # person.name # => "Bill" - # person.name_changed? # => false + # 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 # +[attribute_name]_will_change!+ to mark that the attribute is changing. - # Otherwise Active Model can't track changes to in-place attributes. Note + # 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 +[attribute_name]_will_change!+ 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_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' @@ -168,15 +166,15 @@ module ActiveModel @changed_attributes ||= ActiveSupport::HashWithIndifferentAccess.new end - # Handle <tt>*_changed?</tt> for +method_missing+. + # Handles <tt>*_changed?</tt> for +method_missing+. def attribute_changed?(attr, options = {}) #:nodoc: - result = changed_attributes.include?(attr) + 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+. + # Handles <tt>*_was</tt> for +method_missing+. def attribute_was(attr) # :nodoc: attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr) end @@ -188,33 +186,30 @@ module ActiveModel 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? + # Removes current changes and makes them accessible through +previous_changes+. def changes_applied # :doc: @previously_changed = changes @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new end - # Clear all dirty data: current changes and previous changes. + # 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 - def reset_changes - ActiveSupport::Deprecation.warn(<<-MSG.squish) - `#reset_changes` is deprecated and will be removed on Rails 5. - Please use `#clear_changes_information` instead. - MSG - - clear_changes_information - 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>*_will_change!</tt> for +method_missing+. def attribute_will_change!(attr) return if attribute_changed?(attr) @@ -227,17 +222,7 @@ module ActiveModel set_attribute_was(attr, value) end - # Handle <tt>reset_*!</tt> for +method_missing+. - def reset_attribute!(attr) - ActiveSupport::Deprecation.warn(<<-MSG.squish) - `#reset_#{attr}!` is deprecated and will be removed on Rails 5. - Please use `#restore_#{attr}!` instead. - MSG - - restore_attribute!(attr) - end - - # Handle <tt>restore_*!</tt> for +method_missing+. + # Handles <tt>restore_*!</tt> for +method_missing+. def restore_attribute!(attr) if attribute_changed?(attr) __send__("#{attr}=", changed_attributes[attr]) @@ -246,7 +231,7 @@ module ActiveModel end # This is necessary because `changed_attributes` might be overridden in - # other implemntations (e.g. in `ActiveRecord`) + # other implementations (e.g. in `ActiveRecord`) alias_method :attributes_changed_by_setter, :changed_attributes # :nodoc: # Force an attribute to have a particular "before" value @@ -255,7 +240,7 @@ module ActiveModel end # Remove changes information for the provided attributes. - def clear_attribute_changes(attributes) + def clear_attribute_changes(attributes) # :doc: attributes_changed_by_setter.except!(*attributes) end end diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index 9105ef5dd6..f843b279ce 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -2,6 +2,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 @@ -23,7 +25,7 @@ module ActiveModel # attr_reader :errors # # def validate! - # errors.add(:name, "cannot 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,11 +34,11 @@ 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 @@ -58,8 +60,9 @@ module ActiveModel 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,11 +73,13 @@ 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 @@ -85,6 +90,7 @@ module ActiveModel # person.errors.full_messages # => [] def clear messages.clear + details.clear end # Returns +true+ if the error messages include an error for the given key @@ -96,35 +102,46 @@ module ActiveModel def include?(attribute) messages[attribute].present? end - # aliases include? alias :has_key? :include? - # aliases include? alias :key? :include? # Get messages for +key+. # # person.errors.messages # => {:name=>["cannot be nil"]} # person.errors.get(:name) # => ["cannot be nil"] - # person.errors.get(:age) # => 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) # => ["cannot 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) # => ["cannot be nil"] + # person.errors[:name] # => ["cannot be nil"] # person.errors.delete(:name) # => ["cannot be nil"] - # person.errors.get(:name) # => nil + # person.errors[:name] # => [] def delete(key) + details.delete(key) messages.delete(key) end @@ -134,7 +151,7 @@ module ActiveModel # 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. @@ -142,38 +159,45 @@ 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. # @@ -191,40 +215,20 @@ module ActiveModel 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 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\"?> @@ -261,17 +265,20 @@ module ActiveModel 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+). @@ -283,9 +290,9 @@ module ActiveModel # 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 # => {} @@ -293,17 +300,23 @@ module ActiveModel # +attribute+ should be set to <tt>:base</tt> if the error is not # directly associated with a single attribute. # - # person.errors.add(:base, "either name or email must be present") + # 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+ @@ -313,6 +326,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 @@ -327,6 +348,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? @@ -339,6 +368,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 @@ -356,6 +386,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. # @@ -368,7 +399,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. @@ -388,8 +419,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 @@ -447,12 +478,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 @@ -472,4 +505,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/gem_version.rb b/activemodel/lib/active_model/gem_version.rb index 932fe3e5a9..762f4fe939 100644 --- a/activemodel/lib/active_model/gem_version.rb +++ b/activemodel/lib/active_model/gem_version.rb @@ -1,14 +1,14 @@ module ActiveModel - # Returns the version of the currently loaded Active Model as a <tt>Gem::Version</tt> + # 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 = 4 - MINOR = 2 + MAJOR = 5 + MINOR = 0 TINY = 0 - PRE = "beta4" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activemodel/lib/active_model/lint.rb b/activemodel/lib/active_model/lint.rb index 38087521a2..010eaeb170 100644 --- a/activemodel/lib/active_model/lint.rb +++ b/activemodel/lib/active_model/lint.rb @@ -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,32 +49,34 @@ 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 and 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 class should respond to model_name" model_name = model.class.model_name @@ -88,12 +89,15 @@ module ActiveModel 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 bf07945fe1..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" @@ -16,7 +17,7 @@ en: present: "must be blank" too_long: one: "is too long (maximum is 1 character)" - other: "is too long (maximum is %{count} characters)" + 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)" diff --git a/activemodel/lib/active_model/model.rb b/activemodel/lib/active_model/model.rb index d51d6ddcc9..dac8d549a7 100644 --- a/activemodel/lib/active_model/model.rb +++ b/activemodel/lib/active_model/model.rb @@ -57,6 +57,7 @@ module ActiveModel # (see below). module Model extend ActiveSupport::Concern + include ActiveModel::AttributeAssignment include ActiveModel::Validations include ActiveModel::Conversion @@ -75,10 +76,8 @@ 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 + 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 4e6b02c246..1f1749af4e 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 @@ -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('/', '_') end end @@ -211,7 +213,7 @@ module ActiveModel # 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: diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb index 8f2a069ba3..89da74efa8 100644 --- a/activemodel/lib/active_model/secure_password.rb +++ b/activemodel/lib/active_model/secure_password.rb @@ -26,7 +26,7 @@ module ActiveModel # it). When this attribute has a +nil+ value, the validation will not be # triggered. # - # For further customizability, it is possible to supress the default + # For further customizability, it is possible to suppress the default # validations by passing <tt>validations: false</tt> as an argument. # # Add bcrypt (~> 3.1.7) to Gemfile to use #has_secure_password: @@ -77,13 +77,6 @@ module ActiveModel validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED validates_confirmation_of :password, allow_blank: true end - - # This code is necessary as long as the protected_attributes gem is supported. - if respond_to?(:attributes_protected_by_default) - def self.attributes_protected_by_default #:nodoc: - super + ['password_digest'] - end - end end end @@ -99,7 +92,7 @@ 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 diff --git a/activemodel/lib/active_model/serializers/json.rb b/activemodel/lib/active_model/serializers/json.rb index 77f2a64b11..b66dbf1afe 100644 --- a/activemodel/lib/active_model/serializers/json.rb +++ b/activemodel/lib/active_model/serializers/json.rb @@ -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 index 3ad3bf30ad..e33c766627 100644 --- a/activemodel/lib/active_model/serializers/xml.rb +++ b/activemodel/lib/active_model/serializers/xml.rb @@ -6,7 +6,7 @@ require 'active_support/core_ext/time/acts_like' module ActiveModel module Serializers - # == Active Model XML Serializer + # == \Active \Model XML Serializer module Xml extend ActiveSupport::Concern include ActiveModel::Serialization diff --git a/activemodel/lib/active_model/validations.rb b/activemodel/lib/active_model/validations.rb index c1e344b215..74d60327d6 100644 --- a/activemodel/lib/active_model/validations.rb +++ b/activemodel/lib/active_model/validations.rb @@ -87,7 +87,7 @@ module ActiveModel validates_with BlockValidator, _merge_attributes(attr_names), &block end - VALID_OPTIONS_FOR_VALIDATE = [:on, :if, :unless].freeze + VALID_OPTIONS_FOR_VALIDATE = [:on, :if, :unless, :prepend].freeze # Adds a validation method or block to the class. This is useful when # overriding the +validate+ instance method becomes too unwieldy and @@ -371,6 +371,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 @@ -392,9 +401,33 @@ module ActiveModel protected def run_validations! #:nodoc: - _run_validate_callbacks + run_callbacks :validate 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 9b5416fb1d..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? diff --git a/activemodel/lib/active_model/validations/acceptance.rb b/activemodel/lib/active_model/validations/acceptance.rb index ac5e79859b..ee160fb483 100644 --- a/activemodel/lib/active_model/validations/acceptance.rb +++ b/activemodel/lib/active_model/validations/acceptance.rb @@ -3,12 +3,12 @@ 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 @@ -20,6 +20,10 @@ module ActiveModel klass.send(:attr_reader, *attr_readers) klass.send(:attr_writer, *attr_writers) end + + def acceptable_option?(value) + Array(options[:accept]).include?(value) + end end module HelperMethods diff --git a/activemodel/lib/active_model/validations/callbacks.rb b/activemodel/lib/active_model/validations/callbacks.rb index 25ccabd66b..b4301c23e4 100644 --- a/activemodel/lib/active_model/validations/callbacks.rb +++ b/activemodel/lib/active_model/validations/callbacks.rb @@ -15,15 +15,14 @@ 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 }, skip_after_callbacks_if_terminated: true, scope: [:kind, :name] end @@ -110,7 +109,7 @@ module ActiveModel # Overwrite run validations to include callbacks. def run_validations! #:nodoc: - _run_validation_callbacks { super } + run_callbacks(:validation) { super } end end end diff --git a/activemodel/lib/active_model/validations/format.rb b/activemodel/lib/active_model/validations/format.rb index 02478dd5b6..46a2e54fba 100644 --- a/activemodel/lib/active_model/validations/format.rb +++ b/activemodel/lib/active_model/validations/format.rb @@ -77,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 diff --git a/activemodel/lib/active_model/validations/length.rb b/activemodel/lib/active_model/validations/length.rb index a96b30cadd..c22a58f9e1 100644 --- a/activemodel/lib/active_model/validations/length.rb +++ b/activemodel/lib/active_model/validations/length.rb @@ -1,3 +1,5 @@ +require "active_support/core_ext/string/strip" + module ActiveModel # == Active \Model Length Validator @@ -18,6 +20,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 +61,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 +82,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 @@ -84,8 +111,13 @@ 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: @@ -108,10 +140,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 13d6a966c0..4ba4e3e8f7 100644 --- a/activemodel/lib/active_model/validations/numericality.rb +++ b/activemodel/lib/active_model/validations/numericality.rb @@ -23,6 +23,10 @@ module ActiveModel raw_value = record.send(before_type_cast) if record.respond_to?(before_type_cast) 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) @@ -86,6 +90,13 @@ module ActiveModel 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 module HelperMethods diff --git a/activemodel/lib/active_model/validator.rb b/activemodel/lib/active_model/validator.rb index 0116de68ab..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 # @@ -127,7 +127,7 @@ module ActiveModel # 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 @@ -163,6 +163,10 @@ module ActiveModel # +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 b1f9082ea7..6da3b4117b 100644 --- a/activemodel/lib/active_model/version.rb +++ b/activemodel/lib/active_model/version.rb @@ -1,7 +1,7 @@ require_relative 'gem_version' module ActiveModel - # Returns the version of the currently loaded ActiveModel as a <tt>Gem::Version</tt> + # Returns the version of the currently loaded \Active \Model as a <tt>Gem::Version</tt> def self.version gem_version end |