At present our data model covers Category and Thread. Discussion in Threads occurs via Posts. We'll add the ability to create a new Post, and we'll create a new Post in a Thread when we create a Thread since a Thread without a Post is extremely boring. Let's get started.

Project

We're starting with the dailydrip/firestorm repo tagged before this episode.

We'll create posts by a user on a thread - they just have a body. We won't generate a controller or anything for now.

mix phx.gen.schema Forums.Post forums_posts body:text thread_id:references:forums_threads user_id:references:forums_users

Since we're referencing threads and threads can be deleted, we'll open up the migration and specify that when a parent thread is deleted, all the posts in that thread are deleted as well:

defmodule FirestormWeb.Repo.Migrations.CreateFirestormWeb.Forums.Post do
  use Ecto.Migration

  def change do
    create table(:forums_posts) do
      add :body, :text
      add :thread_id, references(:forums_threads, on_delete: :delete_all) # <---
      add :user_id, references(:forums_users, on_delete: :nothing)

      timestamps()
    end

    create index(:forums_posts, [:thread_id])
    create index(:forums_posts, [:user_id])
  end
end

Now we'll add the ability to create a post on a thread to our Forums context. We'll start with a test:

vim test/forums_test.exs

We'll generate a user, category, and thread to work with inside a describe block:

# ...
  describe "posting in a thread" do
    setup [:create_user, :create_category, :create_thread]

    # And we'll pass the thread and user when creating the post, verifying that
    # it is created as expected.
    test "creating a post in a thread", %{thread: thread, user: user} do
      {:ok, post} = Forums.create_post(thread, user, %{body: "Some body"})
      assert post.thread_id == thread.id
      assert post.user_id == user.id
      assert post.body == "Some body"
    end
  end

  def create_category(_) do
    category = fixture(:category, @create_category_attrs)
    {:ok, category: category}
  end
  def create_thread(%{category: category}) do
    thread = fixture(:thread, category, @create_thread_attrs)
    {:ok, thread: thread}
  end
  def create_user(_) do
    user = fixture(:user, @create_user_attrs)
    {:ok, user: user}
  end
# ...

If we run the test it will fail because there's no create_post function. We can add that:

vim lib/firestorm_web/forums/forums.ex
defmodule FirestormWeb.Forums do
  # ...
  alias FirestormWeb.Forums.Post

  def create_post(%Thread{} = thread, %User{} = user, attrs) do
    attrs =
      attrs
      |> Map.put(:thread_id, thread.id)
      |> Map.put(:user_id, user.id)

    %Post{}
    |> post_changeset(attrs)
    |> Repo.insert()
  end

  defp post_changeset(%Post{} = post, attrs) do
    post
    |> cast(attrs, [:body, :thread_id, :user_id])
    |> validate_required([:body, :thread_id, :user_id])
  end
end

Now we can create Posts. Transactions come in with the "first post". We want to make sure that no Thread can be created without a "first post". To do this, we'll modify create_thread to require that Post attributes be passed in as well when creating a Thread.

First, we'll modify the tests for creating a thread:

defmodule FirestormWeb.ForumsTest do
  # ...
  # We'll include the post body in the thread attributes, but the user will be
  # passed in as an argument to `create_thread`
  @create_thread_attrs %{title: "some title", body: "some body"}
  @update_thread_attrs %{title: "some updated title"}
  @invalid_thread_attrs %{title: nil}
  # ...
  def fixture(:thread, category, user, attrs) do
    {:ok, thread} = Forums.create_thread(category, user, attrs)
    thread
  end
  # ...
  # We'll also introduce a describe block to reduce some duplication regarding
  # creating users and categories
  describe "threads" do
    setup [:create_user, :create_category]

    test "list_threads/1 returns all threads", %{category: category, user: user} do
      thread = fixture(:thread, category, user, @create_thread_attrs)
      assert Forums.list_threads(category) == [thread]
    end

    test "get_thread! returns the thread with given id", %{category: category, user: user} do
      thread = fixture(:thread, category, user, @create_thread_attrs)
      assert Forums.get_thread!(category, thread.id) == thread
    end

    test "create_thread/1 with valid data creates a thread", %{category: category, user: user} do
      assert {:ok, %Thread{} = thread} = Forums.create_thread(category, user, @create_thread_attrs)
      assert thread.title == "some title"
    end

    test "create_thread/1 with invalid data returns error changeset", %{category: category, user: user} do
      assert {:error, %Ecto.Changeset{}} = Forums.create_thread(category, user, @invalid_thread_attrs)
    end

    test "update_thread/2 with valid data updates the thread", %{category: category, user: user} do
      thread = fixture(:thread, category, user, @create_thread_attrs)
      assert {:ok, thread} = Forums.update_thread(thread, @update_thread_attrs)
      assert %Thread{} = thread
      assert thread.title == "some updated title"
    end

    test "update_thread/2 with invalid data returns error changeset", %{category: category, user: user} do
      thread = fixture(:thread, category, user, @create_thread_attrs)
      assert {:error, %Ecto.Changeset{}} = Forums.update_thread(thread, @invalid_thread_attrs)
      assert thread == Forums.get_thread!(category, thread.id)
    end

    test "delete_thread/1 deletes the thread", %{category: category, user: user} do
      thread = fixture(:thread, category, user, @create_thread_attrs)
      assert {:ok, %Thread{}} = Forums.delete_thread(thread)
      assert_raise Ecto.NoResultsError, fn -> Forums.get_thread!(category, thread.id) end
    end

    test "change_thread/1 returns a thread changeset", %{category: category, user: user} do
      thread = fixture(:thread, category, user, @create_thread_attrs)
      assert %Ecto.Changeset{} = Forums.change_thread(thread)
    end
  end
  # ...
