Categories
DRY Rails Ruby

Dry Schema: Decouple Validations from Rails Models

dry

Standard Ruby on Rails best practices suggest that we should define our validations on the model object. RoR gives you the tool, aka DSL (domain specific language), to implement these validations. For simple situations, say a sign up form, this works really well, but what about more complicated scenarios? What if your model serves several different controllers? Or what if, for example, different types of users could submit different values to the same model? Do we want to use messy if blocks to check the user type and apply the correct set of validations? Probably not. So whats the solution? Enter Dry-schema.

First lets look at what comes out of the box with rails:

class User
    validates :email, presence: true
    validates :password, confirmation: true
end

It’s simple enough and works well too. Now lets consider another scenario:

Let’s say you have a somewhat complex model which requires you to do multiple “steps” or page submits to gather the data you need. A simple Google search yields Developing a wizard or multi-steps forms in Rails by Nicolas Blanco. Here I summarize his article:

First, create a model, in this case User, and define all the persistence validations:

class User < ApplicationRecord
  validates :email, presence: true, format: { with: /@/ }
  validates :name, presence: true
  validates :city, presence: true
  validates :country, presence: true
  validates :phone_number, presence: true
end

Then create a separate class for each of the wizard steps each of which encapsulates the validations for that step:

module Wizard
  module User
    STEPS = %w(step1 step2 step3 step4).freeze

    class Base
      include ActiveModel::Model
      attr_accessor :user

      delegate *::User.attribute_names.map { |attr| [attr, "#{attr}="] }.flatten, to: :user

      def initialize(user_attributes)
        @user = ::User.new(user_attributes)
      end
    end

    class Step1 < Base
      validates :email, presence: true, format: { with: /@/ }
    end

    class Step2 < Step1
      validates :name, presence: true
    end

    class Step3 < Step2
      validates :city, presence: true
      validates :country, presence: true
    end

    class Step4 < Step3
      validates :phone_number, presence: true
    end
  end
end

And the controller, which uses a session variable to pass arguments from one step to another (a no-no in my book):

class WizardsController < ApplicationController
  before_action :load_user_wizard, except: %i(validate_step)

  def validate_step
    current_step = params[:current_step]

    @user_wizard = wizard_user_for_step(current_step)
    @user_wizard.user.attributes = user_wizard_params
    session[:user_attributes] = @user_wizard.user.attributes

    if @user_wizard.valid?
      next_step = wizard_user_next_step(current_step)
      create and return unless next_step

      redirect_to action: next_step
    else
      render current_step
    end
  end

  def create
    if @user_wizard.user.save
      session[:user_attributes] = nil
      redirect_to root_path, notice: 'User succesfully created!'
    else
      redirect_to({ action: Wizard::User::STEPS.first }, alert: 'There were a problem when creating the user.')
    end
  end

  private

  def load_user_wizard
    @user_wizard = wizard_user_for_step(action_name)
  end

  def wizard_user_next_step(step)
    Wizard::User::STEPS[Wizard::User::STEPS.index(step) + 1]
  end

  def wizard_user_for_step(step)
    raise InvalidStep unless step.in?(Wizard::User::STEPS)

    "Wizard::User::#{step.camelize}".constantize.new(session[:user_attributes])
  end

  def user_wizard_params
    params.require(:user_wizard).permit(:email, :name :city, :country, :phone_number)
  end

  class InvalidStep < StandardError; end
end

Now lets write this another way, using dry-schema:

module Wizard
  StepOneSchema = Dry::Schema.Params do
  required(:user).hash do
    required(:email).filter(format?: /@/).filled(:string)
  end
end

StepTwoSchema = Dry::Schema.Params do
  required(:user).hash do
    required(:email).filter(format?: /@/).filled(:string)
    required(:name).filled(:string)
  end
end

StepThreeSchema = Dry::Schema.Params do
  required(:user).hash do
    required(:email).filter(format?: /@/).filled(:string)
    required(:name).filled(:string)
    required(:city).filled(:string)
    required(:country).filled(:string)
  end
end

StepFourSchema = Dry::Schema.Params do
  required(:user).hash do
    required(:email).filter(format?: /@/).filled(:string)
    required(:name).filled(:string)
    required(:city).filled(:string)
    required(:country).filled(:string)
    required(:phone_number).filled(:integer, min_size?: 7)
  end
end

class WizardsController < ApplicationController
  before_action :convert_params_to_hash

  def new
    @user = init_user
  end

  def step_one
    validation = Wizard::StepOneSchema.call(@hash_params)
    @user = init_user(@hash_params)
    if validation.success?
      render :step_two
      return
    end
    add_errors_to_object(@user, validation)
    render :step_one
  end

  def step_two
    validation = Wizard::StepTwoSchema.call(@hash_params)
    @user = init_user(@hash_params)
    if validation.success?
      render :step_three
      return
    end
    add_errors_to_object(@user, validation)
    render :step_two
  end

  def step_three
    validation = Wizard::StepThreeSchema.call(@hash_params)
    @user = init_user(@hash_params)
    if validation.success?
      render :step_four
      return
    end
    add_errors_to_object(@user, validation)
    render :step_three
  end

  def step_four
    validation = Wizard::StepFourSchema.call(@hash_params)
    @user = init_user(@hash_params)
    if validation.success?
      flash[:success] = 'User created successfully'
      redirect_to root_path
      return
    end
    add_errors_to_object(@user, validation)
    render :step_four
  end

  private

  def init_user(params = {})
    User.new(params)
  end

  def add_errors_to_object(object, validation)
    validation.errors.each {|key, value| object.errors.add(key, value)}
  end

  def convert_params_to_hash
    @hash_params = params.to_unsafe_h
  end
end

Here, I used the dry-schema library to capture what needs to be validated in each step. The only thing that I wish Dry-schema implemented, is the ability to include the previous step schema rules into the next one instead of repeating the code. Alas, that is not the case. We could, however, check the success? of the previous schema(s) in the controller action instead of repeating the rules.

Other than simplifying the code and being framework agnostic, the decoupling of validations from the model is a great thing. You could use the schemas anywhere in your code. We use a similar structure to validate certain structures in the view, and we use it to validate the params in a controller, and if we really wanted to, we could also use it in a before_save action on the model.