Полиморфные связи «многие ко многим»
Исходный кодПомимо связей belongs_to, has_many, has_one и :through, Ecto также включает связи many_to_many. Связи many_to_many, как следует из названия, позволяют записи из таблицы X иметь множество связанных записей из таблицы Y и наоборот. Хотя связи many_to_many можно реализовать с помощью has_many :through, использование many_to_many может значительно упростить некоторые рабочие процессы.
В этом руководстве мы поговорим о полиморфных связях и о том, как many_to_many может избавить от шаблонного кода в некоторых подходах по сравнению с has_many :through.
Списки задач v65131
В интернете появилось немало приложений для списков задач. Но это не помешает нам создать собственное!
В нашем случае нас интересует один аспект таких приложений — связь, в которой у списка задач есть множество элементов. Именно этот сценарий подробно рассмотрен в публикации о вложенных связях и встраиваемых данных в блоге Dashbit. Давайте вспомним основные моменты.
В нашем приложении для списков задач есть две схемы: Todo.List и Todo.Item:
defmodule MyApp.TodoList do
use Ecto.Schema
schema "todo_lists" do
field :title
has_many :todo_items, MyApp.TodoItem
timestamps()
end
end
defmodule MyApp.TodoItem do
use Ecto.Schema
schema "todo_items" do
field :description
timestamps()
end
end
Один из способов добавить в базу данных список задач с несколькими элементами — связать представление в пользовательском интерфейсе с нашими схемами. Именно этот подход мы использовали в публикации о Phoenix. Примерно так:
<%= form_for @todo_list_changeset,
todo_list_path(@conn, :create),
fn f -> %>
<%= text_input f, :title %>
<%= inputs_for f, :todo_items, fn i -> %>
...
<% end %>
<% end %>
При отправке такой формы в Phoenix будут переданы параметры следующего вида:
%{
"todo_list" => %{
"title" => "shopping list",
"todo_items" => %{
0 => %{"description" => "bread"},
1 => %{"description" => "eggs"}
}
}
}
Затем мы можем получить эти параметры и передать их в набор изменений Ecto, а Ecto автоматически определит, что нужно сделать:
# In MyApp.TodoList
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:title])
|> Ecto.Changeset.cast_assoc(:todo_items, required: true)
end
# And then in MyApp.TodoItem
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:description])
end
При вызове Ecto.Changeset.cast_assoc/3 Ecto найдёт ключ "todo_items" среди переданных параметров и сравнит их с элементами, сохранёнными в структуре списка задач. Ecto автоматически сформирует инструкции для добавления, обновления или удаления элементов списка задач, чтобы:
- если переданный в параметрах элемент списка задач имеет идентификатор, совпадающий с идентификатором существующего связанного элемента, считать, что этот элемент нужно обновить
- если переданный в параметрах элемент списка задач не имеет идентификатора (или идентификатора, которому соответствует существующий элемент), считать, что этот элемент нужно добавить
- если элемент списка задач в данный момент связан, но его идентификатор не был передан в параметрах, считать, что этот элемент заменяется, и действовать согласно обратному вызову
:on_replace. По умолчанию:on_replaceвызовет исключение, поэтому вы можете выбрать, заменять, удалять, игнорировать или обнулять связь
Преимущество использования cast_assoc/3 в том, что Ecto берёт на себя всю сложную работу по поддержанию связей между записями, если передавать данные именно в том формате, которого ожидает Ecto. Однако такой подход не всегда предпочтителен, и во многих случаях лучше иначе спроектировать связи или отделить пользовательский интерфейс от представления данных в базе.
Полиморфные элементы задач
Чтобы показать пример, в котором использование cast_assoc/3 оказывается слишком сложным и не оправдывает затраченных усилий, представим, что мы хотим сделать «элементы задач» полиморфными. Например, мы хотим добавлять элементы задач не только в «списки задач», но и во многие другие части приложения, например в проекты, этапы и так далее.
Прежде всего важно помнить, что Ecto не предоставляет такой же тип полиморфных связей, как фреймворки вроде Rails и Laravel. В таких фреймворках полиморфная связь использует два столбца: parent_id и parent_type. Например, у одного элемента задачи было бы значение parent_id, равное 1, и значение parent_type, равное "TodoList", а у другого — значение parent_id, равное 1, и значение parent_type, равное "Project".
Проблема приведённой выше схемы в том, что она нарушает ссылочную целостность базы данных. База данных больше не может гарантировать, что связанная запись существует или продолжит существовать в будущем. Это приводит к несогласованности данных и вынуждает добавлять обходные решения в приложение.
Кроме того, приведённая выше схема крайне неэффективна, особенно при работе с большими таблицами. Если это ваш случай, имейте в виду, что в будущем вам, возможно, придётся отказаться от таких полиморфных ссылок: частые полиморфные запросы могут довести базу данных до полной остановки, даже после добавления индексов и оптимизации.
К счастью, документация по макросу Ecto.Schema.belongs_to/3 содержит раздел «Полиморфные связи» с примерами того, как проектировать разумные и производительные связи. Один из подходов заключается в использовании нескольких таблиц соединения. Помимо таблиц "todo_lists", "projects" и "todo_items", мы создадим таблицы "todo_list_items" и "project_items", чтобы связывать элементы задач со списками задач и проектами соответственно. С точки зрения миграций нам понадобится следующее:
create table(:todo_lists) do add :title timestamps() end create table(:projects) do add :name timestamps() end create table(:todo_items) do add :description timestamps() end create table(:todo_list_items) do add :todo_item_id, references(:todo_items) add :todo_list_id, references(:todo_lists) timestamps() end create table(:project_items) do add :todo_item_id, references(:todo_items) add :project_id, references(:projects) timestamps() end
Создав отдельную таблицу для каждой пары связей, мы сохраняем ссылочную целостность и можем эффективно выполнять запросы, использующие индексы.
Сначала посмотрим, как реализовать эту функциональность в Ecto с помощью has_many :through, а затем используем many_to_many, чтобы избавиться от значительной части шаблонного кода, который пришлось добавить.
Полиморфизм с has_many :through
Поскольку мы хотим, чтобы наши элементы задач были полиморфными, мы больше не можем связывать список задач с элементами напрямую. Вместо этого мы создадим промежуточную схему, связывающую MyApp.TodoList и MyApp.TodoItem.
defmodule MyApp.TodoList do
use Ecto.Schema
schema "todo_lists" do
field :title
has_many :todo_list_items, MyApp.TodoListItem
has_many :todo_items,
through: [:todo_list_items, :todo_item]
timestamps()
end
end
defmodule MyApp.TodoListItem do
use Ecto.Schema
schema "todo_list_items" do
belongs_to :todo_list, MyApp.TodoList
belongs_to :todo_item, MyApp.TodoItem
timestamps()
end
end
defmodule MyApp.TodoItem do
use Ecto.Schema
schema "todo_items" do
field :description
timestamps()
end
end
Хотя мы добавили MyApp.TodoListItem в качестве промежуточной схемы, has_many :through позволяет прозрачно получать все элементы задач для любого списка задач:
todo_lists |> Repo.preload(:todo_items)
Проблема в том, что связи :through доступны только для чтения, поскольку Ecto не располагает достаточной информацией, чтобы заполнить промежуточную схему. Это означает, что если мы по-прежнему хотим использовать cast_assoc, чтобы добавлять список задач с множеством элементов напрямую из пользовательского интерфейса, мы не можем использовать связь :through и должны действовать поэтапно. Сначала нужно вызвать cast_assoc(:todo_list_items) для TodoList, а затем вызвать cast_assoc(:todo_item) для схемы TodoListItem:
# In MyApp.TodoList
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:title])
|> Ecto.Changeset.cast_assoc(
:todo_list_items,
required: true
)
end
# And then in the MyApp.TodoListItem
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast_assoc(:todo_item, required: true)
end
# And then in MyApp.TodoItem
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:description])
end
Чтобы ещё больше усложнить задачу, вспомним, что cast_assoc ожидает данные определённой формы, соответствующей вашим связям. В данном случае из-за промежуточной схемы данные, передаваемые через формы Phoenix, должны выглядеть так:
%{"todo_list" => %{
"title" => "shipping list",
"todo_list_items" => %{
0 => %{"todo_item" => %{"description" => "bread"}},
1 => %{"todo_item" => %{"description" => "eggs"}},
}
}}
Хуже того, эту логику пришлось бы дублировать для каждой промежуточной схемы и создавать MyApp.TodoListItem для списков задач, MyApp.ProjectItem для проектов и так далее.
К счастью, many_to_many позволяет избавиться от всего этого шаблонного кода.
Полиморфизм с many_to_many
Идея связей many_to_many заключается в том, что они позволяют связывать две схемы через промежуточную схему, автоматически обрабатывая все детали, связанные с ней. Перепишем приведённые выше схемы, чтобы использовать many_to_many:
defmodule MyApp.TodoList do
use Ecto.Schema
schema "todo_lists" do
field :title
many_to_many :todo_items, MyApp.TodoItem,
join_through: MyApp.TodoListItem
timestamps()
end
end
defmodule MyApp.TodoListItem do
use Ecto.Schema
schema "todo_list_items" do
belongs_to :todo_list, MyApp.TodoList
belongs_to :todo_item, MyApp.TodoItem
timestamps()
end
end
defmodule MyApp.TodoItem do
use Ecto.Schema
schema "todo_items" do
field :description
timestamps()
end
end
Обратите внимание: MyApp.TodoList больше не нужно определять связь has_many со схемой MyApp.TodoListItem. Вместо этого мы можем просто связать её с :todo_items с помощью many_to_many.
В отличие от has_many :through, связи many_to_many также доступны для записи. Это означает, что мы можем передавать данные через формы точно так же, как в начале этого руководства:
%{"todo_list" => %{
"title" => "shipping list",
"todo_items" => %{
0 => %{"description" => "bread"},
1 => %{"description" => "eggs"},
}
}}
И нам больше не нужно определять функцию набора изменений в промежуточной схеме:
# In MyApp.TodoList
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:title])
|> Ecto.Changeset.cast_assoc(:todo_items, required: true)
end
# And then in MyApp.TodoItem
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:description])
end
Другими словами, мы можем использовать тот же код, что и в случае «у списка задач есть связь has_many с элементами задач». Поэтому даже если внешние ограничения требуют использовать таблицу соединения, связи many_to_many могут автоматически управлять ею за нас. Всё, что вы знаете о связях, будет работать и со связями many_to_many.
Наконец, хотя в параметре :join_through макроса many_to_many мы указали схему, many_to_many может работать и без промежуточных схем — достаточно передать ему имя таблицы:
defmodule MyApp.TodoList do
use Ecto.Schema
schema "todo_lists" do
field :title
many_to_many :todo_items, MyApp.TodoItem,
join_through: "todo_list_items"
timestamps()
end
end
В этом случае вы можете полностью удалить схему MyApp.TodoListItem из приложения, и приведённый выше код продолжит работать. Единственное отличие в том, что при использовании таблиц значения, автоматически заполняемые схемой Ecto, например временные метки, не будут добавлены, поскольку схемы больше нет. Чтобы решить эту проблему, можно либо удалить эти поля из миграций, либо задать значения по умолчанию на уровне базы данных.
Итоги
В этом руководстве мы использовали связи many_to_many, чтобы значительно улучшить схему полиморфных связей, основанную на has_many :through. Мы хотели, чтобы "todo_items" могли связываться с разными сущностями в нашей кодовой базе, например с "todo_lists" и "projects". Для этого мы создали промежуточные таблицы и использовали связи many_to_many для автоматического управления этими таблицами соединения.
В итоге наши схемы могут выглядеть так:
defmodule MyApp.TodoList do
use Ecto.Schema
schema "todo_lists" do
field :title
many_to_many :todo_items, MyApp.TodoItem,
join_through: "todo_list_items"
timestamps()
end
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:title])
|> Ecto.Changeset.cast_assoc(
:todo_items,
required: true
)
end
end
defmodule MyApp.Project do
use Ecto.Schema
schema "projects" do
field :name
many_to_many :todo_items, MyApp.TodoItem,
join_through: "project_items"
timestamps()
end
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:name])
|> Ecto.Changeset.cast_assoc(
:todo_items,
required: true
)
end
end
defmodule MyApp.TodoItem do
use Ecto.Schema
schema "todo_items" do
field :description
timestamps()
end
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:description])
end
end
А миграция базы данных — так:
create table("todo_lists") do
add :title
timestamps()
end
create table("projects") do
add :name
timestamps()
end
create table("todo_items") do
add :description
timestamps()
end
# Primary key and timestamps are not required if
# using many_to_many without schemas
create table("todo_list_items", primary_key: false) do
add :todo_item_id, references(:todo_items)
add :todo_list_id, references(:todo_lists)
# timestamps()
end
# Primary key and timestamps are not required if
# using many_to_many without schemas
create table("project_items", primary_key: false) do
add :todo_item_id, references(:todo_items)
add :project_id, references(:projects)
# timestamps()
end
В целом структура нашего кода выглядит так же, как выглядела бы has_many, хотя на уровне базы данных наши связи представлены таблицами соединения.
В этом руководстве мы изменили код, чтобы он соответствовал формату параметров, необходимому для cast_assoc. В руководстве Ограничения и операции upsert мы полностью отказываемся от cast_assoc и используем put_assoc, что обеспечивает большую гибкость при работе со связями.
© 2014 Chris McCord
Licensed under the MIT License.
https://ecto.hexdocs.pm/polymorphic-associations-with-many-to-many.html