Book Search: Tags
Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.9", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"}
])
Navigation
Home Report An Issue Blog: AuthenticationBlog: TagsReview Questions
Upon completing this lesson, a student should be able to answer the following questions.
- How do we configure a many-to-many association with Ecto?
- How do we load an association when making a database query?
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 on the tags
branch.
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.
Follow Along: BookSearch: Tags
Ensure you have completed the BookSearch
project from the previous lesson. If not, you can clone the BookSearch/book_form project.
If you cloned the project, ensure you use the latest code on the book_form
branch from the previous exercise.
$ git checkout book_form
All tests should pass.
$ mix deps.get
$ mix test
Start the server.
$ mix phx.server
If you encounter any issues with your database you may need to reset it. Reset the database now to ensure you have a clean database.
mix ecto.reset
If you encounter issues with your database in your test environment, you can drop it.
MIX_ENV=test mix ecto.drop
Create Tags Resource
We can run the following to create our tags resource.
$ mix phx.gen.html Tags Tag tags name:string
Then add the "/tags"
resources to our router.
# Set Up A Scope For The BookSearchWeb Web Application
scope "/", BookSearchWeb do
# Use the :browser pipeline for all routes within this scope
pipe_through :browser
# Define a route for the root path that maps to the index action of the PageController
get "/", PageController, :index
# Define routes for the AuthorController actions
get "/authors", AuthorController, :index # Index action
get "/authors/new", AuthorController, :new # New action
get "/authors/:id", AuthorController, :show # Show action
get "/authors/edit/:id", AuthorController, :edit # Edit action
post "/authors", AuthorController, :create # Create action
put "/authors/:id", AuthorController, :update # Update action
patch "/authors/:id", AuthorController, :update # Update action
delete "/authors/:id", AuthorController, :delete # Delete
# Define routes for the BookController actions
get "/books", BookController, :index # Index action
get "/books/new", BookController, :new # New action
get "/books/:id", BookController, :show # Show action
get "/books/edit/:id", BookController, :edit # Edit action
post "/books", BookController, :create # Create action
put "/books/:id", BookController, :update # Update action
patch "/books/:id", BookController, :update # Update action
delete "/books/:id", BookController, :delete # Delete action
# Define routes for the TagController actions
get "/tags", TagController, :index # Index action
get "/tags/new", TagController, :new # New action
post "/tags", TagController, :create # Create action
get "/tags/:id", TagController, :show # Show action
get "/tags/:id/edit", TagController, :edit # Edit action
put "/tags/:id", TagController, :update # Update action
patch "/tags/:id", TagController, :update # Update action
delete "/tags/:id", TagController, :delete # Delete action
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
Migration: Create Book With Tags
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
In the generated migration file, create the join table book_tags
that defines the many-to-many association between books and tags.
defmodule BookSearch.Repo.Migrations.CreateBookTags do
# Use the Ecto.Migration module to create a database migration
use Ecto.Migration
def change do
# Create a new table called 'book_tags'
create table(:book_tags) do
# Add a column named 'book_id' with a reference to the 'books' table
add(:book_id, references(:books, on_delete: :delete_all), null: false)
# Add a column named 'tag_id' with a reference to the 'tags' table
add(:tag_id, references(:tags, on_delete: :delete_all), null: false)
end
# Create a unique index on the 'book_tags' table with the 'book_id' and 'tag_id' columns
create(unique_index(:book_tags, [:book_id, :tag_id]))
end
end
Form: Create Book With 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.
Add tags
to our 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, :author_id %>
<%= select f, :author_id, Enum.map(@authors, fn author -> {author.name, author.id} end), prompt: "Select an author" %>
<%= error_tag f, :author_id %>
<%= label f, :tags %>
<%= multiple_select f, :tags, [] %>
<%= error_tag f, :tags %>
<%= submit "Save" %>
Visit http://localhost:4000/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"] %>
# Using Lists Of Tuples
<%= multiple_select f, :tags, [{"label", "value"}] %>
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.
Create a tag_options/0
function in the BookView
module.
# Lib/book_search_web/views/book_view.ex
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() %>
Seeding: Seed Tags
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.
To accomplish this, add the following content to priv/repo/seeds.exs
.
# Add Aliases To Your Existing Aliases
alias BookSearch.Tags
alias BookSearch.Tags.Tag
# Create Tags
["fiction", "fantasy", "history", "sci-fi"]
|> Enum.each(fn tag_name ->
case Repo.get_by(Tag, name: tag_name) do
%Tag{} = tag ->
IO.inspect(tag_name, label: "Tag Already Created")
nil ->
Tags.create_tag(%{name: tag_name})
end
end)
Run the following command to seed tags in our database. Make sure you stop the server first. Otherwise, the command will fail.
$ mix run priv/repo/seeds.exs
Now that our database is seeded correctly. Start the server again, and we should see the form has tags when we visit http://localhost:4000/books/new.
Controller: Create Book With 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" => "", "author_id" => ""}
We need to provide tags when we try to create a book in the BookController.create/2
action. We’ll need to full tag, not just the tag id. To accomplish this, we’ll map over our tag ids and fetch each one from the database.
It’s not performant to make a query to the database for every tag. It would be ideal to make a single query for all tags in our list of tag ids. However, this code is simpler and avoids unnecessary complexity for our demonstration.
def create(conn, %{"book" => book_params}) do
# Extract the 'tags' field from the 'book_params' map and assign its value to the 'tag_ids' variable.
# If the 'tags' field is not present, assign an empty list to 'tag_ids'.
# The remaining key-value pairs in the 'book_params' map are assigned to the 'book_params' variable.
{tag_ids, book_params} = Map.pop(book_params, "tags", [])
# Use the 'Enum.map/2' function to transform the 'tag_ids' list into a list of 'Tag' structs.
# The '&Tags.get_tag!/1' function is passed to the '&' operator and used as the iterator function.
tags = Enum.map(tag_ids, &Tags.get_tag!/1)
case Books.create_book(book_params, tags) do
{:ok, book} ->
conn
|> put_flash(:info, "Book created successfully.")
|> redirect(to: Routes.book_path(conn, :show, book))
{:error, %Ecto.Changeset{} = changeset} ->
authors = Authors.list_authors()
render(conn, "new.html", changeset: changeset, authors: authors)
end
end
Context: Create Book With Tags
Now that we’re providing tags, we need to modify our Books
context to provide tags when we try to create a book. It’s idomatic to let the changeset handle associations, so we’ll provide tags
to the changeset.
def create_book(attrs \\ %{}, tags \\ []) do
# Create a new Book struct with the default values
%Book{}
# Use the changeset function to update the struct with the provided attributes and tags
|> Book.changeset(attrs, tags)
# Insert the updated struct into the repository
|> Repo.insert()
end
Changeset: Create Book With Tags
Since the tags already exist, and we want to work with the tags association as a whole, we’ll use put_assoc/4 to put tags into our Book
changeset in book.ex
.
def changeset(book, attrs, tags \\ []) do
book
|> cast(attrs, [:title, :author_id])
|> put_assoc(:tags, tags)
|> validate_required([:title])
end
By adding this association, we’ve broken several tests. We’ll circle back to these later.
Schema: Associate Book With Tags
To let Ecto handle this association with put_assoc/4
, we need to tell the Book
schema about the many-to-many relationship. The Ecto.Schema.many_to_many/3 macro lets us define a many-to-many relationship.
Add the :tags
association on the Book
schema. Provide the Tag
struct, the book_tags
join through table, and define the on_replace
behavior so we’ll delete existing associations if we update book tags.
defmodule BookSearch.Books.Book do
# Use the Ecto.Schema module to define a schema for the 'books' table
use Ecto.Schema
# Import the Ecto.Changeset module for use in the changeset function
import Ecto.Changeset
# Define the schema for the 'books' table
schema "books" do
# Add a 'title' column of type 'string'
field :title, :string
# Add a foreign key column named 'author_id' that references the 'authors' table
belongs_to :author, BookSearch.Authors.Author
# Add a many-to-many relationship with the 'tags' table through the 'book_tags' join table
many_to_many :tags, BookSearch.Tags.Tag, join_through: "book_tags", on_replace: :delete
# Add timestamps for the 'inserted_at' and 'updated_at' columns
timestamps()
end
# Define a changeset function for updating books
@doc false
def changeset(book, attrs, tags \\ []) do
# Cast the 'attrs' map to include only the 'title' and 'author_id' fields
book
|> cast(attrs, [:title, :author_id])
# Update the many-to-many relationship with the 'tags' table
|> put_assoc(:tags, tags)
# Validate that the 'title' field is present
|> validate_required([:title])
end
end
We’ll add the same association on the Tag
schema in tag.ex
.
defmodule BookSearch.Tags.Tag do
# Use the Ecto.Schema module to define a schema for the 'tags' table
use Ecto.Schema
# Import the Ecto.Changeset module for use in the changeset function
import Ecto.Changeset
# Define the schema for the 'tags' table
schema "tags" do
# Add a 'name' column of type 'string'
field :name, :string
# Add a many-to-many relationship with the 'books' table through the 'book_tags' join table
many_to_many :books, BookSearch.Books.Book, join_through: "book_tags", on_replace: :delete
# Add timestamps for the 'inserted_at' and 'updated_at' columns
timestamps()
end
# Define a changeset function for updating tags
@doc false
def changeset(tag, attrs) do
# Cast the 'attrs' map to include only the 'name' field
tag
|> cast(attrs, [:name])
# Validate that the 'name' field is present
|> validate_required([:name])
end
end
Template: Create Book With Tags
To check if we’ve created a book with tags, we’ll display the book’s tag on the book show page.
Add the following to the book show.html.heex
template file.
<li>
<strong>Tags:</strong>
<ul>
<%= for tag <- @book.tags do %>
<li><%= link tag.name, to: Routes.tag_path(@conn, :show, tag) %></li>
<% end %>
</ul>
</li>
We’ll get an error:
protocol Enumerable not implemented for #Ecto.Association.NotLoaded of type Ecto.Association.NotLoaded (a struct)
Because we haven’t preloaded the tags
association on the book yet.
Preload: Tags
Preload tags
in the BookController.show/2
action so that we have access to @book.tags
in our template.
def show(conn, %{"id" => id}) do
book = Books.get_book!(id) |> BookSearch.Repo.preload([:author, :tags])
render(conn, "show.html", book: book)
end
Fix Failing Tests
By adding the tags
association, we’ve broken any tests that rely on creating a book and testing on the book contents.
For example, the following test in books_test.exs
:
test "list_books/0 returns all books" do
book = book_fixture()
assert Books.list_books() == [book]
end
Fails due to us loading the association when we create a book, but not when we list books.
1) test books list_books/0 returns all books (BookSearch.BooksTest)
test/book_search/books_test.exs:15
Assertion with == failed
code: assert Books.list_books() == [book]
left: [
%BookSearch.Books.Book{
__meta__: #Ecto.Schema.Metadata<:loaded, "books">,
author: #Ecto.Association.NotLoaded,
author_id: nil,
id: 237,
inserted_at: ~N[2022-12-21 07:16:08],
tags: #Ecto.Association.NotLoaded,
title: "some title",
updated_at: ~N[2022-12-21 07:16:08]
}
]
right: [
%BookSearch.Books.Book{
__meta__: #Ecto.Schema.Metadata<:loaded, "books">,
author: #Ecto.Association.NotLoaded,
author_id: nil,
id: 237,
inserted_at: ~N[2022-12-21 07:16:08],
tags: [],
title: "some title",
updated_at: ~N[2022-12-21 07:16:08]
}
]
stacktrace:
test/book_search/books_test.exs:17: (test)
We have several options for fixing these tests. Here are two options.
- Always preload tags (not performant, but easy to write)
-
Remove
:tags
field from every assertion where tags is irrelevant (more performant, harder to write, less comprehensive testing)
Since this is a demonstration, we’ll choose to preload our tags. We don’t expect to have large amounts of tags for every book, so this shouldn’t cause any major performance issues.
Modify the get_book!/1
and list_books/0
functions in books.ex
.
def list_books do
Repo.all(Book) |> Repo.preload(:tags)
end
def get_book!(id), do: Repo.get!(Book, id) |> Repo.preload(:tags)
Now all tests should pass! Run your tests now to confirm.
mix test
Update Book Tags
First, create a book on http://localhost:4000/books/new with some tags.
Our edit form doesn’t include the selected tags. Provide a list of tag ids in the BookController.edit/2
action.
def edit(conn, %{"id" => id}) do
book = Books.get_book!(id)
# Use the 'Enum.map/2' function to transform the 'tags' field of the 'book' struct into a list of tag ids.
# The '& &1.id' function is passed to the '&' operator and used as the iterator function.
tag_ids = Enum.map(book.tags, & &1.id)
changeset = Books.change_book(book)
authors = Authors.list_authors()
render(conn, "edit.html", book: book, changeset: changeset, authors: authors, tag_ids: tag_ids)
end
Then use the tag_ids
with the selected
option for the multi_select
.
<%= multiple_select f, :tags, tag_options(), selected: assigns[:tag_ids] || [] %>
Now our edit page pre-selects tags.
Try to edit this book, and you’ll notice it removes any existing tags.
That’s because when we update a book, we don’t provide any tags in our BookController.update/2
action. Let’s add tags to our update action in book_controller.ex
.
def update(conn, %{"id" => id, "book" => book_params}) do
book = Books.get_book!(id)
# Extract the 'tags' field from the 'book_params' map and assign its value to the 'tag_ids' variable.
# If the 'tags' field is not present, assign an empty list to 'tag_ids'.
# The remaining key-value pairs in the 'book_params' map are assigned to the 'book_params' variable.
{tag_ids, book_params} = Map.pop(book_params, "tags", [])
# Use the 'Enum.map/2' function to transform the 'tag_ids' list into a list of 'Tag' structs.
# The '&Tags.get_tag!/1' function is passed to the '&' operator and used as the iterator function.
tags = Enum.map(tag_ids, &Tags.get_tag!/1)
case Books.update_book(book, book_params, tags) do
{:ok, book} ->
conn
|> put_flash(:info, "Book updated successfully.")
|> redirect(to: Routes.book_path(conn, :show, book))
{:error, %Ecto.Changeset{} = changeset} ->
authors = Authors.list_authors()
render(conn, "edit.html", book: book, changeset: changeset, authors: authors)
end
Now we need to use tags
in our Books.update/3
function.
def update_book(%Book{} = book, attrs, tags \\ []) do
book
|> Book.changeset(attrs, tags)
|> Repo.update()
end
Try updating the book’s tags and it should work!
Lists Books By Tag
Let’s try using the many-to-many association from the opposite direction by displaying a list of books for a tag.
We’ll preload the books
association when we retrieve a tag in tags.ex
. This is a good opportunity to demonstrate that we can even preload nested associations so we’ll also load the book’s author.
def get_tag!(id), do: Repo.get!(Tag, id) |> Repo.preload([books: [:author]])
Then use the tags.books
and book.author
association to create a list of books on the tag show page show.html.heex
.
Show Tag
-
Name:
<%= @tag.name %>
-
Books:
<%= for book <- @tag.books do %>
-
<%= link book.title, to: Routes.book_path(@conn, :show, book) %>
<%= if book.author do %>
by
<%= link book.author.name, to: Routes.author_path(@conn, :show, book.author) %>
<% end %>
<% end %>
<%= link "Edit", to: Routes.tag_path(@conn, :edit, @tag) %> |
<%= link "Back", to: Routes.tag_path(@conn, :index) %>
Now we’ll see a list of books and their authors if the author exists.
Fix Failing Tests
Similar to before, by preloading the association in Tags
we’ve broken some tests. Since a tag might have a lot of books, we won’t solve this by preloading books for every tag. Instead, we’ll modify our tag tests.
Fix the failing get_tag!/1
test.
# Original
test "get_tag!/1 returns the tag with given id" do
tag = tag_fixture()
assert Tags.get_tag!(tag.id) == tag
end
# Fixed
test "get_tag!/1 returns the tag with given id" do
tag = tag_fixture()
found_tag = Tags.get_tag!(tag.id)
assert found_tag.id == tag.id
assert found_tag.name == tag.name
end
Fix the failing update_tag/2
test.
# Original
test "update_tag/2 with invalid data returns error changeset" do
tag = tag_fixture()
assert {:error, %Ecto.Changeset{}} = Tags.update_tag(tag, @invalid_attrs)
assert tag == Tags.get_tag!(tag.id)
end
# Fixed
test "update_tag/2 with invalid data returns error changeset" do
tag = tag_fixture()
assert {:error, %Ecto.Changeset{}} = Tags.update_tag(tag, @invalid_attrs)
assert tag.id == Tags.get_tag!(tag.id).id
end
Your Turn: Add Tests
Test this feature. Write a test for:
-
The
BooksController
create action with associated tags. -
The
BooksController
update action with associated tags. -
The
TagController
show action with associated tags. -
The
Books.create_book/1
context function with associated tags. -
The
Books.update_book/2
context function with associated tags.
Then, compare your tests with the examples below.
TagsFixtures
Import the BookSearch.TagsFixtures
into the book_controller_test.exs
and book_test.exs
and tag_controller_test.exs
files where necessary. This import gives us access to the tag_fixture/1
, book_fixture/1
and author_fixture/1
functions when we need to create books, authors, or tags in our tests.
# Add With Your Existing Imports
import BookSearch.AuthorsFixtures
import BookSearch.BooksFixtures
import BookSearch.TagsFixtures
Book Controller create
Test
# Test The 'create' Action Of The 'BookController' With A Request That Includes Tags.
test "create a book with tags", %{conn: conn} do
# Create two tags.
tag1 = tag_fixture(name: "tag1")
tag2 = tag_fixture(name: "tag2")
# Add the tag ids to the 'create_attrs' map.
create_attrs_with_tags = Map.put(@create_attrs, :tags, [tag1.id, tag2.id])
# Send a POST request to the 'create' action with the 'create_attrs_with_tags' map as the request body.
conn = post(conn, Routes.book_path(conn, :create), book: create_attrs_with_tags)
# Assert that the response is a redirect to the 'show' action with the new book's id as a parameter.
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == Routes.book_path(conn, :show, id)
# Send a GET request to the 'show' action with the new book's id as a parameter.
conn = get(conn, Routes.book_path(conn, :show, id))
# Assert that the response has a 200 status code and contains the tag names.
response = html_response(conn, 200)
assert response =~ "Show Book"
assert response =~ tag1.name
assert response =~ tag2.name
end
Book Controller update
Test
# Test The 'update' Action Of The 'BookController' With A Request That Includes Tags.
test "update a book with tags", %{conn: conn, book: book} do
# Create two tags.
tag1 = tag_fixture(name: "tag1")
tag2 = tag_fixture(name: "tag2")
# Add the tag ids to the 'update_attrs' map.
update_attrs_with_author = Map.put(@update_attrs, :tags, [tag1.id, tag2.id])
# Send a PUT request to the 'update' action with the 'update_attrs_with_author' map as the request body and the book's id as a parameter.
conn = put(conn, Routes.book_path(conn, :update, book), book: update_attrs_with_author)
# Assert that the response is a redirect to the 'show' action with the book's id as a parameter.
assert redirected_to(conn) == Routes.book_path(conn, :show, book)
# Send a GET request to the 'show' action with the book's id as a parameter.
conn = get(conn, Routes.book_path(conn, :show, book))
# Assert that the response has a 200 status code and contains the expected text.
response = html_response(conn, 200)
assert response =~ "some updated title"
assert response =~ tag1.name
assert response =~ tag2.name
end
Context Books.create/2
Test
# Test The 'create_book/2' Function With A Request That Includes Tags.
test "create_book/1 with tags" do
# Create two tags.
tag1 = tag_fixture(name: "tag1")
tag2 = tag_fixture(name: "tag2")
# Define a map of valid book attributes.
valid_attrs = %{title: "some title"}
# Call the 'create_book/2' function with the valid attributes and the associated tags.
# Assert that the function returns an ':ok' tuple with a book struct as the second element.
# Assign the book struct to the 'book' variable.
assert {:ok, %Book{} = book} = Books.create_book(valid_attrs, [tag1, tag2])
# Assert that the book has the expected 'title' and 'tags' fields.
assert book.title == "some title"
assert book.tags == [tag1, tag2]
end
Context Books.update/3
Test.
# Test The 'update_book/3' Function With A Request That Includes Tags.
test "update_book/3 with tags" do
# Create two tags.
tag1 = tag_fixture()
tag2 = tag_fixture()
# Create a book with no tags.
book = book_fixture(tags: [])
# Define a map of valid book attributes.
update_attrs = %{title: "some updated title"}
# Call the 'update_book/3' function with the book fixture, the update attributes, and the tag to associate.
# Assert that the function returns an ':ok' tuple with a book struct as the second element.
# Assign the book struct to the 'book' variable.
assert {:ok, %Book{} = book} = Books.update_book(book, update_attrs, [tag1, tag2])
# Assert that the book has the expected 'title' and 'tags' fields.
assert book.title == "some updated title"
assert book.tags == [tag1, tag2]
end
Tag Controller show
Test
# Test The 'show' Action Of The 'TagController'.
describe "show" do
# Test the 'show' action with a tag fixture that has an associated book.
test "tag with associated book", %{conn: conn} do
# Create a tag.
tag = tag_fixture()
# Create an author.
author = author_fixture()
# Create a book fixture with the associated tag and author.
book = book_fixture([author: author], [tag])
# Send a GET request to the 'show' action with the tag's id as a parameter.
conn = get(conn, Routes.tag_path(conn, :show, tag))
# Assert that the response has a 200 status code and contains the expected text.
response = html_response(conn, 200)
assert response =~ tag.name
assert response =~ author.name
assert response =~ book.title
end
end
This required a modification to the book_fixture
to use tags.
def book_fixture(attrs \\ %{}, tags \\ []) do
{:ok, book} =
attrs
|> Enum.into(%{
title: "some title"
})
|> BookSearch.Books.create_book(tags)
book
end
Push To GitHub
Ensure all of your tests continue to pass.
mix test
ONLY If you cloned the book_search
project: you’ll have to re-initialize it as a git project so you have ownership over the project. The following command removes the git folder and re-initializes it. You’ll then have to create a repository on GitHub and follow the instructions to connect the project.
$ rm -rf .git
$ git init
Then stage and commit your changes to GitHub from the book_search
folder.
git add .
git commit -m "many to many association between tags and books"
git push
Further Reading
For more on Phoenix, consider the following resources.
- Ecto
- Pragmatic Bookshelf: Programming Ecto
- Elixir Schools: Associations
- Elixir Forum: Ecto associations and the purpose of has many through and many to many
- Ecto.Changeset
Commit Your Progress
DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.
Run git status
to ensure there are no undesirable changes.
Then run the following in your command line from the curriculum
folder to commit your progress.
$ git add .
$ git commit -m "finish Book Search: Tags reading"
$ git push
We’re proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.
We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.