Как реализовать связь многие ко многим в базе данных
Что проверяют
Схема связи одинакова в любом языке, поэтому смотрят на прикладную часть: как это выглядит в GORM или в чистом SQL, откуда берётся N+1 и чем ORM платит за удобство.
Как отвечать
Схема. Связующая таблица с составным первичным ключом:
create table users (
id bigint generated always as identity primary key,
name text not null
);
create table user_groups (
user_id bigint not null references users(id) on delete cascade,
group_id bigint not null references groups(id) on delete cascade,
primary key (user_id, group_id)
);
create index on user_groups (group_id, user_id);Составной ключ гарантирует уникальность пары и покрывает выборку по user_id; обратное направление требует отдельного индекса. generated always as identity это текущая рекомендация Postgres, serial оставлен ради совместимости.
GORM.
type User struct {
ID int64
Name string
Groups []Group `gorm:"many2many:user_groups;"`
}Читать связь через db.Preload("Groups").Find(&users): Preload делает второй запрос с IN (...), а не по запросу на каждого пользователя. Association("Groups").Append(...) добавляет строки связи, Replace(...) удаляет те, которых нет в новом наборе. Это разные операции, и путать их дорого.
AutoMigrate годится для локальной разработки. На проде схему катать миграциями (goose, golang-migrate): AutoMigrate не удаляет и не переименовывает колонки и не даёт воспроизводимой истории изменений.
Без ORM. Часто проще один запрос с агрегацией на стороне базы, чем ORM с preload:
select u.id, u.name,
coalesce(json_agg(g.*) filter (where g.id is not null), '[]') as groups
from users u
left join user_groups ug on ug.user_id = u.id
left join groups g on g.id = ug.group_id
group by u.id, u.name;С pgx или sqlx это одна поездка в базу и явный контроль над планом запроса.
Когда связь перестаёт быть простой. Появились собственные поля (дата вступления, роль в группе, кто добавил) значит связующую таблицу описывают отдельной структурой и работают с ней как с обычной сущностью, а тег many2many убирают.
Где спотыкаются
Забывают составной первичный ключ и получают дубли пар после повторного Append; в цикле по пользователям дёргают группы отдельным запросом вместо Preload или одного join.