Импортируем категории товаров

Сегодня мы доработаем модель категорий товаров, импортируем старые из miniShop2 и обновим админку.
Новые категории хранят свои текстовые колонки в отдельной таблице, так что нам нужно будет создать модель и для неё, а потом написать всякие вспомогательные методы в админке для удобного использования переводов.
Помимо этого, категории еще могут иметь картинку, но тут мы просто используем компонент upload-file, который написали вчера для юзеров.

Модель Language

Первым делом создаём модель для работы с языками - это нужно для создания связи с другими моделями.
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

/**php
 * @property string $lang
 * @property string $title
 * @property bool $active
 * @property int $rank
 *
 * @property-read CategoryTranslation[] $categoriesTranslations
 * @property-read Category[] $categories
 */
class Language extends Model
{
    public $timestamps = false;
    protected $primaryKey = 'lang';
    protected $keyType = 'string';
    protected $fillable = ['code', 'title', 'active', 'rank'];
    protected $casts = [
        'active' => 'boolean',
    ];

    public function categoriesTranslations(): HasMany
    {
        return $this->hasMany(CategoryTranslation::class, 'lang');
    }

    public function categories(): HasManyThrough
    {
        return $this->hasManyThrough(
            Category::class,
            CategoryTranslation::class,
            'lang',
            'id',
            'lang',
            'category_id'
        );
    }
}
Обратите внимание на связь с категориями через модель CategoryTranslation. Через неё Eloquent сделает присоединение таблицы CategoryTranslation и получит все категории на нужном языке. Не знаю, где это может пригодиться, но пусть будет.

Модель CategoryTranslation

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * @property int $category_id
 * @property string $lang
 * @property string $title
 * @property string $description
 *
 * @property-read Category $category
 * @property-read Language $language
 */
class CategoryTranslation extends Model
{
    use Traits\CompositeKey;

    public $timestamps = false;
    protected $primaryKey = ['category_id', 'lang'];
    protected $fillable = ['category_id', 'lang', 'title', 'description'];

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function language(): BelongsTo
    {
        return $this->belongsTo(Language::class, 'lang');
    }
}
Эта модель использует ещё один новый трейт CompositeKey, потому что, что Eloquent не поддерживает составные ключи по умолчанию - и нам надо переопределить некоторые методы модели для правильной работы.
Сам код трейта я приводить не буду, посмотрите в исходниках, там ничего интересного. Когда-то давно я столкнулся с этой проблемой и нашёл решение, теперь таскаю этот трейт из проекта в проект, по необходимости.
Еще можно обратить внимание на связь language(), где явно указано по какой колонке привязывать записи. Для метода category() это не требуется, потому что Eloquent автоматически будет смотреть на колонку category_id, исходя из имени связи. А вот для языка у нас не lang_id, а просто lang - поэтому её и нужно указать.

Модель Category

Эта модель у нас уже есть, нужно добавить новые свойства:
/**
 * @property int $id
 * @property int $parent_id
 * @property ?int $file_id
 * @property string $alias
 * @property string $uri
 * @property bool $active
 * @property int $rank
 * @property int $remote_id
 * @property Carbon $created_at
 * @property Carbon $updated_at
 *
 * @property-read Category $parent
 * @property-read CategoryTranslation[] $translations
 * @property-read File $file
 * @property-read Category[] $children
 * @property-read Product[] $products
 */
Из-за того, что теперь категории могут быть вложены друг в друга, у нас появилась колонка uri, которая будет хранить полный адрес категории. Мне не хочется следить за её обновлением самостоятельно, так что пропишу функцию на событие сохранения модели:
    protected static function booted(): void
    {
        static::saving(static function (self $model) {
            $uri = [$model->alias];
            if ($model->parent) {
                array_unshift($uri, $model->parent->uri);
            }
            $model->uri = implode('/', $uri);
        });
    }
