Spec-Zone.ru › Phoenix

3. Связи внутри контекста

Исходный код

Наши базовые возможности каталога хороши, но давайте сделаем их еще лучше, добавив категоризацию товаров. Многие решения для электронной коммерции позволяют относить товары к разным категориям, например отмечать, что товар относится к моде, электроинструментам и так далее. Если начать с отношения «один к одному» между товаром и категориями, позднее при необходимости поддержки нескольких категорий придется значительно изменить код. Давайте настроим связь с категориями, которая позволит сначала отслеживать одну категорию для каждого товара, но при этом легко поддержать больше категорий по мере развития функциональности.

Пока категории будут содержать только текстовую информацию. В первую очередь нужно решить, где в приложении будут храниться категории. У нас есть контекст Catalog, который отвечает за представление наших товаров. Категоризация товаров здесь вполне уместна. Phoenix также умеет генерировать код в существующем контексте, поэтому добавлять в него новые ресурсы очень просто. Выполните следующую команду в корневом каталоге проекта:

Иногда бывает сложно определить, относятся ли два ресурса к одному контексту. В таких случаях лучше создавать отдельный контекст для каждого ресурса и при необходимости реорганизовать код позднее. Иначе можно легко получить большие контексты с сущностями, слабо связанными друг с другом. Также имейте в виду: тот факт, что два ресурса связаны, не обязательно означает, что они относятся к одному контексту. В противном случае у вас быстро появится один большой контекст, ведь большинство ресурсов в приложении связаны друг с другом. Иными словами, если вы сомневаетесь, предпочтительнее использовать отдельные модули (контексты).

mix phx.gen.context Catalog Category categories \
title:string:unique --no-scope

В терминале вы увидите следующий вывод:

You are generating into an existing context.

The Hello.Catalog context currently has 7 functions and 1 file in its directory.

  * It's OK to have multiple resources in the same context as long as they are closely related. 

...

Would you like to proceed? [Yn]

Введите y, а затем клавишу Return. Вы должны увидеть примерно такой вывод:

* creating lib/hello/catalog/category.ex
* creating priv/repo/migrations/20250203192325_create_categories.exs
* injecting lib/hello/catalog.ex
* injecting test/hello/catalog_test.exs
* injecting test/support/fixtures/catalog_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate

На этот раз мы использовали mix phx.gen.context, которая работает так же, как mix phx.gen.html, но не генерирует для нас веб-файлы. Поскольку у нас уже есть контроллеры и шаблоны для управления товарами, мы можем добавить новые возможности для категорий в существующую веб-форму и на страницу просмотра товара. Теперь мы видим новую схему Category рядом со схемой товара в lib/hello/catalog/category.ex, а Phoenix сообщает, что добавляет новые функции для работы с категориями в существующий контекст Catalog. Добавленные функции будут очень похожи на функции для товаров: например, create_category, list_categories и другие. Прежде чем выполнить миграцию, нужно сгенерировать еще немного кода. Наша схема категорий отлично подходит для представления отдельной категории в системе, но нам нужно поддержать связь «многие ко многим» между товарами и категориями. К счастью, ecto позволяет легко сделать это с помощью таблицы соединения, поэтому давайте сгенерируем ее командой ecto.gen.migration:

mix ecto.gen.migration create_product_categories

Вы увидите сообщение о создании файла миграции:

* creating priv/repo/migrations/20250203192958_create_product_categories.exs

Затем откройте новый файл миграции и добавьте следующий код в функцию change:

defmodule Hello.Repo.Migrations.CreateProductCategories do
  use Ecto.Migration

  def change do
    create table(:product_categories, primary_key: false) do
      add :product_id, references(:products, on_delete: :delete_all)
      add :category_id, references(:categories, on_delete: :delete_all)
    end

    create index(:product_categories, [:product_id])
    create unique_index(:product_categories, [:category_id, :product_id])
  end
end

Мы создали таблицу product_categories и использовали параметр primary_key: false, поскольку таблице соединения не нужен первичный ключ. Затем мы определили внешние ключи :product_id и :category_id и передали on_delete: :delete_all, чтобы база данных удаляла записи из таблицы соединения, если связанный товар или категория удаляются. Используя ограничение базы данных, мы обеспечиваем целостность данных на уровне базы данных, а не полагаемся на произвольную и подверженную ошибкам логику приложения.

Затем мы создали индексы для внешних ключей, один из которых — уникальный индекс, гарантирующий, что у товара не может быть дублирующихся категорий. Обратите внимание, что отдельный индекс для category_id не обязательно нужен, поскольку это поле является крайним левым префиксом составного индекса, чего достаточно для оптимизатора базы данных. С другой стороны, добавление избыточного индекса лишь увеличивает накладные расходы при записи.

Теперь, когда миграции готовы, можно выполнить их.

mix ecto.migrate

Вы увидите следующий вывод, подтверждающий успешное выполнение миграции:

18:20:36.489 [info] == Running 20250222231834 Hello.Repo.Migrations.CreateCategories.change/0 forward

18:20:36.493 [info] create table categories

18:20:36.508 [info] create index categories_title_index

18:20:36.512 [info] == Migrated 20250222231834 in 0.0s

18:20:36.547 [info] == Running 20250222231930 Hello.Repo.Migrations.CreateProductCategories.change/0 forward

18:20:36.547 [info] create table product_categories

18:20:36.557 [info] create index product_categories_product_id_index

18:20:36.560 [info]  create index product_categories_category_id_product_id_index

18:20:36.562 [info] == Migrated 20250222231930 in 0.0s

Теперь, когда у нас есть схема Catalog.Product и таблица соединения для связи товаров с категориями, мы почти готовы приступить к реализации новых возможностей. Но сначала нам понадобятся реальные категории, которые можно будет выбрать в веб-интерфейсе. Давайте быстро добавим в приложение несколько категорий. Добавьте следующий код в файл начальных данных priv/repo/seeds.exs:

for title <- ["Home Improvement", "Power Tools", "Gardening", "Books", "Education"] do
  {:ok, _} = Hello.Catalog.create_category(%{title: title})
end

Мы просто перебираем список названий категорий и используем сгенерированную функцию create_category/1 контекста каталога, чтобы сохранить новые записи. Запустить начальные данные можно с помощью mix run:

mix run priv/repo/seeds.exs

Вывод в терминале подтверждает, что команда seeds.exs выполнена успешно:

[debug] QUERY OK db=3.1ms decode=1.1ms queue=0.7ms idle=2.2ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Home Improvement", ~N[2025-02-03 19:39:53], ~N[2025-02-03 19:39:53]]
[debug] QUERY OK db=1.2ms queue=1.3ms idle=12.3ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Power Tools", ~N[2025-02-03 19:39:53], ~N[2025-02-03 19:39:53]]
[debug] QUERY OK db=1.1ms queue=1.1ms idle=15.1ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Gardening", ~N[2025-02-03 19:39:53], ~N[2025-02-03 19:39:53]]
[debug] QUERY OK db=2.4ms queue=1.0ms idle=17.6ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Books", ~N[2025-02-03 19:39:53], ~N[2025-02-03 19:39:53]]

Отлично. Прежде чем добавлять категории в веб-слой, нужно сообщить контексту, как связывать товары и категории. Сначала откройте lib/hello/catalog/product.ex и добавьте следующую связь:

+ alias Hello.Catalog.Category

  schema "products" do
    field :description, :string
    field :price, :decimal
    field :title, :string
    field :views, :integer

+   many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete

    timestamps(type: :utc_datetime)
  end

Мы использовали макрос many_to_many из Ecto.Schema, чтобы сообщить Ecto, как связывать товар с несколькими категориями через таблицу соединения "product_categories". Мы также использовали параметр on_replace: :delete, чтобы указать, что все существующие записи в таблице соединения следует удалить при изменении категорий.

После настройки связей в схемах можно добавить выбор категорий в форму товара. Для этого нужно преобразовать введенные пользователем на стороне интерфейса идентификаторы категорий в связь «многие ко многим». К счастью, теперь, когда схема настроена, Ecto позволяет сделать это очень просто. Откройте контекст каталога и внесите следующие изменения:

+ alias Hello.Catalog.Category

- def get_product!(id), do: Repo.get!(Product, id)
+ def get_product!(id) do
+   Product
+   |> Repo.get!(id)
+   |> Repo.preload(:categories)
+ end

  def create_product(attrs) do
    %Product{}
