Subscribe to access all episodes. View plans →

#2: Adding Comments with Ecto Associations

Published January 23, 2017

Elixir 1.3.4

Phoenix 1.2.1

Episode source code on GitHub


Let’s pick up where we left off in Getting Started with Phoenix.

Now that we can create posts on our blog, let’s take the next step and add comments.

We’ll use another Phoenix task, this time to generate the model. We’ll use a singular module name ‘Comment’, and the plural version ‘comments’ for the database table.

Then a list of attributes for our comments. In this case we’ll want them to have a body that’s a text. And since comments will belong to a post, we’ll create a ‘post_id’ and reference posts. This will help setup our association.

$ mix phoenix.gen.model Comment comments body:text post_id:references:posts

Now before we migrate our database let’s look at the comment model it generated.

Since we referenced ‘posts’, the belongs_to :post association was setup for us. Now let’s setup the other half of this relationship.

We’ll open our post module and add the has_many :comments association to the schema.

web/models/post.ex

defmodule Teacher.Post do  
  use Teacher.Web, :model

  schema "posts" do
    field :title, :string
    field :body, :string
    has_many :comments, Teacher.Comment
  end
  ...
end

Since comments and posts have an association what would be the best way to delete all comments associated with a post if that post is deleted?

To do this let’s go to the database migration that was just created for comments: priv/repo/migrations/{{timestamp}}_create_comment.exs

When we add the ‘post_id’ column, we’re given the :on_delete option. By default it’s set to :nothing. If we change it to :delete_all this will trigger all of a post’s comments to be deleted along with it. With that changed, we can run our database migration.

$ mix ecto.migrate

Now we need to add a form to our blog so people can add comments. Let’s start by adding a new controller named comment_controller.ex. And in it we’ll define a create function. It will take the connection and pattern match to get the comment params and the post id.

web/controllers/comment_controller.ex

defmodule Teacher.CommentController do
  use Teacher.Web, :controller

  alias Teacher.Post

  def create(conn, %{"comment" => comment_params, "post_id" => post_id}) do
    post = Repo.get(Post, post_id)
    comment_changeset = Ecto.build_assoc(post, :comments, body: comment_params["body"])
    Repo.insert(comment_changeset)

    conn
    |> put_flash(:info, "Comment created successfully.")
    |> redirect(to: post_path(conn, :show, post))
  end
end

Let’s walk through what this function’s doing.

Inside we lookup the post with the post_id.

Then build a comment changeset. Ecto.build_assoc/3 builds an association between comment and post and sets the value of the body key from our comment params as our comment’s body attribute.

Finally we’ll insert it into the database and then redirect to our post.

With our controller good-to-go, let’s build the view for our comments, adding a CommentView module.

web/views/comment_view.ex

defmodule Teacher.CommentView do
  use Teacher.Web, :view
end

Now we can add our template. First we need to create a comment directory. And then a file in that directory named new.html.eex

Then we can add our form:

web/templates/comment/new.html.eex

<%= form_for @comment_changeset, post_comment_path(@conn, :create, @post), fn f -> %>
  <%= label f, :body, class: "control-label" %>
  <%= text_input f, :body, class: "form-control" %>
  <%= error_tag f, :body %>
  <%= submit "Submit", class: "btn btn-primary" %>
<% end %>

We’ll use the Phoenix form_for helper passing in our comment changeset and our action, which we’ll define in our routes later.

Now that we have our form, let’s display it beneath our posts.

Let’s open our post’s show template. We’ll render our new template here, using the render/3 function. It takes the module, in this case Teacher.CommentView, the template, and a set of assigns, which will be our connection, post, and comment changeset. All three are used in our template.

web/templates/post/show.html.eex

...
<%= render Teacher.CommentView, "new.html", 
                                conn: @conn, 
                                post: @post, 
                                comment_changeset: @comment_changeset %>
...

Now we need to add the route that our comment form will post to.

So let’s open up our router. And we’ll add our comments resource just below our posts.

web/router.ex

defmodule Teacher.Router do
  ...
  resources "/posts", PostController
  resources "/comments", CommentController
  ...
end

Since comments have an association with posts, let’s nest comments in posts. This will include the post’s id in the params, making it easier for us to associate the comment with its post. And for the purposes of this demo we don’t need all of the routes that resources macro builds. So let’s limit this to only have a route to create comments.

web/router.ex

defmodule Teacher.Router do
  ...
  resources "/posts", PostController do
      resources "/comments", CommentController, only: [:create]
  end
  ...
end

Now let’s go to a post in our browser. And we’ll see an error.

This is because we are calling the comment changeset in our comment form, but haven’t defined it. Let’s fix that.

We’ll go to our PostController and update the show function. Creating the comment_changeset that our form needs. Then we’ll pass in the comment_changeset so it can be accessed by our template.

There’s one quick improvement we can make to this. Having to write Teacher.Comment every time we want to use comment is a little long, but Elixir provides a simple way for us to shorten it and just write Comment.

Going to to top of our controller, let’s update our alias to include comment in addition to post. Now we can go back and remove Teacher from Teacher.Comment.

web/controllers/post_controller.ex

defmodule Teacher.PostController do
  use Teacher.Web, :controller

  alias Teacher.{Post, Comment}
  ...
  def show(conn, %{"id" => id}) do
    post = Repo.get!(Post, id)
    comment_changeset = Comment.changeset(%Comment{})
    render(conn, "show.html", post: post, comment_changeset: comment_changeset)
  end
  ...
end

Now if we go back to our post - we see our form.

Let’s test it out and add some comments.

And if we check our database directly, we can see that our comments were saved.

Now we just need to display it below our post.

Going back to our show action on our PostController we’ll need to explicitly tell Ecto that we want to load all the comments for our post.

web/controllers/post_controller.ex

  def show(conn, %{"id" => id}) do
    post = Repo.get(Post, id)
    post = Repo.preload(post, :comments)
    comment_changeset = Comment.changeset(%Comment{})
    render(conn, "show.html", post: post, comment_changeset: comment_changeset)
  end

This will work, but let’s update it to take advantage of one of Elixir’s great features the pipe operator.

The pipe operator will take whatever is returned and pass it in as the first argument of the subsequent function call on it’s right side.

So rewriting this - Post gets passed in initially as the first argument in Repo.get/3, which then returns a post struct.

That post struct is then passed in as the first argument of Repo.preload/3.

web/controllers/post_controller.ex

  def show(conn, %{"id" => id}) do
    post = Post
           |> Repo.get(id)
           |> Repo.preload(:comments)
    comment_changeset = Teacher.Comment.changeset(%Teacher.Comment{})
    render(conn, "show.html", post: post, comment_changeset: comment_changeset)
  end

Now that we’re loading our comments, let’s update our Post’s show template.

web/templates/post/show.html.eex

...
<%= for comment <- @post.comments do %>
  <%= comment.body %>
<% end %>
...

Here we’re just looping through each of our post’s comments and displaying it’s body.

And if we come back to our post - we can see it’s comments.

© 2024 HEXMONSTER LLC