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
Пока выглядит не слишком впечатляюще, но работает! Мы добавили связи внутри контекста и обеспечили целостность данных на уровне базы данных. Неплохо. Продолжим!
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/in_context_relationships.html