EntityReader::loadOneToManyWithPagination()   C
last analyzed

Complexity

Conditions 13
Paths 216

Size

Total Lines 101
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 13
eloc 52
nc 216
nop 6
dl 0
loc 101
rs 5.5833
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Framework\DataAbstractionLayer\Dbal;
4
5
use Doctrine\DBAL\ArrayParameterType;
6
use Doctrine\DBAL\Connection;
7
use Psr\Log\LoggerInterface;
8
use Shopware\Core\Framework\Context;
9
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\Exception\ParentAssociationCanNotBeFetched;
10
use Shopware\Core\Framework\DataAbstractionLayer\Entity;
11
use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;
12
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
13
use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
14
use Shopware\Core\Framework\DataAbstractionLayer\Field\ChildrenAssociationField;
15
use Shopware\Core\Framework\DataAbstractionLayer\Field\Field;
16
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\CascadeDelete;
17
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Extension;
18
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Inherited;
19
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
20
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Runtime;
21
use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField;
22
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
23
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
24
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField;
25
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
26
use Shopware\Core\Framework\DataAbstractionLayer\Field\ParentAssociationField;
27
use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware;
28
use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslatedField;
29
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
30
use Shopware\Core\Framework\DataAbstractionLayer\Read\EntityReaderInterface;
31
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
32
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
33
use Shopware\Core\Framework\DataAbstractionLayer\Search\Parser\SqlQueryParser;
34
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
35
use Shopware\Core\Framework\Log\Package;
36
use Shopware\Core\Framework\Struct\ArrayEntity;
37
use Shopware\Core\Framework\Struct\ArrayStruct;
38
use Shopware\Core\Framework\Uuid\Uuid;
39
40
/**
41
 * @internal
42
 */
