Fork me on GitHub
Hoopla! - now with extra whiz-bang home

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
      # UPDATE: all of the above require the following line just like the first example.  Thanks to Duke Jones for pointing this out
      target.send :alias_method_chain, :method_missing, :scope_out_countable
    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.
blog comments powered by Disqus