golang

Пишем автомигратор на Go: как узнать схему PostgreSQL

  • среда, 10 июня 2026 г. в 00:00:17
https://habr.com/ru/articles/1045496/

Когда говорят «генератор миграций», обычно в голове сразу появляется что-то вроде:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL
);

Но настоящий генератор миграций начинается не с CREATE TABLE. Он начинается с менее эффектного, но гораздо более важного вопроса:

Как вообще представить текущую схему базы в коде?

Пока у нас нет ответа на этот вопрос, мы не можем нормально сделать почти ничего:

  • сгенерировать начальную миграцию для уже существующей базы;

  • сравнить ожидаемую схему с реальной;

  • построить diff между двумя состояниями;

  • понять, какие ALTER TABLE, CREATE INDEX и DROP CONSTRAINT нужно выполнить.

В этой серии я буду идти к генератору миграций на Go постепенно. В первой части мы не будем генерировать полноценные миграции и рассматривать сложный код на Go. Вместо этого научимся получать схему PostgreSQL и превращать его во внутреннюю Go-модель.

Примерно такую:

type Schema struct {
    Tables []Table
}

type Table struct {
    Schema      string
    Name        string
    Columns     []Column
    Constraints []Constraint
    Indexes     []Index
}

Генератор миграций будет частью библиотеки qrafter: библиотека уже умеет строить типизированные SQL-запросы и DDL-выражения, а в пакете миграций есть структуры Schema, Table, Column, Constraint, Index, которые потом можно конвертировать в ddl.Statements.

Зачем вообще читать схему из базы

Представьте обычный проект, который живёт уже несколько лет.

В базе есть таблицы, индексы, внешние ключи, какие-то default now(), пара уникальных ограничений, возможно, несколько странных индексов, которые кто-то добавил в пятницу вечером перед релизом.

И вот хочется сделать инструмент, который сможет сказать:

  • Вот что сейчас лежит в базе.

  • Вот что ожидает приложение.

  • Вот чем они отличаются.

  • Вот так поменять схему, потому что проект растет и меняется.

Наивный подход: взять pg_dump --schema-only, получить SQL и начать его парсить.

Звучит заманчиво. Но для генератора миграций это быстро превращается в болото.

Проблема в том, что SQL-текст — это не модель. Это уже отрендеренное представление. Две разные строки SQL могут описывать одну и ту же схему:

CREATE UNIQUE INDEX users_email_idx ON users USING btree (email);
CREATE UNIQUE INDEX users_email_idx ON public.users (email);

Для человека это почти одно и то же. Для строкового diff-а — разные файлы.

А ещё pg_dump хорош для восстановления базы, но не обязан быть удобным AST для вашего автомигратора. Нам нужна не простыня SQL, а структура:

Table{
    Schema: "public",
    Name:   "users",
    Columns: []Column{
        {Name: "id", DatabaseType: "bigint", NotNull: true, ...},
        {Name: "email", DatabaseType: "character varying(320)", NotNull: true, ...},
    },
    Constraints: []Constraint{
        {Kind: ConstraintPrimaryKey, Columns: []string{"id"}, ...},
        {Kind: ConstraintUnique, Columns: []string{"email"}, ...},
    },
}

Из такой структуры уже можно что-то делать: сортировать, сравнивать, сериализовать, нормализовать, переводить в DDL.


Что будем считать схемой

PostgreSQL большой. Очень большой.

Если попытаться сразу поддержать вообще всё, статья закончится где-то в районе pg_depend, pg_rewrite, RLS policies и тихого отчаяния.

Поэтому ограничим первую версию.

Будем читать:

  • схемы PostgreSQL;

  • таблицы;

  • колонки;

  • типы колонок;

  • NOT NULL;

  • default expressions;

  • primary key;

  • unique constraints;

  • foreign keys;

  • indexes.

