Completed
Pull Request — master (#1100)
by Antoine
10:14 queued 06:57
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\Bridge\Doctrine\Orm\Util\ShouldEagerLoad;
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
29
/**
30
 * Eager loads relations.
31
 *
32
 * @author Charles Sarrazin <[email protected]>
33
 * @author Kévin Dunglas <[email protected]>
34
 * @author Antoine Bluchet <[email protected]>
35
 * @author Baptiste Meyer <[email protected]>
36
 */
37
final class EagerLoadingExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
38
{
39
    use ShouldEagerLoad;
40
41
    private $propertyNameCollectionFactory;
42
    private $propertyMetadataFactory;
43
    private $maxJoins;
44
    private $serializerContextBuilder;
45
    private $requestStack;
46
47
    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, int $maxJoins = 30, bool $forceEager = true, RequestStack $requestStack = null, SerializerContextBuilderInterface $serializerContextBuilder = null)
48
    {
49
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
50
        $this->propertyMetadataFactory = $propertyMetadataFactory;
51
        $this->resourceMetadataFactory = $resourceMetadataFactory;
52
        $this->maxJoins = $maxJoins;
53
        $this->forceEager = $forceEager;
54
        $this->serializerContextBuilder = $serializerContextBuilder;
55
        $this->requestStack = $requestStack;
56
    }
57
58
    /**
59
     * {@inheritdoc}
60
     */
61
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
62
    {
63
        $options = [];
64
65
        if (null !== $operationName) {
66
            $options = ['collection_operation_name' => $operationName];
67
        }
68
69
        $forceEager = $this->shouldOperationForceEager($resourceClass, $options);
70
71
        $groups = $this->getSerializerGroups($resourceClass, $options, 'normalization_context');
72
73
        $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $queryBuilder->getRootAliases()[0], $groups);
74
    }
75
76
    /**
77
     * {@inheritdoc}
78
     * The context may contain serialization groups which helps defining joined entities that are readable.
79
     */
80
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
81
    {
82
        $options = [];
83
84
        if (null !== $operationName) {
85
            $options = ['item_operation_name' => $operationName];
86
        }
87
88
        $forceEager = $this->shouldOperationForceEager($resourceClass, $options);
89
90
        if (isset($context['groups'])) {
91
            $groups = ['serializer_groups' => $context['groups']];
92
        } elseif (isset($context['resource_class'])) {
93
            $groups = $this->getSerializerGroups($context['resource_class'], $options, isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context');
94
        } else {
95
            $groups = $this->getSerializerGroups($resourceClass, $options, 'normalization_context');
96
        }
97
98
        $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $queryBuilder->getRootAliases()[0], $groups);
99
    }
100
101
    /**
102
     * Joins relations to eager load.
103
     *
104
     * @param QueryBuilder                $queryBuilder
105
     * @param QueryNameGeneratorInterface $queryNameGenerator
106
     * @param string                      $resourceClass
107
     * @param bool                        $forceEager
108
     * @param string                      $parentAlias
109
     * @param array                       $propertyMetadataOptions
110
     * @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
111
     * @param int                         $joinCount               the number of joins
112
     *
113
     * @throws RuntimeException when the max number of joins has been reached
114
     */
115
    private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, string $parentAlias, array $propertyMetadataOptions = [], bool $wasLeftJoin = false, int &$joinCount = 0)
116
    {
117
        if ($joinCount > $this->maxJoins) {
118
            throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary.');
119
        }
120
121
        $entityManager = $queryBuilder->getEntityManager();
122
        $classMetadata = $entityManager->getClassMetadata($resourceClass);
123
124
        foreach ($classMetadata->associationMappings as $association => $mapping) {
125
            try {
126
                $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $association, $propertyMetadataOptions);
127
            } catch (PropertyNotFoundException $propertyNotFoundException) {
128
                //skip properties not found
129
                continue;
130
            } catch (ResourceClassNotFoundException $resourceClassNotFoundException) {
131
                //skip associations that are not resource classes
132
                continue;
133
            }
134
135
            // We don't want to interfere with doctrine on this association
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
        if ($targetClassMetadata->subClasses) {
176
            $queryBuilder->addSelect($associationAlias);
177
        } else {
178
            foreach ($this->propertyNameCollectionFactory->create($entity) as $property) {
179
                $propertyMetadata = $this->propertyMetadataFactory->create($entity, $property, $propertyMetadataOptions);
180
181
                if (true === $propertyMetadata->isIdentifier()) {
182
                    $select[] = $property;
183
                    continue;
184
                }
185
186
                //the field test allows to add methods to a Resource which do not reflect real database fields
187
                if (true === $targetClassMetadata->hasField($property) && (true === $propertyMetadata->isFetchable() || true === $propertyMetadata->isReadable())) {
188
                    $select[] = $property;
189
                }
190
            }
191
192
            $queryBuilder->addSelect(sprintf('partial %s.{%s}', $associationAlias, implode(',', $select)));
193
        }
194
    }
195
196
    /**
197
     * Gets serializer groups if available, if not it returns the $options array.
198
     *
199
     * @param string $resourceClass
200
     * @param array  $options       represents the operation name so that groups are the one of the specific operation
201
     * @param string $context       normalization_context or denormalization_context
202
     *
203
     * @return array
204
     */
205
    private function getSerializerGroups(string $resourceClass, array $options, string $context): array
206
    {
207
        $request = null;
208
209
        if (null !== $this->requestStack && null !== $this->serializerContextBuilder) {
210
            $request = $this->requestStack->getCurrentRequest();
211
        }
212
213
        if (null !== $this->serializerContextBuilder && null !== $request) {
214
            $contextFromRequest = $this->serializerContextBuilder->createFromRequest($request, $context === 'normalization_context');
215
216
            if (isset($contextFromRequest['groups'])) {
217
                return ['serializer_groups' => $contextFromRequest['groups']];
218
            }
219
        }
220
221
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
222
223
        if (isset($options['collection_operation_name'])) {
224
            $context = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], $context, null, true);
225
        } elseif (isset($options['item_operation_name'])) {
226
            $context = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], $context, null, true);
227
        } else {
228
            $context = $resourceMetadata->getAttribute($context);
229
        }
230
231
        if (empty($context['groups'])) {
232
            return $options;
233
        }
234
235
        return ['serializer_groups' => $context['groups']];
236
    }
237
}
238