Passed
Pull Request — master (#2789)
by
unknown
04:14
created

PaginationExtension::isPaginationEnabled()

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 10
c 0
b 0
f 0
eloc 5
nc 2
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\AbstractPaginator;
17
use ApiPlatform\Core\Bridge\Doctrine\Orm\Cache\QueryExpander;
18
use ApiPlatform\Core\Bridge\Doctrine\Orm\Paginator;
19
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryChecker;
20
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
21
use ApiPlatform\Core\DataProvider\Pagination;
22
use ApiPlatform\Core\Exception\InvalidArgumentException;
23
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
24
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
25
use Doctrine\Common\Persistence\ManagerRegistry;
26
use Doctrine\ORM\QueryBuilder;
27
use Doctrine\ORM\Tools\Pagination\CountWalker;
28
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
29
use Symfony\Component\HttpFoundation\Request;
30
use Symfony\Component\HttpFoundation\RequestStack;
31
32
/**
33
 * Applies pagination on the Doctrine query for resource collection when enabled.
34
 *
35
 * @author Kévin Dunglas <[email protected]>
36
 * @author Samuel ROZE <[email protected]>
37
 */
38
final class PaginationExtension implements ContextAwareQueryResultCollectionExtensionInterface
39
{
40
    private $managerRegistry;
41
    private $requestStack;
42
    private $resourceMetadataFactory;
43
    private $enabled;
44
    private $clientEnabled;
45
    private $clientItemsPerPage;
46
    private $itemsPerPage;
47
    private $pageParameterName;
48
    private $enabledParameterName;
49
    private $itemsPerPageParameterName;
50
    private $maximumItemPerPage;
51
    private $partial;
52
    private $clientPartial;
53
    private $partialParameterName;
54
    private $pagination;
55
    private $queryExpander;
56
57
    /**
58
     * @param ResourceMetadataFactoryInterface|RequestStack $resourceMetadataFactory
59
     * @param Pagination|ResourceMetadataFactoryInterface $pagination
60
     */
61
    public function __construct(ManagerRegistry $managerRegistry, QueryExpander $queryExpander, /* ResourceMetadataFactoryInterface */ $resourceMetadataFactory, /* Pagination */ $pagination)
62
    {
63
        if ($resourceMetadataFactory instanceof RequestStack && $pagination instanceof ResourceMetadataFactoryInterface) {
64
            @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);
65
            @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);
66
67
            $this->requestStack = $resourceMetadataFactory;
68
            $resourceMetadataFactory = $pagination;
69
            $pagination = null;
70
71
            $args = \array_slice(\func_get_args(), 4);
72
            $legacyPaginationArgs = [
73
                ['arg_name' => 'enabled', 'type' => 'bool', 'default' => true],
74
                ['arg_name' => 'clientEnabled', 'type' => 'bool', 'default' => false],
75
                ['arg_name' => 'clientItemsPerPage', 'type' => 'bool', 'default' => false],
76
                ['arg_name' => 'itemsPerPage', 'type' => 'int', 'default' => 30],
77
                ['arg_name' => 'pageParameterName', 'type' => 'string', 'default' => 'page'],
78
                ['arg_name' => 'enabledParameterName', 'type' => 'string', 'default' => 'pagination'],
79
                ['arg_name' => 'itemsPerPageParameterName', 'type' => 'string', 'default' => 'itemsPerPage'],
80
                ['arg_name' => 'maximumItemPerPage', 'type' => 'int', 'default' => null],
81
                ['arg_name' => 'partial', 'type' => 'bool', 'default' => false],
82
                ['arg_name' => 'clientPartial', 'type' => 'bool', 'default' => false],
83
                ['arg_name' => 'partialParameterName', 'type' => 'string', 'default' => 'partial'],
84
            ];
85
86
            foreach ($legacyPaginationArgs as $pos => $arg) {
87
                if (\array_key_exists($pos, $args)) {
88
                    @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);
89
90
                    if (!((null === $arg['default'] && null === $args[$pos]) || \call_user_func("is_{$arg['type']}", $args[$pos]))) {
91
                        throw new InvalidArgumentException(sprintf('The "$%s" argument is expected to be a %s%s.', $arg['arg_name'], $arg['type'], null === $arg['default'] ? ' or null' : ''));
92
                    }
93
94
                    $value = $args[$pos];
95
                } else {
96
                    $value = $arg['default'];
97
                }
98
99
                $this->{$arg['arg_name']} = $value;
100
            }
101
        } elseif (!$resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) {
102
            throw new InvalidArgumentException(sprintf('The "$resourceMetadataFactory" argument is expected to be an implementation of the "%s" interface.', ResourceMetadataFactoryInterface::class));
103
        } elseif (!$pagination instanceof Pagination) {
104
            throw new InvalidArgumentException(sprintf('The "$pagination" argument is expected to be an instance of the "%s" class.', Pagination::class));
105
        }
106
107
        $this->managerRegistry = $managerRegistry;
