Пишем автомигратор на Go: как узнать схему PostgreSQL
- среда, 10 июня 2026 г. в 00:00:17
Когда говорят «генератор миграций», обычно в голове сразу появляется что-то вроде:
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.
В 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.
Перед 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 | 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 описана среди функций, которые извлекают информацию из системных каталогов.
Допустим, у нас есть таблица:
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.
После таблиц и колонок начинается более сложная часть PostgreSQL.
Системные каталоги PostgreSQL — это место, где СУБД хранит метаданные схемы: таблицы, колонки и внутреннюю служебную информацию. Документация подчёркивает, что это обычные таблицы PostgreSQL, но руками менять их не надо: для этого есть SQL-команды.
Главные каталоги для первой версии интроспектора:
Что нужно | Где искать | Что хранит |
Схемы |
| namespaces, то есть внутреннюю основу SQL-схем |
Таблицы, индексы, sequences, views |
| |
Колонки |
| строки по колонкам таблиц |
Типы |
| информация о типах |
Default/generated expressions |
| default и выражения для генерации колонок |
Constraints |
| primary key, unique, foreign key, check и другие констреинты |
Индексы |
|
|
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, то есть восстановленное представление, а не исходный текст команды, которую когда-то написал разработчик.
Это ещё один аргумент в пользу нормализации. Не надо сравнивать «исходный текст». Его уже нет. Есть состояние базы.
Самая недооценённая часть 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.
Очень хочется после introspection сразу написать:
schema.DDL().Render(dialect.PostgreSQL{})
И получить миграцию. Но если сделать это слишком рано, инструмент будет красивым на demo и опасным в реальности.
Правильный путь длиннее:
Снять snapshot текущей схемы.
Нормализовать snapshot.
Снять или построить ожидаемый snapshot.
Сравнить две модели.
Получить отличия.
Превратить diff в DDL.
Только потом рендерить SQL.
То есть генерация миграций начинается не с SQL-рендера, а с модели данных.
Это особенно хорошо ложится на архитектуру с DDL-билдером. В qrafter DDL уже представлен отдельными statement-объектами: можно явно строить CreateTable, Unique, ForeignKey, CreateIndex, а потом рендерить это под конкретный dialect.
Snapshot — это мост между реальной базой и таким DDL API.
Пока у нас есть главное — стабильный snapshot.
А хороший diff без хорошего snapshot-а не построить.
Репозиторий qrafter в GitHub: https://github.com/SennovE/qrafter