Header logo.
Small Hallucinations
HomeArchiveTagsAboutFeed

Adding a new context and define associations in Phoenix

I'm still doing Dockyard's tutorial of Elixir and Phoenix.

So far I've progressed to building a blog context. Now I'm trying to add a comment context to the app. This would also allow the user to post a comment and list all the comments below a post.

This exercise is designed to teach you about how to create one-to-many relations in Phoenix.

Although the concept is easy to grasp, there are a few steps you need to take and quite a few files you need to change.

The rest of this post shows how I did it. The idea is to remind myself and show other beginners of Phoenix how to do it on a nitty-gritty level. I can't guarantee this is best practice.

SPOILER ALERT If you are also doing this exercise, I strongly recommend you to work on it on your own first.


Steps

For the sake of clarity, suppose we are adding comments to posts.

  1. generate a new context (comments), edit migration file and migrate
  2. change schemas to specify the association between them
  3. change posts repo file to preload comments
  4. change router file to specify which controller function should handle requests made to comments
  5. change comment controller to handle those requests
  6. change post controller to add comment_changeset
  7. change post template to display and edit comments

1. generate a new context; edit migration file and migrate

Generate a context by doing this:

mix phx.gen.context Comments Comment comments content:text post_id:references:posts

Generate a migration file (I forgot if the last command would generate a migration file. So this may or may not be necessary.):

mix ecto.gen.migration add_comments

In the generated empty migration file, add this. This defines a comments table of two fields. null: false makes sure a comment must point to a post.

def change do
  create table(:comments) do
    add :content, :text
    add :post_id, references(:posts, on_delete: :nothing), null: false

    timestamps()
  end

Run the migration file.

2. schemas

In lib/blog/posts/post.ex (this file defines the posts schema). Add:

defmodule Blog.Posts.Post do
  # ...

  schema "posts" do
    # ... other fields ...
    has_many :comments, Blog.Comments.Comment
    # ...
  end
  # ...

In lib/blog/comments/comment.ex (this file defines the comments schema).

defmodule Blog.Comments.Comment do
  use Ecto.Schema
  import Ecto.Changeset

  schema "comments" do
    field :content, :string
    # ADD belongs_to
    belongs_to :post, Blog.Posts.Post

    timestamps()
  end

  @doc false
  def changeset(comment, attrs) do
    comment
    # ADD post_id
    |> cast(attrs, [:content, :post_id])
    |> validate_required([:content])
    |> foreign_key_constraint(:post_id)
  end
end

post_id is added to the cast function so that the created comments will accept and store a post_id value.

3. posts repo

In lib/blog/posts.ex add preload to preload comments. This file defines the Blog.Posts module along with functions to get, create and change posts.

This way, comments associated with the post record will be preloaded. So that the HEEx template will have something to display.

def get_post!(id) do
  Repo.get!(Post, id)
  |> Repo.preload(:comments) # ADDED
end

4. router

In lib/blog_web/router.ex (where the router is defined), add a route to handle the post request made to /comments. This request is used to create a comment.

scope "/", BlogWeb do
  pipe_through :browser

  get "/", PageController, :home
  post "/comments", CommentController, :create # ADDED
  resources "/posts", PostController
end

5. comment controller

At lib/blog_web/controllers/comment_controller.ex create a comment controller. This is actually directly taken from Dockyard tutorial.

defmodule BlogWeb.CommentController do
  use BlogWeb, :controller

  alias Blog.Posts
  alias Blog.Comments

  def create(conn, %{"comment" => comment_params}) do
    case Comments.create_comment(comment_params) do
      {:ok, comment} ->
        conn
        |> put_flash(:info, "Comment created successfully.")
        |> redirect(to: ~p"/posts/#{comment.post_id}")

      {:error, %Ecto.Changeset{} = comment_changeset} ->
        post = Posts.get_post!(comment_params["post_id"])
        render(conn, :show, post: post, comment_changeset: comment_changeset)
    end
  end
end

6. post controller

In lib/blog_web_controllers/post_controller.ex, because we are trying to show comments below each blog post, we need to add a comment_changeset to the show function.

def show(conn, %{"id" => id}) do
  post = Posts.get_post!(id)
  comment_changeset = Comments.change_comment(%Comment{})
  render(conn, :show,
    post: post,
    comment_changeset: comment_changeset
    )
end

7. template

In lib/blog_web/controllers/post_html/show.html.heex, we need to add these lines to be able to create and display the comments. (A lot of the code in this part is also taken from the tutorial.)

<.simple_form :let={f} for={@comment_changeset} action={~p"/comments"}>
  <.input type="hidden" field={f[:post_id]} value={@post.id} />
  <.input field={f[:content]} type="text" label="Content" />
  <:actions>
    <.button>Comment</.button>
  </:actions>
</.simple_form>

<%= for comment <- @post.comments do %>
  <p><%= comment.content %></p>
<% end %>