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

от автора

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

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 stringconst (    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-- оставляем только обычные таблицы, исключая представления/viewWHERE 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-- соединяем с каталогом схем, чтобы получить имя схемы по OIDJOIN 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 -- системное представление со сведениями о колонках таблиц-- исключаем системные схемы PostgreSQLWHERE 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 и CHECKWHERE 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-- исключаем стандартные системные схемы PostgreSQLWHERE 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

ссылка на оригинал статьи https://habr.com/ru/articles/1045496/