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

Phoenix and Ecto

phoenix_and_ecto.livemd

Phoenix and Ecto

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

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

Phoenix uses Ecto to handle the data layer of a web application.

We will walk through building a journal application to learn how to use Phoenix with Ecto.

Our journal will store entries that have a title and content.

classDiagram
  class Entry {
    title: :string
    content: :text 
  }

We will be able to create, read, update, and delete journal entries.

These four CRUD actions (Create, Read, Update, and Delete) form the basis of most common applications.

Create the Journal Project

First, create a new journal project.

$ mix phx.new journal

Install project dependencies from the project folder.

$ mix deps.get

Create and migrate the database from the project folder.

$ mix ecto.setup

Next, we can use generators provided by phoenix to create the controllers, views, and context for the journal Entries.

$ mix phx.gen.html Entries Entry entries title:string content:text

The mix phx.gen.html command accepts several arguments.

  • Entries: The context.
  • Entry: The schema module.
  • entries: The table name and an optional list of attributes and their types.

This command creates several modules and files.

  • The JournalWeb.EntryController controller module.
  • The JournalWeb.EntryView view module.
  • The Journal.Entries context file.
  • Templates for the entry CRUD actions.
  • A migration file that creates the entries table in PostgreSQL.
  • Several test files.

We can see the exact files created in the output.

* creating lib/journal_web/controllers/entry_controller.ex
* creating lib/journal_web/templates/entry/edit.html.heex
* creating lib/journal_web/templates/entry/form.html.heex
* creating lib/journal_web/templates/entry/index.html.heex
* creating lib/journal_web/templates/entry/new.html.heex
* creating lib/journal_web/templates/entry/show.html.heex
* creating lib/journal_web/views/entry_view.ex
* creating test/journal_web/controllers/entry_controller_test.exs
* creating lib/journal/entries/entry.ex
* creating priv/repo/migrations/20220710205852_create_entries.exs
* creating lib/journal/entries.ex
* injecting lib/journal/entries.ex
* creating test/journal/entries_test.exs
* injecting test/journal/entries_test.exs
* creating test/support/fixtures/entries_fixtures.ex
* injecting test/support/fixtures/entries_fixtures.ex

Migrations

Earlier, the mix phx.gen.html command generated an Ecto migration file priv/repo/migrations/create_entries.ex which creates the entries table in our PostgreSQL database.

defmodule Journal.Repo.Migrations.CreateEntries do
  use Ecto.Migration

  def change do
    create table(:entries) do
      add :title, :string
      add :content, :text

      timestamps()
    end
  end
end

> The actual file will have a timestamp in its name. i.e. 20220710205852_create_entries.

:string is a simple string limited to 255 characters, and :text is a string with a larger character limit.

See the Attributes documentation for a complete list of attribute types.

Run the following command to run all migration files in the priv/repo/migrations/ folder.

$ mix ecto.migrate
The database for Journal.Repo has been created

14:13:55.631 [info]  == Running 20220710205852 Journal.Repo.Migrations.CreateEntries.change/0 forward

14:13:55.633 [info]  create table entries

14:13:55.642 [info]  == Migrated 20220710205852 in 0.0s

Resources and HTTP Actions

We need to add a route for entries. We’ll replace the page controller and use the "/" route with the resources/4 macro.

scope "/", JournalWeb do
  pipe_through :browser

  resources "/", EntryController
end

The resources/4 macro creates a standard matrix of HTTP actions.

The main HTTP actions are GET, POST, PATCH, PUT, and DELETE. They generally refer to different intentions for manipulating a resource, such as our journal entries.

  • GET: retrieve a resource.
  • POST: submit a resource.
  • PATCH: partially modify a resource.
  • PUT: completely replace a resource.
  • DELETE: delete a resource.

We can see the generated routes by running the following command.

$ mix phx.routes

Observe the following in the output.

entries_path  GET     /                                     JournalWeb.EntryController :index
entries_path  GET     /:id/edit                             JournalWeb.EntryController :edit
entries_path  GET     /new                                  JournalWeb.EntryController :new
entries_path  GET     /:id                                  JournalWeb.EntryController :show
entries_path  POST    /                                     JournalWeb.EntryController :create
entries_path  PATCH   /:id                                  JournalWeb.EntryController :update
              PUT     /:id                                  JournalWeb.EntryController :update
entries_path  DELETE  /:id                                  JournalWeb.EntryController :delete

When the client triggers one of these HTTP actions, the server executes the corresponding action in the JournalWeb.EntryController, either :index, :edit, :new, :show, :create, :update, or :delete.

We can see these functions in lib/journal_web/controllers/entry_controller.ex.

