Passed
Push — master ( bef4fd...91295c )
by Sergei
02:35
created

KeysetPaginator   F

Complexity

Total Complexity 60

Size/Duplication

Total Lines 387
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 140
dl 0
loc 387
ccs 166
cts 166
cp 1
rs 3.6
c 5
b 0
f 0
wmc 60

29 Methods

Rating   Name   Duplication   Size   Complexity  
A withNextPageToken() 0 6 1
A withPreviousPageToken() 0 6 1
A __construct() 0 34 6
A __clone() 0 7 1
A withPageSize() 0 9 2
A getPreviousPageToken() 0 3 2
A withFilterCallback() 0 5 1
A readOne() 0 7 2
A getNextPageToken() 0 3 2
A read() 0 28 5
A getCurrentPageSize() 0 4 1
A getPageSize() 0 3 1
A getSort() 0 3 1
A getValue() 0 3 2
A readData() 0 19 4
A isOnLastPage() 0 4 1
A previousPageExist() 0 5 1
A reverseSort() 0 9 3
A isOnFirstPage() 0 8 3
A initialize() 0 13 3
A isGoingToPreviousPage() 0 3 2
A getFieldAndSortingFromSort() 0 7 2
A getReverseFilter() 0 17 3
A isGoingSomewhere() 0 3 2
A isPaginationRequired() 0 3 2
A getFilter() 0 17 3
A withSort() 0 5 1
A reverseData() 0 5 1
A isSortable() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like KeysetPaginator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use KeysetPaginator, and based on these observations, apply Extract Interface, too.

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 ?string $firstValue = null;
65
    private ?string $lastValue = null;
66
    private ?string $currentFirstValue = null;
67
    private ?string $currentLastValue = null;
68
69
    /**
70
     * @var bool Whether there is previous page.
71
     */
72
    private bool $hasPreviousPage = false;
73
74
    /**
75
     * @var bool Whether there is next page.
76
     */
77
    private bool $hasNextPage = false;
78
79
    /**
80
     * @psalm-var FilterCallback|null
81
     */
82
    private ?Closure $filterCallback = null;
83
84
    /**
85
     * Reader cache against repeated scans.
86
     * See more {@see __clone()} and {@see initialize()}.
87
     *
88
     * @psalm-var null|array<TKey, TValue>
89
     */
90
    private ?array $readCache = null;
91
92
    /**
93
     * @param ReadableDataInterface $dataReader Data reader being paginated.
94
     * @psalm-param ReadableDataInterface<TKey, TValue>&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader
95
     * @psalm-suppress DocblockTypeContradiction Needed to allow validating `$dataReader`
96
     */
97 80
    public function __construct(ReadableDataInterface $dataReader)
98
    {
99 80
        if (!$dataReader instanceof FilterableDataInterface) {
100 1
            throw new InvalidArgumentException(sprintf(
101 1
                'Data reader should implement "%s" to be used with keyset paginator.',
102 1
                FilterableDataInterface::class,
103 1
            ));
104
        }
105
106 79
        if (!$dataReader instanceof SortableDataInterface) {
107 1
            throw new InvalidArgumentException(sprintf(
108 1
                'Data reader should implement "%s" to be used with keyset paginator.',
109 1
                SortableDataInterface::class,
110 1
            ));
111
        }
112
113 78
        if (!$dataReader instanceof LimitableDataInterface) {
114 1
            throw new InvalidArgumentException(sprintf(
115 1
                'Data reader should implement "%s" to be used with keyset paginator.',
116 1
                LimitableDataInterface::class,
117 1
            ));
118
        }
119
120 77
        $sort = $dataReader->getSort();
121
122 77
        if ($sort === null) {
123 1
            throw new RuntimeException('Data sorting should be configured to work with keyset pagination.');
124
        }
125
126 76
        if (empty($sort->getOrder())) {
127 1
            throw new RuntimeException('Data should be always sorted to work with keyset pagination.');
128
        }
129
130 75
        $this->dataReader = $dataReader;
131
    }
132
133 71
    public function __clone()
134
    {
135 71
        $this->readCache = null;
136 71
        $this->hasPreviousPage = false;
137 71
        $this->hasNextPage = false;
138 71
        $this->currentFirstValue = null;
139 71
        $this->currentLastValue = null;
140
    }
