Oftentimes we need contextual validation in ActiveRecord models and find no ideal way of doing so. This article exposes an elegant, lightweight, dependency-free solution.
First, let’s have in mind two case studies that perfectly illustrate this matter:
State machines: you need to run different validations depending on the current state of the record.
Progressive completion: one may fill the form at step n only if all previous steps are completed.
What tools ActiveRecord provides to deal with this?
Create an STI and mutate the type of your record.
— Upside: you can run “always required” validations and “type-specific” validations.
— Downside: you cannot run “type-specific” validations for multiple types all at once, you would have to mutate your object for each type and merge errors.
Use contexts.
— Upside: you can run “always required” validations and “type-specific” validations.
— Downside: your models remain messy/heavy and you need to call
valid?
for each context you need to validate.
You can also use other tools such as dry-validation.
— Upside: powerful tool.
— Downside: add yet another dependency, learn yet another tool. Also you get out of ActiveRecord and lose many benefits (all errors in record.errors
, i18n lazy lookup, …). It’s a great tool but rather for complex cases.
None of the above fits my expectations: a modular, lightweight solution that needs no extra dependency and relies solely on ActiveModel::Validations.
Build an object with only the behavior that is required for the context at hand.
Said differently: instead of having a model with all validations and running only the ones required, have a model with only always-required validations and extend it with context/state -specific validations.
Upsides:
No extra dependency
Completely modular
No extra DSL to learn
Lightens your models
Downsides:
I’m still looking for one.
State machine example
Let’s assume we have an Article
model with a title
, a subtitle
a content
and a state
(values: %i[draft published]
).
While the article is a
draft
, it only requires either a title or a subtitle to be present.To be
published
, the article must have a title, a subtitle and a content present. Also the title must be unique amongst published articles.
app/models/article.rb
class Article < ApplicationRecord
validates :state, inclusion: { in: %i[draft published] }
end
app/validations/articles/draft_validation.rb
module Articles::DraftValidation
def self.extended(obj)
obj.class_eval do
validates :title, presence: true, unless: ->{ subtitle.present? }
end
end
end
app/validations/articles/published_validation.rb
module Articles::PublishedValidation
def self.extended(obj)
obj.class_eval do
validates :title, presence: true, uniqueness: { conditions: ->{ where(status: 'published') } }
validates :subtitle, presence: true
validates :content, presence: true
end
end
end
Now, here is how to validate an article depending on its state:
class ArticlesController < ApplicationController
def update
@article.assign_attributes(article_params)
case @article.state
when :draft
@article.extend(Articles::DraftValidation)
when :published
@article.extend(Articles::PublishedValidation)
end
if @article.save
redirect_to @article, notice: 'Article was successfully updated.'
else
render :edit
end
end
end
This is an oversimplified yet functional way of building an article with just the right validations for its current state.
Progressive completion example
Let’s say we have a Profile
model and, for UX purposes, we decided to divide the registration process into 4 steps:
Personal information (last name, first name, date of birth, ..)
Family situation (marital situation, number of children, ..)
Work expectations (country, expected salary, ..)
Financial situation (current salary, current debt amount, ..)
Since I demonstrated earlier how to write modules which contain context/state -specific validations, we will assume that the 4 following modules exist:
Profiles::PersonalInformationValidation
Profiles::FamilySituationValidation
Profiles::WorkExpectationsValidation
Profiles::FinancialSituationValidation
In order to fill the form at step N, it is required that all previous steps are correctly filled first.
Here is an example of how one could build a progressive form completion:
class ProfilesController < ApplicationController
STEPS = %w[personal_information family_situation work_expectations financial_situation].freeze
def update
@profile = Profile.find(params[:id])
previous_incomplete_step = find_previous_incomplete_step
if previous_incomplete_step
redirect_to edit_profiles_controller(id: @profile.id, current_step: previous_incomplete_step), notice: "Please complete this form first"
return
end
@profile.assign_attributes(profile_params)
@profile.extend(step_validation_module(current_step))
if @profile.save
redirect_to_next_step
else
render :edit
end
end
private
def current_step
params[:current_step]
end
def redirect_to_next_step
if current_step == STEPS.last
redirect_to root_path, notice: "Profile successfully completed!"
else
next_step = STEPS[STEPS.find_index(current_step) + 1]
edit_profiles_controller(id: @profile.id, current_step: next_step)
end
end
def find_previous_incomplete_step
STEPS.each do |step|
break if step == current_step
@profile.extend(step_validation_module(step))
return step unless @profile.valid?
end
nil
end
def step_validation_module(step)
"Profiles::#{step.to_s.camelize}Validation".constantize
end
end
I am purposefully not writing all the required methods and security checks in this code sample to keep it as easy to read as possible. The goal here is obviously to illustrate a technique, not to write production-ready code.
One last thing about this approach: you can compose a validation made of other validations.
Suppose that you need, in multiple places, to validate that the profile is complete. Would you keep extending your record with all 4 separate steps’ validations each time? No, you would just write another validation module that is itself composed of the 4 steps’ validations. As such:
module Profiles::FullValidation
def self.extended(obj)
obj.class_eval do
extend Profiles::PersonalInformationValidation
extend Profiles::FamilySituationValidation
extend Profiles::WorkExpectationsValidation
extend Profiles::FinancialSituationValidation
end
end
end
Thanks for reading!