homeASCIIcasts

154: Polymorphic Association 

(view original Railscast)

Other translations: Cn

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.

The site we want to add comments to.

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

We can now add a comment to an article, a photo or an event.

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.