Peter Marklund

Peter Marklund's Home

Thu August 17, 2006
Programming

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

Comments

evan said over 8 years ago:

Thanks! Needed this information today and your explanations are good.

--------------------------------------------------------------------------------

Jesse Clark said over 7 years ago:

Nice explanation. Thanks.

I am not exactly clear on where in my rails app to add this bit though:


class ActiveRecord::Base
  include Mcm::Validations
end

I tried adding it at the bottom of the validations.rb file I created in /lib but that didn’t seem to do it..

Also just a minor note on the validation:

Adding a constant to the Module for the number of digits allowed makes the final solution very slightly DRYer. Although, it might be nice to add this as a parameter with a default value. This would give the calling code the flexibility of deciding how many digits to require.

--------------------------------------------------------------------------------

Jesse Clark said over 7 years ago:

I was able to get the final listing working by still adding "require ‘validations’ to environment.rb but I had assumed that part of the point of mixing in to ActiveRecord::Base was that this would be unnecessary…?

--------------------------------------------------------------------------------

Peter Marklund said over 7 years ago:

Jesse,
no, you shouldn’t have to require the validation, Rails will automatically load it for you when the Module is first encountered (since the lib dir is in the load path). Just make sure your file has the same name as the module, and also that any namespace has a corresponding directory. In my case the Mcm::Validations module should be in the file lib/mcm/validations.rb

--------------------------------------------------------------------------------

Serene said over 7 years ago:

Hi,

I am learning Ruby on Rails.

I have question/s on creating my own validation rules.

1. Can it be written in this manner?

class Group < ActiveRecord::Base
# Table has id, name, parent_id, created_at

# Prevent user from deleting a group when it is a parent of another group/s
def validate
errors.add_to_base("Cannot delete #{@name} as parent of another group/s" if find_by_parent_id(@id)
end
end

2. How do I access group's attributes within itself (i.e. group)? By @id, @parent_id? Or, by group.id, group.parent_id?

3. What if my validation in 'group' also involved accessing another table? How do I access from, say user, from group model?

4. Could you recommend some websites where I could have access to good documentation and tips?

I would appreciate very much if you could assist. I have been looking around for quite a long time for answer. Hope to hear from you. Thanks.

--------------------------------------------------------------------------------

Jeff Wattenmaker said over 7 years ago:

got it to work, thanks
to allow blanks I had to add the following

return true if number.nil?
return true if number.size == 0

for some reason my number was size 0, not nil
also the error message says it number must be 5, yet it uses >5 which means 6.
I changed it to >= 5 and not it matches the error message

--------------------------------------------------------------------------------

velu said over 6 years ago:

i do not able to understand

--------------------------------------------------------------------------------

fgfd said over 6 years ago:

gdfg

--------------------------------------------------------------------------------

Armen said over 6 years ago:

barev Artur

--------------------------------------------------------------------------------

Armen said over 6 years ago:

heriqa nayes

--------------------------------------------------------------------------------

Arthur said over 6 years ago:

barev inch chka

--------------------------------------------------------------------------------

Karol said over 6 years ago:

Thanks a lot

--------------------------------------------------------------------------------

Matt said over 6 years ago:

Good stuff, thanks!

--------------------------------------------------------------------------------

julie said over 6 years ago:

Very nice information

--------------------------------------------------------------------------------

Harsha vaiashnav said over 6 years ago:

Good information,,,thanks a lot

--------------------------------------------------------------------------------

a.non said over 5 years ago:

The question Serene asked pretty much cover what i don't understand too :/

--------------------------------------------------------------------------------

ashu said over 4 years ago:

i need help to create a validtaion on the range of seat numbers of bookings of a show that new entry does not overlap on it can you help on that i am a fresher

--------------------------------------------------------------------------------

poti said over 4 years ago:

Apparently you wouldn't need to include the "require" call if you add a folder named "mcm" inside your "lib".

So the file needs to be in lib/mcm/validations.rb

in order for rails to automagically load it

--------------------------------------------------------------------------------