Skip to content

Latest commit

 

History

History
1828 lines (1329 loc) · 54.9 KB

book_search_books.livemd

File metadata and controls

1828 lines (1329 loc) · 54.9 KB

Book Search: Books

Mix.install([
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"},
  {:tested_cell, github: "brooklinjazz/tested_cell"},
  {:utils, path: "#{__DIR__}/../utils"}
])

Navigation

Return Home Report An Issue

Setup

Ensure you type the ea keyboard shortcut to evaluate all Elixir cells before starting. Alternatively, you can evaluate the Elixir cells as you read.

Overview

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
Loading

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
Loading

Databases use foreign keys to associate one table with another. In this case, each book stores a foreign key author_id to an author.

Run Book Search Project

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 & Authors Association

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

Has Many

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

Belongs to

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

Books Context

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).

list_books/0

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

get_book!/1

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

Your Turn

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

Book Controller Tests

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

BookController.index/2

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.

BookController.new/2

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.

BookController.create/2

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

Valid Creation

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

Invalid Creation

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.

Books.list_books/1

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

BookController.edit/2

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.

BookController.update/2

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.

BookController.delete/2

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.

List All Books

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!

List Books By Author

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.

Search Books

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.

Your Turn (Bonus)

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.

Further Reading

For more on Ecto and Phoenix, consider the following resources.

Commit Your Progress

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"

Up Next

Previous Next
Portfolio Blog Search Portfolio Comments