Пока оставим за кадром:

  • views;

  • materialized views;

  • triggers;

  • functions;

  • enum/domain types как отдельные объекты;

  • partitions;

  • policies/RLS;

  • comments;

  • extensions;

  • privileges;

  • sequences как самостоятельные сущности;

  • exclusion constraints;

  • storage options;

  • tablespaces, кроме сохранения поля для индексов.

Это не потому, что они не важны. Наоборот: если вы пишете инструмент для PostgreSQL, рано или поздно туда придётся прийти. Просто первая задача автомигратора — не стать вторым pg_dump, а построить минимальный, детерминированный snapshot.


Почему не просто information_schema?

В PostgreSQL есть два больших источника метаданных:

  • information_schema;

  • pg_catalog.

information_schema — это стандартизованный слой. В документации PostgreSQL прямо сказано, что information schema определена SQL-стандартом, поэтому более переносима и стабильна, а системные каталоги PostgreSQL специфичны для реализации. Там же есть важное уточнение: PostgreSQL-специфичные возможности через information schema не получить, для них нужны системные каталоги.

Звучит так, будто достаточно использовать information_schema.

Но на практике получается так:

information_schema — удобно для первого приближения

pg_catalog — нужен там, где начинается настоящий PostgreSQL

Для списка таблиц и простых колонок information_schema приятна. Например, information_schema.columns показывает имя схемы, имя таблицы, имя колонки, позицию, default expression, nullability, тип, identity/generated-признаки и дополнительные данные о типах.

Но как только мы хотим обычные индексы, частичные индексы, индексы по выражениям, точные описания констреинтов или PostgreSQL-специфичные детали — идём в pg_catalog.


Минимальная модель схемы в Go

Перед SQL-запросами надо сделать скучную, но важную вещь: спроектировать модель.

Если этого не сделать, интроспектор быстро превратится в набор функций loadSomething, которые возвращают случайные DTO, а потом где-то в конце всё склеивается на честном слове.

Для первой версии можно начать так:

type Schema struct {
    Tables []Table
}

type Table struct {
    Schema      string
    Name        string
    Columns     []Column
    Constraints []Constraint
    Indexes     []Index
}

Колонка:

type Column struct {
    Schema        string
    TableName     string
    Position      int
    Name          string
    DatabaseType  string
    NotNull       bool
    HasDefault    bool
    DefaultExpr   string
    Identity      IdentityKind
    Generated     GeneratedKind
    GeneratedExpr string

}

Constraint:

type ConstraintKind string

const (
    ConstraintPrimaryKey ConstraintKind = "primary_key"
    ConstraintUnique     ConstraintKind = "unique"
    ConstraintCheck      ConstraintKind = "check"
    ConstraintForeignKey ConstraintKind = "foreign_key"

)

type Reference struct {
    Schema    string
    TableName string
    Columns   []string
}

type Constraint struct {
    Schema    string
    TableName string
    Name      string
    Kind      ConstraintKind
    Columns   []string
    CheckExpr string
    Reference Reference
    OnDelete  string
    OnUpdate  string
}

Индекс:

type IndexKey struct {
    // Для обычного индекса это будет имя колонки:
    // "email"
    //
    // Для expression index:
    // "lower(email)"
    Expression string
}

type Index struct {
    Schema      string
    TableSchema string
    TableName   string
    Name        string
    Unique      bool
    Method      string
    Keys        []IndexKey
    Include     []string
    Predicate   string // partial index: where ...
}

В реальном коде qrafter модель похожая, но уже связана с ddl-пакетом: Column хранит ddl.Type, Constraint умеет превращаться в ddl.TableConstraint, а Index — в ddl.CreateIndexStmt. У Schema.DDL() есть важная деталь: сначала генерируются CREATE TABLE, потом отдельным проходом индексы. Это удобно, потому что не все индексы являются частью CREATE TABLE.

Главная мысль: модель должна быть достаточно общей, чтобы не закопаться в PostgreSQL навсегда, но не настолько абстрактной, чтобы потерять реальные свойства PostgreSQL.

