
We often need contextual validation in ActiveRecord models and can't find an ideal way to do it. This article presents an elegant, lightweight, and dependency-free solution. First, let's keep in mind two case studies that illustrate this issue perfectly:
What tools does ActiveRecord provide to deal with this problem?
You can also use other tools such as dry validation.
None of the above meets my expectations: a modular, lightweight solution that requires no additional dependencies, and is based solely on ActiveModel: :Validations.

Build an object with only the behavior required for the context in question.
In other words: instead of having a model with all the validations and only running the ones that are needed, have a model with only the validations that are always needed and extend it with context/state-specific validations.

Advantages:
Disadvantages:

Suppose we have a model Article With a Title, a subtitle, a thrilled And a State (values: %i [draft published]).
Although the item is a Draft, all you need is a title or a subtitle.
To be Published, the article must have a title, a subtitle, and present content. The title should also be unique among published articles.
app/models/article.rb
class Article < ApplicationRecord
validates :state, inclusion: { in: %i[draft published] }
endapp/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
endapp/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
endNow, here's how to validate an item based on its condition:
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
endThis is an extremely simplified, but functional, way of building an article with just the right validations for its current state.
Suppose we have a model of Profile and that, for reasons of ergonomics, we decided to divide the registration process into 4 steps:
Since I demonstrated previously how to write modules that contain context and state-specific validations, we will assume that the following 4 modules exist:
To be able to fill out the form in step N, all the previous steps must first be filled out correctly. Here is an example of how to build a gradual form filling:
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
endBy design, I am not writing all of the necessary security methods and controls in this sample code in order to make it as easy to read as possible. The aim 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.
Let's say you need to validate that the profile is complete in several places. Are you going to continue to expand your registration with the validations of the 4 distinct stages each time? No, you will simply write another validation module which will itself be composed of the validations of the 4 steps. 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
endThanks for reading!