diff --git a/.github/workflows/bc.yml b/.github/workflows/bc.yml index ba3e9ef1..15c4880a 100644 --- a/.github/workflows/bc.yml +++ b/.github/workflows/bc.yml @@ -1,6 +1,15 @@ on: - - pull_request - - push + pull_request: + paths: + - 'src/**' + - '.github/workflows/bc.yml' + - 'composer.json' + push: + branches: ['master'] + paths: + - 'src/**' + - '.github/workflows/bc.yml' + - 'composer.json' name: backwards compatibility diff --git a/CHANGELOG.md b/CHANGELOG.md index beb5f8a3..cb979180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Yii Data Change Log -## 1.0.2 under development +## 2.0.0 under development -- no changes in this release. +- New #150: Extract `withLimit()` from `ReadableDataInterface` into `LimitableDataInterface` (@vjik) +- Enh #150: `PaginatorInterface` now extends `ReadableDataInterface` (@vjik) ## 1.0.1 January 25, 2023 diff --git a/README.md b/README.md index 9ae9da92..38eac5be 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ Additional interfaces could be implemented in order to support different paginat - `CountableDataInterface` - allows getting total number of items in data reader. - `FilterableDataInterface` - allows returning subset of items based on criteria. +- `LimitableDataInterface` - allows returning limited subset of items. - `SortableDataInterface` - allows sorting by one or multiple fields. - `OffsetableDataInterface` - allows to skip first N items when reading data. diff --git a/infection.json.dist b/infection.json.dist index 3776e223..204fd163 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -11,6 +11,21 @@ } }, "mutators": { - "@default": true + "@default": true, + "LogicalAndAllSubExprNegation": { + "ignore": [ + "Yiisoft\\Data\\Paginator\\KeysetPaginator::isOnFirstPage" + ] + }, + "IncrementInteger": { + "ignore": [ + "Yiisoft\\Data\\Paginator\\OffsetPaginator::readOne" + ] + }, + "DecrementInteger": { + "ignore": [ + "Yiisoft\\Data\\Paginator\\OffsetPaginator::readOne" + ] + } } } diff --git a/psalm-php80.xml b/psalm-php80.xml index f3ed238f..ba4ad097 100644 --- a/psalm-php80.xml +++ b/psalm-php80.xml @@ -1,6 +1,8 @@ &FilterableDataInterface&SortableDataInterface + * @psalm-var ReadableDataInterface&LimitableDataInterface&FilterableDataInterface&SortableDataInterface */ private ReadableDataInterface $dataReader; @@ -82,7 +83,7 @@ final class KeysetPaginator implements PaginatorInterface /** * @param ReadableDataInterface $dataReader Data reader being paginated. - * @psalm-param ReadableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader + * @psalm-param ReadableDataInterface&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader * @psalm-suppress DocblockTypeContradiction Needed to allow validating `$dataReader` */ public function __construct(ReadableDataInterface $dataReader) @@ -101,6 +102,13 @@ public function __construct(ReadableDataInterface $dataReader) )); } + if (!$dataReader instanceof LimitableDataInterface) { + throw new InvalidArgumentException(sprintf( + 'Data reader should implement "%s" to be used with keyset paginator.', + LimitableDataInterface::class, + )); + } + $sort = $dataReader->getSort(); if ($sort === null) { @@ -185,6 +193,15 @@ public function read(): iterable return $this->readCache = $data; } + public function readOne(): array|object|null + { + foreach ($this->read() as $item) { + return $item; + } + + return null; + } + public function getPageSize(): int { return $this->pageSize; @@ -289,7 +306,7 @@ private function reverseData(array $data): array } /** - * @psalm-param ReadableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader + * @psalm-param ReadableDataInterface&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader */ private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort): bool { diff --git a/src/Paginator/OffsetPaginator.php b/src/Paginator/OffsetPaginator.php index 0dd5c132..823a7c2d 100644 --- a/src/Paginator/OffsetPaginator.php +++ b/src/Paginator/OffsetPaginator.php @@ -7,6 +7,7 @@ use Generator; use InvalidArgumentException; use Yiisoft\Data\Reader\CountableDataInterface; +use Yiisoft\Data\Reader\LimitableDataInterface; use Yiisoft\Data\Reader\OffsetableDataInterface; use Yiisoft\Data\Reader\ReadableDataInterface; use Yiisoft\Data\Reader\Sort; @@ -50,13 +51,13 @@ final class OffsetPaginator implements PaginatorInterface /** * Data reader being paginated. * - * @psalm-var ReadableDataInterface&OffsetableDataInterface&CountableDataInterface + * @psalm-var ReadableDataInterface&LimitableDataInterface&OffsetableDataInterface&CountableDataInterface */ private ReadableDataInterface $dataReader; /** * @param ReadableDataInterface $dataReader Data reader being paginated. - * @psalm-param ReadableDataInterface&OffsetableDataInterface&CountableDataInterface $dataReader + * @psalm-param ReadableDataInterface&LimitableDataInterface&OffsetableDataInterface&CountableDataInterface $dataReader * @psalm-suppress DocblockTypeContradiction Needed to allow validating `$dataReader` */ public function __construct(ReadableDataInterface $dataReader) @@ -75,6 +76,13 @@ public function __construct(ReadableDataInterface $dataReader) )); } + if (!$dataReader instanceof LimitableDataInterface) { + throw new InvalidArgumentException(sprintf( + 'Data reader should implement "%s" in order to be used with offset paginator.', + LimitableDataInterface::class, + )); + } + $this->dataReader = $dataReader; } @@ -213,6 +221,14 @@ public function read(): iterable ->read(); } + public function readOne(): array|object|null + { + return $this->dataReader + ->withLimit(1) + ->withOffset($this->getOffset()) + ->readOne(); + } + public function isOnFirstPage(): bool { return $this->currentPage === 1; diff --git a/src/Paginator/PaginatorInterface.php b/src/Paginator/PaginatorInterface.php index 58a69e2e..a0ea3bed 100644 --- a/src/Paginator/PaginatorInterface.php +++ b/src/Paginator/PaginatorInterface.php @@ -4,6 +4,7 @@ namespace Yiisoft\Data\Paginator; +use Yiisoft\Data\Reader\ReadableDataInterface; use Yiisoft\Data\Reader\Sort; /** @@ -14,8 +15,10 @@ * * @template TKey as array-key * @template TValue as array|object + * + * @extends ReadableDataInterface */ -interface PaginatorInterface +interface PaginatorInterface extends ReadableDataInterface { /** * Page size that is used in case it is not set explicitly. diff --git a/src/Reader/DataReaderInterface.php b/src/Reader/DataReaderInterface.php index b1ca8c78..e266da5f 100644 --- a/src/Reader/DataReaderInterface.php +++ b/src/Reader/DataReaderInterface.php @@ -24,6 +24,7 @@ */ interface DataReaderInterface extends ReadableDataInterface, + LimitableDataInterface, OffsetableDataInterface, CountableDataInterface, SortableDataInterface, diff --git a/src/Reader/LimitableDataInterface.php b/src/Reader/LimitableDataInterface.php new file mode 100644 index 00000000..c9fdd243 --- /dev/null +++ b/src/Reader/LimitableDataInterface.php @@ -0,0 +1,25 @@ +getNonSortableDataReader()); } + public function testDataReaderWithoutLimitableInterface(): void + { + $dataReader = new class () implements ReadableDataInterface, SortableDataInterface, FilterableDataInterface { + public function withSort(?Sort $sort): static + { + return clone $this; + } + + public function getSort(): ?Sort + { + return Sort::only([]); + } + + public function read(): iterable + { + return []; + } + + public function readOne(): array|object|null + { + return null; + } + + public function withFilter(FilterInterface $filter): static + { + return clone $this; + } + + public function withFilterHandlers(FilterHandlerInterface ...$filterHandlers): static + { + return clone $this; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Data reader should implement "%s" to be used with keyset paginator.', + LimitableDataInterface::class, + )); + + new KeysetPaginator($dataReader); + } + public function testThrowsExceptionWhenReaderHasNoSort(): void { $dataReader = new IterableDataReader($this->getDataSet()); @@ -256,6 +300,40 @@ public function testReadSecondPageOrderedByName(): void $this->assertSame((string)$last['name'], $paginator->getNextPageToken()); } + public function dataReadOne(): array + { + $data = []; + + $data['empty'] = [ + null, + new KeysetPaginator( + (new IterableDataReader([]))->withSort(Sort::only(['id'])->withOrderString('id')) + ), + ]; + + $data['base'] = [ + ['id' => 2, 'name' => 'John'], + new KeysetPaginator( + (new IterableDataReader([ + ['id' => 1, 'name' => 'Mike'], + ['id' => 2, 'name' => 'John'], + ])) + ->withSort(Sort::only(['id'])->withOrderString('-id')) + ), + ]; + + return $data; + } + + /** + * @dataProvider dataReadOne + */ + public function testReadOne(mixed $expected, KeysetPaginator $paginator): void + { + $result = $paginator->readOne(); + $this->assertSame($expected, $result); + } + public function testBackwardPagination(): void { $sort = Sort::only(['id', 'name'])->withOrderString('id'); @@ -511,7 +589,7 @@ private function getDataSet(array $keys = null): array private function getNonSortableDataReader() { - return new class () implements ReadableDataInterface, FilterableDataInterface { + return new class () implements ReadableDataInterface, LimitableDataInterface, FilterableDataInterface { public function withLimit(int $limit): static { return clone $this; @@ -541,7 +619,7 @@ public function withFilterHandlers(FilterHandlerInterface ...$filterHandlers): s private function getNonFilterableDataReader() { - return new class () implements ReadableDataInterface, SortableDataInterface { + return new class () implements ReadableDataInterface, LimitableDataInterface, SortableDataInterface { public function withLimit(int $limit): static { return clone $this; diff --git a/tests/Paginator/OffsetPaginatorTest.php b/tests/Paginator/OffsetPaginatorTest.php index bc993b75..5988c0b8 100644 --- a/tests/Paginator/OffsetPaginatorTest.php +++ b/tests/Paginator/OffsetPaginatorTest.php @@ -10,6 +10,7 @@ use Yiisoft\Data\Paginator\PaginatorInterface; use Yiisoft\Data\Reader\CountableDataInterface; use Yiisoft\Data\Reader\Iterable\IterableDataReader; +use Yiisoft\Data\Reader\LimitableDataInterface; use Yiisoft\Data\Reader\OffsetableDataInterface; use Yiisoft\Data\Reader\ReadableDataInterface; use Yiisoft\Data\Reader\Sort; @@ -119,6 +120,45 @@ public function withOffset(int $offset): static new OffsetPaginator($nonCountableDataReader); } + public function testDataReaderWithoutLimitableInterface(): void + { + $nonLimitableDataReader = new class () implements + ReadableDataInterface, + CountableDataInterface, + OffsetableDataInterface { + public function read(): iterable + { + return []; + } + + public function readOne(): array|object|null + { + return null; + } + + public function count(): int + { + return 0; + } + + public function withOffset(int $offset): static + { + // do nothing + return $this; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf( + 'Data reader should implement "%s" in order to be used with offset paginator.', + LimitableDataInterface::class, + ) + ); + + new OffsetPaginator($nonLimitableDataReader); + } + public function testDefaultState(): void { $dataReader = new IterableDataReader(self::DEFAULT_DATASET); @@ -253,6 +293,37 @@ public function testReadLastPage(): void $this->assertSame($expected, array_values($this->iterableToArray($paginator->read()))); } + public function dataReadOne(): array + { + $data = []; + + $data['empty'] = [ + null, + new OffsetPaginator(new IterableDataReader([])), + ]; + + $data['base'] = [ + ['id' => 2, 'name' => 'John'], + (new OffsetPaginator( + new IterableDataReader([ + ['id' => 1, 'name' => 'Mike'], + ['id' => 2, 'name' => 'John'], + ]) + ))->withPageSize(1)->withCurrentPage(2), + ]; + + return $data; + } + + /** + * @dataProvider dataReadOne + */ + public function testReadOne(mixed $expected, OffsetPaginator $paginator): void + { + $result = $paginator->readOne(); + $this->assertSame($expected, $result); + } + public function testTotalPages(): void { $dataReader = new IterableDataReader(self::DEFAULT_DATASET);