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