Как всё начиналось

Мне всегда не нравилось, во что со временем превращаются папки с SQL-миграциями. В рабочих проектах, особенно легаси, их количество легко переваливает за сотни, а иногда и за тысячу. Разобраться в них сложно, а timestamp-имена делают навигацию ещё хуже.

Тестирование таких миграций — отдельная, хорошо знакомая боль.


Почему решил делать своё

Я попробовал goose — не зашло. Его init() — вещь, конечно, сомнительная, да и timestamp-миграции никуда не делись. Всё те же проблемы с читаемостью, всё те же коллизии. Про golang-migrate и так понятно — SQL не нравится.

И тут я поймал себя на мысли:

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

Не в пет-проекте ради пет-проекта, а в инструменте, который решает конкретную боль. Именно поэтому большинство наших пет-проектов так и не доходят даже до репозитория — нет настоящей необходимости.

А здесь она была:

  • есть проблема
  • есть потребность
  • есть скиллы

Значит, нужно делать.


Я понял, что мне нужен инструмент, в котором миграции — это часть кода.

  • Компилируется вместе
  • Деплоится вместе
  • Тестируется вместе

Мне нужен был инструмент под конкретные проблемы, с которыми я уже сталкивался.

• • •

Что искал

Я пересмотрел несколько докладов и провёл значительное время на Хабре, Reddit и в Github issue, пытаясь понять, как люди на самом деле пользуются миграциями: что их раздражает, где они чаще всего обжигаются и какие проблемы повторяются из проекта в проект.

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


1. Никаких SQL-файлов

Я понимаю плюсы SQL-файлов: простота, наглядность, возможность править без перекомпиляции. Но нам как разработчикам на Go подход code-first даёт заметно больше:

  • IDE-поддержка — автокомплит, навигация и рефакторинг работают из коробки
  • Нативный Git — история изменений, diff и blame без костылей
  • Тестирование — миграции можно импортировать как обычный Go-пакет
  • Type-safety — там, где это возможно
  • Единый бинарник — невозможно забыть файлы при деплое
  • Программная логика — условия, циклы и API-вызовы внутри миграций

Подход не новый — так работают миграции во многих фреймворках.


2. Убрать timestamp из версий

Timestamp в названиях миграций — сомнительное удовольствие. Вот с чем я сталкивался на практике:

  • CI/CD-коллизии — параллельные ветки генерируют одинаковые timestamp
  • Timezone-проблемы — разработчики в разных часовых поясах
  • Out of range — timestamp как int64 не помещается в колонку и ломает SELECT
  • Clock skew — разное системное время на машинах

Так что в Queen:

Простые версии — 001, 002 или users_001, posts_001.

Читаемые. Предсказуемые. Без сюрпризов.

Пожалуйста

Договаривайтесь с командой об именовании заранее. А всё, о чём не договоритесь, решит queen validate и queen create.

3. Прозрачная история

В коде:

Обычные git log и git blame — ничего нового изучать не нужно.

В базе:

Таблица queen_migrations с версиями, временем применения и checksum. Команда queen status показывает текущее состояние всех миграций.


4. Без ORM, но удобно

Писать SQL вручную иногда утомительно, но полноценный ORM вроде gorm не вписывается в философию инструмента.

В Queen я пришел к генерации шаблонов.

Для простого SQL:

./queen create add_users_table --type sql

генерирует

q.MustAdd(queen.M{
    Version: "002",
    Name:    "add_users_table",
    UpSQL:   `-- TODO: add your SQL here`,
    DownSQL: `-- TODO: add rollback SQL here`,
})

Для сложной логики — Go-функции:

./queen create normalize_emails --type go

генерирует

q.MustAdd(queen.M{
    Version: "003",
    Name:    "normalize_emails",
    UpFunc: func(ctx context.Context, tx *sql.Tx) error {
        // Читаем, обрабатываем, пишем
        return nil
    },
    DownFunc: func(ctx context.Context, tx *sql.Tx) error {
        return nil
    },
    ManualChecksum: "v1",
})

Это не ORM, но рутина снимается. SQL остаётся явным, а структура — готовой.

Философия: простота и прозрачность.

Queen не скрывает SQL — Queen делает работу с ним удобнее.


Первые шаги

С этим списком я начал работу.

Кстати, в начале проект назывался honey-migrate, но, посмотрев на свой бэклог пет-проектов (где половина начинается с honey), решил взять название попроще — Queen.

Первым делом я сделал драйвер для PostgreSQL — основной базы, с которой мы работаем почти каждый день. Затем добавил SQLite3 для тестов и после этого — MySQL.


