Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~20 мин +50 XP

Ассоциации: has one и has many — зеркальная сторона связи, каскады и полиморфизм в GORM

Прошлый урок смотрел на колонку todo_item_id глазами комментария: внешний ключ на объявляющей модели, владелец вставляется первым, Append играет роль Replace. Сегодня та же колонка читается с другого конца — и связь gorm has many покажет, почему одна и та же колонка в базе описывается двумя объявлениями. Дока вводит обе связи одной формулой: has many устанавливает соединение один-ко-многим, при котором владелец держит ноль, одну или много записей другой модели, а has one — соединение один-к-одному, где у владельца ровно один сосед; пользователь имеет один профиль, список собирает много задач. Разворот против belongs to в одном: внешний ключ живёт на ДРУГОЙ модели. Из этого разворота растёт весь урок: угадыватель guessHas и конвенция «имя типа-владельца плюс ID» — UserID в профиле, и провал, если назвать ключ ProfileID; теги foreignKey и references, у которых на has-стороне меняется направление — теперь они называют поле соседа и своё поле; сохранение детей ПОСЛЕ владельца колбэком gorm:save_after_associations — пакетный INSERT, в который GORM сам вписывает ключи; upsert ON CONFLICT (id) DO UPDATE SET, из-за которого Create с существующим ребёнком пересаживает его к новому владельцу; внутренний флаг OwnPrimaryKey из schema.Reference, объясняющий, почему констрейнт строится на дочерней таблице, а чтение и каскады считают WHERE fk IN (наши ключи); каскады удаления двух уровней — Select-driven колбэк gorm:delete_before_associations, обещанный девятым уроком, и физический ON DELETE из constraint-тега; полиморфная связь, поддержанная ровно для has one и has many; и Association API со срезом: Append вставляет или пересаживает, Replace меняет состав, Delete и Clear обнуляют ключи, Find и Count читают одним условием. Канон продолжается: к задачам и комментариям добавляются пользователи с профилями, списки собирают задачи под одним именем, а вложения цепляются и к спискам, и к задачам одной таблицей. Одна честная оговорка о формате: СУБД в песочницу не поставишь, поэтому runner-ов в уроке нет — вас проверяют quiz, predict, fill, order и карточки, а примеры рассчитаны на локальный Go с PostgreSQL или SQLite; SQL-след печатает логгер из второго урока.

Содержание урока · 13 разделов

gorm has many и has one: связь 1-n gorm с другого конца#

Дока определяет has many так: связь устанавливает соединение один-ко-многим с другой моделью и, в отличие от has one, владелец может держать ноль или много экземпляров. has one — тот же взгляд, но единственный: каждый экземпляр модели содержит или имеет ровно один экземпляр другой. С точки зрения SQL обе связи — одна и та же картина: колонка внешнего ключа в дочерней таблице, свободная для has many и фактически единственная для has one. В этом и разворот против одиннадцатого урока: там Comment объявлял TodoItemID и «принадлежал» задаче — belongs to смотрит глазами записи с ключом; здесь смотрит владелец, и правило грамматики переворачивается: у has-связи внешний ключ НЕ у владельца. User не хранит ProfileID — Profile хранит UserID; TodoList не хранит идентификаторы задач — каждая задача несёт todo_list_id. Поле-ассоциация у владельца — это объявление связи: структура для has one, срез для has many; тип поля решает, сколько соседей GORM ждёт — у структуры один, у среза сколько угодно, включая ноль. Угадыватель parseRelation из schema/relationship.go отправляет срез прямо в сценарий guessHas — срез не бывает belongs to по определению, ключ-то не на нём, — а структурное поле проходит guessGuess, который пробует has первым и belongs вторым. Связь 1-n gorm описывается одним объявлением: Items []TodoItem на TodoList — и колонка todo_list_id появляется у задач. И симметрия: TodoItem.TodoList с TodoListID из прошлого урока и TodoList.Items описывают ОДНУ колонку; объявлять обе стороны законно, но не обязательно — констрейнт построится один раз, причём имя возьмёт has-сторона, о чём ниже.

Четыре модели: пользователь, профиль, список, задачи

GO
// Пользователь: has one - у него ровно один профиль
type User struct {
        gorm.Model
        Name    string
        Profile Profile // поле-ассоциация: ключ живёт на другой стороне
}

// Профиль: хранит внешний ключ по конвенции - UserID
type Profile struct {
        gorm.Model
        UserID uint   // колонка user_id: имя владельца плюс первичный ключ
        Avatar string
        Bio    string
}

// Список: has many - владелец многих задач
type TodoList struct {
        gorm.Model
        Name  string
        Items []TodoItem // поле-срез: колонка todo_list_id остаётся у задач
}

// Задача: belongs to из одиннадцатого урока - вторая сторона той же колонки
type TodoItem struct {
        gorm.Model
        Title      string
        Status     string
        TodoListID uint     // внешний ключ: колонка todo_list_id
        TodoList   TodoList // belongs to-объявление, теперь зеркальное
}