Плохая идея:

type Column struct {
    Name string
    Type string
}

Лучше:

type Column struct {
    Name string

    // Нормализованный тип, который мы хотим использовать в DDL.
    Type ddl.Type

    // То, что вернула конкретная база.
    DatabaseType string

    // PostgreSQL-specific данные можно сохранить отдельно.
    Raw map[string]string
}

Почему так? Потому что универсальный генератор для всех СУБД обычно ломается не на CREATE TABLE users, а на мелочах:

  • serial vs identity;

  • timestamp with time zone vs datetime;

  • partial indexes;

  • generated columns;

  • ON DELETE SET NULL;

  • NULLS NOT DISTINCT у unique indexes;

  • разные правила кавычек и placeholder-ов.

В qrafter уже есть диалекты PostgreSQL, MySQL, SQLite, Oracle и SQL Server, а DDL-рендеринг учитывает особенности конкретной СУБД. Это хороший аргумент в пользу архитектуры: introspection можно писать PostgreSQL-first, но внутреннюю модель проектировать так, чтобы потом добавить другие диалекты.


Читаем таблицы

Начнём с самого простого: список пользовательских таблиц.

Через information_schema это выглядит так:

SELECT
    table_schema, -- имя схемы, в которой находится таблица
    table_name    -- имя таблицы
FROM information_schema.tables
-- оставляем только обычные таблицы, исключая представления/view
WHERE table_type = 'BASE TABLE'
  -- исключаем системные схемы PostgreSQL
  AND table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name;
Пример вывода

table_schema

table_name

public

customers

public

order_items

sales

payments

Если хочется сразу пойти PostgreSQL-way, можно читать таблицы через pg_class и pg_namespace:

SELECT
    n.nspname AS table_schema, -- имя схемы, в которой находится таблица
    c.relname AS table_name,   -- имя таблицы
    c.oid     AS table_oid     -- внутренний OID таблицы в PostgreSQL
-- системный каталог PostgreSQL с объектами-отношениями: таблицами, индексами, view и т.д.
FROM pg_catalog.pg_class c
-- соединяем с каталогом схем, чтобы получить имя схемы по OID
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
-- оставляем обычные таблицы ('r') и секционированные таблицы ('p')
WHERE c.relkind IN ('r', 'p')
  -- исключаем стандартные системные схемы
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  -- исключаем TOAST-схемы PostgreSQL для хранения больших значений
  AND n.nspname NOT LIKE 'pg_toast%'
ORDER BY n.nspname, c.relname;
Скрытый текст

table_schema

table_name

table_oid

public

customers

16421

sales

payments

16433

pg_class описывает таблицы и похожие на таблицы объекты: индексы, sequences, views, materialized views, composite types и другие relation-like объекты. Поле relkind как раз помогает отличать обычные таблицы, индексы, sequences, views, partitioned tables и так далее.


Читаем колонки

Колонки тоже удобно начать с information_schema.columns:

