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.