Book Search: Tags
Mix.install([
{:kino, github: "livebook-dev/kino", override: true},
{:kino_lab, "~> 0.1.0-dev", github: "jonatanklosko/kino_lab"},
{:vega_lite, "~> 0.1.4"},
{:kino_vega_lite, "~> 0.1.1"},
{:benchee, "~> 0.1"},
{:ecto, "~> 3.7"},
{:math, "~> 0.7.0"},
{:faker, "~> 0.17.0"},
{:utils, path: "#{__DIR__}/../utils"},
{:httpoison, "~> 1.8"},
{:poison, "~> 5.0"}
])
Navigation
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 tags to our BookSearch
application from the previous lesson.
If you need clarification during this reading, you can reference the completed BookSearch/tags project.
Tags describe the type of book. For example we might have "fiction"
or "history"
tags.
Books can have multiple tags. For example, a book might have both the "action"
tag and the "fiction"
tag.
Tags can also have multiple books. For example, We want to be able to query all books that have the "action"
tag.
Books and Tags have a many-to-many relationship.
To model many-to-many relationships in a Database we use a join table to associate one resource with another.
Books Table
id | name |
---|---|
123 | “Name of the Wind” |
124 | “A Wise Man’s Fear” |
Tags Table
id | name |
---|---|
456 | “fiction” |
457 | “action” |
Book_tags Table (join Table)
id | book_id | tag_id |
---|---|---|
1 | 123 | 456 |
2 | 124 | 456 |
3 | 123 | 457 |
14 | 124 | 457 |
This way, tags can have many books, and books can have many tags.
classDiagram
direction RL
class Book {
title: :string
tags: [Tag]
}
class Tag {
name: :string
books: [Book]
}
class BookTag {
book_id: :id
tag_id: :id
}
Book "*" --> "*" BookTag :has_many
Tag "*" --> "*" BookTag :has_many
The "book_tags"
table stores a foreign key to both the book and the tag. We can query this table to find all of the tags for a book, and all of the books for a tag.
Run Book Search Project
Ensure you have completed the BookSearch
project from the previous lesson. If not, you can clone the BookSearch project on the books
branch.
All tests should pass.
$ mix test
Start the server.
$ mix phx.server
Books Tags Table
We can run the following to create our tags resource.
$ mix phx.gen.html Tags Tag tags name:string
Then add the "/tags"
resource to our router.
scope "/", BookSearchWeb do
pipe_through :browser
get "/", PageController, :index
resources "/authors", AuthorController do
resources "/books", BookController
end
get "/books", BookController, :index
resources "/tags", TagController
end
Run migrations.
$ mix ecto.migrate
All tests should pass.
$ mix test
Now we can perform standard CRUD actions for tags when we visit http://localhost:4000/tags
Associating Tags and Books
To associate Tags and Books we need to add join table. This table will store a reference (a foreign key) to both the books and the tags tables.
We can generate "book_tags"
table migration with the following. We don’t need to generate a context or controller for the join
table.
$ mix ecto.gen.migration create_book_tags
Now we need to create our join table migration in the generated migration priv/repo/migrations/_create_book_tags.exs
file.
The :book_tags
table should contain a reference to :book_id
and :tag_id
. We want to delete the association if the book or the tag are deleted. We also want to enforce that both the :book_id
and :tag_id
cannot be null.
The create unique_index(:book_tags, [:book_id, :tag_id])
ensures we don’t associate a book with the same tag multiple times. Each association must be unique.
defmodule BookSearch.Repo.Migrations.CreateBookTags do
use Ecto.Migration
def change do
create table(:book_tags) do
add :book_id, references(:books, on_delete: :delete_all), null: false
add :tag_id, references(:tags, on_delete: :delete_all), null: false
end
create unique_index(:book_tags, [:book_id, :tag_id])
end
end
Selecting Tags
When we create a book, we want to be able to associate the book with selected tags.
Ideally, clients should be able to select from a list of the existing tags.
Phoenix provides a Phoenix.HTML.Form.multiple_select/4 input we can use to select tags when creating a book. Add this select input to the book form.
# lib/book_search_web/templates/book/form.html.heex
<.form let={f} for={@changeset} action={@action}>
<%= if @changeset.action do %>
<p>Oops, something went wrong! Please check the errors below.</p>
<% end %>
<%= label f, :title %>
<%= text_input f, :title %>
<%= error_tag f, :title %>
<%= label f, :tags %>
<%= multiple_select f, :tags, [], selected: [] %>
<%= error_tag f, :tags %>
<%= submit "Save" %>
Visit http://localhost:4000/authors/1/books/new where 1
is the id of an author in your application, and we should see the following. The multiple_select/4
input currently has no options because we haven’t provided them.
We need to provide the tags to the template.
The multiple_select/4
input accepts a list of tuples with a label and a value as selection options.
# lib/book_search_web/templates/book/form.html.heex
# Using Keyword lists
<%= multiple_select f, :tags, ["label": "value"], selected: [] %>
# Using Lists of Tuples
<%= multiple_select f, :tags, [{"label", "value"}], selected: [] %>
To provide the tag options to the template, we can create a tag_options/0
function in the view. All functions defined in the view are available in every template.
We want the label to be the tag’s name and the value to be the tag’s id.
We can map over the tags to create the list of {label, value}
tuples.
defmodule BookSearchWeb.BookView do
use BookSearchWeb, :view
def tag_options do
BookSearch.Tags.list_tags() |> Enum.map(fn tag -> {tag.name, tag.id} end)
end
end
Provide tag_options/0
to the multiple_select/4
input.
# lib/book_search_web/templates/book/form.html.heex
<%= multiple_select f, :tags, tag_options(), selected: [] %>
Seeding
We haven’t created any tags, so the text input is still empty. However, this is an excellent opportunity to demonstrate the value of seeding.
Seeding is the initial creation of data in your database typically run before your tests or to create a convenient developer environment. Though, you can seed in a production or other environments as well.
It would be convenient to programmatically create several authors, books, and tags in our development environment through a seed file. This way, we can explore and test features in our application without needing to do any manual setup.
Fortunately, Phoenix provides a priv/repo/seeds.exs
where we can seed data in our development and test environment.
Add the following content to priv/repo/seeds.exs
.
alias BookSearch.Tags
alias BookSearch.Authors
alias BookSearch.Books
if Mix.env() == :dev do
{:ok, author1} = BookSearch.Authors.create_author(%{name: "Patrick Rothfuss"})
{:ok, author2} = BookSearch.Authors.create_author(%{name: "Dennis E Taylor"})
["fiction", "fantasy", "history", "sci-fi"]
|> Enum.each(fn tag_name ->
BookSearch.Tags.create_tag(%{name: tag_name})
end)
BookSearch.Books.create_book(%{title: "Name of the Wind", author: author1})
BookSearch.Books.create_book(%{title: "We are Legend (We are Bob)", author: author2})
end
For the most part, this code should look familiar other than perhaps Mix.env() == :dev
.
Mix.env()
returns the current environment. When we run tests Mix.env()
returns :test
. When we run the application locally, Mix.env()
returns :dev
. We’ve added this condition to avoid seeding data in our tests, which could cause unexpected behavior.
We can seed our database by running the following. Make sure you stop the server first. Otherwise, the command will fail.
$ mix run priv/repo/seeds.exs
However, keep in mind if we keep running this file, we’ll keep creating duplicate values in the database. We currently don’t prevent two authors having the same name.
To avoid this issue, we can instead run the following command to drop the database, recreate an empty database, run migrations, and then run the seed file.
$ mix ecto.reset
This command is defined in our mix.exs
file in the aliases/0
function.
defp aliases do
[
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.deploy": ["esbuild default --minify", "phx.digest"]
]
end
After running mix ecto.reset
our database is seeded correctly. Start the server again, and we should see the following when we visit http://localhost:4000/authors/1/books/new.
Create Book Tags
Submitting our form doesn’t associate a book with a tag. However, it does provide the data we need for the controller action.
Here’s the book_params
value in BookController.create/2
when we submit the form with the first three tags selected.
%{"tags" => ["1", "2", "3"], "title" => ""}
Feel free to IO.inspect/2
the book_params
in BookController.create/2
, then create a book with tags selected to verify this is true.
# lib/book_search_web/controllers/book_controller.ex
def create(conn, %{"book" => book_params, "author_id" => author_id}) do
IO.inspect(book_params, label: "Book Params") # <- remove this IO.inspect when you are done
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)
end
end
Books.create_book/1
Now that we know we can send a list of tags, let’s start writing a test for the Books.create_book/1
function. First, import the BookSearch.TagsFixtures
into the test file. This import gives us access to the tag_fixture/1
function to create tags in our tests.
# test/book_search/books_test.exs
import BookSearch.BooksFixtures
import BookSearch.AuthorsFixtures
import BookSearch.TagsFixtures
Write a new "create_book/1 with tags"
test.
test "create_book/1 with tags" do
author = author_fixture()
tag1 = tag_fixture()
tag2 = tag_fixture()
valid_attrs = %{title: "some title", author: author, tags: [tag1, tag2]}
assert {:ok, %Book{} = book} = Books.create_book(valid_attrs)
assert book.title == "some title"
assert book.tags == [tag1, tag2]
end
We can use Ecto.Changeset.put_assoc/4 to create the association between the book and the tags.
> The Ecto.Changeset.put_assoc/4
call should be the responsibility of the Book
schema. We’re calling Ecto.Changeset.put_assoc/4
directly for demonstration purposes only.
def create_book(attrs \\ %{}) do
{author, attrs} = Map.pop!(attrs, :author)
author
|> Ecto.build_assoc(:books, attrs)
|> Book.changeset(attrs)
|> Ecto.Changeset.put_assoc(:tags, attrs.tags)
|> Repo.insert()
end
To let Ecto
handle this association with put_assoc/4
, we need to tell the Book
schema about the many-to-many relationship. The many_to_many/3 macro lets us define the :tags
field, the Tag
struct value, and to join through the "book_tags"
table.
defmodule BookSearch.Books.Book do
use Ecto.Schema
import Ecto.Changeset
schema "books" do
field :title, :string
belongs_to :author, BookSearch.Authors.Author
many_to_many :tags, BookSearch.Tags.Tag, join_through: "book_tags"
timestamps()
end
@doc false
def changeset(book, attrs) do
book
|> cast(attrs, [:title])
|> validate_required([:title])
end
end
Now our test should pass! Run the following in the command line where 40
is the correct line number of the test.
$ mix test test/book_search/books_test.exs:40
...
9 tests, 0 failures, 8 excluded
However, if we visit http://localhost:4000/authors/1/books/new and submit the form with tags selected, we’ll see the following error.
That’s because Books.create_book/1
expects attrs
to contain a :tags
field that is a list of tags. However, we’re passing it a "tags"
field that is a list of tag ids.
Let’s retrieve the tag for each tag id, and Map.put/2
the :tags
field into the book_params
map. Also, we’ll provide a default value for tags to avoid breaking existing controller tests.
def create(conn, %{"book" => book_params, "author_id" => author_id}) do
author = BookSearch.Authors.get_author!(author_id)
# Provide a default value for tag_ids to avoid breaking existing tests
{tag_ids, book_params} = Map.pop(book_params, "tags", [])
tags = Enum.map(tag_ids, &Tags.get_tag!/1)
book_params = book_params |> Map.put(:author, author) |> Map.put(:tags, tags)
case Books.create_book(book_params) 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)
end
end
We’re not allowed to pass a map with both string keys and atom keys to Ecto.Changeset.cast/4
, so let’s Map.pop/2
the :tags
field in Books.create_book/1
.
def create_book(attrs \\ %{}) do
{author, attrs} = Map.pop!(attrs, :author)
{tags, attrs} = Map.pop(attrs, :tags, [])
author
|> Ecto.build_assoc(:books, attrs)
|> Book.changeset(attrs)
|> Ecto.Changeset.put_assoc(:tags, tags)
|> Repo.insert()
end
All tests should pass!
$ mix test
...
59 tests, 0 failures
Show Book Tags
To ensure we’re creating books with tags successfully, let’s display the list of tags on the book show page.
First, add a controller test to create a book with tags. We’ll then assert we find the tag text on the page.
# test/book_search_web/controllers/book_controller_test.exs
describe "create book" do
test "with tags, displays tags on show page", %{conn: conn} do
author = author_fixture()
tag = tag_fixture(name: "Fantasy")
create_attrs = %{title: "some title", tags: [tag.id]}
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) =~ tag.name
end
...
end
We’ll display the book’s tags on the book show page.
# lib/book_search_web/templates/book/show.html.heex
<h1>Show Book</h1>
<ul>
<li>
<strong>Title:</strong>
<%= @book.title %>
</li>
<li>
<strong>Tags:</strong>
<%= for tag <- @book.tags do %>
<%= tag.name %>
<% end %>
</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>
However, by default, we don’t load the tags associated with each book, so this will fail when we visit http://localhost:4000/authors/1/books/1
The book show page uses Books.get_book!/1
to retrieve the book. Currently, this does not load the :tags
association.
# lib/book_search_web/controllers/book_controller.exs
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
To load associations from other tables, we can use preload/3.
# lib/book_search/books.ex
def get_book!(id), do: Book |> preload(:tags) |> Repo.get!(id)
preload/3
loads the associated tags
table through the book_tags
table to preload each associated BookSearch.Tags.Tag
struct. Now we have access to book.tags
and our test passes!
$ mix test
...
60 tests, 0 failures
Seeding
Let’s seed some tags in our books to ensure the show page displays tags.
alias BookSearch.Tags
alias BookSearch.Authors
alias BookSearch.Books
if Mix.env() == :dev do
{:ok, author1} = Authors.create_author(%{name: "Patrick Rothfuss"})
{:ok, author2} = Authors.create_author(%{name: "Dennis E Taylor"})
[tag1, tag2, tag3, tag4] =
["fiction", "fantasy", "history", "sci-fi"]
|> Enum.map(fn tag_name ->
{:ok, tag} = Tags.create_tag(%{name: tag_name})
tag
end)
|> IO.inspect()
Books.create_book(%{title: "Name of the Wind", author: author1, tags: [tag1, tag2]})
Books.create_book(%{
title: "We are Legend (We are Bob)",
author: author2,
tags: [tag3, tag4]
})
end
Then stop the server and reset the database.
$ mix ecto.reset
Start the server again, and visit http://localhost:4000/authors/1/books/1. We should see the following.
$ mix phx.server
Edit Book Tags
Currently, we cannot update a book’s tags. We can visit http://localhost:4000/authors/1/books/1/edit and submit the form with different tags selected. However, the tags aren’t updated.
Let’s make a new test for updating books to catch this error.
# test/book_search_web/controllers/book_controller_test.exs
describe "update book" do
setup [:create_book]
test "with tags", %{conn: conn, book: book} do
tag = tag_fixture(name: "Fantasy")
update_attrs = %{title: "Name of the Wind", tags: [tag.id]}
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) =~ update_attrs.title
assert html_response(conn, 200) =~ tag.name
end
...
end
To make the test pass, we need to retrieve all of the book tags by their id.
# lib/book_search_web/controllers/book_controller.ex
def update(conn, %{"id" => id, "book" => book_params, "author_id" => author_id}) do
book = Books.get_book!(id)
{tag_ids, book_params} = Map.pop(book_params, "tags", [])
tags = Enum.map(tag_ids, &Tags.get_tag!/1)
book_params = Map.put(book_params, :tags, tags)
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
Let’s also write a test for Books.update_book/2
.
# test/book_search/books_test.exs
test "update_book/2 with tags" do
author = author_fixture()
tag1 = tag_fixture(name: "Fantasy")
tag2 = tag_fixture(name: "Fiction")
book = book_fixture(author: author, tags: [tag1])
update_attrs = %{title: "Name of the Wind", tags: [tag2]}
assert {:ok, %Book{} = book} = Books.update_book(book, update_attrs)
assert book.title == update_attrs.title
assert book.tags == [tag2]
end
Then we need to use put_assoc/4 to put the :tags
association into the update book changeset.
# lib/book_search/books.ex
def update_book(%Book{} = book, attrs) do
{tags, attrs} = Map.pop(attrs, :tags, [])
book
|> Book.changeset(attrs)
|> Ecto.Changeset.put_assoc(:tags, tags)
|> Repo.update()
end
However, our tests still fail. Run the following in the command line to run the test. Replace 57
with the correct line number of the "update_book/2 with tags
test, and we’ll see an interesting error.
$ mix test test/book_search/books_test.exs:57
** (RuntimeError) you are attempting to change relation :tags of
BookSearch.Books.Book but the `:on_replace` option of this relation
is set to `:raise`.
By default it is not possible to replace or delete embeds and
associations during `cast`. Therefore Ecto requires the parameters
given to `cast` to have IDs matching the data currently associated
to BookSearch.Books.Book. Failing to do so results in this error message.
If you want to replace data or automatically delete any data
not sent to `cast`, please set the appropriate `:on_replace`
option when defining the relation. The docs for `Ecto.Changeset`
covers the supported options in the "Associations, embeds and on
replace" section.
However, if you don't want to allow data to be replaced or
deleted, only updated, make sure that:
* If you are attempting to update an existing entry, you
are including the entry primary key (ID) in the data.
* If you have a relationship with many children, all children
must be given on update.
This error explains that, by default, we cannot delete or replace associated data. That’s because of the default value for the :on_replace
option for Ecto.Schema.many_to_many/3.
We can see the default behavior of :on_replace
is :raise
in the documentation:
> :on_replace - The action taken on associations when the record is replaced when casting or manipulating parent changeset. May be :raise (default), :mark_as_invalid, or :delete. :delete will only remove data from the join source, never the associated records. See Ecto.Changeset’s section on related data for more info.
By default, if we try to replace one associated record with another, we :raise
an error. So instead, let’s use the :delete
option in the Book
schema to delete the record in the book_tags
join table and replace it with another record.
# lib/book_search/books/book.ex
defmodule BookSearch.Books.Book do
use Ecto.Schema
import Ecto.Changeset
schema "books" do
field :title, :string
belongs_to :author, BookSearch.Authors.Author
many_to_many :tags, BookSearch.Tags.Tag, join_through: "book_tags", on_replace: :delete
timestamps()
end
@doc false
def changeset(book, attrs) do
book
|> cast(attrs, [:title])
|> validate_required([:title])
end
end
Now our test passes! Replace 57
with the correct line number of the test.
$ mix test test/book_search/books_test.exs:57
...
10 tests, 0 failures, 9 excluded
Now we can update a book’s tags when we visit http://localhost:4000/authors/1/books/1/edit.
Preselecting
By default, we don’t select any tags in the book form.
# lib/book_search_web/templates/book/form.html.heex
<%= multiple_select f, :tags, tag_options(), selected: [] %>
When we submit the edit form without selecting tags, we accidentally remove the tags associated with a book!
To fix this, we need to use the book’s tags as the default :selected
option in multiple_select/4
. The :selected
option accepts a list of ids.
# lib/book_search_web/templates/book/form.html.heex
<%= multiple_select f, :tags, tag_options(), selected: [1, 2] %>
So we can retrieve a list of ids from @book.tags
.
# lib/book_search_web/templates/book/form.html.heex
<%= multiple_select f, :tags, tag_options(), selected: Enum.map(@book.tags, fn book -> book.id end) %>
Some pages use this form without an @book
in the assigns, so we have to check if :book
exists. If it doesn’t, we won’t have any pre-selected options.
# lib/book_search_web/templates/book/form.html.heex
<%= multiple_select f, :tags, tag_options(), selected: if assigns[:book], do: Enum.map(@book.tags, fn book -> book.id end), else: [] %>
Now the book’s tags will be selected by default.
Lists Books By Tag
We’ve shown we can retrieve tags that belong to a book, but what about books that belong to a tag?
To demonstrate, we’re going to list all of the books associated with a tag on the tag show page.
Let’s start with our test. First, we’ll import BookSeach.BooksFixtures
to create books.
# lib/book_search_web/controllers/tag_controller_test.exs
import BookSearch.BooksFixtures
import BookSearch.AuthorsFixtures
import BookSearch.TagsFixtures
We’ll create a new describe block for the "show"
action with a test for displaying all books matching the tag.
# test/book_search_web/controllers/tag_controller.exs
describe "show" do
test "lists all books matching the tag", %{conn: conn} do
author = author_fixture(name: "Dennis E Taylor")
tag = tag_fixture(name: "sci-fi")
tagged_book = book_fixture(author: author, title: "We are Legend (We are Bob)", tags: [tag])
untagged_book = book_fixture(author: author, title: "Name of the Wind")
conn = get(conn, Routes.tag_path(conn, :show, tag.id))
assert html_response(conn, 200) =~ tag.name
assert html_response(conn, 200) =~ tagged_book.title
refute html_response(conn, 200) =~ untagged_book.title
end
end
We’ll use the tag.books
association to render the list of books on the tag show page.
# lib/book_search_web/templates/book/show.html.heex
<h1>Show Tag</h1>
<ul>
<li>
<strong>Name:</strong>
<%= @tag.name %>
</li>
</ul>
<%= for book <- @tag.books do %>
<tr>
<td><%= book.author.name %></td>
<td><%= book.title %></td>
</tr>
<% end %>
<span><%= link "Edit", to: Routes.tag_path(@conn, :edit, @tag) %></span> |
<span><%= link "Back", to: Routes.tag_path(@conn, :index) %></span>
However, this association isn’t loaded when we retrieve the tag, so we encounter the following error when we visit http://localhost:4000/tags/1.
That’s because while we’ve defined the many_to_many/3 relationship for books, we have not defined this relationship for tags. So let’s add this now.
defmodule BookSearch.Tags.Tag do
use Ecto.Schema
import Ecto.Changeset
schema "tags" do
field :name, :string
many_to_many :books, BookSearch.Books.Book, join_through: "book_tags"
timestamps()
end
@doc false
def changeset(tag, attrs) do
tag
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
Tags can also have many books through the same book_tags
table.
flowchart LR
T1[Tag]
BT[Book Tags]
B1[Book]
B2[Book]
B3[Book]
B1 --> BT
B2 --> BT
B3 --> BT
BT --> T1
Now that we’ve defined the :books
field in the BookSearch.Tags.Tag
, we’ll see a new error when we visit http://localhost:4000/tags/1.
To fix this issue, we need to load the associated books. The TagController.show/2
action uses Tags.get_tag!/1
.
# lib/book_search_web/controllers/tag_controller.ex
def show(conn, %{"id" => id}) do
tag = Tags.get_tag!(id)
render(conn, "show.html", tag: tag)
end
We’ll preload the books in the Tags.get_tag!/1
function.
# lib/book_search/tags.ex
def get_tag!(id), do: Tag |> preload(:books) |> Repo.get!(id)
However, that’s not enough! For example, if we visit http://localhost:4000/tags/1 we’ll see the following error.
> If you don’t see the error, try resetting your database with mix ecto.reset
to ensure the tag has associated books. If no books are associated with the tag, then @tag.books
returns an empty list, and we won’t trigger the bug.
We have an issue because while we’re preloading the :books
associated with the tag, we’re not loading the :author
associated with each book.
flowchart LR
T1[Tag]
B1[Book]
A1[Author]
n[name]
T1 --> B1 --> A1 --> n
To resolve this, we can preload the author for each book by passing a keyword list to preload/3.
# lib/book_search/tags.ex
def get_tag!(id), do: Tag |> preload(books: [:author]) |> Repo.get!(id)
Our test should pass! Run the following and replace 20
with the correct line number of the "list all books matching the tag"
test.
$ mix test test/book_search_web/controllers/tag_controller_test.exs:20
...
9 tests, 0 failures, 8 excluded
However, we’ve broken a couple of other tests by preloading this data.
$ mix test
...
63 tests, 2 failures
We can fix these tests by explicitly checking the fields on the tag.
# test/book_search/tags_test.exs
test "get_tag!/1 returns the tag with given id" do
tag = tag_fixture()
retrieved_tag = Tags.get_tag!(tag.id)
assert retrieved_tag.id == tag.id
assert retrieved_tag.name == tag.name
end
# test/book_search/tags_test.exs
test "update_tag/2 with invalid data returns error changeset" do
tag = tag_fixture()
assert {:error, %Ecto.Changeset{}} = Tags.update_tag(tag, @invalid_attrs)
found_tag = Tags.get_tag!(tag.id)
assert found_tag.id == tag.id
assert found_tag.name == tag.name
end
All tests should pass!
$ mix test
...
63 tests, 0 failures
Filter Books By Tags
Let’s add the ability to search for books by tags. We’ll need a multiple_select/4
in the book search form.
# 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>
<%= if assigns[:display_form] do %>
<.form let={f} for={@conn} method={"get"} action={Routes.book_path(@conn, :index)}>
<%= text_input f, :title %>
<%= error_tag f, :title %>
<%= label f, :tags %>
<%= multiple_select f, :tags, tag_options(), selected: [] %>
<%= error_tag f, :tags %>
<%= submit "Search" %>
<% end %>
<%= 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 a new test for searching by tags and the book title.
# test/book_search_web/controllers/book_controller_test.exs`
test "list all books filtered by search and tags query", %{conn: conn} do
author = author_fixture(name: "Brandon Sanderson")
tag = tags_fixture(name: "Fantasy")
book = book_fixture(author: author, title: "The Final Empire", tags: [tag])
untagged_book = book_fixture(author: author, title: "Mistborn: The Final Empire")
non_matching_book = book_fixture(author: author, title: "The Hero Of Ages")
conn = get(conn, Routes.book_path(conn, :index, title: "Empire", tags: [tag.id]))
assert html_response(conn, 200) =~ book.title
refute html_response(conn, 200) =~ untagged_book.title
refute html_response(conn, 200) =~ non_matching_book.title
end
We’ll add "tags"
the the BookController.index/2
action for searching by title.
# lib/book_search_web/controllers/book_controller.ex`
def index(conn, %{"title" => title, "tags" => tag_ids}) do
books = Books.list_books(title: title, tags: tag_ids)
render(conn, "index.html", books: books, display_form: true)
end
We can search for tags by using join/5 to join the tags table. Then check if the book has any tags whose id is in the list of tag ids.
# lib/book_search/books.ex
def list_books(title: title, tags: tag_ids) do
search = "%#{title}%"
Book
|> preload(:author)
|> where([book], ilike(book.title, ^search))
|> join(:inner, [book], tag in assoc(book, :tags))
|> where([book, tag], tag.id in ^tag_ids)
|> Repo.all()
end
Our test passes! Replace 20
with the correct line number.
$ mix test test/book_search_web/controllers/book_controller_test.exs:20
However, we’ve broken our previous filtering test "list all books filtered by search query"
. That’s because it doesn’t contain the :tags
search field.
# test/book_search_web/controllers/book_controller_test.exs`
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
We’ll change the controller to provide a default value for tags since they won’t be available if the list is empty.
# lib/book_search_web/controllers/book_controller.ex
def index(conn, %{"title" => title} = params) do
tag_ids = Map.get(params, "tags", [])
books = Books.list_books(title: title, tags: tag_ids)
render(conn, "index.html", books: books, display_form: true)
end
The test still fails because of how we’ve written the query. For example, if tags is an empty list, then our where/3 query is always false.
where([book, tag], tag.id in ^tag_ids)
There are many patterns for filtering queries ranging from simple to advanced solutions.
For now, we’ll show a pattern using control flow to re-bind the query depending on the value of tag_ids
.
def list_books(title: title, tags: tag_ids) do
search = "%#{title}%"
query =
Book
|> preload(:author)
|> where([book], ilike(book.title, ^search))
query =
case tag_ids do
[] ->
query
tag_ids ->
query
|> join(:inner, [book], tag in assoc(book, :tags))
|> where([book, tag], tag.id in ^tag_ids)
end
Repo.all(query)
end
All tests should pass!
$ mix test
...
65 tests, 0 failures
We can visit http://localhost:4000/books and filter books by tags.
Commit Your Progress
Run the following in your command line from the project folder to track and save your progress in a Git commit.
$ git add .
$ git commit -m "finish book search tags section"