Passed
Push — master ( e2fd7e...408742 )
by Sergei
02:55
created

KeysetPaginator::isPaginationRequired()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 1
nc 2
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Data\Paginator;
6
7
use InvalidArgumentException;
8
use RuntimeException;
9
use Yiisoft\Arrays\ArrayHelper;
10
use Yiisoft\Data\Reader\Filter\Compare;
11
use Yiisoft\Data\Reader\Filter\GreaterThan;
12
use Yiisoft\Data\Reader\Filter\GreaterThanOrEqual;
13
use Yiisoft\Data\Reader\Filter\LessThan;
14
use Yiisoft\Data\Reader\Filter\LessThanOrEqual;
15
use Yiisoft\Data\Reader\FilterableDataInterface;
16
use Yiisoft\Data\Reader\LimitableDataInterface;
17
use Yiisoft\Data\Reader\ReadableDataInterface;
18
use Yiisoft\Data\Reader\Sort;
19
use Yiisoft\Data\Reader\SortableDataInterface;
20
21
use function array_reverse;
22
use function count;
23
use function key;
24
use function reset;
25
use function sprintf;
26
27
/**
28
 * Keyset paginator.
29
 *
30
 * Advantages:
31
 *
32
 * - Performance does not depend on page number
33
 * - Consistent results regardless of insertions and deletions
34
 *
35
 * Disadvantages:
36
 *
37
 * - Total number of pages is not available
38
 * - Can not get to specific page, only "previous" and "next"
39
 * - Data cannot be unordered
40
 *
41
 * @link https://use-the-index-luke.com/no-offset
42
 *
43
 * @template TKey as array-key
44
 * @template TValue as array|object
45
 *
46
 * @implements PaginatorInterface<TKey, TValue>
47
 */
