Thursday, September 15, 2011

Rails 3 Custom Validator Quandary -- Solution Step One

Remember when I had a Rails 3 custom validator quandary? My bottom-line question was "how should I access a specific error condition, cleanly, in both an action, and a template?" I sketched a few solutions, ranging from checking for a specific error message to providing a function that indicates if the error has occurred.

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.

      def initialize(options)
        @attributes = options[:attributes]
        super
        @how_count = options[:how_count]
        @upto = options[:upto]
      end

The validate_each method uses a saved copy of the "upto" option, runs the "how_count" lambda, and compares the values.

      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.

      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.

No comments: