Skip to content

Commit

Permalink
feat: add eloquent cursor pagination implementation (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
haddowg authored Aug 7, 2024
1 parent 98bbec0 commit d46b030
Show file tree
Hide file tree
Showing 10 changed files with 1,969 additions and 2 deletions.
116 changes: 116 additions & 0 deletions src/Pagination/Cursor/Cursor.php
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;
}
}
173 changes: 173 additions & 0 deletions src/Pagination/Cursor/CursorBuilder.php
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();
}
}
Loading

0 comments on commit d46b030

Please sign in to comment.