CRUD
Saving data with Ecto
Read time: 10 minutes
We will finally start implementing our CRUD (Create-Read-Update-Delete). Currently our project already has both LiveView and Ecto installed, so we will focus on getting that to work. In this lesson we will learn how to persist our product in the database.
This class is a direct continuation of the previous class
git clone https://github.com/adopt-liveview/first-crud.git --branch my-first- liveview-project-done
.
#Important concepts of Ecto
Before we start new code we will explore a little bit of what the Phoenix project has already installed for you and, at the same time, talk about the project patterns.
#Introducing Repo
If you go to the file lib/super_store/repo.ex
, you'll see the following code:
defmodule SuperStore.Repo do
use Ecto.Repo,
otp_app: :super_store,
adapter: Ecto.Adapters.SQLite3
end
Ecto uses a Design Pattern called Repository to access the database. The rule of thumb is simple: if you intend to execute a query you will use this module. Whenever the database needs to be accessed you'll see something like Repo.insert()
or Repo.one()
.
Phoenix automatically generated this module SuperStore.Repo
. The naming convention will always be in the format YourProject.Repo
. Inside it the use Ecto.Repo
line sets up the module with functions like Repo.insert
and the options passed define the configuration of our Repo. The otp_app
option contains the name of our Mix project, :super_store
, and we use Ecto.Adapters.SQLite3
as the adapter.
#Migrating the database
To manage database modifications, Ecto uses schema migrations design pattern. The way migrations work is simple: whenever you need to modify the structure of your database you generate a migration that instructs Ecto what needs to be done.
Let's create your first migration: we want to create the products table. Using your terminal execute mix ecto.gen.migration create_products
. The result will be something like:
* creating priv/repo/migrations/20240405213602_create_products.exs
Don't worry if the name isn't exactly the same. Migrations have a timestamp at the beginning of their name to make the order they should be executed clear. At this point your migration should have a code similar to the following:
defmodule SuperStore.Repo.Migrations.CreateProducts do
use Ecto.Migration
def change do
end
end
Lets change it to:
defmodule SuperStore.Repo.Migrations.CreateProducts do
use Ecto.Migration
def change do
create table(:products) do
add :name, :string, null: false
add :description, :string, null: false
end
end
end
Within our module we should have a change/0
method. This method is used to specify what changed in your database. The Ecto.Migration
module that we imported at the top of our migration contains this and other Data Definition Language (DDL) functions prepared for common operations to modify the structure of our database.
Inside change/0
, we can use create/2
to specify that we are creating something, table/2
to indicate that we are creating a new table called products
, and add/3
to define the two columns named name
and description
within this table.
When your migration is ready and saved execute mix ecto.migrate
to run it:
$ mix ecto.migrate
08:56:48.950 [info] == Running 20240405213602 SuperStore.Repo.Migrations.CreateProducts.change/0 forward
08:56:48.952 [info] create table products
08:56:48.955 [info] == Migrated 20240405213602 in 0.0s
How to undo a migration?
mix ecto.rollback
to undo the migrations applied the last time you ran mix ecto.migrate
(even if there were more than one). If you're curious about how Ecto knows how to roll back, it's quite simple: if your migration has a
create/2
method with table/2
it knows that the reverse of this is to delete a table. That's why we can create a migration with just the change/0
function instead of up
and down
as in other frameworks although Ecto optionally accepts these callbacks if you have a very specific migration for your current database.
#Updating our Ecto.Schema
Go to lib/super_store/catalog/product.ex
. At the moment, it should be defined as:
defmodule SuperStore.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :name, :string, default: ""
field :description, :string, default: ""
end
def changeset(product, params \\ %{}) do
product
|> cast(params, [:name, :description])
|> validate_required([:name, :description])
end
end
An embedded_schema
is useful when you don't intend to work with a database. To make this schema work with a database the only modification needed is very simple! We'll use the macro schema/2
which takes the table name as the first argument so that when we use our Repo
it will know where to read/write the data from/to.
defmodule SuperStore.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :name, :string, default: ""
field :description, :string, default: ""
end
def changeset(product, params \\ %{}) do
product
|> cast(params, [:name, :description])
|> validate_required([:name, :description])
end
end
With just one line of code our schema is ready for all CRUD operations!
#The context Product.Catalog
Let's go to lib/super_store/catalog.ex
. In the previous lesson we only created this module. We will concentrate all CRUD operations of our product system in this Context.
Everything related to products will be here. Phoenix is heavily inspired by Domain-Driven Design (DDD) where each part of your application focuses on its specific domain.
Let's add our first one: create_product/1
:
defmodule SuperStore.Catalog do
alias SuperStore.Repo
alias SuperStore.Catalog.Product
def create_product(attrs) do
Product.changeset(%Product{}, attrs)
|> Repo.insert()
end
end
Use an alias
to write a bit less code. We created a function that takes attrs
and validates them with our Product.changeset/2
, then attempts to insert into our database. This function has two possible outcomes: {:ok, %Product{...}
if everything goes well or {:error, %Ecto.Changeset{...}}
if there are validation errors.
#Testing our module directly from the terminal
We can test everything we've built so far without even starting to work on our LiveView! Since we've constructed a module SuperStore.Catalog
that doesn't depend on anything related to the web, we can simply start an interactive terminal with our project's mix code and execute the function create_product/2
.
Using your terminal execture the command that follows the $
prompt:
$ iex -S mix
[info] Migrations already up
Erlang/OTP 26 [erts-14.2.2] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit]
Interactive Elixir (1.16.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
Using iex -S mix
we entered Interactive Elixir (iex
) mode containing all the functions of our project. At the beginning of the last line you can see that iex(1)>
has become your new command prompt. Let's alias our context:
iex(1)> alias SuperStore.Catalog
SuperStore.Catalog
iex(2)>
Now we can write Catalog.
instead of SuperStore.Catalog.
. Let's create our first product! Execute: Catalog.create_product(%{ name: "Elixir in Action", description: "A great book" })
.
iex(2)> Catalog.create_product(%{ name: "Elixir in Action", description: "A great book" })
[debug] QUERY OK source="products" db=0.7ms idle=1532.9ms
INSERT INTO "products" ("name","description") VALUES (?,?) RETURNING "id" ["Elixir in Action", "A great book"]
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:405
{:ok,
%SuperStore.Catalog.Product{
__meta__: #Ecto.Schema.Metadata<:loaded, "products">,
id: 1,
name: "Elixir in Action",
description: "A great book"
}}
Excellent! Our database has one product. Let's check our validations. Use the following command to create an invalid product: Catalog.create_product(%{ name: "Missing description" })
.
iex(3)> Catalog.create_product(%{ name: "Missing description" })
{:error,
#Ecto.Changeset<
action: :insert,
changes: %{name: "Missing description"},
errors: [description: {"can't be blank", [validation: :required]}],
data: #SuperStore.Catalog.Product<>,
valid?: false
>}
As expected our changeset handled our validation correctly and did not create anything in the database. Now that we know our Context works as expected we can return to our LiveView.
#Using our Context in our LiveView
At this point your PageLive should look like this:
defmodule SuperStoreWeb.PageLive do
use SuperStoreWeb, :live_view
import SuperStoreWeb.CoreComponents
alias SuperStore.Catalog.Product
def mount(_params, _session, socket) do
form =
Product.changeset(%Product{})
|> to_form()
{:ok, assign(socket, form: form)}
end
def handle_event("validate_product", %{"product" => product_params}, socket) do
form =
Product.changeset(%Product{}, product_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, form: form)}
end
def handle_event("create_product", %{"product" => product_params}, socket) do
IO.inspect({"Form submitted!!", product_params})
{:noreply, socket}
end
def render(assigns) do
~H"""
<div class="bg-grey-100">
<.form
for={@form}
phx-change="validate_product"
phx-submit="create_product"
class="flex flex-col max-w-96 mx-auto bg-gray-100 p-24"
>
<h1>Creating a product</h1>
<.input field={@form[:name]} placeholder="Name" />
<.input field={@form[:description]} placeholder="Description" />
<.button type="submit">Send</.button>
</.form>
</div>
"""
end
end
Our "create_product"
event currently does nothing but generate a log in the terminal: IO.inspect({"Form submitted!!", product_params})
. Let's fix that.
#Improving our "create_product"
event
At the top of your PageLive create an alias SuperStore.Catalog
. Modify the "create_product"
event to:
def handle_event("create_product", %{"product" => product_params}, socket) do
socket =
case Catalog.create_product(product_params) do
{:ok, %Product{} = product} ->
put_flash(socket, :info, "Product ID #{product.id} created!")
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
socket
|> assign(form: form)
|> put_flash(:error, "Invalid product!")
end
{:noreply, socket}
end
Our Catalog.create_product/2
function has two possibilities. Using case-do
, we can gracefully handled both. If the result is {:ok, %Product{} = product}
, we add a success message to our socket using put_flash/3
.
If a validation error occurs we receive the changeset and convert it into a form
using to_form/2
. Then, we use put_flash/3
, this time to inform an error.
#Final code
Our final code for the LiveView:
defmodule SuperStoreWeb.PageLive do
use SuperStoreWeb, :live_view
import SuperStoreWeb.CoreComponents
alias SuperStore.Catalog
alias SuperStore.Catalog.Product
def mount(_params, _session, socket) do
form =
Product.changeset(%Product{})
|> to_form()
{:ok, assign(socket, form: form)}
end
def handle_event("validate_product", %{"product" => product_params}, socket) do
form =
Product.changeset(%Product{}, product_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, form: form)}
end
def handle_event("create_product", %{"product" => product_params}, socket) do
socket =
case Catalog.create_product(product_params) do
{:ok, %Product{} = product} ->
put_flash(socket, :info, "Product ID #{product.id} created!")
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
socket
|> assign(form: form)
|> put_flash(:error, "Invalid product!")
end
{:noreply, socket}
end
def render(assigns) do
~H"""
<div class="bg-grey-100">
<.form
for={@form}
phx-change="validate_product"
phx-submit="create_product"
class="flex flex-col max-w-96 mx-auto bg-gray-100 p-24"
>
<h1>Creating a product</h1>
<.input field={@form[:name]} placeholder="Name" />
<.input field={@form[:description]} placeholder="Description" />
<.button type="submit">Send</.button>
</.form>
</div>
"""
end
end
If you had any issues you can see the final code for this lesson using git checkout saving-data-done
or by cloning it into another folder using git clone https://github.com/adopt-liveview/first-crud.git --branch saving-data-done
.
#Recap!
- Ecto uses the Repository design pattern to interact with databases.
-
Whenever we need to use the database we'll use a function from the
Repo
module. - Ecto uses the schema migrations design pattern to modify the database structure.
-
To create a migration you need to run
mix ecto.gen.migration migration_name
in the terminal. -
To apply pending migrations you should run
mix ecto.migrate
in the terminal. -
To rollback the lastest applied migrations you can run
mix ecto.rollback
in the terminal. -
A schema with
embedded_schema do
doesn't interact with the database butschema "table_name" do
is all you need to instruct Ecto how to interact with that table. - In Phoenix projects we concentrate functions related to a specific domain in a context module, following inspiration from DDD.
-
In our current project we focused our product management domain in the
SuperStore.Catalog
context. -
You can use
iex -S mix
to enter Interactive Elixir mode and test all functions in your project. - When your context and schema are well-designed, adding functions to your LiveView becomes trivial.
Feedback
Got any feedback about this page? Let us know!