Passed
Push — master ( 3c9c42...019716 )
by Alexander
02:06 queued 34s
created

KeysetPaginator::previousPageExist()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

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