Passed
Push — master ( 01a1a3...426505 )
by Alexander
08:34 queued 06:03
created

KeysetPaginator   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 328
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 112
dl 0
loc 328
ccs 125
cts 125
cp 1
rs 6
c 5
b 0
f 0
wmc 55

26 Methods

Rating   Name   Duplication   Size   Complexity  
A isRequired() 0 3 2
A getSort() 0 4 1
A getValueFromItem() 0 10 3
A getValue() 0 3 2
A getPreviousPageToken() 0 3 2
A withNextPageToken() 0 6 1
A readData() 0 20 4
A isOnLastPage() 0 4 1
A previousPageExist() 0 9 2
A reverseSort() 0 9 3
A isOnFirstPage() 0 8 3
A initialize() 0 13 3
A isGoingToPreviousPage() 0 3 2
A getNextPageToken() 0 3 2
A getFieldAndSortingFromSort() 0 7 1
A getReverseFilter() 0 6 2
A read() 0 33 5
A isGoingSomewhere() 0 3 2
A withPreviousPageToken() 0 6 1
A __construct() 0 26 5
A __clone() 0 7 1
A getFilter() 0 6 2
A getCurrentPageSize() 0 4 1
A getPageSize() 0 3 1
A reverseData() 0 5 1
A withPageSize() 0 9 2

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 InvalidArgumentException;
8
use RuntimeException;
9
use Yiisoft\Arrays\ArrayHelper;
10
use Yiisoft\Data\Reader\Filter\CompareFilter;
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\ReadableDataInterface;
17
use Yiisoft\Data\Reader\Sort;
18
use Yiisoft\Data\Reader\SortableDataInterface;
19
20
use function array_reverse;
21
use function count;
22
use function is_callable;
23
use function is_object;
24
use function key;
25
use function reset;
26
use function sprintf;
27
use function ucfirst;
28
29
/**
30
 * Keyset paginator.
31
 *
32
 * - Equally fast for 1st and 1000nd page
33
 * - Total number of pages is not available
34
 * - Cannot get to specific page, only "next" and "previous"
35
 *
36
 * @link https://use-the-index-luke.com/no-offset
37
 *
38
 * @psalm-template DataReaderType = ReadableDataInterface<TKey, TValue>&FilterableDataInterface&SortableDataInterface
39
 *
40
 * @template TKey as array-key
41
 * @template TValue
42
 *
43
 * @implements PaginatorInterface<TKey, TValue>
44
 */
