Версионная миграция структуры базы данных через PHP атрибуты

Всегда немного раздражало что при написании миграций в Laravel сначала необходимо прописывать поля в классе модели, а затем эти же поля в миграциях. И когда мне понадобилось написать версионирование структуры БД, то решил совместить класс модели и миграции. И сделал я это через атрибуты PHP. Также вместе с миграциями я получил состояние базы данных которое можно использовать при работе с ней.
sbnrs-lczjka8eezlwrsxscq_uk.png


Введение

Простой пример определения таблицы БД:

#[Comment('Таблица для примера')]
class TabExample
{
    #[Comment('Идентификатор')]
    protected ColumnId $id;
    #[Comment('Имя')]
    protected ColumnString $name;
    // Первичный ключ
    #[Columns('id')]
    protected IndexPrimary $pkKey;
}

В данном примере через класс TabExample определяется таблица, содержащая два поля и один индекс. Миграции для указанного примера создаются следующим образом:

    // Создать PDO соединение
    $pdoConnection = new PdoConnectionMySql([
        'dbname' => 'cmg-db-test',
        'host' => 'localhost',
        'username' => 'root'
    ]);
    // Получить миграции
    $migrations = DbSchemaMigrations::get([
            TabExample::class,      // Класс таблицы
        ],
        DbSchemaDriverMySql::class   // Драйвер для получения миграций
    );
    // Выполнить миграции
    $migrations->run($pdoConnection);

В результате выполнения миграций в БД создастся таблица БД с помощью следующего SQL кода

CREATE TABLE `shasoft-dbschema-tests-table-tabexample`(
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Идентификатор',
    `name` VARCHAR(255) NULL COMMENT 'Имя',
    PRIMARY KEY(`id`) USING BTREE
) COMMENT 'Таблица для примера';

Можно отменить последнии миграции с помощью следующего кода

// Отменить последнии миграции
$migrations->cancel($pdoConnection);

эти миграции будут отменены с помощью следующего SQL кода

DROP TABLE IF EXISTS
    `shasoft-dbschema-tests-table-tabexample`

Основная идея очень простая: создается класс таблицы, в котором определяются колонки (поля), индексы, отношения и ссылки на поля.
Каждая сущность (таблица, колонка, индекс, отношение, ссылка на поле) поддерживает заданный список команд, которые можно указывать через атрибуты PHP. В примере выше используются команды Comment и Columns.
Если нам нужно добавить в класс новую миграцию, то сделать это можно с помощью команды Migration следующим образом:

#[Comment('Таблица для примера')]
class TabExample
{
    #[Comment('Идентификатор')]
    protected ColumnId $id;
    #[Comment('Имя')]
    protected ColumnString $name;
    #[Migration('2023-12-28T22:00:00+03:00')]
    #[Comment('Фамилия')]
    protected ColumnString $fam;
    // Первичный ключ
    #[Columns('id')]
    protected IndexPrimary $pkKey;
}

Т.е. указываем команду Migration в качестве параметров строку с датой/временем миграции (можно указывать не строку, а объект DateTime) и после указываем команды изменений которые вносит эта миграция. В данном случае мы добавили новое поле fam. В результате миграции будут содержать две SQL команды. Первая команда — создание таблицы (как в первом примере) и вторая команда — добавление нового поля:

ALTER TABLE
    `shasoft-dbschema-tests-table-tabexample` ADD `fam` VARCHAR(255) NULL COMMENT 'Фамилия'

Добавим ещё одну миграцию с переименованием поля и удалением поля

#[Comment('Таблица для примера')]
class TabExample
{
    #[Comment('Идентификатор')]
    protected ColumnId $id;
    #[Comment('Имя')]
    #[Migration('2023-12-28T22:10:00+03:00')]
    #[Drop]
    protected ColumnString $name;
    #[Migration('2023-12-28T22:00:00+03:00')]
    #[Comment('Фамилия')]
    #[Migration('2023-12-28T22:10:00+03:00')]
    #[Name('surname')]
    protected ColumnString $fam;
    // Первичный ключ
    #[Columns('id')]
    protected IndexPrimary $pkKey;
}

И тогда в миграции добавится ещё две SQL команды для удаления

ALTER TABLE
    `shasoft-dbschema-tests-table-tabexample`
DROP COLUMN
    `name`;

и переименования поля

ALTER TABLE
    `shasoft-dbschema-tests-table-tabexample` CHANGE `fam` `surname` VARCHAR(255) NULL COMMENT 'Фамилия';


Типы колонок (полей)

На текущий момент поддерживаются основные типы БД


  • ColumnString — Текст
  • ColumnInteger — Целое число
  • ColumnReal — Вещественное число
  • ColumnBoolean — Логическое значение
  • ColumnBinary — Двоичные данные
  • ColumnDatetime — Дата/время
  • ColumnDecimal — Число с фиксированной точностью
  • ColumnEnum — Перечисление

И дополнительные типы (они основаны на основных)


  • ColumnId — Идентификатор
  • ColumnJson — Json данные

Для примера рассмотрим тип ColumnInteger — Целое число. Поле содержащие целое число может быть 8, 16, 24, 32, 48 и 64 битным в зависимости от БД. При этом какие БД поддерживают 48 битные целые поля, какие-то нет. Именно поэтому нет команд, которые определяют размерность числа в битах, зато есть команды MinValue И MaxValue которые определяют минимальное и максимальное значение поля. А уже на основе этих значений драйвер БД определяет какой тип поля необходим для хранения. По умолчанию MinValue = PHP_INT_MIN, MaxValue = PHP_INT_MAX. Однако эти значения можно переопределить с помощью команд при определении поля.

#[Comment('Таблица для примера')]
class TabExample
{
    #[Comment('Рост человека, мм')]
    #[MinValue(0)]
    #[MaxValue(4000)]
    protected ColumnInteger $rost;
}

SQL код для MySql

CREATE TABLE `shasoft-dbschema-tests-table-tabexample`(
    `rost` SMALLINT NULL COMMENT 'Рост человека, мм'
) COMMENT 'Таблица для примера';

По умолчанию значение колонки (поля) может быть NULL. Однако можно переопределить значение по умолчанию с помощью команды DefaultValue

#[Comment('Таблица для примера')]
class TabExample
{
    #[Comment('Рост человека, мм')]
    #[MinValue(0)]
    #[MaxValue(4009)]
    #[DefaultValue(1800)]
    protected ColumnInteger $rost;
}

и получаем код где по умолчанию рост устанавливается = 1800

CREATE TABLE `shasoft-dbschema-tests-table-tabexample`(
    `rost` SMALLINT DEFAULT 1800 COMMENT 'Рост человека, мм'
) COMMENT 'Таблица для примера';


Состояние базы данных и её использование

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

// Получить максимальное значение колонки id
$migrations
    ->database()
    ->table(TabExample::class)
    ->column('id')
    ->value(MaxValue::class);

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

Зная минимальное и максимальное значение колонки мы можем легко сгенерировать случайное значение колонки (поля). Также в список поддерживаемых команд входит команда Seeder в которой можно задать статический метод класса/функцию для генерации случайного значения. А чтобы процесс генерации данных сделать совсем простым в состоянии таблицы добавлен метод seeder, который генерирует строку данных для таблицы:

// Сгенерировать 10 строк случайных значений
$rows = $migrations
    ->database()
    ->table(TabExample::class)
    ->seeder(10,30);

Код выше генерирует 10 строк со случайными данными для указанной таблицы. Количество строк задаётся первым параметром. Вторым параметром задаётся вероятность установки поля в значение NULL (если колонка такое поддерживает).

Сгенерированные строки необходимо добавить в таблицу БД. И тут возникает необходимость конвертировать значения из формата PHP в формат БД и обратно. И для этого тоже есть свои команды:


  • ConversionInput — конвертировать из формата PHP в формат БД
  • ConversionOutput — конвертировать из формата БД в формат PHP

В качестве параметра указывается статический метод класса/функция для конвертации значений. Ниже представлен тип колонки Json данных в котором показано использование команд конвертации:

