Сегодня продолжаем работать с выводом товаров на сайте - у нас осталась пара незаконченых дел.
Во-первых, у нас нет вывода товаров, вложенных в дочерние категории. И поддержки товаров, привязанных через ProductCategory
тоже нет. Через это, например, большая категория /products/tabak
выводится пустой.
Во-вторых, хлебные крошки умеют выводить только непосредственно родительскую категорию товара, без цепочки остальных категорий до корня сайта.
Надо это исправить.
Это вопрос решается довольно-таки просто, и он уже даже решён в админке - я просто забыл об этом рассказать.
Меняем условие вывода товаров категории в Controllers/Web/Category/Products.php
:
protected function beforeCount(Builder $c): Builder
{
$c->where('active', true);
$c->where(function (Builder $c) {
// Товары категории
$c->where('category_id', $this->category->id);
// Товары мультикатегорий
$c->orWhereHas('productCategories', function (Builder $c) {
$c->where('category_id', $this->category->id);
});
});
return $c;
}
Получается вот такой запрос:
SELECT *
FROM `app_products`
WHERE `active` = '1'
and (
`category_id` = '1'
or exists (
SELECT *
FROM `app_product_categories`
WHERE `app_products`.`id` = `app_product_categories`.`product_id`
and `category_id` = '1'
)
)
ORDER BY `price` ASC
LIMIT 9 offset 0
На этом, вроде бы, всё. Но мне бы хотелось выводить не только вручную связанные с категорией товары, но и товары из её подкатегорий.
Сделать это можно только одним способом - получить все id
активных подкатегорий и добавить их в условие запроса.
Метод это полезный, так что добавляем его напрямую в модель категории:
public static function getChildIds(int $parentId, bool $onlyActive = false): array {
$ids = [];
$children = self::query()->where('parent_id', $parentId)->select('id');
if ($onlyActive) {
$children->where('active', true);
}
foreach ($children->cursor() as $child) {
$ids = [...$ids, $child->id, ...self::getChildIds($child->id, $onlyActive)];
}
return $ids;
}
Это статический метод, который требует только указание id
категории для рекурсивного получения массива c id
всех её подкатегорий. Опционально можно выбрать только активные подкатегории - для публичного сайта.
Обновляем условие Web/Category/Products
:
protected function beforeCount(Builder $c): Builder
{
$c->where('active', true);
$c->where(function (Builder $c) {
// Товары категории
$c->where('category_id', $this->category->id);
// Товары подкатегорий
if ($children = Category::getChildIds($this->category->id, true)) {
$c->orWhereIn('category_id', $children);
}
// Товары мультикатегорий
$c->orWhereHas('productCategories', function (Builder $c) {
$c->where('category_id', $this->category->id);
});
});
return $c;
}
С помощью Clockwork смотрим в итоговый SQL:
SELECT *
FROM `app_products`
WHERE `active` = '1'
and (
`category_id` = '1'
or `category_id` in ('11', '12', '13', '14', '10', '15')
or exists (
SELECT *
FROM `app_product_categories`
WHERE `app_products`.`id` = `app_product_categories`.`product_id`
and `category_id` = '1'
)
)
ORDER BY `price` ASC
LIMIT 9 offset 0
Насколько я вижу по мультикатегориям исходной базы miniShop2, владельцы как раз добавляли вручную товары подкатегорий в основную категорию.
Обновляем и контроллер админки Admin/Products
:
protected function beforeCount(Builder $c): Builder
{
if ($category = (int)$this->getProperty('category')) {
$c->where(static function (Builder $c) use ($category) {
$c->where('category_id', $category);
if ($children = Category::getChildIds($category)) {
$c->orWhereIn('category_id', $children);
}
$c->orWhereHas('productCategories', static function (Builder $c) use ($category) {
$c->where('category_id', $category);
});
});
}
// ...
}
В админке будут выводиться товары всех категорий, включая неактивные.
Вывод хлебных крошек от товара или категории - очень похожая задача, только здесь нужно получать массив с id
родителей, а не потомков.
Создаём соответствующий метод в модели категорий:
public static function getParentIds(int $childId, bool $onlyActive = false): array
{
$ids = [];
$child = self::query()->where('id', $childId)->select('parent_id');
if ($onlyActive) {
$child->where('active', true);
}
if ($parentId = $child->value('parent_id')) {
$ids = [$parentId, ...self::getParentIds($parentId)];
}
return $ids;
}
Этот метод выдаст нам все id
родителей категории, в порядке убывания уровня вложения. Опциональный второй параметр onlyActive
позволяет остановить выборку, если один из родителей отключен.
Но для вывода хлебных крошек этой информации нам не хватит, ведь нужно подгрузить еще и переводы. Также хотелось бы не останавливать вывод категорий при нахождении неактивной, а просто прятать.
Пишем еще один метод:
public static function getBreadcrumbs(int $categoryId, bool $onlyActive = true): array
{
$ids = [$categoryId, ...self::getParentIds($categoryId)];
$breadcrumbs = self::query()
->select('id', 'uri')
->whereIn('id', $ids)
->with('translations:category_id,lang,title')
->orderByRaw('FIELD(id, ' . implode(',', $ids) . ')');
if ($onlyActive) {
$breadcrumbs->where('active', true);
}
return $breadcrumbs->get()->toArray();
}
Вот теперь мы выбираем всё нужное:
uri
В контроллере категорий мы вызываем эту функцию вот так:
public function prepareRow(Model $object): array
{
/** @var Category $object */
$array = $object->toArray();
if ($this->getPrimaryKey()) {
$array['breadcrumbs'] = Category::getBreadcrumbs($object->id);
}
return $array;
}
Метод prepareRow()
позволяет модифицировать вывод данных на фронтенд для каждой записи, а проверка наличия getPrimaryKey()
позволяет нам не делать ненужные выборки при выводе списка категорий.
Остаётся доработать наш компонент вывода хлебных крошек:
<template>
<b-breadcrumb>
<!-- Ссылка на корень каталога выводится всегда -->
<b-breadcrumb-item :to="home">
<fa icon="home" />
</b-breadcrumb-item>
<!-- Дальше выводим подготовленные крошки -->
<b-breadcrumb-item v-for="(i, idx) in breadcrumbs" :key="idx" :to="i.route" :active="i.active">
{{ i.title }}
</b-breadcrumb-item>
</b-breadcrumb>
</template>
<script>
export default {
name: 'Breadcrumbs',
props: {
// Этот параметр теперь требует массив с категориями, вместо объекта с одной категорией
categories: {
type: Array,
required: true,
},
product: {
type: Object,
default: null,
},
},
computed: {
// Ссылка на корень каталога зависит от нашей системной настройки
home() {
return '/' + this.$config.PRODUCTS_PREFIX
},
breadcrumbs() {
// Переворачиваем порядок массива категорий, чтобы было от корня к текущей категории
const res = [...this.categories].reverse().map((i, idx) => {
// Пробегаем по элементам и формируем данные
return {
id: i.id,
title: this.$translate(i.translations),
route: this.$productLink(i),
// Категория активна, если не указан товар и она последняя в массиве
active: !this.product && this.categories.length === idx + 1,
}
})
// Если указан товар
if (this.product) {
// Добавляем его в итоговый массив без ссылки
res.push({
id: this.product.id,
title: this.$translate(this.product.translations),
route: null,
active: true,
})
}
return res
},
},
}
</script>
Теперь обновляем вызов в компоненте категории:
<breadcrumbs v-if="category && category.breadcrumbs" :categories="category.breadcrumbs" />
И в компоненте товара:
<breadcrumbs v-if="product.breadcrumbs" :categories="product.breadcrumbs" :product="product" />
Проверяем результат:
Вот мы и накрутили новых дополнительных запросов в БД, при этом на текущей базе я не вижу никаких тормозов.
Возможно, на огромном каталоге с десятком уровней вложения категорий могут возникнуть какие-то проблемы - но там можно и дописать промежуточное хранение дерева категорий в JSON файле, примерно как это сделано у MODX. Весь функционал вынесен в отдельные функции, так что будет несложно их доработать, при желании.
Ссылка на изменения в репозитории.