Опирается на правила: R-SQLC-QF-1R-SQLC-QF-5 и R-SQLC-QF-X1R-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 — минимум три позиции: uuidgithub.com/google/uuid.UUID, pg_catalog.numericgithub.com/shopspring/decimal.Decimal, timestamptztime.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)список, может быть пустым
:execerrorINSERT/UPDATE/DELETE без возврата
:execrows(int64, error)INSERT/UPDATE/DELETE + число затронутых строк
:batchexec*BatchResultsbulk-операция через 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 — критический блок. Без него:

  • uuidstring (нет типобезопасности, теряется семантика UUID)
  • numericstring или float64 (потеря точности для денег)
  • timestamptztime.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 = " + idR-SQLC-QF-X1Запрос в *.sql-файле, параметр через $1
Ручная правка db/*.goR-SQLC-QF-X2sqlc generate перезапишет; правка только через *.sql
sqlc.yaml без overrides для UUID/numeric/timestamptzR-SQLC-QF-X3Блок overrides обязателен, иначе UUID → string, деньги → float64
sql.NullString / sql.NullInt64 для nullableR-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 в Gopgx.ErrNoRows → доменная NotFoundError, 23505 → конфликт, обёртка через fmt.Errorf("%w").