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

Doctrine/Orm/Extension/PaginationExtension.php (1 issue)

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