It looks like the release of Rails 1.1 is just days away, so I thought I'd share some of what I've learned about some of the new features and how they work together. Given the name of this blog, I'm going to start with the has_many :through
association. Just to make things interesting, I'll show how association extensions can make queries on :through associations incredibly easy to use.
This is looking to be a fair amount of material for a blog entry, so I'm going to split it into installments. I think it should take about 3 parts to cover, unless I get sidetracked and start writing about something cool but obscure.
The has_many :through
association allows you to specify a one-to-many relationship indirectly via an intermediate join table. In fact, you can specify more than one such relationship via the same table, which effectively makes it a replacement for has_and_belongs_to_many
. The biggest advantage is that the join table contains full-fledged model objects complete with primary keys and ancillary data. No more push_with_attributes
; join models just work the same way all your other ActiveRecord models do.
Let's try an example. The typical example uses Authors and Books, but I'm going to shift that around a bit to show you some new things later on.
First, the tables. Remember create_table
creates a primary key named id
by default.
create_table "books" do |t|
t.column "title", :string
t.column "isbn", :string
t.column "year", :integer
end
create_table "contributors" do |t|
t.column "name", :string
end
create_table "contributions" do |t|
t.column "book_id", :integer
t.column "contributor_id", :integer
t.column "royalty", :float
end
Then the model classes...
class Book < ActiveRecord::Base
has_many :contributions, :dependent => true
has_many :contributors, :through => :contributions
end
class Contributor < ActiveRecord::Base
has_many :contributions, :dependent => true
has_many :books, :through => :contributions
end
class Contribution < ActiveRecord::Base
belongs_to :book
belongs_to :contributor
end
Using habtm
, the join table would have been named books_contributors
. Here I've named it contributions
as it's now a real model. To prove it's more than just a join table, I've added a royalty
field to the contribution to indicate the percentage royalty the contributor will earn from sales of the book. I've also made the contribution dependent on both the book and contributor, as if either book or contributor is deleted there's no point in preserving the object that represents the relationship between them.
Now let's play with this a bit. (Please excuse the shameless use of a book I actually had published. It never sold a darn copy because OpenDoc was cancelled a week before it hit the stores, so this is the most use I'll ever get out of having worked on it.)
bgod = Book.create(:title => "BYTE Guide to OpenDoc",
:isbn => "0078821185", :year => 1996)
drew = Contributor.create(:name => "Andrew MacBride")
josh = Contributor.create(:name => "Joshua Susser")
Contribution.create(:book => bgod, :contributor => drew,
:royalty => 0.20)
Contribution.create(:book => bgod, :contributor => josh,
:royalty => 0.10)
Time to do some queries. Notice we can use the :through
association to get right at the data on the other side, just as if it was an habtm
association.
Contributor.find(1).books.first.year # => 1996
Book.find_by_year(1996).contributors.first.name # => "Andrew MacBride"
But we can also get at data stored in the join model.
bgod.contributions.size # => 2
drew.contributions.first.royalty # => 0.20
And more interestingly...
josh.books.find_by_year(1996).title # => "BYTE Guide to OpenDoc"
josh.contributions.find_by_book_id(bgod.id).royalty # => 0.10
That last expression found the join model object for the contribution between contributor josh
and book bgod
, then retrieved the royalty amount from that. The next logical thing to expect would be this:
josh.contributions.find_by_book_id(bgod.id).royalty = 0.30
However this is either a bug or a design limitation, and you can't write to fields obtained by chained accessors and finders. I'm not sure what the actual problem is but I've seen it mentioned a few times on various blogs and mailing lists. But at least you can still do this:
author = josh.contributions.find_by_book_id(bgod.id)
author.royalty = 0.30
Not ideal, but at least it works.
That's enough about has_many :through
associations for this installment. Next time I'll show you how to make pretty queries using association extensions.
I hope this has been interesting or useful information for someone. I'm still learning how these new features work, so please let me know if you find I've got something wrong.
Hi Josh,
I think that in the Contribution model, there's a typo in the belongs_to association, it should be:
belongs_to :book belongs_to :contributor
Anyway very interesting post !
Oops! You're right. I fixed it. That's what I get for not testing my example code!
Cool !!
Keep um coming
Hey Josh,
Great stuff! Could you give a quick rundown on how this differs from how 1.0 handles this setup? i.e. In 1.0 you still had hasmany in the two models, and two belongsto in the join table model. What limitations did 1.0 have in relation to what you've described here?
@Michael: Good question. I think the advantages over what you could do in 1.0 come down to two main areas. 1: you have both direct support for referencing the associated objects and, 2: you get to store (and modify) data in the join table.
In 1.0, you either got the easy references using habtm, or you got data in the join model, but not both. In the above example,
has_many :through
lets me sayjosh.books
to get to the associated objects, whereas in 1.0 you'd have to construct a special query to do that.The other advantage is that you can update data saved in the join table. habtm lets you do
push_with_attributes
, but since there is no primary key on the row there's no good way to update those attributes if you want to change the values.Could "royalty" just as well be a foreign key for a fourth model?
I'm doing a setup with users, groups and roles where users habtm groups. Each such relation defines what role the user has in this group. A role is a first class citizen, so I would like that to have its own model, rather than being an attribute on the join table.
If I understand your article correct, I can do this using :through associations, and then point what is a "royalty" in your example to a role_id. Will this work?
Nice read!
Just two typos:
should be..
right?
Also your comment area didnt let me leave my url/email. Clicking on the link just jumps to the top the page. Using Firefox 1.5.0.1 and Win XP SP2.
Oh.. and the Preview button doesnt seem to work neither.
Regards, Riad (http://www.riad.de)
PS: I guess the broken functionality is because of upgrading to Rails1.1. Didn't want to sound naggy.
Not to worry. Those functions break because there are some javascript files that aren't where they are expected. I think I've got some version skew between Scribbish and Typo. I'll be cleaning that up shortly, and with luck that will fix the problem.
Josh, great article. Along with the limitation (or bug) of not being able to update fields obtained by chained accessors and finders I was also surprised at the following. I have 3 models: User, Group, and Membership. User
has_many
:groups, :through memberships. Grouphas_many
:users :through memberships. Membershipbelongs_to
:group, :user. Now in the console I see this:It seems that the new association is not being saved. On a traditional has_many the << op does the save for me. I tried a bunch of ways to manually call the save, but could not get that to work either. Seen similar?
-b
@brian: This is a known issue with
has_many :through
associations. I've been sorting through it and am working on a post on it.Hi Josh and Brian,
Please expand upon your example Group, User, Membership, Role In other words a user can be a member of different groups and have different roles. Example: Sally is a member of the Singing Group and the Organizer for the Gardening Group. Just giving her the membership does not work nor does just giving her the role. She has two memberships. Then she has one role as organizer but only for one of the groups.
Best,
David
Hi name-mate :-)
I'm just working through your tutorial and it seems that you have forgotten two commatas (,) in your schema import! After isbn and year they are missing.
Thanks for your work and have a great time, Joshua
Another error:
drew = Contributor.create(:name = "Andrew MacBride") josh = Contributor.create(:name = "Joshua Susser")
The "=" thingies should be "=>" thingies... ;-)
Great article.
I have a question regarding sorting. Let's say you wanted a list of books, and their contributor, sorted by contributor?
To complicate matters, assume the Contributions model has an attribute 'created_at', and you only want to output contrubutions submitted in the last 24 hrs.
Thanks, Grant
@Grant: the sorting is going to be hard for me to write for you, as I don't know all your constraints. You could use
:order => "contributors.last_name, contributors.first_name"
if you have the right attributes on that table. Or you can do the sorting in Ruby code if you don't. It's probably easiest to :include contributors in your book query, then it's quick to grab the contributor info after the query.You should be able to do the date filtering with something like
:conditions => ["contributions.created_at > ?", 24.hours.ago]