Peter Marklund's Home |
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...