#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.