From fcd1e41e82d3c9993f96e1763ac49a196678f931 Mon Sep 17 00:00:00 2001 From: lulalala Date: Sun, 28 Apr 2019 20:25:51 +0800 Subject: [PATCH] Document on ActiveModel::Errors changes Mark private constants Display alternative for deprecation removal warning Annotate Error's attributes More emphasis on adding an error instead of message Rewrite scaffold template using new errors API Set first and last with behavior change deprecation Update more doc and example Add inspect for easier debugging --- activemodel/CHANGELOG.md | 15 ++ activemodel/lib/active_model/error.rb | 15 +- activemodel/lib/active_model/errors.rb | 89 +++++--- activemodel/lib/active_model/nested_error.rb | 11 - guides/source/active_record_validations.md | 206 ++++++++---------- .../erb/scaffold/templates/_form.html.erb.tt | 4 +- 6 files changed, 185 insertions(+), 155 deletions(-) diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 42f76cd656c..04834276605 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -19,5 +19,20 @@ *DHH* +* Encapsulate each validation error as an Error object. + + The `ActiveModel`’s `errors` collection is now an array of these Error + objects, instead of messages/details hash. + + For each of these `Error` object, its `message` and `full_message` methods + are for generating error messages. Its `details` method would return error’s + extra parameters, found in the original `details` hash. + + The change tries its best at maintaining backward compatibility, however + some edge cases won’t be covered, mainly related to manipulating + `errors.messages` and `errors.details` hashes directly. Moving forward, + please convert those direct manipulations to use provided API methods instead. + + *lulalala* Please check [6-0-stable](https://github.com/rails/rails/blob/6-0-stable/activemodel/CHANGELOG.md) for previous changes. diff --git a/activemodel/lib/active_model/error.rb b/activemodel/lib/active_model/error.rb index d529fbc3654..777c75f85af 100644 --- a/activemodel/lib/active_model/error.rb +++ b/activemodel/lib/active_model/error.rb @@ -110,7 +110,16 @@ module ActiveModel @options = @options.deep_dup end - attr_reader :base, :attribute, :type, :raw_type, :options + # The object which the error belongs to + attr_reader :base + # The attribute of +base+ which the error belongs to + attr_reader :attribute + # The type of error, defaults to `:invalid` unless specified + attr_reader :type + # The raw value provided as the second parameter when calling `errors#add` + attr_reader :raw_type + # The options provided when calling `errors#add` + attr_reader :options def message case raw_type @@ -159,6 +168,10 @@ module ActiveModel attributes_for_hash.hash end + def inspect # :nodoc: + "<##{self.class.name} attribute=#{@attribute}, type=#{@type}, options=#{@options.inspect}>" + end + protected def attributes_for_hash [@base, @attribute, @raw_type, @options.except(*CALLBACKS_OPTIONS)] diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index a689189b6d9..0448f98b4dd 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -11,7 +11,7 @@ require "forwardable" module ActiveModel # == Active \Model \Errors # - # Provides a modified +Hash+ that you can include in your object + # Provides error related functionalities you can include in your object # for handling error messages and interacting with Action View helpers. # # A minimal implementation could be: @@ -68,7 +68,10 @@ module ActiveModel def_delegators :@errors, :count LEGACY_ATTRIBUTES = [:messages, :details].freeze + private_constant :LEGACY_ATTRIBUTES + # The actual array of +Error+ objects + # This method is aliased to objects. attr_reader :errors alias :objects :errors @@ -205,17 +208,37 @@ module ActiveModel DeprecationHandlingMessageArray.new(messages_for(attribute), self, attribute) end - # Iterates through each error key, value pair in the error messages hash. + def first + deprecation_index_access_warning(:first) + super + end + + def last + deprecation_index_access_warning(:last) + super + end + + # Iterates through each error object. + # + # person.errors.add(:name, :too_short, count: 2) + # person.errors.each do |error| + # # Will yield <#ActiveModel::Error attribute=name, type=too_short, + # options={:count=>3}> + # end + # + # To be backward compatible with past deprecated hash-like behavior, + # when block accepts two parameters instead of one, it + # 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, :blank, message: "can't be blank") - # person.errors.each do |attribute, error| + # person.errors.each do |attribute, message| # # Will yield :name and "can't be blank" # end # # person.errors.add(:name, :not_specified, message: "must be specified") - # person.errors.each do |attribute, error| + # person.errors.each do |attribute, message| # # Will yield :name and "can't be blank" # # then yield :name and "must be specified" # end @@ -248,7 +271,7 @@ module ActiveModel # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]} # person.errors.values # => [["cannot be nil", "must be specified"]] def values - deprecation_removal_warning(:values) + deprecation_removal_warning(:values, "errors.map { |error| error.message }") @errors.map(&:message).freeze end @@ -257,7 +280,7 @@ module ActiveModel # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]} # person.errors.keys # => [:name] def keys - deprecation_removal_warning(:keys) + deprecation_removal_warning(:keys, "errors.map { |error| error.attribute }") keys = @errors.map(&:attribute) keys.uniq! keys.freeze @@ -329,25 +352,25 @@ module ActiveModel @errors.group_by(&:attribute) end - # Adds +message+ to the error messages and used validator type to +details+ on +attribute+. + # Adds a new error of +type+ on +attribute+. # More than one error can be added to the same +attribute+. - # If no +message+ is supplied, :invalid is assumed. + # If no +type+ is supplied, :invalid is assumed. # # person.errors.add(:name) - # # => ["is invalid"] + # # Adds <#ActiveModel::Error attribute=name, type=invalid> # person.errors.add(:name, :not_implemented, message: "must be implemented") - # # => ["is invalid", "must be implemented"] + # # Adds <#ActiveModel::Error attribute=name, type=not_implemented, + # options={:message=>"must be implemented"}> # # person.errors.messages # # => {:name=>["is invalid", "must be implemented"]} # - # person.errors.details - # # => {:name=>[{error: :not_implemented}, {error: :invalid}]} + # If +type+ is a string, it will be used as error message. # - # If +message+ is a symbol, it will be translated using the appropriate + # If +type+ is a symbol, it will be translated using the appropriate # scope (see +generate_message+). # - # If +message+ is a proc, it will be called, allowing for things like + # If +type+ is a proc, it will be called, allowing for things like # Time.now to be used within an error. # # If the :strict option is set to +true+, it will raise @@ -384,14 +407,14 @@ module ActiveModel error end - # Returns +true+ if an error on the attribute with the given message is - # present, or +false+ otherwise. +message+ is treated the same as for +add+. + # Returns +true+ if an error matches provided +attribute+ and +type+, + # or +false+ otherwise. +type+ is treated the same as for +add+. # # person.errors.add :name, :blank # person.errors.added? :name, :blank # => true # person.errors.added? :name, "can't be blank" # => true # - # If the error message requires options, then it returns +true+ with + # If the error requires options, then it returns +true+ with # the correct options, or +false+ with incorrect or missing options. # # person.errors.add :name, :too_long, { count: 25 } @@ -412,8 +435,8 @@ module ActiveModel end end - # Returns +true+ if an error on the attribute with the given message is - # present, or +false+ otherwise. +message+ is treated the same as for +add+. + # Returns +true+ if an error on the attribute with the given type is + # present, or +false+ otherwise. +type+ is treated the same as for +add+. # # person.errors.add :age # person.errors.add :name, :too_long, { count: 25 } @@ -423,13 +446,13 @@ module ActiveModel # person.errors.of_kind? :name, "is too long (maximum is 25 characters)" # => true # person.errors.of_kind? :name, :not_too_long # => false # person.errors.of_kind? :name, "is too long" # => false - def of_kind?(attribute, message = :invalid) - attribute, message = normalize_arguments(attribute, message) + def of_kind?(attribute, type = :invalid) + attribute, type = normalize_arguments(attribute, type) - if message.is_a? Symbol - !where(attribute, message).empty? + if type.is_a? Symbol + !where(attribute, type).empty? else - messages_for(attribute).include?(message) + messages_for(attribute).include?(type) end end @@ -541,13 +564,27 @@ module ActiveModel } end - def deprecation_removal_warning(method_name) - ActiveSupport::Deprecation.warn("ActiveModel::Errors##{method_name} is deprecated and will be removed in Rails 6.2") + def deprecation_removal_warning(method_name, alternative_message = nil) + message = +"ActiveModel::Errors##{method_name} is deprecated and will be removed in Rails 6.2." + if alternative_message + message << "\n\nTo achieve the same use:\n\n " + message << alternative_message + end + ActiveSupport::Deprecation.warn(message) end def deprecation_rename_warning(old_method_name, new_method_name) ActiveSupport::Deprecation.warn("ActiveModel::Errors##{old_method_name} is deprecated. Please call ##{new_method_name} instead.") end + + def deprecation_index_access_warning(method_name, alternative_message) + message = +"ActiveModel::Errors##{method_name} is deprecated. In the next release it would return `Error` object instead." + if alternative_message + message << "\n\nTo achieve the same use:\n\n " + message << alternative_message + end + ActiveSupport::Deprecation.warn(message) + end end class DeprecationHandlingMessageHash < SimpleDelegator diff --git a/activemodel/lib/active_model/nested_error.rb b/activemodel/lib/active_model/nested_error.rb index 93348c77716..60d40930933 100644 --- a/activemodel/lib/active_model/nested_error.rb +++ b/activemodel/lib/active_model/nested_error.rb @@ -4,17 +4,6 @@ require "active_model/error" require "forwardable" module ActiveModel - # Represents one single error - # @!attribute [r] base - # @return [ActiveModel::Base] the object which the error belongs to - # @!attribute [r] attribute - # @return [Symbol] attribute of the object which the error belongs to - # @!attribute [r] type - # @return [Symbol] error's type - # @!attribute [r] options - # @return [Hash] additional options - # @!attribute [r] inner_error - # @return [Error] inner error class NestedError < Error def initialize(base, inner_error, override_options = {}) @base = base diff --git a/guides/source/active_record_validations.md b/guides/source/active_record_validations.md index 992d639e69c..c6a99499b5c 100644 --- a/guides/source/active_record_validations.md +++ b/guides/source/active_record_validations.md @@ -165,7 +165,7 @@ Person.create(name: nil).valid? # => false ``` After Active Record has performed validations, any errors found can be accessed -through the `errors.messages` instance method, which returns a collection of errors. +through the `errors` instance method, which returns a collection of errors. By definition, an object is valid if this collection is empty after running validations. @@ -180,18 +180,18 @@ end >> p = Person.new # => # ->> p.errors.messages -# => {} +>> p.errors.size +# => 0 >> p.valid? # => false ->> p.errors.messages -# => {name:["can't be blank"]} +>> p.errors.objects.first.full_message +# => "Name can't be blank" >> p = Person.create # => # ->> p.errors.messages -# => {name:["can't be blank"]} +>> p.errors.objects.first.full_message +# => "Name can't be blank" >> p.save # => false @@ -209,7 +209,7 @@ returning true if any errors were found in the object, and false otherwise. ### `errors[]` To verify whether or not a particular attribute of an object is valid, you can -use `errors[:attribute]`. It returns an array of all the errors for +use `errors[:attribute]`. It returns an array of all the error messages for `:attribute`. If there are no errors on the specified attribute, an empty array is returned. @@ -231,32 +231,13 @@ end We'll cover validation errors in greater depth in the [Working with Validation Errors](#working-with-validation-errors) section. -### `errors.details` - -To check which validations failed on an invalid attribute, you can use -`errors.details[:attribute]`. It returns an array of hashes with an `:error` -key to get the symbol of the validator: - -```ruby -class Person < ApplicationRecord - validates :name, presence: true -end - ->> person = Person.new ->> person.valid? ->> person.errors.details[:name] # => [{error: :blank}] -``` - -Using `details` with custom validators is covered in the [Working with -Validation Errors](#working-with-validation-errors) section. - Validation Helpers ------------------ Active Record offers many pre-defined validation helpers that you can use directly inside your class definitions. These helpers provide common validation -rules. Every time a validation fails, an error message is added to the object's -`errors` collection, and this message is associated with the attribute being +rules. Every time a validation fails, an error is added to the object's +`errors` collection, and this is associated with the attribute being validated. Each helper accepts an arbitrary number of attribute names, so with a single @@ -747,7 +728,7 @@ end The block receives the record, the attribute's name, and the attribute's value. You can do anything you like to check for valid data within the block. If your -validation fails, you should add an error message to the model, therefore +validation fails, you should add an error to the model, therefore making it invalid. Common Validation Options @@ -1041,7 +1022,7 @@ own custom validators. ### Custom Methods You can also create methods that verify the state of your models and add -messages to the `errors` collection when they are invalid. You must then +errors to the `errors` collection when they are invalid. You must then register these methods by using the `validate` ([API](https://api.rubyonrails.org/classes/ActiveModel/Validations/ClassMethods.html#method-i-validate)) class method, passing in the symbols for the validation methods' names. @@ -1090,13 +1071,16 @@ end Working with Validation Errors ------------------------------ -In addition to the `valid?` and `invalid?` methods covered earlier, Rails provides a number of methods for working with the `errors` collection and inquiring about the validity of objects. +The `valid?` and `invalid?` methods only provide a summary status on validity. However you can dig deeper into each individual error by using various methods from the `errors` collection. The following is a list of the most commonly used methods. Please refer to the `ActiveModel::Errors` documentation for a list of all the available methods. ### `errors` -Returns an instance of the class `ActiveModel::Errors` containing all errors. Each key is the attribute name and the value is an array of strings with all errors. +The gateway through which you can drill down into various details of each error. + +This returns an instance of the class `ActiveModel::Errors` containing all errors, +each error is represented by an `ActiveModel::Error` object. ```ruby class Person < ApplicationRecord @@ -1105,12 +1089,12 @@ end person = Person.new person.valid? # => false -person.errors.messages - # => {:name=>["can't be blank", "is too short (minimum is 3 characters)"]} +person.errors.full_messages + # => ["Name can't be blank", "Name is too short (minimum is 3 characters)"] person = Person.new(name: "John Doe") person.valid? # => true -person.errors.messages # => {} +person.errors.full_messages # => [] ``` ### `errors[]` @@ -1136,80 +1120,11 @@ person.errors[:name] # => ["can't be blank", "is too short (minimum is 3 characters)"] ``` -### `errors.add` +### `errors.where` and error object -The `add` method lets you add an error message related to a particular attribute. It takes as arguments the attribute and the error message. +Sometimes we may need more information about each error beside its message. Each error is encapsulated as an `ActiveModel::Error` object, and `where` method is the most common way of access. -The `errors.full_messages` method (or its equivalent, `errors.to_a`) returns the error messages in a user-friendly format, with the capitalized attribute name prepended to each message, as shown in the examples below. - -```ruby -class Person < ApplicationRecord - def a_method_used_for_validation_purposes - errors.add(:name, "cannot contain the characters !@#%*()_-+=") - end -end - -person = Person.create(name: "!@#") - -person.errors[:name] - # => ["cannot contain the characters !@#%*()_-+="] - -person.errors.full_messages - # => ["Name cannot contain the characters !@#%*()_-+="] -``` - -### `errors.details` - -You can specify a validator type to the returned error details hash using the -`errors.add` method. - -```ruby -class Person < ApplicationRecord - def a_method_used_for_validation_purposes - errors.add(:name, :invalid_characters) - end -end - -person = Person.create(name: "!@#") - -person.errors.details[:name] -# => [{error: :invalid_characters}] -``` - -To improve the error details to contain the unallowed characters set for instance, -you can pass additional keys to `errors.add`. - -```ruby -class Person < ApplicationRecord - def a_method_used_for_validation_purposes - errors.add(:name, :invalid_characters, not_allowed: "!@#%*()_-+=") - end -end - -person = Person.create(name: "!@#") - -person.errors.details[:name] -# => [{error: :invalid_characters, not_allowed: "!@#%*()_-+="}] -``` - -All built in Rails validators populate the details hash with the corresponding -validator type. - -### `errors[:base]` - -You can add error messages that are related to the object's state as a whole, instead of being related to a specific attribute. You can use this method when you want to say that the object is invalid, no matter the values of its attributes. Since `errors[:base]` is an array, you can add a string to it and it will be used as an error message. - -```ruby -class Person < ApplicationRecord - def a_method_used_for_validation_purposes - errors.add :base, "This person is invalid because ..." - end -end -``` - -### `errors.clear` - -The `clear` method is used when you intentionally want to clear all the messages in the `errors` collection. Of course, calling `errors.clear` upon an invalid object won't actually make it valid: the `errors` collection will now be empty, but the next time you call `valid?` or any method that tries to save this object to the database, the validations will run again. If any of the validations fail, the `errors` collection will be filled again. +`where` returns an array of error objects, filtered by various degree of conditions. ```ruby class Person < ApplicationRecord @@ -1218,21 +1133,82 @@ end person = Person.new person.valid? # => false -person.errors[:name] - # => ["can't be blank", "is too short (minimum is 3 characters)"] + +>> person.errors.where(:name) # errors linked to :name attribute +>> person.errors.where(:name, :too_short) # further filtered to only :too_short type error +``` + +You can read various information from these error objects: + +```ruby +>> error = person.errors.where(:name).last +>> error.attribute # => :name +>> error.type # => :too_short +>> error.options[:count] # => 3 +``` + +You can also generate the error message: + +>> error.message # => "is too short (minimum is 3 characters)" +>> error.full_message # => "Name is too short (minimum is 3 characters)" + +The `full_message` method generates a more user-friendly message, with the capitalized attribute name prepended. + +### `errors.add` + +The `add` method creates the error object by taking the `attribute`, the error `type` and additional options hash. This is useful for writing your own validator. + +```ruby +class Person < ApplicationRecord + validate do |person| + errors.add :name, :too_plain, message: "is not cool enough" + end +end + +person = Person.create +person.errors.where(:name).first.type # => :too_plain +person.errors.where(:name).first.full_message # => "Name is not cool enough" +``` + +### `errors[:base]` + +You can add errors that are related to the object's state as a whole, instead of being related to a specific attribute. You can add errors to `:base` when you want to say that the object is invalid, no matter the values of its attributes. + +```ruby +class Person < ApplicationRecord + validate do |person| + errors.add :base, :invalid, message: "This person is invalid because ..." + end +end + +person = Person.create +person.errors.where(:base).first.full_message # => "This person is invalid because ..." +``` + +### `errors.clear` + +The `clear` method is used when you intentionally want to clear the `errors` collection. Of course, calling `errors.clear` upon an invalid object won't actually make it valid: the `errors` collection will now be empty, but the next time you call `valid?` or any method that tries to save this object to the database, the validations will run again. If any of the validations fail, the `errors` collection will be filled again. + +```ruby +class Person < ApplicationRecord + validates :name, presence: true, length: { minimum: 3 } +end + +person = Person.new +person.valid? # => false +person.errors.empty? # => false person.errors.clear person.errors.empty? # => true person.save # => false -person.errors[:name] -# => ["can't be blank", "is too short (minimum is 3 characters)"] +person.errors.empty? # => false ``` ### `errors.size` -The `size` method returns the total number of error messages for the object. +The `size` method returns the total number of errors for the object. ```ruby class Person < ApplicationRecord @@ -1271,8 +1247,8 @@ Assuming we have a model that's been saved in an instance variable named

<%= pluralize(@article.errors.count, "error") %> prohibited this article from being saved:

    - <% @article.errors.full_messages.each do |msg| %> -
  • <%= msg %>
  • + <% @article.errors.each do |error| %> +
  • <%= error.full_message %>
  • <% end %>
diff --git a/railties/lib/rails/generators/erb/scaffold/templates/_form.html.erb.tt b/railties/lib/rails/generators/erb/scaffold/templates/_form.html.erb.tt index 1dddc3d698b..88089565370 100644 --- a/railties/lib/rails/generators/erb/scaffold/templates/_form.html.erb.tt +++ b/railties/lib/rails/generators/erb/scaffold/templates/_form.html.erb.tt @@ -4,8 +4,8 @@

<%%= pluralize(<%= singular_table_name %>.errors.count, "error") %> prohibited this <%= singular_table_name %> from being saved:

    - <%% <%= singular_table_name %>.errors.full_messages.each do |message| %> -
  • <%%= message %>
  • + <%% <%= singular_table_name %>.errors.each do |error| %> +
  • <%%= error.full_message %>
  • <%% end %>