База данных · PPA

Миграции

Схема не описывается отдельно: она уже описана атрибутами сущностей. Команда db migrate читает эту разметку и создаёт в базе то, чего в ней ещё нет — таблицы, индексы, ограничения. Повторный запуск безопасен.

Команда call db migrateОпт-ин #[Migratable] + #[Table]Режим только создание, идемпотентно

Что это и зачем

Миграция здесь — создание структуры базы из кода.

Проблема. Схема, живущая отдельно от сущностей, — это двойная работа и постоянный риск расхождения: добавили свойство в класс, забыли ALTER TABLE, и приложение падает уже в рантайме. Поднять базу с нуля на новой машине или в CI — ручной труд, который каждый делает по-своему.

Решение. Структура описана один раз — атрибутами сущности. Команда выводит из неё DDL и применяет. Источник правды один, и это код.

Это не система версионированных миграций

Инструмент создаёт недостающее и ничего больше. Он не сравнивает схему с базой, не изменяет и не удаляет существующее, не переносит данные и не ведёт таблицу применённых версий.

Этого достаточно для первого развёртывания, для CI и для разработки. Эволюцию схемы живого прода — переименования, смену типов, перенос данных — ведут отдельным инструментом. Границы разобраны ниже.

Что нужно, чтобы таблица создалась

Три условия, и все три обязательны. Не выполнено любое — таблица просто не появится, без ошибки.

Условие Где
1 Есть репозиторий с этой таблицей Обход идёт по репозиториям, а не по сущностям
2 У сущности стоит #[Table] Класс без него пропускается
3 У конфигурации стоит #[Migratable] Без него не рассматривается вся база
php
#[Migratable]                                    // (3) the database is opted in
class MainDbConfig extends PgDbConfig { /* ... */ }

#[Table]                                         // (2) the entity is a table
class Order { /* ... */ }

class OrderRepository extends Repository         // (1) ties the two together
{
  protected string $dbConfigClassName = MainDbConfig::class;
  protected string $entityClassName   = Order::class;
  public static string $table         = 'orders';
}

Команда сообщает, какое из условий не выполнено:

text
[Project] No DB configs found — no entity has #[Table].
[Project] No migratable configs — add #[Migratable] to a DbConfig to opt in.

Почему опт-ин, а не всё подряд

#[Migratable] — это заявление «схемой этой базы управляет код». Его отсутствие означает обратное: схему ведут снаружи, и трогать её не нужно.

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

Что получается

Возьмём сущность со страницы Сущности:

main/Entity/Order.php
#[Table]
class Order
{
  #[BigId]
  public ?int $id = null;

  #[Varchar(32)]
  #[Unique]
  public string $number;

  #[BigInteger]
  #[Index(['created_at'])]
  public int $user_id;

  #[SmallInteger]
  public int $status = 0;

  #[Decimal(12, 2)]
  #[Check('total >= 0')]
  public string $total;

  #[Timestamp]
  #[DefaultVal('NOW()')]
  public string $created_at;
}

Для PostgreSQL из неё получается:

sql
CREATE TABLE public.orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
number VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
total NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX orders_number_udx ON public.orders USING BTREE (number);
CREATE INDEX orders_user_id_created_at_idx ON public.orders USING BTREE (user_id, created_at);
ALTER TABLE public.orders ADD CONSTRAINT chk_orders_a0f6358602c3db66492a6ac4bcdb3862 CHECK (total >= 0);

Для MySQL — тот же набор объектов на его диалекте: AUTO_INCREMENT вместо GENERATED … AS IDENTITY, DECIMAL вместо NUMERIC, TIMESTAMP без указания пояса, схема не используется.

Имена объектов

Если имя не задано явно, оно собирается по правилу — предсказуемо и одинаково во всех проектах:

Объект Шаблон Пример
Первичный ключ таблица_pkey orders_pkey
Уникальный индекс таблица_колонки_udx orders_number_udx
Индекс таблица_колонки_idx orders_user_id_created_at_idx
Внешний ключ fk_таблица_колонка fk_orders_user_id
Ограничение CHECK chk_таблица_хеш chk_orders_a0f63586…

