Completed
Push — master ( 9c4a11...131c91 )
by Kévin
06:24 queued 02:30
created

EagerLoadingExtension   C

Complexity

Total Complexity 51

Size/Duplication

Total Lines 252
Duplicated Lines 5.16 %

Coupling/Cohesion

Components 1
Dependencies 16

Importance

Changes 0
Metric Value
wmc 51
lcom 1
cbo 16
dl 13
loc 252
rs 6.0708
c 0
b 0
f 0

7 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 12 1
A applyToCollection() 0 16 2
A applyToItem() 0 21 4
C addSelect() 6 35 12
C getSerializerContext() 7 24 8
A getSerializerGroups() 0 8 2
C joinRelations() 0 75 22

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like EagerLoadingExtension 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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 EagerLoadingExtension, and based on these observations, apply Extract Interface, too.

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
declare(strict_types=1);
13
14
namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Extension;
15
16
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\EagerLoadingTrait;
17
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18
use ApiPlatform\Core\Exception\PropertyNotFoundException;
19
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
20
use ApiPlatform\Core\Exception\RuntimeException;
21
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
22
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
23
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
24
use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
25
use Doctrine\ORM\Mapping\ClassMetadataInfo;
26
use Doctrine\ORM\QueryBuilder;
27
use Symfony\Component\HttpFoundation\RequestStack;
28
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
29
30
/**
31
 * Eager loads relations.
32
 *
33
 * @author Charles Sarrazin <[email protected]>
34
 * @author Kévin Dunglas <[email protected]>
35
 * @author Antoine Bluchet <[email protected]>
36
 * @author Baptiste Meyer <[email protected]>
37
 */
38
final class EagerLoadingExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
39
{
40
    use EagerLoadingTrait;
41
42
    private $propertyNameCollectionFactory;
43
    private $propertyMetadataFactory;
44
    private $classMetadataFactory;
45
    private $maxJoins;
46
    private $serializerContextBuilder;
47
    private $requestStack;
48
49
    /**
50
     * @TODO move $fetchPartial after $forceEager (@soyuka) in 3.0
51
     */
52
    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, int $maxJoins = 30, bool $forceEager = true, RequestStack $requestStack = null, SerializerContextBuilderInterface $serializerContextBuilder = null, bool $fetchPartial = false, ClassMetadataFactoryInterface $classMetadataFactory = null)
53
    {
54
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
55
        $this->propertyMetadataFactory = $propertyMetadataFactory;
56
        $this->resourceMetadataFactory = $resourceMetadataFactory;
57
        $this->classMetadataFactory = $classMetadataFactory;
58
        $this->maxJoins = $maxJoins;
59
        $this->forceEager = $forceEager;
60
        $this->fetchPartial = $fetchPartial;
61
        $this->serializerContextBuilder = $serializerContextBuilder;
62
        $this->requestStack = $requestStack;
63
    }
64
65
    /**
66
     * {@inheritdoc}
67
     */
68
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
69
    {
70
        $options = [];
71
72
        if (null !== $operationName) {
73
            $options = ['collection_operation_name' => $operationName];
74
        }
75
76
        $forceEager = $this->shouldOperationForceEager($resourceClass, $options);
77
        $fetchPartial = $this->shouldOperationFetchPartial($resourceClass, $options);
78
        $serializerContext = $this->getSerializerContext($resourceClass, 'normalization_context', $options);
79
80
        $groups = $this->getSerializerGroups($options, $serializerContext);
81
82
        $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $groups, $serializerContext);
83
    }
84
85
    /**
86
     * {@inheritdoc}
87
     * The context may contain serialization groups which helps defining joined entities that are readable.
88
     */
