Passed
Pull Request — master (#2142)
by Alan
03:11
created

PaginationExtension::useOutputWalkers()

Size

Total Lines 41
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

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