Имя CHECK содержит хеш выражения — иначе два разных ограничения на одной таблице столкнулись бы именами. Если имя должно быть читаемым, задайте его явно: у #[Index], #[Unique], #[ForeignKey], #[Check] есть аргумент name.

Своё имя проверяется: оно должно начинаться с буквы или подчёркивания и состоять из букв, цифр и подчёркиваний — иначе InvalidArgumentException при разборе.

Запуск

bash
php call db ping       # is the database reachable at all
php call db sql        # print the DDL, change nothing
php call db migrate    # execute it

Начинать стоит с db sql: он печатает ровно те операторы, которые выполнит db migrate, но ничего не трогает. Это же удобный способ отдать DDL администратору базы, если применять его будете не вы.

db sql — предпросмотр

Вывод сгруппирован по конфигурации, внутри — по фазам, с числом операторов в каждой:

text
[Project] MainConfigurationsMainDbConfig
Extensions (1)
  CREATE EXTENSION IF NOT EXISTS "pgcrypto";
Tables (3)
  CREATE TABLE public.users ( … );
  CREATE TABLE public.orders ( … );
Indexes (4)
  CREATE UNIQUE INDEX orders_number_udx ON public.orders USING BTREE (number);
Constraints (2)
  ALTER TABLE public.orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY … ;

Команда подключается к базе — ей нужен драйвер, чтобы выбрать диалект, — но не выполняет ни одного из напечатанных операторов. Флаги работают так же, как у migrate: db sql -t покажет только таблицы.

db migrate — выполнение

Вывод построчный, по объектам:

text
[Project] MainMainDbConfig
Tables (3)
public.users                                   OK
public.orders                                  OK
public.order_items                             EXIST
Indexes (4)
orders_number_udx                              OK
orders_user_id_created_at_idx                  EXIST
Constraints (2)
constraint 'fk_orders_user_id'                 OK
Метка Значение
OK Объект создан
EXIST Уже был — оператор пропущен, это не ошибка
FAILED Не создан; текст ошибки печатается при DEBUG=true

`FAILED` без объяснения — поставьте `DEBUG=true`

Сообщение базы показывается только в отладочном режиме, чтобы вывод команды не раскрывал внутренности схемы там, где её запускают в общем логе. Если объект не создался и непонятно почему — запустите с DEBUG=true.

Справочник команды

text
call db [command] -[flags] --[options]
Команда Что делает
ping проверяет связь и печатает задержку для каждой конфигурации
sql печатает сгенерированный DDL, ничего не выполняя
migrate выполняет его
pool утилизация пулов работающего сервера — см. Пул

Флаги (-e -s -t -i -c) и опции (--plugin=<name>, --plugins) разобраны ниже; полный список печатает call db --help.

Порядок

Внутри одной базы операторы идут по цепочке зависимостей — каждый следующий шаг опирается на предыдущий:

text
1. EXTENSIONS    CREATE EXTENSION IF NOT EXISTS …     (только PostgreSQL)
2. SCHEMAS       CREATE SCHEMA …                      (только PostgreSQL)
3. TABLES        CREATE TABLE … + PRIMARY KEY
4. INDEXES       CREATE INDEX / CREATE UNIQUE INDEX
5. CONSTRAINTS   ALTER TABLE … ADD CONSTRAINT         (FK, CHECK)

Расширения первыми, потому что на их функции ссылаются значения по умолчанию: DEFAULT gen_random_uuid() не создастся, пока нет pgcrypto. Схемы — до таблиц. Индексы и ограничения — после, они ссылаются на уже существующие колонки.

Порядок между базами

Когда конфигураций несколько, очерёдность задаётся приоритетом:

php
use Flytachi\Winter\Ppa\Mapping\Attributes\Config\Migratable;
use Flytachi\Winter\Ppa\Mapping\Constants\MigratablePriority;

