Tuesday, 23 October 2007

Extending ActiveRecord::Validations for positive numbers

I noticed I could DRY up a lot of my def validate items by finding a better way to check if a numerical field was a positive number (when provided). I figured this would be a perfect candidate for extending the "validates_numericality_of" method. But then I had a play with extending it and got stuck on "how exactly do you extend a class method?"

I've successfully added new validations by reopening ActiveRecord::Base, but how do you open an exising method and add more bits on?

I tried alising the method... but the question became: how do you alias a method that is defined as self.validates_whatever?

This excellent post describes how to extend class methods by using base.class_eval and putting the alises into class << self

Note: you definitely need to double-alias your function (as in the class extension section below) or you will find yourself instantly spiralling into a "stack level too deep" exception the first time you call it.

Here's how to extend a validation in Rails (includes my extension to validates_numericality_of, and also my preivous validates_percentage method):

module MyNewValidations
  # magic to allow us to override existing validations
  def self.included(base)
    base.extend(ClassMethods)
    base.class_eval do
      class << self
        alias old_numericality_of :validates_numericality_of unless method_defined?(:old_numericality_of)
        alias validates_numericality_of :my_validates_numericality
      end
    end
  end

  module ClassMethods
    # extends the "validates numericality of" validation to allow the option
    # ":positive_only => true" or ":negative_only => true"
    # This will validate to true only if the given number is positive (>= 0)
    # or negative (<= 0) respectively
    # Otherwise is behaves exactly as the standard validation
    def my_validates_numericality(fields, args = {})
      ret = old_numericality_of(fields, args) # first call standard numericality

      pos = args[:positive_only] || false
      neg = args[:negative_only] || false

      if pos || neg
        msg = args[:message] || "should be a #{pos ? 'positive' : 'negative'} number"
        validates_each fields do |model, attr, val|
          if (pos && val.to_f < 0) || (neg && val.to_f > 0)
            model.errors.add attr, msg
            ret = false
          end
        end
      end

      ret
    end

    # validates whether the given object is a percentage
    # Can also take optional args which will get passed verbatim into the
    # validation methods. Thus it's only really safe to use ":allow_nil" and
    # ":message"
    def validates_percentage(fields, args = {})
      msg = args[:message] || "should be a percentage (0-100)"
      validates_each fields do |model, attr, val|
         pre_val = model.send("#{attr}_before_type_cast".to_sym)
         unless val.nil? || !pre_val.is_a?(String) || pre_val =~ /^[-+]?\d+(\.\d*)?%?$/
           model.errors.add(attr, msg)
         end
       end
      args[:message] = msg
      args[:in] = 0..100
      validates_inclusion_of fields, args
    end

  end
end

class ActiveRecord::Base
  # add in the extra validations created above
  include MyNewValidations

  #other stuff I'd defined goes here
end

No comments: