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
Prev Previous commit
Next Next commit
Add support for query builder
paras-malhotra committed May 2, 2021
commit fe228664cf02a5c2ff74172dd80ebf316002b32e
69 changes: 69 additions & 0 deletions src/Illuminate/Database/Query/Builder.php
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

use Closure;
use DateTimeInterface;
use Exception;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Concerns\BuildsQueries;
use Illuminate\Database\Concerns\ExplainsQueries;
@@ -12,6 +13,7 @@
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Grammars\Grammar;
use Illuminate\Database\Query\Processors\Processor;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@@ -2360,6 +2362,73 @@ 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 \Exception
*/
public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'cursor', $cursor = null)
{
$cursor = $cursor ?: CursorPaginator::resolveCurrentCursor($cursorName);

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

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

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

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

if (count($parameters) === 1 && ! is_null($cursor)) {
$this->where($column = $parameters[0], $comparisonOperator, $cursor->getParam($column));
} elseif (count($parameters) > 1 && ! is_null($cursor)) {
$this->whereRowValues($parameters, $comparisonOperator, $cursor->getParams($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 \Exception
*/
protected function ensureOrderForCursorPagination($shouldReverse = false)
{
$this->enforceOrderBy();

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

if ($orderDirections->count() > 1) {
throw new Exception('Only a single order by direction is supported in 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.
*
16 changes: 13 additions & 3 deletions src/Illuminate/Pagination/AbstractCursorPaginator.php
Original file line number Diff line number Diff line change
@@ -2,12 +2,14 @@

namespace Illuminate\Pagination;

use ArrayAccess;
use Closure;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Support\Traits\ForwardsCalls;
use stdClass;

/**
* @mixin \Illuminate\Support\Collection
@@ -168,7 +170,9 @@ public function nextCursor()
}

/**
* @param \ArrayAccess $item
* Get a cursor instance for the given item.
*
* @param \ArrayAccess|\stdClass $item
* @param bool $isNext
* @return \Illuminate\Pagination\Cursor
*/
@@ -178,14 +182,20 @@ public function getCursorForItem($item, $isNext = true)
}

/**
* @param \ArrayAccess $item
* @param \ArrayAccess|\stdClass $item
*/
public function getParametersForItem($item)
{
return collect($this->parameters)
->flip()
->map(function ($_, $parameterName) use ($item) {
return $item[$parameterName] ?? $item[Str::afterLast($parameterName, '.')];
if ($item instanceof ArrayAccess) {
return $item[$parameterName] ?? $item[Str::afterLast($parameterName, '.')];
} else if ($item instanceof stdClass) {
return $item->{$parameterName} ?? $item->{Str::afterLast($parameterName, '.')};
}

throw new \Exception('A cursor paginator item must either implement ArrayAccess or be an stdClass instance');
})->toArray();
}

99 changes: 99 additions & 0 deletions tests/Integration/Database/EloquentCursorPaginateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace Illuminate\Tests\Integration\Database;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
* @group integration
*/
class EloquentCursorPaginateTest extends DatabaseTestCase
{
protected function setUp(): void
{
parent::setUp();

Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->string('title')->nullable();
$table->unsignedInteger('user_id')->nullable();
$table->timestamps();
});

Schema::create('users', function ($table) {
$table->increments('id');
$table->timestamps();
});
}

public function testCursorPaginationOnTopOfColumns()
{
for ($i = 1; $i <= 50; $i++) {
Post::create([
'title' => 'Title '.$i,
]);
}

$this->assertCount(15, Post::cursorPaginate(15, ['id', 'title']));
}

public function testPaginationWithDistinct()
{
for ($i = 1; $i <= 3; $i++) {
Post::create(['title' => 'Hello world']);
Post::create(['title' => 'Goodbye world']);
}

$query = Post::query()->distinct();

$this->assertEquals(6, $query->get()->count());
$this->assertEquals(6, $query->count());
$this->assertCount(6, $query->cursorPaginate()->items());
}

public function testPaginationWithDistinctColumnsAndSelect()
{
for ($i = 1; $i <= 3; $i++) {
Post::create(['title' => 'Hello world']);
Post::create(['title' => 'Goodbye world']);
}

$query = Post::query()->distinct('title')->select('title');

$this->assertEquals(2, $query->get()->count());
$this->assertEquals(2, $query->count());
$this->assertCount(2, $query->cursorPaginate()->items());
}

public function testPaginationWithDistinctColumnsAndSelectAndJoin()
{
for ($i = 1; $i <= 5; $i++) {
$user = User::create();
for ($j = 1; $j <= 10; $j++) {
Post::create([
'title' => 'Title '.$i,
'user_id' => $user->id,
]);
}
}

$query = User::query()->join('posts', 'posts.user_id', '=', 'users.id')
->distinct('users.id')->select('users.*');

$this->assertEquals(5, $query->get()->count());
$this->assertEquals(5, $query->count());
$this->assertCount(5, $query->cursorPaginate()->items());
}
}

class Post extends Model
{
protected $guarded = [];
}

class User extends Model
{
protected $guarded = [];
}
3 changes: 1 addition & 2 deletions tests/Integration/Database/EloquentPaginateTest.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
<?php

namespace Illuminate\Tests\Integration\Database\EloquentPaginateTest;
namespace Illuminate\Tests\Integration\Database;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Tests\Integration\Database\DatabaseTestCase;

/**
* @group integration