#[Migratable(MigratablePriority::High)]        // first
class AuthDbConfig extends PgDbConfig { /* ... */ }

#[Migratable]                                   // Normal — default
class MainDbConfig extends PgDbConfig { /* ... */ }

#[Migratable(MigratablePriority::Low)]         // last
class AnalyticsDbConfig extends PgDbConfig { /* ... */ }

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

Расширения PostgreSQL

php
use Flytachi\Winter\Ppa\Mapping\Attributes\Config\Extension;

#[Migratable]
#[Extension('pgcrypto')]                       // gen_random_uuid() for #[UuidPk]
#[Extension('postgis', version: '3.4', schema: 'gis')]
class MainDbConfig extends PgDbConfig { /* ... */ }
Аргумент Назначение
name Имя расширения
version Требуемая версия
schema Схема, в которую его установить
cascade Ставить вместе с зависимостями

Атрибут повторяемый — по одному на расширение. На не-PostgreSQL молча игнорируется.

Повторный запуск

db migrate можно запускать сколько угодно раз: существующие объекты помечаются EXIST и пропускаются. Это работает двумя способами — где диалект позволяет, используется IF NOT EXISTS, где нет — распознаётся код ошибки «объект уже существует»:

Код Что означает
42P07 (pgsql), 42S01 (mysql) Таблица уже есть
42P07 (pgsql), 42000 (mysql) Индекс уже есть
42P06 Схема уже есть
42710 Ограничение уже есть

Любой другой код — настоящая ошибка, и объект помечается FAILED.

Идемпотентность — это не сравнение схем

«Безопасно запускать повторно» означает только, что существующие объекты не будут пересозданы. Оно не означает, что база придёт в соответствие с кодом.

Добавили свойство в сущность и запустили миграцию ещё раз — таблица уже существует, поэтому её оператор пропускается целиком, и новой колонки не появится. Добавление колонок в существующую таблицу нужно делать самостоятельно.

Выборочный запуск

Флаги ограничивают набор фаз — без них выполняются все:

bash
php call db migrate           # all phases: -e -s -t -i -c
php call db migrate -t         # tables only
php call db migrate -i -c      # indexes and constraints only
php call db migrate -e         # extensions only (pgsql)
Флаг Фаза
-e Расширения
-s Схемы
-t Таблицы
-i Индексы
-c Ограничения

Разделение полезно, когда таблицы уже созданы, а индекс добавили позже: -i выполнит только его, не трогая остального.

Плагины

По умолчанию обрабатывается сам проект. Плагины подключаются опциями:

bash
php call db migrate --plugin=billing    # one registered plugin
php call db migrate --plugins           # every registered plugin

Чего инструмент не делает

Границы стоит знать заранее — они объясняют, где нужен второй инструмент.

Не делает Что это значит на практике
Не сравнивает схему с кодом Изменённый тип колонки или удалённое свойство останутся незамеченными
Не изменяет существующее Новая колонка в существующей таблице не появится
Не удаляет Ни таблиц, ни колонок, ни индексов — DROP не генерируется никогда
Не переносит данные Только структура; наполнение — отдельная задача
Не ведёт версии Нет таблицы применённых миграций и нет отката
Не оборачивает в транзакцию Каждый оператор выполняется сам по себе; при сбое в середине часть объектов создана

Отсюда область применения: развернуть схему с нуля — на новой машине, в CI, в контейнере при первом запуске. Для эволюции схемы работающего прода берите инструмент с версиями и откатом.

Одна база под кодом, другая под внешним инструментом

Эти подходы не исключают друг друга. Конфигурация без #[Migratable] полностью невидима для команды — так база, которой управляет Liquibase или Phinx, остаётся нетронутой, а служебная база проекта продолжает подниматься из кода.

Точно так же можно исключить отдельную сущность — снять с неё #[Table]. На чтение и запись это никак не влияет: атрибут управляет только миграцией.

Дальше