sqlc: типобезопасный SQL в Go без магии ORM
Наверное, я не один такой: после долгой работы с Django, когда понадобилось писать код на Go, первым делом начал искать «нормальную ORM». После удобства Django ORM казалось, что без похожего инструмента работать с базой данных будет неудобно.
Но в Go всё оказалось иначе. Здесь сильна культура явного кода, и со временем я пришёл к выводу, что наиболее удобный инструмент для работы с базой данных — вовсе не ORM. Это sqlc.
Возникает закономерный вопрос: почему не использовать database/sql или привычную ORM?
При работе с database/sql приходится писать много однотипного кода: описывать структуры параметров, сканировать результаты запросов, обрабатывать ошибки и выполнять другую рутинную работу.
ORM решает часть этих проблем, но делает это за счёт дополнительных абстракций. Она хорошо подходит для простых CRUD-операций, однако по мере усложнения запросов — с JOIN, оконными функциями, CTE или возможностями, специфичными для PostgreSQL, — вы либо начинаете бороться с ограничениями ORM, либо всё равно пишете обычный SQL, только уже внутри её API.
sqlc занимает промежуточное положение. Вы продолжаете писать обычный SQL, но получаете типобезопасный Go-код, который генерируется автоматически. В результате не приходится жертвовать возможностями SQL или писать большое количество шаблонного кода.
В этой статье я покажу, что такое sqlc, зачем он нужен, как его настроить и использовать на практике. Также разберу несколько неочевидных моментов и честно расскажу о его ограничениях.
В экосистеме Go есть и полноценные ORM, например GORM. Я тоже использую его в некоторых проектах, но в этой статье речь пойдёт именно о
sqlc.
Что такое sqlc
sqlc — это генератор кода.
Вы пишете обычный SQL в .sql файлах, а sqlc на основе схемы вашей базы генерирует типобезопасный Go-код. Он анализирует SQL-запросы и создает Go-функции, структуры и методы для работы с ними. Вместо строковых запросов, разбросанных по проекту, вы получаете обычный Go-код с типами, которые проверяются компилятором.
Схема работы выглядит так:
- Вы пишете SQL.
- Запускаете
sqlc generate. - Получаете готовый Go-код.
- Используете этот код в приложении.
Например, вместо такого кода:
row := db.QueryRow(
context.Background(),
"SELECT id, name, email FROM users WHERE id = $1",
userID,
)
var user User
err := row.Scan(
&user.ID,
&user.Name,
&user.Email,
)
можно писать:
user, err := queries.GetUser(ctx, userID)
Весь код для выполнения SQL и сканирования данных будет сгенерирован автоматически.
Установка
Поставить можно через Go:
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
Либо через пакетный менеджер (Homebrew, например), либо запускать в Docker — тогда на машине разработчика вообще ничего ставить не нужно, что удобно для CI.
Сейчас я в основном работаю на Mac, поэтому ставлю через Homebrew:
brew install sqlc
После выполнения можно проверить установку:
sqlc version
Минимальный проект
Покажу полный цикл на маленьком примере. Допустим, у нас есть таблица авторов. Создадим папку db, в ней папку sqlc с тремя файлами (вы можете сделать структуру папок по-другому, я показываю как предпочитаю делать сам):
sqlc.yaml — конфигурация sqlc:
version: "2"
sql:
- engine: "postgresql"
queries: "query.sql"
schema: "schema.sql"
gen:
go:
package: "db"
out: "."
sql_package: "pgx/v5"
emit_json_tags: true
schema.sql — описание схемы. Удобно, что здесь можно прямо переиспользовать ваши файлы миграций:
CREATE TABLE authors (
id BIGSERIAL PRIMARY KEY,
name text NOT NULL,
bio text
);
query.sql — запросы. Вот тут начинается самое интересное. Каждый запрос аннотируется комментарием с именем метода и типом результата:
-- name: GetAuthor :one
SELECT * FROM authors
WHERE id = $1 LIMIT 1;
-- name: ListAuthors :many
SELECT * FROM authors
ORDER BY name;
-- name: CreateAuthor :one
INSERT INTO authors (name, bio)
VALUES ($1, $2)
RETURNING *;
-- name: DeleteAuthor :exec
DELETE FROM authors
WHERE id = $1;
Разберем запись:
-- name: GetAuthor :one
Здесь:
- GetAuthor — имя будущей функции.
:one— запрос возвращает одну запись.
:one — запрос вернёт одну строку (метод отдаст структуру);:many — вернёт срез структур;:exec — ничего не возвращает, только выполняется.
Есть и другие варианты, например :execrows, если нужно количество затронутых строк.
Теперь, находясь в папке sqlc, запускаем генерацию:
sqlc generate
В папке db появятся три файла: models.go со структурой Author, db.go с базовой обвязкой и query.sql.go с методами. Сгенерированный метод выглядит примерно так:
const getAuthor = `-- name: GetAuthor :one
SELECT id, name, bio FROM authors
WHERE id = $1 LIMIT 1
`
func (q *Queries) GetAuthor(ctx context.Context, id int64) (Author, error) {
row := q.db.QueryRow(ctx, getAuthor, id)
var i Author
err := row.Scan(&i.ID, &i.Name, &i.Bio)
return i, err
}
Обратите внимание: SELECT * в исходнике sqlc сам развернул в явный список колонок. Это решает ту самую проблему со звёздочкой — сгенерированный код всегда знает точный набор полей.
Как это использовать в коде
Подключаемся к базе и вызываем сгенерированные методы. Для подключения я использую pgxpool:
package main
import (
"context"
"log"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"hello/db"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, "postgres://postgres_user:password@localhost:5432/postgres_test")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
queries := db.New(pool)
author, err := queries.CreateAuthor(ctx, db.CreateAuthorParams{
Name: "Лев Толстой",
Bio: pgtype.Text{String: "Писатель", Valid: true},
})
if err != nil {
log.Fatal(err)
}
got, err := queries.GetAuthor(ctx, author.ID)
if err != nil {
log.Fatal(err)
}
log.Println(got.Name)
}
Если запрос принимает больше одного параметра, sqlc генерирует под него структуру ...Params — как CreateAuthorParams выше. Это удобнее позиционных аргументов: видно, что куда передаёшь, и порядок не перепутаешь.
pgxpool— это реализация пула соединений, входящая в составpgx. Пул автоматически создаёт и поддерживает набор открытых соединений с PostgreSQL, выдавая их по мере необходимости. Благодаря этому не нужно вручную управлять жизненным циклом соединений, а выполнение большого количества запросов становится значительно эффективнее.
Работа с транзакциями
Один из плюсов sqlc заключается в том, что он отлично работает с транзакциями.
Пример на основе main.go описанного выше:
tx, err := pool.Begin(ctx)
if err != nil {
log.Fatal(err)
}
defer tx.Rollback(ctx) // если Commit уже вызван — Rollback станет no-op
qtx := queries.WithTx(tx)
author, err := qtx.CreateAuthor(ctx, db.CreateAuthorParams{
Name: "Лев Толстой",
Bio: pgtype.Text{String: "Писатель", Valid: true},
})
if err != nil {
log.Fatal(err)
}
// ещё один запрос в той же транзакции
got, err := qtx.GetAuthor(ctx, author.ID)
if err != nil {
log.Fatal(err)
}
if err := tx.Commit(ctx); err != nil {
log.Fatal(err)
}
log.Println(got.Name)
Суть:
pool.Begin(ctx)открывает транзакцию (pgx.Tx).queries.WithTx(tx)возвращает копию*Queries, привязанную к этой транзакции — исходныйqueries(на пуле) не трогается.defer tx.Rollback(ctx)— страховка: если где-то по путиreturn/log.FatalдоCommit, транзакция откатится.tx.Commit(ctx)фиксирует изменения.
Проверка схемы
Современные версии sqlc умеют проверять совместимость SQL-запросов с изменениями схемы базы данных через команду sqlc verify. Это позволяет обнаруживать потенциальные ошибки еще до применения миграций в production.
Например, если кто-то удалит колонку, которая используется в запросе, проблема будет найдена на этапе проверки.
Ограничения sqlc
Несмотря на множество преимуществ, sqlc подходит не всем проектам.
Стоит учитывать несколько особенностей.
Во-первых, sqlc не является ORM.
Он не умеет автоматически строить запросы на основе структур и не генерирует CRUD за вас. Все SQL-запросы необходимо писать вручную.
Во-вторых, динамические запросы могут потребовать дополнительных решений.
Например, если пользователь может фильтровать данные по десяткам параметров, иногда удобнее использовать query builder вроде Squirrel или писать несколько отдельных SQL-запросов.
В-третьих, для комфортной работы необходимо хорошо знать SQL. Если команда привыкла к ORM и практически не пишет запросы вручную, переход может оказаться непростым.
sqlc или GORM
Если проект небольшой и нужно быстро получить результат, GORM остается хорошим вариантом.
Но для сервисов с высокой нагрузкой и большим количеством сложных запросов я рекомендую sqlc.
Сравнение выглядит примерно так:
| Критерий | sqlc | GORM |
|---|---|---|
| Типобезопасность | Да | Частично |
| Производительность | Очень высокая | Ниже |
| Контроль SQL | Полный | Ограниченный |
| Сложные запросы | Удобно | Часто неудобно |
| Скорость старта | Средняя | Высокая |
| Требуется знание SQL | Да | Не обязательно |
Итоги
sqlc занимает интересную нишу между низкоуровневым database/sql и полноценными ORM.
Инструмент позволяет писать обычный SQL, получать типобезопасный код и избегать большого количества шаблонного кода. При этом разработчик полностью контролирует запросы и может оптимизировать их так же, как если бы работал напрямую с PostgreSQL.
Если вы разрабатываете backend на Go и уже уверенно владеете SQL, я бы рекомендовал хотя бы попробовать sqlc на одном из новых проектов. Во многих случаях он позволяет получить преимущества ORM без большинства их недостатков.
