Hoopla!

now with extra whiz-bang!

Hoopla!

Counting with scope_out (or: extending Rails)

August 08, 2007 · 3 comments

I figure it’s time I write a post with some guts to it. One thing I keep hearing people ask about is how to write code that extends Rails. Most of the books and tutorials out there (RailsCasts and PeepCode excluded) focus on getting you up and running but don’t show you how to really master your application.

So for folks who want to get better at Rails here’s a not uncommon situation involving external libraries and how to extend them cleanly. Using this technique you can easily add any kind of functionality to your program.

I’m finally using scope_out in an app and so far I’m loving it. I’d written some code that acted similarly to the scope_out plugin but it was not nearly as good. I’ve decided to replace that code with the plugin because the smaller I can get my own application code the better.

Scope out lets you define extensions to your normal finders. For example:

class Book < ActiveRecord::Base
  scope_out :rated, :conditions => 'rating is not null'
end
That gives you:

Book.find_rated(:all) # == Book.find(:all, :conditions => 'rating is not null')
Book.calculate_rated(:count, :all) # == Book.calculate(:count, :all, :conditions => 'rating is not null')

Which is pretty handy. In fact it’s beautiful. It’s so good that Rick Olson even recommends it over his acts_as_paranoid plugin.

But my old code had the ability to count records like:

Book.count_rated
. I find myself doing this all the time. I suppose I could just replace my calls to
Book.count_rated
with
Book.calculate_rated(:count, :all)
but that would add an ugly level of complexity.

As I see it I have some options for how to get the counting functionality back in my app:
  1. I can submit a patch to the scope_out project to get my favorite method added to the code.
  2. I can manually edit my installation of the plugin.
  3. I can add a method_missing to ActiveRecord::Base that intercepts count_* and reformats the call to be used with scope_out
  4. I can add an extension to the scope_out plugin under my /lib directory that modifies the plugin without having to actually modify the plugin’s installed files.

So which is best?

#1 is the best idea but it’ll take a while and they might not accept it. Oddly enough I’m too lazy to submit a patch but I found time for this blog post.
#2 is a bad idea. I don’t want to support this plugin myself – I want the folks upstream to do all the work for me.
#3 might depend on the scope_out plugin depending how I write it. It seems like a clean solution otherwise.
#4 Kinda like #2 in that I’ll have to make sure my code is working fine every time I update the scope_out plugin. Too much work.

I’m going to go with #3. It’s the cleanest overall and will cause the fewest headaches down the road.

This kind of code could be made into a plugin but since I don’t plan to use this in any other apps yet I should start with the simplest option – which is to create a new file under ./lib and require it in my environment.rb file.


$ touch ./lib/scoped_count_extension.rb
$ echo "require 'scoped_count_extension'" >> ./config/envirionment.rb

Crafting the Ruby

If I wanted to be a total bum I could just do it like this:

class ActiveRecord::Base
  def count_rated(*args)
    calculate_rated(:count, :all, *args)
  end
end
and that would kinda sorta solve my problem. Technically it would work and I could now use Book.count_rated(). But it would only work for the rated_scope – for any other scope I’d have to duplicate this code. And every agile developer I know would pummel me if I did that. So I’m going to have to use method_missing.

class ActiveRecord::Base
  def method_missing(method_called, *args, &block)
    match = method_called.to_s.match(/^count_(\w+)$/)
    if match
      send "calculate_#{match[1]}", :count, :all, *args
    end
  end
end

Hmmm, it’s working but I think I’d rather only do the count -> calculate replacement if I know that with_scope is going to catch it. So I should check for with_rating or something like it because that’s a method that scope_out provides.


class ActiveRecord::Base
  def method_missing(method_called, *args, &block)
    match = method_called.to_s.match(/^count_(\w+)$/)
    if match && respond_to?("with_#{match[1]}")
      send "calculate_#{match[1]}", :count, :all, *args
    end
  end
end

There. That means that anytime a method gets called that looks like ‘count_something’ on any model class and there’s a ‘with_something’ defined (which means that scope_out is applied to this ‘something’) it’ll pass it along to calculate_something(:count, :all). The match() method is just performing a simple regular expression so I can capture the bit that comes after count_ and put calculate_ before it.

But now I’ve got a serious problem. Method_missing is a really handy method and it’s already used by ActiveRecord pretty heavily. So all I’ve really managed to do is break Rails. Things like Book.find_by_title rely on method_missing and I’d like to keep that behavior.

