Skip to content

Commit fda0658

Browse files
committed
feat(support): add skip-take pagination support
1 parent 4386578 commit fda0658

File tree

6 files changed

+548
-0
lines changed

6 files changed

+548
-0
lines changed
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 InvalidArgumentException 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\InvalidArgumentException;
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 InvalidArgumentException('Total items cannot be negative');
17+
}
18+
19+
if ($this->itemsPerPage <= 0) {
20+
throw new InvalidArgumentException('Items per page must be positive');
21+
}
22+
23+
if ($this->currentPage <= 0) {
24+
throw new InvalidArgumentException('Current page must be positive');
25+
}
26+
27+
if ($this->maxLinks <= 0) {
28+
throw new InvalidArgumentException('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+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
namespace Tempest\Support\Tests\Paginator;
4+
5+
use PHPUnit\Framework\Attributes\Test;
6+
use PHPUnit\Framework\TestCase;
7+
use Tempest\Support\Paginator\PaginatedData;
8+
9+
final class PaginatedDataTest extends TestCase
10+
{
11+
private function createSamplePaginatedData(array $data = ['item1', 'item2', 'item3']): PaginatedData
12+
{
13+
return new PaginatedData(
14+
data: $data,
15+
currentPage: 2,
16+
totalPages: 5,
17+
totalItems: 100,
18+
itemsPerPage: 20,
19+
offset: 20,
20+
limit: 20,
21+
hasNext: true,
22+
hasPrevious: true,
23+
nextPage: 3,
24+
previousPage: 1,
25+
pageRange: [1, 2, 3, 4, 5],
26+
);
27+
}
28+
29+
#[Test]
30+
public function it_stores_data_and_pagination_info(): void
31+
{
32+
$data = ['item1', 'item2', 'item3'];
33+
$paginatedData = $this->createSamplePaginatedData($data);
34+
35+
$this->assertSame($data, $paginatedData->data);
36+
$this->assertSame(2, $paginatedData->currentPage);
37+
$this->assertSame(5, $paginatedData->totalPages);
38+
$this->assertSame(100, $paginatedData->totalItems);
39+
$this->assertTrue($paginatedData->hasNext);
40+
$this->assertTrue($paginatedData->hasPrevious);
41+
}
42+
43+
#[Test]
44+
public function it_calculates_count_property(): void
45+
{
46+
$paginatedData = $this->createSamplePaginatedData(['a', 'b', 'c', 'd']);
47+
48+
$this->assertSame(4, $paginatedData->count);
49+
}
50+
51+
#[Test]
52+
public function it_checks_empty_status(): void
53+
{
54+
$emptyData = $this->createSamplePaginatedData([]);
55+
$nonEmptyData = $this->createSamplePaginatedData(['item']);
56+
57+
$this->assertTrue($emptyData->isEmpty);
58+
$this->assertFalse($emptyData->isNotEmpty);
59+
60+
$this->assertFalse($nonEmptyData->isEmpty);
61+
$this->assertTrue($nonEmptyData->isNotEmpty);
62+
}
63+
64+
#[Test]
65+
public function it_maps_data_while_preserving_pagination(): void
66+
{
67+
$original = $this->createSamplePaginatedData([1, 2, 3]);
68+
$mapped = $original->map(fn ($x) => $x * 2);
69+
70+
$this->assertSame([2, 4, 6], $mapped->data);
71+
$this->assertSame($original->currentPage, $mapped->currentPage);
72+
$this->assertSame($original->totalPages, $mapped->totalPages);
73+
$this->assertSame($original->totalItems, $mapped->totalItems);
74+
}
75+
76+
#[Test]
77+
public function it_converts_to_array(): void
78+
{
79+
$paginatedData = $this->createSamplePaginatedData(['a', 'b']);
80+
$array = $paginatedData->toArray();
81+
82+
$expected = [
83+
'data' => ['a', 'b'],
84+
'pagination' => [
85+
'current_page' => 2,
86+
'total_pages' => 5,
87+
'total_items' => 100,
88+
'items_per_page' => 20,
89+
'offset' => 20,
90+
'limit' => 20,
91+
'has_next' => true,
92+
'has_previous' => true,
93+
'next_page' => 3,
94+
'previous_page' => 1,
95+
'page_range' => [1, 2, 3, 4, 5],
96+
'count' => 2,
97+
],
98+
];
99+
100+
$this->assertEquals($expected, $array);
101+
}
102+
}

0 commit comments

Comments
 (0)