Раздел 22 · Go

Миграции, sqlc, squirrel: типизированный SQL без ORM

middle~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Миграции, sqlc, squirrel: типизированный SQL без ORM

Урок-конспект про то, как версионировать схему Postgres и писать к ней SQL так, чтобы Go-код оставался типизированным, читаемым и без магии ORM. Считаем, что 08-persistence-and-sql уже пройден: знаешь database/sql, pgx, как открывается пул, как закрывается контекст.

Главные тезисы:

  • SQL пишем руками, не доверяем ORM-у.
  • Схему версионируем через goose или golang-migrate.
  • Из SQL генерим типизированный Go-код через sqlc. ORM не нужен.
  • Динамические запросы строим через squirrel, не через fmt.Sprintf.

1. Зачем миграции и почему не “ORM-автомиграция”

Миграция, версионированный SQL-скрипт, который меняет схему. Запускается один раз, имеет уникальный номер, имеет обратную команду. В репозитории живут вместе с кодом, в продакшн прокатываются вместе с релизом.

Альтернатива из мира Java/JS, “ORM-автомиграция” (spring.jpa.hibernate.ddl-auto=update, prisma migrate dev), в Go считается дурным тоном. Причины:

  • ORM не знает про индексы, partial indexes, partitioning, generated columns. Любая нетривиальная схема выпадает.
  • В продакшне нельзя “пусть schema подгонит под структуру”. Нужен явный план, явный порядок, явный rollback.
  • ORM-миграции непрозрачны: непонятно, какой именно SQL пойдёт в БД.

В Go идиома, разработчик пишет SQL, ORM не используется. См. обсуждение почему не gorm в секции 6.

2. goose: миграции для прагматиков

goose ставится одной командой:

go install github.com/pressly/goose/v3/cmd/goose@latest

Создаём миграцию:

goose -dir db/migrations create create_users sql
# пишет файл: db/migrations/20260505123456_create_users.sql

Содержимое:

-- +goose Up
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    email      TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- +goose Down
DROP TABLE users;

Применяем:

goose -dir db/migrations postgres "$DSN" up
goose -dir db/migrations postgres "$DSN" status
# Applied At                  Migration
# 2026-05-05 12:34:56         20260505123456_create_users.sql

Откат на одну версию назад, down. Откат полностью, reset. Перепрогон последней, redo. Имя файла из timestamp плюс slug, поэтому конфликтов между PR-ами обычно нет.

Goose умеет ещё и embed через goose.SetBaseFS, чтобы миграции прокатывались самим бинарём при старте:

//go:embed db/migrations/*.sql
var migrations embed.FS

func migrate(ctx context.Context, db *sql.DB) error {
    goose.SetBaseFS(migrations)
    if err := goose.SetDialect("postgres"); err != nil {
        return err
    }
    return goose.UpContext(ctx, db, "db/migrations")
}

Это удобно для маленьких сервисов: один бинарь, миграции внутри. Но для команд из 3+ человек безопаснее держать отдельный goose up шаг в CI или deploy-pipeline, иначе один из инстансов параллельно запустит миграцию, поломает.

3. golang-migrate: минималистичная альтернатива

golang-migrate/migrate делает то же, иначе. Файлы парами, NNN_name.up.sql и NNN_name.down.sql, номера последовательные (1, 2, 3 …), а не timestamp:

db/migrations/
├── 000001_create_users.up.sql
├── 000001_create_users.down.sql
├── 000002_add_email_verified.up.sql
└── 000002_add_email_verified.down.sql

CLI:

brew install golang-migrate
migrate -path db/migrations -database "$DSN" up
migrate -path db/migrations -database "$DSN" down 1
migrate -path db/migrations -database "$DSN" version
goosegolang-migrate
Имя файла<ts>_<name>.sql<NNN>_<name>.up.sql плюс <NNN>_<name>.down.sql
Один или два файлаодин на миграцию (Up/Down)два на миграцию
Embed в бинарьgoose.SetBaseFSiofs.New(...)
CLIодин бинарьодин бинарь
Поддержка драйверовpostgres, sqlite, mysql, mssql, clickhouseте же плюс ещё
Нумерацияtimestampsequential
Стиль“rails-like”минималистичный

Что выбирать в pet-проекте: голова болит одинаково. Я обычно беру goose за один файл и timestamp-нумерацию (меньше merge-конфликтов). В команде на 5+ людей golang-migrate чище в плане единого формата.

4. sqlc: SQL внутри, Go снаружи

sqlc это codegen: ты пишешь SQL в файле, sqlc разбирает запросы, проверяет их против схемы, генерирует Go-функции.

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

Минимальный конфиг sqlc.yaml:

version: '2'
sql:
  - engine: postgresql
    queries: db/query.sql
    schema: db/migrations
    gen:
      go:
        package: 'dbgen'
        out: 'internal/dbgen'
        sql_package: 'pgx/v5'

db/query.sql:

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

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

-- name: ListUsersByEmail :many
SELECT id, email, created_at
FROM users
WHERE email LIKE $1
ORDER BY id;

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

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

После sqlc generate появляется пакет internal/dbgen с типами:

// internal/dbgen/users.sql.go (сгенерирован)
type User struct {
    ID        int64
    Email     string
    CreatedAt time.Time
}

type Queries struct { db DBTX }
func New(db DBTX) *Queries { /* ... */ }

func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { /* ... */ }
func (q *Queries) SaveUser(ctx context.Context, email string) (User, error) { /* ... */ }
func (q *Queries) ListUsersByEmail(ctx context.Context, email string) ([]User, error) { /* ... */ }
func (q *Queries) UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) error { /* ... */ }
func (q *Queries) DeleteUser(ctx context.Context, id int64) error { /* ... */ }

Что важно:

  • Аннотации :one, :many, :exec, :execrows, :execresult сообщают sqlc, какой результат ждать.
  • Параметры запроса ($1, $2) автоматически становятся параметрами Go-функции. Если 3+ параметра, sqlc создаёт XxxParams структуру.
  • RETURNING в INSERT нужен, чтобы получить id и created_at одной командой (Postgres-фишка). Без RETURNING пришлось бы делать два запроса.
  • Схема sqlc читает из миграций (schema: db/migrations), поэтому при обновлении миграции запросы перепроверяются на типы.

Использование в коде:

// internal/user/pg.go
type pgRepo struct{ q *dbgen.Queries }

func NewPGRepo(pool *pgxpool.Pool) *pgRepo {
    return &pgRepo{q: dbgen.New(pool)}
}

func (r *pgRepo) Save(ctx context.Context, u *user.User) error {
    rec, err := r.q.SaveUser(ctx, u.Email)
    if err != nil {
        return fmt.Errorf("save user: %w", err)
    }
    u.ID = rec.ID
    u.CreatedAt = rec.CreatedAt
    return nil
}

Идиома: бизнес-логика живёт в internal/user, sqlc-сгенерированный пакет в internal/dbgen. Доменный тип user.User в репо превращается в dbgen.User адаптером. Это чуть-чуть mapping-кода, но это лучше, чем втащить в домен типы, привязанные к схеме БД.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.