db.AutoMigrate(&User{}, &Profile{}, &TodoList{}, &TodoItem{})
// CREATE TABLE users (id integer PRIMARY KEY, created_at, updated_at, deleted_at, name)
// CREATE TABLE profiles (..., user_id integer, CONSTRAINT fk_users_profile FOREIGN KEY (user_id) REFERENCES users(id))
// CREATE TABLE todo_lists (..., name)
// ALTER TABLE todo_items ADD CONSTRAINT fk_todo_lists_items FOREIGN KEY (todo_list_id) REFERENCES todo_lists (id)

Обратите внимание на имя констрейнта: в одиннадцатом уроке, когда у TodoList не было поля Items, belongs to-сторона строила fk_todo_items_todo_list. Стоило объявить срез Items — дедупликация ParseConstraint уступила констрейнт has-стороне, и имя стало fk_todo_lists_items: RelationshipFKName собирает его из таблицы ВЛАДЕЛЬЦА и имени поля ассоциации (todo_lists + items). Колонка не изменилась — todo_list_id у задач, поменялась только точка зрения. Колонку user_id профилу ставит has one-объявление User.Profile.

Конвенция с has-стороны: UserID, а не ProfileID#

Как guessRelation понимает, что User.Profile — это has one, и где ищет ключ? Для структурного поля первым пробуется сценарий has: primarySchema — объявляющая модель (User), foreignSchema — сосед (Profile). Ключ ищется на СОСЕДЕ, а имя строится по формуле «имя типа-владельца плюс имя первичного поля»: User плюс ID даёт UserID. Перебор щедрый, как и у belongs to: UserID, UserId и колонка user_id от namer.ColumnName, причём двумя словарями — сначала LookUpFieldByBindName по Go-имени, затем LookUpField по имени поля или колонке (знакомый механизм из десятого урока). Дока формулирует ту же конвенцию в лоб: имя поля внешнего ключа генерируется из типа has one-модели плюс её первичный ключ, для примера — UserID. Отсюда ловушка, на которой спотыкается каждый второй: назвать ключ ProfileID «по аналогии с TodoItemID» — и связь не соберётся. Перебор строится из имени ВЛАДЕЛЬЦА, а не из имени поля ассоциации, поэтому ProfileID в профиле никто не ищет; guessHas терпит неудачу, регадывает belongs to (ищет ProfileID уже на User), потом embedded-варианты — и парсер завершается знакомой ошибкой invalid field found for struct ... define a valid foreign key for relations or implement the Valuer/Scanner interface. У has many формула та же: срез CreditCards []CreditCard ищет UserID у карточки — дока повторяет её дословно: «имя внешнего ключа по умолчанию — имя типа-владельца плюс имя его первичного поля». Разница между has one и has many существует только на стороне Go: структура или срез; в таблице обе связи оставляют одну и ту же колонку.

Проверка: что ищет guessHas

В модели User объявлено поле Profile Profile, а в Profile — поля Avatar и ProfileID, поля UserID нет. Что сделает GORM при первом обращении к модели?

foreignKey и references: направление тегов меняется#

Пара тегов из одиннадцатого урока живёт и на has-стороне, но направление у неё другое — и это самая частая путаница после конвенции. foreignKey теперь называет поле на ДОЧЕРНЕЙ модели: gorm:"foreignKey:UserRefer" на срезе CreditCards говорит «ключ хранится в поле UserRefer карточки», а не в поле объявляющей модели, как было у belongs to. Дока приводит ровно такой пример: User с CreditCards и CreditCard с UserRefer uint. references называет поле ВЛАДЕЛЬЦА, куда смотрит ключ, когда хочется ссылаться не на первичный ключ: gorm:"foreignKey:UserNumber;references:MemberNumber" — карточка хранит строку из member_number пользователя. Проверить направление помогает вопрос «чья это колонка»: foreignKey всегда описывает колонку, которая физически создаётся, — а на has-стороне она в соседней таблице. Третий знакомый тег — constraint: OnUpdate и OnDelete задают поведение констрейнта при миграции; дока для has one показывает gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" прямо на поле ассоциации. У has-стороны есть и отдельный бонус — самосвязи: дока показывает has one с ManagerID uint и Manager User и has many с Team []User, где ключом служит то же ManagerID; иерархия сотрудников на одной таблице собирается двумя объявлениями. Регистр тега не важен — TagSettings нормализуется к верхнему регистру, поэтому дока спокойно пишет foreignkey в примере самосвязи.

Своё имя ключа, ссылка на MemberNumber и самосвязь

GO
// Вариант 1: свой ключ у дочерней модели - foreignKey называет ПОЛЕ СОСЕДА
type User struct {
        gorm.Model
        CreditCards []CreditCard `gorm:"foreignKey:UserRefer"`
}

