Better contextual validations with ActiveRecord

Younes Serraj
Younes SerrajNov 25, 2019
Better contextual validations with ActiveRecord


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.

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.

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.

example

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!

Partager
Younes Serraj
Younes SerrajNov 25, 2019

Capsens' blog

Capsens is an agency specialized in the development of fintech solutions. We love startups, scrum methodology, Ruby and React.

Ruby Biscuit

The french newsletter for Ruby on Rails developers.
Get similar content for free every month in your mailbox!
Subscribe