Passed
Push — master ( 8bd912...d93388 )
by Alan
06:58 queued 02:20
created

PaginationExtension.php$0 ➔ getPagination()   D

Complexity

Conditions 19

Size

Total Lines 63

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 19
c 0
b 0
f 0
dl 0
loc 63
rs 4.5166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\AbstractPaginator;
17
use ApiPlatform\Core\Bridge\Doctrine\Orm\Paginator;
18
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryChecker;
19
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
20
use ApiPlatform\Core\DataProvider\Pagination;
21
use ApiPlatform\Core\Exception\InvalidArgumentException;
22
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
23
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
24
use Doctrine\Common\Persistence\ManagerRegistry;
25
use Doctrine\ORM\QueryBuilder;
26
use Doctrine\ORM\Tools\Pagination\CountWalker;
27
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
28
use Symfony\Component\HttpFoundation\Request;
29
use Symfony\Component\HttpFoundation\RequestStack;
30
31
/**
32
 * Applies pagination on the Doctrine query for resource collection when enabled.
33
 *
34
 * @author Kévin Dunglas <[email protected]>
35
 * @author Samuel ROZE <[email protected]>
36
 */
37
final class PaginationExtension implements ContextAwareQueryResultCollectionExtensionInterface
38
{
39
    private $managerRegistry;
40
    private $requestStack;
41
    /**
42
     * @var ResourceMetadataFactoryInterface
43
     */
44
    private $resourceMetadataFactory;
45
    private $enabled;
46
    private $clientEnabled;
47
    private $clientItemsPerPage;
48
    private $itemsPerPage;
49
    private $pageParameterName;
50
    private $enabledParameterName;
51
    private $itemsPerPageParameterName;
52
    private $maximumItemPerPage;
53
    private $partial;
54
    private $clientPartial;
55
    private $partialParameterName;
56
    /**
57
     * @var Pagination|null
58
     */
59
    private $pagination;
60
61
    /**
62
     * @param ResourceMetadataFactoryInterface|RequestStack $resourceMetadataFactory
63
     * @param Pagination|ResourceMetadataFactoryInterface   $pagination
64
     */
65
    public function __construct(ManagerRegistry $managerRegistry, /* ResourceMetadataFactoryInterface */ $resourceMetadataFactory, /* Pagination */ $pagination)
66
    {
67
        if ($resourceMetadataFactory instanceof RequestStack && $pagination instanceof ResourceMetadataFactoryInterface) {
68
            @trigger_error(sprintf('Passing an instance of "%s" as second argument of "%s" is deprecated since API Platform 2.4 and will not be possible anymore in API Platform 3. Pass an instance of "%s" instead.', RequestStack::class, self::class, ResourceMetadataFactoryInterface::class), E_USER_DEPRECATED);
69
            @trigger_error(sprintf('Passing an instance of "%s" as third argument of "%s" is deprecated since API Platform 2.4 and will not be possible anymore in API Platform 3. Pass an instance of "%s" instead.', ResourceMetadataFactoryInterface::class, self::class, Pagination::class), E_USER_DEPRECATED);
70
71
            $this->requestStack = $resourceMetadataFactory;
72
            $resourceMetadataFactory = $pagination;
73
            $pagination = null;
74
75
            $args = \array_slice(\func_get_args(), 3);
76
            $legacyPaginationArgs = [
77
                ['arg_name' => 'enabled', 'type' => 'bool', 'default' => true],
78
                ['arg_name' => 'clientEnabled', 'type' => 'bool', 'default' => false],
79
                ['arg_name' => 'clientItemsPerPage', 'type' => 'bool', 'default' => false],
80
                ['arg_name' => 'itemsPerPage', 'type' => 'int', 'default' => 30],
81
                ['arg_name' => 'pageParameterName', 'type' => 'string', 'default' => 'page'],
82
                ['arg_name' => 'enabledParameterName', 'type' => 'string', 'default' => 'pagination'],
83
                ['arg_name' => 'itemsPerPageParameterName', 'type' => 'string', 'default' => 'itemsPerPage'],
84
                ['arg_name' => 'maximumItemPerPage', 'type' => 'int', 'default' => null],
85
                ['arg_name' => 'partial', 'type' => 'bool', 'default' => false],
86
                ['arg_name' => 'clientPartial', 'type' => 'bool', 'default' => false],
87
                ['arg_name' => 'partialParameterName', 'type' => 'string', 'default' => 'partial'],
88
            ];
89
90
            foreach ($legacyPaginationArgs as $pos => $arg) {
91
                if (\array_key_exists($pos, $args)) {
92
                    @trigger_error(sprintf('Passing "$%s" arguments is deprecated since API Platform 2.4 and will not be possible anymore in API Platform 3. Pass an instance of "%s" as third argument instead.', implode('", "$', array_column($legacyPaginationArgs, 'arg_name')), Paginator::class), E_USER_DEPRECATED);
93
94
                    if (!((null === $arg['default'] && null === $args[$pos]) || \call_user_func("is_{$arg['type']}", $args[$pos]))) {
95
                        throw new InvalidArgumentException(sprintf('The "$%s" argument is expected to be a %s%s.', $arg['arg_name'], $arg['type'], null === $arg['default'] ? ' or null' : ''));
96
                    }
97
98
                    $value = $args[$pos];
99
                } else {
100
                    $value = $arg['default'];
101
                }
102
103
                $this->{$arg['arg_name']} = $value;
104
            }
105
        } elseif (!$resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) {
106
            throw new InvalidArgumentException(sprintf('The "$resourceMetadataFactory" argument is expected to be an implementation of the "%s" interface.', ResourceMetadataFactoryInterface::class));
107
        } elseif (!$pagination instanceof Pagination) {
108
            throw new InvalidArgumentException(sprintf('The "$pagination" argument is expected to be an instance of the "%s" class.', Pagination::class));
109
        }
110
111
        $this->managerRegistry = $managerRegistry;
112
        $this->resourceMetadataFactory = $resourceMetadataFactory;
113
        $this->pagination = $pagination;
114
    }
115
116
    /**
117
     * {@inheritdoc}
118
     */
119
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null, array $context = [])
120
    {
121
        if (null === $pagination = $this->getPagination($queryBuilder, $resourceClass, $operationName, $context)) {
122
            return;
123
        }
124
125
        [$offset, $limit] = $pagination;
126
127
        $queryBuilder
128
            ->setFirstResult($offset)
129
            ->setMaxResults($limit);
130
    }
131
132
    /**
133
     * {@inheritdoc}
134
     */
135
    public function supportsResult(string $resourceClass, string $operationName = null, array $context = []): bool
136
    {
137
        if ($context['graphql_operation_name'] ?? false) {
138
            return $this->pagination->isGraphQlEnabled($resourceClass, $operationName, $context);
0 ignored issues
show
Bug introduced by
The method isGraphQlEnabled() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

138
            return $this->pagination->/** @scrutinizer ignore-call */ isGraphQlEnabled($resourceClass, $operationName, $context);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
139
        }
140
141
        if (null === $this->requestStack) {
142
            return $this->pagination->isEnabled($resourceClass, $operationName, $context);
143
        }
144
145
        if (null === $request = $this->requestStack->getCurrentRequest()) {
146
            return false;
147
        }
148
149
        return $this->isPaginationEnabled($request, $this->resourceMetadataFactory->create($resourceClass), $operationName);
150
    }
151
152
    /**
153
     * {@inheritdoc}
154
     */
155
    public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null, array $context = [])
156
    {
157
        $query = $queryBuilder->getQuery();
158
159
        // Only one alias, without joins, disable the DISTINCT on the COUNT
160
        if (1 === \count($queryBuilder->getAllAliases())) {
161
            $query->setHint(CountWalker::HINT_DISTINCT, false);
162
        }
163
164
        $doctrineOrmPaginator = new DoctrineOrmPaginator($query, $this->shouldDoctrinePaginatorFetchJoinCollection($queryBuilder, $resourceClass, $operationName));
165
        $doctrineOrmPaginator->setUseOutputWalkers($this->shouldDoctrinePaginatorUseOutputWalkers($queryBuilder, $resourceClass, $operationName));
166
167
        if (null === $this->requestStack) {
168
            $isPartialEnabled = $this->pagination->isPartialEnabled($resourceClass, $operationName, $context);
169
        } else {
170
            $isPartialEnabled = $this->isPartialPaginationEnabled(
171
                $this->requestStack->getCurrentRequest(),
172
                null === $resourceClass ? null : $this->resourceMetadataFactory->create($resourceClass),
173
                $operationName
174
            );
175
        }
176
177
        if ($isPartialEnabled) {
178
            return new class($doctrineOrmPaginator) extends AbstractPaginator {
179
            };
180
        }
181
182
        return new Paginator($doctrineOrmPaginator);
183
    }
184
185
    /**
186
     * @throws InvalidArgumentException
187
     */
188
    private function getPagination(QueryBuilder $queryBuilder, string $resourceClass, ?string $operationName, array $context): ?array
189
    {
190
        $request = null;
191
        if (null !== $this->requestStack && null === $request = $this->requestStack->getCurrentRequest()) {
192
            return null;
193
        }
194
195
        if (null === $request) {
196
            if (!$this->pagination->isEnabled($resourceClass, $operationName, $context)) {
197
                return null;
198
            }
199
200
            if (($context['graphql_operation_name'] ?? false) && !$this->pagination->isGraphQlEnabled($resourceClass, $operationName, $context)) {
201
                return null;
202
            }
203
204
            $context = $this->addCountToContext($queryBuilder, $context);
205
206
            return \array_slice($this->pagination->getPagination($resourceClass, $operationName, $context), 1);
207
        }
208
209
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
210
        if (!$this->isPaginationEnabled($request, $resourceMetadata, $operationName)) {
211
            return null;
212
        }
213
214
        $itemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_items_per_page', $this->itemsPerPage, true);
215
        if ($request->attributes->getBoolean('_graphql', false)) {
216
            $collectionArgs = $request->attributes->get('_graphql_collections_args', []);
217
            $itemsPerPage = $collectionArgs[$resourceClass]['first'] ?? $itemsPerPage;
218
        }
219
220
        if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_items_per_page', $this->clientItemsPerPage, true)) {
221
            $maxItemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'maximum_items_per_page', $this->maximumItemPerPage, true);
222
            $itemsPerPage = (int) $this->getPaginationParameter($request, $this->itemsPerPageParameterName, $itemsPerPage);
223
            $itemsPerPage = (null !== $maxItemsPerPage && $itemsPerPage >= $maxItemsPerPage ? $maxItemsPerPage : $itemsPerPage);
224
        }
225
226
        if (0 > $itemsPerPage) {
227
            throw new InvalidArgumentException('Item per page parameter should not be less than 0');
228
        }
229
230
        $page = (int) $this->getPaginationParameter($request, $this->pageParameterName, 1);
231
232
        if (1 > $page) {
233
            throw new InvalidArgumentException('Page should not be less than 1');
234
        }
235
236
        if (0 === $itemsPerPage && 1 < $page) {
237
            throw new InvalidArgumentException('Page should not be greater than 1 if itemsPerPage is equal to 0');
238
        }
239
240
        $firstResult = ($page - 1) * $itemsPerPage;
241
        if ($request->attributes->getBoolean('_graphql', false)) {
242
            $collectionArgs = $request->attributes->get('_graphql_collections_args', []);
243
            if (isset($collectionArgs[$resourceClass]['after'])) {
244
                $after = base64_decode($collectionArgs[$resourceClass]['after'], true);
245
                $firstResult = (int) $after;
246
                $firstResult = false === $after ? $firstResult : ++$firstResult;
247
            }
248
        }
249
250
        return [$firstResult, $itemsPerPage];
251
    }
252
253
    private function isPartialPaginationEnabled(Request $request = null, ResourceMetadata $resourceMetadata = null, string $operationName = null): bool
254
    {
255
        $enabled = $this->partial;
256
        $clientEnabled = $this->clientPartial;
257
258
        if ($resourceMetadata) {
259
            $enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_partial', $enabled, true);
260
261
            if ($request) {
262
                $clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_partial', $clientEnabled, true);
263
            }
264
        }
265
266
        if ($clientEnabled && $request) {
267
            $enabled = filter_var($this->getPaginationParameter($request, $this->partialParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
268
        }
269
270
        return $enabled;
271
    }
272
273
    private function isPaginationEnabled(Request $request, ResourceMetadata $resourceMetadata, string $operationName = null): bool
274
    {
275
        $enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', $this->enabled, true);
276
        $clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_enabled', $this->clientEnabled, true);
277
278
        if ($clientEnabled) {
279
            $enabled = filter_var($this->getPaginationParameter($request, $this->enabledParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
280
        }
281
282
        return $enabled;
283
    }
284
285
    private function getPaginationParameter(Request $request, string $parameterName, $default = null)
286
    {
287
        if (null !== $paginationAttribute = $request->attributes->get('_api_pagination')) {
288
            return \array_key_exists($parameterName, $paginationAttribute) ? $paginationAttribute[$parameterName] : $default;
289
        }
290
291
        return $request->query->get($parameterName, $default);
292
    }
293
294
    private function addCountToContext(QueryBuilder $queryBuilder, array $context): array
295
    {
296
        if (!($context['graphql_operation_name'] ?? false)) {
297
            return $context;
298
        }
299
300
        if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
301
            $context['count'] = (new DoctrineOrmPaginator($queryBuilder))->count();
302
        }
303
304
        return $context;
305
    }
306
307
    /**
308
     * Determines the value of the $fetchJoinCollection argument passed to the Doctrine ORM Paginator.
309
     */
310
    private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
311
    {
312
        if (null !== $resourceClass) {
313
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
314
315
            if (null !== $fetchJoinCollection = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_fetch_join_collection', null, true)) {
316
                return $fetchJoinCollection;
317
            }
318
        }
319
320
        /*
321
         * "Cannot count query which selects two FROM components, cannot make distinction"
322
         *
323
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/WhereInWalker.php#L81
324
         * @see https://github.com/doctrine/doctrine2/issues/2910
325
         */
326
        if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
327
            return false;
328
        }
329
330
        if (QueryChecker::hasJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
331
            return true;
332
        }
333
334
        // disable $fetchJoinCollection by default (performance)
335
        return false;
336
    }
337
338
    /**
339
     * Determines whether the Doctrine ORM Paginator should use output walkers.
340
     */
341
    private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
342
    {
343
        if (null !== $resourceClass) {
344
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
345
346
            if (null !== $useOutputWalkers = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_use_output_walkers', null, true)) {
347
                return $useOutputWalkers;
348
            }
349
        }
350
351
        /*
352
         * "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
353
         *
354
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L56
355
         */
356
        if (QueryChecker::hasHavingClause($queryBuilder)) {
357
            return true;
358
        }
359
360
        /*
361
         * "Cannot count query which selects two FROM components, cannot make distinction"
362
         *
363
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L64
364
         */
365
        if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
366
            return true;
367
        }
368
369
        /*
370
         * "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
371
         *
372
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L77
373
         */
374
        if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
375
            return true;
376
        }
377
378
        /*
379
         * "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
380
         *
381
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L150
382
         */
383
        if (QueryChecker::hasMaxResults($queryBuilder) && QueryChecker::hasOrderByOnFetchJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
384
            return true;
385
        }
386
387
        // Disable output walkers by default (performance)
388
        return false;
389
    }
390
}
391