Go: GORM

Теория: Связи между моделями

Когда в приложении появляются несколько сущностей, данные перестают жить в отдельных таблицах. Пользователь пишет посты, посты помечаются тегами, у каждого пользователя есть профиль. В реляционной базе всё это связывается внешними ключами, а в GORM — описывается через поля структур и специальные теги. ORM берёт на себя создание внешних ключей, промежуточных таблиц и правильных JOIN-ов в запросах.

GORM поддерживает четыре базовых типа связей, которые напрямую соответствуют привычным отношениям в базе данных:

  • has one — «имеет одну» запись;
  • has many — «имеет много»;
  • belongs to — «принадлежит»;
  • many2many — «многие ко многим».

Связь задаётся комбинацией полей и тегов gorm. ORM анализирует структуру, находит внешние ключи, создаёт нужные столбцы и строит SQL при чтении и записи.

Связь один к одному: has one / belongs to

Связь один к одному удобно рассматривать на примере пользователя и его профиля. У каждого пользователя есть один профиль, у профиля — один владелец.

type User struct {
	ID      uint
	Name    string
	Profile Profile // has one: у пользователя один профиль
}

type Profile struct {
	ID     uint
	UserID uint   // внешний ключ на users.id
	Bio    string // краткая информация
}

Здесь поле Profile в структуре User задаёт отношение has one: один пользователь имеет одну запись профиля. Поле UserID в Profile выступает внешним ключом. При миграции GORM создаст таблицу profiles с колонкой user_id и ограничением ссылочной целостности.

Чтобы загрузить пользователя вместе с профилем, используется Preload():

var user User

// Загрузка пользователя и его профиля одним вызовом.
if err := db.Preload("Profile").First(&user, 1).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

log.Println("имя:", user.Name)
if user.Profile.ID != 0 {
	log.Println("bio:", user.Profile.Bio)
}

Под капотом GORM выполнит два запроса: сначала выберет строку из users, затем — профиль с profiles.user_id = users.id. Вложенная структура Profile в памяти заполняется автоматически.

Обратная сторона связи — belongs to. Профиль принадлежит пользователю, и иногда удобно явно иметь доступ к владельцу:

type Profile struct {
	ID     uint
	UserID uint
	User   User `gorm:"foreignKey:UserID"` // профиль принадлежит пользователю
	Bio    string
}

Теперь можно загрузить профиль и сразу получить информацию о пользователе через Preload("User").

Связь один ко многим: has many / belongs to

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

type User struct {
	ID    uint
	Name  string
	Email string
	Posts []Post // has many: у пользователя много постов
}

type Post struct {
	ID     uint
	Title  string
	Body   string
	UserID uint // внешний ключ на users.id
	User   User // belongs to: пост принадлежит пользователю
}

Здесь:

  • Срез Posts в User задаёт связь has many.
  • Поле UserID в Post хранит идентификатор автора.
  • Поле User в Post позволяет получить самого.пользователя из поста.

Миграция создаёт две таблицы и внешний ключ:

if err := db.AutoMigrate(&User{}, &Post{}); err != nil {
	log.Fatal("ошибка миграции:", err)
}

Добавление пользователя и постов укладывается в несколько строк:

// Создаём пользователя.
user := User{Name: "Анна", Email: "anna@mail.com"}
if err := db.Create(&user).Error; err != nil {
	log.Println("ошибка создания пользователя:", err)
}

// Привязываем посты через внешний ключ.
posts := []Post{
	{Title: "Первый пост", Body: "Текст поста", UserID: user.ID},
	{Title: "Второй пост", Body: "Ещё один текст", UserID: user.ID},
}
if err := db.Create(&posts).Error; err != nil {
	log.Println("ошибка создания постов:", err)
}

Для выборки автора вместе с его постами снова используется Preload():

var u User

// Загрузка пользователя и всех его постов.
if err := db.Preload("Posts").First(&u, user.ID).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

log.Println("автор:", u.Name, "количество постов:", len(u.Posts))

for _, p := range u.Posts {
	log.Println("—", p.Title)
}

GORM выполнит один запрос к users и один к posts с фильтром по user_id.

Связь многие ко многим: many2many

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

В GORM many-to-many описывается с помощью тега gorm:"many2many:<имя_таблицы_связи>":

type Post struct {
	ID    uint
	Title string
	Body  string
	Tags  []Tag `gorm:"many2many:post_tags"` // связь многие ко многим через post_tags
}

