Skip to content

Commit 1219528

Browse files
committed
Add a partial paginator to prevent COUNT SQL queries
1 parent 4fffea6 commit 1219528

24 files changed

+510
-160
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
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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 Doctrine\ORM\Tools\Pagination\Paginator;
18+
19+
abstract class AbstractPaginator implements \IteratorAggregate, PartialPaginatorInterface
20+
{
21+
protected $paginator;
22+
protected $iterator;
23+
protected $firstResult;
24+
protected $maxResults;
25+
26+
public function __construct(Paginator $paginator)
27+
{
28+
$this->paginator = $paginator;
29+
$query = $paginator->getQuery();
30+
$this->firstResult = $query->getFirstResult();
31+
$this->maxResults = $query->getMaxResults();
32+
}
33+
34+
/**
35+
* {@inheritdoc}
36+
*/
37+
public function getCurrentPage(): float
38+
{
39+
return floor($this->firstResult / $this->maxResults) + 1.;
40+
}
41+
42+
/**
43+
* {@inheritdoc}
44+
*/
45+
public function getItemsPerPage(): float
46+
{
47+
return (float) $this->maxResults;
48+
}
49+
50+
/**
51+
* {@inheritdoc}
52+
*/
53+
public function getIterator()
54+
{
55+
return $this->iterator ?? $this->iterator = $this->paginator->getIterator();
56+
}
57+
58+
/**
59+
* {@inheritdoc}
60+
*/
61+
public function count()
62+
{
63+
return count($this->getIterator());
64+
}
65+
}

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.1.', __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.1.', __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 ? $this->resourceMetadataFactory->create($resourceClass) : null;
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
@@ -144,13 +144,16 @@ private function handleConfig(ContainerBuilder $container, array $config, array
144144
$container->setParameter('api_platform.collection.order', $config['collection']['order']);
145145
$container->setParameter('api_platform.collection.order_parameter_name', $config['collection']['order_parameter_name']);
146146
$container->setParameter('api_platform.collection.pagination.enabled', $config['collection']['pagination']['enabled']);
147+
$container->setParameter('api_platform.collection.pagination.partial', $config['collection']['pagination']['partial']);
147148
$container->setParameter('api_platform.collection.pagination.client_enabled', $config['collection']['pagination']['client_enabled']);
148149
$container->setParameter('api_platform.collection.pagination.client_items_per_page', $config['collection']['pagination']['client_items_per_page']);
150+
$container->setParameter('api_platform.collection.pagination.client_partial', $config['collection']['pagination']['client_partial']);
149151
$container->setParameter('api_platform.collection.pagination.items_per_page', $config['collection']['pagination']['items_per_page']);
150152
$container->setParameter('api_platform.collection.pagination.maximum_items_per_page', $config['collection']['pagination']['maximum_items_per_page']);
151153
$container->setParameter('api_platform.collection.pagination.page_parameter_name', $config['collection']['pagination']['page_parameter_name']);
152154
$container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['collection']['pagination']['enabled_parameter_name']);
153155
$container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['collection']['pagination']['items_per_page_parameter_name']);
156+
$container->setParameter('api_platform.collection.pagination.partial_parameter_name', $config['collection']['pagination']['partial_parameter_name']);
154157
$container->setParameter('api_platform.http_cache.etag', $config['http_cache']['etag']);
155158
$container->setParameter('api_platform.http_cache.max_age', $config['http_cache']['max_age']);
156159
$container->setParameter('api_platform.http_cache.shared_max_age', $config['http_cache']['shared_max_age']);

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,16 @@ public function getConfigTreeBuilder()
9696
->addDefaultsIfNotSet()
9797
->children()
9898
->booleanNode('enabled')->defaultTrue()->info('To enable or disable pagination for all resource collections by default.')->end()
99+
->booleanNode('partial')->defaultFalse()->info('')->end()
99100
->booleanNode('client_enabled')->defaultFalse()->info('To allow the client to enable or disable the pagination.')->end()
100101
->booleanNode('client_items_per_page')->defaultFalse()->info('To allow the client to set the number of items per page.')->end()
102+
->booleanNode('client_partial')->defaultFalse()->info('')->end()
101103
->integerNode('items_per_page')->defaultValue(30)->info('The default number of items per page.')->end()
102104
->integerNode('maximum_items_per_page')->defaultNull()->info('The maximum number of items per page.')->end()
103105
->scalarNode('page_parameter_name')->defaultValue('page')->cannotBeEmpty()->info('The default name of the parameter handling the page number.')->end()
104106
->scalarNode('enabled_parameter_name')->defaultValue('pagination')->cannotBeEmpty()->info('The name of the query parameter to enable or disable pagination.')->end()
105107
->scalarNode('items_per_page_parameter_name')->defaultValue('itemsPerPage')->cannotBeEmpty()->info('The name of the query parameter to set the number of items per page.')->end()
108+
->scalarNode('partial_parameter_name')->defaultValue('partial')->cannotBeEmpty()->info('')->end()
106109
->end()
107110
->end()
108111
->end()

src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@
148148
<argument>%api_platform.collection.pagination.enabled_parameter_name%</argument>
149149
<argument>%api_platform.collection.pagination.items_per_page_parameter_name%</argument>
150150
<argument>%api_platform.collection.pagination.maximum_items_per_page%</argument>
151+
<argument>%api_platform.collection.pagination.partial%</argument>
152+
<argument>%api_platform.collection.pagination.client_partial%</argument>
153+
<argument>%api_platform.collection.pagination.partial_parameter_name%</argument>
151154

152155
<tag name="api_platform.doctrine.orm.query_extension.collection" priority="8" />
153156
</service>

0 commit comments

Comments
 (0)