Go: SQL

Теория: Чтение данных через sqlc

В sqlc вся логика действительно начинается в .sql-файлах. Разработчик пишет обычный SQL, помечает запросы директивой -- name:, указывает форму результата через суффикс, а все структуры, параметры и методы генератор создаёт сам. Важно сразу договориться о стиле: только явные колонки вместо SELECT *, понятные алиасы под имена полей, стабильный порядок столбцов и плейсхолдеры $1, $2 и так далее для PostgreSQL. Такая дисциплина позволяет sqlc выводить точные типы и делает код устойчивым к изменениям схемы.

Для пользователей удобно завести каталог query/users и положить туда файл users.sql. В этом файле описываются запросы для одной доменной области. Сначала можно добавить короткий запрос, который создаёт запись и сразу возвращает всю строку. Комментарий -- name: задаёт имя будущей функции, а суффикс :one означает, что запрос вернёт одну строку.

-- query/users/users.sql

-- name: CreateUser :one
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id, email, name, created_at;

После генерации появится структура User и метод CreateUser(ctx, params) с типобезопасными полями. Если столбец name допускает NULL, sqlc подберёт подходящий тип: sql.NullString или указатель — в зависимости от настроек конфигурации. В результате сигнатура функции будет соответствовать схеме, а ручной Scan() не понадобится.

Для чтения по естественному ключу запрос оформляют так, чтобы имена колонок совпадали с желаемыми полями структуры. Явные колонки и стабильный порядок избавляют от сюрпризов при миграциях и изменении таблиц.

-- name: GetUserByEmail :one
SELECT id, email, name, created_at
FROM users
WHERE email = $1;

Списки возвращаются в форме :many. Пагинация выражается через LIMIT и OFFSET, передаваемые параметрами, а порядок фиксируется индексируемым столбцом или датой создания. Такая форма делает вывод предсказуемым, а план запроса легко кэшируется СУБД.

-- name: ListUsers :many
SELECT id, email, name, created_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;

Команды без возвращаемых данных помечаются как :exec. Этот контракт говорит генератору, что функция вернёт только ошибку. Если важно знать количество затронутых строк, используют :execrows, и сигнатура дополнится счётчиком. Выбор формы зависит от того, требуется ли проверка числа изменённых записей в бизнес-логике.

-- name: UpdateUserName :exec
UPDATE users
SET name = $2
WHERE id = $1;

-- name: DeleteUser :execrows
DELETE FROM users
WHERE id = $1;

Для сложных выборок из нескольких таблиц имеет смысл завести отдельный файл под конкретную проекцию. Алиасы в SELECT должны совпасть с желаемыми полями результата. Тогда сгенерированная структура получается читаемой, а маппинг остаётся однозначным.

-- query/orders/order_summaries.sql

-- name: ListOrderSummaries :many
SELECT
  o.id           AS order_id,
  u.email        AS user_email,
  o.amount_cents AS amount_cents,
  o.status       AS status,
  o.created_at   AS created_at
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = ANY($1)
ORDER BY o.created_at DESC
LIMIT $2 OFFSET $3;

Когда требуется вернуть идентификатор сразу после вставки, конструкция с RETURNING остаётся лучшим выбором. Такой запрос выполняется атомарно, не требует второго похода в базу и легко типизируется sqlc.

-- name: CreateOrder :one
INSERT INTO orders (user_id, amount_cents, status)
VALUES ($1, $2, $3)
RETURNING id, user_id, amount_cents, status, created_at;

Иногда параметры запроса оказываются двусмысленными для парсера, особенно в выражениях вроде count($1) или при сложных преобразованиях типов. В таких местах полезно явно приводить типы на стороне SQL, чтобы генератор однозначно понимал сигнатуру. В PostgreSQL явные касты через ::type решают проблему и не ухудшают производительность.

-- name: CountOrdersByStatus :one
SELECT COUNT(*)::bigint
FROM orders
WHERE status = $1;

Условия с множеством значений удобно писать в PostgreSQL через = ANY($1) и передавать массив. sqlc по схеме понимает тип массива и генерирует параметр как слайс нужного базового типа. Это позволяет обойтись без динамического построения IN (...) и ручной склейки параметров.

-- name: ListByIDs :many
SELECT id, email, name, created_at
FROM users
WHERE id = ANY($1)
ORDER BY id;

Подготовка данных в CTE делает запросы понятнее и не мешает генерации. sqlc рассматривает итоговый SELECT как источник правды о форме результата и по нему строит тип. При этом сохраняется то же правило: явные имена столбцов и аккуратные алиасы держат код устойчивым к изменениям.

-- name: FindRecentActiveUsers :many
WITH recent AS (
  SELECT id
  FROM logins
  WHERE happened_at >= $1
)
SELECT
  u.id,
  u.email,
  u.name,
  u.created_at
FROM users u
JOIN recent r ON r.id = u.id
ORDER BY u.created_at DESC;