```

SELECT
    table_schema, -- имя схемы, в которой находится таблица
    table_name, -- имя таблицы
    column_name, -- имя колонки
    ordinal_position, -- порядковый номер колонки в таблице
    is_nullable, -- может ли колонка содержать NULL: YES или NO
    data_type, -- общий SQL-тип данных колонки
    udt_schema, -- схема внутреннего PostgreSQL-типа данных
    udt_name, -- имя внутреннего PostgreSQL-типа данных
    character_maximum_length, -- максимальная длина для строковых типов, varchar(255)
    numeric_precision, -- точность для числовых типов: общее количество значимых цифр
    numeric_scale, -- масштаб для числовых типов: количество цифр после запятой
    datetime_precision, -- точность для времени: количество знаков дробной части секунд
    column_default, -- значение по умолчанию для колонки, если оно задано
    is_identity, -- является ли колонка identity-колонкой: YES или NO
    identity_generation, -- способ генерации identity-значения: ALWAYS или BY DEFAULT
    is_generated, -- является ли колонка генерируемой
    generation_expression -- выражение, по которому вычисляется сгенерированная колонка
FROM information_schema.columns -- системное представление со сведениями о колонках таблиц
-- исключаем системные схемы PostgreSQL
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') 
ORDER BY table_schema, table_name, ordinal_position;
Скрытый текст

table_schema

table_name

column_name

ordinal_position

is_nullable

data_type

udt_schema

udt_name

character_maximum_length

numeric_precision

numeric_scale

datetime_precision

column_default

is_identity

identity_generation

is_generated

generation_expression

public

customers

id

1

NO

integer

pg_catalog

int4

32

0

nextval('customers_id_seq'::regclass)

NO

NEVER

public

customers

name

2

YES

character varying

pg_catalog

varchar

255

NO

NEVER

public

customers

email

3

NO

character varying

pg_catalog

varchar

320

6

now()

NO

NEVER

public

customers

created_at

4

NO

timestamp without time zone

pg_catalog

timestamp

NO

NEVER

Здесь важно не обмануться полем data_type.

Для простого text оно выглядит понятно. Для integer тоже. Но PostgreSQL-документация отдельно говорит, что data_type может быть ARRAY для массивов или USER-DEFINED для пользовательских типов, а для domain-типов data_type указывает на underlying type, тогда как domain определяется отдельными полями. Поэтому для генератора миграций полезно хранить не только data_type, но и udt_schema / udt_name, а ещё длину, precision и scale.

Например, varchar(320) — это не просто character varying. Нам нужен размер.

Позже можно перейти на pg_catalog.format_type(a.atttypid, a.atttypmod), потому что PostgreSQL уже умеет восстанавливать SQL-имя типа по OID и typemod. Функция format_type описана среди функций, которые извлекают информацию из системных каталогов.


Небольшая ловушка: bigserial — это не настоящий тип

Допустим, у нас есть таблица:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY
);

Можно ожидать, что интроспектор вернёт тип bigserial.

Но PostgreSQL устроен иначе: smallserial, serial и bigserial — это не настоящие типы, а удобная запись, которая создаёт integer/bigint-колонку, sequence, default nextval(...) и ownership sequence на колонку.

То есть snapshot может увидеть примерно такую картину:

Column{
    Name:         "id",
    DatabaseType: "bigint",
    NotNull:      true,
    HasDefault:   true,
    DefaultExpr:  "nextval('users_id_seq'::regclass)",
}

Позже можно добавить слой нормализации и сказать: «если колонка bigint, default nextval(...), sequence owned by этой колонкой — можно отрендерить это как bigserial». Но это уже отдельное решение, а не факт из information_schema.columns.


Когда portable SQL заканчивается

После таблиц и колонок начинается более сложная часть PostgreSQL.

Системные каталоги PostgreSQL — это место, где СУБД хранит метаданные схемы: таблицы, колонки и внутреннюю служебную информацию. Документация подчёркивает, что это обычные таблицы PostgreSQL, но руками менять их не надо: для этого есть SQL-команды.

Главные каталоги для первой версии интроспектора:

Что нужно

Где искать

Что хранит

Схемы

pg_namespace

namespaces, то есть внутреннюю основу SQL-схем

Таблицы, индексы, sequences, views

pg_class

Колонки

pg_attribute

строки по колонкам таблиц

Типы

pg_type

информация о типах

Default/generated expressions

pg_attrdef

default и выражения для генерации колонок

Constraints

pg_constraint

primary key, unique, foreign key, check и другие констреинты

Индексы

pg_index, pg_class, pg_am

pg_index хранит часть информации об индексах, а остальное лежит в pg_class

SQL-представление выражений

pg_get_expr(...), pg_get_constraintdef(...), pg_get_indexdef(...)

На этом месте становится понятно, почему универсальный интроспектор для всех СУБД не пишется за один раз. У каждой базы своя модель мира.


Constraints: не строка SQL, а семантика

С констреинтами легко попасть в ловушку.

Можно взять:

SELECT pg_get_constraintdef(oid)
FROM pg_constraint;

И сохранить строку:

PRIMARY KEY (id)

Но для diff-а этого мало.

Нам нужно не только «как это выглядит в SQL», а что это значит:

Constraint{
    Name:    "users_pkey",
    Kind:    ConstraintPrimaryKey,
    Columns: []string{"id"},
}

Для foreign key:

Constraint{
    Name:    "orders_user_id_fkey",
    Kind:    ConstraintForeignKey,
    Columns: []string{"user_id"},
    Reference: Reference{
        Schema:    "public",
        TableName: "users",
        Columns:   []string{"id"},
    },
    OnDelete: "cascade",
    OnUpdate: "no_action",
}

Почему это важно?

Потому что две SQL-строки могут быть разными, но означать одно и то же. А автомигратор должен сравнивать смысл, а не форматирование.

Базовый запрос для constraints может выглядеть так:

SELECT
    -- имя схемы, в которой находится таблица
    ns.nspname AS table_schema,
    -- имя таблицы
    tbl.relname AS table_name,
    -- внутренний OID ограничения в PostgreSQL
    con.oid,
    -- имя ограничения, например users_pkey или orders_user_id_fkey
    con.conname,
    -- тип ограничения: p = PRIMARY KEY, u = UNIQUE, f = FOREIGN KEY, c = CHECK
    con.contype, pg_get_constraintdef(con.oid, false) AS definition,
    -- полное SQL-описание ограничения в читаемом виде
    ARRAY(
        -- имя колонки, входящей в ограничение
        SELECT att.attname
        -- разворачиваем номера колонок ограничения с сохранением их порядка
        FROM unnest(con.conkey) WITH ordinality AS cols(attnum, ord)
        -- системный каталог PostgreSQL с колонками таблиц
        JOIN pg_attribute att
          -- колонка должна принадлежать таблице, на которой задано ограничение
          ON att.attrelid = con.conrelid
          -- сопоставляем номер колонки из ограничения с номером колонки в таблице
         AND att.attnum = cols.attnum
        -- сохраняем порядок колонок внутри ограничения
        ORDER BY cols.ord
    ) AS columns,
    -- схема таблицы, на которую ссылается FOREIGN KEY
    ref_ns.nspname AS ref_schema,
    -- таблица, на которую ссылается FOREIGN KEY
    ref_tbl.relname AS ref_table,
    -- список колонок в таблице, на которую ссылается FOREIGN KEY
    ARRAY(
        -- имя колонки в таблице, на которую ссылается FOREIGN KEY
        SELECT att.attname 
        -- разворачиваем номера ссылочных колонок с сохранением их порядка
        FROM unnest(con.confkey) WITH ordinality AS cols(attnum, ord)
        -- системный каталог PostgreSQL с колонками таблиц
        JOIN pg_attribute att
          -- колонка должна принадлежать ссылочной таблице
          ON att.attrelid = con.confrelid
          -- сопоставляем номер ссылочной колонки с номером колонки в ссылочной таблице
         AND att.attnum = cols.attnum
        ORDER BY cols.ord
    ) AS ref_columns,
    -- действие FOREIGN KEY при UPDATE: a = NO ACTION, r = RESTRICT, c = CASCADE, n = SET NULL, d = SET DEFAULT
    con.confupdtype,
    -- действие FOREIGN KEY при DELETE: a = NO ACTION, r = RESTRICT, c = CASCADE, n = SET NULL, d = SET DEFAULT
    con.confdeltype
-- системный каталог PostgreSQL с ограничениями таблиц
FROM pg_constraint con
-- соединяем constraint с таблицей, на которой он задан
JOIN pg_class tbl ON tbl.oid = con.conrelid
-- соединяем таблицу со схемой
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
-- для FOREIGN KEY получаем таблицу, на которую идёт ссылка
LEFT JOIN pg_class ref_tbl ON ref_tbl.oid = con.confrelid
-- для FOREIGN KEY получаем схему ссылочной таблицы
LEFT JOIN pg_namespace ref_ns ON ref_ns.oid = ref_tbl.relnamespace
-- оставляем только PRIMARY KEY, UNIQUE, FOREIGN KEY и CHECK
WHERE con.contype IN ('p', 'u', 'f', 'c')
  -- исключаем стандартные системные схемы PostgreSQL
  AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY ns.nspname, tbl.relname, con.conname;

В pg_constraint.contype PostgreSQL кодирует тип constraint-а: p для primary key, u для unique, f для foreign key, c для check, а также другие варианты вроде not-null и exclusion. Там же лежат confupdtype и confdeltype, то есть действия foreign key на update/delete.

Ещё одна неприятная деталь: имя constraint-а в PostgreSQL не обязательно уникально глобально. В документации pg_constraint.conname прямо помечен как «not necessarily unique», поэтому внутри интроспектора лучше не считать Name самостоятельным ID. Надёжнее использовать связку schema + table + name + kind, а при работе с каталогами — oid.


Индексы: первая настоящая ловушка

Наивная модель индекса выглядит так:

```

