Rails Model Validations

I wrote this article as a note to self while learning how Rails model validation messages are produced so that I could modify them. A key feature of the Rails ActiveModel is attribute validation. You can add a declaration to a model, like this

validates :password, presence: true

to get an error message like this

The actual display of the error message requires the view to render them and this is typically achieved by a loop:

obj.errors.each do |name,msg|
  message(:error,obj.errors.full_message(name,msg))
end 

Here, obj.errors is an instance of ActiveModel::Errors, a modified hash of errors for the model object, obj. Each message is keyed by the name of the attribute to which it applies, represented as a Ruby symbol like :password in our example.

The message method is an application-specific helper that formats the text so that it renders the image shown above.

The full_message ActiveModel method produces a string from the attribute's name and the error message text, msg. For example,

full_message(:password,"can't be blank")

produces "Password can't be blank". The message text is added to the hash by the validation routines.

The message text can optionally be specified in the model:

validates :password, presence: true, message: "can't be blank"

If this isn't done then ActiveModel obtains the message by translating keywords. The translation path for the message when such an attribute is blank is

activerecord.errors.models.model_name.attributes.attribute_name.blank

where

  • model_name is the model raising the error, e.g. Identity, specified in lower-case, e.g. identity.
  • attribute_name is the attribute the error applies to, e.g. identity.

For an example User model, the path would be

activerecord.errors.models.user.attributes.password.blank

In a locale file, e.g. config/locales/en.yml this would be

activerecord:
  errors:
    models:
      user:
        attributes:
          password:
            blank: "can't be blank"

ActiveModel translates attribute names from its symbol representation into human-readable text. This is performed by a method called humanattributename. It computes a number of translation paths, looks up and returns the translated attribute name. The paths it searches are for the above example are:

activerecord.attributes.user.password
attributes.password

If no such translation exists then the default string "Password" is used.

This process generally works but is a little more involved when nested attributes are handled.

Nested attributes

A simple data model serves as an example. It has a User model with a has_many relationship to an Identitiy model. We have

# app/models/User.rb
class User < ActiveRecord::Base
  has_secure_password
  has_many :identities, :dependent => :destroy
  accepts_nested_attributes_for :identities
end

and

# app/models/Identity.rb
class Identity < ActiveRecord::Base
  belongs_to :user
  validates_presence_of :identity
end

! Here the alternate form validates_presence_of was used becuase it reads more clearly. This and the validates form are compared towards the end of the article.

This sets up the basic relationship between the models. A controller view provides a form to create a new user:

<%= semantic_form_for user, url:sign_up_url, html: { role: 'form' } do |f| %>           
  ... user fields here ...
  <%= f.semantic_fields_for :identities do |i| %>
    <%= i.input :identity, label: t('.email') %>
    <%= i.input :identity_type, :as => :hidden, input_html: {value: 'email'} %>
  <% end %>
  <%= f.submit t('.sign_up'), class: 'btn btn-primary' %>
<% end %>

This is a basic form for the User model with nested fields for the Identity model. The example uses formtastic tags. The identity_type field is hidden; it contains the type of identity and will be used to customise the validation message for the identity field (ActiveModel having an internal type attribute prevented this field from being called just type).

Because the Identity model validates_presence_of :identity, the form must be submitted with its identity field filled in. If it is blank then an ActiveModel error will be raised.

The first thing that a controller action needs to do is specify the permitted fields for Strong Parameters. The controller provides a private method

def user_params
 params.require(:user).permit(:password, :password_confirmation,
  identities_attributes: [:id, :identity, :identity_type] )
end 

This permits the password and password_confirmation fields for the User model, followed by the attributes for the Identity model.

The controller action then uses this method when creating the new user record:

def sign_up(evt=nil)
  @user = User.new(user_params)
  if @user.save
    sign_in
  else
    messages_for @user
    redirect_to(root_path)
  end
end

The validation is performed during User.save and, if this fails, the User object's errors hash will contain its errors messages, prepared by ActiveModel as described above.
The translation path for our example would be

