src/Core/Content/Product/SalesChannel/Listing/ProductListingFeaturesSubscriber.php line 165

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Content\Product\SalesChannel\Listing;
  3. use Doctrine\DBAL\Connection;
  4. use Shopware\Core\Content\Product\Events\ProductListingCollectFilterEvent;
  5. use Shopware\Core\Content\Product\Events\ProductListingCriteriaEvent;
  6. use Shopware\Core\Content\Product\Events\ProductListingResultEvent;
  7. use Shopware\Core\Content\Product\Events\ProductSearchCriteriaEvent;
  8. use Shopware\Core\Content\Product\Events\ProductSearchResultEvent;
  9. use Shopware\Core\Content\Product\Events\ProductSuggestCriteriaEvent;
  10. use Shopware\Core\Content\Product\SalesChannel\Exception\ProductSortingNotFoundException;
  11. use Shopware\Core\Content\Product\SalesChannel\Sorting\ProductSortingCollection;
  12. use Shopware\Core\Content\Product\SalesChannel\Sorting\ProductSortingEntity;
  13. use Shopware\Core\Content\Property\Aggregate\PropertyGroupOption\PropertyGroupOptionCollection;
  14. use Shopware\Core\Framework\Context;
  15. use Shopware\Core\Framework\DataAbstractionLayer\Dbal\Common\RepositoryIterator;
  16. use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\FetchModeHelper;
  17. use Shopware\Core\Framework\DataAbstractionLayer\Entity;
  18. use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;
  19. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
  20. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Aggregation;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\FilterAggregation;
  22. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\TermsAggregation;
  23. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\EntityAggregation;
  24. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\MaxAggregation;
  25. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\StatsAggregation;
  26. use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Bucket\TermsResult;
  27. use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Metric\EntityResult;
  28. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  29. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
  30. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  31. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
  32. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter;
  33. use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
  34. use Shopware\Core\Framework\Log\Package;
  35. use Shopware\Core\Framework\Uuid\Uuid;
  36. use Shopware\Core\Profiling\Profiler;
  37. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  38. use Shopware\Core\System\SystemConfig\SystemConfigService;
  39. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  40. use Symfony\Component\HttpFoundation\Request;
  41. use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  42. /**
  43.  * @internal
  44.  */
  45. #[Package('inventory')]
  46. class ProductListingFeaturesSubscriber implements EventSubscriberInterface
  47. {
  48.     final public const DEFAULT_SEARCH_SORT 'score';
  49.     final public const PROPERTY_GROUP_IDS_REQUEST_PARAM 'property-whitelist';
  50.     /**
  51.      * @internal
  52.      */
  53.     public function __construct(private readonly Connection $connection, private readonly EntityRepository $optionRepository, private readonly EntityRepository $sortingRepository, private readonly SystemConfigService $systemConfigService, private readonly EventDispatcherInterface $dispatcher)
  54.     {
  55.     }
  56.     public static function getSubscribedEvents(): array
  57.     {
  58.         return [
  59.             ProductListingCriteriaEvent::class => [
  60.                 ['handleListingRequest'100],
  61.                 ['handleFlags', -100],
  62.             ],
  63.             ProductSuggestCriteriaEvent::class => [
  64.                 ['handleFlags', -100],
  65.             ],
  66.             ProductSearchCriteriaEvent::class => [
  67.                 ['handleSearchRequest'100],
  68.                 ['handleFlags', -100],
  69.             ],
  70.             ProductListingResultEvent::class => [
  71.                 ['handleResult'100],
  72.                 ['removeScoreSorting', -100],
  73.             ],
  74.             ProductSearchResultEvent::class => 'handleResult',
  75.         ];
  76.     }
  77.     public function handleFlags(ProductListingCriteriaEvent $event): void
  78.     {
  79.         $request $event->getRequest();
  80.         $criteria $event->getCriteria();
  81.         if ($request->get('no-aggregations')) {
  82.             $criteria->resetAggregations();
  83.         }
  84.         if ($request->get('only-aggregations')) {
  85.             // set limit to zero to fetch no products.
  86.             $criteria->setLimit(0);
  87.             // no total count required
  88.             $criteria->setTotalCountMode(Criteria::TOTAL_COUNT_MODE_NONE);
  89.             // sorting and association are only required for the product data
  90.             $criteria->resetSorting();
  91.             $criteria->resetAssociations();
  92.         }
  93.     }
  94.     public function handleListingRequest(ProductListingCriteriaEvent $event): void
  95.     {
  96.         $request $event->getRequest();
  97.         $criteria $event->getCriteria();
  98.         $context $event->getSalesChannelContext();
  99.         if (!$request->get('order')) {
  100.             $request->request->set('order'$this->getSystemDefaultSorting($context));
  101.         }
  102.         $criteria->addAssociation('options');
  103.         $this->handlePagination($request$criteria$event->getSalesChannelContext());
  104.         $this->handleFilters($request$criteria$context);
  105.         $this->handleSorting($request$criteria$context);
  106.     }
  107.     public function handleSearchRequest(ProductSearchCriteriaEvent $event): void
  108.     {
  109.         $request $event->getRequest();
  110.         $criteria $event->getCriteria();
  111.         $context $event->getSalesChannelContext();
  112.         if (!$request->get('order')) {
  113.             $request->request->set('order'self::DEFAULT_SEARCH_SORT);
  114.         }
  115.         $this->handlePagination($request$criteria$event->getSalesChannelContext());
  116.         $this->handleFilters($request$criteria$context);
  117.         $this->handleSorting($request$criteria$context);
  118.     }
  119.     public function handleResult(ProductListingResultEvent $event): void
  120.     {
  121.         Profiler::trace('product-listing::feature-subscriber', function () use ($event): void {
  122.             $this->groupOptionAggregations($event);
  123.             $this->addCurrentFilters($event);
  124.             $result $event->getResult();
  125.             /** @var ProductSortingCollection $sortings */
  126.             $sortings $result->getCriteria()->getExtension('sortings');
  127.             $currentSortingKey $this->getCurrentSorting($sortings$event->getRequest())->getKey();
  128.             $result->setSorting($currentSortingKey);
  129.             $result->setAvailableSortings($sortings);
  130.             $result->setPage($this->getPage($event->getRequest()));
  131.             $result->setLimit($this->getLimit($event->getRequest(), $event->getSalesChannelContext()));
  132.         });
  133.     }
  134.     public function removeScoreSorting(ProductListingResultEvent $event): void
  135.     {
  136.         $sortings $event->getResult()->getAvailableSortings();
  137.         $defaultSorting $sortings->getByKey(self::DEFAULT_SEARCH_SORT);
  138.         if ($defaultSorting !== null) {
  139.             $sortings->remove($defaultSorting->getId());
  140.         }
  141.         $event->getResult()->setAvailableSortings($sortings);
  142.     }
  143.     private function handleFilters(Request $requestCriteria $criteriaSalesChannelContext $context): void
  144.     {
  145.         $criteria->addAssociation('manufacturer');
  146.         $filters $this->getFilters($request$context);
  147.         $aggregations $this->getAggregations($request$filters);
  148.         foreach ($aggregations as $aggregation) {
  149.             $criteria->addAggregation($aggregation);
  150.         }
  151.         foreach ($filters as $filter) {
  152.             if ($filter->isFiltered()) {
  153.                 $criteria->addPostFilter($filter->getFilter());
  154.             }
  155.         }
  156.         $criteria->addExtension('filters'$filters);
  157.     }
  158.     /**
  159.      * @return array<Aggregation>
  160.      */
  161.     private function getAggregations(Request $requestFilterCollection $filters): array
  162.     {
  163.         $aggregations = [];
  164.         if ($request->get('reduce-aggregations') === null) {
  165.             foreach ($filters as $filter) {
  166.                 $aggregations array_merge($aggregations$filter->getAggregations());
  167.             }
  168.             return $aggregations;
  169.         }
  170.         foreach ($filters as $filter) {
  171.             $excluded $filters->filtered();
  172.             if ($filter->exclude()) {
  173.                 $excluded $excluded->blacklist($filter->getName());
  174.             }
  175.             foreach ($filter->getAggregations() as $aggregation) {
  176.                 if ($aggregation instanceof FilterAggregation) {
  177.                     $aggregation->addFilters($excluded->getFilters());
  178.                     $aggregations[] = $aggregation;
  179.                     continue;
  180.                 }
  181.                 $aggregation = new FilterAggregation(
  182.                     $aggregation->getName(),
  183.                     $aggregation,
  184.                     $excluded->getFilters()
  185.                 );
  186.                 $aggregations[] = $aggregation;
  187.             }
  188.         }
  189.         return $aggregations;
  190.     }
  191.     private function handlePagination(Request $requestCriteria $criteriaSalesChannelContext $context): void
  192.     {
  193.         $limit $this->getLimit($request$context);
  194.         $page $this->getPage($request);
  195.         $criteria->setOffset(($page 1) * $limit);
  196.         $criteria->setLimit($limit);
  197.         $criteria->setTotalCountMode(Criteria::TOTAL_COUNT_MODE_EXACT);
  198.     }
  199.     private function handleSorting(Request $requestCriteria $criteriaSalesChannelContext $context): void
  200.     {
  201.         /** @var ProductSortingCollection $sortings */
  202.         $sortings $criteria->getExtension('sortings') ?? new ProductSortingCollection();
  203.         $sortings->merge($this->getAvailableSortings($request$context->getContext()));
  204.         $currentSorting $this->getCurrentSorting($sortings$request);
  205.         $criteria->addSorting(
  206.             ...$currentSorting->createDalSorting()
  207.         );
  208.         $criteria->addExtension('sortings'$sortings);
  209.     }
  210.     private function getCurrentSorting(ProductSortingCollection $sortingsRequest $request): ProductSortingEntity
  211.     {
  212.         $key $request->get('order');
  213.         $sorting $sortings->getByKey($key);
  214.         if ($sorting !== null) {
  215.             return $sorting;
  216.         }
  217.         throw new ProductSortingNotFoundException($key);
  218.     }
  219.     private function getAvailableSortings(Request $requestContext $context): ProductSortingCollection
  220.     {
  221.         $criteria = new Criteria();
  222.         $criteria->setTitle('product-listing::load-sortings');
  223.         $availableSortings $request->get('availableSortings');
  224.         $availableSortingsFilter = [];
  225.         if ($availableSortings) {
  226.             arsort($availableSortings\SORT_DESC \SORT_NUMERIC);
  227.             $availableSortingsFilter array_keys($availableSortings);
  228.             $criteria->addFilter(new EqualsAnyFilter('key'$availableSortingsFilter));
  229.         }
  230.         $criteria
  231.             ->addFilter(new EqualsFilter('active'true))
  232.             ->addSorting(new FieldSorting('priority''DESC'));
  233.         /** @var ProductSortingCollection $sortings */
  234.         $sortings $this->sortingRepository->search($criteria$context)->getEntities();
  235.         if ($availableSortings) {
  236.             $sortings->sortByKeyArray($availableSortingsFilter);
  237.         }
  238.         return $sortings;
  239.     }
  240.     private function getSystemDefaultSorting(SalesChannelContext $context): string
  241.     {
  242.         return $this->systemConfigService->getString(
  243.             'core.listing.defaultSorting',
  244.             $context->getSalesChannel()->getId()
  245.         );
  246.     }
  247.     /**
  248.      * @return array<int, non-falsy-string>
  249.      */
  250.     private function collectOptionIds(ProductListingResultEvent $event): array
  251.     {
  252.         $aggregations $event->getResult()->getAggregations();
  253.         /** @var TermsResult|null $properties */
  254.         $properties $aggregations->get('properties');
  255.         /** @var TermsResult|null $options */
  256.         $options $aggregations->get('options');
  257.         $options $options $options->getKeys() : [];
  258.         $properties $properties $properties->getKeys() : [];
  259.         return array_unique(array_filter([...$options, ...$properties]));
  260.     }
  261.     private function groupOptionAggregations(ProductListingResultEvent $event): void
  262.     {
  263.         $ids $this->collectOptionIds($event);
  264.         if (empty($ids)) {
  265.             return;
  266.         }
  267.         $criteria = new Criteria($ids);
  268.         $criteria->setLimit(500);
  269.         $criteria->addAssociation('group');
  270.         $criteria->addAssociation('media');
  271.         $criteria->addFilter(new EqualsFilter('group.filterable'true));
  272.         $criteria->setTitle('product-listing::property-filter');
  273.         $criteria->addSorting(new FieldSorting('id'FieldSorting::ASCENDING));
  274.         $mergedOptions = new PropertyGroupOptionCollection();
  275.         $repositoryIterator = new RepositoryIterator($this->optionRepository$event->getContext(), $criteria);
  276.         while (($result $repositoryIterator->fetch()) !== null) {
  277.             /** @var PropertyGroupOptionCollection $entities */
  278.             $entities $result->getEntities();
  279.             $mergedOptions->merge($entities);
  280.         }
  281.         // group options by their property-group
  282.         $grouped $mergedOptions->groupByPropertyGroups();
  283.         $grouped->sortByPositions();
  284.         $grouped->sortByConfig();
  285.         $aggregations $event->getResult()->getAggregations();
  286.         // remove id results to prevent wrong usages
  287.         $aggregations->remove('properties');
  288.         $aggregations->remove('configurators');
  289.         $aggregations->remove('options');
  290.         /** @var EntityCollection<Entity> $grouped */
  291.         $aggregations->add(new EntityResult('properties'$grouped));
  292.     }
  293.     private function addCurrentFilters(ProductListingResultEvent $event): void
  294.     {
  295.         $result $event->getResult();
  296.         $filters $result->getCriteria()->getExtension('filters');
  297.         if (!$filters instanceof FilterCollection) {
  298.             return;
  299.         }
  300.         foreach ($filters as $filter) {
  301.             $result->addCurrentFilter($filter->getName(), $filter->getValues());
  302.         }
  303.     }
  304.     /**
  305.      * @return list<string>
  306.      */
  307.     private function getManufacturerIds(Request $request): array
  308.     {
  309.         $ids $request->query->get('manufacturer''');
  310.         if ($request->isMethod(Request::METHOD_POST)) {
  311.             $ids $request->request->get('manufacturer''');
  312.         }
  313.         if (\is_string($ids)) {
  314.             $ids explode('|'$ids);
  315.         }
  316.         /** @var list<string> $ids */
  317.         $ids array_filter((array) $ids);
  318.         return $ids;
  319.     }
  320.     /**
  321.      * @return list<string>
  322.      */
  323.     private function getPropertyIds(Request $request): array
  324.     {
  325.         $ids $request->query->get('properties''');
  326.         if ($request->isMethod(Request::METHOD_POST)) {
  327.             $ids $request->request->get('properties''');
  328.         }
  329.         if (\is_string($ids)) {
  330.             $ids explode('|'$ids);
  331.         }
  332.         /** @var list<string> $ids */
  333.         $ids array_filter((array) $ids);
  334.         return $ids;
  335.     }
  336.     private function getLimit(Request $requestSalesChannelContext $context): int
  337.     {
  338.         $limit $request->query->getInt('limit'0);
  339.         if ($request->isMethod(Request::METHOD_POST)) {
  340.             $limit $request->request->getInt('limit'$limit);
  341.         }
  342.         $limit $limit $limit $this->systemConfigService->getInt('core.listing.productsPerPage'$context->getSalesChannel()->getId());
  343.         return $limit <= 24 $limit;
  344.     }
  345.     private function getPage(Request $request): int
  346.     {
  347.         $page $request->query->getInt('p'1);
  348.         if ($request->isMethod(Request::METHOD_POST)) {
  349.             $page $request->request->getInt('p'$page);
  350.         }
  351.         return $page <= $page;
  352.     }
  353.     private function getFilters(Request $requestSalesChannelContext $context): FilterCollection
  354.     {
  355.         $filters = new FilterCollection();
  356.         $filters->add($this->getManufacturerFilter($request));
  357.         $filters->add($this->getPriceFilter($request));
  358.         $filters->add($this->getRatingFilter($request));
  359.         $filters->add($this->getShippingFreeFilter($request));
  360.         $filters->add($this->getPropertyFilter($request));
  361.         if (!$request->request->get('manufacturer-filter'true)) {
  362.             $filters->remove('manufacturer');
  363.         }
  364.         if (!$request->request->get('price-filter'true)) {
  365.             $filters->remove('price');
  366.         }
  367.         if (!$request->request->get('rating-filter'true)) {
  368.             $filters->remove('rating');
  369.         }
  370.         if (!$request->request->get('shipping-free-filter'true)) {
  371.             $filters->remove('shipping-free');
  372.         }
  373.         if (!$request->request->get('property-filter'true)) {
  374.             $filters->remove('properties');
  375.             if (\count($propertyWhitelist $request->request->all(self::PROPERTY_GROUP_IDS_REQUEST_PARAM))) {
  376.                 $filters->add($this->getPropertyFilter($request$propertyWhitelist));
  377.             }
  378.         }
  379.         $event = new ProductListingCollectFilterEvent($request$filters$context);
  380.         $this->dispatcher->dispatch($event);
  381.         return $filters;
  382.     }
  383.     private function getManufacturerFilter(Request $request): Filter
  384.     {
  385.         $ids $this->getManufacturerIds($request);
  386.         return new Filter(
  387.             'manufacturer',
  388.             !empty($ids),
  389.             [new EntityAggregation('manufacturer''product.manufacturerId''product_manufacturer')],
  390.             new EqualsAnyFilter('product.manufacturerId'$ids),
  391.             $ids
  392.         );
  393.     }
  394.     /**
  395.      * @param array<string>|null $groupIds
  396.      */
  397.     private function getPropertyFilter(Request $request, ?array $groupIds null): Filter
  398.     {
  399.         $ids $this->getPropertyIds($request);
  400.         $propertyAggregation = new TermsAggregation('properties''product.properties.id');
  401.         $optionAggregation = new TermsAggregation('options''product.options.id');
  402.         if ($groupIds) {
  403.             $propertyAggregation = new FilterAggregation(
  404.                 'properties-filter',
  405.                 $propertyAggregation,
  406.                 [new EqualsAnyFilter('product.properties.groupId'$groupIds)]
  407.             );
  408.             $optionAggregation = new FilterAggregation(
  409.                 'options-filter',
  410.                 $optionAggregation,
  411.                 [new EqualsAnyFilter('product.options.groupId'$groupIds)]
  412.             );
  413.         }
  414.         if (empty($ids)) {
  415.             return new Filter(
  416.                 'properties',
  417.                 false,
  418.                 [$propertyAggregation$optionAggregation],
  419.                 new MultiFilter(MultiFilter::CONNECTION_OR, []),
  420.                 [],
  421.                 false
  422.             );
  423.         }
  424.         $grouped $this->connection->fetchAllAssociative(
  425.             'SELECT LOWER(HEX(property_group_id)) as property_group_id, LOWER(HEX(id)) as id
  426.              FROM property_group_option
  427.              WHERE id IN (:ids)',
  428.             ['ids' => Uuid::fromHexToBytesList($ids)],
  429.             ['ids' => Connection::PARAM_STR_ARRAY]
  430.         );
  431.         $grouped FetchModeHelper::group($grouped);
  432.         $filters = [];
  433.         foreach ($grouped as $options) {
  434.             $options array_column($options'id');
  435.             $filters[] = new MultiFilter(
  436.                 MultiFilter::CONNECTION_OR,
  437.                 [
  438.                     new EqualsAnyFilter('product.optionIds'$options),
  439.                     new EqualsAnyFilter('product.propertyIds'$options),
  440.                 ]
  441.             );
  442.         }
  443.         return new Filter(
  444.             'properties',
  445.             true,
  446.             [$propertyAggregation$optionAggregation],
  447.             new MultiFilter(MultiFilter::CONNECTION_AND$filters),
  448.             $ids,
  449.             false
  450.         );
  451.     }
  452.     private function getPriceFilter(Request $request): Filter
  453.     {
  454.         $min $request->get('min-price');
  455.         $max $request->get('max-price');
  456.         $range = [];
  457.         if ($min !== null && $min >= 0) {
  458.             $range[RangeFilter::GTE] = $min;
  459.         }
  460.         if ($max !== null && $max >= 0) {
  461.             $range[RangeFilter::LTE] = $max;
  462.         }
  463.         return new Filter(
  464.             'price',
  465.             !empty($range),
  466.             [new StatsAggregation('price''product.cheapestPrice'truetruefalsefalse)],
  467.             new RangeFilter('product.cheapestPrice'$range),
  468.             [
  469.                 'min' => (float) $request->get('min-price'),
  470.                 'max' => (float) $request->get('max-price'),
  471.             ]
  472.         );
  473.     }
  474.     private function getRatingFilter(Request $request): Filter
  475.     {
  476.         $filtered $request->get('rating');
  477.         return new Filter(
  478.             'rating',
  479.             $filtered !== null,
  480.             [
  481.                 new FilterAggregation(
  482.                     'rating-exists',
  483.                     new MaxAggregation('rating''product.ratingAverage'),
  484.                     [new RangeFilter('product.ratingAverage', [RangeFilter::GTE => 0])]
  485.                 ),
  486.             ],
  487.             new RangeFilter('product.ratingAverage', [
  488.                 RangeFilter::GTE => (int) $filtered,
  489.             ]),
  490.             $filtered
  491.         );
  492.     }
  493.     private function getShippingFreeFilter(Request $request): Filter
  494.     {
  495.         $filtered = (bool) $request->get('shipping-free'false);
  496.         return new Filter(
  497.             'shipping-free',
  498.             $filtered === true,
  499.             [
  500.                 new FilterAggregation(
  501.                     'shipping-free-filter',
  502.                     new MaxAggregation('shipping-free''product.shippingFree'),
  503.                     [new EqualsFilter('product.shippingFree'true)]
  504.                 ),
  505.             ],
  506.             new EqualsFilter('product.shippingFree'true),
  507.             $filtered
  508.         );
  509.     }
  510. }