Completed
Push — master ( 6cddcd...0c634d )
by Kévin
11s
created

EagerLoadingExtension::applyToItem()   B

Complexity

Conditions 6
Paths 7

Size

Total Lines 22
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 22
rs 8.6737
c 0
b 0
f 0
cc 6
eloc 13
nc 7
nop 6
1
<?php
2
3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Extension;
13
14
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
15
use ApiPlatform\Core\Exception\RuntimeException;
16
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
17
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
18
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
19
use Doctrine\ORM\Mapping\ClassMetadataInfo;
20
use Doctrine\ORM\QueryBuilder;
21
22
/**
23
 * Eager loads relations.
24
 *
25
 * @author Charles Sarrazin <[email protected]>
26
 * @author Kévin Dunglas <[email protected]>
27
 * @author Antoine Bluchet <[email protected]>
28
 */
29
final class EagerLoadingExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
30
{
31
    private $propertyNameCollectionFactory;
32
    private $propertyMetadataFactory;
33
    private $resourceMetadataFactory;
34
    private $enabled;
35
    private $maxJoins;
36
    private $eagerOnly;
37
38
    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, bool $enabled = true, int $maxJoins = 30, bool $eagerOnly = true)
39
    {
40
        $this->propertyMetadataFactory = $propertyMetadataFactory;
41
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
42
        $this->resourceMetadataFactory = $resourceMetadataFactory;
43
        $this->enabled = $enabled;
44
        $this->maxJoins = $maxJoins;
45
        $this->eagerOnly = $eagerOnly;
46
    }
47
48
    /**
49
     * Gets serializer groups once if available, if not it returns the $options array.
50
     *
51
     * @param array  $options       represents the operation name so that groups are the one of the specific operation
52
     * @param string $resourceClass
53
     * @param string $context       normalization_context or denormalization_context
54
     *
55
     * @return string[]
56
     */
57
    private function getSerializerGroups(string $resourceClass, array $options, string $context): array
58
    {
59
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
60
61
        if (isset($options['collection_operation_name'])) {
62
            $context = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], $context, null, true);
63
        } elseif (isset($options['item_operation_name'])) {
64
            $context = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], $context, null, true);
65
        } else {
66
            $context = $resourceMetadata->getAttribute($context);
67
        }
68
69
        if (empty($context['groups'])) {
70
            return $options;
71
        }
72
73
        return ['serializer_groups' => $context['groups']];
74
    }
75
76
    /**
77
     * {@inheritdoc}
78
     */
79
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
80
    {
81
        if (false === $this->enabled) {
82
            return;
83
        }
84
85
        $options = [];
86
87
        if (null !== $operationName) {
88
            $options = ['collection_operation_name' => $operationName];
89
        }
90
91
        $groups = $this->getSerializerGroups($resourceClass, $options, 'normalization_context');
92
93
        $this->joinRelations($queryBuilder, $resourceClass, $groups);
94
    }
95
96
    /**
97
     * {@inheritdoc}
98
     * The context may contain serialization groups which helps defining joined entities that are readable.
99
     */
100
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
101
    {
102
        if (false === $this->enabled) {
103
            return;
104
        }
105
106
        $options = [];
107
108
        if (null !== $operationName) {
109
            $options = ['item_operation_name' => $operationName];
110
        }
111
112
        if (isset($context['groups'])) {
113
            $groups = ['serializer_groups' => $context['groups']];
114
        } elseif (isset($context['resource_class'])) {
115
            $groups = $this->getSerializerGroups($context['resource_class'], $options, isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context');
116
        } else {
117
            $groups = $this->getSerializerGroups($resourceClass, $options, 'normalization_context');
118
        }
119
120
        $this->joinRelations($queryBuilder, $resourceClass, $groups);
121
    }
122
123
    /**
124
     * Left joins relations to eager load.
125
     *
126
     * @param QueryBuilder $queryBuilder
127
     * @param string       $resourceClass
128
     * @param array        $propertyMetadataOptions
129
     * @param string       $originAlias             the current entity alias (first o, then a1, a2 etc.)
130
     * @param string       $relationAlias           the previous relation alias to keep it unique
131
     * @param bool         $wasLeftJoin             if the relation containing the new one had a left join, we have to force the new one to left join too
132
     * @param int          $joinCount               the number of joins
133
     *
134
     * @throws RuntimeException when the max number of joins has been reached
135
     */
136
    private function joinRelations(QueryBuilder $queryBuilder, string $resourceClass, array $propertyMetadataOptions = [], string $originAlias = 'o', string &$relationAlias = 'a', bool $wasLeftJoin = false, int &$joinCount = 0)
137
    {
138
        if ($joinCount > $this->maxJoins) {
139
            throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary.');
140
        }
141
142
        $entityManager = $queryBuilder->getEntityManager();
143
        $classMetadata = $entityManager->getClassMetadata($resourceClass);
144
        $j = 0;
145
        $i = 0;
146
147
        foreach ($classMetadata->associationMappings as $association => $mapping) {
148
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $association, $propertyMetadataOptions);
149
150
            if (true === $this->eagerOnly && ClassMetadataInfo::FETCH_EAGER !== $mapping['fetch']) {
151
                continue;
152
            }
153
154
            if (false === $propertyMetadata->isReadableLink() || false === $propertyMetadata->isReadable()) {
155
                continue;
156
            }
157
158
            if (false === $wasLeftJoin) {
159
                $joinColumns = $mapping['joinColumns'] ?? $mapping['joinTable']['joinColumns'] ?? null;
160
161
                if (null === $joinColumns) {
162
                    $method = 'leftJoin';
163
                } else {
164
                    $method = false === $joinColumns[0]['nullable'] ? 'innerJoin' : 'leftJoin';
165
                }
166
            } else {
167
                $method = 'leftJoin';
168
            }
169
170
            $associationAlias = $relationAlias.$i++;
171
            $queryBuilder->{$method}($originAlias.'.'.$association, $associationAlias);
172
            ++$joinCount;
173
            $select = [];
174
            $targetClassMetadata = $entityManager->getClassMetadata($mapping['targetEntity']);
175
176
            foreach ($this->propertyNameCollectionFactory->create($mapping['targetEntity']) as $property) {
177
                $propertyMetadata = $this->propertyMetadataFactory->create($mapping['targetEntity'], $property, $propertyMetadataOptions);
178
179
                if (true === $propertyMetadata->isIdentifier()) {
180
                    $select[] = $property;
181
                    continue;
182
                }
183
184
                //the field test allows to add methods to a Resource which do not reflect real database fields
185
                if (true === $targetClassMetadata->hasField($property) && true === $propertyMetadata->isReadable()) {
186
                    $select[] = $property;
187
                }
188
            }
189
190
            $queryBuilder->addSelect(sprintf('partial %s.{%s}', $associationAlias, implode(',', $select)));
191
192
            $relationAlias .= ++$j;
193
194
            $this->joinRelations($queryBuilder, $mapping['targetEntity'], $propertyMetadataOptions, $associationAlias, $relationAlias, $method === 'leftJoin', $joinCount);
195
        }
196
    }
197
}
198