Принципы Queen

Никакой магии — Явная регистрация вместо init(), явный SQL вместо ORM

Один артефакт — Миграции компилируются вместе с CLI

Контроль в руках разработчикаDown пишется явно, автоматических rollback нет

Простота важнее фич.

Разработка PostgreSQL драйвера: первые грабли

Казалось бы, PostgreSQL-драйвер для миграций — что может быть проще? Табличка, INSERT/DELETE, транзакции.

Но, как обычно, дьявол оказался в деталях.


Проблема 1: Версии “1”, “2”, “10”

Первая версия сортировала миграции лексикографически:

1, 10, 100, 2, 20, 3

Красота. Добавляешь миграцию "10" после "9", а она накатывается раньше "2".

Именно в этот момент понимаешь, что лексикографическая сортировка — плохая идея для версий.

Решение: Natural sorting. Парсим строки, выделяем числовые и текстовые части, сравниваем числа как числа:

// Простые версии
// "1" < "2" < "10" < "100"

// С префиксами
// "v1" < "v2" < "v10"
// "user_001" < "user_002" < "user_010"

// Смешанные
// "user_001a" < "user_001b" < "user_002a"

Теперь работает как ожидает человек.


Проблема 2: Checksum и переформатирование

Зачем вообще checksum?

Чтобы знать, изменилась ли уже накаченная миграция. Если кто-то поменял SQL в миграции, которая уже в проде — это проблема, и Queen скажет об этом.

Сделал checksum миграций (SHA-256) для детекта изменений. Переформатировал SQL:

-- Было
CREATE TABLE users(id INT);

-- Стало
CREATE TABLE users (
    id INT
);

Checksum изменился → Queen скажет “миграция изменена!”

Но логически-то ничего не поменялось.

Решение: Нормализация whitespace перед хешированием

func normalizeWhitespace(s string) string {
    // Убираем leading/trailing пробелы
    // Схлопываем пустые строки
}

Теперь можно спокойно форматировать SQL, не боясь сломать историю миграций.


Проблема 3: Параллельные миграции

Два разработчика одновременно запускают миграции. Или CI/CD деплоит на несколько инстансов параллельно:

Developer 1: INSERT migration 001
Developer 2: INSERT migration 001  ← Дубликат!

Решение: PostgreSQL advisory locks

func (d *Driver) Lock(ctx context.Context, timeout time.Duration) error {
    var acquired bool
    err = d.db.QueryRowContext(ctx,
        "SELECT pg_try_advisory_lock($1)", d.lockID).Scan(&acquired)

    if !acquired {
        return ErrLockTimeout
    }
    return nil
}

Lock ID генерируется из имени таблицы миграций — разные таблицы = разные локи. Можно иметь несколько независимых наборов миграций в одной БД.


Проблема 4: SQL Injection

Наивная реализация:

query := fmt.Sprintf("CREATE TABLE %s (...)", tableName)

Пользователь передает:

tableName = "users; DROP TABLE important_data; --"
Упс

Привет hh...

Решение: Функция quoteIdentifier — экранируем кавычки, оборачиваем в двойные кавычки. Теперь безопасно.


Проблема 5: Rollback при ошибках

Миграция упала посередине. Половина изменений применилась. База в inconsistent состоянии.

Решение: Всё в транзакциях

func (d *Driver) Exec(ctx context.Context, fn func(*sql.Tx) error) error {
    tx, err := d.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }

    if err := fn(tx); err != nil {
        _ = tx.Rollback()
        return err
    }

    return tx.Commit()
}

Миграция либо применяется полностью, либо откатывается полностью.


Testing helpers

Это убрало 90% бойлерплейта и сделало тесты миграций такими же обычными, как тесты бизнес-логики.

Сделал NewTest helper:

q := queen.NewTest(t, driver)  // Auto-cleanup!

q.MustAdd(migration)
q.TestUpDown()  // Автоматически проверяет Up и Down

testing.go делает тестирование миграций тривиальным.


Что получилось

PostgreSQL драйвер:

  • Правильно сортирует версии — natural sort
  • Детектит изменения, но игнорирует форматирование — checksum с нормализацией
  • Защищен от параллельных запусков — advisory locks
  • Безопасен от SQL injection — экранирование идентификаторов
  • Атомарен — транзакции
  • Легко тестируетсяNewTest helper

Весь код в одном файлеpostgres.go, ~170 строк.

Без магии.

Что дальше?

В следующих постах разберём драйверы для Clickhouse и CockroachDB, а так же разберем CLI

Star on GitHub

Если попробуете — буду рад фидбеку.