141
142 28
    public function withNextPageToken(?string $token): static
143
    {
144 28
        $new = clone $this;
145 28
        $new->firstValue = null;
146 28
        $new->lastValue = $token;
147 28
        return $new;
148
    }
149
150 26
    public function withPreviousPageToken(?string $token): static
151
    {
152 26
        $new = clone $this;
153 26
        $new->firstValue = $token;
154 26
        $new->lastValue = null;
155 26
        return $new;
156
    }
157
158 65
    public function withPageSize(int $pageSize): static
159
    {
160 65
        if ($pageSize < 1) {
161 1
            throw new InvalidArgumentException('Page size should be at least 1.');
162
        }
163
164 64
        $new = clone $this;
165 64
        $new->pageSize = $pageSize;
166 64
        return $new;
167
    }
168
169
    /**
170
     * Returns a new instance with defined closure for preparing data reader filters.
171
     *
172
     * @psalm-param FilterCallback|null $callback Closure with signature:
173
     *
174
     * ```php
175
     * function(
176
     *    GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual $filter,
177
     *    KeysetFilterContext $context
178
     * ): FilterInterface
179
     * ```
180
     */
181 4
    public function withFilterCallback(?Closure $callback): self
182
    {
183 4
        $new = clone $this;
184 4
        $new->filterCallback = $callback;
185 4
        return $new;
186
    }
187
188
    /**
189
     * Reads items of the page.
190
     *
191
     * This method uses the read cache to prevent duplicate reads from the data source. See more {@see resetInternal()}.
192
     */
193 69
    public function read(): iterable
194
    {
195 69
        if ($this->readCache !== null) {
196 34
            return $this->readCache;
197
        }
198
199
        /** @var Sort $sort */
200 69
        $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

200
        /** @scrutinizer ignore-call */ 
201
        $sort = $this->dataReader->getSort();
Loading history...
201
        /** @infection-ignore-all Any value more one in line below will be ignored into `readData()` method */
202 69
        $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

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

240
        return count(/** @scrutinizer ignore-type */ $this->readCache);
Loading history...
241
    }
242
243 3
    public function getPreviousPageToken(): ?string
244
    {
245 3
        return $this->isOnFirstPage() ? null : $this->currentFirstValue;
246
    }
247
248 9
    public function getNextPageToken(): ?string
249
    {
250 9
        return $this->isOnLastPage() ? null : $this->currentLastValue;
251
    }
252
253 1
    public function isSortable(): bool
254
    {
255 1
        return true;
256
    }
257
258 1
    public function withSort(?Sort $sort): static
259
    {
260 1
        $new = clone $this;
261 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

261
        /** @scrutinizer ignore-call */ 
262
        $new->dataReader = $this->dataReader->withSort($sort);
Loading history...
262 1
        return $new;
263
    }
264
265 2
    public function getSort(): ?Sort
266
    {
267 2
        return $this->dataReader->getSort();
268
    }
269
270 59
    public function isOnFirstPage(): bool
271
    {
272 59
        if ($this->lastValue === null && $this->firstValue === null) {
273 20
            return true;
274
        }
275
276 39
        $this->initialize();
277 39
        return !$this->hasPreviousPage;
278
    }
279
280 60
    public function isOnLastPage(): bool
281
    {
282 60
        $this->initialize();
283 60
        return !$this->hasNextPage;
284
    }
285
286 3
    public function isPaginationRequired(): bool
287
    {
288 3
        return !$this->isOnFirstPage() || !$this->isOnLastPage();
289
    }
290
291
    /**
292
     * @psalm-assert array<TKey, TValue> $this->readCache
293
     */
294 62
    private function initialize(): void
295
    {
296 62
        if ($this->readCache !== null) {
297 45
            return;
298
        }
299
300 54
        $cache = [];
301
302 54
        foreach ($this->read() as $key => $value) {
303 36
            $cache[$key] = $value;
304
        }
305
306 54
        $this->readCache = $cache;
307
    }
308
309
    /**
310
     * @psalm-param ReadableDataInterface<TKey, TValue> $dataReader
311
     * @psalm-return array<TKey, TValue>
312
     */
313 69
    private function readData(ReadableDataInterface $dataReader, Sort $sort): array
