- 9.1 Scopes
- 9.2 Callbacks
- 9.3 Calculation Methods
- 9.4 Single-Table Inheritance (STI)
- 9.5 Abstract Base Model Classes
- 9.6 Polymorphic has_many Relationships
- 9.7 Enums
- 9.8 Foreign-Key Constraints
- 9.9 Modules for Reusing Common Behavior
- 9.10 Modifying Active Record Classes at Runtime
- 9.11 Using Value Objects
- 9.12 Nonpersisted Models
- 9.13 PostgreSQL Enhancements
- 9.14 Conclusion
9.6 Polymorphic has_many Relationships
Rails gives you the ability to make one class belong_to more than one type of another class, as eloquently stated by blogger Mike Bayer:
- The “polymorphic association,” on the other hand, while it bears some resemblance to the regular polymorphic union of a class hierarchy, is not really the same since you’re only dealing with a particular association to a single target class from any number of source classes, source classes which don’t have anything else to do with each other; i.e. they aren’t in any particular inheritance relationship and probably are all persisted in completely different tables. In this way, the polymorphic association has a lot less to do with object inheritance and a lot more to do with aspect-oriented programming (AOP); a particular concept needs to be applied to a divergent set of entities which otherwise are not directly related. Such a concept is referred to as a cross cutting concern, such as, all the entities in your domain need to support a history log of all changes to a common logging table. In the AR example, an Order and a User object are illustrated to both require links to an Address object.9
In other words, this is not polymorphism in the typical object-oriented sense of the word; rather, it is something unique to Rails.
9.6.1 In the Case of Models with Comments
In our recurring time and expenses example, let’s assume that we want both BillableWeek and Timesheet to have many comments (a shared Comment class). A naive way to solve this problem might be to have the Comment class belong to both the BillableWeek and Timesheet classes and have billable_week_id and timesheet_id as columns in its database table.
1 class Comment < ActiveRecord::Base
2 belongs_to :timesheet
3 belongs_to :expense_report
4 end
I call that approach naive because it would be difficult to work with and hard to extend. Among other things, you would need to add code to the application to ensure that a Comment never belonged to both a BillableWeek and a Timesheet at the same time. The code to figure out what a given comment is attached to would be cumbersome to write. Even worse, every time you want to be able to add comments to another type of class, you’d have to add another nullable foreign key column to the comments table.
Rails solves this problem in an elegant fashion by allowing us to define what it terms polymorphic associations, which we covered when we described the polymorphic: true option of the belongs_to association in Chapter 7, “Active Record Associations.”
9.6.1.1 The Interface
Using a polymorphic association, we need to define only a single belongs_to and add a pair of related columns to the underlying database table. From that moment on, any class in our system can have comments attached to it (which would make it commentable) without needing to alter the database schema or the Comment model itself.
1 class Comment < ActiveRecord::Base
2 belongs_to :commentable, polymorphic: true
3 end
There isn’t a Commentable class (or module) in our application. We named the association :commentable because it accurately describes the interface of objects that will be associated in this way. The name :commentable will turn up again on the other side of the association:
1 class Timesheet < ActiveRecord::Base
2 has_many :comments, as: :commentable
3 end
4
5 class BillableWeek < ActiveRecord::Base
6 has_many :comments, as: :commentable
7 end
Here we have the friendly has_many association using the :as option. The :as marks this association as polymorphic and specifies which interface we are using on the other side of the association. While we’re on the subject, the other end of a polymorphic belongs_to can be either a has_many or a has_one and work identically.
9.6.1.2 The Database Columns
Here’s a migration that will create the comments table:
1 class CreateComments < ActiveRecord::Migration
2 def change
3 create_table :comments do |t|
4 t.text :body
5 t.integer :commentable
6 t.string :commentable_type
7 end
8 end
9 end
As you can see, there is a column called commentable_type, which stores the class name of associated object. The Migrations API actually gives you a one-line shortcut with the references method, which takes a polymorphic option:
1 create_table :comments do |t|
2 t.text :body
3 t.references :commentable, polymorphic: true
4 end
We can see how it comes together using the Rails console (some lines omitted for brevity):
>> c = Comment.create(body: 'I could be commenting anything.')
>> t = TimeSheet.create
>> b = BillableWeek.create
>> c.update_attribute(:commentable, t)
=> true
>> "#{c.commentable_type}: #{c.commentable_id}"
=> "Timesheet: 1"
>> c.update_attribute(:commentable, b)
=> true
>> "#{c.commentable_type}: #{c.commentable_id}"
=> "BillableWeek: 1"
As you can tell, both the Timesheet and the BillableWeek that we played with in the console had the same id (1). Thanks to the commentable_type attribute, stored as a string, Rails can figure out which is the correct related object.
9.6.1.3 Has_many :through and Polymorphics
There are some logical limitations that come into play with polymorphic associations. For instance, since it is impossible for Rails to know the tables necessary to join through a polymorphic association, the following hypothetical code, which tries to find everything that the user has commented on, will not work:
1 class Comment < ActiveRecord::Base
2 belongs_to :user # author of the comment
3 belongs_to :commentable, polymorphic: true
4 end
5
6 class User < ActiveRecord::Base
7 has_many :comments
8 has_many :commentables, through: :comments
9 end
10
11 >> User.first.commentables
12 ActiveRecord::HasManyThroughAssociationPolymorphicSourceError: Cannot
13 have a has_many :through association 'User#commentables' on the polymorphic object
If you really need it, has_many :through is possible with polymorphic associations but only by specifying exactly what type of polymorphic associations you want. To do so, you must use the :source_type option. In most cases, you will also need to use the :source option, since the association name will not match the interface name used for the polymorphic association:
1 class User < ActiveRecord::Base
2 has_many :comments
3 has_many :commented_timesheets, through: :comments,
4 source: :commentable, source_type: 'Timesheet'
5 has_many :commented_billable_weeks, through: :comments,
6 source: :commentable, source_type: 'BillableWeek'
7 end
It’s verbose, and the whole scheme loses its elegance if you go this route, but it works:
>> User.first.commented_timesheets.to_a
=> [#<Timesheet ...>]