Rails Association Proxies and Caching

I had the privilege of attending the Pragmatic Studio Advanced Rails Studio in Chicago this week. One of the topics covered was Association Proxies, and various uses for them. I was a little suprised at how few people knew about them. This will be a quick introduction to the feature and a discussion of the tradeoffs involved in using them.

Briefly, Association Proxies are functions defined on an association. For example, in a library example, a Patron might have many books. This would look like

class Patron < ActiveRecord::Base
  has_many :books
end

That’s pretty simple. Each book probably has a due date (I’m purposefully being simple, this is not an indication of my data modeling skills.) To look for all of a patron’s overdue books, a new rails user might right

  Book.find(:all,:conditions=>["patron_id=? and due_date<?",patron.id,Time.now])

That works, but a much better solution would be to use attribute finders and write:

  patron.books.find(:all,:conditions=>["due_date<?",Time.now])

Of course, writing that everywhere we need to get a list of past due books is painful. It would be nice if we could make that an association. And many people do. In fact, it isn’t unusual to see code that looks like:

class Patron < ActiveRecord::Base
  has_many :books
  has_many :overdue_books, :class=>"Book", :foreign_key=>:book_id, :conditions=>["due_date<?",Time.now]
end

This is still pretty ugly, and has a lot of duplication. It would be much cleaner if we could get by with just one association. Luckily, you can. You can pass a block to an association, which defines functions on the association. For example:

class Patron < ActiveRecord::Base
  has_many :books do
    def overdue
      find(:all,:conditions=>["due_date<?",Time.now]
    end
  end
end

To call, this, we simply use patron.books.overdue. Isn’t that cleaner? There are a couple of downsides. First, it isn’t cached. By default, active record only load associations the first time you use them. After that, you can reload them by passing true to the association; for example: patron.books(true). Luckily, this is easy to overcome. We can cache our overdue function and provide the same functionality. For example:

class Patron < ActiveRecord::Base
  has_many :books do
    def overdue(reload=false)
      @overdue_books = nil if reload
      @overdue_books ||= find(:all,:conditions=>["due_date<?",Time.now]
    end
  end
end

This gets us caching with just an additional line of code! That overcomes the only big downside we’ve run into. There are several others, like lack of counter caches, but for the most part, this solution can help you clean up your code

We also use this style of coding on our :through associations. If we have a user who has many magazines through subscriptions, and we want to get some information about a particular subscription, we will often define a relationship like:

class User < ActiveRecord::Base
  has_many :subscriptions do
    def for(magazine)
      find_by_magazine_id(magazine.id)
    end
  end
  has_many :magazines, :through=>:subscriptions
end

We think that calls like user.subscriptions.for(business_week) make our code readable