89
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
90
    {
91
        $options = [];
92
93
        if (null !== $operationName) {
94
            $options = ['item_operation_name' => $operationName];
95
        }
96
97
        $forceEager = $this->shouldOperationForceEager($resourceClass, $options);
98
        $fetchPartial = $this->shouldOperationFetchPartial($resourceClass, $options);
99
        $contextType = isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context';
100
        $serializerContext = $this->getSerializerContext($context['resource_class'] ?? $resourceClass, $contextType, $options);
101
102
        if (isset($context['groups'])) {
103
            $groups = ['serializer_groups' => $context['groups']];
104
        } else {
105
            $groups = $this->getSerializerGroups($options, $serializerContext);
106
        }
107
108
        $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $groups, $serializerContext);
109
    }
110
111
    /**
112
     * Joins relations to eager load.
113
     *
114
     * @param QueryBuilder                $queryBuilder
115
     * @param QueryNameGeneratorInterface $queryNameGenerator
116
     * @param string                      $resourceClass
117
     * @param bool                        $forceEager
118
     * @param string                      $parentAlias
119
     * @param array                       $propertyMetadataOptions
120
     * @param array                       $context
121
     * @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
122
     * @param int                         $joinCount               the number of joins
123
     * @param int                         $currentDepth            the current max depth
124
     *
125
     * @throws RuntimeException when the max number of joins has been reached
126
     */
127
    private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $propertyMetadataOptions = [], array $context = [], bool $wasLeftJoin = false, int &$joinCount = 0, int $currentDepth = null)
128
    {
129
        if ($joinCount > $this->maxJoins) {
130
            throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary, or use the "max_depth" option of the Symfony serializer.');
131
        }
132
133
        $currentDepth = $currentDepth > 0 ? $currentDepth - 1 : $currentDepth;
134
        $entityManager = $queryBuilder->getEntityManager();
135
        $classMetadata = $entityManager->getClassMetadata($resourceClass);
136
        $attributesMetadata = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($resourceClass)->getAttributesMetadata() : null;
137
138
        foreach ($classMetadata->associationMappings as $association => $mapping) {
139
            //Don't join if max depth is enabled and the current depth limit is reached
140
            if (isset($context['enable_max_depth']) && 0 === $currentDepth) {
141
                continue;
142
            }
143
144
            try {
145
                $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $association, $propertyMetadataOptions);
146
            } catch (PropertyNotFoundException $propertyNotFoundException) {
147
                //skip properties not found
148
                continue;
149
            } catch (ResourceClassNotFoundException $resourceClassNotFoundException) {
150
                //skip associations that are not resource classes
151
                continue;
152
            }
153
154
            // We don't want to interfere with doctrine on this association
155
            if (false === $forceEager && ClassMetadataInfo::FETCH_EAGER !== $mapping['fetch']) {
156
                continue;
157
            }
158
159
            if (false === $propertyMetadata->isReadableLink() || false === $propertyMetadata->isReadable()) {
160
                continue;
161
            }
162
163
            $isNullable = $mapping['joinColumns'][0]['nullable'] ?? true;
164
            if (false !== $wasLeftJoin || true === $isNullable) {
165
                $method = 'leftJoin';
166
            } else {
167
                $method = 'innerJoin';
168
            }
169
170
            $associationAlias = $queryNameGenerator->generateJoinAlias($association);
171
            $queryBuilder->{$method}(sprintf('%s.%s', $parentAlias, $association), $associationAlias);
172
            ++$joinCount;
173
174
            if (true === $fetchPartial) {
175
                try {
176
                    $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyMetadataOptions);
177
                } catch (ResourceClassNotFoundException $resourceClassNotFoundException) {
178
                    continue;
179
                }
180
            } else {
181
                $queryBuilder->addSelect($associationAlias);
182
            }
183
184
            // Avoid recursion
185
            if ($mapping['targetEntity'] === $resourceClass) {
186
                $queryBuilder->addSelect($associationAlias);
187
                continue;
188
            }
