Skip to content

Commit 07f9f4d

Browse files
authored
feat(database): add pagination support (#1417)
1 parent 4af4429 commit 07f9f4d

File tree

11 files changed

+655
-6
lines changed

11 files changed

+655
-6
lines changed

packages/database/src/Builder/ModelInspector.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ final class ModelInspector
3131

3232
private(set) object|string $instance;
3333

34-
public function __construct(object|string $model)
35-
{
34+
public function __construct(
35+
private(set) object|string $model,
36+
) {
3637
if ($model instanceof HasMany) {
3738
$model = $model->property->getIterableType()->asClass();
3839
$this->reflector = $model;

packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
use Tempest\Database\QueryStatements\SelectStatement;
2121
use Tempest\Support\Arr\ImmutableArray;
2222
use Tempest\Support\Conditions\HasConditions;
23+
use Tempest\Support\Paginator\PaginatedData;
24+
use Tempest\Support\Paginator\Paginator;
2325

2426
use function Tempest\Database\model;
2527
use function Tempest\map;
@@ -78,6 +80,23 @@ public function first(mixed ...$bindings): mixed
7880
return $result[array_key_first($result)];
7981
}
8082

83+
/** @return PaginatedData<TModelClass> */
84+
public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxLinks = 10): PaginatedData
85+
{
86+
$total = new CountQueryBuilder($this->model->model)->execute();
87+
88+
$paginator = new Paginator(
89+
totalItems: $total,
90+
itemsPerPage: $itemsPerPage,
91+
currentPage: $currentPage,
92+
maxLinks: $maxLinks,
93+
);
94+
95+
return $paginator->paginateWith(
96+
callback: fn (int $limit, int $offset) => $this->limit($limit)->offset($offset)->all(),
97+
);
98+
}
99+
81100
/** @return TModelClass|null */
82101
public function get(Id $id): mixed
83102
{

packages/support/src/Json/functions.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,16 @@
2121
*
2222
* @throws Exception\JsonCouldNotBeDecoded If an error occurred.
2323
*/
24-
function decode(string $json, bool $associative = true): mixed
24+
function decode(string $json, bool $associative = true, bool $base64 = false): mixed
2525
{
26+
if ($base64) {
27+
$json = base64_decode($json, strict: true);
28+
29+
if ($json === false) {
30+
throw new Exception\JsonCouldNotBeDecoded('The provided base64 string is not valid.');
31+
}
32+
}
33+
2634
try {
2735
/** @var mixed $value */
2836
$value = json_decode($json, $associative, 512, JSON_BIGINT_AS_STRING | JSON_THROW_ON_ERROR);
@@ -40,7 +48,7 @@ function decode(string $json, bool $associative = true): mixed
4048
*
4149
* @return non-empty-string
4250
*/
43-
function encode(mixed $value, bool $pretty = false, int $flags = 0): string
51+
function encode(mixed $value, bool $pretty = false, int $flags = 0, bool $base64 = false): string
4452
{
4553
$flags |= JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR;
4654

@@ -55,6 +63,10 @@ function encode(mixed $value, bool $pretty = false, int $flags = 0): string
5563
throw new Exception\JsonCouldNotBeEncoded(sprintf('%s.', $jsonException->getMessage()), $jsonException->getCode(), $jsonException);
5664
}
5765

66+
if ($base64) {
67+
return base64_encode($json);
68+
}
69+
5870
return $json;
5971
}
6072

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Tempest\Support\Paginator\Exceptions;
4+
5+
use InvalidArgumentException as PhpInvalidArgumentException;
6+
7+
final class ArgumentWasInvalid extends PhpInvalidArgumentException implements PaginationException
8+
{
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Tempest\Support\Paginator\Exceptions;
4+
5+
interface PaginationException
6+
{
7+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Tempest\Support\Paginator;
4+
5+
use JsonSerializable;
6+
7+
/**
8+
* @template T
9+
*/
10+
final class PaginatedData implements JsonSerializable
11+
{
12+
/**
13+
* @param array<T> $data
14+
*/
15+
public function __construct(
16+
public array $data,
17+
public int $currentPage,
18+
public int $totalPages,
19+
public int $totalItems,
20+
public int $itemsPerPage,
21+
public int $offset,
22+
public int $limit,
23+
public bool $hasNext,
24+
public bool $hasPrevious,
25+
public ?int $nextPage,
26+
public ?int $previousPage,
27+
public array $pageRange,
28+
) {}
29+
30+
public int $count {
31+
get => count($this->data);
32+
}
33+
34+
public bool $isEmpty {
35+
get => $this->count === 0;
36+
}
37+
38+
public bool $isNotEmpty {
39+
get => ! $this->isEmpty;
40+
}
41+
42+
/**
43+
* @template U
44+
* @param callable(mixed): U $callback
45+
* @return PaginatedData<U>
46+
*/
47+
public function map(callable $callback): self
48+
{
49+
return new self(
50+
data: array_map($callback, $this->data),
51+
currentPage: $this->currentPage,
52+
totalPages: $this->totalPages,
53+
totalItems: $this->totalItems,
54+
itemsPerPage: $this->itemsPerPage,
55+
offset: $this->offset,
56+
limit: $this->limit,
57+
hasNext: $this->hasNext,
58+
hasPrevious: $this->hasPrevious,
59+
nextPage: $this->nextPage,
60+
previousPage: $this->previousPage,
61+
pageRange: $this->pageRange,
62+
);
63+
}
64+
65+
public function toArray(): array
66+
{
67+
return [
68+
'data' => $this->data,
69+
'pagination' => [
70+
'current_page' => $this->currentPage,
71+
'total_pages' => $this->totalPages,
72+
'total_items' => $this->totalItems,
73+
'items_per_page' => $this->itemsPerPage,
74+
'offset' => $this->offset,
75+
'limit' => $this->limit,
76+
'has_next' => $this->hasNext,
77+
'has_previous' => $this->hasPrevious,
78+
'next_page' => $this->nextPage,
79+
'previous_page' => $this->previousPage,
80+
'page_range' => $this->pageRange,
81+
'count' => $this->count,
82+
],
83+
];
84+
}
85+
86+
public function jsonSerialize(): array
87+
{
88+
return $this->toArray();
89+
}
90+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
namespace Tempest\Support\Paginator;
4+
5+
use Tempest\Support\Paginator\Exceptions\ArgumentWasInvalid;
6+
7+
final class Paginator
8+
{
9+
public function __construct(
10+
private(set) int $totalItems,
11+
private(set) int $itemsPerPage = 20,
12+
private(set) int $currentPage = 1,
13+
private(set) int $maxLinks = 10,
14+
) {
15+
if ($this->totalItems < 0) {
16+
throw new ArgumentWasInvalid('Total items cannot be negative');
17+
}
18+
19+
if ($this->itemsPerPage <= 0) {
20+
throw new ArgumentWasInvalid('Items per page must be positive');
21+
}
22+
23+
if ($this->currentPage <= 0) {
24+
throw new ArgumentWasInvalid('Current page must be positive');
25+
}
26+
27+
if ($this->maxLinks <= 0) {
28+
throw new ArgumentWasInvalid('Max links must be positive');
29+
}
30+
31+
$this->currentPage = min(max(1, $this->currentPage), $this->totalPages);
32+
}
33+
34+
public int $totalPages {
35+
get => max(1, (int) ceil($this->totalItems / $this->itemsPerPage));
36+
}
37+
38+
public int $offset {
39+
get => ($this->currentPage - 1) * $this->itemsPerPage;
40+
}
41+
42+
public int $limit {
43+
get => $this->itemsPerPage;
44+
}
45+
46+
public bool $hasNext {
47+
get => $this->currentPage < $this->totalPages;
48+
}
49+
50+
public bool $hasPrevious {
51+
get => $this->currentPage > 1;
52+
}
53+
54+
public ?int $nextPage {
55+
get => $this->hasNext ? ($this->currentPage + 1) : null;
56+
}
57+
58+
public ?int $previousPage {
59+
get => $this->hasPrevious ? ($this->currentPage - 1) : null;
60+
}
61+
62+
public ?int $firstPage {
63+
get => $this->totalPages > 0 ? 1 : null;
64+
}
65+
66+
public ?int $lastPage {
67+
get => $this->totalPages > 0 ? $this->totalPages : null;
68+
}
69+
70+
public array $pageRange {
71+
get => $this->calculatePageRange();
72+
}
73+
74+
public function withPage(int $page): self
75+
{
76+
return new self(
77+
totalItems: $this->totalItems,
78+
itemsPerPage: $this->itemsPerPage,
79+
currentPage: $page,
80+
maxLinks: $this->maxLinks,
81+
);
82+
}
83+
84+
public function withItemsPerPage(int $itemsPerPage): self
85+
{
86+
return new self(
87+
totalItems: $this->totalItems,
88+
itemsPerPage: $itemsPerPage,
89+
currentPage: $this->currentPage,
90+
maxLinks: $this->maxLinks,
91+
);
92+
}
93+
94+
/**
95+
* Creates paginated data with the provided items.
96+
*
97+
* @template T
98+
* @param array<T> $data
99+
* @return PaginatedData<T>
100+
*/
101+
public function paginate(array $data): PaginatedData
102+
{
103+
return new PaginatedData(
104+
data: $data,
105+
currentPage: $this->currentPage,
106+
totalPages: $this->totalPages,
107+
totalItems: $this->totalItems,
108+
itemsPerPage: $this->itemsPerPage,
109+
offset: $this->offset,
110+
limit: $this->limit,
111+
hasNext: $this->hasNext,
112+
hasPrevious: $this->hasPrevious,
113+
nextPage: $this->nextPage,
114+
previousPage: $this->previousPage,
115+
pageRange: $this->pageRange,
116+
);
117+
}
118+
119+
/**
120+
* Creates paginated data from a callable that fetches data.
121+
*
122+
* @template T
123+
* @param callable(int $limit, int $offset): array<T> $callback
124+
* @return PaginatedData<T>
125+
*/
126+
public function paginateWith(callable $callback): PaginatedData
127+
{
128+
return $this->paginate($callback($this->limit, $this->offset));
129+
}
130+
131+
private function calculatePageRange(): array
132+
{
133+
if ($this->totalPages <= $this->maxLinks) {
134+
return range(1, $this->totalPages);
135+
}
136+
137+
$half = (int) floor($this->maxLinks / 2);
138+
$start = max(1, $this->currentPage - $half);
139+
$end = min($this->totalPages, ($start + $this->maxLinks) - 1);
140+
141+
if ((($end - $start) + 1) < $this->maxLinks) {
142+
$start = max(1, ($end - $this->maxLinks) + 1);
143+
}
144+
145+
return range($start, $end);
146+
}
147+
}

packages/support/tests/Json/JsonTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,28 @@ public function test_is_valid(): void
121121
$this->assertFalse(Json\is_valid(['foo' => 'bar']));
122122
$this->assertFalse(Json\is_valid(1));
123123
}
124+
125+
public function test_base64_encode_and_decode(): void
126+
{
127+
$data = [
128+
'name' => 'azjezz/psl',
129+
'type' => 'library',
130+
'description' => 'PHP Standard Library.',
131+
'keywords' => ['php', 'std', 'stdlib', 'utility', 'psl'],
132+
'license' => 'MIT',
133+
];
134+
135+
$encoded = Json\encode($data, base64: true);
136+
$decoded = Json\decode($encoded, base64: true);
137+
138+
$this->assertSame($data, $decoded);
139+
}
140+
141+
public function test_base64_decode_failure(): void
142+
{
143+
$this->expectException(Json\Exception\JsonCouldNotBeDecoded::class);
144+
$this->expectExceptionMessage('The provided base64 string is not valid.');
145+
146+
Json\decode('invalid_base64', base64: true);
147+
}
124148
}

0 commit comments

Comments
 (0)