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

от автора

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

Введение

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

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

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

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

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

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

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

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

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

DROP TABLE IF EXISTS     `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     `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     `tabexample` DROP COLUMN     `name`;

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

ALTER 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 `tabexample`(     `rost` SMALLINT NULL COMMENT 'Рост человека, мм' ) COMMENT 'Таблица для примера';

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

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

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

CREATE 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;     } };

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

Индексы

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

  • 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
Справка по всем сущностям (таблица, колонка(поле), индекс, отношение, ссылка на поле)

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

  • DbSchemaDriverMySql
  • DbSchemaDriverPostgreSql

UPDATE
В версии 2.0 убрал вообще все зависимости, переместив функционал документирования в отдельный пакет. Теперь пакет работает с голым драйвером PDO


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