From 5757040685ea378f14efb697941676be85bd03bd Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 10:41:41 -0400 Subject: [PATCH 01/20] Add strategy-independent pager, position and cursor contracts --- .gitattributes | 1 + adr/0001-position-marker-interface.md | 37 +++++++++++ lib/Core/Adapter/AdapterInterface.php | 6 +- .../Adapter/CountableAdapterInterface.php | 20 ++++++ lib/Core/Adapter/CursorAdapterInterface.php | 35 ++++++++++ lib/Core/Adapter/CursorSlice.php | 36 ++++++++++ lib/Core/Adapter/OffsetAdapterInterface.php | 21 ++++++ lib/Core/CountablePagerInterface.php | 21 ++++++ lib/Core/Cursor/Cursor.php | 35 ++++++++++ lib/Core/Cursor/CursorEncoderInterface.php | 18 +++++ lib/Core/Cursor/Direction.php | 12 ++++ lib/Core/CursorPagerInterface.php | 26 ++++++++ lib/Core/Exception/InvalidCursorException.php | 5 ++ lib/Core/OffsetPagerInterface.php | 37 +++++++++++ lib/Core/PagerInterface.php | 51 +++++++++++++++ lib/Core/Pagerfanta.php | 25 ++++++- lib/Core/PagerfantaInterface.php | 5 +- lib/Core/Position/CursorPosition.php | 15 +++++ lib/Core/Position/PagePosition.php | 24 +++++++ lib/Core/Position/Position.php | 10 +++ .../PageRouteGeneratorWrapper.php | 58 +++++++++++++++++ .../PositionRouteGeneratorInterface.php | 19 ++++++ lib/Core/Tests/Adapter/CursorSliceTest.php | 47 ++++++++++++++ lib/Core/Tests/Cursor/CursorTest.php | 61 +++++++++++++++++ lib/Core/Tests/PagerfantaTest.php | 43 ++++++++++++ .../Tests/Position/CursorPositionTest.php | 17 +++++ lib/Core/Tests/Position/PagePositionTest.php | 34 ++++++++++ .../PageRouteGeneratorWrapperTest.php | 65 +++++++++++++++++++ 28 files changed, 781 insertions(+), 3 deletions(-) create mode 100644 adr/0001-position-marker-interface.md create mode 100644 lib/Core/Adapter/CountableAdapterInterface.php create mode 100644 lib/Core/Adapter/CursorAdapterInterface.php create mode 100644 lib/Core/Adapter/CursorSlice.php create mode 100644 lib/Core/Adapter/OffsetAdapterInterface.php create mode 100644 lib/Core/CountablePagerInterface.php create mode 100644 lib/Core/Cursor/Cursor.php create mode 100644 lib/Core/Cursor/CursorEncoderInterface.php create mode 100644 lib/Core/Cursor/Direction.php create mode 100644 lib/Core/CursorPagerInterface.php create mode 100644 lib/Core/Exception/InvalidCursorException.php create mode 100644 lib/Core/OffsetPagerInterface.php create mode 100644 lib/Core/PagerInterface.php create mode 100644 lib/Core/Position/CursorPosition.php create mode 100644 lib/Core/Position/PagePosition.php create mode 100644 lib/Core/Position/Position.php create mode 100644 lib/Core/RouteGenerator/PageRouteGeneratorWrapper.php create mode 100644 lib/Core/RouteGenerator/PositionRouteGeneratorInterface.php create mode 100644 lib/Core/Tests/Adapter/CursorSliceTest.php create mode 100644 lib/Core/Tests/Cursor/CursorTest.php create mode 100644 lib/Core/Tests/Position/CursorPositionTest.php create mode 100644 lib/Core/Tests/Position/PagePositionTest.php create mode 100644 lib/Core/Tests/RouteGenerator/PageRouteGeneratorWrapperTest.php diff --git a/.gitattributes b/.gitattributes index 90144ccf..4c03fb5c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ /.github export-ignore +/adr export-ignore /bin export-ignore /docs export-ignore /lib/**/*/Tests export-ignore diff --git a/adr/0001-position-marker-interface.md b/adr/0001-position-marker-interface.md new file mode 100644 index 00000000..a1d6ff6d --- /dev/null +++ b/adr/0001-position-marker-interface.md @@ -0,0 +1,37 @@ +# 1. Positions are a marker interface + +- Status: Accepted +- Date: 2026-09-25 +- Tracking issue: [#30](https://github.com/BabDev/Pagerfanta/issues/30) + +## Context + +Pagerfanta is adding cursor pagination alongside the existing offset pagination. Route generators and views need to link to +the "previous" and "next" pages without knowing which strategy the pager uses. Offset pagers identify a page by its number, +while cursor pagers identify a page by a cursor holding sort key values and a direction. + +The root pager interface therefore needs a strategy-independent way to describe "where" a link points. Two options were +considered: + +1. **Marker interface**: an empty `Pagerfanta\Position\Position` interface, implemented by `PagePosition` (a page number) + and `CursorPosition` (a cursor). Pagers return positions, and route generators accept a `Position`. +2. **Generics only**: no shared type at runtime. The root pager declares `@template TPosition` and the position methods return + `mixed` (an `int` for offset pagers, a cursor for cursor pagers). + +## Decision + +Positions are a marker interface (option 1). The root `PagerInterface` also declares `@template TPosition of Position` so that +static analysis can narrow the position type for the offset (`PagePosition`) and cursor (`CursorPosition`) pagers. + +## Consequences + +- Route generators and views stay simply typed: `__invoke(Position $position): string` works for every strategy, and + implementations dispatch with `instanceof`. +- Each generated link allocates a small value object. This is negligible compared to rendering the link. +- Offset pagers wrap page numbers in `PagePosition`, so existing `int` based APIs (`getPreviousPage()`, `getNextPage()`, + `RouteGeneratorInterface`) remain in 4.x. `PageRouteGeneratorWrapper` adapts `int` based route generators to the position + based API. +- A generator given a position it does not support (e.g. an `int` based generator given a `CursorPosition`) throws an + `InvalidArgumentException` rather than silently producing a wrong URL. +- The generics-only approach would have pushed `mixed` into every generator and view signature, losing runtime type safety + for code not analyzed with PHPStan or Psalm. diff --git a/lib/Core/Adapter/AdapterInterface.php b/lib/Core/Adapter/AdapterInterface.php index 9776c1a7..016a29b0 100644 --- a/lib/Core/Adapter/AdapterInterface.php +++ b/lib/Core/Adapter/AdapterInterface.php @@ -5,9 +5,13 @@ use Pagerfanta\Exception\NotValidResultCountException; /** + * An adapter supporting offset based pagination which can report the total number of results. + * * @template-covariant T + * + * @extends OffsetAdapterInterface */ -interface AdapterInterface +interface AdapterInterface extends OffsetAdapterInterface, CountableAdapterInterface { /** * Returns the number of results for the list. diff --git a/lib/Core/Adapter/CountableAdapterInterface.php b/lib/Core/Adapter/CountableAdapterInterface.php new file mode 100644 index 00000000..76b524d4 --- /dev/null +++ b/lib/Core/Adapter/CountableAdapterInterface.php @@ -0,0 +1,20 @@ + + * + * @throws NotValidResultCountException if the number of results is less than zero + */ + public function getNbResults(): int; +} diff --git a/lib/Core/Adapter/CursorAdapterInterface.php b/lib/Core/Adapter/CursorAdapterInterface.php new file mode 100644 index 00000000..36f1ac1c --- /dev/null +++ b/lib/Core/Adapter/CursorAdapterInterface.php @@ -0,0 +1,35 @@ + + * + * @throws InvalidCursorException if the cursor is not valid for this adapter + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice; + + /** + * Whether this adapter can paginate backwards (i.e. generate a cursor for the previous page). + */ + public function supportsBackwardNavigation(): bool; +} diff --git a/lib/Core/Adapter/CursorSlice.php b/lib/Core/Adapter/CursorSlice.php new file mode 100644 index 00000000..8974a4eb --- /dev/null +++ b/lib/Core/Adapter/CursorSlice.php @@ -0,0 +1,36 @@ + $items The items on the page, in the list's sort order + * @param Cursor|null $previous The cursor for the previous page, or null when there is no previous page (or backward navigation is not supported) + * @param Cursor|null $next The cursor for the next page, or null when there is no next page + * + * @throws InvalidArgumentException if a cursor's direction does not match its role + */ + public function __construct( + public readonly array $items, + public readonly ?Cursor $previous = null, + public readonly ?Cursor $next = null, + ) { + if ($previous instanceof Cursor && Direction::Previous !== $previous->direction) { + throw new InvalidArgumentException('The previous cursor of a slice must have the previous direction.'); + } + + if ($next instanceof Cursor && Direction::Next !== $next->direction) { + throw new InvalidArgumentException('The next cursor of a slice must have the next direction.'); + } + } +} diff --git a/lib/Core/Adapter/OffsetAdapterInterface.php b/lib/Core/Adapter/OffsetAdapterInterface.php new file mode 100644 index 00000000..4d0576f5 --- /dev/null +++ b/lib/Core/Adapter/OffsetAdapterInterface.php @@ -0,0 +1,21 @@ + $offset + * @param int<0, max> $length + * + * @return iterable + */ + public function getSlice(int $offset, int $length): iterable; +} diff --git a/lib/Core/CountablePagerInterface.php b/lib/Core/CountablePagerInterface.php new file mode 100644 index 00000000..017e62a3 --- /dev/null +++ b/lib/Core/CountablePagerInterface.php @@ -0,0 +1,21 @@ + + */ +interface CountablePagerInterface extends PagerInterface +{ + /** + * @return int<0, max> + */ + public function getNbResults(): int; +} diff --git a/lib/Core/Cursor/Cursor.php b/lib/Core/Cursor/Cursor.php new file mode 100644 index 00000000..9feebdf2 --- /dev/null +++ b/lib/Core/Cursor/Cursor.php @@ -0,0 +1,35 @@ + $fields The sort key values, keyed by sort key (e.g. "p.createdAt" or "_id") + * + * @throws InvalidArgumentException if the fields are empty, not keyed by string, or contain non-scalar values + */ + public function __construct( + public readonly array $fields, + public readonly Direction $direction = Direction::Next, + ) { + if ([] === $fields) { + throw new InvalidArgumentException('A cursor must have at least one field.'); + } + + foreach ($fields as $key => $value) { + if (!\is_string($key)) { + throw new InvalidArgumentException(\sprintf('The fields of a cursor must be keyed by their sort key, "%s" given as a key.', get_debug_type($key))); + } + + if (null !== $value && !\is_scalar($value)) { + throw new InvalidArgumentException(\sprintf('The value of the "%s" cursor field must be a scalar or null, "%s" given.', $key, get_debug_type($value))); + } + } + } +} diff --git a/lib/Core/Cursor/CursorEncoderInterface.php b/lib/Core/Cursor/CursorEncoderInterface.php new file mode 100644 index 00000000..6e69d284 --- /dev/null +++ b/lib/Core/Cursor/CursorEncoderInterface.php @@ -0,0 +1,18 @@ + + */ +interface CursorPagerInterface extends PagerInterface +{ + /** + * @return CursorPosition|null The position of the current page, or null when on the first page + */ + public function getCurrentPosition(): ?CursorPosition; + + public function supportsBackwardNavigation(): bool; +} diff --git a/lib/Core/Exception/InvalidCursorException.php b/lib/Core/Exception/InvalidCursorException.php new file mode 100644 index 00000000..61162e08 --- /dev/null +++ b/lib/Core/Exception/InvalidCursorException.php @@ -0,0 +1,5 @@ + + */ +interface OffsetPagerInterface extends CountablePagerInterface +{ + /** + * @return positive-int + */ + public function getCurrentPage(): int; + + /** + * @return positive-int + */ + public function getNbPages(): int; + + /** + * Get page number of the item at specified position (1-based index). + * + * @param positive-int $position + * + * @return positive-int + * + * @throws OutOfBoundsException if the item is outside the result set + */ + public function getPageNumberForItemAtPosition(int $position): int; +} diff --git a/lib/Core/PagerInterface.php b/lib/Core/PagerInterface.php new file mode 100644 index 00000000..9a73396c --- /dev/null +++ b/lib/Core/PagerInterface.php @@ -0,0 +1,51 @@ + + */ +interface PagerInterface extends \IteratorAggregate /* , \Countable */ +{ + /** + * @return iterable + */ + public function getCurrentPageResults(): iterable; + + /** + * @return positive-int + */ + public function getMaxPerPage(): int; + + public function haveToPaginate(): bool; + + public function hasPreviousPage(): bool; + + public function hasNextPage(): bool; + + /** + * @return TPosition + * + * @throws LogicException if there is no previous page + */ + public function getPreviousPosition(): Position; + + /** + * @return TPosition + * + * @throws LogicException if there is no next page + */ + public function getNextPosition(): Position; +} diff --git a/lib/Core/Pagerfanta.php b/lib/Core/Pagerfanta.php index 75a89f95..af752d6a 100644 --- a/lib/Core/Pagerfanta.php +++ b/lib/Core/Pagerfanta.php @@ -10,13 +10,15 @@ use Pagerfanta\Exception\LogicException; use Pagerfanta\Exception\OutOfBoundsException; use Pagerfanta\Exception\OutOfRangeCurrentPageException; +use Pagerfanta\Position\PagePosition; /** * @template T * * @implements PagerfantaInterface + * @implements OffsetPagerInterface */ -class Pagerfanta implements PagerfantaInterface, \JsonSerializable +class Pagerfanta implements PagerfantaInterface, OffsetPagerInterface, \JsonSerializable { private bool $allowOutOfRangePages = false; private bool $normalizeOutOfRangePages = false; @@ -378,6 +380,14 @@ public function getPreviousPage(): int return $this->currentPage - 1; } + /** + * @throws LogicException if there is no previous page + */ + public function getPreviousPosition(): PagePosition + { + return new PagePosition($this->getPreviousPage()); + } + public function hasNextPage(): bool { return $this->currentPage < $this->getNbPages(); @@ -398,6 +408,19 @@ public function getNextPage(): int } /** + * @throws LogicException if there is no next page + */ + public function getNextPosition(): PagePosition + { + return new PagePosition($this->getNextPage()); + } + + /** + * Returns the total number of results. + * + * In 5.0, this will return the number of items on the current page to match the other pager implementations, + * use {@see Pagerfanta::getNbResults()} to get the total number of results. + * * @return int<0, max> */ public function count(): int diff --git a/lib/Core/PagerfantaInterface.php b/lib/Core/PagerfantaInterface.php index acaa6939..5a9903ce 100644 --- a/lib/Core/PagerfantaInterface.php +++ b/lib/Core/PagerfantaInterface.php @@ -8,6 +8,7 @@ use Pagerfanta\Exception\LessThan1MaxPerPageException; use Pagerfanta\Exception\LogicException; use Pagerfanta\Exception\OutOfRangeCurrentPageException; +use Pagerfanta\Position\PagePosition; /** * @template-covariant T @@ -15,8 +16,10 @@ * @extends \IteratorAggregate * * @method \Generator autoPagingIterator() + * @method PagePosition getPreviousPosition() + * @method PagePosition getNextPosition() */ -interface PagerfantaInterface extends \Countable, \IteratorAggregate +interface PagerfantaInterface extends /* OffsetPagerInterface, */ \Countable, \IteratorAggregate { /** * @return AdapterInterface diff --git a/lib/Core/Position/CursorPosition.php b/lib/Core/Position/CursorPosition.php new file mode 100644 index 00000000..17b8246e --- /dev/null +++ b/lib/Core/Position/CursorPosition.php @@ -0,0 +1,15 @@ +decorated = $decorated; + } + + /** + * Wraps the given route generator if it is not already position based. + * + * Callables which are not an instance of {@see PositionRouteGeneratorInterface} are treated as page number based generators. + * + * @param PositionRouteGeneratorInterface|(callable(int $page): string) $routeGenerator + */ + public static function wrap(PositionRouteGeneratorInterface|callable $routeGenerator): PositionRouteGeneratorInterface + { + if ($routeGenerator instanceof PositionRouteGeneratorInterface) { + return $routeGenerator; + } + + return new self($routeGenerator); + } + + /** + * @throws InvalidArgumentException if the position is not a page position + */ + public function __invoke(Position $position): string + { + if (!$position instanceof PagePosition) { + throw new InvalidArgumentException(\sprintf('The "%s" route generator only supports "%s" positions, "%s" given.', self::class, PagePosition::class, get_debug_type($position))); + } + + $decorated = $this->decorated; + + return $decorated($position->page); + } +} diff --git a/lib/Core/RouteGenerator/PositionRouteGeneratorInterface.php b/lib/Core/RouteGenerator/PositionRouteGeneratorInterface.php new file mode 100644 index 00000000..81faeab1 --- /dev/null +++ b/lib/Core/RouteGenerator/PositionRouteGeneratorInterface.php @@ -0,0 +1,19 @@ +assertSame([], $slice->items); + $this->assertNull($slice->previous); + $this->assertNull($slice->next); + } + + public function testTheSliceExposesItsItemsAndCursors(): void + { + $previous = new Cursor(['id' => 1], Direction::Previous); + $next = new Cursor(['id' => 2], Direction::Next); + + $slice = new CursorSlice([1, 2], $previous, $next); + + $this->assertSame([1, 2], $slice->items); + $this->assertSame($previous, $slice->previous); + $this->assertSame($next, $slice->next); + } + + public function testThePreviousCursorMustHaveThePreviousDirection(): void + { + $this->expectException(InvalidArgumentException::class); + + new CursorSlice([1], new Cursor(['id' => 1], Direction::Next)); + } + + public function testTheNextCursorMustHaveTheNextDirection(): void + { + $this->expectException(InvalidArgumentException::class); + + new CursorSlice([1], null, new Cursor(['id' => 1], Direction::Previous)); + } +} diff --git a/lib/Core/Tests/Cursor/CursorTest.php b/lib/Core/Tests/Cursor/CursorTest.php new file mode 100644 index 00000000..ef1e86e4 --- /dev/null +++ b/lib/Core/Tests/Cursor/CursorTest.php @@ -0,0 +1,61 @@ +assertSame(Direction::Next, (new Cursor(['_id' => 'abc']))->direction); + } + + public function testTheCursorSupportsMultipleFields(): void + { + $fields = ['p.createdAt' => '2026-09-25 12:00:00', 'p.rating' => 4.5, 'p.published' => true, 'p.deletedAt' => null, 'p.id' => 42]; + + $cursor = new Cursor($fields, Direction::Previous); + + $this->assertSame($fields, $cursor->fields); + $this->assertSame(Direction::Previous, $cursor->direction); + } + + public function testTheCursorRequiresAtLeastOneField(): void + { + $this->expectException(InvalidArgumentException::class); + + // @phpstan-ignore-next-line argument.type + new Cursor([]); + } + + public function testTheCursorFieldsMustBeKeyedByString(): void + { + $this->expectException(InvalidArgumentException::class); + + // @phpstan-ignore-next-line argument.type + new Cursor([42]); + } + + /** + * @return \Generator + */ + public static function dataNonScalarValues(): \Generator + { + yield 'array' => [[1]]; + yield 'object' => [new \stdClass()]; + yield 'date' => [new \DateTimeImmutable()]; + } + + #[DataProvider('dataNonScalarValues')] + public function testTheCursorFieldsMustBeScalarOrNull(mixed $value): void + { + $this->expectException(InvalidArgumentException::class); + + new Cursor(['p.id' => $value]); + } +} diff --git a/lib/Core/Tests/PagerfantaTest.php b/lib/Core/Tests/PagerfantaTest.php index d6116121..97320569 100644 --- a/lib/Core/Tests/PagerfantaTest.php +++ b/lib/Core/Tests/PagerfantaTest.php @@ -9,6 +9,7 @@ use Pagerfanta\Exception\LogicException; use Pagerfanta\Exception\OutOfRangeCurrentPageException; use Pagerfanta\Pagerfanta; +use Pagerfanta\Position\PagePosition; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -479,6 +480,48 @@ public function testGetNextPageShouldThrowALogicExceptionIfTheCurrentPageIsTheLa $this->pagerfanta->getNextPage(); } + public function testGetPreviousPositionShouldReturnThePreviousPagePosition(): void + { + $this->adapter->expects($this->atLeastOnce()) + ->method('getNbResults') + ->willReturn(100); + + $this->pagerfanta->setCurrentPage(3); + + $this->assertEquals(new PagePosition(2), $this->pagerfanta->getPreviousPosition()); + } + + public function testGetPreviousPositionShouldThrowALogicExceptionIfThereIsNoPreviousPage(): void + { + $this->expectException(LogicException::class); + + $this->pagerfanta->getPreviousPosition(); + } + + public function testGetNextPositionShouldReturnTheNextPagePosition(): void + { + $this->adapter->expects($this->atLeastOnce()) + ->method('getNbResults') + ->willReturn(100); + + $this->pagerfanta->setCurrentPage(3); + + $this->assertEquals(new PagePosition(4), $this->pagerfanta->getNextPosition()); + } + + public function testGetNextPositionShouldThrowALogicExceptionIfTheCurrentPageIsTheLast(): void + { + $this->expectException(LogicException::class); + + $this->adapter->expects($this->once()) + ->method('getNbResults') + ->willReturn(100); + + $this->pagerfanta->setCurrentPage($this->pagerfanta->getNbPages()); + + $this->pagerfanta->getNextPosition(); + } + public function testThePagerCanBeCounted(): void { $this->adapter->expects($this->once()) diff --git a/lib/Core/Tests/Position/CursorPositionTest.php b/lib/Core/Tests/Position/CursorPositionTest.php new file mode 100644 index 00000000..43f76d1f --- /dev/null +++ b/lib/Core/Tests/Position/CursorPositionTest.php @@ -0,0 +1,17 @@ + 10]); + + $this->assertSame($cursor, (new CursorPosition($cursor))->cursor); + } +} diff --git a/lib/Core/Tests/Position/PagePositionTest.php b/lib/Core/Tests/Position/PagePositionTest.php new file mode 100644 index 00000000..0dfc57b8 --- /dev/null +++ b/lib/Core/Tests/Position/PagePositionTest.php @@ -0,0 +1,34 @@ +assertSame(3, (new PagePosition(3))->page); + } + + /** + * @return \Generator + */ + public static function dataLessThan1(): \Generator + { + yield 'zero' => [0]; + yield 'negative number' => [-1]; + } + + #[DataProvider('dataLessThan1')] + public function testThePageMustBeAtLeast1(int $page): void + { + $this->expectException(LessThan1CurrentPageException::class); + + // @phpstan-ignore-next-line argument.type + new PagePosition($page); + } +} diff --git a/lib/Core/Tests/RouteGenerator/PageRouteGeneratorWrapperTest.php b/lib/Core/Tests/RouteGenerator/PageRouteGeneratorWrapperTest.php new file mode 100644 index 00000000..a5dfdfb4 --- /dev/null +++ b/lib/Core/Tests/RouteGenerator/PageRouteGeneratorWrapperTest.php @@ -0,0 +1,65 @@ + '/posts?page='.$page); + + $this->assertSame('/posts?page=3', $generator(new PagePosition(3))); + } + + public function testACursorPositionIsNotSupported(): void + { + $this->expectException(InvalidArgumentException::class); + + $generator = new PageRouteGeneratorWrapper(static fn (int $page): string => '/posts?page='.$page); + $generator(new CursorPosition(new Cursor(['id' => 1]))); + } + + public function testAPositionRouteGeneratorIsNotWrapped(): void + { + $generator = new class implements PositionRouteGeneratorInterface { + public function __invoke(Position $position): string + { + return '/posts'; + } + }; + + $this->assertSame($generator, PageRouteGeneratorWrapper::wrap($generator)); + } + + public function testAPageRouteGeneratorIsWrapped(): void + { + $generator = new class implements RouteGeneratorInterface { + public function __invoke(int $page): string + { + return '/posts/page/'.$page; + } + }; + + $wrapped = PageRouteGeneratorWrapper::wrap($generator); + + $this->assertInstanceOf(PageRouteGeneratorWrapper::class, $wrapped); + $this->assertSame('/posts/page/2', $wrapped(new PagePosition(2))); + } + + public function testACallableIsWrappedAsAPageRouteGenerator(): void + { + $wrapped = PageRouteGeneratorWrapper::wrap(static fn (int $page): string => '/posts?page='.$page); + + $this->assertSame('/posts?page=5', $wrapped(new PagePosition(5))); + } +} From b0b84b4dfb6c6d18baa06edd8583caeeef3ef484 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 11:04:22 -0400 Subject: [PATCH 02/20] Add the cursor pagers, factory, and Base64 JSON cursor encoder --- lib/Core/CountableCursorPagerfanta.php | 168 ++++++++++++++ lib/Core/Cursor/Base64JsonCursorEncoder.php | 75 ++++++ lib/Core/Cursor/CursorEncoderInterface.php | 4 + lib/Core/CursorPagerfanta.php | 199 ++++++++++++++++ lib/Core/CursorPagerfantaFactory.php | 38 +++ .../Tests/CountableCursorPagerfantaTest.php | 117 ++++++++++ .../Cursor/Base64JsonCursorEncoderTest.php | 103 +++++++++ .../Tests/CursorPagerfantaFactoryTest.php | 37 +++ lib/Core/Tests/CursorPagerfantaTest.php | 218 ++++++++++++++++++ 9 files changed, 959 insertions(+) create mode 100644 lib/Core/CountableCursorPagerfanta.php create mode 100644 lib/Core/Cursor/Base64JsonCursorEncoder.php create mode 100644 lib/Core/CursorPagerfanta.php create mode 100644 lib/Core/CursorPagerfantaFactory.php create mode 100644 lib/Core/Tests/CountableCursorPagerfantaTest.php create mode 100644 lib/Core/Tests/Cursor/Base64JsonCursorEncoderTest.php create mode 100644 lib/Core/Tests/CursorPagerfantaFactoryTest.php create mode 100644 lib/Core/Tests/CursorPagerfantaTest.php diff --git a/lib/Core/CountableCursorPagerfanta.php b/lib/Core/CountableCursorPagerfanta.php new file mode 100644 index 00000000..a9de17b8 --- /dev/null +++ b/lib/Core/CountableCursorPagerfanta.php @@ -0,0 +1,168 @@ + + * @implements CountablePagerInterface + */ +final class CountableCursorPagerfanta implements CursorPagerInterface, CountablePagerInterface, \Countable, \JsonSerializable +{ + /** + * @var CursorPagerfanta + */ + private readonly CursorPagerfanta $pager; + + /** + * @var int<0, max>|null + */ + private ?int $nbResults = null; + + /** + * @param CursorAdapterInterface&CountableAdapterInterface $adapter + * @param positive-int $maxPerPage + * @param CursorPosition|null $currentPosition The position of the current page, or null for the first page + * + * @throws LessThan1MaxPerPageException if the max per page is less than 1 + */ + public function __construct( + private readonly CursorAdapterInterface&CountableAdapterInterface $adapter, + int $maxPerPage = 10, + ?CursorPosition $currentPosition = null, + ) { + $this->pager = new CursorPagerfanta($adapter, $maxPerPage, $currentPosition); + } + + /** + * @return CursorAdapterInterface&CountableAdapterInterface + */ + public function getAdapter(): CursorAdapterInterface&CountableAdapterInterface + { + return $this->adapter; + } + + /** + * Returns a new pager for the given position, keeping this pager's configuration. + * + * The total number of results is not carried over to the new pager. + * + * @param CursorPosition|null $position The position of the page, or null for the first page + * + * @return self + */ + public function withPosition(?CursorPosition $position): self + { + return new self($this->adapter, $this->pager->getMaxPerPage(), $position); + } + + /** + * @return int<0, max> + */ + public function getNbResults(): int + { + return $this->nbResults ??= $this->adapter->getNbResults(); + } + + public function getCurrentPosition(): ?CursorPosition + { + return $this->pager->getCurrentPosition(); + } + + /** + * @return positive-int + */ + public function getMaxPerPage(): int + { + return $this->pager->getMaxPerPage(); + } + + /** + * @return list + */ + public function getCurrentPageResults(): array + { + return $this->pager->getCurrentPageResults(); + } + + public function supportsBackwardNavigation(): bool + { + return $this->pager->supportsBackwardNavigation(); + } + + public function haveToPaginate(): bool + { + return $this->pager->haveToPaginate(); + } + + public function hasPreviousPage(): bool + { + return $this->pager->hasPreviousPage(); + } + + public function hasNextPage(): bool + { + return $this->pager->hasNextPage(); + } + + /** + * @throws LogicException if there is no previous page + */ + public function getPreviousPosition(): CursorPosition + { + return $this->pager->getPreviousPosition(); + } + + /** + * @throws LogicException if there is no next page + */ + public function getNextPosition(): CursorPosition + { + return $this->pager->getNextPosition(); + } + + /** + * Returns the number of items on the current page, use {@see getNbResults()} for the total number of results. + * + * @return int<0, max> + */ + public function count(): int + { + return $this->pager->count(); + } + + /** + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return $this->pager->getIterator(); + } + + /** + * @return list + */ + public function jsonSerialize(): array + { + return $this->pager->jsonSerialize(); + } + + /** + * Generates an iterator to automatically iterate over all pages in a result set, starting from the current page. + * + * @return \Generator + */ + public function autoPagingIterator(): \Generator + { + return $this->pager->autoPagingIterator(); + } +} diff --git a/lib/Core/Cursor/Base64JsonCursorEncoder.php b/lib/Core/Cursor/Base64JsonCursorEncoder.php new file mode 100644 index 00000000..26b51c5d --- /dev/null +++ b/lib/Core/Cursor/Base64JsonCursorEncoder.php @@ -0,0 +1,75 @@ + $cursor->fields, + 'd' => match ($cursor->direction) { + Direction::Next => self::DIRECTION_NEXT, + Direction::Previous => self::DIRECTION_PREVIOUS, + }, + ]; + + try { + $json = json_encode($payload, \JSON_THROW_ON_ERROR | \JSON_PRESERVE_ZERO_FRACTION | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } catch (\JsonException $exception) { + throw new InvalidArgumentException(\sprintf('The cursor could not be encoded: %s', $exception->getMessage()), 0, $exception); + } + + return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + } + + /** + * @throws InvalidCursorException if the string cannot be decoded to a valid cursor + */ + public function decode(string $encoded): Cursor + { + $json = base64_decode(strtr($encoded, '-_', '+/'), true); + + if (false === $json || '' === $json) { + throw new InvalidCursorException('The cursor is not a valid Base64 string.'); + } + + try { + $payload = json_decode($json, true, 3, \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new InvalidCursorException('The cursor is not valid JSON.', 0, $exception); + } + + if (!\is_array($payload) || 2 !== \count($payload) || !isset($payload['f'], $payload['d']) || !\is_array($payload['f'])) { + throw new InvalidCursorException('The cursor payload is malformed.'); + } + + $direction = match ($payload['d']) { + self::DIRECTION_NEXT => Direction::Next, + self::DIRECTION_PREVIOUS => Direction::Previous, + default => throw new InvalidCursorException('The cursor direction is not valid.'), + }; + + try { + // @phpstan-ignore-next-line argument.type + return new Cursor($payload['f'], $direction); + } catch (InvalidArgumentException $exception) { + throw new InvalidCursorException(\sprintf('The cursor fields are not valid: %s', $exception->getMessage()), 0, $exception); + } + } +} diff --git a/lib/Core/Cursor/CursorEncoderInterface.php b/lib/Core/Cursor/CursorEncoderInterface.php index 6e69d284..e90cd388 100644 --- a/lib/Core/Cursor/CursorEncoderInterface.php +++ b/lib/Core/Cursor/CursorEncoderInterface.php @@ -2,6 +2,7 @@ namespace Pagerfanta\Cursor; +use Pagerfanta\Exception\InvalidArgumentException; use Pagerfanta\Exception\InvalidCursorException; /** @@ -9,6 +10,9 @@ */ interface CursorEncoderInterface { + /** + * @throws InvalidArgumentException if the cursor cannot be encoded + */ public function encode(Cursor $cursor): string; /** diff --git a/lib/Core/CursorPagerfanta.php b/lib/Core/CursorPagerfanta.php new file mode 100644 index 00000000..613c0dc2 --- /dev/null +++ b/lib/Core/CursorPagerfanta.php @@ -0,0 +1,199 @@ + + */ +final class CursorPagerfanta implements CursorPagerInterface, \Countable, \JsonSerializable +{ + /** + * @var CursorSlice|null + */ + private ?CursorSlice $slice = null; + + /** + * @param CursorAdapterInterface $adapter + * @param positive-int $maxPerPage + * @param CursorPosition|null $currentPosition The position of the current page, or null for the first page + * + * @throws LessThan1MaxPerPageException if the max per page is less than 1 + */ + public function __construct( + private readonly CursorAdapterInterface $adapter, + private readonly int $maxPerPage = 10, + private readonly ?CursorPosition $currentPosition = null, + ) { + if ($maxPerPage < 1) { + throw new LessThan1MaxPerPageException(); + } + } + + /** + * @return CursorAdapterInterface + */ + public function getAdapter(): CursorAdapterInterface + { + return $this->adapter; + } + + /** + * Returns a new pager for the given position, keeping this pager's configuration. + * + * @param CursorPosition|null $position The position of the page, or null for the first page + * + * @return self + */ + public function withPosition(?CursorPosition $position): self + { + return new self($this->adapter, $this->maxPerPage, $position); + } + + public function getCurrentPosition(): ?CursorPosition + { + return $this->currentPosition; + } + + /** + * @return positive-int + */ + public function getMaxPerPage(): int + { + return $this->maxPerPage; + } + + /** + * @return list + */ + public function getCurrentPageResults(): array + { + return $this->getSlice()->items; + } + + public function supportsBackwardNavigation(): bool + { + return $this->adapter->supportsBackwardNavigation(); + } + + public function haveToPaginate(): bool + { + return $this->hasPreviousPage() || $this->hasNextPage(); + } + + public function hasPreviousPage(): bool + { + return $this->supportsBackwardNavigation() && $this->getSlice()->previous instanceof Cursor; + } + + public function hasNextPage(): bool + { + return $this->getSlice()->next instanceof Cursor; + } + + /** + * @throws LogicException if there is no previous page + */ + public function getPreviousPosition(): CursorPosition + { + $cursor = $this->getSlice()->previous; + + if (!$this->supportsBackwardNavigation() || !$cursor instanceof Cursor) { + throw new LogicException('There is no previous page.'); + } + + return new CursorPosition($cursor); + } + + /** + * @throws LogicException if there is no next page + */ + public function getNextPosition(): CursorPosition + { + $cursor = $this->getSlice()->next; + + if (!$cursor instanceof Cursor) { + throw new LogicException('There is no next page.'); + } + + return new CursorPosition($cursor); + } + + /** + * Returns the number of items on the current page. + * + * @return int<0, max> + */ + public function count(): int + { + return \count($this->getCurrentPageResults()); + } + + /** + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->getCurrentPageResults()); + } + + /** + * @return list + */ + public function jsonSerialize(): array + { + return $this->getCurrentPageResults(); + } + + /** + * Generates an iterator to automatically iterate over all pages in a result set, starting from the current page. + * + * @return \Generator + */ + public function autoPagingIterator(): \Generator + { + $pager = $this; + + while (true) { + foreach ($pager->getCurrentPageResults() as $item) { + yield $item; + } + + if (!$pager->hasNextPage()) { + break; + } + + $pager = $pager->withPosition($pager->getNextPosition()); + } + } + + /** + * @return CursorSlice + * + * @throws LogicException if the adapter returns more items than the max per page + */ + private function getSlice(): CursorSlice + { + if (!$this->slice instanceof CursorSlice) { + $slice = $this->adapter->getSlice($this->currentPosition?->cursor, $this->maxPerPage); + + if (\count($slice->items) > $this->maxPerPage) { + throw new LogicException(\sprintf('The "%s" adapter returned %d items, but the limit is %d. Adapters must trim the extra item used to detect another page before returning a slice.', get_debug_type($this->adapter), \count($slice->items), $this->maxPerPage)); + } + + $this->slice = $slice; + } + + return $this->slice; + } +} diff --git a/lib/Core/CursorPagerfantaFactory.php b/lib/Core/CursorPagerfantaFactory.php new file mode 100644 index 00000000..be498214 --- /dev/null +++ b/lib/Core/CursorPagerfantaFactory.php @@ -0,0 +1,38 @@ + $adapter + * @param positive-int $maxPerPage + * @param CursorPosition|null $currentPosition The position of the current page, or null for the first page + * + * @return CursorPagerfanta|CountableCursorPagerfanta + * + * @throws LessThan1MaxPerPageException if the max per page is less than 1 + */ + public static function create(CursorAdapterInterface $adapter, int $maxPerPage = 10, ?CursorPosition $currentPosition = null): CursorPagerfanta|CountableCursorPagerfanta + { + if ($adapter instanceof CountableAdapterInterface) { + return new CountableCursorPagerfanta($adapter, $maxPerPage, $currentPosition); + } + + return new CursorPagerfanta($adapter, $maxPerPage, $currentPosition); + } +} diff --git a/lib/Core/Tests/CountableCursorPagerfantaTest.php b/lib/Core/Tests/CountableCursorPagerfantaTest.php new file mode 100644 index 00000000..b3848976 --- /dev/null +++ b/lib/Core/Tests/CountableCursorPagerfantaTest.php @@ -0,0 +1,117 @@ +&CountableAdapterInterface + */ + private MockObject&CursorAdapterInterface&CountableAdapterInterface $adapter; + + protected function setUp(): void + { + $this->adapter = $this->createMockForIntersectionOfInterfaces([CursorAdapterInterface::class, CountableAdapterInterface::class]); + } + + public function testTheMaxPerPageMustBeAtLeast1(): void + { + $this->expectException(LessThan1MaxPerPageException::class); + + // @phpstan-ignore-next-line argument.type + new CountableCursorPagerfanta($this->adapter, 0); + } + + public function testTheTotalIsFetchedOnceAndOnlyWhenRequested(): void + { + $this->adapter->method('getSlice') + ->willReturn(new CursorSlice([1, 2, 3], null, new Cursor(['id' => 3]))); + + $this->adapter->expects($this->once()) + ->method('getNbResults') + ->willReturn(7); + + $pager = new CountableCursorPagerfanta($this->adapter, 3); + + $this->assertCount(3, $pager, 'count() is the number of items on the page'); + $this->assertSame(7, $pager->getNbResults()); + $this->assertSame(7, $pager->getNbResults()); + } + + public function testThePagerDelegatesToTheCursorPager(): void + { + $current = new Cursor(['id' => 3]); + $previous = new Cursor(['id' => 4], Direction::Previous); + $next = new Cursor(['id' => 6]); + + $this->adapter->expects($this->once()) + ->method('getSlice') + ->with($current, 3) + ->willReturn(new CursorSlice([4, 5, 6], $previous, $next)); + + $this->adapter->method('supportsBackwardNavigation') + ->willReturn(true); + + $pager = new CountableCursorPagerfanta($this->adapter, 3, new CursorPosition($current)); + + $this->assertSame($this->adapter, $pager->getAdapter()); + $this->assertSame(3, $pager->getMaxPerPage()); + $this->assertSame($current, $pager->getCurrentPosition()?->cursor); + $this->assertSame([4, 5, 6], $pager->getCurrentPageResults()); + $this->assertSame([4, 5, 6], iterator_to_array($pager)); + $this->assertSame([4, 5, 6], $pager->jsonSerialize()); + $this->assertTrue($pager->supportsBackwardNavigation()); + $this->assertTrue($pager->haveToPaginate()); + $this->assertTrue($pager->hasPreviousPage()); + $this->assertTrue($pager->hasNextPage()); + $this->assertSame($previous, $pager->getPreviousPosition()->cursor); + $this->assertSame($next, $pager->getNextPosition()->cursor); + } + + public function testThereIsNoNextPositionOnTheLastPage(): void + { + $this->adapter->method('getSlice') + ->willReturn(new CursorSlice([1])); + + $this->expectException(LogicException::class); + + (new CountableCursorPagerfanta($this->adapter))->getNextPosition(); + } + + public function testNavigatingReturnsANewCountablePager(): void + { + $next = new Cursor(['id' => 3]); + + $this->adapter->method('getSlice') + ->willReturnCallback(static fn (?Cursor $cursor, int $limit): CursorSlice => $cursor instanceof Cursor ? new CursorSlice([4]) : new CursorSlice([1, 2, 3], null, $next)); + + $pager = new CountableCursorPagerfanta($this->adapter, 3); + $nextPager = $pager->withPosition($pager->getNextPosition()); + + $this->assertNotSame($pager, $nextPager); + $this->assertNull($pager->getCurrentPosition()); + $this->assertSame($next, $nextPager->getCurrentPosition()?->cursor); + $this->assertSame(3, $nextPager->getMaxPerPage()); + $this->assertSame([4], $nextPager->getCurrentPageResults()); + } + + public function testTheAutoPagingIteratorWalksEveryPage(): void + { + $this->adapter->method('getSlice') + ->willReturnCallback(static fn (?Cursor $cursor, int $limit): CursorSlice => $cursor instanceof Cursor ? new CursorSlice([3]) : new CursorSlice([1, 2], null, new Cursor(['id' => 2]))); + + $this->assertSame([1, 2, 3], iterator_to_array((new CountableCursorPagerfanta($this->adapter, 2))->autoPagingIterator(), false)); + } +} diff --git a/lib/Core/Tests/Cursor/Base64JsonCursorEncoderTest.php b/lib/Core/Tests/Cursor/Base64JsonCursorEncoderTest.php new file mode 100644 index 00000000..3fad3b5a --- /dev/null +++ b/lib/Core/Tests/Cursor/Base64JsonCursorEncoderTest.php @@ -0,0 +1,103 @@ +encoder = new Base64JsonCursorEncoder(); + } + + /** + * @return \Generator + */ + public static function dataCursors(): \Generator + { + yield 'single field, next' => [new Cursor(['_id' => '507f1f77bcf86cd799439011'])]; + yield 'single field, previous' => [new Cursor(['p.id' => 42], Direction::Previous)]; + yield 'multiple fields of each scalar type' => [new Cursor(['p.createdAt' => '2026-09-25 12:00:00', 'p.rating' => 4.5, 'p.score' => 1.0, 'p.published' => false, 'p.deletedAt' => null, 'p.id' => 42])]; + yield 'unicode and slashes' => [new Cursor(['a.name' => 'Zoë/Ω?&=+'])]; + } + + #[DataProvider('dataCursors')] + public function testACursorSurvivesARoundTrip(Cursor $cursor): void + { + $this->assertEquals($cursor, $this->encoder->decode($this->encoder->encode($cursor))); + } + + #[DataProvider('dataCursors')] + public function testTheEncodedCursorIsUrlSafe(Cursor $cursor): void + { + $this->assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $this->encoder->encode($cursor)); + } + + public function testAFloatWithoutAFractionKeepsItsType(): void + { + $decoded = $this->encoder->decode($this->encoder->encode(new Cursor(['p.score' => 1.0]))); + + $this->assertEqualsWithDelta(1.0, $decoded->fields['p.score'], PHP_FLOAT_EPSILON); + } + + /** + * @return \Generator + */ + public static function dataUnencodableCursors(): \Generator + { + yield 'invalid UTF-8' => [new Cursor(['p.name' => "\xB1\x31"])]; + yield 'NAN' => [new Cursor(['p.rating' => \NAN])]; + yield 'INF' => [new Cursor(['p.rating' => \INF])]; + } + + #[DataProvider('dataUnencodableCursors')] + public function testACursorWhichCannotBeEncodedIsRejected(Cursor $cursor): void + { + $this->expectException(InvalidArgumentException::class); + + $this->encoder->encode($cursor); + } + + /** + * @return \Generator + */ + public static function dataInvalidCursors(): \Generator + { + $encode = static fn (string $json): string => rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + + yield 'empty string' => ['']; + yield 'not Base64' => ['not base64!']; + yield 'not JSON' => [$encode('not json')]; + yield 'JSON scalar' => [$encode('42')]; + yield 'JSON list' => [$encode('[{"p.id":1},"n"]')]; + yield 'missing direction' => [$encode('{"f":{"p.id":1}}')]; + yield 'missing fields' => [$encode('{"d":"n"}')]; + yield 'extra keys' => [$encode('{"f":{"p.id":1},"d":"n","x":1}')]; + yield 'fields not an object' => [$encode('{"f":"p.id","d":"n"}')]; + yield 'empty fields' => [$encode('{"f":{},"d":"n"}')]; + yield 'fields as a list' => [$encode('{"f":[1,2],"d":"n"}')]; + yield 'numeric field key' => [$encode('{"f":{"0":1},"d":"n"}')]; + yield 'nested field value' => [$encode('{"f":{"p.id":[1]},"d":"n"}')]; + yield 'excessive nesting' => [$encode('{"f":{"p.id":[[1]]},"d":"n"}')]; + yield 'unknown direction' => [$encode('{"f":{"p.id":1},"d":"x"}')]; + yield 'direction not a string' => [$encode('{"f":{"p.id":1},"d":true}')]; + yield 'tampered payload' => [substr((new Base64JsonCursorEncoder())->encode(new Cursor(['p.id' => 1])), 0, -3)]; + } + + #[DataProvider('dataInvalidCursors')] + public function testAnInvalidCursorIsRejected(string $encoded): void + { + $this->expectException(InvalidCursorException::class); + + $this->encoder->decode($encoded); + } +} diff --git a/lib/Core/Tests/CursorPagerfantaFactoryTest.php b/lib/Core/Tests/CursorPagerfantaFactoryTest.php new file mode 100644 index 00000000..0088259a --- /dev/null +++ b/lib/Core/Tests/CursorPagerfantaFactoryTest.php @@ -0,0 +1,37 @@ + 1])); + + $pager = CursorPagerfantaFactory::create($this->createMock(CursorAdapterInterface::class), 5, $position); + + $this->assertInstanceOf(CursorPagerfanta::class, $pager); + $this->assertSame(5, $pager->getMaxPerPage()); + $this->assertSame($position, $pager->getCurrentPosition()); + } + + public function testACountableCursorPagerIsCreatedForACountableAdapter(): void + { + $position = new CursorPosition(new Cursor(['id' => 1])); + + $pager = CursorPagerfantaFactory::create($this->createMockForIntersectionOfInterfaces([CursorAdapterInterface::class, CountableAdapterInterface::class]), 5, $position); + + $this->assertInstanceOf(CountableCursorPagerfanta::class, $pager); + $this->assertSame(5, $pager->getMaxPerPage()); + $this->assertSame($position, $pager->getCurrentPosition()); + } +} diff --git a/lib/Core/Tests/CursorPagerfantaTest.php b/lib/Core/Tests/CursorPagerfantaTest.php new file mode 100644 index 00000000..1c2ecfde --- /dev/null +++ b/lib/Core/Tests/CursorPagerfantaTest.php @@ -0,0 +1,218 @@ + + */ + private MockObject&CursorAdapterInterface $adapter; + + protected function setUp(): void + { + $this->adapter = $this->createMock(CursorAdapterInterface::class); + } + + /** + * @return \Generator + */ + public static function dataLessThan1(): \Generator + { + yield 'zero' => [0]; + yield 'negative number' => [-1]; + } + + #[DataProvider('dataLessThan1')] + public function testTheMaxPerPageMustBeAtLeast1(int $maxPerPage): void + { + $this->expectException(LessThan1MaxPerPageException::class); + + // @phpstan-ignore-next-line argument.type + new CursorPagerfanta($this->adapter, $maxPerPage); + } + + public function testTheSliceIsNotFetchedUntilNeeded(): void + { + $this->adapter->expects($this->never()) + ->method('getSlice'); + + $pager = new CursorPagerfanta($this->adapter, 5); + + $this->assertSame($this->adapter, $pager->getAdapter()); + $this->assertSame(5, $pager->getMaxPerPage()); + $this->assertNull($pager->getCurrentPosition()); + } + + public function testTheFirstPageIsFetchedWithoutACursor(): void + { + $next = new Cursor(['id' => 3]); + + $this->adapter->expects($this->once()) + ->method('getSlice') + ->with(null, 3) + ->willReturn(new CursorSlice([1, 2, 3], null, $next)); + + $this->adapter->method('supportsBackwardNavigation') + ->willReturn(true); + + $pager = new CursorPagerfanta($this->adapter, 3); + + $this->assertSame([1, 2, 3], $pager->getCurrentPageResults()); + $this->assertSame([1, 2, 3], iterator_to_array($pager)); + $this->assertSame([1, 2, 3], $pager->jsonSerialize()); + $this->assertCount(3, $pager); + $this->assertTrue($pager->haveToPaginate()); + $this->assertFalse($pager->hasPreviousPage()); + $this->assertTrue($pager->hasNextPage()); + $this->assertEquals(new CursorPosition($next), $pager->getNextPosition()); + } + + public function testAMiddlePageIsFetchedWithTheCurrentCursor(): void + { + $current = new Cursor(['id' => 3]); + $previous = new Cursor(['id' => 4], Direction::Previous); + $next = new Cursor(['id' => 6]); + + $this->adapter->expects($this->once()) + ->method('getSlice') + ->with($current, 3) + ->willReturn(new CursorSlice([4, 5, 6], $previous, $next)); + + $this->adapter->method('supportsBackwardNavigation') + ->willReturn(true); + + $pager = new CursorPagerfanta($this->adapter, 3, new CursorPosition($current)); + + $this->assertSame($current, $pager->getCurrentPosition()?->cursor); + $this->assertTrue($pager->supportsBackwardNavigation()); + $this->assertTrue($pager->hasPreviousPage()); + $this->assertTrue($pager->hasNextPage()); + $this->assertSame($previous, $pager->getPreviousPosition()->cursor); + $this->assertSame($next, $pager->getNextPosition()->cursor); + } + + public function testTheLastPageHasNoNextPage(): void + { + $this->adapter->method('getSlice') + ->willReturn(new CursorSlice([7], new Cursor(['id' => 7], Direction::Previous))); + + $this->adapter->method('supportsBackwardNavigation') + ->willReturn(true); + + $pager = new CursorPagerfanta($this->adapter, 3, new CursorPosition(new Cursor(['id' => 6]))); + + $this->assertTrue($pager->haveToPaginate()); + $this->assertTrue($pager->hasPreviousPage()); + $this->assertFalse($pager->hasNextPage()); + + $this->expectException(LogicException::class); + + $pager->getNextPosition(); + } + + public function testAnEmptyResultSetHasNoPages(): void + { + $this->adapter->method('getSlice') + ->willReturn(new CursorSlice([])); + + $this->adapter->method('supportsBackwardNavigation') + ->willReturn(true); + + $pager = new CursorPagerfanta($this->adapter); + + $this->assertSame([], $pager->getCurrentPageResults()); + $this->assertCount(0, $pager); + $this->assertFalse($pager->haveToPaginate()); + $this->assertFalse($pager->hasPreviousPage()); + $this->assertFalse($pager->hasNextPage()); + } + + public function testAForwardOnlyAdapterNeverReportsAPreviousPage(): void + { + $this->adapter->method('getSlice') + ->willReturn(new CursorSlice([4, 5, 6], new Cursor(['id' => 4], Direction::Previous), new Cursor(['id' => 6]))); + + $this->adapter->method('supportsBackwardNavigation') + ->willReturn(false); + + $pager = new CursorPagerfanta($this->adapter, 3, new CursorPosition(new Cursor(['id' => 3]))); + + $this->assertFalse($pager->supportsBackwardNavigation()); + $this->assertFalse($pager->hasPreviousPage()); + + $this->expectException(LogicException::class); + + $pager->getPreviousPosition(); + } + + public function testTheFirstPageHasNoPreviousPosition(): void + { + $this->adapter->method('getSlice') + ->willReturn(new CursorSlice([1, 2, 3], null, new Cursor(['id' => 3]))); + + $this->adapter->method('supportsBackwardNavigation') + ->willReturn(true); + + $this->expectException(LogicException::class); + + (new CursorPagerfanta($this->adapter, 3))->getPreviousPosition(); + } + + public function testAnAdapterReturningMoreItemsThanTheLimitIsRejected(): void + { + $this->adapter->method('getSlice') + ->willReturn(new CursorSlice([1, 2, 3, 4], null, new Cursor(['id' => 3]))); + + $this->expectException(LogicException::class); + + (new CursorPagerfanta($this->adapter, 3))->getCurrentPageResults(); + } + + public function testNavigatingReturnsANewPagerWithTheSameConfiguration(): void + { + $next = new Cursor(['id' => 3]); + + $this->adapter->expects($this->exactly(2)) + ->method('getSlice') + ->willReturnCallback(static fn (?Cursor $cursor, int $limit): CursorSlice => $cursor instanceof Cursor ? new CursorSlice([4]) : new CursorSlice([1, 2, 3], null, $next)); + + $pager = new CursorPagerfanta($this->adapter, 3); + $nextPager = $pager->withPosition($pager->getNextPosition()); + + $this->assertNotSame($pager, $nextPager); + $this->assertNull($pager->getCurrentPosition(), 'The original pager is unchanged'); + $this->assertSame([1, 2, 3], $pager->getCurrentPageResults(), 'The original pager keeps its results'); + $this->assertSame($next, $nextPager->getCurrentPosition()?->cursor); + $this->assertSame(3, $nextPager->getMaxPerPage()); + $this->assertSame([4], $nextPager->getCurrentPageResults()); + } + + public function testTheAutoPagingIteratorWalksEveryPageFromTheCurrentPosition(): void + { + $this->adapter->method('getSlice') + ->willReturnCallback(static fn (?Cursor $cursor, int $limit): CursorSlice => match ($cursor?->fields['id']) { + null => new CursorSlice([1, 2], null, new Cursor(['id' => 2])), + 2 => new CursorSlice([3, 4], null, new Cursor(['id' => 4])), + 4 => new CursorSlice([5]), + default => throw new \UnexpectedValueException('Unexpected cursor'), + }); + + $pager = new CursorPagerfanta($this->adapter, 2); + + $this->assertSame([1, 2, 3, 4, 5], iterator_to_array($pager->autoPagingIterator(), false)); + $this->assertNull($pager->getCurrentPosition(), 'The pager is not changed by auto paging'); + } +} From 717f8338a3feae4e138312a1e747f86119db80f0 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 11:15:36 -0400 Subject: [PATCH 03/20] Add the Array, Callback, Transforming, and Empty cursor adapters --- lib/Core/Adapter/ArrayCursorAdapter.php | 153 +++++++++++++ lib/Core/Adapter/CallbackCursorAdapter.php | 57 +++++ lib/Core/Adapter/EmptyCursorAdapter.php | 28 +++ .../Adapter/TransformingCursorAdapter.php | 56 +++++ .../Tests/Adapter/ArrayCursorAdapterTest.php | 208 ++++++++++++++++++ .../Adapter/CallbackCursorAdapterTest.php | 59 +++++ .../Tests/Adapter/EmptyCursorAdapterTest.php | 21 ++ .../Adapter/TransformingCursorAdapterTest.php | 35 +++ 8 files changed, 617 insertions(+) create mode 100644 lib/Core/Adapter/ArrayCursorAdapter.php create mode 100644 lib/Core/Adapter/CallbackCursorAdapter.php create mode 100644 lib/Core/Adapter/EmptyCursorAdapter.php create mode 100644 lib/Core/Adapter/TransformingCursorAdapter.php create mode 100644 lib/Core/Tests/Adapter/ArrayCursorAdapterTest.php create mode 100644 lib/Core/Tests/Adapter/CallbackCursorAdapterTest.php create mode 100644 lib/Core/Tests/Adapter/EmptyCursorAdapterTest.php create mode 100644 lib/Core/Tests/Adapter/TransformingCursorAdapterTest.php diff --git a/lib/Core/Adapter/ArrayCursorAdapter.php b/lib/Core/Adapter/ArrayCursorAdapter.php new file mode 100644 index 00000000..126ffc21 --- /dev/null +++ b/lib/Core/Adapter/ArrayCursorAdapter.php @@ -0,0 +1,153 @@ + + */ +class ArrayCursorAdapter implements CursorAdapterInterface, CountableAdapterInterface +{ + /** + * @var list + */ + private readonly array $items; + + /** + * @var callable(T): non-empty-array + */ + private $keyExtractor; + + /** + * The position of each item in the list, indexed by its serialized cursor fields. + * + * @var array>|null + */ + private ?array $positions = null; + + /** + * @param array $array The items, sorted in the order to paginate them in + * @param callable(T): non-empty-array $keyExtractor Returns the cursor fields for an item + */ + public function __construct(array $array, callable $keyExtractor) + { + $this->items = array_values($array); + $this->keyExtractor = $keyExtractor; + } + + /** + * @return int<0, max> + */ + public function getNbResults(): int + { + return \count($this->items); + } + + public function supportsBackwardNavigation(): bool + { + return true; + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + * + * @throws InvalidCursorException if the cursor does not point to an item in the array + * @throws InvalidArgumentException if the key extractor does not return unique, valid cursor fields for every item + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + if (!$cursor instanceof Cursor) { + return $this->createSlice(0, $limit); + } + + $position = $this->findPosition($cursor); + + if (Direction::Next === $cursor->direction) { + return $this->createSlice($position + 1, $limit); + } + + return $this->createSlice(max(0, $position - $limit), min($limit, $position)); + } + + /** + * @param int<0, max> $start + * @param int<0, max> $length + * + * @return CursorSlice + */ + private function createSlice(int $start, int $length): CursorSlice + { + $items = \array_slice($this->items, $start, $length); + + if ([] === $items) { + return new CursorSlice([]); + } + + $end = $start + \count($items); + + return new CursorSlice( + $items, + $start > 0 ? $this->createCursor($items[0], Direction::Previous) : null, + $end < \count($this->items) ? $this->createCursor($items[array_key_last($items)], Direction::Next) : null, + ); + } + + /** + * @param T $item + * + * @throws InvalidArgumentException if the key extractor does not return valid cursor fields + */ + private function createCursor(mixed $item, Direction $direction): Cursor + { + $keyExtractor = $this->keyExtractor; + + return new Cursor($keyExtractor($item), $direction); + } + + /** + * @return int<0, max> + * + * @throws InvalidCursorException if the cursor does not point to an item in the array + */ + private function findPosition(Cursor $cursor): int + { + $this->positions ??= $this->indexPositions(); + + return $this->positions[serialize($cursor->fields)] ?? throw new InvalidCursorException('The cursor does not point to an item in the array.'); + } + + /** + * @return array> + * + * @throws InvalidArgumentException if the key extractor does not return unique, valid cursor fields for every item + */ + private function indexPositions(): array + { + $positions = []; + + foreach ($this->items as $position => $item) { + $key = serialize($this->createCursor($item, Direction::Next)->fields); + + if (isset($positions[$key])) { + throw new InvalidArgumentException(\sprintf('The cursor fields for the items at positions %d and %d are the same, the key extractor must return unique fields for each item.', $positions[$key], $position)); + } + + $positions[$key] = $position; + } + + return $positions; + } +} diff --git a/lib/Core/Adapter/CallbackCursorAdapter.php b/lib/Core/Adapter/CallbackCursorAdapter.php new file mode 100644 index 00000000..6e123ca5 --- /dev/null +++ b/lib/Core/Adapter/CallbackCursorAdapter.php @@ -0,0 +1,57 @@ + + */ +class CallbackCursorAdapter implements CursorAdapterInterface +{ + /** + * @var callable(Cursor|null, positive-int): CursorSlice + */ + private $sliceCallable; + + /** + * @param callable(Cursor|null $cursor, positive-int $limit): CursorSlice $sliceCallable Returns the slice for the cursor, see {@see CursorAdapterInterface::getSlice()} for the contract it must follow + * @param bool $supportsBackwardNavigation Whether the callable supports cursors with the previous direction + */ + public function __construct( + callable $sliceCallable, + private readonly bool $supportsBackwardNavigation = false, + ) { + $this->sliceCallable = $sliceCallable; + } + + public function supportsBackwardNavigation(): bool + { + return $this->supportsBackwardNavigation; + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + * + * @throws LogicException if the callable does not return a {@see CursorSlice} + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + $callable = $this->sliceCallable; + + $slice = $callable($cursor, $limit); + + if (!$slice instanceof CursorSlice) { + throw new LogicException(\sprintf('The callable in "%s()" must return an instance of "%s", "%s" returned.', __METHOD__, CursorSlice::class, get_debug_type($slice))); + } + + return $slice; + } +} diff --git a/lib/Core/Adapter/EmptyCursorAdapter.php b/lib/Core/Adapter/EmptyCursorAdapter.php new file mode 100644 index 00000000..6517e02e --- /dev/null +++ b/lib/Core/Adapter/EmptyCursorAdapter.php @@ -0,0 +1,28 @@ + + */ +class EmptyCursorAdapter implements CursorAdapterInterface, CountableAdapterInterface +{ + public function getNbResults(): int + { + return 0; + } + + public function supportsBackwardNavigation(): bool + { + return false; + } + + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + return new CursorSlice([]); + } +} diff --git a/lib/Core/Adapter/TransformingCursorAdapter.php b/lib/Core/Adapter/TransformingCursorAdapter.php new file mode 100644 index 00000000..80d7caf4 --- /dev/null +++ b/lib/Core/Adapter/TransformingCursorAdapter.php @@ -0,0 +1,56 @@ + + */ +class TransformingCursorAdapter implements CursorAdapterInterface +{ + /** + * @var callable(T, int<0, max>): Transformed + */ + private $transformer; + + /** + * @param CursorAdapterInterface $adapter + * @param callable(T, int<0, max>): Transformed $transformer + */ + public function __construct( + private readonly CursorAdapterInterface $adapter, + callable $transformer, + ) { + $this->transformer = $transformer; + } + + public function supportsBackwardNavigation(): bool + { + return $this->adapter->supportsBackwardNavigation(); + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + $slice = $this->adapter->getSlice($cursor, $limit); + + return new CursorSlice( + array_map($this->transformer, $slice->items, array_keys($slice->items)), + $slice->previous, + $slice->next, + ); + } +} diff --git a/lib/Core/Tests/Adapter/ArrayCursorAdapterTest.php b/lib/Core/Tests/Adapter/ArrayCursorAdapterTest.php new file mode 100644 index 00000000..8cd19907 --- /dev/null +++ b/lib/Core/Tests/Adapter/ArrayCursorAdapterTest.php @@ -0,0 +1,208 @@ + + */ + private function createAdapter(int $count): ArrayCursorAdapter + { + return new ArrayCursorAdapter( + array_map(static fn (int $id): array => ['id' => $id], $count > 0 ? range(1, $count) : []), + static fn (array $item): array => ['id' => $item['id']], + ); + } + + /** + * @param CursorSlice $slice + * + * @return list + */ + private function ids(CursorSlice $slice): array + { + return array_column($slice->items, 'id'); + } + + public function testTheAdapterSupportsBackwardNavigation(): void + { + $this->assertTrue($this->createAdapter(3)->supportsBackwardNavigation()); + } + + public function testTheAdapterCountsTheItems(): void + { + $this->assertSame(7, $this->createAdapter(7)->getNbResults()); + } + + public function testTheFirstPageIsReturnedWithoutACursor(): void + { + $slice = $this->createAdapter(7)->getSlice(null, 3); + + $this->assertSame([1, 2, 3], $this->ids($slice)); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['id' => 3], Direction::Next), $slice->next); + } + + public function testTheNextPageFollowsTheCursor(): void + { + $slice = $this->createAdapter(7)->getSlice(new Cursor(['id' => 3]), 3); + + $this->assertSame([4, 5, 6], $this->ids($slice)); + $this->assertEquals(new Cursor(['id' => 4], Direction::Previous), $slice->previous); + $this->assertEquals(new Cursor(['id' => 6], Direction::Next), $slice->next); + } + + public function testThePreviousPagePrecedesTheCursor(): void + { + $slice = $this->createAdapter(7)->getSlice(new Cursor(['id' => 7], Direction::Previous), 3); + + $this->assertSame([4, 5, 6], $this->ids($slice), 'Items are returned in the list order'); + $this->assertEquals(new Cursor(['id' => 4], Direction::Previous), $slice->previous); + $this->assertEquals(new Cursor(['id' => 6], Direction::Next), $slice->next); + } + + public function testThePreviousPageStopsAtTheStartOfTheList(): void + { + $slice = $this->createAdapter(7)->getSlice(new Cursor(['id' => 3], Direction::Previous), 3); + + $this->assertSame([1, 2], $this->ids($slice)); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['id' => 2], Direction::Next), $slice->next); + } + + public function testThePreviousPageOfTheFirstItemIsEmpty(): void + { + $this->assertEquals(new CursorSlice([]), $this->createAdapter(7)->getSlice(new Cursor(['id' => 1], Direction::Previous), 3)); + } + + public function testTheLastPageHasNoNextCursor(): void + { + $slice = $this->createAdapter(7)->getSlice(new Cursor(['id' => 6]), 3); + + $this->assertSame([7], $this->ids($slice)); + $this->assertEquals(new Cursor(['id' => 7], Direction::Previous), $slice->previous); + $this->assertNull($slice->next); + } + + public function testAnExactMultipleOfTheLimitHasNoPhantomNextPage(): void + { + $adapter = $this->createAdapter(6); + + $first = $adapter->getSlice(null, 3); + $this->assertInstanceOf(Cursor::class, $first->next); + + $second = $adapter->getSlice($first->next, 3); + $this->assertSame([4, 5, 6], $this->ids($second)); + $this->assertNull($second->next); + } + + public function testASinglePageListHasNoNeighbouringPages(): void + { + $this->assertEquals(new CursorSlice([['id' => 1], ['id' => 2], ['id' => 3]]), $this->createAdapter(3)->getSlice(null, 3)); + } + + public function testAnEmptyListHasNoPages(): void + { + $adapter = $this->createAdapter(0); + + $this->assertSame(0, $adapter->getNbResults()); + $this->assertEquals(new CursorSlice([]), $adapter->getSlice(null, 3)); + } + + public function testTiesOnTheLeadingSortFieldAreBrokenByTheFollowingFields(): void + { + // Sorted by score descending, then by id ascending + $items = [ + ['id' => 2, 'score' => 90], + ['id' => 1, 'score' => 80], + ['id' => 3, 'score' => 80], + ['id' => 5, 'score' => 80], + ['id' => 4, 'score' => 70], + ]; + + $adapter = new ArrayCursorAdapter($items, static fn (array $item): array => ['score' => $item['score'], 'id' => $item['id']]); + + $first = $adapter->getSlice(null, 2); + $this->assertSame([2, 1], array_column($first->items, 'id')); + $this->assertEquals(new Cursor(['score' => 80, 'id' => 1]), $first->next); + + $second = $adapter->getSlice($first->next, 2); + $this->assertSame([3, 5], array_column($second->items, 'id'), 'The page boundary falls between items with the same score'); + + $this->assertInstanceOf(Cursor::class, $second->previous); + + $back = $adapter->getSlice($second->previous, 2); + $this->assertSame([2, 1], array_column($back->items, 'id')); + } + + public function testThePagesCanBeWalkedForwardAndBackwardThroughEncodedCursors(): void + { + $encoder = new Base64JsonCursorEncoder(); + $pager = CursorPagerfantaFactory::create($this->createAdapter(10), 3); + + $this->assertInstanceOf(CountableCursorPagerfanta::class, $pager); + $this->assertSame(10, $pager->getNbResults()); + + $forward = [array_column($pager->getCurrentPageResults(), 'id')]; + + while ($pager->hasNextPage()) { + $cursor = $encoder->decode($encoder->encode($pager->getNextPosition()->cursor)); + $pager = $pager->withPosition(new CursorPosition($cursor)); + $forward[] = array_column($pager->getCurrentPageResults(), 'id'); + } + + $this->assertSame([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]], $forward); + + $backward = []; + + while ($pager->hasPreviousPage()) { + $cursor = $encoder->decode($encoder->encode($pager->getPreviousPosition()->cursor)); + $pager = $pager->withPosition(new CursorPosition($cursor)); + $backward[] = array_column($pager->getCurrentPageResults(), 'id'); + } + + $this->assertSame([[7, 8, 9], [4, 5, 6], [1, 2, 3]], $backward); + $this->assertTrue($pager->hasNextPage()); + } + + /** + * @return \Generator + */ + public static function dataInvalidCursors(): \Generator + { + yield 'unknown value' => [new Cursor(['id' => 42])]; + yield 'value of a different type' => [new Cursor(['id' => '3'])]; + yield 'unknown field' => [new Cursor(['uuid' => 3])]; + yield 'extra field' => [new Cursor(['id' => 3, 'score' => 1])]; + } + + #[DataProvider('dataInvalidCursors')] + public function testACursorNotPointingToAnItemIsRejected(Cursor $cursor): void + { + $this->expectException(InvalidCursorException::class); + + $this->createAdapter(7)->getSlice($cursor, 3); + } + + public function testTheKeyExtractorMustReturnUniqueFields(): void + { + $this->expectException(InvalidArgumentException::class); + + $adapter = new ArrayCursorAdapter([['id' => 1, 'score' => 5], ['id' => 2, 'score' => 5]], static fn (array $item): array => ['score' => $item['score']]); + $adapter->getSlice(new Cursor(['score' => 5]), 1); + } +} diff --git a/lib/Core/Tests/Adapter/CallbackCursorAdapterTest.php b/lib/Core/Tests/Adapter/CallbackCursorAdapterTest.php new file mode 100644 index 00000000..895fbec9 --- /dev/null +++ b/lib/Core/Tests/Adapter/CallbackCursorAdapterTest.php @@ -0,0 +1,59 @@ + 3]); + $slice = new CursorSlice([4, 5]); + + $adapter = new CallbackCursorAdapter(function (?Cursor $givenCursor, int $limit) use ($cursor, $slice): CursorSlice { + $this->assertSame($cursor, $givenCursor); + $this->assertSame(2, $limit); + + return $slice; + }); + + $this->assertSame($slice, $adapter->getSlice($cursor, 2)); + } + + public function testBackwardNavigationIsNotSupportedByDefault(): void + { + $this->assertFalse((new CallbackCursorAdapter(static fn (): CursorSlice => new CursorSlice([])))->supportsBackwardNavigation()); + } + + public function testBackwardNavigationSupportCanBeEnabled(): void + { + $this->assertTrue((new CallbackCursorAdapter(static fn (): CursorSlice => new CursorSlice([]), true))->supportsBackwardNavigation()); + } + + public function testAForwardOnlyAdapterHasNoPreviousPageInThePager(): void + { + $adapter = new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice([4, 5], new Cursor(['id' => 4], Direction::Previous), new Cursor(['id' => 5]))); + + $pager = new CursorPagerfanta($adapter, 2, new CursorPosition(new Cursor(['id' => 3]))); + + $this->assertFalse($pager->supportsBackwardNavigation()); + $this->assertFalse($pager->hasPreviousPage()); + $this->assertTrue($pager->hasNextPage()); + } + + public function testTheCallableMustReturnACursorSlice(): void + { + $this->expectException(LogicException::class); + + // @phpstan-ignore-next-line argument.type + (new CallbackCursorAdapter(static fn (): array => [1, 2]))->getSlice(null, 2); + } +} diff --git a/lib/Core/Tests/Adapter/EmptyCursorAdapterTest.php b/lib/Core/Tests/Adapter/EmptyCursorAdapterTest.php new file mode 100644 index 00000000..c1ea75eb --- /dev/null +++ b/lib/Core/Tests/Adapter/EmptyCursorAdapterTest.php @@ -0,0 +1,21 @@ +assertSame(0, $adapter->getNbResults()); + $this->assertFalse($adapter->supportsBackwardNavigation()); + $this->assertEquals(new CursorSlice([]), $adapter->getSlice(null, 10)); + $this->assertEquals(new CursorSlice([]), $adapter->getSlice(new Cursor(['id' => 1]), 10)); + } +} diff --git a/lib/Core/Tests/Adapter/TransformingCursorAdapterTest.php b/lib/Core/Tests/Adapter/TransformingCursorAdapterTest.php new file mode 100644 index 00000000..96090165 --- /dev/null +++ b/lib/Core/Tests/Adapter/TransformingCursorAdapterTest.php @@ -0,0 +1,35 @@ + ['id' => $item]); + + $adapter = new TransformingCursorAdapter($inner, static fn (int $item, int $key): string => \sprintf('%d: item %d', $key, $item)); + + $slice = $adapter->getSlice(new Cursor(['id' => 1]), 2); + + $this->assertSame(['0: item 2', '1: item 3'], $slice->items); + $this->assertEquals(new Cursor(['id' => 2], Direction::Previous), $slice->previous); + $this->assertEquals(new Cursor(['id' => 3], Direction::Next), $slice->next); + } + + public function testBackwardNavigationSupportComesFromTheDecoratedAdapter(): void + { + $transformer = static fn (mixed $item): mixed => $item; + + $this->assertTrue((new TransformingCursorAdapter(new ArrayCursorAdapter([], static fn (mixed $item): array => ['id' => 1]), $transformer))->supportsBackwardNavigation()); + $this->assertFalse((new TransformingCursorAdapter(new CallbackCursorAdapter(static fn (): CursorSlice => new CursorSlice([])), $transformer))->supportsBackwardNavigation()); + } +} From e61cba1a50f62380fd18b73fb096b6e01bc3d69e Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 11:33:33 -0400 Subject: [PATCH 04/20] Add a decorator to make any cursor adapter countable --- lib/Core/Adapter/CountingCursorAdapter.php | 71 +++++++++++++++++++ .../Adapter/CountingCursorAdapterTest.php | 65 +++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 lib/Core/Adapter/CountingCursorAdapter.php create mode 100644 lib/Core/Tests/Adapter/CountingCursorAdapterTest.php diff --git a/lib/Core/Adapter/CountingCursorAdapter.php b/lib/Core/Adapter/CountingCursorAdapter.php new file mode 100644 index 00000000..1c5fddb0 --- /dev/null +++ b/lib/Core/Adapter/CountingCursorAdapter.php @@ -0,0 +1,71 @@ + + */ +class CountingCursorAdapter implements CursorAdapterInterface, CountableAdapterInterface +{ + /** + * @var (callable(): int<0, max>)|CountableAdapterInterface + */ + private $counter; + + /** + * @param CursorAdapterInterface $adapter + * @param (callable(): int<0, max>)|CountableAdapterInterface $counter A callable returning the number of results, or an adapter to count the results with + */ + public function __construct( + private readonly CursorAdapterInterface $adapter, + callable|CountableAdapterInterface $counter, + ) { + $this->counter = $counter; + } + + /** + * @return int<0, max> + * + * @throws NotValidResultCountException if the number of results is less than zero + */ + public function getNbResults(): int + { + if ($this->counter instanceof CountableAdapterInterface) { + return $this->counter->getNbResults(); + } + + $counter = $this->counter; + + $count = $counter(); + + if ($count < 0) { + throw new NotValidResultCountException(\sprintf('The callable to calculate the number of results in "%s()" must return a number greater than or equal to zero.', __METHOD__)); + } + + return $count; + } + + public function supportsBackwardNavigation(): bool + { + return $this->adapter->supportsBackwardNavigation(); + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + return $this->adapter->getSlice($cursor, $limit); + } +} diff --git a/lib/Core/Tests/Adapter/CountingCursorAdapterTest.php b/lib/Core/Tests/Adapter/CountingCursorAdapterTest.php new file mode 100644 index 00000000..88405d40 --- /dev/null +++ b/lib/Core/Tests/Adapter/CountingCursorAdapterTest.php @@ -0,0 +1,65 @@ + new CursorSlice([])), static fn (): int => 42); + + $this->assertSame(42, $adapter->getNbResults()); + } + + public function testTheResultsAreCountedWithACountableAdapter(): void + { + $inner = new ArrayCursorAdapter(range(1, 5), static fn (int $item): array => ['id' => $item]); + + $adapter = new CountingCursorAdapter(new TransformingCursorAdapter($inner, static fn (int $item): int => $item * 10), $inner); + + $this->assertSame(5, $adapter->getNbResults()); + } + + public function testTheCallableMustNotReturnANegativeCount(): void + { + $this->expectException(NotValidResultCountException::class); + + // @phpstan-ignore-next-line argument.type + (new CountingCursorAdapter(new CallbackCursorAdapter(static fn (): CursorSlice => new CursorSlice([])), static fn (): int => -1))->getNbResults(); + } + + public function testTheSliceAndBackwardNavigationSupportComeFromTheDecoratedAdapter(): void + { + $cursor = new Cursor(['id' => 1]); + $slice = new CursorSlice([2, 3]); + + $adapter = new CountingCursorAdapter( + new CallbackCursorAdapter( + static fn (?Cursor $givenCursor, int $limit): CursorSlice => $cursor === $givenCursor && 2 === $limit ? $slice : new CursorSlice([]), + true, + ), + static fn (): int => 3, + ); + + $this->assertTrue($adapter->supportsBackwardNavigation()); + $this->assertSame($slice, $adapter->getSlice($cursor, 2)); + } + + public function testTheFactoryCreatesACountablePager(): void + { + $adapter = new CountingCursorAdapter(new CallbackCursorAdapter(static fn (): CursorSlice => new CursorSlice([])), static fn (): int => 0); + + $this->assertInstanceOf(CountableCursorPagerfanta::class, CursorPagerfantaFactory::create($adapter)); + } +} From fd8788f532a2df69631c029c4f1a656a77d3a59c Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 11:51:47 -0400 Subject: [PATCH 05/20] Add cursor pagination adapters for Doctrine ORM 3.7+ --- .../ORM/CountableCursorQueryAdapter.php | 24 ++ .../Doctrine/ORM/CursorQueryAdapter.php | 114 +++++++ .../ORM/Tests/CursorQueryAdapterTest.php | 284 ++++++++++++++++++ .../Doctrine/ORM/Tests/Entity/Post.php | 22 ++ lib/Adapter/Doctrine/ORM/composer.json | 2 +- 5 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 lib/Adapter/Doctrine/ORM/CountableCursorQueryAdapter.php create mode 100644 lib/Adapter/Doctrine/ORM/CursorQueryAdapter.php create mode 100644 lib/Adapter/Doctrine/ORM/Tests/CursorQueryAdapterTest.php create mode 100644 lib/Adapter/Doctrine/ORM/Tests/Entity/Post.php diff --git a/lib/Adapter/Doctrine/ORM/CountableCursorQueryAdapter.php b/lib/Adapter/Doctrine/ORM/CountableCursorQueryAdapter.php new file mode 100644 index 00000000..18409528 --- /dev/null +++ b/lib/Adapter/Doctrine/ORM/CountableCursorQueryAdapter.php @@ -0,0 +1,24 @@ + + */ +class CountableCursorQueryAdapter extends CursorQueryAdapter implements CountableAdapterInterface +{ + /** + * @return int<0, max> + */ + public function getNbResults(): int + { + return $this->countResults(); + } +} diff --git a/lib/Adapter/Doctrine/ORM/CursorQueryAdapter.php b/lib/Adapter/Doctrine/ORM/CursorQueryAdapter.php new file mode 100644 index 00000000..d1cf66d9 --- /dev/null +++ b/lib/Adapter/Doctrine/ORM/CursorQueryAdapter.php @@ -0,0 +1,114 @@ + + */ +class CursorQueryAdapter implements CursorAdapterInterface +{ + /** + * @var CursorPage|null + */ + private ?CursorPage $page = null; + + /** + * @param bool $fetchJoinCollection Whether the query joins a collection (true by default) + * @param bool|null $useOutputWalkers Flag indicating whether output walkers are used in the paginator + * + * @throws LogicException if the installed `doctrine/orm` version does not support cursor pagination + */ + public function __construct( + private readonly Query|QueryBuilder $query, + private readonly bool $fetchJoinCollection = true, + private readonly ?bool $useOutputWalkers = null, + ) { + if (!class_exists(CursorPaginator::class)) { + throw new LogicException(\sprintf('The "%s" class requires doctrine/orm 3.7 or later.', static::class)); + } + } + + public function supportsBackwardNavigation(): bool + { + return true; + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + $this->page = $this->createPaginator($limit)->paginate($this->query, $cursor instanceof Cursor ? $this->toDoctrineCursor($cursor) : null); + + return new CursorSlice( + $this->page->getItems(), + $this->page->hasPreviousPage() && [] !== $this->page->getItems() ? $this->fromDoctrineCursor($this->page->getPreviousCursor()) : null, + $this->page->hasNextPage() && [] !== $this->page->getItems() ? $this->fromDoctrineCursor($this->page->getNextCursor()) : null, + ); + } + + /** + * Counts the total number of results, reusing the page from the last slice when possible. + * + * @return int<0, max> + */ + protected function countResults(): int + { + $this->page ??= $this->createPaginator(1)->paginate($this->query); + + return max(0, $this->page->getTotalCount()); + } + + /** + * @param positive-int $limit + * + * @return CursorPaginator + */ + private function createPaginator(int $limit): CursorPaginator + { + return new CursorPaginator($limit, $this->fetchJoinCollection, $this->useOutputWalkers); + } + + private function toDoctrineCursor(Cursor $cursor): DoctrineCursor + { + // The ORM documents cursor values as scalars but accepts null at runtime (i.e. when decoding its own cursors), so null values are passed through as-is and their handling is left to the ORM. + // @phpstan-ignore-next-line argument.type + return new DoctrineCursor($cursor->fields, Direction::Next === $cursor->direction); + } + + /** + * @throws LogicException if the cursor has no fields + */ + private function fromDoctrineCursor(DoctrineCursor $cursor): Cursor + { + $fields = $cursor->toArray(); + unset($fields['_isNext']); + + if ([] === $fields) { + throw new LogicException('A cursor could not be created for the query, ensure its ORDER BY clause contains at least one entity field.'); + } + + return new Cursor($fields, $cursor->isNext() ? Direction::Next : Direction::Previous); + } +} diff --git a/lib/Adapter/Doctrine/ORM/Tests/CursorQueryAdapterTest.php b/lib/Adapter/Doctrine/ORM/Tests/CursorQueryAdapterTest.php new file mode 100644 index 00000000..cde1021d --- /dev/null +++ b/lib/Adapter/Doctrine/ORM/Tests/CursorQueryAdapterTest.php @@ -0,0 +1,284 @@ +markTestSkipped('Cursor pagination requires doctrine/orm 3.7 or later.'); + } + + parent::setUp(); + + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->createSchema([ + $this->entityManager->getClassMetadata(Group::class), + $this->entityManager->getClassMetadata(Post::class), + $this->entityManager->getClassMetadata(User::class), + ]); + } + + /** + * Creates posts with the given scores, published a day apart in the order given. + */ + private function createPosts(int ...$scores): void + { + foreach ($scores as $index => $score) { + $this->entityManager->persist(new Post($score, new \DateTimeImmutable(\sprintf('2026-01-01 +%d days', $index)))); + } + + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + /** + * @param iterable $posts + * + * @return list + */ + private function ids(iterable $posts): array + { + $ids = []; + + foreach ($posts as $post) { + $ids[] = $post->id; + } + + return $ids; + } + + /** + * @param CursorPagerInterface $pager + * + * @return array{forward: list>, backward: list>} + */ + private function walk(CursorPagerInterface $pager): array + { + \assert($pager instanceof CursorPagerfanta || $pager instanceof CountableCursorPagerfanta); + + $encoder = new Base64JsonCursorEncoder(); + + $forward = [$this->ids($pager->getCurrentPageResults())]; + + while ($pager->hasNextPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getNextPosition()->cursor)))); + $forward[] = $this->ids($pager->getCurrentPageResults()); + } + + $backward = []; + + while ($pager->hasPreviousPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getPreviousPosition()->cursor)))); + $backward[] = $this->ids($pager->getCurrentPageResults()); + } + + return ['forward' => $forward, 'backward' => $backward]; + } + + public function testTheAdapterSupportsBackwardNavigation(): void + { + $this->assertTrue((new CursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.id ASC')))->supportsBackwardNavigation()); + } + + public function testTheFirstPageIsReturnedWithoutACursor(): void + { + $this->createPosts(10, 20, 30, 40, 50); + + $adapter = new CursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.id ASC'), false); + + $slice = $adapter->getSlice(null, 2); + + $this->assertSame([1, 2], $this->ids($slice->items)); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['p.id' => 2], Direction::Next), $slice->next); + } + + public function testThePagesAreFetchedRelativeToTheCursor(): void + { + $this->createPosts(10, 20, 30, 40, 50); + + $adapter = new CursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.id ASC'), false); + + $next = $adapter->getSlice(new Cursor(['p.id' => 2]), 2); + + $this->assertSame([3, 4], $this->ids($next->items)); + $this->assertEquals(new Cursor(['p.id' => 3], Direction::Previous), $next->previous); + $this->assertEquals(new Cursor(['p.id' => 4], Direction::Next), $next->next); + + $previous = $adapter->getSlice(new Cursor(['p.id' => 5], Direction::Previous), 2); + + $this->assertSame([3, 4], $this->ids($previous->items), 'Items are returned in the query order'); + $this->assertEquals(new Cursor(['p.id' => 3], Direction::Previous), $previous->previous); + $this->assertEquals(new Cursor(['p.id' => 4], Direction::Next), $previous->next); + } + + public function testThePagesCanBeWalkedForwardAndBackward(): void + { + $this->createPosts(10, 20, 30, 40, 50, 60, 70); + + $pager = new CursorPagerfanta(new CursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.id ASC'), false), 3); + + $this->assertSame( + [ + 'forward' => [[1, 2, 3], [4, 5, 6], [7]], + 'backward' => [[4, 5, 6], [1, 2, 3]], + ], + $this->walk($pager), + ); + } + + public function testAnExactMultipleOfTheLimitHasNoPhantomNextPage(): void + { + $this->createPosts(10, 20, 30, 40, 50, 60); + + $pager = new CursorPagerfanta(new CursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.id ASC'), false), 3); + + $this->assertSame([[1, 2, 3], [4, 5, 6]], $this->walk($pager)['forward']); + } + + public function testAnEmptyResultSetHasNoPages(): void + { + $pager = new CursorPagerfanta(new CursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.id ASC'), false), 3); + + $this->assertSame([], $pager->getCurrentPageResults()); + $this->assertFalse($pager->haveToPaginate()); + $this->assertFalse($pager->hasPreviousPage()); + $this->assertFalse($pager->hasNextPage()); + } + + public function testTiesOnTheLeadingSortFieldAreBrokenByTheFollowingFields(): void + { + // Sorted by score descending then ID ascending: 2 (90), 1 (80), 3 (80), 5 (80), 6 (80), 4 (70) + $this->createPosts(80, 90, 80, 70, 80, 80); + + $query = $this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.score DESC, p.id ASC'); + + $pager = new CursorPagerfanta(new CursorQueryAdapter($query, false), 2); + + $this->assertEquals(new Cursor(['p.score' => 80, 'p.id' => 1]), $pager->getNextPosition()->cursor); + + $this->assertSame( + [ + 'forward' => [[2, 1], [3, 5], [6, 4]], + 'backward' => [[3, 5], [2, 1]], + ], + $this->walk($pager), + ); + } + + public function testADateTimeSortFieldIsConvertedToADatabaseValue(): void + { + $this->createPosts(10, 20, 30, 40, 50); + + $pager = new CursorPagerfanta(new CursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.publishedAt DESC, p.id DESC'), false), 2); + + $this->assertEquals(new Cursor(['p.publishedAt' => '2026-01-04 00:00:00', 'p.id' => 4]), $pager->getNextPosition()->cursor); + + $this->assertSame( + [ + 'forward' => [[5, 4], [3, 2], [1]], + 'backward' => [[3, 2], [5, 4]], + ], + $this->walk($pager), + ); + } + + public function testTheQueryParametersAreKept(): void + { + $this->createPosts(10, 20, 30, 40, 50, 60, 70); + + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('p') + ->from(Post::class, 'p') + ->where('p.score > :score') + ->orderBy('p.id', 'ASC') + ->setParameter('score', 20); + + $pager = new CursorPagerfanta(new CursorQueryAdapter($queryBuilder, false), 2); + + $this->assertSame( + [ + 'forward' => [[3, 4], [5, 6], [7]], + 'backward' => [[5, 6], [3, 4]], + ], + $this->walk($pager), + ); + } + + public function testAQueryJoiningACollectionReturnsEachRootEntityOnce(): void + { + $users = [new User(), new User(), new User()]; + $groups = [new Group(), new Group(), new Group()]; + + foreach ($users as $user) { + foreach ($groups as $group) { + $user->addGroup($group); + $this->entityManager->persist($group); + } + + $this->entityManager->persist($user); + } + + $this->entityManager->flush(); + $this->entityManager->clear(); + + $pager = new CursorPagerfanta(new CursorQueryAdapter($this->entityManager->createQuery('SELECT u, g FROM '.User::class.' u INNER JOIN u.groups g ORDER BY u.id ASC')), 2); + + $ids = static fn (array $users): array => array_map(static fn (User $user): ?int => $user->id, $users); + + $this->assertSame([1, 2], $ids($pager->getCurrentPageResults())); + $this->assertCount(3, $pager->getCurrentPageResults()[0]->getGroups(), 'The joined collection is fully hydrated'); + + $pager = $pager->withPosition($pager->getNextPosition()); + + $this->assertSame([3], $ids($pager->getCurrentPageResults())); + $this->assertFalse($pager->hasNextPage()); + } + + public function testTheCursorAdapterIsNotCountable(): void + { + $this->assertInstanceOf(CursorPagerfanta::class, CursorPagerfantaFactory::create(new CursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.id ASC')))); + } + + public function testTheCountableAdapterCountsAllResultsIgnoringTheCursor(): void + { + $this->createPosts(10, 20, 30, 40, 50); + + $pager = CursorPagerfantaFactory::create(new CountableCursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p WHERE p.score > 10 ORDER BY p.id ASC'), false), 2); + + $this->assertInstanceOf(CountableCursorPagerfanta::class, $pager); + $this->assertSame(4, $pager->getNbResults()); + + $pager = $pager->withPosition($pager->getNextPosition()); + + $this->assertSame([4, 5], $this->ids($pager->getCurrentPageResults())); + $this->assertSame(4, $pager->getNbResults()); + } + + public function testTheCountableAdapterCanCountBeforeASliceIsFetched(): void + { + $this->createPosts(10, 20, 30); + + $adapter = new CountableCursorQueryAdapter($this->entityManager->createQuery('SELECT p FROM '.Post::class.' p ORDER BY p.id ASC'), false); + + $this->assertSame(3, $adapter->getNbResults()); + } +} diff --git a/lib/Adapter/Doctrine/ORM/Tests/Entity/Post.php b/lib/Adapter/Doctrine/ORM/Tests/Entity/Post.php new file mode 100644 index 00000000..35215767 --- /dev/null +++ b/lib/Adapter/Doctrine/ORM/Tests/Entity/Post.php @@ -0,0 +1,22 @@ + Date: Fri, 25 Sep 2026 12:02:59 -0400 Subject: [PATCH 06/20] Add a helper to build cursor slices from lookahead results --- lib/Core/Adapter/CursorSlice.php | 37 +++++++++++++++ lib/Core/Tests/Adapter/CursorSliceTest.php | 54 ++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/lib/Core/Adapter/CursorSlice.php b/lib/Core/Adapter/CursorSlice.php index 8974a4eb..a03a11e0 100644 --- a/lib/Core/Adapter/CursorSlice.php +++ b/lib/Core/Adapter/CursorSlice.php @@ -33,4 +33,41 @@ public function __construct( throw new InvalidArgumentException('The next cursor of a slice must have the next direction.'); } } + + /** + * Creates a slice from items fetched with one item of lookahead. + * + * @template TItem + * + * @param list $items The items fetched in the direction of the cursor, up to `$limit + 1` items + * @param positive-int $limit The maximum number of items on the page + * @param Cursor|null $cursor The cursor the items were fetched for, or null for the first page + * @param callable(TItem, Direction): Cursor $cursorFactory Creates the cursor pointing to an item + * + * @return self + */ + public static function fromLookahead(array $items, int $limit, ?Cursor $cursor, callable $cursorFactory): self + { + $reverse = $cursor instanceof Cursor && Direction::Previous === $cursor->direction; + $hasMore = \count($items) > $limit; + + $items = \array_slice($items, 0, $limit); + + if ($reverse) { + $items = array_reverse($items); + } + + if ([] === $items) { + return new self([]); + } + + $hasPrevious = $reverse ? $hasMore : $cursor instanceof Cursor; + $hasNext = $reverse || $hasMore; + + return new self( + $items, + $hasPrevious ? $cursorFactory($items[0], Direction::Previous) : null, + $hasNext ? $cursorFactory($items[array_key_last($items)], Direction::Next) : null, + ); + } } diff --git a/lib/Core/Tests/Adapter/CursorSliceTest.php b/lib/Core/Tests/Adapter/CursorSliceTest.php index 7b1c600a..471d9263 100644 --- a/lib/Core/Tests/Adapter/CursorSliceTest.php +++ b/lib/Core/Tests/Adapter/CursorSliceTest.php @@ -44,4 +44,58 @@ public function testTheNextCursorMustHaveTheNextDirection(): void new CursorSlice([1], null, new Cursor(['id' => 1], Direction::Previous)); } + + /** + * @return \Closure(int, Direction): Cursor + */ + private function cursorFactory(): \Closure + { + return static fn (int $item, Direction $direction): Cursor => new Cursor(['id' => $item], $direction); + } + + public function testALookaheadSliceForTheFirstPageWithMoreItems(): void + { + $slice = CursorSlice::fromLookahead([1, 2, 3, 4], 3, null, $this->cursorFactory()); + + $this->assertSame([1, 2, 3], $slice->items); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['id' => 3]), $slice->next); + } + + public function testALookaheadSliceForTheOnlyPage(): void + { + $this->assertEquals(new CursorSlice([1, 2, 3]), CursorSlice::fromLookahead([1, 2, 3], 3, null, $this->cursorFactory())); + } + + public function testALookaheadSliceAfterANextCursor(): void + { + $slice = CursorSlice::fromLookahead([4, 5], 3, new Cursor(['id' => 3]), $this->cursorFactory()); + + $this->assertSame([4, 5], $slice->items); + $this->assertEquals(new Cursor(['id' => 4], Direction::Previous), $slice->previous, 'Arriving from a next cursor implies a previous page'); + $this->assertNull($slice->next); + } + + public function testALookaheadSliceBeforeAPreviousCursorIsReversed(): void + { + $slice = CursorSlice::fromLookahead([6, 5, 4, 3], 3, new Cursor(['id' => 7], Direction::Previous), $this->cursorFactory()); + + $this->assertSame([4, 5, 6], $slice->items); + $this->assertEquals(new Cursor(['id' => 4], Direction::Previous), $slice->previous); + $this->assertEquals(new Cursor(['id' => 6]), $slice->next, 'Arriving from a previous cursor implies a next page'); + } + + public function testALookaheadSliceAtTheStartOfTheList(): void + { + $slice = CursorSlice::fromLookahead([2, 1], 3, new Cursor(['id' => 3], Direction::Previous), $this->cursorFactory()); + + $this->assertSame([1, 2], $slice->items); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['id' => 2]), $slice->next); + } + + public function testAnEmptyLookaheadSliceHasNoCursors(): void + { + $this->assertEquals(new CursorSlice([]), CursorSlice::fromLookahead([], 3, new Cursor(['id' => 3]), $this->cursorFactory())); + } } From 990ff6779c1e59be1edfc3077e725e40b6cd96ef Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 12:24:45 -0400 Subject: [PATCH 07/20] Add a cursor pagination adapter for Doctrine DBAL --- .../Doctrine/DBAL/CursorQueryAdapter.php | 175 ++++++++++++ lib/Adapter/Doctrine/DBAL/SortColumn.php | 56 ++++ .../DBAL/Tests/CursorQueryAdapterTest.php | 255 ++++++++++++++++++ .../Doctrine/DBAL/Tests/SortColumnTest.php | 56 ++++ lib/Adapter/Doctrine/DBAL/composer.json | 2 +- 5 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 lib/Adapter/Doctrine/DBAL/CursorQueryAdapter.php create mode 100644 lib/Adapter/Doctrine/DBAL/SortColumn.php create mode 100644 lib/Adapter/Doctrine/DBAL/Tests/CursorQueryAdapterTest.php create mode 100644 lib/Adapter/Doctrine/DBAL/Tests/SortColumnTest.php diff --git a/lib/Adapter/Doctrine/DBAL/CursorQueryAdapter.php b/lib/Adapter/Doctrine/DBAL/CursorQueryAdapter.php new file mode 100644 index 00000000..9c265d9b --- /dev/null +++ b/lib/Adapter/Doctrine/DBAL/CursorQueryAdapter.php @@ -0,0 +1,175 @@ + + * + * @implements CursorAdapterInterface + */ +class CursorQueryAdapter implements CursorAdapterInterface +{ + private const PARAMETER_PREFIX = 'pagerfanta_cursor_'; + + private readonly QueryBuilder $queryBuilder; + + /** + * @var non-empty-list + */ + private readonly array $sortColumns; + + /** + * @param list $sortColumns The columns to sort the query by, in order of precedence + * + * @throws InvalidArgumentException if no sort columns are given or a sort column is given more than once + */ + public function __construct(QueryBuilder $queryBuilder, array $sortColumns) + { + if ([] === $sortColumns) { + throw new InvalidArgumentException('At least one sort column is required.'); + } + + $expressions = array_map(static fn (SortColumn $column): string => $column->expression, $sortColumns); + + if (\count($expressions) !== \count(array_unique($expressions))) { + throw new InvalidArgumentException('Each sort column must only be given once.'); + } + + $this->queryBuilder = clone $queryBuilder; + $this->sortColumns = array_values($sortColumns); + } + + public function supportsBackwardNavigation(): bool + { + return true; + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + * + * @throws InvalidCursorException if the cursor fields do not match the sort columns + * @throws LogicException if the query uses the parameter names reserved by this adapter, or a result row is missing a sort column or has a null value for one + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + $reverse = $cursor instanceof Cursor && Direction::Previous === $cursor->direction; + + $qb = clone $this->queryBuilder; + + foreach ($this->sortColumns as $index => $column) { + $order = $reverse ? ('ASC' === $column->order ? 'DESC' : 'ASC') : $column->order; + + if (0 === $index) { + $qb->orderBy($column->expression, $order); + } else { + $qb->addOrderBy($column->expression, $order); + } + } + + if ($cursor instanceof Cursor) { + $this->applyCursor($qb, $cursor, $reverse); + } + + /** @var list $rows */ + $rows = $qb->setFirstResult(0) + ->setMaxResults($limit + 1) + ->executeQuery() + ->fetchAllAssociative(); + + return CursorSlice::fromLookahead($rows, $limit, $cursor, $this->createCursor(...)); + } + + /** + * Adds the keyset condition for the cursor to the query. + * + * The condition is expanded to `(a > :a) OR (a = :a AND b > :b) OR ...` instead of using a row value comparison, as + * row values are not supported by every platform and cannot mix sort orders. + * + * @throws InvalidCursorException if the cursor fields do not match the sort columns + * @throws LogicException if the query uses the parameter names reserved by this adapter + */ + private function applyCursor(QueryBuilder $qb, Cursor $cursor, bool $reverse): void + { + $expressions = array_map(static fn (SortColumn $column): string => $column->expression, $this->sortColumns); + + if (\count($cursor->fields) !== \count($expressions) || [] !== array_diff($expressions, array_keys($cursor->fields))) { + throw new InvalidCursorException(\sprintf('The cursor fields must match the sort columns "%s".', implode('", "', $expressions))); + } + + $expr = $qb->expr(); + $conditions = []; + $equalities = []; + + foreach ($this->sortColumns as $index => $column) { + $value = $cursor->fields[$column->expression]; + + if (null === $value) { + throw new InvalidCursorException(\sprintf('The cursor value for the "%s" sort column must not be null.', $column->expression)); + } + + $parameter = self::PARAMETER_PREFIX.$index; + + if (\array_key_exists($parameter, $qb->getParameters())) { + throw new LogicException(\sprintf('The query must not use the "%s" parameter, the "%s" prefix is reserved for the cursor parameters.', $parameter, self::PARAMETER_PREFIX)); + } + + $qb->setParameter($parameter, $value, match (true) { + \is_int($value) => ParameterType::INTEGER, + \is_bool($value) => ParameterType::BOOLEAN, + default => ParameterType::STRING, + }); + + $operator = ('ASC' === $column->order) !== $reverse ? '>' : '<'; + $comparison = $expr->comparison($column->expression, $operator, ':'.$parameter); + + $conditions[] = [] === $equalities ? $comparison : $expr->and(...[...$equalities, $comparison]); + $equalities[] = $expr->eq($column->expression, ':'.$parameter); + } + + $qb->andWhere($expr->or(...$conditions)); + } + + /** + * @param T $row + * + * @throws LogicException if the row is missing a sort column or has a non-scalar or null value for one + */ + private function createCursor(array $row, Direction $direction): Cursor + { + $fields = []; + + foreach ($this->sortColumns as $column) { + if (!\array_key_exists($column->resultKey, $row)) { + throw new LogicException(\sprintf('The "%s" key for the "%s" sort column is missing from the result row, ensure the column is selected or set the result key of the sort column.', $column->resultKey, $column->expression)); + } + + $value = $row[$column->resultKey]; + + if (null === $value || !\is_scalar($value)) { + throw new LogicException(\sprintf('The "%s" sort column must have a scalar value in every result row, "%s" given.', $column->expression, get_debug_type($value))); + } + + $fields[$column->expression] = $value; + } + + return new Cursor($fields, $direction); + } +} diff --git a/lib/Adapter/Doctrine/DBAL/SortColumn.php b/lib/Adapter/Doctrine/DBAL/SortColumn.php new file mode 100644 index 00000000..cbaa3590 --- /dev/null +++ b/lib/Adapter/Doctrine/DBAL/SortColumn.php @@ -0,0 +1,56 @@ +order = $order; + $this->resultKey = $resultKey; + } +} diff --git a/lib/Adapter/Doctrine/DBAL/Tests/CursorQueryAdapterTest.php b/lib/Adapter/Doctrine/DBAL/Tests/CursorQueryAdapterTest.php new file mode 100644 index 00000000..25bda953 --- /dev/null +++ b/lib/Adapter/Doctrine/DBAL/Tests/CursorQueryAdapterTest.php @@ -0,0 +1,255 @@ +connection->createQueryBuilder() + ->select('p.*') + ->from('posts', 'p'); + } + + /** + * @param CursorPagerfanta>|CountableCursorPagerfanta> $pager + * + * @return array{forward: list>, backward: list>} + */ + private function walk(CursorPagerfanta|CountableCursorPagerfanta $pager): array + { + $encoder = new Base64JsonCursorEncoder(); + + $forward = [array_column($pager->getCurrentPageResults(), 'id')]; + + while ($pager->hasNextPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getNextPosition()->cursor)))); + $forward[] = array_column($pager->getCurrentPageResults(), 'id'); + } + + $backward = []; + + while ($pager->hasPreviousPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getPreviousPosition()->cursor)))); + $backward[] = array_column($pager->getCurrentPageResults(), 'id'); + } + + return ['forward' => $forward, 'backward' => $backward]; + } + + public function testAtLeastOneSortColumnIsRequired(): void + { + $this->expectException(InvalidArgumentException::class); + + new CursorQueryAdapter($this->createPostsQueryBuilder(), []); + } + + public function testASortColumnCanOnlyBeGivenOnce(): void + { + $this->expectException(InvalidArgumentException::class); + + new CursorQueryAdapter($this->createPostsQueryBuilder(), [new SortColumn('p.id'), new SortColumn('p.id', 'DESC')]); + } + + public function testTheAdapterSupportsBackwardNavigation(): void + { + $this->assertTrue((new CursorQueryAdapter($this->createPostsQueryBuilder(), [new SortColumn('p.id')]))->supportsBackwardNavigation()); + } + + public function testTheFirstPageIsReturnedWithoutACursor(): void + { + $slice = (new CursorQueryAdapter($this->createPostsQueryBuilder(), [new SortColumn('p.id')]))->getSlice(null, 3); + + $this->assertSame([1, 2, 3], array_column($slice->items, 'id')); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['p.id' => 3]), $slice->next); + } + + public function testThePagesAreFetchedRelativeToTheCursor(): void + { + $adapter = new CursorQueryAdapter($this->createPostsQueryBuilder(), [new SortColumn('p.id')]); + + $next = $adapter->getSlice(new Cursor(['p.id' => 3]), 3); + + $this->assertSame([4, 5, 6], array_column($next->items, 'id')); + $this->assertEquals(new Cursor(['p.id' => 4], Direction::Previous), $next->previous); + $this->assertEquals(new Cursor(['p.id' => 6]), $next->next); + + $previous = $adapter->getSlice(new Cursor(['p.id' => 7], Direction::Previous), 3); + + $this->assertSame([4, 5, 6], array_column($previous->items, 'id'), 'Items are returned in the sort order'); + $this->assertEquals(new Cursor(['p.id' => 4], Direction::Previous), $previous->previous); + $this->assertEquals(new Cursor(['p.id' => 6]), $previous->next); + } + + public function testThePreviousPageStopsAtTheStartOfTheList(): void + { + $slice = (new CursorQueryAdapter($this->createPostsQueryBuilder(), [new SortColumn('p.id')]))->getSlice(new Cursor(['p.id' => 3], Direction::Previous), 3); + + $this->assertSame([1, 2], array_column($slice->items, 'id')); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['p.id' => 2]), $slice->next); + } + + public function testThePagesCanBeWalkedForwardAndBackward(): void + { + $pager = new CursorPagerfanta(new CursorQueryAdapter($this->createPostsQueryBuilder()->where('p.id <= 7'), [new SortColumn('p.id', 'DESC')]), 3); + + $this->assertSame( + [ + 'forward' => [[7, 6, 5], [4, 3, 2], [1]], + 'backward' => [[4, 3, 2], [7, 6, 5]], + ], + $this->walk($pager), + ); + } + + public function testAnExactMultipleOfTheLimitHasNoPhantomNextPage(): void + { + $pager = new CursorPagerfanta(new CursorQueryAdapter($this->createPostsQueryBuilder()->where('p.id <= 6'), [new SortColumn('p.id')]), 3); + + $this->assertSame([[1, 2, 3], [4, 5, 6]], $this->walk($pager)['forward']); + } + + public function testAnEmptyResultSetHasNoPages(): void + { + $pager = new CursorPagerfanta(new CursorQueryAdapter($this->createPostsQueryBuilder()->where('p.id > 1000'), [new SortColumn('p.id')]), 3); + + $this->assertSame([], $pager->getCurrentPageResults()); + $this->assertFalse($pager->haveToPaginate()); + } + + public function testTiesOnTheLeadingSortColumnAreBrokenByTheFollowingColumns(): void + { + // Each post has 5 comments, sorted by post descending then comment ID ascending: post 2 has comments 6-10 and post 1 has comments 1-5 + $queryBuilder = $this->connection->createQueryBuilder() + ->select('c.id', 'c.post_id') + ->from('comments', 'c') + ->where('c.post_id <= 2'); + + $pager = new CursorPagerfanta(new CursorQueryAdapter($queryBuilder, [new SortColumn('c.post_id', 'DESC'), new SortColumn('c.id')]), 3); + + $this->assertEquals(new Cursor(['c.post_id' => 2, 'c.id' => 8]), $pager->getNextPosition()->cursor); + + $this->assertSame( + [ + 'forward' => [[6, 7, 8], [9, 10, 1], [2, 3, 4], [5]], + 'backward' => [[2, 3, 4], [9, 10, 1], [6, 7, 8]], + ], + $this->walk($pager), + ); + } + + public function testTheSortColumnsReplaceTheOrderByClauseAndTheQueryParametersAreKept(): void + { + $queryBuilder = $this->createPostsQueryBuilder() + ->where('p.id > :min_id') + ->andWhere('p.id <= 10 OR p.id = 50') + ->orderBy('p.post_content', 'DESC') + ->setParameter('min_id', 5); + + $pager = new CursorPagerfanta(new CursorQueryAdapter($queryBuilder, [new SortColumn('p.id')]), 3); + + $this->assertSame( + [ + 'forward' => [[6, 7, 8], [9, 10, 50]], + 'backward' => [[6, 7, 8]], + ], + $this->walk($pager), + ); + } + + public function testTheOriginalQueryBuilderIsNotModified(): void + { + $queryBuilder = $this->createPostsQueryBuilder(); + $sql = $queryBuilder->getSQL(); + + (new CursorQueryAdapter($queryBuilder, [new SortColumn('p.id')]))->getSlice(new Cursor(['p.id' => 3]), 3); + + $this->assertSame($sql, $queryBuilder->getSQL()); + $this->assertSame([], $queryBuilder->getParameters()); + } + + public function testAResultKeyCanBeSetForAnAliasedColumn(): void + { + $queryBuilder = $this->connection->createQueryBuilder() + ->select('p.id AS post_id') + ->from('posts', 'p'); + + $slice = (new CursorQueryAdapter($queryBuilder, [new SortColumn('p.id', 'ASC', 'post_id')]))->getSlice(null, 2); + + $this->assertEquals(new Cursor(['p.id' => 2]), $slice->next); + } + + public function testASortColumnMissingFromTheResultIsRejected(): void + { + $this->expectException(LogicException::class); + + $queryBuilder = $this->connection->createQueryBuilder() + ->select('p.username') + ->from('posts', 'p'); + + (new CursorQueryAdapter($queryBuilder, [new SortColumn('p.id')]))->getSlice(null, 2); + } + + public function testTheReservedParameterNamesCannotBeUsedByTheQuery(): void + { + $this->expectException(LogicException::class); + + $queryBuilder = $this->createPostsQueryBuilder() + ->where('p.id > :pagerfanta_cursor_0') + ->setParameter('pagerfanta_cursor_0', 1); + + (new CursorQueryAdapter($queryBuilder, [new SortColumn('p.id')]))->getSlice(new Cursor(['p.id' => 3]), 2); + } + + /** + * @return \Generator + */ + public static function dataInvalidCursors(): \Generator + { + yield 'unknown field' => [new Cursor(['p.username' => 'Jon Doe'])]; + yield 'missing field' => [new Cursor(['c.post_id' => 2])]; + yield 'extra field' => [new Cursor(['c.post_id' => 2, 'c.id' => 8, 'c.username' => 'Jon Doe'])]; + yield 'null value' => [new Cursor(['c.post_id' => null, 'c.id' => 8])]; + } + + #[DataProvider('dataInvalidCursors')] + public function testACursorNotMatchingTheSortColumnsIsRejected(Cursor $cursor): void + { + $this->expectException(InvalidCursorException::class); + + $queryBuilder = $this->connection->createQueryBuilder() + ->select('c.id', 'c.post_id') + ->from('comments', 'c'); + + (new CursorQueryAdapter($queryBuilder, [new SortColumn('c.post_id', 'DESC'), new SortColumn('c.id')]))->getSlice($cursor, 3); + } + + public function testTheResultsCanBeCountedWithAnOffsetAdapter(): void + { + $queryBuilder = $this->createPostsQueryBuilder()->where('p.id <= 7'); + + $pager = CursorPagerfantaFactory::create(new CountingCursorAdapter(new CursorQueryAdapter($queryBuilder, [new SortColumn('p.id')]), new SingleTableQueryAdapter($queryBuilder, 'p.id')), 3); + + $this->assertInstanceOf(CountableCursorPagerfanta::class, $pager); + $this->assertSame(7, $pager->getNbResults()); + } +} diff --git a/lib/Adapter/Doctrine/DBAL/Tests/SortColumnTest.php b/lib/Adapter/Doctrine/DBAL/Tests/SortColumnTest.php new file mode 100644 index 00000000..4260bc68 --- /dev/null +++ b/lib/Adapter/Doctrine/DBAL/Tests/SortColumnTest.php @@ -0,0 +1,56 @@ +assertSame('p.id', $column->expression); + $this->assertSame('ASC', $column->order); + } + + public function testTheOrderIsNormalized(): void + { + $this->assertSame('DESC', (new SortColumn('p.id', 'desc'))->order); + } + + public function testTheResultKeyDefaultsToTheColumnName(): void + { + $this->assertSame('created_at', (new SortColumn('p.created_at'))->resultKey); + $this->assertSame('created_at', (new SortColumn('created_at'))->resultKey); + } + + public function testTheResultKeyCanBeSet(): void + { + $this->assertSame('post_created_at', (new SortColumn('p.created_at', 'ASC', 'post_created_at'))->resultKey); + } + + public function testTheOrderMustBeValid(): void + { + $this->expectException(InvalidArgumentException::class); + + new SortColumn('p.id', 'SIDEWAYS'); + } + + public function testTheExpressionMustNotBeEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + // @phpstan-ignore-next-line argument.type + new SortColumn(''); + } + + public function testTheResultKeyMustNotBeEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + new SortColumn('p.'); + } +} diff --git a/lib/Adapter/Doctrine/DBAL/composer.json b/lib/Adapter/Doctrine/DBAL/composer.json index 24c90ac9..9531d5df 100644 --- a/lib/Adapter/Doctrine/DBAL/composer.json +++ b/lib/Adapter/Doctrine/DBAL/composer.json @@ -7,7 +7,7 @@ "require": { "php": "^8.1", "doctrine/dbal": "^3.5 || ^4.0", - "pagerfanta/core": "^3.7 || ^4.0", + "pagerfanta/core": "^4.10", "symfony/deprecation-contracts": "^2.1 || ^3.0" }, "require-dev": { From 4058fc872b1f7335c4faa5f1cbc9215f38cb8c8a Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 13:02:51 -0400 Subject: [PATCH 08/20] Add a cursor pagination adapter for Doctrine Collections --- .../Collections/SelectableCursorAdapter.php | 207 +++++++++++++ .../Doctrine/Collections/Tests/Item.php | 21 ++ .../Tests/SelectableCursorAdapterTest.php | 281 ++++++++++++++++++ .../Doctrine/Collections/composer.json | 7 +- 4 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 lib/Adapter/Doctrine/Collections/SelectableCursorAdapter.php create mode 100644 lib/Adapter/Doctrine/Collections/Tests/Item.php create mode 100644 lib/Adapter/Doctrine/Collections/Tests/SelectableCursorAdapterTest.php diff --git a/lib/Adapter/Doctrine/Collections/SelectableCursorAdapter.php b/lib/Adapter/Doctrine/Collections/SelectableCursorAdapter.php new file mode 100644 index 00000000..55fd6163 --- /dev/null +++ b/lib/Adapter/Doctrine/Collections/SelectableCursorAdapter.php @@ -0,0 +1,207 @@ + + */ +class SelectableCursorAdapter implements CursorAdapterInterface +{ + private static ?bool $supportsSortDirection = null; + + /** + * @var non-empty-array + */ + private readonly array $sortFields; + + /** + * @param Selectable $selectable + * @param array $sortFields The fields to sort the items by, in order of precedence, mapped to their sort order + * + * @throws InvalidArgumentException if no sort fields are given or a sort order is not valid + */ + public function __construct( + private readonly Selectable $selectable, + private readonly Criteria $criteria, + array $sortFields, + ) { + if ([] === $sortFields) { + throw new InvalidArgumentException('At least one sort field is required.'); + } + + $normalized = []; + + foreach ($sortFields as $field => $order) { + $order = match (true) { + $order instanceof \SortDirection => \SortDirection::Ascending === $order ? 'ASC' : 'DESC', + $order instanceof Order => $order->value, + default => strtoupper($order), + }; + + if ('ASC' !== $order && 'DESC' !== $order) { + throw new InvalidArgumentException(\sprintf('The order of the "%s" sort field must be "ASC" or "DESC", "%s" given.', $field, $order)); + } + + $normalized[$field] = $order; + } + + $this->sortFields = $normalized; + } + + public function supportsBackwardNavigation(): bool + { + return true; + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + * + * @throws InvalidCursorException if the cursor fields do not match the sort fields + * @throws LogicException if an item has a non-scalar or null value for a sort field + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + $reverse = $cursor instanceof Cursor && Direction::Previous === $cursor->direction; + + $criteria = clone $this->criteria; + + // The accepted ordering values depend on the installed doctrine/collections version + // @phpstan-ignore-next-line argument.type + $criteria->orderBy($this->createOrderings($reverse)); + $criteria->setFirstResult(0); + $criteria->setMaxResults($limit + 1); + + if ($cursor instanceof Cursor) { + $criteria->andWhere($this->createKeysetExpression($cursor, $reverse)); + } + + return CursorSlice::fromLookahead(array_values($this->selectable->matching($criteria)->toArray()), $limit, $cursor, $this->createCursor(...)); + } + + /** + * @return array|array|array + */ + private function createOrderings(bool $reverse): array + { + $orderings = []; + + foreach ($this->sortFields as $field => $order) { + if ($reverse) { + $order = 'ASC' === $order ? 'DESC' : 'ASC'; + } + + $orderings[$field] = $order; + } + + if ($this->supportsSortDirection()) { + return array_map(static fn (string $order): \SortDirection => 'ASC' === $order ? \SortDirection::Ascending : \SortDirection::Descending, $orderings); + } + + // The Order enum was added in doctrine/collections 2.2, and passing strings is deprecated since then + if (enum_exists(Order::class)) { + return array_map(static fn (string $order): Order => Order::from($order), $orderings); + } + + return $orderings; + } + + /** + * Checks whether the criteria support the native SortDirection enum, which replaces the Order enum in doctrine/collections 3.1. + * + * The enum may be provided by a polyfill with an older doctrine/collections version which does not support it, so the + * version is detected with the Criteria::getOrderings() method, which was removed in 3.0 and restored with a return type + * in 3.1. + */ + private function supportsSortDirection(): bool + { + return self::$supportsSortDirection ??= enum_exists(\SortDirection::class) + // @phpstan-ignore-next-line function.alreadyNarrowedType + && method_exists(Criteria::class, 'getOrderings') + && (new \ReflectionMethod(Criteria::class, 'getOrderings'))->hasReturnType(); + } + + /** + * Creates the keyset expression for the cursor, expanded to `(a > :a) OR (a = :a AND b > :b) OR ...`. + * + * @throws InvalidCursorException if the cursor fields do not match the sort fields + */ + private function createKeysetExpression(Cursor $cursor, bool $reverse): Expression + { + if (\count($cursor->fields) !== \count($this->sortFields) || [] !== array_diff_key($this->sortFields, $cursor->fields)) { + throw new InvalidCursorException(\sprintf('The cursor fields must match the sort fields "%s".', implode('", "', array_keys($this->sortFields)))); + } + + $expr = Criteria::expr(); + $conditions = []; + $equalities = []; + + foreach ($this->sortFields as $field => $order) { + $value = $cursor->fields[$field]; + + if (null === $value) { + throw new InvalidCursorException(\sprintf('The cursor value for the "%s" sort field must not be null.', $field)); + } + + $comparison = ('ASC' === $order) !== $reverse ? $expr->gt($field, $value) : $expr->lt($field, $value); + + $conditions[] = [] === $equalities ? $comparison : $expr->andX(...[...$equalities, $comparison]); + $equalities[] = $expr->eq($field, $value); + } + + return 1 === \count($conditions) ? $conditions[0] : $expr->orX(...$conditions); + } + + /** + * @param T $item + * + * @throws LogicException if the item has a non-scalar or null value for a sort field + */ + private function createCursor(mixed $item, Direction $direction): Cursor + { + if (!\is_array($item) && !\is_object($item)) { + throw new LogicException(\sprintf('The items must be arrays or objects to read their sort fields, "%s" given.', get_debug_type($item))); + } + + // Read the values the same way the criteria are matched, raw field access is optional in doctrine/collections 2.x and always used in 3.0 + // @phpstan-ignore-next-line function.alreadyNarrowedType + $rawFieldAccessFlag = method_exists($this->criteria, 'isRawFieldValueAccessEnabled') ? [$this->criteria->isRawFieldValueAccessEnabled()] : []; + + $fields = []; + + foreach (array_keys($this->sortFields) as $field) { + $value = ClosureExpressionVisitor::getObjectFieldValue($item, $field, ...$rawFieldAccessFlag); + + if (null === $value || !\is_scalar($value)) { + throw new LogicException(\sprintf('The "%s" sort field must have a scalar value for every item, "%s" given.', $field, get_debug_type($value))); + } + + $fields[$field] = $value; + } + + return new Cursor($fields, $direction); + } +} diff --git a/lib/Adapter/Doctrine/Collections/Tests/Item.php b/lib/Adapter/Doctrine/Collections/Tests/Item.php new file mode 100644 index 00000000..348baacd --- /dev/null +++ b/lib/Adapter/Doctrine/Collections/Tests/Item.php @@ -0,0 +1,21 @@ +id; + } + + public function getScore(): ?int + { + return $this->score; + } +} diff --git a/lib/Adapter/Doctrine/Collections/Tests/SelectableCursorAdapterTest.php b/lib/Adapter/Doctrine/Collections/Tests/SelectableCursorAdapterTest.php new file mode 100644 index 00000000..eca856a3 --- /dev/null +++ b/lib/Adapter/Doctrine/Collections/Tests/SelectableCursorAdapterTest.php @@ -0,0 +1,281 @@ +hasReturnType()) { + return \SortDirection::Descending; + } + + return enum_exists(Order::class) ? Order::Descending : 'DESC'; + } + + /** + * @param int|null ...$scores The score of each item, the IDs of the items are assigned in the order given, starting from 1 + * + * @return ArrayCollection + */ + private function createCollection(?int ...$scores): ArrayCollection + { + $items = []; + + foreach (array_values($scores) as $index => $score) { + $items[] = new Item($index + 1, $score); + } + + // Shuffle the items to ensure the adapter sorts them + shuffle($items); + + return new ArrayCollection($items); + } + + /** + * @param list $items + * + * @return list + */ + private function ids(array $items): array + { + return array_map(static fn (Item $item): int => $item->getId(), $items); + } + + /** + * @param CursorPagerfanta|CountableCursorPagerfanta $pager + * + * @return array{forward: list>, backward: list>} + */ + private function walk(CursorPagerfanta|CountableCursorPagerfanta $pager): array + { + $encoder = new Base64JsonCursorEncoder(); + + $forward = [$this->ids($pager->getCurrentPageResults())]; + + while ($pager->hasNextPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getNextPosition()->cursor)))); + $forward[] = $this->ids($pager->getCurrentPageResults()); + } + + $backward = []; + + while ($pager->hasPreviousPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getPreviousPosition()->cursor)))); + $backward[] = $this->ids($pager->getCurrentPageResults()); + } + + return ['forward' => $forward, 'backward' => $backward]; + } + + public function testAtLeastOneSortFieldIsRequired(): void + { + $this->expectException(InvalidArgumentException::class); + + new SelectableCursorAdapter(new ArrayCollection(), $this->createCriteria(), []); + } + + public function testTheSortOrderMustBeValid(): void + { + $this->expectException(InvalidArgumentException::class); + + // @phpstan-ignore-next-line argument.type + new SelectableCursorAdapter(new ArrayCollection(), $this->createCriteria(), ['id' => 'SIDEWAYS']); + } + + public function testTheAdapterSupportsBackwardNavigation(): void + { + $this->assertTrue((new SelectableCursorAdapter(new ArrayCollection(), $this->createCriteria(), ['id' => 'ASC']))->supportsBackwardNavigation()); + } + + public function testTheFirstPageIsReturnedWithoutACursor(): void + { + $slice = (new SelectableCursorAdapter($this->createCollection(10, 20, 30, 40, 50), $this->createCriteria(), ['id' => 'ASC']))->getSlice(null, 2); + + $this->assertSame([1, 2], $this->ids($slice->items)); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['id' => 2]), $slice->next); + } + + public function testThePagesAreFetchedRelativeToTheCursor(): void + { + $adapter = new SelectableCursorAdapter($this->createCollection(10, 20, 30, 40, 50), $this->createCriteria(), ['id' => 'asc']); + + $next = $adapter->getSlice(new Cursor(['id' => 2]), 2); + + $this->assertSame([3, 4], $this->ids($next->items)); + $this->assertEquals(new Cursor(['id' => 3], Direction::Previous), $next->previous); + $this->assertEquals(new Cursor(['id' => 4]), $next->next); + + $previous = $adapter->getSlice(new Cursor(['id' => 5], Direction::Previous), 2); + + $this->assertSame([3, 4], $this->ids($previous->items), 'Items are returned in the sort order'); + $this->assertEquals(new Cursor(['id' => 3], Direction::Previous), $previous->previous); + $this->assertEquals(new Cursor(['id' => 4]), $previous->next); + } + + public function testThePagesCanBeWalkedForwardAndBackward(): void + { + $pager = new CursorPagerfanta(new SelectableCursorAdapter($this->createCollection(10, 20, 30, 40, 50, 60, 70), $this->createCriteria(), ['id' => 'DESC']), 3); + + $this->assertSame( + [ + 'forward' => [[7, 6, 5], [4, 3, 2], [1]], + 'backward' => [[4, 3, 2], [7, 6, 5]], + ], + $this->walk($pager), + ); + } + + public function testAnExactMultipleOfTheLimitHasNoPhantomNextPage(): void + { + $pager = new CursorPagerfanta(new SelectableCursorAdapter($this->createCollection(10, 20, 30, 40, 50, 60), $this->createCriteria(), ['id' => 'ASC']), 3); + + $this->assertSame([[1, 2, 3], [4, 5, 6]], $this->walk($pager)['forward']); + } + + public function testAnEmptyCollectionHasNoPages(): void + { + $pager = new CursorPagerfanta(new SelectableCursorAdapter(new ArrayCollection(), $this->createCriteria(), ['id' => 'ASC']), 3); + + $this->assertSame([], $pager->getCurrentPageResults()); + $this->assertFalse($pager->haveToPaginate()); + } + + public function testTiesOnTheLeadingSortFieldAreBrokenByTheFollowingFields(): void + { + // Sorted by score descending then ID ascending: 2 (90), 1 (80), 3 (80), 5 (80), 6 (80), 4 (70) + $pager = new CursorPagerfanta(new SelectableCursorAdapter($this->createCollection(80, 90, 80, 70, 80, 80), $this->createCriteria(), ['score' => 'DESC', 'id' => 'ASC']), 2); + + $this->assertEquals(new Cursor(['score' => 80, 'id' => 1]), $pager->getNextPosition()->cursor); + + $this->assertSame( + [ + 'forward' => [[2, 1], [3, 5], [6, 4]], + 'backward' => [[3, 5], [2, 1]], + ], + $this->walk($pager), + ); + } + + public function testTheSortFieldsReplaceTheOrderingsAndTheCriteriaAreKept(): void + { + $criteria = $this->createCriteria() + ->where(Criteria::expr()->gt('score', 20)) + ->setFirstResult(1) + ->setMaxResults(1); + + $criteria->orderBy(['score' => $this->descendingOrdering()]); + + $pager = new CursorPagerfanta(new SelectableCursorAdapter($this->createCollection(10, 20, 30, 40, 50, 60, 70), $criteria, ['id' => 'ASC']), 2); + + $this->assertSame( + [ + 'forward' => [[3, 4], [5, 6], [7]], + 'backward' => [[5, 6], [3, 4]], + ], + $this->walk($pager), + ); + } + + public function testTheSortOrderCanBeGivenAsAnOrderEnum(): void + { + if (!enum_exists(Order::class)) { + $this->markTestSkipped('The Order enum requires doctrine/collections 2.2 or later.'); + } + + $pager = new CursorPagerfanta(new SelectableCursorAdapter($this->createCollection(10, 20, 30), $this->createCriteria(), ['id' => Order::Descending]), 2); + + $this->assertSame([[3, 2], [1]], $this->walk($pager)['forward']); + } + + public function testTheSortOrderCanBeGivenAsASortDirectionEnum(): void + { + if (!enum_exists(\SortDirection::class)) { + $this->markTestSkipped('The SortDirection enum requires PHP 8.6 or symfony/polyfill-php86.'); + } + + $pager = new CursorPagerfanta(new SelectableCursorAdapter($this->createCollection(10, 20, 30), $this->createCriteria(), ['id' => \SortDirection::Descending]), 2); + + $this->assertSame([[3, 2], [1]], $this->walk($pager)['forward']); + } + + public function testArraysCanBePaginatedWithASingleSortField(): void + { + // Multiple sort fields are not tested with arrays as doctrine/collections 3.x does not support arrays in composite expressions + $collection = new ArrayCollection([['id' => 3], ['id' => 1], ['id' => 2]]); + + $adapter = new SelectableCursorAdapter($collection, $this->createCriteria(), ['id' => 'ASC']); + + $first = $adapter->getSlice(null, 2); + + $this->assertSame([1, 2], array_column($first->items, 'id')); + $this->assertEquals(new Cursor(['id' => 2]), $first->next); + $this->assertSame([3], array_column($adapter->getSlice($first->next, 2)->items, 'id')); + } + + public function testANullSortFieldValueIsRejected(): void + { + $this->expectException(LogicException::class); + + (new SelectableCursorAdapter($this->createCollection(10, null), $this->createCriteria(), ['score' => 'ASC', 'id' => 'ASC']))->getSlice(null, 1); + } + + /** + * @return \Generator + */ + public static function dataInvalidCursors(): \Generator + { + yield 'unknown field' => [new Cursor(['name' => 'foo'])]; + yield 'missing field' => [new Cursor(['score' => 80])]; + yield 'extra field' => [new Cursor(['score' => 80, 'id' => 1, 'name' => 'foo'])]; + yield 'null value' => [new Cursor(['score' => null, 'id' => 1])]; + } + + #[DataProvider('dataInvalidCursors')] + public function testACursorNotMatchingTheSortFieldsIsRejected(Cursor $cursor): void + { + $this->expectException(InvalidCursorException::class); + + (new SelectableCursorAdapter($this->createCollection(80, 90), $this->createCriteria(), ['score' => 'DESC', 'id' => 'ASC']))->getSlice($cursor, 2); + } + + public function testTheResultsCanBeCountedWithAnOffsetAdapter(): void + { + $collection = $this->createCollection(10, 20, 30, 40, 50); + $criteria = $this->createCriteria()->where(Criteria::expr()->gt('score', 20)); + + $pager = CursorPagerfantaFactory::create(new CountingCursorAdapter(new SelectableCursorAdapter($collection, $criteria, ['id' => 'ASC']), new SelectableAdapter($collection, $criteria)), 2); + + $this->assertInstanceOf(CountableCursorPagerfanta::class, $pager); + $this->assertSame(3, $pager->getNbResults()); + } +} diff --git a/lib/Adapter/Doctrine/Collections/composer.json b/lib/Adapter/Doctrine/Collections/composer.json index e98712de..97bef682 100644 --- a/lib/Adapter/Doctrine/Collections/composer.json +++ b/lib/Adapter/Doctrine/Collections/composer.json @@ -7,7 +7,7 @@ "require": { "php": "^8.1", "doctrine/collections": "^1.8 || ^2.0 || ^3.0", - "pagerfanta/core": "^3.7 || ^4.0" + "pagerfanta/core": "^4.10" }, "require-dev": { "phpunit/phpunit": "^10.5" @@ -20,5 +20,10 @@ "Tests/" ] }, + "autoload-dev": { + "psr-4": { + "Pagerfanta\\Doctrine\\Collections\\Tests\\": "Tests" + } + }, "minimum-stability": "dev" } From 5bda4eef9fd6579a72af0ef4e38e67d5852a3c17 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 13:18:14 -0400 Subject: [PATCH 09/20] Add a cursor pagination adapter for Elastica --- .../Elastica/ElasticaCursorAdapter.php | 193 ++++++++++ .../Tests/ElasticaCursorAdapterTest.php | 336 ++++++++++++++++++ lib/Adapter/Elastica/composer.json | 2 +- 3 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 lib/Adapter/Elastica/ElasticaCursorAdapter.php create mode 100644 lib/Adapter/Elastica/Tests/ElasticaCursorAdapterTest.php diff --git a/lib/Adapter/Elastica/ElasticaCursorAdapter.php b/lib/Adapter/Elastica/ElasticaCursorAdapter.php new file mode 100644 index 00000000..e0d305fa --- /dev/null +++ b/lib/Adapter/Elastica/ElasticaCursorAdapter.php @@ -0,0 +1,193 @@ + + */ +class ElasticaCursorAdapter implements CursorAdapterInterface +{ + /** + * @var non-empty-array> + */ + private readonly array $sortFields; + + private ?ResultSet $resultSet = null; + + /** + * @param array> $sortFields The fields to sort the documents by, in order of precedence, mapped to their sort order or to their sort options (which must include the "order") + * @param array $options The search options, the "from" and "size" options are set by the adapter + * + * @throws InvalidArgumentException if no sort fields are given or a sort order is not valid + */ + public function __construct( + private readonly SearchableInterface $searchable, + private readonly Query $query, + array $sortFields, + private readonly array $options = [], + ) { + if ([] === $sortFields) { + throw new InvalidArgumentException('At least one sort field is required.'); + } + + $normalized = []; + + foreach ($sortFields as $field => $options) { + if (!\is_array($options)) { + $options = ['order' => $options]; + } + + $order = \is_string($options['order'] ?? null) ? strtolower($options['order']) : null; + + if ('asc' !== $order && 'desc' !== $order) { + throw new InvalidArgumentException(\sprintf('The order of the "%s" sort field must be "asc" or "desc".', $field)); + } + + $normalized[$field] = ['order' => $order] + $options; + } + + $this->sortFields = $normalized; + } + + /** + * Returns the Elastica ResultSet from the last slice. + * + * The results are in reverse sort order if the last slice was for a cursor with the previous direction. Will return + * null if getSlice has not yet been called. + */ + public function getResultSet(): ?ResultSet + { + return $this->resultSet; + } + + public function supportsBackwardNavigation(): bool + { + return true; + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + * + * @throws InvalidCursorException if the cursor fields do not match the sort fields + * @throws LogicException if a document does not have valid sort values + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + $reverse = $cursor instanceof Cursor && Direction::Previous === $cursor->direction; + + $query = clone $this->query; + $query->setSort($this->createSort($reverse)); + + // Paging with "from" cannot be combined with "search_after" + $params = $query->getParams(); + unset($params['from']); + $query->setParams($params); + + $query->setSize($limit + 1); + + if ($cursor instanceof Cursor) { + $query->setParam('search_after', $this->createSearchAfter($cursor)); + } + + $options = $this->options; + unset($options['from'], $options['size']); + + $this->resultSet = $this->searchable->search($query, $options); + + return CursorSlice::fromLookahead(array_values($this->resultSet->getResults()), $limit, $cursor, $this->createCursor(...)); + } + + /** + * @return list>> + */ + private function createSort(bool $reverse): array + { + $sort = []; + + foreach ($this->sortFields as $field => $options) { + if ($reverse) { + $options['order'] = 'asc' === $options['order'] ? 'desc' : 'asc'; + + // Documents missing the field are sorted last by default, a custom missing value is kept as-is + $missing = $options['missing'] ?? '_last'; + + if ('_last' === $missing || '_first' === $missing) { + $options['missing'] = '_last' === $missing ? '_first' : '_last'; + } + } + + $sort[] = [$field => $options]; + } + + return $sort; + } + + /** + * @return list + * + * @throws InvalidCursorException if the cursor fields do not match the sort fields + */ + private function createSearchAfter(Cursor $cursor): array + { + if (\count($cursor->fields) !== \count($this->sortFields) || [] !== array_diff_key($this->sortFields, $cursor->fields)) { + throw new InvalidCursorException(\sprintf('The cursor fields must match the sort fields "%s".', implode('", "', array_keys($this->sortFields)))); + } + + $searchAfter = []; + + foreach (array_keys($this->sortFields) as $field) { + $searchAfter[] = $cursor->fields[$field]; + } + + return $searchAfter; + } + + /** + * @throws LogicException if the document does not have a scalar or null sort value for each sort field + */ + private function createCursor(Result $result, Direction $direction): Cursor + { + $sort = $result->getHit()['sort'] ?? null; + + if (!\is_array($sort) || \count($sort) !== \count($this->sortFields)) { + throw new LogicException(\sprintf('The "%s" document must have a sort value for each sort field.', $result->getId())); + } + + $fields = []; + + foreach (array_keys($this->sortFields) as $index => $field) { + $value = $sort[$index] ?? null; + + if (null !== $value && !\is_scalar($value)) { + throw new LogicException(\sprintf('The sort value for the "%s" field of the "%s" document must be a scalar or null, "%s" given.', $field, $result->getId(), get_debug_type($value))); + } + + $fields[$field] = $value; + } + + return new Cursor($fields, $direction); + } +} diff --git a/lib/Adapter/Elastica/Tests/ElasticaCursorAdapterTest.php b/lib/Adapter/Elastica/Tests/ElasticaCursorAdapterTest.php new file mode 100644 index 00000000..2a96c881 --- /dev/null +++ b/lib/Adapter/Elastica/Tests/ElasticaCursorAdapterTest.php @@ -0,0 +1,336 @@ +, 1: array}> + */ + private array $requests = []; + + /** + * Creates a searchable which applies the sort, search_after, and size parameters of a query to the given documents. + * + * @param list> $documents + */ + private function createSearchable(array $documents): MockObject&SearchableInterface + { + $searchable = $this->createMock(SearchableInterface::class); + + $searchable->method('search') + ->willReturnCallback(function (Query $query, array $options) use ($documents): ResultSet { + $body = $query->toArray(); + $this->requests[] = [$body, $options]; + + /** @var list> $sortBody */ + $sortBody = $body['sort']; + + /** @var list $sort */ + $sort = []; + + foreach ($sortBody as $sortField) { + foreach ($sortField as $name => $sortOptions) { + $sort[] = [$name, $sortOptions['order']]; + } + } + + $sortValues = static fn (array $document): array => array_map(static fn (array $field): int|string => $document[$field[0]], $sort); + + $compare = static function (array $a, array $b) use ($sort): int { + foreach ($sort as $index => [, $order]) { + $result = $a[$index] <=> $b[$index]; + + if (0 !== $result) { + return 'desc' === $order ? -$result : $result; + } + } + + return 0; + }; + + usort($documents, static fn (array $a, array $b): int => $compare($sortValues($a), $sortValues($b))); + + if (isset($body['search_after'])) { + $documents = array_values(array_filter($documents, static fn (array $document): bool => $compare($sortValues($document), $body['search_after']) > 0)); + } + + $results = array_map( + static fn (array $document): Result => new Result(['_id' => (string) $document['id'], '_source' => $document, 'sort' => $sortValues($document)]), + \array_slice($documents, 0, $body['size']), + ); + + $resultSet = $this->createMock(ResultSet::class); + $resultSet->method('getResults')->willReturn($results); + + return $resultSet; + }); + + return $searchable; + } + + /** + * @param int|string ...$scores The score of each document, the IDs of the documents are assigned in the order given, starting from 1 + * + * @return list> + */ + private function createDocuments(int|string ...$scores): array + { + $documents = []; + + foreach (array_values($scores) as $index => $score) { + $documents[] = ['id' => $index + 1, 'score' => $score]; + } + + return $documents; + } + + /** + * @param list $results + * + * @return list + */ + private function ids(array $results): array + { + return array_map(static fn (Result $result): int => (int) $result->getId(), $results); + } + + /** + * @param CursorPagerfanta|CountableCursorPagerfanta $pager + * + * @return array{forward: list>, backward: list>} + */ + private function walk(CursorPagerfanta|CountableCursorPagerfanta $pager): array + { + $encoder = new Base64JsonCursorEncoder(); + + $forward = [$this->ids($pager->getCurrentPageResults())]; + + while ($pager->hasNextPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getNextPosition()->cursor)))); + $forward[] = $this->ids($pager->getCurrentPageResults()); + } + + $backward = []; + + while ($pager->hasPreviousPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getPreviousPosition()->cursor)))); + $backward[] = $this->ids($pager->getCurrentPageResults()); + } + + return ['forward' => $forward, 'backward' => $backward]; + } + + public function testAtLeastOneSortFieldIsRequired(): void + { + $this->expectException(InvalidArgumentException::class); + + new ElasticaCursorAdapter($this->createSearchable([]), new Query(), []); + } + + /** + * @return \Generator + */ + public static function dataInvalidSortOrders(): \Generator + { + yield 'invalid order' => ['sideways']; + yield 'options without an order' => [['missing' => '_first']]; + yield 'options with an invalid order' => [['order' => 'sideways']]; + } + + #[DataProvider('dataInvalidSortOrders')] + public function testTheSortOrderMustBeValid(mixed $order): void + { + $this->expectException(InvalidArgumentException::class); + + new ElasticaCursorAdapter($this->createSearchable([]), new Query(), ['id' => $order]); + } + + public function testTheAdapterSupportsBackwardNavigation(): void + { + $this->assertTrue((new ElasticaCursorAdapter($this->createSearchable([]), new Query(), ['id' => 'asc']))->supportsBackwardNavigation()); + } + + public function testTheFirstPageIsSearchedWithoutSearchAfter(): void + { + $query = new Query(); + $query->setFrom(20); + $query->setSort(['score' => 'desc']); + + $adapter = new ElasticaCursorAdapter($this->createSearchable($this->createDocuments(10, 20, 30)), $query, ['id' => 'ASC'], ['from' => 20, 'size' => 50, 'routing' => 'user1']); + + $slice = $adapter->getSlice(null, 2); + + $this->assertSame([1, 2], $this->ids($slice->items)); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['id' => 2]), $slice->next); + $this->assertInstanceOf(ResultSet::class, $adapter->getResultSet()); + + [$body, $options] = $this->requests[0]; + + $this->assertSame([['id' => ['order' => 'asc']]], $body['sort'], 'The sort fields replace the sort of the query'); + $this->assertSame(3, $body['size'], 'One extra document is requested to detect the next page'); + $this->assertArrayNotHasKey('from', $body); + $this->assertArrayNotHasKey('search_after', $body); + $this->assertSame(['routing' => 'user1'], $options, 'The other search options are kept'); + + $this->assertSame(20, $query->getParam('from'), 'The original query is not modified'); + } + + public function testThePagesAreSearchedRelativeToTheCursor(): void + { + $adapter = new ElasticaCursorAdapter($this->createSearchable($this->createDocuments(10, 20, 30, 40, 50)), new Query(), ['id' => 'asc']); + + $next = $adapter->getSlice(new Cursor(['id' => 2]), 2); + + $this->assertSame([3, 4], $this->ids($next->items)); + $this->assertEquals(new Cursor(['id' => 3], Direction::Previous), $next->previous); + $this->assertEquals(new Cursor(['id' => 4]), $next->next); + $this->assertSame([2], $this->requests[0][0]['search_after']); + + $previous = $adapter->getSlice(new Cursor(['id' => 5], Direction::Previous), 2); + + $this->assertSame([3, 4], $this->ids($previous->items), 'Documents are returned in the sort order'); + $this->assertEquals(new Cursor(['id' => 3], Direction::Previous), $previous->previous); + $this->assertEquals(new Cursor(['id' => 4]), $previous->next); + $this->assertSame([['id' => ['order' => 'desc', 'missing' => '_first']]], $this->requests[1][0]['sort'], 'The sort is reversed for the previous page'); + $this->assertSame([5], $this->requests[1][0]['search_after']); + } + + public function testThePagesCanBeWalkedForwardAndBackward(): void + { + $pager = new CursorPagerfanta(new ElasticaCursorAdapter($this->createSearchable($this->createDocuments(10, 20, 30, 40, 50, 60, 70)), new Query(), ['id' => 'desc']), 3); + + $this->assertSame( + [ + 'forward' => [[7, 6, 5], [4, 3, 2], [1]], + 'backward' => [[4, 3, 2], [7, 6, 5]], + ], + $this->walk($pager), + ); + } + + public function testAnExactMultipleOfTheLimitHasNoPhantomNextPage(): void + { + $pager = new CursorPagerfanta(new ElasticaCursorAdapter($this->createSearchable($this->createDocuments(10, 20, 30, 40, 50, 60)), new Query(), ['id' => 'asc']), 3); + + $this->assertSame([[1, 2, 3], [4, 5, 6]], $this->walk($pager)['forward']); + } + + public function testAnEmptyResultHasNoPages(): void + { + $pager = new CursorPagerfanta(new ElasticaCursorAdapter($this->createSearchable([]), new Query(), ['id' => 'asc']), 3); + + $this->assertSame([], $pager->getCurrentPageResults()); + $this->assertFalse($pager->haveToPaginate()); + } + + public function testTiesOnTheLeadingSortFieldAreBrokenByTheFollowingFields(): void + { + // Sorted by score descending then ID ascending: 2 (90), 1 (80), 3 (80), 5 (80), 6 (80), 4 (70) + $pager = new CursorPagerfanta(new ElasticaCursorAdapter($this->createSearchable($this->createDocuments(80, 90, 80, 70, 80, 80)), new Query(), ['score' => 'desc', 'id' => 'asc']), 2); + + $this->assertEquals(new Cursor(['score' => 80, 'id' => 1]), $pager->getNextPosition()->cursor); + + $this->assertSame( + [ + 'forward' => [[2, 1], [3, 5], [6, 4]], + 'backward' => [[3, 5], [2, 1]], + ], + $this->walk($pager), + ); + } + + public function testTheSortOptionsAreKeptAndTheMissingOptionIsReversedForThePreviousPage(): void + { + $adapter = new ElasticaCursorAdapter( + $this->createSearchable([]), + new Query(), + [ + 'published_at' => ['order' => 'desc', 'format' => 'strict_date_optional_time_nanos'], + 'score' => ['order' => 'asc', 'missing' => '_first'], + 'rating' => ['order' => 'asc', 'missing' => 0], + 'id' => 'asc', + ], + ); + + $adapter->getSlice(new Cursor(['published_at' => '2026-01-01T00:00:00Z', 'score' => 10, 'rating' => 1, 'id' => 1], Direction::Previous), 2); + + $this->assertSame( + [ + ['published_at' => ['order' => 'asc', 'format' => 'strict_date_optional_time_nanos', 'missing' => '_first']], + ['score' => ['order' => 'desc', 'missing' => '_last']], + ['rating' => ['order' => 'desc', 'missing' => 0]], + ['id' => ['order' => 'desc', 'missing' => '_first']], + ], + $this->requests[0][0]['sort'], + ); + $this->assertSame(['2026-01-01T00:00:00Z', 10, 1, 1], $this->requests[0][0]['search_after'], 'The search_after values follow the order of the sort fields'); + } + + /** + * @return \Generator + */ + public static function dataInvalidCursors(): \Generator + { + yield 'unknown field' => [new Cursor(['name' => 'foo'])]; + yield 'missing field' => [new Cursor(['score' => 80])]; + yield 'extra field' => [new Cursor(['score' => 80, 'id' => 1, 'name' => 'foo'])]; + } + + #[DataProvider('dataInvalidCursors')] + public function testACursorNotMatchingTheSortFieldsIsRejected(Cursor $cursor): void + { + $this->expectException(InvalidCursorException::class); + + (new ElasticaCursorAdapter($this->createSearchable([]), new Query(), ['score' => 'desc', 'id' => 'asc']))->getSlice($cursor, 2); + } + + public function testADocumentWithoutSortValuesIsRejected(): void + { + $this->expectException(LogicException::class); + + $resultSet = $this->createMock(ResultSet::class); + $resultSet->method('getResults')->willReturn([new Result(['_id' => '1', '_source' => []]), new Result(['_id' => '2', '_source' => []])]); + + $searchable = $this->createMock(SearchableInterface::class); + $searchable->method('search')->willReturn($resultSet); + + (new ElasticaCursorAdapter($searchable, new Query(), ['id' => 'asc']))->getSlice(null, 1); + } + + public function testTheResultsCanBeCountedWithAnOffsetAdapter(): void + { + $query = new Query(); + $searchable = $this->createSearchable($this->createDocuments(10, 20, 30)); + $searchable->method('count')->willReturn(3); + + $pager = CursorPagerfantaFactory::create(new CountingCursorAdapter(new ElasticaCursorAdapter($searchable, $query, ['id' => 'asc']), new ElasticaAdapter($searchable, $query)), 2); + + $this->assertInstanceOf(CountableCursorPagerfanta::class, $pager); + $this->assertSame(3, $pager->getNbResults()); + } +} diff --git a/lib/Adapter/Elastica/composer.json b/lib/Adapter/Elastica/composer.json index 90a71f38..219c0af3 100644 --- a/lib/Adapter/Elastica/composer.json +++ b/lib/Adapter/Elastica/composer.json @@ -6,7 +6,7 @@ "license": "MIT", "require": { "php": "^8.1", - "pagerfanta/core": "^4.0", + "pagerfanta/core": "^4.10", "ruflin/elastica": "^7.3 || ^8.0 || ^9.0" }, "require-dev": { From ab332c4c09019f81f4ac9e214ed98522d1e9b195 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 13:25:40 -0400 Subject: [PATCH 10/20] Add a cursor pagination adapter for Solarium --- .../Solarium/SolariumCursorAdapter.php | 130 ++++++++++ .../Tests/SolariumCursorAdapterTest.php | 225 ++++++++++++++++++ lib/Adapter/Solarium/composer.json | 2 +- 3 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 lib/Adapter/Solarium/SolariumCursorAdapter.php create mode 100644 lib/Adapter/Solarium/Tests/SolariumCursorAdapterTest.php diff --git a/lib/Adapter/Solarium/SolariumCursorAdapter.php b/lib/Adapter/Solarium/SolariumCursorAdapter.php new file mode 100644 index 00000000..dd300519 --- /dev/null +++ b/lib/Adapter/Solarium/SolariumCursorAdapter.php @@ -0,0 +1,130 @@ + + */ +class SolariumCursorAdapter implements CursorAdapterInterface +{ + public const FIELD_CURSOR_MARK = 'cursorMark'; + public const FIELD_OFFSET = 'offset'; + + private const INITIAL_CURSOR_MARK = '*'; + + private ?Result $resultSet = null; + + private Endpoint|string|null $endpoint = null; + + public function __construct( + private readonly ClientInterface $client, + private readonly Query $query, + ) {} + + /** + * Returns the Solarium Result from the last slice. + * + * Will return null if getSlice has not yet been called. + */ + public function getResultSet(): ?Result + { + return $this->resultSet; + } + + public function setEndpoint(Endpoint|string|null $endpoint): static + { + $this->endpoint = $endpoint; + + return $this; + } + + public function supportsBackwardNavigation(): bool + { + return false; + } + + /** + * @param positive-int $limit + * + * @return CursorSlice + * + * @throws InvalidCursorException if the cursor is not valid for this adapter + * @throws LogicException if Solr does not return a next cursor mark + */ + public function getSlice(?Cursor $cursor, int $limit): CursorSlice + { + [$cursorMark, $offset] = $cursor instanceof Cursor ? $this->readCursor($cursor) : [self::INITIAL_CURSOR_MARK, 0]; + + $query = clone $this->query; + $query->setStart(0) + ->setRows($limit) + ->setCursorMark($cursorMark); + + $this->resultSet = $result = $this->client->select($query, $this->endpoint); + + $documents = array_values($result->getDocuments()); + + if ([] === $documents) { + return new CursorSlice([]); + } + + $nextCursorMark = $result->getNextCursorMark(); + + if (null === $nextCursorMark) { + throw new LogicException('Solr did not return a next cursor mark, ensure the query is sorted and its sort includes the uniqueKey field.'); + } + + $nextOffset = $offset + \count($documents); + $numFound = $result->getNumFound(); + + // Solr returns the same cursor mark when there are no more documents, the number found avoids requesting an empty page + $hasNext = $nextCursorMark !== $cursorMark && (null === $numFound ? \count($documents) === $limit : $nextOffset < $numFound); + + return new CursorSlice( + $documents, + null, + $hasNext ? new Cursor([self::FIELD_CURSOR_MARK => $nextCursorMark, self::FIELD_OFFSET => $nextOffset]) : null, + ); + } + + /** + * @return array{0: non-empty-string, 1: int<0, max>} + * + * @throws InvalidCursorException if the cursor is not valid for this adapter + */ + private function readCursor(Cursor $cursor): array + { + if (Direction::Next !== $cursor->direction) { + throw new InvalidCursorException('Solr cursors do not support backward navigation.'); + } + + $cursorMark = $cursor->fields[self::FIELD_CURSOR_MARK] ?? null; + $offset = $cursor->fields[self::FIELD_OFFSET] ?? null; + + if (2 !== \count($cursor->fields) || !\is_string($cursorMark) || '' === $cursorMark || !\is_int($offset) || $offset < 0) { + throw new InvalidCursorException(\sprintf('The cursor must have a "%s" string field and a non-negative "%s" integer field.', self::FIELD_CURSOR_MARK, self::FIELD_OFFSET)); + } + + return [$cursorMark, $offset]; + } +} diff --git a/lib/Adapter/Solarium/Tests/SolariumCursorAdapterTest.php b/lib/Adapter/Solarium/Tests/SolariumCursorAdapterTest.php new file mode 100644 index 00000000..136c46b1 --- /dev/null +++ b/lib/Adapter/Solarium/Tests/SolariumCursorAdapterTest.php @@ -0,0 +1,225 @@ + + */ + private array $queries = []; + + /** + * Creates a client which simulates Solr's cursorMark behavior for documents with the given IDs. + * + * The cursor mark encodes the number of documents before it, and the same mark is returned when there are no more documents. + * + * @param bool $reportNumFound Whether the number of documents found is reported + */ + private function createClient(int $count, bool $reportNumFound = true): MockObject&ClientInterface + { + $client = $this->createMock(ClientInterface::class); + + $client->method('select') + ->willReturnCallback(function (Query $query) use ($count, $reportNumFound): Result { + $this->queries[] = $query; + + $cursorMark = (string) $query->getCursorMark(); + $start = '*' === $cursorMark ? 0 : (int) substr($cursorMark, \strlen('mark-')); + $end = min($start + (int) $query->getRows(), $count); + + $documents = []; + + for ($id = $start + 1; $id <= $end; ++$id) { + $documents[] = new Document(['id' => $id]); + } + + $result = $this->createMock(Result::class); + $result->method('getDocuments')->willReturn($documents); + $result->method('getNextCursorMark')->willReturn($end === $start ? $cursorMark : 'mark-'.$end); + $result->method('getNumFound')->willReturn($reportNumFound ? $count : null); + + return $result; + }); + + return $client; + } + + /** + * @param list $documents + * + * @return list + */ + private function ids(array $documents): array + { + return array_map(static fn (DocumentInterface $document): int => $document->getFields()['id'], $documents); + } + + /** + * @param CursorPagerfanta|CountableCursorPagerfanta $pager + * + * @return list> + */ + private function walkForward(CursorPagerfanta|CountableCursorPagerfanta $pager): array + { + $encoder = new Base64JsonCursorEncoder(); + + $pages = [$this->ids($pager->getCurrentPageResults())]; + + while ($pager->hasNextPage()) { + $pager = $pager->withPosition(new CursorPosition($encoder->decode($encoder->encode($pager->getNextPosition()->cursor)))); + $pages[] = $this->ids($pager->getCurrentPageResults()); + } + + $this->assertFalse($pager->hasPreviousPage(), 'A forward-only adapter never has a previous page'); + + return $pages; + } + + public function testTheAdapterDoesNotSupportBackwardNavigation(): void + { + $this->assertFalse((new SolariumCursorAdapter($this->createClient(0), new Query()))->supportsBackwardNavigation()); + } + + public function testTheFirstPageIsSelectedWithTheInitialCursorMark(): void + { + $query = new Query(); + $query->setStart(20); + + $adapter = new SolariumCursorAdapter($this->createClient(5), $query); + + $slice = $adapter->getSlice(null, 2); + + $this->assertSame([1, 2], $this->ids($slice->items)); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['cursorMark' => 'mark-2', 'offset' => 2]), $slice->next); + $this->assertInstanceOf(Result::class, $adapter->getResultSet()); + + $this->assertSame('*', $this->queries[0]->getCursorMark()); + $this->assertSame(0, $this->queries[0]->getStart(), 'Solr cursors require starting from the first row'); + $this->assertSame(2, $this->queries[0]->getRows()); + + $this->assertSame(20, $query->getStart(), 'The original query is not modified'); + $this->assertNull($query->getCursorMark()); + } + + public function testTheNextPageIsSelectedWithTheCursorMark(): void + { + $slice = (new SolariumCursorAdapter($this->createClient(5), new Query()))->getSlice(new Cursor(['cursorMark' => 'mark-2', 'offset' => 2]), 2); + + $this->assertSame([3, 4], $this->ids($slice->items)); + $this->assertNull($slice->previous); + $this->assertEquals(new Cursor(['cursorMark' => 'mark-4', 'offset' => 4]), $slice->next); + $this->assertSame('mark-2', $this->queries[0]->getCursorMark()); + } + + public function testThePagesCanBeWalkedForward(): void + { + $this->assertSame([[1, 2, 3], [4, 5, 6], [7]], $this->walkForward(new CursorPagerfanta(new SolariumCursorAdapter($this->createClient(7), new Query()), 3))); + } + + public function testAnExactMultipleOfTheLimitHasNoPhantomNextPage(): void + { + $this->assertSame([[1, 2, 3], [4, 5, 6]], $this->walkForward(new CursorPagerfanta(new SolariumCursorAdapter($this->createClient(6), new Query()), 3))); + $this->assertCount(2, $this->queries, 'No request is made for an empty page'); + } + + public function testTheCursorMarkIsUsedWhenTheNumberFoundIsNotReported(): void + { + // Without the number found, an exact multiple of the limit can only be detected by requesting the next page + $this->assertSame([[1, 2, 3], [4, 5, 6], []], $this->walkForward(new CursorPagerfanta(new SolariumCursorAdapter($this->createClient(6, false), new Query()), 3))); + } + + public function testAnEmptyResultHasNoPages(): void + { + $pager = new CursorPagerfanta(new SolariumCursorAdapter($this->createClient(0), new Query()), 3); + + $this->assertSame([], $pager->getCurrentPageResults()); + $this->assertFalse($pager->haveToPaginate()); + } + + public function testAMissingNextCursorMarkIsRejected(): void + { + $this->expectException(LogicException::class); + + $result = $this->createMock(Result::class); + $result->method('getDocuments')->willReturn([new Document(['id' => 1])]); + $result->method('getNextCursorMark')->willReturn(null); + + $client = $this->createMock(ClientInterface::class); + $client->method('select')->willReturn($result); + + (new SolariumCursorAdapter($client, new Query()))->getSlice(null, 1); + } + + /** + * @return \Generator + */ + public static function dataInvalidCursors(): \Generator + { + yield 'previous direction' => [new Cursor(['cursorMark' => 'mark-2', 'offset' => 2], Direction::Previous)]; + yield 'missing cursor mark' => [new Cursor(['offset' => 2])]; + yield 'missing offset' => [new Cursor(['cursorMark' => 'mark-2'])]; + yield 'empty cursor mark' => [new Cursor(['cursorMark' => '', 'offset' => 2])]; + yield 'cursor mark not a string' => [new Cursor(['cursorMark' => 2, 'offset' => 2])]; + yield 'offset not an integer' => [new Cursor(['cursorMark' => 'mark-2', 'offset' => '2'])]; + yield 'negative offset' => [new Cursor(['cursorMark' => 'mark-2', 'offset' => -1])]; + yield 'extra field' => [new Cursor(['cursorMark' => 'mark-2', 'offset' => 2, 'id' => 2])]; + } + + #[DataProvider('dataInvalidCursors')] + public function testAnInvalidCursorIsRejected(Cursor $cursor): void + { + $this->expectException(InvalidCursorException::class); + + (new SolariumCursorAdapter($this->createClient(5), new Query()))->getSlice($cursor, 2); + } + + public function testTheEndpointIsPassedToTheClient(): void + { + $result = $this->createMock(Result::class); + $result->method('getDocuments')->willReturn([]); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('select') + ->with($this->isInstanceOf(Query::class), 'replica') + ->willReturn($result); + + (new SolariumCursorAdapter($client, new Query()))->setEndpoint('replica')->getSlice(null, 2); + } + + public function testTheResultsCanBeCountedWithAnOffsetAdapter(): void + { + $query = new Query(); + $client = $this->createClient(5); + + $pager = CursorPagerfantaFactory::create(new CountingCursorAdapter(new SolariumCursorAdapter($client, $query), new SolariumAdapter($client, $query)), 2); + + $this->assertInstanceOf(CountableCursorPagerfanta::class, $pager); + $this->assertSame(5, $pager->getNbResults()); + } +} diff --git a/lib/Adapter/Solarium/composer.json b/lib/Adapter/Solarium/composer.json index 7491976b..e67d3101 100644 --- a/lib/Adapter/Solarium/composer.json +++ b/lib/Adapter/Solarium/composer.json @@ -6,7 +6,7 @@ "license": "MIT", "require": { "php": "^8.1", - "pagerfanta/core": "^3.7 || ^4.0", + "pagerfanta/core": "^4.10", "solarium/solarium": "^6.2" }, "require-dev": { From 3d47f52c4f1ce9b4f26a6fe9c8a134932dca194a Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 13:44:58 -0400 Subject: [PATCH 11/20] Add a sequential view and position based templates --- .../PositionRouteGeneratorDecorator.php | 39 +++ ...PositionRouteGeneratorFactoryInterface.php | 15 + .../PositionRouteGeneratorDecoratorTest.php | 19 ++ lib/Core/Tests/View/SequentialViewTest.php | 265 ++++++++++++++++++ lib/Core/View/PagerViewInterface.php | 37 +++ lib/Core/View/SequentialView.php | 85 ++++++ lib/Core/View/Template/DefaultTemplate.php | 19 +- .../View/Template/Foundation6Template.php | 14 +- lib/Core/View/Template/SemanticUiTemplate.php | 14 +- .../Template/SequentialTemplateInterface.php | 51 ++++ lib/Core/View/Template/Template.php | 28 ++ .../Template/TwitterBootstrapTemplate.php | 14 +- 12 files changed, 595 insertions(+), 5 deletions(-) create mode 100644 lib/Core/RouteGenerator/PositionRouteGeneratorDecorator.php create mode 100644 lib/Core/RouteGenerator/PositionRouteGeneratorFactoryInterface.php create mode 100644 lib/Core/Tests/RouteGenerator/PositionRouteGeneratorDecoratorTest.php create mode 100644 lib/Core/Tests/View/SequentialViewTest.php create mode 100644 lib/Core/View/PagerViewInterface.php create mode 100644 lib/Core/View/SequentialView.php create mode 100644 lib/Core/View/Template/SequentialTemplateInterface.php diff --git a/lib/Core/RouteGenerator/PositionRouteGeneratorDecorator.php b/lib/Core/RouteGenerator/PositionRouteGeneratorDecorator.php new file mode 100644 index 00000000..6a93ee4a --- /dev/null +++ b/lib/Core/RouteGenerator/PositionRouteGeneratorDecorator.php @@ -0,0 +1,39 @@ +decorated = $decorated; + } + + public function __invoke(Position $position): string + { + return $this->route($position); + } + + public function route(Position $position): string + { + $decorated = $this->decorated; + + return $decorated($position); + } +} diff --git a/lib/Core/RouteGenerator/PositionRouteGeneratorFactoryInterface.php b/lib/Core/RouteGenerator/PositionRouteGeneratorFactoryInterface.php new file mode 100644 index 00000000..2647ca47 --- /dev/null +++ b/lib/Core/RouteGenerator/PositionRouteGeneratorFactoryInterface.php @@ -0,0 +1,15 @@ + $options + * + * @throws RuntimeException if the route generator cannot be created + */ + public function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface; +} diff --git a/lib/Core/Tests/RouteGenerator/PositionRouteGeneratorDecoratorTest.php b/lib/Core/Tests/RouteGenerator/PositionRouteGeneratorDecoratorTest.php new file mode 100644 index 00000000..1cdeace8 --- /dev/null +++ b/lib/Core/Tests/RouteGenerator/PositionRouteGeneratorDecoratorTest.php @@ -0,0 +1,19 @@ + $position instanceof PagePosition ? '/posts?page='.$position->page : '/posts'); + + $this->assertSame('/posts?page=2', $generator(new PagePosition(2))); + $this->assertSame('/posts?page=3', $generator->route(new PagePosition(3))); + } +} diff --git a/lib/Core/Tests/View/SequentialViewTest.php b/lib/Core/Tests/View/SequentialViewTest.php new file mode 100644 index 00000000..742bae10 --- /dev/null +++ b/lib/Core/Tests/View/SequentialViewTest.php @@ -0,0 +1,265 @@ + + */ + public static function dataThemes(): \Generator + { + yield 'default' => [ + static fn (): SequentialTemplateInterface => new DefaultTemplate(), + '', + '', + '', + 'Previous', + 'Next', + ]; + + yield 'twitter bootstrap' => [ + static fn (): SequentialTemplateInterface => new TwitterBootstrapTemplate(), + '', + '
  • ', + '
  • ', + '
  • Previous
  • ', + '
  • Next
  • ', + ]; + + yield 'twitter bootstrap 3' => [ + static fn (): SequentialTemplateInterface => new TwitterBootstrap3Template(), + '
      %s
    ', + '
  • ', + '
  • ', + '
  • Previous
  • ', + '
  • Next
  • ', + ]; + + yield 'twitter bootstrap 4' => [ + static fn (): SequentialTemplateInterface => new TwitterBootstrap4Template(), + '
      %s
    ', + '
  • ', + '
  • ', + '
  • Previous
  • ', + '
  • Next
  • ', + ]; + + yield 'twitter bootstrap 5' => [ + static fn (): SequentialTemplateInterface => new TwitterBootstrap5Template(), + '
      %s
    ', + '
  • ', + '
  • ', + '
  • Previous
  • ', + '
  • Next
  • ', + ]; + + yield 'foundation 6' => [ + static fn (): SequentialTemplateInterface => new Foundation6Template(), + '', + '
  • ', + '
  • ', + '
  • Previous
  • ', + '
  • Next
  • ', + ]; + + yield 'semantic ui' => [ + static fn (): SequentialTemplateInterface => new SemanticUiTemplate(), + '', + '', + '', + '
    Previous
    ', + '
    Next
    ', + ]; + } + + /** + * @return Pagerfanta + */ + private function createOffsetPager(int $nbResults, int $currentPage): Pagerfanta + { + return Pagerfanta::createForCurrentPageWithMaxPerPage(new ArrayAdapter($nbResults > 0 ? range(1, $nbResults) : []), $currentPage, 10); + } + + /** + * Creates a cursor pager whose slice links to the items with IDs 1 (previous) and 3 (next), as enabled. + * + * @return CursorPagerfanta + */ + private function createCursorPager(bool $supportsBackwardNavigation, bool $hasPrevious = true, bool $hasNext = true): CursorPagerfanta + { + $adapter = new CallbackCursorAdapter( + static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice( + [2], + $hasPrevious ? new Cursor(['id' => 1], Direction::Previous) : null, + $hasNext ? new Cursor(['id' => 3]) : null, + ), + $supportsBackwardNavigation, + ); + + return new CursorPagerfanta($adapter, 1, new CursorPosition(new Cursor(['id' => 1]))); + } + + private function createPositionRouteGenerator(): PositionRouteGeneratorDecorator + { + return new PositionRouteGeneratorDecorator(static fn (Position $position): string => match (true) { + $position instanceof CursorPosition => \sprintf('|%s:%s|', $position->cursor->direction->name, $position->cursor->fields['id']), + $position instanceof PagePosition => \sprintf('|page:%d|', $position->page), + default => throw new \UnexpectedValueException('Unexpected position'), + }); + } + + /** + * @param \Closure(): SequentialTemplateInterface $template + */ + #[DataProvider('dataThemes')] + public function testAnOffsetPagerIsRenderedWithAPageRouteGenerator(\Closure $template, string $container, string $previous, string $next): void + { + $this->assertSame( + \sprintf($container, \sprintf($previous, '|1|').\sprintf($next, '|3|')), + (new SequentialView($template()))->render($this->createOffsetPager(30, 2), static fn (int $page): string => '|'.$page.'|'), + ); + } + + /** + * @param \Closure(): SequentialTemplateInterface $template + */ + #[DataProvider('dataThemes')] + public function testASinglePageRendersDisabledLinks(\Closure $template, string $container, string $previous, string $next, string $previousDisabled, string $nextDisabled): void + { + $this->assertSame( + \sprintf($container, $previousDisabled.$nextDisabled), + (new SequentialView($template()))->render($this->createOffsetPager(5, 1), static fn (int $page): string => '|'.$page.'|'), + ); + } + + /** + * @param \Closure(): SequentialTemplateInterface $template + */ + #[DataProvider('dataThemes')] + public function testACursorPagerIsRenderedWithAPositionRouteGenerator(\Closure $template, string $container, string $previous, string $next): void + { + $this->assertSame( + \sprintf($container, \sprintf($previous, '|Previous:1|').\sprintf($next, '|Next:3|')), + (new SequentialView($template()))->render($this->createCursorPager(true), $this->createPositionRouteGenerator()), + ); + } + + /** + * @param \Closure(): SequentialTemplateInterface $template + */ + #[DataProvider('dataThemes')] + public function testAForwardOnlyCursorPagerIsRenderedWithoutAPreviousLink(\Closure $template, string $container, string $previous, string $next): void + { + $this->assertSame( + \sprintf($container, \sprintf($next, '|Next:3|')), + (new SequentialView($template()))->render($this->createCursorPager(false), $this->createPositionRouteGenerator()), + ); + } + + /** + * @param \Closure(): SequentialTemplateInterface $template + */ + #[DataProvider('dataThemes')] + public function testTheFirstAndLastCursorPagesRenderDisabledLinks(\Closure $template, string $container, string $previous, string $next, string $previousDisabled, string $nextDisabled): void + { + $view = new SequentialView($template()); + + $this->assertSame(\sprintf($container, $previousDisabled.\sprintf($next, '|Next:3|')), $view->render($this->createCursorPager(true, false), $this->createPositionRouteGenerator())); + $this->assertSame(\sprintf($container, \sprintf($previous, '|Previous:1|').$nextDisabled), $view->render($this->createCursorPager(true, true, false), $this->createPositionRouteGenerator())); + } + + public function testThePositionRouteGeneratorIsUsedForAnOffsetPager(): void + { + $this->assertSame( + '', + (new SequentialView(new DefaultTemplate()))->render($this->createOffsetPager(30, 2), $this->createPositionRouteGenerator()), + ); + } + + public function testAPageRouteGeneratorCannotRenderACursorPager(): void + { + $this->expectException(InvalidArgumentException::class); + + (new SequentialView(new DefaultTemplate()))->render($this->createCursorPager(true), static fn (int $page): string => '|'.$page.'|'); + } + + public function testAPagerOnlyImplementingTheLegacyInterfaceIsRenderedWithPagePositions(): void + { + $pager = $this->createMock(PagerfantaInterface::class); + $pager->method('hasPreviousPage')->willReturn(true); + $pager->method('getPreviousPage')->willReturn(4); + $pager->method('hasNextPage')->willReturn(true); + $pager->method('getNextPage')->willReturn(6); + + $this->assertSame( + '', + (new SequentialView(new DefaultTemplate()))->render($pager, static fn (int $page): string => '|'.$page.'|'), + ); + } + + public function testTheOptionsArePassedToTheTemplate(): void + { + $this->assertSame( + '', + (new SequentialView(new DefaultTemplate()))->render($this->createOffsetPager(30, 2), static fn (int $page): string => '|'.$page.'|', ['prev_message' => 'Newer', 'next_message' => 'Older']), + ); + } + + public function testATemplateSharedWithANumberedViewKeepsItsRouteGenerators(): void + { + $template = new DefaultTemplate(); + + (new SequentialView($template))->render($this->createCursorPager(true), $this->createPositionRouteGenerator()); + + $this->assertSame( + '', + (new DefaultView($template))->render($this->createOffsetPager(30, 2), static fn (int $page): string => '|'.$page.'|'), + 'The numbered view uses its own page route generator', + ); + } + + public function testTheViewSupportsEveryPagerAndHasAConfigurableName(): void + { + $this->assertTrue((new SequentialView(new DefaultTemplate()))->supports($this->createCursorPager(true))); + $this->assertTrue((new SequentialView(new DefaultTemplate()))->supports($this->createOffsetPager(5, 1))); + $this->assertSame('sequential', (new SequentialView(new DefaultTemplate()))->getName()); + $this->assertSame('twitter_bootstrap5_sequential', (new SequentialView(new TwitterBootstrap5Template(), 'twitter_bootstrap5_sequential'))->getName()); + } + + public function testATemplateCannotRenderAPositionLinkWithoutAPositionRouteGenerator(): void + { + $this->expectException(RuntimeException::class); + + (new DefaultTemplate())->nextEnabledForPosition(new PagePosition(2)); + } +} diff --git a/lib/Core/View/PagerViewInterface.php b/lib/Core/View/PagerViewInterface.php new file mode 100644 index 00000000..f0c38b41 --- /dev/null +++ b/lib/Core/View/PagerViewInterface.php @@ -0,0 +1,37 @@ +|PagerInterface $pager + * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator + * @param array $options + * + * @throws InvalidArgumentException if the pager is not supported by this view + */ + public function render(PagerfantaInterface|PagerInterface $pager, callable $routeGenerator, array $options = []): string; + + /** + * Checks whether this view can render the given pager. + * + * @param PagerfantaInterface|PagerInterface $pager + */ + public function supports(PagerfantaInterface|PagerInterface $pager): bool; +} diff --git a/lib/Core/View/SequentialView.php b/lib/Core/View/SequentialView.php new file mode 100644 index 00000000..56037a23 --- /dev/null +++ b/lib/Core/View/SequentialView.php @@ -0,0 +1,85 @@ +name; + } + + /** + * @param PagerfantaInterface|PagerInterface $pager + */ + public function supports(PagerfantaInterface|PagerInterface $pager): bool + { + return true; + } + + /** + * @param PagerfantaInterface|PagerInterface $pager + * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator + * @param array $options + */ + public function render(PagerfantaInterface|PagerInterface $pager, callable $routeGenerator, array $options = []): string + { + $this->template->setPositionRouteGenerator(PageRouteGeneratorWrapper::wrap($routeGenerator)); + $this->template->setOptions($options); + + return str_replace('%pages%', $this->previous($pager).$this->next($pager), $this->template->container()); + } + + /** + * @param PagerfantaInterface|PagerInterface $pager + */ + private function previous(PagerfantaInterface|PagerInterface $pager): string + { + if ($pager instanceof CursorPagerInterface && !$pager->supportsBackwardNavigation()) { + return ''; + } + + if (!$pager->hasPreviousPage()) { + return $this->template->previousDisabled(); + } + + // Implementations of PagerfantaInterface are not required to implement the position API until 5.0 + $position = $pager instanceof PagerInterface ? $pager->getPreviousPosition() : new PagePosition($pager->getPreviousPage()); + + return $this->template->previousEnabledForPosition($position); + } + + /** + * @param PagerfantaInterface|PagerInterface $pager + */ + private function next(PagerfantaInterface|PagerInterface $pager): string + { + if (!$pager->hasNextPage()) { + return $this->template->nextDisabled(); + } + + $position = $pager instanceof PagerInterface ? $pager->getNextPosition() : new PagePosition($pager->getNextPage()); + + return $this->template->nextEnabledForPosition($position); + } +} diff --git a/lib/Core/View/Template/DefaultTemplate.php b/lib/Core/View/Template/DefaultTemplate.php index 6a96f9ac..97135008 100644 --- a/lib/Core/View/Template/DefaultTemplate.php +++ b/lib/Core/View/Template/DefaultTemplate.php @@ -2,7 +2,9 @@ namespace Pagerfanta\View\Template; -class DefaultTemplate extends Template +use Pagerfanta\Position\Position; + +class DefaultTemplate extends Template implements SequentialTemplateInterface { /** * @return array @@ -49,8 +51,11 @@ public function pageWithText(int $page, string $text, ?string $rel = null): stri private function pageWithTextAndClass(int $page, string $text, string $class, ?string $rel = null): string { - $href = $this->generateRoute($page); + return $this->linkWithTextAndClass($this->generateRoute($page), $text, $class, $rel); + } + private function linkWithTextAndClass(string $href, string $text, string $class, ?string $rel = null): string + { $replace = [ trim($this->option('css_item_class').' '.$class), $href, @@ -83,6 +88,11 @@ public function previousEnabled(int $page): string return $this->pageWithTextAndClass($page, $this->option('prev_message'), $this->option('css_prev_class'), $this->option('rel_previous')); } + public function previousEnabledForPosition(Position $position): string + { + return $this->linkWithTextAndClass($this->generateRouteForPosition($position), $this->option('prev_message'), $this->option('css_prev_class'), $this->option('rel_previous')); + } + public function nextDisabled(): string { $class = trim( @@ -104,6 +114,11 @@ public function nextEnabled(int $page): string return $this->pageWithTextAndClass($page, $this->option('next_message'), $this->option('css_next_class'), $this->option('rel_next')); } + public function nextEnabledForPosition(Position $position): string + { + return $this->linkWithTextAndClass($this->generateRouteForPosition($position), $this->option('next_message'), $this->option('css_next_class'), $this->option('rel_next')); + } + public function first(): string { return $this->page(1); diff --git a/lib/Core/View/Template/Foundation6Template.php b/lib/Core/View/Template/Foundation6Template.php index 9d0046cf..1df3c777 100644 --- a/lib/Core/View/Template/Foundation6Template.php +++ b/lib/Core/View/Template/Foundation6Template.php @@ -2,7 +2,9 @@ namespace Pagerfanta\View\Template; -class Foundation6Template extends Template +use Pagerfanta\Position\Position; + +class Foundation6Template extends Template implements SequentialTemplateInterface { /** * @return array @@ -65,6 +67,11 @@ public function previousEnabled(int $page): string return $this->pageWithTextAndClass($page, $this->option('prev_message'), $this->option('css_prev_class'), $this->option('rel_previous')); } + public function previousEnabledForPosition(Position $position): string + { + return $this->linkLi($this->option('css_prev_class'), $this->generateRouteForPosition($position), $this->option('prev_message'), $this->option('rel_previous')); + } + public function nextDisabled(): string { return $this->li($this->nextDisabledClass(), $this->option('next_message')); @@ -80,6 +87,11 @@ public function nextEnabled(int $page): string return $this->pageWithTextAndClass($page, $this->option('next_message'), $this->option('css_next_class'), $this->option('rel_next')); } + public function nextEnabledForPosition(Position $position): string + { + return $this->linkLi($this->option('css_next_class'), $this->generateRouteForPosition($position), $this->option('next_message'), $this->option('rel_next')); + } + public function first(): string { return $this->page(1); diff --git a/lib/Core/View/Template/SemanticUiTemplate.php b/lib/Core/View/Template/SemanticUiTemplate.php index a5843bdf..0d822f50 100644 --- a/lib/Core/View/Template/SemanticUiTemplate.php +++ b/lib/Core/View/Template/SemanticUiTemplate.php @@ -2,7 +2,9 @@ namespace Pagerfanta\View\Template; -class SemanticUiTemplate extends Template +use Pagerfanta\Position\Position; + +class SemanticUiTemplate extends Template implements SequentialTemplateInterface { /** * @return array @@ -65,6 +67,11 @@ public function previousEnabled(int $page): string return $this->pageWithTextAndClass($page, $this->option('prev_message'), $this->option('css_prev_class'), $this->option('rel_previous')); } + public function previousEnabledForPosition(Position $position): string + { + return $this->link($this->option('css_prev_class'), $this->generateRouteForPosition($position), $this->option('prev_message'), $this->option('rel_previous')); + } + public function nextDisabled(): string { return $this->div($this->nextDisabledClass(), $this->option('next_message')); @@ -80,6 +87,11 @@ public function nextEnabled(int $page): string return $this->pageWithTextAndClass($page, $this->option('next_message'), $this->option('css_next_class'), $this->option('rel_next')); } + public function nextEnabledForPosition(Position $position): string + { + return $this->link($this->option('css_next_class'), $this->generateRouteForPosition($position), $this->option('next_message'), $this->option('rel_next')); + } + public function first(): string { return $this->page(1); diff --git a/lib/Core/View/Template/SequentialTemplateInterface.php b/lib/Core/View/Template/SequentialTemplateInterface.php new file mode 100644 index 00000000..4509d30f --- /dev/null +++ b/lib/Core/View/Template/SequentialTemplateInterface.php @@ -0,0 +1,51 @@ + $options + */ + public function setOptions(array $options): void; + + /** + * Renders the container for the pagination. + * + * The %pages% placeholder will be replaced by the rendering of the links. + */ + public function container(): string; + + /** + * Renders the disabled state of the previous page. + */ + public function previousDisabled(): string; + + /** + * Renders the enabled state of the previous page, linking to the given position. + */ + public function previousEnabledForPosition(Position $position): string; + + /** + * Renders the disabled state of the next page. + */ + public function nextDisabled(): string; + + /** + * Renders the enabled state of the next page, linking to the given position. + */ + public function nextEnabledForPosition(Position $position): string; +} diff --git a/lib/Core/View/Template/Template.php b/lib/Core/View/Template/Template.php index bcca2964..12ded041 100644 --- a/lib/Core/View/Template/Template.php +++ b/lib/Core/View/Template/Template.php @@ -4,6 +4,8 @@ use Pagerfanta\Exception\InvalidArgumentException; use Pagerfanta\Exception\RuntimeException; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; abstract class Template implements TemplateInterface @@ -18,6 +20,8 @@ abstract class Template implements TemplateInterface */ private $routeGenerator; + private ?PositionRouteGeneratorInterface $positionRouteGenerator = null; + public function __construct() { $this->options = $this->getDefaultOptions(); @@ -35,6 +39,16 @@ public function setRouteGenerator(callable $routeGenerator): void $this->routeGenerator = $routeGenerator; } + /** + * Sets the position based route generator used while rendering the template. + * + * This is used by templates which implement {@see SequentialTemplateInterface}. + */ + public function setPositionRouteGenerator(PositionRouteGeneratorInterface $routeGenerator): void + { + $this->positionRouteGenerator = $routeGenerator; + } + /** * Sets the options for the template, overwriting keys that were previously set. * @@ -55,6 +69,20 @@ protected function generateRoute(int $page): string return $generator($page); } + /** + * Generate the route (URL) for the given position. + * + * @throws RuntimeException if the position route generator has not been set + */ + protected function generateRouteForPosition(Position $position): string + { + if (!$this->positionRouteGenerator instanceof PositionRouteGeneratorInterface) { + throw new RuntimeException(\sprintf('The position route generator was not set to the template, ensure you call %s::setPositionRouteGenerator().', static::class)); + } + + return ($this->positionRouteGenerator)($position); + } + /** * @return array */ diff --git a/lib/Core/View/Template/TwitterBootstrapTemplate.php b/lib/Core/View/Template/TwitterBootstrapTemplate.php index 564c1c68..65948110 100644 --- a/lib/Core/View/Template/TwitterBootstrapTemplate.php +++ b/lib/Core/View/Template/TwitterBootstrapTemplate.php @@ -2,7 +2,9 @@ namespace Pagerfanta\View\Template; -class TwitterBootstrapTemplate extends Template +use Pagerfanta\Position\Position; + +class TwitterBootstrapTemplate extends Template implements SequentialTemplateInterface { /** * @return array @@ -65,6 +67,11 @@ public function previousEnabled(int $page): string return $this->pageWithTextAndClass($page, $this->option('prev_message'), $this->option('css_prev_class'), $this->option('rel_previous')); } + public function previousEnabledForPosition(Position $position): string + { + return $this->linkLi($this->option('css_prev_class'), $this->generateRouteForPosition($position), $this->option('prev_message'), $this->option('rel_previous')); + } + public function nextDisabled(): string { return $this->spanLi($this->nextDisabledClass(), $this->option('next_message')); @@ -80,6 +87,11 @@ public function nextEnabled(int $page): string return $this->pageWithTextAndClass($page, $this->option('next_message'), $this->option('css_next_class'), $this->option('rel_next')); } + public function nextEnabledForPosition(Position $position): string + { + return $this->linkLi($this->option('css_next_class'), $this->generateRouteForPosition($position), $this->option('next_message'), $this->option('rel_next')); + } + public function first(): string { return $this->page(1); From bf600416eea989f7be505af135cdb095f6694be0 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 14:01:44 -0400 Subject: [PATCH 12/20] Render the sequential view from a copy of its template --- lib/Core/Tests/View/SequentialViewTest.php | 28 ++++++++++++++++++++++ lib/Core/View/SequentialView.php | 20 +++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/lib/Core/Tests/View/SequentialViewTest.php b/lib/Core/Tests/View/SequentialViewTest.php index 742bae10..5a80acb2 100644 --- a/lib/Core/Tests/View/SequentialViewTest.php +++ b/lib/Core/Tests/View/SequentialViewTest.php @@ -262,4 +262,32 @@ public function testATemplateCannotRenderAPositionLinkWithoutAPositionRouteGener (new DefaultTemplate())->nextEnabledForPosition(new PagePosition(2)); } + + public function testTheOptionsFromAPreviousRenderAreNotReused(): void + { + $view = new SequentialView(new DefaultTemplate()); + $routeGenerator = static fn (int $page): string => '|'.$page.'|'; + + $this->assertStringContainsString('rel="prev">Newer', $view->render($this->createOffsetPager(30, 2), $routeGenerator, ['prev_message' => 'Newer'])); + $this->assertStringContainsString('rel="prev">Previous', $view->render($this->createOffsetPager(30, 2), $routeGenerator)); + } + + public function testTheOptionsSetOnTheTemplateAreKeptForEachRender(): void + { + $template = new DefaultTemplate(); + $template->setOptions(['prev_message' => 'Newer']); + + $view = new SequentialView($template); + $routeGenerator = static fn (int $page): string => '|'.$page.'|'; + + $first = $view->render($this->createOffsetPager(30, 2), $routeGenerator, ['next_message' => 'Older']); + + $this->assertStringContainsString('rel="prev">Newer', $first); + $this->assertStringContainsString('rel="next">Older', $first); + + $second = $view->render($this->createOffsetPager(30, 2), $routeGenerator); + + $this->assertStringContainsString('rel="prev">Newer', $second); + $this->assertStringContainsString('rel="next">Next', $second); + } } diff --git a/lib/Core/View/SequentialView.php b/lib/Core/View/SequentialView.php index 56037a23..29023a2e 100644 --- a/lib/Core/View/SequentialView.php +++ b/lib/Core/View/SequentialView.php @@ -44,42 +44,44 @@ public function supports(PagerfantaInterface|PagerInterface $pager): bool */ public function render(PagerfantaInterface|PagerInterface $pager, callable $routeGenerator, array $options = []): string { - $this->template->setPositionRouteGenerator(PageRouteGeneratorWrapper::wrap($routeGenerator)); - $this->template->setOptions($options); + // Render from a copy of the template so the options and route generator of this render are not reused by the next one + $template = clone $this->template; + $template->setPositionRouteGenerator(PageRouteGeneratorWrapper::wrap($routeGenerator)); + $template->setOptions($options); - return str_replace('%pages%', $this->previous($pager).$this->next($pager), $this->template->container()); + return str_replace('%pages%', $this->previous($template, $pager).$this->next($template, $pager), $template->container()); } /** * @param PagerfantaInterface|PagerInterface $pager */ - private function previous(PagerfantaInterface|PagerInterface $pager): string + private function previous(SequentialTemplateInterface $template, PagerfantaInterface|PagerInterface $pager): string { if ($pager instanceof CursorPagerInterface && !$pager->supportsBackwardNavigation()) { return ''; } if (!$pager->hasPreviousPage()) { - return $this->template->previousDisabled(); + return $template->previousDisabled(); } // Implementations of PagerfantaInterface are not required to implement the position API until 5.0 $position = $pager instanceof PagerInterface ? $pager->getPreviousPosition() : new PagePosition($pager->getPreviousPage()); - return $this->template->previousEnabledForPosition($position); + return $template->previousEnabledForPosition($position); } /** * @param PagerfantaInterface|PagerInterface $pager */ - private function next(PagerfantaInterface|PagerInterface $pager): string + private function next(SequentialTemplateInterface $template, PagerfantaInterface|PagerInterface $pager): string { if (!$pager->hasNextPage()) { - return $this->template->nextDisabled(); + return $template->nextDisabled(); } $position = $pager instanceof PagerInterface ? $pager->getNextPosition() : new PagePosition($pager->getNextPage()); - return $this->template->nextEnabledForPosition($position); + return $template->nextEnabledForPosition($position); } } From be1c0d1a1ad015b141436cc0c7708dc0034ca58a Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 14:08:20 -0400 Subject: [PATCH 13/20] Support sequential rendering in Twig --- lib/Twig/Extension/PagerfantaExtension.php | 1 + lib/Twig/Extension/PagerfantaRuntime.php | 96 ++++++- .../Tests/Extension/PagerfantaRuntimeTest.php | 120 ++++++++ .../TwigViewSequentialIntegrationTest.php | 256 ++++++++++++++++++ lib/Twig/View/TwigView.php | 96 ++++++- lib/Twig/templates/default.html.twig | 121 +++++---- 6 files changed, 625 insertions(+), 65 deletions(-) create mode 100644 lib/Twig/Tests/View/TwigViewSequentialIntegrationTest.php diff --git a/lib/Twig/Extension/PagerfantaExtension.php b/lib/Twig/Extension/PagerfantaExtension.php index 25b262f5..c547c190 100644 --- a/lib/Twig/Extension/PagerfantaExtension.php +++ b/lib/Twig/Extension/PagerfantaExtension.php @@ -15,6 +15,7 @@ public function getFunctions(): array return [ new TwigFunction('pagerfanta', [PagerfantaRuntime::class, 'renderPagerfanta'], ['is_safe' => ['html']]), new TwigFunction('pagerfanta_page_url', [PagerfantaRuntime::class, 'getPageUrl']), + new TwigFunction('pagerfanta_position_url', [PagerfantaRuntime::class, 'getPositionUrl']), ]; } } diff --git a/lib/Twig/Extension/PagerfantaRuntime.php b/lib/Twig/Extension/PagerfantaRuntime.php index 0f98742e..d7fed603 100644 --- a/lib/Twig/Extension/PagerfantaRuntime.php +++ b/lib/Twig/Extension/PagerfantaRuntime.php @@ -2,36 +2,56 @@ namespace Pagerfanta\Twig\Extension; +use Pagerfanta\Exception\InvalidArgumentException; use Pagerfanta\Exception\OutOfRangeCurrentPageException; use Pagerfanta\PagerfantaInterface; +use Pagerfanta\PagerInterface; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PageRouteGeneratorWrapper; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; +use Pagerfanta\View\PagerViewInterface; use Pagerfanta\View\ViewFactoryInterface; +use Pagerfanta\View\ViewInterface; use Twig\Extension\RuntimeExtensionInterface; final class PagerfantaRuntime implements RuntimeExtensionInterface { + /** + * @param string|null $defaultSequentialView The name of the view to render pagers which the default view cannot render (i.e. cursor pagers with a numbered view) + */ public function __construct( private readonly string $defaultView, private readonly ViewFactoryInterface $viewFactory, private readonly RouteGeneratorFactoryInterface $routeGeneratorFactory, + private readonly ?string $defaultSequentialView = null, ) {} /** - * @param PagerfantaInterface $pagerfanta - * @param string|array|null $viewName The name of the view to render, or the options array - * @param array $options + * @param PagerfantaInterface|PagerInterface $pagerfanta + * @param string|array|null $viewName The name of the view to render, or the options array + * @param array $options + * + * @throws InvalidArgumentException if the view cannot render the pager */ - public function renderPagerfanta(PagerfantaInterface $pagerfanta, string|array|null $viewName = null, array $options = []): string + public function renderPagerfanta(PagerfantaInterface|PagerInterface $pagerfanta, string|array|null $viewName = null, array $options = []): string { if (\is_array($viewName)) { $options = $viewName; $viewName = null; } - $viewName = $viewName ?: $this->defaultView; + $view = $this->resolveView($pagerfanta, $viewName ?: null); + + if ($view instanceof PagerViewInterface) { + return $view->render($pagerfanta, $this->createPositionRouteGenerator($options), $options); + } + + \assert($pagerfanta instanceof PagerfantaInterface); - return $this->viewFactory->get($viewName)->render($pagerfanta, $this->createRouteGenerator($options), $options); + return $view->render($pagerfanta, $this->createRouteGenerator($options), $options); } /** @@ -51,6 +71,56 @@ public function getPageUrl(PagerfantaInterface $pagerfanta, int $page, array $op return $routeGenerator($page); } + /** + * @param array $options + * + * @throws InvalidArgumentException if the position is not supported by the route generator + */ + public function getPositionUrl(Position $position, array $options = []): string + { + $routeGenerator = $this->createPositionRouteGenerator($options); + + return $routeGenerator($position); + } + + /** + * Resolves the view to render the pager with, falling back to the default sequential view when the default view cannot render it. + * + * @param PagerfantaInterface|PagerInterface $pagerfanta + * + * @throws InvalidArgumentException if the view cannot render the pager + */ + private function resolveView(PagerfantaInterface|PagerInterface $pagerfanta, ?string $viewName): ViewInterface + { + $view = $this->viewFactory->get($viewName ?? $this->defaultView); + + if ($this->viewSupports($view, $pagerfanta)) { + return $view; + } + + if (null === $viewName && null !== $this->defaultSequentialView) { + $view = $this->viewFactory->get($this->defaultSequentialView); + + if ($this->viewSupports($view, $pagerfanta)) { + return $view; + } + } + + throw new InvalidArgumentException(\sprintf('The "%s" view cannot render a pager of type "%s"%s.', $view->getName(), get_debug_type($pagerfanta), null === $viewName && null === $this->defaultSequentialView ? ', configure a default sequential view to render these pagers' : '')); + } + + /** + * @param PagerfantaInterface|PagerInterface $pagerfanta + */ + private function viewSupports(ViewInterface $view, PagerfantaInterface|PagerInterface $pagerfanta): bool + { + if ($view instanceof PagerViewInterface) { + return $view->supports($pagerfanta); + } + + return $pagerfanta instanceof PagerfantaInterface; + } + /** * @param array $options */ @@ -58,4 +128,18 @@ private function createRouteGenerator(array $options = []): RouteGeneratorInterf { return $this->routeGeneratorFactory->create($options); } + + /** + * Creates a position route generator if the factory supports it, otherwise a page number based generator is adapted, which only supports offset pagers. + * + * @param array $options + */ + private function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface + { + if ($this->routeGeneratorFactory instanceof PositionRouteGeneratorFactoryInterface) { + return $this->routeGeneratorFactory->createPositionRouteGenerator($options); + } + + return PageRouteGeneratorWrapper::wrap($this->createRouteGenerator($options)); + } } diff --git a/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php b/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php index 9b88a49f..39e32b25 100644 --- a/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php +++ b/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php @@ -2,13 +2,27 @@ namespace Pagerfanta\Twig\Tests\Extension; +use Pagerfanta\Adapter\CallbackCursorAdapter; +use Pagerfanta\Adapter\CursorSlice; use Pagerfanta\Adapter\FixedAdapter; +use Pagerfanta\Cursor\Cursor; +use Pagerfanta\CursorPagerfanta; +use Pagerfanta\Exception\InvalidArgumentException; use Pagerfanta\Exception\OutOfRangeCurrentPageException; use Pagerfanta\Pagerfanta; +use Pagerfanta\Position\CursorPosition; +use Pagerfanta\Position\PagePosition; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; +use Pagerfanta\RouteGenerator\RouteGeneratorDecorator; use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; use Pagerfanta\Twig\Extension\PagerfantaRuntime; use Pagerfanta\View\DefaultView; +use Pagerfanta\View\SequentialView; +use Pagerfanta\View\Template\DefaultTemplate; use Pagerfanta\View\ViewFactory; use PHPUnit\Framework\TestCase; @@ -155,4 +169,110 @@ private function removeWhitespacesBetweenTags(string $string): string { return preg_replace('/>\s+<', $string) ?? ''; } + + /** + * @return CursorPagerfanta + */ + private function createCursorPager(): CursorPagerfanta + { + return new CursorPagerfanta(new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice([1], null, new Cursor(['id' => 1])))); + } + + private function createPositionRouteGeneratorFactory(): RouteGeneratorFactoryInterface&PositionRouteGeneratorFactoryInterface + { + return new class implements RouteGeneratorFactoryInterface, PositionRouteGeneratorFactoryInterface { + /** + * @param array $options + */ + public function create(array $options = []): RouteGeneratorInterface + { + return new RouteGeneratorDecorator(static fn (int $page): string => '/my-page?page='.$page); + } + + /** + * @param array $options + */ + public function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface + { + return new PositionRouteGeneratorDecorator(static fn (Position $position): string => $position instanceof CursorPosition ? '/my-page?after='.$position->cursor->fields['id'] : '/my-page?page='.($position instanceof PagePosition ? $position->page : 1)); + } + }; + } + + private function createViewFactory(): ViewFactory + { + $viewFactory = new ViewFactory(); + $viewFactory->set('default', new DefaultView()); + $viewFactory->set('sequential', new SequentialView(new DefaultTemplate())); + + return $viewFactory; + } + + public function testAPagerWhichTheDefaultViewCannotRenderIsRejectedWithoutADefaultSequentialView(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('configure a default sequential view'); + + (new PagerfantaRuntime('default', $this->createViewFactory(), $this->createPositionRouteGeneratorFactory()))->renderPagerfanta($this->createCursorPager()); + } + + public function testAPagerWhichTheDefaultViewCannotRenderIsRenderedWithTheDefaultSequentialView(): void + { + // The callback adapter is forward-only by default, so there is no previous link + $this->assertSame( + '', + (new PagerfantaRuntime('default', $this->createViewFactory(), $this->createPositionRouteGeneratorFactory(), 'sequential'))->renderPagerfanta($this->createCursorPager()), + ); + } + + public function testTheDefaultViewIsUsedForAPagerItCanRenderWhenADefaultSequentialViewIsSet(): void + { + $this->assertStringContainsString( + '1', + (new PagerfantaRuntime('default', $this->createViewFactory(), $this->createPositionRouteGeneratorFactory(), 'sequential'))->renderPagerfanta($this->createPagerfanta()), + ); + } + + public function testANamedViewWhichCannotRenderThePagerIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new PagerfantaRuntime('sequential', $this->createViewFactory(), $this->createPositionRouteGeneratorFactory(), 'sequential'))->renderPagerfanta($this->createCursorPager(), 'default'); + } + + public function testAPagerViewIsGivenAPositionRouteGenerator(): void + { + $this->assertSame( + '', + (new PagerfantaRuntime('sequential', $this->createViewFactory(), $this->createPositionRouteGeneratorFactory()))->renderPagerfanta($this->createPagerfanta()), + ); + } + + public function testAPagerViewIsGivenAnAdaptedPageRouteGeneratorWhenTheFactoryDoesNotSupportPositions(): void + { + $this->assertSame( + '', + (new PagerfantaRuntime('sequential', $this->createViewFactory(), $this->createRouteGeneratorFactory()))->renderPagerfanta($this->createPagerfanta()), + ); + } + + public function testAPositionUrlCanBeGenerated(): void + { + $runtime = new PagerfantaRuntime('default', $this->createViewFactory(), $this->createPositionRouteGeneratorFactory()); + + $this->assertSame('/my-page?after=3', $runtime->getPositionUrl(new CursorPosition(new Cursor(['id' => 3])))); + $this->assertSame('/my-page?page=3', $runtime->getPositionUrl(new PagePosition(3))); + } + + public function testAPagePositionUrlCanBeGeneratedWhenTheFactoryDoesNotSupportPositions(): void + { + $this->assertSame('/my-page?page=3', $this->extension->getPositionUrl(new PagePosition(3))); + } + + public function testACursorPositionUrlCannotBeGeneratedWhenTheFactoryDoesNotSupportPositions(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->extension->getPositionUrl(new CursorPosition(new Cursor(['id' => 3]))); + } } diff --git a/lib/Twig/Tests/View/TwigViewSequentialIntegrationTest.php b/lib/Twig/Tests/View/TwigViewSequentialIntegrationTest.php new file mode 100644 index 00000000..89db1d75 --- /dev/null +++ b/lib/Twig/Tests/View/TwigViewSequentialIntegrationTest.php @@ -0,0 +1,256 @@ +addPath(__DIR__.'/../../templates', 'Pagerfanta'); + + // Strict variables ensure the sequential rendering does not rely on the variables only given for numbered rendering + $this->twig = new Environment( + new ChainLoader([ + new ArrayLoader([ + 'integration.html.twig' => '{{ pagerfanta(pager, options) }}', + 'position_url.html.twig' => '{{ pagerfanta_position_url(pager.nextPosition) }}', + 'messages.html.twig' => self::MESSAGES_TEMPLATE, + ]), + $filesystemLoader, + ]), + ['strict_variables' => true], + ); + $this->twig->addExtension(new PagerfantaExtension()); + $this->twig->addRuntimeLoader(new FactoryRuntimeLoader([ + PagerfantaRuntime::class => function (): PagerfantaRuntime { + $viewFactory = new ViewFactory(); + $viewFactory->set('twig', new TwigView($this->twig)); + + return new PagerfantaRuntime('twig', $viewFactory, $this->createRouteGeneratorFactory()); + }, + ])); + } + + /** + * Creates a cursor pager on a page after the first, linking to the previous and next pages as allowed. + * + * @return CursorPagerfanta + */ + private function createCursorPager(bool $supportsBackwardNavigation = true, bool $hasPrevious = true): CursorPagerfanta + { + $adapter = new CallbackCursorAdapter( + static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice( + [2], + $hasPrevious ? new Cursor(['id' => 2], Direction::Previous) : null, + new Cursor(['id' => 2]), + ), + $supportsBackwardNavigation, + ); + + return new CursorPagerfanta($adapter, 1, new CursorPosition(new Cursor(['id' => 1]))); + } + + /** + * @return Pagerfanta + */ + private function createOffsetPager(): Pagerfanta + { + return Pagerfanta::createForCurrentPageWithMaxPerPage(new ArrayAdapter(range(1, 30)), 2, 10); + } + + public static function generateUrl(Position $position): string + { + return match (true) { + $position instanceof CursorPosition => '/posts?'.('Next' === $position->cursor->direction->name ? 'after' : 'before').'='.$position->cursor->fields['id'], + $position instanceof PagePosition => '/posts?page='.$position->page, + default => throw new \UnexpectedValueException('Unexpected position'), + }; + } + + private function createPositionRouteGenerator(): PositionRouteGeneratorInterface + { + return new PositionRouteGeneratorDecorator(self::generateUrl(...)); + } + + private function createRouteGeneratorFactory(): RouteGeneratorFactoryInterface&PositionRouteGeneratorFactoryInterface + { + return new class implements RouteGeneratorFactoryInterface, PositionRouteGeneratorFactoryInterface { + /** + * @param array $options + */ + public function create(array $options = []): RouteGeneratorInterface + { + return new RouteGeneratorDecorator(static fn (int $page): string => '/posts?page='.$page); + } + + /** + * @param array $options + */ + public function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface + { + return new PositionRouteGeneratorDecorator(TwigViewSequentialIntegrationTest::generateUrl(...)); + } + }; + } + + /** + * @return \Generator + */ + public static function dataThemes(): \Generator + { + yield 'default' => [ + '@Pagerfanta/default.html.twig', + '', + ]; + + yield 'foundation 6' => [ + '@Pagerfanta/foundation6.html.twig', + '', + ]; + + yield 'semantic ui' => [ + '@Pagerfanta/semantic_ui.html.twig', + '', + ]; + + yield 'tailwind' => [ + '@Pagerfanta/tailwind.html.twig', + '', + ]; + + yield 'twitter bootstrap' => [ + '@Pagerfanta/twitter_bootstrap.html.twig', + '', + ]; + + yield 'twitter bootstrap 3' => [ + '@Pagerfanta/twitter_bootstrap3.html.twig', + '', + ]; + + yield 'twitter bootstrap 4' => [ + '@Pagerfanta/twitter_bootstrap4.html.twig', + '', + ]; + + yield 'twitter bootstrap 5' => [ + '@Pagerfanta/twitter_bootstrap5.html.twig', + '', + ]; + } + + #[DataProvider('dataThemes')] + public function testACursorPagerIsRenderedWithEachTheme(string $template, string $expected): void + { + $this->assertViewOutputMatches($expected, (new TwigView($this->twig))->render($this->createCursorPager(), $this->createPositionRouteGenerator(), ['template' => $template])); + } + + public function testAForwardOnlyCursorPagerIsRenderedWithoutAPreviousLink(): void + { + $this->assertViewOutputMatches( + '', + (new TwigView($this->twig))->render($this->createCursorPager(false), $this->createPositionRouteGenerator()), + ); + } + + public function testTheFirstCursorPageRendersADisabledPreviousLink(): void + { + $this->assertViewOutputMatches( + '', + (new TwigView($this->twig))->render($this->createCursorPager(true, false), $this->createPositionRouteGenerator()), + ); + } + + public function testAnOffsetPagerCanBeRenderedSequentially(): void + { + $this->assertViewOutputMatches( + '', + (new TwigView($this->twig))->render($this->createOffsetPager(), static fn (int $page): string => '/posts?page='.$page, ['sequential' => true]), + ); + } + + public function testAnOffsetPagerIsRenderedWithNumberedLinksFromAPositionRouteGenerator(): void + { + $view = new TwigView($this->twig); + + $this->assertSame( + $view->render($this->createOffsetPager(), static fn (int $page): string => '/posts?page='.$page), + $view->render($this->createOffsetPager(), $this->createPositionRouteGenerator()), + ); + } + + public function testTheMessagesCanBeCustomizedWithAChainOfTemplates(): void + { + if (!class_exists(BlockChain::class)) { + $this->markTestSkipped('This test requires twig/twig 3.29 or later.'); + } + + $this->assertViewOutputMatches( + '', + (new TwigView($this->twig))->render($this->createCursorPager(), $this->createPositionRouteGenerator(), ['template' => ['messages.html.twig', '@Pagerfanta/twitter_bootstrap5.html.twig']]), + ); + } + + public function testACursorPagerIsRenderedWithTheTwigFunction(): void + { + $this->assertViewOutputMatches( + '', + $this->twig->render('integration.html.twig', ['pager' => $this->createCursorPager(), 'options' => []]), + ); + } + + public function testAPositionUrlIsGeneratedWithTheTwigFunction(): void + { + $this->assertSame('/posts?after=2', $this->twig->render('position_url.html.twig', ['pager' => $this->createCursorPager()])); + } + + private function assertViewOutputMatches(string $expected, string $view): void + { + $this->assertSame($expected, preg_replace('/>\s+<', $view)); + } +} diff --git a/lib/Twig/View/TwigView.php b/lib/Twig/View/TwigView.php index b487b16a..9ccf6a1d 100644 --- a/lib/Twig/View/TwigView.php +++ b/lib/Twig/View/TwigView.php @@ -2,15 +2,30 @@ namespace Pagerfanta\Twig\View; +use Pagerfanta\CursorPagerInterface; +use Pagerfanta\Exception\LessThan1CurrentPageException; use Pagerfanta\PagerfantaInterface; +use Pagerfanta\PagerInterface; +use Pagerfanta\Position\PagePosition; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PageRouteGeneratorWrapper; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorDecorator; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; +use Pagerfanta\View\PagerViewInterface; use Pagerfanta\View\View; use Twig\BlockChain; use Twig\Environment; use Twig\TemplateWrapper; -final class TwigView extends View +/** + * View which renders a pager with Twig templates. + * + * Pagers implementing {@see PagerfantaInterface} are rendered with numbered page links, unless the "sequential" option is + * set. All other pagers are rendered with previous and next links, using the "sequential_pager" block of the template. + */ +final class TwigView extends View implements PagerViewInterface { public const DEFAULT_TEMPLATE = '@Pagerfanta/default.html.twig'; @@ -37,15 +52,25 @@ public function getName(): string } /** - * @param PagerfantaInterface $pagerfanta - * @param callable|RouteGeneratorInterface $routeGenerator - * @param array $options - * - * @phpstan-param callable(int $page): string|RouteGeneratorInterface $routeGenerator + * @param PagerfantaInterface|PagerInterface $pager */ - public function render(PagerfantaInterface $pagerfanta, callable $routeGenerator, array $options = []): string + public function supports(PagerfantaInterface|PagerInterface $pager): bool { - $this->initializePagerfanta($pagerfanta); + return true; + } + + /** + * @param PagerfantaInterface|PagerInterface $pager + * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator + * @param array $options + */ + public function render(PagerfantaInterface|PagerInterface $pager, callable $routeGenerator, array $options = []): string + { + if (!$pager instanceof PagerfantaInterface || true === ($options['sequential'] ?? false)) { + return $this->renderSequential($pager, $routeGenerator, $options); + } + + $this->initializePagerfanta($pager); $this->initializeOptions($options); $this->calculateStartAndEndPage(); @@ -53,9 +78,10 @@ public function render(PagerfantaInterface $pagerfanta, callable $routeGenerator return $this->loadTemplate($this->template)->renderBlock( 'pager_widget', [ - 'pagerfanta' => $pagerfanta, + 'pagerfanta' => $pager, 'route_generator' => $this->decorateRouteGenerator($routeGenerator), 'options' => $options, + 'sequential' => false, 'start_page' => $this->startPage, 'end_page' => $this->endPage, 'current_page' => $this->currentPage, @@ -65,10 +91,58 @@ public function render(PagerfantaInterface $pagerfanta, callable $routeGenerator } /** - * @param (callable(int $page): string)|RouteGeneratorInterface $routeGenerator + * @param PagerfantaInterface|PagerInterface $pager + * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator + * @param array $options */ - private function decorateRouteGenerator(callable|RouteGeneratorInterface $routeGenerator): RouteGeneratorDecorator + private function renderSequential(PagerfantaInterface|PagerInterface $pager, callable $routeGenerator, array $options): string { + $this->initializeOptions($options); + + // Implementations of PagerfantaInterface are not required to implement the position API until 5.0 + $previousPosition = match (true) { + !$pager->hasPreviousPage() => null, + $pager instanceof PagerInterface => $pager->getPreviousPosition(), + default => new PagePosition($pager->getPreviousPage()), + }; + + $nextPosition = match (true) { + !$pager->hasNextPage() => null, + $pager instanceof PagerInterface => $pager->getNextPosition(), + default => new PagePosition($pager->getNextPage()), + }; + + return $this->loadTemplate($this->template)->renderBlock( + 'pager_widget', + [ + 'pagerfanta' => $pager, + 'route_generator' => new PositionRouteGeneratorDecorator(PageRouteGeneratorWrapper::wrap($routeGenerator)), + 'options' => $options, + 'sequential' => true, + 'supports_backward_navigation' => !$pager instanceof CursorPagerInterface || $pager->supportsBackwardNavigation(), + 'previous_position' => $previousPosition, + 'next_position' => $nextPosition, + ] + ); + } + + /** + * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator + */ + private function decorateRouteGenerator(callable $routeGenerator): RouteGeneratorDecorator + { + // Numbered pages are linked with page numbers, so a position route generator is given page positions + if ($routeGenerator instanceof PositionRouteGeneratorInterface) { + return new RouteGeneratorDecorator(static function (int $page) use ($routeGenerator): string { + // A template may link to any page number, which must still be a valid page + if ($page < 1) { + throw new LessThan1CurrentPageException(); + } + + return $routeGenerator(new PagePosition($page)); + }); + } + return new RouteGeneratorDecorator($routeGenerator); } diff --git a/lib/Twig/templates/default.html.twig b/lib/Twig/templates/default.html.twig index f978171e..d7a59621 100644 --- a/lib/Twig/templates/default.html.twig +++ b/lib/Twig/templates/default.html.twig @@ -5,72 +5,97 @@ {%- endblock pager_widget -%} {%- block pager -%} - {# Previous Page Link #} - {%- if pagerfanta.hasPreviousPage() -%} - {%- set path = route_generator.route(pagerfanta.getPreviousPage()) -%} - {%- set page = pagerfanta.getPreviousPage() -%} - {{- block('previous_page_link') -}} + {# Sequential pagers (i.e. cursor pagers) only link to the previous and next pages #} + {%- if sequential is defined and sequential -%} + {{- block('sequential_pager') -}} {%- else -%} - {{- block('previous_page_link_disabled') -}} - {%- endif -%} + {# Previous Page Link #} + {%- if pagerfanta.hasPreviousPage() -%} + {%- set path = route_generator.route(pagerfanta.getPreviousPage()) -%} + {%- set page = pagerfanta.getPreviousPage() -%} + {{- block('previous_page_link') -}} + {%- else -%} + {{- block('previous_page_link_disabled') -}} + {%- endif -%} - {# First Page Link #} - {%- if start_page > 1 -%} - {%- set page = 1 -%} - {%- set path = route_generator.route(page) -%} - {{- block('page_link') -}} - {%- endif -%} + {# First Page Link #} + {%- if start_page > 1 -%} + {%- set page = 1 -%} + {%- set path = route_generator.route(page) -%} + {{- block('page_link') -}} + {%- endif -%} - {# Second Page Link, displays if we are on page 3 #} - {%- if start_page == 3 -%} - {%- set page = 2 -%} - {%- set path = route_generator.route(page) -%} - {{- block('page_link') -}} - {%- endif -%} + {# Second Page Link, displays if we are on page 3 #} + {%- if start_page == 3 -%} + {%- set page = 2 -%} + {%- set path = route_generator.route(page) -%} + {{- block('page_link') -}} + {%- endif -%} - {# Separator, creates a "..." separator to limit the number of items if we are starting beyond page 3 #} - {%- if start_page > 3 -%} - {{- block('ellipsis') -}} - {%- endif -%} + {# Separator, creates a "..." separator to limit the number of items if we are starting beyond page 3 #} + {%- if start_page > 3 -%} + {{- block('ellipsis') -}} + {%- endif -%} - {# Page Links #} - {%- for page in range(start_page, end_page) -%} - {%- set path = route_generator.route(page) -%} - {%- if page == current_page -%} - {{- block('current_page_link') -}} - {%- else -%} + {# Page Links #} + {%- for page in range(start_page, end_page) -%} + {%- set path = route_generator.route(page) -%} + {%- if page == current_page -%} + {{- block('current_page_link') -}} + {%- else -%} + {{- block('page_link') -}} + {%- endif -%} + {%- endfor -%} + + {# Separator, creates a "..." separator to limit the number of items if we are over 3 pages away from the last page #} + {%- if end_page < (nb_pages - 2) -%} + {{- block('ellipsis') -}} + {%- endif -%} + + {# Second to Last Page Link, displays if we are on the third from last page #} + {%- if end_page == (nb_pages - 2) -%} + {%- set page = (nb_pages - 1) -%} + {%- set path = route_generator.route(page) -%} {{- block('page_link') -}} {%- endif -%} - {%- endfor -%} - {# Separator, creates a "..." separator to limit the number of items if we are over 3 pages away from the last page #} - {%- if end_page < (nb_pages - 2) -%} - {{- block('ellipsis') -}} - {%- endif -%} + {# Last Page Link #} + {%- if nb_pages > end_page -%} + {%- set page = nb_pages -%} + {%- set path = route_generator.route(page) -%} + {{- block('page_link') -}} + {%- endif -%} - {# Second to Last Page Link, displays if we are on the third from last page #} - {%- if end_page == (nb_pages - 2) -%} - {%- set page = (nb_pages - 1) -%} - {%- set path = route_generator.route(page) -%} - {{- block('page_link') -}} + {# Next Page Link #} + {%- if pagerfanta.hasNextPage() -%} + {%- set path = route_generator.route(pagerfanta.getNextPage()) -%} + {%- set page = pagerfanta.getNextPage() -%} + {{- block('next_page_link') -}} + {%- else -%} + {{- block('next_page_link_disabled') -}} + {%- endif -%} {%- endif -%} +{%- endblock pager -%} - {# Last Page Link #} - {%- if nb_pages > end_page -%} - {%- set page = nb_pages -%} - {%- set path = route_generator.route(page) -%} - {{- block('page_link') -}} +{%- block sequential_pager -%} + {# Previous Page Link, omitted when the pager cannot navigate backward #} + {%- if supports_backward_navigation -%} + {%- if previous_position is not null -%} + {%- set path = route_generator.route(previous_position) -%} + {{- block('previous_page_link') -}} + {%- else -%} + {{- block('previous_page_link_disabled') -}} + {%- endif -%} {%- endif -%} {# Next Page Link #} - {%- if pagerfanta.hasNextPage() -%} - {%- set path = route_generator.route(pagerfanta.getNextPage()) -%} - {%- set page = pagerfanta.getNextPage() -%} + {%- if next_position is not null -%} + {%- set path = route_generator.route(next_position) -%} {{- block('next_page_link') -}} {%- else -%} {{- block('next_page_link_disabled') -}} {%- endif -%} -{%- endblock pager -%} +{%- endblock sequential_pager -%} {%- block page_link -%} {{- page -}} From 68c794ddb9072832634d1c225ffeda7af5d8a6b6 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 14:58:05 -0400 Subject: [PATCH 14/20] Docs pass on cursor and sequential --- docs/adapter.md | 81 ++++++++++++++ docs/adapters.md | 221 ++++++++++++++++++++++++++++++++++++++ docs/cursor-pagination.md | 142 ++++++++++++++++++++++++ docs/index.md | 1 + docs/route-generator.md | 52 +++++++++ docs/templates.md | 44 ++++++++ docs/usage.md | 23 ++++ docs/views.md | 73 +++++++++++++ 8 files changed, 637 insertions(+) create mode 100644 docs/cursor-pagination.md diff --git a/docs/adapter.md b/docs/adapter.md index c8c84456..1e8597f8 100644 --- a/docs/adapter.md +++ b/docs/adapter.md @@ -30,3 +30,84 @@ interface AdapterInterface public function getSlice(int $offset, int $length): iterable; } ``` + +The `AdapterInterface` is composed of two smaller interfaces, which describe the capabilities of an adapter separately: + +- `Pagerfanta\Adapter\CountableAdapterInterface`: An adapter which can count the total number of items in the list with `getNbResults` +- `Pagerfanta\Adapter\OffsetAdapterInterface`: An adapter which can retrieve a page of items with `getSlice`, using an offset and a length + +## Cursor Adapters + +
    Cursor adapters were introduced in Pagerfanta 4.10.
    + +Pagerfanta defines `Pagerfanta\Adapter\CursorAdapterInterface` which is the abstraction layer for any system to provide data to a [cursor pager](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination). + +The interface requires two methods to be implemented: + +- `getSlice`: Retrieves the items following (or preceding, based on the direction of the cursor) the given cursor, along with the cursors pointing to the neighboring pages + - A null cursor requests the first page + - The items must always be returned in the sort order of the list, regardless of the direction of the cursor + - An adapter can throw a `Pagerfanta\Exception\InvalidCursorException` if the cursor is not valid for it, such as when the cursor fields do not match the fields the list is sorted by +- `supportsBackwardNavigation`: Reports whether the adapter can paginate backwards, adapters which cannot should never return a cursor for the previous page + +A cursor adapter does not count the total number of items in the list unless it also implements `Pagerfanta\Adapter\CountableAdapterInterface`. + +```php +fetchPosts($cursor, $limit + 1); + + return CursorSlice::fromLookahead( + $items, + $limit, + $cursor, + static fn (Post $post, Direction $direction): Cursor => new Cursor(['id' => $post->id], $direction), + ); + } + + public function supportsBackwardNavigation(): bool + { + return true; + } +} +``` diff --git a/docs/adapters.md b/docs/adapters.md index 348308e5..7b447944 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -73,6 +73,27 @@ $criteria = Criteria::create()->andWhere(Criteria::expr()->in('id', [1, 2, 3])); $adapter = new SelectableAdapter($user->getGroups(), $criteria); ``` +##### Cursor Pagination + +
    The SelectableCursorAdapter was introduced in Pagerfanta 4.10.
    + +The `SelectableCursorAdapter` supports [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) on a class which implements `Doctrine\Common\Collection\Selectable`. + +The class constructor requires the `Selectable` instance, a `Doctrine\Common\Collections\Criteria` instance, and the fields to sort the items by, in order of precedence, mapped to their sort order (either `'ASC'`/`'DESC'`, a `Doctrine\Common\Collections\Order` case, or a `\SortDirection` case). The sort fields replace any orderings on the criteria, and together they must uniquely identify each item (i.e. the last field should be the identifier). The sort fields must have scalar, non-null values. + +```php +andWhere(Criteria::expr()->eq('active', true)); + +$adapter = new SelectableCursorAdapter($user->getGroups(), $criteria, ['name' => 'ASC', 'id' => 'ASC']); +``` + +To count the results, decorate the adapter with a [`CountingCursorAdapter`](#counting) using a `SelectableAdapter` for the same criteria. + #### DBAL The DBAL adapters are available with the `pagerfanta/doctrine-dbal-adapter` package for use with [Doctrine's DBAL](https://www.doctrine-project.org/projects/dbal.html). @@ -141,6 +162,41 @@ $countQueryBuilderModifier = static function (QueryBuilder $queryBuilder): Query $adapter = new QueryAdapter($query, $countQueryBuilderModifier); ``` +##### Cursor Pagination + +
    The DBAL CursorQueryAdapter was introduced in Pagerfanta 4.10.
    + +The `CursorQueryAdapter` supports [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) (also known as keyset pagination) on a `Doctrine\DBAL\Query\QueryBuilder`. + +As the query builder cannot be inspected for its ORDER BY clause, the class constructor requires a list of `Pagerfanta\Doctrine\DBAL\SortColumn` instances describing the columns to sort the query by, in order of precedence. The adapter replaces any ORDER BY clause on the query with these columns, and together they must uniquely identify each row (i.e. the last column should be the primary key). The sort columns must be selected by the query and must not contain null values. + +Each sort column takes the SQL expression for the column (including the table alias), the sort order (`'ASC'` or `'DESC'`), and optionally the key of the column in the result rows (which defaults to the part of the expression after the last `.`). + +
    The sort column expressions are added to the query as-is, never build them from user input.
    + +```php +createQueryBuilder() + ->select('p.*') + ->from('posts', 'p'); + +$adapter = new CursorQueryAdapter( + $query, + [ + new SortColumn('p.published_at', 'DESC'), + new SortColumn('p.id', 'DESC'), + ] +); +``` + +The adapter binds the cursor values as parameters named `pagerfanta_cursor_0`, `pagerfanta_cursor_1`, and so on, so the query must not use parameters with these names. + +To count the results, decorate the adapter with a [`CountingCursorAdapter`](#counting) using a `QueryAdapter` or `SingleTableQueryAdapter` for the same query. + #### MongoDB ODM The MongoDB ODM adapter is available with the `pagerfanta/doctrine-mongodb-odm-adapter` package for use with [Doctrine' MongoDB ODM](https://www.doctrine-project.org/projects/mongodb-odm.html). @@ -166,6 +222,8 @@ $query = $dm->createQueryBuilder(Article::class); $adapter = new QueryAdapter($query); ``` +
    Cursor pagination is not yet available for the MongoDB ODM, support is planned should the paginators proposed in doctrine/mongodb-odm#2992 be released.
    + #### ORM The ORM adapter is available with the `pagerfanta/doctrine-orm-adapter` package for use with [Doctrine's ORM](https://www.doctrine-project.org/projects/orm.html). @@ -202,6 +260,28 @@ $query = $repository->createQueryBuilder('u'); $adapter = new QueryAdapter($query); ``` +##### Cursor Pagination + +
    The ORM CursorQueryAdapter was introduced in Pagerfanta 4.10.
    + +The `CursorQueryAdapter` supports [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) using the [cursor paginator](https://www.doctrine-project.org/projects/doctrine-orm/en/current/tutorials/pagination.html) from Doctrine ORM 3.7 or later. + +The class constructor requires either a `Doctrine\ORM\Query` or `Doctrine\ORM\QueryBuilder` instance, and accepts the same options as the `QueryAdapter`. The query's ORDER BY clause must be deterministic (i.e. end with a unique field such as the identifier), and every item in it must be a field of an entity. The cursor fields are keyed by the DQL path of each ORDER BY item (such as `p.createdAt`). + +```php +createQueryBuilder('p') + ->orderBy('p.createdAt', 'DESC') + ->addOrderBy('p.id', 'DESC'); + +$adapter = new CursorQueryAdapter($query); +``` + +Use the `CountableCursorQueryAdapter` if the total number of results is needed, which runs an extra COUNT query when the total is requested. + #### PHPCR ODM The PHPCR ODM adapter is available with the `pagerfanta/doctrine-phpcr-odm-adapter` package for use with [Doctrine's PHPCR ODM](https://www.doctrine-project.org/projects/phpcr-odm.html). @@ -257,6 +337,37 @@ $adapter = new ElasticaAdapter($searchable, $query);
    Be careful when paginating a huge set of documents. By default, offset + limit cannot exceed 10,000 items. You can mitigate this by setting the $maxResults parameter when constructing the ElasticaAdapter. For more information, see https://github.com/whiteoctober/Pagerfanta/pull/213#issue-87631892.
    +#### Cursor Pagination + +
    The ElasticaCursorAdapter was introduced in Pagerfanta 4.10.
    + +The `ElasticaCursorAdapter` supports [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) using Elasticsearch's `search_after` parameter, which is not limited by the maximum result window. + +The class constructor requires the searchable object, the query, and the fields to sort the documents by, in order of precedence, mapped to their sort order (`'asc'` or `'desc'`) or to their sort options (which must include the `order`). The sort fields replace any sort on the query, and together they must uniquely identify each document (i.e. the last field should be a unique tiebreaker field). Search options can be given as the last argument. + +```php + 'desc', + 'score' => ['order' => 'asc', 'missing' => '_first'], + 'post_id' => 'asc', + ] +); +``` + +Backward navigation is supported by searching with the reversed sort. When reversing the sort, a field's `missing` option is also reversed so documents without a value keep their position relative to the other documents. + +
    Without a point in time, the pages may shift if the index changes while paginating. Elasticsearch also discourages sorting on the _id field, so use a unique field stored in the document as the tiebreaker.
    + +To count the results, decorate the adapter with a [`CountingCursorAdapter`](#counting) using an `ElasticaAdapter` for the same query. + ### Solarium The Solarium adapter is available with the `pagerfanta/solarium-adapter` package for use with [Solarium](https://github.com/solariumphp/solarium). @@ -272,6 +383,29 @@ $query->setQuery('search term'); $adapter = new SolariumAdapter($solarium, $query); ``` +#### Cursor Pagination + +
    The SolariumCursorAdapter was introduced in Pagerfanta 4.10.
    + +The `SolariumCursorAdapter` supports [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) using Solr's `cursorMark`. The query's sort must include the collection's `uniqueKey` field. + +```php +createSelect(); +$query->setQuery('search term'); +$query->addSort('published_at', $query::SORT_DESC); +$query->addSort('id', $query::SORT_ASC); + +$adapter = new SolariumCursorAdapter($solarium, $query); +``` + +Solr cursors can only move forward, so this adapter does not support backward navigation. + +To count the results, decorate the adapter with a [`CountingCursorAdapter`](#counting) using a `SolariumAdapter` for the same query. + ## First Party There are also several "first party" adapters which are not dependent upon an external storage solution. All first party adapters are available with the `pagerfanta/core` package. @@ -288,6 +422,20 @@ use Pagerfanta\Adapter\ArrayAdapter; $adapter = new ArrayAdapter([]); ``` +#### Cursor Pagination + +
    The ArrayCursorAdapter was introduced in Pagerfanta 4.10.
    + +The `ArrayCursorAdapter` is used for [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) of a pre-sorted array of items. It takes the array and a callable which returns the cursor fields for an item (the values of the fields the array is sorted by), with a signature of `function (mixed $item): array {}`. The fields must uniquely identify each item. + +```php + ['id' => $post['id']]); +``` + ### Callback The `CallbackAdapter` uses callable functions to process pagination. @@ -308,6 +456,47 @@ $adapter = new CallbackAdapter( ); ``` +#### Cursor Pagination + +
    The CallbackCursorAdapter was introduced in Pagerfanta 4.10.
    + +The `CallbackCursorAdapter` uses a callable to process [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination). The callable should have a signature of `function (?Cursor $cursor, int $limit): CursorSlice {}` and follow the [cursor adapter contract](/open-source/packages/pagerfanta/docs/4.x/adapter#cursor-adapters). Backward navigation is not supported unless enabled with the second argument. + +```php + new CursorSlice([]), + supportsBackwardNavigation: true, +); +``` + +### Counting + +
    The CountingCursorAdapter was introduced in Pagerfanta 4.10.
    + +The `CountingCursorAdapter` is a cursor adapter decorator which adds the total number of results to a cursor adapter, using either a callable with a signature of `function (): int {}` or an adapter implementing `Pagerfanta\Adapter\CountableAdapterInterface` (such as the offset adapter for the same data source). + +```php + ['id' => $post['id']]), + static fn (array $post, int $key): string => $post['title'] + ), + static fn (): int => \count($posts), +); +``` + ### Concatenation The `ConcatenationAdapter` allows querying results from multiple adapters. It keeps the order of the given adapters and the order of their results. @@ -365,6 +554,20 @@ if (!$shouldQuery) { } ``` +#### Cursor Pagination + +
    The EmptyCursorAdapter was introduced in Pagerfanta 4.10.
    + +The `EmptyCursorAdapter` is the cursor pagination equivalent of the `EmptyAdapter`. + +```php + $formatter->format($item) ); ``` + +#### Cursor Pagination + +
    The TransformingCursorAdapter was introduced in Pagerfanta 4.10.
    + +The `TransformingCursorAdapter` is the cursor pagination equivalent of the `TransformingAdapter`. The cursors are created by the decorated adapter from the untransformed items, so the transformation does not affect navigation. + +```php + ['id' => $post['id']]), + static fn (array $post, int $key): string => $post['title'] +); +``` diff --git a/docs/cursor-pagination.md b/docs/cursor-pagination.md new file mode 100644 index 00000000..596e1839 --- /dev/null +++ b/docs/cursor-pagination.md @@ -0,0 +1,142 @@ +# Cursor Pagination + +
    Cursor pagination was introduced in Pagerfanta 4.10.
    + +Pagerfanta supports two pagination strategies: + +- **Offset pagination** (the `Pagerfanta\Pagerfanta` class) identifies a page by its number, and fetches the items for a page by skipping the items on the pages before it. It always knows the total number of items and pages, so it can link to any page. +- **Cursor pagination** (also known as keyset pagination) identifies a page by a cursor pointing to an item, and fetches the items after (or before) that item. It performs consistently on large lists and is not affected by items being added or removed on earlier pages, but it can only link to the previous and next pages. + +## Pager Interfaces + +Both strategies share a common set of interfaces, so code such as views and serializers can work with any pager. + +| Interface | Description | +|--------------------------------------|---------------------------------------------------------------------------------------------------------------------------| +| `Pagerfanta\PagerInterface` | The root pager API: the current page results, the max per page, and whether (and where) there are previous and next pages | +| `Pagerfanta\CountablePagerInterface` | A pager which knows the total number of results with `getNbResults()` | +| `Pagerfanta\OffsetPagerInterface` | A countable pager using offset pagination, implemented by `Pagerfanta\Pagerfanta` | +| `Pagerfanta\CursorPagerInterface` | A pager using cursor pagination | + +The previous and next pages of any pager are described by a `Pagerfanta\Position\Position`, which is either a `Pagerfanta\Position\PagePosition` (holding a page number) or a `Pagerfanta\Position\CursorPosition` (holding a cursor). Use the `hasPreviousPage()` and `hasNextPage()` methods before calling `getPreviousPosition()` or `getNextPosition()`, which throw a `Pagerfanta\Exception\LogicException` if there is no page in that direction. + +```php +getCurrentPageResults() as $item) { + // ... + } + + if ($pager->hasNextPage()) { + $position = $pager->getNextPosition(); // A PagePosition or a CursorPosition + } +} +``` + +## Creating A Cursor Pager + +Cursor pagers are created with a cursor adapter (see the [available adapters](/open-source/packages/pagerfanta/docs/4.x/adapters)), the maximum number of items per page, and the position of the current page (or null for the first page). + +The `Pagerfanta\CursorPagerfantaFactory` creates the right pager for the adapter: a `Pagerfanta\CountableCursorPagerfanta` if the adapter can count its results (it implements `Pagerfanta\Adapter\CountableAdapterInterface`), otherwise a `Pagerfanta\CursorPagerfanta`. Check for `Pagerfanta\CountablePagerInterface` to find out whether the total number of results is available. + +```php + ['id' => $post['id']]); + +$pager = CursorPagerfantaFactory::create($adapter, 10); + +if ($pager instanceof CountablePagerInterface) { + $pager->getNbResults(); // The total number of posts +} +``` + +
    Unlike the Pagerfanta class, the count() method of the cursor pagers returns the number of items on the current page. Use getNbResults() on a countable pager for the total number of results.
    + +Cursor pagers are immutable. To move to another page, create a new pager for its position with the `withPosition()` method, which keeps the adapter and the maximum number of items per page. + +```php +hasNextPage()) { + $nextPager = $pager->withPosition($pager->getNextPosition()); +} +``` + +### Totals Are Opt-In + +Counting the results of a query is often the most expensive part of paginating it, and cursor pagination does not need the total to know whether there is another page. Cursor adapters therefore do not count their results unless they implement `Pagerfanta\Adapter\CountableAdapterInterface`, and a countable pager only counts the results when `getNbResults()` is called. + +To add a total to any cursor adapter, decorate it with the `Pagerfanta\Adapter\CountingCursorAdapter`, using either a callable returning the count or another adapter (such as the offset adapter for the same data source) to count with. + +```php +autoPagingIterator() as $item) { + // Iterate over each item from all pages of the result set +} +``` + +## Cursors + +A `Pagerfanta\Cursor\Cursor` holds the sort key values of the item to paginate from, keyed by the sort key (such as `['p.createdAt' => '2026-09-25 12:00:00', 'p.id' => 42]`), and the `Pagerfanta\Cursor\Direction` to paginate in (`Direction::Next` or `Direction::Previous`). The values must be scalars or null. + +### Encoding Cursors + +Cursors are converted to and from strings, such as for a query string parameter in a URL, with a `Pagerfanta\Cursor\CursorEncoderInterface`. The library provides the `Pagerfanta\Cursor\Base64JsonCursorEncoder`, which encodes cursors as URL-safe Base64 JSON strings. + +```php +encode($pager->getNextPosition()->cursor); + +// Decode the cursor from the request +try { + $position = isset($_GET['cursor']) ? new CursorPosition($encoder->decode($_GET['cursor'])) : null; +} catch (InvalidCursorException) { + // Respond with a 400 Bad Request error +} +``` + +
    The Base64JsonCursorEncoder does not sign the cursors it encodes, so a client can decode and alter a cursor to paginate from any position allowed by the underlying query. The adapters validate that a cursor matches their sort fields before using it, but if your application needs to prevent tampering, decorate the encoder with one which signs the payload.
    + +## Rendering Cursor Pagers + +Cursor pagers can only link to the previous and next pages, so they are rendered with a sequential view instead of a view with numbered page links. See the [views documentation](/open-source/packages/pagerfanta/docs/4.x/views#sequential-views) for details, and the [route generator documentation](/open-source/packages/pagerfanta/docs/4.x/route-generator#position-route-generators) for generating the URLs for cursors. diff --git a/docs/index.md b/docs/index.md index 497a75cb..0bb8ebef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ - [Usage](/open-source/packages/pagerfanta/docs/4.x/usage) - [Pagination Adapter](/open-source/packages/pagerfanta/docs/4.x/adapter) - [Available Adapters](/open-source/packages/pagerfanta/docs/4.x/adapters) +- [Cursor Pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) - [Views](/open-source/packages/pagerfanta/docs/4.x/views) - [Templates](/open-source/packages/pagerfanta/docs/4.x/templates) - [Route Generator](/open-source/packages/pagerfanta/docs/4.x/route-generator) diff --git a/docs/route-generator.md b/docs/route-generator.md index 85a13d75..ec5364fd 100644 --- a/docs/route-generator.md +++ b/docs/route-generator.md @@ -65,3 +65,55 @@ Included in the core API is the `Pagerfanta\RouteGenerator\RouteGeneratorDecorat The primary reason this class was created is to allow any generator to be used within Twig, but the decorator can also be used to enforce strict typehinting for generators.
    When using the Twig view, it will automatically decorate any route generator so you will not need to do this on your own.
    + +## Position Route Generators + +
    Position route generators were introduced in Pagerfanta 4.10.
    + +A page number cannot describe a page of a [cursor pager](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination), so pagers describe the pages they link to with a `Pagerfanta\Position\Position`: either a `Pagerfanta\Position\PagePosition` (holding a page number) or a `Pagerfanta\Position\CursorPosition` (holding a cursor). A position route generator implements `Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface` and builds the URL for a position. + +Cursors should be converted to strings with a [cursor encoder](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination#encoding-cursors) when building the URL. A route generator should throw a `Pagerfanta\Exception\InvalidArgumentException` for a position it does not support. + +```php + 'http://localhost/blog?page=' . $position->page, + $position instanceof CursorPosition => 'http://localhost/blog?cursor=' . $this->cursorEncoder->encode($position->cursor), + default => throw new InvalidArgumentException(sprintf('Unsupported position "%s".', get_debug_type($position))), + }; + } +} +``` + +
    In Pagerfanta 4.x, a plain callable given as a route generator is always treated as a page number based route generator. To use a callable as a position route generator, decorate it with the Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator class, which also provides a route() method for use in Twig templates.
    + +```php + /* ... */); +``` + +A page number based route generator can be used where a position route generator is expected by wrapping it with the `Pagerfanta\RouteGenerator\PageRouteGeneratorWrapper` class. The wrapper only supports page positions, so it can only be used with offset pagers. The `PageRouteGeneratorWrapper::wrap()` method wraps any route generator which is not already a position route generator. + +### Position Route Generator Factory + +Factories which can create position route generators implement `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface`. diff --git a/docs/templates.md b/docs/templates.md index 8d5625eb..023f8019 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -97,3 +97,47 @@ interface TemplateInterface public function separator(): string; } ``` + +## Sequential Templates + +
    Sequential templates were introduced in Pagerfanta 4.10.
    + +Pagerfanta defines `Pagerfanta\View\Template\SequentialTemplateInterface` which is used by the [sequential view](/open-source/packages/pagerfanta/docs/4.x/views#sequential-views) to render the previous and next links of any pager, using positions instead of page numbers. + +The interface requires several methods to be implemented: + +- `setPositionRouteGenerator`: Injects the position route generator to use while rendering the template +- `setOptions`: Sets options for the template +- `container`: Generates the wrapping container for the pagination list +- `previousDisabled`: Generates the markup for the previous page button in the disabled state +- `previousEnabledForPosition`: Generates the markup for the previous page button in the enabled state, linking to the given position +- `nextDisabled`: Generates the markup for the next page button in the disabled state +- `nextEnabledForPosition`: Generates the markup for the next page button in the enabled state, linking to the given position + +All of the templates provided by Pagerfanta implement both the `TemplateInterface` and the `SequentialTemplateInterface`, so they can be used with both the numbered and the sequential views. The `Pagerfanta\View\Template\Template` base class provides the `setPositionRouteGenerator()` method and a `generateRouteForPosition()` helper, so a custom template extending it only needs to declare the `SequentialTemplateInterface` and implement the `previousEnabledForPosition()` and `nextEnabledForPosition()` methods to support the sequential view. + +```php +hasNextPage()) { } ``` +
    The position helpers were introduced in Pagerfanta 4.10.
    + +You can also get the previous and next pages as a `Pagerfanta\Position\PagePosition` using the `getPreviousPosition` and `getNextPosition` methods respectively on the `Pagerfanta` instance. Positions describe a page independent of the pagination strategy, which allows code such as route generators and views to support both offset and [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination). + +```php +hasNextPage()) { + $pagerfanta->getNextPosition()->page; // Will return 2 +} +``` + +## Counting The Results + +The `Pagerfanta` class implements `\Countable`, and counting a `Pagerfanta` instance returns the total number of items in the list, the same as the `getNbResults` method. + +
    In Pagerfanta 5.0, counting a Pagerfanta instance will return the number of items on the current page, which is how the cursor pagers are counted. Use the getNbResults method to get the total number of items in the list.
    + ## Retrieving The Adapter If needed, you can retrieve the underlying adapter using the `getAdapter` method on the `Pagerfanta` instance. diff --git a/docs/views.md b/docs/views.md index c81ccf5d..f105c9a8 100644 --- a/docs/views.md +++ b/docs/views.md @@ -51,6 +51,47 @@ Below is a list of the views that are available with this package, and the corre | `twitter_bootstrap4` | `Pagerfanta\View\TwitterBootstrap4View` | `Pagerfanta\View\Template\TwitterBootstrap4Template` | | `twitter_bootstrap5` | `Pagerfanta\View\TwitterBootstrap5View` | `Pagerfanta\View\Template\TwitterBootstrap5Template` | +## Sequential Views + +
    Sequential views were introduced in Pagerfanta 4.10.
    + +The views above render numbered page links, which requires the total number of pages and is only possible with offset pagers. Sequential views only render links to the previous and next pages, so they can render any pager, including [cursor pagers](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination). + +Views which can render any pager implement `Pagerfanta\View\PagerViewInterface`, which extends `ViewInterface` with a `render` method accepting any pager and a `supports` method to check whether the view can render a pager. + +```php +render($pager, $routeGenerator, ['prev_message' => 'Newer', 'next_message' => 'Older']); +``` + +The previous link is disabled when there is no previous page, and omitted entirely for cursor pagers which do not support backward navigation. + +
    To render a cursor pager, the route generator must be a position route generator, see the route generator documentation. A plain callable is treated as a page number based route generator, which can only render offset pagers.
    + ## Twig View Pagerfanta includes native support for the [Twig](https://twig.symfony.com/) templating engine and allows integrators to build flexible templates for rendering their pagers. @@ -111,6 +152,28 @@ $environment->addRuntimeLoader(new ContainerRuntimeLoader($container)); $environment->addExtension(new PagerfantaExtension()); ``` +### Rendering Cursor Pagers + +
    Rendering cursor pagers with the Twig view was introduced in Pagerfanta 4.10.
    + +The Twig view implements `Pagerfanta\View\PagerViewInterface`. Offset pagers are rendered with numbered page links, and all other pagers (such as cursor pagers) are rendered with previous and next links using the same templates. To render an offset pager with only previous and next links, set the `sequential` option. + +```twig +{{ pagerfanta(pager, 'twig', {'sequential': true}) }} +``` + +When the view given to the `pagerfanta()` function (or the default view) cannot render a pager, such as a cursor pager with a numbered view, an exception is thrown. To render these pagers with another view instead, give the name of a default sequential view as the fourth argument of the `Pagerfanta\Twig\Extension\PagerfantaRuntime` constructor. A view explicitly named in the `pagerfanta()` function never falls back to the default sequential view. + +The `pagerfanta_position_url()` function generates the URL for a position, such as the next page of a pager. + +```twig +{% if pager.hasNextPage() %} + Load more +{% endif %} +``` + +When the route generator factory given to the runtime implements `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface`, it is used to create the route generators for views implementing `PagerViewInterface` and for the `pagerfanta_position_url()` function. Otherwise, the page number based route generators are adapted, which only support offset pagers. + ### Creating a Twig View Template If creating a custom template, you are encouraged to extend the `@Pagerfanta/default.html.twig` template and override only the blocks needed. @@ -129,6 +192,16 @@ When rendering a Twig view, the following options are passed into the template f - `end_page` - The calculated end page for the list of items displayed between separators, this is based on the `proximity` option and the total number of pages - `current_page` - The current page in the paginated list - `nb_pages` - The total number of pages in the paginated list +- `sequential` - Whether the pager is rendered with only previous and next links + +When rendering with only previous and next links, the `sequential_pager` block is rendered instead of the numbered page links, and the page number variables are not available. Instead, the following variables are passed into the template: + +- `route_generator` - A `Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator` object, whose `route()` method generates the URL for a position +- `supports_backward_navigation` - Whether the pager supports backward navigation, the previous link is omitted when it does not +- `previous_position` - The position of the previous page, or null if there is no previous page +- `next_position` - The position of the next page, or null if there is no next page + +
    The dispatch to the sequential_pager block is part of the pager block. If your template overrides the pager block, render the sequential_pager block from it when the sequential variable is true to support cursor pagers. Templates which only override the markup blocks (such as pager_widget, previous_page_link, and next_page_link) support cursor pagers without any changes.
    Additionally, for most page blocks (`previous_page_link`, `page_link`, `current_page_link`, and `next_page_link`), there are two additional variables available: From de005a219e2e9e3548048d50787de74f39f64334 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 15:29:07 -0400 Subject: [PATCH 15/20] Deprecate counting a Pagerfanta instance to get the total number of results --- lib/Core/Pagerfanta.php | 5 ++-- lib/Core/Tests/CapturesDeprecations.php | 35 +++++++++++++++++++++++++ lib/Core/composer.json | 3 ++- 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 lib/Core/Tests/CapturesDeprecations.php diff --git a/lib/Core/Pagerfanta.php b/lib/Core/Pagerfanta.php index af752d6a..eeb28925 100644 --- a/lib/Core/Pagerfanta.php +++ b/lib/Core/Pagerfanta.php @@ -418,8 +418,9 @@ public function getNextPosition(): PagePosition /** * Returns the total number of results. * - * In 5.0, this will return the number of items on the current page to match the other pager implementations, - * use {@see Pagerfanta::getNbResults()} to get the total number of results. + * Counting a Pagerfanta instance to get the total number of results is deprecated since 4.10. In 5.0, this will return + * the number of items on the current page to match the other pager implementations, use {@see getNbResults()} + * to get the total number of results. * * @return int<0, max> */ diff --git a/lib/Core/Tests/CapturesDeprecations.php b/lib/Core/Tests/CapturesDeprecations.php new file mode 100644 index 00000000..297714f4 --- /dev/null +++ b/lib/Core/Tests/CapturesDeprecations.php @@ -0,0 +1,35 @@ + + */ + private function captureDeprecations(callable $callback): array + { + $deprecations = []; + + set_error_handler( + static function (int $level, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, + \E_USER_DEPRECATED + ); + + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } +} diff --git a/lib/Core/composer.json b/lib/Core/composer.json index 827b55b5..7ee7de95 100644 --- a/lib/Core/composer.json +++ b/lib/Core/composer.json @@ -6,7 +6,8 @@ "license": "MIT", "require": { "php": "^8.1", - "ext-json": "*" + "ext-json": "*", + "symfony/deprecation-contracts": "^2.1 || ^3.0" }, "require-dev": { "phpunit/phpunit": "^10.5" From e39ca0a702034a0dc1e6acc392712844cd1fea5a Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 15:29:33 -0400 Subject: [PATCH 16/20] Deprecate the page number based route generator and view APIs --- docs/route-generator.md | 10 ++- docs/templates.md | 2 + docs/views.md | 8 ++- .../PageNumberRouteGenerator.php | 53 +++++++++++++++ .../PageRouteGeneratorWrapper.php | 2 + .../RouteGeneratorDecorator.php | 5 ++ .../RouteGeneratorFactoryInterface.php | 3 + .../RouteGeneratorInterface.php | 3 + .../PageNumberRouteGeneratorTest.php | 41 ++++++++++++ .../RouteGeneratorDecoratorTest.php | 26 ++++++++ lib/Core/Tests/View/OptionableViewTest.php | 51 +++++++++++++++ lib/Core/Tests/View/SequentialViewTest.php | 17 +++++ lib/Core/Tests/View/TemplateViewTest.php | 61 ++++++++++++++++++ lib/Core/View/OptionableView.php | 37 ++++++++++- lib/Core/View/SequentialView.php | 4 ++ lib/Core/View/Template/TemplateInterface.php | 9 ++- lib/Core/View/TemplateView.php | 23 ++++--- lib/Core/View/View.php | 12 ++++ lib/Core/View/ViewInterface.php | 17 +++-- lib/Twig/Extension/PagerfantaRuntime.php | 28 ++++++-- lib/Twig/Tests/CapturesDeprecations.php | 35 ++++++++++ .../Tests/Extension/PagerfantaRuntimeTest.php | 64 ++++++++++++++++++- .../TwigViewSequentialIntegrationTest.php | 35 +++++++++- lib/Twig/View/TwigView.php | 29 ++------- lib/Twig/composer.json | 3 +- 25 files changed, 525 insertions(+), 53 deletions(-) create mode 100644 lib/Core/RouteGenerator/PageNumberRouteGenerator.php create mode 100644 lib/Core/Tests/RouteGenerator/PageNumberRouteGeneratorTest.php create mode 100644 lib/Core/Tests/RouteGenerator/RouteGeneratorDecoratorTest.php create mode 100644 lib/Twig/Tests/CapturesDeprecations.php diff --git a/docs/route-generator.md b/docs/route-generator.md index ec5364fd..1e3222ba 100644 --- a/docs/route-generator.md +++ b/docs/route-generator.md @@ -8,14 +8,20 @@ A route generator is any callable which accepts a single `$page` parameter (the $routeGenerator = static fn (int $page): string => 'http://localhost/blog?page=' . $page; ``` +
    Page number based route generators are deprecated since Pagerfanta 4.10. Use position route generators instead, which support both offset and cursor pagination. In Pagerfanta 5.0, a callable route generator will be given a position instead of a page number.
    + ## Generator Interface It is recommended that route generators are classes which implement `Pagerfanta\RouteGenerator\RouteGeneratorInterface`. +
    The RouteGeneratorInterface is deprecated since Pagerfanta 4.10, implement the Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface instead.
    + ## Generator Factory Often, it is necessary to configure a route generator based on runtime information (such as data from the current request). The `Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface` defines a class which can assist in building your route generators. +
    The RouteGeneratorFactoryInterface is deprecated since Pagerfanta 4.10, implement the Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface instead.
    + A basic example of how these factories can be used is with a Twig extension when rendering your pagination list. ```php @@ -62,6 +68,8 @@ final class PagerfantaExtension extends AbstractExtension Included in the core API is the `Pagerfanta\RouteGenerator\RouteGeneratorDecorator` class which can be used to decorate any route generator, whether the generator implements the interface or any callable. +
    The RouteGeneratorDecorator is deprecated since Pagerfanta 4.10, use the Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator instead.
    + The primary reason this class was created is to allow any generator to be used within Twig, but the decorator can also be used to enforce strict typehinting for generators.
    When using the Twig view, it will automatically decorate any route generator so you will not need to do this on your own.
    @@ -112,7 +120,7 @@ use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; $routeGenerator = new PositionRouteGeneratorDecorator(static fn (Position $position): string => /* ... */); ``` -A page number based route generator can be used where a position route generator is expected by wrapping it with the `Pagerfanta\RouteGenerator\PageRouteGeneratorWrapper` class. The wrapper only supports page positions, so it can only be used with offset pagers. The `PageRouteGeneratorWrapper::wrap()` method wraps any route generator which is not already a position route generator. +All of the views provided by Pagerfanta accept position route generators. The numbered views give the route generator a `Pagerfanta\Position\PagePosition` for each page they link to. ### Position Route Generator Factory diff --git a/docs/templates.md b/docs/templates.md index 023f8019..81e60ba3 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -18,6 +18,8 @@ The interface requires several methods to be implemented: - `current`: Generates the markup for the current page button - `separator`: Generates the markup for a separator button, used to represent a break in a list of pages (i.e. 1, 2, ..., 6, 7) +
    In Pagerfanta 5.0, the TemplateInterface will extend the SequentialTemplateInterface, which adds the setPositionRouteGenerator, previousEnabledForPosition, and nextEnabledForPosition methods.
    + ```php Passing a page number based route generator to a view is deprecated since Pagerfanta 4.10, all of the views provided by Pagerfanta accept a position route generator. In Pagerfanta 5.0, the render method will accept any pager and a position route generator, and a supports method will be added to check whether a view can render a pager. Implement the Pagerfanta\View\PagerViewInterface to prepare for this change. + ## Base Classes Pagerfanta provides two base classes to build upon to assist in creating custom views. @@ -172,7 +174,9 @@ The `pagerfanta_position_url()` function generates the URL for a position, such {% endif %} ``` -When the route generator factory given to the runtime implements `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface`, it is used to create the route generators for views implementing `PagerViewInterface` and for the `pagerfanta_position_url()` function. Otherwise, the page number based route generators are adapted, which only support offset pagers. +When the route generator factory given to the runtime implements `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface`, it is used to create the route generators for the views and for the `pagerfanta_position_url()` function. Views which only accept page number based route generators are given one adapted from the position route generator. + +
    Giving the runtime a route generator factory which does not implement the PositionRouteGeneratorFactoryInterface is deprecated since Pagerfanta 4.10. Until then, its page number based route generators are adapted, which only support offset pagers.
    ### Creating a Twig View Template diff --git a/lib/Core/RouteGenerator/PageNumberRouteGenerator.php b/lib/Core/RouteGenerator/PageNumberRouteGenerator.php new file mode 100644 index 00000000..bb561db0 --- /dev/null +++ b/lib/Core/RouteGenerator/PageNumberRouteGenerator.php @@ -0,0 +1,53 @@ +routeGenerator = $routeGenerator; + } + + /** + * @throws LessThan1CurrentPageException if the page is less than 1 and the route generator is position based + */ + public function __invoke(int $page): string + { + return $this->route($page); + } + + /** + * @throws LessThan1CurrentPageException if the page is less than 1 and the route generator is position based + */ + public function route(int $page): string + { + $routeGenerator = $this->routeGenerator; + + if (!$routeGenerator instanceof PositionRouteGeneratorInterface) { + return $routeGenerator($page); + } + + if ($page < 1) { + throw new LessThan1CurrentPageException(); + } + + return $routeGenerator(new PagePosition($page)); + } +} diff --git a/lib/Core/RouteGenerator/PageRouteGeneratorWrapper.php b/lib/Core/RouteGenerator/PageRouteGeneratorWrapper.php index 725e56b5..3ca1f504 100644 --- a/lib/Core/RouteGenerator/PageRouteGeneratorWrapper.php +++ b/lib/Core/RouteGenerator/PageRouteGeneratorWrapper.php @@ -10,6 +10,8 @@ * Adapts a page number based route generator to the position based API. * * Only {@see PagePosition} positions are supported. + * + * @internal */ final class PageRouteGeneratorWrapper implements PositionRouteGeneratorInterface { diff --git a/lib/Core/RouteGenerator/RouteGeneratorDecorator.php b/lib/Core/RouteGenerator/RouteGeneratorDecorator.php index 3380abdc..57171d24 100644 --- a/lib/Core/RouteGenerator/RouteGeneratorDecorator.php +++ b/lib/Core/RouteGenerator/RouteGeneratorDecorator.php @@ -2,6 +2,9 @@ namespace Pagerfanta\RouteGenerator; +/** + * @deprecated since Pagerfanta 4.10, use {@see PositionRouteGeneratorDecorator} instead + */ final class RouteGeneratorDecorator implements RouteGeneratorInterface { /** @@ -14,6 +17,8 @@ final class RouteGeneratorDecorator implements RouteGeneratorInterface */ public function __construct(callable $decorated) { + trigger_deprecation('pagerfanta/core', '4.10', 'The "%s" class is deprecated, use "%s" instead.', self::class, PositionRouteGeneratorDecorator::class); + $this->decorated = $decorated; } diff --git a/lib/Core/RouteGenerator/RouteGeneratorFactoryInterface.php b/lib/Core/RouteGenerator/RouteGeneratorFactoryInterface.php index 4749e24e..10ef1baf 100644 --- a/lib/Core/RouteGenerator/RouteGeneratorFactoryInterface.php +++ b/lib/Core/RouteGenerator/RouteGeneratorFactoryInterface.php @@ -4,6 +4,9 @@ use Pagerfanta\Exception\RuntimeException; +/** + * @deprecated since Pagerfanta 4.10, implement {@see PositionRouteGeneratorFactoryInterface} instead + */ interface RouteGeneratorFactoryInterface { /** diff --git a/lib/Core/RouteGenerator/RouteGeneratorInterface.php b/lib/Core/RouteGenerator/RouteGeneratorInterface.php index aa5fe16b..9654192f 100644 --- a/lib/Core/RouteGenerator/RouteGeneratorInterface.php +++ b/lib/Core/RouteGenerator/RouteGeneratorInterface.php @@ -2,6 +2,9 @@ namespace Pagerfanta\RouteGenerator; +/** + * @deprecated since Pagerfanta 4.10, implement {@see PositionRouteGeneratorInterface} instead + */ interface RouteGeneratorInterface { /** diff --git a/lib/Core/Tests/RouteGenerator/PageNumberRouteGeneratorTest.php b/lib/Core/Tests/RouteGenerator/PageNumberRouteGeneratorTest.php new file mode 100644 index 00000000..ad0350e7 --- /dev/null +++ b/lib/Core/Tests/RouteGenerator/PageNumberRouteGeneratorTest.php @@ -0,0 +1,41 @@ + $position instanceof PagePosition ? '/posts?page='.$position->page : '/posts'); + } + + public function testAPositionRouteGeneratorIsGivenAPagePosition(): void + { + $generator = new PageNumberRouteGenerator($this->createPositionRouteGenerator()); + + $this->assertSame('/posts?page=2', $generator(2)); + $this->assertSame('/posts?page=3', $generator->route(3)); + } + + public function testAPositionRouteGeneratorCannotBeGivenAPageLessThan1(): void + { + $this->expectException(LessThan1CurrentPageException::class); + + (new PageNumberRouteGenerator($this->createPositionRouteGenerator()))->route(0); + } + + public function testAPageNumberRouteGeneratorIsGivenThePageAsIs(): void + { + $generator = new PageNumberRouteGenerator(static fn (int $page): string => '/posts?page='.$page); + + $this->assertSame('/posts?page=2', $generator(2)); + $this->assertSame('/posts?page=0', $generator->route(0)); + } +} diff --git a/lib/Core/Tests/RouteGenerator/RouteGeneratorDecoratorTest.php b/lib/Core/Tests/RouteGenerator/RouteGeneratorDecoratorTest.php new file mode 100644 index 00000000..367f6dd6 --- /dev/null +++ b/lib/Core/Tests/RouteGenerator/RouteGeneratorDecoratorTest.php @@ -0,0 +1,26 @@ +captureDeprecations(function (): void { + $generator = new RouteGeneratorDecorator(static fn (int $page): string => '/posts?page='.$page); + + $this->assertSame('/posts?page=2', $generator(2)); + $this->assertSame('/posts?page=3', $generator->route(3)); + }); + + $this->assertSame(['Since pagerfanta/core 4.10: The "Pagerfanta\\RouteGenerator\\RouteGeneratorDecorator" class is deprecated, use "Pagerfanta\\RouteGenerator\\PositionRouteGeneratorDecorator" instead.'], $deprecations); + } +} diff --git a/lib/Core/Tests/View/OptionableViewTest.php b/lib/Core/Tests/View/OptionableViewTest.php index 298b3d99..e85106f8 100644 --- a/lib/Core/Tests/View/OptionableViewTest.php +++ b/lib/Core/Tests/View/OptionableViewTest.php @@ -2,8 +2,20 @@ namespace Pagerfanta\Tests\View; +use Pagerfanta\Adapter\ArrayAdapter; +use Pagerfanta\Adapter\CallbackCursorAdapter; +use Pagerfanta\Adapter\CursorSlice; +use Pagerfanta\CursorPagerfanta; +use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; +use Pagerfanta\Position\PagePosition; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; +use Pagerfanta\View\DefaultView; use Pagerfanta\View\OptionableView; +use Pagerfanta\View\SequentialView; +use Pagerfanta\View\Template\DefaultTemplate; use Pagerfanta\View\ViewInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -65,4 +77,43 @@ private function createViewMock(array $expectedOptions): MockObject&ViewInterfac return $view; } + + private function createPositionRouteGenerator(): PositionRouteGeneratorDecorator + { + return new PositionRouteGeneratorDecorator(static fn (Position $position): string => '|'.($position instanceof PagePosition ? $position->page : 'cursor').'|'); + } + + public function testAPositionRouteGeneratorIsGivenToAViewAcceptingIt(): void + { + $pagerfanta = Pagerfanta::createForCurrentPageWithMaxPerPage(new ArrayAdapter(range(1, 30)), 2, 10); + + $this->assertStringContainsString( + 'href="|3|" rel="next">Siguiente', + (new OptionableView(new DefaultView(), ['next_message' => 'Siguiente']))->render($pagerfanta, $this->createPositionRouteGenerator()), + ); + } + + public function testAPositionRouteGeneratorIsAdaptedForAViewWhichOnlyAcceptsPageNumbers(): void + { + $view = $this->createMock(ViewInterface::class); + $view->expects($this->once()) + ->method('render') + ->willReturnCallback(function (PagerfantaInterface $pagerfanta, callable $routeGenerator): string { + $this->assertNotInstanceOf(PositionRouteGeneratorInterface::class, $routeGenerator); + + return $routeGenerator(4); + }); + + $this->assertSame('|4|', (new OptionableView($view, []))->render($this->pagerfanta, $this->createPositionRouteGenerator())); + } + + public function testTheSupportedPagersComeFromTheDecoratedView(): void + { + $cursorPager = new CursorPagerfanta(new CallbackCursorAdapter(static fn (): CursorSlice => new CursorSlice([]))); + + $this->assertTrue((new OptionableView(new DefaultView(), []))->supports($this->pagerfanta)); + $this->assertFalse((new OptionableView(new DefaultView(), []))->supports($cursorPager)); + $this->assertTrue((new OptionableView(new SequentialView(new DefaultTemplate()), []))->supports($cursorPager)); + $this->assertFalse((new OptionableView($this->createMock(ViewInterface::class), []))->supports($cursorPager), 'A view without a supports() method only supports offset pagers'); + } } diff --git a/lib/Core/Tests/View/SequentialViewTest.php b/lib/Core/Tests/View/SequentialViewTest.php index 5a80acb2..5010f00d 100644 --- a/lib/Core/Tests/View/SequentialViewTest.php +++ b/lib/Core/Tests/View/SequentialViewTest.php @@ -16,6 +16,7 @@ use Pagerfanta\Position\PagePosition; use Pagerfanta\Position\Position; use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; +use Pagerfanta\Tests\CapturesDeprecations; use Pagerfanta\View\DefaultView; use Pagerfanta\View\SequentialView; use Pagerfanta\View\Template\DefaultTemplate; @@ -27,10 +28,13 @@ use Pagerfanta\View\Template\TwitterBootstrap5Template; use Pagerfanta\View\Template\TwitterBootstrapTemplate; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; final class SequentialViewTest extends TestCase { + use CapturesDeprecations; + /** * The markup of each theme, as sprintf formats: the container (with the links), the enabled previous and next links (with the URL), and the disabled previous and next links. * @@ -290,4 +294,17 @@ public function testTheOptionsSetOnTheTemplateAreKeptForEachRender(): void $this->assertStringContainsString('rel="prev">Newer', $second); $this->assertStringContainsString('rel="next">Next', $second); } + + #[Group('legacy')] + public function testAPageNumberRouteGeneratorIsDeprecated(): void + { + $deprecations = $this->captureDeprecations(fn () => (new SequentialView(new DefaultTemplate()))->render($this->createOffsetPager(30, 2), static fn (int $page): string => '|'.$page.'|')); + + $this->assertSame(['Since pagerfanta/core 4.10: Passing a page number based route generator to "Pagerfanta\\View\\SequentialView::render()" is deprecated, pass an instance of "Pagerfanta\\RouteGenerator\\PositionRouteGeneratorInterface" instead.'], $deprecations); + } + + public function testAPositionRouteGeneratorIsNotDeprecated(): void + { + $this->assertSame([], $this->captureDeprecations(fn () => (new SequentialView(new DefaultTemplate()))->render($this->createCursorPager(true), $this->createPositionRouteGenerator()))); + } } diff --git a/lib/Core/Tests/View/TemplateViewTest.php b/lib/Core/Tests/View/TemplateViewTest.php index 1b044ca4..dd28fb96 100644 --- a/lib/Core/Tests/View/TemplateViewTest.php +++ b/lib/Core/Tests/View/TemplateViewTest.php @@ -3,13 +3,23 @@ namespace Pagerfanta\Tests\View; use Pagerfanta\Adapter\ArrayAdapter; +use Pagerfanta\Adapter\CallbackCursorAdapter; +use Pagerfanta\Adapter\CursorSlice; +use Pagerfanta\CursorPagerfanta; use Pagerfanta\Pagerfanta; +use Pagerfanta\Position\PagePosition; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; +use Pagerfanta\Tests\CapturesDeprecations; use Pagerfanta\View\DefaultView; use Pagerfanta\View\Template\DefaultTemplate; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; final class TemplateViewTest extends TestCase { + use CapturesDeprecations; + /** * @return Pagerfanta */ @@ -45,4 +55,55 @@ public function testTheOptionsSetOnTheTemplateAreKeptForEachRender(): void $this->assertStringContainsString('rel="prev">Newer', $second); $this->assertStringContainsString('rel="next">Next', $second); } + + public function testAPositionRouteGeneratorIsGivenPagePositions(): void + { + $view = new DefaultView(); + + $routeGenerator = new PositionRouteGeneratorDecorator(static fn (Position $position): string => '|'.($position instanceof PagePosition ? $position->page : 'cursor').'|'); + + $rendered = null; + $deprecations = $this->captureDeprecations(function () use ($view, $routeGenerator, &$rendered): void { + $rendered = $view->render($this->createPagerfanta(), $routeGenerator); + }); + + $this->assertSame([], $deprecations); + $this->assertSame( + $this->captureRender($view, static fn (int $page): string => '|'.$page.'|'), + $rendered, + 'The output matches a page number based route generator', + ); + } + + #[Group('legacy')] + public function testAPageNumberRouteGeneratorIsDeprecated(): void + { + $deprecations = $this->captureDeprecations(fn () => (new DefaultView())->render($this->createPagerfanta(), static fn (int $page): string => '|'.$page.'|')); + + $this->assertSame(['Since pagerfanta/core 4.10: Passing a page number based route generator to "Pagerfanta\\View\\DefaultView::render()" is deprecated, pass an instance of "Pagerfanta\\RouteGenerator\\PositionRouteGeneratorInterface" instead.'], $deprecations); + } + + public function testANumberedViewOnlySupportsOffsetPagers(): void + { + $view = new DefaultView(); + + $this->assertTrue($view->supports($this->createPagerfanta())); + $this->assertFalse($view->supports(new CursorPagerfanta(new CallbackCursorAdapter(static fn (): CursorSlice => new CursorSlice([]))))); + } + + /** + * Renders the view with a page number based route generator, ignoring its deprecation. + * + * @param callable(int): string $routeGenerator + */ + private function captureRender(DefaultView $view, callable $routeGenerator): string + { + $rendered = ''; + + $this->captureDeprecations(function () use ($view, $routeGenerator, &$rendered): void { + $rendered = $view->render($this->createPagerfanta(), $routeGenerator); + }); + + return $rendered; + } } diff --git a/lib/Core/View/OptionableView.php b/lib/Core/View/OptionableView.php index d8c23f36..266c7d71 100644 --- a/lib/Core/View/OptionableView.php +++ b/lib/Core/View/OptionableView.php @@ -3,6 +3,10 @@ namespace Pagerfanta\View; use Pagerfanta\PagerfantaInterface; +use Pagerfanta\PagerInterface; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PageNumberRouteGenerator; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; /** @@ -19,17 +23,44 @@ public function __construct( ) {} /** - * @param array $options - * - * @phpstan-param callable(int $page): string|RouteGeneratorInterface $routeGenerator + * @param PagerfantaInterface $pagerfanta + * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator + * @param array $options */ public function render(PagerfantaInterface $pagerfanta, callable $routeGenerator, array $options = []): string { + // Views which do not accept position route generators are given a page number based route generator + if ($routeGenerator instanceof PositionRouteGeneratorInterface && !self::acceptsPositionRouteGenerators($this->view)) { + $routeGenerator = new PageNumberRouteGenerator($routeGenerator); + } + return $this->view->render($pagerfanta, $routeGenerator, [...$this->defaultOptions, ...$options]); } + /** + * @param PagerfantaInterface|PagerInterface $pager + */ + public function supports(PagerfantaInterface|PagerInterface $pager): bool + { + if ($this->view instanceof PagerViewInterface || $this->view instanceof View || $this->view instanceof self) { + return $this->view->supports($pager); + } + + return $pager instanceof PagerfantaInterface; + } + public function getName(): string { return 'optionable'; } + + /** + * Checks whether a view is known to accept position route generators. + * + * @internal + */ + public static function acceptsPositionRouteGenerators(ViewInterface $view): bool + { + return $view instanceof PagerViewInterface || $view instanceof TemplateView || $view instanceof self; + } } diff --git a/lib/Core/View/SequentialView.php b/lib/Core/View/SequentialView.php index 29023a2e..95d1821a 100644 --- a/lib/Core/View/SequentialView.php +++ b/lib/Core/View/SequentialView.php @@ -44,6 +44,10 @@ public function supports(PagerfantaInterface|PagerInterface $pager): bool */ public function render(PagerfantaInterface|PagerInterface $pager, callable $routeGenerator, array $options = []): string { + if (!$routeGenerator instanceof PositionRouteGeneratorInterface) { + trigger_deprecation('pagerfanta/core', '4.10', 'Passing a page number based route generator to "%s::render()" is deprecated, pass an instance of "%s" instead.', self::class, PositionRouteGeneratorInterface::class); + } + // Render from a copy of the template so the options and route generator of this render are not reused by the next one $template = clone $this->template; $template->setPositionRouteGenerator(PageRouteGeneratorWrapper::wrap($routeGenerator)); diff --git a/lib/Core/View/Template/TemplateInterface.php b/lib/Core/View/Template/TemplateInterface.php index 0cd84a68..d3cad5b3 100644 --- a/lib/Core/View/Template/TemplateInterface.php +++ b/lib/Core/View/Template/TemplateInterface.php @@ -2,9 +2,16 @@ namespace Pagerfanta\View\Template; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; -interface TemplateInterface +/** + * @method void setPositionRouteGenerator(PositionRouteGeneratorInterface $routeGenerator) + * @method string previousEnabledForPosition(Position $position) + * @method string nextEnabledForPosition(Position $position) + */ +interface TemplateInterface /* extends SequentialTemplateInterface */ { /** * Sets the route generator used while rendering the template. diff --git a/lib/Core/View/TemplateView.php b/lib/Core/View/TemplateView.php index a9d8ac37..36ee16b7 100644 --- a/lib/Core/View/TemplateView.php +++ b/lib/Core/View/TemplateView.php @@ -3,6 +3,8 @@ namespace Pagerfanta\View; use Pagerfanta\PagerfantaInterface; +use Pagerfanta\RouteGenerator\PageNumberRouteGenerator; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; use Pagerfanta\View\Template\TemplateInterface; @@ -27,14 +29,19 @@ public function __construct(?TemplateInterface $template = null) abstract protected function createDefaultTemplate(): TemplateInterface; /** - * @param PagerfantaInterface $pagerfanta - * @param callable|RouteGeneratorInterface $routeGenerator - * @param array $options - * - * @phpstan-param callable(int $page): string|RouteGeneratorInterface $routeGenerator + * @param PagerfantaInterface $pagerfanta + * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator + * @param array $options */ public function render(PagerfantaInterface $pagerfanta, callable $routeGenerator, array $options = []): string { + if ($routeGenerator instanceof PositionRouteGeneratorInterface) { + // The numbered templates link to pages by their number + $routeGenerator = new PageNumberRouteGenerator($routeGenerator); + } else { + trigger_deprecation('pagerfanta/core', '4.10', 'Passing a page number based route generator to "%s::render()" is deprecated, pass an instance of "%s" instead.', static::class, PositionRouteGeneratorInterface::class); + } + $this->template = clone $this->baseTemplate; $this->initializePagerfanta($pagerfanta); @@ -46,10 +53,10 @@ public function render(PagerfantaInterface $pagerfanta, callable $routeGenerator } /** - * @param callable(int $page): string|RouteGeneratorInterface $routeGenerator - * @param array $options + * @param callable(int $page): string $routeGenerator + * @param array $options */ - private function configureTemplate(callable|RouteGeneratorInterface $routeGenerator, array $options): void + private function configureTemplate(callable $routeGenerator, array $options): void { $this->template->setRouteGenerator($routeGenerator); $this->template->setOptions($options); diff --git a/lib/Core/View/View.php b/lib/Core/View/View.php index 196da010..1b97aaf9 100644 --- a/lib/Core/View/View.php +++ b/lib/Core/View/View.php @@ -3,6 +3,8 @@ namespace Pagerfanta\View; use Pagerfanta\PagerfantaInterface; +use Pagerfanta\PagerInterface; +use Pagerfanta\Position\Position; abstract class View implements ViewInterface { @@ -32,6 +34,16 @@ abstract class View implements ViewInterface */ protected ?int $endPage = null; + /** + * Checks whether this view can render the given pager, numbered views can only render offset pagers. + * + * @param PagerfantaInterface|PagerInterface $pager + */ + public function supports(PagerfantaInterface|PagerInterface $pager): bool + { + return $pager instanceof PagerfantaInterface; + } + /** * @param PagerfantaInterface $pagerfanta */ diff --git a/lib/Core/View/ViewInterface.php b/lib/Core/View/ViewInterface.php index 570e6ac0..26923a5c 100644 --- a/lib/Core/View/ViewInterface.php +++ b/lib/Core/View/ViewInterface.php @@ -3,16 +3,25 @@ namespace Pagerfanta\View; use Pagerfanta\PagerfantaInterface; +use Pagerfanta\PagerInterface; +use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; +/** + * In 5.0, the render() method will accept any {@see PagerInterface} and a {@see PositionRouteGeneratorInterface}, and the + * supports() method will be added. Implement {@see PagerViewInterface} to prepare for this change. + * + * @method bool supports(PagerfantaInterface|PagerInterface $pager) + */ interface ViewInterface { /** - * @param PagerfantaInterface $pagerfanta - * @param callable|RouteGeneratorInterface $routeGenerator - * @param array $options + * Passing a page number based route generator is deprecated since 4.10, views should accept a {@see PositionRouteGeneratorInterface}. * - * @phpstan-param callable(int $page): string|RouteGeneratorInterface $routeGenerator + * @param PagerfantaInterface $pagerfanta + * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator + * @param array $options */ public function render(PagerfantaInterface $pagerfanta, callable $routeGenerator, array $options = []): string; diff --git a/lib/Twig/Extension/PagerfantaRuntime.php b/lib/Twig/Extension/PagerfantaRuntime.php index d7fed603..8960c273 100644 --- a/lib/Twig/Extension/PagerfantaRuntime.php +++ b/lib/Twig/Extension/PagerfantaRuntime.php @@ -7,11 +7,12 @@ use Pagerfanta\PagerfantaInterface; use Pagerfanta\PagerInterface; use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PageNumberRouteGenerator; use Pagerfanta\RouteGenerator\PageRouteGeneratorWrapper; use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; -use Pagerfanta\RouteGenerator\RouteGeneratorInterface; +use Pagerfanta\View\OptionableView; use Pagerfanta\View\PagerViewInterface; use Pagerfanta\View\ViewFactoryInterface; use Pagerfanta\View\ViewInterface; @@ -25,9 +26,13 @@ final class PagerfantaRuntime implements RuntimeExtensionInterface public function __construct( private readonly string $defaultView, private readonly ViewFactoryInterface $viewFactory, - private readonly RouteGeneratorFactoryInterface $routeGeneratorFactory, + private readonly RouteGeneratorFactoryInterface|PositionRouteGeneratorFactoryInterface $routeGeneratorFactory, private readonly ?string $defaultSequentialView = null, - ) {} + ) { + if (!$routeGeneratorFactory instanceof PositionRouteGeneratorFactoryInterface) { + trigger_deprecation('pagerfanta/twig', '4.10', 'Using a route generator factory which does not implement "%s" with "%s" is deprecated.', PositionRouteGeneratorFactoryInterface::class, self::class); + } + } /** * @param PagerfantaInterface|PagerInterface $pagerfanta @@ -51,7 +56,10 @@ public function renderPagerfanta(PagerfantaInterface|PagerInterface $pagerfanta, \assert($pagerfanta instanceof PagerfantaInterface); - return $view->render($pagerfanta, $this->createRouteGenerator($options), $options); + // Before 5.0, views are only required to accept page number based route generators + $routeGenerator = OptionableView::acceptsPositionRouteGenerators($view) ? $this->createPositionRouteGenerator($options) : $this->createRouteGenerator($options); + + return $view->render($pagerfanta, $routeGenerator, $options); } /** @@ -122,11 +130,19 @@ private function viewSupports(ViewInterface $view, PagerfantaInterface|PagerInte } /** + * Creates a page number based route generator, adapting a position route generator when the factory does not support page numbers. + * * @param array $options + * + * @return callable(int): string */ - private function createRouteGenerator(array $options = []): RouteGeneratorInterface + private function createRouteGenerator(array $options = []): callable { - return $this->routeGeneratorFactory->create($options); + if ($this->routeGeneratorFactory instanceof RouteGeneratorFactoryInterface) { + return $this->routeGeneratorFactory->create($options); + } + + return new PageNumberRouteGenerator($this->routeGeneratorFactory->createPositionRouteGenerator($options)); } /** diff --git a/lib/Twig/Tests/CapturesDeprecations.php b/lib/Twig/Tests/CapturesDeprecations.php new file mode 100644 index 00000000..fd308d57 --- /dev/null +++ b/lib/Twig/Tests/CapturesDeprecations.php @@ -0,0 +1,35 @@ + + */ + private function captureDeprecations(callable $callback): array + { + $deprecations = []; + + set_error_handler( + static function (int $level, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, + \E_USER_DEPRECATED + ); + + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } +} diff --git a/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php b/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php index 39e32b25..9ac58d58 100644 --- a/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php +++ b/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php @@ -10,24 +10,29 @@ use Pagerfanta\Exception\InvalidArgumentException; use Pagerfanta\Exception\OutOfRangeCurrentPageException; use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; use Pagerfanta\Position\CursorPosition; use Pagerfanta\Position\PagePosition; use Pagerfanta\Position\Position; use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; -use Pagerfanta\RouteGenerator\RouteGeneratorDecorator; use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; +use Pagerfanta\Twig\Tests\CapturesDeprecations; use Pagerfanta\Twig\Extension\PagerfantaRuntime; use Pagerfanta\View\DefaultView; use Pagerfanta\View\SequentialView; use Pagerfanta\View\Template\DefaultTemplate; +use Pagerfanta\View\ViewInterface; +use PHPUnit\Framework\Attributes\Group; use Pagerfanta\View\ViewFactory; use PHPUnit\Framework\TestCase; final class PagerfantaRuntimeTest extends TestCase { + use CapturesDeprecations; + private PagerfantaRuntime $extension; protected function setUp(): void @@ -186,7 +191,12 @@ private function createPositionRouteGeneratorFactory(): RouteGeneratorFactoryInt */ public function create(array $options = []): RouteGeneratorInterface { - return new RouteGeneratorDecorator(static fn (int $page): string => '/my-page?page='.$page); + return new class implements RouteGeneratorInterface { + public function __invoke(int $page): string + { + return '/my-page?page='.$page; + } + }; } /** @@ -275,4 +285,54 @@ public function testACursorPositionUrlCannotBeGeneratedWhenTheFactoryDoesNotSupp $this->extension->getPositionUrl(new CursorPosition(new Cursor(['id' => 3]))); } + + #[Group('legacy')] + public function testARouteGeneratorFactoryWithoutPositionSupportIsDeprecated(): void + { + $deprecations = $this->captureDeprecations(fn () => new PagerfantaRuntime('default', $this->createViewFactory(), $this->createRouteGeneratorFactory())); + + $this->assertSame(['Since pagerfanta/twig 4.10: Using a route generator factory which does not implement "Pagerfanta\\RouteGenerator\\PositionRouteGeneratorFactoryInterface" with "Pagerfanta\\Twig\\Extension\\PagerfantaRuntime" is deprecated.'], $deprecations); + } + + public function testRenderingWithAPositionRouteGeneratorFactoryIsNotDeprecated(): void + { + $this->assertSame([], $this->captureDeprecations(function (): void { + $runtime = new PagerfantaRuntime('default', $this->createViewFactory(), $this->createPositionRouteGeneratorFactory(), 'sequential'); + + $runtime->renderPagerfanta($this->createPagerfanta()); + $runtime->renderPagerfanta($this->createCursorPager()); + $runtime->getPageUrl($this->createPagerfanta(), 2); + })); + } + + public function testAViewWhichOnlyAcceptsPageNumbersIsGivenAPageNumberRouteGenerator(): void + { + $view = $this->createMock(ViewInterface::class); + $view->method('render') + ->willReturnCallback(function (PagerfantaInterface $pagerfanta, callable $routeGenerator): string { + $this->assertNotInstanceOf(PositionRouteGeneratorInterface::class, $routeGenerator); + + return $routeGenerator(2); + }); + + $viewFactory = new ViewFactory(); + $viewFactory->set('legacy', $view); + + $this->assertSame('/my-page?page=2', (new PagerfantaRuntime('legacy', $viewFactory, $this->createPositionRouteGeneratorFactory()))->renderPagerfanta($this->createPagerfanta())); + } + + public function testAPageUrlCanBeGeneratedWithAFactoryOnlySupportingPositions(): void + { + $factory = new class implements PositionRouteGeneratorFactoryInterface { + /** + * @param array $options + */ + public function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface + { + return new PositionRouteGeneratorDecorator(static fn (Position $position): string => '/my-page?page='.($position instanceof PagePosition ? $position->page : 0)); + } + }; + + $this->assertSame('/my-page?page=3', (new PagerfantaRuntime('default', $this->createViewFactory(), $factory))->getPageUrl($this->createPagerfanta(), 3)); + } } diff --git a/lib/Twig/Tests/View/TwigViewSequentialIntegrationTest.php b/lib/Twig/Tests/View/TwigViewSequentialIntegrationTest.php index 89db1d75..9774f2b8 100644 --- a/lib/Twig/Tests/View/TwigViewSequentialIntegrationTest.php +++ b/lib/Twig/Tests/View/TwigViewSequentialIntegrationTest.php @@ -15,14 +15,15 @@ use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; -use Pagerfanta\RouteGenerator\RouteGeneratorDecorator; use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; use Pagerfanta\Twig\Extension\PagerfantaExtension; use Pagerfanta\Twig\Extension\PagerfantaRuntime; +use Pagerfanta\Twig\Tests\CapturesDeprecations; use Pagerfanta\Twig\View\TwigView; use Pagerfanta\View\ViewFactory; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Twig\BlockChain; use Twig\Environment; @@ -36,6 +37,8 @@ */ final class TwigViewSequentialIntegrationTest extends TestCase { + use CapturesDeprecations; + private const MESSAGES_TEMPLATE = << '/posts?page='.$page); + return new class implements RouteGeneratorInterface { + public function __invoke(int $page): string + { + return '/posts?page='.$page; + } + }; } /** @@ -253,4 +261,27 @@ private function assertViewOutputMatches(string $expected, string $view): void { $this->assertSame($expected, preg_replace('/>\s+<', $view)); } + + #[Group('legacy')] + public function testAPageNumberRouteGeneratorIsDeprecated(): void + { + $deprecations = $this->captureDeprecations(fn () => (new TwigView($this->twig))->render($this->createOffsetPager(), static fn (int $page): string => '/posts?page='.$page)); + + $this->assertSame(['Since pagerfanta/twig 4.10: Passing a page number based route generator to "Pagerfanta\\Twig\\View\\TwigView::render()" is deprecated, pass an instance of "Pagerfanta\\RouteGenerator\\PositionRouteGeneratorInterface" instead.'], $deprecations); + } + + public function testAPositionRouteGeneratorIsNotDeprecated(): void + { + $view = new TwigView($this->twig); + + $this->assertSame([], $this->captureDeprecations(function () use ($view): void { + $view->render($this->createOffsetPager(), $this->createPositionRouteGenerator()); + $view->render($this->createCursorPager(), $this->createPositionRouteGenerator()); + })); + } + + public function testAPositionRouteGeneratorFactoryIsNotDeprecated(): void + { + $this->assertSame([], $this->captureDeprecations(fn () => $this->twig->render('integration.html.twig', ['pager' => $this->createOffsetPager(), 'options' => []]))); + } } diff --git a/lib/Twig/View/TwigView.php b/lib/Twig/View/TwigView.php index 9ccf6a1d..ca440cc1 100644 --- a/lib/Twig/View/TwigView.php +++ b/lib/Twig/View/TwigView.php @@ -3,15 +3,14 @@ namespace Pagerfanta\Twig\View; use Pagerfanta\CursorPagerInterface; -use Pagerfanta\Exception\LessThan1CurrentPageException; use Pagerfanta\PagerfantaInterface; use Pagerfanta\PagerInterface; use Pagerfanta\Position\PagePosition; use Pagerfanta\Position\Position; +use Pagerfanta\RouteGenerator\PageNumberRouteGenerator; use Pagerfanta\RouteGenerator\PageRouteGeneratorWrapper; use Pagerfanta\RouteGenerator\PositionRouteGeneratorDecorator; use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; -use Pagerfanta\RouteGenerator\RouteGeneratorDecorator; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; use Pagerfanta\View\PagerViewInterface; use Pagerfanta\View\View; @@ -66,6 +65,10 @@ public function supports(PagerfantaInterface|PagerInterface $pager): bool */ public function render(PagerfantaInterface|PagerInterface $pager, callable $routeGenerator, array $options = []): string { + if (!$routeGenerator instanceof PositionRouteGeneratorInterface) { + trigger_deprecation('pagerfanta/twig', '4.10', 'Passing a page number based route generator to "%s::render()" is deprecated, pass an instance of "%s" instead.', self::class, PositionRouteGeneratorInterface::class); + } + if (!$pager instanceof PagerfantaInterface || true === ($options['sequential'] ?? false)) { return $this->renderSequential($pager, $routeGenerator, $options); } @@ -79,7 +82,7 @@ public function render(PagerfantaInterface|PagerInterface $pager, callable $rout 'pager_widget', [ 'pagerfanta' => $pager, - 'route_generator' => $this->decorateRouteGenerator($routeGenerator), + 'route_generator' => new PageNumberRouteGenerator($routeGenerator), 'options' => $options, 'sequential' => false, 'start_page' => $this->startPage, @@ -126,26 +129,6 @@ private function renderSequential(PagerfantaInterface|PagerInterface $pager, cal ); } - /** - * @param PositionRouteGeneratorInterface|RouteGeneratorInterface|callable(int): string $routeGenerator - */ - private function decorateRouteGenerator(callable $routeGenerator): RouteGeneratorDecorator - { - // Numbered pages are linked with page numbers, so a position route generator is given page positions - if ($routeGenerator instanceof PositionRouteGeneratorInterface) { - return new RouteGeneratorDecorator(static function (int $page) use ($routeGenerator): string { - // A template may link to any page number, which must still be a valid page - if ($page < 1) { - throw new LessThan1CurrentPageException(); - } - - return $routeGenerator(new PagePosition($page)); - }); - } - - return new RouteGeneratorDecorator($routeGenerator); - } - /** * @param string|list $template * diff --git a/lib/Twig/composer.json b/lib/Twig/composer.json index 26ba3fe7..807faba5 100644 --- a/lib/Twig/composer.json +++ b/lib/Twig/composer.json @@ -6,7 +6,8 @@ "license": "MIT", "require": { "php": "^8.1", - "pagerfanta/core": "^3.7 || ^4.0", + "pagerfanta/core": "^4.10", + "symfony/deprecation-contracts": "^2.1 || ^3.0", "twig/twig": "^2.13 || ^3.0" }, "require-dev": { From 602a9ab60ac1dfa43c8605251c3f96ba19d5f30c Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Fri, 25 Sep 2026 15:30:32 -0400 Subject: [PATCH 17/20] Add CHANGELOG entries --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3a6d9b..87b59e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - Fix the template views reusing the options from a previous render - Add support for `ruflin/elastica` 9.x - [#66](https://github.com/BabDev/Pagerfanta/issues/66) Improved handling of zero-length slices in the pagination adapters +- Add cursor pagination support +- Add support for rendering sequential pagination views (previous/next links only) +- Deprecate the page number based route generator and view APIs ## 4.9.0 (2026-09-08) From cc9b45eeff6c8457febbd97e18e3822a43060464 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 28 Sep 2026 15:33:34 -0400 Subject: [PATCH 18/20] Generate page URLs with the position route generator when available --- lib/Twig/Extension/PagerfantaRuntime.php | 7 ++++- .../Tests/Extension/PagerfantaRuntimeTest.php | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/Twig/Extension/PagerfantaRuntime.php b/lib/Twig/Extension/PagerfantaRuntime.php index 8960c273..f1c34cbc 100644 --- a/lib/Twig/Extension/PagerfantaRuntime.php +++ b/lib/Twig/Extension/PagerfantaRuntime.php @@ -6,6 +6,7 @@ use Pagerfanta\Exception\OutOfRangeCurrentPageException; use Pagerfanta\PagerfantaInterface; use Pagerfanta\PagerInterface; +use Pagerfanta\Position\PagePosition; use Pagerfanta\Position\Position; use Pagerfanta\RouteGenerator\PageNumberRouteGenerator; use Pagerfanta\RouteGenerator\PageRouteGeneratorWrapper; @@ -70,10 +71,14 @@ public function renderPagerfanta(PagerfantaInterface|PagerInterface $pagerfanta, */ public function getPageUrl(PagerfantaInterface $pagerfanta, int $page, array $options = []): string { - if ($page < 0 || $page > $pagerfanta->getNbPages()) { + if ($page < 1 || $page > $pagerfanta->getNbPages()) { throw new OutOfRangeCurrentPageException("Page '{$page}' is out of bounds"); } + if ($this->routeGeneratorFactory instanceof PositionRouteGeneratorFactoryInterface) { + return $this->routeGeneratorFactory->createPositionRouteGenerator($options)(new PagePosition($page)); + } + $routeGenerator = $this->createRouteGenerator($options); return $routeGenerator($page); diff --git a/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php b/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php index 9ac58d58..faf1a9b3 100644 --- a/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php +++ b/lib/Twig/Tests/Extension/PagerfantaRuntimeTest.php @@ -165,6 +165,37 @@ public function testAPageUrlCannotBeGeneratedIfThePageIsOutOfBounds(): void $this->extension->getPageUrl($this->createPagerfanta(), 1000); } + public function testAPageUrlCannotBeGeneratedForPageZero(): void + { + $this->expectException(OutOfRangeCurrentPageException::class); + $this->expectExceptionMessage("Page '0' is out of bounds"); + + $this->extension->getPageUrl($this->createPagerfanta(), 0); + } + + public function testAPageUrlIsGeneratedWithThePositionRouteGeneratorWhenTheFactorySupportsBothApis(): void + { + $factory = new class implements RouteGeneratorFactoryInterface, PositionRouteGeneratorFactoryInterface { + /** + * @param array $options + */ + public function create(array $options = []): RouteGeneratorInterface + { + throw new \LogicException('The page number based route generator should not be used.'); + } + + /** + * @param array $options + */ + public function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface + { + return new PositionRouteGeneratorDecorator(static fn (Position $position): string => '/my-page?page='.($position instanceof PagePosition ? $position->page : 0)); + } + }; + + $this->assertSame('/my-page?page=3', (new PagerfantaRuntime('default', $this->createViewFactory(), $factory))->getPageUrl($this->createPagerfanta(), 3)); + } + private function assertViewOutputMatches(string $view, string $expected): void { $this->assertSame($this->removeWhitespacesBetweenTags($expected), $view); From a0f8f8add0aec9ca618800913e926a222438c91d Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 5 Oct 2026 07:42:36 -0400 Subject: [PATCH 19/20] Remove method overrides now inherited from the feature interfaces --- lib/Core/Adapter/AdapterInterface.php | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/lib/Core/Adapter/AdapterInterface.php b/lib/Core/Adapter/AdapterInterface.php index 016a29b0..94a48938 100644 --- a/lib/Core/Adapter/AdapterInterface.php +++ b/lib/Core/Adapter/AdapterInterface.php @@ -2,8 +2,6 @@ namespace Pagerfanta\Adapter; -use Pagerfanta\Exception\NotValidResultCountException; - /** * An adapter supporting offset based pagination which can report the total number of results. * @@ -11,24 +9,4 @@ * * @extends OffsetAdapterInterface */ -interface AdapterInterface extends OffsetAdapterInterface, CountableAdapterInterface -{ - /** - * Returns the number of results for the list. - * - * @return int<0, max> - * - * @throws NotValidResultCountException if the number of results is less than zero - */ - public function getNbResults(): int; - - /** - * Returns a slice of the results representing the current page of items in the list. - * - * @param int<0, max> $offset - * @param int<0, max> $length - * - * @return iterable - */ - public function getSlice(int $offset, int $length): iterable; -} +interface AdapterInterface extends OffsetAdapterInterface, CountableAdapterInterface {} From 5ca8d86efdbe564b2ec60a5d95a9c57c7439d676 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 5 Oct 2026 07:44:41 -0400 Subject: [PATCH 20/20] PageNumberRouteGenerator doesn't need to be internal --- lib/Core/RouteGenerator/PageNumberRouteGenerator.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/Core/RouteGenerator/PageNumberRouteGenerator.php b/lib/Core/RouteGenerator/PageNumberRouteGenerator.php index bb561db0..3d97c485 100644 --- a/lib/Core/RouteGenerator/PageNumberRouteGenerator.php +++ b/lib/Core/RouteGenerator/PageNumberRouteGenerator.php @@ -7,8 +7,6 @@ /** * Generates the URL for a page by its number, from either a position based route generator or a page number based route generator. - * - * @internal */ final class PageNumberRouteGenerator {