diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d740b..8cdbc49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. This projec ## Unreleased +### Added + +- [#37](https://github.com/laravel-json-api/eloquent/pull/37) Add Eloquent cursor pagination implementation. + ## [4.1.0] - 2024-06-26 ### Added diff --git a/src/Pagination/Cursor/Cursor.php b/src/Pagination/Cursor/Cursor.php index 485c11f..2dc5603 100644 --- a/src/Pagination/Cursor/Cursor.php +++ b/src/Pagination/Cursor/Cursor.php @@ -1,18 +1,10 @@ $limit) { + public function __construct( + private ?string $before = null, + private ?string $after = null, + private ?int $limit = null + ) { + if (is_int($this->limit) && 1 > $this->limit) { throw new InvalidArgumentException('Expecting a limit that is 1 or greater.'); } - - $this->before = $before ?: null; - $this->after = $after ?: null; - $this->limit = $limit; } /** @@ -97,10 +72,12 @@ public function getAfter(): ?string */ public function withDefaultLimit(int $limit): self { - if (is_null($this->limit)) { - $copy = clone $this; - $copy->limit = $limit; - return $copy; + if ($this->limit === null) { + return new self( + before: $this->before, + after: $this->after, + limit: $limit, + ); } return $this; diff --git a/src/Pagination/Cursor/CursorBuilder.php b/src/Pagination/Cursor/CursorBuilder.php index 43c11ad..c3fce5a 100644 --- a/src/Pagination/Cursor/CursorBuilder.php +++ b/src/Pagination/Cursor/CursorBuilder.php @@ -1,4 +1,11 @@ query = $query; - $this->id = $id; - $this->keyName = $key ?: $this->guessKey(); + public function __construct( + private readonly Builder|Relation $query, + private readonly ID $id, + ?string $key = null + ) { + $this->keyName = $key ?: $this->id->key(); $this->parser = new CursorParser(IdParser::make($this->id), $this->keyName); } @@ -62,8 +78,11 @@ public function withDefaultPerPage(?int $perPage): self return $this; } - - public function withKeySort(bool $keySort): self + /** + * @param bool $keySort + * @return $this + */ + public function withKeySort(bool $keySort = true): self { $this->keySort = $keySort; @@ -86,6 +105,10 @@ public function withDirection(string $direction): self throw new \InvalidArgumentException('Unexpected query direction.'); } + /** + * @param bool $withTotal + * @return $this + */ public function withTotal(bool $withTotal): self { $this->withTotal = $withTotal; @@ -103,12 +126,20 @@ public function paginate(Cursor $cursor, array $columns = ['*']): CursorPaginato $this->applyKeySort(); $total = $this->getTotal(); - $laravelPaginator = $this->query->cursorPaginate($cursor->getLimit(), $columns, 'cursor', $this->parser->decode($cursor)); + $laravelPaginator = $this->query->cursorPaginate( + $cursor->getLimit(), + $columns, + 'cursor', + $this->parser->decode($cursor), + ); $paginator = new CursorPaginator($this->parser, $laravelPaginator, $cursor, $total); return $paginator->withCurrentPath(); } + /** + * @return void + */ private function applyKeySort(): void { if (!$this->keySort) { @@ -125,35 +156,17 @@ private function applyKeySort(): void } } + /** + * @return int|null + */ private function getTotal(): ?int { return $this->withTotal ? $this->query->count() : null; } - private function convertCursor(Cursor $cursor): ?LaravelCursor - { - $encodedCursor = $cursor->isBefore() ? $cursor->getBefore() : $cursor->getAfter(); - if (!is_string($encodedCursor)) { - return null; - } - - $parameters = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $encodedCursor)), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - return null; - } - - $pointsToNextItems = $parameters['_pointsToNextItems']; - unset($parameters['_pointsToNextItems']); - if (isset($parameters[$this->keyName])) { - $parameters[$this->keyName] = IdParser::make($this->id)->decode( - (string) $parameters[$this->keyName], - ); - } - - return new LaravelCursor($parameters, $pointsToNextItems); - } - + /** + * @return int + */ private function getDefaultPerPage(): int { if (is_int($this->defaultPerPage)) { @@ -162,12 +175,4 @@ private function getDefaultPerPage(): int return $this->query->getModel()->getPerPage(); } - - /** - * Guess the key to use for the cursor. - */ - private function guessKey(): string - { - return $this->id?->key() ?? $this->query->getModel()->getKeyName(); - } } diff --git a/src/Pagination/Cursor/CursorPage.php b/src/Pagination/Cursor/CursorPage.php index bf31a51..f7468cb 100644 --- a/src/Pagination/Cursor/CursorPage.php +++ b/src/Pagination/Cursor/CursorPage.php @@ -1,47 +1,51 @@ paginator = $paginator; } /** * Fluent constructor. + * + * @param CursorPaginator $paginator + * @return self */ public static function make(CursorPaginator $paginator): self { @@ -56,7 +60,7 @@ public static function make(CursorPaginator $paginator): self public function withAfterParam(string $key): self { if (empty($key)) { - throw new \InvalidArgumentException('Expecting a non-empty string.'); + throw new InvalidArgumentException('Expecting a non-empty string.'); } $this->after = $key; @@ -72,7 +76,7 @@ public function withAfterParam(string $key): self public function withBeforeParam(string $key): self { if (empty($key)) { - throw new \InvalidArgumentException('Expecting a non-empty string.'); + throw new InvalidArgumentException('Expecting a non-empty string.'); } $this->before = $key; @@ -88,7 +92,7 @@ public function withBeforeParam(string $key): self public function withLimitParam(string $key): self { if (empty($key)) { - throw new \InvalidArgumentException('Expecting a non-empty string.'); + throw new InvalidArgumentException('Expecting a non-empty string.'); } $this->limit = $key; @@ -96,6 +100,9 @@ public function withLimitParam(string $key): self return $this; } + /** + * @return Link|null + */ public function first(): ?Link { return new Link('first', $this->url([ @@ -103,6 +110,9 @@ public function first(): ?Link ])); } + /** + * @return Link|null + */ public function prev(): ?Link { if ($this->paginator->isNotEmpty() && $this->paginator->hasPrev()) { @@ -115,6 +125,9 @@ public function prev(): ?Link return null; } + /** + * @return Link|null + */ public function next(): ?Link { if ($this->paginator->isNotEmpty() && $this->paginator->hasNext()) { @@ -127,6 +140,9 @@ public function next(): ?Link return null; } + /** + * @return Link|null + */ public function last(): ?Link { return null; @@ -140,18 +156,24 @@ public function url(array $page): string return $this->paginator->path() . '?' . $this->stringifyQuery($page); } + /** + * @return \Traversable + */ public function getIterator(): \Traversable { yield from $this->paginator; } + /** + * @return int + */ public function count(): int { return $this->paginator->count(); } /** - * @return array + * @return array */ protected function metaForPage(): array { diff --git a/src/Pagination/Cursor/CursorPaginator.php b/src/Pagination/Cursor/CursorPaginator.php index 8cd848f..c30057e 100644 --- a/src/Pagination/Cursor/CursorPaginator.php +++ b/src/Pagination/Cursor/CursorPaginator.php @@ -1,32 +1,62 @@ items = Collection::make($this->laravelPaginator->items()); } + /** + * @return Collection + */ public function getItems(): Collection { - return $this->items; + return clone $this->items; } + /** + * @return string|null + */ public function firstItem(): ?string { @@ -37,6 +67,9 @@ public function firstItem(): ?string return $this->parser->encode($this->laravelPaginator->getCursorForItem($this->items->first(), false)); } + /** + * @return string|null + */ public function lastItem(): ?string { if ($this->laravelPaginator->isEmpty()) { @@ -46,66 +79,105 @@ public function lastItem(): ?string return $this->parser->encode($this->laravelPaginator->getCursorForItem($this->items->last())); } + /** + * @return bool + */ public function hasMorePages(): bool { return ($this->cursor->isBefore() && !$this->laravelPaginator->onFirstPage()) || $this->laravelPaginator->hasMorePages(); } - public function hasNext() + /** + * @return bool + */ + public function hasNext(): bool { return ((!$this->cursor->isAfter() && !$this->cursor->isBefore()) || $this->cursor->isAfter()) && $this->hasMorePages(); } - public function hasPrev() + /** + * @return bool + */ + public function hasPrev(): bool { return ($this->cursor->isBefore() && $this->hasMorePages()) || $this->cursor->isAfter(); } + /** + * @return bool + */ public function hasNoMorePages(): bool { return !$this->hasMorePages(); } + /** + * @return int + */ public function getPerPage(): int { return $this->laravelPaginator->perPage(); } + /** + * @return string|null + */ public function getFrom(): ?string { return $this->firstItem(); } + /** + * @return string|null + */ public function getTo(): ?string { return $this->lastItem(); } + /** + * @return int|null + */ public function getTotal(): ?int { return $this->total; } + /** + * @return \Traversable + */ public function getIterator(): \Traversable { yield from $this->items; } + /** + * @return int + */ public function count(): int { return $this->items->count(); } + /** + * @return bool + */ public function isEmpty(): bool { return $this->items->isEmpty(); } + /** + * @return bool + */ public function isNotEmpty(): bool { return !$this->isEmpty(); } + /** + * @return $this + */ public function withCurrentPath(): self { $this->path = Paginator::resolveCurrentPath(); @@ -113,6 +185,10 @@ public function withCurrentPath(): self return $this; } + /** + * @param string $path + * @return $this + */ public function withPath(string $path): self { $this->path = $path; diff --git a/src/Pagination/Cursor/CursorParser.php b/src/Pagination/Cursor/CursorParser.php index 3b8b71a..aa92d3f 100644 --- a/src/Pagination/Cursor/CursorParser.php +++ b/src/Pagination/Cursor/CursorParser.php @@ -1,20 +1,39 @@ parameter($this->keyName); + $key = $cursor->parameter($this->keyName); + if (!$key) { return $cursor->encode(); } @@ -28,6 +47,10 @@ public function encode(LaravelCursor $cursor): string return $newCursor->encode(); } + /** + * @param Cursor $cursor + * @return LaravelCursor|null + */ public function decode(Cursor $cursor): ?LaravelCursor { $encodedCursor = $cursor->isBefore() ? $cursor->getBefore() : $cursor->getAfter(); diff --git a/src/Pagination/CursorPagination.php b/src/Pagination/CursorPagination.php index b010f9c..41204b6 100644 --- a/src/Pagination/CursorPagination.php +++ b/src/Pagination/CursorPagination.php @@ -1,46 +1,84 @@ |null */ + /** + * @var string|array|null + */ private string|array|null $columns = null; + /** + * @var int|null + */ private ?int $defaultPerPage = null; + /** + * @var bool + */ private bool $withTotal; + /** + * @var bool + */ private bool $withTotalOnFirstPage; + /** + * @var bool + */ private bool $keySort = true; /** * CursorPagination constructor. + * + * @param ID $id */ public function __construct(private readonly ID $id) { @@ -55,6 +93,9 @@ public function __construct(private readonly ID $id) /** * Fluent constructor. + * + * @param ID $id + * @return self */ public static function make(ID $id): self { @@ -135,6 +176,10 @@ public function withDefaultPerPage(?int $perPage): self return $this; } + /** + * @param bool $withTotal + * @return $this + */ public function withTotal(bool $withTotal = true): self { $this->withTotal = $withTotal; @@ -142,6 +187,10 @@ public function withTotal(bool $withTotal = true): self return $this; } + /** + * @param bool $withTotal + * @return $this + */ public function withTotalOnFirstPage(bool $withTotal = true): self { $this->withTotalOnFirstPage = $withTotal; @@ -149,6 +198,10 @@ public function withTotalOnFirstPage(bool $withTotal = true): self return $this; } + /** + * @param string $column + * @return $this + */ public function withKeyName(string $column): self { $this->primaryKey = $column; @@ -167,6 +220,10 @@ public function withColumns($columns): self return $this; } + /** + * @param bool $keySort + * @return $this + */ public function withKeySort(bool $keySort = true): self { $this->keySort = $keySort; @@ -174,6 +231,9 @@ public function withKeySort(bool $keySort = true): self return $this; } + /** + * @return $this + */ public function withoutKeySort(): self { return $this->withKeySort(false); @@ -231,7 +291,8 @@ private function query(Builder|Relation $query): CursorBuilder /** * Extract the cursor from the provided paging parameters. - * @param array $page + * + * @param array $page */ private function cursor(array $page): Cursor { diff --git a/tests/lib/Acceptance/Pagination/CursorPaginationTest.php b/tests/lib/Acceptance/Pagination/CursorPaginationTest.php index 7f4ee89..1362f0b 100644 --- a/tests/lib/Acceptance/Pagination/CursorPaginationTest.php +++ b/tests/lib/Acceptance/Pagination/CursorPaginationTest.php @@ -18,6 +18,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\AbstractPaginator; +use Illuminate\Pagination\Cursor; use Illuminate\Support\LazyCollection; use LaravelJsonApi\Contracts\Pagination\Page; use LaravelJsonApi\Core\Query\QueryParameters; @@ -30,7 +31,6 @@ class CursorPaginationTest extends TestCase { - /** * @var CursorPagination */ @@ -91,25 +91,18 @@ public function testDefaultPagination(): void $this->posts->method('defaultPagination')->willReturn(['limit' => 10]); $meta = [ - 'from' => $this->encodeCursor([ - "id" => "4", - "_pointsToNextItems" => false - ]), + 'from' => $this->encodeCursor(["id" => "4"], pointsToNextItems: false), 'hasMore' => false, 'perPage' => 10, - 'to' => $this->encodeCursor([ - "id" => "1", - "_pointsToNextItems" => true - ]), + 'to' => $this->encodeCursor(["id" => "1"], pointsToNextItems: true), ]; $links = [ - 'first' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['limit' => '10'] - ]), - ] + 'page' => ['limit' => '10'] + ]), + ], ]; $posts = Post::factory()->count(4)->create(); @@ -118,12 +111,16 @@ public function testDefaultPagination(): void ->repository() ->queryAll() ->firstOrPaginate(null); + $this->assertInstanceOf(Page::class, $page); $this->assertSame(['page' => $meta], $page->meta()); $this->assertSame($links, $page->links()->toArray()); $this->assertPage($posts->reverse(), $page); } + /** + * @return void + */ public function testNoDefaultPagination(): void { $this->posts->method('defaultPagination')->willReturn(null); @@ -213,7 +210,7 @@ public function testNoPages(): void $links = [ 'first' => [ - 'href' => $first = 'http://localhost/api/v1/posts?' . Arr::query([ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ 'page' => ['limit' => '3'] ]), ], @@ -226,37 +223,34 @@ public function testNoPages(): void $this->assertEmpty($page); } + /** + * @return void + */ public function testWithoutCursor(): void { $posts = Post::factory()->count(4)->create(); $meta = [ - 'from' => $this->encodeCursor([ - "id" => "4", - "_pointsToNextItems" => false - ]), + 'from' => $this->encodeCursor(["id" => "4"], pointsToNextItems: false), 'hasMore' => true, 'perPage' => 3, - 'to' => $this->encodeCursor([ - "id" => "2", - "_pointsToNextItems" => true - ]) + 'to' => $this->encodeCursor(["id" => "2"], pointsToNextItems: true), ]; $links = [ 'first' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['limit' => '3'] - ]), + 'page' => ['limit' => '3'] + ]), ], 'next' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['after' => $this->encodeCursor([ - "id" => "2", - "_pointsToNextItems" => true - ]), 'limit' => '3'] - ]), - ] + 'page' => [ + 'after' => $this->encodeCursor(["id" => "2"], pointsToNextItems: true), + 'limit' => '3', + ], + ]), + ], ]; $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); @@ -266,6 +260,9 @@ public function testWithoutCursor(): void $this->assertPage($posts->reverse()->take(3), $page); } + /** + * @return void + */ public function testAfter(): void { $posts = Post::factory()->count(4)->create(); @@ -273,44 +270,41 @@ public function testAfter(): void $this->paginator->withCamelCaseMeta(); $meta = [ - 'from' => $this->encodeCursor([ - "id" => "1", - "_pointsToNextItems" => false - ]), + 'from' => $this->encodeCursor(["id" => "1"], pointsToNextItems: false), 'hasMore' => false, 'perPage' => 3, - 'to' => $this->encodeCursor([ - "id" => "1", - "_pointsToNextItems" => true - ]) + 'to' => $this->encodeCursor(["id" => "1"], pointsToNextItems: true), ]; $links = [ 'first' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['limit' => '3'] - ]), + 'page' => ['limit' => '3'] + ]), ], 'prev' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['before' => $this->encodeCursor([ - "id" => "1", - "_pointsToNextItems" => false - ]), 'limit' => '3'] - ]), + 'page' => [ + 'before' => $this->encodeCursor(["id" => "1"], pointsToNextItems: false), + 'limit' => '3', + ], + ]), ], ]; - $page = $this->posts->repository()->queryAll()->paginate(['after' => $this->encodeCursor([ - "id" => "2", - "_pointsToNextItems" => true - ]), 'limit' => '3']); + $page = $this->posts->repository()->queryAll()->paginate([ + 'after' => $this->encodeCursor(["id" => "2"], pointsToNextItems: true), + 'limit' => '3', + ]); $this->assertSame(['page' => $meta], $page->meta()); $this->assertSame($links, $page->links()->toArray()); $this->assertPage([$posts->first()], $page); } + /** + * @return void + */ public function testAfterWithIdEncoding(): void { $this->withIdEncoding(); @@ -322,43 +316,46 @@ public function testAfterWithIdEncoding(): void $meta = [ 'from' => $this->encodeCursor([ "id" => 'TEST-7', - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'hasMore' => true, 'perPage' => 3, 'to' => $this->encodeCursor([ "id" => 'TEST-5', - "_pointsToNextItems" => true - ]), + ], pointsToNextItems: true), ]; $links = [ 'first' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['limit' => '3'] - ]), + 'page' => ['limit' => '3'] + ]), ], 'next' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['after' => $this->encodeCursor([ + 'page' => [ + 'after' => $this->encodeCursor([ "id" => "TEST-5", - "_pointsToNextItems" => true - ]),'limit' => '3'] - ]),], + ], pointsToNextItems: true), + 'limit' => '3', + ] + ]), + ], 'prev' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['before' => $this->encodeCursor([ + 'page' => [ + 'before' => $this->encodeCursor([ "id" => "TEST-7", - "_pointsToNextItems" => false - ]),'limit' => '3'] - ]),], + ], pointsToNextItems: false), + 'limit' => '3', + ], + ]), + ], ]; $page = $this->posts->repository()->queryAll()->paginate([ 'after' => $this->encodeCursor([ "id" => 'TEST-8', - "_pointsToNextItems" => true - ]), + ], pointsToNextItems: true), 'limit' => 3, ]); $this->assertSame(['page' => $meta], $page->meta()); @@ -366,6 +363,9 @@ public function testAfterWithIdEncoding(): void $this->assertPage($expected, $page); } + /** + * @return void + */ public function testBefore(): void { $posts = Post::factory()->count(4)->create(); @@ -375,42 +375,47 @@ public function testBefore(): void $meta = [ 'from' => $this->encodeCursor([ "id" => "4", - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'hasMore' => true, 'perPage' => 3, 'to' => $this->encodeCursor([ "id" => "2", - "_pointsToNextItems" => true - ]), + ], pointsToNextItems: true), ]; $links = [ 'first' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['limit' => '3'] - ]), + 'page' => ['limit' => '3'] + ]), ], 'prev' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['before' => $this->encodeCursor([ + 'page' => [ + 'before' => $this->encodeCursor([ "id" => "4", - "_pointsToNextItems" => false - ]), 'limit' => '3'] - ]), + ], pointsToNextItems: false), + 'limit' => '3', + ] + ]), ], ]; - $page = $this->posts->repository()->queryAll()->paginate(['before' => $this->encodeCursor([ - "id" => "1", - "_pointsToNextItems" => false - ]), 'limit' => '3']); + $page = $this->posts->repository()->queryAll()->paginate([ + 'before' => $this->encodeCursor([ + "id" => "1", + ], pointsToNextItems: false), + 'limit' => '3', + ]); $this->assertSame(['page' => $meta], $page->meta()); $this->assertSame($links, $page->links()->toArray()); $this->assertPage($posts->reverse()->take(3), $page); } + /** + * @return void + */ public function testBeforeWithIdEncoding(): void { $this->withIdEncoding(); @@ -422,36 +427,36 @@ public function testBeforeWithIdEncoding(): void $meta = [ 'from' => $this->encodeCursor([ "id" => 'TEST-7', - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'hasMore' => true, 'perPage' => 3, 'to' => $this->encodeCursor([ "id" => 'TEST-5', - "_pointsToNextItems" => true - ]), + ], pointsToNextItems: true), ]; $links = [ 'first' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['limit' => '3'] - ]), + 'page' => ['limit' => '3'] + ]), ], 'prev' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['before' => $this->encodeCursor([ + 'page' => [ + 'before' => $this->encodeCursor([ "id" => "TEST-7", - "_pointsToNextItems" => false - ]),'limit' => '3'] - ]),], + ], pointsToNextItems: false), + 'limit' => '3', + ], + ]), + ], ]; $page = $this->posts->repository()->queryAll()->paginate([ 'before' => $this->encodeCursor([ - "id" => 'TEST-4', - "_pointsToNextItems" => false - ]), + "id" => 'TEST-4', + ], pointsToNextItems: false), 'limit' => 3, ]); $this->assertSame(['page' => $meta], $page->meta()); @@ -470,15 +475,12 @@ public function testItUsesModelDefaultPerPage(): void $meta = [ 'from' => $this->encodeCursor([ "id" => (string) ($expected + 1), - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'hasMore' => true, 'perPage' => $expected, 'to' => $this->encodeCursor([ "id" => '2', - "_pointsToNextItems" => true - ]), - + ], pointsToNextItems: true), ]; $links = [ @@ -489,15 +491,17 @@ public function testItUsesModelDefaultPerPage(): void ], 'next' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['after' => $this->encodeCursor([ - "id" => '2', - "_pointsToNextItems" => true - ]), 'limit' => $expected] + 'page' => [ + 'after' => $this->encodeCursor([ + "id" => '2', + ], pointsToNextItems: true), + 'limit' => $expected, + ], ]), ], ]; - $page = $this->posts->repository()->queryAll()->paginate(['number' => '1']); + $page = $this->posts->repository()->queryAll()->paginate([]); $this->assertSame(['page' => $meta], $page->meta()); $this->assertSame($links, $page->links()->toArray()); @@ -518,39 +522,42 @@ public function testItUsesDefaultPerPage(): void $meta = [ 'from' => $this->encodeCursor([ "id" => (string) ($expected + 1), - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'hasMore' => true, 'perPage' => $expected, 'to' => $this->encodeCursor([ "id" => '2', - "_pointsToNextItems" => true - ]), + ], pointsToNextItems: true), ]; $links = [ 'first' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['limit' => $expected] - ]), + 'page' => ['limit' => $expected] + ]), ], 'next' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['after' => $this->encodeCursor([ + 'page' => [ + 'after' => $this->encodeCursor([ "id" => '2', - "_pointsToNextItems" => true - ]), 'limit' => $expected] - ]), + ], pointsToNextItems: true), + 'limit' => $expected, + ], + ]), ], ]; - $page = $this->posts->repository()->queryAll()->paginate(['number' => '1']); + $page = $this->posts->repository()->queryAll()->paginate([]); $this->assertSame(['page' => $meta], $page->meta()); $this->assertSame($links, $page->links()->toArray()); $this->assertPage($posts->reverse()->take($expected), $page); } + /** + * @return void + */ public function testPageWithReverseKey(): void { $posts = Post::factory()->count(4)->create(); @@ -587,7 +594,9 @@ public function testDeterministicOrder(): void 'created_at' => $second->created_at, ]); - $page = $this->videos->repository()->queryAll() + $page = $this->videos + ->repository() + ->queryAll() ->sort('createdAt') ->paginate(['limit' => '3']); @@ -600,6 +609,9 @@ public function testDeterministicOrder(): void $this->assertPage([$fourth, $third, $second], $page); } + /** + * @return void + */ public function testMultipleSorts(): void { $first = Video::factory()->create([ @@ -635,6 +647,9 @@ public function testMultipleSorts(): void $this->assertPage([$fourth, $second, $third], $page); } + /** + * @return void + */ public function testWithoutKeySort(): void { $this->paginator->withoutKeySort(); @@ -668,10 +683,11 @@ public function testWithoutKeySort(): void ->paginate(['limit' => '3']); $this->assertPage([$second, $first, $fourth], $page); - } - + /** + * @return void + */ public function testCustomPageKeys(): void { Post::factory()->count(4)->create(); @@ -686,10 +702,14 @@ public function testCustomPageKeys(): void ], 'next' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['next' => $this->encodeCursor( - ["id" => "2", "_pointsToNextItems" => true] - ), 'perPage' => '3'] - ]), + 'page' => [ + 'next' => $this->encodeCursor( + ["id" => "2"], + pointsToNextItems: true, + ), + 'perPage' => '3', + ], + ]), ], ]; @@ -706,20 +726,28 @@ public function testCustomPageKeys(): void ], 'prev' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['perPage' => '3', 'prev' => $this->encodeCursor( - ["id" => "1", "_pointsToNextItems" => false] - )] - ]), + 'page' => [ + 'perPage' => '3', + 'prev' => $this->encodeCursor( + ["id" => "1"], + pointsToNextItems: false, + ), + ], + ]), ], ]; - $page = $this->posts->repository()->queryAll()->paginate(['next' => $this->encodeCursor( - ["id" => "2", "_pointsToNextItems" => true] - ), 'perPage' => '3']); + $page = $this->posts->repository()->queryAll()->paginate([ + 'next' => $this->encodeCursor(["id" => "2"], pointsToNextItems: true), + 'perPage' => '3', + ]); $this->assertSame($links, $page->links()->toArray()); } + /** + * @return void + */ public function testSnakeCaseMetaAndCustomMetaKey(): void { $posts = Post::factory()->count(4)->create(); @@ -729,14 +757,12 @@ public function testSnakeCaseMetaAndCustomMetaKey(): void $meta = [ 'from' => $this->encodeCursor([ "id" => "4", - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'has_more' => true, 'per_page' => 3, 'to' => $this->encodeCursor([ "id" => "2", - "_pointsToNextItems" => true - ]) + ], pointsToNextItems: true), ]; $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); @@ -745,6 +771,9 @@ public function testSnakeCaseMetaAndCustomMetaKey(): void $this->assertPage($posts->reverse()->take(3), $page); } + /** + * @return void + */ public function testDashCaseMeta(): void { $posts = Post::factory()->count(4)->create(); @@ -754,14 +783,12 @@ public function testDashCaseMeta(): void $meta = [ 'from' => $this->encodeCursor([ "id" => "4", - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'has-more' => true, 'per-page' => 3, 'to' => $this->encodeCursor([ "id" => "2", - "_pointsToNextItems" => true - ]) + ], pointsToNextItems: true), ]; $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); @@ -770,6 +797,9 @@ public function testDashCaseMeta(): void $this->assertPage($posts->reverse()->take(3), $page); } + /** + * @return void + */ public function testMetaNotNested(): void { $posts = Post::factory()->count(4)->create(); @@ -779,14 +809,12 @@ public function testMetaNotNested(): void $meta = [ 'from' => $this->encodeCursor([ "id" => "4", - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'hasMore' => true, 'perPage' => 3, 'to' => $this->encodeCursor([ "id" => "2", - "_pointsToNextItems" => true - ]) + ], pointsToNextItems: true), ]; $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); @@ -795,6 +823,9 @@ public function testMetaNotNested(): void $this->assertPage($posts->reverse()->take(3), $page); } + /** + * @return void + */ public function testItCanRemoveMeta(): void { $posts = Post::factory()->count(4)->create(); @@ -809,10 +840,12 @@ public function testItCanRemoveMeta(): void ], 'next' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'page' => ['after' => $this->encodeCursor([ - "id" => "2", - "_pointsToNextItems" => true - ]), 'limit' => '3'] + 'page' => [ + 'after' => $this->encodeCursor([ + "id" => "2", + ], pointsToNextItems: true), + 'limit' => '3', + ], ]), ], ]; @@ -824,6 +857,9 @@ public function testItCanRemoveMeta(): void $this->assertPage($posts->reverse()->take(3), $page); } + /** + * @return void + */ public function testUrlsIncludeOtherQueryParameters(): void { $posts = Post::factory()->count(6)->create(); @@ -832,25 +868,27 @@ public function testUrlsIncludeOtherQueryParameters(): void $links = [ 'first' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'fields' => $fields = [ - 'posts' => 'author,slug,title', - 'users' => 'name', - ], - 'filter' => ['slugs' => $slugs], - 'include' => 'author', - 'page' => ['limit' => '3'], - ]), + 'fields' => $fields = [ + 'posts' => 'author,slug,title', + 'users' => 'name', + ], + 'filter' => ['slugs' => $slugs], + 'include' => 'author', + 'page' => ['limit' => '3'], + ]), ], 'next' => [ 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ - 'fields' => $fields, - 'filter' => ['slugs' => $slugs], - 'include' => 'author', - 'page' => ['after' => $this->encodeCursor([ + 'fields' => $fields, + 'filter' => ['slugs' => $slugs], + 'include' => 'author', + 'page' => [ + 'after' => $this->encodeCursor([ "id" => "2", - "_pointsToNextItems" => true - ]), 'limit' => '3'], - ]), + ], pointsToNextItems: true), + 'limit' => '3', + ], + ]), ], ]; @@ -868,21 +906,22 @@ public function testUrlsIncludeOtherQueryParameters(): void $this->assertSame($links, $page->links()->toArray()); } - public function testWithTotal() + /** + * @return void + */ + public function testWithTotal(): void { $this->paginator->withTotal(); $meta = [ 'from' => $this->encodeCursor([ "id" => "4", - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'hasMore' => true, 'perPage' => 3, 'to' => $this->encodeCursor([ "id" => "2", - "_pointsToNextItems" => true - ]), + ], pointsToNextItems: true), 'total' => 4, ]; @@ -899,10 +938,12 @@ public function testWithTotal() $page = $this->posts ->repository() ->queryAll() - ->paginate(['after' => $this->encodeCursor([ - "id" => "2", - "_pointsToNextItems" => true - ]), 'limit' => 3]); + ->paginate([ + 'after' => $this->encodeCursor([ + "id" => "2", + ], pointsToNextItems: true), + 'limit' => 3, + ]); $this->assertInstanceOf(Page::class, $page); $this->assertArrayHasKey('page', $page->meta()); @@ -911,6 +952,9 @@ public function testWithTotal() } + /** + * @return void + */ public function testWithTotalOnFirstPage() { $this->paginator->withTotalOnFirstPage(); @@ -918,14 +962,12 @@ public function testWithTotalOnFirstPage() $meta = [ 'from' => $this->encodeCursor([ "id" => "4", - "_pointsToNextItems" => false - ]), + ], pointsToNextItems: false), 'hasMore' => true, 'perPage' => 3, 'to' => $this->encodeCursor([ "id" => "2", - "_pointsToNextItems" => true - ]), + ], pointsToNextItems: true), 'total' => 4, ]; @@ -942,10 +984,12 @@ public function testWithTotalOnFirstPage() $page = $this->posts ->repository() ->queryAll() - ->paginate(['after' => $this->encodeCursor([ - "id" => "2", - "_pointsToNextItems" => true - ]), 'limit' => 3]); + ->paginate([ + 'after' => $this->encodeCursor([ + "id" => "2", + ], pointsToNextItems: true), + 'limit' => 3, + ]); $this->assertInstanceOf(Page::class, $page); $this->assertArrayHasKey('page', $page->meta()); @@ -976,9 +1020,15 @@ private function withIdEncoding(): void ); } - private function encodeCursor(array $params) : string + /** + * @param array $params + * @param bool $pointsToNextItems + * @return string + */ + private function encodeCursor(array $params, bool $pointsToNextItems) : string { - return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(json_encode($params))); - } + $cursor = new Cursor($params, $pointsToNextItems); + return $cursor->encode(); + } }