Completed
Pull Request — 2.0 (#959)
by Kévin
02:58
created

EagerLoadingExtension::getSerializerGroups()   D

Complexity

Conditions 9
Paths 26

Size

Total Lines 32
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 32
rs 4.909
c 0
b 0
f 0
cc 9
eloc 18
nc 26
nop 3
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\QueryNameGeneratorInterface;
17
use ApiPlatform\Core\Exception\PropertyNotFoundException;
18
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
19
use ApiPlatform\Core\Exception\RuntimeException;
20
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
21
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
22
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
23
use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
24
use Doctrine\ORM\Mapping\ClassMetadataInfo;
25
use Doctrine\ORM\QueryBuilder;
26
use Symfony\Component\HttpFoundation\RequestStack;
27
28
/**
29
 * Eager loads relations.
30
 *
31
 * @author Charles Sarrazin <[email protected]>
32
 * @author Kévin Dunglas <[email protected]>
33
 * @author Antoine Bluchet <[email protected]>
34
 * @author Baptiste Meyer <[email protected]>
35
 */
36
final class EagerLoadingExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
37
{
38
    private $propertyNameCollectionFactory;
39
    private $propertyMetadataFactory;
40
    private $resourceMetadataFactory;
41
    private $maxJoins;
42
    private $forceEager;
43
    private $serializerContextBuilder;
44
    private $requestStack;
45
46
    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, int $maxJoins = 30, bool $forceEager = true, RequestStack $requestStack = null, SerializerContextBuilderInterface $serializerContextBuilder = null)
47
    {
48
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
49
        $this->propertyMetadataFactory = $propertyMetadataFactory;
50
        $this->resourceMetadataFactory = $resourceMetadataFactory;
51
        $this->maxJoins = $maxJoins;
52
        $this->forceEager = $forceEager;
53
        $this->serializerContextBuilder = $serializerContextBuilder;
54
        $this->requestStack = $requestStack;
55
    }
56
57
    /**
58
     * {@inheritdoc}
59
     */
60
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
61
    {
62
        $options = [];
63
64
        if (null !== $operationName) {
65
            $options = ['collection_operation_name' => $operationName];
66
        }
67
68
        $forceEager = $this->isForceEager($resourceClass, $options);
69
70
        $groups = $this->getSerializerGroups($resourceClass, $options, 'normalization_context');
71
72
        $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $queryBuilder->getRootAliases()[0], $groups);
73
    }
74
75
    /**
76
     * {@inheritdoc}
77
     * The context may contain serialization groups which helps defining joined entities that are readable.
78
     */
79
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
80
    {
81
        $options = [];
82
83
        if (null !== $operationName) {
84
            $options = ['item_operation_name' => $operationName];
85
        }
86
87
        $forceEager = $this->isForceEager($resourceClass, $options);
88
89
        if (isset($context['groups'])) {
90
            $groups = ['serializer_groups' => $context['groups']];
91
        } elseif (isset($context['resource_class'])) {
92
            $groups = $this->getSerializerGroups($context['resource_class'], $options, isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context');
93
        } else {
94
            $groups = $this->getSerializerGroups($resourceClass, $options, 'normalization_context');
95
        }
96
97
        $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $queryBuilder->getRootAliases()[0], $groups);
98
    }
99
100
    /**
101
     * Joins relations to eager load.
102
     *
103
     * @param QueryBuilder                $queryBuilder
104
     * @param QueryNameGeneratorInterface $queryNameGenerator
105
     * @param string                      $resourceClass
106
     * @param bool                        $forceEager
107
     * @param string                      $parentAlias
108
     * @param array                       $propertyMetadataOptions
109
     * @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
110
     * @param int                         $joinCount               the number of joins
111
     *
112
     * @throws RuntimeException when the max number of joins has been reached
113
     */
114
    private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, string $parentAlias, array $propertyMetadataOptions = [], bool $wasLeftJoin = false, int &$joinCount = 0)