Здесь мы генерируем uri модели, складывая её alias с родительским uri.
Осталось прописать связи с другими моделями:
    public function parent(): BelongsTo
    {
        return $this->belongsTo(__CLASS__);
    }

    public function translations(): HasMany
    {
        return $this->hasMany(CategoryTranslation::class);
    }

    public function file(): BelongsTo
    {
        return $this->belongsTo(File::class);
    }

    public function children(): HasMany
    {
        return $this->hasMany(__CLASS__, 'parent_id');
    }

    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }
Связи parent и children ссылаются сами на себя, то есть на саму таблицу категорий, так что мы используем встроенную в PHP константу __CLASS__.

Засеиваем языки

Создаём новый сид в core/db/seeds/Languages.php, так как мы планируем на них везде ссылаться.
<?php

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

class Languages extends AbstractSeed
{
    protected array $data = [
        'ru' => [
            'title' => 'Русский',
            'rank' => 0,
        ],
        'en' => [
            'title' => 'English',
            'rank' => 1,
        ],
    ];

    public function run(): void
    {
        foreach ($this->data as $id => $data) {
            /** @var Language $record */
            if (!$record = Language::query()->find($id)) {
                $record = new Language();
                $record->lang = $id;
            }
            $record->fill($data);
            $record->save();
        }
    }
}
Можно сразу же его и запустить: composer db:seed-one Languages

Импортируем категории из miniShop2

Общая логика здесь та же, что и при импорте пользователей, с одним отличием: нам нужно присоединить еще и переводы. К счастью, в исходном проекте на MODX использовалось дополнение Lingua, которое хранит тексты в отдельной таблице. Вот её и будем подключать.
Там тексты на немецком и француском, но мы сделаем вид, что это русский и английский!
Во время запуска сида я столкнулся с проблемой проверки уникальности индекса таблицы category_translations - она ругалась, что нельзя создать 2 записи на 1 языке с одинаковым title. Причём, если title пустой - это всё равно считается за значение. Так как не у всех категорий есть перевод на обоих языках, мне вылетало вот такое
Пришлось откатить миграции до юзеров, сделать $table->string('title')->nullable(); в миграции категорий и накатить снова. Null не считается значением, и не влияет на уникальность индекса.
Теперь можно и написать сам сидер в core/db/seeds/Categories.php.
<?php

use App\Models\Category;
use App\Models\File;
use Phinx\Seed\AbstractSeed;
use Slim\Psr7\UploadedFile;
use Vesp\Services\Eloquent;

class Categories extends AbstractSeed
{
    public function getDependencies(): array
    {
        return ['Languages'];
    }

    public function run(): void
    {
        $tvImageId = 1;

        // Выбираем категории вместе с переводами и картинками
        $pdo = (new Eloquent())->getDatabaseManager()->getPdo();
        $stmt = $pdo->query(
            "SELECT `r`.*, `tv`.`value` AS `image`, `ln`.`pagetitle` as `pagetitle_fr`, `ln`.`content` as `content_fr`
            FROM `modx_site_content` `r`
            LEFT JOIN `modx_site_tmplvar_contentvalues` `tv` ON `tmplvarid` = '$tvImageId' AND `contentid` = `r`.`id`
            LEFT JOIN `modx_lingua_site_content` `ln` ON `resource_id` = `r`.`id`
            WHERE `class_key` = 'msCategory' ORDER BY `parent`,`r`.`id`"
        );

        $parents = [];
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            // Проверяем наличие категории по remote_id
            if (!$category = Category::query()->where('remote_id', $row['id'])->first()) {
                $category = new Category();
                $category->remote_id = $row['id'];
            }

            // Замолняем основные поля
            $category->alias = trim($row['alias']);
            $category->uri = preg_replace('#^shop/#', '', trim($row['uri'], '/'));
            $category->active = empty($row['deleted']) && !empty($row['published']);
            $category->rank = $row['menuindex'];
            $category->created_at = $row['createdon'];
            $category->updated_at = $row['editedon'] ?: null;
            // Заменяем id родителя, если есть
            $category->parent_id = $parents[$row['parent']] ?? null;

            // Если у категории есть картинка - загружаем плейсхолдер
            if (!empty($row['image'])) {
                $filename = tempnam(getenv('UPLOAD_DIR'), 'img_');
                file_put_contents($filename, file_get_contents('https://i.pravatar.cc/500'));
                $data = new UploadedFile($filename, basename($filename), mime_content_type($filename));
                if (!$file = $category->file) {
                    $file = new File();
                }
                $file->uploadFile($data);
                $category->update(['file_id' => $file->id]);
                unlink($filename);
            }
            // --

            // Сохраняем категорию
            $category->save();
            // И сразу создаём переводы через связь модели
            $category->translations()->updateOrCreate(['lang' => 'ru'], [
                'title' => trim($row['pagetitle']) ?: null,
                'description' => trim($row['content']) ?: null,
            ]);
            $category->translations()->updateOrCreate(['lang' => 'en'], [
                'title' => trim($row['pagetitle_fr']) ?: null,
                'description' => trim($row['content_fr']) ?: null,
            ]);

            // Обязательно сохраняем соответствие старого и нового id для
            // добавления дочерних категорий
            $parents[$category->remote_id] = $category->id;
        }
    }
}
Небольшая хитрость здесь в том, чтобы выбирать исходные категории по возрастанию родителя (колонка parent), чтобы мы сначала создали корневые категории, а потом добавили в них дочерние.
id из MODX и id в Vesp сопоставляются через массив $parents, в который мы закидываем новые записи по мере создания категорий.
В проекте на MODX картинка категории хранится в TV, поэтому я подключаю таблицу modx_site_tmplvar_contentvalues, но настоящих файлов у нас нет, так что я создаю случайные плейсхолдеры. Оставил просто для примера импорта из TV.
После запуска скрипта вижу новые записи в БД:

Плагин для вывода текста в админке

Здесь и далее мы будем выводить тексты от присоединённых моделей перевода, поэтому лучше сразу предусмотреть для этого специальную функцию.
Я предпочитаю создавать плагин с утилитами в admin/plugins/utils.js для всякого такого:
// Ищет требуемый ключ в переданном объете и возвращает строку,
// если она не пустая
function translate(translations, field, lang = 'ru') {
  const data = translations.filter((i) => i.lang === lang)
  if (data.length > 0 && data[0][field] && data[0][field].length > 0) {
    return data[0][field]
  }
  return null
}

// Плагин Nuxt должен возвращать вот такой объект
export default ({app}, inject) => {
  // Регистрируем глобальную функцию приложения 
  inject('translate', (translations, field = 'title') => {
    // Пробуем вывести строку для требуемого языка
    let text = translate(translations, field, app.i18n.locale)
    if (!text && app.i18n.locale !== app.i18n.defaultLocale) {
      //  Или строку языка по умолчанию
      text = translate(translations, field, app.i18n.defaultLocale)
    }
    // Если переводов нет - надо уведомить разработчика, чтобы добавил
    if (!text) {
      console.info(`Could not find translation for ${field} in ${JSON.stringify(translations)}`)
    }
    return text
  })
}
Побробнее про регистрацию плагинов Nuxt 2 можно почитать в документации.
А мы просто добавляем его в nuxt.config.js:
Config.plugins = ['@/plugins/utils.js']
После этого можно везде использовать функцию $translate().

Дорабатываем таблицу категорий

Сначала нужно подготовить бэкенд. Я создаю новый трейт для контроллеров Controllers/Traits/TranslateModelController.php:
<?php

namespace App\Controllers\Traits;

use Illuminate\Database\Eloquent\Model;

trait TranslateModelController
{
    protected function afterSave(Model $record): Model
    {
        if ($translations = $this->getProperty('translations')) {
            foreach ($translations as $data) {
                $record->translations()->updateOrCreate(['lang' => $data['lang']], $data);
            }
        }

        return $record;
    }
}
Трейт очень простой - обрабатывает присланные тексты после сохранения модели.
Дальше крутим Controllers/Admin/Categories.php.
class Categories extends ModelController
{
    // Наши новые трейты для переводов и загрузки файла
    use FileModelController;
    use TranslateModelController;
    //...
    protected $attachments = ['file'];

    // Подключаем нужные модели при выводе для редактирования
    protected function beforeGet(Builder $c): Builder
    {
        $c->with('file:id,updated_at');
        $c->with('translations');

        return $c;
    }

    protected function beforeCount(Builder $c): Builder
    {
        // Поиск категории теперь проводится по таблице текстов
        if ($query = trim($this->getProperty('query', ''))) {
            $c->whereHas('translations', static function (Builder $c) use ($query) {
                $c->where('title', 'LIKE', "%$query%");
                $c->orWhere('description', 'LIKE', "%$query%");
            });
        }

        // Возможность прятать некоторые категории,
        // пригодится для автодополнения
        if ($exclude = $this->getProperty('exclude')) {
            if (is_array($exclude)) {
                $c->whereNotIn('id', $exclude);
            } else {
                $c->where('id', '!=', $exclude);
            }
        }

        return $c;
    }

    // Нужные связи для вывода таблицы категорий
    protected function afterCount(Builder $c): Builder
    {
        $c->with('translations:category_id,lang,title');
        $c->with('parent:id', 'parent.translations:category_id,lang,title');
        $c->with('file:id,updated_at');
        $c->withCount('children');
        $c->withCount('products');

        return $c;
    }

    protected function beforeSave(Model $record): ?ResponseInterface
    {
        /** @var Category $record */
        if (!$record->parent_id) {
            // Фронт может прислать 0 или пустую строку в качестве id родителя
            // Это вызовет ошибку внешнего ключа, так что форсируем null
            $record->parent_id = null;
        }

        // Обрабатываем файлы методом из трейта
        if ($error = $this->processFiles($record)) {
            return $error;
        }

        // Уникальность alias теперь проверяем с учётом родителя
        $c = Category::query()->where('alias', $record->alias);
        if ($record->exists) {
            $c->where('id', '!=', $record->id);
        }
        if ($record->parent_id) {
            $c->where('parent_id', $record->parent_id);
        }
        if ($c->count()) {
            return $this->failure('errors.category.alias_exists');
        }

        return null;
    }
Далее нужно обновить и таблицу категорий admin/pages/categories.vue. В самой таблице у нас добавляется специальное оформление ячеек:
<vesp-table ....>
      <template #cell(title)="{item}">
        {{ $translate(item.translations) }}
        <div class="small text-muted">{{ item.uri }}</div>
      </template>
      <template #cell(parent)="{item}">
        <template v-if="item.parent">
          {{ $translate(item.parent.translations) }}
        </template>
      </template>
      <template #cell(file)="{value}">
        <b-img
          v-if="value.id"
          :key="value.id"
          :src="$image(value, {w: 150, h: 75, fit: 'crop'})"
          :srcset="$image(value, {w: 300, h: 150, fit: 'crop'}) + ' 2x'"
          width="150"
          height="75"
          alt=""
          class="rounded"
        />
      </template>
</vesp-table>
А в js коде добавляем только новые колонки:
<script>
export default {
  //...
  computed: {
    // ...
    fields() {
      return [
        {key: 'id', label: this.$t('components.table.columns.id'), sortable: true},
        {key: 'file', label: this.$t('models.category.file')},
        {key: 'title', label: this.$t('models.category.title'), sortable: true},
        {key: 'parent', label: this.$t('models.category.parent')},
        {key: 'products_count', label: this.$t('models.product.title_many')},
      ]
    },
  }
}
</script>
В итоге у нас получается вот такая магия - имена категорий меняются при смене локализации админки:
Языком по умолчанию сейчас является английский, через это на видео некоторые строки выходят пыстыми - ведь что на английском они не заполнены. Меняем порядок языков и умолчание в nuxt.config.js
Config.i18n.defaultLocale = 'ru'
Config.i18n.locales = [
  {code: 'ru', title: 'Русский'},
  {code: 'en', title: 'English'},
]

Форма редактирования категории

Вот здесь может быть немного сложно для понимания.
Дело в том, что для удобной работы с переводами, мы добавим переключение языков прямо в форму. Но нужно сделать это универсально, ведь у нас еще будут как минимум товары с такой же функцией. Да и в другие проекты потом можно утащить, что тоже неплохо.
Поэтому нам понадобятся:
  • Новый контроллер Controllers/Admin/Languages.php для получения списка языков системы
  • Новый маршрут для него в core/routes.php
  • Результат загрузки мы будем хранить в store приложения. Для этого добавим новый ключ в state, новый метод в mutations и заведём action для запроса в API.
  • Всякие нужные методы для работы формы сгруппируем в один общий mixin для добавления формам. Это как трейт в PHP, только для Vue.
  • Активный язык будем отображать для каждого поля ввода с помощью стилей CSS.
  • Ну а сам язык формы будем переключать через отдельный компонент lang-tabs.vue
Контроллер с маршрутом описывать не буду, там ничего нового.
А вот хранилище (admin/store/index.js) мы меняем вот так:
export const state = () => ({
  // ...
  languages: [],
})

export const mutations = {
  // ...
  languages(state, payload) {
    state.languages = payload
  },
}

export const actions = {
  async languages({commit, state}) {
    if (!state.languages.length) {
      try {
        const {data} = await this.$axios.get('admin/languages', {params: {combo: true}})
        commit('languages', data.rows)
      } catch (e) {}
    }
    return state.languages
  },
}
Вновь созданный action мы будем вызывать как this.$store.dispatch('languages') и в ответ получим языки системы. Загружать он их будет только один раз, а потом сохранит в this.$store.state.languages и будет выдавать уже оттуда. Можно сказать, это такое кэширование.
Именно на этой логике и основана наша примесь в admin/mixins/translations.js:
export default {
  // Работаем до рендера компонента на странице
  async beforeMount() {
    try {
      const translations = []
      // Поулучаем языки
      const languages = await this.$store.dispatch('languages')
      // Проверяем значения для языков в моделе и создаем пустые переводы, если надо
      languages.forEach((v) => {
        const idx = this.record.translations.findIndex((i) => i.lang === v.lang)
        translations.push(idx > -1 ? {...this.record.translations[idx]} : {...this.translationFields, lang: v.lang})
      })
      // Выставляем переводы модели в форму, получая ключи из translationFields
      this.$set(this.record, 'translations', translations)
    } catch (e) {
      console.error(e)
    }
  },
  data() {
    return {
      // Здесь каждый компонент должен указать, какие поля у него переводимые 
      // например {title: '', description: ''}
      translationFields: {},
    }
  },
  directives: {
    // См. ниже
    langIcon: {
      update(el, {value}) {
        if (!el.classList.contains('form-group')) {
          el = el.querySelector('.form-group')
        }
        if (!el) {
          return
        }

        el.classList.forEach((cls) => {
          if (cls.startsWith('icon-')) {
            el.classList.remove(cls)
          }
        })
        el.classList.add('icon-' + value)
      },
    },
  },
}
Сам по себе этот mixin, как и трейт в PHP, работать не будет - нам нужно импортировать его в форму категории.
import Translations from '@/mixins/translations.js'

export default {
  // ...  
  mixins: [Translations],
  data() {
    return {
      lang: this.$i18n.locale || 'ru',
      translationFields: {title: '', description: ''},
    }
  },
  // ...  
}
При вызозе формы он:
  1. Добавит метод beforeMount(), который пробежится по указанным translationFields, проверит их ключи в переводах редактируемой модели и добавит путые, если нужно. Это чтобы избежать ошибок при создании новой категории, у которой еще не может быть переводов, и добавлении новых языков.
  2. Добавит директиву langIcon, котрая будет прикручивать специальный CSS класс для поля ввода, чтобы там появлялся флажочек.
Директива указывается непосредственно элементам формы, и передаёт их, элементы, в метод update(), где мы на чистом javascript можем сделать с ним всё, что угодно. Например, добавлять класс icon-lang-ru для отрисовки российского флага.
По наличию этих флажочков пользователь админки поймёт, что это специальные переводимые поля формы.
Теперь можно изменить шаблон формы категории:
<template>
<!-- -->
        <b-form-group v-lang-icon="lang" :label="$t('models.category.title')">
          <b-form-input
            v-for="(translation, idx) in record.translations"
            v-show="translation.lang === lang"
            :key="idx"
            v-model.trim="translation.title"
            autofocus
            required
          />
        </b-form-group>
<!-- -->
</template>
Итак, v-lang-icon="lang" - это и есть наша директива, которая срабатывает на элемент формы и получает текущий активный язык. На этот момент уже отработал метод beforeMount() и в нашей моделе обязательно есть значения для каждого языка внутри record.translations - этот массив мы и перебираем для вывода стольких полей ввода.
Но видеть мы будем только то поле, которое соответствует активному языку, потому что v-show="translation.lang === lang". Все эти поля будут обязательны для ввода, а значение будет сохраняться в соответствующий ключ record.translations.
На сервер у нас должен улетать примерно такой массив:
{
  "id": 20,
  // ...
  "file": null,
  "translations": [
    {
      "category_id": 20,
      "lang": "ru",
      "title": "Дочерняя Категория",
      "description": "Описание дочерней категории"
    },
    {
      "category_id": 20,
      "lang": "en",
      "title": "Children Category",
      "description": "Children Category Description"
    }
  ]
}
Там его поймает и обработает наш трейт TranslateModelController.
Последний штрих - компонент для переключения активного lang, пусть он будет в admin/components/lang-tabs.vue:
<template>
  <b-tabs v-model="tab" :pills="pills" :small="small" :align="align" :active-nav-item-class="activeClass">
    <b-tab v-for="(language, idx) in languages" :key="idx">
      <template #title>
        <div class="d-flex align-items-center">
          <b-img :src="require('~/assets/images/icon-lang-' + language.lang + '.svg')" width="20" height="20" />
          <div class="ml-2">{{ language.title }}</div>
        </div>
      </template>
    </b-tab>
  </b-tabs>
</template>

<script>
export default {
  name: 'LangTabs',
  // ...
  data() {
    // Активная вкладка при загрузке зависит от текущего языка админки
    let tab = 0
    const languages = this.$store.state.languages
    if (this.value && languages.length) {
      tab = languages.findIndex((i) => i.lang === this.value)
    }
    return {tab}
  },
  computed: {
    languages() {
      return this.$store.state.languages
    },
  },
  watch: {
    // Меняем v-model при переключении вкладки
    tab(newValue) {
      if (this.languages[newValue]) {
        this.myValue = this.languages[newValue].lang
      }
    },
  },
}
</script>
Этот компонент тоже нужно импортировать в форму и вызвать в любом удобном месте. У меня он на самом верху:
<template>
  <div>
    <lang-tabs v-model="lang" />

