Passed
Pull Request — master (#2689)
by Antoine
07:00 queued 02:10
created

PaginationExtension::isPartialPaginationEnabled()

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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