Passed
Push — master ( 44018e...e279c8 )
by Eric
01:50
created

Paginator::validateConfig()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 9
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 16
ccs 11
cts 11
cp 1
crap 2
rs 9.9666
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Pagination - Simple, lightweight and universal service that implements pagination on collections of things.
7
 *
8
 * @author    Eric Sizemore <[email protected]>
9
 * @version   2.0.1
10
 * @copyright (C) 2024 Eric Sizemore
11
 * @license   The MIT License (MIT)
12
 *
13
 * Copyright (C) 2024 Eric Sizemore<https://www.secondversion.com/>.
14
 * Copyright (c) 2015-2019 Ashley Dawson<[email protected]>
15
 *
16
 * Permission is hereby granted, free of charge, to any person obtaining a copy
17
 * of this software and associated documentation files (the "Software"), to
18
 * deal in the Software without restriction, including without limitation the
19
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
20
 * sell copies of the Software, and to permit persons to whom the Software is
21
 * furnished to do so, subject to the following conditions:
22
 *
23
 * The above copyright notice and this permission notice shall be included in
24
 * all copies or substantial portions of the Software.
25
 *
26
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
32
 * THE SOFTWARE.
33
 */
34
35
/**
36
 * Esi\Pagination is a fork of AshleyDawson\SimplePagination (https://github.com/AshleyDawson/SimplePagination) which is:
37
 *     Copyright (c) 2015-2019 Ashley Dawson
38
 *
39
 * For a list of changes made in Esi\Pagination in comparison to the original library {@see CHANGELOG.md}.
40
 */
41
42
namespace Esi\Pagination;
43
44
use Closure;
45
use Esi\Pagination\Exception\CallbackNotFoundException;
46
use Esi\Pagination\Exception\InvalidPageNumberException;
47
use Iterator;
48
49
use function array_filter;
50
use function ceil;
51
use function in_array;
52
use function is_int;
53
use function iterator_to_array;
54
use function max;
55
use function min;
56
use function range;
57
use function sprintf;
58
59
use const ARRAY_FILTER_USE_BOTH;
60
61
/**
62
 * Main Paginator Class.
63
 *
64
 * @see \Esi\Pagination\Tests\PaginatorTest
65
 */
