Разбираемся с миграциями

На прошлом уроке мы дошли до запуска проекта со стандартными таблицами и данными, теперь давайте разберёмся, откуда они взялись.
В директории core/db есть 2 поддиректории: migrations и seeds. В первой, понятно, миграции, а во второй скрипты для "засеивания" начальных данных. Для работы используется Phinx, конфигурация которого хранится в core/phinx.php - там нам ничего менять не нужно, все настройки берутся из настроек окружения .env.
Миграциями называют изменение структуры базы данных без потери её консистентности. Каждая миграция может быть отменена, при этом она возвращает структуру БД к предыдущему виду. На продакшене, конечно же, никто миграции не откатывает - это нужно только на время разработки.
Для запуска миграций в Vesp мы используем команду composer db:migrate, для отката composer db:rollback.
Теперь давайте посмотрим на сами файлы миграций.
Название файла состоит из отметки времени и произвольного имени, которое будет использоваться для работы. Временной отпечаток сортирует миграции в порядке их создания, чтобы они всегда выполнялись последовательно.

Миграция Files

Vesp уже содержит 2 миграции: таблицы файлов и пользователей.
<?php

use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;

// Класс должен расширять Vesp\Services\Migration
class Files extends Migration
{
    // Функция внесения изменений
    public function up(): void
    {
        // Создаём таблицу для файлов
        $this->schema->create(
            'files',
            function (Blueprint $table) {
                $table->id(); // Первичный ключ Unsigned Big Integer с автоинкрементом
                // Дальше колонки Varchar  
                $table->string('file'); 
                $table->string('path');
                // Следующие 2 колонки могут быть null при создании записи
                // То есть, они не являются обязательными
                $table->string('title')->nullable();
                $table->string('type')->nullable();
                $table->smallInteger('width')->unsigned()->nullable();
                $table->smallInteger('height')->unsigned()->nullable();
                $table->integer('size')->unsigned()->nullable();
                // колонка с типом JSON появилась только в MySQL 5.7
                $table->json('metadata')->nullable(); 
                // Специальная команда Eloquent, которая добавляет 2 колонки:
                // created_at и updated_at для сохранения времени создания и 
                // последнего изменения записи
                $table->timestamps();
            }
        );
    }

    // Функция отката изменений
    public function down(): void
    {
        // Просто удаляем таблицу
        $this->schema->drop('files');
    }
}
Вам не нужно запоминать типы колонок, здесь и далее везде прекрасно работает автодополнение IDE.
Но крайне желательно знать типы данных MySQL, все они перечислены в документации.
Итак, при запуске composer db:migrate Phinx смотрит на имеющиес миграции и выполняет новые. Как он знает, какие миграции уже были выполнены? Очень просто - он сохраняет их имена в таблице old_migrations.

Миграция Users

Теперь давайте посмотрим на вторую миграцию, она создаёт сразу 3 таблицы, и связывает их друг с другом.
<?php

use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;

class Users extends Migration
{

    public function up(): void
    {
        // Первым делом создаём таблицу для хранения групп пользователей
        $this->schema->create(
            'user_roles',
            function (Blueprint $table) {
                // Первичный ключ
                $table->id();
                // Название группы должно быть уникальным
                $table->string('title')->unique();
                // Права доступа группы могут быть null
                $table->json('scope')->nullable();
                // Стандартные колонки с датами
                $table->timestamps();
            }
        );

        // Теперь таблица пользователей
        $this->schema->create(
            'users',
            function (Blueprint $table) {
                $table->id();
                // Уникальное имя пользователя
                $table->string('username')->unique();
                // Обязательный пароль (мы его захэшируем, конечно)
                $table->string('password');
                // Следующие 2 колонки не обязательны
                $table->string('fullname')->nullable();
                $table->string('email')->nullable();
                // Связь с таблицей группы пользователей (расскажу ниже)
                $table->foreignId('role_id')
                    ->constrained('user_roles')->onDelete('cascade');
                // Статус записи индексируется отдельно
                $table->boolean('active')->default(true)->index();
                $table->timestamps();
            }
        );

        // Таблица "сессий" пользователя
        $this->schema->create(
            'user_tokens',
            function (Blueprint $table) {
                // Здесь первичным ключом является строчный токен
                $table->string('token')->primary();
                // Свзяь с таблицей пользователей
                $table->foreignId('user_id')
                    ->constrained('users')->onDelete('cascade');
                // Срок годности токена
                $table->timestamp('valid_till')->index();
                // Ip адрес пользователя
                $table->string('ip', 16)->nullable();
                // Статус записи
                $table->boolean('active')->default(true);
                $table->timestamps();

                // Комбинированный индекс для быстрого поиска 
                // активных токенов пользователя
                $table->index(['token', 'user_id', 'active']);
            }
        );
    }

