Skip to content

Commit d84d81d

Browse files
committed
Merge tag v2.3.0 into develop
2 parents 02c962a + 04b78a2 commit d84d81d

File tree

4 files changed

+366
-0
lines changed

4 files changed

+366
-0
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ All notable changes to this project will be documented in this file. This projec
99

1010
- Upgraded to Laravel 10 and set minimum PHP version to `8.1`.
1111

12+
## [2.3.0] - 2023-02-09
13+
14+
### Added
15+
16+
- New `MultiPaginator` that allows a schema to offer multiple different pagination strategies.
17+
1218
## [2.2.1] - 2023-01-23
1319

1420
### Fixed

src/Pagination/MultiPagination.php

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
/*
3+
* Copyright 2023 Cloud Creativity Limited
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace LaravelJsonApi\Eloquent\Pagination;
21+
22+
use LaravelJsonApi\Contracts\Pagination\Page;
23+
use LaravelJsonApi\Eloquent\Contracts\Paginator;
24+
25+
class MultiPagination implements Paginator
26+
{
27+
/**
28+
* @var Paginator[]
29+
*/
30+
private array $paginators;
31+
32+
/**
33+
* @var array|null
34+
*/
35+
private ?array $keys = null;
36+
37+
/**
38+
* MultiPagination constructor.
39+
*
40+
* @param Paginator ...$paginators
41+
*/
42+
public function __construct(Paginator ...$paginators)
43+
{
44+
$this->paginators = $paginators;
45+
}
46+
47+
/**
48+
* @inheritDoc
49+
*/
50+
public function withColumns($columns): Paginator
51+
{
52+
foreach ($this->paginators as $paginator) {
53+
$paginator->withColumns($columns);
54+
}
55+
56+
return $this;
57+
}
58+
59+
/**
60+
* @inheritDoc
61+
*/
62+
public function keys(): array
63+
{
64+
if ($this->keys !== null) {
65+
return $this->keys;
66+
}
67+
68+
$keys = [];
69+
70+
foreach ($this->paginators as $paginator) {
71+
$keys = [
72+
...$keys,
73+
...$paginator->keys(),
74+
];
75+
}
76+
77+
return $this->keys = array_values(array_unique($keys));
78+
}
79+
80+
/**
81+
* @inheritDoc
82+
*/
83+
public function withKeyName(string $column): Paginator
84+
{
85+
foreach ($this->paginators as $paginator) {
86+
$paginator->withKeyName($column);
87+
}
88+
89+
return $this;
90+
}
91+
92+
/**
93+
* @inheritDoc
94+
*/
95+
public function paginate($query, array $page): Page
96+
{
97+
$pageKeys = array_keys($page);
98+
$selected = null;
99+
100+
foreach ($this->paginators as $paginator) {
101+
$keys = $paginator->keys();
102+
$intersection = array_intersect($keys, $pageKeys);
103+
104+
/** Exact match for a paginator - immediately use this one. */
105+
if (!empty($intersection) && empty(array_diff($pageKeys, $keys))) {
106+
$selected = $paginator;
107+
break;
108+
}
109+
110+
/**
111+
* Does match but has a diff, we'll remember the paginator
112+
* and use it if there are no exact matches.
113+
*/
114+
if ($selected === null && !empty($intersection)) {
115+
$selected = $paginator;
116+
}
117+
}
118+
119+
if ($selected !== null) {
120+
return $selected->paginate($query, $page);
121+
}
122+
123+
throw new \LogicException(
124+
'Could not determine which paginator to use. ' .
125+
'Use validation to ensure the client provides query parameters that match at least one paginator. ' .
126+
'Keys received: ' . implode(',', $pageKeys),
127+
);
128+
}
129+
}

tests/lib/Unit/.gitkeep

