Related articles

No items found.
The French newsletter for Ruby on Rails developers. Find similar content for free every month in your inbox!
Register
Share:
Blog
>

Better contextual validations with ActiveRecord

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:

  • State machines: You need to perform different validations based on the current state of the record.
  • Gradual completion: The form can only be completed in step n if all of the previous steps have been completed.

What tools does ActiveRecord provide to deal with this problem?

  • Create an STI and change the type of your recording.
    • Benefit: you can run “always required” validations and “type-specific” validations.
    • Disadvantage: you can't run “type-specific” validations for multiple types at the same time, you would have to mutate your object for each type and merge errors.
  • Use contexts.
    • Benefit: you can run “always required” validations and “type-specific” validations.
    • Disadvantage: do your models remain messy and heavy and you have to call valid? for each context to be validated.

You can also use other tools such as dry validation.

  • Advantages: powerful tool.
  • Disadvantage: adding yet another dependency, learning yet another tool. You also leave ActiveRecord and lose a lot of advantages (all the errors in record.errors, i18n lazy lookup,...). It is a great tool but rather for complex cases.

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:

  • No additional dependencies
  • Fully modular
  • No additional DSL to learn
  • Lighten your models

Disadvantages:

  • I am always looking for a.

Example of a state machine

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] }
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'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
end

This is an extremely simplified, but functional, way of building an article with just the right validations for its current state.

Example of gradual completion

Suppose we have a model of Profile and that, for reasons of ergonomics, we decided to divide the registration process into 4 steps:

  • Personal information (last name, first name, date of birth,...)
  • Family situation (marital status, number of children,...)
  • Professional expectations (country, expected salary,...)
  • Financial situation (current salary, current debt amount,...)

Since I demonstrated previously how to write modules that contain context and state-specific validations, we will assume that the following 4 modules exist:

  • Profiles: :Personal Information Validation
  • Profiles: :FamilySituationValidation
  • Profiles: :WorkExpectationsValidation
  • Profiles: :Financial SituationValidation

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
end

By 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
end

Thanks for reading!