vendor/shopware/core/Content/Product/SalesChannel/Listing/ProductListingFeaturesSubscriber.php line 104

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