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

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