PostgreSQL CookbookПодключение и ориентацияТипизированные запросы через sqlc
0 / 63 (0%)

Типизированные запросы через sqlc

В 00-04 мы прочитали меню Brew из Go руками: pool.Query, цикл rows.Next, rows.Scan в поля структуры по порядку. Код рабочий, но хрупкий — и это не гипотетика:

Ботыр (в чате)
Переставил колонки в SELECT — тесты зелёные, а на витрине три напитка по имени coffee и категория «Латте». Scan промолчал: name и category — обе text, компилятору всё равно.

Таков весь этот класс багов: пока типы совпадают, перепутанный порядок колонок не видят ни компилятор, ни тесты — он всплывает в проде, а не на ревью. Туда же rows.Err(): забудешь — пропустишь ошибку чтения.

Цель юнита — закрыть этот класс целиком: пишем SQL в query.sql, запускаем sqlc generate, и получаем типизированный Go-код, в котором порядок и типы колонок сверены со схемой на этапе сборки. Этот конвейер в общих чертах уже знаком тебе по 00-02; здесь мы разбираем его как рабочий инструмент — с параметром $1, тремя формами результата и тем самым boilerplate'ом из 00-04, который теперь генерируется.

Что такое sqlc (и чем он не является)

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

Ботыр
Так это же ORM, только сбоку?
Дмитрий
Нет. SQL остаётся твоим. Генерируется только обвязка.

Вердикт Дмитрия стоит развернуть. sqlc не прячет запросы за «магическим» API вроде .Where("category", cat).First() — ты по-прежнему пишешь SELECT ... WHERE category = $1, и именно этот SQL уезжает на сервер. sqlc убирает только механический разбор строк — тот самый цикл Scan, который мы писали в 00-04.

Это не ORM: sqlc не управляет связями, не делает lazy loading, не строит запросы динамически и не накатывает миграции (схемой и миграциями займётся модуль 02). Он делает ровно одно — превращает написанный руками SQL в типобезопасный Go. Поэтому SQL и остаётся в центре курса.

query.sql: аннотации и формы результата

Запрос для sqlc — это обычный SQL плюс строка-аннотация над ним:

sql
-- name: ListDrinksByCategory :many
SELECT id, sku, name, category, base_price
FROM drinks
WHERE category = $1
ORDER BY id;

-- name: ListDrinksByCategory задаёт имя метода. Суффикс — форму результата:

СуффиксЧто возвращает методКогда брать
:many[]XxxRow — срез строк (пустой, если совпадений нет)SELECT, отдающий 0..N строк
:oneXxxRow — одна строка (или pgx.ErrNoRows, если её нет)SELECT ровно одной строки
:one (скаляр)сам тип колонки: count(*) → int64, без обёртки-структурыодна колонка в одной строке
:execтолько errorINSERT/UPDATE/DELETE без RETURNING

А $1 — параметр из 00-04, но теперь о нём заботится sqlc. Глядя на схему, он видит, что drinks.category — это text, и генерирует метод с аргументом category string. Имя параметра он берёт из колонки в условии — поэтому метод читается как ListDrinksByCategory(ctx, category string), а не (ctx, arg1 string).

Что показывает наш код

Из трёх запросов в query.sql sqlc сгенерировал три метода. Сигнатуры (из internal/db/querier.go) говорят сами за себя:

go
ListDrinksByCategory(ctx, category string) ([]ListDrinksByCategoryRow, error)  // :many
GetDrinkBySKU(ctx, sku string)             (GetDrinkBySKURow, error)            // :one
CountDrinksByCategory(ctx, category string) (int64, error)                      // :one (скаляр)

Параметры типизированы (category string, sku string), результат — тоже: :many отдаёт срез структур, :one — одну структуру, а :one со скаляром (count(*)) — сразу int64, без лишней обёртки. Внутри сгенерированного ListDrinksByCategory лежит ровно тот цикл Query → rows.Next → Scan → rows.Err из 00-04 — только написал его не ты, а генератор, и ошибиться в порядке Scan он не может.