type Index struct {
    Name    string
    Table   string
    Columns []string
    Unique  bool
}

И она сломается почти сразу.

Потому что в PostgreSQL индекс — это не просто «таблица плюс колонки».

Вот три обычных примера:

CREATE INDEX users_email_lower_idx
    -- используем выражение, а не просто колонку
    ON users (lower(email));

CREATE INDEX users_active_email_idx
    ON users (email)
    -- частичный индекс, только по строкам, которые попали под условие
    WHERE deleted_at IS NULL;

CREATE INDEX users_data_gin_idx
    -- явно определяем тип индекса через USING
    ON users USING gin (data);

Поэтому модель лучше сразу делать такой:

type Index struct {
    Schema      string
    TableSchema string
    TableName   string

    Name   string
    Unique bool
    Method string

    Keys []IndexKey

    Include []string

    Predicate string
}

А ключ индекса — выражением:

type IndexKey struct {
    Expression string
}

Для обычного индекса там будет:

IndexKey{Expression: "email"}

Для индекса с выражением:

IndexKey{Expression: "lower(email)"}

Для первой версии можно поддержать обычные индексы по колонкам, а expression/partial indexes сохранять как обычный SQL. Мы не теряем информацию, но и не обещаем полноценный AST всех выражений PostgreSQL.

