Содержание урока · 12 разделов
- ent many2many: join-таблица вместо колонки
- Что ent кладёт в базу: таблица group_users
- Две каскадные стрелки: почему в M2M сирот не бывает
- O2M против M2M: одна пара, разная анатомия
- Симметричная связь: друзья без owner и inverse
- Through: через-таблица с датой добавления
- Что даёт через-таблица на практике
- Запросы через связи: QueryGroups и QueryUsers
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
В пятом уроке пара edge.To и edge.From с Ref() склеила User и Post отношением one-to-many: колонка внешнего ключа жила в таблице постов, а Unique() на обратной стороне говорил «у поста ровно один автор». Сегодня уберём это ограничение — и получим ent many2many. Тема урока: связи «многие ко многим», которые не влезают в одну колонку, а живут отдельной таблицей. Возьмём два кейса: пользователи и группы (классическая пара из двух схем) и друзья (симметричная связь пользователя с самим собой). А в финале заменим служебную join-таблицу от ent на собственную схему с датой добавления — методом Through(). Правило прошлого урока остаётся в силе: имена рёбер — часть публичного API схемы, а весь код ниже выдаёт go generate ./ent — механику, настроенную ещё во втором уроке курса.
ent many2many: join-таблица вместо колонки#
Начнём с вопроса, который откладывался с прошлого урока: почему для many-to-many нельзя обойтись колонкой? Поставьте себя на место базы. У Анны пять групп, в каждой группе двадцать участников. Хочется колонку group_ids в строке пользователя — но тогда в одной ячейке живёт список идентификаторов, а с такими списками не работают ни внешние ключи, ни индексы, ни JOIN, ни ограничение целостности. Реляционное решение старо как мир: отдельная таблица, где каждая строка — одна пара «пользователь–группа». Называется она join-таблицей, а по-русски — связующей или промежуточной.
Именно так описывает связку m2m ent: отношение «многие ко многим» хранится не в колонках схем, а в отдельной таблице, которую ent создаёт по вашим рёбрам. Роль Unique() переосмысляется: Unique() с обеих сторон делал one-to-one, Unique() на одной стороне — O2M/M2O из пятого урока, а отсутствие Unique() с обеих сторон — это и есть many-to-many. Никакого нового синтаксиса для этого не нужно: достаточно пары To и From, которую мы уже умеем писать.
Две половины M2M-ребра: без Unique()
GO// ent/schema/group.go — Group объявляет связь и владеет ею
func (Group) Edges() []ent.Edge {
return []ent.Edge{
edge.To("users", User.Type), // участники группы
}
}
// ent/schema/user.go — обратная половина
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.From("groups", Group.Type). // группы пользователя
Ref("users"), // без Unique() — значит M2M
}
}Тот же скелет, что в паре User и Post, с одной разницей: у обратного ребра нет Unique(). Снимите Unique с постового ребра пятого урока — и posts_users тоже станет M2M-парой. Ref() здесь работает без изменений: он называет ребро-владельца в group.go, и без совпадения имён генерация падает. Владельцем связи остаётся сторона edge.To — Group решает, как связь устроена, User лишь ссылается.
Что ent кладёт в базу: таблица group_users#
Сгенерируем код и заглянем в миграцию. Имя таблицы ent собирает из типа-владельца и имени ребра: для пары Group.users и User.groups получится group_users. Колонки зовутся по типам схем: group_id и user_id. Вместе они образуют составной PRIMARY KEY — пара уникальна по построению, задвоить членство не выйдет даже руками. Обе колонки NOT NULL: в отличие от Optional-полей из четвёртого урока, NULL в join-таблице не живёт — строка связи без одной из сторон бессмысленна.
Самое приятное — внешние ключи. ent создаёт два констрейнта, и оба с ON DELETE CASCADE: это видно в сгенерированном migrate/schema.go любого проекта с M2M — у обеих FK стоит schema.Cascade. Имена констрейнтов собираются по шаблону «таблица_колонка»: group_users_group_id и group_users_user_id. В Go-структурах появляются поля связи: у Group — Users []User, у User — Groups []Group, оба с json-тегом по имени ребра и заполненные только после явной загрузки через With-методы или обходы.
AddUsers, AddUserIDs, RemoveUsers, ClearUsers
GO// создать группу сразу с участниками (принимаются сущности):
hub, err := client.Group.Create().
SetName("go-ninja").
AddUsers(anna, boris).
Save(ctx)
// то же по идентификаторам — без загрузки сущностей:
hub, err = client.Group.Create().
SetName("go-ninja").
AddUserIDs(anna.ID, boris.ID).
Save(ctx)
// дописать участника позже — на update-билдере:
hub, err = client.Group.UpdateOneID(hub.ID).AddUsers(nati).Save(ctx)
// убрать одного участника и очистить состав целиком:
hub, err = client.Group.UpdateOneID(hub.ID).RemoveUsers(nati).Save(ctx)
hub, err = client.Group.UpdateOneID(hub.ID).ClearUsers().Save(ctx)
// обходы с обеих сторон:
users, _ := hub.QueryUsers().All(ctx) // кто в группе
groups, _ := anna.QueryGroups().All(ctx) // где состоит АннаНабор методов повторяет логику пятого урока с одним отличием: Set-методов у M2M-ребра нет. AddUsers принимает готовые сущности, AddUserIDs — идентификаторы; оба есть и на create-билдере, и на update. RemoveUsers вынимает конкретных участников, ClearUsers стирает все строки связи — но оба живут только на update-билдерах: при создании удалять ещё нечего. Со стороны User набор симметричен: AddGroups и AddGroupIDs дописывают группы Анне, RemoveGroups и ClearGroups работают на update. QueryUsers и QueryGroups возвращают обычные билдеры — на них вешаются Where, Order и Limit.
Проверка: какие методы сгенерирует M2M
Для M2M-ребра Group.users ent отработал по схеме. Какой вариант описывает сгенерированные мутаторы точно?
Повторное добавление участника
Прочитайте код и выберите, что он выведет:
// В группе go-ninja два участника: anna и boris.
// Строка (hub.ID, anna.ID) в group_users уже есть.
hub, _ := client.Group.Query().
Where(group.Name("go-ninja")).
Only(ctx)
err := client.Group.UpdateOneID(hub.ID).
AddUsers(anna). // Анна уже в группе
Exec(ctx)
n, _ := hub.QueryUsers().Count(ctx)
fmt.Println(err, n)Две каскадные стрелки: почему в M2M сирот не бывает#
Каскад из пятого урока удалял контент: entsql.OnDelete(entsql.Cascade) на ребре posts стирал посты вместе с их автором. В служебной M2M-таблице каскад работает иначе, и это важно почувствовать. При удалении пользователя база каскадно удалит строки его членства — но сами группы останутся целыми: связь исчезла, данные живы. То же в обратную сторону: снесите группу, и пользователи даже не заметят, кроме минус одной строки в group_users. Сирот, как у Optional-ребра с SET NULL, здесь не бывает по построению: связь не может указывать в никуда, потому что не существует без обеих сторон.
Отсюда практический вывод. Если у вас есть бизнес-правило «пустая группа умирает» или «пользователь без групп удаляется» — это правило вашего кода, а не базы: ent не уследит за последним участником, провяжите его в обработчиках или хуках. Зато управлять составом групп безопасно всегда: худшее, что случится, — пустая группа. И да, в отличие от пятого урока, аннотация entsql.OnDelete здесь не нужна: для служебной таблицы ent ставит CASCADE на обе колонки сам, не спрашивая.
O2M против M2M: одна пара, разная анатомия#
| Вопрос | O2M/M2O (урок 05) | M2M (этот урок) |
|---|---|---|
| Где живёт связь | колонка FK в таблице «многих» (author_id в posts) | отдельная join-таблица (group_users) |
| Что создаёт ent | колонку по шаблону «владелец_ребро» или Edge Field | таблицу с двумя колонками и составным PRIMARY KEY |
| Set-мутаторы | SetAuthor / SetAuthorID на стороне «многие» | не существуют: только Add, Remove, Clear |
| OnDelete по умолчанию | SET NULL у Optional, NO ACTION у Required | CASCADE на обеих колонках — всегда и без аннотаций |
| Что удалит CASCADE | сами посты вместе с автором | только строки связи; группы и пользователи живы |
| Обратная сторона | edge.From + Ref + Unique() | edge.From + Ref без Unique() или вообще без неё |
Симметричная связь: друзья без owner и inverse#
Теперь кейс, где обе стороны — одна и та же схема: друзья. Объявлять пару edge.To с edge.From и Ref здесь можно, но для дружбы это избыточно: у неё нет владельца и нет обратной стороны — если Борис друг Анны, то и Анна друг Бориса. ent описывает такую связь одним ребром: edge.To("friends", User.Type) без всякого edge.From. В документации это bidirectional edge, и терминология owner и inverse к ней не применяется вовсе.
Для контраста возьмите подписки — ту же схему пользователей, но направленное отношение: edge.To("following", User.Type).From("followers") объявляет ребро и его ссылку одним билдером. Подписка Бориса не делает Анну подписчицей Бориса. Друзья и подписки — два разных отношения между одними и теми же таблицами, и ent различает их ровно симметрией. Дефолтная таблица для друзей — user_friends с колонками user_id и friend_id: вторую ent образует от имени ребра в единственном числе, чтобы обе колонки не превратились в одинаковые user_id. При добавлении друга ent пишет в таблицу две зеркальные строки — поэтому обходы QueryFriends работают с обеих сторон одинаково.
StorageKey: своё имя для join-таблицы
GO// симметричная связь: одна декларация, без edge.From
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("friends", User.Type).
// своё имя таблицы и колонок вместо дефолтных:
StorageKey(edge.Table("user_friends"),
edge.Columns("user_id", "friend_id")),
}
}
// после Save в user_friends появятся две зеркальные строки:
boris, err := client.User.Create().
SetName("boris").
AddFriends(anna).
Save(ctx)StorageKey переименовывает join-таблицу и обе её колонки: edge.Table задаёт имя таблицы, edge.Columns — пары колонок; первая колонка относится к стороне To, вторая — к обратной. Для имён констрейнтов есть edge.Symbols(to, from). В примере имена совпадают с дефолтными user_friends, user_id, friend_id — но в реальном проекте StorageKey выручает, когда таблица уже существует со своей историей имён. AddFriends и QueryFriends генерируются как обычно: у симметричной связи методы на обеих сторонах совпадают.
Проверка: двусторонние друзья
Связь объявлена как edge.To("friends", User.Type) — одно ребро без обратной стороны. Борис добавил Анну: boris.AddFriends(anna). Что вернёт anna.QueryFriends().All(ctx)?
Through: через-таблица с датой добавления#
Служебная join-таблица хранит только пары идентификаторов: ни даты добавления, ни статуса, ни веса. Как только связь обрастает данными, ей нужна собственная схема. В ent для этого есть Through: edge.To("friends", User.Type).Through("friendships", Friendship.Type) читается как «связь живёт в таблице схемы Friendship». Through — метод ребра, а не аннотация: не путайте с entsql.Annotation, у которого есть OnDelete для каскадов, но нет поля Through. Через-таблица (through-таблица) — полноценная сущность со своим клиентом, полями и хуками; в терминах ent она называется edge schema.
Правила жёсткие, и все проверяются на генерации. Через-таблица разрешена только для M2M-рёбер: попытка повесить Through на связь с колонкой FK упадёт с ошибкой «is allowed only on M2M edges». Два ребра к владельцам связи должны быть Required и привязаны к полям через Field. Одна edge schema обслуживает ровно одну связь — переиспользовать её для второй пары нельзя. И имя из Through должно быть свободно: ребра с таким именем в схеме-владельце уже быть не должно.
Edge schema: Friendship как обычная схема
GO// ent/schema/user.go — ребро через таблицу
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("friends", User.Type).
Through("friendships", Friendship.Type),
}
}
// ent/schema/friendship.go — edge schema
func (Friendship) Fields() []ent.Field {
return []ent.Field{
field.Time("created_at").Default(time.Now), // когда подружились
field.Int("weight").Default(1), // «сила» дружбы
field.Int("user_id"),
field.Int("friend_id"),
}
}
func (Friendship) Edges() []ent.Edge {
return []ent.Edge{
edge.To("user", User.Type).
Unique().Required().Field("user_id"),
edge.To("friend", User.Type).
Unique().Required().Field("friend_id"),
}
}Friendship — обычная схема: те же Fields, Edges и любые дополнения вроде Indexes. Обязательные рёбра user и friend привязаны к полям Field("user_id") и Field("friend_id") — именно эти пары полей составляют связь, поэтому Required обязателен: строка дружбы без участников запрещена. ent построит таблицу friendships с собственным инкрементным id, уникальным индексом на паре user_id и friend_id и двумя FK. Схема Friendship перестаёт быть невидимой служебной таблицей: у неё появляется клиент, мутации и предикаты, как у любой сущности.
Что даёт через-таблица на практике#
Работать со связью можно двумя путями. Первый — по-старому: AddFriends(anna) у пользователя существует и после Through; ent сам вставит строку в friendships, а недостающие значения возьмёт из дефолтов полей схемы. Второй — напрямую: client.Friendship.Create().SetUser(anna).SetFriend(boris).SetWeight(3) — когда нужен полный контроль над полями. Обходы тоже на месте: QueryFriendships у пользователя вернёт строки дружбы с датами, QueryFriend от строки — второго участника.
Есть и расплата. В служебной M2M-таблице повторное добавление — тихий no-op: ent дописывает в INSERT оговорку ON CONFLICT DO NOTHING. Для edge schema этого не происходит: дополнительные поля при каждой вставке могут отличаться — time.Now() или uuid.New() — и ent отказывается угадывать, какое значение правильное. Дубликат дружбы упадёт с ошибкой уникального индекса, обрабатывайте её сами. И второе: каскад здесь не автоматический. Обязательные рёбра дружбы получают дефолтное NO ACTION из пятого урока, и удаление пользователя с дружбами упадёт с ошибкой базы, пока вы не повесите entsql.OnDelete(entsql.Cascade) на оба ребра Friendship.
Проверка: удаление при through
Дружба оформлена через Through: у Friendship два Required-ребра с полями user_id и friend_id, аннотаций entsql нет. Что произойдёт при client.User.DeleteOne(anna).Exec(ctx), если у Анны есть дружбы?
Запросы через связи: QueryGroups и QueryUsers#
Обходы M2M-связи генерируются на обеих сторонах: QueryUsers у группы, QueryGroups у пользователя. Это обычные билдеры — на них вешаются Where, Order и Limit так же, как на client.Group.Query(). Цепочки рёбер читаются слева направо: anna.QueryGroups().QueryUsers() — «все участники всех групп Анны», а hub.QueryUsers().Where(...) фильтрует участников перед выборкой. Рёбра работают и в предикатах: HasUsersWith и HasGroupsWith собирают выражения вида «группы, где есть пользователь с именем Борис» одним вызовом — полный словарь предикатов ждёт в уроке про фильтры.
Для through-связей появляются обходы самой edge schema: QueryFriendships у пользователя вернёт строки дружбы с полями, а QueryFriend от строки — второго участника; существуют и обратные HasFriendWith. Про жадную загрузку помните тизер пятого урока: WithGroups и WithFriends существуют наравне с WithPosts и лечат N+1 тем же способом. Полный разбор With-методов — в одиннадцатом уроке; здесь достаточно знать, что M2M грузится так же, как O2M, и поле Groups после загрузки заполнено сущностями.
Обходы и фильтры по M2M-рёбрам
GO// группы, где состоит Борис:
ninja, err := client.Group.Query().
Where(group.HasUsersWith(user.Name("boris"))).
All(ctx)
// все участники всех групп Анны — проход по графу:
users, err := anna.QueryGroups().QueryUsers().All(ctx)
// группы Анны, отфильтрованные по участникам:
teams, err := anna.QueryGroups().
Where(group.HasUsersWith(user.Name("boris"))).
All(ctx)
// через-таблица как обычная сущность: недавние дружбы
weekAgo := time.Now().AddDate(0, 0, -7)
recent, err := client.Friendship.Query().
Where(friendship.CreatedAtGTE(weekAgo)).
WithUser(). // жадная загрузка участников строки связи
All(ctx)QueryGroups и QueryUsers — те же билдеры, что и client.Group.Query(): Where с предикатами group.HasUsersWith, Order, Limit, GroupBy. Цепочка anna.QueryGroups().QueryUsers() выполняется двумя SQL-запросами через join по join-таблице, а не циклом. Строки edge schema запрашиваются своим клиентом: friendship.CreatedAtGTE — обычный предикат поля, WithUser грузит участника дружбы так же, как WithPosts грузил посты.
Коду этого урока нужны сгенерированный каталог ent и живая база — в браузерной песочнице такое не запускается, поэтому runner-ов в уроке нет. Вместо запуска — интерактивы: квизы и predict-задачи проверяют понимание API, fill и order восстанавливают объявления рёбер, карточки закрепляют анатомию join-таблиц. Схемы и мутаторы доверьте компилятору: скопируйте примеры в свой проект, прогоните go generate ./ent — несуществующие методы и опечатки в именах рёбер он покажет быстрее любого ручного прогона.
Финал урока: group.go и user.go целиком
GO// ent/schema/group.go
func (Group) Edges() []ent.Edge {
return []ent.Edge{
edge.To("users", User.Type), // M2M: участники группы
}
}
// ent/schema/user.go
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.From("groups", Group.Type).
Ref("users"), // обратная половина без Unique()
edge.To("friends", User.Type). // симметричная связь
StorageKey(edge.Table("user_friends"),
edge.Columns("user_id", "friend_id")),
}
}
// дружба как данные — в отдельной схеме friendship.go
// с Through("friendships", Friendship.Type) из блока вышеИтоговая схема: групповая связь — пара To и From с Ref без Unique, дружба — одиночное ребро со StorageKey, а серьёзные отношения с полями — через edge schema. Файл friendship.go целиком приведён в блоке про Through. После генерации в базе появятся group_users с двумя каскадными FK и user_friends с зеркальными строками — сверьте их с миграцией в своём проекте.
Что дальше#
M2M замыкает типовую триаду: one-to-one осталась за кадром пятого урока как частный случай Unique() с двух сторон, one-to-many и many-to-one разобраны там же, many-to-many — сегодня. Схема микроблога обзавелась группами, друзьями и датой знакомства — и во всех трёх случаях вы не написали ни строчки SQL. Следующий урок — 07-generate — про кодогенерацию: что именно go generate ./ent кладёт в каталог ent/, из чего состоят сгенерированные билдеры и почему после каждой правки схемы нужен повторный прогон. Полный план из 25 уроков — на странице курса. Домашнее задание — лучшая проверка того, что join-таблицы перестали быть магией: соберите их руками в своей базе и сравните с миграцией ent.
Жизненный цикл M2M-связи
Расставьте шаги от объявления связи до обратного обхода и через-таблицы.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Восстановите M2M-объявления
Впишите метод through-связи, обязательность ребра edge schema, ссылку на владельца и добавлятор по идентификаторам.
Что окажется в базе после AddFriends
Прочитайте код и выберите, что он выведет:
// связь объявлена через edge schema:
// edge.To("friends", User.Type).
// Through("friendships", Friendship.Type)
boris, err := client.User.Create().
SetName("boris").
AddFriends(anna).
Save(ctx)
rows, _ := client.Friendship.Query().Count(ctx)
fmt.Println(rows)Карточки: M2M и Through
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- M2M в ent — пара рёбер без Unique(): edge.To("users", User.Type) у владельца и edge.From("groups", Group.Type).Ref("users") у второй стороны. Уберите Unique() из пары пятого урока — и связь станет many-to-many.
- Вместо колонки FK ent строит join-таблицу «тип_ребро»: group_users с колонками group_id и user_id, составным PRIMARY KEY и двумя FK — оба с ON DELETE CASCADE и без единой аннотации.
- Мутаторы M2M: AddUsers и AddUserIDs на create и update, RemoveUsers и ClearUsers только на update; Set-методов не существует. Повторное добавление существующей связи в служебную таблицу — no-op.
- Симметричная связь — одно edge.To("friends", User.Type) без обратной стороны: ent пишет две зеркальные строки в user_friends, никаких owner и inverse. Имена таблицы и колонок переопределяет StorageKey(edge.Table(...), edge.Columns(...)).
- Through("friendships", Friendship.Type) заменяет служебную таблицу на edge schema: свой id, доп. поля вроде даты добавления, уникальный индекс пары и полноценный клиент. Одна edge schema обслуживает ровно одну связь.
- В служебной M2M-таблице каскад автоматический; в through-таблице дефолт — NO ACTION у Required-рёбер: каскад включается аннотацией entsql.OnDelete(entsql.Cascade) на обоих рёбрах схемы.
- Обходы: QueryGroups и QueryUsers с обеих сторон, цепочки вида QueryGroups().QueryUsers(), предикаты HasUsersWith; With-методы жадной загрузки работают и для M2M — подробности в одиннадцатом уроке.
Домашнее задание
Возьмите финальные схемы урока и сгенерируйте код. Создайте двух пользователей и две группы, сверьте содержимое таблицы group_users: должно получиться четыре строки, а в миграции — два констрейнта с ON DELETE CASCADE. Затем повторите AddUsers с уже состоящим участником и убедитесь, что строка осталась одна — no-op. Объявите симметричное ребро friends и проверьте на двух пользователях, что в user_friends появились зеркальные строки и QueryFriends работает с обеих сторон. Переведите дружбу на Through("friendships", Friendship.Type) с полями created_at и weight, добавьте связь через AddFriends и найдите её через client.Friendship.Query(). Попробуйте добавить ту же пару второй раз и объясните, почему в через-таблице это ошибка уникального индекса, а в служебной — нет. Вопрос на подумать: какой OnDelete вы поставите на рёбра Friendship и почему дефолт NO ACTION здесь опаснее, чем в служебной таблице?
Личные заметкиПусто
Вопросы и ответы по уроку
Почему ent не генерирует SetUsers для many-to-many?
Set имеет смысл, когда связь хранится в колонке самой строки — как SetAuthor из пятого урока: одно значение, одна ячейка. У M2M колонки нет: связь — это строки join-таблицы, поэтому ent даёт AddUsers и AddUserIDs, чтобы собрать коллекцию, RemoveUsers и RemoveGroupIDs, чтобы убрать выбранных, и ClearUsers, чтобы снять всё. «Перезаписать ячейку» в такой модели просто нечего.
Чем through-таблица отличается от служебной join-таблицы ent?
Служебная group_users создаётся автоматически: две NOT NULL колонки, составной PK, оба FK CASCADE, из кода к ней не добраться. Через-таблица из Through — полноценная схема: свой id, любые поля (created_at, weight), уникальный индекс пары, клиент, хуки и политики. Каскад при удалении в ней не автоматический: настраивается аннотацией entsql.OnDelete на её двух рёбрах.
Что выбрать для друзей: симметричное ребро или Through?
Если нужен только список друзей — одно edge.To("friends", User.Type): ent сам пишет зеркальные строки и глотает дубликаты. Если дружба — это данные (когда подружились, статус заявки, вес связи) — Through("friendships", Friendship.Type): появятся доп. поля и отдельный клиент Friendship, но дубликаты начнут падать с ошибкой уникального индекса, а каскад придётся настраивать явно.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker