src/Core/Framework/DataAbstractionLayer/Write/EntityWriter.php line 223

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Framework\DataAbstractionLayer\Write;
  3. use Shopware\Core\Framework\Api\Exception\IncompletePrimaryKeyException;
  4. use Shopware\Core\Framework\Api\Exception\InvalidSyncOperationException;
  5. use Shopware\Core\Framework\Api\Sync\SyncOperation;
  6. use Shopware\Core\Framework\DataAbstractionLayer\Dbal\EntityForeignKeyResolver;
  7. use Shopware\Core\Framework\DataAbstractionLayer\Dbal\EntityHydrator;
  8. use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry;
  9. use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
  10. use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult;
  11. use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
  12. use Shopware\Core\Framework\DataAbstractionLayer\Field\Field;
  13. use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\SetNullOnDelete;
  14. use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
  15. use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware;
  16. use Shopware\Core\Framework\DataAbstractionLayer\Field\VersionField;
  17. use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;
  18. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\CascadeDeleteCommand;
  19. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\DeleteCommand;
  20. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\InsertCommand;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\SetNullOnDeleteCommand;
  22. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\UpdateCommand;
  23. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue;
  24. use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\RestrictDeleteViolation;
  25. use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\RestrictDeleteViolationException;
  26. use Shopware\Core\Framework\Log\Package;
  27. use Shopware\Core\Framework\Uuid\Uuid;
  28. use Shopware\Core\System\Language\LanguageLoaderInterface;
  29. /**
  30.  * @internal
  31.  *
  32.  * Handles all write operations in the system.
  33.  * Builds first a command queue over the WriteCommandExtractor and let execute this queue
  34.  * over the EntityWriteGateway (sql implementation in default).
  35.  */
  36. #[Package('core')]
  37. class EntityWriter implements EntityWriterInterface
  38. {
  39.     /**
  40.      * @internal
  41.      */
  42.     public function __construct(
  43.         private readonly WriteCommandExtractor $commandExtractor,
  44.         private readonly EntityForeignKeyResolver $foreignKeyResolver,
  45.         private readonly EntityWriteGatewayInterface $gateway,
  46.         private readonly LanguageLoaderInterface $languageLoader,
  47.         private readonly DefinitionInstanceRegistry $registry,
  48.         private readonly EntityWriteResultFactory $factory
  49.     ) {
  50.     }
  51.     // TODO: prefetch
  52.     /**
  53.      * @param SyncOperation[] $operations
  54.      * @throw InvalidSyncOperationException
  55.      */
  56.     public function sync(array $operationsWriteContext $context): WriteResult
  57.     {
  58.         $commandQueue = new WriteCommandQueue();
  59.         $context->setLanguages(
  60.             $this->languageLoader->loadLanguages()
  61.         );
  62.         $writes = [];
  63.         $notFound = [];
  64.         $deletes = [];
  65.         foreach ($operations as $operation) {
  66.             if (!$operation instanceof SyncOperation) {
  67.                 continue;
  68.             }
  69.             $this->validateSyncOperationInput($operation);
  70.             $definition $this->registry->getByEntityName($operation->getEntity());
  71.             $this->validateWriteInput($operation->getPayload());
  72.             if ($operation->getAction() === SyncOperation::ACTION_DELETE) {
  73.                 $deletes[] = $this->factory->resolveDelete($definition$operation->getPayload());
  74.                 $notFound[] = $this->extractDeleteCommands($definition$operation->getPayload(), $context$commandQueue);
  75.                 continue;
  76.             }
  77.             if ($operation->getAction() === SyncOperation::ACTION_UPSERT) {
  78.                 $parameters = new WriteParameterBag($definition$context''$commandQueue);
  79.                 $payload $this->commandExtractor->normalize($definition$operation->getPayload(), $parameters);
  80.                 $this->gateway->prefetchExistences($parameters);
  81.                 $key $operation->getKey();
  82.                 foreach ($payload as $index => $row) {
  83.                     $parameters->setPath('/' $key '/' $index);
  84.                     $context->resetPaths();
  85.                     $this->commandExtractor->extract($row$parameters);
  86.                 }
  87.                 $writes[] = $this->factory->resolveWrite($definition$payload);
  88.             }
  89.         }
  90.         $context->getExceptions()->tryToThrow();
  91.         $this->gateway->execute($commandQueue->getCommandsInOrder(), $context);
  92.         $result $this->factory->build($commandQueue);
  93.         $notFound array_merge_recursive(...$notFound);
  94.         $writes array_merge_recursive(...$writes);
  95.         $deletes array_merge_recursive(...$deletes);
  96.         $result $this->factory->addParentResults($result$writes);
  97.         $result $this->factory->addDeleteResults($result$notFound$deletes);
  98.         return $result;
  99.     }
  100.     /**
  101.      * @param array<mixed> $rawData
  102.      *
  103.      * @return array<mixed>
  104.      */
  105.     public function upsert(EntityDefinition $definition, array $rawDataWriteContext $writeContext): array
  106.     {
  107.         return $this->write($definition$rawData$writeContext);
  108.     }
  109.     /**
  110.      * @param array<mixed> $rawData
  111.      *
  112.      * @return array<mixed>
  113.      */
  114.     public function insert(EntityDefinition $definition, array $rawDataWriteContext $writeContext): array
  115.     {
  116.         return $this->write($definition$rawData$writeContextInsertCommand::class);
  117.     }
  118.     /**
  119.      * @param array<mixed> $rawData
  120.      *
  121.      * @return array<mixed>
  122.      */
  123.     public function update(EntityDefinition $definition, array $rawDataWriteContext $writeContext): array
  124.     {
  125.         return $this->write($definition$rawData$writeContextUpdateCommand::class);
  126.     }
  127.     /**
  128.      * @param array<mixed> $ids
  129.      *
  130.      * @throws IncompletePrimaryKeyException
  131.      * @throws RestrictDeleteViolationException
  132.      */
  133.     public function delete(EntityDefinition $definition, array $idsWriteContext $writeContext): WriteResult
  134.     {
  135.         $this->validateWriteInput($ids);
  136.         $parents = [];
  137.         if (!$writeContext->hasState('merge-scope')) {
  138.             $parents $this->factory->resolveDelete($definition$ids);
  139.         }
  140.         $commandQueue = new WriteCommandQueue();
  141.         $notFound $this->extractDeleteCommands($definition$ids$writeContext$commandQueue);
  142.         $writeContext->setLanguages($this->languageLoader->loadLanguages());
  143.         $this->gateway->execute($commandQueue->getCommandsInOrder(), $writeContext);
  144.         $result $this->factory->build($commandQueue);
  145.         $parents array_merge_recursive($parents$this->factory->resolveMappings($result));
  146.         return $this->factory->addDeleteResults($result$notFound$parents);
  147.     }
  148.     /**
  149.      * @param array<mixed> $rawData
  150.      *
  151.      * @return array<mixed>
  152.      */
  153.     private function write(EntityDefinition $definition, array $rawDataWriteContext $writeContext, ?string $ensure null): array
  154.     {
  155.         $this->validateWriteInput($rawData);
  156.         if (!$rawData) {
  157.             return [];
  158.         }
  159.         $commandQueue = new WriteCommandQueue();
  160.         $parameters = new WriteParameterBag($definition$writeContext''$commandQueue);
  161.         $writeContext->setLanguages($this->languageLoader->loadLanguages());
  162.         $rawData $this->commandExtractor->normalize($definition$rawData$parameters);
  163.         $writeContext->getExceptions()->tryToThrow();
  164.         $this->gateway->prefetchExistences($parameters);
  165.         foreach ($rawData as $index => $row) {
  166.             $parameters->setPath('/' $index);
  167.             $writeContext->resetPaths();
  168.             $this->commandExtractor->extract($row$parameters);
  169.         }
  170.         if ($ensure) {
  171.             $commandQueue->ensureIs($definition$ensure);
  172.         }
  173.         $writeContext->getExceptions()->tryToThrow();
  174.         $ordered $commandQueue->getCommandsInOrder();
  175.         $this->gateway->execute($ordered$writeContext);
  176.         $result $this->factory->build($commandQueue);
  177.         $parents array_merge(
  178.             $this->factory->resolveWrite($definition$rawData),
  179.             $this->factory->resolveMappings($result)
  180.         );
  181.         return $this->factory->addParentResults($result$parents);
  182.     }
  183.     /**
  184.      * @param array<mixed> $data
  185.      *
  186.      * @throws \InvalidArgumentException
  187.      */
  188.     private function validateWriteInput(array $data): void
  189.     {
  190.         $valid array_keys($data) === range(0\count($data) - 1) || $data === [];
  191.         if (!$valid) {
  192.             throw new \InvalidArgumentException('Expected input to be non associative array.');
  193.         }
  194.     }
  195.     /**
  196.      * @throws InvalidSyncOperationException
  197.      */
  198.     private function validateSyncOperationInput(SyncOperation $operation): void
  199.     {
  200.         $errors $operation->validate();
  201.         if (\count($errors)) {
  202.             throw new InvalidSyncOperationException(sprintf('Invalid sync operation. %s'implode(' '$errors)));
  203.         }
  204.     }
  205.     /**
  206.      * @param array<mixed> $resolved
  207.      */
  208.     private function addReverseInheritedCommands(WriteCommandQueue $queueEntityDefinition $definitionWriteContext $writeContext, array $resolved): void
  209.     {
  210.         if ($definition instanceof MappingEntityDefinition) {
  211.             return;
  212.         }
  213.         $cascades $this->foreignKeyResolver->getAllReverseInherited($definition$resolved$writeContext->getContext());
  214.         foreach ($cascades as $affectedDefinitionClass => $keys) {
  215.             $affectedDefinition $this->registry->getByEntityName($affectedDefinitionClass);
  216.             foreach ($keys as $key) {
  217.                 if (!\is_array($key)) {
  218.                     $key = ['id' => $key];
  219.                 }
  220.                 $primary EntityHydrator::encodePrimaryKey($affectedDefinition$key$writeContext->getContext());
  221.                 $existence = new EntityExistence($affectedDefinition->getEntityName(), $primarytruefalsefalse, []);
  222.                 $queue->add($affectedDefinition, new UpdateCommand($affectedDefinition, [], $primary$existence''));
  223.             }
  224.         }
  225.     }
  226.     /**
  227.      * @param array<mixed> $resolved
  228.      */
  229.     private function addDeleteCascadeCommands(WriteCommandQueue $queueEntityDefinition $definitionWriteContext $writeContext, array $resolved): void
  230.     {
  231.         if ($definition instanceof MappingEntityDefinition) {
  232.             return;
  233.         }
  234.         $cascades $this->foreignKeyResolver->getAffectedDeletes($definition$resolved$writeContext->getContext());
  235.         foreach ($cascades as $affectedDefinitionClass => $keys) {
  236.             $affectedDefinition $this->registry->getByEntityName($affectedDefinitionClass);
  237.             foreach ($keys as $key) {
  238.                 if (!\is_array($key)) {
  239.                     $key = ['id' => $key];
  240.                 }
  241.                 $primary EntityHydrator::encodePrimaryKey($affectedDefinition$key$writeContext->getContext());
  242.                 $existence = new EntityExistence($affectedDefinition->getEntityName(), $primarytruefalsefalse, []);
  243.                 $queue->add($affectedDefinition, new CascadeDeleteCommand($affectedDefinition$primary$existence));
  244.             }
  245.         }
  246.     }
  247.     /**
  248.      * @param array<mixed> $resolved
  249.      */
  250.     private function addSetNullOnDeletesCommands(WriteCommandQueue $queueEntityDefinition $definitionWriteContext $writeContext, array $resolved): void
  251.     {
  252.         if ($definition instanceof MappingEntityDefinition) {
  253.             return;
  254.         }
  255.         $setNullFields $definition->getFields()->filterByFlag(SetNullOnDelete::class);
  256.         $setNulls $this->foreignKeyResolver->getAffectedSetNulls($definition$resolved$writeContext->getContext());
  257.         foreach ($setNulls as $affectedDefinitionClass => $restrictions) {
  258.             [$entity$field] = explode('.'$affectedDefinitionClass);
  259.             $affectedDefinition $this->registry->getByEntityName($entity);
  260.             /** @var AssociationField $associationField */
  261.             $associationField $setNullFields
  262.                 ->filter(fn (Field $setNullField) => $setNullField instanceof AssociationField && $setNullField->getReferenceField() === $field)
  263.                 ->first();
  264.             /** @var SetNullOnDelete $flag */
  265.             $flag $associationField->getFlag(SetNullOnDelete::class);
  266.             foreach ($restrictions as $key) {
  267.                 $payload = ['id' => Uuid::fromHexToBytes($key), $field => null];
  268.                 $primary EntityHydrator::encodePrimaryKey($affectedDefinition, ['id' => $key], $writeContext->getContext());
  269.                 $existence = new EntityExistence($affectedDefinition->getEntityName(), $primarytruefalsefalse, []);
  270.                 if ($definition->isVersionAware()) {
  271.                     $versionField str_replace('_id''_version_id'$field);
  272.                     $payload[$versionField] = null;
  273.                 }
  274.                 $queue->add($affectedDefinition, new SetNullOnDeleteCommand($affectedDefinition$payload$primary$existence''$flag->isEnforcedByConstraint()));
  275.             }
  276.         }
  277.     }
  278.     /**
  279.      * @param array<mixed> $ids
  280.      *
  281.      * @return array<mixed>
  282.      */
  283.     private function resolvePrimaryKeys(array $idsEntityDefinition $definitionWriteContext $writeContext): array
  284.     {
  285.         $fields $definition->getPrimaryKeys();
  286.         $resolved = [];
  287.         foreach ($ids as $raw) {
  288.             $mapped = [];
  289.             foreach ($fields as $field) {
  290.                 $property $field->getPropertyName();
  291.                 if (!($field instanceof StorageAware)) {
  292.                     continue;
  293.                 }
  294.                 if (\array_key_exists($property$raw)) {
  295.                     $mapped[$property] = $raw[$property];
  296.                     continue;
  297.                 }
  298.                 if ($field instanceof ReferenceVersionField) {
  299.                     $mapped[$property] = $writeContext->getContext()->getVersionId();
  300.                     continue;
  301.                 }
  302.                 if ($field instanceof VersionField) {
  303.                     $mapped[$property] = $writeContext->getContext()->getVersionId();
  304.                     continue;
  305.                 }
  306.                 $fieldKeys $fields
  307.                     ->filter(
  308.                         fn (Field $field) => !$field instanceof VersionField && !$field instanceof ReferenceVersionField
  309.                     )
  310.                     ->map(
  311.                         fn (Field $field) => $field->getPropertyName()
  312.                     );
  313.                 throw new IncompletePrimaryKeyException($fieldKeys);
  314.             }
  315.             $resolved[] = $mapped;
  316.         }
  317.         return $resolved;
  318.     }
  319.     /**
  320.      * @param array<mixed> $ids
  321.      *
  322.      * @return array<mixed>
  323.      */
  324.     private function extractDeleteCommands(EntityDefinition $definition, array $idsWriteContext $writeContextWriteCommandQueue $commandQueue): array
  325.     {
  326.         $parameters = new WriteParameterBag($definition$writeContext''$commandQueue);
  327.         $ids $this->commandExtractor->normalize($definition$ids$parameters);
  328.         $this->gateway->prefetchExistences($parameters);
  329.         $resolved $this->resolvePrimaryKeys($ids$definition$writeContext);
  330.         if (!$definition instanceof MappingEntityDefinition) {
  331.             $restrictions $this->foreignKeyResolver->getAffectedDeleteRestrictions($definition$resolved$writeContext->getContext(), true);
  332.             if (!empty($restrictions)) {
  333.                 throw new RestrictDeleteViolationException($definition, [new RestrictDeleteViolation($restrictions)]);
  334.             }
  335.         }
  336.         $skipped = [];
  337.         foreach ($resolved as $primaryKey) {
  338.             $mappedBytes = [];
  339.             /**
  340.              * @var string $key
  341.              * @var string $value
  342.              */
  343.             foreach ($primaryKey as $key => $value) {
  344.                 /** @var StorageAware $field */
  345.                 $field $definition->getFields()->get($key);
  346.                 $mappedBytes[$field->getStorageName()] = Uuid::fromHexToBytes($value);
  347.             }
  348.             $existence $this->gateway->getExistence($definition$mappedBytes, [], $commandQueue);
  349.             if ($existence->exists()) {
  350.                 $commandQueue->add($definition, new DeleteCommand($definition$mappedBytes$existence));
  351.                 continue;
  352.             }
  353.             $stripped = [];
  354.             /**
  355.              * @var string $key
  356.              * @var string $value
  357.              */
  358.             foreach ($primaryKey as $key => $value) {
  359.                 $field $definition->getFields()->get($key);
  360.                 if ($field instanceof VersionField || $field instanceof ReferenceVersionField) {
  361.                     continue;
  362.                 }
  363.                 $stripped[$key] = $value;
  364.             }
  365.             $skipped[$definition->getEntityName()][] = new EntityWriteResult(
  366.                 \count($stripped) === array_shift($stripped) : $stripped,
  367.                 $stripped,
  368.                 $definition->getEntityName(),
  369.                 EntityWriteResult::OPERATION_DELETE,
  370.                 $existence
  371.             );
  372.         }
  373.         // we had some logic in the command layer (pre-validate, post-validate, indexer which listens to this events)
  374.         // to trigger this logic for cascade deletes or set nulls, we add a fake commands for the affected rows
  375.         $this->addReverseInheritedCommands($commandQueue$definition$writeContext$resolved);
  376.         $this->addDeleteCascadeCommands($commandQueue$definition$writeContext$resolved);
  377.         $this->addSetNullOnDeletesCommands($commandQueue$definition$writeContext$resolved);
  378.         return $skipped;
  379.     }
  380. }