March 15, 2013

Dynamic Model validations with Ruby on Rails

Ruby on Rails 3 made dynamic validations really easy. Yet I feel like most people follow bad advice when implementing dynamic validations in rails. If you read posts on Stackoverflow you’ll find something like this:

class Project < ActiveRecord::Base
  validates_presence_of :name, if: :name_should_be_present
  attr_accessor :name_should_be_present
end
# ...
class ProjectsController < ApplicationController
  def create
    @project = Project.new(params)
    @project.name_should_be_present = true
    if @project.save
      # snip
    else
      # snap
    end
  end
end

The more complex your validations get e.g. when implementing a multi-step wizard your model will get unreadable and most probably untestable as well. If you need access to your current_user inside your validations all hell will break loose (like Thread global variables).

While the above version works I opt for the slightly longer version which cleanly separates my custom validations: ActiveModel::Validator. I have to write a little more code which enables me to cleanly separate my custom per-case validation logic from the remaining persistence layer.

class Project < ActiveRecord::Base
  validate :instance_validations
  attr_accessor :custom_validator
  attr_accessor :custom_validator_options
  def instance_validations
    validates_with custom_validator, (custom_validator_options || {}) if custom_validator.present?
  end
end
# ...
class ProjectsController < ApplicationController
  def create
    @project = Project.new(params)

    @project.custom_validator = ProjectValidator
    @project.custom_validator_options = { current_user: current_user }
    if @project.save
      # snip
    else
      # snap
    end
  end
end
# ...
class ProjectValidator  < ActiveModel::Validator
  def validate(project)
    current_user = options[:current_user]

    if current_user.project_owner?
      unless project.name.present?
        project.errors[:name] << I18n.t('activerecord.errors.messages.blank', attribute: I18n.t(:"activerecord.attributes.project.name"))
      end
    end
  end
end

A word of warning: you might be tempted to write this inside your controller:

class ProjectsController < ApplicationController
  def create
    @project = Project.new(params)

    @project.validates_with ProjectValidator, { current_user: current_user }
    if @project.save
      # fail, save works because #valid? returned true
    else
      # snap
    end
  end
end

While the errors are added to your instance #valid? will still return true because the errors need to be present when evaluating the validation callback. If you know a working solution which does not require an attr_accessor I’d be grateful for sharing.

© Raphael Randschau 2010 - 2022 | Impressum