PaginationExtension   B
last analyzed

Complexity

Total Complexity 52

Size/Duplication

Total Lines 359
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 162
c 0
b 0
f 0
dl 0
loc 359
rs 7.44
wmc 52

19 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 49 11
A applyToCollection() 0 11 2
getResult() 0 28 ?
A supportsResult() 0 15 4
A hp$0 ➔ getResult() 0 28 5
isPartialPaginationEnabled() 0 18 ?
A hp$0 ➔ isPaginationEnabled() 0 10 2
A hp$0 ➔ addCountToContext() 0 11 4
shouldDoctrinePaginatorFetchJoinCollection() 0 26 ?
A hp$0 ➔ shouldDoctrinePaginatorFetchJoinCollection() 0 26 5
getPagination() 0 70 ?
isPaginationEnabled() 0 10 ?
shouldDoctrinePaginatorUseOutputWalkers() 0 48 ?
A hp$0 ➔ isPartialPaginationEnabled() 0 18 5
D hp$0 ➔ getPagination() 0 70 20
getPaginationParameter() 0 7 ?
B hp$0 ➔ shouldDoctrinePaginatorUseOutputWalkers() 0 48 8
addCountToContext() 0 11 ?
A hp$0 ➔ getPaginationParameter() 0 7 3

How to fix   Complexity   

Complex Class

Complex classes like PaginationExtension 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.

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 PaginationExtension, 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\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', null, true);
222
223
            if (null !== $maxItemsPerPage) {
224
                @trigger_error('The "maximum_items_per_page" option has been deprecated since API Platform 2.5 in favor of "pagination_maximum_items_per_page" and will be removed in API Platform 3.', E_USER_DEPRECATED);
225
            }
226
227
            $maxItemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_maximum_items_per_page', $maxItemsPerPage ?? $this->maximumItemPerPage, true);
228
229
            $itemsPerPage = (int) $this->getPaginationParameter($request, $this->itemsPerPageParameterName, $itemsPerPage);
230
            $itemsPerPage = (null !== $maxItemsPerPage && $itemsPerPage >= $maxItemsPerPage ? $maxItemsPerPage : $itemsPerPage);
231
        }
232
233
        if (0 > $itemsPerPage) {
234
            throw new InvalidArgumentException('Item per page parameter should not be less than 0');
235
        }
236
237
        $page = (int) $this->getPaginationParameter($request, $this->pageParameterName, 1);
238
239
        if (1 > $page) {
240
            throw new InvalidArgumentException('Page should not be less than 1');
241
        }
242
243
        if (0 === $itemsPerPage && 1 < $page) {
244
            throw new InvalidArgumentException('Page should not be greater than 1 if itemsPerPage is equal to 0');
245
        }
246
247
        $firstResult = ($page - 1) * $itemsPerPage;
248
        if ($request->attributes->getBoolean('_graphql', false)) {
249
            $collectionArgs = $request->attributes->get('_graphql_collections_args', []);
250
            if (isset($collectionArgs[$resourceClass]['after'])) {
251
                $after = base64_decode($collectionArgs[$resourceClass]['after'], true);
252
                $firstResult = (int) $after;
253
                $firstResult = false === $after ? $firstResult : ++$firstResult;
254
            }
255
        }
256
257
        return [$firstResult, $itemsPerPage];
258
    }
259
260
    private function isPartialPaginationEnabled(Request $request = null, ResourceMetadata $resourceMetadata = null, string $operationName = null): bool
261
    {
262
        $enabled = $this->partial;
263
        $clientEnabled = $this->clientPartial;
264
265
        if ($resourceMetadata) {
266
            $enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_partial', $enabled, true);
267
268
            if ($request) {
269
                $clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_partial', $clientEnabled, true);
270
            }
271
        }
272
273
        if ($clientEnabled && $request) {
274
            $enabled = filter_var($this->getPaginationParameter($request, $this->partialParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
275
        }
276
277
        return $enabled;
278
    }
279
280
    private function isPaginationEnabled(Request $request, ResourceMetadata $resourceMetadata, string $operationName = null): bool
281
    {
282
        $enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', $this->enabled, true);
283
        $clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_enabled', $this->clientEnabled, true);
284
285
        if ($clientEnabled) {
286
            $enabled = filter_var($this->getPaginationParameter($request, $this->enabledParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
287
        }
288
289
        return $enabled;
290
    }
291
292
    private function getPaginationParameter(Request $request, string $parameterName, $default = null)
293
    {
294
        if (null !== $paginationAttribute = $request->attributes->get('_api_pagination')) {
295
            return \array_key_exists($parameterName, $paginationAttribute) ? $paginationAttribute[$parameterName] : $default;
296
        }
297
298
        return $request->query->get($parameterName, $default);
299
    }
300
301
    private function addCountToContext(QueryBuilder $queryBuilder, array $context): array
302
    {
303
        if (!($context['graphql_operation_name'] ?? false)) {
304
            return $context;
305
        }
306
307
        if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
308
            $context['count'] = (new DoctrineOrmPaginator($queryBuilder))->count();
309
        }
310
311
        return $context;
312
    }
313
314
    /**
315
     * Determines the value of the $fetchJoinCollection argument passed to the Doctrine ORM Paginator.
316
     */
317
    private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
318
    {
319
        if (null !== $resourceClass) {
320
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
321
322
            if (null !== $fetchJoinCollection = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_fetch_join_collection', null, true)) {
323
                return $fetchJoinCollection;
324
            }
325
        }
326
327
        /*
328
         * "Cannot count query which selects two FROM components, cannot make distinction"
329
         *
330
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/WhereInWalker.php#L81
331
         * @see https://github.com/doctrine/doctrine2/issues/2910
332
         */
333
        if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
334
            return false;
335
        }
336
337
        if (QueryChecker::hasJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
338
            return true;
339
        }
340
341
        // disable $fetchJoinCollection by default (performance)
342
        return false;
343
    }
344
345
    /**
346
     * Determines whether the Doctrine ORM Paginator should use output walkers.
347
     */
348
    private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
349
    {
350
        if (null !== $resourceClass) {
351
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
352
353
            if (null !== $useOutputWalkers = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_use_output_walkers', null, true)) {
354
                return $useOutputWalkers;
355
            }
356
        }
357
358
        /*
359
         * "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
360
         *
361
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L56
362
         */
363
        if (QueryChecker::hasHavingClause($queryBuilder)) {
364
            return true;
365
        }
366
367
        /*
368
         * "Cannot count query which selects two FROM components, cannot make distinction"
369
         *
370
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L64
371
         */
372
        if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
373
            return true;
374
        }
375
376
        /*
377
         * "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
378
         *
379
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L77
380
         */
381
        if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
382
            return true;
383
        }
384
385
        /*
386
         * "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
387
         *
388
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L150
389
         */
390
        if (QueryChecker::hasMaxResults($queryBuilder) && QueryChecker::hasOrderByOnFetchJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
391
            return true;
392
        }
393
394
        // Disable output walkers by default (performance)
395
        return false;
396
    }
397
}
398