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
6 changes: 6 additions & 0 deletions src/DependencyInjection/SetonoSyliusNavigationExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ public function load(array $configs, ContainerBuilder $container): void

$this->registerResources('setono_sylius_navigation', SyliusResourceBundle::DRIVER_DOCTRINE_ORM, $config['resources'], $container);

// The closure repository queries items and taxon items directly, hence it needs the configured model classes
$container->getDefinition('setono_sylius_navigation.repository.closure')
->addArgument('%setono_sylius_navigation.model.item.class%')
->addArgument('%setono_sylius_navigation.model.taxon_item.class%')
;

// Set cache parameters
$cacheEnabled = $config['cache']['enabled'] ?? !$container->getParameter('kernel.debug');
$container->setParameter('setono_sylius_navigation.cache.enabled', $cacheEnabled);
Expand Down
78 changes: 76 additions & 2 deletions src/Repository/ClosureRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,30 @@

namespace Setono\SyliusNavigationPlugin\Repository;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Setono\SyliusNavigationPlugin\Model\ClosureInterface;
use Setono\SyliusNavigationPlugin\Model\ItemInterface;
use Setono\SyliusNavigationPlugin\Model\NavigationInterface;
use Setono\SyliusNavigationPlugin\Model\TaxonItemInterface;
use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository;
use Webmozart\Assert\Assert;