43
#[Package('core')]
44
class EntityReader implements EntityReaderInterface
45
{
46
    final public const INTERNAL_MAPPING_STORAGE = 'internal_mapping_storage';
47
    final public const FOREIGN_KEYS = 'foreignKeys';
48
    final public const MANY_TO_MANY_LIMIT_QUERY = 'many_to_many_limit_query';
49
50
    public function __construct(
51
        private readonly Connection $connection,
52
        private readonly EntityHydrator $hydrator,
53
        private readonly EntityDefinitionQueryHelper $queryHelper,
54
        private readonly SqlQueryParser $parser,
55
        private readonly CriteriaQueryBuilder $criteriaQueryBuilder,
56
        private readonly LoggerInterface $logger,
57
        private readonly CriteriaFieldsResolver $criteriaFieldsResolver
58
    ) {
59
    }
60
61
    /**
62
     * @return EntityCollection<Entity>
63
     */
64
    public function read(EntityDefinition $definition, Criteria $criteria, Context $context): EntityCollection
65
    {
66
        $criteria->resetSorting();
67
        $criteria->resetQueries();
68
69
        /** @var EntityCollection<Entity> $collectionClass */
70
        $collectionClass = $definition->getCollectionClass();
71
72
        $fields = $this->criteriaFieldsResolver->resolve($criteria, $definition);
73
74
        return $this->_read(
75
            $criteria,
76
            $definition,
77
            $context,
78
            new $collectionClass(),
79
            $definition->getFields()->getBasicFields(),
80
            true,
81
            $fields
82
        );
83
    }
84
85
    protected function getParser(): SqlQueryParser
86
    {
87
        return $this->parser;
88
    }
89
90
    /**
91
     * @param EntityCollection<Entity> $collection
92
     * @param array<string, mixed> $partial
93
     *
94
     * @return EntityCollection<Entity>
95
     */
96
    private function _read(
97
        Criteria $criteria,
98
        EntityDefinition $definition,
99
        Context $context,
100
        EntityCollection $collection,
101
        FieldCollection $fields,
102
        bool $performEmptySearch = false,
103
        array $partial = []
104
    ): EntityCollection {
105
        $hasFilters = !empty($criteria->getFilters()) || !empty($criteria->getPostFilters());
106
        $hasIds = !empty($criteria->getIds());
107
108
        if (!$performEmptySearch && !$hasFilters && !$hasIds) {
109
            return $collection;
110
        }
111
112
        if ($partial !== []) {
113
            $fields = $definition->getFields()->filter(function (Field $field) use (&$partial) {
114
                if ($field->getFlag(PrimaryKey::class)) {
115
                    $partial[$field->getPropertyName()] = [];
116
117
                    return true;
118
                }
119
120
                return isset($partial[$field->getPropertyName()]);
121
            });
122
        }
123
124
        // always add the criteria fields to the collection, otherwise we have conflicts between criteria.fields and criteria.association logic
125
        $fields = $this->addAssociationFieldsToCriteria($criteria, $definition, $fields);
126
127
        if ($definition->isInheritanceAware() && $criteria->hasAssociation('parent')) {
128
            throw new ParentAssociationCanNotBeFetched();
129
        }
130
131
        $rows = $this->fetch($criteria, $definition, $context, $fields, $partial);
132
133
        $collection = $this->hydrator->hydrate($collection, $definition->getEntityClass(), $definition, $rows, $definition->getEntityName(), $context, $partial);
134
135
        $collection = $this->fetchAssociations($criteria, $definition, $context, $collection, $fields, $partial);
136
137
        $hasIds = !empty($criteria->getIds());
138
        if ($hasIds && empty($criteria->getSorting())) {
139
            $collection->sortByIdArray($criteria->getIds());
140
        }
141
142
        return $collection;
143
    }
144
145
    /**
146
     * @param array<string, mixed> $partial
147
     */
148
    private function joinBasic(
149
        EntityDefinition $definition,
150
        Context $context,
151
        string $root,
152
        QueryBuilder $query,
153
        FieldCollection $fields,
154
        ?Criteria $criteria = null,
155
        array $partial = []
156
    ): void {
157
        $isPartial = $partial !== [];
158
        $filtered = $fields->filter(static function (Field $field) use ($isPartial, $partial) {
159
            if ($field->is(Runtime::class)) {
160
                return false;
161
            }
162
163
            if (!$isPartial || $field->getFlag(PrimaryKey::class)) {
164
                return true;
165
            }
166
167
            return isset($partial[$field->getPropertyName()]);
168
        });
169
170
        $parentAssociation = null;
171
172
        if ($definition->isInheritanceAware() && $context->considerInheritance()) {
173
            $parentAssociation = $definition->getFields()->get('parent');
174
175
            if ($parentAssociation !== null) {
176
                $this->queryHelper->resolveField($parentAssociation, $definition, $root, $query, $context);
177
            }
178
        }
179
180
        $addTranslation = false;
181
182
        /** @var Field $field */
183
        foreach ($filtered as $field) {
184
            // translated fields are handled after loop all together
185
            if ($field instanceof TranslatedField) {
186
                $this->queryHelper->resolveField($field, $definition, $root, $query, $context);
187
188
                $addTranslation = true;
189
190
                continue;
191
            }
192
193
            // self references can not be resolved if set to autoload, otherwise we get an endless loop
194
            if (!$field instanceof ParentAssociationField && $field instanceof AssociationField && $field->getAutoload() && $field->getReferenceDefinition() === $definition) {
195
                continue;
196
            }
197
198
            // many to one associations can be directly fetched in same query
199
            if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) {
200
                $reference = $field->getReferenceDefinition();
201
202
                $basics = $reference->getFields()->getBasicFields();
203
204
                $this->queryHelper->resolveField($field, $definition, $root, $query, $context);
205
206
                $alias = $root . '.' . $field->getPropertyName();
207
208
                $joinCriteria = null;
209
                if ($criteria && $criteria->hasAssociation($field->getPropertyName())) {
210
                    $joinCriteria = $criteria->getAssociation($field->getPropertyName());
211
                    $basics = $this->addAssociationFieldsToCriteria($joinCriteria, $reference, $basics);
212
                }
213
214
                $this->joinBasic($reference, $context, $alias, $query, $basics, $joinCriteria, $partial[$field->getPropertyName()] ?? []);
215
216
                continue;
217
            }
218
219
            // add sub select for many to many field
220
            if ($field instanceof ManyToManyAssociationField) {
221
                if ($this->isAssociationRestricted($criteria, $field->getPropertyName())) {
222
                    continue;
223
                }
224
225
                // requested a paginated, filtered or sorted list
226
227
                $this->addManyToManySelect($definition, $root, $field, $query, $context);
228
229
                continue;
230
            }
231
232
            // other associations like OneToManyAssociationField fetched lazy by additional query
233
            if ($field instanceof AssociationField) {
234
                continue;
235
            }
236
237
            if ($parentAssociation !== null
238
                && $field instanceof StorageAware
239
                && $field->is(Inherited::class)
240
                && $context->considerInheritance()
241
            ) {
242
                $parentAlias = $root . '.' . $parentAssociation->getPropertyName();
243
244
                // contains the field accessor for the child value (eg. `product.name`.`name`)
245
                $childAccessor = EntityDefinitionQueryHelper::escape($root) . '.'
246
                    . EntityDefinitionQueryHelper::escape($field->getStorageName());
247
248
                // contains the field accessor for the parent value (eg. `product.parent`.`name`)
249
                $parentAccessor = EntityDefinitionQueryHelper::escape($parentAlias) . '.'
250
                    . EntityDefinitionQueryHelper::escape($field->getStorageName());
251
252
                // contains the alias for the resolved field (eg. `product.name`)
253
                $fieldAlias = EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName());
254
255
                if ($field instanceof JsonField) {
256
                    // merged in hydrator
257
                    $parentFieldAlias = EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName() . '.inherited');
258
                    $query->addSelect(sprintf('%s as %s', $parentAccessor, $parentFieldAlias));
259
                }
260
                // add selection for resolved parent-child inheritance field
261
                $query->addSelect(sprintf('COALESCE(%s, %s) as %s', $childAccessor, $parentAccessor, $fieldAlias));
262
263
                continue;
264
            }
265
266
            // all other StorageAware fields are stored inside the main entity
267
            if ($field instanceof StorageAware) {
268
                $query->addSelect(
269
                    EntityDefinitionQueryHelper::escape($root) . '.'
270
                    . EntityDefinitionQueryHelper::escape($field->getStorageName()) . ' as '
271
                    . EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName())
272
                );
273
            }
274
        }
275
276
        if ($addTranslation) {
277
            $this->queryHelper->addTranslationSelect($root, $definition, $query, $context, $partial);
278
        }
279
    }
280
281
    /**
282
     * @param array<string, mixed> $partial
283
     *
284
     * @return list<array<string, mixed>>
285
     */
286
    private function fetch(Criteria $criteria, EntityDefinition $definition, Context $context, FieldCollection $fields, array $partial = []): array
287
    {
288
        $table = $definition->getEntityName();
289
290
        $query = $this->criteriaQueryBuilder->build(
291
            new QueryBuilder($this->connection),
292
            $definition,
293
            $criteria,
294
            $context
295
        );
296
297
        $this->joinBasic($definition, $context, $table, $query, $fields, $criteria, $partial);
298
299
        if (!empty($criteria->getIds())) {
300
            $this->queryHelper->addIdCondition($criteria, $definition, $query);
301
        }
302
303
        if ($criteria->getTitle()) {
304
            $query->setTitle($criteria->getTitle() . '::read');
305
        }
306
307
        return $query->executeQuery()->fetchAllAssociative();
308
    }
309
310
    /**
311
     * @param EntityCollection<Entity> $collection
312
     * @param array<string, mixed> $partial
313
     */
314
    private function loadManyToMany(
315
        Criteria $criteria,
316
        ManyToManyAssociationField $association,
317
        Context $context,
318
        EntityCollection $collection,
319
        array $partial
320
    ): void {
321
        $associationCriteria = $criteria->getAssociation($association->getPropertyName());
322
323
        if (!$associationCriteria->getTitle() && $criteria->getTitle()) {
324
            $associationCriteria->setTitle(
325
                $criteria->getTitle() . '::association::' . $association->getPropertyName()
326
            );
327
        }
328
329
        // check if the requested criteria is restricted (limit, offset, sorting, filtering)
330
        if ($this->isAssociationRestricted($criteria, $association->getPropertyName())) {
331
            // if restricted load paginated list of many to many
332
            $this->loadManyToManyWithCriteria($associationCriteria, $association, $context, $collection, $partial);
333
334
            return;
335
        }
336
337
        // otherwise the association is loaded in the root query of the entity as sub select which contains all ids
338
        // the ids are extracted in the entity hydrator (see: \Shopware\Core\Framework\DataAbstractionLayer\Dbal\EntityHydrator::extractManyToManyIds)
339
        $this->loadManyToManyOverExtension($associationCriteria, $association, $context, $collection, $partial);
340
    }
341
342
    private function addManyToManySelect(
343
        EntityDefinition $definition,
344
        string $root,
345
        ManyToManyAssociationField $field,
346
        QueryBuilder $query,
347
        Context $context
348
    ): void {
349
        $mapping = $field->getMappingDefinition();
350
351
        $versionCondition = '';
352
        if ($mapping->isVersionAware() && $definition->isVersionAware() && $field->is(CascadeDelete::class)) {
353
            $versionField = $definition->getEntityName() . '_version_id';
354
            $versionCondition = ' AND #alias#.' . $versionField . ' = #root#.version_id';
355
        }
356
357
        $source = EntityDefinitionQueryHelper::escape($root) . '.' . EntityDefinitionQueryHelper::escape($field->getLocalField());
358
        if ($field->is(Inherited::class) && $context->considerInheritance()) {
359
            $source = EntityDefinitionQueryHelper::escape($root) . '.' . EntityDefinitionQueryHelper::escape($field->getPropertyName());
360
        }
361
362
        $parameters = [
363
            '#alias#' => EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName() . '.mapping'),
364
            '#mapping_reference_column#' => EntityDefinitionQueryHelper::escape($field->getMappingReferenceColumn()),
365
            '#mapping_table#' => EntityDefinitionQueryHelper::escape($mapping->getEntityName()),
366
            '#mapping_local_column#' => EntityDefinitionQueryHelper::escape($field->getMappingLocalColumn()),
367
            '#root#' => EntityDefinitionQueryHelper::escape($root),
368
            '#source#' => $source,
369
            '#property#' => EntityDefinitionQueryHelper::escape($root . '.' . $field->getPropertyName() . '.id_mapping'),
370
        ];
371
372
        $query->addSelect(
373
            str_replace(
374
                array_keys($parameters),
375
                array_values($parameters),
376
                '(SELECT GROUP_CONCAT(HEX(#alias#.#mapping_reference_column#) SEPARATOR \'||\')
377
                  FROM #mapping_table# #alias#
378
                  WHERE #alias#.#mapping_local_column# = #source#'
379
                  . $versionCondition
380
                  . ' ) as #property#'
381
            )
382
        );
383
    }
384
385
    /**
386
     * @param EntityCollection<Entity> $collection
387
     *
388
     * @return array<string>
389
     */
390
    private function collectManyToManyIds(EntityCollection $collection, AssociationField $association): array
391
    {
392
        $ids = [];
393
        $property = $association->getPropertyName();
394
        /** @var Entity $struct */
395
        foreach ($collection as $struct) {
396
            /** @var ArrayStruct<string, mixed> $ext */
397
            $ext = $struct->getExtension(self::INTERNAL_MAPPING_STORAGE);
398
            /** @var array<string> $tmp */
399
            $tmp = $ext->get($property);
400
            foreach ($tmp as $id) {
401
                $ids[] = $id;
402
            }
403
        }
404
405
        return $ids;
406
    }
407
408
    /**
409
     * @param EntityCollection<Entity> $collection
410
     * @param array<string, mixed> $partial
411
     */
412
    private function loadOneToMany(
413
        Criteria $criteria,
414
        EntityDefinition $definition,
415
        OneToManyAssociationField $association,
416
        Context $context,
417
        EntityCollection $collection,
418
        array $partial
419
    ): void {
420
        $fieldCriteria = new Criteria();
421
        if ($criteria->hasAssociation($association->getPropertyName())) {
422
            $fieldCriteria = $criteria->getAssociation($association->getPropertyName());
423
        }
424
425
        if (!$fieldCriteria->getTitle() && $criteria->getTitle()) {
426
            $fieldCriteria->setTitle(
427
                $criteria->getTitle() . '::association::' . $association->getPropertyName()
428
            );
429
        }
430
431
        // association should not be paginated > load data over foreign key condition
432
        if ($fieldCriteria->getLimit() === null) {
433
            $this->loadOneToManyWithoutPagination($definition, $association, $context, $collection, $fieldCriteria, $partial);
434
435
            return;
436
        }
437
438
        // load association paginated > use internal counter loops
439
        $this->loadOneToManyWithPagination($definition, $association, $context, $collection, $fieldCriteria, $partial);
440
    }
441
442
    /**
443
     * @param EntityCollection<Entity> $collection
444
     * @param array<string, mixed> $partial
445
     */
446
    private function loadOneToManyWithoutPagination(
447
        EntityDefinition $definition,
448
        OneToManyAssociationField $association,
449
        Context $context,
450
        EntityCollection $collection,
451
        Criteria $fieldCriteria,
452
        array $partial
453
    ): void {
454
        $ref = $association->getReferenceDefinition()->getFields()->getByStorageName(
455
            $association->getReferenceField()
456
        );
457
458
        \assert($ref instanceof Field);
459
460
        $propertyName = $ref->getPropertyName();
461
        if ($association instanceof ChildrenAssociationField) {
462
            $propertyName = 'parentId';
463
        }
464
465
        // build orm property accessor to add field sortings and conditions `customer_address.customerId`
466
        $propertyAccessor = $association->getReferenceDefinition()->getEntityName() . '.' . $propertyName;
467
468
        $ids = array_values($collection->getIds());
469
470
        $isInheritanceAware = $definition->isInheritanceAware() && $context->considerInheritance();
471
472
        if ($isInheritanceAware) {
473
            $parentIds = array_values(\array_filter($collection->map(fn (Entity $entity) => $entity->get('parentId'))));
474
475
            $ids = array_unique([...$ids, ...$parentIds]);
476
        }
477
478
        $fieldCriteria->addFilter(new EqualsAnyFilter($propertyAccessor, $ids));
479
480
        $referenceClass = $association->getReferenceDefinition();
481
        /** @var EntityCollection<Entity> $collectionClass */
482
        $collectionClass = $referenceClass->getCollectionClass();
483
484
        if ($partial !== []) {
485
            // Make sure our collection index will be loaded
486
            $partial[$propertyName] = [];
487
            $collectionClass = EntityCollection::class;
488
        }
489
490
        $data = $this->_read(
491
            $fieldCriteria,
492
            $referenceClass,
493
            $context,
494
            new $collectionClass(),
495
            $referenceClass->getFields()->getBasicFields(),
496
            false,
497
            $partial
498
        );
499
500
        $grouped = [];
501
        foreach ($data as $entity) {
502
            $fk = $entity->get($propertyName);
503
504
            $grouped[$fk][] = $entity;
505
        }
506
507
        // assign loaded data to root entities
508
        foreach ($collection as $entity) {
509
            $structData = new $collectionClass();
510
            if (isset($grouped[$entity->getUniqueIdentifier()])) {
511
                $structData->fill($grouped[$entity->getUniqueIdentifier()]);
512
            }
513
514
            // assign data of child immediately
515
            if ($association->is(Extension::class)) {
516
                $entity->addExtension($association->getPropertyName(), $structData);
517
            } else {
518
                // otherwise the data will be assigned directly as properties
519
                $entity->assign([$association->getPropertyName() => $structData]);
520
            }
521
522
            if (!$association->is(Inherited::class) || $structData->count() > 0 || !$context->considerInheritance()) {
523
                continue;
524
            }
525
526
            // if association can be inherited by the parent and the struct data is empty, filter again for the parent id
527
            $structData = new $collectionClass();
528
            if (isset($grouped[$entity->get('parentId')])) {
529
                $structData->fill($grouped[$entity->get('parentId')]);
530
            }
531
532
            if ($association->is(Extension::class)) {
533
                $entity->addExtension($association->getPropertyName(), $structData);
534
535
                continue;
536
            }
537
            $entity->assign([$association->getPropertyName() => $structData]);
538
        }
539
    }
540
541
    /**
542
     * @param EntityCollection<Entity> $collection
543
     * @param array<string, mixed> $partial
544
     */
545
    private function loadOneToManyWithPagination(
546
        EntityDefinition $definition,
547
        OneToManyAssociationField $association,
548
        Context $context,
549
        EntityCollection $collection,
550
        Criteria $fieldCriteria,
551
        array $partial
552
    ): void {
553
        $isPartial = $partial !== [];
554
555
        $propertyAccessor = $this->buildOneToManyPropertyAccessor($definition, $association);
556
557
        // inject sorting for foreign key, otherwise the internal counter wouldn't work `order by customer_address.customer_id, other_sortings`
558
        $sorting = array_merge(
559
            [new FieldSorting($propertyAccessor, FieldSorting::ASCENDING)],
560
            $fieldCriteria->getSorting()
561
        );
562
563
        $fieldCriteria->resetSorting();
564
        $fieldCriteria->addSorting(...$sorting);
565
566
        $ids = array_values($collection->getIds());
567
568
        if ($isPartial) {
569
            // Make sure our collection index will be loaded
570
            $partial[$association->getPropertyName()] = [];
571
        }
572
573
        $isInheritanceAware = $definition->isInheritanceAware() && $context->considerInheritance();
574
575
        if ($isInheritanceAware) {
576
            $parentIds = array_values(\array_filter($collection->map(fn (Entity $entity) => $entity->get('parentId'))));
577
578
            $ids = array_unique([...$ids, ...$parentIds]);
579
        }
580
581
        $fieldCriteria->addFilter(new EqualsAnyFilter($propertyAccessor, $ids));
582
583
        $mapping = $this->fetchPaginatedOneToManyMapping($definition, $association, $context, $collection, $fieldCriteria);
584
585
        $ids = [];
586
        foreach ($mapping as $associationIds) {
587
            foreach ($associationIds as $associationId) {
588
                $ids[] = $associationId;
589
            }
590
        }
591
592
        $fieldCriteria->setIds(\array_filter($ids));
593
        $fieldCriteria->resetSorting();
594
        $fieldCriteria->resetFilters();
595
        $fieldCriteria->resetPostFilters();
596
597
        $referenceClass = $association->getReferenceDefinition();
598
        /** @var EntityCollection<Entity> $collectionClass */
599
        $collectionClass = $referenceClass->getCollectionClass();
600
601
        $data = $this->_read(
602
            $fieldCriteria,
603
            $referenceClass,
604
            $context,
605
            new $collectionClass(),
606
            $referenceClass->getFields()->getBasicFields(),
607
            false,
608
            $partial
609
        );
610
611
        // assign loaded reference collections to root entities
612
        /** @var Entity $entity */
613
        foreach ($collection as $entity) {
614
            // extract mapping ids for the current entity
615
            $mappingIds = $mapping[$entity->getUniqueIdentifier()] ?? [];
616
617
            $structData = $data->getList($mappingIds);
618
619
            // assign data of child immediately
620
            if ($association->is(Extension::class)) {
621
                $entity->addExtension($association->getPropertyName(), $structData);
622
            } else {
623
                $entity->assign([$association->getPropertyName() => $structData]);
624
            }
625
626
            if (!$association->is(Inherited::class) || $structData->count() > 0 || !$context->considerInheritance()) {
627
                continue;
628
            }
629
630
            $parentId = $entity->get('parentId');
631
632
            if ($parentId === null) {
633
                continue;
634
            }
635
636
            // extract mapping ids for the current entity
637
            $mappingIds = $mapping[$parentId];
638
639
            $structData = $data->getList($mappingIds);
640
641
            // assign data of child immediately
642
            if ($association->is(Extension::class)) {
643
                $entity->addExtension($association->getPropertyName(), $structData);
644
            } else {
645
                $entity->assign([$association->getPropertyName() => $structData]);
646
            }
647
        }
648
    }