45
final class KeysetPaginator implements PaginatorInterface
46
{
47
    /**
48
     * @psalm-var DataReaderType
49
     */
50
    private ReadableDataInterface $dataReader;
51
    private int $pageSize = self::DEFAULT_PAGE_SIZE;
52
    private ?string $firstValue = null;
53
    private ?string $lastValue = null;
54
    private ?string $currentFirstValue = null;
55
    private ?string $currentLastValue = null;
56
57
    /**
58
     * @var bool Previous page has item indicator.
59
     */
60
    private bool $hasPreviousPageItem = false;
61
62
    /**
63
     * @var bool Next page has item indicator.
64
     */
65
    private bool $hasNextPageItem = false;
66
67
    /**
68
     * Reader cache against repeated scans.
69
     * See more {@see __clone()} and {@see initialize()}.
70
     *
71
     * @psalm-var array<TKey, TValue>|null
72
     */
73
    private ?array $readCache = null;
74
75
    /**
76
     * @psalm-param DataReaderType $dataReader
77
     */
78 41
    public function __construct(ReadableDataInterface $dataReader)
79
    {
80 41
        if (!$dataReader instanceof FilterableDataInterface) {
81 1
            throw new InvalidArgumentException(sprintf(
82 1
                'Data reader should implement "%s" to be used with keyset paginator.',
83
                FilterableDataInterface::class,
84
            ));
85
        }
86
87 40
        if (!$dataReader instanceof SortableDataInterface) {
88 1
            throw new InvalidArgumentException(sprintf(
89 1
                'Data reader should implement "%s" to be used with keyset paginator.',
90
                SortableDataInterface::class,
91
            ));
92
        }
93
94 39
        if ($dataReader->getSort() === null) {
95 1
            throw new RuntimeException('Data sorting should be configured to work with keyset pagination.');
96
        }
97
98
        /** @psalm-suppress PossiblyNullReference */
99 38
        if ($dataReader->getSort()->getOrder() === []) {
100 1
            throw new RuntimeException('Data should be always sorted to work with keyset pagination.');
101
        }
102
103 37
        $this->dataReader = $dataReader;
104
    }
105
106 34
    public function __clone()
107
    {
108 34
        $this->readCache = null;
109 34
        $this->hasPreviousPageItem = false;
110 34
        $this->hasNextPageItem = false;
111 34
        $this->currentFirstValue = null;
112 34
        $this->currentLastValue = null;
113
    }
114
115 12
    public function withNextPageToken(?string $token): self
116
    {
117 12
        $new = clone $this;
118 12
        $new->firstValue = null;
119 12
        $new->lastValue = $token;
120 12
        return $new;
121
    }
122
123 7
    public function withPreviousPageToken(?string $token): self
124
    {
125 7
        $new = clone $this;
126 7
        $new->firstValue = $token;
127 7
        $new->lastValue = null;
128 7
        return $new;
129
    }
130
131 30
    public function withPageSize(int $pageSize): self
132
    {
133 30
        if ($pageSize < 1) {
134 1
            throw new InvalidArgumentException('Page size should be at least 1.');
135
        }
136
137 29
        $new = clone $this;
138 29
        $new->pageSize = $pageSize;
139 29
        return $new;
140
    }
141
142
    /**
143
     * Reads items of the page.
144
     *
145
     * This method uses the read cache to prevent duplicate reads from the data source. See more {@see resetInternal()}.
146
     *
147
     * @psalm-suppress MixedMethodCall
148
     */
149 31
    public function read(): iterable
150
    {
151 31
        if ($this->readCache !== null) {
152 2
            return $this->readCache;
153
        }
154
155
        /** @var Sort $sort */
156 31
        $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 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

156
        /** @scrutinizer ignore-call */ 
157
        $sort = $this->dataReader->getSort();
Loading history...
157
        /** @psalm-var DataReaderType $dataReader */
158 31
        $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
159
160 31
        if ($this->isGoingToPreviousPage()) {
161 6
            $sort = $this->reverseSort($sort);
162
            /** @psalm-var DataReaderType $dataReader */
163 6
            $dataReader = $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 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

163
            /** @scrutinizer ignore-call */ 
164
            $dataReader = $dataReader->withSort($sort);
Loading history...
164
        }
165
166 31
        if ($this->isGoingSomewhere()) {
167
            /** @psalm-var FilterableDataInterface $dataReader */
168 10
            $dataReader = $dataReader->withFilter($this->getFilter($sort));
169
            /** @psalm-var DataReaderType $dataReader */
170 10
            $this->hasPreviousPageItem = $this->previousPageExist($dataReader, $sort);
171
        }
172
173
        /** @psalm-var ReadableDataInterface<TKey, TValue> $dataReader */
174 31
        $data = $this->readData($dataReader, $sort);
175
176 31
        if ($this->isGoingToPreviousPage()) {
177 6
            $data = $this->reverseData($data);
178
        }
179
180
        /** @psalm-var array<TKey, TValue> $data */
181 31
        return $this->readCache = $data;
182
    }
183
184 2
    public function getPageSize(): int
185
    {
186 2
        return $this->pageSize;
187
    }
188
189 2
    public function getCurrentPageSize(): int
190
    {
191 2
        $this->initialize();
192 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

192
        return count(/** @scrutinizer ignore-type */ $this->readCache);
Loading history...
193
    }
194
195 3
    public function getPreviousPageToken(): ?string
196
    {
197 3
        return $this->isOnFirstPage() ? null : $this->currentFirstValue;
198
    }
199
200 8
    public function getNextPageToken(): ?string
201
    {
202 8
        return $this->isOnLastPage() ? null : $this->currentLastValue;
203
    }
204
205 1
    public function getSort(): ?Sort
206
    {
207
        /** @psalm-var SortableDataInterface $this->dataReader */
208 1
        return $this->dataReader->getSort();
209
    }
210
211 25
    public function isOnFirstPage(): bool
212
    {
213 25
        if ($this->lastValue === null && $this->firstValue === null) {
214 19
            return true;
215
        }
216
217 6
        $this->initialize();
218 6
        return !$this->hasPreviousPageItem;
219
    }
220
221 26
    public function isOnLastPage(): bool
222
    {
223 26
        $this->initialize();
224 26
        return !$this->hasNextPageItem;
225
    }
226
227 3
    public function isRequired(): bool
228
    {
229 3
        return !$this->isOnFirstPage() || !$this->isOnLastPage();
230
    }
231
232
    /**
233
     * @psalm-assert array<TKey, TValue> $this->readCache
234
     */
235 28
    private function initialize(): void
236
    {
237 28
        if ($this->readCache !== null) {
238 11
            return;
239
        }
240
241 21
        $cache = [];
242
243 21
        foreach ($this->read() as $key => $value) {
244 16
            $cache[$key] = $value;
245
        }
246
247 21
        $this->readCache = $cache;
248
    }
249
250
    /**
251
     * @psalm-param ReadableDataInterface<TKey, TValue> $dataReader
252
     * @psalm-return array<TKey, TValue>
253
     */
254 31
    private function readData(ReadableDataInterface $dataReader, Sort $sort): array
255
    {
256 31
        $data = [];
257
        /** @var string $field */
258 31
        [$field] = $this->getFieldAndSortingFromSort($sort);
259
260 31
        foreach ($dataReader->read() as $key => $item) {
261 26
            if ($this->currentFirstValue === null) {
262 26
                $this->currentFirstValue = (string) $this->getValueFromItem($item, $field);
263
            }
264
265 26
            if (count($data) === $this->pageSize) {
266 14
                $this->hasNextPageItem = true;
267
            } else {
268 26
                $this->currentLastValue = (string) $this->getValueFromItem($item, $field);
269 26
                $data[$key] = $item;
270
            }
271
        }
272
273 31
        return $data;
274
    }
275
276
    /**
277
     * @psalm-param array<TKey, TValue> $data
278
     * @psalm-return array<TKey, TValue>
279
     */
280 6
    private function reverseData(array $data): array
281
    {
282 6
        [$this->currentFirstValue, $this->currentLastValue] = [$this->currentLastValue, $this->currentFirstValue];
283 6
        [$this->hasPreviousPageItem, $this->hasNextPageItem] = [$this->hasNextPageItem, $this->hasPreviousPageItem];
284 6
        return array_reverse($data, true);
285
    }
286
287
    /**
288
     * @psalm-param DataReaderType $dataReader
289
     * @psalm-suppress MixedAssignment, MixedMethodCall, UnusedForeachValue
290
     */
291 11
    private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort): bool
