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])
Isn't that better? 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 simple 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
That's it for today. One of us will be writing an article a week for the next few weeks, so keep tuned.
Posted by Mike Mangino on Friday, March 16, 2007