Rails validation contexts

Rails validators are expressive and powerful, yet easy to use and extend. One interesting feature is that validators can run only when a condition is met (using if: and unless:), or in selected contexts, using on:. But did you know that you can also define custom contexts? Here’s how to do it, and why that might be useful.

Tags : Ruby on Rails Validation

Published: August 26, 2024

Basic Validation Contexts

In addition to if: and unless:, Rails provides built-in contexts for common scenarios:

  1. :create - Checks that run only when creating the record. Equivalent to: if: :new_record?.
  2. :update - Validations that run only when updating an existing record. Equivalent to: if: :persisted?.

These contexts can be specified in your model like this:

class User < ApplicationRecord
  validates :email, presence: true, on: :create
  validates :age, numericality: { greater_than: 18 }, on: :update
end

In this example, the email presence is only checked when creating a new user, while the age validation only applies when updating a user. Validations with no :on option will always run, no matter the context.

Custom Validation Contexts

Beyond the built-in contexts, Rails allows you to define custom contexts for more specific scenarios:

class Article < ApplicationRecord
  validates :title, presence: true, on: :publish

  def publish
    return false unless valid?(:publish)
    # publishing logic goes here
  end
end

Here, the custom :publish context is used to trigger validations that need to run only before an article is published. If the checks require hitting the database for instance, it’s a good idea to only perform them when needed.

You may even define multiple contexts by passing an array of symbols: on: [:create, :publish].

Gotcha: validation context is passed down to nested models

An undocumented feature is that when you use has_one or has_many associations, the parent validation context is automatically passed to the associated models. Be aware that nested models might thus receive a context you were not expecting!

Consider this situation:

class Author < ApplicationRecord
  has_many :books
  validates :name, presence: true, on: :publish
end

class Book < ApplicationRecord
  belongs_to :author
  validates :title, presence: true, on: :create
end

author = Author.new(books_attributes: [{}])
author.valid?(:publish)

In this case, calling valid?(:publish) on the author will validate all associated books, but only validations with the :publish context, or no context at all. Here, Book’s presence validation won’t run because it runs only on create. Beware also that could trigger endless circular validations!

Best Practices for Input Validation

Validate at the model level

It’s best to validate in models instead of controllers (skinny controllers, fat models). This allows ensuring that associated models perform the required checks.

Prevent or Limit database queries

To prevent N+1, try to avoid validations that need to talk to the database, like checking for associated model presence.

In one of my apps for instance, I noticed that Rails was querying the DB to check for email uniqueness every time the user was updated, even though the email had not changed. I was able to remove that extra DB hit by adding on: :create since users cannot change their email in that app.

In another model, uniqueness validation was running every time the record was saved, even if the attribute had been left untouched. I changed it to only happen when needed, using dirty attributes. It has now become a habit to check attribute uniqueness only if the attribute has changed.

validates_uniqueness_of :name, if: :name_changed?

Validation methods visibility

Custom validation methods (called using validate instead of validates) rarely need to be part of the public API of a model, and should therefore be private. You may need to call them from outside, to test business logic or to conditionally display a message for instance. This should be avoided but legacy code may call for it, or when adding a presenter would be overkill.

Consider Using POROs and Form Objects

Forms that create or update multiple models or have complex validation logic are often good candidates for the Form Object pattern. Remember that POROs, including form objects, can include ActiveModel::Attributes and ActiveModel::Validations to reuse Rails’ validation API. This is another technique to prevent model files from being filled with validations that run only in specific situations, and it can lead to code that’s much easier to read.

Don’t rely on validations only

Browser validation (input required attribute) is easy to bypass, and should only be used to prevent users from wasting time submitting invalid forms.

Also, a model may need to be imported from a file (csv for instance), and insert_all can be used to minimise pressure on the database. It’s therefore important that consistency is enforced at the database level, using non-null columns, foreign keys and unique indexes. The Database Consistency gem can be used to check for divergence between model validations and database constraints.

And finally, if you want to go further, please read Thoughtbot’s article that compares between validation, database constraints, or both to know the pros and cons of both options.