Validation Contexts in ActiveRecord

Validation Contexts in ActiveRecord

Validation contexts are a little-appreciated but immensely practical feature of Ruby on Rails’ object-relational mapper, ActiveRecord. I cannot count the number of times I have seen hacks around a problem for which a validation context would have been a perfect fit simply because this feature lives a bit under the radar and isn’t in every Rails developer’s toolbox.

What is a validation context, precisely? It is a way to constrain a model validation to a particular usage context for a record. This is similar to what you might achieve with something like state_machine, but far more lightweight.

Let’s say we have an application where we want to dispense gift cards to select users. Administrators can manage an inventory of gift cards and then invite users to claim them by filling out a form at a tokenized link.

A schema for such a feature might look as follows:

class DefineSchema < ActiveRecord::Migration[5.0]
  def change
    create_table :gift_cards do |t|
      t.string :code
      t.string :token
      t.string :name
      t.string :email
      t.datetime :claimed_at
      t.timestamps
    end
  end
end

Different validation rules apply in different contexts. In the admin-editing context (which we’ll consider the default context) the record is valid so long as it has a code. In the user-claiming scenario, the gift card is only valid if it has been assigned a name, email and the user has supplied a valid confirmation of its token.

We can tie these contexts to model validations by supplying the :on option to our validates and validate calls. Our gift card model might therefore look as follows:

class GiftCard < ApplicationRecord 
  attr_accessor :token_confirmation 

  validates :code, presence: true 

  # Contextual validations:
  validates :name, presence: true, on: :claim 
  validates :email, presence: true, format: /\A\S+@.+\.\S+\z/, on: :claim 
  validate :token_match?, on: :claim 

  before_create :generate_token 

  scope :unclaimed, ->{ where(claimed_at: nil) }

  def claim(attrs={})
    self.attributes = attrs.merge(claimed_at: Time.now)
    save(context: :claim)
  end  

  private

  def token_match?
    unless token_confirmation == token
      errors[:base] << "You are not authorized to claim this gift card"
    end
  end

  # generate a random token for this Gift Card (i.e. for token link authorization)
  def generate_token
    self.token = SecureRandom.hex(10)
  end
end

The beauty of validation contexts for use cases like we have here is how declarative and readable they are and how foolproof they become once we’re further up in the stack. To drive this point home, let’s have a look at how skinny the controller and UI layers we build around this model to handle the full user flow are.

class GiftCards::ClaimsController < ApplicationController
  before_action :find_gift_card

  def new
    @gift_card.token_confirmation = params[:token_confirmation]
  end

  def create
    if @gift_card.claim(gift_card_params)
      GiftCardMailer.claim_notification(@gift_card).deliver_later
      redirect_to gift_card_claim_path(@gift_card)
    else
      render :new
    end
  end

  def show
    # default render
  end

  private

  def gift_card_params
    params.fetch(:gift_card, {}).permit(:name, :email, :token_confirmation)
  end

  def find_gift_card
    @gift_card = GiftCard.find(params[:gift_card_id])
  end
end

This minimal controller implementation can handle our entire flow of presenting a claim form, processing and validating user input, delivering an email and presenting the user a success page when they are done. The minimal form implementation below is enough to take all the requisite input, as well as keep the token that the user came into the flow with in scope (note: this is using simple_form):


<div class="row">

<div class="col-md-4 col-md-offset-4">
    <%= simple_form_for @gift_card, url: gift_card_claims_path(@gift_card), method: :post do |f| %>
      <%= f.error :base %>
      <%= f.input :name %>
      <%= f.input :email %>
      <%= f.input :token_confirmation, as: :hidden %>
      <%= f.button :submit, "Claim Gift Card" %>
    <% end %>
  </div>

</div>

It is worth noting that as of Rails 4.1, the on option to validate/validates can now take multiple contexts. This is welcome flexibility and in my opinion even further reduces the number of real-world use cases for heavyweight solutions like state_machine.

Nicholas

Hi! I'm Nicholas and I like building stuff. I spent a decade working with startups in NYC as a developer before turning my attention to seed-stage investing beginning in 2018. I write here on topics including startups, investing, travel, software development, and just about any other matter of personal relevance. I can be reached by email at [email protected].

No Comments

Comments Closed