defmodule JournalWeb.EntryController do
  use JournalWeb, :controller

  alias Journal.Entries
  alias Journal.Entries.Entry

  def index(conn, _params) do
    entries = Entries.list_entries()
    render(conn, "index.html", entries: entries)
  end

  def new(conn, _params) do
    changeset = Entries.change_entry(%Entry{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"entry" => entry_params}) do
    case Entries.create_entry(entry_params) do
      {:ok, entry} ->
        conn
        |> put_flash(:info, "Entry created successfully.")
        |> redirect(to: Routes.entry_path(conn, :show, entry))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    entry = Entries.get_entry!(id)
    render(conn, "show.html", entry: entry)
  end

  def edit(conn, %{"id" => id}) do
    entry = Entries.get_entry!(id)
    changeset = Entries.change_entry(entry)
    render(conn, "edit.html", entry: entry, changeset: changeset)
  end

  def update(conn, %{"id" => id, "entry" => entry_params}) do
    entry = Entries.get_entry!(id)

    case Entries.update_entry(entry, entry_params) do
      {:ok, entry} ->
        conn
        |> put_flash(:info, "Entry updated successfully.")
        |> redirect(to: Routes.entry_path(conn, :show, entry))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "edit.html", entry: entry, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    entry = Entries.get_entry!(id)
    {:ok, _entry} = Entries.delete_entry(entry)

    conn
    |> put_flash(:info, "Entry deleted successfully.")
    |> redirect(to: Routes.entry_path(conn, :index))
  end
end

The resources/4 macro is for convenience, so we don’t need to define individual routes.

For example, replace resources/4 with the following. The application will still have the same behavior.

scope "/", JournalWeb do
  pipe_through :browser

  get "/", EntryController, :index
  get "/:id/edit", EntryController, :edit
  get "/new", EntryController, :new
  get "/:id", EntryController, :show
  post "/", EntryController, :create
  patch "/:id", EntryController, :update
  put "/:id", EntryController, :update
  delete "/:id", EntryController, :delete
end

See Phoenix.Router.resources/4 for more configuration options.

GET :index

Now ensure you have started the server, and visit http://localhost:4000.

$ mix phx.server

We’ll see the following HTML web page.

When we visit http://localhost:4000, the browser sends an HTTP GET request to the "/" route of the application.

This triggers the EntryController.index/2 function in lib/journal_web/controllers/entry_controller.ex.

def index(conn, _params) do
  entries = Entries.list_entries()
  render(conn, "index.html", entries: entries)
end

Which retrieves a list of entries (currently an empty list []) from the Entries context in lib/journal/entries.ex.

def list_entries do
  Repo.all(Entry)
end

The EntryController.index/2 function then calls the render/3 macro which delegates to the EntryView module in lib/journal_web/views/journal_view.ex.

defmodule JournalWeb.EntryView do
  use JournalWeb, :view
end

Which uses the lib/journal_web/templates/entry/index.html.heex template file to build the HTML web page. This web page includes a link that navigates us to the "/new" route.

<span><%= link "New Entry", to: Routes.entry_path(@conn, :new) %></span>

See Phoenix.HTML.Link for more on the link/2 function.

GET :new

Click the "New Entry" link from the browser to navigate to http://localhost:4000/new.

By clicking the link, the browser sends an HTTP GET request to the "/new" route, which triggers the EntryController.new/2 action.

def new(conn, _params) do
  changeset = Entries.change_entry(%Entry{})
  render(conn, "new.html", changeset: changeset)
end

The Entries.change_entry/1 function retrieves the Ecto Changeset for the Entry schema. A form later uses this changeset to display form data validation errors.

# lib/journal/entries.ex
def change_entry(%Entry{} = entry, attrs \\ %{}) do
  Entry.changeset(entry, attrs)
end

# lib/journal/entries/entry.ex
def changeset(entry, attrs) do
  entry
  |> cast(attrs, [:title, :content])
  |> validate_required([:title, :content])
end

The render/3 macro delegates to the EntryView module and renders the lib/journal_web/templates/entry/new.html.heex template file.

<h1>New Entry</h1>

<%= render "form.html", Map.put(assigns, :action, Routes.entry_path(@conn, :create)) %>

<span><%= link "Back", to: Routes.entry_path(@conn, :index) %></span>

Which uses the render/3 macro to render the lib/journal_web/templates/entry/form.html.heex template file.

<.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, :content %>
  <%= textarea f, :content %>
  <%= error_tag f, :content %>

  
    <%= submit "Save" %>
  

This form uses the changeset from the Entry schema to display validation errors. Click "Submit" without filling in the form, and you’ll see the following errors.

When we submit the form, it sends an HTTP POST request to the "/" route with the content of the form as parameters.

For more on forms, see the Phoenix.HTML.Form documentation.

POST :create

While still on http://localhost:4000/new, enter a title and some content into the form and click the submit button.

This sends a POST request to "/" which triggers the EntryController.create/2 function.

def create(conn, %{"entry" => entry_params}) do
  case Entries.create_entry(entry_params) do
    {:ok, entry} ->
      conn
      |> put_flash(:info, "Entry created successfully.")
      |> redirect(to: Routes.entry_path(conn, :show, entry))

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

entry_params is a map with the form content such as %{"title" => "My Title", "content" => "My Content"}.

If we successfully create an entry, we flash a success message using the put_flash/3 macro and redirect the user to the show page.

