Skip to content

Commit d46b030

Browse files
authored
feat: add eloquent cursor pagination implementation (#37)
1 parent 98bbec0 commit d46b030

File tree

10 files changed

+1969
-2
lines changed

10 files changed

+1969
-2
lines changed

src/Pagination/Cursor/Cursor.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
/*
3+
* Copyright 2023 Cloud Creativity Limited
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace LaravelJsonApi\Eloquent\Pagination\Cursor;
21+
22+
use InvalidArgumentException;
23+
24+
class Cursor
25+
{
26+
27+
/**
28+
* @var string|null
29+
*/
30+
private ?string $before;
31+
32+
/**
33+
* @var string|null
34+
*/
35+
private ?string $after;
36+
37+
/**
38+
* @var int|null
39+
*/
40+
private ?int $limit;
41+
42+
/**
43+
* Cursor constructor.
44+
*
45+
* @param string|null $before
46+
* @param string|null $after
47+
* @param int|null $limit
48+
*/
49+
public function __construct(string $before = null, string $after = null, int $limit = null)
50+
{
51+
if (is_int($limit) && 1 > $limit) {
52+
throw new InvalidArgumentException('Expecting a limit that is 1 or greater.');
53+
}
54+
55+
$this->before = $before ?: null;
56+
$this->after = $after ?: null;
57+
$this->limit = $limit;
58+
}
59+
60+
/**
61+
* @return bool
62+
*/
63+
public function isBefore(): bool
64+
{
65+
return !is_null($this->before);
66+
}
67+
68+
/**
69+
* @return string|null
70+
*/
71+
public function getBefore(): ?string
72+
{
73+
return $this->before;
74+
}
75+
76+
/**
77+
* @return bool
78+
*/
79+
public function isAfter(): bool
80+
{
81+
return !is_null($this->after) && !$this->isBefore();
82+
}
83+
84+
/**
85+
* @return string|null
86+
*/
87+
public function getAfter(): ?string
88+
{
89+
return $this->after;
90+
}
91+
92+
/**
93+
* Set a limit, if no limit is set on the cursor.
94+
*
95+
* @param int $limit
96+
* @return Cursor
97+
*/
98+
public function withDefaultLimit(int $limit): self
99+
{
100+
if (is_null($this->limit)) {
101+
$copy = clone $this;
102+
$copy->limit = $limit;
103+
return $copy;
104+
}
105+
106+
return $this;
107+
}
108+
109+
/**
110+
* @return int|null
111+
*/
112+
public function getLimit(): ?int
113+
{
114+
return $this->limit;
115+
}
116+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaravelJsonApi\Eloquent\Pagination\Cursor;
6+
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Database\Eloquent\Relations\Relation;
9+
use Illuminate\Pagination\Cursor as LaravelCursor;
10+
use LaravelJsonApi\Contracts\Schema\ID;
11+
use LaravelJsonApi\Core\Schema\IdParser;
12+
13+
class CursorBuilder
14+
{
15+
private Builder|Relation $query;
16+
17+
private ID $id;
18+
19+
private string $keyName;
20+
21+
private string $direction;
22+
23+
private ?int $defaultPerPage = null;
24+
25+
private bool $withTotal;
26+
27+
private bool $keySort = true;
28+
29+
private CursorParser $parser;
30+
31+
/**
32+
* CursorBuilder constructor.
33+
*
34+
* @param Builder|Relation $query
35+
* the column to use for the cursor
36+
* @param string|null $key
37+
* the key column that the before/after cursors related to
38+
*/
39+
public function __construct($query, ID $id, string $key = null)
40+
{
41+
if (!$query instanceof Builder && !$query instanceof Relation) {
42+
throw new \InvalidArgumentException('Expecting an Eloquent query builder or relation.');
43+
}
44+
45+
$this->query = $query;
46+
$this->id = $id;
47+
$this->keyName = $key ?: $this->guessKey();
48+
$this->parser = new CursorParser(IdParser::make($this->id), $this->keyName);
49+
}
50+
51+
/**
52+
* Set the default number of items per-page.
53+
*
54+
* If null, the default from the `Model::getPage()` method will be used.
55+
*
56+
* @return $this
57+
*/
58+
public function withDefaultPerPage(?int $perPage): self
59+
{
60+
$this->defaultPerPage = $perPage;
61+
62+
return $this;
63+
}
64+
65+
66+
public function withKeySort(bool $keySort): self
67+
{
68+
$this->keySort = $keySort;
69+
70+
return $this;
71+
}
72+
73+
/**
74+
* Set the query direction.
75+
*
76+
* @return $this
77+
*/
78+
public function withDirection(string $direction): self
79+
{
80+
if (\in_array($direction, ['asc', 'desc'])) {
81+
$this->direction = $direction;
82+
83+
return $this;
84+
}
85+
86+
throw new \InvalidArgumentException('Unexpected query direction.');
87+
}
88+
89+
public function withTotal(bool $withTotal): self
90+
{
91+
$this->withTotal = $withTotal;
92+
93+
return $this;
94+
}
95+
96+
/**
97+
* @param array<string> $columns
98+
*/
99+
public function paginate(Cursor $cursor, array $columns = ['*']): CursorPaginator
100+
{
101+
$cursor = $cursor->withDefaultLimit($this->getDefaultPerPage());
102+
103+
$this->applyKeySort();
104+
105+
$total = $this->getTotal();
106+
$laravelPaginator = $this->query->cursorPaginate($cursor->getLimit(), $columns, 'cursor', $this->parser->decode($cursor));
107+
$paginator = new CursorPaginator($this->parser, $laravelPaginator, $cursor, $total);
108+
109+
return $paginator->withCurrentPath();
110+
}
111+
112+
private function applyKeySort(): void
113+
{
114+
if (!$this->keySort) {
115+
return;
116+
}
117+
118+
if (
119+
empty($this->query->getQuery()->orders)
120+
|| collect($this->query->getQuery()->orders)
121+
->whereIn('column', [$this->keyName, $this->query->qualifyColumn($this->keyName)])
122+
->isEmpty()
123+
) {
124+
$this->query->orderBy($this->keyName, $this->direction);
125+
}
126+
}
127+
128+
private function getTotal(): ?int
129+
{
130+
return $this->withTotal ? $this->query->count() : null;
131+
}
132+
133+
private function convertCursor(Cursor $cursor): ?LaravelCursor
134+
{
135+
$encodedCursor = $cursor->isBefore() ? $cursor->getBefore() : $cursor->getAfter();
136+
if (!is_string($encodedCursor)) {
137+
return null;
138+
}
139+
140+
$parameters = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $encodedCursor)), true);
141+
142+
if (json_last_error() !== JSON_ERROR_NONE) {
143+
return null;
144+
}
145+
146+
$pointsToNextItems = $parameters['_pointsToNextItems'];
147+
unset($parameters['_pointsToNextItems']);
148+
if (isset($parameters[$this->keyName])) {
149+
$parameters[$this->keyName] = IdParser::make($this->id)->decode(
150+
(string) $parameters[$this->keyName],
151+
);
152+
}
153+
154+
return new LaravelCursor($parameters, $pointsToNextItems);
155+
}
156+
157+
private function getDefaultPerPage(): int
158+
{
159+
if (is_int($this->defaultPerPage)) {
160+
return $this->defaultPerPage;
161+
}
162+
163+
return $this->query->getModel()->getPerPage();
164+
}
165+
166+
/**
167+
* Guess the key to use for the cursor.
168+
*/
169+
private function guessKey(): string
170+
{
171+
return $this->id?->key() ?? $this->query->getModel()->getKeyName();
172+
}
173+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy