Many-To-Many Relationships
AAHC

Click on A to make all fonts on the page smaller.

Click on A to make all fonts on the page larger.

Click on HC to toggle high contrast mode. When you move your mouse over some bold words in high contrast mode, related words are automatically highlighted. Text is shown in black and white.


Lesson Objectives
When you complete this lesson, you will be able to:

In this lesson, you'll learn how to model more complex data structures in Rails. We've already looked at ways to connect different data types using relationships. For example, the belongs_to relationship creates a connection from a model object to its owner:

We've also looked at the reverse relationship called has_many. A has_many relationship allows a model object to find the list of things it owns:

There is another type of relationship that's a little more complex. Suppose a user (author) wants to mark certain messages as favorites within the application:

The author would click a button next to a message to say that they like it. So, what kind of relationship is it when an author favorites a message? Well, an author can favorite more than one message, so the favorite relationship could be considered like way:

However, since each message could be favorited by more than one author, you can also think of the favorite relationship like this:

In has_many and belongs_to relationships, a single piece of data from one model type is associated with many pieces of data from another model type. However, a relationship like "favorite" associates many pieces of data of a specifc type with many pieces of data from another type. It's a many-to-many relationship. In this lesson we'll look at how to create these relationships, how they work, and how to use and test them.

Recording Favorites on the Database

We have tables on the database used to record Authors and Messages. If we want to keep a record of which authors favorited which messages, where would we store the data?

Well, we can't store it on the Authors table, because if we did, we'd need to add a column for every message that the author has favorited:

We can't record it on the Messages table either, because we'd need a column for each author that liked the message:

Instead, many-to-many relationships are created by introducing a third table, called a join table. The join table contains the id of an author and the id of a message. Each time an author favorites a message, we'll add a new row to the join table that says, in effect, "Author x likes message y."

In this way, a single author can favorite many messages:

In addition, a single message can be favorited by several authorst:

It's quite complex to manage and use a join table, but Rails has a number of features that make many-to-many relationships easier to use.

Creating the Join Table

First we need to create a table containing two columns:

In the past when we created migrations, we gave the migration a carefully constructed name that allowed Rails to infer what the migration needed to do; for example, we added an admin column to the authors table using this command:

OBSERVE:
rails generate migration AddAdminColumnToAuthors admin:boolean

That way of generating migrations is fine if you're doing something straightforward like adding or removing a column, but to create the favorites table, we're going to be a little more hands-on.

In a Unix shell, type this command to create a migration:

INTERACTIVE SESSION: Create a new migration for the favorites table
cold1:~/railsapps/ostapp$ rails generate migration CreateFavoritesTable
      invoke  active_record
      create    db/migrate/20130927115920_create_favorites_table.rb
cold1:~/railsapps/ostapp$ 

This command doesn't give Rails enough information about what will be stored in the table, so the migration that was just generated looks like this:

OBSERVE: ~/railsapps/ostapp/db/migrate/<timestamp>_create_favorites_table.rb
class CreateFavoritesTable < ActiveRecord::Migration
  def self.up
  end
  def self.down
  end
end

