For now, I want simple boolean methods on the model to encapsulate the error condition. I don't want to write them by hand; I want the validator itself to dictate the interface. For example, if I'm validating an "email" property that can only repeat n times on a given day, I'd like to have an email_repeats_error? method available in the model, returning what we'd expect.
This will be developed in two steps: first, as a simple library (makes tweaking easier, at least for me). Second, as a standalone gem (the way it should be). Naturally, this led to a brief WTF spike as I couldn't figure out how to get the library to expose the validator properly.
Loading the Library
Rails 3.0 changed how libraries are loaded, at least a little bit. I did two things to get things loading from an arbitrarily-named file in the /lib directory. First in my application.rb file, I added the /lib directory to the autoload path.
module RepeatsValidatorWork
class Application < Rails::Application
config.autoload_paths += %W(#{config.root}/lib)
# auto-require; see next code fragment.
# ... etc ...
Second, I require the library manually since... well, I'm not 100% sure I understand why, although this blog entry pointed me in the right direction. For testing purposes, this is fine--it might not be a great idea in general. Once it's in a gem, it won't matter anyway.
config.autoload_paths.each do |path|
Dir["#{path}/*.rb"].each do |file|
puts "Auto-requiring #{file}..."
require file
end
end
The Validator Proper
We'll work a bit backwards to get to the final implementation--starting with the familiar and common, ending with tasty snacks.
I'd like to configure the validator as below. For now, we'll just use a lambda to determine how to count the repeats--in the future we might want shortcuts for common use cases. All mine have been different so far, so a lambda it is. We'll pass it the same things a custom validator normally gets (see next code block).
class Item < ActiveRecord::Base
validates :name,
:presence => true,
:repeats => {
:upto => 2,
:how_count => lambda { |rec, attr, val|
Item.count(:conditions => ['name=?', val])
}
}
end
The validator is an ActiveModel::EachValidator subclass. We'll save the "upto" and "how_count" options in the class's initialize method. Since we'll care which model attributes are using the validator, we'll save that before calling super: Rails initializes that option, and plucks it out again in the base class. If we want 'em, we gotta act fast.
The validate_each method uses a saved copy of the "upto" option, runs the "how_count" lambda, and compares the values.
def initialize(options)
@attributes = options[:attributes]
super
@how_count = options[:how_count]
@upto = options[:upto]
end
def validate_each(record, attr, value)
err = false
if @how_count.call(record, attr, value) >= @upto
record.errors[attr] << (options[:message] \
|| "repeats too much")
err = true
end
record.instance_variable_set \
"@#{attr}_repeats_error", err
end
The last line of the method is important: it sets an instance property to true or false depending on whether or not we had a repeats violation. Now we just need to access that in an attribute-specific method for every attribute that uses this validator.
The final bit of pseudo-magic occurs in our class's setup method. Turns out that Rails validation internals call a setup method in each of the registered validators that defines one. (Discovered while looking through Rails source--I can't emphasize the usefulness of reading through source code.) The argument is the class of the model--if that sounds handy, you're on to something.
The final bit of pseudo-magic occurs in our class's setup method. Turns out that Rails validation internals call a setup method in each of the registered validators that defines one. (Discovered while looking through Rails source--I can't emphasize the usefulness of reading through source code.) The argument is the class of the model--if that sounds handy, you're on to something.
def setup(clazz)
clazz.class_eval @attributes.inject("") {|s, attr| s += <<END
def #{attr}_repeats_error?
@#{attr}_repeats_error
end
END
end
This code is less-dense than it seems (it seems dense to me, I guess). In a nutshell, we're looping over our saved attributes (in @attributes) and using inject to build up a string containing everything between the two upper-case ENDs (except for the closing bracket). (Didn't know that was legal syntax.) If it makes it easier to visualize, think of it like this:
def plain_ol_setup(clazz)
s = ""
@attributes.each do |attr|
s += """
def #{attr}_repeats_error?
@#{attr}_repeats_error
end
"""
end
clazz.class_eval s
end
If it didn't make sense before, does it now? We're adding a method
The only thing left to do is to tell Rails that our validator is alive and we wants me some; this is just a single line at the bottom of our library file. (The validator code above lives in some nested modules.)
ActiveModel::Validations.__send__(:include, Newton::Validator)
Here's the complete code in a gist (also appended at the bottom of the post).
So, does it work?
pry(main)> i = Item.new(:name => 'wat!')
=> #
pry(main)> i.name_repeats_error?
=> nil
Huh? Oh, right--the model hasn't been validated yet. Remember, as it stands right now, the method is added during validation.
(This may be a Bad Idea--it might be better to add the method to the model in some other way, but unless we add it manually, or add it to all our models, create a method our models can use to get the method from the same module our validator is in, or...? this (for now!) seems the way most-tied-to-the-validator, which was one of the things I wanted.)
We'll use create instead for illustration and keep creating items with the same name. Once we have two with the same name, additional items with the same name should have a validation error.
pry(main)> i = Item.create(:name => 'wat!')
=> #<Item id: 7, name: "wat!", ...>
pry(main)> i.name_repeats_error?
=> false
pry(main)> i = Item.create(:name => 'wat!')
=> #<Item id: 8, name: "wat!", ...>
pry(main)> i.name_repeats_error?
=> false
pry(main)> i = Item.create(:name => 'wat!')
=> #<Item id: nil, name: "wat!", ...>
pry(main)> i.name_repeats_error?
=> true
That, as they say, is that.
For now.