type CreditCard struct {
        gorm.Model
        Number    string
        UserRefer uint // колонка user_refer вместо user_id
}

// Вариант 2: ссылка на непервичный столбец владельца - references называет СВОЁ поле
type Member struct {
        gorm.Model
        MemberNumber string // уникальный бизнес-номер вместо id
        Cards        []Card `gorm:"foreignKey:UserNumber;references:MemberNumber"`
}

type Card struct {
        gorm.Model
        Number     string
        UserNumber string // строковый ключ: смотрит на member_number
}

// Вариант 3: самосвязь из доки - менеджер и его команда, оба поля на одной модели
type Staff struct {
        gorm.Model
        ManagerID *uint
        Manager   *Staff                                              // has one на себя
        Team      []Staff `gorm:"foreignkey:ManagerID"` // has many на себя
}

Три сценария из доки, собранные в один блок. User.CreditCards с foreignKey:UserRefer связывается через колонку user_refer — уберите тег, и парсер будет искать UserID, не найдёт и ответит invalid field found. Member с парой foreignKey:UserNumber;references:MemberNumber хранит строковый ключ и указывает на непервичный, но уникальный столбец владельца. Staff с ManagerID *uint — самосвязь: указатель делает ключ nullable, что и нужно для сотрудника без менеджера; один и тот же ManagerID обслуживает оба объявления — и Manager, и Team.

Что уедет в базу первым?

Прочитайте код и выберите, что он выведет:

user := User{
        Name:    "Анна",
        Profile: Profile{Avatar: "anna.png", Bio: "Про Go и не только"},
}
db.Create(&user)

fmt.Println(user.ID, user.Profile.ID, user.Profile.UserID)

Сохранение после владельца: gorm:save_after_associations#

Регистрация цепочки Create в callbacks/callbacks.go знакома по прошлым урокам, но теперь важен другой её кусок: gorm:create, затем gorm:save_after_associations, затем gorm:after_create. Колбэк SaveAfterAssociations (callbacks/associations.go) обходит Relationships.HasOne и Relationships.HasMany и для каждого заполненного поля делает трёхслойную работу. Первый слой — вписывание ключей: для каждой ссылки с флагом OwnPrimaryKey значение первичного поля владельца читается и записывается в поле-ключ ребёнка — ref.ForeignKey.Set кладёт UserID в каждый элемент среза ещё в памяти, до единого запроса; у полиморфных связей тем же способом проставляется и тип-дискриминатор из PrimaryValue. Второй слой — saveAssociations создаёт свежую сессию Session{NewDB: true} без унаследованных условий и вызывает tx.Create по всем детям разом: срез уезжает одним пакетным INSERT с несколькими VALUES — тот же приём из пятого урока, только колонку ключа GORM добавил за вас. Третий слой — upsert-клоз, и вот здесь has-сторона отличается от belongs to принципиально: для детей собирается список assignmentColumns из полей внешнего ключа, поэтому дефолтный клоз — не DO NOTHING, а ON CONFLICT (id) DO UPDATE SET user_id = excluded.user_id. Смысл: если в срезе оказался ребёнок с готовым первичным ключом, он не игнорируется и не дублируется — он переезжает к новому владельцу. Защита от двойной вставки тех же элементов внутри одного вызова — карта identityMap и checkAssociationsSaved. Тот же колбэк стоит и в цепочке Updates: Updates(&list) с заполненным срезом тоже потянет детей в базу — не забыли Omit, ждите INSERT.

db.Create со срезом детей: след SQL

GO
// Новый список сразу с двумя новыми задачами
list := TodoList{
        Name: "Учёба",
        Items: []TodoItem{
                {Title: "Сдать лабораторную", Status: "todo"},
                {Title: "Купить конспект", Status: "todo"},
        },
}
db.Create(&list)
// BEGIN TRANSACTION;
// INSERT INTO todo_lists (created_at, updated_at, deleted_at, name)
//   VALUES ('...','...',NULL,'Учёба') RETURNING id;  -- id = 2
// INSERT INTO todo_items (created_at, updated_at, deleted_at, title, status, todo_list_id)
//   VALUES ('...','...',NULL,'Сдать лабораторную','todo',2),
//          ('...','...',NULL,'Купить конспект','todo',2) RETURNING id;
// COMMIT;

fmt.Println(list.ID, list.Items[0].ID, list.Items[0].TodoListID) // 2 8 2 - ключи проставлены

// Существующая задача в срезе: переезд через upsert
var old TodoItem
db.First(&old, 4) // «Заплатить за интернет» - пока без списка
moved := TodoList{Name: "Работа", Items: []TodoItem{old}}
db.Create(&moved)
// INSERT INTO todo_lists (...) VALUES ('...','...','Работа') RETURNING id;  -- id = 3
// INSERT INTO todo_items (..., id, todo_list_id) VALUES (..., 4, 3)
//   ON CONFLICT (id) DO UPDATE SET todo_list_id = excluded.todo_list_id;  -- задача 4 теперь в «Работе»