activerecord.errors.models.identity.attributes.identity.blank

which, in a locale file, e.g. config/locales/en.yml, would be

activerecord:
  errors:
    models:
      identity:
        attributes:
          identity:
            blank:   "can't be blank"

The controller has a messages_for method that puts the model's error messages into the flash:

def messages_for(obj)
  obj.errors.each do |name,msg|
    flash[:error] = obj.errors.full_message(name,msg)
  end 
end 

This uses ActiveModel full_message method to prepare each message's text.

# File activemodel/lib/active_model/errors.rb, line 369
def full_message(attribute, message)
  return message if attribute == :base
  attr_name = attribute.to_s.tr('.', '_').humanize
  attr_name = @base.class.human_attribute_name(attribute, default: attr_name)
  I18n.t(:"errors.format", {
    default:  "%{attribute} %{message}",
    attribute: attr_name,
    message:   message
  })
end

It takes two arguments, the attribute name and the message text, which was obtained through the translation described above. The attribute is passed through an ActiveModel class method human_attribute_name which computes a number of translation paths, looks up and returns the translated attribute name. The paths it searches are for the above example are:

activerecord.attributes.user/identities.identity
activerecord.attributes.identities.identity
attributes.identity

If no such translation exists then the default string "Identities Identity" is used.

Altering the attribute

The requirement that lead to this article was to alter the error message raised when the identity attribute is blank based on the value of the identity_type.

Human Attribute Name

This approach customises the human_attribute_name method for the User model by adding a new method to app/models/user.rb:

def self.human_attribute_name(attribute, options = {})
  attribute = attribute.to_s.gsub('identity','email')
  super(attribute, options = {})
end

It alters the attribute, which is passed in as identities.identity, to change identity to email so the attribute becomes identities.email. This is then passed to the original method so that it looks up the attribute translation using a different path, for example:

activerecord.attributes.user/identities.email

The problem with this method is that it's a class method and, therefore, can't access an instance's variables, so it cannot use the identity_type upon which the solution depends.

After-validation hook

The easiest, and proably most correct, way to implement the requirement is to add an after_validation hook to the Identity model. In app/models/identity.rb:

after_validation :alias_identity_type
def alias_identity_type
  unless identity_type.nil?
    if errors.include? :identity
      errors.set(identity_type.to_sym,errors.delete(:identity))
    end
  end
end

The errors hash is actually a modifed hash class that provides methods that are used here to interrogate and amend the messages it contains.

The after_validation hook sets up a method alias_identity_type that replaces any message for the identity attribute with one for an attribute with the name given by the identity_type attribute.

Note that there is no model attribute with the substituted name, it is just used while preparing the full_message in the translation performed in human_attribute_name which, if the identity_type was email, would search these translation paths:

activerecord.attributes.user/identities.email
activerecord.attributes.identities.email
attributes.email

If no such translation exists then the default string "Identities Email" is used. Equivalent YAML in a locale file, e.g. config/locales/en.yml for the first path would be

activerecord:
  attributes:
    user/identities:
      email:   "Email address"

It is also possible to perform the above substitution with a similar after_validation hook in the User model (in app/models/user.rb). Such a hook would need to consider the compounded attribute name when performing the substitution:

after_validation :alias_identity_type
def alias_identity_type
  if errors.include? :'identities.identity'
    errors.set(:'identities.email',errors.delete(:'identities.identity'))
  end
end

The order in which after_validation hooks are called is that the related model's one (Identity) goes first, followed by the model's one (User).

Other

There is a related question on Stack Overflow.

You can refer to the entered value in the error message text using interpolation in a similar way to i18n.

validates_uniqueness_of :name, :message => '%{value} has already been taken'
Validation Helpers

There are two syles of validations, the one shown above uses validates along with an argument that specifies what to validate (presence: true). There are shortcut helpers that can be used instead:

validates_presence_of :password

The current rails way is to use arguments, but the helpers may be more efficient.

(end)