Go: GORM

Теория: Загрузка связанных данных

Как только в приложении появляются связи между моделями, вопрос «как загрузить данные» усложняется. Уже редко хватает «просто выбрать пользователей» — почти всегда нужно сразу получить их посты, теги, профиль, заказы. В чистом SQL это быстро превращается в набор JOIN’ов и ручное разруливание дубликатов строк. В GORM та же задача решается двумя приёмами: Preload() и Joins(), плюс управлением тем, когда именно подгружать связи.

Preload и Joins

Preload() — основной способ подгрузки связей. Он делает дополнительные SELECT-запросы, связывает данные по внешним ключам и аккуратно раскладывает их в структуры. Joins() — более низкоуровневый инструмент: он строит SQL с JOIN прямо на стороне базы и возвращает плоский результат.

Пример: есть пользователь и его посты.

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

type Post struct {
	ID     uint
	Title  string
	UserID uint // внешний ключ на users.id
}

Жадная подгрузка постов через Preload():

var users []User

// Загружаем всех пользователей и сразу подгружаем их посты.
if err := db.Preload("Posts").Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

for _, u := range users {
	log.Println("пользователь:", u.Name, "постов:", len(u.Posts))
}

GORM под капотом сделает два запроса:

SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);

Он соберёт результат в памяти: каждому пользователю сопоставит его посты по user_id.

К тому же Preload() умеет фильтровать связи:

// Подгружаем только посты, в заголовке которых есть «Go».
if err := db.
	Preload("Posts", "title LIKE ?", "%Go%").
	Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

SQL для связей будет таким:

SELECT * FROM posts
WHERE user_id IN (1, 2, 3, ...) AND title LIKE '%Go%';

Joins() полезен, когда фильтровать нужно не связи, а основную сущность по данным в связанных таблицах.

var users []User

// Выбираем только тех пользователей, у которых есть пост с GORM в заголовке.
if err := db.
	Joins("JOIN posts ON posts.user_id = users.id").
	Where("posts.title LIKE ?", "%GORM%").
	Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

SQL получится примерно таким:

SELECT users.* FROM users
JOIN posts ON posts.user_id = users.id
WHERE posts.title LIKE '%GORM%';

Частый приём: использовать Joins() для фильтрации, а Preload() — для аккуратной подгрузки связей:

var users []User

if err := db.
	Joins("JOIN posts ON posts.user_id = users.id").
	Where("posts.title LIKE ?", "%Go%").
	Preload("Posts").
	Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

Здесь Joins() отберёт пользователей, а Preload("Posts") подгрузит для них все посты (не только отфильтрованные).

Отложенная и жадная загрузка

По умолчанию GORM не подгружает связи «автоматом». Это и есть отложенная (lazy) загрузка: сначала в память попадает только основная модель, а связи загружаются отдельно, когда это явно нужно.

Отложенная загрузка:

type User struct {
	ID    uint
	Name  string
	Posts []Post
}

type Post struct {
	ID     uint
	Title  string
	UserID uint
}

var user User

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

// Posts пока пустой срез: данные не загружены.
log.Println("постов до подгрузки:", len(user.Posts))

// Отложенная загрузка постов через Association API.
if err := db.
	Model(&user).
	Association("Posts").
	Find(&user.Posts); err != nil {
	log.Println("ошибка подгрузки постов:", err)
}

log.Println("постов после подгрузки:", len(user.Posts))

Сначала будет:

SELECT * FROM users
WHERE id = 1;

а при Association("Posts").Find — отдельный запрос:

SELECT * FROM posts
WHERE user_id = 1;

Жадная (eager) загрузка делается через Preload(): связи приходят сразу, в рамках одного вызова GORM.

var user User

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

log.Println("постов:", len(user.Posts))

Теперь GORM выполнит два запроса подряд (к users и posts), но для кода это один вызов.

Вложенные связи тоже можно подгружать жадно:

type Comment struct {
	ID     uint
	Body   string
	PostID uint
}

type Post struct {
	ID       uint
	Title    string
	UserID   uint
	Comments []Comment
}

type User struct {
	ID    uint
	Name  string
	Posts []Post
}

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

Под капотом GORM:

  • выберет пользователя,
  • выберет его посты,
  • выберет комментарии к этим постам,
  • соберёт всё дерево в памяти.

На практике удобно комбинировать подходы: список сущностей получать с жадной подгрузкой (например, пользователей и их посты), а тяжёлые связи (комментарии, историю изменений) подгружать отложенно только там, где они действительно нужны.

Ограничения и условия при загрузке связей

Часто нужно не просто «подгрузить все связи», а сделать это с фильтром, сортировкой или лимитом. Preload() поддерживает условия и принимает вторым параметром либо SQL-строку с аргументами, либо функцию, которая настраивает вложенный *gorm.DB.

Простой фильтр по флагу:

type Post struct {
	ID        uint
	Title     string
	Published bool
	UserID    uint
}

type User struct {
	ID    uint
	Name  string
	Posts []Post
}

var users []User

// Подгружаем только опубликованные посты.
if err := db.
	Preload("Posts", "published = ?", true).
	Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

SQL для связей:

SELECT * FROM posts
WHERE user_id IN (1, 2, 3, ...) AND published = true;

Сортировка и ограничение количества подгружаемых записей через функцию:

// Подгружаем не все посты, а только три последних для каждого пользователя.
if err := db.
	Preload("Posts", func(db *gorm.DB) *gorm.DB {
		return db.Order("created_at DESC").Limit(3)
	}).
	Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

Для каждой связи GORM выполнит запрос вида:

SELECT * FROM posts
WHERE user_id IN (1, 2, 3, ...)
ORDER BY created_at DESC
LIMIT 3;

Разным связям можно задать разные ограничения:

type User struct {
	ID       uint
	Name     string
	Posts    []Post
	Comments []Comment
}

// Подгружаем только опубликованные посты
// и только одобренные комментарии.
if err := db.
	Preload("Posts", "published = ?", true).
	Preload("Comments", func(db *gorm.DB) *gorm.DB {
		return db.Where("approved = ?", true)
	}).
	Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

Условия работают и для вложенных связей:

// Подгружаем только те теги, в имени которых есть «Go».
if err := db.
	Preload("Posts.Tags", func(db *gorm.DB) *gorm.DB {
		return db.Where("tags.name LIKE ?", "%Go%")
	}).
	Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

А если нужно отфильтровать основную сущность по связям и в то же время аккуратно подгрузить связи — соединяем Joins() и Preload():

// Берём только пользователей с опубликованными постами,
// и сразу подгружаем эти посты.
if err := db.
	Joins("JOIN posts ON posts.user_id = users.id AND posts.published = true").
	Preload("Posts", "published = ?", true).
	Find(&users).Error; err != nil {
	log.Println("ошибка выборки:", err)
}

Joins() ограничит список пользователей, Preload() сделает второй запрос и подгрузит к ним все опубликованные посты.

Загрузка связанных данных в GORM — это управляемый процесс. Preload() отвечает за жадную подгрузку связей, Association — за отложенную загрузку по требованию, Joins() — за фильтрацию основной сущности по связям. Условия и ограничения внутри Preload() позволяют не перетаскивать из базы лишнее. Вся магия связей остаётся внутри ORM, а код работает с обычными структурами Go.

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

+7 800 100 22 47

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

+7 495 085 21 62

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

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