KeysetPaginator::getToken()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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

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

195
        /** @scrutinizer ignore-call */ 
196
        $sort = $this->dataReader->getSort();
Loading history...
196
        /** @infection-ignore-all Any value more one in line below will be ignored into `readData()` method */
197 70
        $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 said class. However, the method does not exist in Yiisoft\Data\Paginator\PaginatorInterface or anonymous//tests/Paginat...ysetPaginatorTest.php$0 or anonymous//tests/Paginat...fsetPaginatorTest.php$2 or Yiisoft\Data\Paginator\OffsetPaginator or Yiisoft\Data\Paginator\KeysetPaginator. Are you sure you never get one of those? ( Ignorable by Annotation )

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

197
        /** @scrutinizer ignore-call */ 
198
        $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
Loading history...
198
199 70
        if ($this->token?->isPrevious === true) {
200 25
            $sort = $this->reverseSort($sort);
201 25
            $dataReader = $dataReader->withSort($sort);
202
        }
203
204 70
        if ($this->token !== null) {
205 44
            $dataReader = $dataReader->withFilter($this->getFilter($sort));
206 44
            $this->hasPreviousPage = $this->previousPageExist($dataReader, $sort);
207
        }
208
209 70
        $data = $this->readData($dataReader, $sort);
210
211 70
        if ($this->token?->isPrevious === true) {
212 25
            $data = $this->reverseData($data);
213
        }
214
215 70
        return $this->readCache = $data;
216
    }
217
218 2
    public function readOne(): array|object|null
219
    {
220 2
        foreach ($this->read() as $item) {
221 1
            return $item;
222
        }
223
224 1
        return null;
225
    }
226
227 2
    public function getPageSize(): int
228
    {
229 2
        return $this->pageSize;
230
    }
231
232 2
    public function getCurrentPageSize(): int
233
    {
234 2
        $this->initialize();
235 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

235
        return count(/** @scrutinizer ignore-type */ $this->readCache);
Loading history...
236
    }
237
238 3
    public function getPreviousToken(): ?PageToken
239
    {
240 3
        return $this->isOnFirstPage()
241 2
            ? null
242 3
            : ($this->currentFirstValue === null ? null : PageToken::previous($this->currentFirstValue));
243
    }
244
245 9
    public function getNextToken(): ?PageToken
246
    {
247 9
        return $this->isOnLastPage()
248 1
            ? null
249 9
            : ($this->currentLastValue === null ? null : PageToken::next($this->currentLastValue));
250
    }
251
252 1
    public function isSortable(): bool
253
    {
254 1
        return true;
255
    }
256
257 1
    public function withSort(?Sort $sort): static
258
    {
259 1
        $new = clone $this;
260 1
        $new->dataReader = $this->dataReader->withSort($sort);
0 ignored issues
show
Bug introduced by
The method withSort() 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 or Yiisoft\Data\Tests\Support\MutationDataReader. ( Ignorable by Annotation )

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

260
        /** @scrutinizer ignore-call */ 
261
        $new->dataReader = $this->dataReader->withSort($sort);
Loading history...
261 1
        return $new;
262
    }
263
264 2
    public function getSort(): ?Sort
265
    {
266 2
        return $this->dataReader->getSort();
267
    }
268
269 1
    public function isFilterable(): bool
270
    {
271 1
        return true;
272
    }
273
274 1
    public function withFilter(FilterInterface $filter): static
275
    {
276 1
        $new = clone $this;
277 1
        $new->dataReader = $this->dataReader->withFilter($filter);
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 Yiisoft\Data\Paginator\PaginatorInterface or anonymous//tests/Paginat...ysetPaginatorTest.php$0 or anonymous//tests/Paginat...ysetPaginatorTest.php$2 or Yiisoft\Data\Reader\DataReaderInterface or Yiisoft\Data\Tests\Support\MutationDataReader. ( Ignorable by Annotation )

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

277
        /** @scrutinizer ignore-call */ 
278
        $new->dataReader = $this->dataReader->withFilter($filter);
Loading history...
278 1
        return $new;
279
    }
280
281 59
    public function isOnFirstPage(): bool
282
    {
283 59
        if ($this->token === null) {
284 21
            return true;
285
        }
286
287 38
        $this->initialize();
288 38
        return !$this->hasPreviousPage;
289
    }
290
291 60
    public function isOnLastPage(): bool
292
    {
293 60
        $this->initialize();
294 60
        return !$this->hasNextPage;
295
    }
296
297 3
    public function isPaginationRequired(): bool
