Extending Rails’ magic finders

One of the many cool things of Ruby on Rails are magic finders, or Dynamic attribute-based finders as the documentation calls them. Thanks to them we can write:

User.find_by_login_and_status(some_login, 1)

instead of:

User.find(:first, ['login = ? and status = ?', some_login, 1])

The first form is shorter and easier for human to parse.

Unfortunately, we can only combine attributes using and operator, but the possibility to use or and not operators would also be nice. And, as always when using Ruby on Rails, when we find that library is lacking some feature, we can easily add it. Let’s see how to do it.

First, let’s check out how Rails builds its own magic finders. We’ll have to take a look at ActiveRecord’s base.rb that is located somewhere under Ruby gems repository. On my system, the path to this file is

/usr/local/lib/ruby/gems/1.8/gems/activerecord-1.15.3/lib/active_record/base.rb

In this file, as you have probably guessed by now, we have to locate method called method_missing that is responsible for doing the machinations behind the curtains.

At first glance it may seem like we have to replace the method completely. But after some examination I found out that it delegates all the hard work to a handful of helper methods and I might be able to add or and not operator support replacing some of them.

The code

Following is a solution that works for me. It needs to be in some file that is run at application startup, e.g. environment.rb. It may have some bugs, because I tested it only on a couple of examples, so use it at your own risk. Look below for explanations.

module ActiveRecord 
  class << Base 
    def extract_attribute_names_from_match(match) 
      match.captures.last.split(/_(and|or)_|_?(not)_/).grep(/./) 
    end 
    def all_attributes_exists?(attribute_names) 
      attribute_names.all? { |name| 
        column_methods_hash.include?(name.to_sym) or 
          %w(and or not).include? name 
      } 
    end 
    def construct_attributes_from_arguments(attribute_names, arguments) 
      parens = 0 
      sql = "" 
      attribute_names.each_with_index do |a, i| 
        case a 
        when 'and', 'or' 
          sql << " #{a} (" 
          parens += 1 
        when 'not' 
          sql << "#{a} (" 
          parens += 1 
        else 
          sql << "#{a} #{attribute_condition arguments[i - parens]}" 
        end 
      end 
      sql << ")" * parens 
      attribute_names.reject! {|a| %w(and or not).include? a} 
      sanitize_sql([sql] + expand_range_bind_variables(arguments.dup)) 
    end 
  end 
end

The explanations

If you’re not familiar with advanced Ruby syntax quirks, you probably wonder WTF is this class << Base thing. Well, it’s just a shortcut that lets us define several class methods without writing def self.method_name in each method.

Next comes extract_attribute_names_from_match that does exactly what its name says. Original version just splits the method name on and‘s and removes them. We want to split also on or and not and keep the operators so we can use them later. The grep part is needed, because there will be empty entries on list if there are two operators next to each other (e.g. ..._and_not_...).

The method all_attributes_exists? is used to decide whether the finder was called with real attributes’ names. If not, exception is thrown. Since in our version attribute_names array contains also the logical operations, we have to take care of them here too.

Last method and the biggest one is construct_attributes_from_arguments. Original method just pairs up each attribute name with corresponding argument and returns them in a hash, but this is not a good solution for us, because all the hash pairs would be combined with and‘s and this is not what we want. Instead we construct an SQL query string and combine it with arguments using sanitize_sql. We also remove all the operators from the attribute_names array since method_missing depends on its size.

The query is built in such a way that operator precedence is forced with parentheses. I think that it’s better than depending on not always clear logical operator precedence without parentheses. So find_by_x_and_y_or_z becomes x = ? and (y = ? or (z = ?)).

And that’s all! Wasn’t that bad, was it? Pity that the method_missing in ActiveRecord::Base was not designed open for extending. We wouldn’t have to do so much hacking then.

Post scriptum

Caveat emptor: this breaks the find_or_create_by_... finders. I found that just after I finished this article and was ready to post it. This proves that there is never enough testing. The problem seems to be caused by our SQL query — find_or_create expects hash instead, just as in the original version. The hash is needed to set object’s attributes if it was not found.

I don’t think this is a big problem as these methods are not used very often (I’ve never used them). To have or and not support in finders and also have find_or_create working would require several additional hacks or just rewriting the whole thing from scratch. If you manage to do it I’d be glad to hear about it.

About these ads

One response to “Extending Rails’ magic finders

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Follow

Get every new post delivered to your Inbox.

%d bloggers like this: