Completed
Pull Request — 2.0 (#1041)
by Antoine
02:50
created

EagerLoadingExtension::applyToItem()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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