314
    {
315 69
        $data = [];
316 69
        [$field] = $this->getFieldAndSortingFromSort($sort);
317
318 69
        foreach ($dataReader->read() as $key => $item) {
319 50
            if ($this->currentFirstValue === null) {
320 50
                $this->currentFirstValue = (string) ArrayHelper::getValue($item, $field);
321
            }
322
323 50
            if (count($data) === $this->pageSize) {
324 28
                $this->hasNextPage = true;
325
            } else {
326 50
                $this->currentLastValue = (string) ArrayHelper::getValue($item, $field);
327 50
                $data[$key] = $item;
328
            }
329
        }
330
331 69
        return $data;
332
    }
333
334
    /**
335
     * @psalm-param array<TKey, TValue> $data
336
     * @psalm-return array<TKey, TValue>
337
     */
338 25
    private function reverseData(array $data): array
339
    {
340 25
        [$this->currentFirstValue, $this->currentLastValue] = [$this->currentLastValue, $this->currentFirstValue];
341 25
        [$this->hasPreviousPage, $this->hasNextPage] = [$this->hasNextPage, $this->hasPreviousPage];
342 25
        return array_reverse($data, true);
343
    }
344
345
    /**
346
     * @psalm-param ReadableDataInterface<TKey, TValue>&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader
347
     */
348 46
    private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort): bool
349
    {
350 46
        $reverseFilter = $this->getReverseFilter($sort);
351
352 46
        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 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

352
        return !empty($dataReader->/** @scrutinizer ignore-call */ withFilter($reverseFilter)->readOne());
Loading history...
353
    }
354
355 46
    private function getFilter(Sort $sort): FilterInterface
356
    {
357 46
        $value = $this->getValue();
358 46
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
359
360 46
        $filter = $sorting === SORT_ASC ? new GreaterThan($field, $value) : new LessThan($field, $value);
361 46
        if ($this->filterCallback === null) {
362 43
            return $filter;
363
        }
364
365 3
        return ($this->filterCallback)(
366 3
            $filter,
367 3
            new KeysetFilterContext(
368 3
                $field,
369 3
                $value,
370 3
                $sorting,
371 3
                false,
372 3
            )
373 3
        );
374
    }
375
376 47
    private function getReverseFilter(Sort $sort): FilterInterface
377
    {
378 47
        $value = $this->getValue();
379 47
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
380
381 47
        $filter = $sorting === SORT_ASC ? new LessThanOrEqual($field, $value) : new GreaterThanOrEqual($field, $value);
382 47
        if ($this->filterCallback === null) {
383 44
            return $filter;
384
        }
385
386 3
        return ($this->filterCallback)(
387 3
            $filter,
388 3
            new KeysetFilterContext(
389 3
                $field,
390 3
                $value,
391 3
                $sorting,
392 3
                true,
393 3
            )
394 3
        );
395
    }
396
397
    /**
398
     * @psalm-suppress NullableReturnStatement, InvalidNullableReturnType, PossiblyNullArgument The code calling this
399
     * method must ensure that at least one of the properties `$firstValue` or `$lastValue` is not `null`.
400
     */
401 48
    private function getValue(): string
402
    {
403 48
        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...
404
    }
405
406 25
    private function reverseSort(Sort $sort): Sort
407
    {
408 25
        $order = $sort->getOrder();
409
410 25
        foreach ($order as &$sorting) {
411 25
            $sorting = $sorting === 'asc' ? 'desc' : 'asc';
412
        }
413
414 25
        return $sort->withOrder($order);
415
    }
416
417
    /**
418
     * @psalm-return array{0: string, 1: int}
419
     */
420 72
    private function getFieldAndSortingFromSort(Sort $sort): array
421
    {
422 72
        $order = $sort->getOrder();
423
424 72
        return [
425 72
            (string) key($order),
426 72
            reset($order) === 'asc' ? SORT_ASC : SORT_DESC,
427 72
        ];
428
    }
429
430 72
    private function isGoingToPreviousPage(): bool
431
    {
432 72
        return $this->firstValue !== null && $this->lastValue === null;
433
    }
434
435 69
    private function isGoingSomewhere(): bool
436
    {
437 69
        return $this->firstValue !== null || $this->lastValue !== null;
438
    }
439
}
440