Содержание урока · 14 разделов
- gorm belongs to: связь многие-к-одному, где живёт внешний ключ
- Конвенция внешнего ключа: TodoItemID, todo_item_id и invalid field found
- foreignKey-тег и references: когда имена свои
- Автосоздание: belongs to сохраняется до главной вставки
- Select, Omit и FullSaveAssociations: ручной контроль автосоздания
- Association API: один метод — шесть операций
- Миграция: какой констрейнт создаёт AutoMigrate
- belongs to и его зеркало: взгляд с другой стороны
- Шпаргалка: belongs to в GORM
- Preload: как читать belongs to (тизер)
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gorm belongs to: связь многие-к-одному, где живёт внешний ключ#
Дока определяет belongs to так: связь устанавливает соединение «один-к-одному» с другой моделью, при котором каждый экземпляр объявляющей модели belongs to — принадлежит — одному экземпляру другой. Формулировка хитрая: по одному владельцу у каждой записи, зато записей с одним владельцем может быть сколько угодно — это и есть многие-к-одному. Канонические примеры из жизни проекта: пользователь принадлежит компании, комментарий принадлежит задаче, задача принадлежит списку. Ключевое правило грамматики belongs to: внешний ключ живёт на объявляющей модели — той, что «принадлежит». Comment хранит TodoItemID и указывает на todo_items; таблице todo_items, чтобы связь существовала, не нужно ничего. Отсюда и направление стрелки в названии: запись с ключом принадлежит записи, на которую ключ смотрит. Связи gorm устроены симметрично: одна и та же колонка todo_item_id описывается с двух сторон — Comment belongs to TodoItem и TodoItem has many Comments, но это взгляды, а не две связи; колонка в базе одна. В этом уроке мы смотрим глазами Comment, в двенадцатом — глазами TodoItem. belongs to — первая из триады связей GORM (belongs to, has one/has many, many-to-many) и фундамент остальных: даже join-таблица many-to-many из тринадцатого урока внутри состоит из двух belongs to.
Три модели: список, задача, комментарий
GO// Список задач: владелец верхнего уровня
type TodoList struct {
gorm.Model
Name string // «Работа», «Дом»
}
// Задача: принадлежит списку - внешний ключ на стороне задачи
type TodoItem struct {
gorm.Model
Title string
Status string
TodoListID uint // внешний ключ: колонка todo_list_id
TodoList TodoList // ассоциация: список, в котором лежит задача
}
// Комментарий: принадлежит задаче - главный пример урока
type Comment struct {
gorm.Model
Text string // текст комментария
TodoItemID uint // внешний ключ по конвенции: колонка todo_item_id
TodoItem TodoItem // ассоциация: задача, к которой оставлен комментарий
}
db.AutoMigrate(&TodoList{}, &TodoItem{}, &Comment{})
// CREATE TABLE todo_lists (id integer PRIMARY KEY, created_at, updated_at, deleted_at, name, ...)
// CREATE TABLE todo_items (..., todo_list_id integer, CONSTRAINT fk_todo_items_todo_list FOREIGN KEY (todo_list_id) REFERENCES todo_lists(id))
// CREATE TABLE comments (..., todo_item_id integer, CONSTRAINT fk_comments_todo_item FOREIGN KEY (todo_item_id) REFERENCES todo_items(id))У каждой модели ровно два новых поля: внешний ключ (TodoListID, TodoItemID) и поле-ассоциация с типом соседа (TodoList, TodoItem). Поле-ассоциация — это объявление связи: GORM парсит его при первом обращении, строит Relationship и дальше знает, кто на кого смотрит. Констрейнты fk_todo_items_todo_list и fk_comments_todo_item в следе — работа AutoMigrate, о ней ближе к концу урока; поля gorm.Model из третьего урока дают id, метки времени и мягкое удаление, часть колонок в следе опущена для краткости.
Конвенция внешнего ключа: TodoItemID, todo_item_id и invalid field found#
Как GORM понимает, что Comment.TodoItem — это belongs to? Разбор связи живёт в schema/relationship.go: у поля-ассоциации читаются теги, а дальше работает угадыватель guessRelation. Для структурного поля сначала пробуется сценарий has: GORM ищет на соседней модели ключ с именем объявляющей (CommentID) — у задачи такого нет. Тогда включается guessBelongs: имя ищется на объявляющей модели по формуле «имя поля ассоциации плюс имя первичного поля» — TodoItem плюс ID даёт TodoItemID. Дока формулирует ту же конвенцию со стороны типа: внешний ключ по умолчанию — имя типа-владельца плюс имя первичного поля (CompanyID у User с полем Company); в каноническом стиле поле ассоциации называют как тип, поэтому обе формулы дают одно имя. Перебор щедрый: ищутся TodoItemID, TodoItemId и колонка todo_item_id, причём двумя словарями — по Go-имени (LookUpFieldByBindName) и по имени поля или колонки (LookUpField, как LookUpField из десятого урока). Значит, подойдёт и поле с column-тегом todo_item_id под любым Go-именем. Не нашлось ничего — перебор исчерпан и парсер возвращает ту самую ошибку, которую слышал каждый: invalid field found for struct, define a valid foreign key for relations or implement the Valuer/Scanner interface. Она значит ровно одно: «я вижу поле-ассоциацию, но не вижу внешнего ключа» — и лечится либо переименованием поля, либо тегом foreignKey из следующего раздела.
Проверка: какую колонку ищет GORM
В модели Comment объявлено поле TodoItem TodoItem, отдельное поле TodoItemID вы забыли. Что сделает GORM при первом обращении к модели?
foreignKey-тег и references: когда имена свои#
Конвенция — дефолт, а не закон. Первый тег — foreignKey: он называется именем ПОЛЯ внешнего ключа на объявляющей модели, когда поле названо не по конвенции. Вешается тег на поле-ассоциацию, и это частая путаница новичков: gorm:"foreignKey:ItemRef" стоит у поля TodoItem, а не у поля ItemRef. Второй тег — references: он называет поле модели-владельца, куда смотрит ключ, когда хочется ссылаться не на первичный ключ, а на другой уникальный столбец — например, на slug задачи. По умолчанию references равен первичному ключу, и в этом смысле belongs to всегда ссылается «на ID», если не сказано иное. У пары тегов есть и разгадка из доки: если имя внешнего ключа уже существует в типе-владельце, GORM обычно решает, что перед ним has one, — в спорных случаях явно указывайте references, чтобы belongs to не был перепутан. Третий тег — constraint: он не про имена, а про поведение констрейнта при миграции (OnUpdate, OnDelete) или отключает его совсем значением «-»; разберём его в разделе про AutoMigrate. Важно другое: все три тега живут на поле-ассоциации — одна точка настройки на всю связь.
Своё имя ключа и ссылка на slug
GO// Вариант 1: внешний ключ назван по-своему - foreignKey указывает поле
type Note struct {
gorm.Model
Text string
ItemRef uint // колонка item_ref вместо todo_item_id
TodoItem TodoItem `gorm:"foreignKey:ItemRef"` // тег - на поле ассоциации
}
// Вариант 2: ссылка на непервичный столбец - references называет поле владельца
type Link struct {
gorm.Model
Title string
TodoItemID string // строковый ключ: поедет в todo_items.slug
TodoItem TodoItem `gorm:"references:Slug"`
}
// Соседнее поле владельца, на которое смотрит references
type TodoItem struct {
gorm.Model
Title string
Slug string `gorm:"size:64;uniqueIndex"` // ссылаться можно на уникальный столбец
}
// Вариант 3: поведение констрейнта при миграции (данные - дальше в уроке)
type Comment struct {
gorm.Model
Text string
TodoItemID uint
TodoItem TodoItem `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
}Три отдельные модели — три сценария из доки. Note с foreignKey:ItemRef связывается через колонку item_ref; уберите тег — парсер ответит invalid field found, потому что TodoItemID на Note нет. Link с references:Slug хранит строковый ключ и указывает на уникальный slug задачи — GORM подставит тип поля-владельца в References. Comment показывает синтаксис constraint: OnUpdate и OnDelete перечисляются в одном теге через запятую, точка с запятой в конце — как в доке.
Что случится при First без тега?
Прочитайте код и выберите, что он выведет:
type Comment struct {
gorm.Model
Text string
ItemRef uint // внешнего ключа по конвенции нет
TodoItem TodoItem // тега foreignKey нет
}
var c Comment
db.First(&c, 1)Автосоздание: belongs to сохраняется до главной вставки#
Дока обещает: GORM автоматически сохраняет ассоциации и их ссылки при создании и обновлении записей, используя технику upsert. Механика — в цепочке колбэков Create, знакомой по пятому и девятому урокам: gorm:begin_transaction, gorm:before_create, gorm:save_before_associations, gorm:create, gorm:save_after_associations, gorm:after_create, gorm:commit_or_rollback_transaction (регистрация — в callbacks/callbacks.go). Названия говорят сами за себя: belongs to сохраняется ДО главной вставки — колбэк SaveBeforeAssociations (файл callbacks/associations.go) обходит Relationships.BelongsTo и для каждого заполненного поля-ассоциации делает свою работу. Она трёхслойная. Первый слой: saveAssociations создаёт свежую сессию Session с NewDB: true — без унаследованных условий цепочки — и вставляет связанную запись обычным Create с upsert-клозом: по умолчанию ON CONFLICT DO NOTHING, поэтому существующая запись с заполненным ключом не дублируется, а новая честно вставится. Второй слой — setupReferences: первичный ключ свежевставленного (или существующего) соседа читается и записывается в поле внешнего ключа объявляющей модели — ref.ForeignKey.Set кладёт значение в Comment.TodoItemID. Третий слой — собственно gorm:create: главная вставка уже с заполненным todo_item_id. Всё в одной транзакции: либо появится и задача, и комментарий, либо ничего. Сущности has one, has many и many2many ждут зеркального колбэка SaveAfterAssociations после главной вставки — им ключ владельца нужен раньше, чем он появится; belongs to повезло больше: его ключ известен после вставки соседа, поэтому он идёт первым.
db.Create с заполненным TodoItem: след SQL
GO// Автосоздание: комментарий с НОВОЙ задачей - GORM вставит обе записи и свяжет
comment := Comment{
Text: "Оплатил вчера, ждать чек",
TodoItem: TodoItem{
Title: "Оплатить хостинг", // задача 6: первичный ключ нулевой
Status: "todo",
},
}
db.Create(&comment)
// BEGIN TRANSACTION;
// INSERT INTO todo_items (created_at, updated_at, deleted_at, title, status)
// VALUES ('...','...',NULL,'Оплатить хостинг','todo') RETURNING id; -- id = 6
// INSERT INTO comments (created_at, updated_at, deleted_at, text, todo_item_id)
// VALUES ('...','...',NULL,'Оплатил вчера, ждать чек',6) RETURNING id;
// COMMIT;
fmt.Println(comment.ID, comment.TodoItemID, comment.TodoItem.ID) // 4 6 6 - ключи проставлены в структуры
// Существующая задача: INSERT всё равно уедет, но конфликт по ключу сработает вхолостую
var task TodoItem
db.First(&task, 4) // «Заплатить за интернет»
reply := Comment{Text: "Напомнить за день до срока", TodoItem: task}
db.Create(&reply)
// INSERT INTO todo_items (...) VALUES (..., id = 4) ON CONFLICT DO NOTHING -- задача уже в базе
// INSERT INTO comments (created_at, updated_at, deleted_at, text, todo_item_id)
// VALUES ('...','...',NULL,'Напомнить за день до срока',4) RETURNING id;Два сценария одного вызова. С новой задачей: сначала INSERT в todo_items, RETURNING (или LastInsertId в SQLite — из пятого урока) возвращает сгенерированный ключ, setupReferences вписывает его в comment.TodoItemID, затем INSERT комментария. С существующей задачей: INSERT в todo_items с заполненным id всё равно отправляется — ON CONFLICT DO NOTHING превращает его в вежливый холостой ход, дубль не создаётся, ключ известен заранее. След упрощён; диалектные кавычки и полный список колонок опущены.
Проверка: порядок вставок при автосоздании
db.Create(&comment) вызван с заполненным полем TodoItem, у задачи первичный ключ нулевой. Какой INSERT уедет в базу первым?
Select, Omit и FullSaveAssociations: ручной контроль автосоздания#
Автосохранение — дефолт, и его можно направлять теми же Select и Omit из пятого урока. Omit("TodoItem") перед Create или Updates пропускает конкретную ассоциацию: SaveBeforeAssociations проверяет список колонок и делает continue — задача не вставляется и ключ не пишется. Omit(clause.Associations) — константа GORM — пропускает все ассоциации разом: правильный выбор, когда заполненные поля-ассоциации приехали из JSON и в базу их тащить нельзя. Select("TodoItem") наоборот сохранит только ассоциацию (и внешние ключи — они добавляются к списку всегда), а точечные поля настраиваются через точку: Select("TodoItem.Title") создаст задачу, заполнив у неё один заголовок. Управляет техникой upsert настройка FullSaveAssociations: без неё конфликт по ключу разрешается молча (DO NOTHING — «ссылка главное, сосед не трогается»), с ней — полный update: ON CONFLICT (id) DO UPDATE SET перечисляет ВСЕ колонки ассоциированной записи, и её состояние в базе дословно совпадает со структурой. Дока формулирует это как гарантию целостности состояния модели; практика добавляет предупреждение: FullSaveAssociations на непрочитанной ассоциации затрёт живую запись нулями — поля-то в структуре пустые. Тот же механизм работает и в цепочке Updates и Save: там стоит тот же gorm:save_before_associations, поэтому Updates(&comment) с заполненным TodoItem тоже потянет задачу в базу — не забыли Omit, ждите INSERT.
Что окажется в базе после Omit?
Прочитайте код и выберите, что он выведет:
comment := Comment{
Text: "Не забыть про квитанцию",
TodoItem: TodoItem{Title: "Оплатить интернет", Status: "todo"},
}
db.Omit("TodoItem").Create(&comment)
fmt.Println(comment.ID, comment.TodoItemID, comment.TodoItem.ID)Association API: один метод — шесть операций#
Когда записи уже живут в базе, для точечных операций со связью у GORM есть Association Mode: db.Model(&comment).Association("TodoItem") возвращает объект-связь, у которого шесть методов. Точкой входа важно ничего не испортить: Model получает модель с заполненным первичным ключом, Association по имени поля находит Relationship — опечатка в имени даст unsupported relations (ошибка ErrUnsupportedRelation из errors.go), а нулевой ключ у source при операциях изменения — primary key required. Find(&item) достаёт связанную запись одним запросом по внешнему ключу — ручная альтернатива Preload, о нём ниже. Append для belongs to — не добавление, а замена: у связи максимум один владелец, поэтому внутри Append просто зовёт Replace. Replace(&item) вставляет переданную запись, если её ключ нулевой, и перезаписывает ссылку: внутри это Updates по объявляющей модели с Select из полей ассоциации — по SQL это UPDATE comments SET todo_item_id = ?, updated_at = ? WHERE id = ?. Delete(&item) и Clear() разрывают связь, не трогая саму запись: внешний ключ обнуляется UpdateColumns — тот самый метод из восьмого урока без хуков и без updated_at; Clear — это Replace без аргументов. Unscoped меняет поведение удаления записей ассоциации: Unscoped().Clear() мягко удалит связанную запись (проставит deleted_at), а db.Unscoped().Model(&comment).Association("TodoItem").Unscoped().Clear() — физически. Count() вернёт 0 или 1 — у belongs to не бывает больше. Срез тоже легален: db.Model(&comments).Association("TodoItem") работает пачкой — пакетный режим доки, у Replace и Append при этом число аргументов обязано совпасть с длиной среза, иначе invalid association values, length doesn't match.
Association API в действии
GOvar c Comment
db.First(&c, 3) // «Сосед попросил полить и его» - связана с задачей 2 «Полить цветы»
// Find: связанная задача одним запросом по внешнему ключу
var linked TodoItem
db.Model(&c).Association("TodoItem").Find(&linked)
// SELECT * FROM todo_items WHERE todo_items.id = 2
fmt.Println(linked.Title) // Полить цветы
// Append для belongs to = Replace: перепривязать комментарий к задаче 4
var target TodoItem
db.First(&target, 4) // «Заплатить за интернет»
db.Model(&c).Association("TodoItem").Append(&target)
// INSERT INTO todo_items (...) ON CONFLICT DO NOTHING -- задача уже в базе
// UPDATE comments SET todo_item_id = 4, updated_at = '...' WHERE id = 3
// Count: связь одна - счётчик вернёт 0 или 1
fmt.Println(db.Model(&c).Association("TodoItem").Count()) // 1
// Delete: разорвать связь - внешнему ключу станет NULL, задача жива
db.Model(&c).Association("TodoItem").Delete(&target)
// UPDATE comments SET todo_item_id = NULL WHERE id = 3 AND todo_item_id = 4
fmt.Println(c.TodoItemID) // 0 - поле в структуре тоже обнуленоЧетыре операции одного объекта. Find строит условие по внешнему ключу и читает соседа. Append переигрывается в Replace: INSERT задачи уходит с ON CONFLICT DO NOTHING даже для существующей записи — это цена универсального upsert, затем UPDATE переписывает todo_item_id. Delete обнуляет ключ через UpdateColumns (без updated_at и хуков — восьмой урок) и подчищает поле в структуре. Clear сделал бы то же без аргументов: UPDATE comments SET todo_item_id = NULL WHERE id = 3.
Проверка: Append у belongs to
У комментария уже есть связанная задача. Что сделает db.Model(&comment).Association("TodoItem").Append(&anotherItem)?
Миграция: какой констрейнт создаёт AutoMigrate#
Поля-ассоциации меняют и поведение миграций из четвёртого урока: AutoMigrate создаёт не только таблицы и индексы, но и внешние ключи. Перед созданием каждой связи GORM вызывает её ParseConstraint (schema/relationship.go): тег constraint разбирается на настройки — «-» возвращает nil и констрейнта не будет вовсе; имя берётся из тега до первой запятой, а если его не указали — генерирует namer методом RelationshipFKName: fk_ + таблица объявляющей модели + имя поля ассоциации в snake_case. Для Comment.TodoItem это fk_comments_todo_item; для TodoList из начала урока — fk_todo_items_todo_list. Собирается SQL в Build по шаблону CONSTRAINT ? FOREIGN KEY ? REFERENCES ?? плюс ON DELETE и ON UPDATE, если заданы: по умолчанию оба пустые, и действует поведение СУБД — NO ACTION у PostgreSQL, то есть удалить задачу с живыми комментариями база не даст. AutoMigrate добавляет констрейнт отдельным шагом — ALTER TABLE comments ADD CONSTRAINT fk_comments_todo_item FOREIGN KEY (todo_item_id) REFERENCES todo_items (id) — и только если !DisableForeignKeyConstraintWhenMigrating, настройка из второго урока полностью выключает это хозяйство (плюс IgnoreRelationshipsWhenMigrating для совсем радикального игнора связей). Две тонкости на прод. Первая: OnDelete:SET NULL требует nullable колонки — todo_item_id должен быть указателем или sql.NullInt64, иначе констрейнт не соберётся; CASCADE при удалении владельца снесёт подчинённые записи. Вторая: если обе стороны связи объявлены (у Comment есть TodoItem, у TodoItem есть Comments), belongs to уступает зеркальной has-стороне — ParseConstraint видит совпадающую пару ключей и возвращает nil, чтобы констрейнт создался один раз.
belongs to и его зеркало: взгляд с другой стороны#
Колонка todo_item_id в базе одна, а описывать её можно с двух концов — и от того, каким объявлением вы воспользуетесь, зависит, у какой модели появятся поля. Comment belongs to TodoItem: ключ и поле-ассоциация у комментария, «у комментария одна задача». TodoItem has many Comments: ключ остаётся у комментария, поле-срез появляется у задачи, «у задачи много комментариев». belongs to — это взгляд записи с ключом, has-связи — взгляд записи, на которую смотрят; в двенадцатом уроке пройдём вторую сторону со всеми её правилами (и каскадами при удалении, девятый урок уже обещал gorm:delete_before_associations). Отсюда практическое правило выбора: если новая сущность «принадлежит» существующей и ключ логично хранить у неё — это belongs to; писать belongs to-объявление с обеих сторон одной колонки не нужно и даже вредно — констрейнт задвоится. И ещё одна вещь, которую belongs to подсвечивает: ленивой загрузки в GORM нет. Прочитали комментарий First-ом — поле TodoItem нулевое, GORM не ходит в базу за полем сам и не вернёт ошибку: связь объявлена, но не наполнена. Наполнять её — работа Preload, Joins или Association из прошлого раздела, и это следующий разговор.
Первая: автосохранение стреляет на каждом Create и Updates, где поле-ассоциация заполнено — INSERT с ON CONFLICT DO NOTHING уедет даже для уже существующей записи; на горячих путях пишите Omit(clause.Associations). Вторая: Append у belongs to не добавляет, а заменяет — рефлекс из has many здесь портит данные. Третья: FullSaveAssociations на непрочитанной ассоциации — почти гарантированная порча: нулевые поля уедут в DO UPDATE SET и затрут живую запись. Четвёртая: нулевой внешний ключ (0) при включённом констрейнте в PostgreSQL нарушит ссылочную целостность — id = 0 не существует; SQLite проверяет FK только с включённой прагмой, и ноль может пройти незамеченным вплоть до продакшена. Правило гигиены одно: заполняйте поле-ассоциацию осознанно — либо настоящий ключ, либо Omit.
Шпаргалка: belongs to в GORM#
| Задача | Код | Что происходит |
|---|---|---|
| Объявить ассоциацию | TodoItem TodoItem | поле-ассоциация: guessBelongs ищет TodoItemID, варианты TodoItemId и todo_item_id |
| Объявить внешний ключ | TodoItemID uint | колонка todo_item_id по naming-стратегии из третьего урока |
| Переименовать ключ | gorm:"foreignKey:ItemRef" | внешний ключ — поле ItemRef (колонка item_ref); тег ставится на поле ассоциации |
| Сменить цель ключа | gorm:"references:Slug" | ключ смотрит на уникальный столбец владельца, а не на первичный ключ |
| Настроить констрейнт | gorm:"constraint:OnDelete:SET NULL" | AutoMigrate допишет ON DELETE SET NULL; значение «-» — констрейнта не будет |
| Создать со связью | db.Create(&comment) | INSERT задачи, ключ в TodoItemID, INSERT комментария — одна транзакция |
| Отказаться от автосохранения | db.Omit(clause.Associations).Create(&c) | ассоциации не сохраняются; Omit("TodoItem") — только эта |
| Полный upsert связи | Session{FullSaveAssociations: true} | ON CONFLICT (id) DO UPDATE SET всех колонок связанной записи |
| Работать со связью | db.Model(&c).Association("TodoItem") | Find, Append (= Replace), Replace, Delete, Clear, Count |
| Прочитать связь | db.Preload("TodoItem").Find(&comments) | второй запрос WHERE id IN (...) — тизер четырнадцатого урока |
Preload: как читать belongs to (тизер)#
Последний штрих — чтение. Запрос db.Find(&comments) вернёт комментарии с заполненными TodoItemID, но поле TodoItem у каждого останется нулевым: SELECT из comments просто не содержит колонок задачи, а ленивой загрузки, как мы выяснили, в GORM нет. Первое лекарство — Preload("TodoItem"): GORM соберёт значения todo_item_id со всех загруженных комментариев и вторым запросом SELECT * FROM todo_items WHERE id IN (...) подтянет их разом, раскладывая по полям через карту идентичности (механика — в callbacks/preload.go). Это жадная загрузка: два запроса вместо одного, зато без дублирования строк и без N+1 — классической болезни, когда ассоциация читается отдельным запросом на каждую запись. Второе лекарство — Joins: LEFT JOIN в один запрос, полезный, когда нужны условия по связанной таблице. Оба приёма, вложенные Preload, условия внутри Preload и честный разбор цены N+1 — тема четырнадцатого урока «Preloading и жадная загрузка»; сегодня достаточно уметь читать belongs to осознанно и знать, что пустое поле-ассоциация после Find — норма, а не баг.
Заполните пропуски: связь от тега до операции
Соберите объявление связи, замену владельца и управление автосохранением: имена методов и тегов — из этого урока.
Соберите путь db.Create(&comment) с заполненной связью
Расставьте шаги, которые проходит автосоздание belongs to: от открытия транзакции до коммита.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: belongs to
Что дальше по курсу#
Первая связь встала: belongs to знает, где живёт внешний ключ, как зовётся по конвенции и что делать, когда имена свои; автосоздание вставляет владельца до главной записи и вписывает его ключ; Association API точечно заменяет, перепривязывает и разрывает связь; AutoMigrate ставит констрейнт с именем fk_comments_todo_item. Полезно сверить следы со своими запросами: логгер из второго урока печатает каждый INSERT и UPDATE, а JOIN комментариев с задачами вручную можно собрать сырыми запросами из десятого урока и сравнить с тем, что сгенерирует Preload. Освежить смежное: хуки и RowsAffected — в восьмом уроке про обновления, границы AutoMigrate и гибрид с versioned migrations — в четвёртом уроке про автомиграции; карта курса из 25 уроков — на странице курса. Дальше — вторая связь триады: Двенадцатый урок «Ассоциации: has one и has many» перевернёт взгляд — те же колонки ключей, но с другого конца: профиль у пользователя, комментарии у задачи, каскады при удалении. Домашка ниже собирает belongs to в один скрипт.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- belongs to — связь многие-к-одному: объявляющая модель хранит внешний ключ (Comment.TodoItemID) и поле-ассоциация (Comment.TodoItem); ключ всегда на стороне belongs to, а зеркальные взгляды has one/has many описывают ту же колонку с другого конца.
- Конвенция: имя поля ассоциации плюс ID даёт TodoItemID (перебор включает TodoItemId и колонку todo_item_id, поиск по Go-имени и DBName); если ключа нет — ошибка invalid field found ... define a valid foreign key, лечится тегом foreignKey.
- references называет поле владельца вместо первичного ключа (например, Slug); если имя внешнего ключа уже есть в типе-владельце, GORM может принять связь за has one — задавайте references явно; теги вешаются на поле-ассоциацию.
- Автосоздание: Create/Updates с заполненной ассоциацией сохраняет belongs to до главной вставки (gorm:save_before_associations, Session{NewDB: true}, upsert ON CONFLICT DO NOTHING; FullSaveAssociations — DO UPDATE SET всех колонок), затем setupReferences вписывает ключ в FK; Omit("TodoItem") и Omit(clause.Associations) отключают.
- Association API: db.Model(&c).Association("TodoItem") — Find, Append (равен Replace: связь одна), Replace, Delete и Clear (SET NULL без удаления записи, UpdateColumns без хуков), Count (0 или 1); ошибки unsupported relations и primary key required; Unscoped удаляет записи ассоциации мягко, физически — с db.Unscoped().
- AutoMigrate создаёт констрейнт fk_таблица_поле (fk_comments_todo_item) с ON DELETE/ON UPDATE из тега constraint; по умолчанию NO ACTION; constraint: "-" и DisableForeignKeyConstraintWhenMigrating отключают; SET NULL требует nullable колонки.
- После Find поле-ассоциация нулевое: ленивой загрузки нет — читайте связи через Preload (второй запрос WHERE id IN, без N+1) или Joins; подробности — четырнадцатый урок про Preloading.
Домашнее задание
Разверните канон локально: добавьте модели TodoList, TodoItem с полями TodoListID и TodoList, Comment с TodoItemID и TodoItem, запустите AutoMigrate и найдите в базе констрейнты fk_todo_items_todo_list и fk_comments_todo_item (в psql — d comments, в SQLite — .schema comments). Создайте список «Дом», две задачи с заполненным TodoList и три комментария: первый с автосозданием новой задачи, второй с существующей (db.First(&task, 4)), третий через Omit("TodoItem") — по SQL-следу убедитесь, что во втором случае INSERT задачи уехал с ON CONFLICT DO NOTHING, а в третьем todo_item_id остался нулевым. Переименуйте внешний ключ комментария в ItemRef через foreignKey-тег и воспроизведите ошибку invalid field found, сняв тег. Соберите сводку «комментарии с заголовками задач» двумя способами: JOIN вручную через Raw из десятого урока и Preload("TodoItem") — посчитайте запросы в логе. Попробуйте Append двух задач одному комментарию подряд и объясните по следу, почему вторая заменила первую. Разорвите связь Association("TodoItem").Delete и проверьте, что задача осталась, а todo_item_id стал NULL; повторите с Unscoped().Clear() и найдите deleted_at у задачи. Попробуйте связать комментарий с архивной задачей 1 «Купить хлеб» — мягкое удаление владельца из девятого урока не мешает belongs to, но помните про автофильтр при чтении. Финал: включите FullSaveAssociations на Updates с непрочитанной ассоциацией и объясните, что случилось с задачей и почему так делать нельзя.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем belongs to отличается от has one и has many?
Стороной внешнего ключа и направлением взгляда. belongs to — модель хранит ключ и принадлежит одной записи: Comment хранит TodoItemID. has many — взгляд владельца: у TodoItem много Comments, хотя колонка todo_item_id по-прежнему у комментария. has one — тот же взгляд, но связь единственная (профиль у пользователя). Одна колонка — два объявления; выбирать надо то, у какой модели должны появиться поля и методы.
Почему после Find поле TodoItem пустое?
В GORM нет ленивой загрузки: SELECT из comments возвращает только собственные колонки, и поле-ассоциация остаётся нулевым без ошибок. Наполните его Preload("TodoItem") — второй запрос WHERE id IN по всем ключам сразу, Joins — LEFT JOIN в один запрос, или вручную Association("TodoItem").Find(&item). Полный разбор жадной загрузки и N+1 — в четырнадцатом уроке.
Как запретить GORM трогать связанную запись при Create и Updates?
Omit("TodoItem") пропускает конкретную ассоциацию, Omit(clause.Associations) — все сразу: вставки связанной записи не будет, внешний ключ останется нулевым. Точечная настройка — Select("TodoItem.Title"). Наоборот работает FullSaveAssociations: upsert перезапишет все колонки связанной записи. Помните: заполненное поле-ассоциация уезжает в базу на каждом Create по умолчанию — INSERT с ON CONFLICT DO NOTHING избыточен, если связь уже установлена.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
sqlc: типобезопасный код из SQL-запросов
Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.
- Настраивать sqlc.yaml для Postgres/MySQL/SQLite
- Писать аннотированные запросы с типизированными параметрами
- Работать с NULL, массивами и JSON-колонками
- Организовывать транзакции через WithTx