298
    {
299 3
        return !$this->isOnFirstPage() || !$this->isOnLastPage();
300
    }
301
302
    /**
303
     * @psalm-assert array<TKey, TValue> $this->readCache
304
     */
305 62
    private function initialize(): void
306
    {
307 62
        if ($this->readCache !== null) {
308 44
            return;
309
        }
310
311 54
        $cache = [];
312
313 54
        foreach ($this->read() as $key => $value) {
314 36
            $cache[$key] = $value;
315
        }
316
317 54
        $this->readCache = $cache;
318
    }
319
320
    /**
321
     * @psalm-param ReadableDataInterface<TKey, TValue> $dataReader
322
     * @psalm-return array<TKey, TValue>
323
     */
324 70
    private function readData(ReadableDataInterface $dataReader, Sort $sort): array
325
    {
326 70
        $data = [];
327 70
        [$field] = $this->getFieldAndSortingFromSort($sort);
328
329 70
        foreach ($dataReader->read() as $key => $item) {
330 51
            if ($this->currentFirstValue === null) {
331 51
                $this->currentFirstValue = (string) ArrayHelper::getValue($item, $field);
332
            }
333
334 51
            if (count($data) === $this->pageSize) {
335 28
                $this->hasNextPage = true;
336
            } else {
337 51
                $this->currentLastValue = (string) ArrayHelper::getValue($item, $field);
338 51
                $data[$key] = $item;
339
            }
340
        }
341
342 70
        return $data;
343
    }
344
345
    /**
346
     * @psalm-param array<TKey, TValue> $data
347
     * @psalm-return array<TKey, TValue>
348
     */
349 25
    private function reverseData(array $data): array
350
    {
351 25
        [$this->currentFirstValue, $this->currentLastValue] = [$this->currentLastValue, $this->currentFirstValue];
352 25
        [$this->hasPreviousPage, $this->hasNextPage] = [$this->hasNextPage, $this->hasPreviousPage];
353 25
        return array_reverse($data, true);
354
    }
355
356
    /**
357
     * @psalm-param ReadableDataInterface<TKey, TValue>&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader
358
     */
359 45
    private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort): bool
360
    {
361 45
        $reverseFilter = $this->getReverseFilter($sort);
362
363 45
        return !empty($dataReader->withFilter($reverseFilter)->readOne());
364
    }
365
366 45
    private function getFilter(Sort $sort): FilterInterface
367
    {
368
        /**
369
         * @psalm-var PageToken $this->token The code calling this method must ensure that page token is not null.
370
         */
371 45
        $value = $this->token->value;
372 45
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
373
374 45
        $filter = $sorting === SORT_ASC ? new GreaterThan($field, $value) : new LessThan($field, $value);
375 45
        if ($this->filterCallback === null) {
376 42
            return $filter;
377
        }
378
379 3
        return ($this->filterCallback)(
380 3
            $filter,
381 3
            new KeysetFilterContext(
382 3
                $field,
383 3
                $value,
384 3
                $sorting,
385 3
                false,
386 3
            )
387 3
        );
388
    }
389
390 46
    private function getReverseFilter(Sort $sort): FilterInterface
391
    {
392
        /**
393
         * @psalm-var PageToken $this->token The code calling this method must ensure that page token is not null.
394
         */
395 46
        $value = $this->token->value;
396 46
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
397
398 46
        $filter = $sorting === SORT_ASC ? new LessThanOrEqual($field, $value) : new GreaterThanOrEqual($field, $value);
399 46
        if ($this->filterCallback === null) {
400 43
            return $filter;
401
        }
402
403 3
        return ($this->filterCallback)(
404 3
            $filter,
405 3
            new KeysetFilterContext(
406 3
                $field,
407 3
                $value,
408 3
                $sorting,
409 3
                true,
410 3
            )
411 3
        );
412
    }
413
414 25
    private function reverseSort(Sort $sort): Sort
415
    {
416 25
        $order = $sort->getOrder();
417
418 25
        foreach ($order as &$sorting) {
419 25
            $sorting = $sorting === 'asc' ? 'desc' : 'asc';
420
        }
421
422 25
        return $sort->withOrder($order);
423
    }
424
425
    /**
426
     * @psalm-return array{0: string, 1: int}
427
     */
428 73
    private function getFieldAndSortingFromSort(Sort $sort): array
429
    {
430 73
        $order = $sort->getOrder();
431
432 73
        return [
433 73
            (string) key($order),
434 73
            reset($order) === 'asc' ? SORT_ASC : SORT_DESC,
435 73
        ];
436
    }
437
}
438