The semantics of method calls in Ruby are simple:

  1. Call the named method; or
  2. 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.

Published by Avdi Grimm

12 Comments

  1. 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

    Reply
  2. 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 🙂

    Reply
  3. 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.

    Reply
    • Hey Lee I think you've missed the “Let’s say, for the sake of example (..)” part of the post.

      Reply
    • 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.

      Reply
  4. Hey Lee I think you've missed the “Let’s say, for the sake of example (..)” part of the post.

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *