Содержание урока · 13 разделов
- gorm has many и has one: связь 1-n gorm с другого конца
- Конвенция с has-стороны: UserID, а не ProfileID
- foreignKey и references: направление тегов меняется
- Сохранение после владельца: gorm:save_after_associations
- OwnPrimaryKey: что означает флаг у связи
- Association API со срезом: Append, Replace, Delete, Clear
- Каскады удаления: GORM-уровень и уровень СУБД
- Полиморфные связи: polymorphic и его границы
- Шпаргалка: has one и has many в GORM
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 в действии
GOvar 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.
Первая: конвенция зовёт ключ по ТИПУ владельца — UserID, а не ProfileID; «аналогия с TodoItemID» из belongs to здесь проваливает связь с invalid field found. Вторая: Create и Append с готовым id ребёнка ПЕРЕСАЖИВАЮТ запись к новому владельцу через ON CONFLICT (id) DO UPDATE SET — рефлекс DO NOTHING из belongs to тут не работает, проверяйте, чей ребёнок в срезе. Третья: FullSaveAssociations на непрочитанных детях затрёт их колонки нулями — UpdateAll не различает «не читал» и «очистил». Четвёртая: Delete владельца без Select оставляет детей-сирот со ссылками на призрака, а физическое удаление без ON DELETE упирается в NO ACTION констрейнта — каскад всегда должен быть осознанным Select. Пятая: полиморфный owner_type — строка с именем ТАБЛИЦЫ (todo_items), а не Go-типа (TodoItem); в сырых запросах из десятого урока сравнивайте с тем, что вернул бы namer.
Шпаргалка: has one и has many в GORM#
| Задача | Код | Что происходит |
|---|---|---|
| Объявить has one | Profile Profile | поле-структура у владельца; guessHas ищет UserID на Profile |
| Объявить has many | Items []TodoItem | поле-срез у владельца; колонка todo_list_id у детей |
| Свой ключ ребёнка | gorm:"foreignKey:UserRefer" | ключ — поле UserRefer на дочерней модели |
| Ссылка не на PK | gorm:"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-сторону целиком: имена тегов и методов — из этого урока, пять пропусков идут по порядку.
Соберите путь db.Create(&user) с профилем
Расставьте шаги, которые проходит Create с заполненной has one-связью: от транзакции до коммита.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: has one и has many
Что дальше по курсу#
Второй взгляд на связь встал: 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 не перепутал их.
Личные заметкиПусто
Вопросы и ответы по уроку
Где живёт внешний ключ у 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.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
sqlc: типобезопасный код из SQL-запросов
Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.
- Настраивать sqlc.yaml для Postgres/MySQL/SQLite
- Писать аннотированные запросы с типизированными параметрами
- Работать с NULL, массивами и JSON-колонками
- Организовывать транзакции через WithTx