#154 Polymorphic Association (revised)
- Download:
- source codeProject Files in Zip (321 KB)
- mp4Full Size H.264 Video (19.5 MB)
- m4vSmaller H.264 Video (11.6 MB)
- webmFull Size VP8 Video (14.8 MB)
- ogvFull Size Theora Video (24.4 MB)
The application below has three different models: articles, photos and events.
We want to give users the ability to add comments to all three of these. One option would be to add a separate comment model for each type, giving us an ArticleComment model, a PhotoComment model and an EventComment model. This would involve a lot of work and create duplication in our application, however, especially as the three types of comment should all have the same behaviour and attributes. When we’re faced with a situation like this we should consider using a polymorphic association. In this episode we’ll show you how to do this.
Creating a Single Comment Model
To start we’ll create a single model for comments that we’ll call Comment and which we’ll give a content field. To set up a polymorphic association we have to ask ourselves what the other models that this one relates to have in common. In this case they’re all commentable and so we’ll add two more fields to this model called commentable_id and commentable_type.
$ rails g model comment content:text commentable_id:integer commentable_type:string
The generated migration looks like this:
class CreateComments < ActiveRecord::Migration def change create_table :comments do |t| t.text :content t.integer :commentable_id t.string :commentable_type t.timestamps end add_index :comments, [:commentable_id, :commentable_type] end end
The name of the class is stored in commentable_type and Rails will use this along with the commentable_id to determine the record that the comment is associated with. As these two columns will often be queried together we’ve added an index for them. A polymorphic association can be specified in a different way by calling belongs_to, like this:
class CreateComments < ActiveRecord::Migration def change create_table :comments do |t| t.text :content t.belongs_to :commentable, polymorphic: true t.timestamps end add_index :comments, [:commentable_id, :commentable_type] end end
This will generate the id and type columns for us. We can now generate the new table by running rake db:migrate.
Next we’ll need to modify our Comment model and add a belongs_to association for commentable.
class Comment < ActiveRecord::Base attr_accessible :content belongs_to :commentable, polymorphic: true end
By default the commentable_id and commentable_type fields are added to the attr_accessible list but as we don’t need these fields to be accessible through mass assignment we’ve removed them. Next we need to go into each of the other models and set the other side of the association. It’s important to specify the as option here and set it to the other name of the association, in this case commentable.
class Article < ActiveRecord::Base attr_accessible :content, :name has_many :comments, as: :commentable end
We’ll do the same thing to the Event and Photo models too. We can then use this just like any other has_many association and we’ll demonstrate this in the console by adding a new comment to an article.
1.9.3p125 :001 > a = Article.first Article Load (0.2ms) SELECT "articles".* FROM "articles" LIMIT 1 => #<Article id: 1, name: "Batman", content: "Batman is a fictional character created by the arti...", created_at: "2012-05-27 08:35:54", updated_at: "2012-05-27 08:35:54"> 1.9.3p125 :002 > c = a.comments.create!(content: "Hello World") (0.1ms) begin transaction SQL (18.5ms) INSERT INTO "comments" ("commentable_id", "commentable_type", "content", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) [["commentable_id", 1], ["commentable_type", "Article"], ["content", "Hello World"], ["created_at", Sun, 27 May 2012 18:43:42 UTC +00:00], ["updated_at", Sun, 27 May 2012 18:43:42 UTC +00:00]] (2.4ms) commit transaction => #<Comment id: 1, content: "Hello World", commentable_id: 1, commentable_type: "Article", created_at: "2012-05-27 18:43:42", updated_at: "2012-05-27 18:43:42">
When the comment is created Rails automatically sets the commentable_type attribute to “Article” so that it knows which type of model the comment is associated with. If we want to go the other way and determine which article a comment belongs to we can’t just call comment.article as this is completely dynamic. Instead we need to call commentable.
1.9.3p125 :003 > c.commentable Article Load (0.3ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = 1 LIMIT 1 => #<Article id: 1, name: "Batman", content: "Batman is a fictional character created by the arti...", created_at: "2012-05-27 08:35:54", updated_at: "2012-05-27 08:35:54">
This method could also return a Photo, an Event or any other model we want the comment to relate to.
Using a Polymorphic Association in our Application
Now that we know how polymorphic associations work how do we use them in our application? In particular how do we use nested resources so that we can use a path like /articles/1/comments to get the comments for a given article. First we’ll create a CommentsController so that we have a place to list comments. We’ll give it a new action too so that we can create comments.
$ rails g controller comments index new
We want comments to be a nested resource under articles, photos and events so we’ll need to modify our routes file.
Blog::Application.routes.draw do resources :photos do resources :comments end resources :events do resources :comments end resources :articles do resources :comments end root to: 'articles#index' end
These routes will direct to the CommentsController that we generated. In the index action we want to fetch the comments for whatever commentable model was passed in. To do this we need to fetch the commentable record that owns the comments but for now we’ll assume that the id passed in belongs to an Article.
class CommentsController < ApplicationController def index @commentable = Article.find(params[:article_id]) @comments = @commentable.comments end def new end end
In the view template we’ll loop through the comments and display them.
<h1>Comments</h1> <div id="comments"> <% @comments.each do |comment| %> <div class="comment"> <%= simple_format comment.content %> </div> <% end %> </div>
When we visit /articles/1/comments now we’ll see the one comment that we added to that article earlier. (We’ve already added some CSS to the comments.css.scss file.)
When we try visiting the comments page for a photo this won’t work as the code is trying to find an article, even though no article_id has been passed in. To fix this we’ll need to make the find in the controller more dynamic. We’ll move the code to find the related model into a before_filter. We need to determine the name of the commentable resource and its id. We’ll get these from request.path by splitting it at every slash and grabbing the second and third elements so if the path is /photos/1 these will be the two elements used. We can use these to set @commentable by calling singlularize.classify.constantize to get the class of the model and calling find on that to get the instance by the id.
class CommentsController < ApplicationController before_filter :load_commentable def index @comments = @commentable.comments end def new end private def load_commentable resource, id = request.path.split('/')[1,2] @commentable = resource.singularize.classify.constantize.find(id) end end
This is the easiest way to do this but it introduces a lot of coupling between the controller and the format of the URL. If we’re using custom URLs we could use a different technique, like this:
def load_commentable klass = [Article, Photo, Event].detect { |c| params["#{c.name.underscore}_id"]} @commentable = klass.find(params["#{klass.name.underscore}_id"]) end
This will take each of the commentable classes and look in the params for one matching the class name followed by _id. We’ll then use the class that matches to find one matching the id. when we try viewing the comments for a photo now the page works.
Adding Links
Next we’ll look at how to deal with links. How do we make a link for adding a comment to a commentable item? If the comments page was just for photos we could use the path new_photo_comment_path and pass in a photo. We need to support articles and events too so this approach won’t work here. What we can do is pass in an array so that Rails generates the call dynamically based on what we pass in, like this:
<p><%= link_to "New Comment", [:new, @commentable, :comment] %></p>
Rails will now generate the correct path dynamically based on the type of the @commentable variable. Clicking the link will take us to the CommentsController’s new action so next we’ll write the code to handle adding comments. The code for the new and create actions is fairly standard. In new we build a comment through the comments association while in create when the comment is saved successfully we redirect to the index action using the array technique that we used in the view to go to the article comments path, the photos comments path or the events comments path depending on what the new comment is being saved against.
def new @comment = @commentable.comments.new end def create @comment = @commentable.comments.new(params[:comment]) if @comment.save redirect_to [@commentable, :comments], notice: "Comment created." else render :new end end
Next we’ll write the view for adding a comment.
<h1>New Comment</h1> <%= form_for [@commentable, @comment] do |f| %> <% if @comment.errors.any? %> <div class="error_messages"> <h2>Please correct the following errors.</h2> <ul> <% @comment.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %> <div class="field"> <%= f.text_area :content, rows: 8 %> </div> <div class="actions"> <%= f.submit %> </div> <% end %>
This code is also fairly standard with a form for editing the comment’s content. One key difference is the array we pass to form_for so that it generates the URL correctly for the polymorphic association. When we reload the new comment page now we’ll see the form and when we add a comment we’ll be redirected back to the correct page.
From a user interface perspective it would probably be better if all the comment functionality was inline on the given model’s show page. This is easy to do if we move what we’ve created into a couple of partials. We’ll move the form from the “new comment” page into a partial at /app/views/comments/_form.html.erb and use that in the new template.
<h1>New Comment</h1> <%= render 'form' %>
In the the index view we’ll make a partial for the code that lists the comments.
<div id="comments"> <% @comments.each do |comment| %> <div class="comment"> <%= simple_format comment.content %> </div> <% end %> </div>
This leaves the index template looking like this:
<h1>Comments</h1> <%= render 'comments' %> <p><%= link_to "New Comment", [:new, @commentable, :comment] %></p>
Now in the show template for each commentable model we can add these partials.
<h1><%= @article.name %></h1> <%= simple_format @article.content %> <p><%= link_to "Back to Articles", articles_path %></p> <h2>Comments</h2> <%= render "comments/comments" %> <%= render "comments/form" %>
We also need to modify the show action to prepare the instance variables for these partials.
def show @article = Article.find(params[:id]) @commentable = @article @comments = @commentable.comments @comment = Comment.new end
We could alternatively do some some renaming and change the partials so that we don’t need to specify all these instance variables. Whichever approach we take we’ll need to make the same changes to the photos and events controllers and views.
Finally in the CommentsController we’ll need to change the redirect behaviour so that when a comment is successfully created the user is redirected back to that commentable show action instead of the index action.
def create @comment = @commentable.comments.new(params[:comment]) if @comment.save redirect_to @commentable, notice: "Comment created." else render :new end end
Let’s try this out. If we visit an article now we’ll see its comments along with the form for creating a new one. When we add a comment we’ll be redirect back to the same page with the new comment showing.
We can easily apply these same steps to photos and events to add inline commenting behaviour.


