Опирается на правила:
R-SQLC-QF-1…R-SQLC-QF-5иR-SQLC-QF-X1…R-SQLC-QF-X3из sqlc Style Guide → раздел 2. sqlc codegen и query-файлы. Здесь — те же правила подробно, с примерами и контекстом.
Важно знать
- SQL-запросы живут в
*.sql-файлах подdb/queries/— не в Go-строках. Сгенерированный Go-код появляется вdb/черезsqlc generate.- Каждый запрос обязательно аннотирован:
-- name: <Name> :one|:many|:exec|:execrows|:batchexec. Имя — действие + контекст:GetOrderByID,ListActiveOrders,InsertOrder.sqlc.yamlзадаётengine: postgresql,emit_json_tags: true,emit_pointers_for_null_fields: trueи блокoverrides— без него UUID деградирует доstring, числа доfloat64.- Nullable-колонки — через
pgtype.*или кастомный тип изoverrides.sql.NullString/sql.NullInt64— запрещены.- Сгенерированные файлы
db/*.goне редактируются вручную — они перезаписываются каждымsqlc generate.- Конфигурация
overrides— минимум три позиции:uuid→github.com/google/uuid.UUID,pg_catalog.numeric→github.com/shopspring/decimal.Decimal,timestamptz→time.Time.- Статус сгенерированного
db/(в.gitignoreили коммитится) фиксируется один раз вsqlc.yamlи CI и больше не меняется.
sqlc — это компилятор: он читает SQL из *.sql-файлов, проверяет запросы против схемы из миграций и генерирует типобезопасный Go-код. В отличие от jOOQ, который строит запросы через Java-DSL во время выполнения, sqlc фиксирует SQL статически — ошибки типов и несоответствие схеме ловятся на этапе sqlc generate, до компиляции Go.
Query-файлы: структура и аннотации
R-SQLC-QF-1: SQL только в *.sql-файлах, не строках Go-кода. R-SQLC-QF-2: каждый запрос аннотирован.
-- db/queries/orders.sql
-- name: GetOrderByID :one
SELECT id, customer_id, amount, status, created_at
FROM orders
WHERE id = $1;
-- name: InsertOrder :exec
INSERT INTO orders (id, customer_id, amount, status, created_at)
VALUES ($1, $2, $3, $4, $5);
-- name: ListOrdersByCustomer :many
SELECT id, customer_id, amount, status, created_at
FROM orders
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: UpdateOrderStatus :exec
UPDATE orders
SET status = $2
WHERE id = $1;
-- name: DeleteOrder :exec
DELETE FROM orders WHERE id = $1;
Аннотация -- name: <Name> :action определяет, что сгенерирует sqlc:
| Действие | Возвращает | Когда брать |
|---|---|---|
:one | (Row, error) | ровно одна строка ожидается |
:many | ([]Row, error) | список, может быть пустым |
:exec | error | INSERT/UPDATE/DELETE без возврата |
:execrows | (int64, error) | INSERT/UPDATE/DELETE + число затронутых строк |
:batchexec | *BatchResults | bulk-операция через pgx batch |
Имя запроса GetOrderByID — не случайный выбор. sqlc генерирует Go-метод с этим именем на структуре *Queries. Имя должно отражать действие и контекст, не технику: GetOrderByID — да, SelectFromOrdersWhereIdEquals — нет.
sqlc.yaml: конфигурация codegen
R-SQLC-QF-3: обязательные поля конфигурации.
# sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "db/queries"
schema: "db/migrations"
gen:
go:
package: "db"
out: "db"
emit_json_tags: true
emit_pointers_for_null_fields: true
overrides:
- db_type: "uuid"
go_type: "github.com/google/uuid.UUID"
- db_type: "pg_catalog.numeric"
go_type: "github.com/shopspring/decimal.Decimal"
- db_type: "timestamptz"
go_type: "time.Time"
Разберём каждую настройку:
schema: "db/migrations" — sqlc читает схему из SQL-миграций, не из живой БД. Это означает, что sqlc generate воспроизводимо в CI без поднятого PostgreSQL. Порядок применения миграций определяется именами файлов (001_create_orders.sql, 002_add_status.sql).
emit_json_tags: true — генерирует json:"..." теги на структурах. Нужно, если сгенерированные структуры используются как DTO (на практике — не должны, но теги не вредят).
emit_pointers_for_null_fields: true — nullable-колонки становятся указателями: *string, *uuid.UUID, *decimal.Decimal. Без этого nullable вернётся как нулевое значение без различения NULL и пустой строки.
overrides — критический блок. Без него:
uuid→string(нет типобезопасности, теряется семантика UUID)numeric→stringилиfloat64(потеря точности для денег)timestamptz→time.Time(без явного override тожеtime.Time, но override гарантирует это)
-- db/queries/products.sql
-- name: GetProductByID :one
SELECT id, name, price, customer_id, created_at
FROM products
WHERE id = $1;
-- name: ListProductsByCustomer :many
SELECT id, name, price, customer_id, created_at
FROM products
WHERE customer_id = $1
ORDER BY created_at DESC;
-- name: InsertProduct :exec
INSERT INTO products (id, name, price, customer_id, created_at)
VALUES ($1, $2, $3, $4, $5);
Сгенерированный код для GetProductByID :one будет выглядеть так:
// db/products.sql.go (генерируется sqlc, не редактируется вручную)
const getProductByID = `-- name: GetProductByID :one
SELECT id, name, price, customer_id, created_at
FROM products
WHERE id = $1
`
type GetProductByIDRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Price decimal.Decimal `json:"price"`
CustomerID uuid.UUID `json:"customer_id"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) GetProductByID(ctx context.Context, id uuid.UUID) (GetProductByIDRow, error) {
row := q.db.QueryRow(ctx, getProductByID, id)
var i GetProductByIDRow
err := row.Scan(&i.ID, &i.Name, &i.Price, &i.CustomerID, &i.CreatedAt)
return i, err
}
Типы uuid.UUID и decimal.Decimal появились именно из блока overrides. Без него было бы string и string.
Nullable-поля через pgtype
R-SQLC-QF-4: nullable — через pgtype.*, не sql.Null*.
Когда колонка может быть NULL, emit_pointers_for_null_fields: true даёт указатель. Для полей, где нужна полная семантика pgx (включая различение NULL в сложных типах), используется pgtype.*:
# sqlc.yaml — расширенный override для nullable-полей
overrides:
- db_type: "uuid"
go_type: "github.com/google/uuid.UUID"
- db_type: "pg_catalog.numeric"
go_type: "github.com/shopspring/decimal.Decimal"
- db_type: "timestamptz"
go_type: "time.Time"
- db_type: "text"
nullable: true
go_type: "github.com/jackc/pgx/v5/pgtype.Text"
-- db/queries/customers.sql
-- name: GetCustomerByID :one
SELECT id, name, email, sber_id, created_at
FROM customers
WHERE id = $1;
Если sber_id — nullable text, с pgtype.Text из override поле будет pgtype.Text со структурой {String string; Valid bool}. С emit_pointers_for_null_fields и базовым override оно станет *string.
sql.NullString / sql.NullInt64 из стандартной библиотеки не используются: они из database/sql, а pgx/v5 работает напрямую с pgtype без database/sql-интерфейса.
Сгенерированный db/ в системе контроля версий
R-SQLC-QF-5: статус сгенерированного кода фиксируется один раз.
Два допустимых подхода:
Коммитить db/ в репозиторий. CI запускает sqlc generate и проверяет через git diff --exit-code db/ — если diff есть, значит *.sql изменились без перегенерации, сборка падает. Преимущество: сгенерированный код видим в PR-ревью, чтения sqlc.yaml и *.sql достаточно для понимания изменения.
Добавить db/ в .gitignore. CI генерирует db/ перед go build. Чище по истории, но сложнее дебаггить — что именно сгенерировалось, не видно в PR.
Оба подхода рабочие. Выбор фиксируется в sqlc.yaml (комментарий или отдельная sqlc.yml в CI) и в Makefile/Taskfile. После выбора не меняется.
# Makefile
.PHONY: sqlc
sqlc:
sqlc generate
.PHONY: sqlc-verify
sqlc-verify:
sqlc generate
git diff --exit-code db/
Несколько query-файлов по доменам
Один db/queries/ может содержать несколько .sql-файлов. Разбивка — по агрегатам или логическим группам:
db/
queries/
orders.sql -- GetOrderByID, InsertOrder, ListOrdersByCustomer
products.sql -- GetProductByID, InsertProduct, ListProductsByCustomer
customers.sql -- GetCustomerByID, InsertCustomer
order_items.sql -- ListOrdersWithItems, InsertOrderItem
migrations/
001_create_orders.sql
002_create_products.sql
003_create_customers.sql
orders.sql.go -- генерируется
products.sql.go -- генерируется
customers.sql.go -- генерируется
db.go -- Queries struct, New(db DBTX)
models.go -- общие типы (если есть)
sqlc создаёт по одному .go-файлу на каждый .sql-файл с запросами. Структура *Queries одна для всего пакета db.
Запрос с несколькими параметрами: именованные params
Для запросов с несколькими параметрами sqlc генерирует *Params-структуру:
-- db/queries/orders.sql
-- name: ListOrdersByCustomerWithPagination :many
SELECT id, customer_id, amount, status, created_at
FROM orders
WHERE customer_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4;
Сгенерированный метод:
type ListOrdersByCustomerWithPaginationParams struct {
CustomerID uuid.UUID `json:"customer_id"`
Status string `json:"status"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListOrdersByCustomerWithPagination(
ctx context.Context,
arg ListOrdersByCustomerWithPaginationParams,
) ([]ListOrdersByCustomerWithPaginationRow, error) {
// ...
}
Вызов из репозитория:
rows, err := r.q.ListOrdersByCustomerWithPagination(ctx, db.ListOrdersByCustomerWithPaginationParams{
CustomerID: customerID,
Status: string(order.StatusActive),
Limit: int32(limit),
Offset: int32(offset),
})
Параметры по имени — не по позиции. Это делает вызов читаемым и защищает от перестановки аргументов.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
SQL-строка в Go-коде: "SELECT ... WHERE id = " + id | R-SQLC-QF-X1 | Запрос в *.sql-файле, параметр через $1 |
Ручная правка db/*.go | R-SQLC-QF-X2 | sqlc generate перезапишет; правка только через *.sql |
sqlc.yaml без overrides для UUID/numeric/timestamptz | R-SQLC-QF-X3 | Блок overrides обязателен, иначе UUID → string, деньги → float64 |
sql.NullString / sql.NullInt64 для nullable | R-SQLC-QF-4 (нарушение) | pgtype.Text / pgtype.Int4 или указатель через emit_pointers_for_null_fields |
Запрос без аннотации -- name: | R-SQLC-QF-2 (нарушение) | Каждый запрос аннотирован; без аннотации sqlc его игнорирует |
Куда дальше
- Repository pattern в sqlc (Go) — как
*db.Queriesинжектируется вPostgresOrderRepositoryи почему наружу идут только доменные объекты (R-SQLC-REPO-*). - Маппинг sqlc ↔ domain (Go) — функции
toDomain/toInsertParamsрядом с репозиторием, сборка агрегата из flat-результата вtoOrdersWithItems(R-SQLC-MAP-*). - Транзакции в sqlc (Go) — граница транзакции на Handler,
WithTx(pgx.Tx),defer tx.Rollbackи почемуBeginв репозитории запрещён (R-SQLC-TX-*). - Ошибки persistence в Go —
pgx.ErrNoRows→ доменнаяNotFoundError,23505→ конфликт, обёртка черезfmt.Errorf("%w").