In part 1 I covered the basics of how to set up a has_many :through
association and simple queries using the default accessors. In this installment, I'll show how setting up association extensions can make using through associations easier.
If you recall from last time, I created some model classes to represent books, the people who contributed to them, and the nature of their contribution. Now I'm going to enhance those models with association extensions, one of the niftier features in Rails 1.1.
First I'll add some more information to the join model. I'll include some information about the kind of contribution made to the book. I'm not going to get cute and fake up the migration code here, as that's not the point of this article. Here's the generic schema definition with the new data added.
create_table "contributions" do |t|
t.column "book_id", :integer
t.column "contributor_id", :integer
t.column "royalty", :float
t.column "role", :string
end
I've added a role
field to indicate the kind of contribution. It will hold values like "author", "editor", "illustrator", etc. With that addition we could do queries using dynamic finders, like so:
josh = Contributor.find_by_name("Joshua Susser")
books = josh.books.find(:all, :conditions => ["contributions.role = ?", "author"])
If you look at the log you'll see this generates a query that looks like:
SELECT books.* FROM contributions, books
WHERE (books.id = contributions.book_id
AND contributions.contributor_id = 1
AND (contributions.role = 'author'))
Here I'm taking advantage of the fact that the find on the association includes a join on contributions to bind back to the contributor, so I can use other fields of that table in the query. Nifty, but ugly to type and read. How can we make that better? That's where association extensions come in.
class Contributor < ActiveRecord::Base
has_many :contributions, :dependent => true
has_many :books, :through => :contributions do
def by_role(role)
find(:all, :conditions => ["contributions.role = ?", role])
end
end
end
This lets us do a query like so:
books = josh.books.by_role("author")
What goes on in the extension is that finders are bound to the association just as if they were sent to it as in the earlier example. The advantage is they make things easy to read and write, and can encapsulate more business logic into the queries too.
The documentation for extensions provides a nice way to reuse code. Put the extension definition into a module then bind it with this syntax:
has_many :books, :through => :contributions,
:extend => BooksExtension
Here is a trick for using more than one extension module for an association:
has_many :books, :through => :contributions do
include BooksExtension
include ContributionsExtension
end
I didn't see that documented anywhere, but I figured it was worth trying and the experiment worked great. And if you're wondering where to put the extension modules, they do just fine in the lib directory.
That's it for association extensions today. Tomorrow (I hope) I'll show how to do a 3-way join and really put extensions through their paces.
Josh,
there's a little error here:
find(:all, :conditions => ["contributions.role = '?'", role])
as you are using a placeholder dont' put quotes around ?, just use:
find(:all, :conditions => ["contributions.role = ?", role])
Oops! I really need to test my example code better.
Would it be possible to have role point to a Role model instead? Ie. use role_id :integer rather than :string.
@Morten: Yes, in fact I took just that approach initially. I was going to describe how to do that in Part 3 of this writeup, but I haven't managed to get it written up yet. I'll try to do that soon, but the short answer is yes it's not too hard to do.
maybe a little stupid of a question but is is at all possible to merge the through table results with that of one of the two tables you link from. i.e. i want to in your example have it spit back at me the role as well as the contributer data
thanks
@Lawrence: You should be able to do that with a :select option. Something like:
You can add that to an association or to a finder on that association.
fantastic. been trying to get that work all night. thanks very much
Great article. I think you can actually drop the return [] unless role line as find() seems to handle nil and false values fine - it simply returns an empty array.
Oh, and something you didn't mention - one doesn't have to use :through with association extensions,
works fine.
@Henrik: Right, both good points. I used the trivial reject when role is nil to avoid hitting the database as I was assuming contributions would never have a NULL role, but if you wanted to find something where a value was NULL, ActiveRecord will do the right thing for a nil parameter. And yes, association extensions work on all associations - I just didn't notice them myself until I started looking more closely at the 1.1 features and :through.
Thanks for this.
FWIW, I'm looking forward to the discussion of using a full role model. I'm struggling a bit with my own example of this, where I need to not only be able to separately track different roles (author, editor, translator, publisher), but also need to ensure that the first two (at least) acts_as_list. So I need to be able to do book.authors.first and such.
So what happened to the next part with the three-way join? :)
@Henrik: Perhaps I need to remove that last para in the post. In trying to sort out the approach I was going to take I realized it wasn't as useful as I'd hoped. I'm working on another approach, but it depends on a ticket that is still unresolved. I'll post something as soon as I get it sorted out, which I hope will be soon.