// Json данные
class ColumnJson extends ColumnString
{
    // Конструктор
    public function __construct()
    {
        // Вызвать конструктор родителя
        parent::__construct();
        // Удалить команды
        $this->removeCommand(Seeder::class);
        // Установить команды
        $this->setCommand(new Comment('Json данные'));
        $this->setCommand(new MaxLength(256 * 256 - 1));
        $this->setCommand(new DefaultValue());
        $this->setCommand(new ConversionInput(self::class . '::inputJson'), false);
        $this->setCommand(new ConversionOutput(self::class . '::outputJson'), false);
        // Удалить команды из списка поддерживаемых
        $this->removeSupportCommand(Variable::class);
    }
    // PHP=>БД
    public static function inputJson(array|null $value): ?string
    {
        if (is_null($value)) {
            return null;
        }
        return is_array($value) ? (json_encode($value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)) : '{}';
    }
    // БД=>PHP
    public static function outputJson(string|null $value): ?array
    {
        if (is_null($value)) {
            return null;
        }
        $ret = [];
        if (!empty($value) && is_string($value)) {
            $ret = json_decode($value, true);
        }
        return $ret;
    }
};

Теперь чтобы произвести конвертацию данных достаточно из состояния колонки получить соответствующую команду и вызвать нужный метод. Для добавления данных в состоянии таблицы уже реализован метод insert который вызывает методы конвертации:

// Вставить в таблицу БД сгенерированные ранее строки
$rows = $migrations
    ->database()
    ->table(TabExample::class)
    ->insert($pdoConnection, $rows);

В качестве параметра метод получает объект PDO соединения с БД и строки таблицы.

Для ускорения работы с БД используются индексы. Поддерживаются следующие типы индексов:


  • IndexPrimary — Первичный ключ (индекс)
  • IndexUnique — Уникальный индекс
  • IndexKey — Неуникальный индекс

Индексы поддерживают обязательную команду Columns(т.е. без её указания будет генерироваться ошибка) которая задаёт список полей индекса.

Иногда необходимо в одной таблице ссылаться на поле в другой таблице. К примеру в таблице Статьи добавить поле идентификатор пользователя который ссылается на поле в таблице Пользователи. При этом необходимо чтобы при изменении типа колонки в таблице Пользователи во всех таблицах где идет ссылка на это поле тоже бы изменялся тип. Для этого и существует сущность — Reference. Пример использования будет показан в разделе Отношения.

Обычно таблицы связываются отношениями. Поддерживаются следующие виды отношений:


  • RelationManyToOne — Отношение многие-к-одному
  • RelationOneToMany — Отношение один-ко-многим
  • RelationOneToOne — Отношение один-к-одному

В коде ниже демонстрируется пример отношения Многие-к-Одному. Нескольким статьям может соответствовать один пользователь.

#[Comment('Пользователи')]
class User
{
    //
    #[Comment('Идентификатор')]
    protected ColumnId $id;
    #[Comment('Имя')]
    protected ColumnString $name;
    #[Columns('id')]
    protected IndexPrimary $pkId;
}
#[Comment('Статьи')]
class Article
{
    //
    #[Comment('Идентификатор')]
    protected ColumnId $id;
    #[Comment('Ссылка на автора')]
    #[ReferenceTo(User::class, 'id')]
    protected Reference $userId;
    #[Comment('Название')]
    protected ColumnString $title;
    #[Columns('id')]
    protected IndexPrimary $pkId;
    // Отношение
    #[RelTableTo(User::class)]
    #[RelNameTo('articles')]
    #[Columns(['userId' => 'id'])]
    #[Comment('Автор')]
    protected RelationManyToOne $author;
}

В таблице Article определяется поле userId вида Reference (Ссылка на поле) и с помощью команды ReferenceTo указывается ссылочное поле. Также указывается отношение author со всеми нужными параметрами. В результате в таблице Article и User будут созданы все необходимые индексы для быстрого поиска по этим отношениям. Т.е. в таблице Article нет необходимости создавать индекс по полю userId, он будет создан на основе указанного отношения. Также через состояние БД можно получить всю информацию об этом отношении.
В принципе можно создавать внешние связи в тех БД, где это поддерживается. Но пока отношение — это просто создание соответствующих индексов + информация о них в состоянии БД.

Ссылка на пакет shasoft/db-schema
Справка по всем сущностям (таблица, колонка (поле), индекс, отношение, ссылка на поле)

© Habrahabr.ru