vendor/shopware/core/Content/Category/SalesChannel/NavigationRoute.php line 261

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Content\Category\SalesChannel;
  3. use Doctrine\DBAL\Connection;
  4. use Shopware\Core\Content\Category\CategoryCollection;
  5. use Shopware\Core\Content\Category\CategoryEntity;
  6. use Shopware\Core\Content\Category\Exception\CategoryNotFoundException;
  7. use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\FetchModeHelper;
  8. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\TermsAggregation;
  9. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\CountAggregation;
  10. use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Bucket\TermsResult;
  11. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  12. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter;
  13. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  14. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter;
  15. use Shopware\Core\Framework\Log\Package;
  16. use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
  17. use Shopware\Core\Framework\Uuid\Uuid;
  18. use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepository;
  19. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  20. use Symfony\Component\HttpFoundation\Request;
  21. use Symfony\Component\Routing\Annotation\Route;
  22. /**
  23.  * @phpstan-type CategoryMetaInformation array{id: string, level: int, path: string}
  24.  */
  25. #[Route(defaults: ['_routeScope' => ['store-api']])]
  26. #[Package('content')]
  27. class NavigationRoute extends AbstractNavigationRoute
  28. {
  29.     /**
  30.      * @internal
  31.      */
  32.     public function __construct(
  33.         private readonly Connection $connection,
  34.         private readonly SalesChannelRepository $categoryRepository
  35.     ) {
  36.     }
  37.     public function getDecorated(): AbstractNavigationRoute
  38.     {
  39.         throw new DecorationPatternException(self::class);
  40.     }
  41.     #[Route(path: '/store-api/navigation/{activeId}/{rootId}', name: 'store-api.navigation', methods: ['GET', 'POST'], defaults: ['_entity' => 'category'])]
  42.     public function load(
  43.         string $activeId,
  44.         string $rootId,
  45.         Request $request,
  46.         SalesChannelContext $context,
  47.         Criteria $criteria
  48.     ): NavigationRouteResponse {
  49.         $depth = $request->query->getInt('depth', $request->request->getInt('depth', 2));
  50.         $metaInfo = $this->getCategoryMetaInfo($activeId, $rootId);
  51.         $active = $this->getMetaInfoById($activeId, $metaInfo);
  52.         $root = $this->getMetaInfoById($rootId, $metaInfo);
  53.         // Validate the provided category is part of the sales channel
  54.         $this->validate($activeId, $active['path'], $context);
  55.         $isChild = $this->isChildCategory($activeId, $active['path'], $rootId);
  56.         // If the provided activeId is not part of the rootId, a fallback to the rootId must be made here.
  57.         // The passed activeId is therefore part of another navigation and must therefore not be loaded.
  58.         // The availability validation has already been done in the `validate` function.
  59.         if (!$isChild) {
  60.             $activeId = $rootId;
  61.         }
  62.         $categories = new CategoryCollection();
  63.         if ($depth > 0) {
  64.             // Load the first two levels without using the activeId in the query
  65.             $categories = $this->loadLevels($rootId, (int) $root['level'], $context, clone $criteria, $depth);
  66.         }
  67.         // If the active category is part of the provided root id, we have to load the children and the parents of the active id
  68.         $categories = $this->loadChildren($activeId, $context, $rootId, $metaInfo, $categories, clone $criteria);
  69.         return new NavigationRouteResponse($categories);
  70.     }
  71.     /**
  72.      * @param string[] $ids
  73.      */
  74.     private function loadCategories(array $ids, SalesChannelContext $context, Criteria $criteria): CategoryCollection
  75.     {
  76.         $criteria->setIds($ids);
  77.         $criteria->addAssociation('media');
  78.         $criteria->setTotalCountMode(Criteria::TOTAL_COUNT_MODE_NONE);
  79.         /** @var CategoryCollection $missing */
  80.         $missing = $this->categoryRepository->search($criteria, $context)->getEntities();
  81.         return $missing;
  82.     }
  83.     private function loadLevels(string $rootId, int $rootLevel, SalesChannelContext $context, Criteria $criteria, int $depth = 2): CategoryCollection
  84.     {
  85.         $criteria->addFilter(
  86.             new ContainsFilter('path', '|' . $rootId . '|'),
  87.             new RangeFilter('level', [
  88.                 RangeFilter::GT => $rootLevel,
  89.                 RangeFilter::LTE => $rootLevel + $depth + 1,
  90.             ])
  91.         );
  92.         $criteria->addAssociation('media');
  93.         $criteria->setLimit(null);
  94.         $criteria->setTotalCountMode(Criteria::TOTAL_COUNT_MODE_NONE);
  95.         /** @var CategoryCollection $levels */
  96.         $levels = $this->categoryRepository->search($criteria, $context)->getEntities();
  97.         $this->addVisibilityCounts($rootId, $rootLevel, $depth, $levels, $context);
  98.         return $levels;
  99.     }
  100.     /**
  101.      * @return array<string, CategoryMetaInformation>
  102.      */
  103.     private function getCategoryMetaInfo(string $activeId, string $rootId): array
  104.     {
  105.         $result = $this->connection->fetchAllAssociative('
  106.             # navigation-route::meta-information
  107.             SELECT LOWER(HEX(`id`)), `path`, `level`
  108.             FROM `category`
  109.             WHERE `id` = :activeId OR `parent_id` = :activeId OR `id` = :rootId
  110.         ', ['activeId' => Uuid::fromHexToBytes($activeId), 'rootId' => Uuid::fromHexToBytes($rootId)]);
  111.         if (!$result) {
  112.             throw new CategoryNotFoundException($activeId);
  113.         }
  114.         return FetchModeHelper::groupUnique($result);
  115.     }
  116.     /**
  117.      * @param array<string, CategoryMetaInformation> $metaInfo
  118.      *
  119.      * @return CategoryMetaInformation
  120.      */
  121.     private function getMetaInfoById(string $id, array $metaInfo): array
  122.     {
  123.         if (!\array_key_exists($id, $metaInfo)) {
  124.             throw new CategoryNotFoundException($id);
  125.         }
  126.         return $metaInfo[$id];
  127.     }
  128.     /**
  129.      * @param array<string, CategoryMetaInformation> $metaInfo
  130.      */
  131.     private function loadChildren(string $activeId, SalesChannelContext $context, string $rootId, array $metaInfo, CategoryCollection $categories, Criteria $criteria): CategoryCollection
  132.     {
  133.         $active = $this->getMetaInfoById($activeId, $metaInfo);
  134.         unset($metaInfo[$rootId], $metaInfo[$activeId]);
  135.         $childIds = array_keys($metaInfo);
  136.         // Fetch all parents and first-level children of the active category, if they're not already fetched
  137.         $missing = $this->getMissingIds($activeId, $active['path'], $childIds, $categories);
  138.         if (empty($missing)) {
  139.             return $categories;
  140.         }
  141.         $categories->merge(
  142.             $this->loadCategories($missing, $context, $criteria)
  143.         );
  144.         return $categories;
  145.     }
  146.     /**
  147.      * @param array<string> $childIds
  148.      *
  149.      * @return list<string>
  150.      */
  151.     private function getMissingIds(string $activeId, ?string $path, array $childIds, CategoryCollection $alreadyLoaded): array
  152.     {
  153.         $parentIds = array_filter(explode('|', $path ?? ''));
  154.         $haveToBeIncluded = array_merge($childIds, $parentIds, [$activeId]);
  155.         $included = $alreadyLoaded->getIds();
  156.         $included = array_flip($included);
  157.         return array_values(array_diff($haveToBeIncluded, $included));
  158.     }
  159.     private function validate(string $activeId, ?string $path, SalesChannelContext $context): void
  160.     {
  161.         $ids = array_filter([
  162.             $context->getSalesChannel()->getFooterCategoryId(),
  163.             $context->getSalesChannel()->getServiceCategoryId(),
  164.             $context->getSalesChannel()->getNavigationCategoryId(),
  165.         ]);
  166.         foreach ($ids as $id) {
  167.             if ($this->isChildCategory($activeId, $path, $id)) {
  168.                 return;
  169.             }
  170.         }
  171.         throw new CategoryNotFoundException($activeId);
  172.     }
  173.     private function isChildCategory(string $activeId, ?string $path, string $rootId): bool
  174.     {
  175.         if ($rootId === $activeId) {
  176.             return true;
  177.         }
  178.         if ($path === null) {
  179.             return false;
  180.         }
  181.         if (mb_strpos($path, '|' . $rootId . '|') !== false) {
  182.             return true;
  183.         }
  184.         return false;
  185.     }
  186.     private function addVisibilityCounts(string $rootId, int $rootLevel, int $depth, CategoryCollection $levels, SalesChannelContext $context): void
  187.     {
  188.         $counts = [];
  189.         foreach ($levels as $category) {
  190.             if (!$category->getActive() || !$category->getVisible()) {
  191.                 continue;
  192.             }
  193.             $parentId = $category->getParentId();
  194.             $counts[$parentId] ??= 0;
  195.             ++$counts[$parentId];
  196.         }
  197.         foreach ($levels as $category) {
  198.             $category->setVisibleChildCount($counts[$category->getId()] ?? 0);
  199.         }
  200.         // Fetch additional level of categories for counting visible children that are NOT included in the original query
  201.         $criteria = new Criteria();
  202.         $criteria->addFilter(
  203.             new ContainsFilter('path', '|' . $rootId . '|'),
  204.             new EqualsFilter('level', $rootLevel + $depth + 1),
  205.             new EqualsFilter('active', true),
  206.             new EqualsFilter('visible', true)
  207.         );
  208.         $criteria->addAggregation(
  209.             new TermsAggregation('category-ids', 'parentId', null, null, new CountAggregation('visible-children-count', 'id'))
  210.         );
  211.         $termsResult = $this->categoryRepository
  212.             ->aggregate($criteria, $context)
  213.             ->get('category-ids');
  214.         if (!($termsResult instanceof TermsResult)) {
  215.             return;
  216.         }
  217.         foreach ($termsResult->getBuckets() as $bucket) {
  218.             $key = $bucket->getKey();
  219.             if ($key === null) {
  220.                 continue;
  221.             }
  222.             $parent = $levels->get($key);
  223.             if ($parent instanceof CategoryEntity) {
  224.                 $parent->setVisibleChildCount($bucket->getCount());
  225.             }
  226.         }
  227.     }
  228. }