    <hr />

    <b-row class="mt-4">
<!-- ... -->
Итоговый результат:

Заключение

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

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

Дмитрий П.
А если у класса CategoryTranslations использовать не трейт CompositeKey, а наследовать класс Pivot? Он хорошо работает с композитным ключом из двух колонок, и как раз создан для связи many-to-many. Но, как показала практика, можно связь не настраивать и использовать для one-to-many. Или есть какие-то подводные камни? :)
Василий Наумкин
Так здесь дело не про отношения, а про составной первичный ключ.
У обычный моделей есть id, по которому модель можно выбрать и обновить/удалить. У CategoryTranslation есть category_id и lang, и только их сочетание уникально. То есть, у этой модели всегда нужно указывать 2 колонки для работы, вместо 1й.
Именно для того, чтобы такая модель нормально находилась, сохранялась и удалялась и нужен наш trait.
Если я чего-то не понимаю - приведи пример, пожалйста, как это можно сделать, наследуя класс Pivot. Как минимум там в модели должен указываться $primaryKey в виде массива колонок, в документации я такого не вижу.
Дмитрий П.
Эта модель как раз и использует составной первичный ключ, но нормальной документации я не нашел, только упоминание, что такой класс есть. Но если посмотреть исходники этой модели, то получается как-то так:
Миграция с композитным ключом:
$this->schema->create(
    'composites',
    function (Blueprint $table) {

        $table->foreignId('example_model_id')
            ->constrained('example_models')->cascadeOnDelete();
        $table->foreignId('key_2')
            ->constrained('table_2')->cascadeOnDelete();
        // ...
       $table->primary(['example_model_id', 'key_2']);
    }
);
Модель, которой нужно указать таблицу (по умолчанию Eloquent ее имя не очень красиво определяет), foreignKey и relatedKey:
class Composite extends Pivot
{
    protected $table = 'composites';
    protected $foreignKey = 'example_model_id';
    protected $relatedKey = 'key_2';
    // ...
}
В какой-то другой модели прописана связь:
class ExampleModel extends Pivot
{
    public composites(): HasMany
    {
        return $this->hasMany(Composite::class);
    }
}
И использовать это дело можно как и обычную модель, только
$exampleModel = ExampleModel::query->first();
$tableModels = $exampleModel->composites()->where('key_2', '=', 2)->get();
$composites = Composite::query()->where(['example_model_id' => 1, 'key_2' => 2]);
Контроллеры VESP'а прекрасно справляются если указать массивом $primaryKey.
Единственное, на каких-то сложных Pivot еще не удалось потестировать, так что может быть какое-то непредвиденное поведение...
Василий Наумкин
Выглядит любопытно, надо будет поэксперементировать на досуге, спасибо.
Только непонятно, а как составить ключ из 3х колонок? У меня и такие модели есть. Полагаю, что никак.
Вот такую ошибку пишет при попытке запуска сида.
Base table or view not found: 1146 Table 'vesp.modx_lingua  
  _site_content' doesn't exist