end

Now the tests won't pass until we modify the create_thread function to accept our new arguments:

defmodule FirestormWeb.Forums do
  # ...
  @doc """
  Creates a thread.

  ## Examples

      iex> create_thread(category, user, %{field: value})
      {:ok, %Thread{}}

      iex> create_thread(category, user, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_thread(category, user, attrs \\ %{}) do
    attrs =
      attrs
      |> Map.put(:category_id, category.id)
      |> Map.delete(:body)
    # We'll handle the user and the body in the attributes when creating a post,
    # but we'll leave that for later.

    %Thread{}
    |> thread_changeset(attrs)
    |> Repo.insert()
  end
  # ...
end

Now our tests all pass still, but we're passing the information we need to create a thread with a first post. If it's not clear why we need to use a transaction here, it's because we require a thread_id for a post, but we can't get that until we create the thread. However, we don't want to create a thread without a corresponding first post ever. If we couldn't use transactions, we'd have a hard tiem satisfying this requirement.

Luckily for us, Ecto.Multi makes dealing with transactions fantastic. Let's use it to first create the thread, then the post, and roll back if the post is invalid.

First, we'll write a test to verify that we have a post in our thread after creating the thread:

defmodule FirestormWeb.ForumsTest do
  # ...
  alias FirestormWeb.Forums.{User, Category, Thread, Post}
  # ...
  def fixture(:thread, category, user, attrs) do
    # We'll return a 2-tuple containing the thread and its first post, in the
    # case of success
    {:ok, {thread, _first_post}} = Forums.create_thread(category, user, attrs)
    thread
  end
  describe "threads" do
    # ...
    test "create_thread/1 with valid data creates a thread and its first post", %{category: category, user: user} do
      assert {:ok, {%Thread{} = thread, %Post{} = first_post}} = Forums.create_thread(category, user, @create_thread_attrs)
      assert thread.title == "some title"
      assert first_post.thread_id == thread.id
      assert first_post.body == "some body"
    end
    # ...
  end
  # ...
end

The test fails because it doesn't return what we're looking for. Let's introduce Ecto.Multi, create the post, and change our return value:

defmodule FirestormWeb.Forums do
  # ...
  alias Ecto.Multi
  alias FirestormWeb.Forums.{User, Category, Thread, Post}
  # ...
  @doc """
  Creates a thread.

  ## Examples

      iex> create_thread(category, user, %{field: value, body: "some body"})
      {:ok, {%Thread{}, %Post{}}}

      iex> create_thread(category, user, %{field: bad_value})
      {:error, :thread, %Ecto.Changeset{}}

  """
  def create_thread(category, user, attrs \\ %{}) do
    # We'll build as much of the post attributes we can for now - everything but
    # the thread id
    post_attrs =
      attrs
      |> Map.take([:body])
      |> Map.put(:user_id, user.id)

    # We'll also build the thread attributes a bit more explicitly
    thread_attrs =
      attrs
      |> Map.take([:title])
      |> Map.put(:category_id, category.id)

    # We'll generate a thread changeset
    thread_changeset =
      %Thread{}
      |> thread_changeset(thread_attrs)

    # And we'll start a new Ecto.Multi.
    # This is a data structure that identifies the changes that we wish to make.
    # We'll run it later in a `Repo.transaction`
    multi =
      # We create a new Multi with Multi.new
      Multi.new
      # We'll insert our thread. The first argument here is the key by which we
      # can refer to this operation when we get the results or when we use
      # intermediate values mid-transaction in future `Multi` functions
      |> Multi.insert(:thread, thread_changeset)
      # Once we've inserted the thread, we'll use `Multi.run` so we can
      # reference the resulting thread to extract its id
      |> Multi.run(:post, fn %{thread: thread} ->
        # We'll add the thread_id to our post attributes
        post_attrs =
          post_attrs
          |> Map.put(:thread_id, thread.id)

        # We generate the post changeset and insert it
        post_changeset =
          %Post{}
          |> post_changeset(post_attrs)
          |> Repo.insert
      end)

    # Now we've described the transaction. All that remains is to actually run
    # the transaction. This is accomplished by passing our Multi to
    # Repo.transaction.
    case Repo.transaction(multi) do
      # if it succeeds, we'll get an ok-tuple containing the result, which is a
      # map of our keys with the result of each operation. In this case, we'll
      # have a map with a `thread` and a `post` key.
      {:ok, result} ->
        # We'll return them in a 2-tuple, which is how I decided this return
        # should look.
        {:ok, {result.thread, result.post}}
      # In the event of an error, we get a 4-tuple containing :error, the key
      # that errored, the changeset for the error, and a map of the changes that
      # have occurred so far. We'll just return the thread changeset if there
      # was an error there.
      {:error, :thread, thread_changeset, _changes_so_far} ->
        {:error, :thread, thread_changeset}
      # Ditto for the post changeset if there's an error there.
      {:error, :post, post_changeset, _changes_so_far} ->
        {:error, :post, post_changeset}
    end
  end
  # ...
end

That's the overall gist of using Ecto.Multi to manage transactions. We can run the test to see that it passes. We'll also want to modify the rest of our code to use the new create_thread API. First, we'll handle the test for creating a thread with invalid data:

    test "create_thread/1 with invalid data returns error changeset", %{category: category, user: user} do
      assert {:error, :thread, %Ecto.Changeset{}} = Forums.create_thread(category, user, @invalid_thread_attrs)
    end

Then we can run the full test suite to find the remaining offenders. They appear to all be in the ThreadControllerTest:

defmodule FirestormWeb.Web.ThreadControllerTest do
  # ...
  @create_attrs %{title: "some title", body: "some body"}
  # ...
  setup do
    {:ok, category} = Forums.create_category(%{title: "Category"})
    {:ok, user} = Forums.create_user(%{username: "knewter", email: "josh@dailydrip.com", name: "Josh Adams"})
    {:ok, category: category, user: user}
  end

  def fixture(category, user, :thread) do
    {:ok, {thread, _first_post}} = Forums.create_thread(category, user, @create_attrs)
    thread
  end
  # ...
  test "renders form for editing chosen thread", %{conn: conn, category: category, user: user} do
    thread = fixture(category, user, :thread)
    conn = get conn, category_thread_path(conn, :edit, category, thread)
    assert html_response(conn, 200) =~ "Edit Thread"
  end

  test "updates chosen thread and redirects when data is valid", %{conn: conn, category: category, user: user} do
    thread = fixture(category, user, :thread)
    conn = put conn, category_thread_path(conn, :update, category, thread), thread: @update_attrs
    assert redirected_to(conn) == category_thread_path(conn, :show, category, thread)

    conn = get conn, category_thread_path(conn, :show, category, thread)
    assert html_response(conn, 200) =~ "some updated title"
  end

  test "does not update chosen thread and renders errors when data is invalid", %{conn: conn, category: category, user: user} do
    thread = fixture(category, user, :thread)
    conn = put conn, category_thread_path(conn, :update, category, thread), thread: @invalid_attrs
    assert html_response(conn, 200) =~ "Edit Thread"
  end

  test "deletes chosen thread", %{conn: conn, category: category, user: user} do
    thread = fixture(category, user, :thread)
    conn = delete conn, category_thread_path(conn, :delete, category, thread)
    assert redirected_to(conn) == category_thread_path(conn, :index, category)
    assert_error_sent 404, fn ->
      get conn, category_thread_path(conn, :show, category, thread)
    end
  end
end

With that, the tests don't have issues any more - all that remains are a couple of issues in the ThreadController itself. First, we need to use atoms in the params arguments for create_thread because of how we're extracting fields, and we need to pass the user and category in and handle the new return values:

defmodule FirestormWeb.Web.ThreadController do
  # ...
  def create(conn, %{"thread" => thread_params}, category) do
    case Forums.create_thread(category, current_user(conn), %{title: thread_params["title"], body: thread_params["body"]}) do
      {:ok, {thread, _first_post}} ->
        conn
        |> put_flash(:info, "Thread created successfully.")
        |> redirect(to: category_thread_path(conn, :show, category, thread))
      {:error, _, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset, category: category)
    end
  end
  # ...
end

Secondly, we need to modify our controller test to set a user in our session in order for this to be valid. We can do this with init_test_session/2:

defmodule FirestormWeb.Web.ThreadControllerTest do
  # ...
  setup do
    {:ok, category} = Forums.create_category(%{title: "Category"})
    {:ok, user} = Forums.create_user(%{username: "knewter", email: "josh@dailydrip.com", name: "Josh Adams"})

    conn =
      build_conn()
      |> Plug.Test.init_test_session(%{current_user: user.id})

    {:ok, category: category, user: user, conn: conn}
  end
  # ...
end

With that, we've simulated logging in and we have an active current user in our session, and the whole suite should pass.

Summary

In today's episode we saw quite a lot of stuff. The primary goal was to show off Ecto.Multi, but in order to show that we had to add Posts to Threads, which led to changing our API a fair bit. We also saw how to inject session data for a test case. I hope you enjoyed it. See you soon!

Resources