Два сценария одного вызова. Новые дети: один пакетный INSERT, в котором todo_list_id уже заполнен ключом свежевставленного списка, — отдельные INSERT на каждого ребёнка не нужны. Существующий ребёнок с готовым id: конфликт по первичному ключу разрешается DO UPDATE SET только по колонке внешнего ключа — запись пересаживается из одного списка в другой одной командой. Это осознанное поведение GORM, а не побочный эффект; след упрощён, диалектные кавычки опущены.

Проверка: существующий ребёнок в срезе

db.Create(&list) вызван с заполненным Items, у задачи из среза уже есть id = 4 и другой владелец. Что окажется в SQL-следе?

OwnPrimaryKey: что означает флаг у связи#

Внутри GORM каждая связь несёт набор ссылок-референсов — []Reference из schema/relationship.go, и у каждой ссылки есть булев флаг OwnPrimaryKey. Для has-стороны он всегда true: ключ-прототип связи (поле PrimaryKey в Reference) лежит на самой объявляющей модели — «свой первичный ключ», хотя физическая колонка живёт в соседней таблице. Флаг проставляется в guessRelation условием schema == primarySchema при сценарии has; полиморфные и many2many-ссылки помечаются им отдельно. На флаге держатся три механизма, и каждый вы уже видели в деле. Первый — SaveAfterAssociations: ветка if ref.OwnPrimaryKey читает значение ключа владельца и вписывает его в ребёнка (ref.ForeignKey.Set) — без этого флага GORM не знал бы, с какого конца связи брать ключ. Второй — ParseConstraint: при OwnPrimaryKey констрейнт получает Schema = таблицу ребёнка и ReferenceSchema = таблицу владельца, то есть FOREIGN KEY строится на дочерней таблице и ссылается на нашу; имя собирает namer методом RelationshipFKName — fk_таблица-владельца_поле: fk_users_profile, fk_todo_lists_items. Третий — ToQueryConditions: WHERE fk IN (значения наших ключей), на котором стоят Find и Count в Association Mode, каскад DeleteBeforeAssociations в callbacks/delete.go и отвязка в Replace/Delete. Флаг не тег и не настройка — это внутренняя анатомия, но именно она объясняет, почему у has one и has many нет «своей» колонки и почему вся арифметика связи сводится к одному списку ключей владельца.

Association API со срезом: Append, Replace, Delete, Clear#

Точка входа та же, что в одиннадцатом уроке: db.Model(&list).Association("Items") возвращает объект-связь, у которого шесть методов, но на has-стороне семантика другая — добавлять теперь можно по-настоящему. Append для HasMany не заменяет, а добавляет: каждый переданный элемент получает ключ владельца и уезжает INSERT'ом (новый) или переезжает через ON CONFLICT (id) DO UPDATE SET (существующий); опечатка в имени ассоциации по-прежнему даёт unsupported relations — ошибку ErrUnsupportedRelation, а нулевой ключ у владельца при операциях изменения — primary key required (ErrPrimaryKeyRequired). Replace(clear) собирает новый состав среза: переданные дети привязываются к владельцу, а все прочие дети с этим ключом отвязываются — UPDATE ... SET todo_list_id = NULL WHERE todo_list_id = 1 AND id NOT IN (переданные). Delete(&item) разрывает связь точечно: UPDATE ... SET todo_list_id = NULL WHERE todo_list_id = 1 AND id = 4 — через UpdateColumns, без хуков и без updated_at, восьмой урок; записи остаётся жить. Clear() — это Replace без аргументов: всех детей с ключом владельца отвязывает одним UPDATE, а с Unscoped() вместо UpdateColumns уходит DELETE — мягкое удаление детей, а с db.Unscoped() физическое. Find(&items) читает детей одним запросом WHERE todo_list_id = 1, Count() считает их; после Delete и Clear GORM подчищает срез в памяти — удалённые элементы пропадают из list.Items. Работает и пакетный режим: db.Model(&lists).Association("Items") на срезе владельцев, только число аргументов обязано совпасть с длиной среза, иначе invalid association values, length doesn't match.

Association API в действии

GO
var list TodoList
db.First(&list, 1) // «Дом», задачи 6 и 7 уже с todo_list_id = 1

// Find: дети одним запросом по ключу владельца
var items []TodoItem
db.Model(&list).Association("Items").Find(&items)
// SELECT * FROM todo_items WHERE todo_list_id = 1 AND deleted_at IS NULL
fmt.Println(len(items)) // 2

// Append новой задачи: INSERT с вписанным ключом владельца
db.Model(&list).Association("Items").Append(&TodoItem{Title: "Разобрать гараж", Status: "todo"})
// INSERT INTO todo_items (created_at, updated_at, deleted_at, title, status, todo_list_id)
//   VALUES ('...','...',NULL,'Разобрать гараж','todo',1) RETURNING id;

