diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fa69b0..b7f72aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ - New #224: Add filtering by nested values support in `IterableDataReader` (@vjik) - Chg #225: Rename classes: `All` to `AndX`, `Any` to `OrX`. Remove `Group` class (@vjik) - Chg #226: Refactor filter classes to use readonly properties instead of getters (@vjik) +- New #213: Add `nextPage()` and `previousPage()` methods to `PaginatorInterface` (@samdark) ## 1.0.1 January 25, 2023 diff --git a/README.md b/README.md index 74ecc55..849ad33 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,28 @@ $paginator = (new KeysetPaginator($dataReader)) When displaying first page ID (or another field name to paginate by) of the item displayed last is used with `withNextPageToken()` to get next page. +#### Page-by-page navigation + +Both `OffsetPaginator` and `KeysetPaginator` provide `nextPage()` and `previousPage()` methods for easy page-by-page data reading: + +```php +$dataReader = (new QueryDataReader($query))->withSort(Sort::only(['id'])); +$paginator = (new KeysetPaginator($dataReader))->withPageSize(1000); + +// Iterate through all pages +for ( + $currentPaginator = $paginator; + $currentPaginator !== null; + $currentPaginator = $currentPaginator->nextPage() +) { + foreach ($currentPaginator->read() as $data) { + // Process each item + } +} +``` + +The `nextPage()` method returns a new paginator instance configured for the next page, or `null` when there are no more pages. Similarly, `previousPage()` returns a paginator for the previous page, or `null` when at the first page. + ## Writing data ```php diff --git a/src/Paginator/KeysetPaginator.php b/src/Paginator/KeysetPaginator.php index 707afa3..f1c1e8d 100644 --- a/src/Paginator/KeysetPaginator.php +++ b/src/Paginator/KeysetPaginator.php @@ -246,6 +246,22 @@ public function getNextToken(): ?PageToken : ($this->currentLastValue === null ? null : PageToken::next($this->currentLastValue)); } + public function nextPage(): ?static + { + $nextToken = $this->getNextToken(); + return $nextToken === null + ? null + : $this->withToken($nextToken); + } + + public function previousPage(): ?static + { + $previousToken = $this->getPreviousToken(); + return $previousToken === null + ? null + : $this->withToken($previousToken); + } + public function isSortable(): bool { return true; diff --git a/src/Paginator/OffsetPaginator.php b/src/Paginator/OffsetPaginator.php index add94ed..b916804 100644 --- a/src/Paginator/OffsetPaginator.php +++ b/src/Paginator/OffsetPaginator.php @@ -156,6 +156,22 @@ public function getPreviousToken(): ?PageToken return $this->isOnFirstPage() ? null : PageToken::next((string) ($this->getCurrentPage() - 1)); } + public function nextPage(): ?static + { + $nextToken = $this->getNextToken(); + return $nextToken === null + ? null + : $this->withToken($nextToken); + } + + public function previousPage(): ?static + { + $previousToken = $this->getPreviousToken(); + return $previousToken === null + ? null + : $this->withToken($previousToken); + } + public function getPageSize(): int { return $this->pageSize; diff --git a/src/Paginator/PaginatorInterface.php b/src/Paginator/PaginatorInterface.php index 60d470c..c284bc1 100644 --- a/src/Paginator/PaginatorInterface.php +++ b/src/Paginator/PaginatorInterface.php @@ -75,6 +75,20 @@ public function getNextToken(): ?PageToken; */ public function getPreviousToken(): ?PageToken; + /** + * Get a data reader for the next page. + * + * @return static|null Data reader for the next page or `null` if on last page. + */ + public function nextPage(): ?static; + + /** + * Get a data reader for the next page. + * + * @return static|null Data reader for the next page or `null` if on last page. + */ + public function previousPage(): ?static; + /** * Get the maximum number of items per page. * diff --git a/tests/Paginator/KeysetPaginatorTest.php b/tests/Paginator/KeysetPaginatorTest.php index 07b18dd..8245b0d 100644 --- a/tests/Paginator/KeysetPaginatorTest.php +++ b/tests/Paginator/KeysetPaginatorTest.php @@ -1168,4 +1168,86 @@ public function testGetPageToken(): void $this->assertSame($token, $paginator->getToken()); } + + public function testNextPage(): void + { + $dataSet = [['id' => 1], ['id' => 2], ['id' => 3]]; + $sort = Sort::only(['id']); + $dataReader = (new IterableDataReader($dataSet))->withSort($sort); + $paginator = (new KeysetPaginator($dataReader))->withPageSize(2); + + // Test first page has next page + $nextPageReader = $paginator->nextPage(); + $this->assertInstanceOf(KeysetPaginator::class, $nextPageReader); + + // Verify the next page returns correct data + $nextPageData = array_values($this->iterableToArray($nextPageReader->read())); + $this->assertSame([['id' => 3]], $nextPageData); + + // Test that returns null when there are no more pages + $this->assertNull($nextPageReader->nextPage()); + } + + public function testNextPageIterativeReading(): void + { + $dataSet = [['id' => 1], ['id' => 2], ['id' => 3]]; + $sort = Sort::only(['id']); + $dataReader = (new IterableDataReader($dataSet))->withSort($sort); + $paginator = (new KeysetPaginator($dataReader))->withPageSize(2); + + $allData = []; + + // Read all pages iteratively + for ( + $currentPaginator = $paginator; + $currentPaginator !== null; + $currentPaginator = $currentPaginator->nextPage() + ) { + $pageData = array_values($this->iterableToArray($currentPaginator->read())); + $allData = array_merge($allData, $pageData); + } + + // Verify we got all the data + $this->assertSame($dataSet, $allData); + } + + public function testPreviousPage(): void + { + $dataSet = [['id' => 1], ['id' => 2], ['id' => 3]]; + $sort = Sort::only(['id']); + $dataReader = (new IterableDataReader($dataSet))->withSort($sort); + $paginator = (new KeysetPaginator($dataReader))->withPageSize(2)->withToken(PageToken::next('2')); + + // Test reader has previous page + $previousPageReader = $paginator->previousPage(); + $this->assertInstanceOf(KeysetPaginator::class, $previousPageReader); + + // Verify the previous page returns correct data + $previousPageData = array_values($this->iterableToArray($previousPageReader->read())); + $this->assertSame([['id' => 1], ['id' => 2]], $previousPageData); + + // Test that returns null when there are no more pages + $this->assertNull($previousPageReader->previousPage()); + } + + public function testPreviousPageIterativeReading(): void + { + $dataSet = [['id' => 1], ['id' => 2], ['id' => 3]]; + $sort = Sort::only(['id']); + $dataReader = (new IterableDataReader($dataSet))->withSort($sort); + $paginator = (new KeysetPaginator($dataReader))->withPageSize(2)->withToken(PageToken::next('2')); + + $allData = []; + $currentPaginator = $paginator; + + // Read all pages iteratively + while ($currentPaginator !== null) { + $pageData = array_values($this->iterableToArray($currentPaginator->read())); + $allData = array_merge($pageData, $allData); + $currentPaginator = $currentPaginator->previousPage(); + } + + // Verify we got all the data + $this->assertSame($dataSet, $allData); + } } diff --git a/tests/Paginator/OffsetPaginatorTest.php b/tests/Paginator/OffsetPaginatorTest.php index 5a0021a..439819c 100644 --- a/tests/Paginator/OffsetPaginatorTest.php +++ b/tests/Paginator/OffsetPaginatorTest.php @@ -671,4 +671,83 @@ public function testReadOneWithLimit1(): void $this->assertSame(self::ITEM_1, $result); } + + public function testNextPage(): void + { + $dataSet = [['id' => 1], ['id' => 2], ['id' => 3]]; + $dataReader = new IterableDataReader($dataSet); + $paginator = (new OffsetPaginator($dataReader))->withPageSize(2); + + // Test first page has next page + $nextPageReader = $paginator->nextPage(); + $this->assertInstanceOf(OffsetPaginator::class, $nextPageReader); + + // Verify the next page returns correct data + $nextPageData = array_values($this->iterableToArray($nextPageReader->read())); + $this->assertSame([['id' => 3]], $nextPageData); + + // Test that returns null when there are no more pages + $this->assertNull($nextPageReader->nextPage()); + } + + public function testNextPageIterativeReading(): void + { + $dataSet = [['id' => 1], ['id' => 2], ['id' => 3]]; + $sort = Sort::only(['id']); + $dataReader = (new IterableDataReader($dataSet))->withSort($sort); + $paginator = (new OffsetPaginator($dataReader))->withPageSize(2); + + $allData = []; + $currentPaginator = $paginator; + + // Read all pages iteratively + while ($currentPaginator !== null) { + $pageData = array_values($this->iterableToArray($currentPaginator->read())); + $allData = array_merge($allData, $pageData); + $currentPaginator = $currentPaginator->nextPage(); + } + + // Verify we got all the data + $this->assertSame($dataSet, $allData); + } + + public function testPreviousPage(): void + { + $dataSet = [['id' => 1], ['id' => 2], ['id' => 3]]; + $sort = Sort::only(['id']); + $dataReader = (new IterableDataReader($dataSet))->withSort($sort); + $paginator = (new OffsetPaginator($dataReader))->withPageSize(2)->withCurrentPage(2); + + // Test reader has previous page + $previousPageReader = $paginator->previousPage(); + $this->assertInstanceOf(OffsetPaginator::class, $previousPageReader); + + // Verify the previous page returns correct data + $previousPageData = array_values($this->iterableToArray($previousPageReader->read())); + $this->assertSame([['id' => 1], ['id' => 2]], $previousPageData); + + // Test that returns null when there are no more pages + $this->assertNull($previousPageReader->previousPage()); + } + + public function testPreviousPageIterativeReading(): void + { + $dataSet = [['id' => 1], ['id' => 2], ['id' => 3]]; + $sort = Sort::only(['id']); + $dataReader = (new IterableDataReader($dataSet))->withSort($sort); + $paginator = (new OffsetPaginator($dataReader))->withPageSize(2)->withCurrentPage(2); + + $allData = []; + $currentPaginator = $paginator; + + // Read all pages iteratively + while ($currentPaginator !== null) { + $pageData = array_values($this->iterableToArray($currentPaginator->read())); + $allData = array_merge($pageData, $allData); + $currentPaginator = $currentPaginator->previousPage(); + } + + // Verify we got all the data + $this->assertSame($dataSet, $allData); + } }