<?php declare(strict_types=1);
namespace Shopware\Core\Content\Rule\DataAbstractionLayer;
use Doctrine\DBAL\Connection;
use Shopware\Core\Checkout\Cart\CachedRuleLoader;
use Shopware\Core\Content\Rule\RuleDefinition;
use Shopware\Core\Framework\Adapter\Cache\CacheInvalidator;
use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\EntityDefinitionQueryHelper;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\QueryBuilder;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\FetchModeHelper;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\RetryableQuery;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\RuleAreas;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\ChangeSetAware;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\DeleteCommand;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\PreWriteValidationEvent;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Rule\Collector\RuleConditionRegistry;
use Shopware\Core\Framework\Uuid\Uuid;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* @internal
*/
#[Package('business-ops')]
class RuleAreaUpdater implements EventSubscriberInterface
{
private Connection $connection;
private RuleDefinition $definition;
private RuleConditionRegistry $conditionRegistry;
private CacheInvalidator $cacheInvalidator;
/**
* @internal
*/
public function __construct(
Connection $connection,
RuleDefinition $definition,
RuleConditionRegistry $conditionRegistry,
CacheInvalidator $cacheInvalidator
) {
$this->connection = $connection;
$this->definition = $definition;
$this->conditionRegistry = $conditionRegistry;
$this->cacheInvalidator = $cacheInvalidator;
}
public static function getSubscribedEvents(): array
{
return [
PreWriteValidationEvent::class => 'triggerChangeSet',
EntityWrittenContainerEvent::class => 'onEntityWritten',
];
}
public function triggerChangeSet(PreWriteValidationEvent $event): void
{
$associatedEntities = $this->getAssociationEntities();
foreach ($event->getCommands() as $command) {
$definition = $command->getDefinition();
$entity = $definition->getEntityName();
if (!$command instanceof ChangeSetAware || !\in_array($entity, $associatedEntities, true)) {
continue;
}
if ($command instanceof DeleteCommand) {
$command->requestChangeSet();
continue;
}
foreach ($this->getForeignKeyFields($definition) as $field) {
if ($command->hasField($field->getStorageName())) {
$command->requestChangeSet();
}
}
}
}
public function onEntityWritten(EntityWrittenContainerEvent $event): void
{
$associationFields = $this->getAssociationFields();
$ruleIds = [];
foreach ($event->getEvents() ?? [] as $nestedEvent) {
if (!$nestedEvent instanceof EntityWrittenEvent) {
continue;
}
$definition = $this->getAssociationDefinitionByEntity($associationFields, $nestedEvent->getEntityName());
if (!$definition) {
continue;
}
$ruleIds = $this->hydrateRuleIds($this->getForeignKeyFields($definition), $nestedEvent, $ruleIds);
}
if (empty($ruleIds)) {
return;
}
$this->update(Uuid::fromBytesToHexList(array_unique(array_filter($ruleIds))));
$this->cacheInvalidator->invalidate([CachedRuleLoader::CACHE_KEY]);
}
/**
* @param list<string> $ids
*/
public function update(array $ids): void
{
$associationFields = $this->getAssociationFields();
$areas = $this->getAreas($ids, $associationFields);
$update = new RetryableQuery(
$this->connection,
$this->connection->prepare('UPDATE `rule` SET `areas` = :areas WHERE `id` = :id')
);
/** @var array<string, string[]> $associations */
foreach ($areas as $id => $associations) {
$areas = [];
foreach ($associations as $propertyName => $match) {
if ((bool) $match === false) {
continue;
}
if ($propertyName === 'flowCondition') {
$areas = array_unique(array_merge($areas, [RuleAreas::FLOW_CONDITION_AREA]));
continue;
}
$field = $associationFields->get($propertyName);
if (!$field || !$flag = $field->getFlag(RuleAreas::class)) {
continue;
}
$areas = array_unique(array_merge($areas, $flag instanceof RuleAreas ? $flag->getAreas() : []));
}
$update->execute([
'areas' => json_encode(array_values($areas)),
'id' => Uuid::fromHexToBytes($id),
]);
}
}
/**
* @param FkField[] $fields
* @param string[] $ruleIds
*
* @return string[]
*/
private function hydrateRuleIds(array $fields, EntityWrittenEvent $nestedEvent, array $ruleIds): array
{
foreach ($nestedEvent->getWriteResults() as $result) {
$changeSet = $result->getChangeSet();
$payload = $result->getPayload();
foreach ($fields as $field) {
if ($changeSet && $changeSet->hasChanged($field->getStorageName())) {
$ruleIds[] = $changeSet->getBefore($field->getStorageName());
$ruleIds[] = $changeSet->getAfter($field->getStorageName());
}
if ($changeSet) {
continue;
}
if (!empty($payload[$field->getPropertyName()])) {
$ruleIds[] = Uuid::fromHexToBytes($payload[$field->getPropertyName()]);
}
}
}
return $ruleIds;
}
/**
* @param list<string> $ids
*
* @return array<string, string[][]>
*/
private function getAreas(array $ids, CompiledFieldCollection $associationFields): array
{
$query = new QueryBuilder($this->connection);
$query->select('LOWER(HEX(`rule`.`id`)) AS array_key')
->from('rule')
->andWhere('`rule`.`id` IN (:ids)');
/** @var AssociationField $associationField */
foreach ($associationFields->getElements() as $associationField) {
$this->addSelect($query, $associationField);
}
$this->addFlowConditionSelect($query);
$query->setParameter(
'ids',
Uuid::fromHexToBytesList($ids),
Connection::PARAM_STR_ARRAY
)->setParameter(
'flowTypes',
$this->conditionRegistry->getFlowRuleNames(),
Connection::PARAM_STR_ARRAY
);
return FetchModeHelper::groupUnique($query->executeQuery()->fetchAllAssociative());
}
private function addSelect(QueryBuilder $query, AssociationField $associationField): void
{
$template = 'EXISTS(%s) AS %s';
$propertyName = $associationField->getPropertyName();
if ($associationField instanceof OneToOneAssociationField || $associationField instanceof ManyToOneAssociationField) {
$template = 'IF(%s.%s IS NOT NULL, 1, 0) AS %s';
$query->addSelect(sprintf($template, '`rule`', $this->escape($associationField->getStorageName()), $propertyName));
return;
}
if ($associationField instanceof ManyToManyAssociationField) {
$mappingTable = $this->escape($associationField->getMappingDefinition()->getEntityName());
$mappingLocalColumn = $this->escape($associationField->getMappingLocalColumn());
$localColumn = $this->escape($associationField->getLocalField());
$subQuery = (new QueryBuilder($this->connection))
->select('1')
->from($mappingTable)
->andWhere(sprintf('%s = `rule`.%s', $mappingLocalColumn, $localColumn));
$query->addSelect(sprintf($template, $subQuery->getSQL(), $propertyName));
return;
}
if ($associationField instanceof OneToManyAssociationField) {
$referenceTable = $this->escape($associationField->getReferenceDefinition()->getEntityName());
$referenceColumn = $this->escape($associationField->getReferenceField());
$localColumn = $this->escape($associationField->getLocalField());
$subQuery = (new QueryBuilder($this->connection))
->select('1')
->from($referenceTable)
->andWhere(sprintf('%s = `rule`.%s', $referenceColumn, $localColumn));
$query->addSelect(sprintf($template, $subQuery->getSQL(), $propertyName));
}
}
private function addFlowConditionSelect(QueryBuilder $query): void
{
$subQuery = (new QueryBuilder($this->connection))
->select('1')
->from('rule_condition')
->andWhere('`rule_id` = `rule`.`id`')
->andWhere('`type` IN (:flowTypes)');
$query->addSelect(sprintf('EXISTS(%s) AS flowCondition', $subQuery->getSQL()));
}
private function escape(string $string): string
{
return EntityDefinitionQueryHelper::escape($string);
}
private function getAssociationFields(): CompiledFieldCollection
{
return $this->definition
->getFields()
->filterByFlag(RuleAreas::class);
}
/**
* @return FkField[]
*/
private function getForeignKeyFields(EntityDefinition $definition): array
{
/** @var FkField[] $fields */
$fields = $definition->getFields()->filterInstance(FkField::class)->filter(function (FkField $fk): bool {
return $fk->getReferenceDefinition()->getEntityName() === $this->definition->getEntityName();
})->getElements();
return $fields;
}
/**
* @return string[]
*/
private function getAssociationEntities(): array
{
return $this->getAssociationFields()->filter(function (AssociationField $associationField): bool {
return $associationField instanceof OneToManyAssociationField;
})->map(function (AssociationField $field): string {
return $field->getReferenceDefinition()->getEntityName();
});
}
private function getAssociationDefinitionByEntity(CompiledFieldCollection $collection, string $entityName): ?EntityDefinition
{
/** @var AssociationField|null $field */
$field = $collection->filter(function (AssociationField $associationField) use ($entityName): bool {
if (!$associationField instanceof OneToManyAssociationField) {
return false;
}
return $associationField->getReferenceDefinition()->getEntityName() === $entityName;
})->first();
return $field ? $field->getReferenceDefinition() : null;
}
}