Passed
Push — master ( 273064...5c5da0 )
by Alexander
02:36
created

KeysetPaginator   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 307
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 107
dl 0
loc 307
ccs 125
cts 125
cp 1
rs 7.92
c 5
b 0
f 0
wmc 51

25 Methods

Rating   Name   Duplication   Size   Complexity  
A getValue() 0 3 2
A reverseSort() 0 9 3
A isGoingToPreviousPage() 0 3 2
A getFieldAndSortingFromSort() 0 7 1
A getReverseFilter() 0 6 2
A isGoingSomewhere() 0 3 2
A getFilter() 0 6 2
A getSort() 0 4 1
A getPreviousPageToken() 0 3 2
A withNextPageToken() 0 6 1
A isRequired() 0 3 2
A readData() 0 20 4
A isOnLastPage() 0 4 1
A previousPageExist() 0 5 1
A isOnFirstPage() 0 8 3
A initialize() 0 13 3
A getNextPageToken() 0 3 2
A read() 0 27 5
A withPreviousPageToken() 0 6 1
A __construct() 0 27 5
A __clone() 0 7 1
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\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\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 key;
23
use function reset;
24
use function sprintf;
25
26
/**
27
 * Keyset paginator.
28
 *
29
 * Advantages:
30
 *
31
 * - Performance does not depend on page number
32
 * - Consistent results regardless of insertions and deletions
33
 *
34
 * Disadvantages:
35
 *
36
 * - Total number of pages is not available
37
 * - Can not get to specific page, only "previous" and "next"
38
 * - Data cannot be unordered
39
 *
40
 * @link https://use-the-index-luke.com/no-offset
41
 *
42
 * @template TKey as array-key
43
 * @template TValue as array|object
44
 *
45
 * @implements PaginatorInterface<TKey, TValue>
46
 */
47
final class KeysetPaginator implements PaginatorInterface
48
{
49
    /**
50
     * Data reader being paginated.
51
     *
52
     * @psalm-var ReadableDataInterface<TKey, TValue>&FilterableDataInterface&SortableDataInterface
53
     */
54
    private ReadableDataInterface $dataReader;
55
56
    /**
57
     * @var int Maximum number of items per page.
58
     */
59
    private int $pageSize = self::DEFAULT_PAGE_SIZE;
60
    private ?string $firstValue = null;
61
    private ?string $lastValue = null;
62
    private ?string $currentFirstValue = null;
63
    private ?string $currentLastValue = null;
64
65
    /**
66
     * @var bool Whether there is previous page.
67
     */
68
    private bool $hasPreviousPage = false;
69
70
    /**
71
     * @var bool Whether there is next page.
72
     */
73
    private bool $hasNextPage = false;
74
75
    /**
76
     * Reader cache against repeated scans.
77
     * See more {@see __clone()} and {@see initialize()}.
78
     *
79
     * @psalm-var null|array<TKey, TValue>
80
     */
81
    private ?array $readCache = null;
82
83
    /**
84
     * @param ReadableDataInterface $dataReader Data reader being paginated.
85
     * @psalm-param ReadableDataInterface<TKey, TValue>&FilterableDataInterface&SortableDataInterface $dataReader
86
     * @psalm-suppress DocblockTypeContradiction
87
     */
88 42
    public function __construct(ReadableDataInterface $dataReader)
89
    {
90 42
        if (!$dataReader instanceof FilterableDataInterface) {
91 1
            throw new InvalidArgumentException(sprintf(
92 1
                'Data reader should implement "%s" to be used with keyset paginator.',
93 1
                FilterableDataInterface::class,
94 1
            ));
95
        }
96
97 41
        if (!$dataReader instanceof SortableDataInterface) {
98 1
            throw new InvalidArgumentException(sprintf(
99 1
                'Data reader should implement "%s" to be used with keyset paginator.',
100 1
                SortableDataInterface::class,
101 1
            ));
102
        }
103
104 40
        $sort = $dataReader->getSort();
105
106 40
        if ($sort === null) {
107 1
            throw new RuntimeException('Data sorting should be configured to work with keyset pagination.');
108
        }
109
110 39
        if (empty($sort->getOrder())) {
111 1
            throw new RuntimeException('Data should be always sorted to work with keyset pagination.');
112
        }
113
114 38
        $this->dataReader = $dataReader;
115
    }
116
117 35
    public function __clone()
118
    {
119 35
        $this->readCache = null;
120 35
        $this->hasPreviousPage = false;
121 35
        $this->hasNextPage = false;
122 35
        $this->currentFirstValue = null;
123 35
        $this->currentLastValue = null;
124
    }
125
126 12
    public function withNextPageToken(?string $token): static
127
    {
128 12
        $new = clone $this;
129 12
        $new->firstValue = null;
130 12
        $new->lastValue = $token;
131 12
        return $new;
132
    }
133
134 7
    public function withPreviousPageToken(?string $token): static
135
    {
136 7
        $new = clone $this;
137 7
        $new->firstValue = $token;
138 7
        $new->lastValue = null;
139 7
        return $new;
140
    }
141
142 31
    public function withPageSize(int $pageSize): static
143
    {
144 31
        if ($pageSize < 1) {
145 1
            throw new InvalidArgumentException('Page size should be at least 1.');
146
        }
147
148 30
        $new = clone $this;
149 30
        $new->pageSize = $pageSize;
150 30
        return $new;
151
    }
152
153
    /**
154
     * Reads items of the page.
155
     *
156
     * This method uses the read cache to prevent duplicate reads from the data source. See more {@see resetInternal()}.
157
     */
158 32
    public function read(): iterable
159
    {
160 32
        if ($this->readCache !== null) {
161 2
            return $this->readCache;
162
        }
163
164
        /** @var Sort $sort */
165 32
        $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

165
        /** @scrutinizer ignore-call */ 
166
        $sort = $this->dataReader->getSort();
Loading history...
166 32
        $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
167
168 32
        if ($this->isGoingToPreviousPage()) {
169 6
            $sort = $this->reverseSort($sort);
170 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

170
            /** @scrutinizer ignore-call */ 
171
            $dataReader = $dataReader->withSort($sort);
Loading history...
171
        }
172
173 32
        if ($this->isGoingSomewhere()) {
174 10
            $dataReader = $dataReader->withFilter($this->getFilter($sort));
175 10
            $this->hasPreviousPage = $this->previousPageExist($dataReader, $sort);
176
        }
177
178 32
        $data = $this->readData($dataReader, $sort);
179
180 32
        if ($this->isGoingToPreviousPage()) {
181 6
            $data = $this->reverseData($data);
182
        }
183
184 32
        return $this->readCache = $data;
185
    }
186
187 2
    public function getPageSize(): int
188
    {
189 2
        return $this->pageSize;
190
    }
191
192 2
    public function getCurrentPageSize(): int
193
    {
194 2
        $this->initialize();
195 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

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

297
        return !empty($dataReader->/** @scrutinizer ignore-call */ withFilter($reverseFilter)->withLimit(1)->read());
Loading history...
298
    }
299
300 11
    private function getFilter(Sort $sort): Compare
301
    {
302 11
        $value = $this->getValue();
303
        /** @var string $field */
304 11
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
305 11
        return $sorting === 'asc' ? new GreaterThan($field, $value) : new LessThan($field, $value);
306
    }
307
308 12
    private function getReverseFilter(Sort $sort): Compare
309
    {
310 12
        $value = $this->getValue();
311
        /** @var string $field */
312 12
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
313 12
        return $sorting === 'asc' ? new LessThanOrEqual($field, $value) : new GreaterThanOrEqual($field, $value);
314
    }
315
316
    /**
317
     * @psalm-suppress NullableReturnStatement, InvalidNullableReturnType The code calling this method
318
     * must ensure that at least one of the properties `$firstValue` or `$lastValue` is not `null`.
319
     */
320 13
    private function getValue(): string
321
    {
322 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...
323
    }
324
325 6
    private function reverseSort(Sort $sort): Sort
326
    {
327 6
        $order = $sort->getOrder();
328
329 6
        foreach ($order as &$sorting) {
330 6
            $sorting = $sorting === 'asc' ? 'desc' : 'asc';
331
        }
332
333 6
        return $sort->withOrder($order);
334
    }
335
336 35
    private function getFieldAndSortingFromSort(Sort $sort): array
337
    {
338 35
        $order = $sort->getOrder();
339
340 35
        return [
341 35
            (string) key($order),
342 35
            reset($order),
343 35
        ];
344
    }
345
346 35
    private function isGoingToPreviousPage(): bool
347
    {
348 35
        return $this->firstValue !== null && $this->lastValue === null;
349
    }
350
351 32
    private function isGoingSomewhere(): bool
352
    {
353 32
        return $this->firstValue !== null || $this->lastValue !== null;
354
    }
355
}
356