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

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