sqlc и SQL-builders в Go: генерация кода из SQL vs конструирование запросов

Два подхода к работе с SQL в Go без ORM на одном домене: sqlc генерирует типизированный код из SQL, а SQL-builders (squirrel, goqu) конструируют запросы программно. Где каждый силён, где кусается и как их комбинировать

В Go-экосистеме между raw SQL и полноценным ORM есть заметная ниша: инструменты, которые помогают писать SQL безопасно и удобно, но не прячут его за абстракциями. Два основных подхода — генерация Go-кода из SQL (sqlc) и программное конструирование запросов (squirrel, goqu). Они решают разные задачи и отлично уживаются вместе.

Шестая статья серии. В #4 был драйвер, в #5 — ORM против SQL-builder go-jet. Здесь разберём нишу подробнее и на одном домене: sqlc для статичных запросов и builders для динамических.

sqlc и SQL-builders: генерация типизированного кода из SQL и программная сборка запроса сходятся к одной базе

В статье

Зачем что-то между 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/sql
q := 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 (или *T c emit_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...) // отдаём в pgx
import (
    "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: как мерить, а не угадывать, где у сервиса узкое место.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии