Passed
Push — master ( b1e616...dc4fde )
by Sergei
02:50
created

KeysetPaginator::__construct()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
eloc 15
nc 5
nop 1
dl 0
loc 28
ccs 11
cts 11
cp 1
crap 5
rs 9.4555
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\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 Yiisoft\Strings\Inflector;
21
use function array_reverse;
22
use function count;
23
use function is_callable;
24
use function is_object;
25
use function key;
26
use function reset;
27
use function sprintf;
28
29
/**
30
 * Keyset paginator.
31
 *
32
 * - Equally fast for 1st and 1000th 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 null|array<TKey, TValue>
72
     */
73
    private ?array $readCache = null;
74
75
    /**
76
     * @psalm-param DataReaderType $dataReader
77
     */
78 42
    public function __construct(ReadableDataInterface $dataReader)
79
    {
80 42
        if (!$dataReader instanceof FilterableDataInterface) {
81 1
            throw new InvalidArgumentException(sprintf(
82
                'Data reader should implement "%s" to be used with keyset paginator.',
83
                FilterableDataInterface::class,
84
            ));
85
        }
86
87 41
        if (!$dataReader instanceof SortableDataInterface) {
88 1
            throw new InvalidArgumentException(sprintf(
89
                'Data reader should implement "%s" to be used with keyset paginator.',
90
                SortableDataInterface::class,
91
            ));
92
        }
93
94 40
        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
        if ($dataReader
100 39
                ->getSort()
101 39
                ->getOrder() === []) {
102 1
            throw new RuntimeException('Data should be always sorted to work with keyset pagination.');
103
        }
104
105 38
        $this->dataReader = $dataReader;
106
    }
107
108 35
    public function __clone()
109
    {
110 35
        $this->readCache = null;
111 35
        $this->hasPreviousPageItem = false;
112 35
        $this->hasNextPageItem = false;
113 35
        $this->currentFirstValue = null;
114 35
        $this->currentLastValue = null;
115
    }
116
117 12
    public function withNextPageToken(?string $token): self
118
    {
119 12
        $new = clone $this;
120 12
        $new->firstValue = null;
121 12
        $new->lastValue = $token;
122 12
        return $new;
123
    }
124
125 7
    public function withPreviousPageToken(?string $token): self
126
    {
127 7
        $new = clone $this;
128 7
        $new->firstValue = $token;
129 7
        $new->lastValue = null;
130 7
        return $new;
131
    }
132
133 31
    public function withPageSize(int $pageSize): self
134
    {
135 31
        if ($pageSize < 1) {
136 1
            throw new InvalidArgumentException('Page size should be at least 1.');
137
        }
138
139 30
        $new = clone $this;
140 30
        $new->pageSize = $pageSize;
141 30
        return $new;
142
    }
143
144
    /**
145
     * Reads items of the page.
146
     *
147
     * This method uses the read cache to prevent duplicate reads from the data source. See more {@see resetInternal()}.
148
     *
149
     * @psalm-suppress MixedMethodCall
150
     */
151 32
    public function read(): iterable
152
    {
153 32
        if ($this->readCache !== null) {
154 2
            return $this->readCache;
155
        }
156
157
        /** @var Sort $sort */
158 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

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

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

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

297
        foreach ($dataReader->/** @scrutinizer ignore-call */ withFilter($reverseFilter)->withLimit(1)->read() as $void) {
Loading history...
298 10
            return true;
299
        }
300
301 1
        return false;
302
    }
303
304
    /**
305
     * @return mixed
306
     */
307 27
    private function getValueFromItem(mixed $item, string $field)
308
    {
309 27
        $methodName = 'get' . (new Inflector())->toPascalCase($field);
310
311 27
        if (is_object($item) && is_callable([$item, $methodName])) {
312 2
            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 35
    private function getFieldAndSortingFromSort(Sort $sort): array
356
    {
357 35
        $order = $sort->getOrder();
358
359
        return [
360 35
            (string) key($order),
361 35
            reset($order),
362
        ];
363
    }
364
365 35
    private function isGoingToPreviousPage(): bool
366
    {
367 35
        return $this->firstValue !== null && $this->lastValue === null;
368
    }
369
370 32
    private function isGoingSomewhere(): bool
371
    {
372 32
        return $this->firstValue !== null || $this->lastValue !== null;
373
    }
374
}
375