649
650
    /**
651
     * @param EntityCollection<Entity> $collection
652
     * @param array<string, mixed> $partial
653
     */
654
    private function loadManyToManyOverExtension(
655
        Criteria $criteria,
656
        ManyToManyAssociationField $association,
657
        Context $context,
658
        EntityCollection $collection,
659
        array $partial
660
    ): void {
661
        // collect all ids of many to many association which already stored inside the struct instances
662
        $ids = $this->collectManyToManyIds($collection, $association);
663
664
        $criteria->setIds($ids);
665
666
        $referenceClass = $association->getToManyReferenceDefinition();
667
        /** @var EntityCollection<Entity> $collectionClass */
668
        $collectionClass = $referenceClass->getCollectionClass();
669
670
        $data = $this->_read(
671
            $criteria,
672
            $referenceClass,
673
            $context,
674
            new $collectionClass(),
675
            $referenceClass->getFields()->getBasicFields(),
676
            false,
677
            $partial
678
        );
679
680
        /** @var Entity $struct */
681
        foreach ($collection as $struct) {
682
            /** @var ArrayEntity $extension */
683
            $extension = $struct->getExtension(self::INTERNAL_MAPPING_STORAGE);
684
685
            // use assign function to avoid setter name building
686
            $structData = $data->getList(
687
                $extension->get($association->getPropertyName())
688
            );
689
690
            // if the association is added as extension (for plugins), we have to add the data as extension
691
            if ($association->is(Extension::class)) {
692
                $struct->addExtension($association->getPropertyName(), $structData);
693
            } else {
694
                $struct->assign([$association->getPropertyName() => $structData]);
695
            }
696
        }
697
    }
698
699
    /**
700
     * @param EntityCollection<Entity> $collection
701
     * @param array<string, mixed> $partial
702
     */