66
class Paginator implements PaginatorInterface
67
{
68
    /**
69
     * A callback that is used to determine the total number of items in your collection (returned as an integer).
70
     */
71
    private ?Closure $itemTotalCallback = null;
72
73
    /**
74
     * A callback to slice your collection given an offset and length argument.
75
     */
76
    private ?Closure $sliceCallback = null;
77
78
    /**
79
     * A callback to run before the count and slice queries.
80
     */
81
    private ?Closure $beforeQueryCallback = null;
82
83
    /**
84
     * A callback to run after the count and slice queries.
85
     */
86
    private ?Closure $afterQueryCallback = null;
87
88
    /**
89
     * Number of items to include per page.
90
     */
91
    private int $itemsPerPage = 10;
92
93
    /**
94
     * Number of pages in range.
95
     */
96
    private int $pagesInRange = 5;
97
98
    /**
99
     * Constructor - passing optional configuration
100
     *
101
     * <code>
102
     * $paginator = new Paginator([
103
     *     'itemTotalCallback' => function () {
104
     *         // ...
105
     *     },
106
     *     'sliceCallback' => function (int $offset, int $length) {
107
     *         // ...
108
     *     },
109
     *     'itemsPerPage' => 10,
110
     *     'pagesInRange' => 5,
111
     * ]);
112
     * </code>
113
     *
114
     * @param null|array{}|array{
115
     *     itemTotalCallback: Closure,
116
     *     sliceCallback: Closure,
117
     *     itemsPerPage: int,
118
     *     pagesInRange: int
119
     * } $config
120
     */
121 17
    public function __construct(?array $config = null)
122
    {
123 17
        $config = self::validateConfig($config);
124
125 17
        if ($config === []) {
126 17
            return;
127
        }
128
129 5
        $this->setItemTotalCallback($config['itemTotalCallback']);
130 5
        $this->setSliceCallback($config['sliceCallback']);
131 5
        $this->setItemsPerPage($config['itemsPerPage']);
132 5
        $this->setPagesInRange($config['pagesInRange']);
133
    }
134
135
    /**
136
     * {@inheritdoc}
137
     */
138 12
    #[\Override]
139
    public function paginate(int $currentPageNumber = 1): Pagination
140
    {
141 12
        if ($this->itemTotalCallback === null) {
142 2
            throw new CallbackNotFoundException(
143 2
                'Item total callback not found, set it using Paginator::setItemTotalCallback()'
144 2
            );
145
        }
146
147 10
        if ($this->sliceCallback === null) {
148 1
            throw new CallbackNotFoundException(
149 1
                'Slice callback not found, set it using Paginator::setSliceCallback()'
150 1
            );
151
        }
152
153 9
        if ($currentPageNumber <= 0) {
154 1
            throw new InvalidPageNumberException(
155 1
                sprintf('Current page number must have a value of 1 or more, %s given', $currentPageNumber)
156 1
            );
157
        }
158
159 8
        $sliceCallback       = $this->sliceCallback;
160 8
        $itemTotalCallback   = $this->itemTotalCallback;
161 8
        $beforeQueryCallback = $this->prepareBeforeQueryCallback();
162 8
        $afterQueryCallback  = $this->prepareAfterQueryCallback();
163
164 8
        $pagination = new Pagination();
165
166 8
        $beforeQueryCallback($this, $pagination);
167 8
        $totalNumberOfItems = (int) $itemTotalCallback($pagination);
168 8
        $afterQueryCallback($this, $pagination);
169
170 8
        $numberOfPages = (int) ceil($totalNumberOfItems / $this->itemsPerPage);
171 8
        $pagesInRange  = min($this->pagesInRange, $numberOfPages);
172 8
        $pages         = self::determinePageRange($currentPageNumber, $pagesInRange, $numberOfPages);
173 8
        $offset        = ($currentPageNumber - 1) * $this->itemsPerPage;
174
175 8
        $beforeQueryCallback($this, $pagination);
176
177 8
        if (-1 === $this->itemsPerPage) {
178 1
            $items = $sliceCallback(0, 999999999, $pagination);
179
        } else {
180 7
            $items = $sliceCallback($offset, $this->itemsPerPage, $pagination);
181
        }
182
183 8
        if ($items instanceof Iterator) {
184 1
            $items = iterator_to_array($items);
185
        }
186
187 8
        $afterQueryCallback($this, $pagination);
188
189 8
        $previousPageNumber = self::determinePreviousPageNumber($currentPageNumber);
190 8
        $nextPageNumber     = self::determineNextPageNumber($currentPageNumber, $numberOfPages);
191
192
        /** @var non-empty-array<int> $pages **/
193 8
        $pagination
194 8
            ->setItems($items)
195 8
            ->setPages($pages)
196 8
            ->setTotalNumberOfPages($numberOfPages)
197 8
            ->setCurrentPageNumber($currentPageNumber)
198 8
            ->setFirstPageNumber(1)
199 8
            ->setLastPageNumber($numberOfPages)
200 8
            ->setPreviousPageNumber($previousPageNumber)
201 8
            ->setNextPageNumber($nextPageNumber)
202 8
            ->setItemsPerPage($this->itemsPerPage)
203 8
            ->setTotalNumberOfItems($totalNumberOfItems)
204 8
            ->setFirstPageNumberInRange(min($pages))
205 8
            ->setLastPageNumberInRange(max($pages))
206 8
        ;
207
208 8
        return $pagination;
209
    }
210
211
    /**
212
     * {@inheritdoc}
213
     */
214 2
    #[\Override]
215
    public function getItemTotalCallback(): ?Closure
216
    {
217 2
        return $this->itemTotalCallback;
218
    }
219
220
    /**
221
     * {@inheritdoc}
222
     */
223 12
    #[\Override]
224
    public function setItemTotalCallback(?Closure $itemTotalCallback): static
225
    {
226 12
        $this->itemTotalCallback = $itemTotalCallback;
227
228 12
        return $this;
229
    }
230
231
    /**
232
     * {@inheritdoc}
233
     */
234 2
    #[\Override]
235
    public function getSliceCallback(): ?Closure
236
    {
237 2
        return $this->sliceCallback;
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     */
243 12
    #[\Override]
244
    public function setSliceCallback(?Closure $sliceCallback): static
245
    {
246 12
        $this->sliceCallback = $sliceCallback;
247
248 12
        return $this;
249
    }
250
251
    /**
252
     * {@inheritdoc}
253
     */
254 1
    #[\Override]
255
    public function getBeforeQueryCallback(): ?Closure
256
    {
257 1
        return $this->beforeQueryCallback;
258
    }
259
260
    /**
261
     * {@inheritdoc}
262
     */
263 2
    #[\Override]
264
    public function setBeforeQueryCallback(?Closure $beforeQueryCallback): static
265
    {
266 2
        $this->beforeQueryCallback = $beforeQueryCallback;
267
268 2
        return $this;
269
    }
270
271
    /**
272
     * {@inheritdoc}
273
     */
274 1
    #[\Override]
275
    public function getAfterQueryCallback(): ?Closure
276
    {
277 1
        return $this->afterQueryCallback;
278
    }
279
280
    /**
281
     * {@inheritdoc}
282
     */
283 2
    #[\Override]
284
    public function setAfterQueryCallback(?Closure $afterQueryCallback): static
285
    {
286 2
        $this->afterQueryCallback = $afterQueryCallback;
287
288 2
        return $this;
289
    }
290
291
    /**
292
     * {@inheritdoc}
293
     */
294 2
    #[\Override]
295
    public function getItemsPerPage(): int
296
    {
297 2
        return $this->itemsPerPage;
298
    }
299
300
    /**
301
     * {@inheritdoc}
302
     */
303 12
    #[\Override]
304
    public function setItemsPerPage(int $itemsPerPage): static
305
    {
306 12
        $this->itemsPerPage = $itemsPerPage;
307
308 12
        return $this;
309
    }
310
311
    /**
312
     * {@inheritdoc}
313
     */
314 2
    #[\Override]
315
    public function getPagesInRange(): int
316
    {
317 2
        return $this->pagesInRange;
318
    }
319
320
    /**
321
     * {@inheritdoc}
322
     */
323 12
    #[\Override]
324
    public function setPagesInRange(int $pagesInRange): static
325
    {
326 12
        $this->pagesInRange = $pagesInRange;
327
328 12
        return $this;
329
    }
330
331
    /**
332
     * Helper function for __construct() to validate the passed $config.
333
     *
334
     * @param null|array{}|array{
335
     *     itemTotalCallback: Closure,
336
     *     sliceCallback: Closure,
337
     *     itemsPerPage: int,
338
     *     pagesInRange: int
339
     * } $config Expected array signature.
340
     *
341
     * @return array{}|array{
0 ignored issues
show
Documentation Bug introduced by
The doc comment array{}|array{ at position 6 could not be parsed: the token is null at position 6.
Loading history...
342
     *     itemTotalCallback: Closure,
343
     *     sliceCallback: Closure,
344
     *     itemsPerPage: int,
345
     *     pagesInRange: int
346
     * }
347
     */
348 17
    protected static function validateConfig(?array $config = null): array
349
    {
350 17
        static $validKeys = ['itemTotalCallback', 'sliceCallback', 'itemsPerPage', 'pagesInRange'];
351
352 17
        $config ??= [];
353
354 17
        return array_filter($config, static function (mixed $value, string $key) use ($validKeys): bool {
355 6
            if (!in_array($key, $validKeys, true)) {
356 1
                return false;
357
            }
358
359 6
            return match($key) {
360 6
                'itemTotalCallback', 'sliceCallback' => $value instanceof Closure,
361 6
                default => is_int($value)
362 6
            };
363 17
        }, ARRAY_FILTER_USE_BOTH);
364
365
    }
366
367
    /**
368
     * A helper function to {@see self::paginate()}.
369
     *
370
     * Ensures the beforeQueryCallback is a valid Closure. If the currently set
371
     * beforeQueryCallback is null, it will return an empty Closure object.
372
     */
373 8
    protected function prepareBeforeQueryCallback(): Closure
374
    {
375 8
        if ($this->beforeQueryCallback instanceof Closure) {
376 1
            return $this->beforeQueryCallback;
377
        }
378
379 7
        return static function (): void {};
380
    }
381
382
    /**
383
     * A helper function to {@see self::paginate()}.
384
     *
385
     * Ensures the afterQueryCallback is a valid Closure. If the currently set
386
     * afterQueryCallback is null, it will return an empty Closure object.
387
     */
388 8
    protected function prepareAfterQueryCallback(): Closure
389
    {
390 8
        if ($this->afterQueryCallback instanceof Closure) {
391 1
            return $this->afterQueryCallback;
392
        }
393
394 7
        return static function (): void {};
395
    }
396
397
    /**
398
     * A helper function to {@see self::paginate()}.
399
     *
400
     * Determines the number of pages in range given the current page number, currently
401
     * set pages in range, and total number of pages.
402
     *
403
     * @return array<int>
404
     */
405 8
    protected static function determinePageRange(int $currentPageNumber, int $pagesInRange, int $numberOfPages): array
406
    {
407 8
        $change = (int) ceil($pagesInRange / 2);
408
409 8
        if (($currentPageNumber - $change) > ($numberOfPages - $pagesInRange)) {
410 5
            $pages = range(($numberOfPages - $pagesInRange) + 1, $numberOfPages);
411
        } else {
412 7
            if (($currentPageNumber - $change) < 0) {
413 5
                $change = $currentPageNumber;
414
            }
415
416 7
            $offset = $currentPageNumber - $change;
417 7
            $pages  = range(($offset + 1), $offset + $pagesInRange);
418
        }
419
420 8
        return $pages;
421
    }
422
423
    /**
424
     * A helper function to {@see self::paginate()}.
425
     *
426
     * Determines the previous page number based on the current page number.
427
     */
428 8
    protected static function determinePreviousPageNumber(int $currentPageNumber): ?int
429
    {
430 8
        if (($currentPageNumber - 1) > 0) {
431 5
            return $currentPageNumber - 1;
432
        }
433
434 8
        return null;
435
    }
436
437
    /**
438
     * A helper function to {@see self::paginate()}.
439
     *
440
     * Determines the next page number based on the current page number.
441
     */
442 8
    protected static function determineNextPageNumber(int $currentPageNumber, int $numberOfPages): ?int
443
    {
444 8
        if (($currentPageNumber + 1) <= $numberOfPages) {
445 7
            return $currentPageNumber + 1;
446
        }
447
448 5
        return null;
449
    }
450
}
451