diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b602c2..0ea9b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * refactor: simplify `KeysetExpressionCalculator` * chore: cleanup +* feat: native query adapter now supports row values method # 0.15.1 diff --git a/packages/rekapager-adapter-common/src/SeekMethod.php b/packages/rekapager-adapter-common/src/SeekMethod.php new file mode 100644 index 0000000..ed58331 --- /dev/null +++ b/packages/rekapager-adapter-common/src/SeekMethod.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Rekapager\Adapter\Common; + +enum SeekMethod +{ + case Approximated; + case RowValues; + case Auto; +} diff --git a/packages/rekapager-doctrine-orm-adapter/src/NativeQueryAdapter.php b/packages/rekapager-doctrine-orm-adapter/src/NativeQueryAdapter.php index 2045761..f3ca969 100644 --- a/packages/rekapager-doctrine-orm-adapter/src/NativeQueryAdapter.php +++ b/packages/rekapager-doctrine-orm-adapter/src/NativeQueryAdapter.php @@ -13,7 +13,6 @@ namespace Rekalogika\Rekapager\Doctrine\ORM; -use Doctrine\Common\Collections\Expr\Expression; use Doctrine\Common\Collections\Order; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Query\ResultSetMapping; @@ -23,6 +22,7 @@ use Rekalogika\Rekapager\Adapter\Common\IndexResolver; use Rekalogika\Rekapager\Adapter\Common\KeysetExpressionCalculator; use Rekalogika\Rekapager\Adapter\Common\KeysetExpressionSQLVisitor; +use Rekalogika\Rekapager\Adapter\Common\SeekMethod; use Rekalogika\Rekapager\Doctrine\ORM\Exception\MissingPlaceholderInSQLException; use Rekalogika\Rekapager\Doctrine\ORM\Exception\NoCountResultFoundException; use Rekalogika\Rekapager\Doctrine\ORM\Internal\QueryBuilderKeysetItem; @@ -57,6 +57,7 @@ public function __construct( private ?string $countAllSql = null, private array $parameters = [], private string|null $indexBy = null, + private SeekMethod $seekMethod = SeekMethod::Approximated, ) { // clone the ResultSetMapping to avoid modifying the original $resultSetMapping = clone $resultSetMapping; @@ -106,33 +107,106 @@ private function verifySQL( } /** - * @param null|array $boundaryValues Key is the property name, value is the bound value. Null if unbounded. + * @param array $boundaryValues Key is the property name, value is the bound value. Null if unbounded. * @param non-empty-array $orderings + * @return array{string,array} */ private function generateWhereExpression( - null|array $boundaryValues, + array $boundaryValues, array $orderings, - ): ?Expression { - // wrap boundary values using QueryParameter + ): array { + $fields = $this->createCalculatorFields($boundaryValues, $orderings); - $newBoundaryValues = []; + if ($fields === []) { + return ['', []]; + } - /** @var mixed $value */ - foreach ($boundaryValues ?? [] as $property => $value) { - $newBoundaryValues[$property] = new QueryParameter($value, null); + return match ($this->seekMethod) { + SeekMethod::Approximated => $this->generateApproximatedWhereExpression($fields), + SeekMethod::RowValues => $this->generateRowValuesWhereExpression($fields), + SeekMethod::Auto => $this->generateAutoWhereExpression($fields), + }; + } + + /** + * @param non-empty-list $fields + * @return array{string,array} + */ + private function generateAutoWhereExpression(array $fields): array + { + $order = null; + + foreach ($fields as $field) { + if ($order === null) { + $order = $field->getOrder(); + } elseif ($order !== $field->getOrder()) { + return $this->generateApproximatedWhereExpression($fields); + } } - $boundaryValues = $newBoundaryValues; + return $this->generateRowValuesWhereExpression($fields); + } - // construct the metadata for the next step + /** + * @param non-empty-list $fields + * @return array{string,array} + */ + private function generateApproximatedWhereExpression(array $fields): array + { + $expression = KeysetExpressionCalculator::calculate($fields); - $fields = $this->createCalculatorFields($boundaryValues, $orderings); + $visitor = new KeysetExpressionSQLVisitor(); + $result = $visitor->dispatch($expression); + \assert(\is_string($result)); - if ($fields === []) { - return null; + $where = 'AND ' . $result; + + /** @var array */ + $parameters = $visitor->getParameters(); + + return [$where, $parameters]; + } + + /** + * @param non-empty-list $fields + * @return array{string,array} + */ + private function generateRowValuesWhereExpression(array $fields): array + { + $order = null; + $whereFields = []; + $whereValues = []; + $queryParameters = []; + $i = 1; + + foreach ($fields as $field) { + if ($order === null) { + $order = $field->getOrder(); + } elseif ($order !== $field->getOrder()) { + throw new LogicException('Row values require all fields to have the same order.'); + } + + $template = 'rekapager_where_' . $i; + + $whereFields[] = $field->getName(); + $whereValues[] = ':' . $template; + + $value = $field->getValue(); + \assert($value instanceof QueryParameter); + + $queryParameters[$template] = $value; + + $i++; } - return KeysetExpressionCalculator::calculate($fields); + $where = sprintf( + 'AND (%s) %s (%s)', + implode(', ', $whereFields), + $order === Order::Ascending ? '>' : '<', + implode(', ', $whereValues) + ); + + return [$where, $queryParameters]; } /** @@ -206,29 +280,30 @@ private function getSQL( BoundaryType $boundaryType, bool $count = false, ): SQLStatement { + // wrap boundary values using QueryParameter + + $newBoundaryValues = []; + + /** @var mixed $value */ + foreach ($boundaryValues ?? [] as $property => $value) { + $newBoundaryValues[$property] = new QueryParameter($value, null); + } + + $boundaryValues = $newBoundaryValues; + + // orderings + $orderings = $this->getSortOrder($boundaryType === BoundaryType::Upper); if ($orderings === []) { throw new LogicException('No ordering is set.'); } - $expression = $this->generateWhereExpression( + [$where, $parameters] = $this->generateWhereExpression( boundaryValues: $boundaryValues, orderings: $orderings ); - if ($expression !== null) { - $visitor = new KeysetExpressionSQLVisitor(); - $result = $visitor->dispatch($expression); - \assert(\is_string($result)); - $where = 'AND ' . $result; - - $parameters = $visitor->getParameters(); - } else { - $where = ''; - $parameters = []; - } - $orderBy = $this->generateOrderBy($orderings); $sql = str_replace( diff --git a/tests/src/App/PageableGenerator/KeySetPageableNativeQueryAdapterNativeQuery.php b/tests/src/App/PageableGenerator/KeySetPageableNativeQueryAdapterNativeQuery.php index cce4753..8b16b2e 100644 --- a/tests/src/App/PageableGenerator/KeySetPageableNativeQueryAdapterNativeQuery.php +++ b/tests/src/App/PageableGenerator/KeySetPageableNativeQueryAdapterNativeQuery.php @@ -42,7 +42,7 @@ public static function getKey(): string #[\Override] public function getTitle(): string { - return 'KeysetPageable - NativeQueryAdapter - NativeQuery'; + return 'KeysetPageable - NativeQueryAdapter (approximated) - NativeQuery '; } #[\Override] @@ -56,8 +56,6 @@ public function generatePageable( $resultSetMapping = new ResultSetMappingBuilder($this->entityManager); $resultSetMapping->addRootEntityFromClassMetadata(Post::class, 'p'); - - $sql = " SELECT {$resultSetMapping}, {{SELECT}} FROM post p @@ -66,17 +64,6 @@ public function generatePageable( LIMIT {{LIMIT}} OFFSET {{OFFSET}} "; - // $countSql = " - // SELECT COUNT(*) AS count - // FROM ( - // SELECT * - // FROM post p - // WHERE p.set_name = :setName {{WHERE}} - // ORDER BY {{ORDER}} - // LIMIT {{LIMIT}} OFFSET {{OFFSET}} - // ) - // "; - $countAllSql = " SELECT COUNT(*) AS count FROM post p @@ -87,7 +74,6 @@ public function generatePageable( entityManager: $this->entityManager, resultSetMapping: $resultSetMapping, sql: $sql, - // countSql: $countSql, // optional, will encase $sql in a subquery countAllSql: $countAllSql, // optional, if null, total will not be available orderBy: [ 'p.date' => Order::Descending, diff --git a/tests/src/App/PageableGenerator/KeySetPageableNativeQueryAdapterNativeQueryRowValues.php b/tests/src/App/PageableGenerator/KeySetPageableNativeQueryAdapterNativeQueryRowValues.php new file mode 100644 index 0000000..2374dbf --- /dev/null +++ b/tests/src/App/PageableGenerator/KeySetPageableNativeQueryAdapterNativeQueryRowValues.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Rekapager\Tests\App\PageableGenerator; + +use Doctrine\Common\Collections\Order; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query\ResultSetMappingBuilder; +use Rekalogika\Contracts\Rekapager\PageableInterface; +use Rekalogika\Rekapager\Adapter\Common\SeekMethod; +use Rekalogika\Rekapager\Doctrine\ORM\NativeQueryAdapter; +use Rekalogika\Rekapager\Doctrine\ORM\Parameter; +use Rekalogika\Rekapager\Keyset\KeysetPageable; +use Rekalogika\Rekapager\Tests\App\Contracts\PageableGeneratorInterface; +use Rekalogika\Rekapager\Tests\App\Entity\Post; + +/** + * @implements PageableGeneratorInterface + */ +class KeySetPageableNativeQueryAdapterNativeQueryRowValues implements PageableGeneratorInterface +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + ) { + } + + #[\Override] + public static function getKey(): string + { + return 'keysetpageable-nativequeryadapter-nativequery-row-values'; + } + + #[\Override] + public function getTitle(): string + { + return 'KeysetPageable - NativeQueryAdapter (row values) - NativeQuery'; + } + + #[\Override] + public function generatePageable( + int $itemsPerPage, + bool|int|\Closure $count, + string $setName, + ?int $pageLimit = null, + ): PageableInterface { + // @highlight-start + $resultSetMapping = new ResultSetMappingBuilder($this->entityManager); + $resultSetMapping->addRootEntityFromClassMetadata(Post::class, 'p'); + + $sql = " + SELECT {$resultSetMapping}, {{SELECT}} + FROM post p + WHERE p.set_name = :setName {{WHERE}} + ORDER BY {{ORDER}} + LIMIT {{LIMIT}} OFFSET {{OFFSET}} + "; + + $countAllSql = " + SELECT COUNT(*) AS count + FROM post p + WHERE p.set_name = :setName + "; + + $adapter = new NativeQueryAdapter( + entityManager: $this->entityManager, + resultSetMapping: $resultSetMapping, + sql: $sql, + countAllSql: $countAllSql, // optional, if null, total will not be available + orderBy: [ + 'p.date' => Order::Ascending, + 'p.title' => Order::Ascending, + 'p.id' => Order::Ascending, + ], + parameters: [ + new Parameter('setName', $setName), + ], + indexBy: 'id', + seekMethod: SeekMethod::RowValues, + ); + + $pageable = new KeysetPageable( + adapter: $adapter, + itemsPerPage: $itemsPerPage, + count: $count, + ); + // @highlight-end + + // @phpstan-ignore-next-line + return $pageable; + } + + #[\Override] + public function count(): int + { + return 0; + } +} diff --git a/tests/src/IntegrationTests/DataProvider/PageableGeneratorProvider.php b/tests/src/IntegrationTests/DataProvider/PageableGeneratorProvider.php index 8546b42..cb71147 100644 --- a/tests/src/IntegrationTests/DataProvider/PageableGeneratorProvider.php +++ b/tests/src/IntegrationTests/DataProvider/PageableGeneratorProvider.php @@ -14,6 +14,7 @@ namespace Rekalogika\Rekapager\Tests\IntegrationTests\DataProvider; use Rekalogika\Rekapager\Tests\App\PageableGenerator\KeySetPageableNativeQueryAdapterNativeQuery; +use Rekalogika\Rekapager\Tests\App\PageableGenerator\KeySetPageableNativeQueryAdapterNativeQueryRowValues; use Rekalogika\Rekapager\Tests\App\PageableGenerator\KeysetPageableQueryBuilderAdapterQueryBuilder; use Rekalogika\Rekapager\Tests\App\PageableGenerator\KeysetPageableSelectableAdapterCollection; use Rekalogika\Rekapager\Tests\App\PageableGenerator\KeysetPageableSelectableAdapterEntityRepository; @@ -33,6 +34,7 @@ public static function all(): iterable yield [KeysetPageableSelectableAdapterCollection::class]; yield [KeysetPageableSelectableAdapterEntityRepository::class]; yield [KeySetPageableNativeQueryAdapterNativeQuery::class]; + yield [KeySetPageableNativeQueryAdapterNativeQueryRowValues::class]; yield [OffsetPageableQueryBuilderAdapterQueryBuilder::class]; yield [OffsetPageableCollectionAdapterCollection::class]; yield [OffsetPageableSelectableAdapterCollection::class]; @@ -48,6 +50,7 @@ public static function keyset(): iterable yield [KeysetPageableSelectableAdapterCollection::class]; yield [KeysetPageableSelectableAdapterEntityRepository::class]; yield [KeySetPageableNativeQueryAdapterNativeQuery::class]; + yield [KeySetPageableNativeQueryAdapterNativeQueryRowValues::class]; } /**