703
    private function loadManyToManyWithCriteria(
704
        Criteria $fieldCriteria,
705
        ManyToManyAssociationField $association,
706
        Context $context,
707
        EntityCollection $collection,
708
        array $partial
709
    ): void {
710
        $fields = $association->getToManyReferenceDefinition()->getFields();
711
        $reference = null;
712
        foreach ($fields as $field) {
713
            if (!$field instanceof ManyToManyAssociationField) {
714
                continue;
715
            }
716
717
            if ($field->getReferenceDefinition() !== $association->getReferenceDefinition()) {
718
                continue;
719
            }
720
721
            $reference = $field;
722
723
            break;
724
        }
725
726
        if (!$reference) {
727
            throw new \RuntimeException(
728
                sprintf(
729
                    'No inverse many to many association found, for association %s',
730
                    $association->getPropertyName()
731
                )
732
            );
733
        }
734
735
        // build inverse accessor `product.categories.id`
736
        $accessor = $association->getToManyReferenceDefinition()->getEntityName() . '.' . $reference->getPropertyName() . '.id';
737
738
        $fieldCriteria->addFilter(new EqualsAnyFilter($accessor, $collection->getIds()));
739
740
        $root = EntityDefinitionQueryHelper::escape(
741
            $association->getToManyReferenceDefinition()->getEntityName() . '.' . $reference->getPropertyName() . '.mapping'
742
        );
743
744
        $query = new QueryBuilder($this->connection);
745
        // to many selects results in a `group by` clause. In this case the order by parts will be executed with MIN/MAX aggregation
746
        // but at this point the order by will be moved to an sub select where we don't have a group state, the `state` prevents this behavior
747
        $query->addState(self::MANY_TO_MANY_LIMIT_QUERY);
748
749
        $query = $this->criteriaQueryBuilder->build(
750
            $query,
751
            $association->getToManyReferenceDefinition(),
752
            $fieldCriteria,
753
            $context
754
        );
755
756
        $localColumn = EntityDefinitionQueryHelper::escape($association->getMappingLocalColumn());
757
        $referenceColumn = EntityDefinitionQueryHelper::escape($association->getMappingReferenceColumn());
758
759
        $orderBy = '';
760
        $parts = $query->getQueryPart('orderBy');
761
        if (!empty($parts)) {
762
            $orderBy = ' ORDER BY ' . implode(', ', $parts);
763
            $query->resetQueryPart('orderBy');
764
        }
765
        // order by is handled in group_concat
766
        $fieldCriteria->resetSorting();
767
768
        $query->select([
769
            'LOWER(HEX(' . $root . '.' . $localColumn . ')) as `key`',
770
            'GROUP_CONCAT(LOWER(HEX(' . $root . '.' . $referenceColumn . ')) ' . $orderBy . ') as `value`',
771
        ]);
772
773
        $query->addGroupBy($root . '.' . $localColumn);
774
775
        if ($fieldCriteria->getLimit() !== null) {
776
            $limitQuery = $this->buildManyToManyLimitQuery($association);
777
778
            $params = [
779
                '#source_column#' => EntityDefinitionQueryHelper::escape($association->getMappingLocalColumn()),
780
                '#reference_column#' => EntityDefinitionQueryHelper::escape($association->getMappingReferenceColumn()),
781
                '#table#' => $root,
782
            ];
783
            $query->innerJoin(
784
                $root,
785
                '(' . $limitQuery . ')',
786
                'counter_table',
787
                str_replace(
788
                    array_keys($params),
789
                    array_values($params),
790
                    'counter_table.#source_column# = #table#.#source_column# AND
791
                     counter_table.#reference_column# = #table#.#reference_column# AND
792
                     counter_table.id_count <= :limit'
793
                )
794
            );
795
            $query->setParameter('limit', $fieldCriteria->getLimit());
796
797
            $this->connection->executeQuery('SET @n = 0; SET @c = null;');
798
        }
799
800
        $mapping = $query->executeQuery()->fetchAllKeyValue();
801
802
        $ids = [];
803
        foreach ($mapping as &$row) {
804
            $row = \array_filter(explode(',', (string) $row));
805
            foreach ($row as $id) {
806
                $ids[] = $id;
807
            }
808
        }
809
        unset($row);
810
811
        $fieldCriteria->setIds($ids);
812
813
        $referenceClass = $association->getToManyReferenceDefinition();
814
        /** @var EntityCollection<Entity> $collectionClass */
815
        $collectionClass = $referenceClass->getCollectionClass();
816
        $data = $this->_read(
817
            $fieldCriteria,
818
            $referenceClass,
819
            $context,
820
            new $collectionClass(),
821
            $referenceClass->getFields()->getBasicFields(),
822
            false,
823
            $partial
824
        );
825
826
        /** @var Entity $struct */
827
        foreach ($collection as $struct) {
828
            $structData = new $collectionClass();
829
830
            $id = $struct->getUniqueIdentifier();
831
832
            $parentId = $struct->has('parentId') ? $struct->get('parentId') : '';
833
834
            if (\array_key_exists($struct->getUniqueIdentifier(), $mapping)) {
835
                // filter mapping list of whole data array
836
                $structData = $data->getList($mapping[$id]);
837
838
                // sort list by ids if the criteria contained a sorting
839
                $structData->sortByIdArray($mapping[$id]);
840
            } elseif (\array_key_exists($parentId, $mapping) && $association->is(Inherited::class) && $context->considerInheritance()) {
841
                // filter mapping for the inherited parent association
842
                $structData = $data->getList($mapping[$parentId]);
843
844
                // sort list by ids if the criteria contained a sorting
845
                $structData->sortByIdArray($mapping[$parentId]);
846
            }
847
848
            // if the association is added as extension (for plugins), we have to add the data as extension
849
            if ($association->is(Extension::class)) {
850
                $struct->addExtension($association->getPropertyName(), $structData);
851
            } else {
852
                $struct->assign([$association->getPropertyName() => $structData]);
853
            }
854
        }
855
    }
856
857
    /**
858
     * @param EntityCollection<Entity> $collection
859
     *
860
     * @return array<string, string[]>
861
     */
