src/Core/System/SalesChannel/Validation/SalesChannelValidator.php line 53

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\System\SalesChannel\Validation;
  3. use Doctrine\DBAL\Connection;
  4. use Shopware\Core\Defaults;
  5. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\DeleteCommand;
  6. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\InsertCommand;
  7. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\UpdateCommand;
  8. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommand;
  9. use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\PreWriteValidationEvent;
  10. use Shopware\Core\Framework\Log\Package;
  11. use Shopware\Core\Framework\Uuid\Uuid;
  12. use Shopware\Core\Framework\Validation\WriteConstraintViolationException;
  13. use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelLanguage\SalesChannelLanguageDefinition;
  14. use Shopware\Core\System\SalesChannel\SalesChannelDefinition;
  15. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  16. use Symfony\Component\Validator\ConstraintViolation;
  17. use Symfony\Component\Validator\ConstraintViolationList;
  18. /**
  19.  * @internal
  20.  */
  21. #[Package('sales-channel')]
  22. class SalesChannelValidator implements EventSubscriberInterface
  23. {
  24.     private const INSERT_VALIDATION_MESSAGE 'The sales channel with id "%s" does not have a default sales channel language id in the language list.';
  25.     private const INSERT_VALIDATION_CODE 'SYSTEM__NO_GIVEN_DEFAULT_LANGUAGE_ID';
  26.     private const DUPLICATED_ENTRY_VALIDATION_MESSAGE 'The sales channel language "%s" for the sales channel "%s" already exists.';
  27.     private const DUPLICATED_ENTRY_VALIDATION_CODE 'SYSTEM__DUPLICATED_SALES_CHANNEL_LANGUAGE';
  28.     private const UPDATE_VALIDATION_MESSAGE 'Cannot update default language id because the given id is not in the language list of sales channel with id "%s"';
  29.     private const UPDATE_VALIDATION_CODE 'SYSTEM__CANNOT_UPDATE_DEFAULT_LANGUAGE_ID';
  30.     private const DELETE_VALIDATION_MESSAGE 'Cannot delete default language id from language list of the sales channel with id "%s".';
  31.     private const DELETE_VALIDATION_CODE 'SYSTEM__CANNOT_DELETE_DEFAULT_LANGUAGE_ID';
  32.     /**
  33.      * @internal
  34.      */
  35.     public function __construct(private readonly Connection $connection)
  36.     {
  37.     }
  38.     public static function getSubscribedEvents(): array
  39.     {
  40.         return [
  41.             PreWriteValidationEvent::class => 'handleSalesChannelLanguageIds',
  42.         ];
  43.     }
  44.     public function handleSalesChannelLanguageIds(PreWriteValidationEvent $event): void
  45.     {
  46.         $mapping $this->extractMapping($event);
  47.         if (!$mapping) {
  48.             return;
  49.         }
  50.         $salesChannelIds array_keys($mapping);
  51.         $states $this->fetchCurrentLanguageStates($salesChannelIds);
  52.         $mapping $this->mergeCurrentStatesWithMapping($mapping$states);
  53.         $this->validateLanguages($mapping$event);
  54.     }
  55.     /**
  56.      * Build a key map with the following data structure:
  57.      *
  58.      * 'sales_channel_id' => [
  59.      *     'current_default' => 'en',
  60.      *     'new_default' => 'de',
  61.      *     'inserts' => ['de', 'en'],
  62.      *     'updates' => ['de', 'de'],
  63.      *     'deletions' => ['gb'],
  64.      *     'state' => ['en', 'gb']
  65.      * ]
  66.      *
  67.      * @return array<string, array<string, list<string>>>
  68.      */
  69.     private function extractMapping(PreWriteValidationEvent $event): array
  70.     {
  71.         $mapping = [];
  72.         foreach ($event->getCommands() as $command) {
  73.             if ($command->getDefinition() instanceof SalesChannelDefinition) {
  74.                 $this->handleSalesChannelMapping($mapping$command);
  75.                 continue;
  76.             }
  77.             if ($command->getDefinition() instanceof SalesChannelLanguageDefinition) {
  78.                 $this->handleSalesChannelLanguageMapping($mapping$command);
  79.             }
  80.         }
  81.         return $mapping;
  82.     }
  83.     /**
  84.      * @param array<string, array<string, list<string>>> $mapping
  85.      */
  86.     private function handleSalesChannelMapping(array &$mappingWriteCommand $command): void
  87.     {
  88.         if (!isset($command->getPayload()['language_id'])) {
  89.             return;
  90.         }
  91.         if ($command instanceof UpdateCommand) {
  92.             $id Uuid::fromBytesToHex($command->getPrimaryKey()['id']);
  93.             $mapping[$id]['updates'] = Uuid::fromBytesToHex($command->getPayload()['language_id']);
  94.             return;
  95.         }
  96.         if (!$command instanceof InsertCommand || !$this->isSupportedSalesChannelType($command)) {
  97.             return;
  98.         }
  99.         $id Uuid::fromBytesToHex($command->getPrimaryKey()['id']);
  100.         $mapping[$id]['new_default'] = Uuid::fromBytesToHex($command->getPayload()['language_id']);
  101.         $mapping[$id]['inserts'] = [];
  102.         $mapping[$id]['state'] = [];
  103.     }
  104.     private function isSupportedSalesChannelType(WriteCommand $command): bool
  105.     {
  106.         $typeId Uuid::fromBytesToHex($command->getPayload()['type_id']);
  107.         return $typeId === Defaults::SALES_CHANNEL_TYPE_STOREFRONT
  108.             || $typeId === Defaults::SALES_CHANNEL_TYPE_API;
  109.     }
  110.     /**
  111.      * @param array<string, list<string>> $mapping
  112.      */
  113.     private function handleSalesChannelLanguageMapping(array &$mappingWriteCommand $command): void
  114.     {
  115.         $language Uuid::fromBytesToHex($command->getPrimaryKey()['language_id']);
  116.         $id Uuid::fromBytesToHex($command->getPrimaryKey()['sales_channel_id']);
  117.         $mapping[$id]['state'] = [];
  118.         if ($command instanceof DeleteCommand) {
  119.             $mapping[$id]['deletions'][] = $language;
  120.             return;
  121.         }
  122.         if ($command instanceof InsertCommand) {
  123.             $mapping[$id]['inserts'][] = $language;
  124.         }
  125.     }
  126.     /**
  127.      * @param array<string, list<string>> $mapping
  128.      */
  129.     private function validateLanguages(array $mappingPreWriteValidationEvent $event): void
  130.     {
  131.         $inserts = [];
  132.         $duplicates = [];
  133.         $deletions = [];
  134.         $updates = [];
  135.         foreach ($mapping as $id => $channel) {
  136.             if (isset($channel['inserts'])) {
  137.                 if (!$this->validInsertCase($channel)) {
  138.                     $inserts[$id] = $channel['new_default'];
  139.                 }
  140.                 $duplicatedIds $this->getDuplicates($channel);
  141.                 if ($duplicatedIds) {
  142.                     $duplicates[$id] = $duplicatedIds;
  143.                 }
  144.             }
  145.             if (isset($channel['deletions']) && !$this->validDeleteCase($channel)) {
  146.                 $deletions[$id] = $channel['current_default'];
  147.             }
  148.             if (isset($channel['updates']) && !$this->validUpdateCase($channel)) {
  149.                 $updates[$id] = $channel['updates'];
  150.             }
  151.         }
  152.         $this->writeInsertViolationExceptions($inserts$event);
  153.         $this->writeDuplicateViolationExceptions($duplicates$event);
  154.         $this->writeDeleteViolationExceptions($deletions$event);
  155.         $this->writeUpdateViolationExceptions($updates$event);
  156.     }
  157.     /**
  158.      * @param array<string, mixed> $channel
  159.      */
  160.     private function validInsertCase(array $channel): bool
  161.     {
  162.         return empty($channel['new_default'])
  163.             || \in_array($channel['new_default'], $channel['inserts'], true);
  164.     }
  165.     /**
  166.      * @param array<string, mixed> $channel
  167.      */
  168.     private function validUpdateCase(array $channel): bool
  169.     {
  170.         $updateId $channel['updates'];
  171.         return \in_array($updateId$channel['state'], true)
  172.             || empty($channel['new_default']) && $updateId === $channel['current_default']
  173.             || isset($channel['inserts']) && \in_array($updateId$channel['inserts'], true);
  174.     }
  175.     /**
  176.      * @param array<string, mixed> $channel
  177.      */
  178.     private function validDeleteCase(array $channel): bool
  179.     {
  180.         return !\in_array($channel['current_default'], $channel['deletions'], true);
  181.     }
  182.     /**
  183.      * @param array<string, mixed> $channel
  184.      *
  185.      * @return array<string, mixed>
  186.      */
  187.     private function getDuplicates(array $channel): array
  188.     {
  189.         return array_intersect($channel['state'], $channel['inserts']);
  190.     }
  191.     /**
  192.      * @param array<string, mixed> $inserts
  193.      */
  194.     private function writeInsertViolationExceptions(array $insertsPreWriteValidationEvent $event): void
  195.     {
  196.         if (!$inserts) {
  197.             return;
  198.         }
  199.         $violations = new ConstraintViolationList();
  200.         $salesChannelIds array_keys($inserts);
  201.         foreach ($salesChannelIds as $id) {
  202.             $violations->add(new ConstraintViolation(
  203.                 sprintf(self::INSERT_VALIDATION_MESSAGE$id),
  204.                 sprintf(self::INSERT_VALIDATION_MESSAGE'{{ salesChannelId }}'),
  205.                 ['{{ salesChannelId }}' => $id],
  206.                 null,
  207.                 '/',
  208.                 null,
  209.                 null,
  210.                 self::INSERT_VALIDATION_CODE
  211.             ));
  212.         }
  213.         $this->writeViolationException($violations$event);
  214.     }
  215.     /**
  216.      * @param array<string, mixed> $duplicates
  217.      */
  218.     private function writeDuplicateViolationExceptions(array $duplicatesPreWriteValidationEvent $event): void
  219.     {
  220.         if (!$duplicates) {
  221.             return;
  222.         }
  223.         $violations = new ConstraintViolationList();
  224.         foreach ($duplicates as $id => $duplicateLanguages) {
  225.             foreach ($duplicateLanguages as $languageId) {
  226.                 $violations->add(new ConstraintViolation(
  227.                     sprintf(self::DUPLICATED_ENTRY_VALIDATION_MESSAGE$languageId$id),
  228.                     sprintf(self::DUPLICATED_ENTRY_VALIDATION_MESSAGE'{{ languageId }}''{{ salesChannelId }}'),
  229.                     [
  230.                         '{{ salesChannelId }}' => $id,
  231.                         '{{ languageId }}' => $languageId,
  232.                     ],
  233.                     null,
  234.                     '/',
  235.                     null,
  236.                     null,
  237.                     self::DUPLICATED_ENTRY_VALIDATION_CODE
  238.                 ));
  239.             }
  240.         }
  241.         $this->writeViolationException($violations$event);
  242.     }
  243.     /**
  244.      * @param array<string, mixed> $deletions
  245.      */
  246.     private function writeDeleteViolationExceptions(array $deletionsPreWriteValidationEvent $event): void
  247.     {
  248.         if (!$deletions) {
  249.             return;
  250.         }
  251.         $violations = new ConstraintViolationList();
  252.         $salesChannelIds array_keys($deletions);
  253.         foreach ($salesChannelIds as $id) {
  254.             $violations->add(new ConstraintViolation(
  255.                 sprintf(self::DELETE_VALIDATION_MESSAGE$id),
  256.                 sprintf(self::DELETE_VALIDATION_MESSAGE'{{ salesChannelId }}'),
  257.                 ['{{ salesChannelId }}' => $id],
  258.                 null,
  259.                 '/',
  260.                 null,
  261.                 null,
  262.                 self::DELETE_VALIDATION_CODE
  263.             ));
  264.         }
  265.         $this->writeViolationException($violations$event);
  266.     }
  267.     /**
  268.      * @param array<string, mixed> $updates
  269.      */
  270.     private function writeUpdateViolationExceptions(array $updatesPreWriteValidationEvent $event): void
  271.     {
  272.         if (!$updates) {
  273.             return;
  274.         }
  275.         $violations = new ConstraintViolationList();
  276.         $salesChannelIds array_keys($updates);
  277.         foreach ($salesChannelIds as $id) {
  278.             $violations->add(new ConstraintViolation(
  279.                 sprintf(self::UPDATE_VALIDATION_MESSAGE$id),
  280.                 sprintf(self::UPDATE_VALIDATION_MESSAGE'{{ salesChannelId }}'),
  281.                 ['{{ salesChannelId }}' => $id],
  282.                 null,
  283.                 '/',
  284.                 null,
  285.                 null,
  286.                 self::UPDATE_VALIDATION_CODE
  287.             ));
  288.         }
  289.         $this->writeViolationException($violations$event);
  290.     }
  291.     /**
  292.      * @param array<string> $salesChannelIds
  293.      *
  294.      * @return array<string, string>
  295.      */
  296.     private function fetchCurrentLanguageStates(array $salesChannelIds): array
  297.     {
  298.         /** @var array<string, mixed> $result */
  299.         $result $this->connection->fetchAllAssociative(
  300.             'SELECT LOWER(HEX(sales_channel.id)) AS sales_channel_id,
  301.             LOWER(HEX(sales_channel.language_id)) AS current_default,
  302.             LOWER(HEX(mapping.language_id)) AS language_id
  303.             FROM sales_channel
  304.             LEFT JOIN sales_channel_language mapping
  305.                 ON mapping.sales_channel_id = sales_channel.id
  306.                 WHERE sales_channel.id IN (:ids)',
  307.             ['ids' => Uuid::fromHexToBytesList($salesChannelIds)],
  308.             ['ids' => Connection::PARAM_STR_ARRAY]
  309.         );
  310.         return $result;
  311.     }
  312.     /**
  313.      * @param array<string, mixed> $mapping
  314.      * @param array<string, mixed> $states
  315.      *
  316.      * @return array<string, mixed>
  317.      */
  318.     private function mergeCurrentStatesWithMapping(array $mapping, array $states): array
  319.     {
  320.         foreach ($states as $record) {
  321.             $id = (string) $record['sales_channel_id'];
  322.             $mapping[$id]['current_default'] = $record['current_default'];
  323.             $mapping[$id]['state'][] = $record['language_id'];
  324.         }
  325.         return $mapping;
  326.     }
  327.     private function writeViolationException(ConstraintViolationList $violationsPreWriteValidationEvent $event): void
  328.     {
  329.         $event->getExceptions()->add(new WriteConstraintViolationException($violations));
  330.     }
  331. }