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


Введение


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


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


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


  • DbSchemaDriverMySql
  • DbSchemaDriverPostgreSql

UPDATE
Убрал twig из зависимостей, переместив функционал документирования в отдельный пакет

Комментарии (13)


  1. PycmaM
    29.12.2023 06:30
    +4

    Библиотека миграций в зависимостях у которой twig?) спасибо, но лучше уж doctrine-migrations или eloquent прости хоспади


    1. PycmaM
      29.12.2023 06:30
      +1

      но как пет-проект и для резюме вполне пойдет, если причесать) плюсанул пост


    1. shasoftX Автор
      29.12.2023 06:30

      На самом деле twig (да и вообще все зависимости кроме shasoft/pdo) там только чтобы генерировать справку по сущностям в таком виде и справку по миграциям. Это я использовал для отладки. Скорее всего я это в дальнейшем в отдельный пакет выделю или в require-dev перенесу. Но для первой версии оставил "как есть".


  1. inik23
    29.12.2023 06:30
    +1

    #[Drop]
    protected ColumnString $name;

    Кажется немного странным оставлять дропнутое поле в определении таблицы


    1. shasoftX Автор
      29.12.2023 06:30

      Это миграции. А значит нельзя удалять то что было определено ранее. Иначе нарушится последовательность шагов.


      1. inik23
        29.12.2023 06:30
        +2

        Но это также является и моделькой на сколько я понял с поста. И мне кажется что модель не должна содержать такие данные. Но это просто мое мнение )


        1. shasoftX Автор
          29.12.2023 06:30

          Задача то как раз и была в совмещении модели и миграций в одной файле. И данный подход реализовался с прикидкой что это не модель, а просто класс для определения структуры таблицы. Т.е. я не планирую его использовать как модель, хотя этот вариант также возможен так как никаких ограничений на класс не накладывается. Т.е. его не нужно от чего то наследовать, он не должен содержать какие-то трейты. Это может быть абсолютно любой класс.


          1. kiaplayer
            29.12.2023 06:30
            +1

            Если у вас отдельная модель для описания таблицы, то чем это проще обычных миграций?

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

            А тут разве не то же самое получилось? Сначала правим бизнес-модель, потом модель для таблицы.


            1. shasoftX Автор
              29.12.2023 06:30

              Так ведь мой подход позволяет совместить бизнес-модель и модель для миграций. К тому же обычные миграции обычно в отдельных файлах. В моем случае даже если использовать отдельный класс для таблицы, то это всегда будет один файл. Т.е. всё будет в одном месте. Хотя тут уже вопрос насколько это будет удобно для понимания когда миграций будет много.
              Но тут как раз и помогут классы для документирования.

              Мы берем вот такой класс
              #[Comment('Таблица с изменениями')]
              #[Migration('2011-11-11T12:00:00+03:00')]
              #[Comment('Таблица для тестов изменений')]
              class AllMigrations
              {
                  // Колонка
                  #[Comment('Идентификатор')]
                  protected ColumnId $id;
                  // Колонка
                  #[Migration('2012-12-12T12:00:00+03:00')]
                  #[DefaultValue('Имя')]
                  #[Migration('2013-12-14T12:00:00+03:00')]
                  #[DefaultValue]
                  #[Comment('Колонка с именем')]
                  #[Migration('2017-12-12T12:00:00+03:00')]
                  #[Name('Imj')]
                  #[Migration('2018-12-12T12:00:00+03:00')]
                  #[Type(ColumnBoolean::class)]
                  #[Name('NAME')]
                  #[Migration('2019-12-12T12:00:00+03:00')]
                  #[MaxLength(2 ** 16)]
                  protected ColumnString $name;
                  // Колонка
                  #[Migration('2015-12-12T12:00:00+03:00')]
                  protected ColumnReal $rost;
                  //
                  #[Columns('id')]
                  protected IndexPrimary $pkKey;
                  // Индекс
                  #[Columns('id')]
                  #[Migration('2011-11-11T12:00:00+03:00')]
                  #[Drop]
                  #[Migration('2015-12-12T12:00:00+03:00')]
                  #[Create]
                  #[Columns('id', 'name')]
                  #[Migration('2016-12-12T12:00:00+03:00')]
                  #[Columns('id', 'rost')]
                  protected IndexKey $testIdx;
              }

              и получаем такую картинку

              и генерируем из него вот такую справочную информацию по которой всё достаточно понятно. Какая миграций за какой выполнялась и какой SQL код выполнялся.


  1. SerafimArts
    29.12.2023 06:30
    +2

    А не проще ли в таком случае использовать доктрину? Потому что пока что выглядит как изобретение велосипеда, но только переусложнённого.


    1. shasoftX Автор
      29.12.2023 06:30

      В моём случае - нет, не проще. Мне помимо миграций нужно иметь состояние БД чтобы на её основе потом свой sql-builder сделать.

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


      1. SerafimArts
        29.12.2023 06:30

        В моём случае - нет, не проще. Мне помимо миграций нужно иметь состояние БД чтобы на её основе потом свой sql-builder сделать.

        Так доктрина как раз и берёт текущие сущности/модели, делает дифф с состоянием базы и на выходе выдаёт миграцию со всеми отличиями.


        1. shasoftX Автор
          29.12.2023 06:30

          Очевидно вы либо не читали пост, либо не дочитали.

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

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

          Или получить команду конвертации данных из БД в PHP

          // Получить фукнцию конвертации значение колонки id БД=>PHP
          $migrations
              ->database()
              ->table(TabExample::class)
              ->column('id')
              ->value(ConversionOutpute::class);

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

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

          В общем много чего можно придумать. Достаточно просто ввести новую команду и обрабатывать её. На основании этой мета-информации я планирую делать свой пакет работы с БД с блэкджеком и шлюхами, само собой. :)