Sunday, 1 July 2007

has_many, :through (captain obvious)

You know how you would create a database to model a multiple relationship especially where each object is independent. eg Objects X and Y are modelled in tables X and Y, and the relationship between them is modelled by a table containing (key(X), key(Y)). So how to do this in Rails?

The documentation on join-associations, ie has_many :through, has the following example, but its missing some obvious details for the Rails-newbie who knows DB stuff, but doesn't know Rails.
  class Author < ActiveRecord::Base
has_many :authorships
has_many :books, :through => :authorships

class Authorship < ActiveRecord::Base
belongs_to :author
belongs_to :book
What's missing is the background stuff, how to get here, and what the DB needs to look like for this to work. The belongs_to association implies that the table associated with the object (eg Authorship) contains a foreign-key for that object. In this case the table "authorships" must have columns with foreign-keys for book and author. In the normal rails setup this means two columns called book_id and author_id.

So to get this example going head to your rails directory and run:
  ./script/generate model Authorship
./script/generate model Book
./script/generate model Author

You now have (among other things) 3 files in db/migrations called nnn_create_authorships (and book and author). The skeleton is already in place to create the "authorships" table. It needs the two foreign keys, so add them:
create_table :authorships do |t|
t.column :author_id, :integer # foreign key for author
t.column :book_id, :integer # foreign key for book
You can add any columns you want to the migration definitions for the other tables (names would be obvious) but bear in mind that there will (if you don't do anything special) be a hidden primary-key defined for each one as if you included the line:
  t.column :id, :integer
You don't need to add any external references to either the books or authors table.

To make these new tables take effect in the DB you use the db:migrate task. This allows you to move the databases between the different versions (this is the nnn_ prefix on the files in the db/migration directory). So use the command
  rake db:migrate
(you can choose any given version of your DB by appending VERSION=N to the line above - N corresponds to the nnn_prefix as above).

The belongs_to and has_many relationships are defined in app/models/authorship.rb (and author.rb, book.rb), so make them now. The has_many relationship in author will add a list called .books which hides all of the details of the join-relationship.

To see if any of this works its best to use the unit-tests for your model - eg with the example in test/units/author_test.rb. You should create fixtures for each model - these are sample data-sets that will be available for in your test-harnesses and development database, so go create some books, authors and authorships now. The generated fixtures will have the id: field, keep this, and remember you'll need this to fill in the authorship foreign-keys (book_id, author_id). Add values for the other fields (eg name of book,..).

To get our author_test working, and check the Author#books methods work we'll need the authorship and book data as well as author data. So add authorships and books to the fixtures list at the top of author_test.rb. If you don't do this you'll probably spend hours wondering why your tests fail, returning empty lists.

So create a quick test to verify everything is plumbed together properly, check that all of your test-data correctly fills out the lists as expected. Example test code:

# Test the books relationship works as expected using the test data defined in fixtures
def test_fixtures_books_relationship
author = Authors.find(1) # grab the author you defined with id: field of 1
assert author # ensure author was found

assert_equals 2, author.books.length # check the authorship connections
# you defined in your fixtures
book_names = ["Some book", "Another Book"]
author.books.each do |book|
assert book_names.find( # ensure that each of the books was
# one of the two expected
Run your test using:
  rake test:units

No comments: