-
-
Notifications
You must be signed in to change notification settings - Fork 921
Add a partial paginator to prevent SQL COUNT queries #1292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the API Platform project. | ||
* | ||
* (c) Kévin Dunglas <[email protected]> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
declare(strict_types=1); | ||
|
||
namespace ApiPlatform\Core\Bridge\Doctrine\Orm; | ||
|
||
use ApiPlatform\Core\DataProvider\PartialPaginatorInterface; | ||
use ApiPlatform\Core\Exception\InvalidArgumentException; | ||
use Doctrine\ORM\Query; | ||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; | ||
|
||
abstract class AbstractPaginator implements \IteratorAggregate, PartialPaginatorInterface | ||
{ | ||
protected $paginator; | ||
protected $iterator; | ||
protected $firstResult; | ||
protected $maxResults; | ||
|
||
/** | ||
* @throws InvalidArgumentException | ||
*/ | ||
public function __construct(DoctrinePaginator $paginator) | ||
{ | ||
$query = $paginator->getQuery(); | ||
|
||
if (null === ($firstResult = $query->getFirstResult()) || null === $maxResults = $query->getMaxResults()) { | ||
throw new InvalidArgumentException(sprintf('"%1$s::setFirstResult()" or/and "%1$s::setMaxResults()" was/were not applied to the query.', Query::class)); | ||
} | ||
|
||
$this->paginator = $paginator; | ||
$this->firstResult = $firstResult; | ||
$this->maxResults = $maxResults; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getCurrentPage(): float | ||
{ | ||
return floor($this->firstResult / $this->maxResults) + 1.; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getItemsPerPage(): float | ||
{ | ||
return (float) $this->maxResults; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getIterator(): \Traversable | ||
{ | ||
return $this->iterator ?? $this->iterator = $this->paginator->getIterator(); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function count(): int | ||
{ | ||
return iterator_count($this->getIterator()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,6 +13,7 @@ | |
|
||
namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Extension; | ||
|
||
use ApiPlatform\Core\Bridge\Doctrine\Orm\AbstractPaginator; | ||
use ApiPlatform\Core\Bridge\Doctrine\Orm\Paginator; | ||
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryChecker; | ||
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; | ||
|
@@ -44,8 +45,11 @@ final class PaginationExtension implements QueryResultCollectionExtensionInterfa | |
private $enabledParameterName; | ||
private $itemsPerPageParameterName; | ||
private $maximumItemPerPage; | ||
private $partial; | ||
private $clientPartial; | ||
private $partialParameterName; | ||
|
||
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) | ||
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') | ||
{ | ||
$this->managerRegistry = $managerRegistry; | ||
$this->requestStack = $requestStack; | ||
|
@@ -58,6 +62,9 @@ public function __construct(ManagerRegistry $managerRegistry, RequestStack $requ | |
$this->enabledParameterName = $enabledParameterName; | ||
$this->itemsPerPageParameterName = $itemsPerPageParameterName; | ||
$this->maximumItemPerPage = $maximumItemPerPage; | ||
$this->partial = $partial; | ||
$this->clientPartial = $clientPartial; | ||
$this->partialParameterName = $partialParameterName; | ||
} | ||
|
||
/** | ||
|
@@ -108,14 +115,55 @@ public function supportsResult(string $resourceClass, string $operationName = nu | |
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getResult(QueryBuilder $queryBuilder) | ||
public function getResult(QueryBuilder $queryBuilder/*, string $resourceClass, string $operationName = null*/) | ||
{ | ||
$resourceClass = $operationName = null; | ||
|
||
if (func_num_args() >= 2) { | ||
$resourceClass = func_get_arg(1); | ||
} else { | ||
@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); | ||
} | ||
|
||
if (func_num_args() >= 3) { | ||
$operationName = func_get_arg(2); | ||
} else { | ||
@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); | ||
} | ||
|
||
$doctrineOrmPaginator = new DoctrineOrmPaginator($queryBuilder, $this->useFetchJoinCollection($queryBuilder)); | ||
$doctrineOrmPaginator->setUseOutputWalkers($this->useOutputWalkers($queryBuilder)); | ||
|
||
$resourceMetadata = null === $resourceClass ? null : $this->resourceMetadataFactory->create($resourceClass); | ||
|
||
if ($this->isPartialPaginationEnabled($this->requestStack->getCurrentRequest(), $resourceMetadata, $operationName)) { | ||
return new class($doctrineOrmPaginator) extends AbstractPaginator { | ||
}; | ||
} | ||
|
||
return new Paginator($doctrineOrmPaginator); | ||
} | ||
|
||
private function isPartialPaginationEnabled(Request $request = null, ResourceMetadata $resourceMetadata = null, string $operationName = null): bool | ||
{ | ||
$enabled = $this->partial; | ||
$clientEnabled = $this->clientPartial; | ||
|
||
if ($resourceMetadata) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should I do it after yesterday's conversation? 😛 |
||
$enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_partial', $enabled, true); | ||
|
||
if ($request) { | ||
$clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_partial', $clientEnabled, true); | ||
} | ||
} | ||
|
||
if ($clientEnabled && $request) { | ||
$enabled = filter_var($request->query->get($this->partialParameterName, $enabled), FILTER_VALIDATE_BOOLEAN); | ||
} | ||
|
||
return $enabled; | ||
} | ||
|
||
private function isPaginationEnabled(Request $request, ResourceMetadata $resourceMetadata, string $operationName = null): bool | ||
{ | ||
$enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', $this->enabled, true); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -33,8 +33,10 @@ public function supportsResult(string $resourceClass, string $operationName = nu | |
|
||
/** | ||
* @param QueryBuilder $queryBuilder | ||
* @param string $resourceClass | ||
* @param string|null $operationName | ||
* | ||
* @return mixed | ||
*/ | ||
public function getResult(QueryBuilder $queryBuilder); | ||
public function getResult(QueryBuilder $queryBuilder/*, string $resourceClass, string $operationName = null*/); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To keep consistency with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep it, at least we have them if we need it later :P. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,95 +14,32 @@ | |
namespace ApiPlatform\Core\Bridge\Doctrine\Orm; | ||
|
||
use ApiPlatform\Core\DataProvider\PaginatorInterface; | ||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator; | ||
|
||
/** | ||
* Decorates the Doctrine ORM paginator. | ||
* | ||
* @author Kévin Dunglas <[email protected]> | ||
*/ | ||
final class Paginator implements \IteratorAggregate, PaginatorInterface | ||
final class Paginator extends AbstractPaginator implements PaginatorInterface | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nop, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, keep it as is :) Sorry. |
||
{ | ||
private $paginator; | ||
|
||
/** | ||
* @var int | ||
*/ | ||
private $firstResult; | ||
|
||
/** | ||
* @var int | ||
*/ | ||
private $maxResults; | ||
|
||
/** | ||
* @var int | ||
*/ | ||
private $totalItems; | ||
|
||
/** | ||
* @var \Traversable | ||
*/ | ||
private $iterator; | ||
|
||
public function __construct(DoctrineOrmPaginator $paginator) | ||
{ | ||
$this->paginator = $paginator; | ||
$query = $paginator->getQuery(); | ||
$this->firstResult = $query->getFirstResult(); | ||
$this->maxResults = $query->getMaxResults(); | ||
$this->totalItems = count($paginator); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getCurrentPage(): float | ||
{ | ||
return floor($this->firstResult / $this->maxResults) + 1.; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getLastPage(): float | ||
{ | ||
return ceil($this->totalItems / $this->maxResults) ?: 1.; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getItemsPerPage(): float | ||
{ | ||
return (float) $this->maxResults; | ||
return ceil($this->getTotalItems() / $this->maxResults) ?: 1.; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getTotalItems(): float | ||
{ | ||
return (float) $this->totalItems; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getIterator() | ||
{ | ||
if (null === $this->iterator) { | ||
$this->iterator = $this->paginator->getIterator(); | ||
} | ||
|
||
return $this->iterator; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function count() | ||
{ | ||
return count($this->getIterator()); | ||
return (float) ($this->totalItems ?? $this->totalItems = count($this->paginator)); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I saw the test below but I don't get why we need such message (as we didn't needed it before)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because these two methods can potentially return
null
. See the Doctrine PHPDoc:We always call
Query::setFirstResult()
andQuery::setMaxResults()
before instantiating ourPaginator
class, it's why we never had the bug.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes that's the reason why we don't need the test no? I mean we're still always using
setFirstResult
andsetMaxResults
or I miss something.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes but this is a library and this class is not internal so users are free to use it as they want. IMO we have to prevent a fatal error by preventing this specific use case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay wasn't sure this was the reason but I thought about it! Thanks for clarifying!