Skip to main content

58 Comments With Polymorphic Associations

Episode 36 · December 19, 2014

Learn how to set up polymorphic associations, add comments to your app, and think about the structure of your Rails application

ActiveRecord


Transcripts

In this episode, we're going to talk about polymorphic associations and what they are. I've got a movie database with films and actors, and I want to add comments on both. When you click on a movie, you should be able to see comments down here and when you click on actors, you should be able to see comments underneath their profile as well.

If we were to do this normally, we would probably add comments and then we would have a Film ID, and that would be an integer and then we'd have an actor ID and that would be an integer, and we'd have a belongs_to :film and belongs_to :actor and so on, but that's not actually how we want to do this. We want to use this comment model a little bit more generically so we don't have to specify each and every individual association between the two, and luckily for us, Rails provides polymorphic associations, that allow you to create a generic association like in this case imageable and when you belongs_to :imageable you can add polymorphic: true and that lets you say that employees can have many pictures and products can have many pictures and they share the same association.

We can make comments, also polymorphic and we'll just make our things, that we want to comment on commentable. So let's go and do that here

rails g model Comment commentable_type:string commentable_id:integer

Rails is going to store the string "film" or "actor" in the commentable_type and to know which record it's going to be associated with, it's going to use the commentable_id.

We will combine the two to join the association and look up the record, and it will also assign both of those when you assign the commentable to the comment.

We'll also add a regular user_id and our body which will be text and we run

rake db:migrate

to add these to our app. in the film and in the actor model, we add:

app/models/actor.rb
app/models/film.rb
has_many :comments, as: :commentable

and in:

app/models/comment.rb
belongs_to :commentable, polymorphic: true

This is going to allow us to assign films or objects like that to commentable on a comment. In the rails console, we look up the comments of the first film with

Film.first.comments

and you'll see that whenit queries for it, it plugs in the commentable_id is one, and then film is the type searched against the commentable type. Rails automatically handles filling out these two columns instead of the single ID like it normally does. Here we can just say, if we want to create a comment inside the controller or the console here, we can do it through the association or we can do it directly through building a new comment where the commentable object is the first film and we're going to leave the user_id blank for now and the body will be "testing".

When we create that, you can see that it does the same thing, it inserts the commentable attributes there, and now when we grab the first film's comments, we receive this comment back. So it's properly associated and this is the way that you probably want to test most of your associations in the console because it's a lot less work to make sure they're correct than trying to do that in your Rails app immediately.

If we go into our films, and to the show view, we can add:

app/views/films/show.html.erb

<h3>Comments</h3>
<% @film.comments.each do |comment| %>
    <div class="well">
        <%= comment.body %>
    </div>
<% end %>

Once we refresh our movie database page for 'The Matrix', we can see that there's the comment there. Now we have a working polymorphic association and we can go do the same thing in our console to create a comment for the first actor. This past time, I've created the comment in the console and assigning commentable, and if we do it the other way around, with Actor.first.comments.create(), this will automatically set the commentable_type for us and we just need to say body: "Whatever". It does the same thing, and sets the body but then it also set the commentable type to actor and the id to 1 because it's the first actor.

We can refesh this page and we don't get to see the comments but we want to have the same generic comments section there.

We can pull the bit of code on top into a comment's partial and we'll render the partial and add in the film show page

<%= render partial: "comments/comments", locals: {commentable: @film} %>

We will also add this last code to our actors show page passing in @actor as the commentable object instead of the film

We'll create the app/views/comments directory and inside of there we will create a comments partial that will be used for the commentable objects. We will render the exact same view for every different type. The reason why we passed in the commentable local variable into this partial is because we can take @film and pass that in and use commentable here, and then that means we can take the same render partial and go into the actors show action and paste that in but this time we pass in actor as the local variable instead and we never have to change our partial for the comments and it will properly work. We are going to use the exact same partial, we'll pass in the different commentable object, it will get that object's comments and display them. So we have the exact same views, same code. It's reused in both places and it's really really flexible. That is basically the simplest way that you can get comments shared across multiple things, so we can add users, if users have a profile of their favorite movies or whatever, we could add comments to that or lists or anything like that. If you even wanted individual pictures to have comments you could do that as well and all you would have to do is to have this line and replace the object here that's commentable and this partial will magically work for you.

