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