-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add eloquent cursor pagination implementation (#37)
- Loading branch information
Showing
10 changed files
with
1,969 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
<?php | ||
/* | ||
* Copyright 2023 Cloud Creativity Limited | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
declare(strict_types=1); | ||
|
||
namespace LaravelJsonApi\Eloquent\Pagination\Cursor; | ||
|
||
use InvalidArgumentException; | ||
|
||
class Cursor | ||
{ | ||
|
||
/** | ||
* @var string|null | ||
*/ | ||
private ?string $before; | ||
|
||
/** | ||
* @var string|null | ||
*/ | ||
private ?string $after; | ||
|
||
/** | ||
* @var int|null | ||
*/ | ||
private ?int $limit; | ||
|
||
/** | ||
* Cursor constructor. | ||
* | ||
* @param string|null $before | ||
* @param string|null $after | ||
* @param int|null $limit | ||
*/ | ||
public function __construct(string $before = null, string $after = null, int $limit = null) | ||
{ | ||
if (is_int($limit) && 1 > $limit) { | ||
throw new InvalidArgumentException('Expecting a limit that is 1 or greater.'); | ||
} | ||
|
||
$this->before = $before ?: null; | ||
$this->after = $after ?: null; | ||
$this->limit = $limit; | ||
} | ||
|
||
/** | ||
* @return bool | ||
*/ | ||
public function isBefore(): bool | ||
{ | ||
return !is_null($this->before); | ||
} | ||
|
||
/** | ||
* @return string|null | ||
*/ | ||
public function getBefore(): ?string | ||
{ | ||
return $this->before; | ||
} | ||
|
||
/** | ||
* @return bool | ||
*/ | ||
public function isAfter(): bool | ||
{ | ||
return !is_null($this->after) && !$this->isBefore(); | ||
} | ||
|
||
/** | ||
* @return string|null | ||
*/ | ||
public function getAfter(): ?string | ||
{ | ||
return $this->after; | ||
} | ||
|
||
/** | ||
* Set a limit, if no limit is set on the cursor. | ||
* | ||
* @param int $limit | ||
* @return Cursor | ||
*/ | ||
public function withDefaultLimit(int $limit): self | ||
{ | ||
if (is_null($this->limit)) { | ||
$copy = clone $this; | ||
$copy->limit = $limit; | ||
return $copy; | ||
} | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @return int|null | ||
*/ | ||
public function getLimit(): ?int | ||
{ | ||
return $this->limit; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace LaravelJsonApi\Eloquent\Pagination\Cursor; | ||
|
||
use Illuminate\Database\Eloquent\Builder; | ||
use Illuminate\Database\Eloquent\Relations\Relation; | ||
use Illuminate\Pagination\Cursor as LaravelCursor; | ||
use LaravelJsonApi\Contracts\Schema\ID; | ||
use LaravelJsonApi\Core\Schema\IdParser; | ||
|
||
class CursorBuilder | ||
{ | ||
private Builder|Relation $query; | ||
|
||
private ID $id; | ||
|
||
private string $keyName; | ||
|
||
private string $direction; | ||
|
||
private ?int $defaultPerPage = null; | ||
|
||
private bool $withTotal; | ||
|
||
private bool $keySort = true; | ||
|
||
private CursorParser $parser; | ||
|
||
/** | ||
* CursorBuilder constructor. | ||
* | ||
* @param Builder|Relation $query | ||
* the column to use for the cursor | ||
* @param string|null $key | ||
* the key column that the before/after cursors related to | ||
*/ | ||
public function __construct($query, ID $id, string $key = null) | ||
{ | ||
if (!$query instanceof Builder && !$query instanceof Relation) { | ||
throw new \InvalidArgumentException('Expecting an Eloquent query builder or relation.'); | ||
} | ||
|
||
$this->query = $query; | ||
$this->id = $id; | ||
$this->keyName = $key ?: $this->guessKey(); | ||
$this->parser = new CursorParser(IdParser::make($this->id), $this->keyName); | ||
} | ||
|
||
/** | ||
* Set the default number of items per-page. | ||
* | ||
* If null, the default from the `Model::getPage()` method will be used. | ||
* | ||
* @return $this | ||
*/ | ||
public function withDefaultPerPage(?int $perPage): self | ||
{ | ||
$this->defaultPerPage = $perPage; | ||
|
||
return $this; | ||
} | ||
|
||
|
||
public function withKeySort(bool $keySort): self | ||
{ | ||
$this->keySort = $keySort; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Set the query direction. | ||
* | ||
* @return $this | ||
*/ | ||
public function withDirection(string $direction): self | ||
{ | ||
if (\in_array($direction, ['asc', 'desc'])) { | ||
$this->direction = $direction; | ||
|
||
return $this; | ||
} | ||
|
||
throw new \InvalidArgumentException('Unexpected query direction.'); | ||
} | ||
|
||
public function withTotal(bool $withTotal): self | ||
{ | ||
$this->withTotal = $withTotal; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @param array<string> $columns | ||
*/ | ||
public function paginate(Cursor $cursor, array $columns = ['*']): CursorPaginator | ||
{ | ||
$cursor = $cursor->withDefaultLimit($this->getDefaultPerPage()); | ||
|
||
$this->applyKeySort(); | ||
|
||
$total = $this->getTotal(); | ||
$laravelPaginator = $this->query->cursorPaginate($cursor->getLimit(), $columns, 'cursor', $this->parser->decode($cursor)); | ||
$paginator = new CursorPaginator($this->parser, $laravelPaginator, $cursor, $total); | ||
|
||
return $paginator->withCurrentPath(); | ||
} | ||
|
||
private function applyKeySort(): void | ||
{ | ||
if (!$this->keySort) { | ||
return; | ||
} | ||
|
||
if ( | ||
empty($this->query->getQuery()->orders) | ||
|| collect($this->query->getQuery()->orders) | ||
->whereIn('column', [$this->keyName, $this->query->qualifyColumn($this->keyName)]) | ||
->isEmpty() | ||
) { | ||
$this->query->orderBy($this->keyName, $this->direction); | ||
} | ||
} | ||
|
||
private function getTotal(): ?int | ||
{ | ||
return $this->withTotal ? $this->query->count() : null; | ||
} | ||
|
||
private function convertCursor(Cursor $cursor): ?LaravelCursor | ||
{ | ||
$encodedCursor = $cursor->isBefore() ? $cursor->getBefore() : $cursor->getAfter(); | ||
if (!is_string($encodedCursor)) { | ||
return null; | ||
} | ||
|
||
$parameters = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $encodedCursor)), true); | ||
|
||
if (json_last_error() !== JSON_ERROR_NONE) { | ||
return null; | ||
} | ||
|
||
$pointsToNextItems = $parameters['_pointsToNextItems']; | ||
unset($parameters['_pointsToNextItems']); | ||
if (isset($parameters[$this->keyName])) { | ||
$parameters[$this->keyName] = IdParser::make($this->id)->decode( | ||
(string) $parameters[$this->keyName], | ||
); | ||
} | ||
|
||
return new LaravelCursor($parameters, $pointsToNextItems); | ||
} | ||
|
||
private function getDefaultPerPage(): int | ||
{ | ||
if (is_int($this->defaultPerPage)) { | ||
return $this->defaultPerPage; | ||
} | ||
|
||
return $this->query->getModel()->getPerPage(); | ||
} | ||
|
||
/** | ||
* Guess the key to use for the cursor. | ||
*/ | ||
private function guessKey(): string | ||
{ | ||
return $this->id?->key() ?? $this->query->getModel()->getKeyName(); | ||
} | ||
} |
Oops, something went wrong.