Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add eloquent cursor pagination implementation #37

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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