108
        $this->resourceMetadataFactory = $resourceMetadataFactory;
109
        $this->pagination = $pagination;
110
        $this->queryExpander = $queryExpander;
111
    }
112
113
    /**
114
     * {@inheritdoc}
115
     */
116
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null, array $context = [])
117
    {
118
        if (null === $pagination = $this->getPagination($queryBuilder, $resourceClass, $operationName, $context)) {
119
            return;
120
        }
121
122
        [$offset, $limit] = $pagination;
123
124
        $queryBuilder
125
            ->setFirstResult($offset)
126
            ->setMaxResults($limit);
127
    }
128
129
    /**
130
     * {@inheritdoc}
131
     */
132
    public function supportsResult(string $resourceClass, string $operationName = null, array $context = []): bool
133
    {
134
        if (null === $this->requestStack) {
135
            return $this->pagination->isEnabled($resourceClass, $operationName, $context);
136
        }
137
138
        if (null === $request = $this->requestStack->getCurrentRequest()) {
139
            return false;
140
        }
141
142
        return $this->isPaginationEnabled($request, $this->resourceMetadataFactory->create($resourceClass), $operationName);
143
    }
144
145
    /**
146
     * {@inheritdoc}
147
     */
148
    public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null, array $context = [])
149
    {
150
        $query = $queryBuilder->getQuery();
151
        $queryExpanderResourceClass = $resourceClass ?? $this->getResourceClassFromQueryBuilder($queryBuilder);
152
        if (null !== $queryExpanderResourceClass) {
153
            $this->queryExpander->expand($queryExpanderResourceClass, $query);
154
        }
155
156
        // Only one alias, without joins, disable the DISTINCT on the COUNT
157
        if (1 === \count($queryBuilder->getAllAliases())) {
158
            $query->setHint(CountWalker::HINT_DISTINCT, false);
159
        }
160
161
        $doctrineOrmPaginator = new DoctrineOrmPaginator($query, $this->shouldDoctrinePaginatorFetchJoinCollection($queryBuilder, $resourceClass, $operationName));
162
        $doctrineOrmPaginator->setUseOutputWalkers($this->shouldDoctrinePaginatorUseOutputWalkers($queryBuilder, $resourceClass, $operationName));
163
164
        if (null === $this->requestStack) {
165
            $isPartialEnabled = $this->pagination->isPartialEnabled($resourceClass, $operationName, $context);
166
        } else {
167
            $isPartialEnabled = $this->isPartialPaginationEnabled(
168
                $this->requestStack->getCurrentRequest(),
169
                null === $resourceClass ? null : $this->resourceMetadataFactory->create($resourceClass),
170
                $operationName
171
            );
172
        }
173
174
        if ($isPartialEnabled) {
175
            return new class($doctrineOrmPaginator) extends AbstractPaginator {
176
            };
177
        }
178
179
        return new Paginator($doctrineOrmPaginator);
180
    }
181
182
    /**
183
     * @throws InvalidArgumentException
184
     */
185
    private function getPagination(QueryBuilder $queryBuilder, string $resourceClass, ?string $operationName, array $context): ?array
186
    {
187
        $request = null;
188
        if (null !== $this->requestStack && null === $request = $this->requestStack->getCurrentRequest()) {
189
            return null;
190
        }
191
192
        if (null === $request) {
193
            if (!$this->pagination->isEnabled($resourceClass, $operationName, $context)) {
194
                return null;
195
            }
196
197
            $context = $this->addCountToContext($queryBuilder, $context);
198
199
            return \array_slice($this->pagination->getPagination($resourceClass, $operationName, $context), 1);
200
        }
201
202
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
203
        if (!$this->isPaginationEnabled($request, $resourceMetadata, $operationName)) {
204
            return null;
205
        }
206
207
        $itemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_items_per_page', $this->itemsPerPage, true);
208
        if ($request->attributes->getBoolean('_graphql', false)) {
209
            $collectionArgs = $request->attributes->get('_graphql_collections_args', []);
210
            $itemsPerPage = $collectionArgs[$resourceClass]['first'] ?? $itemsPerPage;
211
        }
212
213
        if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_items_per_page', $this->clientItemsPerPage, true)) {
214
            $maxItemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'maximum_items_per_page', $this->maximumItemPerPage, true);
215
            $itemsPerPage = (int) $this->getPaginationParameter($request, $this->itemsPerPageParameterName, $itemsPerPage);
216
            $itemsPerPage = (null !== $maxItemsPerPage && $itemsPerPage >= $maxItemsPerPage ? $maxItemsPerPage : $itemsPerPage);
217
        }
218
219
        if (0 > $itemsPerPage) {
220
            throw new InvalidArgumentException('Item per page parameter should not be less than 0');
221
        }
222
223
        $page = (int) $this->getPaginationParameter($request, $this->pageParameterName, 1);
224
225
        if (1 > $page) {
226
            throw new InvalidArgumentException('Page should not be less than 1');
227
        }
