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.
No Comments