862
    private function fetchPaginatedOneToManyMapping(
863
        EntityDefinition $definition,
864
        OneToManyAssociationField $association,
865
        Context $context,
866
        EntityCollection $collection,
867
        Criteria $fieldCriteria
868
    ): array {
869
        $sortings = $fieldCriteria->getSorting();
870
871
        // Remove first entry
872
        array_shift($sortings);
873
874
        // build query based on provided association criteria (sortings, search, filter)
875
        $query = $this->criteriaQueryBuilder->build(
876
            new QueryBuilder($this->connection),
877
            $association->getReferenceDefinition(),
878
            $fieldCriteria,
879
            $context
880
        );
881
882
        $foreignKey = $association->getReferenceField();
883
884
        if (!$association->getReferenceDefinition()->getField('id')) {
885
            throw new \RuntimeException(
886
                sprintf(
887
                    'Paginated to many association must have an id field. No id field found for association %s.%s',
888
                    $definition->getEntityName(),
889
                    $association->getPropertyName()
890
                )
891
            );
892
        }
893
894
        // build sql accessor for foreign key field in reference table `customer_address.customer_id`
895
        $sqlAccessor = EntityDefinitionQueryHelper::escape($association->getReferenceDefinition()->getEntityName()) . '.'
896
            . EntityDefinitionQueryHelper::escape($foreignKey);
897
898
        $query->select(
899
            [
900
                // build select with an internal counter loop, the counter loop will be reset if the foreign key changed (this is the reason for the sorting inject above)
901
                '@n:=IF(@c=' . $sqlAccessor . ', @n+1, IF(@c:=' . $sqlAccessor . ',1,1)) as id_count',
902
903
                // add select for foreign key for join condition
904
                $sqlAccessor,
905
906
                // add primary key select to group concat them
907
                EntityDefinitionQueryHelper::escape($association->getReferenceDefinition()->getEntityName()) . '.id',
908
            ]
909
        );
910
911
        foreach ($query->getQueryPart('orderBy') as $i => $sorting) {
912
            // The first order is the primary key
913
            if ($i === 0) {
914
                continue;
915
            }
916
            --$i;
917
918
            // Strip the ASC/DESC at the end of the sort
919
            $query->addSelect(\sprintf('%s as sort_%d', substr((string) $sorting, 0, -4), $i));
920
        }
921
922
        $root = EntityDefinitionQueryHelper::escape($definition->getEntityName());
923
924
        // create a wrapper query which select the root primary key and the grouped reference ids
925
        $wrapper = $this->connection->createQueryBuilder();
926
        $wrapper->select(
927
            [
928
                'LOWER(HEX(' . $root . '.id)) as id',
929
                'LOWER(HEX(child.id)) as child_id',
930
            ]
931
        );
932
933
        foreach ($sortings as $i => $sorting) {
934
            $wrapper->addOrderBy(sprintf('sort_%s', $i), $sorting->getDirection());
935
        }
936
937
        $wrapper->from($root, $root);
938
939
        // wrap query into a sub select to restrict the association count from the outer query
940
        $wrapper->leftJoin(
941
            $root,
942
            '(' . $query->getSQL() . ')',
943
            'child',
944
            'child.' . $foreignKey . ' = ' . $root . '.id AND id_count >= :offset AND id_count <= :limit'
945
        );
946
947
        // filter result to loaded root entities
948
        $wrapper->andWhere($root . '.id IN (:rootIds)');
949
950
        $bytes = $collection->map(
951
            fn (Entity $entity) => Uuid::fromHexToBytes($entity->getUniqueIdentifier())
952
        );
953
954
        if ($definition->isInheritanceAware() && $context->considerInheritance()) {
955
            /** @var Entity $entity */
956
            foreach ($collection->getElements() as $entity) {
957
                if ($entity->get('parentId')) {
958
                    $bytes[$entity->get('parentId')] = Uuid::fromHexToBytes($entity->get('parentId'));
959
                }
960
            }
961
        }
962
963
        $wrapper->setParameter('rootIds', $bytes, ArrayParameterType::STRING);
964
965
        $limit = $fieldCriteria->getOffset() + $fieldCriteria->getLimit();
966
        $offset = $fieldCriteria->getOffset() + 1;
967
968
        $wrapper->setParameter('limit', $limit);
969
        $wrapper->setParameter('offset', $offset);
970
971
        foreach ($query->getParameters() as $key => $value) {
972
            $type = $query->getParameterType($key);
973
            $wrapper->setParameter($key, $value, $type);
974
        }
975
976
        // initials the cursor and loop counter, pdo do not allow to execute SET and SELECT in one statement
977
        $this->connection->executeQuery('SET @n = 0; SET @c = null;');
978
979
        $rows = $wrapper->executeQuery()->fetchAllAssociative();
980
981
        $grouped = [];
982
        foreach ($rows as $row) {
983
            $id = (string) $row['id'];
984
985
            if (!isset($grouped[$id])) {
986
                $grouped[$id] = [];
987
            }
988
989
            if (empty($row['child_id'])) {
990
                continue;
991
            }
992
993
            $grouped[$id][] = (string) $row['child_id'];
994
        }
995
996
        return $grouped;
997
    }
998
999
    private function buildManyToManyLimitQuery(ManyToManyAssociationField $association): QueryBuilder
1000
    {
1001
        $table = EntityDefinitionQueryHelper::escape($association->getMappingDefinition()->getEntityName());
1002
1003
        $sourceColumn = EntityDefinitionQueryHelper::escape($association->getMappingLocalColumn());
1004
        $referenceColumn = EntityDefinitionQueryHelper::escape($association->getMappingReferenceColumn());
1005
1006
        $params = [
1007
            '#table#' => $table,
1008
            '#source_column#' => $sourceColumn,
1009
        ];
1010
1011
        $query = new QueryBuilder($this->connection);
1012
        $query->select([
1013
            str_replace(
1014
                array_keys($params),
1015
                array_values($params),
1016
                '@n:=IF(@c=#table#.#source_column#, @n+1, IF(@c:=#table#.#source_column#,1,1)) as id_count'
1017
            ),
1018
            $table . '.' . $referenceColumn,
1019
            $table . '.' . $sourceColumn,
1020
        ]);
1021
        $query->from($table, $table);
1022
        $query->orderBy($table . '.' . $sourceColumn);
1023
1024
        return $query;
1025
    }
1026
1027
    private function buildOneToManyPropertyAccessor(EntityDefinition $definition, OneToManyAssociationField $association): string
1028
    {
1029
        $reference = $association->getReferenceDefinition();
1030
1031
        if ($association instanceof ChildrenAssociationField) {
1032
            return $reference->getEntityName() . '.parentId';
1033
        }
1034
1035
        $ref = $reference->getFields()->getByStorageName(
1036
            $association->getReferenceField()
1037
        );
1038
1039
        if (!$ref) {
1040
            throw new \RuntimeException(
1041
                sprintf(
1042
                    'Reference field %s not found in definition %s for definition %s',
1043
                    $association->getReferenceField(),
1044
                    $reference->getEntityName(),
1045
                    $definition->getEntityName()
1046
                )
1047
            );
1048
        }
1049
1050
        return $reference->getEntityName() . '.' . $ref->getPropertyName();
1051
    }
1052
1053
    private function isAssociationRestricted(?Criteria $criteria, string $accessor): bool
1054
    {
1055
        if ($criteria === null) {
1056
            return false;
1057
        }
1058
1059
        if (!$criteria->hasAssociation($accessor)) {
1060
            return false;
1061
        }
1062
1063
        $fieldCriteria = $criteria->getAssociation($accessor);
1064
1065
        return $fieldCriteria->getOffset() !== null
1066
            || $fieldCriteria->getLimit() !== null
1067
            || !empty($fieldCriteria->getSorting())
1068
            || !empty($fieldCriteria->getFilters())
1069
            || !empty($fieldCriteria->getPostFilters())
1070
        ;
1071
    }
