Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions Block/LayeredNavigation/RenderLayered/LinkRenderer.php
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
<?php

/**
* Tweakwise (https://www.tweakwise.com/) - All Rights Reserved
*
* @copyright Copyright (c) 2017-2022 Tweakwise.com B.V. (https://www.tweakwise.com)
* @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0)
*/
declare(strict_types=1);

namespace Tweakwise\Magento2Tweakwise\Block\LayeredNavigation\RenderLayered;

use Magento\Framework\Escaper;
use Magento\Framework\View\Element\Template\Context;
use Magento\Framework\Serialize\Serializer\Json;
use Tweakwise\Magento2Tweakwise\Block\LayeredNavigation\RenderLayered\LinkRenderer\ItemRenderer;
use Tweakwise\Magento2Tweakwise\Model\Catalog\Layer\Filter\Item;
use Tweakwise\Magento2Tweakwise\Model\Catalog\Layer\Url\StrategyHelper;
use Tweakwise\Magento2Tweakwise\Model\Config;
use Tweakwise\Magento2Tweakwise\Model\NavigationConfig;
use Tweakwise\Magento2Tweakwise\Model\Seo\FilterHelper;
use Tweakwise\Magento2TweakwiseExport\Model\Helper;

class LinkRenderer extends DefaultRenderer
{
Expand All @@ -19,6 +22,47 @@ class LinkRenderer extends DefaultRenderer
*/
protected $_template = 'Tweakwise_Magento2Tweakwise::product/layered/link.phtml';

/**
* @param Context $context
* @param Config $config
* @param NavigationConfig $navigationConfig
* @param FilterHelper $filterHelper
* @param Json $jsonSerializer
* @param Helper $helper
* @param Escaper $escaper
* @param StrategyHelper $strategyHelper
* @param array $data
*/
public function __construct(
Context $context,
Config $config,
NavigationConfig $navigationConfig,
FilterHelper $filterHelper,
Json $jsonSerializer,
Helper $helper,
Escaper $escaper,
private readonly StrategyHelper $strategyHelper,
array $data = []
) {
parent::__construct($context, $config, $navigationConfig, $filterHelper, $jsonSerializer, $helper, $escaper, $data);
}

/**
* Returns filter items and pre-warms the category + URL-rewrite caches so that
* all subsequent per-item calls to getCategoryFromItem() and Category::getUrl()
* are served from memory instead of issuing individual DB queries.
*
* @return Item[]
*/
public function getItems()
{
$items = parent::getItems();

$this->strategyHelper->warmUp($items, (int) $this->filter->getStoreId());

return $items;
}

/**
* @param Item $item
* @return string
Expand Down
141 changes: 134 additions & 7 deletions Model/Catalog/Layer/Url/StrategyHelper.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
<?php

declare(strict_types=1);

namespace Tweakwise\Magento2Tweakwise\Model\Catalog\Layer\Url;

use Tweakwise\Magento2Tweakwise\Model\Catalog\Layer\Filter\Item;
use Tweakwise\Magento2TweakwiseExport\Model\Helper as ExportHelper;
use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Catalog\Api\Data\CategoryInterface;
use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory as CategoryCollectionFactory;
use Magento\CatalogUrlRewrite\Model\CategoryUrlRewriteGenerator;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Store\Model\StoreManagerInterface;
use Magento\UrlRewrite\Model\UrlFinderInterface;
use Magento\UrlRewrite\Service\V1\Data\UrlRewrite;

class StrategyHelper
{
Expand All @@ -26,38 +32,159 @@ class StrategyHelper
*/
private $storeManager;

/**
* Local cache: [categoryId][storeId] => CategoryInterface
*
* @var array<int, array<int, CategoryInterface>>
*/
private array $categoryCache = [];

/**
* StrategyHelper constructor.
* @param ExportHelper $exportHelper
* @param CategoryRepositoryInterface $categoryRepository
* @param StoreManagerInterface $storeManager
* @param CategoryCollectionFactory $categoryCollectionFactory
* @param UrlFinderInterface $urlFinder
*/
public function __construct(
ExportHelper $exportHelper,
CategoryRepositoryInterface $categoryRepository,
StoreManagerInterface $storeManager
StoreManagerInterface $storeManager,
private readonly CategoryCollectionFactory $categoryCollectionFactory,
private readonly UrlFinderInterface $urlFinder
) {
$this->exportHelper = $exportHelper;
$this->categoryRepository = $categoryRepository;
$this->storeManager = $storeManager;
}

/**
* Pre-load all category entities and their URL request paths in two batch queries.
* Call this before iterating over filter items to avoid N+1 queries.
*
* @param Item[] $items
* @param int $storeId
* @return void
*/
public function warmUp(array $items, int $storeId): void
{
$categoryIds = $this->collectCategoryIds($items);
if (empty($categoryIds)) {
return;
}

$uncachedIds = array_filter(
$categoryIds,
fn(int $id) => !isset($this->categoryCache[$id][$storeId])
);

if (empty($uncachedIds)) {
return;
}

$collection = $this->categoryCollectionFactory->create();
$collection->setStoreId($storeId);
$collection->addAttributeToSelect(['name', 'url_key', 'url_path', 'is_active']);
$collection->addFieldToFilter('entity_id', ['in' => array_values($uncachedIds)]);
$collection->load();

$loadedIds = [];

/** @var CategoryInterface $category */
foreach ($collection as $category) {
$id = (int) $category->getId();
$this->categoryCache[$id][$storeId] = $category;
$loadedIds[] = $id;
}

$this->preloadUrlRewrites($loadedIds, $storeId);
}

/**
* @param Item $item
* @return CategoryInterface
* @throws NoSuchEntityException
*/
public function getCategoryFromItem(Item $item): CategoryInterface
{
$tweakwiseCategoryId = $item->getAttribute()->getAttributeId();
$categoryId = $this->exportHelper->getStoreId($tweakwiseCategoryId);
$tweakwiseCategoryId = (int) $item->getAttribute()->getAttributeId();
$categoryId = (int) $this->exportHelper->getStoreId($tweakwiseCategoryId);

try {
$storeId = $this->storeManager->getStore()->getId();
$storeId = (int) $this->storeManager->getStore()->getId();
} catch (NoSuchEntityException $exception) {
$storeId = null;
$storeId = 0;
}

if (isset($this->categoryCache[$categoryId][$storeId])) {
return $this->categoryCache[$categoryId][$storeId];
}

return $this->categoryRepository->get($categoryId, $storeId);
return $this->categoryRepository->get($categoryId, $storeId !== 0 ? $storeId : null);
}

/**
* Recursively collect all Magento category IDs from a flat or nested item list.
*
* @param Item[] $items
* @return int[]
*/
private function collectCategoryIds(array $items): array
{
$ids = [];
foreach ($items as $item) {
$tweakwiseCategoryId = (int) $item->getAttribute()->getAttributeId();
$ids[] = (int) $this->exportHelper->getStoreId($tweakwiseCategoryId);

$children = $item->getChildren();
if (empty($children)) {
continue;
}

foreach ($this->collectCategoryIds($children) as $childId) {
$ids[] = $childId;
}
}

return array_unique($ids);
}

/**
* Bulk-load URL rewrites for the given category IDs and pre-populate `request_path`
* on each cached Category object so that Category::getUrl() skips its own DB lookup.
*
* @param int[] $categoryIds
* @param int $storeId
* @return void
*/
private function preloadUrlRewrites(array $categoryIds, int $storeId): void
{
if (empty($categoryIds)) {
return;
}

$rewrites = $this->urlFinder->findAllByData(
[
UrlRewrite::ENTITY_ID => $categoryIds,
UrlRewrite::ENTITY_TYPE => CategoryUrlRewriteGenerator::ENTITY_TYPE,
UrlRewrite::STORE_ID => $storeId,
UrlRewrite::REDIRECT_TYPE => 0,
]
);

foreach ($rewrites as $rewrite) {
$categoryId = (int) $rewrite->getEntityId();
if (!isset($this->categoryCache[$categoryId][$storeId])) {
continue;
}

$category = $this->categoryCache[$categoryId][$storeId];
if ($category->getData('url') !== null) {
continue;
}

$category->setData('request_path', $rewrite->getRequestPath());
}
}
}
Loading