115
    {
116
        if ($joinCount > $this->maxJoins) {
117
            throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary.');
118
        }
119
120
        $entityManager = $queryBuilder->getEntityManager();
121
        $classMetadata = $entityManager->getClassMetadata($resourceClass);
122
123
        foreach ($classMetadata->associationMappings as $association => $mapping) {
124
            try {
125
                $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $association, $propertyMetadataOptions);
126
            } catch (PropertyNotFoundException $propertyNotFoundException) {
127
                //skip properties not found
128
                continue;
129
            } catch (ResourceClassNotFoundException $resourceClassNotFoundException) {
130
                //skip associations that are not resource classes
131
                continue;
132
            }
133
134
            if (false === $forceEager && ClassMetadataInfo::FETCH_EAGER !== $mapping['fetch']) {
135
                continue;
136
            }
137
138
            if (false === $propertyMetadata->isReadableLink() || false === $propertyMetadata->isReadable()) {
139
                continue;
140
            }
141
142
            $isNullable = $mapping['joinColumns'][0]['nullable'] ?? true;
143
            if (false !== $wasLeftJoin || true === $isNullable) {
144
                $method = 'leftJoin';
145
            } else {
146
                $method = 'innerJoin';
147
            }
148
149
            $associationAlias = $queryNameGenerator->generateJoinAlias($association);
150
            $queryBuilder->{$method}(sprintf('%s.%s', $parentAlias, $association), $associationAlias);
151
            ++$joinCount;
152
153
            try {
154
                $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyMetadataOptions);
155
            } catch (ResourceClassNotFoundException $resourceClassNotFoundException) {
156
                continue;
157
            }
158
159
            if ($mapping['targetEntity'] === $resourceClass) {
160
                $queryBuilder->addSelect($associationAlias);
161
                continue;
162
            }
163
164
            $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $associationAlias, $propertyMetadataOptions, $method === 'leftJoin', $joinCount);
165
        }
166
    }
167
168
    private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions)
169
    {
170
        $select = [];
171
        $entityManager = $queryBuilder->getEntityManager();
172
        $targetClassMetadata = $entityManager->getClassMetadata($entity);
173
174
        foreach ($this->propertyNameCollectionFactory->create($entity) as $property) {
175
            $propertyMetadata = $this->propertyMetadataFactory->create($entity, $property, $propertyMetadataOptions);
176
177
            if (true === $propertyMetadata->isIdentifier()) {
178
                $select[] = $property;
179
                continue;
180
            }
181
182
            //the field test allows to add methods to a Resource which do not reflect real database fields
183
            if (true === $targetClassMetadata->hasField($property) && true === $propertyMetadata->isReadable()) {
184
                $select[] = $property;
185
            }
186
        }
187
188
        $queryBuilder->addSelect(sprintf('partial %s.{%s}', $associationAlias, implode(',', $select)));
189
    }
190
191
    /**
192
     * Gets serializer groups if available, if not it returns the $options array.
193
     *
194
     * @param string $resourceClass
195
     * @param array  $options       represents the operation name so that groups are the one of the specific operation
196
     * @param string $context       normalization_context or denormalization_context
197
     *
198
     * @return array
199
     */
200
    private function getSerializerGroups(string $resourceClass, array $options, string $context): array
201
    {
202
        $request = null;
203
204
        if (null !== $this->requestStack && null !== $this->serializerContextBuilder) {
205
            $request = $this->requestStack->getCurrentRequest();
206
        }
207
208
        if (null !== $this->serializerContextBuilder && null !== $request) {
209
            $contextFromRequest = $this->serializerContextBuilder->createFromRequest($request, $context === 'normalization_context');
210
211
            if (isset($contextFromRequest['groups'])) {
212
                return ['serializer_groups' => $contextFromRequest['groups']];
213
            }
214
        }
215
216
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
217
218
        if (isset($options['collection_operation_name'])) {
219
            $context = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], $context, null, true);
220
        } elseif (isset($options['item_operation_name'])) {
221
            $context = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], $context, null, true);
222
        } else {
223
            $context = $resourceMetadata->getAttribute($context);
224
        }
225
226
        if (empty($context['groups'])) {
227
            return $options;
228
        }
229
230
        return ['serializer_groups' => $context['groups']];
231
    }
232
233
    /**
234
     * Does an operation force eager?
235
     *
236
     * @param string $resourceClass
237
     * @param array  $options
238
     *
239
     * @return bool
240
     */
241
    private function isForceEager(string $resourceClass, array $options): bool
242
    {
243
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
244
245
        if (isset($options['collection_operation_name'])) {
246
            $forceEager = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], 'force_eager', null, true);
247
        } elseif (isset($options['item_operation_name'])) {
248
            $forceEager = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], 'force_eager', null, true);
249
        } else {
250
            $forceEager = $resourceMetadata->getAttribute('force_eager');
251
        }
252
253
        return is_bool($forceEager) ? $forceEager : $this->forceEager;
254
    }
255
}
256