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.
Basic Validation Contexts
In addition to if:
and unless:
, Rails provides built-in contexts for common scenarios:
:create
- Checks that run only when creating the record. Equivalent to:if: :new_record?
.: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.