The semantics of method calls in Ruby are simple:
- Call the named method; or
- If no method exists, call
#method_missing()
instead.
Normally #send()
obeys these rules as well. ActiveRecord association proxies mangle #send()
‘s semantics, however, violating the POLS and potentially breaking your code in the process.
Let’s say we have some monsters:
class Monster < ActiveRecord::Base has_many :friendships has_many :friends, :through => :friendships end class Friendship < ActiveRecord::Base belongs_to :monster belongs_to :friend, :class_name => "Monster" end cookie = Monster.create(:name => "Cookie Monster", :color => "blue") grover = Monster.create(:name => "Grover", :color => "blue") oscar = Monster.create(:name => "Oscar the Grouch", :color => "green") clancy = Monster.create(:name => "Clancy", :color => "green")
Cookie monster, being a personable kinda guy, is friends with all the others:
cookie.friends < < grover << oscar << clancy
Now lets say we want to filter Cookie’s friends by color. Not that we’re prejudiced or anything; we just want to teach the nice kids about GREEN and BLUE.
We could define filters for each color manually:
module ByColor def blue find_all_by_color("blue") end def green find_all_by_color("green") end # ... end class Monster < ActiveRecord::Base has_many :friends, :through => :friendships, :extend => ByColor end cookie.friends.green.map(&:name).inspect # => ["Oscar the Grouch", "Clancy"]
Note that we’ve extended the friends
association with ByColor
.
But that’s a repetitive way of defining ByColor
. Let’s say, for the sake of example, that we decide to use #method_missing()
instead. In this implementation, any undefined zero-arg method call on the association will be treated as a color filter:
module ByColor def method_missing(method_name, *args, &block) if args.empty? find_all_by_color(method_name) else super end end end
Let’s check that our filters still work:
cookie.friends.blue.map(&:name).inspect # => ["Grover"] cookie.friends.green.map(&:name).inspect # => ["Oscar the Grouch", "Clancy"]
And just to be thorough, let’s try using #send()
to do the same thing:
cookie.friends.send(:green).map(&:name).inspect
But instead of getting Oscar and Clancy again, we get an exception!
.../association_proxy.rb:154:in `send': undefined method `blue' for #<#array:0x7f6fcb17fa38> (NoMethodError) from .../association_proxy.rb:154:in `send' from association_extensions.rb:47
I’m not going to dig deep into the ActiveRecord association proxy gymnastics that lead to this error. Suffice to say, ActiveRecord overrides #send()
, and as a result our #method_missing()
is never tried.
How often is this really going to matter? Well, chances are this difference will bite you when you try to test your code. RSpec’s matchers make heavy use of #send()
, under the (reasonable) assumption that it will behave identically to calling the method directly. So where you’re likely to run into this is in specs that seemingly behave differently than your production code. That’s where I ran into it.
The solution is to always define a #send()
when defining #method_missing()
in an association proxy:
module ByColor # ... def send(method_name, *args, &block) method_missing(method_name, *args, &block) rescue NoMethodError => e super end end
This will ensure that #method_missing()
is tried before giving up on a method. Note, the code above is a particularly dumb implementation of #send()
, but it’s sufficient to get our example working again. Your #send()
may need to be smarter.
Oh and while you’re at it, you should probably define your own #respond_to?
for maximum consistency. Make sure it falls back to super
if it doesn’t find the method.
Not sure if it helps, but you might take a look at how ActiveRecord scope is defined. After defining a scope on an AR object, you can use send(:[scope name]) and it works.
http://api.rubyonrails.org/cla…
Yeah, I know how to use scopes 🙂 And in this case a scope would probably be more appropriate. Association extensions are probably better suited to less scope-ish jobs.
Well I'm sure you know how to use scopes, silly. I was suggesting you maybe look at the implementation of a scope to see if it could shed any light on making send work in this example.
Ah. Yes. I am indeed silly for not catching your meaning. Good point.
It's another great example where overriding such sensitive methods like #send or #method_missing is dangerous and you should be extra careful while doing so. Nice catch anyway, thanks for the heads-up 🙂
“…and you should be extra careful when doing so”. Now if only someone would convey that to the ActiveRecord crew…
Yet another “lets hack method_missing in a completely inappropriate way” post… I hope you don't do this in any application you care about. Sorry to sound rude, but posting this kind of stuff creates lots of headaches for those of us who have to deal with code written by those who don't know any better, and found some bad advice online.
Hey Lee I think you've missed the “Let’s say, for the sake of example (..)” part of the post.
As solinic noted, I'm not telling anyone to go override method_missing willy-nilly. This is a PSA that they way ActiveRecord overrides method_missing and send already is subtly broken, so that if anyone is ever banging their head against a wall trying to understand why #send() doesn't behave the same as calling the method directly, they can Google it and hopefully find this and avoid some cranial contusions.
Hey Lee I think you've missed the “Let’s say, for the sake of example (..)” part of the post.
THANKS FOR THE POST.
Great post. Thanks.