1072
1073
    private function addAssociationFieldsToCriteria(
1074
        Criteria $criteria,
1075
        EntityDefinition $definition,
1076
        FieldCollection $fields
1077
    ): FieldCollection {
1078
        foreach ($criteria->getAssociations() as $fieldName => $_fieldCriteria) {
1079
            $field = $definition->getFields()->get($fieldName);
1080
            if (!$field) {
1081
                $this->logger->warning(
1082
                    sprintf('Criteria association "%s" could not be resolved. Double check your Criteria!', $fieldName)
1083
                );
1084
1085
                continue;
1086
            }
1087
1088
            $fields->add($field);
1089
        }
1090
1091
        return $fields;
1092
    }
1093
1094
    /**
1095
     * @param EntityCollection<Entity> $collection
1096
     * @param array<string, mixed> $partial
1097
     */
1098
    private function loadToOne(
1099
        AssociationField $association,
1100
        Context $context,
1101
        EntityCollection $collection,
1102
        Criteria $criteria,
1103
        array $partial
1104
    ): void {
1105
        if (!$association instanceof OneToOneAssociationField && !$association instanceof ManyToOneAssociationField) {
1106
            return;
1107
        }
1108
1109
        if (!$criteria->hasAssociation($association->getPropertyName())) {
1110
            return;
1111
        }
1112
1113
        $associationCriteria = $criteria->getAssociation($association->getPropertyName());
1114
        if (!$associationCriteria->getAssociations()) {
1115
            return;
1116
        }
1117
1118
        if (!$associationCriteria->getTitle() && $criteria->getTitle()) {
1119
            $associationCriteria->setTitle(
1120
                $criteria->getTitle() . '::association::' . $association->getPropertyName()
1121
            );
1122
        }
1123
1124
        $related = \array_filter($collection->map(function (Entity $entity) use ($association) {
1125
            if ($association->is(Extension::class)) {
1126
                return $entity->getExtension($association->getPropertyName());
1127
            }
1128
1129
            return $entity->get($association->getPropertyName());
1130
        }));
1131
1132
        $referenceDefinition = $association->getReferenceDefinition();
1133
        $collectionClass = $referenceDefinition->getCollectionClass();
1134
1135
        if ($partial !== []) {
1136
            $collectionClass = EntityCollection::class;
1137
        }
1138
1139
        $fields = $referenceDefinition->getFields()->getBasicFields();
1140
        $fields = $this->addAssociationFieldsToCriteria($associationCriteria, $referenceDefinition, $fields);
1141
1142
        // This line removes duplicate entries, so after fetchAssociations the association must be reassigned
1143
        $relatedCollection = new $collectionClass();
1144
        if (!$relatedCollection instanceof EntityCollection) {
1145
            throw new \RuntimeException(sprintf('Collection class %s has to be an instance of EntityCollection', $collectionClass));
1146
        }
1147
1148
        $relatedCollection->fill($related);
1149
1150
        $this->fetchAssociations($associationCriteria, $referenceDefinition, $context, $relatedCollection, $fields, $partial);
1151
1152
        foreach ($collection as $entity) {
1153
            if ($association->is(Extension::class)) {
1154
                $item = $entity->getExtension($association->getPropertyName());
1155
            } else {
1156
                $item = $entity->get($association->getPropertyName());
1157
            }
1158
1159
            if (!$item instanceof Entity) {
1160
                continue;
1161
            }
1162
1163
            if ($association->is(Extension::class)) {
1164
                $extension = $relatedCollection->get($item->getUniqueIdentifier());
1165
                if ($extension !== null) {
1166
                    $entity->addExtension($association->getPropertyName(), $extension);
1167
                }
1168
1169
                continue;
1170
            }
1171
1172
            $entity->assign([
1173
                $association->getPropertyName() => $relatedCollection->get($item->getUniqueIdentifier()),
1174
            ]);
1175
        }
1176
    }
1177
1178
    /**
1179
     * @param EntityCollection<Entity> $collection
1180
     * @param array<string, mixed> $partial
1181
     *
1182
     * @return EntityCollection<Entity>
1183
     */
1184
    private function fetchAssociations(
1185
        Criteria $criteria,
1186
        EntityDefinition $definition,
1187
        Context $context,
1188
        EntityCollection $collection,
1189
        FieldCollection $fields,
1190
        array $partial
1191
    ): EntityCollection {
1192
        if ($collection->count() <= 0) {
1193
            return $collection;
1194
        }
1195
1196
        foreach ($fields as $association) {
1197
            if (!$association instanceof AssociationField) {
1198
                continue;
1199
            }
1200
1201
            if ($association instanceof OneToOneAssociationField || $association instanceof ManyToOneAssociationField) {
1202
                $this->loadToOne($association, $context, $collection, $criteria, $partial[$association->getPropertyName()] ?? []);
1203
1204
                continue;
1205
            }
1206
1207
            if ($association instanceof OneToManyAssociationField) {
1208
                $this->loadOneToMany($criteria, $definition, $association, $context, $collection, $partial[$association->getPropertyName()] ?? []);
1209
1210
                continue;
1211
            }
1212
1213
            if ($association instanceof ManyToManyAssociationField) {
1214
                $this->loadManyToMany($criteria, $association, $context, $collection, $partial[$association->getPropertyName()] ?? []);
1215
            }
1216
        }
1217
1218
        foreach ($collection as $struct) {
1219
            $struct->removeExtension(self::INTERNAL_MAPPING_STORAGE);
1220
        }
1221
1222
        return $collection;
1223
    }
1224
}
1225