At present our data model covers
Category
and
Thread
. Discussion in
Thread
s occurs via
Post
s. 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
Post
s. 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@smoothterminal.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@smoothterminal.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
Post
s to
Thread
s,
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!
Comments for Adding Posts to Threads with Database Transactions