154: Polymorphic Association
(view original Railscast)
Polymorphic associations aren’t a new feature of Rails, they’ve been around since Rails 1.1, but they can be a little confusing for less experienced Rails developers to understand. In this episode we’re going to give a simple demonstration of how to use them that will hopefully explain how they’re used.
Above is a page from a site that has three different models: articles, photos and events. We’d like to enhance the site so that its users can add comments to an article, a photograph or an event. If we were just adding comments to one of the models, say articles we’d create a Comment
model and have a has_many
/ belongs_to
relationship between Article
and Comment
. Without polymorphic associations we’d have to create three different types of comment model so that each one could belong_to
the appropriate model, which would create a lot of repetition in the code. Polymorphic associations allow us to create just one Comment model and have each comment know which other model it should be associated with.
Creating The Comment Model
The first thing to do is to create the Comment
model. We’ll create this in the normal way, but with one small difference. If we were just creating comments for an Article
we’d have an integer field called article_id
in the model to store the foreign key, but in this case we’re going to need something more abstract. All of the models we’re adding comments to have one thing in common: they allow comments. Therefore we’ll call the foreign key commentable_id
. A comment will need to know which model it’s associated with, so we’ll need another field to store that, which we’ll call commentable_type
. This field will hold the class name of the model the comment is associated with.
script/generate model Comment content:text commentable_id:integer commentable_type:string
Once we’ve created the model and run the migration we’ll need to associate our comment model with the article, photo and event models.
class Comment < ActiveRecord::Base belongs_to :commentable, :polymorphic => true end
Instead of saying that a comment belongs_to
another model, we make it belong to commentable
and declare it as a polymorphic relationship. Now, any other model that has_many commentable
s can have comments. In the Article
, Photo
and Event
classes we define the relationship like this.
has_many :comments, :as => :commentable
This tells the classes that they have many comments through the polymorphic commentable relationship.
Sorting Out Comments and Views
Now that the relationships are set up between our models we need to look at the controllers and views. We’ll need a controller to go with our Comment
model.
script/generate controller Comments
We can mostly treat our comments model as we would any other relationship, for example to get an article’s comments we can use article.comments
.
Getting The Right Comments
Difficulty can arise when we try to use nested resources, such as /articles/1/comments
to see all of the comments for an article. To get this to work we need to configure the routes.rb
file so that the comments are seen as a nested resource for each of the other models.
map.resources :articles, :has_many => :comments map.resources :photos, :has_many => :comments map.resources :events, :has_many => :comments
If we go to URL that shows the comments for a given article (/articles/1/comments
), the index
action in the CommentsController
will be executed, and here we find a problem.
def index @comments = Comment.all end
The same index action will be called whether we get the comments for an Article
, a Photo
or an Event
. We need a way for the index
action to know which comments to return. One way to do this is to loop through the parameters passed to the action to look one called <parent_resource>_id
which will enable us to know which of the commentable
models we’re dealing with. We can write a method to do this and add it to the CommentsController
.
def find_commentable params.each do |name, value| if name =~ /(.+)_id$/ return $1.classify.constantize.find(value) end end nil end
The method above loops through each of the params and looks for one ending in _id
. If we were looking for the first Article
’s comments then there should be a parameter called article_id
with a value of 1
.
If the method finds a matching parameter it calls classify
on the part of the name before the _id
to turn in it from a table name in to a model name (so “articles”
; or “article”
will become “Article”
) and then calls constantize
on that which tries to find a constant to match the name in the string returned by classify
. Finally find
is called on that constant with the value of the matching key to return the commentable
record whose comments we’re looking for. In our example it will return the first Article
.
We can now use this method in the index action to get the appropriate comments.
def index @commentable = find_commentable @comments = @commentable.comments end
Adding a Comment
As well as listing the comments on the index
page we want users to be able to add a new comment. Here’s what the view code looks like.
<h1>Comments</h1> <ul id="comments"> <% @comments.each do |comment| %> <li><%= comment.content %></li> <% end %> </ul> <h2>New Comment</h2> <% form_for [@commentable, Comment.new] do |form| %> <ol class="formList"> <li> <%= form.label :content %> <%= form.text_area :content, :rows => 5 %> </li> <li><%= submit_tag "Add comment" %></li> </ol> <% end %>
The first part of the view code renders the comments as an unordered list. Below that is the form for adding a comment. As the form is for a nested resource we pass an array to form_for
; the first element in the array being the commentable
object we returned from find_commentable
and the second being a new Comment
.
When the form is submitted it will call the create
action of the CommentsController
.
def create @commentable = find_commentable @comment = @commentable.comments.build(params[:comment]) if @comment.save flash[:notice] = "Successfully saved comment." redirect_to :id => nil else render :action => 'new' end end
The first thing to note here is that we use find_commentable
again to get the current commentable record, be it an Article
,Photo
or Event
. Once we’ve got it we use build
to create the new Comment
. Once we’ve saved the model we have a problem knowing which index
page to redirect back to as we don’t know what resource the comment is nested under. We can take advantage of a small hack and redirect to an id
of nil
. That will redirect back to the current index action and show the right page.
Our comment form works and redirects back to the index action.
Our comment form now works. We’ve added a comment and it has redirected back to the correct page. This will work too with comments for the Photo
and Event
models.
An Exercise For The Reader
The next thing we’d have to do would be to apply what we’ve done to the index
and create
actions to the other actions in the CommentsController
. That will be left as an exercise for the reader.