Peter Marklund

Peter Marklund's Home

Thu Aug 17 2006 04:17:00 GMT+0000 (Coordinated Universal Time)

Rails Custom Validation

Suppose you have validation code for a certain type of data that you want to reuse across your ActiveRecord models. What is the best approach to doing this in Rails? As you probably know, Rails comes with a number of validation macros such as validates_presence_of, validates_format_of, and validates_uniqueness_of etc. One approach is to write your own validation macro that wraps one of the Rails validation macros. The validates_as_email plugin shows us how:

module ActiveRecord
  module Validations
    module ClassMethods
      def validates_as_email(*attr_names)
        configuration = {
          :message   => 'is an invalid email',
          :with      => RFC822::EmailAddress,
          :allow_nil => true }
        configuration.update(attr_names.pop) if attr_names.last.is_a?(Hash)

        validates_format_of attr_names, configuration
      end
    end
  end
end

The RFC822::EmailAddress constant in the code above is a huge regexp that I'm not going to list here... A macro that wraps validates_format_of works well as long as your validation can be done with a regexp. What about validations that are too unwieldly or impossible to do with a single Regexp? Suppose you want to validate phone numbers to make sure they have 10 digits and only contain the characters 0-9()/-.+. Using validates_format_of as a starting point we could write our own macro:

      def validates_as_phone(*attr_names)
        configuration = {
          :message => 'is an invalid phone number, must contain at least 5 digits, only the following characters are allowed: 0-9/-()+', 
          :on => :save 
        }

        validates_each(attr_names, configuration) do |record, attr_name, value|
          n_digits = value.scan(/[0-9]/).size
          valid_chars = (value =~ /^[+\/\-() 0-9]+$/)
          if !(n_digits > 5 && valid_chars)
            record.errors.add(attr_name, configuration[:message])
          end
        end
      end

In the general case we could reduce duplication by hacking validates_format_of and make its :with option accept Proc objects:

      def validates_format_of(*attr_names)
        configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid], :on => :save, :with => nil }
        configuration.update(attr_names.pop) if attr_names.last.is_a?(Hash)
        
        raise(ArgumentError, 
          "A regular expression or Proc object / method must be supplied as the :with option of the configuration hash") \
            unless (configuration[:with].is_a?(Regexp) || validation_block?(configuration[:with]))

        validates_each(attr_names, configuration) do |record, attr_name, value|
          if validation_block?(configuration[:with])
            value_valid = configuration[:with].send('call', value)
          else
            value_valid = (value.to_s =~ configuration[:with])
          end
          record.errors.add(attr_name, configuration[:message]) unless value_valid
        end
      end
      
      def validation_block?(validation)
        validation.respond_to?("call") && (validation.arity == 1 || validation.arity == -1)
      end

Using our new validates_format_of could look like:

  validates_format_of :phone, :fax, :with => Mcm::Validations.valid_phone


  module Mcm
    class Validations
      def self.valid_phone
        Proc.new do |number|
          return_value = false
          if !number.nil?
            n_digits = number.scan(/[0-9]/).size
            valid_chars = (number =~ /^[+\/\-() 0-9]+$/)
            return_value = (n_digits > 5 && valid_chars)
          end
          return_value
        end
      end    
    end
  end

An issue with this approach is that the validation proc object may want to determine what the error message should be. What we could do is adopt the convention that if the Proc object returns a string, then that is the error message.

After all this touching of interal Rails method (potentially making us vulnerable to upgrades), we arrive at the safer and simpler approach that Rails provides for custom validations, namely to implement the validate method:

  # In the ActiveRecord class to be validated
  def validate
    Mcm::Validations.validate_phone(self, 'phone', 'fax')
  end

  # In lib
  class Mcm::Validations
    def self.validate_phone(model, *attributes)
      error_message = 'is an invalid phone number, must contain at least 5 digits, only the following characters are allowed: 0-9/-()+'
      attributes.each do |attribute|
        model.errors.add(attribute, error_message) unless Mcm::Validations.valid_phone?(model.send(attribute))
      end
    end    

    def self.valid_phone?(number)
      return true if number.nil?

      n_digits = number.scan(/[0-9]/).size
      valid_chars = (number =~ /^[+\/\-() 0-9]+$/)
      return n_digits > 5 && valid_chars
    end  
  end

  # In config/environemnt.rb (not sure why this is needed)
  require 'validations'

We can make the validate method approach more Rails like by using a mixin instead:

  # In the ActiveRecord class to be validated
  def validate
    validate_phone('phone', 'fax')
  end

module Mcm::Validations
  def validate_phone(*attributes)
    error_message = 'is an invalid phone number, must contain at least 5 digits, only the following characters are allowed: 0-9/-()+'
    attributes.each do |attribute|
      self.errors.add(attribute, error_message) unless valid_phone?(self.send(attribute))
    end
  end    

  def valid_phone?(number)
    return true if number.nil?

    n_digits = number.scan(/[0-9]/).size
    valid_chars = (number =~ /^[+\/\-() 0-9]+$/)
    return n_digits > 5 && valid_chars
  end  
end

class ActiveRecord::Base
  include Mcm::Validations
end

Which looks clean and I'm reasonably happy with this last approach. Finally...