// Append существующей задачи 4: переезд через upsert
var old TodoItem
db.First(&old, 4) // «Заплатить за интернет» - списка нет
db.Model(&list).Association("Items").Append(&old)
// INSERT INTO todo_items (..., id, todo_list_id) VALUES (..., 4, 1)
//   ON CONFLICT (id) DO UPDATE SET todo_list_id = excluded.todo_list_id

fmt.Println(db.Model(&list).Association("Items").Count()) // 4

// Delete: разорвать связь одной задачи - ключ обнулится, задача останется
db.Model(&list).Association("Items").Delete(&old)
// UPDATE todo_items SET todo_list_id = NULL WHERE todo_list_id = 1 AND todo_items.id = 4

// Clear: отвязать всех детей владельца разом
db.Model(&list).Association("Items").Clear()
// UPDATE todo_items SET todo_list_id = NULL WHERE todo_list_id = 1

Пять операций одного объекта. Find строит условие по ключу владельца и уважает автофильтр мягкого удаления. Append вставляет нового ребёнка и пересаживает существующего — обратите внимание на RETURNING id в первом случае. Delete обнуляет ключ через UpdateColumns и выкидывает элемент из среза list.Items в памяти; Clear делает то же без аргументов. После Clear у списка в памяти пустой срез, а в базе — задачи 6, 7 и «Разобрать гараж» с todo_list_id = NULL.

Проверка: Delete у has many

Что сделает db.Model(&list).Association("Items").Delete(&task) с задачей, привязанной к списку?

Каскады удаления: GORM-уровень и уровень СУБД#

Девятый урок обещал: связанные записи GORM сам не трогает, каскад появляется только при явном Select, и колбэк gorm:delete_before_associations смотрит ровно туда. Обещание выполняется. Цепочка Delete знакома: gorm:begin_transaction, gorm:before_delete, gorm:delete_before_associations, gorm:delete, gorm:after_delete, gorm:commit_or_rollback_transaction. Колбэк DeleteBeforeAssociations (callbacks/delete.go) начинается с проверки SelectAndOmitColumns: если явных Select не было — restricted равно false и колбэк выходит, не сделав ничего. Со Select("Items") он находит связь HasMany, строит условие через ToQueryConditions — WHERE todo_list_id IN (ключи владельца) — и удаляет детей отдельным DELETE в свежей сессии: мягким по умолчанию, физическим с Unscoped, в той же транзакции, что и владелец. Пустой ключ владельца каскад пропускает — условие без значений сработало бы по всей таблице, а это не то, что вы хотели. Select(clause.Associations) включает каскад по всем связям, Omit(clause.Associations) отключает его для конкретного вызова; работает и вложенность — Select("Items.Comments") снесёт и комментарии этих задач. Второй уровень — констрейнт СУБД: constraint-тег на has-стороне строит ALTER TABLE на ДОЧЕРНЕЙ таблице, и ON DELETE CASCADE снесёт записи физически даже из psql, мимо GORM. Разница принципиальная: GORM-каскад уважает мягкое удаление и хуки, СУБД-каскад — нет; смешивать их стоит осознанно, иначе мягко удалённый владелец из девятого урока и физически снесённые дети разъедутся.

Каскад в действии: след SQL

GO
// Мягкий каскад: свежий список «Ремонт» с двумя задачами
fresh := TodoList{Name: "Ремонт", Items: []TodoItem{{Title: "Замесить раствор"}, {Title: "Поклеить обои"}}}
db.Create(&fresh) // id = 4
db.Select("Items").Delete(&fresh)
// BEGIN TRANSACTION;
// UPDATE todo_items SET deleted_at = '...' WHERE todo_list_id = 4 AND todo_items.deleted_at IS NULL;
// UPDATE todo_lists SET deleted_at = '...' WHERE id = 4;
// COMMIT;

// Физический каскад: список «Черновики» - насовсем
scratch := TodoList{Name: "Черновики", Items: []TodoItem{{Title: "Прикинуть план"}}}
db.Create(&scratch) // id = 5
db.Unscoped().Select(clause.Associations).Delete(&scratch)
// DELETE FROM todo_items WHERE todo_list_id = 5;   -- дети снесены физически
// DELETE FROM todo_lists WHERE id = 5;             -- затем владелец

// Вложенный каскад: задачи списка «Учёба» и их комментарии
db.Select("Items.Comments").Delete(&study)
// UPDATE comments SET deleted_at ... WHERE todo_item_id IN (8, 9)  -- комментарии задач
// UPDATE todo_items SET deleted_at ... WHERE todo_list_id = 2      -- задачи
// UPDATE todo_lists SET deleted_at ... WHERE id = 2                -- владелец

// Отключить каскад на один вызов - дети останутся
keep := TodoList{Name: "Впрок", Items: []TodoItem{{Title: "Не забыть про шурупы"}}}
db.Create(&keep) // id = 6
db.Omit(clause.Associations).Delete(&keep)
// UPDATE todo_lists SET deleted_at = '...' WHERE id = 6;  -- задача осталась живой

