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

Мы стартуем с момента окончания прошлого курса, поэтому я предлагаю вам скачать архив с кодом после его завершения.

Внутри есть директория 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)
bezumkinВасилий Наумкин
16.08.2023 03:45

Надо еще обновить зависимости composer, чтобы скачался vesp/core 2.9.0, а вместе с ним и phinx 13.4, который поддерживает эту настройку.

bezumkinВасилий Наумкин
15.11.2023 14:36

Как понимаю в этом случае мне не нужно обнулять или сносить БД?

Да, сносить просто нечего.

И второй вопрос

В заметке речь о том, что я беру старые миграции от предыдущего курса и работаю с ними. Думал, так будет наглядее.

Итоговый результат всегда можно посмотреть по ссылке в конце заметки. Сама заметка - просто развёрнутое объяснение для него.

bezumkinВасилий Наумкин
16.11.2023 16:08

Что-то у тебя там не то.

Я зашёл в релизы и скачал архив с итоговым кодом этого урока

Зашёл в директорию docker, скопировал .env.dist в .env, поправил параметр COMPOSE_PROJECT_NAME, и запустил контейнеры

PHP установил зависимости при запуске

Дальше проверил migrate и rollback-all

Ошибок нет. Так что, попробуй удалить БД и накатить миграции заново из репозитория, там всё в порядке, я проверил.

bezumkinВасилий Наумкин
17.11.2023 12:57

Т. е. имеешь в виду

Нет, просто показываю, что но всё рабаотает само. Без ошибок.

Удалить контейнер mariadb-1 ?

Остановить контейнеры. Удалить директорию docker/mariadb и запустить контейнеры заново.

bezumkinВасилий Наумкин
17.11.2023 14:15

Дальше я попробовал засеять базу

В этом уроке такого и не предлагается. Тут изменены таблицы, а сиды - не изменены. Логично, что они не будут работать.

Проходи в следующую заметку.

bezumkinВасилий Наумкин
18.11.2023 13:16

Странно, ведь этой директории нет при первом запуске, когда проект только скачан из репозитория. Как-то же она создаётся без ошибки.

Может и правда особенность WSL.

bezumkinВасилий Наумкин
26.01.2024 04:52

Файл .env у тебя вообще есть? Ты переименовал его из .env.dist? Все переменные - там.

У меня docker-compose -v говорит

Docker Compose version 2.23.3

bezumkin
Василий Наумкин
09.04.2024 04:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 08:56
Я просто немного запутался. Когда в абзаце "Vesp/Core" ты пишешь про "новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 21:21
Volledig!
Андрей
14.03.2024 13:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 20:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 03:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 07:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 14:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 05:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 11:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...