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

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

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

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

Ассоциации: belongs to — внешний ключ, автосоздание и Association API в GORM

До сих пор каждая таблица курса жила сама по себе: todo_items хранила задачи, и ни одна модель не ссылалась на другую. Живые данные так не устроены — комментарий относится к задаче, задача к списку, и связи gorm начинаются с ассоциации gorm belongs to, «принадлежит»: связь многие-к-одному, при которой объявляющая модель хранит внешний ключ и указывает ровно на одну запись другой таблицы — у комментария одна задача, даже если у задачи тысячи комментариев. Разберём belongs to по исходникам GORM: конвенцию внешнего ключа TodoItemID (имя поля ассоциации плюс ID) и ошибку invalid field found, если конвенция не сработала; теги foreignKey и references — когда имена свои; автосоздание — db.Create(&comment) с заполненным Comment.TodoItem, при котором GORM сам вставит задачу, возьмёт её новый ключ и впишет в todo_item_id (по умолчанию через upsert ON CONFLICT DO NOTHING, с FullSaveAssociations — с полным обновлением связанной записи); базовый Association API — Find, Append, Replace, Delete, Clear и Count у единственной связи; и то, что при этом создаёт AutoMigrate: констрейнт fk_comments_todo_item с ON DELETE и ON UPDATE из тега constraint. Канон продолжается: к пяти задачам в todo_items добавляются таблицы todo_lists и comments — комментарий принадлежит задаче, задача принадлежит списку. Одна честная оговорка о формате: СУБД в браузерной песочнице не живёт, поэтому runner-ов в уроке нет — вас проверяют quiz, predict, fill, order и карточки, а примеры рассчитаны на локальный Go с PostgreSQL или SQLite; SQL-след, как всегда, печатает логгер из второго урока.

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

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 в действии

GO
var 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 из прошлого раздела, и это следующий разговор.

Шпаргалка: 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 — норма, а не баг.

Заполните пропуски: связь от тега до операции

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

// 1) Кастомный внешний ключ: поле ItemRef вместо TodoItemID type Note struct { gorm.Model Text string ItemRef uint TodoItem TodoItem `gorm:":ItemRef"` } // 2) Ссылка на непервичный столбец владельца TodoItem TodoItem `gorm:":Slug"` // 3) Перепривязать комментарий к задаче 4 var target TodoItem db.First(&target, 4) db.Model(&c).("TodoItem").Replace(&target) // 4) Разорвать связь, не удаляя задачу db.Model(&c).Association("TodoItem").() // 5) Полный upsert связанной записи при автосохранении db.Session(&gorm.Session{: true}).Updates(&comment)

Соберите путь db.Create(&comment) с заполненной связью

Расставьте шаги, которые проходит автосоздание belongs to: от открытия транзакции до коммита.

Строки

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

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

Карточки: belongs to

Термин

belongs to

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

Связь многие-к-одному: объявляющая модель хранит внешний ключ и принадлежит ровно одной записи другой таблицы (Comment belongs to TodoItem). Внешний ключ всегда на стороне belongs to — главное правило; зеркальные взгляды (has one, has many) описывают ту же колонку с другого конца и приходят в двенадцатом уроке.

1 / 6

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

Первая связь встала: 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 с непрочитанной ассоциацией и объясните, что случилось с задачей и почему так делать нельзя.

Личные заметкиПусто
Хранятся только в вашем браузере (GORM · Ассоциации: belongs to — внешний ключ, автосоздание и Association API в GORM)

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

Чем 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 избыточен, если связь уже установлена.

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

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

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

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

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

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

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

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

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