<?php declare(strict_types=1);
namespace Shopware\Core\System\SalesChannel\Validation;
use Doctrine\DBAL\Connection;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\DeleteCommand;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\InsertCommand;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\UpdateCommand;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommand;
use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\PreWriteValidationEvent;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\Framework\Validation\WriteConstraintViolationException;
use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelLanguage\SalesChannelLanguageDefinition;
use Shopware\Core\System\SalesChannel\SalesChannelDefinition;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
class SalesChannelValidator implements EventSubscriberInterface
{
private const INSERT_VALIDATION_MESSAGE = 'The sales channel with id "%s" does not have a default sales channel language id in the language list.';
private const INSERT_VALIDATION_CODE = 'SYSTEM__NO_GIVEN_DEFAULT_LANGUAGE_ID';
private const DUPLICATED_ENTRY_VALIDATION_MESSAGE = 'The sales channel language "%s" for the sales channel "%s" already exists.';
private const DUPLICATED_ENTRY_VALIDATION_CODE = 'SYSTEM__DUPLICATED_SALES_CHANNEL_LANGUAGE';
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"';
private const UPDATE_VALIDATION_CODE = 'SYSTEM__CANNOT_UPDATE_DEFAULT_LANGUAGE_ID';
private const DELETE_VALIDATION_MESSAGE = 'Cannot delete default language id from language list of the sales channel with id "%s".';
private const DELETE_VALIDATION_CODE = 'SYSTEM__CANNOT_DELETE_DEFAULT_LANGUAGE_ID';
private Connection $connection;
public function __construct(
Connection $connection
) {
$this->connection = $connection;
}
public static function getSubscribedEvents(): array
{
return [
PreWriteValidationEvent::class => 'handleSalesChannelLanguageIds',
];
}
public function handleSalesChannelLanguageIds(PreWriteValidationEvent $event): void
{
$mapping = $this->extractMapping($event);
if (!$mapping) {
return;
}
$salesChannelIds = array_keys($mapping);
$states = $this->fetchCurrentLanguageStates($salesChannelIds);
$mapping = $this->mergeCurrentStatesWithMapping($mapping, $states);
$this->validateLanguages($mapping, $event);
}
/**
* Build a key map with the following data structure:
*
* 'sales_channel_id' => [
* 'current_default' => 'en',
* 'new_default' => 'de',
* 'inserts' => ['de', 'en'],
* 'updates' => ['de', 'de'],
* 'deletions' => ['gb'],
* 'state' => ['en', 'gb']
* ]
*/
private function extractMapping(PreWriteValidationEvent $event): array
{
$mapping = [];
foreach ($event->getCommands() as $command) {
if ($command->getDefinition() instanceof SalesChannelDefinition) {
$this->handleSalesChannelMapping($mapping, $command);
continue;
}
if ($command->getDefinition() instanceof SalesChannelLanguageDefinition) {
$this->handleSalesChannelLanguageMapping($mapping, $command);
}
}
return $mapping;
}
private function handleSalesChannelMapping(array &$mapping, WriteCommand $command): void
{
if (!isset($command->getPayload()['language_id'])) {
return;
}
if ($command instanceof UpdateCommand) {
$id = Uuid::fromBytesToHex($command->getPrimaryKey()['id']);
$mapping[$id]['updates'] = Uuid::fromBytesToHex($command->getPayload()['language_id']);
return;
}
if (!$command instanceof InsertCommand || !$this->isSupportedSalesChannelType($command)) {
return;
}
$id = Uuid::fromBytesToHex($command->getPrimaryKey()['id']);
$mapping[$id]['new_default'] = Uuid::fromBytesToHex($command->getPayload()['language_id']);
$mapping[$id]['inserts'] = [];
$mapping[$id]['state'] = [];
}
private function isSupportedSalesChannelType(WriteCommand $command): bool
{
$typeId = Uuid::fromBytesToHex($command->getPayload()['type_id']);
return $typeId === Defaults::SALES_CHANNEL_TYPE_STOREFRONT
|| $typeId === Defaults::SALES_CHANNEL_TYPE_API;
}
private function handleSalesChannelLanguageMapping(array &$mapping, WriteCommand $command): void
{
$language = Uuid::fromBytesToHex($command->getPrimaryKey()['language_id']);
$id = Uuid::fromBytesToHex($command->getPrimaryKey()['sales_channel_id']);
$mapping[$id]['state'] = [];
if ($command instanceof DeleteCommand) {
$mapping[$id]['deletions'][] = $language;
return;
}
if ($command instanceof InsertCommand) {
$mapping[$id]['inserts'][] = $language;
}
}
private function validateLanguages(array $mapping, PreWriteValidationEvent $event): void
{
$inserts = [];
$duplicates = [];
$deletions = [];
$updates = [];
foreach ($mapping as $id => $channel) {
if (isset($channel['inserts'])) {
if (!$this->validInsertCase($channel)) {
$inserts[$id] = $channel['new_default'];
}
$duplicatedIds = $this->getDuplicates($channel);
if ($duplicatedIds) {
$duplicates[$id] = $duplicatedIds;
}
}
if (isset($channel['deletions']) && !$this->validDeleteCase($channel)) {
$deletions[$id] = $channel['current_default'];
}
if (isset($channel['updates']) && !$this->validUpdateCase($channel)) {
$updates[$id] = $channel['updates'];
}
}
$this->writeInsertViolationExceptions($inserts, $event);
$this->writeDuplicateViolationExceptions($duplicates, $event);
$this->writeDeleteViolationExceptions($deletions, $event);
$this->writeUpdateViolationExceptions($updates, $event);
}
private function validInsertCase(array $channel): bool
{
return empty($channel['new_default'])
|| \in_array($channel['new_default'], $channel['inserts'], true);
}
private function validUpdateCase(array $channel): bool
{
$updateId = $channel['updates'];
return \in_array($updateId, $channel['state'], true)
|| empty($channel['new_default']) && $updateId === $channel['current_default']
|| isset($channel['inserts']) && \in_array($updateId, $channel['inserts'], true);
}
private function validDeleteCase(array $channel): bool
{
return !\in_array($channel['current_default'], $channel['deletions'], true);
}
private function getDuplicates(array $channel): array
{
return array_intersect($channel['state'], $channel['inserts']);
}
private function writeInsertViolationExceptions(array $inserts, PreWriteValidationEvent $event): void
{
if (!$inserts) {
return;
}
$violations = new ConstraintViolationList();
$salesChannelIds = array_keys($inserts);
foreach ($salesChannelIds as $id) {
$violations->add(new ConstraintViolation(
sprintf(self::INSERT_VALIDATION_MESSAGE, $id),
sprintf(self::INSERT_VALIDATION_MESSAGE, '{{ salesChannelId }}'),
['{{ salesChannelId }}' => $id],
null,
'/',
null,
null,
self::INSERT_VALIDATION_CODE
));
}
$this->writeViolationException($violations, $event);
}
private function writeDuplicateViolationExceptions(array $duplicates, PreWriteValidationEvent $event): void
{
if (!$duplicates) {
return;
}
$violations = new ConstraintViolationList();
foreach ($duplicates as $id => $duplicateLanguages) {
foreach ($duplicateLanguages as $languageId) {
$violations->add(new ConstraintViolation(
sprintf(self::DUPLICATED_ENTRY_VALIDATION_MESSAGE, $languageId, $id),
sprintf(self::DUPLICATED_ENTRY_VALIDATION_MESSAGE, '{{ languageId }}', '{{ salesChannelId }}'),
[
'{{ salesChannelId }}' => $id,
'{{ languageId }}' => $languageId,
],
null,
'/',
null,
null,
self::DUPLICATED_ENTRY_VALIDATION_CODE
));
}
}
$this->writeViolationException($violations, $event);
}
private function writeDeleteViolationExceptions(array $deletions, PreWriteValidationEvent $event): void
{
if (!$deletions) {
return;
}
$violations = new ConstraintViolationList();
$salesChannelIds = array_keys($deletions);
foreach ($salesChannelIds as $id) {
$violations->add(new ConstraintViolation(
sprintf(self::DELETE_VALIDATION_MESSAGE, $id),
sprintf(self::DELETE_VALIDATION_MESSAGE, '{{ salesChannelId }}'),
['{{ salesChannelId }}' => $id],
null,
'/',
null,
null,
self::DELETE_VALIDATION_CODE
));
}
$this->writeViolationException($violations, $event);
}
private function writeUpdateViolationExceptions(array $updates, PreWriteValidationEvent $event): void
{
if (!$updates) {
return;
}
$violations = new ConstraintViolationList();
$salesChannelIds = array_keys($updates);
foreach ($salesChannelIds as $id) {
$violations->add(new ConstraintViolation(
sprintf(self::UPDATE_VALIDATION_MESSAGE, $id),
sprintf(self::UPDATE_VALIDATION_MESSAGE, '{{ salesChannelId }}'),
['{{ salesChannelId }}' => $id],
null,
'/',
null,
null,
self::UPDATE_VALIDATION_CODE
));
}
$this->writeViolationException($violations, $event);
}
private function fetchCurrentLanguageStates(array $salesChannelIds): array
{
return $this->connection->fetchAllAssociative(
'SELECT LOWER(HEX(sales_channel.id)) AS sales_channel_id,
LOWER(HEX(sales_channel.language_id)) AS current_default,
LOWER(HEX(mapping.language_id)) AS language_id
FROM sales_channel
LEFT JOIN sales_channel_language mapping
ON mapping.sales_channel_id = sales_channel.id
WHERE sales_channel.id IN (:ids)',
['ids' => Uuid::fromHexToBytesList($salesChannelIds)],
['ids' => Connection::PARAM_STR_ARRAY]
);
}
private function mergeCurrentStatesWithMapping(array $mapping, array $states): array
{
foreach ($states as $record) {
$id = $record['sales_channel_id'];
$mapping[$id]['current_default'] = $record['current_default'];
$mapping[$id]['state'][] = $record['language_id'];
}
return $mapping;
}
private function writeViolationException(ConstraintViolationList $violations, PreWriteValidationEvent $event): void
{
$event->getExceptions()->add(new WriteConstraintViolationException($violations));
}
}