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
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 filelib/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"