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

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