228
229
        if (0 === $itemsPerPage && 1 < $page) {
230
            throw new InvalidArgumentException('Page should not be greater than 1 if itemsPerPage is equal to 0');
231
        }
232
233
        $firstResult = ($page - 1) * $itemsPerPage;
234
        if ($request->attributes->getBoolean('_graphql', false)) {
235
            $collectionArgs = $request->attributes->get('_graphql_collections_args', []);
236
            if (isset($collectionArgs[$resourceClass]['after'])) {
237
                $after = base64_decode($collectionArgs[$resourceClass]['after'], true);
238
                $firstResult = (int) $after;
239
                $firstResult = false === $after ? $firstResult : ++$firstResult;
240
            }
241
        }
242
243
        return [$firstResult, $itemsPerPage];
244
    }
245
246
    private function getResourceClassFromQueryBuilder(QueryBuilder $queryBuilder): ?string
247
    {
248
        $fromPart = $queryBuilder->getDQLPart('from');
249
        if (1 === count($fromPart)) {
250
            return $fromPart[0]->getFrom();
251
        }
252
    }
253
254
    private function isPartialPaginationEnabled(Request $request = null, ResourceMetadata $resourceMetadata = null, string $operationName = null): bool
255
    {
256
        $enabled = $this->partial;
257
        $clientEnabled = $this->clientPartial;
258
259
        if ($resourceMetadata) {
260
            $enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_partial', $enabled, true);
261
262
            if ($request) {
263
                $clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_partial', $clientEnabled, true);
264
            }
265
        }
266
267
        if ($clientEnabled && $request) {
268
            $enabled = filter_var($this->getPaginationParameter($request, $this->partialParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
269
        }
270
271
        return $enabled;
272
    }
273
274
    private function isPaginationEnabled(Request $request, ResourceMetadata $resourceMetadata, string $operationName = null): bool
275
    {
276
        $enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', $this->enabled, true);
277
        $clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_enabled', $this->clientEnabled, true);
278
279
        if ($clientEnabled) {
280
            $enabled = filter_var($this->getPaginationParameter($request, $this->enabledParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
281
        }
282
283
        return $enabled;
284
    }
285
286
    private function getPaginationParameter(Request $request, string $parameterName, $default = null)
287
    {
288
        if (null !== $paginationAttribute = $request->attributes->get('_api_pagination')) {
289
            return \array_key_exists($parameterName, $paginationAttribute) ? $paginationAttribute[$parameterName] : $default;
290
        }
291
292
        return $request->query->get($parameterName, $default);
293
    }
294
295
    private function addCountToContext(QueryBuilder $queryBuilder, array $context): array
296
    {
297
        if (!($context['graphql'] ?? false)) {
298
            return $context;
299
        }
300
301
        if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
302
            $context['count'] = (new DoctrineOrmPaginator($queryBuilder))->count();
303
        }
304
305
        return $context;
306
    }
307
308
    /**
309
     * Determines the value of the $fetchJoinCollection argument passed to the Doctrine ORM Paginator.
310
     */
311
    private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
312
    {
313
        if (null !== $resourceClass) {
314
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
315
316
            if (null !== $fetchJoinCollection = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_fetch_join_collection', null, true)) {
317
                return $fetchJoinCollection;
318
            }
319
        }
320
321
        /*
322
         * "Cannot count query which selects two FROM components, cannot make distinction"
323
         *
324
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/WhereInWalker.php#L81
325
         * @see https://github.com/doctrine/doctrine2/issues/2910
326
         */
327
        if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
328
            return false;
329
        }
330
331
        if (QueryChecker::hasJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
332
            return true;
333
        }
334
335
        // disable $fetchJoinCollection by default (performance)
336
        return false;
337
    }
338
339
    /**
340
     * Determines whether the Doctrine ORM Paginator should use output walkers.
341
     */
342
    private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
343
    {
344
        if (null !== $resourceClass) {
345
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
346
347
            if (null !== $useOutputWalkers = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_use_output_walkers', null, true)) {
348
                return $useOutputWalkers;
349
            }
350
        }
351
352
        /*
353
         * "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
354
         *
355
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L56
356
         */
357
        if (QueryChecker::hasHavingClause($queryBuilder)) {
358
            return true;
359
        }
360
361
        /*
362
         * "Cannot count query which selects two FROM components, cannot make distinction"
363
         *
364
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L64
365
         */
366
        if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
367
            return true;
368
        }
369
370
        /*
371
         * "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
372
         *
373
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L77
374
         */
375
        if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
376
            return true;
377
        }
378
379
        /*
380
         * "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
381
         *
382
         * @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L150
383
         */
384
        if (QueryChecker::hasMaxResults($queryBuilder) && QueryChecker::hasOrderByOnFetchJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
385
            return true;
386
        }
387
388
        // Disable output walkers by default (performance)
389
        return false;
390
    }
391
}
392