Порядок всегда один: дети до владельца, всё внутри одной транзакции. Мягкий каскад превращает DELETE в UPDATE deleted_at и добавляет автофильтр к WHERE. Unscoped переключает оба шага на физическое удаление. Omit(clause.Associations) — зеркальный выключатель. Вложенная форма работает по той же схеме: префикс Items. пробрасывается в каскад детей, и комментарии уходят раньше задач, задачи — раньше списка.

Что случится с задачами при Delete без Select?

Прочитайте код и выберите, что он выведет:

var plan TodoList
db.First(&plan, 7) // «Отпуск», задачи 11 и 12 с todo_list_id = 7

db.Delete(&plan)

Полиморфные связи: polymorphic и его границы#

Полиморфная связь — случай, когда одна дочерняя таблица обслуживает несколько владельцев разом: вложения и у списков, и у задач. У дочерней модели появляются два поля: ключ владельца и его тип-дискриминатор. Дока описывает механику честно и коротко: GORM поддерживает полиморфизм для has one и has many — в поле типа пишется имя таблицы владельца, в поле ключа его первичный ключ. Тег polymorphic задаёт префикс: gorm:"polymorphic:Owner;" требует у ребёнка полей OwnerID и OwnerType (колонки owner_id и owner_type); значение типа по умолчанию — имя ТАБЛИЦЫ владельца: дока показывает игрушечный пример Dog с Toys, где в owner_type уезжает «dogs». Своя константа — тег polymorphicValue (дока ставит «master»), свои имена полей — теги polymorphicType и polymorphicId. Дальше всё знакомое: SaveAfterAssociations вписывает и ключ, и тип в каждый элемент до вставки; чтение через Association("Attachments") добавляет WHERE owner_type = 'todo_items' автоматически — условие строится из PrimaryValue связи; каскад Select("Attachments") удаляет только вложения этого владельца. А теперь границы, о которых дока говорит прямо: полиморфными бывают только has one и has many. Обратного belongs to-поля у Attachment не объявишь — владелец «зашит» в строку owner_type, и у дочерней модели нет типизированного поля Task или TodoList; искать «все вложения задачи» можно со стороны владельца, а many2many полиморфным не бывает вовсе. Тип-дискриминатор — строка с именем таблицы из namer третьего урока: переименование таблицы меняет и данные в owner_type.

Полиморфный has many: вложения для списков и задач

GO
// Вложение: одно хранилище для файлов списков и задач
type Attachment struct {
        gorm.Model
        Path      string
        OwnerID   uint   // колонка owner_id: ключ владельца
        OwnerType string // колонка owner_type: имя таблицы владельца
}

// Один тег polymorphic на обеих has-сторонах
type TodoList struct {
        gorm.Model
        Name        string
        Attachments []Attachment `gorm:"polymorphic:Owner;"`
}

type TodoItem struct {
        gorm.Model
        Title       string
        Status      string
        Attachments []Attachment `gorm:"polymorphic:Owner;"`
}

task := TodoItem{
        Title:       "Отсканировать чек",
        Status:      "todo",
        Attachments: []Attachment{{Path: "/files/chek.pdf"}},
}
db.Create(&task)
// INSERT INTO todo_items (...) VALUES (..., 'Отсканировать чек', 'todo') RETURNING id;  -- id = 10
// INSERT INTO attachments (created_at, updated_at, deleted_at, path, owner_id, owner_type)
//   VALUES ('...','...',NULL,'/files/chek.pdf',10,'todo_items')

// Чтение со стороны владельца: условие по типу добавляется само
var docs []Attachment
db.Model(&task).Association("Attachments").Find(&docs)
// SELECT * FROM attachments WHERE owner_id = 10 AND owner_type = 'todo_items' AND deleted_at IS NULL

Один и тот же тег на списках и задачах делает таблицу attachments общей: в owner_type GORM сам пишет имя таблицы владельца — todo_items для задач, todo_lists для списков, — в owner_id его ключ. Find через Association добавляет условие по типу, поэтому вложения задачи не перепутаются с вложениями списка. Каскад Select("Attachments").Delete(&task) снесёт только строки с owner_type = 'todo_items' и owner_id = 10.

Шпаргалка: has one и has many в GORM#

