Go: GORM

Теория: Работа с gorm.Expr и сырыми SQL

GORM закрывает 90% повседневных задач: CRUD, связи, фильтры, транзакции. Но база данных умеет гораздо больше: оконные функции, сложные агрегаты, хитрые JOIN-ы, CTE, работу с JSONB и массивами. Не всё это удобно и вообще возможно выразить «чистым» API ORM. На этот случай у GORM есть два запасных пути: сырые запросы Raw()/Exec() и выражения gorm.Expr().

Сырые запросы позволяют писать SQL напрямую, но по-прежнему пользоваться параметрами и сканированием результатов в структуры. gorm.Expr() встраивает отдельные выражения в обычные вызовы GORM — для инкрементов, формул, CASE-условий, подзапросов и диалектных функций.

Сырые запросы: Raw и Exec

  • Raw() — для SELECT-ов (чтение и сканирование данных);
  • Exec() — для INSERT/UPDATE/DELETE и любых запросов без результата.

Оба метода принимают SQL-строку с ? и список параметров. GORM передаёт их драйверу через database/sql, так что параметры не попадают в SQL как конкатенация строк — защита от инъекций сохраняется.

Чтение данных через Raw

Когда нужно выполнить произвольный SELECT и получить результат в структуры, Raw() + Scan() работают как обычный запрос GORM, только SQL вы пишете сами:

type User struct {
	ID   uint
	Name string
}

var users []User

db.Raw("SELECT id, name FROM users WHERE name LIKE ?", "%Ан%").
	Scan(&users)

Под капотом будет обычный запрос:

SELECT
    id,
    name
FROM users
WHERE name LIKE '%Ан%';

Точно так же можно читать агрегаты в простые типы:

var count int64
db.Raw("SELECT COUNT(*) FROM users WHERE active = ?", true).
	Scan(&count)

log.Println("Активных пользователей:", count)

GORM берёт на себя только передачу параметров и маппинг результата в структуру/переменную. Всё, что касается текста запроса, — ваша ответственность.

Изменения через Exec

Exec() используют, когда запрос ничего не возвращает (или вас не интересует результат):

result := db.Exec(
	"UPDATE users SET active = ? WHERE last_login < ?",
	false,
	time.Now().AddDate(0, -6, 0),
)

if result.Error != nil {
	log.Println("ошибка обновления:", result.Error)
}

log.Println("Обновлено строк:", result.RowsAffected)

Точно так же — массовое удаление:

result := db.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now())
log.Println("Удалено строк:", result.RowsAffected)

SQL при этом остаётся прямым:

DELETE FROM sessions
WHERE expires_at < '2025-11-02 12:00:00';

INSERT с RETURNING

В PostgreSQL удобно сразу получить идентификатор свежесозданной записи через RETURNING. Это тоже можно сделать через Raw():

var id uint

db.Raw(
	"INSERT INTO users (name) VALUES (?) RETURNING id",
	"Иван",
).Scan(&id)

log.Println("Создан пользователь с ID:", id)

Здесь Raw() выполняет INSERT, а затем сканирует возвращённое значение в переменную.

Гибриды: сложные JOIN-ы и представления

Когда SELECT становится сложно выразить через Preload()/Joins(), проще честно написать SQL, а результат положить в нужный тип:

type PostWithAuthor struct {
	ID         uint
	Title      string
	Body       string
	AuthorName string
}

var posts []PostWithAuthor

db.Raw(`
	SELECT p.id, p.title, p.body, u.name AS author_name
	FROM posts p
	JOIN users u ON p.user_id = u.id
	WHERE p.published = TRUE
	ORDER BY p.created_at DESC
`).Scan(&posts)

Вы задаёте нужный набор полей и JOIN-ов, база выполняет запрос, GORM просто раскладывает строки по структуре.

Процедуры и функции

Если база поддерживает хранимые процедуры или SQL-функции, Exec() подойдёт и для них:

db.Exec("SELECT update_statistics(?)", "users")
db.Exec("CALL recalc_totals(?)", 100)

Всё это происходит в том же контексте: можно вызывать tx.Exec внутри транзакции, добавлять WithContext() с тайм-аутами и видеть запросы в логгере GORM.

Сложные выражения с gorm.Expr

Иногда нужен не целый кастомный запрос, а точечная «умная» операция: инкремент счётчика без чтения, изменение поля по формуле, CASE-условие, работа с JSONB, массивами, NOW() и другими функциями базы. Для таких случаев у GORM есть gorm.Expr().

Expr() — это объект, который говорит: «запиши здесь вот это SQL-выражение с параметрами». Его используют в Update/Updates, Select(), Where(), Order() и других местах, где обычно передаётся простое значение.

Атомарный инкремент без чтения

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

db.Model(&Post{}).
	Where("id = ?", id).
	Update("views", gorm.Expr("COALESCE(views, 0) + ?", 1))

В SQL получится:

UPDATE posts
SET views = COALESCE(views, 0) + 1
WHERE id = 42;

Инкремент выполняется на стороне базы, операция атомарная, гонок нет.

Массовое обновление по формуле

Допустим, нужно выдать 10% скидку на все книги. Вместо того чтобы выгружать их в память и пересчитывать цену, проще отдать формулу базе:

db.Model(&Product{}).
	Where("category = ?", "Books").
	Update("price", gorm.Expr("ROUND(price * ?, 2)", 0.9))

БД сама умножит цену на 0.9, округлит и запишет обратно всем подходящим товарам.

CASE в UPDATE: защита от отрицательных остатков

Ещё один популярный сценарий — не уходить в минус при списании со склада:

db.Model(&Item{}).
	Where("id = ?", id).
	Updates(map[string]interface{}{
		"stock": gorm.Expr(
			"CASE WHEN stock >= ? THEN stock - ? ELSE stock END",
			n, n,
		),
		"updated_at": gorm.Expr("NOW()"),
	})

Логика «если достаточно товара — списать, иначе оставить как есть» живёт в базе, а не в приложении. Даже если два процесса обновят одну строку, CASE отработает корректно.

Вычисляемые поля в Select и Order

Иногда удобнее сразу вернуть производное значение и сортировку по нему:

type Row struct {
	Name      string
	AgeMonths int
}

var rows []Row

db.Model(&User{}).
	Select("name, age * 12 AS age_months").
	Order(gorm.Expr("age * 12 DESC")).
	Scan(&rows)

База посчитает возраст в месяцах и отсортирует по нему же, GORM просто заберёт результат.

Диалектные фишки: JSONB, массивы, функции

GORM не знает всех операторов PostgreSQL, но Expr() позволяет аккуратно вставить их в запрос.

Например, обновление поля в JSONB:

db.Model(&User{}).
	Where("id = ?", id).
	Update("data", gorm.Expr(
		"jsonb_set(COALESCE(data, '{}'::jsonb), '{profile,city}', to_jsonb(?), true)",
		"Москва",
	))

Или добавление тега в массив:

db.Model(&Post{}).
	Where("id = ?", id).
	Update("tags", gorm.Expr("COALESCE(tags, '{}') || ARRAY[?]", "go"))

База применяет свои функции и операторы, параметры по-прежнему передаются через ?.

Подзапросы и upsert с выражениями

Подзапрос в условии:

avg := db.Table("orders").Select("AVG(total)")

db.Model(&Customer{}).
	Where("total_spent > (?)", avg).
	Find(&customers)

Upsert с вычислениями при конфликте:

db.Clauses(clause.OnConflict{
	Columns: []clause.Column{{Name: "sku"}},
	DoUpdates: clause.Assignments(map[string]interface{}{
		"stock":      gorm.Expr("products.stock + EXCLUDED.stock"),
		"updated_at": gorm.Expr("NOW()"),
	}),
}).Create(&Product{SKU: "A1", Stock: 5})

PostgreSQL использует EXCLUDED.*, GORM аккуратно подставляет выражения в DO UPDATE.

Когда пора выходить за рамки GORM

gorm.Expr() отлично работает, пока выражение укладывается в одну-две строки и ещё можно прочитать запрос целиком. Как только начинается:

  • несколько CTE (WITH / WITH RECURSIVE);
  • пачка оконных функций;
  • сложные HAVING, хинты оптимизатора, специфичные индексы;
  • многоуровневые JOIN-ы с ветвящейся логикой, удобнее честно перейти на «чистый» SQL — через Raw() для SELECT и Exec() для изменений. В больших проектах такие запросы часто выносят в отдельные файлы рядом с миграциями или генерируют через инструменты вроде sqlc, чтобы получить типобезопасные обёртки.

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

+7 800 100 22 47

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

+7 495 085 21 62

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

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