Mix.install([
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"},
{:tested_cell, github: "brooklinjazz/tested_cell"},
{:utils, path: "#{__DIR__}/../utils"}
])
Ensure you type the ea
keyboard shortcut to evaluate all Elixir cells before starting. Alternatively, you can evaluate the Elixir cells as you read.
We will add books to our BookSearch
application from the previous lesson.
We need to associate books with a particular author.
If you need clarification during this reading, you can reference the completed BookSearch/books project.
To associate books with an author, we need to model their relationship in our Database. Ecto handles the data layer in our application. By default, Ecto uses PostgreSQL, which is a relational database.
Relational databases store data in any number of tables and use foreign keys to relate data to one another. One of the most common data relationships is one-to-many.
We use belongs to, has many, to describe the nature of a one-to-many relationship.
Authors and books have a one-to-many relationship because there are potentially many books for a single author. We would say that an author has many books, and a book belongs to a single author.
flowchart
Author
Author --> Book1
Author --> Book2
Author --> Book3
Sometimes we model relationships and data tables using diagrams. For example, there is a specific diagram specification called a UML (Unified Modelling Language) with particular rules and symbols. However, we'll use a more lightweight style. Generally, 1
represents the one in the relationship, and *
represent many.
classDiagram
direction RL
class Author {
name: :string
books: [Book]
}
class Book {
title: :string
author_id: :id
}
Book "1" --> "*" Author :belongs to
Databases use foreign keys to associate one table with another. In this case, each book stores a foreign key author_id
to an author.
Ensure you have completed the BookSearch
project from the previous lesson. If not, you can clone the BookSearch project.
All tests should pass.
$ mix test
Start the server.
$ mix phx.server
Books will store a foreign key to reference the author. For example, we can generate the books resource with the following command.
author_id:references:author
creates the foreign key to the "authors"
table.
$ mix phx.gen.html Books Book books title:string author_id:references:authors
We've generated the following migration for books.
# priv/repo/migrations/_create_books.exs
defmodule BookSearch.Repo.Migrations.CreateBooks do
use Ecto.Migration
def change do
create table(:books) do
add :title, :string
add :author_id, references(:authors, on_delete: :nothing)
timestamps()
end
create index(:books, [:author_id])
end
end
The references/2 function defines the foreign key relationship with the author table.
on_delete: :nothing
means that if an author is deleted, the book will remain in the Database.
Instead, we want to delete all associated records. Change the delete behavior to on_delete: :delete_all
. The :delete_all
option means we'll delete all books associated with an author if we delete that author.
We'll also add null: false
. This indicates that books must have an author because the relationship is not allowed to be null
.
Replace the migration file with the following.
defmodule BookSearch.Repo.Migrations.CreateBooks do
use Ecto.Migration
def change do
create table(:books) do
add :title, :string
add :author_id, references(:authors, on_delete: :delete_all), null: false
timestamps()
end
create index(:books, [:author_id])
end
end
Run migrations.
$ mix ecto.migrate
Schemas describe the application domain representation of data in our Database.
We've associated authors and books in our Database. However, we have not associated them in our application schemas. We'll need this association to access the book.author
field of a book or the author.books
field of the author.
We can use the has_many/3 macro to indicate a one-to-many association with another schema.
Add has_many/3
to the Author
schema.
defmodule BookSearch.Authors.Author do
use Ecto.Schema
import Ecto.Changeset
schema "authors" do
field :name, :string
has_many :books, BookSearch.Books.Book
timestamps()
end
@doc false
def changeset(author, attrs) do
author
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
To indicate our books belong to a single author, we need to add the belongs_to/3 relationship.
Replace field :author_id, :id
with the belongs_to/3
macro.
defmodule BookSearch.Books.Book do
use Ecto.Schema
import Ecto.Changeset
schema "books" do
field :title, :string
belongs_to :author, BookSearch.Authors.Author
timestamps()
end
@doc false
def changeset(book, attrs) do
book
|> cast(attrs, [:title])
|> validate_required([:title])
end
end
Because we added the null: false
constraint on the BookSearch.Repo.Migrations.CreateBooks
migration, we have eight failing tests in the Books
context.
Run the following to execute the failing tests.
$ mix test test/book_search/books_test.exs
...
8 tests, 8 failures
The test output should include errors similar to the following.
** (Postgrex.Error) ERROR 23502 (not_null_violation) null value in column "author_id" violates not-null constraint
table: books
column: author_id
Failing row contains (18, some title, null, 2022-07-28 03:33:48, 2022-07-28 03:33:48).
Let's fix these tests, starting with "list_books/0 returns all books"
.
# test/book_search/books_test.exs
test "list_books/0 returns all books" do
book = book_fixture()
assert Books.list_books() == [book]
end
The book_fixture/1
function causes this error. That's because books must have an author, and the book_fixture/1
function does not include an author.
Let's import the AuthorFixtures
and use the author_fixture/1
function to create an author that we'll provide to the book_fixture/1
.
# import the AuthorFixtures where we already import BooksFixtures
import BookSearch.BooksFixtures
import BookSearch.AuthorsFixtures
Then create an author and pass it to the book_fixture/1
in our test.
test "list_books/0 returns all books" do
author = author_fixture()
book = book_fixture(author: author)
assert Books.list_books() == [book]
end
The book_fixture/1
calls the BookSearch.Books.create_book/1
function.
def book_fixture(attrs \\ %{}) do
{:ok, book} =
attrs
|> Enum.into(%{
title: "some title"
})
|> BookSearch.Books.create_book()
book
end
So we need to modify the create_book/1
function to associate the author with the book.
def create_book(attrs \\ %{}) do
%Book{}
|> Book.changeset(attrs)
|> Repo.insert()
end
We can use Ecto.build_assoc/3 to associate the author with the book.
Replace the create_book/1
function with the following.
def create_book(attrs \\ %{}) do
attrs.author
|> Ecto.build_assoc(:books, attrs)
|> Book.changeset(attrs)
|> Repo.insert()
end
Ecto.build_assoc/3 builds the association with author
in the Book
struct.
%BookSearch.Books.Book{
__meta__: #Ecto.Schema.Metadata<:built, "books">,
author: %BookSearch.Authors.Author{
__meta__: #Ecto.Schema.Metadata<:loaded, "authors">,
books: #Ecto.Association.NotLoaded<association :books is not loaded>,
id: 329,
inserted_at: ~N[2022-07-28 03:59:10],
name: "some name",
updated_at: ~N[2022-07-28 03:59:10]
},
author_id: 329,
id: nil,
inserted_at: nil,
title: "some title",
updated_at: nil
}
This struct then gets saved to the Database when it's passed to Repo.insert/2
.
Run our first test where 14
is the actual line number of the test, and unfortunately, it still fails.
$ mix test test/book_search/books_test.exs:14
1) test books list_books/0 returns all books (BookSearch.BooksTest)
test/book_search/books_test.exs:14
Assertion with == failed
code: assert Books.list_books() == [book]
left: [
%BookSearch.Books.Book{
__meta__: #Ecto.Schema.Metadata<:loaded, "books">,
author: #Ecto.Association.NotLoaded<association :author is not loaded>,
author_id: 336,
id: 38,
inserted_at: ~N[2022-07-28 04:05:19],
title: "some title",
updated_at: ~N[2022-07-28 04:05:19]
}
]
right: [
%BookSearch.Books.Book{
__meta__: #Ecto.Schema.Metadata<:loaded, "books">,
author: %BookSearch.Authors.Author{__meta__: #Ecto.Schema.Metadata<:loaded, "authors">, books: #Ecto.Association.NotLoaded<association :books is not loaded>, id: 336, inserted_at: ~N[2022-07-28 04:05:19], name: "some name", updated_at: ~N[2022-07-28 04:05:19]},
author_id: 336,
id: 38,
inserted_at: ~N[2022-07-28 04:05:19],
title: "some title",
updated_at: ~N[2022-07-28 04:05:19]
}
]
stacktrace:
test/book_search/books_test.exs:17: (test)
We're very close! Books.list_books/1
doesn't load the author association. That's why we see the difference between the two :author
fields.
author: #Ecto.Association.NotLoaded<association :author is not loaded>,
author: %BookSearch.Authors.Author{__meta__: #Ecto.Schema.Metadata<:loaded, "authors">, books: #Ecto.Association.NotLoaded<association :books is not loaded>, id: 336, inserted_at: ~N[2022-07-28 04:05:19], name: "some name", updated_at: ~N[2022-07-28 04:05:19]},
We could modify the assertion, but instead, we'll use this opportunity to demonstrate loading associated data. We can use Ecto.preload/3 to load associated data in a query.
Replace the Books.list_books/1
function with the following to load the author association.
def list_books do
Book
|> preload(:author)
|> Repo.all()
end
Now the test should pass!
$ mix test test/book_search/books_test.exs:14
...
8 tests, 0 failures, 7 excluded
In the next test "get_book!/1 returns the book with the given id"
we need to associate the author with the book.
test "get_book!/1 returns the book with given id" do
author = author_fixture()
book = book_fixture(author: author)
assert Books.get_book!(book.id) == book
end
However, the test fails because the :author
association is not loaded when we get a single book in the Books.get_book!/1
function.
$ mix test test/book_search/books_test.exs:20
...
1) test books get_book!/1 returns the book with given id (BookSearch.BooksTest)
test/book_search/books_test.exs:20
Assertion with == failed
code: assert Books.get_book!(book.id) == book
left: %BookSearch.Books.Book{
__meta__: #Ecto.Schema.Metadata<:loaded, "books">,
author: #Ecto.Association.NotLoaded<association :author is not loaded>,
author_id: 340,
id: 42,
inserted_at: ~N[2022-07-28 04:17:03],
title: "some title",
updated_at: ~N[2022-07-28 04:17:03]
}
right: %BookSearch.Books.Book{
__meta__: #Ecto.Schema.Metadata<:loaded, "books">,
author: %BookSearch.Authors.Author{__meta__: #Ecto.Schema.Metadata<:loaded, "authors">, books: #Ecto.Association.NotLoaded<association :books is not loaded>, id: 340, inserted_at: ~N[2022-07-28 04:17:03], name: "some name", updated_at: ~N[2022-07-28 04:17:03]},
author_id: 340,
id: 42,
inserted_at: ~N[2022-07-28 04:17:03],
title: "some title",
updated_at: ~N[2022-07-28 04:17:03]
}
stacktrace:
test/book_search/books_test.exs:23: (test)
We could fix this using Ecto.preload/3 again. However, let's use this opportunity to demonstrate how we could alter the test to pass if we didn't want to load the author association. Replace the test with the following.
test "get_book!/1 returns the book with given id" do
author = author_fixture()
%{title: title, id: id, author_id: author_id} = book_fixture(author: author)
assert %{title: ^title, id: ^id, author_id: ^author_id} = Books.get_book!(id)
end
Above, we use the pin operator ^
and fields of the book created by book_fiture/1
to assert that the :title
, :id
, and :author_id
fields match. We could accomplish the same thing more verbosely with the following.
test "get_book!/1 returns the book with given id" do
author = author_fixture()
book = book_fixture(author: author)
assert fetched_book = Books.get_book!(book.id)
assert fetched_book.id == book.id
assert fetched_book.author_id == book.author_id
assert fetched_book.title == book.title
end
Now the test should pass!
$ mix test test/book_search/books_test.exs:20
You can resolve the remaining tests by following the same patterns.
Fix the remaining tests. When complete, all tests should pass, and your test/book_search/books_test.exs
file should look similar to the following.
defmodule BookSearch.BooksTest do
use BookSearch.DataCase
alias BookSearch.Books
describe "books" do
alias BookSearch.Books.Book
import BookSearch.BooksFixtures
import BookSearch.AuthorsFixtures
@invalid_attrs %{title: nil}
test "list_books/0 returns all books" do
author = author_fixture()
book = book_fixture(author: author)
assert Books.list_books() == [book]
end
test "get_book!/1 returns the book with given id" do
author = author_fixture()
book = book_fixture(author: author)
fetched_book = Books.get_book!(book.id)
assert fetched_book.id == book.id
assert fetched_book.author_id == book.author_id
assert fetched_book.title == book.title
end
test "create_book/1 with valid data creates a book" do
author = author_fixture()
valid_attrs = %{title: "some title", author: author}
assert {:ok, %Book{} = book} = Books.create_book(valid_attrs)
assert book.title == "some title"
end
test "create_book/1 with invalid data returns error changeset" do
author = author_fixture()
invalid_attrs = %{title: nil, author: author}
assert {:error, %Ecto.Changeset{}} = Books.create_book(invalid_attrs)
end
test "update_book/2 with valid data updates the book" do
author = author_fixture()
book = book_fixture(author: author)
update_attrs = %{title: "some updated title"}
assert {:ok, %Book{} = book} = Books.update_book(book, update_attrs)
assert book.title == "some updated title"
end
test "update_book/2 with invalid data returns error changeset" do
author = author_fixture()
book = book_fixture(author: author)
invalid_attrs = %{title: nil, author: author}
assert {:error, %Ecto.Changeset{}} = Books.update_book(book, invalid_attrs)
fetched_book = Books.get_book!(book.id)
assert fetched_book.id == book.id
assert fetched_book.author_id == book.author_id
assert fetched_book.title == book.title
end
test "delete_book/1 deletes the book" do
author = author_fixture()
book = book_fixture(author: author)
assert {:ok, %Book{}} = Books.delete_book(book)
assert_raise Ecto.NoResultsError, fn -> Books.get_book!(book.id) end
end
test "change_book/1 returns a book changeset" do
author = author_fixture()
book = book_fixture(author: author)
assert %Ecto.Changeset{} = Books.change_book(book)
end
end
end
All tests should now pass.
$ mix test test/book_search/books_test.exs
...
8 tests, 0 failures
Our book controller tests fail due to the association between authors and books. To associate authors and books, we can nest resources in our router.
scope "/", BookSearchWeb do
pipe_through(:browser)
get("/", PageController, :index)
resources "/authors", AuthorController do
resources "/books", BookController
end
end
We can see the routes we've created by running mix phx.routes
. Here are the important ones.
$ mix phx.routes
...
author_path GET /authors BookSearchWeb.AuthorController :index
author_path GET /authors/:id/edit BookSearchWeb.AuthorController :edit
author_path GET /authors/new BookSearchWeb.AuthorController :new
author_path GET /authors/:id BookSearchWeb.AuthorController :show
author_path POST /authors BookSearchWeb.AuthorController :create
author_path PATCH /authors/:id BookSearchWeb.AuthorController :update
PUT /authors/:id BookSearchWeb.AuthorController :update
author_path DELETE /authors/:id BookSearchWeb.AuthorController :delete
author_book_path GET /authors/:author_id/books BookSearchWeb.BookController :index
author_book_path GET /authors/:author_id/books/:id/edit BookSearchWeb.BookController :edit
author_book_path GET /authors/:author_id/books/new BookSearchWeb.BookController :new
author_book_path GET /authors/:author_id/books/:id BookSearchWeb.BookController :show
author_book_path POST /authors/:author_id/books BookSearchWeb.BookController :create
author_book_path PATCH /authors/:author_id/books/:id BookSearchWeb.BookController :update
PUT /authors/:author_id/books/:id BookSearchWeb.BookController :update
author_book_path DELETE /authors/:author_id/books/:id BookSearchWeb.BookController :delete
...
All of our controller tests still fail. However, we've laid the groundwork for fixing them.
$ mix test test/book_search_web/controllers/book_controller_test.exs
...
8 tests, 8 failures
We'll need authors to associate with our books, so add the BookSearch.AuthorsFixtures
to test/book_search_web/controllers/book_controller_test.exs
.
# add the import below the existing BookSearch.BooksFixtures import.
import BookSearch.BooksFixtures
import BookSearch.AuthorsFixtures
Let's start with the "index"
test.
describe "index" do
test "lists all books", %{conn: conn} do
conn = get(conn, Routes.book_path(conn, :index))
assert html_response(conn, 200) =~ "Listing Books"
end
end
This test fails with the following error.
(UndefinedFunctionError) function BookSearchWeb.Router.Helpers.book_path/2 is undefined or private
That's because of our nested routes. We need to use Routes.author_book_path
instead of Routes.book_path
.
Replace the test with the following.
test "lists all books", %{conn: conn} do
author = author_fixture()
conn = get(conn, Routes.author_book_path(conn, :index, author))
assert html_response(conn, 200) =~ "Listing Books"
end
We still have the same error because the lib/book_search_web/templates/books/index.html.heex
template also uses Routes.book_path
.
Replace Routes.book_path
with Routes.author_book_path
. Routes.author_book_path
requires an author, so we'll pass in @author_id
, which we'll define in a moment in the controller.
# lib/book_search_web/templates/books/index.html.heex
<h1>Listing Books</h1>
<table>
<thead>
<tr>
<th>Title</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for book <- @books do %>
<tr>
<td><%= book.title %></td>
<td>
<span><%= link "Show", to: Routes.author_book_path(@conn, :show, @author_id, book) %></span>
<span><%= link "Edit", to: Routes.author_book_path(@conn, :edit, @author_id, book) %></span>
<span><%= link "Delete", to: Routes.author_book_path(@conn, :delete, @author_id, book), method: :delete, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<span><%= link "New Book", to: Routes.author_book_path(@conn, :new, @author_id) %></span>
We have access to the "author_id"
in the second argument controller because we nested our author and book routes.
We need to pass the author_id
into the render/3 function to provide it to the template.
Replace BookController.index/2
with the following.
def index(conn, %{"author_id" => author_id}) do
books = Books.list_books()
render(conn, "index.html", books: books, author_id: author_id)
end
The "index"
test should pass!
$ mix test test/book_search_web/controllers/book_controller_test.exs:12
...
8 tests, 0 failures, 7 excluded
We can create an author on http://localhost:4000/authors/new and then view an (empty) list of books for the author on
http://localhost:4000/authors/1 where 1
is the id of the author.
However, our test isn't very comprehensive. For example, the route is nested under the author, so we would expect to list only books that belong to that author. Currently, it simply lists all books. We'll circle back to this after we've resolved the other tests.
The "new book"
tests have the same issues as "index"
where they use Routes.book_path
instead of Routes.author_book_path
.
describe "new book" do
test "renders form", %{conn: conn} do
conn = get(conn, Routes.book_path(conn, :new))
assert html_response(conn, 200) =~ "New Book"
end
end
Create an author and use Routes.author_book_path
.
describe "new book" do
test "renders form", %{conn: conn} do
author = author_fixture()
conn = get(conn, Routes.author_book_path(conn, :new, author))
assert html_response(conn, 200) =~ "New Book"
end
end
Provide the "author_id"
to the template in the controller.
def new(conn, %{"author_id" => author_id}) do
changeset = Books.change_book(%Book{})
render(conn, "new.html", changeset: changeset, author_id: author_id)
end
Use the @author_id
in the template and replace Routes.book_path
with Routes.author_book_path
.
<h1>New Book</h1>
<%= render "form.html", Map.put(assigns, :action, Routes.author_book_path(@conn, :create, @author_id)) %>
<span><%= link "Back", to: Routes.author_book_path(@conn, :index, @author_id) %></span>
Now the test should pass! Replace 19
with the correct line number of the test.
$ mix test test/book_search_web/controllers/book_controller_test.exs:19
...
8 tests, 0 failures, 7 excluded
We should be able to visit http://localhost:4000/authors/1/books/new to view the new book page. However, we cannot yet submit the form to create a new book.
We need to associate an author and a book when we create them. There are two failing "create book"
tests we need to fix.
describe "create book" do
test "redirects to show when data is valid", %{conn: conn} do
conn = post(conn, Routes.book_path(conn, :create), book: @create_attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == Routes.book_path(conn, :show, id)
conn = get(conn, Routes.book_path(conn, :show, id))
assert html_response(conn, 200) =~ "Show Book"
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, Routes.book_path(conn, :create), book: @invalid_attrs)
assert html_response(conn, 200) =~ "New Book"
end
end
The first test performs a book creation with valid parameters and tests that the client is redirected to the book show page. It also verifies we can visit the show page directly.
test "redirects to show when data is valid", %{conn: conn} do
conn = post(conn, Routes.book_path(conn, :create), book: @create_attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == Routes.book_path(conn, :show, id)
conn = get(conn, Routes.book_path(conn, :show, id))
assert html_response(conn, 200) =~ "Show Book"
end
Create an author and replace Routes.book_path
with Routes.author_book_path
.
test "redirects to show when data is valid", %{conn: conn} do
author = author_fixture()
conn = post(conn, Routes.author_book_path(conn, :create, author), book: @create_attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == Routes.author_book_path(conn, :show, author, id)
conn = get(conn, Routes.author_book_path(conn, :show, author, id))
assert html_response(conn, 200) =~ "Show Book"
end
Now we need to modify the BookController.create/2
function to use the "author_id"
parameter.
And replace Routes.book_path
with Routes.author_book_path
.
Books.create_book/1
needs the full author, not just the id. So, we need to retrieve the author using BookSearch.Authors.get_author!/1
.
def create(conn, %{"book" => book_params, "author_id" => author_id}) do
author = BookSearch.Authors.get_author!(author_id)
case Books.create_book(Map.put(book_params, :author, author)) do
{:ok, book} ->
conn
|> put_flash(:info, "Book created successfully.")
|> redirect(to: Routes.author_book_path(conn, :show, author_id, book))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
However, we've got a problem. BookSearch.create_book/1
expects the author is in an atom key.
def create_book(attrs \\ %{}) do
attrs.author
|> Ecto.build_assoc(:books, attrs)
|> Book.changeset(attrs)
|> Repo.insert()
end
Our test currently fails with the following.
$ mix test test/book_search_web/controllers/book_controller_test.exs:28
** (Ecto.CastError) expected params to be a map with atoms or string keys, got a map with mixed keys: %{:author => %BookSearch.Authors.Author{__meta__: #Ecto.Schema.Metadata<:loaded, "authors">, books: #Ecto.Association.NotLoaded<association :books is not loaded>, id: 477, inserted_at: ~N[2022-07-28 06:17:24], name: "some name", updated_at: ~N[2022-07-28 06:17:24]}, "title" => "some title"}
That's because Ecto.Changeset.cast/4 expects a map with either string keys, or atom keys, not both.
There are a few solutions to this problem. We've chosen to use Map.pop!/2 to separate the :author
key and the rest of the parameters. However, we could have changed the interface to the Books.create_book/1
function.
author = %{name: "Dennis E Taylor"}
attrs = %{title: "We are Legion (We are Bob)", author: author}
Map.pop!(attrs, :author)
Replace Books.create_books/1
with the following.
def create_book(attrs \\ %{}) do
{author, attrs} = Map.pop!(attrs, :author)
author
|> Ecto.build_assoc(:books, attrs)
|> Book.changeset(attrs)
|> Repo.insert()
end
We're successfully creating the book. However we encounter the Routes.book_path
issue when we render the book show page.
Replace Routes.book_path
with Routes.author_book_path
in the show template.
<h1>Show Book</h1>
<ul>
<li>
<strong>Title:</strong>
<%= @book.title %>
</li>
</ul>
<span><%= link "Edit", to: Routes.author_book_path(@conn, :edit, @author_id, @book) %></span> |
<span><%= link "Back", to: Routes.author_book_path(@conn, :index, @author_id) %></span>
An provide the author id in the BookController.show/2
action.
def show(conn, %{"id" => id, "author_id" => author_id}) do
book = Books.get_book!(id)
render(conn, "show.html", author_id: author_id, book: book)
end
Now the test should pass! Run the following in the command line to run the test. Replace 28
with the correct line number.
$ mix test test/book_search_web/controllers/book_controller_test.exs:28
...
8 tests, 0 failures, 7 excluded
We have a second "create book"
test "renders errors when data is invalid"
that is still failing.
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, Routes.book_path(conn, :create), book: @invalid_attrs)
assert html_response(conn, 200) =~ "New Book"
end
Create an author and replace Routes.book_path
with Routes.author_book_path
.
test "renders errors when data is invalid", %{conn: conn} do
author = author_fixture()
conn = post(conn, Routes.author_book_path(conn, :create, author), book: @invalid_attrs)
assert html_response(conn, 200) =~ "New Book"
end
We need to provide the author id in the error case of BookController.create/2
.
def create(conn, %{"book" => book_params, "author_id" => author_id}) do
author = BookSearch.Authors.get_author!(author_id)
case Books.create_book(Map.put(book_params, :author, author)) do
{:ok, book} ->
conn
|> put_flash(:info, "Book created successfully.")
|> redirect(to: Routes.author_book_path(conn, :show, author_id, book))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset, author_id: author_id) # <-- Add the author_id
end
end
Run the following from the command line and the test should pass. Replace 39
with the correct line number of the test.
$ mix test test/book_search_web/controllers/book_controller_test.exs:39
...
8 tests, 0 failures, 7 excluded
Now we can visit http://localhost:4000/authors/1/books/new.
Then submit the form to create a new book.
Unfortunately, our previous change to Books.create_book/1
caused one of our Books
context tests to fail.
test "list_books/0 returns all books" do
author = author_fixture()
book = book_fixture(author: author)
assert Books.list_books() == [book]
end
That's because the Books.create_book/1
function no longer builds the author association because :author
is no longer in the attrs
map.
def create_book(attrs \\ %{}) do
{author, attrs} = Map.pop!(attrs, :author)
author
|> Ecto.build_assoc(:books, attrs)
|> Book.changeset(attrs)
|> Repo.insert()
end
The behavior of list_books/0
to build the :author
is desirable, so we don't need to change it. Instead, let's modify the "list_books/0 returns all books"
test to check for the author association.
test "list_books/0 returns all books" do
author = author_fixture()
book = book_fixture(author: author)
assert [fetched_book] = Books.list_books()
assert fetched_book.title == book.title
assert fetched_book.author_id == book.author_id
assert fetched_book.author == author
end
Now run the following in the command line to verify the test passes! Replace 14
with the correct line number of the test.
$ mix test test/book_search/books_test.exs:14
...
8 tests, 0 failures, 7 excluded
The "edit book"
tests use a setup function :create_book
.
describe "edit book" do
setup [:create_book]
test "renders form for editing chosen book", %{conn: conn, book: book} do
conn = get(conn, Routes.book_path(conn, :edit, book))
assert html_response(conn, 200) =~ "Edit Book"
end
end
This function creates a book without an author, so it fails.
defp create_book(_) do
book = book_fixture()
%{book: book}
end
We can modify it to associate an author with the book or remove the function and use the fixtures directly.
It can be an anti-pattern to create associated data in a fixture. With complex domains, this can cause unexpected behavior. However, we will associate an author with the book in the create_book/1
function for demonstration purposes.
defp create_book(_) do
book = book_fixture(author: author_fixture())
%{book: book}
end
Now we can replace Routes.book_path
with Routes.author_book_path
in the test. We'll use the book.author_id
since we don't have an author
variable.
describe "edit book" do
setup [:create_book]
test "renders form for editing chosen book", %{conn: conn, book: book} do
conn = get(conn, Routes.author_book_path(conn, :edit, book.author_id, book))
assert html_response(conn, 200) =~ "Edit Book"
end
end
We need to replace Routes.book_path
with Routes.author_book_path
in the edit template.
<h1>Edit Book</h1>
<%= render "form.html", Map.put(assigns, :action, Routes.author_book_path(@conn, :update, @author_id, @book)) %>
<span><%= link "Back", to: Routes.author_book_path(@conn, :index, @author_id) %></span>
Provide the author id to the template in the BookController.edit/2
function.
def edit(conn, %{"id" => id, "author_id" => author_id}) do
book = Books.get_book!(id)
changeset = Books.change_book(book)
render(conn, "edit.html", book: book, changeset: changeset, author_id: author_id)
end
Run the following in the terminal, and the test should pass! Replace 46
with the correct line number of the test.
$ mix test test/book_search_web/controllers/book_controller_test.exs:46
8 tests, 0 failures, 7 excluded
Now we should be able to visit the edit page on http://localhost:4000/authors/1/book/1/edit. We cannot yet submit the form.
The "update book"
tests are very similar to the "create book"
tests. There is one test for updating a book with valid parameters and another for updating a book with invalid parameters.
describe "update book" do
setup [:create_book]
test "redirects when data is valid", %{conn: conn, book: book} do
conn = put(conn, Routes.book_path(conn, :update, book), book: @update_attrs)
assert redirected_to(conn) == Routes.book_path(conn, :show, book)
conn = get(conn, Routes.book_path(conn, :show, book))
assert html_response(conn, 200) =~ "some updated title"
end
test "renders errors when data is invalid", %{conn: conn, book: book} do
conn = put(conn, Routes.book_path(conn, :update, book), book: @invalid_attrs)
assert html_response(conn, 200) =~ "Edit Book"
end
end
Replace Routes.book_path
with Routes.author_book_path
and use the book.author_id
to provide the author id to the path.
describe "update book" do
setup [:create_book]
test "redirects when data is valid", %{conn: conn, book: book} do
conn =
put(conn, Routes.author_book_path(conn, :update, book.author_id, book),
book: @update_attrs
)
assert redirected_to(conn) == Routes.author_book_path(conn, :show, book.author_id, book)
conn = get(conn, Routes.author_book_path(conn, :show, book.author_id, book))
assert html_response(conn, 200) =~ "some updated title"
end
test "renders errors when data is invalid", %{conn: conn, book: book} do
conn =
put(conn, Routes.author_book_path(conn, :update, book.author_id, book),
book: @invalid_attrs
)
assert html_response(conn, 200) =~ "Edit Book"
end
end
We've already updated the show template, so we only need to modify the BookController.update/2
function. The Books.update_book/2
function doesn't require an author, so we only need to replace Routes.book_path
with Routes.author_book_path
.
def update(conn, %{"id" => id, "book" => book_params, "author_id" => author_id}) do
book = Books.get_book!(id)
case Books.update_book(book, book_params) do
{:ok, book} ->
conn
|> put_flash(:info, "Book updated successfully.")
|> redirect(to: Routes.author_book_path(conn, :show, author_id, book))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "edit.html", book: book, changeset: changeset, author_id: author_id)
end
end
Run the following in your command line, and both tests should pass! Replace 55
with the correct line number of the describe
block.
$ mix test test/book_search_web/controllers/book_controller_test.exs:55
...
8 tests, 0 failures, 6 excluded
Now we can visit http://localhost:4000/authors/1/book/1/edit.
Then submit the form to edit our book.
We're on to our last failing controller test "delete book"
.
describe "delete book" do
setup [:create_book]
test "deletes chosen book", %{conn: conn, book: book} do
conn = delete(conn, Routes.book_path(conn, :delete, book))
assert redirected_to(conn) == Routes.book_path(conn, :index)
assert_error_sent 404, fn ->
get(conn, Routes.book_path(conn, :show, book))
end
end
end
Replace Routes.book_path
with Routes.author_book_path
in the test.
describe "delete book" do
setup [:create_book]
test "deletes chosen book", %{conn: conn, book: book} do
conn = delete(conn, Routes.author_book_path(conn, :delete, book.author_id, book))
assert redirected_to(conn) == Routes.author_book_path(conn, :index, book.author_id)
assert_error_sent 404, fn ->
get(conn, Routes.author_book_path(conn, :show, book.author_id, book))
end
end
end
Replace Routes.book_path
with Routes.author_book_path
in the controller.
def delete(conn, %{"id" => id, "author_id" => author_id}) do
book = Books.get_book!(id)
{:ok, _book} = Books.delete_book(book)
conn
|> put_flash(:info, "Book deleted successfully.")
|> redirect(to: Routes.author_book_path(conn, :index, author_id))
end
Now we can visit http://localhost:4000/authors/1/books and press the Delete button to delete our book.
We have a bug in our application. The BookController.index/2
function does not filter books by their author.
def index(conn, %{"author_id" => author_id}) do
books = Books.list_books()
render(conn, "index.html", books: books, author_id: author_id)
end
Let's expand the functionality of our BookSearch
application. We want clients to be able to visit http://localhost:4000/books to view all books and http://localhost:4000/authors/1/books to view all books for an author.
We'll start by writing the test for listing all books.
test "lists all books", %{conn: conn} do
author = author_fixture()
conn = get(conn, Routes.author_book_path(conn, :index, author))
assert html_response(conn, 200) =~ "Listing Books"
end
For this test, we want to visit http://localhost:4000/books. There's no defined route for that URL, so we need to make one in our router.
scope "/", BookSearchWeb do
pipe_through :browser
get "/", PageController, :index
resources "/authors", AuthorController do
resources "/books", BookController
end
get "/books", BookController, :index
end
We can see the new route when we run mix phx.routes
.
$ mix phx.routes
...
book_path GET /books BookSearchWeb.BookController :index
...
Replace Routes.author_book_path
with Routes.book_path
. We also want to create a book and assert that we find its title in the HTML response.
test "lists all books", %{conn: conn} do
author = author_fixture()
book = book_fixture(author: author)
conn = get(conn, Routes.book_path(conn, :index))
assert html_response(conn, 200) =~ book.title
end
This test fails because the BookController.index/2
function assumes there's an "author_id"
in the parameters.
def index(conn, %{"author_id" => author_id}) do
books = Books.list_books()
render(conn, "index.html", books: books, author_id: author_id)
end
Define a new BookController.index/2
function clause to handle the case where there is no author id. Ensure it's after the first function clause, otherwise it will always match.
def index(conn, %{"author_id" => author_id}) do
books = Books.list_books()
render(conn, "index.html", books: books, author_id: author_id)
end
def index(conn, _params) do
books = Books.list_books()
render(conn, "index.html", books: books)
end
We need to handle the case where there's no author id in the index template. We can use the book.author_id
instead of @author_id
for the list of books.
We can wrap the New Book button in an if statement to only render if there is an @author_id
value. We can use assigns
with square bracket syntax to safely access a value that's may not exist.
<h1>Listing Books</h1>
<table>
<thead>
<tr>
<th>Title</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for book <- @books do %>
<tr>
<td><%= book.title %></td>
<td>
<span><%= link "Show", to: Routes.author_book_path(@conn, :show, book.author_id, book) %></span>
<span><%= link "Edit", to: Routes.author_book_path(@conn, :edit, book.author_id, book) %></span>
<span><%= link "Delete", to: Routes.author_book_path(@conn, :delete, book.author_id, book), method: :delete, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= if assigns[:author_id] do %>
<span><%= link "New Book", to: Routes.author_book_path(@conn, :new, @author_id) %></span>
<% end %>
Now the test should pass. Run the following in the command line and replace 12
with the correct line number of the test.
$ mix test test/book_search_web/controllers/book_controller_test.exs:12
...
8 tests, 0 failures, 7 excluded
For convenient UI, let's add a New Book button to the author show page.
<h1>Show Author</h1>
<ul>
<li>
<strong>Name:</strong>
<%= @author.name %>
</li>
</ul>
<span><%= link "Edit", to: Routes.author_path(@conn, :edit, @author) %></span> |
<span><%= link "Back", to: Routes.author_path(@conn, :index) %></span> |
<span><%= link "New Book", to: Routes.author_book_path(@conn, :new, @author) %></span>
We should be able to visit http://localhost:4000/books to view all books. Consider creating a couple of books under different authors.
To make it more obvious, let's take advantage of the preloaded :author
data in each book and add an Author column to our index page. Because we've preloaded the :author
data we can access the associated author data through book.author
.
<h1>Listing Books</h1>
<table>
<thead>
<tr>
<th>Author</th>
<th>Title</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for book <- @books do %>
<tr>
<td><%= book.author.name %></td>
<td><%= book.title %></td>
<td>
<span><%= link "Show", to: Routes.author_book_path(@conn, :show, book.author_id, book) %></span>
<span><%= link "Edit", to: Routes.author_book_path(@conn, :edit, book.author_id, book) %></span>
<span><%= link "Delete", to: Routes.author_book_path(@conn, :delete, book.author_id, book), method: :delete, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= if assigns[:author_id] do %>
<span><%= link "New Book", to: Routes.author_book_path(@conn, :new, @author_id) %></span>
<% end %>
Looking good! That's the power of associating data!
We need a new test for listing books by their author. First, we'll create two authors, each with one book. Then we'll use the get/3
macro to send a GET request to http://localhost:4000/authors/1/books
where 1
is the author's id. Lastly, we assert we only find the author's book on the page.
test "lists all books by author", %{conn: conn} do
author1 = author_fixture(name: "Dennis E Taylor")
author2 = author_fixture(name: "Patrick Rothfuss")
book1 = book_fixture(author: author1, title: "We are Legend")
book2 = book_fixture(author: author2, title: "Name of the Wind")
conn = get(conn, Routes.author_book_path(conn, :index, author1))
# find the title of the author's book
assert html_response(conn, 200) =~ book1.title
# Do not find the title of the other author's book
refute html_response(conn, 200) =~ book2.title
end
Now modify the BookController.index/2
function that accepts "author_id"
as a parameter to pass the author id to the Books.list_books/0
function.
def index(conn, %{"author_id" => author_id}) do
books = Books.list_books(author_id) # <-- provide author_id as an argument
render(conn, "index.html", books: books, author_id: author_id)
end
We'll need to implement the Books.list_book/1
function. Create a new function clause as we still want the Books.list_book/0
function to work. We can filter books by a matching author id with where/3.
def list_books do
Book
|> preload(:author)
|> Repo.all()
end
def list_books(author_id) do
Book
|> preload(:author)
|> where([book], book.author_id == ^author_id)
|> Repo.all()
end
All tests pass!
$ mix test
Now we can visit http://localhost:4000/authors/1/books to view only the books for a single author.
We're going to add a form to search for books. We'll be able to search all books, and search books by an author.
First, let's add a form to the book index page. We're going to pass the action from the controller since it will be different depending on the current page.
# lib/book_search_web/templates/book/index.html.heex
<h1>Listing Books</h1>
<table>
<thead>
<tr>
<th>Author</th>
<th>Title</th>
<th></th>
</tr>
</thead>
<tbody>
<.form let={f} for={@conn} method={"get"} action={@action}>
<%= text_input f, :title %>
<%= error_tag f, :title %>
<div>
<%= submit "Search" %>
</div>
</.form>
<%= for book <- @books do %>
<tr>
<td><%= book.author.name %></td>
<td><%= book.title %></td>
<td>
<span><%= link "Show", to: Routes.author_book_path(@conn, :show, book.author_id, book) %></span>
<span><%= link "Edit", to: Routes.author_book_path(@conn, :edit, book.author_id, book) %></span>
<span><%= link "Delete", to: Routes.author_book_path(@conn, :delete, book.author_id, book), method: :delete, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= if assigns[:author_id] do %>
<span><%= link "New Book", to: Routes.author_book_path(@conn, :new, @author_id) %></span>
<% end %>
Let's add the test to ensure we filter books by the search query when we visit http://localhost:4000/books?name=search_input where search_input
is the book name entered in the form.
describe "index" do
test "list all books filtered by search query", %{conn: conn} do
author = author_fixture(name: "Brandon Sanderson")
book = book_fixture(author: author, title: "The Final Empire")
non_matching_book = book_fixture(author: author, title: "The Hero of Ages")
conn = get(conn, Routes.book_path(conn, :index, title: book.title))
assert html_response(conn, 200) =~ book.title
refute html_response(conn, 200) =~ non_matching_book.title
end
...
end
We'll add a new function clause for the BookController.index/2
action. Do not delete any existing function clauses.
def index(conn, %{"name" => book_name}) do
books = Books.list_books(title: book_name)
render(conn, "index.html", books: books)
end
We can implement the search using ilike/2.
def list_books(name: name) do
search = "%#{name}%"
Book
|> preload(:author)
|> where([book], ilike(book.name, ^search))
|> Repo.all()
end
All tests pass!
$ mix test
...
43 tests, 0 failures
However, we have a bug. The search input also displays on http://localhost:4000/authors/1/books, but it searches all books, not just books for the author.
We'll use a value on the assigns
called :display_search
to toggle the search form.
<%= if assigns[:display_form] do %>
<.form let={f} for={@conn} method={"get"} action={@action}>
<%= text_input f, :title %>
<%= error_tag f, :title %>
<div>
<%= submit "Search" %>
</div>
</.form>
<% end %>
Then enable the form through the BookController.index/2
function clauses for listing all books, but not for listing books by author.
def index(conn, %{"author_id" => author_id}) do
books = Books.list_books(author_id)
render(conn, "index.html", books: books, author_id: author_id)
end
def index(conn, %{"title" => title}) do
books = Books.list_books(title: title)
render(conn, "index.html", books: books, display_form: true)
end
def index(conn, _params) do
books = Books.list_books()
render(conn, "index.html", books: books, display_form: true)
end
Now the form will not display when we visit http://localhost:4000/authors/1/books.
We omitted tests for Books.list_books(name: name)
. Create tests to ensure we can filter books by their name. You may take inspiration from how we've already tested Authors.list_authors/1
.
For more on Ecto and Phoenix, consider the following resources.
Run the following in your command line from the beta_curriculum folder to track and save your progress in a Git commit.
$ git add .
$ git commit -m "finish book search books section"
Previous | Next |
---|---|
Portfolio Blog Search | Portfolio Comments |