Go: SQL

Теория: Введение в sqlc

sqlc — это инструмент, который превращает SQL-запросы в готовый Go-код. Он читает .sql-файлы, анализирует запросы и на их основе генерирует функции, структуры и типы. Сгенерированный код использует обычный пакет database/sql, поэтому его можно применять в любом Go-проекте без дополнительной магии. sqlc выступает мостом между «чистым SQL» и типобезопасным кодом на Go.

Главная идея sqlc проста. Пишите SQL руками, как в консоли или в миграциях, а генератор берёт на себя рутину: создаёт структуры для результатов, типы параметров, функции для вызова запросов и маппинг колонок в поля. Вместо ручного QueryContext(), Scan() и sql.NullString появляются методы вроде CreateUser() или GetUserByEmail() с нормальными Go-типами и проверкой на этапе компиляции.

Пример работы sqlc

Создадим в базе таблицу пользователей.

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    name TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

В файле query/users.sql описываются запросы, которые нужно сгенерировать.

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

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

Директива -- name говорит sqlc, как назвать функцию и какой контракт у запроса. Суффикс :one означает, что запрос должен вернуть одну строку, а при пустом результате будет ошибка.

После генерации sqlc создаёт структуры и методы.

type User struct {
	ID        int32
	Email     string
	Name      sql.NullString
	CreatedAt time.Time
}

type CreateUserParams struct {
	Email string
	Name  sql.NullString
}

func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error)

Теперь работа с базой сводится к вызову обычных функций.

db, _ := sql.Open("pgx", dsn)
queries := New(db)

user, err := queries.CreateUser(ctx, CreateUserParams{
	Email: "alex@example.com",
	Name:  sql.NullString{String: "Алексей", Valid: true},
})
if err != nil {
	log.Fatal(err)
}
fmt.Println(user.ID, user.CreatedAt)

u, err := queries.GetUserByEmail(ctx, "alex@example.com")
if err != nil {
	log.Fatal(err)
}
fmt.Println("найден:", u.Email, u.Name.String)

Здесь нет ручного Scan(), нет rows.Close(), нет риска перепутать порядок колонок или тип. Типы проверяются компилятором, а соответствие колонок и полей один раз фиксируется генератором.

Зачем нужен sqlc и чем он отличается от ручной работы

Ручная работа с database/sql даёт полный контроль над запросами. Код сам решает, когда открывать соединение, как профилировать сложный SQL и какие конструкции строить динамически. Но такой подход создаёт много шаблонного кода. Каждая выборка требует Scan(), каждое изменение — QueryRow() с ручным маппингом, а любая ошибка в порядке колонок или типе всплывает только в рантайме.

При «голом» database/sql программист описывает структуры, пишет SQL как строку, следит за колоночными позициями и руками обрабатывает NULL-значения.

var u User

err := db.QueryRowContext(ctx,
	`SELECT id, email, name FROM users WHERE id = $1`,
	id,
).Scan(&u.ID, &u.Email, &u.Name)
if err != nil {
	// обработка ошибки
}

С sqlc код сводится к одному вызову функции.

u, err := queries.GetUser(ctx, id)
if err != nil {
	// обработка ошибки
}

Внутри по-прежнему работает database/sql и тот же драйвер. Меняется только то, что типобезопасный слой сгенерирован автоматически и не требует ручного Scan.

sqlc сохраняет контроль над SQL. Запросы по-прежнему пишутся руками, без скрытой ORM. При этом инструмент добавляет типобезопасность: структура результата и типы параметров проверяются компилятором. Объём повторяющегося кода сокращается, а изменения в .sql-файлах после sqlc generate автоматически попадают в Go-слой.

Установка sqlc

Сам sqlc написан на Go, поэтому устанавливается через стандартный go install.

go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

Эта команда скачивает исходники и собирает бинарь sqlc в каталог $GOPATH/bin или ~/go/bin, если используется путь по умолчанию. Чтобы инструмент был доступен из терминала, этот каталог добавляют в переменную PATH. Проверка установки выполняется через простой вызов.

sqlc version

В ответ отображается версия и список поддерживаемых языков. В контексте Go-проекта достаточно того, что среди них есть Go.

Базовая настройка и структура проекта

Конфигурация sqlc описывается в файле sqlc.yaml в корне проекта. Этот файл говорит генератору, где лежат миграции и запросы, какую СУБД использовать и в какой пакет складывать сгенерированный код.

Простейшая конфигурация для PostgreSQL выглядит так.

version: "2"
sql:
  - engine: "postgresql"
    schema: "migrations"
    queries: "query"
    gen:
      go:
        package: "db"
        out: "internal/db"

Версия указывает формат конфигурации. Блок sql — это список описаний схем, потому что в одном проекте можно генерировать код для нескольких баз данных. Каждый элемент списка связывает конкретную схему, набор запросов и параметры генерации.

Параметр engine задаёт тип СУБД. Для разных движков используются разные значения: postgresql, mysql, sqlite, sqlserver. Выбор определяет, как sqlc интерпретирует типы и особенности синтаксиса.

Поле schema указывает путь к SQL-файлам со схемой базы. Обычно сюда попадает каталог с миграциями. В этих файлах описываются таблицы, типы, индексы и внешние ключи. sqlc читает их, чтобы понять, какие SQL-типы нужно сопоставить Go-типам.

Поле queries описывает путь к SQL-файлам с запросами. Здесь лежат SELECT, INSERT, UPDATE и DELETE, снабжённые директивами -- name:. На основе этих файлов sqlc генерирует функции и структуры.

Блок gen.go управляет кодогенерацией. В нём задаётся имя пакета и путь, куда положить сгенерированные .go-файлы. Пакет попадёт в директиву package, а out определит каталог, внутри которого появятся db.go, models.go и файлы с реализацией запросов.

