5.5. Through-Associations

Referring back to Figure 5.9, there are direct associations between Moviegoers and Reviews as well as between Movies and Reviews. But since any given Review is associated with both a Moviegoer and a Movie, we could say that there’s an indirect association between Moviegoers and Movies. For example, we might ask “What are all the movies Gloria has reviewed?” or “Which moviegoers have reviewed Inception?” Indeed, line 13 in Figure 5.11 essentially answers the second question.

 1# Run 'rails generate migration create_reviews' and then
 2#   edit db/migrate/*_create_reviews.rb to look like this:
 3class CreateReviews < ActiveRecord::Migration
 4    def change
 5        create_table 'reviews' do |t|
 6        t.integer    'potatoes'
 7        t.text       'comments'
 8        t.references 'moviegoer'
 9        t.references 'movie'
10        end
11    end
12end

This kind of indirect association is so common that Rails and other frameworks provide an abstraction to simplify its use. It’s sometimes called a through-association, since Moviegoers are related to Movies through their reviews and vice versa. Figure 5.15 shows how to use the :through option to Rails’ has_many to represent this indirect association. You can similarly add has_many :moviegoers, :through=>:reviews to the Movie model, and write movie.moviegoers to ask which moviegoers are associated with (wrote reviews for) a given movie.

5.14
Figure 5.14: In the Active Record design pattern (left), used by Rails and implemented in the ActiveRecord module, the model object itself knows how it’s stored in the persistence tier, and how its relationship to other types of models is represented there. In the Data Mapper pattern (right), used by Google AppEngine, PHP and Sinatra, a separate class isolates model objects from the underlying storage layer. Each approach has pros and cons. This class diagram is one form of Unified Modeling Language (UML) diagram, which we’ll learn more about in Chapter 11.

How is a through-association “traversed” in the database? Referring again to Figure 5.10, finding all the movies reviewed by Gloria first requires forming the Cartesian product of the three tables (movies, reviews, moviegoers), resulting in a table that conceptually has 27 rows and 9 columns in our example. From this table we then select those rows for which the movie’s ID matches the review’s movie_id and the moviegoer’s ID matches the review’s moviegoer_id. Extending the explanation of Section 5.4, the SQL query might look like this:

 1# in moviegoer.rb:
 2class Moviegoer
 3    has_many :reviews
 4    has_many :movies , :through
 5    # ... other moviegoer model code
 6end
 7gloria = Moviegoer.where(:name => 'Gloria')
 8gloria_movies = gloria.movies
 9# MAY work, but a bad idea - see caption:
10gloria.movies << Movie.where(:title => 'Inception') # Don't do this!
Figure 5.15: Using through-associations in Rails. As before, the object returned by alice.movies in line 8 quacks like a collection. Note, however, that since the association between a Movie and a Moviegoer occurs through a Review belonging to both, the syntax in line 10 will cause a Review object to be created to “link” the association, and by default all its attributes will be nil. This is almost certainly not what you want, and if you have validations on the Review object (for example, the number of potatoes must be an integer), the newly-created Review object will fail validation and cause the entire operation to abort.
1class Review < ActiveRecord::Base
2    # review is valid only if it's associated with a movie:
3    validates :movie_id , :presence => true
4    # can ALSO require that the referenced movie itself be valid
5    # in order for the review to be valid:
6    validates_associated :movie
7end
Figure 5.16: This example validation on an association ensures that a review is only saved if it has been associated with some movie.
SELECT movies .*
    FROM movies JOIN reviews ON movies.id = reviews.movie_id
    JOIN moviegoers ON moviegoers.id = reviews.moviegoer_id
    WHERE moviegoers.id = 1;

For efficiency, the intermediate Cartesian product table is usually not materialized, that is, not explicitly constructed by the database. Indeed, Rails 3 has a sophisticated relational algebra engine that constructs and performs optimized SQL join queries for traversing associations.

The point of this section and the previous one, though, is not only to explain how to use associations, but also to point out the elegant use of duck typing and metaprogramming that makes them possible. In Figure 5.12(c) you added has_many :reviews to the Movie class. The has_many method performs some metaprogramming to define the new instance method reviews= that we used in Figure 5.11. has_many is not a declaration, but a regular method call that does all of this work at runtime, adding several new instance methods to your model class to help manage the association. As you’ve no doubt guessed, convention over configuration determines the name of the new method, the table it will use in the database, and so on.

Associations are one of the most feature-rich aspects of Rails, so take a good look at the full documentation for them. In particular:

  • Just like ActiveRecord lifecycle hooks, associations provide additional hooks that can be triggered when objects are added to or removed from an association (such as when new Reviews are added for a Movie), which are distinct from the lifecycle hooks of Movies or Reviews themselves.

  • Validations can be declared on associated models, as Figure 5.16 shows.

  • Because calling save or save! on an object that uses associations also affects the associated objects, various caveats apply to what happens if any of the saves fails. For example, if you have just created a new Movie and two new Reviews to link to it, and you now try to save the Movie, any of the three saves could fail if the objects aren’t valid (among other reasons).

  • Additional options to association methods control what happens to “owned” objects when an “owning” object is destroyed. For example, has_many :reviews, dependent: destroy specifies that the reviews belonging to a movie should be deleted from the database if the movie is destroyed.

Self-Check 5.5.1. Describe in English the steps required to determine all the moviegoers who have reviewed a movie with some given id (primary key).

Find all the reviews whose movie_id field contains the id of the movie of interest. For each review, find the moviegoer whose id matches the review’s moviegoer_id field.