diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d740b..e8d6704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. This projec ## Unreleased +## [4.2.0] - 2024-08-26 + +### 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 new file mode 100644 index 0000000..2dc5603 --- /dev/null +++ b/src/Pagination/Cursor/Cursor.php @@ -0,0 +1,93 @@ +limit) && 1 > $this->limit) { + throw new InvalidArgumentException('Expecting a limit that is 1 or greater.'); + } + } + + /** + * @return bool + */ + public function isBefore(): bool + { + return !is_null($this->before); + } + + /** + * @return string|null + */ + public function getBefore(): ?string + { + return $this->before; + } + + /** + * @return bool + */ + public function isAfter(): bool + { + return !is_null($this->after) && !$this->isBefore(); + } + + /** + * @return string|null + */ + public function getAfter(): ?string + { + return $this->after; + } + + /** + * Set a limit, if no limit is set on the cursor. + * + * @param int $limit + * @return Cursor + */ + public function withDefaultLimit(int $limit): self + { + if ($this->limit === null) { + return new self( + before: $this->before, + after: $this->after, + limit: $limit, + ); + } + + return $this; + } + + /** + * @return int|null + */ + public function getLimit(): ?int + { + return $this->limit; + } +} diff --git a/src/Pagination/Cursor/CursorBuilder.php b/src/Pagination/Cursor/CursorBuilder.php new file mode 100644 index 0000000..c3fce5a --- /dev/null +++ b/src/Pagination/Cursor/CursorBuilder.php @@ -0,0 +1,178 @@ +keyName = $key ?: $this->id->key(); + $this->parser = new CursorParser(IdParser::make($this->id), $this->keyName); + } + + /** + * Set the default number of items per-page. + * + * If null, the default from the `Model::getPage()` method will be used. + * + * @return $this + */ + public function withDefaultPerPage(?int $perPage): self + { + $this->defaultPerPage = $perPage; + + return $this; + } + + /** + * @param bool $keySort + * @return $this + */ + public function withKeySort(bool $keySort = true): self + { + $this->keySort = $keySort; + + return $this; + } + + /** + * Set the query direction. + * + * @return $this + */ + public function withDirection(string $direction): self + { + if (\in_array($direction, ['asc', 'desc'])) { + $this->direction = $direction; + + return $this; + } + + throw new \InvalidArgumentException('Unexpected query direction.'); + } + + /** + * @param bool $withTotal + * @return $this + */ + public function withTotal(bool $withTotal): self + { + $this->withTotal = $withTotal; + + return $this; + } + + /** + * @param array $columns + */ + public function paginate(Cursor $cursor, array $columns = ['*']): CursorPaginator + { + $cursor = $cursor->withDefaultLimit($this->getDefaultPerPage()); + + $this->applyKeySort(); + + $total = $this->getTotal(); + $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) { + return; + } + + if ( + empty($this->query->getQuery()->orders) + || collect($this->query->getQuery()->orders) + ->whereIn('column', [$this->keyName, $this->query->qualifyColumn($this->keyName)]) + ->isEmpty() + ) { + $this->query->orderBy($this->keyName, $this->direction); + } + } + + /** + * @return int|null + */ + private function getTotal(): ?int + { + return $this->withTotal ? $this->query->count() : null; + } + + /** + * @return int + */ + private function getDefaultPerPage(): int + { + if (is_int($this->defaultPerPage)) { + return $this->defaultPerPage; + } + + return $this->query->getModel()->getPerPage(); + } +} diff --git a/src/Pagination/Cursor/CursorPage.php b/src/Pagination/Cursor/CursorPage.php new file mode 100644 index 0000000..f7468cb --- /dev/null +++ b/src/Pagination/Cursor/CursorPage.php @@ -0,0 +1,193 @@ +after = $key; + + return $this; + } + + /** + * Set the "before" parameter. + * + * @return $this + */ + public function withBeforeParam(string $key): self + { + if (empty($key)) { + throw new InvalidArgumentException('Expecting a non-empty string.'); + } + + $this->before = $key; + + return $this; + } + + /** + * Set the "limit" parameter. + * + * @return $this + */ + public function withLimitParam(string $key): self + { + if (empty($key)) { + throw new InvalidArgumentException('Expecting a non-empty string.'); + } + + $this->limit = $key; + + return $this; + } + + /** + * @return Link|null + */ + public function first(): ?Link + { + return new Link('first', $this->url([ + $this->limit => $this->paginator->getPerPage(), + ])); + } + + /** + * @return Link|null + */ + public function prev(): ?Link + { + if ($this->paginator->isNotEmpty() && $this->paginator->hasPrev()) { + return new Link('prev', $this->url([ + $this->before => $this->paginator->firstItem(), + $this->limit => $this->paginator->getPerPage(), + ])); + } + + return null; + } + + /** + * @return Link|null + */ + public function next(): ?Link + { + if ($this->paginator->isNotEmpty() && $this->paginator->hasNext()) { + return new Link('next', $this->url([ + $this->after => $this->paginator->lastItem(), + $this->limit => $this->paginator->getPerPage(), + ])); + } + + return null; + } + + /** + * @return Link|null + */ + public function last(): ?Link + { + return null; + } + + /** + * @param array $page + */ + 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 + */ + protected function metaForPage(): array + { + $meta = [ + 'perPage' => $this->paginator->getPerPage(), + 'from' => $this->paginator->getFrom(), + 'to' => $this->paginator->getTo(), + 'hasMore' => $this->paginator->hasMorePages(), + ]; + $total = $this->paginator->getTotal(); + if ($total !== null) { + $meta['total'] = $total; + } + + return $meta; + } +} diff --git a/src/Pagination/Cursor/CursorPaginator.php b/src/Pagination/Cursor/CursorPaginator.php new file mode 100644 index 0000000..c30057e --- /dev/null +++ b/src/Pagination/Cursor/CursorPaginator.php @@ -0,0 +1,206 @@ +items = Collection::make($this->laravelPaginator->items()); + } + + /** + * @return Collection + */ + public function getItems(): Collection + { + return clone $this->items; + } + + /** + * @return string|null + */ + public function firstItem(): ?string + { + + if ($this->laravelPaginator->isEmpty()) { + return null; + } + + return $this->parser->encode($this->laravelPaginator->getCursorForItem($this->items->first(), false)); + } + + /** + * @return string|null + */ + public function lastItem(): ?string + { + if ($this->laravelPaginator->isEmpty()) { + return null; + } + + 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(); + } + + /** + * @return bool + */ + public function hasNext(): bool + { + return ((!$this->cursor->isAfter() && !$this->cursor->isBefore()) || $this->cursor->isAfter()) && $this->hasMorePages(); + } + + /** + * @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(); + + return $this; + } + + /** + * @param string $path + * @return $this + */ + public function withPath(string $path): self + { + $this->path = $path; + + return $this; + } + + /** + * Get the base path for paginator generated URLs. + */ + public function path(): ?string + { + return $this->path; + } +} diff --git a/src/Pagination/Cursor/CursorParser.php b/src/Pagination/Cursor/CursorParser.php new file mode 100644 index 0000000..5c17d0b --- /dev/null +++ b/src/Pagination/Cursor/CursorParser.php @@ -0,0 +1,87 @@ +parameter($this->keyName); + + if ($key) { + $parameters = $this->withoutPrivate($cursor->toArray()); + $parameters[$this->keyName] = $this->idParser->encode($key); + $cursor = new LaravelCursor($parameters, $cursor->pointsToNextItems()); + } + + return $cursor->encode(); + } + + /** + * @param Cursor $cursor + * @return LaravelCursor|null + */ + public function decode(Cursor $cursor): ?LaravelCursor + { + $decoded = LaravelCursor::fromEncoded( + $cursor->isBefore() ? $cursor->getBefore() : $cursor->getAfter(), + ); + + if ($decoded === null) { + return null; + } + + $parameters = $this->withoutPrivate($decoded->toArray()); + + if (isset($parameters[$this->keyName])) { + $parameters[$this->keyName] = $this->idParser->decode( + $parameters[$this->keyName], + ); + } + + return new LaravelCursor($parameters, $decoded->pointsToNextItems()); + } + + /** + * @param array $values + * @return array + */ + private function withoutPrivate(array $values): array + { + $result = []; + + foreach ($values as $key => $value) { + if (!str_starts_with($key, '_')) { + $result[$key] = $value; + } + } + + return $result; + } +} diff --git a/src/Pagination/CursorPagination.php b/src/Pagination/CursorPagination.php new file mode 100644 index 0000000..41204b6 --- /dev/null +++ b/src/Pagination/CursorPagination.php @@ -0,0 +1,309 @@ +|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) + { + $this->before = 'before'; + $this->after = 'after'; + $this->limit = 'limit'; + $this->metaKey = 'page'; + $this->direction = 'desc'; + $this->withTotal = false; + $this->withTotalOnFirstPage = false; + } + + /** + * Fluent constructor. + * + * @param ID $id + * @return self + */ + public static function make(ID $id): self + { + return new self($id); + } + + /** + * Set the "after" key. + * + * @return $this + */ + public function withAfterKey(string $key): self + { + if (empty($key)) { + throw new \InvalidArgumentException('Expecting a non-empty string.'); + } + + $this->after = $key; + + return $this; + } + + /** + * Set the "before" key. + * + * @return $this + */ + public function withBeforeKey(string $key): self + { + if (empty($key)) { + throw new \InvalidArgumentException('Expecting a non-empty string.'); + } + + $this->before = $key; + + return $this; + } + + /** + * Set the "limit" key. + * + * @return $this + */ + public function withLimitKey(string $key): self + { + if (empty($key)) { + throw new \InvalidArgumentException('Expecting a non-empty string.'); + } + + $this->limit = $key; + + return $this; + } + + /** + * Use an ascending order. + * + * @return $this + */ + public function withAscending(): self + { + $this->direction = 'asc'; + + return $this; + } + + /** + * Use the provided number as the default items per-page. + * + * If null, the default per-page set on the model class will be used. + * + * @return $this + */ + public function withDefaultPerPage(?int $perPage): self + { + $this->defaultPerPage = $perPage; + + return $this; + } + + /** + * @param bool $withTotal + * @return $this + */ + public function withTotal(bool $withTotal = true): self + { + $this->withTotal = $withTotal; + + return $this; + } + + /** + * @param bool $withTotal + * @return $this + */ + public function withTotalOnFirstPage(bool $withTotal = true): self + { + $this->withTotalOnFirstPage = $withTotal; + + return $this; + } + + /** + * @param string $column + * @return $this + */ + public function withKeyName(string $column): self + { + $this->primaryKey = $column; + + return $this; + } + + /** + * @param string|array $columns + * @return $this + */ + public function withColumns($columns): self + { + $this->columns = $columns; + + return $this; + } + + /** + * @param bool $keySort + * @return $this + */ + public function withKeySort(bool $keySort = true): self + { + $this->keySort = $keySort; + + return $this; + } + + /** + * @return $this + */ + public function withoutKeySort(): self + { + return $this->withKeySort(false); + } + + /** + * @return array + */ + public function keys(): array + { + return [ + $this->before, + $this->after, + $this->limit, + ]; + } + + /** + * @param Builder|Relation $query + * @param array $page + */ + public function paginate($query, array $page): Page + { + $cursor = $this->cursor($page); + + $withTotal = $this->withTotal + || ($this->withTotalOnFirstPage + && !$cursor->isBefore() + && !$cursor->isAfter()); + + $paginator = $this + ->query($query) + ->withDirection($this->direction) + ->withKeySort($this->keySort) + ->withDefaultPerPage($this->defaultPerPage) + ->withTotal($withTotal) + ->paginate($cursor, $this->columns ?? ['*']); + + return CursorPage::make($paginator) + ->withBeforeParam($this->before) + ->withAfterParam($this->after) + ->withLimitParam($this->limit) + ->withMeta($this->hasMeta) + ->withNestedMeta($this->metaKey) + ->withMetaCase($this->metaCase); + } + + /** + * Create a new cursor query. + */ + private function query(Builder|Relation $query): CursorBuilder + { + return new CursorBuilder($query, $this->id, $this->primaryKey); + } + + /** + * Extract the cursor from the provided paging parameters. + * + * @param array $page + */ + private function cursor(array $page): Cursor + { + $before = $page[$this->before] ?? null; + $after = $page[$this->after] ?? null; + $limit = $page[$this->limit] ?? null; + + return new Cursor( + !is_null($before) ? strval($before) : null, + !is_null($after) ? strval($after) : null, + !is_null($limit) ? intval($limit) : null, + ); + } +} diff --git a/tests/app/Models/Video.php b/tests/app/Models/Video.php index 9920873..afe332a 100644 --- a/tests/app/Models/Video.php +++ b/tests/app/Models/Video.php @@ -52,7 +52,7 @@ class Video extends Model protected static function booting() { self::creating(static function (Video $video) { - $video->uuid = $video->uuid ?: Str::uuid()->toString(); + $video->uuid = $video->uuid ?: Str::orderedUuid()->toString(); }); } diff --git a/tests/app/Schemas/VideoSchema.php b/tests/app/Schemas/VideoSchema.php index 197cbec..f25a06b 100644 --- a/tests/app/Schemas/VideoSchema.php +++ b/tests/app/Schemas/VideoSchema.php @@ -45,7 +45,7 @@ public function fields(): iterable BelongsToMany::make('tags') ->fields(new ApprovedPivot()) ->canCount(), - Str::make('title'), + Str::make('title')->sortable(), DateTime::make('updatedAt')->sortable()->readOnly(), Str::make('url'), ]; diff --git a/tests/lib/Acceptance/Pagination/CursorPaginationTest.php b/tests/lib/Acceptance/Pagination/CursorPaginationTest.php new file mode 100644 index 0000000..24776fc --- /dev/null +++ b/tests/lib/Acceptance/Pagination/CursorPaginationTest.php @@ -0,0 +1,1168 @@ +paginator = CursorPagination::make(ID::make()->uuid()); + + $this->posts = $this + ->getMockBuilder(PostSchema::class) + ->onlyMethods(['pagination', 'defaultPagination']) + ->setConstructorArgs(['server' => $this->server()]) + ->getMock(); + + $this->videos = $this + ->getMockBuilder(VideoSchema::class) + ->onlyMethods(['pagination', 'defaultPagination']) + ->setConstructorArgs(['server' => $this->server()]) + ->getMock(); + + $this->posts->method('pagination')->willReturnCallback(fn () => $this->paginator); + $this->videos->method('pagination')->willReturnCallback(fn () => $this->paginator); + + $this->app->instance(PostSchema::class, $this->posts); + $this->app->instance(VideoSchema::class, $this->videos); + + AbstractPaginator::currentPathResolver(fn() => url('/api/v1/posts')); + } + + /** + * An schema's default pagination is used if no pagination parameters are sent. + * + * @see https://github.com/cloudcreativity/laravel-json-api/issues/131 + */ + public function testDefaultPagination(): void + { + $this->posts->method('defaultPagination')->willReturn(['limit' => 10]); + + $posts = Post::factory()->count(4)->create(); + + $meta = [ + 'from' => $this->encodeCursor( + ["id" => (string) $posts[3]->getRouteKey()], + pointsToNextItems: false, + ), + 'hasMore' => false, + 'perPage' => 10, + 'to' => $this->encodeCursor( + ["id" => (string) $posts[0]->getRouteKey()], + pointsToNextItems: true, + ), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '10'] + ]), + ], + ]; + + $page = $this->posts + ->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); + + $posts = Post::factory()->count(4)->create(); + + $actual = $this->posts + ->repository() + ->queryAll() + ->firstOrPaginate(null); + + $this->assertInstanceOf(LazyCollection::class, $actual); + $this->assertPage($posts, $actual); + } + + /** + * If the schema has default pagination, but the client has used + * a singular filter AND not provided paging parameters, we + * expect the singular filter to be respected I.e. the default + * pagination must be ignored. + */ + public function testDefaultPaginationWithSingularFilter(): void + { + $this->posts->method('defaultPagination')->willReturn(['limit' => 1]); + + $posts = Post::factory()->count(4)->create(); + $post = $posts[2]; + + $actual = $this->posts + ->repository() + ->queryAll() + ->filter(['slug' => $post->slug]) + ->firstOrPaginate(null); + + $this->assertInstanceOf(Post::class, $actual); + $this->assertTrue($post->is($actual)); + } + + /** + * Same as previous test but the filter does not match any models. + */ + public function testDefaultPaginationWithSingularFilterThatDoesNotMatch(): void + { + $this->posts->method('defaultPagination')->willReturn(['limit' => 1]); + + $post = Post::factory()->make(); + + $actual = $this->posts + ->repository() + ->queryAll() + ->filter(['slug' => $post->slug]) + ->firstOrPaginate(null); + + $this->assertNull($actual); + } + + /** + * If the client uses a singular filter, but provides page parameters, + * they should get a page - not a zero-to-one response. + */ + public function testPaginationWithSingularFilter(): void + { + $posts = Post::factory()->count(4)->create(); + $post = $posts[2]; + + $actual = $this->posts + ->repository() + ->queryAll() + ->filter(['slug' => $post->slug]) + ->firstOrPaginate(['number' => '1']); + + $this->assertInstanceOf(Page::class, $actual); + $this->assertPage([$post], $actual); + } + + /** + * If the search does not match any models, then there are no pages. + */ + public function testNoPages(): void + { + $meta = [ + 'from' => null, + 'hasMore' => false, + 'perPage' => 3, + 'to' => null, + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '3'], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); + + $this->assertSame(['page' => $meta], $page->meta()); + $this->assertSame($links, $page->links()->toArray()); + $this->assertEmpty($page); + } + + /** + * @return void + */ + public function testWithoutCursor(): void + { + $posts = Post::factory()->count(4)->create(); + + $meta = [ + 'from' => $this->encodeCursor( + ["id" => (string) $posts[3]->getRouteKey()], + pointsToNextItems: false, + ), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $this->encodeCursor( + ["id" => (string) $posts[1]->getRouteKey()], + pointsToNextItems: true, + ), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '3'] + ]), + ], + 'next' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'after' => $this->encodeCursor( + ["id" => (string) $posts[1]->getRouteKey()], + pointsToNextItems: true, + ), + 'limit' => '3', + ], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate(['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 testWithAscending(): void + { + $this->paginator->withAscending(); + + $posts = Post::factory()->count(4)->create(); + + $meta = [ + 'from' => $this->encodeCursor( + ["id" => (string) $posts[0]->getRouteKey()], + pointsToNextItems: false, + ), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $this->encodeCursor( + ["id" => (string) $posts[2]->getRouteKey()], + pointsToNextItems: true, + ), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '3'] + ]), + ], + 'next' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'after' => $this->encodeCursor( + ["id" => (string) $posts[2]->getRouteKey()], + pointsToNextItems: true, + ), + 'limit' => '3', + ], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); + + $this->assertSame(['page' => $meta], $page->meta()); + $this->assertSame($links, $page->links()->toArray()); + $this->assertPage($posts->take(3), $page); + } + + /** + * @return void + */ + public function testAfter(): void + { + $posts = Post::factory()->count(4)->create(); + + $this->paginator->withCamelCaseMeta(); + + $meta = [ + 'from' => $this->encodeCursor( + ["id" => (string) $posts[0]->getRouteKey()], + pointsToNextItems: false, + ), + 'hasMore' => false, + 'perPage' => 3, + 'to' => $this->encodeCursor( + ["id" => (string) $posts[0]->getRouteKey()], + pointsToNextItems: true, + ), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '3'] + ]), + ], + 'prev' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'before' => $this->encodeCursor( + ["id" => (string) $posts[0]->getRouteKey()], + pointsToNextItems: false, + ), + 'limit' => '3', + ], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate([ + 'after' => $this->encodeCursor( + ["id" => (string) $posts[1]->getRouteKey()], + 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(); + + $posts = Post::factory()->count(10)->create()->values(); + + $expected = [$posts[6], $posts[5], $posts[4]]; + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => 'TEST-' . $posts[6]->getRouteKey(), + ], pointsToNextItems: false), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $this->encodeCursor([ + "id" => 'TEST-' . $posts[4]->getRouteKey(), + ], pointsToNextItems: true), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '3'] + ]), + ], + 'next' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'after' => $this->encodeCursor([ + "id" => "TEST-" . $posts[4]->getRouteKey(), + ], pointsToNextItems: true), + 'limit' => '3', + ] + ]), + ], + 'prev' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'before' => $this->encodeCursor([ + "id" => "TEST-" . $posts[6]->getRouteKey(), + ], pointsToNextItems: false), + 'limit' => '3', + ], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate([ + 'after' => $this->encodeCursor([ + "id" => 'TEST-' . $posts[7]->getRouteKey(), + ], pointsToNextItems: true), + 'limit' => 3, + ]); + $this->assertSame(['page' => $meta], $page->meta()); + $this->assertSame($links, $page->links()->toArray()); + $this->assertPage($expected, $page); + } + + /** + * @return void + */ + public function testBefore(): void + { + $posts = Post::factory()->count(4)->create(); + + $this->paginator->withCamelCaseMeta(); + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => (string) $posts[3]->getRouteKey(), + ], pointsToNextItems: false), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '3'] + ]), + ], + 'prev' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'before' => $this->encodeCursor([ + "id" => (string) $posts[3]->getRouteKey(), + ], pointsToNextItems: false), + 'limit' => '3', + ] + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate([ + 'before' => $this->encodeCursor([ + "id" => (string) $posts[0]->getRouteKey(), + ], 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(); + + $posts = Post::factory()->count(10)->create()->values(); + + $expected = [$posts[6], $posts[5], $posts[4]]; + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => 'TEST-' . $posts[6]->getRouteKey(), + ], pointsToNextItems: false), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $this->encodeCursor([ + "id" => 'TEST-' . $posts[4]->getRouteKey(), + ], pointsToNextItems: true), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '3'] + ]), + ], + 'prev' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'before' => $this->encodeCursor([ + "id" => "TEST-" . $posts[6]->getRouteKey(), + ], pointsToNextItems: false), + 'limit' => '3', + ], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate([ + 'before' => $this->encodeCursor([ + "id" => 'TEST-' . $posts[3]->getRouteKey(), + ], pointsToNextItems: false), + 'limit' => 3, + ]); + $this->assertSame(['page' => $meta], $page->meta()); + $this->assertSame($links, $page->links()->toArray()); + $this->assertPage($expected, $page); + } + + /** + * When no page size is provided, the default is used from the model. + */ + public function testItUsesModelDefaultPerPage(): void + { + $expected = (new Post())->getPerPage(); + $posts = Post::factory()->count($expected + 1)->create(); + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => (string) $posts->last()->getRouteKey(), + ], pointsToNextItems: false), + 'hasMore' => true, + 'perPage' => $expected, + 'to' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => $expected] + ]), + ], + 'next' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'after' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + 'limit' => $expected, + ], + ]), + ], + ]; + + $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); + } + + /** + * The default per-page value can be overridden on the paginator. + */ + public function testItUsesDefaultPerPage(): void + { + $expected = (new Post())->getPerPage() - 5; + + $this->paginator->withDefaultPerPage($expected); + + $posts = Post::factory()->count($expected + 1)->create(); + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => (string) $posts->last()->getRouteKey(), + ], pointsToNextItems: false), + 'hasMore' => true, + 'perPage' => $expected, + 'to' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + ]; + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => $expected] + ]), + ], + 'next' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'after' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + 'limit' => $expected, + ], + ]), + ], + ]; + + $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(); + + $page = $this->posts->repository()->queryAll() + ->sort('id') + ->paginate(['limit' => '3']); + + $this->assertPage($posts->take(3), $page); + } + + /** + * @return void + */ + public function testPageWithReverseKeyWhenAscending(): void + { + $this->paginator->withAscending(); + + $posts = Post::factory()->count(4)->create(); + + $page = $this->posts->repository()->queryAll() + ->sort('-id') + ->paginate(['limit' => '3']); + + $this->assertPage($posts->reverse()->take(3), $page); + } + + /** + * If we are sorting by a column that might not be unique, we expect + * the page to always be returned in a particular order i.e. by the + * key column. + * + * @see https://github.com/cloudcreativity/laravel-json-api/issues/313 + */ + public function testDeterministicOrder1(): void + { + $first = Video::factory()->create([ + 'created_at' => Carbon::now()->subWeek(), + ]); + + $second = Video::factory()->create([ + 'created_at' => Carbon::now()->subHour(), + ]); + + $third = Video::factory()->create([ + 'created_at' => $second->created_at, + ]); + + $fourth = Video::factory()->create([ + 'created_at' => $second->created_at, + ]); + + $page = $this->videos + ->repository() + ->queryAll() + ->sort('createdAt') + ->paginate(['limit' => '3']); + + $this->assertPage([$first, $fourth, $third], $page); + } + + /** + * @return void + */ + public function testDeterministicOrder2(): void + { + Video::factory()->create([ + 'created_at' => Carbon::now()->subWeek(), + ]); + + $second = Video::factory()->create([ + 'created_at' => Carbon::now()->subHour(), + ]); + + $third = Video::factory()->create([ + 'created_at' => $second->created_at, + ]); + + $fourth = Video::factory()->create([ + 'created_at' => $second->created_at, + ]); + + $page = $this->videos->repository()->queryAll() + ->sort('-createdAt') + ->paginate(['limit' => '3']); + + $this->assertPage([$fourth, $third, $second], $page); + } + + /** + * @return void + */ + public function testMultipleSorts1(): void + { + $first = Video::factory()->create([ + 'title' => 'b', + 'created_at' => Carbon::now()->subWeek(), + ]); + + $second = Video::factory()->create([ + 'title' => 'a', + 'created_at' => Carbon::now()->subHour(), + ]); + + Video::factory()->create([ + 'title' => 'b', + 'created_at' => $second->created_at, + ]); + + $fourth = Video::factory()->create([ + 'title' => 'a', + 'created_at' => $second->created_at, + ]); + + $page = $this->videos->repository()->queryAll() + ->sort('title,createdAt') + ->paginate(['limit' => '3']); + + $this->assertPage([$fourth, $second, $first], $page); + } + + /** + * @return void + */ + public function testMultipleSorts2(): void + { + Video::factory()->create([ + 'title' => 'b', + 'created_at' => Carbon::now()->subWeek(), + ]); + + $second = Video::factory()->create([ + 'title' => 'a', + 'created_at' => Carbon::now()->subHour(), + ]); + + $third = Video::factory()->create([ + 'title' => 'b', + 'created_at' => $second->created_at, + ]); + + $fourth = Video::factory()->create([ + 'title' => 'a', + 'created_at' => $second->created_at, + ]); + + $page = $this->videos->repository()->queryAll() + ->sort('title,-createdAt') + ->paginate(['limit' => '3']); + + $this->assertPage([$fourth, $second, $third], $page); + } + + /** + * @return void + */ + public function testWithoutKeySort(): void + { + $this->paginator->withoutKeySort(); + + $first = Video::factory()->create([ + 'title' => 'a', + ]); + + $second = Video::factory()->create([ + 'title' => 'a', + ]); + + Video::factory()->create([ + 'title' => 'c', + ]); + + $fourth = Video::factory()->create([ + 'title' => 'b', + ]); + + $page = $this->videos->repository()->queryAll() + ->sort('title') + ->paginate(['limit' => '3']); + + $this->assertPage([$first, $second, $fourth], $page); + } + + /** + * @return void + */ + public function testCustomPageKeys(): void + { + $posts = Post::factory()->count(4)->create(); + + $this->paginator->withAfterKey('next')->withBeforeKey('prev')->withLimitKey('perPage'); + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['perPage' => '3'] + ]), + ], + 'next' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'next' => $this->encodeCursor( + ["id" => (string) $posts[1]->getRouteKey()], + pointsToNextItems: true, + ), + 'perPage' => '3', + ], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate(['perPage' => '3']); + + $this->assertSame($links, $page->links()->toArray()); + + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['perPage' => '3'] + ]), + ], + 'prev' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'perPage' => '3', + 'prev' => $this->encodeCursor( + ["id" => (string) $posts[0]->getRouteKey()], + pointsToNextItems: false, + ), + ], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate([ + 'next' => $this->encodeCursor( + ["id" => (string) $posts[1]->getRouteKey()], + pointsToNextItems: true, + ), + 'perPage' => '3', + ]); + + $this->assertSame($links, $page->links()->toArray()); + } + + /** + * @return void + */ + public function testSnakeCaseMetaAndCustomMetaKey(): void + { + $posts = Post::factory()->count(4)->create(); + + $this->paginator->withMetaKey('paginator')->withSnakeCaseMeta(); + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => (string) $posts[3]->getRouteKey(), + ], pointsToNextItems: false), + 'has_more' => true, + 'per_page' => 3, + 'to' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + ]; + + $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); + + $this->assertSame(['paginator' => $meta], $page->meta()); + $this->assertPage($posts->reverse()->take(3), $page); + } + + /** + * @return void + */ + public function testDashCaseMeta(): void + { + $posts = Post::factory()->count(4)->create(); + + $this->paginator->withDashCaseMeta(); + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => (string) $posts[3]->getRouteKey(), + ], pointsToNextItems: false), + 'has-more' => true, + 'per-page' => 3, + 'to' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + ]; + + $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); + + $this->assertSame(['page' => $meta], $page->meta()); + $this->assertPage($posts->reverse()->take(3), $page); + } + + /** + * @return void + */ + public function testMetaNotNested(): void + { + $posts = Post::factory()->count(4)->create(); + + $this->paginator->withoutNestedMeta(); + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => (string) $posts[3]->getRouteKey(), + ], pointsToNextItems: false), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + ]; + + $page = $this->posts->repository()->queryAll()->paginate(['limit' => '3']); + + $this->assertSame($meta, $page->meta()); + $this->assertPage($posts->reverse()->take(3), $page); + } + + /** + * @return void + */ + public function testItCanRemoveMeta(): void + { + $posts = Post::factory()->count(4)->create(); + + $this->paginator->withoutMeta(); + + $links = [ + 'first' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => ['limit' => '3'] + ]), + ], + 'next' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'page' => [ + 'after' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + 'limit' => '3', + ], + ]), + ], + ]; + + $page = $this->posts->repository()->queryAll()->paginate(['limit' => 3]); + + $this->assertEmpty($page->meta()); + $this->assertSame($links, $page->links()->toArray()); + $this->assertPage($posts->reverse()->take(3), $page); + } + + /** + * @return void + */ + public function testUrlsIncludeOtherQueryParameters(): void + { + $posts = Post::factory()->count(6)->create(); + $slugs = $posts->take(4)->pluck('slug')->implode(','); + + $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'], + ]), + ], + 'next' => [ + 'href' => 'http://localhost/api/v1/posts?' . Arr::query([ + 'fields' => $fields, + 'filter' => ['slugs' => $slugs], + 'include' => 'author', + 'page' => [ + 'after' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + 'limit' => '3', + ], + ]), + ], + ]; + + $query = QueryParameters::make() + ->setFilters(['slugs' => $slugs]) + ->setSparseFieldSets($fields) + ->setIncludePaths('author'); + + $page = $this->posts + ->repository() + ->queryAll() + ->withQuery($query) + ->paginate(['limit' => 3]); + + $this->assertSame($links, $page->links()->toArray()); + } + + /** + * @return void + */ + public function testWithTotal(): void + { + $this->paginator->withTotal(); + + $posts = Post::factory()->count(4)->create(); + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => (string) $posts[3]->getRouteKey(), + ], pointsToNextItems: false), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + 'total' => 4, + ]; + + $page = $this->posts + ->repository() + ->queryAll() + ->paginate(['limit' => 3]); + + $this->assertInstanceOf(Page::class, $page); + $this->assertSame(['page' => $meta], $page->meta()); + + $page = $this->posts + ->repository() + ->queryAll() + ->paginate([ + 'after' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + 'limit' => 3, + ]); + + $this->assertInstanceOf(Page::class, $page); + $this->assertArrayHasKey('page', $page->meta()); + $this->assertArrayHasKey('total', $page->meta()['page']); + $this->assertEquals(4, $page->meta()['page']['total']); + + } + + /** + * @return void + */ + public function testWithTotalOnFirstPage(): void + { + $this->paginator->withTotalOnFirstPage(); + + $posts = Post::factory()->count(4)->create(); + + $meta = [ + 'from' => $this->encodeCursor([ + "id" => (string) $posts[3]->getRouteKey(), + ], pointsToNextItems: false), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + 'total' => 4, + ]; + + $page = $this->posts + ->repository() + ->queryAll() + ->paginate(['limit' => 3]); + + $this->assertInstanceOf(Page::class, $page); + $this->assertSame(['page' => $meta], $page->meta()); + + $page = $this->posts + ->repository() + ->queryAll() + ->paginate([ + 'after' => $this->encodeCursor([ + "id" => (string) $posts[1]->getRouteKey(), + ], pointsToNextItems: true), + 'limit' => 3, + ]); + + $this->assertInstanceOf(Page::class, $page); + $this->assertArrayHasKey('page', $page->meta()); + $this->assertArrayNotHasKey('total', $page->meta()['page']); + } + + /** + * Assert that the pages match. + * + * @param iterable $expected + * @param iterable $actual + */ + private function assertPage(iterable $expected, iterable $actual): void + { + $expected = Collection::make($expected)->modelKeys(); + $actual = Collection::make($actual)->modelKeys(); + + $this->assertSame(array_values($expected), array_values($actual)); + } + + /** + * @return void + */ + private function withIdEncoding(): void + { + $this->paginator = CursorPagination::make( + $this->encodedId = new EncodedId(), + ); + } + + /** + * @param array $params + * @param bool $pointsToNextItems + * @return string + */ + private function encodeCursor(array $params, bool $pointsToNextItems) : string + { + $cursor = new Cursor($params, $pointsToNextItems); + + return $cursor->encode(); + } +} diff --git a/tests/lib/EncodedId.php b/tests/lib/EncodedId.php new file mode 100644 index 0000000..4afc4cd --- /dev/null +++ b/tests/lib/EncodedId.php @@ -0,0 +1,91 @@ +