Все эти запросы хранятся в обычных .sql-файлах, сгруппированных по доменам и зонам ответственности. Один файл отвечает за конкретную область, один запрос описывает один контракт через -- name: и суффикс формы результата. Дальше остальное делает sqlc: читает схему из migrations, заходит в query//.sql, выводит точные Go-типы, создаёт методы и избавляет от ручного rows.Next() и Scan().

Генерация кода и подключение в Go

Генерацию запускает сам sqlc. Инструмент читает файл конфигурации sqlc.yaml, грузит схемы, разбирает запросы и по результатам складывает готовые .go-файлы в указанный пакет. После sqlc generate в папке, заданной в out, появляются файлы db.go, models.go и файлы вида users.sql.go.

В приложении остаётся открыть соединение через database/sql, настроить пул, проверить доступность базы и создать объект Queries из сгенерированного пакета. На его основе строится весь доступ к данным. Ниже показан пример для PostgreSQL и драйвера pgx/stdlib.

package main

import (
	"context"
	"database/sql"
	"log"
	"myapp/internal/db"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	dsn := "host=localhost port=5432 user=app password=secret dbname=shop sslmode=disable"

	conn, err := sql.Open("pgx", dsn)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	conn.SetMaxOpenConns(20)
	conn.SetMaxIdleConns(10)
	conn.SetConnMaxLifetime(30 * time.Minute)

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	if err := conn.PingContext(ctx); err != nil {
		log.Fatal("db not reachable:", err)
	}

	q := db.New(conn)
	_ = q // дальше используются методы q.CreateUser, q.GetUserByEmail и другие
}

Сгенерированный код предоставляет методы, привязанные к контексту и типам схемы. Каждый вызов выглядит как бизнес-операция с понятной сигнатурой.

func createDemoUser(ctx context.Context, q *db.Queries) (db.User, error) {
	u, err := q.CreateUser(ctx, db.CreateUserParams{
		Email: "alex@example.com",
		Name:  sql.NullString{String: "Алексей", Valid: true},
	})
	return u, err
}

func getByEmail(ctx context.Context, q *db.Queries, email string) (db.User, error) {
	return q.GetUserByEmail(ctx, email)
}

func listPaged(ctx context.Context, q *db.Queries, limit, offset int32) ([]db.User, error) {
	return q.ListUsers(ctx, db.ListUsersParams{
		Limit:  limit,
		Offset: offset,
	})
}

Транзакции оформляются через обычный *sql.Tx. Сначала открывается транзакция, затем на её основе создаётся «транзакционный» набор методов, и уже он вызывает сгенерированные функции. Такой подход сохраняет атомарность и не требует ручного Exec() и Scan().

func transfer(ctx context.Context, conn *sql.DB, fromID, toID int64, cents int64) error {
	tx, err := conn.BeginTx(ctx, &sql.TxOptions{
		Isolation: sql.LevelReadCommitted,
	})
	if err != nil {
		return err
	}
	defer tx.Rollback()

	qtx := db.New(tx) // те же методы, но все вызовы идут в пределах одной транзакции

	if err := qtx.DebitAccount(ctx, db.DebitAccountParams{
		ID:     fromID,
		Amount: cents,
	}); err != nil {
		return err
	}

	if err := qtx.CreditAccount(ctx, db.CreditAccountParams{
		ID:     toID,
		Amount: cents,
	}); err != nil {
		return err
	}

	return tx.Commit()
}

При включённой опции emit_interface: true sqlc генерирует интерфейс Querier. Тогда сервисный слой принимает абстракцию, а не конкретный тип *Queries. В продакшене передаётся db.New(conn), в тестах — db.New(tx) или фейковая реализация.

type UserService struct {
	q db.Querier
}

func NewUserService(q db.Querier) *UserService {
	return &UserService{q: q}
}

func (s *UserService) Register(ctx context.Context, email string, name *string) (int32, error) {
	params := db.CreateUserParams{Email: email}
	if name != nil {
		params.Name = sql.NullString{String: *name, Valid: true}
	}
	u, err := s.q.CreateUser(ctx, params)
	if err != nil {
		return 0, err
	}
	return u.ID, nil
}

Контекст и таймауты продолжают работать привычным образом. Каждый метод sqlc принимает ctx, поэтому долго выполняющиеся запросы можно ограничивать по времени и при необходимости отменять.

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

user, err := q.GetUserByEmail(ctx, "alex@example.com")
if err != nil {
	log.Fatal(err)
}
log.Println("user:", user.Email)

Генерация повторяется при любых изменениях .sql-файлов. Рабочий цикл остаётся простым: SQL правится в удобных файлах, затем запускается sqlc generate, после чего компилятор Go проверяет совместимость сигнатур. Изменение схемы не размазывается по всему коду, а отражается в одном месте — в SQL и сгенерированных типах.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845