The semantics of method calls in Ruby are simple:
- Call the named method; or
- If no method exists, call
#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
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:
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.