Skip to content

Commit

Permalink
perf: improve DBAL QueryBuilderAdapter count performance (#167)
Browse files Browse the repository at this point in the history
* perf: improve DBAL adapter counting

* perf: improve DBAL counting performance
  • Loading branch information
priyadi authored Jul 28, 2024
1 parent 2dd2050 commit e6ae680
Show file tree
Hide file tree
Showing 2 changed files with 131 additions and 41 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

/*
* This file is part of rekalogika/rekapager package.
*
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/

namespace Rekalogika\Rekapager\Doctrine\DBAL\Exception;

use Rekalogika\Contracts\Rekapager\Exception\UnexpectedValueException;

class CountUnsupportedException extends UnexpectedValueException
{
public function __construct(string $sql)
{
parent::__construct(sprintf('Unable to do a count query on the provided SQL query "%s". You may wish to file a bug report.', $sql));
}
}
148 changes: 107 additions & 41 deletions packages/rekapager-doctrine-dbal-adapter/src/QueryBuilderAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Rekalogika\Rekapager\Adapter\Common\KeysetExpressionCalculator;
use Rekalogika\Rekapager\Adapter\Common\KeysetExpressionSQLVisitor;
use Rekalogika\Rekapager\Adapter\Common\SeekMethod;
use Rekalogika\Rekapager\Doctrine\DBAL\Exception\CountUnsupportedException;
use Rekalogika\Rekapager\Doctrine\DBAL\Exception\UnsupportedQueryBuilderException;
use Rekalogika\Rekapager\Doctrine\DBAL\Internal\QueryBuilderKeysetItem;
use Rekalogika\Rekapager\Doctrine\DBAL\Internal\QueryParameter;
Expand Down Expand Up @@ -57,28 +58,10 @@ public function __construct(
#[\Override]
public function countItems(): ?int
{
$countQueryBuilder = (clone $this->queryBuilder)
->select('COUNT(*)')
->resetOrderBy();

/** @psalm-suppress MixedAssignment */
$result = $countQueryBuilder->executeQuery()->fetchOne();

if ($result === null) {
return null;
}

if (!is_numeric($result)) {
throw new UnexpectedValueException('Count must be a number.');
}

$count = (int) $result;

if ($count < 0) {
throw new UnexpectedValueException('Count must be greater than or equal to 0.');
}

return $count;
return $this->doCount(
queryBuilder: $this->queryBuilder,
expensive: true,
);
}

/**
Expand Down Expand Up @@ -337,9 +320,6 @@ public function getKeysetItems(
return $results;
}

/**
* @todo
*/
#[\Override]
public function countKeysetItems(
int $offset,
Expand All @@ -353,22 +333,10 @@ public function countKeysetItems(

$queryBuilder = $this->getQueryBuilder($offset, $limit, $boundaryValues, $boundaryType);

$queryBuilder->select('COUNT(*)');

/** @psalm-suppress MixedAssignment */
$result = $queryBuilder->executeQuery()->fetchOne();

if (!is_numeric($result)) {
throw new UnexpectedValueException('Count must be a number.');
}

$count = (int) $result;

if ($count < 0) {
throw new UnexpectedValueException('Count must be greater than or equal to 0.');
}

return $count;
return $this->doCount(
queryBuilder: $queryBuilder,
expensive: false,
) ?? throw new CountUnsupportedException($queryBuilder->getSQL());
}

/**
Expand Down Expand Up @@ -419,8 +387,106 @@ public function countOffsetItems(int $offset = 0, ?int $limit = null): int

$queryBuilder = $this->getQueryBuilder($offset, $limit, null, BoundaryType::Lower);

return $this->doCount(
queryBuilder: $queryBuilder,
expensive: false,
) ?? throw new CountUnsupportedException($queryBuilder->getSQL());
}

/**
* @return int<0,max>|null
*/
private function doCount(
QueryBuilder $queryBuilder,
bool $expensive,
): ?int {
// using subquery is preferred because it should work in all cases. but
// QueryBuilder does not provide a `resetFrom` method that we need. so
// we need to use the deprecated `resetQueryPart` method.

/**
* @psalm-suppress RedundantCondition
* @phpstan-ignore-next-line
*/
if (\is_callable([$queryBuilder, 'resetQueryPart'])) {
return $this->doCountWithSubquery($queryBuilder);
}

// the second preferred method is to replace the select statement with
// a COUNT(*) statement. but it won't work if the query has a GROUP BY
// statement.

$sql = $queryBuilder->getSQL();

if (!str_contains(strtoupper($sql), 'GROUP BY')) {
return $this->doCountWithReplacingSelect($queryBuilder);
}

// it the query is not expensive, i.e. it does not return a lot of rows,
// we can count the rows in PHP.

if (!$expensive) {
return $this->doCountWithRecordCounting($queryBuilder);
}

// else, we give up

return null;
}

/**
* @return int<0,max>
*/
private function doCountWithSubquery(QueryBuilder $queryBuilder): int
{
$queryBuilder = (clone $queryBuilder);
$sql = $queryBuilder->getSQL();

/**
* @psalm-suppress DeprecatedMethod
* @phpstan-ignore-next-line
*/
$queryBuilder
->resetQueryPart('from')
->resetGroupBy()
->resetHaving()
->resetOrderBy()
->resetWhere()
->select('COUNT(*)')
->from('(' . $sql . ')', 'rekapager_count');

return $this->returnCount($queryBuilder);
}

/**
* @return int<0,max>
*/
private function doCountWithReplacingSelect(QueryBuilder $queryBuilder): int
{
$queryBuilder = (clone $queryBuilder);

$queryBuilder->select('COUNT(*)');

return $this->returnCount($queryBuilder);
}

/**
* @return int<0,max>
*/
private function doCountWithRecordCounting(QueryBuilder $queryBuilder): int
{
$queryBuilder = (clone $queryBuilder);

$result = $queryBuilder->executeQuery()->fetchAllAssociative();

return \count($result);
}

/**
* @return int<0,max>
*/
private function returnCount(QueryBuilder $queryBuilder): int
{
/** @psalm-suppress MixedAssignment */
$result = $queryBuilder->executeQuery()->fetchOne();

Expand Down

0 comments on commit e6ae680

Please sign in to comment.