Passed
Push — master ( 9472d4...d4d87b )
by Alexander
02:05
created

KeysetPaginator::getValue()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 1
nc 2
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 2
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 Yiisoft\Arrays\ArrayHelper;
8
use Yiisoft\Data\Reader\Filter\CompareFilter;
9
use Yiisoft\Data\Reader\Filter\GreaterThan;
10
use Yiisoft\Data\Reader\Filter\GreaterThanOrEqual;
11
use Yiisoft\Data\Reader\Filter\LessThan;
12
use Yiisoft\Data\Reader\Filter\LessThanOrEqual;
13
use Yiisoft\Data\Reader\FilterableDataInterface;
14
use Yiisoft\Data\Reader\ReadableDataInterface;
15
use Yiisoft\Data\Reader\Sort;
16
use Yiisoft\Data\Reader\SortableDataInterface;
17
18
/**
19
 * Keyset paginator
20
 *
21
 * - Equally fast for 1st and 1000nd page
22
 * - Total number of pages is not available
23
 * - Cannot get to specific page, only "next" and "previous"
24
 *
25
 * @link https://use-the-index-luke.com/no-offset
26
 *
27
 * @template TKey as array-key
28
 * @template TValue
29
 *
30
 * @implements PaginatorInterface<TKey, TValue>
31
 */
32
class KeysetPaginator implements PaginatorInterface
33
{
34
    /**
35
     * @var FilterableDataInterface|ReadableDataInterface|SortableDataInterface
36
     */
37
    private ReadableDataInterface $dataReader;
38
    private int $pageSize = self::DEFAULT_PAGE_SIZE;
39
    private ?string $firstValue = null;
40
    private ?string $lastValue = null;
41
    private $currentFirstValue;
42
    private $currentLastValue;
43
44
    /**
45
     * @var bool Previous page has item indicator.
46
     */
47
    private bool $hasPreviousPageItem = false;
48
    /**
49
     * @var bool Next page has item indicator.
50
     */
51
    private bool $hasNextPageItem = false;
52
53
    /**
54
     * Reader cache against repeated scans.
55
     * See more {@see __clone()} and {@see initializeInternal()}.
56
     *
57
     * @psalm-var array<TKey, TValue>|null
58
     */
59
    private ?array $readCache = null;
60
61
    /**
62
     * @psalm-param ReadableDataInterface<TKey, TValue> $dataReader
63
     */
64 33
    public function __construct(ReadableDataInterface $dataReader)
65
    {
66 33
        if (!$dataReader instanceof FilterableDataInterface) {
67 1
            throw new \InvalidArgumentException(
68 1
                'Data reader should implement FilterableDataInterface to be used with keyset paginator.'
69
            );
70
        }
71
72 32
        if (!$dataReader instanceof SortableDataInterface) {
73 1
            throw new \InvalidArgumentException(
74 1
                'Data reader should implement SortableDataInterface to be used with keyset paginator.'
75
            );
76
        }
77
78 31
        if ($dataReader->getSort() === null) {
79 1
            throw new \RuntimeException('Data sorting should be configured to work with keyset pagination.');
80
        }
81
82 30
        if ($dataReader->getSort()->getOrder() === []) {
83 1
            throw new \RuntimeException('Data should be always sorted to work with keyset pagination.');
84
        }
85
86 29
        $this->dataReader = $dataReader;
87 29
    }
88
89
    /**
90
     * Reads items of the page
91
     *
92
     * This method uses the read cache to prevent duplicate reads from the data source. See more {@see resetInternal()}
93
     */
94 28
    public function read(): iterable
95
    {
96 28
        if ($this->readCache !== null) {
97 2
            return $this->readCache;
98
        }
99
100 28
        $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
101 28
        $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

101
        /** @scrutinizer ignore-call */ 
102
        $sort = $this->dataReader->getSort();
Loading history...
102
103 28
        if ($this->isGoingToPreviousPage()) {
104 4
            $sort = $this->reverseSort($sort);
105 4
            $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

105
            /** @scrutinizer ignore-call */ 
106
            $dataReader = $dataReader->withSort($sort);
Loading history...
106
        }
107
108 28
        if ($this->isGoingSomewhere()) {
109 8
            $dataReader = $dataReader->withFilter($this->getFilter($sort));
110 8
            $this->hasPreviousPageItem = $this->previousPageExist($dataReader, $sort);
111
        }
112
113 28
        $data = $this->readData($dataReader, $sort);
114 28
        if ($this->isGoingToPreviousPage()) {
115 4
            $data = $this->reverseData($data);
116
        }
117
118
        /** @psalm-var array<TKey, TValue> $data */
119 28
        return $this->readCache = $data;
120
    }
121
122
    /**
123
     * @return $this
124
     *
125
     * @psalm-mutation-free
126
     */
127 4
    public function withPreviousPageToken(?string $value): self
128
    {
129 4
        $new = clone $this;
130 4
        $new->firstValue = $value;
131 4
        $new->lastValue = null;
132 4
        return $new;
133
    }
134
135
    /**
136
     * @return $this
137
     *
138
     * @psalm-mutation-free
139
     */
140 7
    public function withNextPageToken(?string $value): self
141
    {
142 7
        $new = clone $this;
143 7
        $new->firstValue = null;
144 7
        $new->lastValue = $value;
145 7
        return $new;
146
    }
147
148 3
    public function getPreviousPageToken(): ?string
149
    {
150 3
        if ($this->isOnFirstPage()) {
151 2
            return null;
152
        }
153 3
        return (string)$this->currentFirstValue;
154
    }
155
156 8
    public function getNextPageToken(): ?string
157
    {
158 8
        if ($this->isOnLastPage()) {
159 1
            return null;
160
        }
161 8
        return (string)$this->currentLastValue;
162
    }
163
164
    /**
165
     * @return $this
166
     *
167
     * @psalm-mutation-free
168
     */
169 28
    public function withPageSize(int $pageSize): self
170
    {
171 28
        if ($pageSize < 1) {
172 1
            throw new \InvalidArgumentException('Page size should be at least 1.');
173
        }
174
175 27
        $new = clone $this;
176 27
        $new->pageSize = $pageSize;
177 27
        return $new;
178
    }
179
180 25
    public function isOnLastPage(): bool
181
    {
182 25
        $this->initializeInternal();
183 25
        return !$this->hasNextPageItem;
184
    }
185
186 23
    public function isOnFirstPage(): bool
187
    {
188 23
        if ($this->lastValue === null && $this->firstValue === null) {
189 18
            return true;
190
        }
191 5
        $this->initializeInternal();
192 5
        return !$this->hasPreviousPageItem;
193
    }
194
195 2
    public function getPageSize(): int
196
    {
197 2
        return $this->pageSize;
198
    }
199
200 2
    public function getCurrentPageSize(): int
201
    {
202 2
        $this->initializeInternal();
203 2
        return count($this->readCache);
204
    }
205
206 27
    public function __clone()
207
    {
208 27
        $this->readCache = null;
209 27
        $this->hasPreviousPageItem = false;
210 27
        $this->hasNextPageItem = false;
211 27
        $this->currentFirstValue = null;
212 27
        $this->currentLastValue = null;
213 27
    }
214
215 26
    protected function initializeInternal(): void
216
    {
217 26
        if ($this->readCache !== null) {
218 11
            return;
219
        }
220 19
        $cache = [];
221 19
        foreach ($this->read() as $key => $value) {
222 15
            $cache[$key] = $value;
223
        }
224 19
        $this->readCache = $cache;
225 19
    }
226
227 2
    public function isRequired(): bool
228
    {
229 2
        return !$this->isOnFirstPage() || !$this->isOnLastPage();
230
    }
231
232 28
    private function isGoingToPreviousPage(): bool
233
    {
234 28
        return $this->firstValue !== null && $this->lastValue === null;
235
    }
236
237 28
    private function isGoingSomewhere(): bool
238
    {
239 28
        return $this->firstValue !== null || $this->lastValue !== null;
240
    }
241
242 8
    private function getFilter(Sort $sort): CompareFilter
243
    {
244 8
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
245 8
        if ($sorting === 'asc') {
246 7
            return new GreaterThan($field, $this->getValue());
247
        }
248 4
        return new LessThan($field, $this->getValue());
249
    }
250
251 8
    private function getReverseFilter(Sort $sort): CompareFilter
252
    {
253 8
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
254 8
        if ($sorting === 'asc') {
255 7
            return new LessThanOrEqual($field, $this->getValue());
256
        }
257 4
        return new GreaterThanOrEqual($field, $this->getValue());
258
    }
259
260 8
    private function getValue(): string
261
    {
262 8
        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...
263
    }
264
265 4
    private function reverseSort(Sort $sort): Sort
266
    {
267 4
        $order = $sort->getOrder();
268 4
        foreach ($order as &$sorting) {
269 4
            $sorting = $sorting === 'asc' ? 'desc' : 'asc';
270
        }
271
272 4
        return $sort->withOrder($order);
273
    }
274
275 28
    private function getFieldAndSortingFromSort(Sort $sort): array
276
    {
277 28
        $order = $sort->getOrder();
278
279
        return [
280 28
            key($order),
281 28
            reset($order),
282
        ];
283
    }
284
285
    /**
286
     * @psalm-param ReadableDataInterface<TKey, TValue> $dataReader
287
     * @psalm-return array<TKey, TValue>
288
     */
289 28
    private function readData(ReadableDataInterface $dataReader, Sort $sort): array
290
    {
291 28
        $data = [];
292 28
        [$field] = $this->getFieldAndSortingFromSort($sort);
293
294 28
        foreach ($dataReader->read() as $key => $item) {
295 24
            if ($this->currentFirstValue === null) {
296 24
                $this->currentFirstValue = $this->getValueFromItem($item, $field);
297
            }
298 24
            if (count($data) === $this->pageSize) {
299 13
                $this->hasNextPageItem = true;
300
            } else {
301 24
                $this->currentLastValue = $this->getValueFromItem($item, $field);
302 24
                $data[$key] = $item;
303
            }
304
        }
305
306 28
        return $data;
307
    }
308
309
    /**
310
     * @psalm-param array<TKey, TValue>
311
     * @psalm-return array<TKey, TValue>
312
     */
313 4
    private function reverseData(array $data): array
314
    {
315 4
        [$this->currentFirstValue, $this->currentLastValue] = [$this->currentLastValue, $this->currentFirstValue];
316 4
        [$this->hasPreviousPageItem, $this->hasNextPageItem] = [$this->hasNextPageItem, $this->hasPreviousPageItem];
317 4
        return array_reverse($data, true);
318
    }
319
320 8
    private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort): bool
321
    {
322 8
        $reverseFilter = $this->getReverseFilter($sort);
323 8
        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

323
        foreach ($dataReader->/** @scrutinizer ignore-call */ withFilter($reverseFilter)->withLimit(1)->read() as $void) {
Loading history...
324 7
            return true;
325
        }
326 1
        return false;
327
    }
328
329 24
    private function getValueFromItem($item, string $field)
330
    {
331 24
        $methodName = 'get' . ucfirst($field);
332 24
        if (is_object($item) && is_callable([$item, $methodName])) {
333 1
            return $item->$methodName();
334
        }
335
336 23
        return ArrayHelper::getValue($item, $field);
337
    }
338
}
339