diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index bdab365a8..496da87eb 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -28,6 +28,8 @@ use Tempest\Support\Conditions\HasConditions; use Tempest\Support\Paginator\PaginatedData; use Tempest\Support\Paginator\Paginator; +use Tempest\Support\Paginator\SimplePaginatedData; +use Tempest\Support\Paginator\SimplePaginator; use Tempest\Support\Str\ImmutableString; use function Tempest\Container\get; @@ -131,6 +133,32 @@ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxL ); } + /** + * Returns offset-paginated data for the current query without executing a count query. + * + * Because the total number of items is unknown, a page beyond the available data + * is returned empty and still reports a previous page when `currentPage` is greater than one. + * For large or frequently changing datasets, cursor pagination may be more appropriate. + * + * @return SimplePaginatedData + */ + public function simplePaginate( + int $itemsPerPage = 20, + int $currentPage = 1, + ): SimplePaginatedData { + $paginator = new SimplePaginator( + itemsPerPage: $itemsPerPage, + currentPage: $currentPage, + ); + + return $paginator->paginateWith( + callback: fn (int $limit, int $offset) => $this + ->limit($limit) + ->offset($offset) + ->all(), + ); + } + /** * Returns the first record matching the given primary key. * diff --git a/packages/support/src/Paginator/SimplePaginatedData.php b/packages/support/src/Paginator/SimplePaginatedData.php new file mode 100644 index 000000000..c031ef84c --- /dev/null +++ b/packages/support/src/Paginator/SimplePaginatedData.php @@ -0,0 +1,115 @@ + $data + */ + public function __construct( + public array $data, + public int $currentPage, + public int $itemsPerPage, + public int $offset, + public int $limit, + public bool $hasNext, + public bool $hasPrevious, + public ?int $nextPage, + public ?int $previousPage, + ) {} + + public int $count { + get => count($this->data); + } + + public bool $isEmpty { + get => $this->count === 0; + } + + public bool $isNotEmpty { + get => ! $this->isEmpty; + } + + /** + * @template U + * + * @param callable(T): U $callback + * + * @return SimplePaginatedData + */ + public function map(callable $callback): self + { + return new self( + data: array_map($callback, $this->data), + currentPage: $this->currentPage, + itemsPerPage: $this->itemsPerPage, + offset: $this->offset, + limit: $this->limit, + hasNext: $this->hasNext, + hasPrevious: $this->hasPrevious, + nextPage: $this->nextPage, + previousPage: $this->previousPage, + ); + } + + /** + * @return array{ + * data: array, + * pagination: array{ + * current_page: int, + * items_per_page: int, + * offset: int, + * limit: int, + * has_next: bool, + * has_previous: bool, + * next_page: ?int, + * previous_page: ?int, + * count: int + * } + * } + */ + public function toArray(): array + { + return [ + 'data' => $this->data, + 'pagination' => [ + 'current_page' => $this->currentPage, + 'items_per_page' => $this->itemsPerPage, + 'offset' => $this->offset, + 'limit' => $this->limit, + 'has_next' => $this->hasNext, + 'has_previous' => $this->hasPrevious, + 'next_page' => $this->nextPage, + 'previous_page' => $this->previousPage, + 'count' => $this->count, + ], + ]; + } + + /** + * @return array{ + * data: array, + * pagination: array{ + * current_page: int, + * items_per_page: int, + * offset: int, + * limit: int, + * has_next: bool, + * has_previous: bool, + * next_page: ?int, + * previous_page: ?int, + * count: int + * } + * } + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/support/src/Paginator/SimplePaginator.php b/packages/support/src/Paginator/SimplePaginator.php new file mode 100644 index 000000000..b31ab27e0 --- /dev/null +++ b/packages/support/src/Paginator/SimplePaginator.php @@ -0,0 +1,99 @@ +itemsPerPage <= 0) { + throw new ArgumentWasInvalid('Items per page must be positive'); + } + + if ($this->currentPage <= 0) { + throw new ArgumentWasInvalid('Current page must be positive'); + } + } + + public int $offset { + get => ($this->currentPage - 1) * $this->itemsPerPage; + } + + /** + * One additional item is requested to determine whether + * the next page exists. + */ + public int $limit { + get => $this->itemsPerPage + 1; + } + + public bool $hasPrevious { + get => $this->currentPage > 1; + } + + public ?int $previousPage { + get => $this->hasPrevious ? $this->currentPage - 1 : null; + } + + public function withPage(int $page): self + { + return new self( + itemsPerPage: $this->itemsPerPage, + currentPage: $page, + ); + } + + public function withItemsPerPage(int $itemsPerPage): self + { + return new self( + itemsPerPage: $itemsPerPage, + currentPage: $this->currentPage, + ); + } + + /** + * Creates simple paginated data with the provided items. + * + * Any items beyond the configured page size are used to determine + * whether the next page exists and are omitted from the result. + * + * @template T + * @param array $data + * @return SimplePaginatedData + */ + public function paginate(array $data): SimplePaginatedData + { + $hasNext = count($data) > $this->itemsPerPage; + $data = array_slice($data, 0, $this->itemsPerPage); + + return new SimplePaginatedData( + data: $data, + currentPage: $this->currentPage, + itemsPerPage: $this->itemsPerPage, + offset: $this->offset, + limit: $this->itemsPerPage, + hasNext: $hasNext, + hasPrevious: $this->hasPrevious, + nextPage: $hasNext ? $this->currentPage + 1 : null, + previousPage: $this->previousPage, + ); + } + + /** + * Creates simple paginated data from a callable that fetches data. + * + * @template T + * @param callable(int $limit, int $offset): array $callback + * @return SimplePaginatedData + */ + public function paginateWith(callable $callback): SimplePaginatedData + { + return $this->paginate( + $callback($this->limit, $this->offset), + ); + } +} diff --git a/packages/support/tests/Paginator/SimplePaginatorTest.php b/packages/support/tests/Paginator/SimplePaginatorTest.php new file mode 100644 index 000000000..91c95d1bb --- /dev/null +++ b/packages/support/tests/Paginator/SimplePaginatorTest.php @@ -0,0 +1,161 @@ +paginate(['a', 'b', 'c']); + + $this->assertSame(['a', 'b', 'c'], $result->data); + $this->assertFalse($result->hasNext); + $this->assertNull($result->nextPage); + $this->assertSame(3, $result->count); + } + + #[Test] + public function an_additional_item_marks_the_next_page_and_is_removed(): void + { + $result = new SimplePaginator(itemsPerPage: 3)->paginate(['a', 'b', 'c', 'd']); + + $this->assertSame(['a', 'b', 'c'], $result->data); + $this->assertTrue($result->hasNext); + $this->assertSame(2, $result->nextPage); + $this->assertSame(3, $result->count); + } + + #[Test] + public function paginate_never_returns_more_than_the_configured_page_size(): void + { + $result = new SimplePaginator(itemsPerPage: 3)->paginate(['a', 'b', 'c', 'd', 'e']); + + $this->assertSame(['a', 'b', 'c'], $result->data); + $this->assertTrue($result->hasNext); + $this->assertSame(3, $result->count); + } + + #[Test] + public function it_handles_an_empty_first_page(): void + { + $result = new SimplePaginator()->paginate([]); + + $this->assertSame([], $result->data); + $this->assertTrue($result->isEmpty); + $this->assertFalse($result->isNotEmpty); + $this->assertFalse($result->hasNext); + $this->assertFalse($result->hasPrevious); + $this->assertNull($result->nextPage); + $this->assertNull($result->previousPage); + } + + #[Test] + public function a_page_beyond_the_available_data_still_has_a_previous_page(): void + { + $result = new SimplePaginator(itemsPerPage: 20, currentPage: 100)->paginate([]); + + $this->assertSame([], $result->data); + $this->assertSame(1980, $result->offset); + $this->assertFalse($result->hasNext); + $this->assertTrue($result->hasPrevious); + $this->assertNull($result->nextPage); + $this->assertSame(99, $result->previousPage); + } + + #[Test] + public function paginate_with_requests_one_additional_item_at_the_correct_offset(): void + { + $requestedLimit = null; + $requestedOffset = null; + + $result = new SimplePaginator(itemsPerPage: 2, currentPage: 2)->paginateWith( + function (int $limit, int $offset) use (&$requestedLimit, &$requestedOffset): array { + $requestedLimit = $limit; + $requestedOffset = $offset; + + return ['c', 'd', 'e']; + }, + ); + + $this->assertSame(3, $requestedLimit); + $this->assertSame(2, $requestedOffset); + $this->assertSame(['c', 'd'], $result->data); + $this->assertSame(2, $result->currentPage); + $this->assertSame(2, $result->offset); + $this->assertTrue($result->hasNext); + $this->assertTrue($result->hasPrevious); + $this->assertSame(3, $result->nextPage); + $this->assertSame(1, $result->previousPage); + } + + #[Test] + #[DataProvider('invalidArgumentsProvider')] + public function it_rejects_invalid_arguments(int $itemsPerPage, int $currentPage): void + { + $this->expectException(ArgumentWasInvalid::class); + + new SimplePaginator(itemsPerPage: $itemsPerPage, currentPage: $currentPage); + } + + public static function invalidArgumentsProvider(): array + { + return [ + 'zero items per page' => [0, 1], + 'negative items per page' => [-1, 1], + 'zero current page' => [20, 0], + 'negative current page' => [20, -1], + ]; + } + + #[Test] + public function it_maps_data_while_preserving_pagination_metadata(): void + { + $result = new SimplePaginator(itemsPerPage: 2, currentPage: 2) + ->paginate([1, 2, 3]) + ->map(fn (int $value): string => "item-{$value}"); + + $this->assertInstanceOf(SimplePaginatedData::class, $result); + $this->assertSame(['item-1', 'item-2'], $result->data); + $this->assertSame(2, $result->currentPage); + $this->assertSame(2, $result->itemsPerPage); + $this->assertSame(2, $result->offset); + $this->assertSame(2, $result->limit); + $this->assertTrue($result->hasNext); + $this->assertTrue($result->hasPrevious); + $this->assertSame(3, $result->nextPage); + $this->assertSame(1, $result->previousPage); + } + + #[Test] + public function it_converts_to_an_array_and_json(): void + { + $result = new SimplePaginator(itemsPerPage: 2)->paginate(['a', 'b', 'c']); + $expected = [ + 'data' => ['a', 'b'], + 'pagination' => [ + 'current_page' => 1, + 'items_per_page' => 2, + 'offset' => 0, + 'limit' => 2, + 'has_next' => true, + 'has_previous' => false, + 'next_page' => 2, + 'previous_page' => null, + 'count' => 2, + ], + ]; + + $this->assertSame($expected, $result->toArray()); + $this->assertSame($expected, json_decode(json_encode($result, flags: JSON_THROW_ON_ERROR), associative: true, flags: JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index ca5fe2746..caa78ef77 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -10,7 +10,9 @@ use Tempest\Database\Direction; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\QueryExecuted; use Tempest\Database\Table; +use Tests\Tempest\Fixtures\Events\QueryLogger; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; @@ -598,6 +600,55 @@ public function paginate(): void $this->assertSame('Timeline Taxi Chapter 4', $page10->data[0]->title); } + #[Test] + public function simple_paginate_uses_one_query_and_an_additional_item_to_determine_the_next_page(): void + { + $this->seed(); + QueryLogger::reset(); + + $page2 = query(Chapter::class) + ->select() + ->simplePaginate(itemsPerPage: 2, currentPage: 2); + + $this->assertSame(2, $page2->currentPage); + $this->assertSame(2, $page2->itemsPerPage); + $this->assertSame(2, $page2->offset); + $this->assertSame(2, $page2->limit); + $this->assertTrue($page2->hasNext); + $this->assertTrue($page2->hasPrevious); + $this->assertSame(3, $page2->nextPage); + $this->assertSame(1, $page2->previousPage); + $this->assertSame(['LOTR 1.3', 'LOTR 2.1'], array_map(fn (Chapter $chapter): string => $chapter->title, $page2->data)); + + $this->assertCount(1, QueryLogger::$queries); + $this->assertInstanceOf(QueryExecuted::class, QueryLogger::$queries[0]); + $this->assertStringContainsStringIgnoringCase('LIMIT 3 OFFSET 2', QueryLogger::$queries[0]->sql); + $this->assertStringNotContainsStringIgnoringCase('COUNT(', QueryLogger::$queries[0]->sql); + } + + #[Test] + public function simple_paginate_handles_the_last_and_out_of_range_pages(): void + { + $this->seed(); + + $lastPage = query(Chapter::class) + ->select() + ->simplePaginate(itemsPerPage: 2, currentPage: 7); + + $this->assertSame(['Timeline Taxi Chapter 4'], array_map(fn (Chapter $chapter): string => $chapter->title, $lastPage->data)); + $this->assertFalse($lastPage->hasNext); + $this->assertTrue($lastPage->hasPrevious); + + $outOfRangePage = query(Chapter::class) + ->select() + ->simplePaginate(itemsPerPage: 2, currentPage: 100); + + $this->assertSame([], $outOfRangePage->data); + $this->assertFalse($outOfRangePage->hasNext); + $this->assertTrue($outOfRangePage->hasPrevious); + $this->assertSame(99, $outOfRangePage->previousPage); + } + #[Test] public function paginate_with_where_condition(): void {