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

[8.x] Add cursor pagination (aka keyset pagination) #37216

Merged
merged 15 commits into from
May 6, 2021
117 changes: 117 additions & 0 deletions src/Illuminate/Contracts/Pagination/CursorPaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace Illuminate\Contracts\Pagination;

interface CursorPaginator
{
/**
* Get the URL for a given cursor.
*
* @param \Illuminate\Pagination\Cursor|null $cursor
* @return string
*/
public function url($cursor);

/**
* Add a set of query string values to the paginator.
*
* @param array|string|null $key
* @param string|null $value
* @return $this
*/
public function appends($key, $value = null);

/**
* Get / set the URL fragment to be appended to URLs.
*
* @param string|null $fragment
* @return $this|string|null
*/
public function fragment($fragment = null);

/**
* Get the URL for the previous page, or null.
*
* @return string|null
*/
public function previousPageUrl();

/**
* The URL for the next page, or null.
*
* @return string|null
*/
public function nextPageUrl();

/**
* Get all of the items being paginated.
*
* @return array
*/
public function items();

/**
* Get the "cursor" of the previous set of items.
*
* @return \Illuminate\Pagination\Cursor|null
*/
public function previousCursor();

/**
* Get the "cursor" of the next set of items.
*
* @return \Illuminate\Pagination\Cursor|null
*/
public function nextCursor();

/**
* Determine how many items are being shown per page.
*
* @return int
*/
public function perPage();

/**
* Get the current cursor being paginated.
*
* @return \Illuminate\Pagination\Cursor|null
*/
public function cursor();

/**
* Determine if there are enough items to split into multiple pages.
*
* @return bool
*/
public function hasPages();

/**
* Get the base path for paginator generated URLs.
*
* @return string|null
*/
public function path();

/**
* Determine if the list of items is empty or not.
*
* @return bool
*/
public function isEmpty();

/**
* Determine if the list of items is not empty.
*
* @return bool
*/
public function isNotEmpty();

/**
* Render the paginator using a given view.
*
* @param string|null $view
* @param array $data
* @return string
*/
public function render($view = null, $data = []);
}
17 changes: 17 additions & 0 deletions src/Illuminate/Database/Concerns/BuildsQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Container\Container;
use Illuminate\Database\MultipleRecordsFoundException;
use Illuminate\Database\RecordsNotFoundException;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
Expand Down Expand Up @@ -347,4 +348,20 @@ protected function simplePaginator($items, $perPage, $currentPage, $options)
'items', 'perPage', 'currentPage', 'options'
));
}

/**
* Create a new cursor paginator instance.
*
* @param \Illuminate\Support\Collection $items
* @param int $perPage
* @param \Illuminate\Pagination\Cursor $cursor
* @param array $options
* @return \Illuminate\Pagination\Paginator
*/
protected function cursorPaginator($items, $perPage, $cursor, $options)
{
return Container::getInstance()->makeWith(CursorPaginator::class, compact(
'items', 'perPage', 'cursor', 'options'
));
}
}
73 changes: 73 additions & 0 deletions src/Illuminate/Database/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\RecordsNotFoundException;
use Illuminate\Pagination\CursorPaginationException;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
Expand Down Expand Up @@ -812,6 +814,77 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p
]);
}

/**
* Paginate the given query into a cursor paginator.
*
* @param int|null $perPage
* @param array $columns
* @param string $cursorName
* @param string|null $cursor
* @return \Illuminate\Contracts\Pagination\Paginator
* @throws \Illuminate\Pagination\CursorPaginationException
*/
public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null)
{
$cursor = $cursor ?: CursorPaginator::resolveCurrentCursor($cursorName);

$perPage = $perPage ?: $this->model->getPerPage();

$orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems());

$orderDirection = $orders->first()['direction'] ?? 'asc';

$comparisonOperator = $orderDirection === 'asc' ? '>' : '<';

$parameters = $orders->pluck('column')->toArray();

if (! is_null($cursor)) {
if (count($parameters) === 1) {
$this->where($column = $parameters[0], $comparisonOperator, $cursor->parameter($column));
} elseif (count($parameters) > 1) {
$this->whereRowValues($parameters, $comparisonOperator, $cursor->parameters($parameters));
}
}

$this->take($perPage + 1);

return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [
'path' => Paginator::resolveCurrentPath(),
'cursorName' => $cursorName,
'parameters' => $parameters,
]);
}

/**
* Ensure the proper order by required for cursor pagination.
*
* @param bool $shouldReverse
* @return \Illuminate\Support\Collection
* @throws \Illuminate\Pagination\CursorPaginationException
*/
protected function ensureOrderForCursorPagination($shouldReverse = false)
{
$orderDirections = collect($this->query->orders)->pluck('direction')->unique();

if ($orderDirections->count() > 1) {
throw new CursorPaginationException('Only a single order by direction is supported when using cursor pagination.');
}

if ($orderDirections->count() === 0) {
$this->enforceOrderBy();
}

if ($shouldReverse) {
$this->query->orders = collect($this->query->orders)->map(function ($order) {
$order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc';

return $order;
})->toArray();
}

return collect($this->query->orders);
}

/**
* Save a new model and return the instance.
*
Expand Down
71 changes: 71 additions & 0 deletions src/Illuminate/Database/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Grammars\Grammar;
use Illuminate\Database\Query\Processors\Processor;
use Illuminate\Pagination\CursorPaginationException;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
Expand Down Expand Up @@ -2360,6 +2362,75 @@ public function simplePaginate($perPage = 15, $columns = ['*'], $pageName = 'pag
]);
}

/**
* Get a paginator only supporting simple next and previous links.
*
* This is more efficient on larger data-sets, etc.
*
* @param int|null $perPage
* @param array $columns
* @param string $cursorName
* @param string|null $cursor
* @return \Illuminate\Contracts\Pagination\Paginator
* @throws \Illuminate\Pagination\CursorPaginationException
*/
public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'cursor', $cursor = null)
{
$cursor = $cursor ?: CursorPaginator::resolveCurrentCursor($cursorName);

$orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems());

$orderDirection = $orders->first()['direction'] ?? 'asc';

$comparisonOperator = $orderDirection === 'asc' ? '>' : '<';

$parameters = $orders->pluck('column')->toArray();

if (! is_null($cursor)) {
if (count($parameters) === 1) {
$this->where($column = $parameters[0], $comparisonOperator, $cursor->parameter($column));
} elseif (count($parameters) > 1) {
$this->whereRowValues($parameters, $comparisonOperator, $cursor->parameters($parameters));
}
}

$this->limit($perPage + 1);

return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [
'path' => Paginator::resolveCurrentPath(),
'cursorName' => $cursorName,
'parameters' => $parameters,
]);
}

/**
* Ensure the proper order by required for cursor pagination.
*
* @param bool $shouldReverse
* @return \Illuminate\Support\Collection
* @throws \Illuminate\Pagination\CursorPaginationException
*/
protected function ensureOrderForCursorPagination($shouldReverse = false)
{
$this->enforceOrderBy();

$orderDirections = collect($this->orders)->pluck('direction')->unique();

if ($orderDirections->count() > 1) {
throw new CursorPaginationException('Only a single order by direction is supported when using cursor pagination.');
}

if ($shouldReverse) {
$this->orders = collect($this->orders)->map(function ($order) {
$order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc';

return $order;
})->toArray();
}

return collect($this->orders);
}

/**
* Get the count of the total records for the paginator.
*
Expand Down
Loading