Vet
Vet is a simple, lightweight, and ORM/framework-agnostic validation library that validates changes individually rather than atomically.
Instead of running validations altogether, like ActiveRecord or DataMapper, it checks the validity of values before they are applied to your object, keeping the in-memory instance clean. This allows valid changes to be accepted and safely written to a data store even when others are invalid, rather than rejecting every change because one didn't validate. It should work with any attribute that has a getter and setter method, and shouldn't interfere with existing validation libraries.
Installation
Install the gem from rubygems.org:
gem update --system
gem install vet
Basic usage
Simply add the line "include Vet" to any model. Here's an example:
class Mario
include Vet
attr_accessor :lives
attr_accessor :status
attr_accessor :cards
attr_accessor :items
end
From now on, whenever you want to modify an attribute of an instance of that class, do the following:
@instance.vet(attribute_name, new_value, *tests)
Here's an example:
@mario.vet(:lives, 2, :is_an_integer)
When a test needs to accept a parameter, you send the test as an array in the form [test_name, parameter]:
@mario.vet(:lives, 2, :is_an_integer, [:is_in_range, 0..99])
If the new value passes the test and is modified, the name of the attribute is added to the object's vet_modified_attributes array:
@mario.vet(:lives, 2, [:is_instance_of_class, Fixnum], [:is_in_range, 0..99])
puts @mario.vet_modified_attributes # => [:lives]
On the other hand, if the test fails, the error message the test returns will be added to object's vet_errors hash, in an array filed under the name of the attribute:
@mario.vet(:lives, 100, [:is_instance_of_class, Fixnum], [:is_in_range, 0..99])
puts @mario.vet_errors # => {:lives => ["Lives must be between 0 and 99."]}
Lastly, if the new value passes the test but is not modified because it is identical to the old value, both the vet_modified_attributes array and the vet_errors hash will be empty:
@mario.lives = 3
@mario.vet(:lives, 3, [:is_instance_of_class, Fixnum], [:is_in_range, 0..99])
puts @mario.vet_errors # => {}
puts @mario.vet_modified_attributes # => []
Configuration
Defining default tests
Obviously, typing out every test you want to run against an attribute every time it is modified would be repetitive, ugly, and error-prone. Instead, you can define a list of tests to always run against an attribute in the model. Here's an example:
class Mario
include Vet
attr_accessor :lives
attr_accessor :status
attr_accessor :cards
attr_accessor :items
def vet_attribute_tests
{
:lives =>
[
[:is_instance_of_class, Fixnum],
[:is_in_range, 0..99]
],
:status =>
[
[:is_instance_of_class, Symbol],
],
:cards =>
[
[:is_instance_of_class, Array],
[:has_length_in_range, 0..3],
[:only_contains_specified_objects, [:mushroom, :flower, :star]]
],
:items =>
[
[:is_instance_of_class, Array],
[:has_length_in_range, 0..100]
]
}
end
end
Vet will merge the tests passed through the standard vet method call with the ones specified in the model--duplicate tests aren't a problem. There are some tests, like is_identical_to_confirmation, that you will still need to call in-controller because they require dynamic parameters.
Custom attribute names
By default, Vet will try to choose appropriate attribute names for use in error messages by replacing dashes and underscores with spaces and capitalizing them appropriately for the sentence. That said, it isn't perfect, and there will be times where your internal attribute names are different than the public ones. Vet lets you specify custom attribute names that will override the generated ones:
class Mario
include Vet
attr_accessor :lives
attr_accessor :status
attr_accessor :cards
attr_accessor :items
def vet_attribute_names
{
:lives => "Number of lives",
:status => "Status",
:cards => "Card collection",
:items => "Item collection"
}
end
def vet_attribute_tests
{
:lives =>
[
[:is_instance_of_class, Fixnum],
[:is_in_range, 0..99]
],
:status =>
[
[:is_instance_of_class, Symbol],
],
:cards =>
[
[:is_instance_of_class, Array],
[:has_length_in_range, 0..3],
[:only_contains_specified_objects, [:mushroom, :flower, :star]]
],
:items =>
[
[:is_instance_of_class, Array],
[:has_length_in_range, 8..100]
]
}
end
end
@mario = Mario.new()
@mario.vet(:lives, 100, [:is_instance_of_class, Fixnum], [:is_in_range, 0..99])
puts @mario.vet_errors # => {:lives => ["Number of lives must be between 0 and 99."]}
# RATHER THAN: => {:lives => ["Lives must be between 0 and 99."]}
Defining tests
Vet includes a bunch of useful built-in tests that you can check out by looking at the source, but it intentionally doesn't include ORM-specific tests, and there will be other tests that you will need that aren't included. Test definitions are very simple:
def is_not_empty(attribute, new_value)
unless new_value.empty? == false
add_vet_error(attribute, "must not be empty.") # => false
end
end
A test can take 2 or 3 parameters, depending on whether or not it needs to accept a value to test against. For example, here's another test from the source:
def is_equal_to_value(attribute, new_value, good_value)
unless new_value == good_value
add_vet_error(attribute, "must be #{good_value}.") # => false
end
end
If the test fails, it should return add_vet_error(attribute, "must be such and such."), which returns false. If the test passes, it should return anything except false, including nil (there's no need to write "...else return true end").
If you really want to be explicit, you could write that last test as follows:
def is_equal_to_value(attribute, new_value, good_value)
if new_value == good_value
return true
else
add_vet_error(attribute, "must be #{good_value}.") # => false
return false
end
end
Most of this is completely unnecessary--the add_vet_error function automatically returns false, and a nil return (i.e. which would be the case if the shorter version of the test passed), is interpreted as a pass since it isn't equal to false, so there's no need to return true.
Controlling add_vet_error behaviour
add_vet_error will generate errors of the form "#attribute must not be empty", and will try to make sure the capitalization jives. There are options for when you want to override this behaviour:
ATTRIBUTE_NAME
If you put "ATTRIBUTE_NAME" the body of an error, the attribute name will be put there as opposed to at the beginning of it:
# Generates errors like "Mario's lives must be between 0 and 99."
def must_be_between_0_and_99(attribute, new_value)
unless (0..99).include? new_value
add_vet_error(attribute, "Mario's {ATTRIBUTE_NAME} must between 0 and 99.")
end
end
:exclude_attribute_name
If you add the parameter ":exclude_attribute_name" to the end of an add_vet_error call, it won't try to use the attribute name, generated or specified, in the error message, and will spit out the specified error message text verbatim:
# Generates errors like "Mario must have between 0 and 99 lives."
def must_have_between_0_and_99_lives(attribute, new_value)
unless (0..99).include? new_value
add_vet_error(attribute, "Mario must have between 0 and 99 lives.", :exclude_attribute_name)
end
end