ЗадачаКодЧто происходит
Объявить has oneProfile Profileполе-структура у владельца; guessHas ищет UserID на Profile
Объявить has manyItems []TodoItemполе-срез у владельца; колонка todo_list_id у детей
Свой ключ ребёнкаgorm:"foreignKey:UserRefer"ключ — поле UserRefer на дочерней модели
Ссылка не на PKgorm:"foreignKey:UserNumber;references:MemberNumber"foreignKey — поле ребёнка, references — поле владельца
Самосвязьgorm:"foreignkey:ManagerID"Manager *Staff и Team []Staff на одной модели
Констрейнтgorm:"constraint:OnDelete:CASCADE"ALTER TABLE на дочерней таблице, имя fk_todo_lists_items
Создать с детьмиdb.Create(&list)INSERT владельца, ключ в детей, один пакетный INSERT
Переезд ребёнкаdb.Create(&list) с готовым id ребёнкаON CONFLICT (id) DO UPDATE SET todo_list_id
Отключить автосохранениеdb.Omit(clause.Associations).Create(&list)дети не вставляются, как и при Updates
Полный upsert детейSession{FullSaveAssociations: true}ON CONFLICT (id) DO UPDATE SET всех колонок ребёнка
Работать со связьюdb.Model(&list).Association("Items")Append, Replace, Delete, Clear, Find, Count
Каскад при удаленииdb.Select("Items").Delete(&list)дети до владельца, мягко, в одной транзакции
Полиморфияgorm:"polymorphic:Owner;"OwnerID + OwnerType = имя таблицы владельца

Заполните пропуски: от тега до каскада

Соберите has-сторону целиком: имена тегов и методов — из этого урока, пять пропусков идут по порядку.

// 1) has many со своим именем ключа: поле на ДОЧЕРНЕЙ модели Cards []Card `gorm:":UserRefer"` // 2) ссылка на непервичный столбец владельца Cards []Card `gorm:"foreignKey:UserNumber;:MemberNumber"` // 3) полиморфный has many: префикс полей OwnerID и OwnerType Attachments []Attachment `gorm:":Owner;"` // 4) каскад при удалении списка db.("Items").Delete(&list) // 5) сохранить список без детей db.Omit(.Associations).Create(&list)

Соберите путь db.Create(&user) с профилем

Расставьте шаги, которые проходит Create с заполненной has one-связью: от транзакции до коммита.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: has one и has many

Термин

has one

нажмите, чтобы перевернуть
Определение

Связь один-к-одному: у владельца поле-структура (User.Profile), ключ UserID живёт на дочерней модели по конвенции «имя типа-владельца + первичное поле». От has many отличается только типом поля — структура вместо среза; в таблице обе связи оставляют одну колонку. Самосвязь: Manager *Staff с ключом ManagerID *uint.

1 / 6

Что дальше по курсу#

Второй взгляд на связь встал: has one и has many описывают колонку ключа с конца владельца — конвенция UserID по типу-владельцу, теги foreignKey и references со сменой направления, дети сохраняются после владельца одним пакетным INSERT с upsert по ключу, Association API умеет добавлять, заменять состав, отвязывать и считать, каскады работают на двух уровнях — мягкий GORM-каскад по Select и физический ON DELETE, а полиморфная связь обслуживает несколько владельцев одной таблицей. Полезно сверить следы со своими запросами: логгер из второго урока напечатает каждый INSERT и UPDATE, а JOIN списка с задачами можно собрать руками сырыми запросами из десятого урока и сравнить с тем, что даёт Association("Items").Find; зеркальные определения обеих сторон одной колонки удобно перечитать в одиннадцатом уроке; карта курса из 25 уроков — на странице курса. Третья связь триады впереди: Тринадцатый урок «Many-to-many» соберёт списки и теги через join-таблицу — внутри неё окажутся два belongs to, а на стороне GORM тег many2many и автоматическая join-модель. Как читать срезы без N+1 — четырнадцатый урок «Preloading и жадная загрузка», сегодня связей касались ровно настолько, насколько нужно для записи. Домашка ниже собирает has-сторону в один скрипт.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • has one и has many — взгляды владельца на связь: поле-структура или срез у владельца, внешний ключ на дочерней модели по конвенции «имя типа-владельца + ID» (UserID у профиля, todo_list_id у задач); ключ ProfileID GORM не найдёт — guessHas ищет UserID, UserId и user_id, иначе invalid field found.
  • foreignKey называет поле ДОЧЕРНЕЙ модели, references — поле ВЛАДЕЛЬЦА (foreign key:UserNumber; references:MemberNumber); constraint-тег задаёт OnDelete/OnUpdate; самосвязи (Manager, Team) работают на одной таблице через один ключ.
  • Сохранение: gorm:save_after_associations после вставки владельца — ключи вписываются в каждого ребёнка, срез уезжает одним пакетным INSERT; дефолтный upsert детей — ON CONFLICT (id) DO UPDATE SET fk, поэтому Create и Append с готовым id ребёнка пересаживают его к новому владельцу; FullSaveAssociations включает DO UPDATE всех колонок; Omit(clause.Associations) отключает.
  • OwnPrimaryKey — флаг у schema.Reference: ключ-прототип на объявляющей модели; констрейнт строится на дочерней таблице с именем fk_таблица-владельца_поле (fk_users_profile, fk_todo_lists_items — и имя меняется, как только объявлен срез Items), Find/Count/каскады считают WHERE fk IN (ключи владельца).
  • Association API со срезом: Append добавляет или пересаживает, Replace меняет состав и отвязывает остальных (NOT IN), Delete обнуляет ключ выбранных через UpdateColumns, Clear отвязывает всех, Unscoped удаляет записи; Find и Count читают одним условием; ошибки unsupported relations и primary key required; пакетный режим требует совпадения длин.
  • Каскады двух уровней: GORM-каскад gorm:delete_before_associations работает только при явном Select("Items") или Select(clause.Associations), мягко, до владельца, в одной транзакции, умеет вложенность Select("Items.Comments"); СУБД-каскад ON DELETE CASCADE из constraint-тега физический и работает мимо GORM; без Select дети остаются с ссылкой на призрака.
  • Полиморфная связь поддержана ровно для has one и has many: polymorphic:Owner → OwnerID + OwnerType, в тип пишется имя таблицы владельца, переопределяется polymorphicValue (плюс polymorphicType/polymorphicId); belongs to и many2many полиморфными не бывают, обратного поля у дочерней модели нет.

