Новая структура таблиц магазина

Мы стартуем с момента окончания прошлого курса, поэтому я предлагаю вам скачать архив с кодом после его завершения.
Внутри есть директория docker, там нужно запустить docker-compose и можно работать. Более подробно про работу с Docker можно прочитать в отдельной заметке, там много комментариев с решением типичных вопросов.
Если же у вас остались данные от первого курса, то предлагаю обнулить вашу базу и пересоздать всё заново. Для этого можно или откатить все миграции, или просто вручную грохнуть БД. Проще всего удалить docker/db и перезапустить контейнер, он создаст новую базу.
Также я еще обновил php и node зависимости, и внёс вот это в core/phinx.php:
    'templates' => [
        'style' => 'up_down',
    ],
Теперь новые миграции сразу содержат методы up() и down(), вместо change().

Основные таблицы

Из старых миграций оставляем только 20210322072616_files.php и 20210322072635_users.php, остальные удаляем.
Заходим в контейнер php-fpm, переходим в терминал и создаём новую миграцию composer db:create Languages
Дальше меняем даты 3х файлов миграций таким образом, чтобы они выстроились в следующем порядке: files, languages, users.
Миграция таблицы Languages очень простая, но к ней мы будем привязывать остальные таблицы, в которых будет ссылка на язык. Именно поэтому она должна быть выполнена перед users.
public function up(): void
{
    $this->schema->create(
        'languages',
        function (Blueprint $table) {
            $table->char('lang', 2)->primary();
            $table->string('title')->unique();
            $table->unsignedInteger('rank')->nullable()->index();
            $table->boolean('active')->default(true)->index();
        }
    );
}

public function down(): void
{
    $this->schema->drop('languages');
}
В миграции Users оставляем создание групп и токенов и добавляем множество новых колонок пользователям:
$this->schema->create(
    'users',
    function (Blueprint $table) {
        $table->id();
        $table->string('username')->unique();
        $table->string('fullname')->nullable();
        $table->string('password');
        // Колонка для сброса пароля
        $table->string('tmp_password')->nullable();
        // Соль хэширования для старых юзеров MODX
        $table->string('salt')->nullable();
        $table->string('email')->nullable();
        $table->string('phone')->nullable();

        // Пол юзера, компания, адрес и т.д.
        $table->tinyInteger('gender')->nullable();
        $table->string('company')->nullable();
        $table->string('address')->nullable();
        $table->string('country')->nullable();
        $table->string('city')->nullable();
        $table->string('zip')->nullable();
        // А вот и колонка с языком пользователя
        $table->string('lang', 2)->nullable();

        // Связь с группой
        $table->foreignId('role_id')
            ->constrained('user_roles')->cascadeOnDelete();

        // Связь с файлом картинки-аватарки
        $table->foreignId('file_id')->nullable()
            ->constrained('files')->nullOnDelete();

        // Статусы юзера: активирован, заблокирован
        $table->boolean('active')->default(true);
        $table->boolean('blocked')->default(false);

        // Комментарий про юзера
        $table->text('comment')->nullable();
        // Связь этой записи с таблицей MODX
        $table->unsignedInteger('remote_id')->nullable()->index();
        $table->timestamps();

        // Составной интекс на статус юзера
        $table->index(['active', 'blocked']);
        // Связь с таблицей языков
        $table->foreign('lang')
            ->references('lang')
            ->on('languages')
            ->nullOnDelete();
    }
);
Обратите на связь users с languages внешним ключом.
В Eloquent нет функции для удобного создания таких связей по строкам, он умеет их делать только по id (метод foreignId()) или uuid (соответственно foreignUuid()), поэтому здесь мы создаём ключик вручную.
Именно из-за этой связи на момент создания таблицы юзеров мы уже должны создать таблицу languages.
Саму модель User мы будем обновлять позже, вместе с переносом юзеров из MODX.

Категории товаров

