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

15 Comments

  1. Dr Nic

    That’s very nice.

    I guess the alternate is to use association proxy methods:

    class User
      has_many :whispers, :foreign_key => 'recipient_id' do
        def from(sender)
          find(:all, :conditions => ['sender_id => ?', sender]
        end
      end
    end

    One bonus of this is you still have the User#whispers association for general purpose whispering.

  2. Dr Nic

    Oh, and to call this method you’d use:

    user.whispers.from(bob)

  3. Dr Nic

    For other commenters, <code> works, <pre> doesn’t work :)

  4. duncanbeevers

    This technique was developed specifically to address shortcomings in association extensions like the #find you present.

    Given both implementations, getting the count of the number of whispers from a given user generates markedly different queries.

    class Whisper < ActiveRecord::Base
    end
     
    class User < ActiveRecord::Base
      has_many :whispers, :foreign_key => 'recipient_id' do
        def from(sender)
          find(:all, :conditions => [ 'sender_id = ?', sender.id ] )
        end
      end
     
      has_many_with_args :whispers_from, :sender,
        :class_name => 'Whisper',
        :foreign_key => 'recipient_id',
        :conditions => 'sender_id = #{sender.id}'
     
    end
     
    user1.whispers.from(user2).size
    # Generates the SQL
    # SELECT * FROM `whispers` WHERE (`whispers`.recipient_id = 1 AND (sender_id = 2))
     
    user1.whispers_from(user2).size
    # SELECT count(*) AS count_all FROM `whispers` WHERE (`whispers`.recipient_id = 1 AND (sender_id = 2))
    # The database tells us how many Whispers are in this association

    In the first example, all Whispers in the association are instantiated (possibly triggering after_initialize blocks), and then the size of the array they live in is returned. Not the most efficient.

    The second example makes use of the built-in association size method, automatically inhereted by has_many_with_args associations.

    And pushing it a little further:

    user1.whispers_from(user2).find(:all, :conditions => [ 'flagged = ?', true ] )
    # SELECT * FROM `whispers` WHERE (`whispers`.recipient_id = 1 AND (sender_id = 2) AND (flagged = 1))

    You’re free to utilize the association just as you would any other AssociationProxy, including writing your own association extensions on top of the has_many_with_args

  5. Thibaud Guillaume-Gentil

    I like your alternate solution with association proxy methods Dr Nic, but how can you do if you want include this association into a find, like: User.find(:all, :include => :whispers)? You can’t add the from method to the find anymore. Do you have another solution? Because I’m really need this to a pretty complicated find and I don’t want use find_by_sql :-)

  6. Alex Le

    I think I found the most elegant solution to this issue (e.g. no hacking or writing custom association). I was hacking the acts_as_taggable_on_steroids plugin to support different categories for tags and taggings and I bumped right into this issue.

    Now that I have a tag with many taggings and they all have to belongs to the same category as the current tag. How do we do this thru the has_many … :conditions?

    I took the hint from this post to use single quote on the :conditions clause to delay the evaluation of the condition till executing time.

    This is the original declaration for the Tag model

    class Tag < ActiveRecord::Base
      has_many :taggings
    end

    The modified version with dynamic condition is

    class Tag < ActiveRecord::Base
      has_many :taggings, :conditions => '#{Tagging.table_name}.category_id = #{self.send(:category_id)}'
    end

    Notice the single quote for the conditions, and I used #{self.send(:category_id)} to tell the Tag instance, at runtime, to dynamically invoke the cateogory_id attribute reader. A quick glance to check on the tail log for development.log confirmed that the query was indeed evaluated at runtime!

    No more hacking just pure coding fun!

    BTW: I’m running Rails 1.2.5 still.

  7. Pat Maddox

    By far the most elegant solution I’ve seen is has_finder (which I hear has also been accepted as a Rails patch, though under a different name). It generates a has_many with arguments, but it uses an association proxy. This means that you can use all the methods available on the original class, or even chain finders. It will make your life better.

  8. duncanbeevers

    Pat, has_finder looks terrific.

    I worry chaining finders togethers like that could pretty easily generate queries that don’t make the best use of mysql’s indices, but nothing deliberate use wouldn’t ease.

    The ability to generate these kinds of finders with the lambda syntax in has_finder looks really nice as well. I’ll have to dig through it to see some of the implementation details.
    Thanks for the heads-up!

  9. Kamal Fariz

    Pat, how does has_finder differ from the scope_out plugin? Superficially, it looks exactly the same.

  10. rick

    Just FYI, but has_finder has been approved for inclusion into Rails 2.1. We all think it’s a fine plugin.

  11. Nick Kallen

    Hi, I’m the author of has_finder. It’s much better than scope_out. Scope_out has an old-fashioned API, using nested blocks rather than method chaining:

    Person.greeks { find(:all) } vs. Person.greeks.find(:all)

    Scope_out doesn’t allow composition without a sep declaration, vs. w/ HasFinder it comes for free:

    combined_scope :greek_and_mortal #only necessary for scope_out

    Person.greek_and_mortal { find(:all) } vs. Person.greeks.mortal.find(:all)

    This is compelling when you have lots of combinations of scopes.

    scope_out doesn’t work like a proxy:

    Person.find_greek_and_mortal.each { … } and Person.calculate_greek_and_mortal(:count, …) vs.
    Person.greeks.mortals.each { … }, Person.count Person.greeks.mortals.count

    The lambda form in has_finder is very useful:

    Person.gendered(:male).with_hair_color(:purple).count

    It works for free with has_many. With scope_out, you have to explicitly say:

    class Army
      has_many :people, :extend =&gt; Person::AssociationMethods
    end

    With HasFinder that comes for free:

    an_army.people.greeks

    Finally, it supports proxy extensions a la:

    has_many :foo do
      def my_custom_method
      end
    end
     
    has_finder :mortals, :conditions =&gt; ... do
      def my_custom_method
      end
    end

    hence: Person.mortals.my_custom_method

    Anyway, hope you enjoy. I believe HasFinder is to be renamed NamedScope; it will be included in Rails 2.1

  12. Kamal Fariz

    Thanks Nick for the in-depth walkthrough of the features and differences to scope_out. Previously I had issues using scope_out with will_paginate where the options were not merged in paginate’s find call. I’m going to try replacing that with has_finder and give it a spin.

  13. Nick Kallen

    I’ve been told Scope_out works with will_paginate now (has_finder always has).

  14. ivo

    class User
      has_many_with_args :whispers_from, :sender,
        :class_name => 'Whisper',
        :foreign_key => 'recipient_id',
        :conditions => 'sender_id = #{sender.id}'
    end

    must be

    class User
      has_many_with_args :whispers_from, :sender,
        :class_name => 'Whisper',
        :foreign_key => 'recipient_id',
        :conditions => "sender_id = #{sender.id}"
    end

  15. duncanbeevers

    ivo, if you read the blog post you’ll see that the use of single-quotes in the conditions is what allows this to work.

    More importantly, has_finder / named_scope provides this functionality in a more flexible way, and is committed to core Rails. So please, use it instead!

Leave a Comment

Enclose code in <code lang="ruby"></code> if you care.
Preview your comment using the button below.