    // Функция отката изменений
    public function down(): void
    {
        // Просто удаляем все 3 таблицы в обратном порядке
        $this->schema->drop('user_tokens');
        $this->schema->drop('users');
        $this->schema->drop('user_roles');
    }
}
В этой миграции мы впервые видим связь таблиц между собой. Разберём на примере.
В таблице user_roles объявлен первичный ключ через $table->id() - на уровне MySQL это колонка id:
  • тип колонки BIGINT
  • атрибутом UNSIGNED, то есть значение не может быть меньше 0 и это даёт возможность сохранять больше положительных значений - аж до 2*64 степени - 1
  • ключ PRIMARY, то есть первичный уникальный
  • и еще она AUTO_INCREMENT, то есть значения увеличиваются автоматически на 1 в каждой новой записи
Такой вид колонки id - самый распространённый для идентификации конкретной записи. В дальнейшем по ней можно искать записи, обновлять и удалять.
Логично, что если мы хотим связать какую-то другу таблицу с этой - мы будем привязывать её именно к id. Eloquent предлагает для этого очень удобный синтаксис:
$table->foreignId('role_id')
    ->constrained('user_roles')
    ->onUpdate('restrict')
    ->onDelete('cascade');
foreignId создаёт колонку role_id (имя произвольное, но Eloquent очень просит, чтобы заканчивалось на _id) ровно того же типа UNSIGNED BIGINT, а constrained указывает на связь с таблицей user_roles. Эта запись буквально говорит, что role_id обязательно содержит id из таблицы user_roles.
PhpMyAdmin даже делает значения таких колонок ссылками со всплывающими подсказками о родительском значении:
Теперь вы просто не сможете сохранить в БД пользователя с неправильной группой - MySQL выдаст ошибку.
Командами onUpdate и onDelete мы указываем, что делать с юзерами при изменении родительской записи из группы: restrict означает не делать ничего, а cascade - делать тоже самое. В нашем случае указано, что при удалении записи из user_roles, будут удалены все пользователи этой группы. Автоматически, на уровне БД.
Таким образом, связав эти 2 таблицы, мы точно знаем, что пользователей без группы быть не может - MySQL не позволит сохранить такую запись, а при удалении группы удалит всех её пользователей.
Более подробно про связи можно почитать документацию MySQL.

Сиды

Seed - это семя или семечко по-английски. В терминологии миграций это скрипты, которые вносят какие-то данные в таблицы, чтобы не делать этого вручную. Запускаются командой composer db:seed.
В нашем случае сиды создают начальные группы и пользователей. Для этого выполняются 2 скрипта: core/db/seeds/UserRoles.php и core/db/seeds/Users.php.
Вот создание групп пользователей:
<?php

use Phinx\Seed\AbstractSeed;
use App\Models\UserRole;

