Passed
Push — master ( 5ea71e...4d92cd )
by Alexander
01:27
created

KeysetPaginator::withPreviousPageToken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 4
c 0
b 0
f 0
dl 0
loc 6
ccs 5
cts 5
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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
class KeysetPaginator implements PaginatorInterface
28
{
29
    /**
30
     * @var FilterableDataInterface|ReadableDataInterface|SortableDataInterface
31
     */
32
    private ReadableDataInterface $dataReader;
33
    private int $pageSize = self::DEFAULT_PAGE_SIZE;
34
    private ?string $firstValue = null;
35
    private ?string $lastValue = null;
36
    private $currentFirstValue;
37
    private $currentLastValue;
38
39
    /**
40
     * @var bool Previous page has item indicator.
41
     */
42
    private bool $hasPreviousPageItem = false;
43
    /**
44
     * @var bool Next page has item indicator.
45
     */
46
    private bool $hasNextPageItem = false;
47
48
    /**
49
     * @var array|null Reader cache against repeated scans.
50
     *
51
     * See more {@see __clone()} and {@see initializeInternal()}.
52
     */
53
    private ?array $readCache = null;
54
55 33
    public function __construct(ReadableDataInterface $dataReader)
56
    {
57 33
        if (!$dataReader instanceof FilterableDataInterface) {
58 1
            throw new \InvalidArgumentException(
59 1
                'Data reader should implement FilterableDataInterface to be used with keyset paginator.'
60
            );
61
        }
62
63 32
        if (!$dataReader instanceof SortableDataInterface) {
64 1
            throw new \InvalidArgumentException(
65 1
                'Data reader should implement SortableDataInterface to be used with keyset paginator.'
66
            );
67
        }
68
69 31
        if ($dataReader->getSort() === null) {
70 1
            throw new \RuntimeException('Data sorting should be configured to work with keyset pagination.');
71
        }
72
73 30
        if ($dataReader->getSort()->getOrder() === []) {
74 1
            throw new \RuntimeException('Data should be always sorted to work with keyset pagination.');
75
        }
76
77 29
        $this->dataReader = $dataReader;
78
    }
79
80
    /**
81
     * Reads items of the page
82
     *
83
     * This method uses the read cache to prevent duplicate reads from the data source. See more {@see resetInternal()}
84
     */
85 28
    public function read(): iterable
86
    {
87 28
        if ($this->readCache) {
88 1
            return $this->readCache;
89
        }
90
91 28
        $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
92 28
        $sort = $this->getSort();
93
94 28
        if ($this->isGoingToPreviousPage()) {
95 4
            $sort = $this->reverseSort($sort);
96 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

96
            /** @scrutinizer ignore-call */ 
97
            $dataReader = $dataReader->withSort($sort);
Loading history...
97
        }
98
99 28
        if ($this->isGoingSomewhere()) {
100 8
            $dataReader = $dataReader->withFilter($this->getFilter($sort));
101 8
            $this->hasPreviousPageItem = $this->previousPageExist($dataReader, $sort);
102
        }
103
104 28
        $data = $this->readData($dataReader, $sort);
105 28
        if ($this->isGoingToPreviousPage()) {
106 4
            $data = $this->reverseData($data);
107
        }
108
109 28
        return $this->readCache = $data;
110
    }
111
112 4
    public function withPreviousPageToken(?string $value): self
113
    {
114 4
        $new = clone $this;
115 4
        $new->firstValue = $value;
116 4
        $new->lastValue = null;
117 4
        return $new;
118
    }
119
120 7
    public function withNextPageToken(?string $value): self
121
    {
122 7
        $new = clone $this;
123 7
        $new->firstValue = null;
124 7
        $new->lastValue = $value;
125 7
        return $new;
126
    }
127
128 3
    public function getPreviousPageToken(): ?string
129
    {
130 3
        if ($this->isOnFirstPage()) {
131 2
            return null;
132
        }
133 3
        return (string)$this->currentFirstValue;
134
    }
135
136 8
    public function getNextPageToken(): ?string
137
    {
138 8
        if ($this->isOnLastPage()) {
139 1
            return null;
140
        }
141 8
        return (string)$this->currentLastValue;
142
    }
143
144 28
    public function withPageSize(int $pageSize): self
145
    {
146 28
        if ($pageSize < 1) {
147 1
            throw new \InvalidArgumentException('Page size should be at least 1.');
148
        }
149
150 27
        $new = clone $this;
151 27
        $new->pageSize = $pageSize;
152 27
        return $new;
153
    }
154
155 25
    public function isOnLastPage(): bool
156
    {
157 25
        $this->initializeInternal();
158 25
        return !$this->hasNextPageItem;
159
    }
160
161 23
    public function isOnFirstPage(): bool
162
    {
163 23
        if ($this->lastValue === null && $this->firstValue === null) {
164 18
            return true;
165
        }
166 5
        $this->initializeInternal();
167 5
        return !$this->hasPreviousPageItem;
168
    }
169
170 2
    public function getPageSize(): int
171
    {
172 2
        return $this->pageSize;
173
    }
174
175 2
    public function getCurrentPageSize(): int
176
    {
177 2
        $this->initializeInternal();
178 2
        return count($this->readCache);
179
    }
180
181 27
    public function __clone()
182
    {
183 27
        $this->readCache = null;
184 27
        $this->hasPreviousPageItem = false;
185 27
        $this->hasNextPageItem = false;
186 27
        $this->currentFirstValue = null;
187 27
        $this->currentLastValue = null;
188
    }
189
190 26
    protected function initializeInternal(): void
191
    {
192 26
        if ($this->readCache !== null) {
193 11
            return;
194
        }
195 19
        $cache = [];
196 19
        foreach ($this->read() as $value) {
197 15
            $cache[] = $value;
198
        }
199 19
        $this->readCache = $cache;
200
    }
201
202 2
    public function isRequired(): bool
203
    {
204 2
        return !$this->isOnFirstPage() || !$this->isOnLastPage();
205
    }
206
207 28
    private function getSort(): Sort
208
    {
209 28
        return $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

209
        return $this->dataReader->/** @scrutinizer ignore-call */ getSort();
Loading history...
210
    }
211
212 28
    private function isGoingToPreviousPage(): bool
213
    {
214 28
        return $this->firstValue !== null && $this->lastValue === null;
215
    }
216
217 28
    private function isGoingSomewhere(): bool
218
    {
219 28
        return $this->firstValue !== null || $this->lastValue !== null;
220
    }
221
222 8
    private function getFilter(Sort $sort): CompareFilter
223
    {
224 8
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
225 8
        if ($sorting === 'asc') {
226 7
            return new GreaterThan($field, $this->getValue());
227
        }
228 4
        return new LessThan($field, $this->getValue());
229
    }
230
231 8
    private function getReverseFilter(Sort $sort): CompareFilter
232
    {
233 8
        [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
234 8
        if ($sorting === 'asc') {
235 7
            return new LessThanOrEqual($field, $this->getValue());
236
        }
237 4
        return new GreaterThanOrEqual($field, $this->getValue());
238
    }
239
240 8
    private function getValue(): string
241
    {
242 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...
243
    }
244
245 4
    private function reverseSort(Sort $sort): Sort
246
    {
247 4
        $order = $sort->getOrder();
248 4
        foreach ($order as &$sorting) {
249 4
            $sorting = $sorting === 'asc' ? 'desc' : 'asc';
250
        }
251
252 4
        return $sort->withOrder($order);
253
    }
254
255 28
    private function getFieldAndSortingFromSort(Sort $sort): array
256
    {
257 28
        $order = $sort->getOrder();
258
259
        return [
260 28
            key($order),
261 28
            reset($order),
262
        ];
263
    }
264
265 28
    private function readData(ReadableDataInterface $dataReader, Sort $sort): array
266
    {
267 28
        $data = [];
268 28
        [$field] = $this->getFieldAndSortingFromSort($sort);
269
270 28
        foreach ($dataReader->read() as $item) {
271 24
            if ($this->currentFirstValue === null) {
272 24
                $this->currentFirstValue = $this->getValueFromItem($item, $field);
273
            }
274 24
            if (count($data) === $this->pageSize) {
275 13
                $this->hasNextPageItem = true;
276
            } else {
277 24
                $this->currentLastValue = $this->getValueFromItem($item, $field);
278 24
                $data[] = $item;
279
            }
280
        }
281
282 28
        return $data;
283
    }
284
285 4
    private function reverseData(array $data): array
286
    {
287 4
        [$this->currentFirstValue, $this->currentLastValue] = [$this->currentLastValue, $this->currentFirstValue];
288 4
        [$this->hasPreviousPageItem, $this->hasNextPageItem] = [$this->hasNextPageItem, $this->hasPreviousPageItem];
289 4
        return array_reverse($data);
290
    }
291
292 8
    private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort): bool
293
    {
294 8
        $reverseFilter = $this->getReverseFilter($sort);
295 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

295
        foreach ($dataReader->/** @scrutinizer ignore-call */ withFilter($reverseFilter)->withLimit(1)->read() as $void) {
Loading history...
296 7
            return true;
297
        }
298 1
        return false;
299
    }
300
301 24
    private function getValueFromItem($item, string $field)
302
    {
303 24
        $methodName = 'get' . ucfirst($field);
304 24
        if (is_object($item) && is_callable([$item, $methodName])) {
305 1
            return $item->$methodName();
306
        }
307
308 23
        return ArrayHelper::getValue($item, $field);
309
    }
310
}
311