vendor/shopware/core/System/SalesChannel/Validation/SalesChannelValidator.php line 54

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