axe/internal/db/migrate.go
2026-10-18 22:07:25 +03:00

170 lines
4.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package db
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"log/slog"
"path"
"regexp"
"sort"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
const migrationsDir = "migrations"
// Имя файла: <version>_<name>.sql, например 0001_init.sql.
var migrationFileRe = regexp.MustCompile(`^(\d+)_([a-z0-9_-]+)\.sql$`)
type migration struct {
Version int
Name string
Checksum string
SQL string
}
// Migrate применяет недостающие миграции в порядке версии.
// Каждая миграция выполняется в отдельной транзакции; применённые
// фиксируются в schema_migrations вместе с checksum файла.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
migrations, err := loadMigrations()
if err != nil {
return err
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version int PRIMARY KEY,
name text NOT NULL,
checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied, err := appliedVersions(ctx, pool)
if err != nil {
return err
}
for _, m := range migrations {
checksum, ok := applied[m.Version]
if !ok {
if err := applyMigration(ctx, pool, m); err != nil {
return fmt.Errorf("migration %04d_%s: %w", m.Version, m.Name, err)
}
slog.Info("migration applied", "version", m.Version, "name", m.Name)
continue
}
if checksum != m.Checksum {
// Лучше упасть на старте, чем работать с непредсказуемой схемой.
return fmt.Errorf(
"migration %04d_%s: checksum changed after apply (db=%s, file=%s); "+
"applied migrations must not be edited — add a new migration instead",
m.Version, m.Name, checksum[:12], m.Checksum[:12],
)
}
}
return nil
}
// AppliedVersions — версии, которые уже накатаны. Экспортируется для тестов.
func AppliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[int]string, error) {
return appliedVersions(ctx, pool)
}
func appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[int]string, error) {
rows, err := pool.Query(ctx, `SELECT version, checksum FROM schema_migrations`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]string{}
for rows.Next() {
var version int
var checksum string
if err := rows.Scan(&version, &checksum); err != nil {
return nil, err
}
out[version] = checksum
}
return out, rows.Err()
}
func applyMigration(ctx context.Context, pool *pgxpool.Pool, m migration) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck // откат не нужен после Commit
// Без аргументов pgx уходит в simple protocol — разрешает несколько
// statement'ов в одной строке, что удобно для DDL-файлов.
if _, err := tx.Exec(ctx, m.SQL); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`INSERT INTO schema_migrations (version, name, checksum) VALUES ($1, $2, $3)`,
m.Version, m.Name, m.Checksum,
); err != nil {
return err
}
return tx.Commit(ctx)
}
func loadMigrations() ([]migration, error) {
entries, err := fs.ReadDir(migrationsFS, migrationsDir)
if err != nil {
return nil, fmt.Errorf("read migrations: %w", err)
}
var out []migration
seen := map[int]string{}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
match := migrationFileRe.FindStringSubmatch(entry.Name())
if match == nil {
return nil, fmt.Errorf("migration %q: filename must be <version>_<name>.sql", entry.Name())
}
version, err := strconv.Atoi(match[1])
if err != nil {
return nil, fmt.Errorf("migration %q: bad version: %w", entry.Name(), err)
}
if prev, ok := seen[version]; ok {
return nil, fmt.Errorf("migration version %d used twice: %s and %s", version, prev, entry.Name())
}
seen[version] = entry.Name()
body, err := fs.ReadFile(migrationsFS, path.Join(migrationsDir, entry.Name()))
if err != nil {
return nil, fmt.Errorf("migration %q: %w", entry.Name(), err)
}
sum := sha256.Sum256(body)
out = append(out, migration{
Version: version,
Name: match[2],
Checksum: hex.EncodeToString(sum[:]),
SQL: string(body),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
return out, nil
}