Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us
Notesclub

Book Search: Book Content

deprecated_book_search_book_content.livemd

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 Content

Review 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 main User 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

Books 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 and has_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.

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.

Navigation

Home Report An Issue Blog: TagsBlog: Blog Content