189
190
            if (isset($attributesMetadata[$association])) {
191
                $maxDepth = $attributesMetadata[$association]->getMaxDepth();
192
193
                // The current depth is the lowest max depth available in the ancestor tree.
194
                if (null !== $maxDepth && (null === $currentDepth || $maxDepth < $currentDepth)) {
195
                    $currentDepth = $maxDepth;
196
                }
197
            }
198
199
            $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyMetadataOptions, $context, $method === 'leftJoin', $joinCount, $currentDepth);
200
        }
201
    }
202
203
    private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions)
204
    {
205
        $select = [];
206
        $entityManager = $queryBuilder->getEntityManager();
207
        $targetClassMetadata = $entityManager->getClassMetadata($entity);
208
        if ($targetClassMetadata->subClasses) {
209
            $queryBuilder->addSelect($associationAlias);
210
        } else {
211
            foreach ($this->propertyNameCollectionFactory->create($entity) as $property) {
212
                $propertyMetadata = $this->propertyMetadataFactory->create($entity, $property, $propertyMetadataOptions);
213
214
                if (true === $propertyMetadata->isIdentifier()) {
215
                    $select[] = $property;
216
                    continue;
217
                }
218
219
                //the field test allows to add methods to a Resource which do not reflect real database fields
220 View Code Duplication
                if ($targetClassMetadata->hasField($property) && (true === $propertyMetadata->getAttribute('fetchable') || $propertyMetadata->isReadable())) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
221
                    $select[] = $property;
222
                }
223
224
                if (array_key_exists($property, $targetClassMetadata->embeddedClasses)) {
225
                    foreach ($this->propertyNameCollectionFactory->create($targetClassMetadata->embeddedClasses[$property]['class']) as $embeddedProperty) {
226
                        $propertyMetadata = $this->propertyMetadataFactory->create($entity, $property, $propertyMetadataOptions);
227
                        $propertyName = "$property.$embeddedProperty";
228 View Code Duplication
                        if ($targetClassMetadata->hasField($propertyName) && (true === $propertyMetadata->getAttribute('fetchable') || $propertyMetadata->isReadable())) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
229
                            $select[] = $propertyName;
230
                        }
231
                    }
232
                }
233
            }
234
235
            $queryBuilder->addSelect(sprintf('partial %s.{%s}', $associationAlias, implode(',', $select)));
236
        }
237
    }
238
239
    /**
240
     * Gets serializer context.
241
     *
242
     * @param string $resourceClass
243
     * @param string $contextType   normalization_context or denormalization_context
244
     * @param array  $options       represents the operation name so that groups are the one of the specific operation
245
     *
246
     * @return array
247
     */
248
    private function getSerializerContext(string $resourceClass, string $contextType, array $options): array
249
    {
250
        $request = null;
251
252
        if (null !== $this->requestStack && null !== $this->serializerContextBuilder) {
253
            $request = $this->requestStack->getCurrentRequest();
254
        }
255
256
        if (null !== $this->serializerContextBuilder && null !== $request) {
257
            return $this->serializerContextBuilder->createFromRequest($request, 'normalization_context' === $contextType);
258
        }
259
260
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
261
262 View Code Duplication
        if (isset($options['collection_operation_name'])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
263
            $context = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], $contextType, null, true);
264
        } elseif (isset($options['item_operation_name'])) {
265
            $context = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], $contextType, null, true);
266
        } else {
267
            $context = $resourceMetadata->getAttribute($contextType);
268
        }
269
270
        return $context ? $context : [];
271
    }
272
273
    /**
274
     * Gets serializer groups if available, if not it returns the $options array.
275
     *
276
     * @param array $options represents the operation name so that groups are the one of the specific operation
277
     * @param array $context
278
     *
279
     * @return array
280
     */
281
    private function getSerializerGroups(array $options, array $context): array
282
    {
283
        if (empty($context['groups'])) {
284
            return $options;
285
        }
286
287
        return ['serializer_groups' => $context['groups']];
288
    }
289
}
290