Видимо в архиве с дампом такой таблицы не было. Можно дамп этой таблички?
Василий Наумкин
Сорян, что-то я уже второй раз затупил с этим дампом.
Держи правильный , в заметке про импорт юзеров тоже поменял.
ms2.sql.zip
2.93 MB, application/zip
Да, все это очень интересно, но пока для меня разрабатывать на Vesp магазин с переносом данных из Modx неподъемно. Даже несмотря на то, что я уже пробежался по базе php и предыдущий курс прошел. Повторить все как попугай можно конечно. Но толк выйдет сомнительный.)
Зато Vesp мне нравится. Я по итогам предыдущего курса повесил его на свой VPS и сижу спокойно разбираюсь, пытаюсь простой сайт запилить. Но мне хочется научиться работать в докере: на локалке и далее - на сервере. В связи с этим вопрос. В репозитории vesp-shop имеется docker-production.yml , а в исходном VESP его нет. Но это же не значит, что нельзя скачать исходники Vesp, в локальном Докере создать сайт, и, как ты пишешь в конце курса - закинуть это все на рабочий сервер?
Василий Наумкин
но пока для меня разрабатывать на Vesp магазин с переносом данных из Modx неподъемно
Думаю, для многих так. Зато можно почитать и узнать всякое новое. Понять, что за пределами известного тебе есть еще много интересного, и стремиться к этому.
закинуть это все на рабочий сервер
Да просто не дошли руки положить этот файл в общий репозиторий, но он должен подойти без проблем.
Я сам постоянно работаю с Vesp и предумываю что-то новое. Когда выпущу Орбиту, будет большое обновление Vesp, включая обновлённый конфиг Docker.
Зато можно почитать и узнать всякое новое. Понять, что за пределами известного тебе есть еще много интересного, и стремиться к этому.
Вне всякого сомнения. Для многих очень ценно, что ты делишься своими продвинутыми наработками. Думаю в свое время я вернусь к магазину, а пока займусь более осязаемыми вещами.
Когда выпущу Орбиту, будет большое обновление Vesp, включая обновлённый конфиг Docker.
Кстати а возможно впоследствии на готовом проекте обновлять версию Vesp?
Василий Наумкин
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда.
Их можно обновлять, но это не даст каких-то преимуществ само по себе. Просто ты сможешь использовать каие-то новые функции в своём приложении (если они были добавлены).
Обычно там просто исправление ошибок, которых сейчас очень мало, буквально раз-два в год. Текущий Vesp очень стабилен.
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
23.12.2024, 05:33:00
В MODX сначала создали проблему, автоматически генерируя адреса, а потом "решили" заморозкой. Так ч...
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Василий Наумкин
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
Василий! Как всегда очень круто! Моё почтение!
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!