VersionManager   F
last analyzed

Complexity

Total Complexity 123

Size/Duplication

Total Lines 808
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 363
dl 0
loc 808
rs 2
c 0
b 0
f 0
wmc 123

22 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 13 1
A createVersion() 0 23 2
A delete() 0 7 1
A update() 0 8 1
A insert() 0 8 1
A upsert() 0 7 1
B updateVersionData() 0 42 6
A removePrimaryKey() 0 25 6
A getEntityForeignKeyName() 0 6 1
F filterPropertiesForClone() 0 136 36
B buildWrites() 0 40 9
C writeAuditLog() 0 94 13
B addTranslationToPayload() 0 37 7
A deleteClones() 0 23 4
B cloneEntity() 0 53 6
B addCloneAssociations() 0 52 10
A executeWrites() 0 14 4
A addVersionToPayload() 0 9 3
A getCommits() 0 22 2
A translationHasParent() 0 30 5
A clone() 0 9 1
A merge() 0 46 3

How to fix   Complexity   

Complex Class

Complex classes like VersionManager often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use VersionManager, and based on these observations, apply Extract Interface, too.

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