New association goodness in Rails 1.1

— February 28, 2006 at 20:00 PST


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.

16 commentsassociations, rails

Comments
  1. Richard Piacentini2006-03-04 05:22:46

    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 !

  2. Josh Susser2006-03-04 10:11:50

    Oops! You're right. I fixed it. That's what I get for not testing my example code!

  3. Peter2006-03-22 16:32:53

    Cool !!

    Keep um coming

  4. Michael Jurewitz2006-03-22 20:31:23

    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?

  5. Josh Susser2006-03-22 20:56:34

    @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 say josh.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.

  6. Morten2006-03-23 07:11:05

    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?

  7. Riad2006-04-03 06:50:45

    Nice read!

    Just two typos:

    drew = Contributor.create(:name = "Andrew MacBride") josh = Contributor.create(:name = "Joshua Susser")

    should be..

    drew = Contributor.create(:name => "Andrew MacBride") josh = Contributor.create(:name => "Joshua Susser")

    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)

  8. Riad2006-04-03 10:10:34

    PS: I guess the broken functionality is because of upgrading to Rails1.1. Didn't want to sound naggy.

  9. Josh Susser2006-04-03 10:14:23

    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.

  10. brian2006-04-14 18:03:21

    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. Group has_many :users :through memberships. Membership belongs_to :group, :user. Now in the console I see this:

    # create a new group
    >> g = Group.create()
    # it has no users
    >> g.users.size
    => 0
    # find and add a user to the new group
    >> u = User.find 1
    >> g.users << u
    # the size is now 1, that's swell
    >> g.users.size
    => 1
    # reload
    >> g.reload
    # ouch, the size is zero
    >> g.users.size
    => 0
    

    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

  11. Josh Susser2006-04-15 16:57:11

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

  12. cocreators.com2006-05-07 21:16:36

    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

  13. Josh2006-05-14 10:22:11

    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

  14. Josh2006-05-14 10:38:48

    Another error:

    drew = Contributor.create(:name = "Andrew MacBride") josh = Contributor.create(:name = "Joshua Susser")

    The "=" thingies should be "=>" thingies... ;-)

  15. Grant2006-05-26 09:33:17

    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

  16. Josh Susser2006-05-26 10:24:18

    @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]

Sorry, comments for this article are closed.