292
    {
293 11
        $reverseFilter = $this->getReverseFilter($sort);
294
295 11
        foreach ($dataReader->withFilter($reverseFilter)->withLimit(1)->read() as $void) {
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\Reader\DataReaderInterface or anonymous//tests/Paginat...ysetPaginatorTest.php$1. ( Ignorable by Annotation )

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

295
        foreach ($dataReader->/** @scrutinizer ignore-call */ withFilter($reverseFilter)->withLimit(1)->read() as $void) {
Loading history...
296 10
            return true;
297
        }
298
299 1
        return false;
300
    }
301
302
    /**
303
     * @param mixed $item
304
     *
305
     * @return mixed
306
     */
307 26
    private function getValueFromItem($item, string $field)
308
    {
309 26
        $methodName = 'get' . ucfirst($field);
310
311 26
        if (is_object($item) && is_callable([$item, $methodName])) {
312 1
            return $item->$methodName();
313
        }
314
315
        /** @psalm-suppress MixedArgument */
316 25
        return ArrayHelper::getValue($item, $field);
317
    }
318
319 11
    private function getFilter(Sort $sort): CompareFilter
320
    {
321 11
        $value = $this->getValue();
322
        /** @var string $field */
323 11
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
324 11
        return $sorting === 'asc' ? new GreaterThan($field, $value) : new LessThan($field, $value);
325
    }
326
327 12
    private function getReverseFilter(Sort $sort): CompareFilter
328
    {
329 12
        $value = $this->getValue();
330
        /** @var string $field */
331 12
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
332 12
        return $sorting === 'asc' ? new LessThanOrEqual($field, $value) : new GreaterThanOrEqual($field, $value);
333
    }
334
335
    /**
336
     * @psalm-suppress NullableReturnStatement, InvalidNullableReturnType The code calling this method
337
     * must ensure that at least one of the properties `$firstValue` or `$lastValue` is not `null`.
338
     */
339 13
    private function getValue(): string
340
    {
341 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...
342
    }
343
344 6
    private function reverseSort(Sort $sort): Sort
345
    {
346 6
        $order = $sort->getOrder();
347
348 6
        foreach ($order as &$sorting) {
349 6
            $sorting = $sorting === 'asc' ? 'desc' : 'asc';
350
        }
351
352 6
        return $sort->withOrder($order);
353
    }
354
355 34
    private function getFieldAndSortingFromSort(Sort $sort): array
356
    {
357 34
        $order = $sort->getOrder();
358
359
        return [
360 34
            (string) key($order),
361 34
            reset($order),
362
        ];
363
    }
364
365 34
    private function isGoingToPreviousPage(): bool
366
    {
367 34
        return $this->firstValue !== null && $this->lastValue === null;
368
    }
369
370 31
    private function isGoingSomewhere(): bool
371
    {
372 31
        return $this->firstValue !== null || $this->lastValue !== null;
373
    }
374
}
375