Saturday, January 20, 2007

Adding Validations to any Class

On my current project we have data that needs to be collected and then sent to a service. This data needs to be validated, but since it is never put into a model it cannot be validated by using ActiveRecord's validations. To handle this scenario we designed something similar to the module below that can add validations to any class that includes it, which are generally Presenters on my project.
module Validatable
module ClassMethods
def validates_format_of(*args)
validate_all(args) do |attribute, options|
self.validations << ValidatesFormatOf.new(attribute, options[:with], options[:message] || "is invalid")
end
end

def validates_presence_of(*args)
validate_all(args) do |attribute, options|
self.validations << ValidatesPresenceOf.new(attribute, options[:message] || "can't be empty")
end
end

def validate_all(args, &block)
options = args.last.is_a?(Hash) ? args.pop : {}
args.each do |attribute|
yield attribute, options
end
end
protected :validate_all

def validations
@validations ||= []
end

def validate(instance)
self.validations.each do |validation|
instance.errors.add(validation.attribute, validation.message) unless validation.valid?(instance)
end
instance.errors.empty?
end
end

def self.included(klass)
klass.extend Validatable::ClassMethods
end

def valid?
errors.clear
self.class.validate(self)
end

def errors
@errors ||= Validatable::Errors.new
end
end
As you can see I only included the code to expose the validates_presence_of and validates_format_of validations. We actually have quite a bit more, but I think those two are all that's necessary to follow the example. I like the way we designed the validation classes because they can easily be tested and are all treated the same way when it comes time to verify if they are valid or not (the validate method). The code below shows all that is necessary for both the ValidatesPresenceOf and ValidatesFormatOf classes.
module Validatable
class ValidationBase
attr_accessor :message
def initialize(message)
self.message = message
end
end

class ValidatesPresenceOf < ValidationBase
attr_accessor :attribute
def initialize(attribute, message)
self.attribute = attribute
super message
end

def valid?(instance)
(!instance.send(self.attribute).nil? && instance.send(self.attribute).strip.length != 0)
end

end

class ValidatesFormatOf < ValidationBase
attr_accessor :attribute, :regex, :message
def initialize(attribute, regex, message)
self.attribute = attribute
self.regex = regex
super message
end

def valid?(instance)
instance.send(self.attribute) =~ self.regex
end
end
end
The only other piece to the puzzle is the errors collection. The code for this is also very straightforward.
module Validatable
class Errors
extend Forwardable

def_delegators :@errors, :empty?, :clear

def on(attribute)
@errors[attribute.to_sym]
end

def add(attribute, message)
@errors[attribute.to_sym] = message
end

def initialize
@errors = {}
end
end
end
Another important thing to note is that these validations integrate directly with the ActionView::Helpers::ActiveRecordHelper.error_message_on method. This means the same code in the view that displays ActiveRecord errors can also display errors from any object that includes Validatable.
<%= error_message_on :presenter, :name %>
And, here's a few tests if you want to ensure that it works as expected.
class ValidatableTest < Test::Unit::TestCase
test "given no presence when object is validated then valid returns false" do
klass = Class.new
klass.class_eval do
include Validatable
attr_accessor :name
validates_presence_of :name
end

assert_equal false, klass.new.valid?
end

test "given no presence when object is validated then it contains errors" do
klass = Class.new
klass.class_eval do
include Validatable
attr_accessor :name
validates_presence_of :name
end
instance = klass.new
instance.valid?
assert_equal "can't be empty", instance.errors.on(:name)
end

test "given invalid format when object is validated then valid returns false" do
klass = Class.new
klass.class_eval do
include Validatable
attr_accessor :name
validates_format_of :name, :with=>/.+/
end

assert_equal false, klass.new.valid?
end

test "given invalid format when object is validated then it contain errors" do
klass = Class.new
klass.class_eval do
include Validatable
attr_accessor :name
validates_format_of :name, :with=>/.+/
end
instance = klass.new
instance.valid?
assert_equal "is invalid", instance.errors.on(:name)
end

test "given valid data after it is previously invalid when object is validated then it is valid" do
klass = Class.new
klass.class_eval do
include Validatable
attr_accessor :name
validates_format_of :name, :with=>/.+/
end
instance = klass.new
assert_equal false, instance.valid?
instance.name = "Jay"
assert_equal true, instance.valid?
end
end

6 comments:

  1. Marc Beyerlin7:11 PM

    Hey Jay,
    you could also (mis)use ActiveRecords validations. We call it table-less model. Have a look at this example:

    class Foo < ActiveRecord::Base

    validates_presence_of :some_attribute

    def self.columns()
    @columns ||= [];
    end

    def self.column(name, sql_type = nil, default = nil, null = true)
    columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null)
    end

    def save(validate = true)
    validate ? valid? : true
    end

    column :name, :string

    end

    ReplyDelete
  2. Marc,
    We could have gone down that route. Thanks for the example, hopefully it will be exactly what someone needs. We actually needed additional parameters to the validations for other business reasons, so we couldn't have used the AR validations. I plan on releasing a gem on rubyforge that has validations with additional features. When it's ready I'll drop an announcement here.

    Thanks for the note.

    ReplyDelete
  3. Jay, Given the following code

    require 'Validatable'

    class X
    include Validatable
    attr_accessor :name
    validates_presence_of :name
    end

    x = X.new
    puts x.valid?
    x.name = "hello"
    puts x.valid?

    what is printed is "false" for both of the puts even though it is valid after the assignment of name. If you remove the first check for valid by say commenting the second one will print true.

    Thoughts?

    Scott LaBounty

    ReplyDelete
  4. Scott,
    Thanks, I updated the example code. I was missing the call to errors.clear

    ReplyDelete
  5. Here's the diff of changes

    Index: test/validatable_test.rb
    ===================================================================
    --- test/validatable_test.rb (revision 3)
    +++ test/validatable_test.rb (working copy)
    @@ -46,4 +46,18 @@
    instance.valid?
    assert_equal "is invalid", instance.errors.on(:name)
    end
    +
    + test "given valid data after it is previously invalid when object is validated then it is valid" do
    + klass = Class.new
    + klass.class_eval do
    + include Validatable
    + attr_accessor :name
    + validates_format_of :name, :with=>/.+/
    + end
    + instance = klass.new
    + assert_equal false, instance.valid?
    + instance.name = "Jay"
    + assert_equal true, instance.valid?
    + end
    +
    end
    \ No newline at end of file
    Index: lib/base.rb
    ===================================================================
    --- lib/base.rb (revision 3)
    +++ lib/base.rb (working copy)
    @@ -37,6 +37,7 @@
    end

    def valid?
    + errors.clear
    self.class.validate(self)
    end

    Index: lib/errors.rb
    ===================================================================
    --- lib/errors.rb (revision 3)
    +++ lib/errors.rb (working copy)
    @@ -2,7 +2,7 @@
    class Errors
    extend Forwardable

    - def_delegators :@errors, :empty?
    + def_delegators :@errors, :empty?, :clear

    def on(attribute)
    @errors[attribute.to_sym]

    ReplyDelete
  6. Jay,

    Looks good now. Thanks for the quick response and for the interesting code.

    Scott LaBounty

    ReplyDelete

Note: Only a member of this blog may post a comment.