Book Search: Book Content
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: TagsBlog: Blog ContentReview Questions
Upon completing this lesson, a student should be able to answer the following questions.
- Why might you use a one-to-one relationship instead of adding an additional field in a table?
- How do you add an associated data structure to the form for a resource?
Overview
One-to-one Relationships
A one-to-one relationship is a relationship between two database tables where a single record in one table is related to a single record in the other table.
There are several reasons we might choose to use a one-to-one relationship instead of simply storing the data in an additional field in the same table.
- Performance: A one-to-one relationship can improve database performance by storing rarely used or expensive to retrieve data in a separate table.
-
Domain Design: It may make semantic sense to separate data, like a
PhoneNumber
with separate fields, into its own table. Creating an association can also ensure consistency between tables using a common resource. -
Flexibility: A one-to-one relationship allows for flexibility in changing the structure of related data without affecting the rest of the database. For example, storing user
Profile
data in a separate table allows you to add or remove profile fields without changing the structure of the mainUser
table.
One-to-one relationships may add complexity to your database schema and may not always be the best choice, so consider trade-offs and choose the best design for your specific needs.
We use the terms belongs to and has one to describe the nature of a one-to-one relationship. Typically one resource will own another. For example a User
has one PhoneNumber
and a PhoneNumber
belongs to a User
.
BookSearch: Book Content
To learn more about one-to-many relationships with Ecto, we’re going to add a BookContent
resource to our BookSearch
application.
While we could simply add a :contents
field in each Book
, this would likely result in loading too much data when we list all of our books.
classDiagram
class Book {
author_id: :id
title: :string
content: :text
}
Instead, we’ll create a new BookContent
resource. Each BookContent
will contain the full-text contents of the book in a full_text
field.
classDiagram
direction LR
class Book {
author_id: :id
title: :string
}
class BookContent {
full_text: text
}
Book "1" --> "1" BookContent :has_one
Book
s and BookContents
have a one-to-one relationship. Each BookContent
belongs to a Book
. And each Book
has one BookContent
.
Typically one-to-one relationships have a parent and a child entity. In this case, the Book
is the parent, and the BookContents
is the child. Our Database stores the foreign key of the parent record on the child record to track the relationship.
books
Table (omitting author_id
Field)
id | title |
---|---|
123 | “We are Legend (We are Bob)” |
book_content
Table
id | book_id | full_text |
---|---|---|
456 | 123 | …but as for me, I am tormented with an everlasting itch for things remote. |
Follow Along: BookSearch: Book Content
Ensure you have completed the BookSearch
project from the previous lesson. If not, you can clone the BookSearch project and checkout to the tags
branch.
$ git clone https://github.com/DockYard-Academy/book_search
$ git checkout tags
If you are stuck at any point during this lesson, you can reference the completed BookSearch/book_content branch.
Ensure dependencies are installed.
$ mix deps.get
All tests should pass.
$ 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
BookContent Migration
Each BookContent
record will be closely tied to it’s parent resource. When we create or update a Book
we will also create or update BookContent
.
To create our BookContent
resource we need a schema and a migration.
Run the following command to generate our initial boilerplate.
$ mix phx.gen.schema Books.BookContent book_content full_text:text book_id:references:books
This created the migration to create the book_content
table and the schema for BookContent
.
* creating lib/book_search/books/book_content.ex
* creating priv/repo/migrations/TIMESTAMP_create_book_content.exs
When we delete a book, we also want to delete it’s BookContent
, so let’s modify the :on_delete
behavior. We also want to ensure that each BookContent
always has a Book
associated with it so we’ll add null: false
.
defmodule BookSearch.Repo.Migrations.CreateBookContent do
use Ecto.Migration
def change do
create table(:book_content) do
add :full_text, :text
# Delete book content when book is deleted.
# Ensure `BookContent` records are always associate with a `Book`.
add :book_id, references(:books, on_delete: :delete_all), null: false
timestamps()
end
create index(:book_content, [:book_id])
end
end
Run the migration to create the book_content
table.
$ mix ecto.migrate
BookContent Schema
The previous command already generated our BookContent
schema in book_content.ex
. We’ll use Ecto.Schema.belongs_to/3 to define the relationship between books and book contents.
It’s generally preferable to use belongs_to/3
instead of field/3
for associations for several reasons.
-
belongs_to/3
allows you to specify the type of the relationship, which can be helpful for type checking and documentation purposes. -
belongs_to/3
allows you to use helper functions such as Ecto.build_assoc/3. -
belongs_to/3
allows us to conveniently query associated data using functions such as Ecto.Repo.preload/3
defmodule BookSearch.Books.BookContent do
use Ecto.Schema
import Ecto.Changeset
schema "book_content" do
field :full_text, :string
# Use `belongs_to/3` instead of `field/3`
belongs_to :book, BookSearch.Books.Book
timestamps()
end
@doc false
def changeset(book_content, attrs) do
book_content
|> cast(attrs, [:full_text])
|> validate_required([:full_text])
end
end
Books Context
To break down the problem of associating books with book content, we’ll start with the Books
context.
We’ll start by writing a test. This test will verify that when we create a book, it has the associated book_content
field.
Create Books With Book Content
Add the following test inside of the "book"
describe block in books_test.exs
with the other create_book/1
tests.
test "create_book/1 with book content creates a book with associated book content" do
valid_attrs = %{title: "some title", book_content: %{full_text: "some full text"}}
assert {:ok, %Book{} = book} = Books.create_book(valid_attrs)
assert book.title == "some title"
assert book.book_content.full_text == "some full text"
end
To make this test pass, we’ll add the belongs_to/3
relationship on the Books
schema. We’ll also use cast_assoc/3
to include the associated book content when we create or update a book.
defmodule BookSearch.Books.Book do
use Ecto.Schema
import Ecto.Changeset
schema "books" do
field :title, :string
belongs_to :author, BookSearch.Authors.Author
has_one :book_content, BookSearch.Books.BookContent # add `BookContent` association
many_to_many :tags, BookSearch.Tags.Tag, join_through: "book_tags", on_replace: :delete
timestamps()
end
@doc false
def changeset(book, attrs, tags \\ []) do
book
|> cast(attrs, [:title, :author_id])
|> validate_required([:title])
|> put_assoc(:tags, tags)
# add cast_assoc
|> cast_assoc(:book_content)
end
end
Update Books With Book Content
We should also ensure we can update a book. Add the following test inside of the "book"
describe block in books_test.exs
with the other update_book/2
tests.
test "update_book/2 with book content book's associated book content" do
book = book_fixture()
update_attrs = %{book_content: %{full_text: "updated full text"}}
assert {:ok, %Book{} = book} = Books.update_book(book, update_attrs)
assert book.book_content.full_text == "updated full text"
end
This test will fail with the following message:
** (RuntimeError) attempting to cast or change association `book_content` from `BookSearch.Books.Book` that was not loaded. Please preload your associations before manipulating them through changesets
The error nicely communicates that we need to preload the association in order to update the Book
record. Preloading associations is a requirement when updating any associated data.
Add Repo.preload/3
to the Books.update_book/3
function to preload the association before updating it.
def update_book(%Book{} = book, attrs, tags \\ []) do
book
|> Repo.preload(:book_content)
|> Book.changeset(attrs, tags)
|> Repo.update()
end
Ensure all tests pass
$ mix test
New Book Form With Book Content
In order to create or update a book with book content, we need to add the book_content
data to the
existing book form. However, notice in our Books
context that our associated data is created in a nested map. We can’t simply add a :book_content
text field, we need to submit the data in a map with a :full_text
key.
We can use the Phoenix.HTML.Form.inputs_for/3 function from to attach nested data in a form.
Modify the existing book form template in book/form.html.heex
to include an input for book_content
.
<.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, tag_options(), selected: assigns[:tag_ids] || [] %>
<%= error_tag f, :tags %>
<%= inputs_for f, :book_content, fn book_content_form -> %>
<%= label book_content_form, :full_text %>
<%= text_input book_content_form, :full_text %>
<%= error_tag book_content_form, :full_text %>
<% end %>
<%= submit "Save" %>
We’ve just broken the edit
action on the BookController
. Run tests and you’ll see the following error.
** (ArgumentError) using inputs_for for association `book_content` from `BookSearch.Books.Book` but it was not loaded. Please preload your associations before using them in inputs_for
That’s because we need to preload the :book_content
association if we want to edit it in the form. Add the following to the BookController.edit/2
action when we retrieve the book. This should make our failing test pass.
book = Books.get_book!(id) |> BookSearch.Repo.preload(:book_content)
Ensure all tests pass.
$ mix test
BookController With Associated Data
Now that we have a book_content
field in our form, we know that we can send a POST request to create a new book with the book_content
field.
However, we don’t know if we actually create a book with book content when we submit this form because we don’t have a test and we don’t display book content on any page yet.
Let’s start by writing a test. Add the following test to the "create book"
describe block in book_controller_test.exs
.
# Define A Test Case Called "create A Book With Associated Book Content"
test "create a book with associated book content", %{conn: conn} do
# Create a map called "book_content" with a key-value pair for the "full_text" field
book_content = %{full_text: "some full text"}
# Add the "book_content" map to the "create_attrs" map as a value for the "book_content" key
create_attrs_with_book_content = Map.put(@create_attrs, :book_content, book_content)
# Make a POST request to the "Routes.book_path(conn, :create)" route with the modified "create_attrs_with_book_content" map as the request body
conn = post(conn, Routes.book_path(conn, :create), book: create_attrs_with_book_content)
# Assert that the response is a redirect, and that the "id" parameter is present in the redirect parameters
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == Routes.book_path(conn, :show, id)
# Make a GET request to the "Routes.book_path(conn, :show, id)" route, using the "id" from the previous step
conn = get(conn, Routes.book_path(conn, :show, id))
# Assert that the response has a status code of 200 and that the response body contains the strings "Show Book" and the "full_text" value from the "book_content" map
response = html_response(conn, 200)
assert response =~ "Show Book"
assert response =~ book_content.full_text
end
Fortunately our controller and form are already connected in such a way that they create a book content,
so for this test we only need to add the book_content
‘s full_text
value to the book show page.
To use the book_content
field we need to preload it. Preload :book_content
in the existing BookController.show/2
action.
def show(conn, %{"id" => id}) do
book = Books.get_book!(id) |> BookSearch.Repo.preload([:author, :tags, :book_content])
render(conn, "show.html", book: book)
end
Now we can use the book.book_content
field in our book/show.html.heex
template.
<h1>Show Book</h1>
<ul>
<%= if @book.author do %>
<li>
<strong>Author:</strong>
<%= @book.author.name %>
</li>
<% end %>
<li>
<strong>Title:</strong>
<%= @book.title %>
</li>
<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>
<%= if @book.book_content do %>
<li>
<strong>Full Text:</strong>
<%= @book.book_content.full_text %>
</li>
<% end %>
</ul>
<span><%= link "Edit", to: Routes.book_path(@conn, :edit, @book) %></span> |
<span><%= link "Back", to: Routes.book_path(@conn, :index) %></span>
Ensure all tests pass.
$ mix test
Conclusion
Our feature is completely connected! Here are some the key-takeaways you should have gotten from this lesson.
- One-to-one relationships are useful for improving performance
- One-to-one relationships are useful for designing the domain of our application with associated data
-
Use
belongs_to/3
andhas_one/3
in the schemas to define a one-to-one relationship. - Use Ecto.Migration.add/3 to add the foreign key id in a migration file on the child table in the one-to-many relationship.
Further Reading
Consider the following resource(s) to deepen your understanding of the topic.
- HexDocs: belongs to polymorphic associations
- Ecto
- Pragmatic Bookshelf: Programming Ecto
- Elixir Schools: Associations
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: Book Content 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.