Now you're probably thinking that's cool and all but how do I make these connected put it in the controllers and all of that, there's a lot of work left to do, right?

The way that I designed it, it actually turns out that it's not too much work to implement controllers to handle this. So going to our actors/show action, what if we just render a partial:

`<%= render partial: "comments/form", locals: {commentable: @actor} %>

We will also have commentable as our local variable here and we save it, and we go to films/show and do the same thing (passing the film object), this way we can render a form that is for a commentable object. That way we can use the same form and reuse it between the two and then hopefully we'll be able to design our routes to separate the two out and share all that code magically between them.

Now that we have this, we actually need to go create that comments form. I’m going to add the

app/views/comments/_form.html.erb

<%= form_for [commentable, Comment.new] do |f| %>
<% end %> 

We use the square bracket in the form_for to create an array and pass in the commentable and the new comment that we want to create. This is going to generate an interesting form for us, what it's going to do is to make a post request when commentable is a film, it's going to go to /film/1/comments but if you have an actor passed in, it's going to go to /actor/1/comments, so this is nifty, it's going to pass in to two separate controllers, those controllers can set the commentable first and then we can have a generic CREATE action that will just add the new comment to that commentable. This is an interesting way of structuring your controller, and you don't have to do that, you can use the generic comments controller, and then that could have the comment's for that and it could be just generic and you could add the comment.new and then in a hidden field:

<%= f.hidden_field :commentable_type, value: "Film" %>

You could do that if you wanted, but the way that I want to design this, I think you'll find interesting separating out your controllers:

app/views/comments/_form.html.erb

<%= form_for [commentable, Comment.new] do |f| %>
    <div class="form-group">
        <%= f.text_area :body, class: "form-control", placeholder: "Add a comment" %>
    </div>
    <%= f.submit class: "btn btn-primary" %> 
<% end %> 

Since we have our routes, we have our resources for actors and films and the typical way you'll see a lot of people do this is add resources for comments. This is going to have to deal with all the different things of what is the commentable thing and do we redirect to different locations afterwards and all of that.

I don't want to deal with that and I actually would like our code in the file structure to represent that actors have comments and films have comments and have that run through our application. What we're going to do, is that we're going to add a resources :comments to the resources :actors as a nested route, and that will be what will wire up the form that we just created, and then we're going to add the module as actors, so this is going to go to the actors/comments controller and we'll do the same thing for films

config/routes

resources :actors do
    resources :comments, module: actors
end

resources :films do
    resources :comments, module :films
end

Before creating these individual actors comments comments controller and the films comments controller, let's actually create a generic one in our

app/controllers/comments_controller.rb

class CommentsController < ApplicationController
    before_action :authenticate_user!

    def create
        @comment = @commentable.comments.new comment_params
        @comment.user = current_user
        @comment.save
            redirect_to @commentable, notice: "Your comment was successfully posted."
    end

    private

        def comment_params
            params.require(:comment).permit(:body)
        end
end

When we write the create action, we set up the user manually to the current user and then we'll save the comment and redirect them to @commentable.

The private method for comment_params will just need to permit the body because the commentable type will be set beforehand and if you were to do this in a generic controller and accept the hidden fields, you could accept the commentable type and ID.

Because we're setting it up this way, we've set up this generic controller but never did we set commentable, and the reason for that is because now we can go to the comments/controllers that are specific to films and actors. We can start creating this if we add:

app/controllers/films/comments_controller.rb

class Films::CommentsController < CommentsController
    before_action :set_commentable

    private 
        def set_commentable
            @commentable = Film.find(params[:film_id])
        end
end

since the module is films, we have to put the module and the class here, and it's going to be the CommentsController, but instead of inheriting from application controller we can inherit from comments controller, doing this will allow us to inherit this create and comment_params methods, and then we can set our before action to :set_commentable, we can define a private set_commebtable and this will automatically set the commentable object to the film before the create action gets called, which is inherited, and all of this flows now; and this form_submit will talk to the films::CommentsController which then sets the commentable object, and then it goes to the CommentsController for the create action, creates a new one, sets the user, saves it, and then redirects you back. This doesn't really have much in it, but all of the stuff that's very specific to film comments, such as setting the commentable film is there, and it's very easy for anybody to jump in and understand that, and they can see that really there's not much custom logic for film comments, and if there ever becomes that, we're already sort of ready.

Devise takes a very similar approach in the way that you override their controllers if you've ever dove into that, you can override just the actions and the pieces of functionality that you need to override, and I think that's very useful for making a very clean controller like this.

Now we can edit the actors controller and do the same thing for their comments

app/controllers/actors/comments_controller.rb

We can take the same code that we did for films and replace the instances of Films for Actors.

Both of these controllers now have a little bit of duplication but in a sense, it's a good piece to duplicate because if you start to move this out so that it's a little bit more flexible, one of the problems with creating those hidden fields and permitting content on commentable_type and ID, is that anybody can submit in other data. This is not going to be automatically handled by Rails and protected, unless you do it this way with the specific URL's for it. That's a good reason to do this, you can also update these find actions to add in Can-Can or Pundit so that a user can only comment on them that are publishable or the ones that they're allowed to do. You can add in permissions on a model by model benefit basis on the comments section. So you may or may not need to do that, but you're already kind of set up and ready to go for it. Let's check this out in our application.

When I refresh the Matrix page, we've got our new "Add a comment" here, and the moment of truth. We'll create a comment and the comment was created.

That is all functional, and you know that it's posting to the right film because it showed up there, but you can verify that by scrolling down into our logs and see the Started POST "/films/1/comments", and that it was processed by the Films::CommentsController#create, and it inserted into the comments table, which means that it had to have gone into the generic comments controller. That has properly worked, and we can test it on actors as well.

If we create a comment on Keanu Reeves, we should see that it saved here as well, and there's no way for us to edit this html and pass in a hidden field that comments in here but let's actually send it somewhere else. If anyone is trying to do some malicious things to your comments, it's already set up to handle those things appropriately as it should.

One thing that I don't think a lot of people put much attention in, is viewing your Rails application as a design piece of your architecture of your app. The way this works is that we have to duplicate those routes each time, and have the comments controller duplicate it each time, but that's not a bad thing. If you look at our controller’s folder, we have actors, we have comments and we have films that gives us a higher level overview of it but now we can dive in to just actors and we can see the actors have comments.

So if we're just browsing these folders in our Rails application we can actually get an idea of what functionality belongs to films and what belongs to actors just by looking at the files that exist and where they exist. If you didn't do that, then you wouldn't have these folders and you wouldn't have the comments controllers inside of them. You just have comments controller here, and if you glanced at that: Imagine the films and actors folders don't exist and you would see that you have actors, comments and films, but you don't really know where the comments belong to. This structure allows us to visualize that just from looking at the Rails app. I think that's a very important piece of functionality and that's why Rails is designed with the assets, controllers, helpers, mailers, models and views.

This is an important piece of your application, for someone new to jump in, or for you in six months to return to the app and really just in a moment's notice remember everything that it did.

That's something to definitely take into account when you're designing the way that your applications work. Same thing goes with views (you can see that there's comments), but if you go into films, you don't actually know that there are comments there. That's the example, or the counterexample to doing this. And because you want these views to be generic, you want to actually set this up, and you don't need to copy the comments partial into each of the actors and films folders because that's quite a bit of stuff.

These are organized in a different manner than the actual Rails application itself, so internally in the views folder you want stuff just specific to actors to be in there, just specific to comments to be its own thing.

I hope that's useful, it's definitely an interesting level of looking at an applications design that most people don't go into, so I hope that was helpful for you, and there's a billion different ways to do this, so by all means, take this with a grain of salt, see if you like the structure or not, and as your applications grows determine if this is useful for you or not.

Transcript written by Miguel

Discussion