Thankfully Rails provides us with an incredibly useful tool called alias_method_chain. It’s a way to chain together a bunch of methods that all have the same name. As long as each one has a unique identifier they won’t clobber each other.

So I’ll alias_method_chain my method_missing:

class ActiveRecord::Base
  def method_missing_with_scope_out_countable(method_called, *args, &block)
    match = method_called.to_s.match(/^count_(\w+)$/)
    if match && respond_to?("with_#{match[1]}")
      send "calculate_#{match[1]}", :count, :all, *args
    else
      method_missing_without_scope_out_countable(method_called, *args, &block)
    end
  end
  alias_method_chain :method_missing, :scope_out_countable
end

Much better. Now my code can work right alongside the rest of the Rails source. There’s just one more ‘gotcha’: this code reopens ActiveRecord::Base and just adds itself in there. This is affectionately called “monkey-patching” and it’s frowned upon when it can be avoided. So I’m going to rearrange this code to make it kinder to the class it’s modifying.

First I move this extended method_missing into a module of it’s own:

module ScopeOutCountable
  def method_missing_with_scope_out_countable(method_called, *args, &block)
    match = method_called.to_s.match(/^count_(\w+)$/)
    if match && respond_to?("with_#{match[1]}")
      send "calculate_#{match[1]}", :count, :all, *args
    else
      method_missing_without_scope_out_countable(method_called, *args, &block)
    end
  end
end

class ActiveRecord::Base
end
Then I move just the the stuff that I want to become part of ActiveRecord::Base into a submodule of it’s own:

module ScopeOutCountable
  module ClassMethods
    def method_missing_with_scope_out_countable(method_called, *args, &block)
      match = method_called.to_s.match(/^count_(\w+)$/)
      if match && respond_to?("with_#{match[1]}")
        send "calculate_#{match[1]}", :count, :all, *args
      else
        method_missing_without_scope_out_countable(method_called, *args, &block)
      end
    end
  end
end

class ActiveRecord::Base
end
Next I go ahead and include the top-level module into ActiveRecord:

module ScopeOutCountable
  module ClassMethods
    def method_missing_with_scope_out_countable(method_called, *args, &block)
      match = method_str.match(/^count_(\w+)$/)
      if match && respond_to?("with_#{match[1]}")
        send "calculate_#{match[1]}", :count, :all, *args
      else
        method_missing_without_scope_out_countable(method_called, *args, &block)
      end
    end
  end
end

class ActiveRecord::Base
end
Now, to get it actually working I’ll need to add an install hook that connects my method_missing to ActiveRecord::Base

module ScopeOutCountable

  # included() gets executed anytime this module is included into an object.  The object is passed as an argument to included().
  class << self
    def included(target)
      target.extend ClassMethods
    end
  end

  module ClassMethods
    def method_missing_with_scope_out_countable(method_called, *args, &block)
      match = method_str.match(/^count_(\w+)$/)
      if match && respond_to?("with_#{match[1]}")
        send "calculate_#{match[1]}", :count, :all, *args
      else
        method_missing_without_scope_out_countable(method_called, *args, &block)
      end
    end
  end
end

class ActiveRecord::Base
  include ScopeOutCountable
end

There. I’ve now got my custom method_missing call hooked up to a chain of other method_missings within ActiveRecord::Base. I can use it as simply as this:


Book.find_rated(:all) # == Book.find(:all, :conditions => 'rating is not null')
Book.calculate_rated(:count, :all) # == Book.calculate(:count, :all, :conditions => 'rating is not null')
Book.count_rated # == Book.count(:conditions => 'rating is not null')
And for those who are curious how this might be really easily turned into a plugin:

$ ruby ./script/generate plugin scope_out_countable
$ mv ./lib/scope_out_countable.rb /vendor/plugins/scope_out_countable/lib/scope_out_countable.rb
$ echo "require 'scope_out_countable'" > /vendor/plugins/scope_out_countable/init.rb
$ sed -i "s/require 'scope_out_countable'//" ./config/environment.rb # this is a lazy way of deleting the require line from environment.rb

Update: If anyone can explain to me why passing off to method_missing_without_scope_out_countable would completely bypass scope_out’s method_missing I’d love to hear about it.

→ 3 comments Tags: