Skip to content

Latest commit

 

History

History
301 lines (276 loc) · 17.6 KB

index.md

File metadata and controls

301 lines (276 loc) · 17.6 KB

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

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

Введение

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

#[Title('Таблица для примера')]
class TabExample
{
    #[Title('Идентификатор')]
    protected ColumnId $id;
    #[Title('Имя')]
    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 следующим образом:

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

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

ALTER TABLE
    `tabexample` ADD `fam` VARCHAR(255) NULL COMMENT 'Фамилия'

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

#[Title('Таблица для примера')]
class TabExample
{
    #[Title('Идентификатор')]
    protected ColumnId $id;
    #[Title('Имя')]
    #[Migration('2023-12-28T22:10:00+03:00')]
    #[Drop]
    protected ColumnString $name;
    #[Migration('2023-12-28T22:00:00+03:00')]
    #[Title('Фамилия')]
    #[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. Однако эти значения можно переопределить с помощью команд при определении поля.

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

SQL код для MySql

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

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

#[Title('Таблица для примера')]
class TabExample
{
    #[Title('Рост человека, мм')]
    #[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 Title('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 - Отношение один-к-одному

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

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

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

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