open atlas
↑ К треку
Go с нуля до senior GO · 08 · 03

sqlc и спектр обёрток: типизированный SQL-кодоген, билдеры запросов и честные компромиссы ORM

Сырой database/sql — это рутина и баги сканирования; sqlc генерирует типизированный Go из настоящего SQL с проверкой колонок на этапе генерации; билдеры закрывают динамический WHERE; ORM меняет явность на магию. Сгенерированный интерфейс Querier даёт тестовые фейки.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Комплаенс распорядился деактивировать 1200 спящих аккаунтов. Инженер написал очевидную GORM-строку — db.Model(&u).Updates(User{Active: false}), — посмотрел, как она отработала без ошибки, и закрыл тикет. Пять недель спустя аудитор обнаружил все 1200 аккаунтов по-прежнему активными, несколько — со свежими логинами. Строка выполнила UPDATE с пустым SET-списком: GORM пропускает поля структуры с нулевыми значениями в Updates, а false — нулевое значение bool. Задокументированное поведение, не баг: ORM не может отличить «я не упомянул это поле» от «я выставил его в false», поэтому угадывает. Дифф после инцидента, который реально всё исправил, был одним аннотированным SQL-запросом — UPDATE users SET active = false WHERE id = ANY($1), — который ревьюер мог прочитать, прогнать через EXPLAIN и одобрить за тридцать секунд. В этом весь аргумент урока: чем дальше слой данных уезжает от SQL, который видно глазами, тем чаще его отказы — семантические сюрпризы, выполняющиеся без единой ошибки.

Спектр, честно

Прежде чем тянуться к ORM или сырому SQL, спроси себя: чем ты на самом деле торгуешь — читаемостью на ревью, гибкостью динамических запросов, типобезопасностью или объёмом рутины? Каждый слой данных в Go сидит где-то на спектре из четырёх точек, и у каждой точки есть реальная цена — нечестный ход только один: делать вид, что у вашей её нет.

Сырой database/sql — максимум контроля и максимум рутины: каждый запрос повторяет церемонию Query/defer Close/Next/Scan/Err из начала юнита, а позиционный Scan — постоянная угроза при рефакторинге: переставьте две колонки одного типа в SELECT — и name ляжет в email без единой ошибки. Билдеры запросов (стандарт — squirrel) собирают SQL из Go-значений: верный инструмент для по-настоящему динамических запросов — двенадцать опциональных фильтров в админке, — но запрос теперь существует только в рантайме, и до выполнения его никто не проверяет. Полные ORM (GORM) максимизируют эргономику: связи, хуки, миграции-из-структур. Цена — семантическая дистанция: правило нулевых значений из крючка, магия порядка сохранений, N+1-запросы, материализующиеся из невинного обращения к полю, — и ревью, на котором никто не видит SQL, который реально выполнится. sqlc застолбил четвёртую точку: вы пишете настоящие SQL-файлы, а кодогенерация выдаёт типизированные Go-функции. SQL виден; рутина сгенерирована; проверка происходит до того, как код уедет в прод.

Викторина

Выполняется db.Model(&user).Updates(User{Active: false, Name: "Bo"}) через GORM. Какие колонки попадут в сгенерированный UPDATE?

Механика sqlc: SQL на входе, типизированный Go на выходе

Когда Scan-цели пишешь вручную — ты несёшь ответственность за маппинг вечно: одна переставленная колонка, и баг тихий. sqlc забирает эту ответственность у тебя. Запросы пишутся в .sql-файле, каждый — с именем и аннотацией кардинальности:

-- name: GetUser :one
SELECT id, email, active FROM users WHERE id = $1;

-- name: ListOrgUsers :many
SELECT id, email FROM users
WHERE org_id = $1 AND active
ORDER BY created_at DESC
LIMIT $2;

-- name: DeactivateUsers :exec
UPDATE users SET active = false, updated_at = now() WHERE id = ANY($1::bigint[]);

sqlc generate парсит их вместе со схемой и выдаёт типизированный Go:

// Сгенерировано. Порядком Scan и маппингом полей владеет генератор —
// класс багов с перестановкой колонок исчез: Scan больше не пишет человек.
func (q *Queries) GetUser(ctx context.Context, id int64) (GetUserRow, error)
func (q *Queries) ListOrgUsers(ctx context.Context, arg ListOrgUsersParams) ([]ListOrgUsersRow, error)
func (q *Queries) DeactivateUsers(ctx context.Context, ids []int64) error

Проверка происходит на этапе генерации: сослались на несуществующую колонку, передали string туда, где схема говорит bigint, вернули четыре колонки в структуру из трёх полей — sqlc generate падает, в CI, до ревью. :one компилируется в QueryRow (ноль строк по-прежнему всплывает как sql.ErrNoRows на месте вызова — sqlc не прячет семантику слоя ниже); :many — в полный цикл Query/Close/Err, написанный правильно каждый раз; :exec — в ExecContext. Сгенерированный интерфейс Querier — часть, которую команды недооценивают: это тестовый шов, который не пришлось проектировать. Тесты хендлеров берут рукописный фейк; настоящая база нужна только слою репозитория.

Две точки интеграции держат систему честной в масштабе. Транзакции: сгенерированный код работает на чём угодно, что удовлетворяет его интерфейсу DBTX, поэтому WithTx переиспользует хелпер из прошлого урока без изменений —

q := store.New(db) // *Queries, привязанный к пулу

err := withTx(ctx, db, nil, func(tx *sql.Tx) error {
	qtx := q.WithTx(tx) // те же сгенерированные методы, теперь поверх пришпиленного соединения
	if err := qtx.DeactivateUsers(ctx, ids); err != nil {
		return err
	}
	return qtx.InsertAuditEvent(ctx, auditParams)
})

Миграции: схемой sqlc не владеет — ею владеет инструмент миграций (golang-migrate или goose: нумерованные SQL-файлы, применяемые по порядку и записываемые в таблицу версий). sqlc читает те же файлы миграций, чтобы узнать схему, и это замыкает контур дрейфа: переименуйте колонку в миграции, перезапустите sqlc generate — и компилятор выдаст полный список мест вызова, которые надо поправить. То же переименование с рукописным Scan-кодом компилируется спокойно и тихо портит данные.

Там, где sqlc останавливается, он останавливается резко: на каждую аннотацию генерируется один статический стейтмент, поэтому поисковый эндпоинт с двенадцатью опциональными фильтрами не может быть одним sqlc-запросом. Цепочки трюков с coalesce($1, col) технически работают и надёжно производят одновременно нечитаемый SQL и непригодные планы. Это территория билдера — squirrel за тем же интерфейсом репозитория — или рукописного WHERE поверх белого списка колонок. Зрелый слой данных — композиция, а не монокультура.

Викторина

Что именно проверяет sqlc и в какой момент?

Аргумент масштаба команды

Сильнейший довод за SQL-first — не типобезопасность, а ревью. Со sqlc дифф в пулл-реквесте — это и есть запрос: ревьюер может его прочитать, вставить в EXPLAIN ANALYZE и заметить отсутствующий индекс или случайный cross join до мерджа. С ORM-DSL ревьюер мысленно компилирует цепочку методов в SQL и надеется, что его ментальная модель совпадает с версией библиотеки, — крючок показывает, как выглядит несовпадение. То же свойство окупается в три часа ночи: pg_stat_statements показывает медленный запрос, и со sqlc его буквальный текст ищется грепом и приводит к одному именованному, аннотированному запросу. И граница остаётся честной в обратную сторону: отчётные запросы с оконными функциями, рекурсивными CTE и переписываниями под EXPLAIN остаются сырым SQL по своей природе — и sqlc охотно генерирует типы для большинства из них, потому что он никогда не требовал от SQL простоты, только статичности.

Почему это работает

Почему проверка на этапе генерации здесь бьёт рантайм-валидацию? Потому что схема — самый медленно меняющийся и самый разделяемый контракт в системе: она меняется через отревьюенные миграции, а не на каждый запрос. Проверить запросы против неё один раз, на сборке, ничего не стоит в рантайме и превращает целый класс багов (дрейф между кодом и схемой) в ошибки компиляции с файлом и строкой. Остаточный риск честен и явен: гарантия держится, только если база, на которую вы деплоитесь, действительно соответствует миграциям, которые читал sqlc, — ровно поэтому миграции-как-единственный-источник-схемы — это дисциплина, а не вкусовщина.

Вспомните перед уходом
  1. 01
    Разложи четыре точки спектра доступа к данным с режимом отказа каждой.
  2. 02
    Объясни конвейер sqlc целиком — источник схемы, что проверяет generate, транзакции и тестовый шов.
Итог

Спектр доступа к данным тянется от сырого database/sql через sqlc и билдеры запросов к полным ORM, и честный инженерный ход — знать цену каждой точки. Сырой доступ платит рутиной и багами позиционного Scan, где две колонки одного типа меняются местами без ошибки. ORM платит семантической дистанцией: пропуск нулевых значений в структурном Updates у GORM — задокументированное, доступное ревью поведение, которое в крючке всё равно никого не деактивировало, потому что выполнившийся SQL в дифф не попадал. sqlc сознательно занимает середину: вы пишете настоящий SQL с аннотациями :one/:many/:exec, а генерация выдаёт типизированные функции, чей порядок Scan не поддерживает ни один человек. Проверка — на этапе генерации против схемы, распарсенной из файлов миграций: схемой владеют golang-migrate или goose, sqlc её читает, и перезапуск generate в CI превращает дрейф в ошибку сборки с координатами вместо ночной загадки. Сгенерированный код едет на той же механике database/sql, что и всё в этом юните: WithTx проводит его через retry-хелпер с пришпиленным соединением, а интерфейс Querier бесплатно даёт тестам хендлеров фейк. Граница резкая: динамические WHERE — территория билдера или аккуратного сырого SQL за тем же интерфейсом репозитория, а отчётный SQL с оконными функциями остаётся сырым, потому что должен. В масштабе команды решает одно свойство: артефакт на ревью и артефакт в проде — одна и та же строка SQL, читаемая, прогоняемая через EXPLAIN и находимая грепом от pg_stat_statements до одного именованного запроса. Теперь, когда смотришь ревью пулл-реквеста с изменениями в базе, задай один вопрос: виден ли тебе SQL, который реально выполнится? Если нет — абстракция пересекла черту, где её цена превышает ценность.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.