Phoenix продвигает разделение задач по умолчанию, создавая каталоги project и project_web и устанавливая генераторы по умолчанию для использования контекстов. Поскольку мы должны отделить логику нашего веб-приложения от основной логики приложения, я начал использовать встроенные экто-схемы для создания модулей форм в своих проектах.

Это позволяет нам отделить наши представления модели данных внешнего интерфейса от нашей модели данных схемы. Одним из наиболее распространенных предложений по реализации надлежащей системы аутентификации пользователей является добавление в схему поля виртуального пароля. Теперь каждый раз, когда мы получаем пользователя из базы данных, в нашей структуре есть пустое поле пароля! Не было бы лучше, если бы мы могли зашифровать пароль в регистрационной форме и передать его непосредственно в набор изменений нашей схемы?

После того, как вы начали разделять эти модели данных, открывается гораздо больше возможностей принимать ввод, который ожидает пользователь, но сохранять его в нашей базе данных, как ожидает наша модель данных.

В этом примере мы собираемся создать форму создания продукта для интернет-магазина, которая принимает цену продукта в виде десятичного числа (например, «49,99 долларов США»), но сохраняет ее в нашей базе данных как целое число (например, 4999).

Начнем с действия нового продукта. Давайте создадим модуль ProductForm, который даст нам пустую форму. Мы будем использовать embedded_schema Ecto, чтобы получить простую в использовании структуру, которая не поддерживается таблицей базы данных.

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

И теперь в нашем контроллере мы можем передать нашу функцию шаблону, который будет отображаться с помощью 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

Форма работает, но теперь нам нужно подключить ее к нашему действию create. API данных нашего магазина принимает карту атрибутов для функции create_product. Итак, давайте добавим функцию для обработки этого.

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

Теперь давайте подключим это к нашему действию create.

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

Теперь у нас есть проблема: наши атрибуты передают цену в виде строки, но наш API ожидает целое число. Давайте исправим это и добавим еще несколько проверок:

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

Теперь нам нужно использовать форму в наших действиях редактирования и обновления. Мы хотим убедиться, что наша цена снова отображается в виде десятичной строки. Вот наш окончательный модуль формы.

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

И здесь он подключен к действиям нашего контроллера.

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

Первоначально опубликовано на https://mattpruitt.com.