Skip to content

Commit cefd25e

Browse files
committed
Add a partial paginator to prevent COUNT SQL queries
1 parent 0607640 commit cefd25e

27 files changed

+561
-163
lines changed

features/hydra/collection.feature

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,50 @@ Feature: Collections support
164164
}
165165
"""
166166

167+
Scenario: Enable the partial pagination client side
168+
When I send a "GET" request to "/dummies?page=7&partial=1"
169+
Then the response status code should be 200
170+
And the response should be in JSON
171+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
172+
And the JSON should be valid according to this schema:
173+
"""
174+
{
175+
"type": "object",
176+
"properties": {
177+
"@context": {"pattern": "^/contexts/Dummy$"},
178+
"@id": {"pattern": "^/dummies$"},
179+
"@type": {"pattern": "^hydra:Collection$"},
180+
"hydra:totalItems": {"type":"number", "maximum": 30},
181+
"hydra:member": {
182+
"type": "array",
183+
"items": {
184+
"type": "object",
185+
"properties": {
186+
"@id": {
187+
"oneOf": [
188+
{"pattern": "^/dummies/19$"},
189+
{"pattern": "^/dummies/20$"},
190+
{"pattern": "^/dummies/21$"}
191+
]
192+
}
193+
}
194+
},
195+
"maxItems": 3
196+
},
197+
"hydra:view": {
198+
"type": "object",
199+
"properties": {
200+
"@id": {"pattern": "^/dummies\\?partial=1&page=7$"},
201+
"@type": {"pattern": "^hydra:PartialCollectionView$"},
202+
"hydra:next": {"pattern": "^/dummies\\?partial=1&page=8$"},
203+
"hydra:previous": {"pattern": "^/dummies\\?partial=1&page=6$"}
204+
},
205+
"additionalProperties": false
206+
}
207+
}
208+
}
209+
"""
210+
167211
Scenario: Disable the pagination client side
168212
When I send a "GET" request to "/dummies?pagination=0"
169213
Then the response status code should be 200

phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ parameters:
1414
- '#Parameter \#2 \$dqlPart of method Doctrine\\ORM\\QueryBuilder::add\(\) expects Doctrine\\ORM\\Query\\Expr\\Base, Doctrine\\ORM\\Query\\Expr\\Join\[\] given#' # Fixed in Doctrine's master
1515
- '#Call to an undefined method Doctrine\\Common\\Persistence\\ObjectManager::getConnection\(\)#'
1616
- '#Parameter \#1 \$callable of static method Doctrine\\Common\\Annotations\\AnnotationRegistry::registerLoader\(\) expects callable, mixed\[\] given#'
17+
- '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryResult(Item|Collection)ExtensionInterface::getResult\(\) invoked with 3 parameters, 1 required#'
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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;
15+
16+
use ApiPlatform\Core\DataProvider\PartialPaginatorInterface;
17+
use ApiPlatform\Core\Exception\InvalidArgumentException;
18+
use Doctrine\ORM\Query;
19+
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
20+
21+
abstract class AbstractPaginator implements \IteratorAggregate, PartialPaginatorInterface
22+
{
23+
protected $paginator;
24+
protected $iterator;
25+
protected $firstResult;
26+
protected $maxResults;
27+
28+
/**
29+
* @throws InvalidArgumentException
30+
*/
31+
public function __construct(DoctrinePaginator $paginator)
32+
{
33+
$query = $paginator->getQuery();
34+
35+
if (null === ($firstResult = $query->getFirstResult()) || null === $maxResults = $query->getMaxResults()) {
36+
throw new InvalidArgumentException(sprintf('"%1$s::setFirstResult()" or/and "%1$s::setMaxResults()" was/were not applied to the query.', Query::class));
37+
}
38+
39+
$this->paginator = $paginator;
40+
$this->firstResult = $firstResult;
41+
$this->maxResults = $maxResults;
42+
}
43+
44+
/**
45+
* {@inheritdoc}
46+
*/
47+
public function getCurrentPage(): float
48+
{
49+
return floor($this->firstResult / $this->maxResults) + 1.;
50+
}
51+
52+
/**
53+
* {@inheritdoc}
54+
*/
55+
public function getItemsPerPage(): float
56+
{
57+
return (float) $this->maxResults;
58+
}
59+
60+
/**
61+
* {@inheritdoc}
62+
*/
63+
public function getIterator(): \Traversable
64+
{
65+
return $this->iterator ?? $this->iterator = $this->paginator->getIterator();
66+
}
67+
68+
/**
69+
* {@inheritdoc}
70+
*/
71+
public function count(): int
72+
{
73+
return iterator_count($this->getIterator());
74+
}
75+
}

src/Bridge/Doctrine/Orm/CollectionDataProvider.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public function getCollection(string $resourceClass, string $operationName = nul
6565
$extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName);
6666

6767
if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName)) {
68-
return $extension->getResult($queryBuilder);
68+
return $extension->getResult($queryBuilder, $resourceClass, $operationName);
6969
}
7070
}
7171

src/Bridge/Doctrine/Orm/Extension/PaginationExtension.php

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Extension;
1515

16+
use ApiPlatform\Core\Bridge\Doctrine\Orm\AbstractPaginator;
1617
use ApiPlatform\Core\Bridge\Doctrine\Orm\Paginator;
1718
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryChecker;
1819
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
@@ -44,8 +45,11 @@ final class PaginationExtension implements QueryResultCollectionExtensionInterfa
4445
private $enabledParameterName;
4546
private $itemsPerPageParameterName;
4647
private $maximumItemPerPage;
48+
private $partial;
49+
private $clientPartial;
50+
private $partialParameterName;
4751

48-
public function __construct(ManagerRegistry $managerRegistry, RequestStack $requestStack, ResourceMetadataFactoryInterface $resourceMetadataFactory, bool $enabled = true, bool $clientEnabled = false, bool $clientItemsPerPage = false, int $itemsPerPage = 30, string $pageParameterName = 'page', string $enabledParameterName = 'pagination', string $itemsPerPageParameterName = 'itemsPerPage', int $maximumItemPerPage = null)
52+
public function __construct(ManagerRegistry $managerRegistry, RequestStack $requestStack, ResourceMetadataFactoryInterface $resourceMetadataFactory, bool $enabled = true, bool $clientEnabled = false, bool $clientItemsPerPage = false, int $itemsPerPage = 30, string $pageParameterName = 'page', string $enabledParameterName = 'pagination', string $itemsPerPageParameterName = 'itemsPerPage', int $maximumItemPerPage = null, bool $partial = false, bool $clientPartial = false, string $partialParameterName = 'partial')
4953
{
5054
$this->managerRegistry = $managerRegistry;
5155
$this->requestStack = $requestStack;
@@ -58,6 +62,9 @@ public function __construct(ManagerRegistry $managerRegistry, RequestStack $requ
5862
$this->enabledParameterName = $enabledParameterName;
5963
$this->itemsPerPageParameterName = $itemsPerPageParameterName;
6064
$this->maximumItemPerPage = $maximumItemPerPage;
65+
$this->partial = $partial;
66+
$this->clientPartial = $clientPartial;
67+
$this->partialParameterName = $partialParameterName;
6168
}
6269

6370
/**
@@ -108,14 +115,55 @@ public function supportsResult(string $resourceClass, string $operationName = nu
108115
/**
109116
* {@inheritdoc}
110117
*/
111-
public function getResult(QueryBuilder $queryBuilder)
118+
public function getResult(QueryBuilder $queryBuilder/*, string $resourceClass, string $operationName = null*/)
112119
{
120+
$resourceClass = $operationName = null;
121+
122+
if (func_num_args() >= 2) {
123+
$resourceClass = func_get_arg(1);
124+
} else {
125+
@trigger_error(sprintf('Method %s() will have a 2nd `string $resourceClass` argument in version 3.0. Not defining it is deprecated since 2.2.', __METHOD__), E_USER_DEPRECATED);
126+
}
127+
128+
if (func_num_args() >= 3) {
129+
$operationName = func_get_arg(2);
130+
} else {
131+
@trigger_error(sprintf('Method %s() will have a 3rd `string $operationName = null` argument in version 3.0. Not defining it is deprecated since 2.2.', __METHOD__), E_USER_DEPRECATED);
132+
}
133+
113134
$doctrineOrmPaginator = new DoctrineOrmPaginator($queryBuilder, $this->useFetchJoinCollection($queryBuilder));
114135
$doctrineOrmPaginator->setUseOutputWalkers($this->useOutputWalkers($queryBuilder));
115136

137+
$resourceMetadata = null === $resourceClass ? null : $this->resourceMetadataFactory->create($resourceClass);
138+
139+
if ($this->isPartialPaginationEnabled($this->requestStack->getCurrentRequest(), $resourceMetadata, $operationName)) {
140+
return new class($doctrineOrmPaginator) extends AbstractPaginator {
141+
};
142+
}
143+
116144
return new Paginator($doctrineOrmPaginator);
117145
}
118146

147+
private function isPartialPaginationEnabled(Request $request = null, ResourceMetadata $resourceMetadata = null, string $operationName = null): bool
148+
{
149+
$enabled = $this->partial;
150+
$clientEnabled = $this->clientPartial;
151+
152+
if ($resourceMetadata) {
153+
$enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_partial', $enabled, true);
154+
155+
if ($request) {
156+
$clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_partial', $clientEnabled, true);
157+
}
158+
}
159+
160+
if ($clientEnabled && $request) {
161+
$enabled = filter_var($request->query->get($this->partialParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
162+
}
163+
164+
return $enabled;
165+
}
166+
119167
private function isPaginationEnabled(Request $request, ResourceMetadata $resourceMetadata, string $operationName = null): bool
120168
{
121169
$enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', $this->enabled, true);

src/Bridge/Doctrine/Orm/Extension/QueryResultCollectionExtensionInterface.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ public function supportsResult(string $resourceClass, string $operationName = nu
3434

3535
/**
3636
* @param QueryBuilder $queryBuilder
37+
* @param string $resourceClass
38+
* @param string|null $operationName
3739
*
3840
* @return mixed
3941
*/
40-
public function getResult(QueryBuilder $queryBuilder);
42+
public function getResult(QueryBuilder $queryBuilder/*, string $resourceClass, string $operationName = null*/);
4143
}

src/Bridge/Doctrine/Orm/Extension/QueryResultItemExtensionInterface.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ public function supportsResult(string $resourceClass, string $operationName = nu
3333

3434
/**
3535
* @param QueryBuilder $queryBuilder
36+
* @param string $resourceClass
37+
* @param string|null $operationName
3638
*
3739
* @return mixed
3840
*/
39-
public function getResult(QueryBuilder $queryBuilder);
41+
public function getResult(QueryBuilder $queryBuilder/*, string $resourceClass, string $operationName = null*/);
4042
}

src/Bridge/Doctrine/Orm/Paginator.php

Lines changed: 3 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -14,95 +14,32 @@
1414
namespace ApiPlatform\Core\Bridge\Doctrine\Orm;
1515

1616
use ApiPlatform\Core\DataProvider\PaginatorInterface;
17-
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
1817

1918
/**
2019
* Decorates the Doctrine ORM paginator.
2120
*
2221
* @author Kévin Dunglas <[email protected]>
2322
*/
24-
final class Paginator implements \IteratorAggregate, PaginatorInterface
23+
final class Paginator extends AbstractPaginator implements PaginatorInterface
2524
{
26-
private $paginator;
27-
28-
/**
29-
* @var int
30-
*/
31-
private $firstResult;
32-
33-
/**
34-
* @var int
35-
*/
36-
private $maxResults;
37-
3825
/**
3926
* @var int
4027
*/
4128
private $totalItems;
4229

43-
/**
44-
* @var \Traversable
45-
*/
46-
private $iterator;
47-
48-
public function __construct(DoctrineOrmPaginator $paginator)
49-
{
50-
$this->paginator = $paginator;
51-
$query = $paginator->getQuery();
52-
$this->firstResult = $query->getFirstResult();
53-
$this->maxResults = $query->getMaxResults();
54-
$this->totalItems = count($paginator);
55-
}
56-
57-
/**
58-
* {@inheritdoc}
59-
*/
60-
public function getCurrentPage(): float
61-
{
62-
return floor($this->firstResult / $this->maxResults) + 1.;
63-
}
64-
6530
/**
6631
* {@inheritdoc}
6732
*/
6833
public function getLastPage(): float
6934
{
70-
return ceil($this->totalItems / $this->maxResults) ?: 1.;
71-
}
72-
73-
/**
74-
* {@inheritdoc}
75-
*/
76-
public function getItemsPerPage(): float
77-
{
78-
return (float) $this->maxResults;
35+
return ceil($this->getTotalItems() / $this->maxResults) ?: 1.;
7936
}
8037

8138
/**
8239
* {@inheritdoc}
8340
*/
8441
public function getTotalItems(): float
8542
{
86-
return (float) $this->totalItems;
87-
}
88-
89-
/**
90-
* {@inheritdoc}
91-
*/
92-
public function getIterator()
93-
{
94-
if (null === $this->iterator) {
95-
$this->iterator = $this->paginator->getIterator();
96-
}
97-
98-
return $this->iterator;
99-
}
100-
101-
/**
102-
* {@inheritdoc}
103-
*/
104-
public function count()
105-
{
106-
return count($this->getIterator());
43+
return (float) ($this->totalItems ?? $this->totalItems = count($this->paginator));
10744
}
10845
}

src/Bridge/Doctrine/Orm/SubresourceDataProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,15 +178,15 @@ public function getSubresource(string $resourceClass, array $identifiers, array
178178
$extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName);
179179

180180
if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName)) {
181-
return $extension->getResult($queryBuilder);
181+
return $extension->getResult($queryBuilder, $resourceClass, $operationName);
182182
}
183183
}
184184
} else {
185185
foreach ($this->itemExtensions as $extension) {
186186
$extension->applyToItem($queryBuilder, $queryNameGenerator, $resourceClass, $identifiers, $operationName, $context);
187187

188188
if ($extension instanceof QueryResultItemExtensionInterface && $extension->supportsResult($resourceClass, $operationName)) {
189-
return $extension->getResult($queryBuilder);
189+
return $extension->getResult($queryBuilder, $resourceClass, $operationName);
190190
}
191191
}
192192
}

src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,16 @@ private function handleConfig(ContainerBuilder $container, array $config, array
145145
$container->setParameter('api_platform.collection.order', $config['collection']['order']);
146146
$container->setParameter('api_platform.collection.order_parameter_name', $config['collection']['order_parameter_name']);
147147
$container->setParameter('api_platform.collection.pagination.enabled', $config['collection']['pagination']['enabled']);
148+
$container->setParameter('api_platform.collection.pagination.partial', $config['collection']['pagination']['partial']);
148149
$container->setParameter('api_platform.collection.pagination.client_enabled', $config['collection']['pagination']['client_enabled']);
149150
$container->setParameter('api_platform.collection.pagination.client_items_per_page', $config['collection']['pagination']['client_items_per_page']);
151+
$container->setParameter('api_platform.collection.pagination.client_partial', $config['collection']['pagination']['client_partial']);
150152
$container->setParameter('api_platform.collection.pagination.items_per_page', $config['collection']['pagination']['items_per_page']);
151153
$container->setParameter('api_platform.collection.pagination.maximum_items_per_page', $config['collection']['pagination']['maximum_items_per_page']);
152154
$container->setParameter('api_platform.collection.pagination.page_parameter_name', $config['collection']['pagination']['page_parameter_name']);
153155
$container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['collection']['pagination']['enabled_parameter_name']);
154156
$container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['collection']['pagination']['items_per_page_parameter_name']);
157+
$container->setParameter('api_platform.collection.pagination.partial_parameter_name', $config['collection']['pagination']['partial_parameter_name']);
155158
$container->setParameter('api_platform.http_cache.etag', $config['http_cache']['etag']);
156159
$container->setParameter('api_platform.http_cache.max_age', $config['http_cache']['max_age']);
157160
$container->setParameter('api_platform.http_cache.shared_max_age', $config['http_cache']['shared_max_age']);

0 commit comments

Comments
 (0)