Домашнее задание

Разверните канон локально: добавьте модели User с полем Profile и Profile с UserID, запустите AutoMigrate и найдите в базе констрейнты fk_users_profile и fk_todo_lists_items (в psql — d profiles, в SQLite — .schema profiles); сравните имя второго с fk_todo_items_todo_list из одиннадцатого урока и объясните, почему оно сменилось после появления Items. Создайте пользователя с профилем одним Create — по SQL-следу убедитесь, что сначала уехал INSERT users, потом INSERT profiles с user_id, и что user.ID, user.Profile.ID и user.Profile.UserID заполнены. Переименуйте ключ профиля в ProfileID, снимите foreignKey-тег и воспроизведите invalid field found; верните тег с именем foreignKey:ProfileID и проверьте, что связь собралась. Создайте список с двумя новыми задачами и найдите в следе один пакетный INSERT с двумя VALUES; затем добавьте в срез существующую задачу из другого списка с готовым id и найдите в следе ON CONFLICT (id) DO UPDATE SET todo_list_id — проверьте в базе, что задача сменила владельца. Пройдите Association("Items") по кругу: Find с подсчётом запросов, Append новой задачи, Count, Delete с проверкой NULL и живой записи, Clear и пустой срез в памяти. Включите FullSaveAssociations на Updates с непрочитанным ребёнком и объясните, какие колонки он затёр. Проверьте каскады: db.Select("Items").Delete(&list) — сверьте deleted_at у задач и списка; db.Omit(clause.Associations).Delete(&list2) — задачи остались; db.Unscoped().Select(clause.Associations).Delete(&list3) — таблица пуста физически; попробуйте db.Unscoped().Delete(&list) без Select на списке с живыми задачами и разберите foreign key violation от PostgreSQL. Добавьте Attachment с polymorphic:Owner к спискам и задачам, создайте вложения обоим и убедитесь, что в owner_type записаны разные имена таблиц, а Find через Association не перепутал их.

Личные заметкиПусто
Хранятся только в вашем браузере (GORM · Ассоциации: has one и has many — зеркальная сторона связи, каскады и полиморфизм в GORM)

Вопросы и ответы по уроку

Где живёт внешний ключ у has one и has many?

Всегда на дочерней модели: у Profile это UserID по конвенции «имя типа-владельца плюс первичное поле», у TodoItem — TodoListID. Владелец держит только поле-ассоциацию: структуру для has one, срез для has many. В базе обе связи выглядят одинаково — колонка ключа в дочерней таблице; разница лишь в том, сколько строк может носить один ключ и как называется поле в Go.

Чем has one отличается от belongs to?

Стороной ключа. has one: у владельца поле-сосед, ключ у соседа — User.Profile, а UserID хранится в Profile. belongs to: ключ у объявляющей модели — Profile.User с полем UserID у самого Profile. Одна колонка user_id описывается двумя объявлениями, и выбирать надо то, у какой модели должны жить поля и методы. Если объявлены обе стороны, belongs to уступает констрейнт has-стороне — он построится один раз.

Как GORM удаляет связанные записи?

Только по явному указанию: Select("Items") или Select(clause.Associations) перед Delete запускает колбэк gorm:delete_before_associations — дети удаляются до владельца, мягко по умолчанию и физически с Unscoped, в одной транзакции с владельцем. Без Select дети остаются, даже если владелец мягко удалён. Отдельный механизм — ON DELETE CASCADE/SET NULL из constraint-тега: физический констрейнт на дочерней таблице, который работает и мимо GORM.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

sqlc25 уроков~12 чпрактика25 уроков уже доступны

sqlc: типобезопасный код из SQL-запросов

Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.

  • Настраивать sqlc.yaml для Postgres/MySQL/SQLite
  • Писать аннотированные запросы с типизированными параметрами
  • Работать с NULL, массивами и JSON-колонками
  • Организовывать транзакции через WithTx