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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module Newton | |
module Validator | |
class RepeatsValidator < ActiveModel::EachValidator | |
def initialize(options) | |
@attributes = options[:attributes] | |
super | |
@how_count = options[:how_count] | |
@upto = options[:upto] | |
end | |
def setup(clazz) | |
clazz.class_eval @attributes.inject("") {|s, attr| s += <<END} | |
def #{attr}_repeats_error? | |
@#{attr}_repeats_error | |
end | |
END | |
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 | |
end | |
end | |
end | |
ActiveModel::Validations.__send__(:include, Newton::Validator) |
No comments:
Post a Comment