src/Core/Framework/DataAbstractionLayer/VersionManager.php line 104

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Framework\DataAbstractionLayer;
  3. use Shopware\Core\Defaults;
  4. use Shopware\Core\Framework\Api\Context\AdminApiSource;
  5. use Shopware\Core\Framework\Api\Sync\SyncOperation;
  6. use Shopware\Core\Framework\Context;
  7. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
  8. use Shopware\Core\Framework\DataAbstractionLayer\Exception\VersionMergeAlreadyLockedException;
  9. use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
  10. use Shopware\Core\Framework\DataAbstractionLayer\Field\ChildrenAssociationField;
  11. use Shopware\Core\Framework\DataAbstractionLayer\Field\DateTimeField;
  12. use Shopware\Core\Framework\DataAbstractionLayer\Field\Field;
  13. use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField;
  14. use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\CascadeDelete;
  15. use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Extension;
  16. use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
  17. use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\WriteProtected;
  18. use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
  19. use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
  20. use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
  22. use Shopware\Core\Framework\DataAbstractionLayer\Field\ParentFkField;
  23. use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField;
  24. use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware;
  25. use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField;
  26. use Shopware\Core\Framework\DataAbstractionLayer\Field\VersionField;
  27. use Shopware\Core\Framework\DataAbstractionLayer\Read\EntityReaderInterface;
  28. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  29. use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearcherInterface;
  30. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  31. use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
  32. use Shopware\Core\Framework\DataAbstractionLayer\Version\Aggregate\VersionCommit\VersionCommitCollection;
  33. use Shopware\Core\Framework\DataAbstractionLayer\Version\Aggregate\VersionCommit\VersionCommitDefinition;
  34. use Shopware\Core\Framework\DataAbstractionLayer\Version\Aggregate\VersionCommit\VersionCommitEntity;
  35. use Shopware\Core\Framework\DataAbstractionLayer\Version\Aggregate\VersionCommitData\VersionCommitDataDefinition;
  36. use Shopware\Core\Framework\DataAbstractionLayer\Version\Aggregate\VersionCommitData\VersionCommitDataEntity;
  37. use Shopware\Core\Framework\DataAbstractionLayer\Version\VersionDefinition;
  38. use Shopware\Core\Framework\DataAbstractionLayer\Write\CloneBehavior;
  39. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\InsertCommand;
  40. use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence;
  41. use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriteGatewayInterface;
  42. use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface;
  43. use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteContext;
  44. use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteResult;
  45. use Shopware\Core\Framework\Log\Package;
  46. use Shopware\Core\Framework\Util\Json;
  47. use Shopware\Core\Framework\Uuid\Uuid;
  48. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  49. use Symfony\Component\Lock\LockFactory;
  50. use Symfony\Component\Serializer\SerializerInterface;
  51. /**
  52.  * @internal
  53.  */
  54. #[Package('core')]
  55. class VersionManager
  56. {
  57.     final public const DISABLE_AUDIT_LOG 'disable-audit-log';
  58.     final public const MERGE_SCOPE 'merge-scope';
  59.     public function __construct(private readonly EntityWriterInterface $entityWriter, private readonly EntityReaderInterface $entityReader, private readonly EntitySearcherInterface $entitySearcher, private readonly EntityWriteGatewayInterface $entityWriteGateway, private readonly EventDispatcherInterface $eventDispatcher, private readonly SerializerInterface $serializer, private readonly DefinitionInstanceRegistry $registry, private readonly VersionCommitDefinition $versionCommitDefinition, private readonly VersionCommitDataDefinition $versionCommitDataDefinition, private readonly VersionDefinition $versionDefinition, private readonly LockFactory $lockFactory)
  60.     {
  61.     }
  62.     /**
  63.      * @param array<array<string, mixed|null>> $rawData
  64.      *
  65.      * @return array<string, array<EntityWriteResult>>
  66.      */
  67.     public function upsert(EntityDefinition $definition, array $rawDataWriteContext $writeContext): array
  68.     {
  69.         $result $this->entityWriter->upsert($definition$rawData$writeContext);
  70.         $this->writeAuditLog($result$writeContext);
  71.         return $result;
  72.     }
  73.     /**
  74.      * @param array<array<string, mixed|null>> $rawData
  75.      *
  76.      * @return array<string, array<EntityWriteResult>>
  77.      */
  78.     public function insert(EntityDefinition $definition, array $rawDataWriteContext $writeContext): array
  79.     {
  80.         /** @var array<string, array<EntityWriteResult>> $result */
  81.         $result $this->entityWriter->insert($definition$rawData$writeContext);
  82.         $this->writeAuditLog($result$writeContext);
  83.         return $result;
  84.     }
  85.     /**
  86.      * @param array<array<string, mixed|null>> $rawData
  87.      *
  88.      * @return array<string, array<EntityWriteResult>>
  89.      */
  90.     public function update(EntityDefinition $definition, array $rawDataWriteContext $writeContext): array
  91.     {
  92.         /** @var array<string, array<EntityWriteResult>> $result */
  93.         $result $this->entityWriter->update($definition$rawData$writeContext);
  94.         $this->writeAuditLog($result$writeContext);
  95.         return $result;
  96.     }
  97.     /**
  98.      * @param array<array<string, mixed|null>> $ids
  99.      */
  100.     public function delete(EntityDefinition $definition, array $idsWriteContext $writeContext): WriteResult
  101.     {
  102.         $result $this->entityWriter->delete($definition$ids$writeContext);
  103.         $this->writeAuditLog($result->getDeleted(), $writeContext);
  104.         return $result;
  105.     }
  106.     public function createVersion(EntityDefinition $definitionstring $idWriteContext $context, ?string $name null, ?string $versionId null): string
  107.     {
  108.         $versionId $versionId ?? Uuid::randomHex();
  109.         $versionData = ['id' => $versionId];
  110.         if ($name) {
  111.             $versionData['name'] = $name;
  112.         }
  113.         $context->scope(Context::SYSTEM_SCOPE, function ($context) use ($versionData): void {
  114.             $this->entityWriter->upsert($this->versionDefinition, [$versionData], $context);
  115.         });
  116.         $affected $this->cloneEntity($definition$id$id$versionId$context, new CloneBehavior());
  117.         $versionContext $context->createWithVersionId($versionId);
  118.         $event EntityWrittenContainerEvent::createWithWrittenEvents($affected$versionContext->getContext(), []);
  119.         $this->eventDispatcher->dispatch($event);
  120.         $this->writeAuditLog($affected$context$versionIdtrue);
  121.         return $versionId;
  122.     }
  123.     public function merge(string $versionIdWriteContext $writeContext): void
  124.     {
  125.         // acquire a lock to prevent multiple merges of the same version
  126.         $lock $this->lockFactory->createLock('sw-merge-version-' $versionId);
  127.         if (!$lock->acquire()) {
  128.             throw new VersionMergeAlreadyLockedException($versionId);
  129.         }
  130.         // load all commits of the provided version
  131.         $commits $this->getCommits($versionId$writeContext);
  132.         // create context for live and version
  133.         $versionContext $writeContext->createWithVersionId($versionId);
  134.         $liveContext $writeContext->createWithVersionId(Defaults::LIVE_VERSION);
  135.         $versionContext->addState(self::MERGE_SCOPE);
  136.         $liveContext->addState(self::MERGE_SCOPE);
  137.         // group all payloads by their action (insert, update, delete) and by their entity name
  138.         $writes $this->buildWrites($commits);
  139.         // execute writes and get access to the write result to dispatch events later on
  140.         $result $this->executeWrites($writes$liveContext);
  141.         // remove commits which reference the version and create a "merge commit" for the live version with all payloads
  142.         $this->updateVersionData($commits$writeContext$versionId);
  143.         // delete all versioned records
  144.         $this->deleteClones($commits$versionContext$versionId);
  145.         // release lock to ensure no other merge is running
  146.         $lock->release();
  147.         // dispatch events to trigger indexer and other subscribts
  148.         $writes EntityWrittenContainerEvent::createWithWrittenEvents($result->getWritten(), $liveContext->getContext(), []);
  149.         $deletes EntityWrittenContainerEvent::createWithDeletedEvents($result->getDeleted(), $liveContext->getContext(), []);
  150.         if ($deletes->getEvents() !== null) {
  151.             $writes->addEvent(...$deletes->getEvents()->getElements());
  152.         }
  153.         $this->eventDispatcher->dispatch($writes);
  154.         $versionContext->removeState(self::MERGE_SCOPE);
  155.         $liveContext->addState(self::MERGE_SCOPE);
  156.     }
  157.     /**
  158.      * @return array<string, array<EntityWriteResult>>
  159.      */
  160.     public function clone(
  161.         EntityDefinition $definition,
  162.         string $id,
  163.         string $newId,
  164.         string $versionId,
  165.         WriteContext $context,
  166.         CloneBehavior $behavior
  167.     ): array {
  168.         return $this->cloneEntity($definition$id$newId$versionId$context$behaviortrue);
  169.     }
  170.     /**
  171.      * @return array<string, array<EntityWriteResult>>
  172.      */
  173.     private function cloneEntity(
  174.         EntityDefinition $definition,
  175.         string $id,
  176.         string $newId,
  177.         string $versionId,
  178.         WriteContext $context,
  179.         CloneBehavior $behavior,
  180.         bool $writeAuditLog false
  181.     ): array {
  182.         $criteria = new Criteria([$id]);
  183.         $this->addCloneAssociations($definition$criteria$behavior->cloneChildren());
  184.         $detail $this->entityReader->read($definition$criteria$context->getContext())->first();
  185.         if ($detail === null) {
  186.             throw new \RuntimeException(sprintf('Cannot create new version. %s by id (%s) not found.'$definition->getEntityName(), $id));
  187.         }
  188.         $data json_decode($this->serializer->serialize($detail'json'), true512\JSON_THROW_ON_ERROR);
  189.         $keepIds $newId === $id;
  190.         $data $this->filterPropertiesForClone($definition$data$keepIds$id$definition$context->getContext());
  191.         $data['id'] = $newId;
  192.         $createdAtField $definition->getField('createdAt');
  193.         $updatedAtField $definition->getField('updatedAt');
  194.         if ($createdAtField instanceof DateTimeField) {
  195.             $data['createdAt'] = new \DateTime();
  196.         }
  197.         if ($updatedAtField instanceof DateTimeField) {
  198.             if ($updatedAtField->getFlag(Required::class)) {
  199.                 $data['updatedAt'] = new \DateTime();
  200.             } else {
  201.                 $data['updatedAt'] = null;
  202.             }
  203.         }
  204.         $data array_replace_recursive($data$behavior->getOverwrites());
  205.         $versionContext $context->createWithVersionId($versionId);
  206.         $result null;
  207.         $versionContext->scope(Context::SYSTEM_SCOPE, function (WriteContext $context) use ($definition$data, &$result): void {
  208.             $result $this->entityWriter->insert($definition, [$data], $context);
  209.         });
  210.         if ($writeAuditLog) {
  211.             $this->writeAuditLog($result$versionContext);
  212.         }
  213.         return $result;
  214.     }
  215.     /**
  216.      * @param array<string, array<string, mixed|null>|null> $data
  217.      *
  218.      * @return array<string, array<string, mixed|null>|string|null>
  219.      */
  220.     private function filterPropertiesForClone(EntityDefinition $definition, array $databool $keepIdsstring $cloneIdEntityDefinition $cloneDefinitionContext $context): array
  221.     {
  222.         $extensions = [];
  223.         $payload = [];
  224.         $fields $definition->getFields();
  225.         foreach ($fields as $field) {
  226.             /** @var WriteProtected|null $writeProtection */
  227.             $writeProtection $field->getFlag(WriteProtected::class);
  228.             if ($writeProtection && !$writeProtection->isAllowed(Context::SYSTEM_SCOPE)) {
  229.                 continue;
  230.             }
  231.             //set data and payload cursor to root or extensions to simplify following if conditions
  232.             $dataCursor $data;
  233.             $payloadCursor = &$payload;
  234.             if ($field instanceof VersionField || $field instanceof ReferenceVersionField) {
  235.                 continue;
  236.             }
  237.             if ($field->is(Extension::class)) {
  238.                 $dataCursor $data['extensions'] ?? [];
  239.                 $payloadCursor = &$extensions;
  240.                 if (isset($dataCursor['foreignKeys'])) {
  241.                     $fields $definition->getFields();
  242.                     /**
  243.                      * @var string $key
  244.                      * @var string $value
  245.                      */
  246.                     foreach ($dataCursor['foreignKeys'] as $key => $value) {
  247.                         // Clone FK extension and add it to payload
  248.                         if (\is_string($value) && Uuid::isValid($value) && $fields->has($key) && $fields->get($key) instanceof FkField) {
  249.                             $payload[$key] = $value;
  250.                         }
  251.                     }
  252.                 }
  253.             }
  254.             if (!\array_key_exists($field->getPropertyName(), $dataCursor)) {
  255.                 continue;
  256.             }
  257.             if (!$keepIds && $field instanceof ParentFkField) {
  258.                 continue;
  259.             }
  260.             $value $dataCursor[$field->getPropertyName()];
  261.             // remove reference of cloned entity in all sub entity routes. Appears in a parent-child nested data tree
  262.             if ($field instanceof FkField && !$keepIds && $value === $cloneId && $cloneDefinition === $field->getReferenceDefinition()) {
  263.                 continue;
  264.             }
  265.             if ($value === null) {
  266.                 continue;
  267.             }
  268.             //scalar value? assign directly
  269.             if (!$field instanceof AssociationField) {
  270.                 $payloadCursor[$field->getPropertyName()] = $value;
  271.                 continue;
  272.             }
  273.             //many to one should be skipped because it is no part of the root entity
  274.             if ($field instanceof ManyToOneAssociationField) {
  275.                 continue;
  276.             }
  277.             /** @var CascadeDelete|null $flag */
  278.             $flag $field->getFlag(CascadeDelete::class);
  279.             if (!$flag || !$flag->isCloneRelevant()) {
  280.                 continue;
  281.             }
  282.             if ($field instanceof OneToManyAssociationField) {
  283.                 $reference $field->getReferenceDefinition();
  284.                 $nested = [];
  285.                 foreach ($value as $item) {
  286.                     $nestedItem $this->filterPropertiesForClone($reference$item$keepIds$cloneId$cloneDefinition$context);
  287.                     if (!$keepIds) {
  288.                         $nestedItem $this->removePrimaryKey($field$nestedItem);
  289.                     }
  290.                     $nested[] = $nestedItem;
  291.                 }
  292.                 $nested array_filter($nested);
  293.                 if (empty($nested)) {
  294.                     continue;
  295.                 }
  296.                 $payloadCursor[$field->getPropertyName()] = $nested;
  297.                 continue;
  298.             }
  299.             if ($field instanceof ManyToManyAssociationField) {
  300.                 $nested = [];
  301.                 foreach ($value as $item) {
  302.                     $nested[] = ['id' => $item['id']];
  303.                 }
  304.                 if (empty($nested)) {
  305.                     continue;
  306.                 }
  307.                 $payloadCursor[$field->getPropertyName()] = $nested;
  308.                 continue;
  309.             }
  310.             if ($field instanceof OneToOneAssociationField && $value) {
  311.                 $reference $field->getReferenceDefinition();
  312.                 $nestedItem $this->filterPropertiesForClone($reference$value$keepIds$cloneId$cloneDefinition$context);
  313.                 if (!$keepIds) {
  314.                     $nestedItem $this->removePrimaryKey($field$nestedItem);
  315.                 }
  316.                 $payloadCursor[$field->getPropertyName()] = $nestedItem;
  317.             }
  318.         }
  319.         if (!empty($extensions)) {
  320.             $payload['extensions'] = $extensions;
  321.         }
  322.         return $payload;
  323.     }
  324.     /**
  325.      * @param array<string, array<EntityWriteResult>> $writtenEvents
  326.      */
  327.     private function writeAuditLog(array $writtenEventsWriteContext $writeContext, ?string $versionId nullbool $isClone false): void
  328.     {
  329.         if ($writeContext->getContext()->hasState(self::DISABLE_AUDIT_LOG)) {
  330.             return;
  331.         }
  332.         $versionId ??= $writeContext->getContext()->getVersionId();
  333.         if ($versionId === Defaults::LIVE_VERSION) {
  334.             return;
  335.         }
  336.         $commitId Uuid::randomBytes();
  337.         $date = (new \DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT);
  338.         $source $writeContext->getContext()->getSource();
  339.         $userId $source instanceof AdminApiSource && $source->getUserId()
  340.             ? Uuid::fromHexToBytes($source->getUserId())
  341.             : null;
  342.         $insert = new InsertCommand(
  343.             $this->versionCommitDefinition,
  344.             [
  345.                 'id' => $commitId,
  346.                 'user_id' => $userId,
  347.                 'version_id' => Uuid::fromHexToBytes($versionId),
  348.                 'created_at' => $date,
  349.             ],
  350.             ['id' => $commitId],
  351.             new EntityExistence(
  352.                 $this->versionCommitDefinition->getEntityName(),
  353.                 ['id' => Uuid::fromBytesToHex($commitId)],
  354.                 false,
  355.                 false,
  356.                 false,
  357.                 []
  358.             ),
  359.             ''
  360.         );
  361.         $commands = [$insert];
  362.         foreach ($writtenEvents as $items) {
  363.             if (\count($items) === 0) {
  364.                 continue;
  365.             }
  366.             $definition $this->registry->getByEntityName($items[0]->getEntityName());
  367.             $entityName $definition->getEntityName();
  368.             if (!$definition->isVersionAware()) {
  369.                 continue;
  370.             }
  371.             if (mb_strpos('version'$entityName) === 0) {
  372.                 continue;
  373.             }
  374.             /** @var EntityWriteResult $item */
  375.             foreach ($items as $item) {
  376.                 $payload $item->getPayload();
  377.                 $primary $item->getPrimaryKey();
  378.                 if (!\is_array($primary)) {
  379.                     $primary = ['id' => $primary];
  380.                 }
  381.                 $primary['versionId'] = $versionId;
  382.                 $id Uuid::randomBytes();
  383.                 $commands[] = new InsertCommand(
  384.                     $this->versionCommitDataDefinition,
  385.                     [
  386.                         'id' => $id,
  387.                         'version_commit_id' => $commitId,
  388.                         'entity_name' => $entityName,
  389.                         'entity_id' => Json::encode($primary),
  390.                         'payload' => Json::encode($payload),
  391.                         'user_id' => $userId,
  392.                         'action' => $isClone 'clone' $item->getOperation(),
  393.                         'created_at' => $date,
  394.                     ],
  395.                     ['id' => $id],
  396.                     new EntityExistence(
  397.                         $this->versionCommitDataDefinition->getEntityName(),
  398.                         ['id' => Uuid::fromBytesToHex($id)],
  399.                         false,
  400.                         false,
  401.                         false,
  402.                         []
  403.                     ),
  404.                     ''
  405.                 );
  406.             }
  407.         }
  408.         if (\count($commands) <= 1) {
  409.             return;
  410.         }
  411.         $writeContext->scope(Context::SYSTEM_SCOPE, function () use ($commands$writeContext): void {
  412.             $this->entityWriteGateway->execute($commands$writeContext);
  413.         });
  414.     }
  415.     /**
  416.      * @param array<string, array<string, mixed>|string|null> $payload
  417.      *
  418.      * @return array<string, array<string, mixed>|string|null>
  419.      */
  420.     private function addVersionToPayload(array $payloadEntityDefinition $definitionstring $versionId): array
  421.     {
  422.         $fields $definition->getFields()->filter(fn (Field $field) => $field instanceof VersionField || $field instanceof ReferenceVersionField);
  423.         foreach ($fields as $field) {
  424.             $payload[$field->getPropertyName()] = $versionId;
  425.         }
  426.         return $payload;
  427.     }
  428.     /**
  429.      * @param array<string, array<string, mixed>|string|null> $nestedItem
  430.      *
  431.      * @return array<string, array<string, mixed>|string|null>
  432.      */
  433.     private function removePrimaryKey(AssociationField $field, array $nestedItem): array
  434.     {
  435.         $pkFields $field->getReferenceDefinition()->getPrimaryKeys();
  436.         foreach ($pkFields as $pkField) {
  437.             /*
  438.              * `EntityTranslationDefinition`s dont have an `id`, they use a composite primary key consisting of the
  439.              * entity id and the `languageId`. When cloning the entity we want to copy the `languageId`. The entity id
  440.              * has to be unset, so that its set by the parent, resulting in a valid primary key.
  441.              */
  442.             /** @var StorageAware $pkField */
  443.             if ($field instanceof TranslationsAssociationField && $pkField->getStorageName() === $field->getLanguageField()) {
  444.                 continue;
  445.             }
  446.             /** @var Field $pkField */
  447.             if (\array_key_exists($pkField->getPropertyName(), $nestedItem)) {
  448.                 unset($nestedItem[$pkField->getPropertyName()]);
  449.             }
  450.         }
  451.         return $nestedItem;
  452.     }
  453.     private function addCloneAssociations(
  454.         EntityDefinition $definition,
  455.         Criteria $criteria,
  456.         bool $cloneChildren,
  457.         int $childCounter 1
  458.     ): void {
  459.         //add all cascade delete associations
  460.         $cascades $definition->getFields()->filter(function (Field $field) {
  461.             /** @var CascadeDelete|null $flag */
  462.             $flag $field->getFlag(CascadeDelete::class);
  463.             return $flag $flag->isCloneRelevant() : false;
  464.         });
  465.         /** @var AssociationField $cascade */
  466.         foreach ($cascades as $cascade) {
  467.             $nested $criteria->getAssociation($cascade->getPropertyName());
  468.             if ($cascade instanceof ManyToManyAssociationField) {
  469.                 continue;
  470.             }
  471.             //many to one shouldn't be cascaded
  472.             if ($cascade instanceof ManyToOneAssociationField) {
  473.                 continue;
  474.             }
  475.             $reference $cascade->getReferenceDefinition();
  476.             $childrenAware $reference->isChildrenAware();
  477.             //first level of parent-child tree?
  478.             if ($childrenAware && $reference !== $definition) {
  479.                 //where product.children.parentId IS NULL
  480.                 $nested->addFilter(new EqualsFilter($reference->getEntityName() . '.parentId'null));
  481.             }
  482.             if ($cascade instanceof ChildrenAssociationField) {
  483.                 //break endless loop
  484.                 if ($childCounter >= 30 || !$cloneChildren) {
  485.                     $criteria->removeAssociation($cascade->getPropertyName());
  486.                     continue;
  487.                 }
  488.                 ++$childCounter;
  489.                 $this->addCloneAssociations($reference$nested$cloneChildren$childCounter);
  490.                 continue;
  491.             }
  492.             $this->addCloneAssociations($reference$nested$cloneChildren);
  493.         }
  494.     }
  495.     private function translationHasParent(VersionCommitEntity $commitVersionCommitDataEntity $translationData): bool
  496.     {
  497.         /** @var EntityTranslationDefinition $translationDefinition */
  498.         $translationDefinition $this->registry->getByEntityName($translationData->getEntityName());
  499.         $parentEntity $translationDefinition->getParentDefinition()->getEntityName();
  500.         $parentPropertyName $this->getEntityForeignKeyName($parentEntity);
  501.         /** @var array<string, string> $payload */
  502.         $payload $translationData->getPayload();
  503.         $parentId $payload[$parentPropertyName];
  504.         foreach ($commit->getData() as $data) {
  505.             if ($data->getEntityName() !== $parentEntity) {
  506.                 continue;
  507.             }
  508.             $primary $data->getEntityId();
  509.             if (!isset($primary['id'])) {
  510.                 continue;
  511.             }
  512.             if ($primary['id'] === $parentId) {
  513.                 return true;
  514.             }
  515.         }
  516.         return false;
  517.     }
  518.     /**
  519.      * @param array<string> $entityId
  520.      * @param array<string|int, mixed> $payload
  521.      *
  522.      * @return array<string|int, mixed>
  523.      */
  524.     private function addTranslationToPayload(array $entityId, array $payloadEntityDefinition $definitionVersionCommitEntity $commit): array
  525.     {
  526.         $translationDefinition $definition->getTranslationDefinition();
  527.         if (!$translationDefinition) {
  528.             return $payload;
  529.         }
  530.         if (!isset($entityId['id'])) {
  531.             return $payload;
  532.         }
  533.         $id $entityId['id'];
  534.         $translations = [];
  535.         $foreignKeyName $this->getEntityForeignKeyName($definition->getEntityName());
  536.         foreach ($commit->getData() as $data) {
  537.             if ($data->getEntityName() !== $translationDefinition->getEntityName()) {
  538.                 continue;
  539.             }
  540.             $translation $data->getPayload();
  541.             if (!isset($translation[$foreignKeyName])) {
  542.                 continue;
  543.             }
  544.             if ($translation[$foreignKeyName] !== $id) {
  545.                 continue;
  546.             }
  547.             $translations[] = $this->addVersionToPayload($translation$translationDefinitionDefaults::LIVE_VERSION);
  548.         }
  549.         $payload['translations'] = $translations;
  550.         return $payload;
  551.     }
  552.     private function getEntityForeignKeyName(string $parentEntity): string
  553.     {
  554.         $parentPropertyName explode('_'$parentEntity);
  555.         $parentPropertyName array_map('ucfirst'$parentPropertyName);
  556.         return lcfirst(implode(''$parentPropertyName)) . 'Id';
  557.     }
  558.     private function getCommits(string $versionIdWriteContext $writeContext): VersionCommitCollection
  559.     {
  560.         $criteria = new Criteria();
  561.         $criteria->addFilter(new EqualsFilter('version_commit.versionId'$versionId));
  562.         $criteria->addSorting(new FieldSorting('version_commit.autoIncrement'));
  563.         $commitIds $this->entitySearcher->search($this->versionCommitDefinition$criteria$writeContext->getContext());
  564.         $readCriteria = new Criteria();
  565.         if ($commitIds->getTotal() > 0) {
  566.             $readCriteria = new Criteria($commitIds->getIds());
  567.         }
  568.         $readCriteria->addAssociation('data');
  569.         $readCriteria
  570.             ->getAssociation('data')
  571.             ->addSorting(new FieldSorting('autoIncrement'));
  572.         /** @var VersionCommitCollection $commits */
  573.         $commits $this->entityReader->read($this->versionCommitDefinition$readCriteria$writeContext->getContext());
  574.         return $commits;
  575.     }
  576.     /**
  577.      * @return array{insert:array<string, array<int, mixed>>, update:array<string, array<int, mixed>>, delete:array<string, array<int, mixed>>}
  578.      */
  579.     private function buildWrites(VersionCommitCollection $commits): array
  580.     {
  581.         $writes = [
  582.             'insert' => [],
  583.             'update' => [],
  584.             'delete' => [],
  585.         ];
  586.         foreach ($commits as $commit) {
  587.             foreach ($commit->getData() as $data) {
  588.                 $definition $this->registry->getByEntityName($data->getEntityName());
  589.                 switch ($data->getAction()) {
  590.                     case 'insert':
  591.                     case 'update':
  592.                         if ($definition instanceof EntityTranslationDefinition && $this->translationHasParent($commit$data)) {
  593.                             break;
  594.                         }
  595.                         $payload $data->getPayload();
  596.                         if (empty($payload)) {
  597.                             break;
  598.                         }
  599.                         $payload $this->addVersionToPayload($payload$definitionDefaults::LIVE_VERSION);
  600.                         $payload $this->addTranslationToPayload($data->getEntityId(), $payload$definition$commit);
  601.                         $writes[$data->getAction()][$definition->getEntityName()][] = $payload;
  602.                         break;
  603.                     case 'delete':
  604.                         $id $data->getEntityId();
  605.                         $id $this->addVersionToPayload($id$definitionDefaults::LIVE_VERSION);
  606.                         $writes['delete'][$definition->getEntityName()][] = $id;
  607.                         break;
  608.                 }
  609.             }
  610.             $writes['delete']['version_commit'][] = ['id' => $commit->getId()];
  611.         }
  612.         return $writes;
  613.     }
  614.     /**
  615.      * @param array{insert:array<string, array<int, mixed>>, update:array<string, array<int, mixed>>, delete:array<string, array<int, mixed>>} $writes
  616.      */
  617.     private function executeWrites(array $writesWriteContext $liveContext): WriteResult
  618.     {
  619.         $operations = [];
  620.         foreach ($writes['insert'] as $entity => $payload) {
  621.             $operations[] = new SyncOperation('insert-' $entity$entity'upsert'$payload);
  622.         }
  623.         foreach ($writes['update'] as $entity => $payload) {
  624.             $operations[] = new SyncOperation('update-' $entity$entity'upsert'$payload);
  625.         }
  626.         foreach ($writes['delete'] as $entity => $payload) {
  627.             $operations[] = new SyncOperation('delete-' $entity$entity'delete'$payload);
  628.         }
  629.         return $this->entityWriter->sync($operations$liveContext);
  630.     }
  631.     private function updateVersionData(VersionCommitCollection $commitsWriteContext $writeContextstring $versionId): void
  632.     {
  633.         $new = [];
  634.         foreach ($commits as $commit) {
  635.             foreach ($commit->getData() as $data) {
  636.                 // skip clone action, otherwise the payload would contain all data
  637.                 if ($data->getAction() === 'clone' || $data->getPayload() === null) {
  638.                     continue;
  639.                 }
  640.                 $definition $this->registry->getByEntityName($data->getEntityName());
  641.                 $id $data->getEntityId();
  642.                 $id $this->addVersionToPayload($id$definitionDefaults::LIVE_VERSION);
  643.                 $payload $this->addVersionToPayload($data->getPayload(), $definitionDefaults::LIVE_VERSION);
  644.                 $new[] = [
  645.                     'entityId' => $id,
  646.                     'payload' => Json::encode($payload),
  647.                     'userId' => $data->getUserId(),
  648.                     'integrationId' => $data->getIntegrationId(),
  649.                     'entityName' => $data->getEntityName(),
  650.                     'action' => $data->getAction(),
  651.                     'createdAt' => (new \DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT),
  652.                 ];
  653.             }
  654.         }
  655.         $commit = [
  656.             'versionId' => Defaults::LIVE_VERSION,
  657.             'data' => $new,
  658.             'userId' => $writeContext->getContext()->getSource() instanceof AdminApiSource $writeContext->getContext()->getSource()->getUserId() : null,
  659.             'isMerge' => true,
  660.             'message' => 'merge commit ' . (new \DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT),
  661.         ];
  662.         // create new version commit for merge commit
  663.         $this->entityWriter->insert($this->versionCommitDefinition, [$commit], $writeContext);
  664.         // delete version
  665.         $this->entityWriter->delete($this->versionDefinition, [['id' => $versionId]], $writeContext);
  666.     }
  667.     private function deleteClones(VersionCommitCollection $commitsWriteContext $versionContextstring $versionId): void
  668.     {
  669.         $handled = [];
  670.         foreach ($commits as $commit) {
  671.             foreach ($commit->getData() as $data) {
  672.                 $definition $this->registry->getByEntityName($data->getEntityName());
  673.                 $entity = [
  674.                     'definition' => $definition->getEntityName(),
  675.                     'primary' => $data->getEntityId(),
  676.                 ];
  677.                 // deduplicate to prevent deletion errors
  678.                 $entityKey md5(Json::encode($entity));
  679.                 if (isset($handled[$entityKey])) {
  680.                     continue;
  681.                 }
  682.                 $handled[$entityKey] = $entity;
  683.                 $primary $this->addVersionToPayload($data->getEntityId(), $definition$versionId);
  684.                 $this->entityWriter->delete($definition, [$primary], $versionContext);
  685.             }
  686.         }
  687.     }
  688. }