diff --git a/src/CursorPagination.php b/src/CursorPagination.php index 4cc7a4b..6a4b8c8 100644 --- a/src/CursorPagination.php +++ b/src/CursorPagination.php @@ -19,6 +19,7 @@ namespace LaravelJsonApi\CursorPagination; +use Closure; use InvalidArgumentException; use LaravelJsonApi\Contracts\Pagination\Page; use LaravelJsonApi\Core\Pagination\Concerns\HasPageMeta; @@ -61,6 +62,11 @@ class CursorPagination implements Paginator */ private ?string $primaryKey = null; + /** + * @var Closure|null + */ + private ?Closure $keyDecoderCallback = null; + /** * @var string|array|null */ @@ -194,6 +200,22 @@ public function withKeyName(string $column): self return $this; } + /** + * Set the decoder callback for paginator key column values. + * + * In case key column value is stored not as a string, for example uuid in binary format, + * it has to be decoded before passing it to Cursor. + * + * @param callable $callback + * @return $this + */ + public function withKeyDecoder(callable $callback): self + { + $this->keyDecoderCallback = Closure::fromCallable($callback); + + return $this; + } + /** * @inheritDoc */ @@ -260,10 +282,22 @@ private function cursor(array $page): Cursor $limit = $page[$this->limit] ?? null; return new Cursor( - !is_null($before) ? strval($before) : null, - !is_null($after) ? strval($after) : null, + !is_null($before) ? $this->decodePaginationKey($before) : null, + !is_null($after) ? $this->decodePaginationKey($after) : null, !is_null($limit) ? intval($limit) : null, ); } + /** + * Apply key value decoder callback if it's not null. + * + * @param $value + * @return string + */ + private function decodePaginationKey($value): string + { + $decoded = $this->keyDecoderCallback ? ($this->keyDecoderCallback)($value) : $value; + + return strval($decoded); + } } diff --git a/tests/database/factories/VideoFactory.php b/tests/database/factories/VideoFactory.php index 4eff58e..dc6f160 100644 --- a/tests/database/factories/VideoFactory.php +++ b/tests/database/factories/VideoFactory.php @@ -38,9 +38,9 @@ class VideoFactory extends Factory public function definition() { return [ - 'slug' => $this->faker->unique()->slug, + 'slug' => $this->faker->unique()->slug(), 'title' => $this->faker->words(5, true), - 'url' => $this->faker->url, + 'url' => $this->faker->url(), ]; } diff --git a/tests/lib/Acceptance/Test.php b/tests/lib/Acceptance/Test.php index 08f7915..e7781cc 100644 --- a/tests/lib/Acceptance/Test.php +++ b/tests/lib/Acceptance/Test.php @@ -82,7 +82,7 @@ public function testDefaultPagination(): void $this->videos->method('defaultPagination')->willReturn(['limit' => '3']); $videos = Video::factory()->count(5)->create([ - 'created_at' => fn() => $this->faker->dateTime, + 'created_at' => fn() => $this->faker->dateTime(), ])->sortByDesc('created_at')->values(); $expected = $videos->take(3); @@ -191,7 +191,7 @@ public function testNoPages(): void public function testOnlyLimit(): void { $videos = Video::factory()->count(5)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('created_at')->values(); $meta = [ @@ -213,7 +213,7 @@ public function testOnlyLimit(): void public function testBefore(): void { $videos = Video::factory()->count(10)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('created_at')->values(); $expected = [$videos[4], $videos[5], $videos[6]]; @@ -242,7 +242,7 @@ public function testBeforeAscending(): void $this->paginator->withAscending(); $videos = Video::factory()->count(10)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortBy('created_at')->values(); $expected = [$videos[4], $videos[5], $videos[6]]; @@ -307,14 +307,14 @@ public function testBeforeDoesNotExist(): void $this->expectExceptionMessage('does not exist'); $this->videos->repository()->queryAll()->paginate([ - 'before' => $this->faker->uuid, + 'before' => $this->faker->uuid(), ]); } public function testAfter(): void { $videos = Video::factory()->count(10)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('created_at')->values(); $expected = [$videos[4], $videos[5], $videos[6]]; @@ -343,7 +343,7 @@ public function testAfterAscending(): void $this->paginator->withAscending(); $videos = Video::factory()->count(10)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortBy('created_at')->values(); $expected = [$videos[4], $videos[5], $videos[6]]; @@ -370,7 +370,7 @@ public function testAfterAscending(): void public function testAfterWithoutMore(): void { $videos = Video::factory()->count(4)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('created_at')->values(); $expected = [$videos[2], $videos[3]]; @@ -434,7 +434,7 @@ public function testAfterWithCustomKeys(): void ->withLimitKey('per-page'); $videos = Video::factory()->count(6)->create([ - 'created_at' => fn() => $this->faker->dateTime, + 'created_at' => fn() => $this->faker->dateTime(), ])->sortByDesc('created_at')->values(); $expected = [$videos[2], $videos[3], $videos[4]]; @@ -493,7 +493,7 @@ public function testAfterDoesNotExist(): void $this->expectExceptionMessage('does not exist'); $this->videos->repository()->queryAll()->paginate([ - 'after' => $this->faker->uuid, + 'after' => $this->faker->uuid(), ]); } @@ -503,7 +503,7 @@ public function testAfterDoesNotExist(): void public function testBeforeAndAfter(): void { $videos = Video::factory()->count(6)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('created_at')->values(); $expected = [$videos[2], $videos[3], $videos[4]]; @@ -537,7 +537,7 @@ public function testSameColumnAndIdentifier(): void $this->paginator->withCursorColumn('uuid'); $videos = Video::factory()->count(6)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('uuid')->values(); $expected = [$videos[1], $videos[2], $videos[3]]; @@ -567,7 +567,7 @@ public function testSnakeCaseMetaAndCustomMetaKey(): void $this->paginator->withMetaKey('cursor')->withSnakeCaseMeta(); $videos = Video::factory()->count(6)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('created_at')->values(); $expected = [$videos[1], $videos[2], $videos[3]]; @@ -593,7 +593,7 @@ public function testDashCaseMeta(): void $this->paginator->withDashCaseMeta(); $videos = Video::factory()->count(6)->create([ - 'created_at' => fn() => $this->faker->unique()->dateTime, + 'created_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('created_at')->values(); $expected = [$videos[1], $videos[2], $videos[3]]; @@ -622,7 +622,7 @@ public function testColumn(): void $this->paginator->withCursorColumn('updated_at'); $videos = Video::factory()->count(6)->create([ - 'updated_at' => fn() => $this->faker->unique()->dateTime, + 'updated_at' => fn() => $this->faker->unique()->dateTime(), ])->sortByDesc('updated_at')->values(); $expected = [$videos[1], $videos[2], $videos[3]]; @@ -654,7 +654,7 @@ public function testItUsesModelDefaultPerPage(): void $expected = (new Video())->getPerPage(); $videos = Video::factory()->count($expected + 5)->create([ - 'created_at' => fn() => $this->faker->dateTime, + 'created_at' => fn() => $this->faker->dateTime(), ])->sortByDesc('created_at')->values(); $meta = [ @@ -683,7 +683,7 @@ public function testItUsesDefaultPerPage(): void $this->paginator->withDefaultPerPage($expected); $videos = Video::factory()->count($expected + 5)->create([ - 'created_at' => fn() => $this->faker->dateTime, + 'created_at' => fn() => $this->faker->dateTime(), ])->sortByDesc('created_at')->values(); $meta = [ @@ -707,7 +707,7 @@ public function testItCanRemoveMeta(): void $this->paginator->withoutMeta(); $videos = Video::factory()->count(4)->create([ - 'created_at' => fn() => $this->faker->dateTime, + 'created_at' => fn() => $this->faker->dateTime(), ])->sortByDesc('created_at')->values(); $links = $this->createLinks($videos[0], $videos[2], 3); @@ -719,6 +719,39 @@ public function testItCanRemoveMeta(): void $this->assertPage($videos->take(3), $page); } + /** + * Test use of the cursor paginator where the pagination column value is encoded + */ + public function testCursorColumnEncoded(): void + { + $this->paginator->withCursorColumn('uuid')->withKeyDecoder(fn($v) => base64_decode($v)); + + $videos = Video::factory()->count(6)->create([ + 'created_at' => fn() => $this->faker->unique()->dateTime(), + ])->sortByDesc('uuid')->values(); + + $expected = [$videos[1], $videos[2], $videos[3]]; + + $meta = [ + 'from' => $expected[0]->getRouteKey(), + 'hasMore' => true, + 'perPage' => 3, + 'to' => $expected[2]->getRouteKey(), + ]; + + $links = $this->createLinks($expected[0], $expected[2], 3); + + $page = $this->videos->repository()->queryAll()->paginate([ + 'limit' => '3', + 'before' => base64_encode($videos[4]->getRouteKey()), + 'after' => base64_encode($videos[1]->getRouteKey()), + ]); + + $this->assertSame(['page' => $meta], $page->meta()); + $this->assertSame($links, $page->links()->toArray()); + $this->assertPage($expected, $page); + } + /** * @param Model $from * @param Model $to