Типичная структура небольшого проекта с sqlc выглядит так.

project/
├── migrations/
│   └── 001_init.sql
├── query/
│   ├── users.sql
│   └── products.sql
├── internal/
│   └── db/
│       ├── db.go
│       ├── models.go
│       └── users.sql.go
└── sqlc.yaml

В migrations хранятся файлы схемы. В query — запросы для разных сущностей. В internal/db sqlc складывает сгенерированный Go-код, который потом импортируется в сервис.

Директивы в запросах и тип результата

Комментарии вида -- name: внутри .sql-файлов управляют тем, какие функции и с какими контрактами создаст sqlc.

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

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

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

Суффикс :one означает, что запрос должен вернуть одну строку, и генератор создаст функцию, возвращающую структуру и ошибку. Суффикс :many указывает на множество строк, и тогда sqlc генерирует функцию, которая возвращает срез структур. Суффикс :exec нужен для команд, которые не возвращают данных, например для UPDATE или DELETE. Для подсчёта числа затронутых строк используется :execrows.

Генерация кода и использование в приложении

После настройки конфигурации и написания SQL остаётся запустить генерацию.

sqlc generate

Инструмент прочитает файл sqlc.yaml, обработает схему и запросы и создаст Go-код в указанной директории. В пакет попадут модели для таблиц, обёртка над соединением и методы, соответствующие директивам -- name:.

Дальше код подключается к базе и использует сгенерированный пакет как обычный модуль доступа к данным.

package main

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

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

func main() {
	conn, err := sql.Open("pgx", "host=localhost dbname=shop user=app password=secret sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	q := db.New(conn)

	user, err := q.CreateUser(context.Background(), db.CreateUserParams{
		Email: "alex@example.com",
		Name:  sql.NullString{String: "Алексей", Valid: true},
	})
	if err != nil {
		log.Fatal(err)
	}

	log.Println("created:", user.ID, user.Email)
}

Соединение остаётся обычным sql.DB. Методы, сгенерированные sqlc, вызываются как обычные функции, но скрывают внутри себя все детали Scan() и проверки типов.

Дополнительные настройки sqlc.yaml

Конфигурация sqlc не ограничивается базовыми полями. В блоке gen.go можно включать дополнительные опции, влияющие на удобство работы с кодом.

Опция emit_json_tags добавляет JSON-теги к полям структур. Это удобно, когда одни и те же типы используются и для работы с базой, и для выдачи ответов через HTTP или сериализации в JSON.

gen:
  go:
    package: "db"
    out: "internal/db"
    emit_json_tags: true

Опция emit_interface генерирует интерфейс Querier с методами, соответствующими запросам. Такой интерфейс пригодится для тестирования и внедрения зависимостей, потому что сервисы могут принимать Querier вместо конкретной реализации.

gen:
  go:
    package: "db"
    out: "internal/db"
    emit_interface: true

Опция emit_pointers_for_null_types позволяет получать *string и *int вместо sql.NullString и других «null»-типов. Этот режим удобен там, где выше по стеку данные всё равно конвертируются в указатели или в JSON-структуры с указателями.

Если названия SQL-типов пересекаются с ключевыми словами Go или не подходят под соглашения проекта, конфигурация поддерживает переименование. Для этого используется раздел rename, где можно задать новое имя для сгенерированного типа.

Проверка конфигурации перед генерацией выполняется командой sqlc vet. Эта команда анализирует схему, запросы и связи, но не создаёт код. После успешной проверки можно запускать sqlc generate.

Разделение на пакеты и директории

В простых проектах обычно достаточно одного пакета с доступом к базе и одного каталога с запросами. По мере роста системы удобнее делить схему и запросы по подсистемам и генерировать отдельные пакеты для каждой области.

Базовый вариант для небольшого сервиса выглядит так.

app/
  sqlc.yaml
  migrations/
    001_init.sql
  query/
    users.sql
    products.sql
  internal/
    db/
      (сюда `sqlc` положит *.go)

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

app/
  sqlc.yaml
  migrations/
    users/
      001_users.sql
    orders/
      001_orders.sql
  query/
    users/
      users_read.sql
      users_write.sql
    orders/
      orders_read.sql
      orders_write.sql
  internal/
    db/
      users/   ← package usersdb
      orders/  ← package ordersdb

Конфигурация для нескольких пакетов описывается несколькими элементами списка в sqlc.yaml.

version: "2"
sql:
  - engine: postgresql
    schema: "migrations/users"
    queries: "query/users"
    gen:
      go:
        package: "usersdb"
        out: "internal/db/users"
        emit_json_tags: true
        emit_interface: true
  - engine: postgresql
    schema: "migrations/orders"
    queries: "query/orders"
    gen:
      go:
        package: "ordersdb"
        out: "internal/db/orders"
        emit_json_tags: true
        emit_interface: true

В таком варианте каждая подсистема получает свой пакет, свой набор моделей и функций и свою конфигурацию. Импорты между ними остаются явными, а границы ответственности — понятными.

Организация .sql-файлов

Файлы с запросами удобнее группировать по назначению. Один файл может отвечать за чтение, другой — за запись. Это упрощает навигацию и ревью.

query/
  users/
    users_read.sql
    users_write.sql

В файле users_read.sql хранятся выборки и отчёты.

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

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

В файле users_write.sql собраны вставки и обновления.

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

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

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

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

Алиасы в SELECT должны совпадать с желаемыми именами полей результата. Это избавляет от «звёздочек» и делает изменения схемы управляемыми: изменение имени колонки отражается сначала в запросе, затем в сгенерированном типе.

Подведем итоги

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

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

+7 800 100 22 47

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

+7 495 085 21 62

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

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