Запрос для индексов:

SELECT
    -- имя схемы, в которой находится таблица
    table_ns.nspname AS table_schema, 
    -- имя таблицы
    table_cls.relname AS table_name,
    -- имя схемы, в которой находится индекс
    index_ns.nspname AS index_schema,
    -- имя индекса
    index_cls.relname AS index_name, 
    -- true, если индекс является UNIQUE
    idx.indisunique, 
    -- true, если индекс является PRIMARY KEY
    idx.indisprimary, 
    -- true, если в UNIQUE-индексе NULL считается не отличающимся от NULL
    idx.indnullsnotdistinct,
    -- метод индекса: btree, hash, gin, gist, brin и т.д.
    am.amname AS method,
    -- полное SQL-описание индекса
    PG_GET_INDEXDEF(index_cls.oid, 0, FALSE) AS index_def, 
    -- условие частичного индекса, если индекс partial
    PG_GET_EXPR(idx.indpred, idx.indrelid, FALSE) AS predicate 
-- системный каталог PostgreSQL с информацией об индексах
FROM pg_index idx
-- соединяем с объектом индекса, чтобы получить имя индекса и метод доступа
JOIN pg_class index_cls ON index_cls.oid = idx.indexrelid
-- соединяем индекс со схемой индекса
JOIN pg_namespace index_ns ON index_ns.oid = index_cls.relnamespace
-- соединяем индекс с таблицей, на которой он создан
JOIN pg_class table_cls ON table_cls.oid = idx.indrelid
-- соединяем таблицу со схемой таблицы
JOIN pg_namespace table_ns ON table_ns.oid = table_cls.relnamespace
-- соединяем с методом доступа индекса: btree, hash, gin, gist, brin и т.д.
JOIN pg_am am ON am.oid = index_cls.relam
-- исключаем стандартные системные схемы PostgreSQL
WHERE table_ns.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_ns.nspname, table_cls.relname, index_cls.relname;