Магазин у нас мультиязычный, таблица с языками уже прудусмотрена, осталось только прикрутить к ней категории и товары.
Мне не нравится практика копирования товаров в MODX по разным контекстам, это глупо и неэффективно, но ничего не поделать - там так принято. В VespShop мы это легко исправим.
Для этого вынесем текстовые колонки в отдельные таблицы и будем их подтягивать при выводе пользователю. Это может показаться немного сложным, но на самом деле, довольно удобно - особенно если правильно прописать все связи на уровне бд.
Создаём новую миграцию для категорий composer db:create Categories:
public function up(): void
{
    $this->schema->create(
        'categories',
        static function (Blueprint $table) {
            $table->id();
            // Связь с таблицой файлов - категории могут иметь картинку
            $table->foreignId('file_id')->nullable()
                ->constrained('files')->nullOnDelete();
            // Псевдоним и адрес категории
            $table->string('alias');
            $table->string('uri')->unique();
            // Статус и сортировка
            $table->boolean('active')->default(true);
            $table->unsignedInteger('rank')->default(0);
            // Связь с таблицей MODX
            $table->unsignedInteger('remote_id')->nullable()->index();
            $table->timestamps();

            $table->index(['uri', 'active']);
        }
    );

    // А вот здесь мы сразу же меняем только что созданную таблицу, чтобы
    // добавить внешний ключ на саму себя!
    // Категории могут быть вложены друг в друга
    $this->schema->table(
        'categories',
        static function (Blueprint $table) {
            // Добавляем новую колонку с указанием в таблицу
            // с указанием внешнего ключа на саму себя
            $table->foreignId('parent_id')->nullable()->after('id')
                ->constrained('categories')->nullOnDelete();

            $table->index(['parent_id', 'rank']);
        }
    );

    // И, наконец, таблица переводов категорий
    $this->schema->create(
        'category_translations',
        static function (Blueprint $table) {
            // Связь с категорией
            $table->foreignId('category_id')
                ->constrained('categories')->cascadeOnDelete();
            // Связь с языком
            $table->char('lang', 2);

            // Всего 2 текстовые колонки
            $table->string('title');
            $table->text('description')->nullable();

            // Индексы и ключи
            $table->primary(['category_id', 'lang']);
            $table->unique(['lang', 'title']);
            $table->foreign('lang')
                ->references('lang')
                ->on('languages')
                ->cascadeOnDelete();
        }
    );
}
Как вам, а?
В отличие от того же MODX, мы везде прописываем внешние ключи, поэтому:
  • в таблицы нельзя будет воткнуть запись с неправильно указанным или отсутствующим языком
  • при удалении языка будут удалены и связанные записи - нам не нужно контроллировать целостность данных и чистить мусор.
  • ну и при помещении одной категории в другую тоже будет проверка на существование родителя. А при удалении родителя дочерняя категория станет корневой, потому что nullOnDelete().

Таблицы товаров

Здесь всё примерно так же, как и у категорий. Делаем composer db:create Products:
public function up(): void
{
    $this->schema->create(
        'products',
        static function (Blueprint $table) {
            $table->id();
            // Связь с категорией
            // При удалении категории будут удалены и дочерние товары
            $table->foreignId('category_id')
                ->constrained('categories')->cascadeOnDelete();
            // Псевдоним и полный адрес, включая путь категории
            $table->string('alias');
            $table->string('uri')->unique();

            // Свойства товара
            $table->decimal('price')->default(0);
            $table->decimal('old_price')->default(0);
            $table->string('article')->nullable();
            $table->decimal('weight')->nullable();

            $table->boolean('new')->default(false)->index();
            $table->boolean('popular')->default(false)->index();
            $table->boolean('favorite')->default(false)->index();

            $table->string('made_in')->nullable();
            $table->json('colors')->nullable();
            $table->json('variants')->nullable();

            // Статус и сортировка
            $table->boolean('active')->default(true);
            $table->unsignedInteger('rank')->default(0);
            // Связь с таблицей MODX
            $table->unsignedInteger('remote_id')->nullable()->index();
            $table->timestamps();

            $table->index(['uri', 'active']);
            $table->index(['category_id', 'rank']);
        }
    );

    // Таблица переводов товаров
    $this->schema->create(
        'product_translations',
        static function (Blueprint $table) {
            $table->foreignId('product_id')
                ->constrained('products')->cascadeOnDelete();
            $table->char('lang', 2);

            // Здесь у нас 4 текстовых колонки
            $table->string('title')->nullable();
            $table->string('subtitle')->nullable();
            $table->text('description')->nullable();
            $table->text('content')->nullable();

            $table->primary(['product_id', 'lang']);
        }
    );
}

Остальные таблицы товаров

Таблица связи товаров с категориями, так известная как "мультикатегории":
public function up(): void
{
    $this->schema->create(
        'product_categories',
        static function (Blueprint $table) {
            $table->foreignId('product_id')
                ->constrained('products')->cascadeOnDelete();
            $table->foreignId('category_id')
                ->constrained('categories')->cascadeOnDelete();
            $table->unsignedInteger('rank')->default(0);

            $table->primary(['product_id', 'category_id']);
        }
    );
}
Насколько я помню, изначально miniShop2 разрабатывался для реализации конкретного проекта и там этот функционал был востребован. Но лично мне идея включать один товар в разные категории кажется неправильной. Ведь, чтобы на сайте не было дубликатов, у каждого товара всё равно один уникальный url, и, соответственно, одна главная категория.
Поэтому включать эту таблицу в новый VespShop мне не хочется, но раз уж она есть в исходном miniShop2... Да и в табачном магазине, который я переносил, она тоже использовалась.
В общем, пусть будет и здесь.
Дальше таблица связи товаров с файлами, то есть галерея товара:
public function up(): void
{
    $this->schema->create(
        'product_files',
        static function (Blueprint $table) {
            $table->foreignId('product_id')
                ->constrained('products')->cascadeOnDelete();
            $table->foreignId('file_id')
                ->constrained('files')->cascadeOnDelete();
            $table->boolean('active')->default(true)->index();
            $table->unsignedInteger('rank')->default(0)->index();
            //  Связь с файлом в MODX
            $table->unsignedInteger('remote_id')->nullable()->index();

            $table->primary(['product_id', 'file_id']);
        }
    );
}
И, наконец, связи товаров между собой.
Из опыта работы с miniShop2 я вынес, что разные типы связи товаров больше путают, нежели помагают, поэтому здесь у нас простая связь один к одному:
public function up(): void
{
    $this->schema->create(
        'product_links',
        static function (Blueprint $table) {
            $table->foreignId('product_id')
                ->constrained('products')->cascadeOnDelete();
            $table->foreignId('link_id')
                ->constrained('products')->cascadeOnDelete();
            $table->foreignId('rank')->default(0)->index();

            $table->primary(['product_id', 'link_id']);
        }
    );
}
По этой таблице мы можем выбирать как дочерние товары по отношению к product_id, так и родительские по отношению к link_id.
Если вам нужно больше - вы легко можете добавить еще и колонку с какием-то идентификатором связи рядом, главное, не забудьте включить её в primary key.

Заключение

Запускаем наши новые миграции и проверяем как они откатываются, ошибок быть не должно.
В итоге, вот мои миграции и таблицы:
А вот и схема всех связей между таблицами:
Можете её интерактивно понажимать вот здесь - https://drawsql.app/teams/bezumkins-team/diagrams/vesp-shop
Дальше мы будем обновлять старые модели и создавать новые, для работы с этими таблицами. А проверять их работу будем через импорт старых данных из miniShop2.
Итоговый коммит с текущими изменениями.

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