Otherwise, we re-render the new.html.heex template with an updated changeset that contains the form validation errors.

GET :show

After successfully creating a journal entry, the controller redirects us to http://localhost:4000/1.

This is the "/:id" route which triggers the EntryController.show/2 function.

def show(conn, %{"id" => id}) do
  entry = Entries.get_entry!(id)
  render(conn, "show.html", entry: entry)
end

The show/2 function retrieves a single entry by its :id in the database, then uses the render/3 function to render the lib/journal_web/templates/entry/show.html.heex template with the entry data.

<h1>Show Entry</h1>

<ul>

  <li>
    <strong>Title:</strong>
    <%= @entry.title %>
  </li>

  <li>
    <strong>Content:</strong>
    <%= @entry.content %>
  </li>

</ul>

<span><%= link "Edit", to: Routes.entry_path(@conn, :edit, @entry) %></span> |
<span><%= link "Back", to: Routes.entry_path(@conn, :index) %></span>

GET :edit

We can edit existing journal entries. Click on the "Edit" button to be taken to http://localhost:4000/1/edit. This is the ":id/edit" route which triggers the EntryController.edit/2 function.

def edit(conn, %{"id" => id}) do
  entry = Entries.get_entry!(id)
  changeset = Entries.change_entry(entry)
  render(conn, "edit.html", entry: entry, changeset: changeset)
end

The EntryController.edit/2 function retrieves an entry by its id and the Entry changeset.

It then calls the render/3 function to render the lib/journal_web/templates/entry/edit.html.heex template.

<h1>Edit Entry</h1>

<%= render "form.html", Map.put(assigns, :action, Routes.entry_path(@conn, :update, @entry)) %>

<span><%= link "Back", to: Routes.entry_path(@conn, :index) %></span>

The lib/journal_web/templates/entry/edit.html.heex template renders the same form we saw in lib/journal_web/templates/entry/new.html.heex but with a different action.

PUT :update

This time, when we submit the form on http://localhost:4000/1/edit it sends a PUT request to the "/:id" route, which triggers the EntryController.update/2 function.

def update(conn, %{"id" => id, "entry" => entry_params}) do
  entry = Entries.get_entry!(id)

  case Entries.update_entry(entry, entry_params) do
    {:ok, entry} ->
      conn
      |> put_flash(:info, "Entry updated successfully.")
      |> redirect(to: Routes.entry_path(conn, :show, entry))

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "edit.html", entry: entry, changeset: changeset)
  end
end

If we successfully update the entry, we flash a success message and redirect back to the show page.

Otherwise we display any errors in the form.

DELETE :delete

If we navigate back to http://localhost:4000/ we’ll see a list of our entries.

The template in lib/journal_web/templates/entry/index.html.heex builds this web page.

<h1>Listing Entries</h1>

<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Content</th>

      <th></th>
    </tr>
  </thead>
  <tbody>
<%= for entry <- @entries do %>
    <tr>
      <td><%= entry.title %></td>
      <td><%= entry.content %></td>

      <td>
        <span><%= link "Show", to: Routes.entry_path(@conn, :show, entry) %></span>
        <span><%= link "Edit", to: Routes.entry_path(@conn, :edit, entry) %></span>
        <span><%= link "Delete", to: Routes.entry_path(@conn, :delete, entry), method: :delete, data: [confirm: "Are you sure?"] %></span>
      </td>
    </tr>
<% end %>
  </tbody>
</table>

<span><%= link "New Entry", to: Routes.entry_path(@conn, :new) %></span>

Each entry has a "Delete" link which triggers an HTTP DELETE request to the "/:id" route. This calls the EntryController.delete/2 function with the entry id.

def delete(conn, %{"id" => id}) do
  entry = Entries.get_entry!(id)
  {:ok, _entry} = Entries.delete_entry(entry)

  conn
  |> put_flash(:info, "Entry deleted successfully.")
  |> redirect(to: Routes.entry_path(conn, :index))
end

The EntryController.delete/2 function deletes the entry and redirects the user back to the "/" route.

Click the "Delete" link in the browser and see it deleted from the page.

Your Turn: Notes

Generators are fantastic for quickly building a CRUD application with standard boilerplate code. However, many applications deviate from this structure. So, it’s essential to understand the boilerplate code well enough that we can confidently edit it and write it from scratch.

Without using the mix phx.gen.html generator, create a notes resource in your existing journal application.

Notes should have identical functionality to Entries. However, they will only have a :content field.

classDiagram
  class Note {
    content: :text 
  }

Ensure that you:

  • create a migration file (you can generate a template migration file by running mix ecto.gen.migration create_notes).
  • add the routes for the notes resource.
  • create the controller for the notes resource.
  • create the view for the notes resource.
  • create the templates for the notes resource.
  • create the notes context with a context file lib/notes.ex and a schema file lib/notes/note.ex.

You should handle the :index, :edit, :new, :show, :create, :update, and :delete actions.

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 phoenix and ecto section"