That's fine though, because now we'll have a chance to create a migration manually. There are two methods in the migration—one called self.up and one called self.down. When you want the migration to be applied, Rails will run the self.up method. If ever you decide to undo a migration (later in the course, we'll see why you may want to do this), Rails will run the self.down method, so the self.down method will need to do the opposite of the self.up method. In other words, self.up performs the migration, and self.down will undo it.

We want the migration to create a table called favorites, so that code will go into the self.up method. In the self.down method, write some code to remove the favorites table. Open the migration in the text editor and make these changes:

CODE TO TYPE: ~/railsapps/ostapp/db/migrate/<timestamp>_create_favorites_table.rb
class CreateFavoritesTable < ActiveRecord::Migration
  def self.up
     create_table :favorites, :id => false do |t|
       t.integer :author_id
       t.integer :message_id
     end
  end
  def self.down
     drop_table :favorites
  end
end

This code creates the table:

OBSERVE:
     create_table :favorites, :id => false do |t|
       t.integer :author_id
       t.integer :message_id
     end

We create a table named favorites, but do not create an id column automatically. The table will contain an integer field named author_id and another named message_id.

NoteBy default, Rails adds an id column to each table it creates. We don't need an id on our favorites table, so we use the :id => false option to switch it off.

If we choose to undo the migration, Rails will call the code drop_table :favorites in the self.down method, and drop the favorites table. We haven't undone any migrations yet, but we'll see why we might do so later in the course.

Save the migration file and then, in a Unix shell, run the new migration with the rake command:

INTERACTIVE SESSION: Create the table
cold1:~/railsapps/ostapp$ rake db:migrate
(in /users/username/railsapps/ostapp)
==  CreateFavoritesTable: migrating ===========================================
-- create_table(:favorites, {:id=>false})
   -> 0.0038s
==  CreateFavoritesTable: migrated (0.0039s) ==================================
cold1:~/railsapps/ostapp$ 

We now have a favorites table in the database. Next, we need to create a relationship that will use the table. The favorites relationship looks like this:

Each author can have many favorite messages. Each message can have been favorited by many authors. To describe this action, we'll use a new type of relationship called has_and_belongs_to_many.

Creating the Relationship

Open the Author model and add this code:

CODE TO TYPE: ~/railsapps/ostapp/app/models/author.rb
class Author < ActiveRecord::Base
   has_and_belongs_to_many :favorites, :join_table => "favorites", :class_name => "Message"
   validates :username, :uniqueness => true
   validates :username, :password, :full_name, :presence => true
end

Save the file. The has_and_belongs_to_many line tells Rails that each author can have several "favorites", each of which is a Message. Favorites are going to be recorded via the favorites join-table.

Open the Message model script in the text editor and add this line of code:

CODE TO TYPE: ~/railsapps/ostapp/app/models/message.rb
class Message < ActiveRecord::Base
  has_and_belongs_to_many :favorites, :join_table => "favorites", :class_name => "Author"
  belongs_to :author
  validates :contents, :length => { :minimum => 3, :maximum => 140 }
end

The code is almost identical to our eralier code, excep that this code indicates that each message can have many favorites, each of which is an Author object. We could have called this relationship "favoritor" instead of "favorites," but since that's not a real word, we'll stick with "favorites." Again, the "favorites" table is the join-table that will store this relationship.

Now save the message model, then let's try it out.

In the Unix shell, make sure you are in the ~/railsapps/ostapp directory, and then start the Rails console:

INTERACTIVE SESSION:
cold1:~/railsapps/ostapp$ rails console
Loading development environment (Rails 3.0.3)
irb(main):001 > 

We're going to try the new has_and_belongs_to_many relationship between the authors and messages tables. Remember that when the Rails console begins, it loads the code from the application, so it can use the new relationship.

To begin, let's read an author and a message from the database, and then store them in local variables called author and message:

INTERACTIVE SESSION:
irb(main):001 > author=Author.first
 => #<Author id: 1, full_name: "Mark Zuckerberg", username: "markz", password: "FaceMash", profile: "Primarily a coder. Inte
rested in food and social ne...", image: "http://tinyurl.com/zuckport", created_at: "2014-05-01 19:56:26", updated_at: "201
4-05-01 19:56:26", admin: nil>
irb(main):002 > message=Message.first
 => #<Message id: 1, contents: "Trying to book a table for tonight. Things look kin...", created_at: "2013-05-19 13:02:13", updated_at: "2013-05-19 13:02:13", author_id: nil>
irb(main):003 > 

By calling Author.first and Message.first, we pick the first records we find. It doesn't matter which author and message we find; we just need one of each.

Now that we have an author and a message, we can take a look at the author's favorite messages:

INTERACTIVE SESSION:
irb(main):003 > author.favorites
 => []
irb(main):004 > 

It's currently an empty list—this is not surprising because the author hasn't favorited anything yet. Think for a moment about what happens when we type in the expression "author.favorites." The console examines the author object and discovers a relationship called favorites. It finds it on this line of the authors.rb model:

OBSERVE: ~/railsapps/ostapp/app/models/author.rb
class Author < ActiveRecord::Base
   has_and_belongs_to_many :favorites, :join_table => "favorites", :class_name => "Message"
   validates :username, :uniqueness => true
   validates :username, :password, :full_name, :presence => true
end

The name is given by the :favorites symbol. Now that Rails has discovered the relationship, it will search the favorites join-table for records with an author_id that matches the id of the author— currently there are no matching records. In fact, the table is empty:

Rails can't find any matching favorites records, so it returns the empty array ([]).

Note How does Rails know that it needs to match the author_id on the favorites table with the id on the authors table? Rails uses a convention. If we are connecting from a table called authors to another table like favorites, Rails uses the convention that id on the authors table will match a column called author_id on the favorites table. By following this convention, we don't need to give Rails configuration information about how to connect the two tables. This is an example of the Rails Convention over Configuration principle.

Now we want to make the author favorite a message. We've just learned that Rails makes the favorites relationship look like an array, and that the way that the author favorites a method is by adding a message to author.favorites:

INTERACTIVE SESSION:
irb(main):004 > author.favorites << message
 => [#<Message id: 1, contents: "Trying to book a table for tonight. Things look kin...", created_at: "2013-05-19 13:02:13", updated_at: "2013-05-19 13:02:13", author_id: 1>]
irb(main):005 > 

The notation author.favorites << message is the standard way in Ruby to add an item onto the end of an array of values. So if we take a look at author.favorites now, we see the message listed:

INTERACTIVE SESSION:
irb(main):005 > author.favorites
 => [#<Message id: 1, contents: "Trying to book a table for tonight. Things look kin...", created_at: "2013-05-19 13:02:13", updated_at: "2013-05-19 13:02:13", author_id: 1>]
irb(main):006 > 

This code looks fine, but it's hiding quite a lot of complexity. When Rails sees the expression author.favorites << message, it checks the author.rb model to discover where the new favorite is going to be stored:

OBSERVE: ~/railsapps/ostapp/app/models/author.rb
class Author < ActiveRecord::Base
   has_and_belongs_to_many :favorites, :join_table => "favorites", :class_name => "Message"
   validates :username, :uniqueness => true
   validates :username, :password, :full_name, :presence => true
end

It first checks that the object we're going to store in the favorites is a Message. It then reads the id from the message object and the id from the author object and stores them as a new record in the favorites table:

Rails then returns an array with the new list of favorites, which now consists of just one message.

However, that's only half the story. The has_and_belongs_to_many relationship works in both directions. An author can have a list of favorites, and a message can have a list of authors who favorited it. Look at the favorites for the message:

INTERACTIVE SESSION:
irb(main):006 > message.favorites
 => [#<Author id: 1, full_name: "Mark Zuckerberg", username: "markz", password: "FaceMash", profile: "Primarily a coder. Int
erested in food and social ne...", image: "http://tinyurl.com/zuckport", created_at: "2014-05-01 19:56:26", updated_at: "20
14-05-01 19:56:26", admin: nil>]
irb(main):007 > 

We can see that it now includes the author in the list. All of this happened automatically by just typing the one line of code, author.favorites << message.

It's an example of why Ruby was used as the language for Rails. Ruby has the ability to express a whole bunch of meaning in a short, simple phrase.

OK, now log out of the console:

INTERACTIVE SESSION:
irb(main):007 > exit
cold1:~/railsapps/ostapp$ 

Now that we know how to read and write favorites, let's go write some tests.

Creating Unit Tests

Why write tests now? As test-driven developers, we should have written a bunch of tests before adding a new feature like favorites to the application, but when you're new to a system it's often easier to try out the code before writing tests.

We've already seen functional tests that check controller code, and integration tests that check how your code works from the browser's point of view. Now we'll look at unit tests.

If you speak to developers who write code in most other languages and frameworks, a unit test is any piece of test code that checks how a piece of code works in isolation. That's why it's called a unit test, because it checks how a piece of code works as a single unit. But in Rails, unit tests are much more specific. A Rails unit test explicitly tests model code.

Each time you generate scaffolding for a single data type, like Authors or Messages, Rails generates a unit test script for the model. Let's take a look at the unit test for the Message model:

OBSERVE: ~/railsapps/ostapp/test/unit/message_test.rb
require 'test_helper'
class MessageTest < ActiveSupport::TestCase
  # Replace this with your real tests.
  test "the truth" do
    assert true
  end
end

This script contains a single test called the truth, that contains a single assertion that makes sure that true is true. Run this test:

INTERACTIVE SESSION: Run the test
cold1:~$ cd railsapps/ostapp/
cold1:~/railsapps/ostapp$ ruby -Itest test/unit/message_test.rb
Loaded suite test/unit/message_test
Started
.
Finished in 0.013218 seconds.
1 tests, 1 assertions, 0 failures, 0 errors
cold1:~/railsapps/ostapp$ 

We're going to replace this test with several other tests of our own. Before we create the tests, we should think about what test data we need. Open the ~/railsapps/ostapp/test/fixtures/authors.yml file in the text editor, and then add a new author:

CODE TO TYPE: ~/railsapps/ostapp/test/fixtures/authors.yml
one:
  full_name: MyString
  username: user1
  password: password1
  profile: MyText
  image: MyString
two:
  full_name: MyString
  username: MyString
  password: MyString
  profile: MyText
  image: MyString
basic_user:
  full_name: Seymour Papert
  username: seymour
  password: turtle1
  profile: I like to write programs
admin:
  full_name: Robert Morris
  username: robm
  password: tappan
  profile: Security is my passion
  admin: true
john:
  full_name: John McCarthy
  username: johnny
  password: glatt
  profile: People call me "Uncle John"

Now you're going to create a test that checks that two people can favorite the same message. Modify the message_test.rb script as shown:

CODE TO TYPE: ~/railsapps/ostapp/test/unit/message_test.rb
require 'test_helper'
class MessageTest < ActiveSupport::TestCase
  # Replace this with your real tests.
    test "the truth" do
    assert true
  end
  test "an author can favorite a message" do
    seymour=authors(:basic_user)
    john=authors(:john)
    msg = Message.new
    msg.contents = "I just had a great idea for a new language"
    msg.author = seymour
    john.favorites << msg
    assert_equal(john, msg.favorites[0])
    assert_equal(msg, john.favorites[0])
  end
end

Save the message_test.rb file. This new test checks what happens if a person marks a message as a favorite. Let's take a look at the code in detail:

OBSERVE:
    seymour=authors(:basic_user)
    john=authors(:john)
    msg = Message.new
    msg.contents = "I just had a great idea for a new language"
    msg.author = seymour
    john.favorites << msg
    assert_equal(john, msg.favorites[0])
    assert_equal(msg, john.favorites[0])

The authors are defined in the authors.yml file. We create a new message, and identify it as authored by seymour and favorited by john. Then, we check that john was the first to favorite the message and that the message is the first one favorited by john.

Remember that msg.favorites returns an array of authors, so msg.favorites[0] is the first author in the list.

This test code is very similar to the code we were running on the console, but when you're exploring new code, it's often useful to try it out by writing tests as well by using the console. Why? Because all the code you write in the console will disappear as soon as you leave the console, but the tests you write will remain as a part of your project. The feature that you play with early on in a project, will remain as an important regression test as your code grows and grows.

So—to run the test. This is the first time we've run a test that uses the favorites table, so we need to make sure it's been created in the test database. To do that, run this rake command:

INTERACTIVE SESSION: Make sure the test database has a favorites table
cold1:~/railsapps/ostapp$ rake db:test:load
(in /Users/davidg/railsapps/ostapp)
cold1:~/railsapps/ostapp$ 

If you don't run this command, the favorites table will be missing from the test database. The rake db:test:load command will ensure that all of the migrations have been run against the test database. Now run the test as before:

INTERACTIVE SESSION: Run the unit test on the model
cold1:~/railsapps/ostapp$ ruby -Itest test/unit/message_test.rb
Loaded suite test/unit/message_test
Started
.
Finished in 0.038425 seconds.
1 tests, 2 assertions, 0 failures, 0 errors
cold1:~/railsapps/ostapp$ 

The test passed. Once you've added one test, you can add more. For example, this test will make sure that a message can be favorited by more than one author:

CODE TO TYPE: ~/railsapps/ostapp/test/unit/message_test.rb
require 'test_helper'
class MessageTest < ActiveSupport::TestCase
  test "an author can favorite a message" do
    seymour=authors(:basic_user)
    john=authors(:john)
    msg = Message.new
    msg.contents = "I just had a great idea for a new language"
    msg.author = seymour
    john.favorites << msg
    assert_equal(john, msg.favorites[0])
    assert_equal(msg, john.favorites[0])
  end
  test "more than one author can favorite a message" do
    seymour=authors(:basic_user)
    john=authors(:john)
    msg = Message.new
    msg.contents = "I just had a great idea for a new language"
    msg.author = seymour
    john.favorites << msg
    seymour.favorites << msg
    assert_equal(2, msg.favorites.size)
    assert msg.favorites.include?(john)
    assert msg.favorites.include?(seymour)
  end
end

Add this code and save the message_test.rb file again. Now re-run the test:

INTERACTIVE SESSION: Run the unit test on the model
cold1:~/railsapps/ostapp$ ruby -Itest test/unit/message_test.rb
Loaded suite test/unit/message_test
Started
..
Finished in 0.061893 seconds.
2 tests, 5 assertions, 0 failures, 0 errors
cold1:~/railsapps/ostapp$ 

Did you notice that the start of the first test and the start of the second test were the same? They both contained these lines of code:

OBSERVE:
    seymour=authors(:basic_user)
    john=authors(:john)
    msg = Message.new
    msg.contents = "I just had a great idea for a new language"
    msg.author = seymour

When you're writing tests, you will often find they each test requires some sort of initialization code. Rails allows you to put this initialization code into a method called setup. The setup method (if it exists) will be called by Rails before each of the tests.

Open the message_test.rb file in the text editor again, and move the common initialization code into the setup method like this:

CODE TO TYPE: ~/railsapps/ostapp/test/unit/message_test.rb
require 'test_helper'
class MessageTest < ActiveSupport::TestCase
  def setup
    seymour=authors(:basic_user)
    john=authors(:john)
    msg = Message.new
    msg.contents = "I just had a great idea for a new language"
    msg.author = seymour
  end
  test "an author can favorite a message" do
    seymour=authors(:basic_user)
    john=authors(:john)
    msg1 = Message.new
    msg1.contents = "I just had a great idea for a new language"
    msg1.author = seymour
    john.favorites << msg
    assert_equal(john, msg.favorites[0])
    assert_equal(1, msg.favorites.size)
    assert_equal(msg, john.favorites[0])
  end
  test "more than one author can favorite a message" do
    seymour=authors(:basic_user)
    john=authors(:john)
    msg1 = Message.new
    msg1.contents = "I just had a great idea for a new language"
    msg1.author = seymour
    john.favorites << msg
    seymour.favorites << msg
    assert_equal(2, msg.favorites.size)
    assert msg.favorites.include?(john)
    assert msg.favorites.include?(seymour)
  end
end

Isn't that neater? If Rails is going to run the "an author can favorite a message" test, it will first run setup and then run the test. The same thing for the "more than one author can favorite a message." Using a setup method will keep your tests shorter and more concise.

There is, however, a problem with the code as it stands. Can you see what it is? Look carefully at the code again, paying particular attention to the variables...

OBSERVE: ~/railsapps/ostapp/test/unit/message_test.rb
require 'test_helper'
class MessageTest < ActiveSupport::TestCase
  def setup
    seymour=authors(:basic_user)
    john=authors(:john)
    msg = Message.new
    msg.contents = "I just had a great idea for a new language"
    msg.author = seymour
  end
  test "an author can favorite a message" do
    john.favorites << msg
    assert_equal(john, msg.favorites[0])
    assert_equal(msg, john.favorites[0])
  end
  test "more than one author can favorite a message" do
    john.favorites << msg
    seymour.favorites << msg
    assert_equal(2, msg.favorites.size)
    assert msg.favorites.include?(john)
    assert msg.favorites.include?(seymour)
  end
end

The john, seymour, and msg variables in the setup method are local variables. That means they are only meaningful inside the setup function. When Ruby sees seymour or john outside of setup it won't understand what it means. To see this, run the test on the Unix terminal again:

INTERACTIVE SESSION:
cold1:~/railsapps/ostapp$ ruby -Itest test/unit/message_test.rb
Loaded suite test/unit/message_test
Started
EE
Finished in 0.030782 seconds.
  1) Error:
test_an_author_can_favorite_a_message(MessageTest):
NameError: undefined local variable or method `john' for #<MessageTest:0x10c7d3ea0>
    test/unit/message_test.rb:12:in `test_an_author_can_favorite_a_message'
  2) Error:
test_more_than_one_author_can_favorite_a_message(MessageTest):
NameError: undefined local variable or method `john' for #<MessageTest:0x10c7d3e78>
    test/unit/message_test.rb:17:in `test_more_than_one_author_can_favorite_a_message'
2 tests, 0 assertions, 0 failures, 2 errors
cold1:~/railsapps/ostapp$ 

The tests couldn't read the john variable because it had been created as a variable inside setup.

How do you fix this? In Ruby, if we want a variable to be visible outside a method with give it a @-prefix. We did the same thing in the login methods in the controller. We created a variable named @author, which was visible outside the login method.

So in the message_test.rb script, we need to rename the john, seymour, and msg variables @john, @seymour, and @msg:

CODE TO TYPE: ~/railsapps/ostapp/test/unit/message_test.rb
require 'test_helper'
class MessageTest < ActiveSupport::TestCase
  def setup
    @seymour=authors(:basic_user)
    @john=authors(:john)
    @msg = Message.new
    @msg.contents = "I just had a great idea for a new language"
    @msg.author = @seymour
  end
  test "an author can favorite a message" do
    @john.favorites << @msg
    assert_equal(1,  @msg.favorites.size)
    assert_equal( @msg, @john.favorites[0])
  end
  test "more than one author can favorite a message" do
    @john.favorites << @msg
    @seymour.favorites << @msg
    assert_equal(2,  @msg.favorites.size)
    assert @msg.favorites.include?( @john)
    assert @msg.favorites.include?( @seymour)
  end
end

Save message_test.rb and run the test again:

INTERACTIVE SESSION: Run the unit test on the model
cold1:~/railsapps/ostapp$ ruby -Itest test/unit/message_test.rb
Loaded suite test/unit/message_test
Started
..
Finished in 0.054725 seconds.
2 tests, 5 assertions, 0 failures, 0 errors
cold1:~/railsapps/ostapp$ 

The test passes. But that's not the end of the story...

What You Just Learned