class ClosureRepository extends EntityRepository implements ClosureRepositoryInterface
{
/**
* @param class-string<ItemInterface> $itemClass
* @param class-string<TaxonItemInterface> $taxonItemClass
*/
public function __construct(
EntityManagerInterface $entityManager,
ClassMetadata $class,
private readonly string $itemClass,
private readonly string $taxonItemClass,
) {
parent::__construct($entityManager, $class);
}

public function findAncestors(ItemInterface $item): array
{
$qb = $this->createQueryBuilder('c')
Expand Down Expand Up @@ -47,8 +63,14 @@ public function findGraph(ItemInterface $root): array

public function findByNavigation(NavigationInterface $navigation): array
{
// Fetch closures with descendant and ancestor items in a single query.
// Without this, both GraphBuilder and rendering templates trigger
// per-item lazy loads on getDescendant()/getAncestor() and the item's
// associations, causing severe N+1 (thousands of queries for large menus).
$qb = $this->createQueryBuilder('o')
->addSelect('item', 'ancestor')
->join('o.descendant', 'item')
->join('o.ancestor', 'ancestor')
->andWhere('item.navigation = :navigation')
->setParameter('navigation', $navigation)
;
Expand All @@ -59,6 +81,58 @@ public function findByNavigation(NavigationInterface $navigation): array
Assert::isList($objs);
Assert::allIsInstanceOf($objs, ClosureInterface::class);

if ([] === $objs) {
return $objs;
}

$itemIds = [];
foreach ($objs as $closure) {
$descendant = $closure->getDescendant();
if (null !== $descendant) {
$itemIds[(int) $descendant->getId()] = true;
}
}
$itemIds = array_keys($itemIds);

// Preload the item translations. Each preload runs as a separate query
// to avoid a cartesian product with the closures × item.translations join.
// The results attach to the already-hydrated Item instances via the
// Doctrine identity map, so no return value is needed.
$this->_em->createQueryBuilder()
->select('item', 'trans')
->from($this->itemClass, 'item')
->leftJoin('item.translations', 'trans')
->where('item.id IN (:ids)')
->setParameter('ids', $itemIds)
->getQuery()
->getResult()
;

// Preload the channels collection.
$this->_em->createQueryBuilder()
->select('item', 'ch')
->from($this->itemClass, 'item')
->leftJoin('item.channels', 'ch')
->where('item.id IN (:ids)')
->setParameter('ids', $itemIds)
->getQuery()
->getResult()
;

// Preload the taxon and taxon translations for TaxonItem descendants.
// Filtering on TaxonItemInterface restricts the result to the
// discriminator subclass rows on the single-table inheritance.
$this->_em->createQueryBuilder()
->select('taxonItem', 'taxon', 'taxonTrans')
->from($this->taxonItemClass, 'taxonItem')
->leftJoin('taxonItem.taxon', 'taxon')
->leftJoin('taxon.translations', 'taxonTrans')
->where('taxonItem.id IN (:ids)')
->setParameter('ids', $itemIds)
->getQuery()
->getResult()
;

return $objs;
}

Expand All @@ -67,7 +141,7 @@ public function findRootItems(NavigationInterface $navigation): array
// Find items where depth = 0 (self-reference only) and they don't have any ancestors with depth > 0
$qb = $this->_em->createQueryBuilder();
$qb->select('DISTINCT item')
->from(ItemInterface::class, 'item')
->from($this->itemClass, 'item')
->leftJoin($this->getClassName(), 'c', 'WITH', 'c.descendant = item AND c.depth > 0')
->where('item.navigation = :navigation')
->andWhere('c.id IS NULL')
Expand All @@ -88,7 +162,7 @@ public function findDirectChildren(ItemInterface $item): array
{
$qb = $this->_em->createQueryBuilder();
$qb->select('item')
->from(ItemInterface::class, 'item')
->from($this->itemClass, 'item')
->join($this->getClassName(), 'c', 'WITH', 'c.descendant = item')
->where('c.ancestor = :parent')
->andWhere('c.depth = 1')
Expand Down
134 changes: 134 additions & 0 deletions tests/Functional/Repository/ClosureRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@
namespace Setono\SyliusNavigationPlugin\Tests\Functional\Repository;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use Doctrine\Persistence\Proxy;
use Setono\SyliusNavigationPlugin\Model\ClosureInterface;
use Setono\SyliusNavigationPlugin\Model\ItemInterface;
use Setono\SyliusNavigationPlugin\Model\NavigationInterface;
use Setono\SyliusNavigationPlugin\Model\TaxonItemInterface;
use Setono\SyliusNavigationPlugin\Repository\ClosureRepositoryInterface;
use Sylius\Component\Channel\Model\ChannelInterface;
use Sylius\Component\Currency\Model\CurrencyInterface;
use Sylius\Component\Locale\Model\LocaleInterface;
use Sylius\Component\Taxonomy\Model\TaxonInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class ClosureRepositoryTest extends KernelTestCase
Expand Down Expand Up @@ -130,6 +137,57 @@ public function it_finds_closures_by_navigation(): void
}
}

/**
* @test
*/
public function it_preloads_item_associations_when_finding_by_navigation(): void
{
$locale = $this->createLocale('en_US');
$currency = $this->createCurrency('USD');
$channel = $this->createChannel('preload_channel', $locale, $currency);
$taxon = $this->createTaxon('preload-taxon');
$navigation = $this->createNavigation('preload_navigation');
$item = $this->createTaxonItem($navigation, $taxon, $channel, 'Preload label');

$this->createClosure($item, $item, 0);
$this->entityManager->flush();

// Clear the identity map so all associations start unhydrated.
$this->entityManager->clear();

/** @var NavigationInterface $reloadedNavigation */
$reloadedNavigation = $this->entityManager->find($navigation::class, $navigation->getId());

$closures = $this->closureRepository->findByNavigation($reloadedNavigation);
self::assertCount(1, $closures);

$descendant = $closures[0]->getDescendant();
self::assertInstanceOf(TaxonItemInterface::class, $descendant);
self::assertTrue(
!$descendant instanceof Proxy || $descendant->__isInitialized(),
'Descendant Item should be fully hydrated, not a lazy proxy.',
);

$translations = $descendant->getTranslations();
self::assertInstanceOf(PersistentCollection::class, $translations);
self::assertTrue($translations->isInitialized(), 'Item translations should be preloaded.');

$channels = $descendant->getChannels();
self::assertInstanceOf(PersistentCollection::class, $channels);
self::assertTrue($channels->isInitialized(), 'Item channels should be preloaded.');

$descendantTaxon = $descendant->getTaxon();
self::assertNotNull($descendantTaxon);
self::assertTrue(
!$descendantTaxon instanceof Proxy || $descendantTaxon->__isInitialized(),
'TaxonItem taxon should be preloaded.',
);

$taxonTranslations = $descendantTaxon->getTranslations();
self::assertInstanceOf(PersistentCollection::class, $taxonTranslations);
self::assertTrue($taxonTranslations->isInitialized(), 'Taxon translations should be preloaded.');
}

/**
* @test
*/
Expand Down Expand Up @@ -240,4 +298,80 @@ private function createClosure(ItemInterface $ancestor, ItemInterface $descendan

return $closure;
}

private function createLocale(string $code): LocaleInterface
{
/** @var LocaleInterface $locale */
$locale = self::getContainer()->get('sylius.factory.locale')->createNew();
$locale->setCode($code);

$this->entityManager->persist($locale);
$this->entityManager->flush();

return $locale;
}

private function createCurrency(string $code): CurrencyInterface
{
/** @var CurrencyInterface $currency */
$currency = self::getContainer()->get('sylius.factory.currency')->createNew();
$currency->setCode($code);

$this->entityManager->persist($currency);
$this->entityManager->flush();

return $currency;
}

private function createChannel(string $code, LocaleInterface $locale, CurrencyInterface $currency): ChannelInterface
{
/** @var ChannelInterface $channel */
$channel = self::getContainer()->get('sylius.factory.channel')->createNew();
$channel->setCode($code);
$channel->setName($code);
$channel->setDefaultLocale($locale);
$channel->setBaseCurrency($currency);

$this->entityManager->persist($channel);
$this->entityManager->flush();

return $channel;
}

private function createTaxon(string $code): TaxonInterface
{
/** @var TaxonInterface $taxon */
$taxon = self::getContainer()->get('sylius.factory.taxon')->createNew();
$taxon->setCode($code);
$taxon->setCurrentLocale('en_US');
$taxon->setFallbackLocale('en_US');
$taxon->setName(ucfirst($code));
$taxon->setSlug($code);

$this->entityManager->persist($taxon);
$this->entityManager->flush();

return $taxon;
}

private function createTaxonItem(
NavigationInterface $navigation,
TaxonInterface $taxon,
ChannelInterface $channel,
string $label,
): TaxonItemInterface {
/** @var TaxonItemInterface $item */
$item = self::getContainer()->get('setono_sylius_navigation.factory.taxon_item')->createNew();
$item->setCurrentLocale('en_US');
$item->setFallbackLocale('en_US');
$item->setNavigation($navigation);
$item->setTaxon($taxon);
$item->addChannel($channel);
$item->setLabel($label);

$this->entityManager->persist($item);
$this->entityManager->flush();

return $item;
}
}