DEV Community

loading...

Use Ecto Embedded Schemas to Back Phoenix Forms

Matt Pruitt
I'm an Elixir developer and consultant.
Originally published at mattpruitt.com ・5 min read

Phoenix has been pushing separation of concerns through it's defaults by generating project and project_web directories and defaulting generators to use contexts. Since we should be separating our web application logic from our core application logic, I have started using embedded ecto schemas to create form modules in my projects.

This enables us to to separate our frontend data model representations from our schema data model. One of the most common suggestions for implementing a proper user authentication system is to add a virtual password field to the schema. Now, any time we get a user from the database, we have an empty password field in our struct! Wouldn't it be better if we could encrypt the password in the registration form and pass it directly to our schema's changeset?

Once you've started separating these data models, it opens up a lot more options to accept the input the user expects but save it in our database like our data model expects.

In this example, we're going to make a product creation form for an online store that accepts the price of the product as a decimal (e.g. "$49.99") but saves it in our database as an integer (e.g. 4999).

Let's start with the new product action. Let's make a ProductForm module that gives us an empty form. We'll use Ecto's embedded_schema to give us an easy to use struct that isn't backed by a database table.

defmodule StoreWeb.ProductForm do
  use Ecto.Schema
  import Ecto.Changeset
  @required [:name, :price]
  @attributes @required ++ [:description]
  @primary_key false
  embedded_schema do
    field :name, :string
    field :price, :string
    field :description, :string
  end
  @spec form :: Ecto.Changeset.t()
  def form, do: cast(%__MODULE__{}, %{}, @attributes)
end

And now, in our controller, we can pass our function down to the template to be rendered by form_for.

defmodule StoreWeb.ProductController do
  use StoreWeb, :controller
  @spec new(Plug.Conn.t(), map()) :: Plug.Conn.t()
  def new(conn, _params) do
    changeset = StoreWeb.ProductForm.form()
    render(conn, "new.html", changeset: changeset)
  end
end

The form works, but now we need to hook it up to our create action. Our store's data API accepts a map of attributes to the create_product function. So let's add a function to handle that.

