Содержание

Как получить актуальную структуру БД после миграций

При частых изменениях структуры базы данных со временем накапливается множество файлов миграций.
Каждая из них описывает только отдельное изменение — добавить колонку, изменить тип колонки, переименовать таблицу, удалить индекс и т.д.
Они могут быть реализованы по-разному - где-то в чистом SQL, где-то в Go-коде.
Вместе они образуют длинную цепочку, и уже не так просто понять, как выглядит база в итоге.
Особенно если проект развивается быстро или над ним работает несколько команд.

Ситуация усложняется, если в проекте много сервисов, и у каждого своя схема и свой набор миграций.
Знания о том, какая миграция к какой схеме относится, могут легко запутаться, и понять актуальную структуру каждой схемы становится сложнее.

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

Входные данные:
Сервисы написаны на Go, у каждого сервиса свои независимые миграции на sql и на go для собственной схемы. Все сервисы используют одну базу данных PostgreSQL с goose.

Все миграции каждого сервиса находятся в директории migrations:

❯ tree migrations
migrations
├── 20250917161808_tasks_create_table.sql
├── 20250920121003_add_priority_column.go
├── 20250920143353_add_index_to_tasks.sql
├── 20250922201310_migrate_task_status.go
├── 20250921151515_update_task_constraints.sql
...

Файлы миграций могут быть как на SQL, так и на Go, и описаны в формате, который понимает goose.

Каждый сервис содержит MigrationsSchema — название схемы, к которой относятся миграции, и MigrationsFS — встроенную файловую систему (embed.FS), включающую все файлы миграций сервиса:

var (
	//go:embed migrations/*
	MigrationsFS embed.FS

	MigrationsSchema = "taskrepository"
)

Поскольку структура и формат миграций одинаковые для всех сервисов, можно написать общую функцию для применения миграций.
Входные данные: коннектор к БД, название схемы, файлы миграции.

func Migrate(ctx context.Context, logger *slog.Logger, dbHandler *sql.DB, schema string, migrationsFS fs.FS) error {
	_, err := dbHandler.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema)
	if err != nil {
		return err
	}

	store, err := database.NewStore(goose.DialectPostgres, schema+".goose_db_version")
	if err != nil {
		return err
	}

	migrationsFS, err = fs.Sub(migrationsFS, "migrations")
	if err != nil {
		return err
	}

	provider, err := goose.NewProvider("", dbHandler, migrationsFS,
		goose.WithStore(store), goose.WithVerbose(true), goose.WithLogger(sharedlogginggoose.NewGooseLogger(logger)))
	if err != nil {
		return err
	}

	migrations, err := provider.Up(ctx)
	if err != nil {
		return err
	}

	logger.InfoContext(ctx, "migrations applied", "migrations", migrations)

	return nil
}

Общие моменты:

  • Если нужной схемы нет, то её нужно создать. Иначе goose не сможет создать таблицу goose_db_version.
  • migrationsFS изначально указывает на директорию migrations. goose же ожидает, что миграционные файлы будут в текущей директории ("."). Поэтому используется fs.Sub для смены директории.

goose умеет применять миграции с БД. Для этого нужно только указать параметры подключения к БД и данные миграции.

Получить схему БД можно с помощью утилиты pg_dump. Для этого нужно только указать параметры подключения к БД и формат вывода.
Пример команды: pg_dump --schema-only --format=plain --no-owner --username=postgres --dbname=testdb.

Процесс генерации схемы сводится к четырем шагам:

  • Поднять чистую базу PostgreSQL в докере.
  • Применить к базе миграции через goose.
  • Получить схему БД через pg_dump.
  • Сохранить схему в файл.

В коде это реализовано следующим образом: сначала собираем для каждого сервиса его MigrationsSchema и MigrationsFS, а затем сохраняем актуальную схему:

func run() error {
	params := []migrationParam{
		{schema: taskrepository.MigrationsSchema, migrationsFs: taskrepository.MigrationsFS},
		{schema: userrepository.MigrationsSchema, migrationsFs: userrepository.MigrationsFS},
		// ...
	}

	for _, param := range params {
		if err := saveSchema(param); err != nil {
			return err
		}
	}

	return nil
}

Функция saveSchema поднимает контейнер с PostgreSQL, применяет миграции и сохраняет схему в файл:

func saveSchema(param migrationParam) error {
	ctx := context.Background()

	req := testcontainers.ContainerRequest{ //nolint:exhaustruct
		Image:        "postgres:18-alpine",
		Env:          map[string]string{"POSTGRES_PASSWORD": "pass", "POSTGRES_DB": "testdb"},
		ExposedPorts: []string{"5432/tcp"},
		WaitingFor:   wait.ForListeningPort("5432/tcp"),
	}

	postgresC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ //nolint:exhaustruct
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return err
	}

	defer func() { _ = postgresC.Terminate(ctx) }()

	host, _ := postgresC.Host(ctx)
	port, _ := postgresC.MappedPort(ctx, "5432")
	dsn := fmt.Sprintf("postgres://postgres:pass@%s/testdb?sslmode=disable", net.JoinHostPort(host, port.Port()))

	dbHandler, err := shareddatabase.New(ctx, dsn)
	if err != nil {
		return err
	}

	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	if err := sharedsqlmigrator.Migrate(ctx, logger, dbHandler, param.schema, param.migrationsFs); err != nil {
		return err
	}

	cmd := []string{
		"pg_dump",
		"--schema-only",
		"--format=plain",
		"--no-owner",
		"--username=postgres",
		"--dbname=testdb",
		"--restrict-key=000000000000000000000000000000000000000000000000000000000000000",
	}

	_, r, err := postgresC.Exec(ctx, cmd, exec.Multiplexed())
	if err != nil {
		return err
	}

	f, err := os.Create(fmt.Sprintf("./docs/sqlschema/%s.sql", param.schema))
	if err != nil {
		return err
	}

	defer func() { _ = f.Close() }()

	_, err = io.Copy(f, r)
	if err != nil {
		return err
	}

	return nil
}

В этой реализации:

  • Поднимаем чистую базу PostgreSQL в докере на базе легковесного Alpine-образа. Для работы с докером используем testcontainers.
  • Формируем dsn и применяем миграции.
  • В докере запускаем команду pg_dump с параметрами. Результат будет передан в stdout. Важно, чтобы вывод был чистым без служебных бинарных заголовков нужно указать опцию Multiplexed.
  • Копируем вывод из контейнера в файл.

Пример данных из полученной схемы:

-- ...

--
-- Name: tasks; Type: TABLE; Schema: taskrepository; Owner: -
--

CREATE TABLE taskrepository.tasks (
    task_id uuid NOT NULL,
    request_id uuid NOT NULL,
    created_at timestamp with time zone NOT NULL,
    status text NOT NULL,
    data jsonb NOT NULL
);

--
-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: taskrepository; Owner: -
--

ALTER TABLE ONLY taskrepository.tasks
    ADD CONSTRAINT tasks_pkey PRIMARY KEY (task_id);

--
-- Name: idx_tasks_incomplete; Type: INDEX; Schema: taskrepository; Owner: -
--

CREATE INDEX idx_tasks_incomplete ON taskrepository.tasks USING btree (status) WHERE (status <> 'completed'::text);

-- ...

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

Похожее