Skip to content

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

Merged

Conversation

meyerbaptiste
Copy link
Member

@meyerbaptiste meyerbaptiste commented Jul 28, 2017

Q A
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? yes
Tests pass? yes
Fixed tickets N/A
License MIT
Doc PR api-platform/docs#266

Recently we had to profile an API Platform application. This one had quite long response times because of the Doctrine paginator and more specifically the SQL COUNT() query it executes. Based on theses observations, I created a partial paginator which doesn't execute this query and therefore returns only the previous and the next page.

Next step: next and previous page with a cursor (Facebook, Twitter, etc. like).

Cc @thomasglachant

use ApiPlatform\Core\DataProvider\PartialPaginatorInterface;
use Doctrine\ORM\Tools\Pagination\Paginator;

abstract class AbstractPaginator implements \IteratorAggregate, PartialPaginatorInterface
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI,

abstract class AbstractPaginator implements PartialPaginatorInterface, \IteratorAggregate => fatal error
abstract class AbstractPaginator implements \IteratorAggregate, PartialPaginatorInterface => it works

🤔 🤣

*
* @return mixed
*/
public function getResult(QueryBuilder $queryBuilder);
public function getResult(QueryBuilder $queryBuilder/*, string $resourceClass, string $operationName = null*/);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To keep consistency with QueryResultCollectionExtensionInterface but yes, maybe it's useless...

Copy link
Member

Choose a reason for hiding this comment

The 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.

@soyuka
Copy link
Member

soyuka commented Jul 28, 2017

Shouldn't you change the CollectionDataProvider and the SubresourceDataProvider also?

@bendavies
Copy link
Contributor

I also noticed this performance hit.
One thing that could be improved is removing left joins on the count query, which dramatically improved performance for my use case.

@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch 2 times, most recently from 1219528 to d8af927 Compare July 28, 2017 15:45
@soyuka
Copy link
Member

soyuka commented Jul 28, 2017

👍 @bendavies thought about this too but the doctrine paginator is a dark place to change things like this (it works on the same basis QueryBuilder for all the queries). If you managed to do it I'd be interested in the how.

@bendavies
Copy link
Contributor

@soyuka i can't see much apart from writing our own Paginator or decorating it and overriding count

@soyuka
Copy link
Member

soyuka commented Jul 28, 2017

@soyuka i can't see much apart from writing our own Paginator or decorating it and overriding count

I meant to remove leftJoins from the COUNT query but not from the end query. Anyway this pull request gives the ability to skip the COUNT :).

@meyerbaptiste
Copy link
Member Author

If I remember correctly, on the project we profiled there was already a decorator to remove in some cases the LEFT JOIN part of the query but that did not prevent a long execution time.

@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch 3 times, most recently from c7bc360 to 3a74628 Compare July 28, 2017 22:03
@@ -96,13 +96,16 @@ public function getConfigTreeBuilder()
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')->defaultTrue()->info('To enable or disable pagination for all resource collections by default.')->end()
->booleanNode('partial')->defaultFalse()->info('')->end()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add an info?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops!


$currentPage = $lastPage = $itemsPerPage = null;
if ($isPaginator) {
$currentPage = $lastPage = $itemsPerPage = $pageTotalItems = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whew, lots of variables initiated to nil, is this required?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of this?

------ --------------------------------------------- 
  Line   src/Hal/Serializer/CollectionNormalizer.php  
 ------ --------------------------------------------- 
  87     Undefined variable: $currentPage             
  92     Undefined variable: $lastPage                
  97     Undefined variable: $currentPage             
  98     Undefined variable: $currentPage             
  101    Undefined variable: $currentPage             
  101    Undefined variable: $pageTotalItems          
  101    Undefined variable: $itemsPerPage            
  102    Undefined variable: $currentPage             
  123    Undefined variable: $itemsPerPage            
 ------ --------------------------------------------- 
 ------ ---------------------------------------------------------- 
  Line   src/Hydra/Serializer/PartialCollectionViewNormalizer.php  
 ------ ---------------------------------------------------------- 
  75     Undefined variable: $currentPage                          
  80     Undefined variable: $lastPage                             
  85     Undefined variable: $currentPage                          
  86     Undefined variable: $currentPage                          
  89     Undefined variable: $currentPage                          
  89     Undefined variable: $pageTotalItems                       
  89     Undefined variable: $itemsPerPage                         
  90     Undefined variable: $currentPage                          
 ------ ---------------------------------------------------------- 

if (1. === $currentPage && 1. === $lastPage) {
// Consider the collection not paginated if there is only one page
$paginated = false;
$currentPage = $lastPage = $itemsPerPage = $pageTotalItems = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same I wonder why is this needed

@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch from 3a74628 to 6a2f4ad Compare July 31, 2017 15:49

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));
}
Copy link
Member

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)?

Copy link
Member Author

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:

Returns NULL if {link setFirstResult} was not applied to this query.
Returns NULL if {link setMaxResults} was not applied to this query.

We always call Query::setFirstResult() and Query::setMaxResults() before instantiating our Paginator class, it's why we never had the bug.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We always call Query::setFirstResult() and Query::setMaxResults() before instantiating our Paginator class, it's why we never had the bug.

Yes that's the reason why we don't need the test no? I mean we're still always using setFirstResult and setMaxResults or I miss something.

Copy link
Member Author

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.

Copy link
Member

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!

@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch from 6a2f4ad to 577535f Compare July 31, 2017 22:07
Copy link
Member

@soyuka soyuka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

phpstan issues but then 👍

@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch from 577535f to c888e89 Compare August 4, 2017 14:11
@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch 5 times, most recently from 890521a to a15af93 Compare August 11, 2017 12:31
@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch from a15af93 to 347ad0e Compare August 21, 2017 12:46
@Simperfit
Copy link
Contributor

@meyerbaptiste could you please rebase ? this should be ok to merge, @dunglas could you please look at this ?

@Simperfit
Copy link
Contributor

@meyerbaptiste could you rebase again, so we can merge it ?

@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch 2 times, most recently from 724dcf2 to ec5d87a Compare September 12, 2017 14:45
/**
* {@inheritdoc}
*/
public function getIterator()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add the \Traversable return type even if not in the parent class.

phpstan.neon Outdated
@@ -14,3 +14,8 @@ parameters:
- '#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
- '#Call to an undefined method Doctrine\\Common\\Persistence\\ObjectManager::getConnection\(\)#'
- '#Parameter \#1 \$callable of static method Doctrine\\Common\\Annotations\\AnnotationRegistry::registerLoader\(\) expects callable, mixed\[\] given#'
- '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryResult(Item|Collection)ExtensionInterface::getResult\(\) invoked with 3 parameters, 1 required#'
- '#Undefined variable: \$currentPage#'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These Undefined variable look like code smell.

/**
* {@inheritdoc}
*/
public function count()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here (: int)

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.1.', __METHOD__), E_USER_DEPRECATED);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2.2 (same everywhere)

$doctrineOrmPaginator = new DoctrineOrmPaginator($queryBuilder, $this->useFetchJoinCollection($queryBuilder));
$doctrineOrmPaginator->setUseOutputWalkers($this->useOutputWalkers($queryBuilder));

$resourceMetadata = null !== $resourceClass ? $this->resourceMetadataFactory->create($resourceClass) : null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null === $resourceClass ? null : $this->resourceMetadataFactory->create($resourceClass) (negation not needed).

$enabled = $this->partial;
$clientEnabled = $this->clientPartial;

if ($resourceMetadata) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null !== (same for all tests below)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I do it after yesterday's conversation? 😛


/**
* Decorates the Doctrine ORM paginator.
*
* @author Kévin Dunglas <[email protected]>
*/
final class Paginator implements \IteratorAggregate, PaginatorInterface
final class Paginator extends AbstractPaginator implements PaginatorInterface
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

implements PaginatorInterface can be removed (already implemented by the abstract class).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nop, AbstractPaginator implements PartialPaginatorInterface.

Copy link
Member

@dunglas dunglas Sep 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, keep it as is :) Sorry.

->booleanNode('client_enabled')->defaultFalse()->info('To allow the client to enable or disable the pagination.')->end()
->booleanNode('client_items_per_page')->defaultFalse()->info('To allow the client to set the number of items per page.')->end()
->booleanNode('client_partial')->defaultFalse()->info('')->end()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

info('') must be filled (BTW, I'm not sure that it's really necessary to be able to set this option client-side, but as it's implemented, keep it)


$currentPage = $lastPage = $itemsPerPage = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think those changes introduce the PHPStan errors. It should probably be reverted.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, see #1292 (comment)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels weird to me to declare empty variables but maybe it's better... Your call @dunglas.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes the execution path more predictible for static analysis tools (and even for humans IMO).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Humans > Robots

@meyerbaptiste meyerbaptiste force-pushed the paginator_performances branch 4 times, most recently from 658a499 to cefd25e Compare September 14, 2017 12:27
@meyerbaptiste
Copy link
Member Author

Done @dunglas.

if ($object instanceof PaginatorInterface) {
$paginated = 1. !== $lastPage = $object->getLastPage();
} else {
$lastPage = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this one needed if we declared it above?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

if ($object instanceof PaginatorInterface) {
$paginated = 1. !== $lastPage = $object->getLastPage();
} else {
$lastPage = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same now that it's declared above

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@meyerbaptiste meyerbaptiste merged commit f21b165 into api-platform:master Sep 14, 2017
@meyerbaptiste meyerbaptiste deleted the paginator_performances branch September 14, 2017 14:13
@soyuka
Copy link
Member

soyuka commented Sep 14, 2017

Thanks @meyerbaptiste :D

@thomasglachant
Copy link
Contributor

Thanks a lot, great job 👍

hoangnd25 pushed a commit to hoangnd25/core that referenced this pull request Feb 23, 2018
…formances

Add a partial paginator to prevent SQL COUNT queries
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants