Skip to content

Commit

Permalink
[8.x] Add cursor pagination (aka keyset pagination) (#37216)
Browse files Browse the repository at this point in the history
* Add cursor pagination without tests

* Fix styleci

* Add cursor paginator tests

* Add support for query builder

* Fix tests

* Complete all tests for database and Eloquent builders

* Incorporate suggestions

* Fix styleci

* Fix docblocks

* move method

* Fix docblock

* Formatting

* Various formatting - method renaming.

* Add more tests

Co-authored-by: Taylor Otwell <[email protected]>
  • Loading branch information
paras-malhotra and taylorotwell authored May 6, 2021
1 parent cf3fe08 commit f383028
Show file tree
Hide file tree
Showing 19 changed files with 1,845 additions and 2 deletions.
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

0 comments on commit f383028

Please sign in to comment.