Странно... секцию templates в файл core/phinx.php добавил, но новые миграции по умолчанию всё равно содержат метод change, вместо up и down
Василий Наумкин
Надо еще обновить зависимости composer, чтобы скачался vesp/core 2.9.0, а вместе с ним и phinx 13.4, который поддерживает эту настройку.
Василий, вопрос от "особо одаренного".) Предыдущий курс выполнял прямо на сервере, а сейчас в Докере. Т.е. можно сказать, что делаю "с нуля". Как понимаю в этом случае мне не нужно обнулять или сносить БД?
И второй вопрос - поскольку тестовый магазин грузится с кучей товаров и категорий - наверное не стоит после его первичного запуска в Докере выполнять composer db:migrate, composer db:seed? Наверное нужно дальше, как написано в этом уроке, сначала удалить старые миграции (кроме 20210322072616_files.php и 20210322072635_users.php), создать новые (languages, categories, products, product_categories, product_files, product_links) и только после всего этого выполнить миграцию?
Василий Наумкин
Как понимаю в этом случае мне не нужно обнулять или сносить БД?
Да, сносить просто нечего.
И второй вопрос
В заметке речь о том, что я беру старые миграции от предыдущего курса и работаю с ними. Думал, так будет наглядее.
Итоговый результат всегда можно посмотреть по ссылке в конце заметки. Сама заметка - просто развёрнутое объяснение для него.
Странно. Сначала я по итогам урока выполнил composer db:migrate и composer db:rollback-all . И все выполнилось без ошибок. А вот когда снова попробовал выполнить миграции - посыпались ошибки.
Затем я опять скачал архив, запустил все заново и снова выполнил урок и теперь ошибки пошли на этапе composer db:rollback-all . В коде вряд ли мог ошибиться, все внимательно смотрел по коммиту.
Василий Наумкин
Что-то у тебя там не то.
Я зашёл в релизы и скачал архив с итоговым кодом этого урока
Зашёл в директорию docker, скопировал .env.dist в .env, поправил параметр COMPOSE_PROJECT_NAME, и запустил контейнеры
PHP установил зависимости при запуске
Дальше проверил migrate и rollback-all
Ошибок нет. Так что, попробуй удалить БД и накатить миграции заново из репозитория, там всё в порядке, я проверил.
PHP установил зависимости при запуске
Т. е. имеешь в виду что нужно обновить зависимости - compooser update ?
Так что, попробуй удалить БД
А как это правильно делать в Докере? Удалить контейнер mariadb-1 ?
Василий Наумкин
Т. е. имеешь в виду
Нет, просто показываю, что но всё рабаотает само. Без ошибок.
Удалить контейнер mariadb-1 ?
Остановить контейнеры. Удалить директорию docker/mariadb и запустить контейнеры заново.
Я скачал с итоговым кодом урока. Выполнил composer db:migrate, composer db:rollback-all затем опять composer db:migrate. Все проходит без ошибок как у тебя. Дальше я попробовал засеять базу (чтобы получить юзеров и войти в админку) и тут получил ошибки. Но как понимаю это потому, что я поторопился и на этом этапе еще не предусмотрено засевание базы?
Василий Наумкин
Дальше я попробовал засеять базу
В этом уроке такого и не предлагается. Тут изменены таблицы, а сиды - не изменены. Логично, что они не будут работать.
Проходи в следующую заметку.
Спасибо за помощь! На всякий случай уточню один момент - может кто еще столкнется. При "физическом" удалении директории с БД (docker/mariadb) - контейнеры у меня потом не запускаются. Выдается ошибка. Может это особенности работы Докера в WSL Windows. Опытным путем пробовал удалить контейнер mariadb-1 в самом Докере - при последующем запуске все восстанавливается.
Василий Наумкин
Странно, ведь этой директории нет при первом запуске, когда проект только скачан из репозитория. Как-то же она создаётся без ошибки.
Может и правда особенность WSL.
Александр Наумов
Василий, добрый день!
У меня стояла какая-то старая версия Докера и Весп-шоп установился, сейчас поставил версию Docker Engine 24.0.5 как у тебя и сейчас не получается поставить:
Получается, что в docker-compose.yml перестали работать переменные. Подскажи, что можно сделать, может дело в версии Docker Compose?
Василий Наумкин
Файл .env у тебя вообще есть? Ты переименовал его из .env.dist? Все переменные - там.
У меня docker-compose -v говорит
Docker Compose version 2.23.3
Александр Наумов
Василий, спасибо!
Извини, тупанул.
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
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
Спасибо!