defmodule StoreWeb.ProductForm do
  use Ecto.Schema
  import Ecto.Changeset
  @required [:name, :price]
  @attributes @required ++ [:description]
  @primary_key false
  embedded_schema do
    field :name, :string
    field :price, :string
    field :description, :string
  end
  @spec form :: Ecto.Changeset.t()
  def form, do: cast(%__MODULE__{}, %{}, @attributes)
  @spec form :: Ecto.Changeset.t()
  def form, do: form(%{})
  @spec form(map()) :: Ecto.Changeset.t()
  def form(attributes) do
    cast(%__MODULE__{}, attributes, @attributes)
  end
  @spec attributes(Ecto.Changeset.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
  def attributes(form) do
    applied = apply_action(form, :create)
    case applied do
      {:ok, struct} -> {:ok, Map.from_struct(struct)}
      other -> other
    end
  end
end

Now let's plug this in to our create action.

defmodule StoreWeb.ProductController do
  use StoreWeb, :controller
  @spec create(Plug.Conn.t(), map) :: Plug.Conn.t()
  def create(conn, %{"product_form" => product_params}) do
    form = StoreWeb.ProductForm.form(product_params)
    with {:ok, attributes} <- StoreWeb.ProductForm.attributes(form),
         {:ok, product} <- Store.create_product(attributes) do
      conn
      |> put_flash(:info, "Product created successfully.")
      |> redirect(to: Routes.product_path(conn, :show, product))
    else
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
end

We've got a problem now: our attributes are passing the price as a string but our API is expecting an integer. Let's fix this and add some more validations:

defmodule StoreWeb.ProductForm do
  # omitted for length
  @price_regex ~r/^\$?(?<dollars>\d*)(\.?(?<cents>\d{1,2}))?$/
  @spec attributes(Ecto.Changeset.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
  def attributes(form) do
    applied =
      form
      |> validate_required(@required)
      |> validate_format(:price, @price_regex, message: "must be a price ($19.99)")
      |> apply_action(:create)
    case applied do
      {:ok, struct} ->
        attributes =
          struct
          |> Map.from_struct()
          |> price_to_int()
        {:ok, attributes}
      other ->
        other
    end
  end
  defp price_to_int(%{price: price} = attributes) do
    [cents, dollars] = Regex.run(@price_regex, price, capture: :all_names)
    int_dollars = if dollars == "", do: 0, else: String.to_integer(dollars) * 100
    int_cents = if cents == "", do: 0, else: String.to_integer(cents)
    int_price = int_dollars + int_cents
    %{attributes | price: int_price}
  end
end

Now we need to use the form in our edit and update actions. We'll want to make sure to display our price as a decimal string again. Here's our final form module.

defmodule StoreWeb.ProductForm do
  use Ecto.Schema
  import Ecto.Changeset
  @price_regex ~r/^\$?(?<dollars>\d*)(\.?(?<cents>\d{1,2}))?$/
  @required [:name, :price]
  @attributes @required ++ [:description]
  @primary_key false
  embedded_schema do
    field :name, :string
    field :price, :string
    field :description, :string
  end
  @spec form :: Ecto.Changeset.t()
  def form, do: form(%{})
  @spec form(map() | %Store.Product{}) :: Ecto.Changeset.t()
  def form(%_{} = struct), do: form(struct, %{})
  def form(attributes) do
    form(%__MODULE__{}, attributes)
  end
  @spec form(%__MODULE__{} | %Store.Product{}, map()) :: Ecto.Changeset.t()
  def form(%__MODULE__{} = form, attributes) do
    form
    |> int_to_price()
    |> cast(attributes, @attributes)
  end
  def form(%_{} = struct, attributes) do
    merged_attributes =
      struct
      |> Map.from_struct()
      |> int_to_price()
      |> Map.merge(attributes)
    form(%__MODULE__{}, merged_attributes)
  end
  @spec attributes(Ecto.Changeset.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
  def attributes(form) do
    applied =
      form
      |> validate_required(@required)
      |> validate_format(:price, @price_regex, message: "must be a price ($19.99)")
      |> apply_action(:create)
    case applied do
      {:ok, struct} ->
        attributes =
          struct
          |> Map.from_struct()
          |> price_to_int()
        {:ok, attributes}
      other ->
        other
    end
  end
  defp price_to_int(%{price: price} = attributes) do
    [cents, dollars] = Regex.run(@price_regex, price, capture: :all_names)
    int_dollars = if dollars == "", do: 0, else: String.to_integer(dollars) * 100
    int_cents = if cents == "", do: 0, else: String.to_integer(cents)
    int_price = int_dollars + int_cents
    %{attributes | price: int_price}
  end
  defp int_to_price(%{price: int_price} = struct) when is_integer(int_price) do
    price =
      "$" <>
        to_string(Integer.floor_div(int_price, 100)) <>
        "." <> to_string(Integer.mod(int_price, 100))
    %{struct | price: price}
  end
  defp int_to_price(struct), do: struct
end

And here it is plugged in to our controller actions.

defmodule StoreWeb.ProductController do
  use StoreWeb, :controller
  @spec new(Plug.Conn.t(), map()) :: Plug.Conn.t()
  def new(conn, _params) do
    changeset = StoreWeb.ProductForm.form()
    render(conn, "new.html", changeset: changeset)
  end
  @spec create(Plug.Conn.t(), map) :: Plug.Conn.t()
  def create(conn, %{"product_form" => product_params}) do
    form = StoreWeb.ProductForm.form(product_params)
    with {:ok, attributes} <- StoreWeb.ProductForm.attributes(form),
         {:ok, product} <- Store.create_product(attributes) do
      conn
      |> put_flash(:info, "Product created successfully.")
      |> redirect(to: Routes.product_path(conn, :show, product))
    else
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
  @spec edit(Plug.Conn.t(), map) :: Plug.Conn.t()
  def edit(conn, %{"id" => id}) do
    product = Store.get_product!(id)
    changeset = StoreWeb.ProductForm.form(product)
    render(conn, "edit.html", product: product, changeset: changeset)
  end
  @spec update(Plug.Conn.t(), map) :: Plug.Conn.t()
  def update(conn, %{"id" => id, "product_form" => product_params}) do
    product = Store.get_product!(id)
    form = StoreWeb.ProductForm.form(product, product_params)
    with {:ok, attributes} <- StoreWeb.ProductForm.attributes(form),
         {:ok, product} <- Store.update_product(product, attributes) do
      conn
      |> put_flash(:info, "Product updated successfully.")
      |> redirect(to: Routes.product_path(conn, :show, product))
    else
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "edit.html", product: product, changeset: changeset)
    end
  end
end

Discussion (0)