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-код с типами, которые проверяются компилятором.

Схема работы выглядит так:

  1. Вы пишете SQL.
  2. Запускаете sqlc generate.
  3. Получаете готовый Go-код.
  4. Используете этот код в приложении.

Например, вместо такого кода:

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.

Сравнение выглядит примерно так:

КритерийsqlcGORM
ТипобезопасностьДаЧастично
ПроизводительностьОчень высокаяНиже
Контроль SQLПолныйОграниченный
Сложные запросыУдобноЧасто неудобно
Скорость стартаСредняяВысокая
Требуется знание SQLДаНе обязательно

Итоги

sqlc занимает интересную нишу между низкоуровневым database/sql и полноценными ORM.

Инструмент позволяет писать обычный SQL, получать типобезопасный код и избегать большого количества шаблонного кода. При этом разработчик полностью контролирует запросы и может оптимизировать их так же, как если бы работал напрямую с PostgreSQL.

Если вы разрабатываете backend на Go и уже уверенно владеете SQL, я бы рекомендовал хотя бы попробовать sqlc на одном из новых проектов. Во многих случаях он позволяет получить преимущества ORM без большинства их недостатков.

Хостинг для ваших проектов