-   |> Product.changeset(attrs)
+   |> change_product(attrs)
    |> Repo.insert()
  end

  def update_product(%Product{} = product, attrs) do
    product
-   |> Product.changeset(attrs)
+   |> change_product(attrs)
    |> Repo.update()
  end

  def change_product(%Product{} = product, attrs \\ %{}) do
-   Product.changeset(product, attrs)
+   categories = list_categories_by_id(attrs["category_ids"])

+   product
+   |> Repo.preload(:categories)
+   |> Product.changeset(attrs)
+   |> Ecto.Changeset.put_assoc(:categories, categories)
  end

+ def list_categories_by_id(nil), do: []
+ def list_categories_by_id(category_ids) do
+   Repo.all(from c in Category, where: c.id in ^category_ids)
+ end

Сначала мы добавили Repo.preload, чтобы предварительно загружать категории при получении товара. Это позволит обращаться к product.categories в контроллерах, шаблонах и везде, где нужно использовать информацию о категориях. Затем мы изменили функции create_product и update_product, чтобы они вызывали существующую функцию change_product для создания changeset. Внутри change_product мы добавили поиск всех категорий, если присутствует атрибут "category_ids". Затем мы предварительно загрузили категории и вызвали Ecto.Changeset.put_assoc, чтобы добавить найденные категории в changeset. Наконец, мы реализовали функцию list_categories_by_id/1 для поиска категорий, соответствующих идентификаторам категорий, или возврата пустого списка, если атрибут "category_ids" отсутствует. Теперь наши функции create_product и update_product получают changeset с уже настроенными связями категорий, когда мы пытаемся выполнить вставку или обновление в нашем репозитории.

Теперь добавим новую функцию в веб-интерфейс, поместив поле ввода категории в форму товара. Чтобы шаблон формы оставался аккуратным, создадим новую функцию, которая возьмет на себя детали отображения поля выбора категорий для товара. Откройте представление ProductHTML в lib/hello_web/controllers/product_html.ex и добавьте следующий код:

  def category_opts(changeset) do
    existing_ids =
      changeset
      |> Ecto.Changeset.get_change(:categories, [])
      |> Enum.map(& &1.data.id)

    for cat <- Hello.Catalog.list_categories() do
      [key: cat.title, value: cat.id, selected: cat.id in existing_ids]
    end
  end

Мы добавили новую функцию category_opts/1, которая генерирует варианты выбора для поля с множественным выбором, которое мы скоро добавим. Мы получили идентификаторы существующих категорий из changeset, а затем использовали их при генерации вариантов выбора для поля ввода. Для этого мы перебрали все категории и вернули соответствующие значения key, value и selected. Вариант помечался как выбранный, если идентификатор категории находился среди идентификаторов категорий в changeset.

Теперь, когда функция category_opts готова, откройте lib/hello_web/controllers/product_html/product_form.html.heex и добавьте:

  ...
  <.input field={f[:views]} type="number" label="Views" />

+ <.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />

  <.button>Save Product</.button>

Мы добавили category_select над кнопкой сохранения. Теперь давайте проверим результат. Затем отобразим категории товара в шаблоне просмотра товара. Добавьте следующий код в список в lib/hello_web/controllers/product_html/show.html.heex:

<.list>
  ...
+ <:item title="Categories">
+   <ul>
+     <li :for={cat <- @product.categories}>{cat.title}</li>
+   </ul>
+ </:item>
</.list>

Теперь, если запустить сервер с помощью mix phx.server и перейти по адресу http://localhost:4000/products/new, появится новое поле для выбора нескольких категорий. Введите корректные данные о товаре, выберите одну или две категории и нажмите «Сохранить».

Title: Elixir Flashcards
Description: Flash card set for the Elixir programming language
Price: 5.000000
Views: 0
Categories:
Education
Books

Пока выглядит не слишком впечатляюще, но работает! Мы добавили связи внутри контекста и обеспечили целостность данных на уровне базы данных. Неплохо. Продолжим!

← Предыдущая страница 2. Ваш первый контекст
Следующая страница → 4. Границы между контекстами

Пакет Hex Предпросмотр Hex (текущий файл) Скачать версию ePub

Создано с помощью ExDoc (v0.40.3) для языка программирования Elixir

© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/in_context_relationships.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API