Update → has_finder accomplishes everything has_many_with_args does in an elegant and extensible fashion. As it’s slated for inclusion in Rails 2.1, I thought I’d describe how to accomplish the scoped whispers example presented in this post using has_finder.
Read the solution here.
Rails Associations are powerful tools, but sometimes they can be a bit over-reaching.
Let’s examine a hypothetical has_many association and how it would traditionally be managed.
class User
has_many :sent_whispers, :class_name => 'Whisper', :foreign_key => 'sender_id'
has_many :received_whispers, :class_name => 'Whisper', :foreign_key => 'recipient_id'
end
class Whisper
belongs_to :sender, :class_name => 'User'
belongs_to :recipient, :class_name => 'User'
end
Now, if you’d like to scope down received whispers to just those sent by a specific user, you’d ordinarily probably use something along these lines.
def whispers_from sender
with_scope( :find => { :conditions => [ 'sender_id = ?', sender.id ] } ) do
received_whispers.find(:all)
end
end
Unfortunately, this is pretty limiting. For example, any association extensions defined on :received_whispers are unavailable when accessing whispers_from. We also lose out on all the association calculation methods and can no longer use << to insert new elements directly into the association.
We could get the type of focused behavior we wanted by adding a conditions clause to the association.
has_many :whispers_from,
:class_name => 'Whisper',
:foreign_key => 'recipient_id',
:conditions => [ 'sender_id = ?', sender.id ]
This won’t work either! sender isn’t defined in the class scope as the association is being created, so the conditions clause is at a loss. But all is not lost! A poorly-documented feature of the association methods is the lazy evaluation of conditions.
has_many :whispers_from,
:class_name => 'Whisper',
:foreign_key => 'recipient_id',
:conditions => 'sender_id = #{sender.id}'
Notice that the conditions clause is singly-quoted. When the conditions clause is evaulated, the sender method will be called on the User. Better, but still not much use to us, as the User doesn’t have a sender method. Well, what if sender were an ordinary attr_reader on the User? If we could get @sender set prior to the evaluation of the association conditions, we’d be set!
Okay, we can do this!
I whipped up has_many_with_args (suggestions for a better name?) which allows you to create an association with teeth!
class User
has_many_with_args :whispers_from, :sender,
:class_name => 'Whisper',
:foreign_key => 'recipient_id',
:conditions => 'sender_id = #{sender.id}'
end
And that’s it! Now you can use all the regular has_many association methods, calculation methods, and your own association extensions. Boss!
alice = User.find(1)
bob = User.find(2)
alice.whispers_from(bob).count # SELECT COUNT(*) FROM users WHERE recipient_id=1 AND sender_id=2
It’s all packaged up as a plugin for you. Just install using piston:
piston import http://hasmanywithargs.googlecode.com/svn/trunk/ vendor/plugins/has_many_with_args
For the brave and curious, have a look at the source to see how the attr_readers are set up and initialized when the association is accessed.