<?php declare(strict_types=1);
namespace Maxia\MaxiaVariantsTable6\Storefront\Subscriber;
use Doctrine\DBAL\Connection;
use Maxia\MaxiaVariantsTable6\Core\Content\VariantsTable\Column\DataMapping\ProductOptionMapping;
use Maxia\MaxiaVariantsTable6\Core\Content\VariantsTable\Column\DataMapping\ProductOptionsMapping;
use Maxia\MaxiaVariantsTable6\Core\Content\VariantsTable\Column\DataMapping\ProductPropertiesMapping;
use Maxia\MaxiaVariantsTable6\Core\Content\VariantsTable\Column\DataMapping\ProductPropertyMapping;
use Maxia\MaxiaVariantsTable6\Core\Content\VariantsTable\Events\VariantsTableCollectFilterEvent;
use Maxia\MaxiaVariantsTable6\Core\Content\VariantsTable\Events\VariantsTableSearchCriteriaEvent;
use Maxia\MaxiaVariantsTable6\Core\Content\VariantsTable\VariantsTableEntity;
use Maxia\MaxiaVariantsTable6\Storefront\Pagelet\VariantsTable\Events\VariantsTablePageletLoadedEvent;
use Maxia\MaxiaVariantsTable6\Storefront\Pagelet\VariantsTable\VariantsTablePagelet;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Content\Product\SalesChannel\Listing\Filter;
use Shopware\Core\Content\Product\SalesChannel\Listing\FilterCollection;
use Shopware\Core\Content\Product\SalesChannel\Listing\ProductListingFeaturesSubscriber;
use Shopware\Core\Content\Property\Aggregate\PropertyGroupOption\PropertyGroupOptionCollection;
use Shopware\Core\Content\Property\Aggregate\PropertyGroupOption\PropertyGroupOptionEntity;
use Shopware\Core\Content\Property\PropertyGroupEntity;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\Common\RepositoryIterator;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\FetchModeHelper;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\FilterAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\TermsAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\MaxAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\MinAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Bucket\TermsResult;
use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Metric\EntityResult;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\PlatformRequest;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Extends the search criteria and loads filter values
*
* @package Maxia\MaxiaVariantsTable6\Subscriber
*/
class VariantsTableFilterSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
VariantsTableSearchCriteriaEvent::class => 'handleSearchCriteria',
VariantsTablePageletLoadedEvent::class => 'handleResult'
];
}
private SystemConfigService $systemConfig;
private EntityRepositoryInterface $optionRepository;
private Connection $connection;
private EventDispatcherInterface $dispatcher;
public function __construct(
SystemConfigService $systemConfig,
EntityRepositoryInterface $optionRepository,
Connection $connection,
EventDispatcherInterface $dispatcher)
{
$this->systemConfig = $systemConfig;
$this->optionRepository = $optionRepository;
$this->connection = $connection;
$this->dispatcher = $dispatcher;
}
public function handleSearchCriteria(VariantsTableSearchCriteriaEvent $event): void
{
$request = $event->getRequest();
$criteria = $event->getCriteria();
$context = $event->getSalesChannelContext();
$criteria->addAssociation('options');
$this->handleFilters($request, $event->getVariantsTableEntity(), $criteria, $context);
}
public function handleResult(VariantsTablePageletLoadedEvent $event): void
{
if (!$event->getPagelet()->getConfig()->isShowFilters()) {
return;
}
$this->addCurrentFilters($event);
if ($event->getRequest()->attributes->get('_route') === 'frontend.plugins.maxia-variants-table.ajax') {
return;
}
$this->groupOptionAggregations($event);
}
private function handleFilters(Request $request, VariantsTableEntity $variantsTable, Criteria $criteria, SalesChannelContext $context): void
{
$filters = $this->getFilters($request, $variantsTable, $context);
$aggregations = $this->getAggregations($request, $filters);
foreach ($aggregations as $aggregation) {
$criteria->addAggregation($aggregation);
}
foreach ($filters as $filter) {
if ($filter->isFiltered()) {
$criteria->addFilter($filter->getFilter());
}
}
$criteria->addExtension('filters', $filters);
}
private function getAggregations(Request $request, FilterCollection $filters): array
{
$aggregations = [];
if ($request->get('reduce-aggregations') === null) {
foreach ($filters as $filter) {
$aggregations = array_merge($aggregations, $filter->getAggregations());
}
return $aggregations;
}
foreach ($filters as $filter) {
$excluded = $filters->filtered();
if ($filter->exclude()) {
$excluded = $excluded->blacklist($filter->getName());
}
foreach ($filter->getAggregations() as $aggregation) {
if ($aggregation instanceof FilterAggregation) {
$aggregation->addFilters($excluded->getFilters());
$aggregations[] = $aggregation;
continue;
}
$aggregation = new FilterAggregation(
$aggregation->getName(),
$aggregation,
$excluded->getFilters()
);
$aggregations[] = $aggregation;
}
}
return $aggregations;
}
private function collectOptionIds(VariantsTablePageletLoadedEvent $event): array
{
$aggregations = $event->getPagelet()->getSearchResult()->getAggregations();
/** @var TermsResult|null $properties */
$properties = $aggregations->get('properties');
/** @var TermsResult|null $options */
$options = $aggregations->get('options');
$options = $options ? $options->getKeys() : [];
$properties = $properties ? $properties->getKeys() : [];
return array_unique(array_filter(array_merge($options, $properties)));
}
/**
* Returns option IDs mapped by property_group IDs for all variant options of the product.
*
* @param ProductEntity $parentProduct
* @return array
* @throws \Doctrine\DBAL\Exception
*/
private function getVariantPropertyIds(ProductEntity $parentProduct)
{
$results = $this->connection->fetchAllAssociative("
SELECT
`property`.property_group_id, `property`.id
FROM
`product`
LEFT JOIN
`product_option` AS `option` ON `option`.product_id = `product`.id
LEFT JOIN
`property_group_option` `property` ON `property`.id = `option`.property_group_option_id
WHERE
`product`.parent_id = :parentId
AND `option`.product_version_id = :versionId
GROUP BY `property`.id
",
[
'parentId' => Uuid::fromHexToBytes($parentProduct->getId()),
'versionId' => Uuid::fromHexToBytes($parentProduct->getVersionId())
],
);
$options = [];
foreach ($results as $result) {
$groupId = Uuid::fromBytesToHex($result['property_group_id']);
$optionId = Uuid::fromBytesToHex($result['id']);
if (!isset($options[$groupId])) {
$options[$groupId] = [];
}
$options[$groupId][] = $optionId;
}
return $options;
}
private function groupOptionAggregations(VariantsTablePageletLoadedEvent $event): void
{
$ids = $this->collectOptionIds($event);
if (empty($ids)) {
return;
}
$criteria = new Criteria($ids);
$criteria->setLimit(500);
$criteria->addAssociation('group');
$criteria->addAssociation('media');
$criteria->setTitle('product-listing::property-filter');
$criteria->addSorting(new FieldSorting('id', FieldSorting::ASCENDING));
$filterGroupIds = $this->getFilterablePropertyGroupIds($event->getPagelet());
if ($filterGroupIds) {
$criteria->addFilter(new EqualsAnyFilter('group.id', $filterGroupIds));
}
$mergedOptions = new PropertyGroupOptionCollection();
$repositoryIterator = new RepositoryIterator($this->optionRepository, $event->getContext(), $criteria);
while (($result = $repositoryIterator->fetch()) !== null) {
$mergedOptions->merge($result->getEntities());
}
// filter out options that are not associated with any variant, if setting 'mergeVariantOptions' is disabled
$filterConfig = $event->getPagelet()->getConfig()->getFilterConfig();
if ($filterConfig && isset($filterConfig['mergeVariantOptions']) && !$filterConfig['mergeVariantOptions']) {
$variantOptions = $this->getVariantPropertyIds($event->getPagelet()->getParentProduct());
$mergedOptions = $mergedOptions->filter(function (PropertyGroupOptionEntity $property) use ($variantOptions) {
if (!isset($variantOptions[$property->getGroupId()])) {
return false;
}
return in_array($property->getId(), $variantOptions[$property->getGroupId()]);
});
}
// group options by their property-group
$grouped = $mergedOptions->groupByPropertyGroups();
$grouped->sortByPositions();
$grouped->sortByConfig();
$aggregations = $event->getPagelet()->getSearchResult()->getAggregations();
// remove id results to prevent wrong usages
$aggregations->remove('properties');
$aggregations->remove('configurators');
$aggregations->remove('options');
$aggregations->add(new EntityResult('properties', $grouped));
}
private function addCurrentFilters(VariantsTablePageletLoadedEvent $event): void
{
$result = $event->getPagelet()->getSearchResult();
$filters = $result->getCriteria()->getExtension('filters');
if (!$filters instanceof FilterCollection) {
return;
}
foreach ($filters as $filter) {
$event->getPagelet()->getSearchResult()->addCurrentFilter($filter->getName(), $filter->getValues());
}
}
private function getPropertyIdsFromRequest(Request $request): array
{
$ids = $request->query->get('properties', '');
if ($request->isMethod(Request::METHOD_POST)) {
$ids = $request->request->get('properties', '');
}
if (\is_string($ids)) {
$ids = explode('|', $ids);
}
return array_filter((array) $ids);
}
protected function getFilters(Request $request, VariantsTableEntity $variantsTable, SalesChannelContext $context): FilterCollection
{
$filters = new FilterCollection();
$filters->add($this->getAvailableFilter($request, $variantsTable));
if ($variantsTable->isShowFilters()) {
$filters->add($this->getPropertyFilter($request));
if (!$request->request->get('available-filter', true)) {
$filters->remove('available-free');
}
if (!$request->request->get('property-filter', true)) {
$filters->remove('properties');
if (\count($propertyWhitelist = $request->request->all(ProductListingFeaturesSubscriber::PROPERTY_GROUP_IDS_REQUEST_PARAM))) {
$filters->add($this->getPropertyFilter($request, $propertyWhitelist));
}
}
}
$event = new VariantsTableCollectFilterEvent($request, $filters, $context);
$this->dispatcher->dispatch($event);
return $filters;
}
protected function getPropertyFilter(Request $request, ?array $groupIds = null): Filter
{
$ids = $this->getPropertyIdsFromRequest($request);
$propertyAggregation = new TermsAggregation('properties', 'product.properties.id');
$optionAggregation = new TermsAggregation('options', 'product.options.id');
if ($groupIds) {
$optionAggregation = new FilterAggregation(
'options-filter',
$optionAggregation,
[new EqualsAnyFilter('product.options.groupId', $groupIds)]
);
$propertyAggregation = new FilterAggregation(
'properties-filter',
$propertyAggregation,
[new EqualsAnyFilter('product.properties.groupId', $groupIds)]
);
}
if (empty($ids)) {
return new Filter(
'properties',
false,
[$propertyAggregation, $optionAggregation],
new MultiFilter(MultiFilter::CONNECTION_OR, []),
[],
false
);
}
$grouped = $this->connection->fetchAll(
'SELECT LOWER(HEX(property_group_id)) as property_group_id, LOWER(HEX(id)) as id
FROM property_group_option
WHERE id IN (:ids)',
['ids' => Uuid::fromHexToBytesList($ids)],
['ids' => Connection::PARAM_STR_ARRAY]
);
$grouped = FetchModeHelper::group($grouped);
$filters = [];
foreach ($grouped as $options) {
$options = array_column($options, 'id');
$filters[] = new MultiFilter(
MultiFilter::CONNECTION_OR,
[
new EqualsAnyFilter('product.optionIds', $options),
new EqualsAnyFilter('product.propertyIds', $options),
]
);
}
return new Filter(
'properties',
true,
[$propertyAggregation, $optionAggregation],
new MultiFilter(MultiFilter::CONNECTION_AND, $filters),
$ids,
false
);
}
protected function getAvailableFilter(Request $request, VariantsTableEntity $variantsTableEntity): Filter
{
/** @var SalesChannelContext $context */
$salesChannelContext = $request->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT);
$availableField = 'product.available';
if ($this->systemConfig->get('DvsnStoreStock.config.status', $salesChannelContext->getSalesChannelId())) {
$translated = $salesChannelContext->getSalesChannel()->getTranslated();
if (isset($translated['customFields'])
&& isset($translated['customFields']['dvsn_store_stock_sales_channel_status'])
&& $translated['customFields']['dvsn_store_stock_sales_channel_status'])
{
$storeId = $translated['customFields']['dvsn_store_stock_sales_channel_status_store'];
$availableField = 'customFields.dvsn_store_stock."' . $storeId . '".available';
}
}
return $this->createAvailableFilter($request, $variantsTableEntity, $availableField);
}
/**
* Creates the show or hide unavailable products filter, depending on the plugin setting "isShowUnavailable"
*
* @param Request $request
* @param VariantsTableEntity $variantsTableEntity
* @param string $field
* @return Filter
*/
protected function createAvailableFilter(
Request $request,
VariantsTableEntity $variantsTableEntity,
string $field = 'product.available'
) {
$checked = (bool) $request->get('available', false);
if ($variantsTableEntity->isShowUnavailable()) {
return new Filter(
'available',
$checked,
[
new FilterAggregation(
'available-filter',
new MaxAggregation('available', $field),
[new EqualsFilter($field, true)]
),
],
new EqualsFilter($field, true),
$checked
);
} else {
return new Filter(
'available',
!$checked,
[
new FilterAggregation(
'available-filter',
new MinAggregation('available', $field),
[new EqualsFilter($field, false)]
),
],
new EqualsFilter($field, true),
$checked
);
}
}
/**
* Returns filterable property group ids by reading the filter config
*
* @param VariantsTablePagelet $pagelet
* @return array
*/
protected function getFilterablePropertyGroupIds(VariantsTablePagelet $pagelet)
{
$filterGroupIds = [];
$filterConfig = $pagelet->getConfig()->getFilterConfig();
if ($filterConfig['displayMode'] === 'selected-properties') {
if ($filterConfig['selectedProperties']) {
$filterGroupIds = $filterConfig['selectedProperties'];
} else {
$filterConfig['displayMode'] = 'auto';
$pagelet->getConfig()->setFilterConfig($filterConfig);
}
}
if ($filterConfig['displayMode'] === 'auto') {
$filterGroupIds = $this->getPropertyGroupIdsFromTableColumns($pagelet);
if (!$filterGroupIds) {
$filterConfig['displayMode'] = 'all';
$pagelet->getConfig()->setFilterConfig($filterConfig);
}
}
return $filterGroupIds;
}
/**
* Returns property group ids for all columns in the table
*
* @param VariantsTablePagelet $pagelet
* @return array
*/
protected function getPropertyGroupIdsFromTableColumns(VariantsTablePagelet $pagelet)
{
$groupIds = [];
foreach ($pagelet->getConfig()->getColumns() as $column) {
if ($column->getDataMappingClass() === ProductOptionMapping::class) {
if ($column->getConfig() && $column->getConfig()['groupId']) {
$groupIds[] = $column->getConfig()['groupId'];
}
} else if ($column->getDataMappingClass() == ProductPropertyMapping::class) {
if ($column->getConfig() && $column->getConfig()['propertyGroupId']) {
$groupIds[] = $column->getConfig()['propertyGroupId'];
}
} else if ($column->getDataMappingClass() === ProductOptionsMapping::class) {
$ids = $pagelet->getParentProduct()->getOptions()->map(function (PropertyGroupOptionEntity $entity) {
return $entity->getGroupId();
});
$groupIds = array_merge($groupIds, array_unique($ids));
} else if ($column->getDataMappingClass() === ProductPropertiesMapping::class) {
$ids = $pagelet->getParentProduct()->getSortedProperties()->map(function (PropertyGroupEntity $entity) {
return $entity->getId();
});
$groupIds = array_merge($groupIds, array_unique($ids));
}
}
return array_unique($groupIds);
}
}