Whitespace-only changes.
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<?php
2+
/*
3+
* Copyright 2023 Cloud Creativity Limited
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace LaravelJsonApi\Eloquent\Tests\Unit\Pagination;
21+
22+
use Illuminate\Database\Eloquent\Builder;
23+
use LaravelJsonApi\Contracts\Pagination\Page;
24+
use LaravelJsonApi\Eloquent\Contracts\Paginator;
25+
use LaravelJsonApi\Eloquent\Pagination\MultiPagination;
26+
use PHPUnit\Framework\TestCase;
27+
28+
class MultiPaginationTest extends TestCase
29+
{
30+
public function testKeys(): void
31+
{
32+
$paginator1 = $this->createMock(Paginator::class);
33+
$paginator1->expects($this->once())->method('keys')->willReturn(['number', 'size']);
34+
35+
$paginator2 = $this->createMock(Paginator::class);
36+
$paginator2->expects($this->once())->method('keys')->willReturn(['before', 'after', 'limit']);
37+
38+
$paginator3 = $this->createMock(Paginator::class);
39+
$paginator3->expects($this->once())->method('keys')->willReturn(['number', 'chunk']);
40+
41+
$paginator = new MultiPagination(
42+
$paginator1,
43+
$paginator2,
44+
$paginator3,
45+
);
46+
47+
$this->assertSame($expected = [
48+
'number',
49+
'size',
50+
'before',
51+
'after',
52+
'limit',
53+
'chunk',
54+
], $paginator->keys());
55+
$this->assertSame($expected, $paginator->keys());
56+
}
57+
58+
public function testWithColumns(): void
59+
{
60+
$columns = ['foo', 'bar', 'baz'];
61+
62+
$paginator1 = $this->createMock(Paginator::class);
63+
$paginator1
64+
->expects($this->once())
65+
->method('withColumns')
66+
->with($this->identicalTo($columns))
67+
->willReturnSelf();
68+
69+
$paginator2 = $this->createMock(Paginator::class);
70+
$paginator2
71+
->expects($this->once())
72+
->method('withColumns')
73+
->with($this->identicalTo($columns))
74+
->willReturnSelf();
75+
76+
$paginator = new MultiPagination($paginator1, $paginator2);
77+
$actual = $paginator->withColumns($columns);
78+
79+
$this->assertSame($paginator, $actual);
80+
}
81+
82+
public function testWithKeyName(): void
83+
{
84+
$key = 'blah';
85+
86+
$paginator1 = $this->createMock(Paginator::class);
87+
$paginator1
88+
->expects($this->once())
89+
->method('withKeyName')
90+
->with($this->identicalTo($key))
91+
->willReturnSelf();
92+
93+
$paginator2 = $this->createMock(Paginator::class);
94+
$paginator2
95+
->expects($this->once())
96+
->method('withKeyName')
97+
->with($this->identicalTo($key))
98+
->willReturnSelf();
99+
100+
$paginator = new MultiPagination($paginator1, $paginator2);
101+
$actual = $paginator->withKeyName($key);
102+
103+
$this->assertSame($paginator, $actual);
104+
}
105+
106+
public function testItQueriesPaginatorBasedOnKeys(): void
107+
{
108+
$query = $this->createMock(Builder::class);
109+
$page = ['number' => 2, 'chunk' => 3];
110+
111+
$paginator1 = $this->createMock(Paginator::class);
112+
$paginator1->method('keys')->willReturn(['number', 'size']);
113+
$paginator1->expects($this->never())->method('paginate');
114+
115+
$paginator2 = $this->createMock(Paginator::class);
116+
$paginator2->method('keys')->willReturn(['before', 'after', 'limit']);
117+
$paginator2->expects($this->never())->method('paginate');
118+
119+
$paginator3 = $this->createMock(Paginator::class);
120+
$paginator3->method('keys')->willReturn(['number', 'chunk']);
121+
$paginator3
122+
->expects($this->once())
123+
->method('paginate')
124+
->with($this->identicalTo($query), $this->identicalTo($page))
125+
->willReturn($expected = $this->createMock(Page::class));
126+
127+
$paginator = new MultiPagination(
128+
$paginator1,
129+
$paginator2,
130+
$paginator3,
131+
);
132+
133+
$actual = $paginator->paginate($query, $page);
134+
135+
$this->assertSame($expected, $actual);
136+
}
137+
138+
public function testItQueriesPaginatorBasedOnSomeKeys(): void
139+
{
140+
$query = $this->createMock(Builder::class);
141+
$page = ['after' => 'some-id', 'limit' => 10];
142+
143+
$paginator1 = $this->createMock(Paginator::class);
144+
$paginator1->method('keys')->willReturn(['number', 'limit']);
145+
$paginator1->expects($this->never())->method('paginate');
146+
147+
$paginator2 = $this->createMock(Paginator::class);
148+
$paginator2->method('keys')->willReturn(['before', 'after', 'limit']);
149+
$paginator2
150+
->expects($this->once())
151+
->method('paginate')
152+
->with($this->identicalTo($query), $this->identicalTo($page))
153+
->willReturn($expected = $this->createMock(Page::class));
154+
155+
$paginator3 = $this->createMock(Paginator::class);
156+
$paginator3->method('keys')->willReturn(['number', 'chunk']);
157+
$paginator3->expects($this->never())->method('paginate');
158+
159+
$paginator = new MultiPagination(
160+
$paginator1,
161+
$paginator2,
162+
$paginator3,
163+
);
164+
165+
$actual = $paginator->paginate($query, $page);
166+
167+
$this->assertSame($expected, $actual);
168+
}
169+
170+
/**
171+
* If the page keys match multiple paginators, we'll use the first matching paginator.
172+
*
173+
* @return void
174+
*/
175+
public function testItUsesFirstMatchingPaginatorWhenNoneAreConclusive(): void
176+
{
177+
$query = $this->createMock(Builder::class);
178+
$page = ['number' => 1, 'after' => 'some-id'];
179+
180+
$paginator1 = $this->createMock(Paginator::class);
181+
$paginator1->method('keys')->willReturn(['foo', 'bar']);
182+
$paginator1->expects($this->never())->method('paginate');
183+
184+
$paginator2 = $this->createMock(Paginator::class);
185+
$paginator2->method('keys')->willReturn(['before', 'after', 'limit']);
186+
$paginator2
187+
->expects($this->once())
188+
->method('paginate')
189+
->with($this->identicalTo($query), $this->identicalTo($page))
190+
->willReturn($expected = $this->createMock(Page::class));
191+
192+
$paginator3 = $this->createMock(Paginator::class);
193+
$paginator3->method('keys')->willReturn(['number', 'size']);
194+
$paginator3->expects($this->never())->method('paginate');
195+
196+
$paginator = new MultiPagination(
197+
$paginator1,
198+
$paginator2,
199+
$paginator3,
200+
);
201+
202+
$actual = $paginator->paginate($query, $page);
203+
204+
$this->assertSame($expected, $actual);
205+
}
206+
207+
public function testItHasInconclusivePageParameters(): void
208+
{
209+
$query = $this->createMock(Builder::class);
210+
$page = ['foo' => 'bar', 'baz' => 'bat'];
211+
212+
$paginator1 = $this->createMock(Paginator::class);
213+
$paginator1->method('keys')->willReturn(['number', 'size']);
214+
$paginator1->expects($this->never())->method('paginate');
215+
216+
$paginator2 = $this->createMock(Paginator::class);
217+
$paginator2->method('keys')->willReturn(['before', 'after', 'limit']);
218+
$paginator2->expects($this->never())->method('paginate');
219+
220+
$paginator = new MultiPagination($paginator1, $paginator2);
221+
222+
$this->expectException(\LogicException::class);
223+
$this->expectExceptionMessage(
224+
'Could not determine which paginator to use. ' .
225+
'Use validation to ensure the client provides query parameters that match at least one paginator. ' .
226+
'Keys received: foo,baz',
227+
);
228+
229+
$paginator->paginate($query, $page);
230+
}
231+
}

0 commit comments

Comments
 (0)