48
final class KeysetPaginator implements PaginatorInterface
49
{
50
    /**
51
     * Data reader being paginated.
52
     *
53
     * @psalm-var ReadableDataInterface<TKey, TValue>&LimitableDataInterface&FilterableDataInterface&SortableDataInterface
54
     */
55
    private ReadableDataInterface $dataReader;
56
57
    /**
58
     * @var int Maximum number of items per page.
59
     */
60
    private int $pageSize = self::DEFAULT_PAGE_SIZE;
61
    private ?string $firstValue = null;
62
    private ?string $lastValue = null;
63
    private ?string $currentFirstValue = null;
64
    private ?string $currentLastValue = null;
65
66
    /**
67
     * @var bool Whether there is previous page.
68
     */
69
    private bool $hasPreviousPage = false;
70
71
    /**
72
     * @var bool Whether there is next page.
73
     */
74
    private bool $hasNextPage = false;
75
76
    /**
77
     * Reader cache against repeated scans.
78
     * See more {@see __clone()} and {@see initialize()}.
79
     *
80
     * @psalm-var null|array<TKey, TValue>
81
     */
82
    private ?array $readCache = null;
83
84
    /**
85
     * @param ReadableDataInterface $dataReader Data reader being paginated.
86
     * @psalm-param ReadableDataInterface<TKey, TValue>&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader
87
     * @psalm-suppress DocblockTypeContradiction Needed to allow validating `$dataReader`
88
     */
89 43
    public function __construct(ReadableDataInterface $dataReader)
90
    {
91 43
        if (!$dataReader instanceof FilterableDataInterface) {
92 1
            throw new InvalidArgumentException(sprintf(
93 1
                'Data reader should implement "%s" to be used with keyset paginator.',
94 1
                FilterableDataInterface::class,
95 1
            ));
96
        }
97
98 42
        if (!$dataReader instanceof SortableDataInterface) {
99 1
            throw new InvalidArgumentException(sprintf(
100 1
                'Data reader should implement "%s" to be used with keyset paginator.',
101 1
                SortableDataInterface::class,
102 1
            ));
103
        }
104
105 41
        if (!$dataReader instanceof LimitableDataInterface) {
106 1
            throw new InvalidArgumentException(sprintf(
107 1
                'Data reader should implement "%s" to be used with keyset paginator.',
108 1
                LimitableDataInterface::class,
109 1
            ));
110
        }
111
112 40
        $sort = $dataReader->getSort();
113
114 40
        if ($sort === null) {
115 1
            throw new RuntimeException('Data sorting should be configured to work with keyset pagination.');
116
        }
117
118 39
        if (empty($sort->getOrder())) {
119 1
            throw new RuntimeException('Data should be always sorted to work with keyset pagination.');
120
        }
121
122 38
        $this->dataReader = $dataReader;
123
    }
124
125 35
    public function __clone()
126
    {
127 35
        $this->readCache = null;
128 35
        $this->hasPreviousPage = false;
129 35
        $this->hasNextPage = false;
130 35
        $this->currentFirstValue = null;
131 35
        $this->currentLastValue = null;
132
    }
133
134 12
    public function withNextPageToken(?string $token): static
135
    {
136 12
        $new = clone $this;
137 12
        $new->firstValue = null;
138 12
        $new->lastValue = $token;
139 12
        return $new;
140
    }
141
142 7
    public function withPreviousPageToken(?string $token): static
143
    {
144 7
        $new = clone $this;
145 7
        $new->firstValue = $token;
146 7
        $new->lastValue = null;
147 7
        return $new;
148
    }
149
150 31
    public function withPageSize(int $pageSize): static
151
    {
152 31
        if ($pageSize < 1) {
153 1
            throw new InvalidArgumentException('Page size should be at least 1.');
154
        }
155
156 30
        $new = clone $this;
157 30
        $new->pageSize = $pageSize;
158 30
        return $new;
159
    }
160
161
    /**
162
     * Reads items of the page.
163
     *
164
     * This method uses the read cache to prevent duplicate reads from the data source. See more {@see resetInternal()}.
165
     */
166 34
    public function read(): iterable
167
    {
168 34
        if ($this->readCache !== null) {
169 2
            return $this->readCache;
170
        }
171
172
        /** @var Sort $sort */
173 34
        $sort = $this->dataReader->getSort();
0 ignored issues
show
Bug introduced by
The method getSort() does not exist on Yiisoft\Data\Reader\ReadableDataInterface. It seems like you code against a sub-type of Yiisoft\Data\Reader\ReadableDataInterface such as Yiisoft\Data\Paginator\PaginatorInterface or anonymous//tests/Paginat...ysetPaginatorTest.php$0 or anonymous//tests/Paginat...ysetPaginatorTest.php$3 or Yiisoft\Data\Reader\DataReaderInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

173
        /** @scrutinizer ignore-call */ 
174
        $sort = $this->dataReader->getSort();
Loading history...
174
        /** @infection-ignore-all Any value more one in line below will be ignored into `readData()` method */
175 34
        $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
0 ignored issues
show
Bug introduced by
The method withLimit() does not exist on Yiisoft\Data\Reader\ReadableDataInterface. It seems like you code against a sub-type of Yiisoft\Data\Reader\ReadableDataInterface such as anonymous//tests/Paginat...ysetPaginatorTest.php$3 or anonymous//tests/Paginat...fsetPaginatorTest.php$1 or anonymous//tests/Paginat...ysetPaginatorTest.php$2 or Yiisoft\Data\Reader\DataReaderInterface or anonymous//tests/Paginat...fsetPaginatorTest.php$0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

175
        /** @scrutinizer ignore-call */ 
176
        $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
Loading history...
176
177 34
        if ($this->isGoingToPreviousPage()) {
178 6
            $sort = $this->reverseSort($sort);
179 6
            $dataReader = $dataReader->withSort($sort);
180
        }
181
182 34
        if ($this->isGoingSomewhere()) {
183 10
            $dataReader = $dataReader->withFilter($this->getFilter($sort));
184 10
            $this->hasPreviousPage = $this->previousPageExist($dataReader, $sort);
185
        }
186
187 34
        $data = $this->readData($dataReader, $sort);
188
189 34
        if ($this->isGoingToPreviousPage()) {
190 6
            $data = $this->reverseData($data);
191
        }
192
193 34
        return $this->readCache = $data;
194
    }
195
196 2
    public function readOne(): array|object|null
197
    {
198 2
        foreach ($this->read() as $item) {
199 1
            return $item;
200
        }
201
202 1
        return null;
203
    }
204
205 2
    public function getPageSize(): int
206
    {
207 2
        return $this->pageSize;
208
    }
209
210 2
    public function getCurrentPageSize(): int
211
    {
212 2
        $this->initialize();
213 2
        return count($this->readCache);
0 ignored issues
show
Bug introduced by
It seems like $this->readCache can also be of type null; however, parameter $value of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

213
        return count(/** @scrutinizer ignore-type */ $this->readCache);
Loading history...
214
    }
215
216 3
    public function getPreviousPageToken(): ?string
217
    {
218 3
        return $this->isOnFirstPage() ? null : $this->currentFirstValue;
219
    }
220
221 9
    public function getNextPageToken(): ?string
222
    {
223 9
        return $this->isOnLastPage() ? null : $this->currentLastValue;
224
    }
225
226 1
    public function getSort(): ?Sort
227
    {
228
        /** @psalm-var SortableDataInterface $this->dataReader */
229 1
        return $this->dataReader->getSort();
230
    }
231
232 26
    public function isOnFirstPage(): bool
233
    {
234 26
        if ($this->lastValue === null && $this->firstValue === null) {
235 20
            return true;
236
        }
237
238 6
        $this->initialize();
239 6
        return !$this->hasPreviousPage;
240
    }
241
242 27
    public function isOnLastPage(): bool
243
    {
244 27
        $this->initialize();
245 27
        return !$this->hasNextPage;
246
    }
247
248 3
    public function isPaginationRequired(): bool
249
    {
250 3
        return !$this->isOnFirstPage() || !$this->isOnLastPage();
251
    }
252
253
    /**
254
     * @psalm-assert array<TKey, TValue> $this->readCache
255
     */
256 29
    private function initialize(): void
257
    {
258 29
        if ($this->readCache !== null) {
259 12
            return;
260
        }
261
262 21
        $cache = [];
263
264 21
        foreach ($this->read() as $key => $value) {
265 16
            $cache[$key] = $value;
266
        }
267
268 21
        $this->readCache = $cache;
269
    }
270
271
    /**
272
     * @psalm-param ReadableDataInterface<TKey, TValue> $dataReader
273
     * @psalm-return array<TKey, TValue>
274
     */
275 34
    private function readData(ReadableDataInterface $dataReader, Sort $sort): array
276
    {
277 34
        $data = [];
278
        /** @var string $field */
279 34
        [$field] = $this->getFieldAndSortingFromSort($sort);
280
281 34
        foreach ($dataReader->read() as $key => $item) {
282 28
            if ($this->currentFirstValue === null) {
283 28
                $this->currentFirstValue = (string) ArrayHelper::getValue($item, $field);
284
            }
285
286 28
            if (count($data) === $this->pageSize) {
287 15
                $this->hasNextPage = true;
288
            } else {
289 28
                $this->currentLastValue = (string) ArrayHelper::getValue($item, $field);
290 28
                $data[$key] = $item;
291
            }
292
        }
293
294 34
        return $data;
295
    }
296
297
    /**
298
     * @psalm-param array<TKey, TValue> $data
299
     * @psalm-return array<TKey, TValue>
300
     */
301 6
    private function reverseData(array $data): array
302
    {
303 6
        [$this->currentFirstValue, $this->currentLastValue] = [$this->currentLastValue, $this->currentFirstValue];
304 6
        [$this->hasPreviousPage, $this->hasNextPage] = [$this->hasNextPage, $this->hasPreviousPage];
305 6
        return array_reverse($data, true);
306
    }
307
308
    /**
309
     * @psalm-param ReadableDataInterface<TKey, TValue>&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader
310
     */
311 11
    private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort): bool
312
    {
313 11
        $reverseFilter = $this->getReverseFilter($sort);
314
315 11
        return !empty($dataReader->withFilter($reverseFilter)->readOne());
0 ignored issues
show
Bug introduced by
The method withFilter() does not exist on Yiisoft\Data\Reader\ReadableDataInterface. It seems like you code against a sub-type of Yiisoft\Data\Reader\ReadableDataInterface such as anonymous//tests/Paginat...ysetPaginatorTest.php$0 or anonymous//tests/Paginat...ysetPaginatorTest.php$2 or Yiisoft\Data\Reader\DataReaderInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

315
        return !empty($dataReader->/** @scrutinizer ignore-call */ withFilter($reverseFilter)->readOne());
Loading history...
316
    }
317
318 11
    private function getFilter(Sort $sort): Compare
319
    {
320 11
        $value = $this->getValue();
321
        /** @var string $field */
322 11
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
323 11
        return $sorting === 'asc' ? new GreaterThan($field, $value) : new LessThan($field, $value);
324
    }
325
326 12
    private function getReverseFilter(Sort $sort): Compare
327
    {
328 12
        $value = $this->getValue();
329
        /** @var string $field */
330 12
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
331 12
        return $sorting === 'asc' ? new LessThanOrEqual($field, $value) : new GreaterThanOrEqual($field, $value);
332
    }
333
334
    /**
335
     * @psalm-suppress NullableReturnStatement, InvalidNullableReturnType The code calling this method
336
     * must ensure that at least one of the properties `$firstValue` or `$lastValue` is not `null`.
337
     */
338 13
    private function getValue(): string
339
    {
340 13
        return $this->isGoingToPreviousPage() ? $this->firstValue : $this->lastValue;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->isGoingToP...alue : $this->lastValue could return the type null which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
341
    }
342
343 6
    private function reverseSort(Sort $sort): Sort
344
    {
345 6
        $order = $sort->getOrder();
346
347 6
        foreach ($order as &$sorting) {
348 6
            $sorting = $sorting === 'asc' ? 'desc' : 'asc';
349
        }
350
351 6
        return $sort->withOrder($order);
352
    }
353
354 37
    private function getFieldAndSortingFromSort(Sort $sort): array
355
    {
356 37
        $order = $sort->getOrder();
357
358 37
        return [
359 37
            (string) key($order),
360 37
            reset($order),
361 37
        ];
362
    }
363
364 37
    private function isGoingToPreviousPage(): bool
365
    {
366 37
        return $this->firstValue !== null && $this->lastValue === null;
367
    }
368
369 34
    private function isGoingSomewhere(): bool
370
    {
371 34
        return $this->firstValue !== null || $this->lastValue !== null;
372
    }
373
}
374