main.go после этого — тонкий, как в 00-02:

go
queries := db.New(pool)
coffee, err := queries.ListDrinksByCategory(ctx, "coffee")   // :many
cold, err := queries.GetDrinkBySKU(ctx, "CLD-01")            // :one
teaCount, err := queries.CountDrinksByCategory(ctx, "tea")   // :one, скаляр

Никакого rows.Scan, никаких SQL-строк в Go-коде. Если завтра ты добавишь колонку в SELECT и забудешь обновить разбор — после make gen тип просто изменится, и компилятор покажет все места, где сигнатура разъехалась. В этом вся разница с 00-04: ошибка ловится сборкой, а не пользователем.

Запуск

Подними песочницу (из корня репозитория) и накати схему Brew:

sh
docker compose up -d
make lecture L=00-getting-connected/00-05-typed-queries-with-sqlc T=db-reset

Перегенерируй код из query.sql (по желанию — он уже закоммичен) и запусти демо:

sh
make lecture L=00-getting-connected/00-05-typed-queries-with-sqlc T=gen
make lecture L=00-getting-connected/00-05-typed-queries-with-sqlc

(T=run — значение по умолчанию. Изнутри каталога юнита это make gen, make db-reset, make run.)

Вывод:

plaintext
1) ListDrinksByCategory("coffee") — :many, $1 типизирован как string:
ID  SKU     НАЗВАНИЕ  КАТЕГОРИЯ  ЦЕНА
1   ESP-01  Эспрессо  coffee     3.00
2   CAP-01  Капучино  coffee     4.50
3   LAT-01  Латте     coffee     4.80
 
2) GetDrinkBySKU("CLD-01") — :one, одна строка:
   #4  CLD-01  Колд брю  (cold)  5.20
 
3) CountDrinksByCategory("tea") — :one, скаляр int64:  1

:many вернул три напитка категории coffee, :one — одну строку по SKU, :one-скаляр — число 1 (зелёный чай в меню один). Те же данные, что в 00-04, но из кода исчез весь ручной разбор.

Заборчик

  • sqlc типобезопасен ровно настолько, насколько правдива его схема. Он сверяет запросы с тем DDL, что перечислен в sqlc.yaml (../../../schema/brew.sql + schema.sql), — если реальная база разошлась со схемой в файлах, sqlc об этом не узнает (он не подключается к БД при генерации). Поэтому источник правды о структуре — миграции (модуль 02), а файлы схемы в sqlc.yaml должны идти с ними в ногу.
  • sqlc — дефолт курса, но не догма. Когда запросу нужны системные колонки (xmin/ctid), динамический SQL, EXPLAIN или интерактивные сессии — sqlc не подходит, и урок переключается на escape-hatch (raw pgx или psql-скрипты, как в 00-03).
  • Сгенерированный код коммитим: он ревьюится в diff'е и не требует запуска sqlc на чужой машине.

Что забрать с собой

  • sqlc — кодогенератор, а не ORM: ты пишешь SQL руками, он лишь убирает ручной rows.Scan и даёт типобезопасные методы. SQL остаётся в центре.
  • Аннотация -- name: X :many|:one|:exec задаёт имя метода и форму результата; $1 типизируется и именуется из схемы (category string).
  • Ошибка в колонках/типах ловится компилятором после make gen, а не в рантайме у пользователя — это и есть выигрыш над ручным разбором из 00-04.
  • Сгенерированный internal/db/ коммитим; make gen воспроизводим (повторный прогон не даёт diff).

Дальше — юнит 00-06 «жизненный цикл соединения и пулинг»: мы пользовались пулом как чёрным ящиком (pg.NewPool), а теперь заглянем внутрь — сколько соединений он реально держит, когда открывает и закрывает их, и как увидеть свои бэкенды в pg_stat_activity.

·Модуль 01

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Подключение и ориентация / Типизированные запросы через sqlc