В Go-экосистеме между raw SQL и полноценным ORM есть заметная ниша: инструменты, которые помогают писать SQL безопасно и удобно, но не прячут его за абстракциями. Два основных подхода — генерация Go-кода из SQL (sqlc) и программное конструирование запросов (squirrel, goqu). Они решают разные задачи и отлично уживаются вместе.
Шестая статья серии. В #4 был драйвер, в #5 — ORM против SQL-builder go-jet. Здесь разберём нишу подробнее и на одном домене: sqlc для статичных запросов и builders для динамических.
В статье
- Зачем что-то между raw SQL и ORM
- Сквозной домен
- sqlc: генерация типизированного кода из SQL
- Где sqlc кусается
- SQL-builders: squirrel и goqu
- Где builder кусается
- Динамический ORDER BY и пагинация
- Комбинирование: sqlc + builder
- Интеграция, миграции, тесты
- Сравнение
- Выводы
Зачем что-то между raw SQL и ORM
- Raw SQL — полный контроль, но запросы живут строками: ручной маппинг в структуры и опечатки, всплывающие в runtime.
- ORM — удобно для CRUD, но контроль над SQL теряется, а запросы порой неожиданны (см. #5).
- Середина — типобезопасность без отказа от SQL. Её закрывают
sqlcи builders, но с разных сторон:sqlc— для статичных запросов, builders — для динамических.
Сквозной домен
Чтобы примеры не висели порознь, возьмём один домен и погоняем оба подхода по нему.
CREATE TABLE users (
id bigserial PRIMARY KEY,
name text NOT NULL,
email text, -- nullable
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE posts (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
title text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'
);sqlc: генерация типизированного кода из SQL
Принцип: вы пишете обычный SQL с аннотациями, sqlc generate превращает его в типизированные Go-функции и структуры, проверяя запрос по схеме на этапе генерации. Переименуете колонку в миграции — после generate код перестанет компилироваться.
-- name: GetUser :one
SELECT id, name, email, active, created_at FROM users WHERE id = $1;
-- name: ListActiveUsers :many
SELECT id, name, email FROM users WHERE active = $1 ORDER BY name;
-- name: CreateUser :one
INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id;
-- name: DeactivateUser :execrows
UPDATE users SET active = false WHERE id = $1;
-- строки JOIN раскладываются в две вложенные структуры
-- name: ListPostsWithAuthor :many
SELECT sqlc.embed(posts), sqlc.embed(users)
FROM posts JOIN users ON users.id = posts.user_id;version: "2"
sql:
- engine: postgresql
schema: "migrations" # отсюда sqlc узнаёт типы колонок
queries: "query.sql"
gen:
go:
package: db
out: "internal/db"
sql_package: "pgx/v5" # backend — pgx, не database/sqlq := db.New(pool) // pool — *pgxpool.Pool
u, err := q.GetUser(ctx, 1) // u типизирован (db.User)
users, err := q.ListActiveUsers(ctx, true) // []db.ListActiveUsersRow
id, err := q.CreateUser(ctx, db.CreateUserParams{
Name: "alice",
Email: pgtype.Text{String: "a@e.co", Valid: true}, // nullable
})
n, err := q.DeactivateUser(ctx, 1) // :execrows → число затронутых строк
// sqlc.embed: каждая строка — {Post db.Post; User db.User}
rows, err := q.ListPostsWithAuthor(ctx)Что важно про типы и возможности:
- nullable — по умолчанию
pgtype.Text/pgtype.Int8(или*Tcemit_pointers_for_null_types); массивы маппятся в срезы; jsonbпо умолчанию генерируется как[]byte— для доменного типа (структуры/map) задайте override вsqlc.yaml(overrides/go_type);sqlc.embed(table)раскладывает строкуJOINв вложенные структуры-модели — не нужно руками расписывать колонки;- аннотации задают форму результата:
:one,:many,:exec,:execrows,:batchexec,:copyfrom(два последних — только подsql_package: pgx/v5); - именованные параметры —
sqlc.arg('x')иsqlc.narg('x')(nullable).
Где sqlc кусается
Ниша sqlc реальна, но у него есть предсказуемые границы:
- Динамика — главная боль. «Опциональный
WHERE», фильтры по выбору пользователяsqlcвыражает плохо.sqlc.arg('x')— обязательный параметр,sqlc.narg('x')— nullable; частичный обход опционального фильтра —WHERE (name = sqlc.narg('name') OR sqlc.narg('name') IS NULL). Но на 5+ таких фильтрах это каша, и план запроса страдает:col = narg OR narg IS NULLне sargable — планировщик хуже использует индекс. Прямой сигнал взять builder (см. ниже), а не героически бороться сsqlc. - Связка с миграциями. Источник истины типов — схема.
sqlcчитает её наgenerate, значит порядок жёсткий: миграция →sqlc generate→ код. В CI держите доступ к схеме (миграции/дамп), иначе кодоген не воспроизвести. - Сгенерированный код — решение про VCS. Коммитить
internal/dbили генерировать в CI — командная договорённость; в любом случае «протухший» сгенерированный код против новой схемы ловится только повторнымgenerate. - Понимает подмножество SQL. Совсем экзотические конструкции могут не распарситься (хотя покрытие растёт от версии к версии) — проверяйте на своих запросах.
SQL-builders: squirrel и goqu
Builder конструирует запрос программно — идеально, когда форма зависит от условий в рантайме. squirrel — минималистичный и стабильный; goqu — функциональнее (диалекты, больше возможностей), но тяжелее.
import sq "github.com/Masterminds/squirrel"
// PlaceholderFormat(sq.Dollar) обязателен для PostgreSQL: иначе будут '?' (MySQL)
qb := sq.Select("id", "name", "email").From("users").
Where(sq.Eq{"active": true}).
PlaceholderFormat(sq.Dollar)
if f.Name != "" {
qb = qb.Where(sq.ILike{"name": "%" + f.Name + "%"})
}
if f.OnlyWithEmail {
qb = qb.Where("email IS NOT NULL") // несколько Where — это AND
}
sql, args, err := qb.OrderBy("name").ToSql()
rows, err := pool.Query(ctx, sql, args...) // отдаём в pgximport (
"github.com/doug-martin/goqu/v9"
// side-effect: регистрирует postgres-диалект (иначе Prepared даёт ? вместо $1)
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
)
ds := goqu.Dialect("postgres").From("users").
Select("id", "name").
Where(goqu.C("active").IsTrue())
if f.Name != "" {
ds = ds.Where(goqu.C("name").ILike("%" + f.Name + "%"))
}
// ВАЖНО: по умолчанию goqu интерполирует значения в SQL.
// Prepared(true) возвращает плейсхолдеры + args — так и надо для pgx.
sql, args, err := ds.Prepared(true).ToSQL()
rows, err := pool.Query(ctx, sql, args...)Ключевое: фильтры добавляются условно, а значения остаются плейсхолдерами ($1, $2) — никакой конкатенации строк, значит и SQL-инъекций. Оба умеют JOIN и подзапросы. Результат сканируется тем же pgx: users, err := pgx.CollectRows(rows, pgx.RowToStructByName[User]) (см. #4) — builder только строит sql, args.
Где builder кусается
Обратная сторона динамики — ошибки уезжают в runtime:
- Нет проверки схемы. Опечатка в имени колонки/таблицы всплывёт не на компиляции, а как ошибка запроса в рантайме — компилятор тут не помощник (в отличие от
sqlc/go-jet). - Несколько
Where— этоAND. Легко ждатьORи получить пустую выборку. ДляOR— явныеsq.Or{...}/goqu.Or(...). - Забытый
PlaceholderFormat(sq.Dollar)вsquirrel→ плейсхолдеры?вместо$1, и pgx их не поймёт. Ставьте формат сразу. goquпо умолчанию интерполирует значения. БезPrepared(true)вы получите SQL с зашитыми значениями — теряется бенефит плейсхолдеров. ВсегдаPrepared(true)для боевых запросов.sq.Expr("...")/ raw-фрагменты — если сунуть туда пользовательское значение конкатенацией, вернётся инъекция. Значения — только через плейсхолдеры/args.
Динамический ORDER BY и пагинация
Плейсхолдеры защищают значения, но не идентификаторы: имя колонки в ORDER BY нельзя передать как $1. Если сортировку выбирает пользователь — это вектор инъекции. Безопасный путь один — whitelist:
var allowedSort = map[string]string{
"name": "name",
"created": "created_at",
}
col, ok := allowedSort[userSort]
if !ok {
col = "id" // безопасный дефолт
}
qb = qb.OrderBy(col) // в SQL уходит только проверенное имя, не ввод пользователяLIMIT/OFFSET — значения-плейсхолдеры, но глубокий OFFSET дорог: база читает и отбрасывает все пропущенные строки. Для больших списков берите keyset pagination (WHERE id > $last ORDER BY id LIMIT $n) вместо OFFSET — она держит стабильную стоимость страницы.
Комбинирование: sqlc + builder
Это не «или-или». Здоровый паттерн — комбинировать в одном проекте:
sqlc— на статичный CRUD и типовые запросы (их большинство, и там ценна типобезопасность);- builder — на поиск и фильтрацию с динамической формой.
Оба отдают запрос одному и тому же pgxpool.Pool: sqlc — через сгенерированные методы, builder — через sql, args в pool.Query. Никакого конфликта — просто разные инструменты под разные запросы.
Интеграция, миграции, тесты
- pgx.
sqlcгенерирует подpgx/v5(sql_package: pgx/v5), builder отдаётsql, argsпрямо вpool.Query. Не смешивайтеdatabase/sqlи нативныйpgxбез причины — это путаница с типами и пулом (см. #4). - Миграции. Схему держит инструмент миграций (
goose,golang-migrate,atlas);sqlcчитает ту же схему. Порядок: применил/обновил миграции →sqlc generate. В CI полезно падать, еслиsqlc generateдаёт дифф (значит код не пересгенерён), и гонятьsqlc vet— линтер запросов против типичных ошибок. - Тесты. Поднимайте реальный PostgreSQL через
testcontainersи гоняйте и сгенерированные функции, и динамические запросы по-настоящему — это ловит то, что юнит-моки пропускают.
Сравнение
| Критерий | sqlc | SQL-builder (squirrel/goqu) |
|---|---|---|
| Типобезопасность | на generate (компилятор) |
в runtime |
| Сильная сторона | статичные запросы, CRUD, JOIN | динамические запросы, фильтры |
| Рефакторинг схемы | ловит ошибки при generate |
не ловит, всплывёт в рантайме |
Динамический WHERE |
неудобно (narg-каша) |
естественно |
| Инъекции | исключены | исключены при плейсхолдерах (не Expr с конкатенацией) |
| Лишний шаг | codegen в пайплайне | нет |
| Порог входа | нужно объяснить codegen-связку | ниже |
Выводы
sqlc— когда запросов много и они статичные: типобезопасность из коробки, ошибки схемы ловятся наgenerate,sqlc.embedзакрывает JOIN’ы.- Builder (
squirrel/goqu) — когда запрос динамический: опциональные фильтры, поиск, переменная форма. Помните про подводные камни:AND-семантикуWhere,PlaceholderFormat,Prepared(true)уgoqu. - Лучшее решение — сочетание:
sqlcдля CRUD + builder для динамики, оба поверх одногоpgxpool. - Всё это живёт поверх драйвера из #4, не отменяя понимания, какой SQL уходит в базу.
Завершает серию — профилирование и бенчмарки Go: как мерить, а не угадывать, где у сервиса узкое место.
Комментарии