type Tag struct {
	ID   uint
	Name string
}

При миграции GORM создаст три таблицы: posts, tags и post_tags. Последняя содержит пары (post_id, tag_id) и выступает мостом между постами и тегами.

Создание поста с тегами выглядит так:

post := Post{
	Title: "Знакомство с GORM",
	Body:  "Пример работы с ORM в Go",
	Tags: []Tag{
		{Name: "Go"},
		{Name: "GORM"},
		{Name: "SQL"},
	},
}

if err := db.Create(&post).Error; err != nil {
	log.Println("ошибка создания поста:", err)
}

GORM:

  • добавит запись в posts;
  • вставит или найдёт теги в tags;
  • заполнит таблицу post_tags — свяжет post.id с id каждого тега.

Чтобы прочитать пост вместе с тегами, достаточно одного Preload():

var p Post

if err := db.Preload("Tags").First(&p, post.ID).Error; err != nil {
	log.Println("ошибка выборки поста:", err)
}

log.Println("пост:", p.Title)
for _, tag := range p.Tags {
	log.Println("тег:", tag.Name)
}

Внутри GORM выполнит:

  • SELECT по posts;
  • SELECT из post_tags для всех связей конкретного поста;
  • SELECT из tags по списку id, полученных на предыдущем шаге.

Добавление нового тега к уже существующему посту делается через Associations() API:

var post Post
if err := db.First(&post, postID).Error; err != nil {
	log.Println("пост не найден:", err)
	return
}

var tag Tag
// Находим или создаём тег.
if err := db.FirstOrCreate(&tag, Tag{Name: "Database"}).Error; err != nil {
	log.Println("ошибка с тегом:", err)
	return
}

// Привязываем тег к посту через таблицу post_tags.
if err := db.Model(&post).Association("Tags").Append(&tag); err != nil {
	log.Println("ошибка привязки тега:", err)
}

GORM добавит новую строку в post_tags и обновит множество тегов у поста.

Настройка внешних ключей и поведения при удалении

Связи опираются на внешние ключи. По умолчанию GORM ищет поля вида <Имя>ID (UserID, PostID) и связывает их с primary key соответствующей модели. Если имена расходятся с этим шаблоном или требуется особое поведение при удалении и обновлении, параметры задаются явно в тегах.

Простейший пример: пользователь и его заказ. У заказа есть внешний ключ user_id, который ссылается на users.id:

type User struct {
	ID   uint
	Name string
}

type Order struct {
	ID     uint
	Number string
	UserID uint
	User   User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
}

Здесь:

  • поле UserID хранит id пользователя;
  • поле User задаёт связь;
  • constraint описывает поведение внешнего ключа: при изменении id в users значение user_id обновится каскадно, а при удалении пользователя в заказе user_id станет NULL.

При миграции это превратится в SQL с ограничением:

ALTER TABLE orders
	ADD CONSTRAINT fk_orders_users
	FOREIGN KEY (user_id) REFERENCES users(id)
	ON UPDATE CASCADE
	ON DELETE SET NULL;

Если название поля внешнего ключа не следует шаблону, его можно указать явно:

type Account struct {
	ID   uint
	Name string
}

type Profile struct {
	ID        uint
	AccountID uint    // поле-хранилище внешнего ключа
	Account   Account `gorm:"foreignKey:AccountID;references:ID"`
	Bio       string
}

В этом случае GORM понимает, что Profile.AccountID ссылается на Account.ID, даже если имя поля не содержит слова User или другой стандартный префикс.

При нескольких внешних ключах в одной структуре каждый настраивается отдельно:

type Payment struct {
	ID       uint
	BuyerID  uint
	SellerID uint

	Buyer  User `gorm:"foreignKey:BuyerID;constraint:OnDelete:CASCADE"`   // удаляем покупателя — удаляются платежи
	Seller User `gorm:"foreignKey:SellerID;constraint:OnDelete:SET NULL"` // удаляем продавца — ссылка обнуляется
}

GORM создаст два отдельных ограничения с разным поведением при удалении, и база данных будет автоматически поддерживать согласованность связей.

Связи в коде играют роль контракта между моделями. Структуры описывают, как сущности связаны между собой, а ORM преобразует это описание в реальные внешние ключи, промежуточные таблицы и JOIN-ы. Пользователи, их посты и теги остаются обычными структурами Go, а вся реляционная логика живёт в миграциях и запросах, которые GORM генерирует под капотом.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845