From 0638ded4c74bd306aac13375b5362eb52c15f346 Mon Sep 17 00:00:00 2001 From: Hamid Date: Mon, 21 Jun 2021 17:29:57 +0430 Subject: [PATCH] mixed orders in cursor paginate --- .../Database/Concerns/BuildsQueries.php | 47 +++++++++++++ src/Illuminate/Database/Eloquent/Builder.php | 39 +---------- src/Illuminate/Database/Query/Builder.php | 38 +--------- .../Pagination/CursorPaginationException.php | 3 + tests/Database/DatabaseQueryBuilderTest.php | 69 ++++++++++++++++--- 5 files changed, 113 insertions(+), 83 deletions(-) diff --git a/src/Illuminate/Database/Concerns/BuildsQueries.php b/src/Illuminate/Database/Concerns/BuildsQueries.php index 461ef71156e3..8cfbee4a9318 100644 --- a/src/Illuminate/Database/Concerns/BuildsQueries.php +++ b/src/Illuminate/Database/Concerns/BuildsQueries.php @@ -292,6 +292,53 @@ public function tap($callback) return $this->when(true, $callback); } + /** + * Paginate the given query into a cursor paginator. + * + * @param int $perPage + * @param array $columns + * @param string $cursorName + * @param string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + protected function runCursorPaginate($perPage, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $cursor = $cursor ?: CursorPaginator::resolveCurrentCursor($cursorName); + + $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); + + if ($cursor !== null) { + $addCursorConditions = function (self $builder, $prev, $i) use (&$addCursorConditions, $orders, $cursor) { + + if ($prev !== null) { + $builder->where($prev, '=', $cursor->parameter($prev)); + } + + $builder->where(function (self $builder) use ($orders, $cursor, $i, $addCursorConditions) { + ['column' => $column, 'direction' => $direction] = $orders[$i]; + $operator = $direction === 'asc' ? '>' : '<'; + + $builder->where($column, $operator, $cursor->parameter($column)); + + if ($i < $orders->count() - 1) { + $builder->orWhere(function (self $builder) use ($addCursorConditions, $column, $i) { + $addCursorConditions($builder, $column, $i + 1); + }); + } + }); + }; + $addCursorConditions($this, null, 0); + } + + $this->limit($perPage + 1); + + return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [ + 'path' => Paginator::resolveCurrentPath(), + 'cursorName' => $cursorName, + 'parameters' => $orders->pluck('column')->toArray(), + ]); + } + /** * Create a new length-aware paginator instance. * diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 70de771f31c5..a4fa1dbd918a 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -12,8 +12,6 @@ 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; @@ -828,37 +826,12 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p * @param string $cursorName * @param string|null $cursor * @return \Illuminate\Contracts\Pagination\CursorPaginator - * @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, - ]); + return $this->runCursorPaginate($perPage, $columns, $cursorName, $cursor); } /** @@ -866,18 +839,12 @@ public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = * * @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.'); - } + $orders = collect($this->query->orders); - if ($orderDirections->count() === 0) { + if ($orders->count() === 0) { $this->enforceOrderBy(); } diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index 0ef10ac1c28b..a22203d093a2 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -12,8 +12,6 @@ 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; @@ -2407,36 +2405,11 @@ public function simplePaginate($perPage = 15, $columns = ['*'], $pageName = 'pag * @param array $columns * @param string $cursorName * @param string|null $cursor - * @return \Illuminate\Contracts\Pagination\Paginator - * @throws \Illuminate\Pagination\CursorPaginationException + * @return \Illuminate\Contracts\Pagination\CursorPaginator */ 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, - ]); + return $this->runCursorPaginate($perPage, $columns, $cursorName, $cursor); } /** @@ -2444,18 +2417,11 @@ public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'c * * @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'; diff --git a/src/Illuminate/Pagination/CursorPaginationException.php b/src/Illuminate/Pagination/CursorPaginationException.php index 710401751a56..b12ca607f185 100644 --- a/src/Illuminate/Pagination/CursorPaginationException.php +++ b/src/Illuminate/Pagination/CursorPaginationException.php @@ -4,6 +4,9 @@ use RuntimeException; +/** + * @deprecated Will be removed in a future Laravel version. + */ class CursorPaginationException extends RuntimeException { // diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 7e5adbad4cd3..3ff375f201f0 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -3619,13 +3619,24 @@ public function testCursorPaginate() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['test' => 'bar']); - $builder = $this->getMockQueryBuilder()->orderBy('test'); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + $path = 'http://foo.bar?cursor='.$cursor->encode(); $results = collect([['test' => 'foo'], ['test' => 'bar']]); - $builder->shouldReceive('where')->with('test', '>', 'bar')->once()->andReturnSelf(); - $builder->shouldReceive('get')->once()->andReturn($results); + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ?) order by "test" asc limit 17', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); Paginator::currentPathResolver(function () use ($path) { return $path; @@ -3646,13 +3657,25 @@ public function testCursorPaginateMultipleOrderColumns() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['test' => 'bar', 'another' => 'foo']); - $builder = $this->getMockQueryBuilder()->orderBy('test')->orderBy('another'); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test')->orderBy('another'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + $path = 'http://foo.bar?cursor='.$cursor->encode(); $results = collect([['test' => 'foo'], ['test' => 'bar']]); - $builder->shouldReceive('whereRowValues')->with(['test', 'another'], '>', ['bar', 'foo'])->once()->andReturnSelf(); - $builder->shouldReceive('get')->once()->andReturn($results); + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ? or ("test" = ? and ("another" > ?))) order by "test" asc, "another" asc limit 17', + $builder->toSql() + ); + $this->assertEquals(['bar', 'bar', 'foo'], $builder->bindings['where']); + + return $results; + }); Paginator::currentPathResolver(function () use ($path) { return $path; @@ -3672,12 +3695,24 @@ public function testCursorPaginateWithDefaultArguments() $perPage = 15; $cursorName = 'cursor'; $cursor = new Cursor(['test' => 'bar']); - $builder = $this->getMockQueryBuilder()->orderBy('test'); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('test'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + $path = 'http://foo.bar?cursor='.$cursor->encode(); $results = collect([['test' => 'foo'], ['test' => 'bar']]); - $builder->shouldReceive('get')->once()->andReturn($results); + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("test" > ?) order by "test" asc limit 16', + $builder->toSql()); + $this->assertEquals(['bar'], $builder->bindings['where']); + + return $results; + }); CursorPaginator::currentCursorResolver(function () use ($cursor) { return $cursor; @@ -3730,12 +3765,24 @@ public function testCursorPaginateWithSpecificColumns() $columns = ['id', 'name']; $cursorName = 'cursor-name'; $cursor = new Cursor(['id' => 2]); - $builder = $this->getMockQueryBuilder()->orderBy('id'); + $builder = $this->getMockQueryBuilder(); + $builder->from('foobar')->orderBy('id'); + $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { + return new Builder($builder->connection, $builder->grammar, $builder->processor); + }); + $path = 'http://foo.bar?cursor=3'; $results = collect([['id' => 3, 'name' => 'Taylor'], ['id' => 5, 'name' => 'Mohamed']]); - $builder->shouldReceive('get')->once()->andReturn($results); + $builder->shouldReceive('get')->once()->andReturnUsing(function () use ($builder, $results) { + $this->assertEquals( + 'select * from "foobar" where ("id" > ?) order by "id" asc limit 17', + $builder->toSql()); + $this->assertEquals([2], $builder->bindings['where']); + + return $results; + }); Paginator::currentPathResolver(function () use ($path) { return $path; @@ -4142,7 +4189,7 @@ protected function getMySqlBuilderWithProcessor() } /** - * @return m\MockInterface + * @return m\MockInterface|Builder */ protected function getMockQueryBuilder() {