Passed
Push — master ( 0c0121...d6a1fd )
by Alexander
10:21 queued 08:08
created

KeysetPaginator::previousPageExist()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2.0078

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 2
dl 0
loc 12
ccs 7
cts 8
cp 0.875
crap 2.0078
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 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 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 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 39
        if ($dataReader->getSort()->getOrder() === []) {
100 1
            throw new RuntimeException('Data should be always sorted to work with keyset pagination.');
101
        }
102
103 38
        $this->dataReader = $dataReader;
104
    }
105
106 35
    public function __clone()
107
    {
108 35
        $this->readCache = null;
109 35
        $this->hasPreviousPageItem = false;
110 35
        $this->hasNextPageItem = false;
111 35
        $this->currentFirstValue = null;
112 35
        $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 31
    public function withPageSize(int $pageSize): self
132
    {
133 31
        if ($pageSize < 1) {
134 1
            throw new InvalidArgumentException('Page size should be at least 1.');
135
        }
136
137 30
        $new = clone $this;
138 30
        $new->pageSize = $pageSize;
139 30
        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 32
    public function read(): iterable
150
    {
151 32
        if ($this->readCache !== null) {
152 2
            return $this->readCache;
153
        }
154
155
        /** @var Sort $sort */
156 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

156
        /** @scrutinizer ignore-call */ 
157
        $sort = $this->dataReader->getSort();
Loading history...
157
        /** @psalm-var DataReaderType $dataReader */
158 32
        $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
159
160 32
        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 32
        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 32
        $data = $this->readData($dataReader, $sort);
175
176 32
        if ($this->isGoingToPreviousPage()) {
177 6
            $data = $this->reverseData($data);
178
        }
179
180
        /** @psalm-var array<TKey, TValue> $data */
181 32
        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 9
    public function getNextPageToken(): ?string
201
    {
202 9
        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 26
    public function isOnFirstPage(): bool
212
    {
213 26
        if ($this->lastValue === null && $this->firstValue === null) {
214 20
            return true;
215
        }
216
217 6
        $this->initialize();
218 6
        return !$this->hasPreviousPageItem;
219
    }
220
221 27
    public function isOnLastPage(): bool
222
    {
223 27
        $this->initialize();
224 27
        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 29
    private function initialize(): void
236
    {
237 29
        if ($this->readCache !== null) {
238 12
            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 32
    private function readData(ReadableDataInterface $dataReader, Sort $sort): array
255
    {
256 32
        $data = [];
257
        /** @var string $field */
258 32
        [$field] = $this->getFieldAndSortingFromSort($sort);
259
260 32
        foreach ($dataReader->read() as $key => $item) {
261 27
            if ($this->currentFirstValue === null) {
262 27
                $this->currentFirstValue = (string) $this->getValueFromItem($item, $field);
263
            }
264
265 27
            if (count($data) === $this->pageSize) {
266 15
                $this->hasNextPageItem = true;
267
            } else {
268 27
                $this->currentLastValue = (string) $this->getValueFromItem($item, $field);
269 27
                $data[$key] = $item;
270
            }
271
        }
272
273 32
        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
        foreach ($dataReader
296 11
                     ->withFilter($reverseFilter)
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

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