class UserRoles extends AbstractSeed
{
    public function run(): void
    {
        // Просто массив с группами
        $roles = [
            'Administrator' => [
                // И их правами
                'scope' => ['profile', 'users'],
            ],
            'User' => [
                'scope' => ['profile'],
            ],
        ];

        // Дальше проходим по массиву
        foreach ($roles as $title => $data) {
            // Если группы с таким нзавнием нет
            if (!$group = UserRole::query()->where('title', $title)->first()) {
                // То создаём
                $group = new UserRole(['title' => $title]);
            }
            // И обновляем ей свойства
            $group->fill($data);
            $group->save();
        }
    }
}
Сиды используют модели Eloquent, которые мы будем рассматривать на следующем уроке. Но, как вы понимаете, в этих скриптах можно делать что угодно с данными, не только добавлять, но и удалять или менять.
Создание стандартных пользователей отличается только наличием еще одного метода:
public function getDependencies(): array
{
    return ['UserRoles'];
}
Это означает, что пользователи должны создаваться после создания их групп. Иначе мы бы получили ошибку от MySQL, помните?
Ну вот, а так мы указываем, что скрипт Users требует обязательного выполнения UserRoles перед ним.

Заключение

Мы рассмотрели базовые миграции и сиды Vesp. Нужно учесть, что они не выполняются сразу после создания пакета, вам нужно запускать их вручную.
Так сделано потому, что Vesp не требует от вас пользоватся именно этой структурой таблиц. Вы можете изменить пользователей, добавить им полное имя, дату рождения, номер телефона и только после этого создавать таблицы.
Более того, структура таблиц не может быть неизменнной по ходу разработки проекта, так что мы напишем и запустим еще несколько миграций.
На следующем уроке будет изучать модели Eloquent для работы с нашими таблицами.

9 комментариев

Александр Наумов
Классно, особенно про _id придумано. Про типы json тоже узнал впервые, нужно будет разобраться в их преимуществах.
Василий Наумкин
Eloquent вообще толковый, после него xPDO невозможно использовать.
Александр Наумов
Василий, мы изучаем Eloquent 8.x версию?
Василий Наумкин
Да, верно, в зависимостях у vesp-core прописан Eloquent 8.
Кирилл Дворянинов
Заметил что в только что установленной Весте в миграции отсутствует ->onUpdate('restrict') Я так понимаю это самому необходимо дополнить?
Василий Наумкин
Не надо, оно по умолчанию так - я просто чуть более подробно написал.
В таблице user_roles объявлен первичный ключ через $table->id() - на уровне MySQL это колонка id:
тип колонки BIGINT атрибутом UNSIGNED, то есть значение не может быть меньше 0 и это даёт возможность сохранять больше положительных значений - аж до 2*64 степени - 1 ключ PRIMARY, то есть первичный уникальный и еще она AUTO_INCREMENT, то есть значения увеличиваются автоматически на 1 в каждой новой записи
Насколько я понял, таблицы в БД создаются на основе файлов миграции. При этом в файле миграции (users) у первичного ключа (как и у любых других данных) не указываются никакие доп. атрибутов. Значит Eloquent (или Phinx отвечающий за миграции) понимает, что по умолчанию запись первичного ключа $table->id(); должна сопровождаться атрибутами: BIGINT, UNSIGNED и AUTO_INCREMENT?
Василий Наумкин
Да, верно.
Если пройти по цепочке методов Illuminate\Database\Schema::Blueprint, начиная с id(), то увидишь вот это:
    public function id($column = 'id')
    {
        return $this->bigIncrements($column);
    }

    public function bigIncrements($column)
    {
        return $this->unsignedBigInteger($column, true);
    }

    public function unsignedBigInteger($column, $autoIncrement = false)
    {
        return $this->bigInteger($column, $autoIncrement, true);
    }

    public function bigInteger($column, $autoIncrement = false, $unsigned = false)
    {
        return $this->addColumn('bigInteger', $column, compact('autoIncrement', 'unsigned'));
    }
Последний метод и добавляет колонку в схему с нужными параметрами.
Спасибо, разбираюсь
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Futuris
04.04.2024, 08:56:12
Я просто немного запутался. Когда в абзаце "Vesp/Core" ты пишешь про "новый trait FileModel", я поду...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!