pg_index хранит флаги уникальности, primary key, validity, список ключей индекса, expression-части и predicate для partial index. Если в indkey встречается 0, это означает, что соответствующий атрибут индекса — выражение, а не простая ссылка на колонку. indpred хранит predicate partial index-а.

Для восстановления человекочитаемого SQL PostgreSQL даёт функции pg_get_expr, pg_get_constraintdef и pg_get_indexdef. Важная оговорка из документации: это decompiled reconstruction, то есть восстановленное представление, а не исходный текст команды, которую когда-то написал разработчик.

Это ещё один аргумент в пользу нормализации. Не надо сравнивать «исходный текст». Его уже нет. Есть состояние базы.


Нормализация: чтобы diff не сходил с ума

Самая недооценённая часть introspection — нормализация.

Если одно и то же состояние базы данных сегодня даёт один JSON, а завтра другой, diff будет шуметь. А если diff шумит, автомигратору перестают доверять.

Snapshot должен быть детерминированным:

Однои то же состояние базы данных должно давать один и тот же Go-объект, для этого нужна нормализация.

Минимальная нормализация:

func (s *Schema) normalize() {
    sort.SliceStable(s.Tables, func(i, j int) bool {
        if s.Tables[i].Schema == s.Tables[j].Schema {
            return s.Tables[i].Name < s.Tables[j].Name
        }
        return s.Tables[i].Schema < s.Tables[j].Schema
    })

    for i := range s.Tables {
        s.Tables[i].normalize()
    }
}

func (t *Table) normalize() {
    sort.SliceStable(t.Columns, func(i, j int) bool {
        return t.Columns[i].Position < t.Columns[j].Position
    })

    sort.SliceStable(t.Constraints, func(i, j int) bool {
        left := constraintKindOrder(t.Constraints[i].Kind)
        right := constraintKindOrder(t.Constraints[j].Kind)
        if left == right {
            return t.Constraints[i].Name < t.Constraints[j].Name
        }

        return left < right
    })

    sort.SliceStable(t.Indexes, func(i, j int) bool {
        return t.Indexes[i].Name < t.Indexes[j].Name
    })
}

И порядок constraints:

func constraintKindOrder(kind ConstraintKind) int {
    switch kind {
    case ConstraintPrimaryKey:
        return 1
    case ConstraintUnique:
        return 2
    case ConstraintCheck:
        return 3
    case ConstraintForeignKey:
        return 4
    default:
        return 100
    }
}

Похожие функции нормализации есть в qrafter: snapshot сортирует таблицы по схеме и имени, колонки по позиции, constraints по типу и имени, индексы по имени.

Кроме сортировки, постепенно понадобятся и другие правила:

  • приводить типы к единому виду;

  • понимать, что int8 и bigint — синонимы, но не терять исходный тип;

  • аккуратно сравнивать default expressions;

  • не считать автоматически сгенерированные имена важными там, где пользователь их не задавал;

  • отделять constraint indexes от обычных indexes.

Дефолтыне выражения особенно коварны.

Например:

now()

и

CURRENT_TIMESTAMP

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


## Минимальный пример

Возьмём таблицу:

```

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(320) NOT NULL UNIQUE,
    name TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

После introspection snapshot может выглядеть так:

Schema{
    Tables: []Table{
        {
            Schema: "public",
            Name:   "users",
            Columns: []Column{
                {
                    Schema:       "public",
                    TableName:    "users",
                    Position:     1,
                    Name:         "id",
                    DatabaseType: "bigint",
                    NotNull:      true,
                    HasDefault:   true,
                    DefaultExpr:  "nextval('users_id_seq'::regclass)",
                },
                {
                    Schema:       "public",
                    TableName:    "users",
                    Position:     2,
                    Name:         "email",
                    DatabaseType: "character varying(320)",
                    NotNull:      true,
                },
                {
                    Schema:       "public",
                    TableName:    "users",
                    Position:     3,
                    Name:         "name",
                    DatabaseType: "text",
                    NotNull:      false,
                },
                {
                    Schema:       "public",
                    TableName:    "users",
                    Position:     4,
                    Name:         "created_at",
                    DatabaseType: "timestamp with time zone",
                    NotNull:      true,
                    HasDefault:   true,
                    DefaultExpr:  "now()",
                },
            },
            Constraints: []Constraint{
                {
                    Schema:    "public",
                    TableName: "users",
                    Name:      "users_pkey",
                    Kind:      ConstraintPrimaryKey,
                    Columns:   []string{"id"},
                },
                {
                    Schema:    "public",
                    TableName: "users",
                    Name:      "users_email_key",
                    Kind:      ConstraintUnique,
                    Columns:   []string{"email"},
                },
            },
        },
    },
}

Обратите внимание на id.

Мы написали bigserial, но в snapshot получили bigint и nextval(...). Это нормально: PostgreSQL сам раскрывает bigserial в bigint-колонку, sequence и default expression.

Теперь у нас есть структура, с которой уже можно работать:

  • можно писать snapshot-тесты;

  • можно сравнивать dev/stage/prod схемы;

  • можно строить diff в памяти;

  • можно отдавать эту модель в DDL-билдер;

  • можно искать неожиданные изменения схемы в CI.


Что осталось за кадром

В этой версии мы намеренно не трогали много важных вещей.

Например, views и materialized views. В pg_class они тоже есть, но для них нужна отдельная модель: тело view, зависимости, порядок создания. pg_class.relkind позволяет отличать обычные таблицы, индексы, sequences, views, materialized views и partitioned tables.

Не трогали функции и триггеры. Для них есть свои каталоги и функции вроде pg_get_functiondef / pg_get_triggerdef, но это уже отдельный пласт PostgreSQL.

Не трогали полноценную поддержку enum/domain types. information_schema.columns даст часть информации о пользовательских типах, но если мы хотим создавать эти типы в миграциях, их нужно читать как отдельные schema objects. pg_type как раз хранит base types, enum types, domains и composite types.

Не трогали partitions, RLS, comments, extensions, grants.


Почему это важнее, чем сразу генерировать ALTER TABLE

Очень хочется после introspection сразу написать:

schema.DDL().Render(dialect.PostgreSQL{})

И получить миграцию. Но если сделать это слишком рано, инструмент будет красивым на demo и опасным в реальности.

Правильный путь длиннее:

  1. Снять snapshot текущей схемы.

  2. Нормализовать snapshot.

  3. Снять или построить ожидаемый snapshot.

  4. Сравнить две модели.

  5. Получить отличия.

  6. Превратить diff в DDL.

  7. Только потом рендерить SQL.

То есть генерация миграций начинается не с SQL-рендера, а с модели данных.

Это особенно хорошо ложится на архитектуру с DDL-билдером. В qrafter DDL уже представлен отдельными statement-объектами: можно явно строить CreateTable, Unique, ForeignKey, CreateIndex, а потом рендерить это под конкретный dialect.

Snapshot — это мост между реальной базой и таким DDL API.

Пока у нас есть главное — стабильный snapshot.

А хороший diff без хорошего snapshot-а не построить.

Репозиторий qrafter в GitHub: https://github.com/SennovE/qrafter