Skip to content

Commit

Permalink
NEXT-32330 - Implement first example of new event-based extension sys…
Browse files Browse the repository at this point in the history
…tem for product listing loader
  • Loading branch information
OliverSkroblin committed Jun 18, 2024
1 parent 88768d7 commit e5e4e99
Show file tree
Hide file tree
Showing 14 changed files with 493 additions and 52 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@
},
"autoload-dev": {
"psr-4": {
"Shopware\\Tests\\Examples\\": "tests/examples/",
"Shopware\\Tests\\Unit\\": "tests/unit/",
"Shopware\\Tests\\Integration\\": "tests/integration/",
"Shopware\\Tests\\Bench\\": "tests/performance/bench/",
Expand Down
1 change: 1 addition & 0 deletions src/Core/Content/DependencyInjection/product.xml
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@
<argument type="service" id="Doctrine\DBAL\Connection"/>
<argument type="service" id="event_dispatcher"/>
<argument type="service" id="Shopware\Core\Content\Product\SalesChannel\ProductCloseoutFilterFactory"/>
<argument type="service" id="Shopware\Core\Framework\Extensions\ExtensionDispatcher"/>
</service>

<service id="Shopware\Core\Content\Product\SalesChannel\Detail\ProductDetailRoute" public="true">
Expand Down
43 changes: 43 additions & 0 deletions src/Core/Content/Product/Extension/ResolveListingExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Content\Product\Extension;

use Shopware\Core\Content\Product\ProductCollection;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
use Shopware\Core\Framework\Extensions\Extension;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\System\SalesChannel\SalesChannelContext;

/**
* @extends Extension<EntitySearchResult<ProductCollection>>
*/
#[Package('inventory')]
final class ResolveListingExtension extends Extension
{
public const NAME = 'listing-loader.resolve';

/**
* @internal shopware owns the __constructor, but the properties are public API
*/
public function __construct(
/**
* @public
*
* @description The criteria which should be used to load the products. Is also containing the selected customer filter
*/
public readonly Criteria $criteria,
/**
* @public
*
* @description Allows you to access to the current customer/sales-channel context
*/
public readonly SalesChannelContext $context
) {
}

public static function name(): string
{
return self::NAME;
}
}
52 changes: 52 additions & 0 deletions src/Core/Content/Product/Extension/ResolveListingIdsExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Content\Product\Extension;

use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\IdSearchResult;
use Shopware\Core\Framework\Extensions\Extension;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\System\SalesChannel\SalesChannelContext;

/**
* @public this class is used as type-hint for all event listeners, so the class string is "public consumable" API
*
* @title Determination of the listing product ids
*
* @description This event allows intercepting the listing process, when the product ids should be determined for the current category page and the applied filter.
*
* @extends Extension<IdSearchResult>
*/
#[Package('inventory')]
final class ResolveListingIdsExtension extends Extension
{
public const NAME = 'listing-loader.resolve-listing-ids';

/**
* {@inheritdoc}
*/
public static function name(): string
{
return self::NAME;
}

/**
* @internal shopware owns the __constructor, but the properties are public API
*/
public function __construct(
/**
* @public
*
* @description The criteria which should be used to load the product ids. Is also containing the selected customer filter
*/
public Criteria $criteria,

/**
* @public
*
* @description Allows you to access to the current customer/sales-channel context
*/
public SalesChannelContext $context
) {
}
}
115 changes: 87 additions & 28 deletions src/Core/Content/Product/SalesChannel/Listing/ProductListingLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use Doctrine\DBAL\Connection;
use Shopware\Core\Content\Product\Events\ProductListingPreviewCriteriaEvent;
use Shopware\Core\Content\Product\Events\ProductListingResolvePreviewEvent;
use Shopware\Core\Content\Product\Extension\ResolveListingExtension;
use Shopware\Core\Content\Product\Extension\ResolveListingIdsExtension;
use Shopware\Core\Content\Product\ProductCollection;
use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Content\Product\SalesChannel\AbstractProductCloseoutFilterFactory;
Expand All @@ -17,6 +19,8 @@
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Grouping\FieldGrouping;
use Shopware\Core\Framework\DataAbstractionLayer\Search\IdSearchResult;
use Shopware\Core\Framework\Extensions\ExtensionDispatcher;
use Shopware\Core\Framework\FrameworkException;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Struct\ArrayEntity;
use Shopware\Core\Framework\Uuid\Uuid;
Expand All @@ -37,8 +41,9 @@ public function __construct(
private readonly SalesChannelRepository $productRepository,
private readonly SystemConfigService $systemConfigService,
private readonly Connection $connection,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly AbstractProductCloseoutFilterFactory $productCloseoutFilterFactory
private readonly EventDispatcherInterface $dispatcher,
private readonly AbstractProductCloseoutFilterFactory $productCloseoutFilterFactory,
private readonly ExtensionDispatcher $extensions
) {
}

Expand All @@ -47,48 +52,60 @@ public function __construct(
*/
public function load(Criteria $origin, SalesChannelContext $context): EntitySearchResult
{
$origin->addState(Criteria::STATE_ELASTICSEARCH_AWARE);
$criteria = clone $origin;
// allows full service decoration
$result = $this->extensions->publish(
extension: new ResolveListingExtension($origin, $context),
function: $this->_load(...)
);

$this->addGrouping($criteria);
$this->handleAvailableStock($criteria, $context);
if (!$result instanceof EntitySearchResult) {
throw FrameworkException::extensionResultNotSet(ResolveListingExtension::class);
}

$ids = $this->productRepository->searchIds($criteria, $context);
/** @var list<string> $keys */
$keys = $ids->getIds();
$aggregations = $this->productRepository->aggregate($criteria, $context);
return $result;
}

/**
* @return EntitySearchResult<ProductCollection>
*/
private function _load(Criteria $criteria, SalesChannelContext $context): EntitySearchResult
{
$criteria->addState(Criteria::STATE_ELASTICSEARCH_AWARE);
$clone = clone $criteria;

$ids = $this->extensions->publish(
extension: new ResolveListingIdsExtension($clone, $context),
function: $this->resolveIds(...)
);

if (!$ids instanceof IdSearchResult) {
throw FrameworkException::extensionResultNotSet(ResolveListingIdsExtension::class);
}

$aggregations = $this->productRepository->aggregate($clone, $context);

// no products found, no need to continue
if (empty($keys)) {
if (empty($ids->getIds())) {
return new EntitySearchResult(
ProductDefinition::ENTITY_NAME,
0,
new ProductCollection(),
$aggregations,
$origin,
$criteria,
$context->getContext()
);
}

$mapping = array_combine($keys, $keys);

$hasOptionFilter = $this->hasOptionFilter($criteria);
if (!$hasOptionFilter) {
$mapping = $this->resolvePreviews($keys, $context);
}
/** @var list<string> $keys */
$keys = $ids->getIds();

$event = new ProductListingResolvePreviewEvent($context, $criteria, $mapping, $hasOptionFilter);
$this->eventDispatcher->dispatch($event);
$mapping = $event->getMapping();
$mapping = $this->resolvePreviews($keys, $clone, $context);

$read = $criteria->cloneForRead(array_values($mapping));
$read->addAssociation('options.group');

$searchResult = $this->productRepository->search($read, $context);
$searchResult = $this->resolveData($clone, $mapping, $context);

$this->addExtensions($ids, $searchResult, $mapping);

$result = new EntitySearchResult(ProductDefinition::ENTITY_NAME, $ids->getTotal(), $searchResult->getEntities(), $aggregations, $origin, $context->getContext());
$result = new EntitySearchResult(ProductDefinition::ENTITY_NAME, $ids->getTotal(), $searchResult->getEntities(), $aggregations, $criteria, $context->getContext());
$result->addState(...$ids->getStates());

return $result;
Expand Down Expand Up @@ -149,7 +166,7 @@ private function handleAvailableStock(Criteria $criteria, SalesChannelContext $c
*
* @return array<string>
*/
private function resolvePreviews(array $ids, SalesChannelContext $context): array
private function loadPreviews(array $ids, SalesChannelContext $context): array
{
$ids = array_combine($ids, $ids);

Expand Down Expand Up @@ -198,7 +215,7 @@ private function resolvePreviews(array $ids, SalesChannelContext $context): arra
$criteria->addFilter(new ProductAvailableFilter($context->getSalesChannel()->getId()));
$this->handleAvailableStock($criteria, $context);

$this->eventDispatcher->dispatch(
$this->dispatcher->dispatch(
new ProductListingPreviewCriteriaEvent($criteria, $context)
);

Expand Down Expand Up @@ -259,4 +276,46 @@ private function addExtensions(IdSearchResult $ids, EntitySearchResult $entities
$entity->addExtension('search', new ArrayEntity($ids->getDataOfId($id)));
}
}

private function resolveIds(Criteria $criteria, SalesChannelContext $context): IdSearchResult
{
$this->addGrouping($criteria);

$this->handleAvailableStock($criteria, $context);

return $this->productRepository->searchIds($criteria, $context);
}

/**
* @param array<string> $keys
*
* @return array<string, string>
*/
private function resolvePreviews(array $keys, Criteria $criteria, SalesChannelContext $context): array
{
$mapping = array_combine($keys, $keys);

$hasOptionFilter = $this->hasOptionFilter($criteria);
if (!$hasOptionFilter) {
$mapping = $this->loadPreviews($keys, $context);
}

$event = new ProductListingResolvePreviewEvent($context, $criteria, $mapping, $hasOptionFilter);
$this->dispatcher->dispatch($event);

return $event->getMapping();
}

/**
* @param array<string, string> $mapping
*
* @return EntitySearchResult<ProductCollection>
*/
private function resolveData(Criteria $criteria, array $mapping, SalesChannelContext $context): EntitySearchResult
{
$read = $criteria->cloneForRead(array_values($mapping));
$read->addAssociation('options.group');

return $this->productRepository->search($read, $context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ class DomainExceptionRule implements Rule
RedisReverseProxyGateway::class => ReverseProxyException::class,
];

private const GLOBAL_EXCEPTIONS = [
'Shopware\Core\Framework\FrameworkException::extensionResultNotSet',
];

/**
* @var array<string>
*/
Expand Down Expand Up @@ -155,6 +159,13 @@ private function validateDomainExceptionClass(StaticCall $node, Scope $scope): a
}
}

if (method_exists($node->name, 'toString')) {
$full = $exceptionClass . '::' . $node->name->toString();
if (\in_array($full, self::GLOBAL_EXCEPTIONS, true)) {
return [];
}
}

return [
RuleErrorBuilder::message(\sprintf('Expected domain exception class %s, got %s', $acceptedClasses[0], $exceptionClass))
->identifier('shopware.domainException')
Expand Down
Loading

0 comments on commit e5e4e99

Please sign in to comment.