Counting with scope_out (or: extending Rails)

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:

  • I can submit a patch to the scope_out project to get my favorite method added to the code.
  • I can manually edit my installation of the plugin.
  • I can add a method_missing to ActiveRecord::Base that intercepts count_* and reformats the call to be used with scope_out
  • 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
          # 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.

  • Ted said: Brilliant and elegant. I think you hit just about every "best practice" in the book. When shall I expect the short vidcast tutorials to begin? Ryan needs some competition now that he's cutting back on the frequency of releases ;-)
  • Dr Nic said: I think you should also do #1 (patch the plugin) - #count_rated seems to be expected syntax to support, just as #find_rated is supported. Perhaps you can blog about this too to increase your "post with some guts" count :)
  • Jack Danger said: Dr. Nic, I accept your challenge!
  • Duke Jones said: After the initial example introducing the alias_method_chain, I don't see that call again. Does the module form not require alias_method_chain? I also implemented something similar to scope_out, but when I saw how awesome scope_out is, I scrapped my code and just iterated over my constants, calling scope_out on each. def included(base) base.send(:include, ScopeOut) self::STATUS.each do |status, status_value| base.send(:scope_out, status, :conditions => ["status = ?", status_value]) base.send(:scope_out, "not_#{status}".to_sym, :conditions => ["status != ?", status_value]) end end This works well, but I'd be much happier just calling "include ScopeOut" and the rest in a module/class instead of doing all this base.send stuff. Any suggestions? class<
  • Duke Jones said: After the initial example introducing the alias_method_chain, I don't see that call again. Does the module form not require alias_method_chain? I also implemented something similar to scope_out, but when I saw how awesome scope_out is, I scrapped my code and just iterated over my constants, calling scope_out on each. def included(base) base.send(:include, ScopeOut) self::STATUS.each do |status, status_value| base.send(:scope_out, status, :conditions => ["status = ?", status_value]) base.send(:scope_out, "not_#{status}".to_sym, :conditions => ["status != ?", status_value]) end end This works well, but I'd be much happier just calling "include ScopeOut" and the rest in a module/class instead of doing all this base.send stuff. Any suggestions? class<
  • Jack Danger said: Thanks for pointing that out Duke - I've updated the article to show the need for alias_method_chain (though I didn't test the code). I'm not sure how to simplify the code you put together (sorry for the total lack of formatting. blech.) You might want to try base.class_eval if you're just looking to cleanup the self.included method.
blog comments powered by Disqus