diff --git a/CHANGELOG.md b/CHANGELOG.md index 607bfa89..f0c94521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ - New #150: Extract `withLimit()` from `ReadableDataInterface` into `LimitableDataInterface` (@vjik) - Enh #150: `PaginatorInterface` now extends `ReadableDataInterface` (@vjik) - Chg #151: Rename `isRequired()` method in `PaginatorInterface` to `isPaginationRequired()` (@vjik) +- New #153: Add `KeysetPaginator::withFilterCallback()` method that allows set closure for preparing filter passed to + the data reader (@vjik) +- New #153: Add `Compare::withValue()` method (@vjik) ## 1.0.1 January 25, 2023 diff --git a/src/Paginator/KeysetFilterContext.php b/src/Paginator/KeysetFilterContext.php new file mode 100644 index 00000000..ac0b6177 --- /dev/null +++ b/src/Paginator/KeysetFilterContext.php @@ -0,0 +1,16 @@ + + * + * @psalm-type FilterCallback = Closure(GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual,KeysetFilterContext):FilterInterface */ final class KeysetPaginator implements PaginatorInterface { @@ -73,6 +76,11 @@ final class KeysetPaginator implements PaginatorInterface */ private bool $hasNextPage = false; + /** + * @psalm-var FilterCallback|null + */ + private ?Closure $filterCallback = null; + /** * Reader cache against repeated scans. * See more {@see __clone()} and {@see initialize()}. @@ -158,6 +166,25 @@ public function withPageSize(int $pageSize): static return $new; } + /** + * Returns a new instance with defined closure for preparing data reader filters. + * + * @psalm-param FilterCallback|null $callback Closure with signature: + * + * ```php + * function( + * GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual $filter, + * KeysetFilterContext $context + * ): FilterInterface + * ``` + */ + public function withFilterCallback(?Closure $callback): self + { + $new = clone $this; + $new->filterCallback = $callback; + return $new; + } + /** * Reads items of the page. * @@ -275,7 +302,6 @@ private function initialize(): void private function readData(ReadableDataInterface $dataReader, Sort $sort): array { $data = []; - /** @var string $field */ [$field] = $this->getFieldAndSortingFromSort($sort); foreach ($dataReader->read() as $key => $item) { @@ -315,25 +341,51 @@ private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort return !empty($dataReader->withFilter($reverseFilter)->readOne()); } - private function getFilter(Sort $sort): Compare + private function getFilter(Sort $sort): FilterInterface { $value = $this->getValue(); - /** @var string $field */ [$field, $sorting] = $this->getFieldAndSortingFromSort($sort); - return $sorting === 'asc' ? new GreaterThan($field, $value) : new LessThan($field, $value); + + $filter = $sorting === SORT_ASC ? new GreaterThan($field, $value) : new LessThan($field, $value); + if ($this->filterCallback === null) { + return $filter; + } + + return ($this->filterCallback)( + $filter, + new KeysetFilterContext( + $field, + $value, + $sorting, + false, + ) + ); } - private function getReverseFilter(Sort $sort): Compare + private function getReverseFilter(Sort $sort): FilterInterface { $value = $this->getValue(); - /** @var string $field */ [$field, $sorting] = $this->getFieldAndSortingFromSort($sort); - return $sorting === 'asc' ? new LessThanOrEqual($field, $value) : new GreaterThanOrEqual($field, $value); + + $filter = $sorting === SORT_ASC ? new LessThanOrEqual($field, $value) : new GreaterThanOrEqual($field, $value); + if ($this->filterCallback === null) { + return $filter; + } + + return ($this->filterCallback)( + $filter, + new KeysetFilterContext( + $field, + $value, + $sorting, + true, + ) + ); } /** - * @psalm-suppress NullableReturnStatement, InvalidNullableReturnType The code calling this method - * must ensure that at least one of the properties `$firstValue` or `$lastValue` is not `null`. + * @psalm-suppress NullableReturnStatement, InvalidNullableReturnType, PossiblyNullArgument The code calling this + * method must ensure that at least one of the properties `$firstValue` or `$lastValue` is not `null`. */ private function getValue(): string { @@ -351,13 +403,16 @@ private function reverseSort(Sort $sort): Sort return $sort->withOrder($order); } + /** + * @psalm-return array{0: string, 1: int} + */ private function getFieldAndSortingFromSort(Sort $sort): array { $order = $sort->getOrder(); return [ (string) key($order), - reset($order), + reset($order) === 'asc' ? SORT_ASC : SORT_DESC, ]; } diff --git a/src/Reader/Filter/Compare.php b/src/Reader/Filter/Compare.php index de833225..39cd4d85 100644 --- a/src/Reader/Filter/Compare.php +++ b/src/Reader/Filter/Compare.php @@ -22,12 +22,27 @@ abstract class Compare implements FilterInterface */ public function __construct(private string $field, mixed $value) { - FilterAssert::isScalarOrInstanceOfDateTimeInterface($value); - $this->value = $value; + $this->setValue($value); + } + + /** + * @param bool|DateTimeInterface|float|int|string $value Value to compare to. + */ + final public function withValue(mixed $value): static + { + $new = clone $this; + $new->setValue($value); + return $new; } public function toCriteriaArray(): array { return [static::getOperator(), $this->field, $this->value]; } + + private function setValue(mixed $value): void + { + FilterAssert::isScalarOrInstanceOfDateTimeInterface($value); + $this->value = $value; + } } diff --git a/src/Reader/FilterAssert.php b/src/Reader/FilterAssert.php index 4efdddfb..c2aa611a 100644 --- a/src/Reader/FilterAssert.php +++ b/src/Reader/FilterAssert.php @@ -75,6 +75,8 @@ public static function isScalar(mixed $value): void * @param mixed $value Value to check. * * @throws InvalidArgumentException If value is not correct. + * + * @psalm-assert DateTimeInterface|scalar $value */ public static function isScalarOrInstanceOfDateTimeInterface(mixed $value): void { diff --git a/tests/Paginator/KeysetPaginatorTest.php b/tests/Paginator/KeysetPaginatorTest.php index ddea7a79..3b9647ae 100644 --- a/tests/Paginator/KeysetPaginatorTest.php +++ b/tests/Paginator/KeysetPaginatorTest.php @@ -8,6 +8,8 @@ use InvalidArgumentException; use RuntimeException; use stdClass; +use Yiisoft\Arrays\ArrayHelper; +use Yiisoft\Data\Paginator\KeysetFilterContext; use Yiisoft\Data\Paginator\KeysetPaginator; use Yiisoft\Data\Reader\Filter\GreaterThan; use Yiisoft\Data\Reader\Filter\GreaterThanOrEqual; @@ -21,6 +23,7 @@ use Yiisoft\Data\Reader\ReadableDataInterface; use Yiisoft\Data\Reader\Sort; use Yiisoft\Data\Reader\SortableDataInterface; +use Yiisoft\Data\Tests\Support\MutationDataReader; use Yiisoft\Data\Tests\TestCase; use function array_values; @@ -738,6 +741,7 @@ public function testImmutability(): void $this->assertNotSame($paginator, $paginator->withNextPageToken('1')); $this->assertNotSame($paginator, $paginator->withPreviousPageToken('1')); $this->assertNotSame($paginator, $paginator->withPageSize(1)); + $this->assertNotSame($paginator, $paginator->withFilterCallback(null)); } public function testGetPreviousPageExistForCoverage(): void @@ -795,4 +799,249 @@ public function testGetReverseFilterForCoverage(): void $this->invokeMethod($paginator, 'getReverseFilter', [$sort]), ); } + + public function testFilterCallback(): void + { + $dataReader = (new MutationDataReader( + new IterableDataReader(self::DEFAULT_DATASET), + static function ($item) { + $item['id']--; + return $item; + } + ))->withSort(Sort::only(['id'])->withOrderString('id')); + $paginator = (new KeysetPaginator($dataReader)) + ->withPageSize(2) + ->withPreviousPageToken('5') + ->withFilterCallback( + static function ( + GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual $filter, + KeysetFilterContext $context + ): FilterInterface { + if ($context->field === 'id') { + $filter = $filter->withValue((string)($context->value + 1)); + } + return $filter; + } + ); + + $this->assertSame( + [ + [ + 'id' => 2, + 'name' => 'Agent K', + ], + [ + 'id' => 4, + 'name' => 'Agent J', + ], + ], + array_values($paginator->read()) + ); + } + + public function testFilterCallbackExtended(): void + { + $dataReader = (new MutationDataReader( + new IterableDataReader(self::DEFAULT_DATASET), + static function ($item) { + $item['id']--; + return $item; + } + ))->withSort(Sort::only(['id'])->withOrderString('id')); + $paginator = (new KeysetPaginator($dataReader)) + ->withPageSize(2) + ->withPreviousPageToken('2') + ->withFilterCallback( + static function ( + GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual $filter, + KeysetFilterContext $context + ): FilterInterface { + $value = $context->field === 'id' + ? (string)($context->value + 1) + : $context->value; + + if ($context->isReverse) { + $filter = $context->sorting === SORT_ASC + ? new LessThanOrEqual($context->field, $value) + : new GreaterThanOrEqual($context->field, $value); + } else { + $filter = $context->sorting === SORT_ASC + ? new GreaterThan($context->field, $value) + : new LessThan($context->field, $value); + } + + return $filter; + } + ); + + $this->assertSame( + [ + [ + 'id' => 0, + 'name' => 'Codename Boris', + ], + [ + 'id' => 1, + 'name' => 'Codename Doris', + ], + ], + array_values($paginator->read()) + ); + } + + public function testFilterCallbackWithReverse(): void + { + $dataReader = (new IterableDataReader(self::DEFAULT_DATASET)) + ->withSort(Sort::only(['id'])->withOrderString('id')); + $paginator = (new KeysetPaginator($dataReader)) + ->withPreviousPageToken('1') + ->withFilterCallback( + static function ( + GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual $filter, + KeysetFilterContext $context + ): FilterInterface { + if ($context->isReverse) { + return $context->sorting === SORT_ASC + ? new LessThanOrEqual($context->field, $context->value) + : new GreaterThanOrEqual($context->field, $context->value); + } + return $context->sorting === SORT_ASC + ? new GreaterThan($context->field, $context->value) + : new LessThan($context->field, $context->value); + } + ); + + $this->assertTrue($paginator->isOnFirstPage()); + $this->assertFalse($paginator->isOnLastPage()); + } + + public static function dataPageTypeWithPreviousPageToken(): array + { + return [ + /** + * Straight order + * ['id' => 10] + * ['id' => 11] + * ['id' => 12] + * ['id' => 13] + */ + [true, false, [], '8'], + [true, false, [], '9'], + [true, false, [], '10'], + [true, false, [10], '11'], + [true, false, [10, 11], '12'], + [false, false, [11, 12], '13'], + [false, true, [12, 13], '14'], + [false, true, [12, 13], '15'], + + /** + * Reverse order + * ['id' => 13] + * ['id' => 12] + * ['id' => 11] + * ['id' => 10] + */ + [false, true, [11, 10], '8', true], + [false, true, [11, 10], '9', true], + [false, false, [12, 11], '10', true], + [true, false, [13, 12], '11', true], + [true, false, [13], '12', true], + [true, false, [], '13', true], + [true, false, [], '14', true], + [true, false, [], '15', true], + ]; + } + + /** + * @dataProvider dataPageTypeWithPreviousPageToken + */ + public function testPageTypeWithPreviousPageToken( + bool $expectedIsOnFirstPage, + bool $expectedIsOnLastPage, + array $expectedIds, + string $token, + bool $isReverseOrder = false + ): void { + $data = [ + ['id' => 10], + ['id' => 11], + ['id' => 12], + ['id' => 13], + ]; + $sort = Sort::only(['id'])->withOrderString($isReverseOrder ? '-id' : 'id'); + $reader = (new IterableDataReader($data))->withSort($sort); + + $paginator = (new KeysetPaginator($reader)) + ->withPageSize(2) + ->withPreviousPageToken($token); + + $this->assertSame($expectedIsOnFirstPage, $paginator->isOnFirstPage()); + $this->assertSame($expectedIsOnLastPage, $paginator->isOnLastPage()); + $this->assertSame($expectedIds, ArrayHelper::getColumn($paginator->read(), 'id', keepKeys: false)); + } + + public static function dataPageTypeWithNextPageToken(): array + { + return [ + /** + * Straight order + * ['id' => 10] + * ['id' => 11] + * ['id' => 12] + * ['id' => 13] + */ + [true, false, [10, 11], '8'], + [true, false, [10, 11], '9'], + [false, false, [11, 12], '10'], + [false, true, [12, 13], '11'], + [false, true, [13], '12'], + [false, true, [], '13'], + [false, true, [], '14'], + [false, true, [], '15'], + + /** + * Reverse order + * ['id' => 13] + * ['id' => 12] + * ['id' => 11] + * ['id' => 10] + */ + [false, true, [], '8', true], + [false, true, [], '9', true], + [false, true, [], '10', true], + [false, true, [10], '11', true], + [false, true, [11, 10], '12', true], + [false, false, [12, 11], '13', true], + [true, false, [13, 12], '14', true], + [true, false, [13, 12], '15', true], + ]; + } + + /** + * @dataProvider dataPageTypeWithNextPageToken + */ + public function testPageTypeWithNextPageToken( + bool $expectedIsOnFirstPage, + bool $expectedIsOnLastPage, + array $expectedIds, + string $token, + bool $isReverseOrder = false + ): void { + $data = [ + ['id' => 10], + ['id' => 11], + ['id' => 12], + ['id' => 13], + ]; + $sort = Sort::only(['id'])->withOrderString($isReverseOrder ? '-id' : 'id'); + $reader = (new IterableDataReader($data))->withSort($sort); + + $paginator = (new KeysetPaginator($reader)) + ->withPageSize(2) + ->withNextPageToken($token); + + $this->assertSame($expectedIsOnFirstPage, $paginator->isOnFirstPage()); + $this->assertSame($expectedIsOnLastPage, $paginator->isOnLastPage()); + $this->assertSame($expectedIds, ArrayHelper::getColumn($paginator->read(), 'id', keepKeys: false)); + } } diff --git a/tests/Reader/Filter/CompareTest.php b/tests/Reader/Filter/CompareTest.php new file mode 100644 index 00000000..ff4b32b4 --- /dev/null +++ b/tests/Reader/Filter/CompareTest.php @@ -0,0 +1,19 @@ +assertNotSame($filter, $filter->withValue(1)); + $this->assertSame(['<', 'field', 2], $filter->withValue(2)->toCriteriaArray()); + } +} diff --git a/tests/Support/MutationDataReader.php b/tests/Support/MutationDataReader.php new file mode 100644 index 00000000..5792fa15 --- /dev/null +++ b/tests/Support/MutationDataReader.php @@ -0,0 +1,71 @@ +decorated = $this->decorated->withFilter($filter); + return $new; + } + + public function withFilterHandlers(FilterHandlerInterface ...$filterHandlers): static + { + $new = clone $this; + $new->decorated = $this->decorated->withFilterHandlers(...$filterHandlers); + return $new; + } + + public function withLimit(int $limit): static + { + $new = clone $this; + $new->decorated = $this->decorated->withLimit($limit); + return $new; + } + + public function read(): iterable + { + return array_map($this->mutation, $this->decorated->read()); + } + + public function readOne(): array|object|null + { + return call_user_func($this->mutation, $this->decorated->readOne()); + } + + public function withSort(?Sort $sort): static + { + $new = clone $this; + $new->decorated = $this->decorated->withSort($sort); + return $new; + } + + public function getSort(): ?Sort + { + return $this->decorated->getSort(); + } +}