From c243702cb683863e0d1ed5c0dd1c86ac8a9d2744 Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Sun, 2 May 2021 17:18:03 +0530 Subject: [PATCH 01/14] Add cursor pagination without tests --- .../Contracts/Pagination/CursorPaginator.php | 117 ++++ .../Database/Concerns/BuildsQueries.php | 17 + src/Illuminate/Database/Eloquent/Builder.php | 70 ++ .../Pagination/AbstractCursorPaginator.php | 597 ++++++++++++++++++ .../Pagination/AbstractPaginator.php | 15 + src/Illuminate/Pagination/Cursor.php | 154 +++++ src/Illuminate/Pagination/CursorPaginator.php | 160 +++++ src/Illuminate/Pagination/PaginationState.php | 6 + tests/Pagination/CursorTest.php | 40 ++ 9 files changed, 1176 insertions(+) create mode 100644 src/Illuminate/Contracts/Pagination/CursorPaginator.php create mode 100644 src/Illuminate/Pagination/AbstractCursorPaginator.php create mode 100644 src/Illuminate/Pagination/Cursor.php create mode 100644 src/Illuminate/Pagination/CursorPaginator.php create mode 100644 tests/Pagination/CursorTest.php diff --git a/src/Illuminate/Contracts/Pagination/CursorPaginator.php b/src/Illuminate/Contracts/Pagination/CursorPaginator.php new file mode 100644 index 000000000000..a45a9e1c3855 --- /dev/null +++ b/src/Illuminate/Contracts/Pagination/CursorPaginator.php @@ -0,0 +1,117 @@ +makeWith(CursorPaginator::class, compact( + 'items', 'perPage', 'cursor', 'options' + )); + } } diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index a6db9049ef36..ae11d36ca09c 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\RecordsNotFoundException; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -812,6 +813,75 @@ 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 \Exception + */ + 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->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)); + } else if (count($parameters) > 1 && ! is_null($cursor)) { + $this->whereRowValues($parameters, $comparisonOperator, $cursor->getParams($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 \Exception + */ + protected function ensureOrderForCursorPagination($shouldReverse = false) + { + $orderDirections = collect($this->query->orders)->pluck('direction')->unique(); + + if ($orderDirections->count() > 1) { + throw new Exception('Only a single order by direction is supported in 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. * diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php new file mode 100644 index 000000000000..471e840f76ce --- /dev/null +++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php @@ -0,0 +1,597 @@ +cursorName => $cursor->encode()]; + + if (count($this->query) > 0) { + $parameters = array_merge($this->query, $parameters); + } + + return $this->path() + .(Str::contains($this->path(), '?') ? '&' : '?') + .Arr::query($parameters) + .$this->buildFragment(); + } + + /** + * Get the URL for the previous page. + * + * @return string|null + */ + public function previousPageUrl() + { + if (is_null($previousCursor = $this->previousCursor())) { + return null; + } + + return $this->url($previousCursor); + } + + /** + * The URL for the next page, or null. + * + * @return string|null + */ + public function nextPageUrl() + { + if (is_null($nextCursor = $this->nextCursor())) { + return null; + } + + return $this->url($nextCursor); + } + + /** + * Get the "cursor" of the previous set of items. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function previousCursor() + { + if (is_null($this->cursor) || + ($this->cursor->isPrev() && ! $this->hasMore) ) { + return null; + } + + return $this->getCursorForItem($this->items->first(), false); + } + + /** + * Get the "cursor" of the next set of items. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function nextCursor() + { + if ((is_null($this->cursor) && ! $this->hasMore) || + (! is_null($this->cursor) && $this->cursor->isNext() && ! $this->hasMore) ) { + return null; + } + + return $this->getCursorForItem($this->items->last(), true); + } + + /** + * @param \ArrayAccess $item + * @param bool $isNext + * @return \Illuminate\Pagination\Cursor + */ + public function getCursorForItem($item, $isNext = true) + { + return new Cursor($this->getParametersForItem($item), $isNext); + } + + /** + * @param \ArrayAccess $item + */ + public function getParametersForItem($item) + { + return collect($this->parameters) + ->flip() + ->map(function ($_, $parameterName) use ($item) { + return $item[$parameterName] ?? $item[Str::afterLast($parameterName, '.')]; + })->toArray(); + } + + /** + * Get / set the URL fragment to be appended to URLs. + * + * @param string|null $fragment + * @return $this|string|null + */ + public function fragment($fragment = null) + { + if (is_null($fragment)) { + return $this->fragment; + } + + $this->fragment = $fragment; + + return $this; + } + + /** + * 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) + { + if (is_null($key)) { + return $this; + } + + if (is_array($key)) { + return $this->appendArray($key); + } + + return $this->addQuery($key, $value); + } + + /** + * Add an array of query string values. + * + * @param array $keys + * @return $this + */ + protected function appendArray(array $keys) + { + foreach ($keys as $key => $value) { + $this->addQuery($key, $value); + } + + return $this; + } + + /** + * Add all current query string values to the paginator. + * + * @return $this + */ + public function withQueryString() + { + if (! is_null($query = Paginator::resolveQueryString())) { + return $this->appends($query); + } + + return $this; + } + + /** + * Add a query string value to the paginator. + * + * @param string $key + * @param string $value + * @return $this + */ + protected function addQuery($key, $value) + { + if ($key !== $this->cursorName) { + $this->query[$key] = $value; + } + + return $this; + } + + /** + * Build the full fragment portion of a URL. + * + * @return string + */ + protected function buildFragment() + { + return $this->fragment ? '#'.$this->fragment : ''; + } + + /** + * Load a set of relationships onto the mixed relationship collection. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorph($relation, $relations) + { + $this->getCollection()->loadMorph($relation, $relations); + + return $this; + } + + /** + * Load a set of relationship counts onto the mixed relationship collection. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorphCount($relation, $relations) + { + $this->getCollection()->loadMorphCount($relation, $relations); + + return $this; + } + + /** + * Get the slice of items being paginated. + * + * @return array + */ + public function items() + { + return $this->items->all(); + } + + /** + * Transform each item in the slice of items using a callback. + * + * @param callable $callback + * @return $this + */ + public function through(callable $callback) + { + $this->items->transform($callback); + + return $this; + } + + /** + * Get the number of items shown per page. + * + * @return int + */ + public function perPage() + { + return $this->perPage; + } + + /** + * Get the current cursor being paginated. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function cursor() + { + return $this->cursor; + } + + /** + * Get the query string variable used to store the cursor. + * + * @return string + */ + public function getCursorName() + { + return $this->cursorName; + } + + /** + * Set the query string variable used to store the cursor. + * + * @param string $name + * @return $this + */ + public function setCursorName($name) + { + $this->cursorName = $name; + + return $this; + } + + /** + * Set the base path to assign to all URLs. + * + * @param string $path + * @return $this + */ + public function withPath($path) + { + return $this->setPath($path); + } + + /** + * Set the base path to assign to all URLs. + * + * @param string $path + * @return $this + */ + public function setPath($path) + { + $this->path = $path; + + return $this; + } + + /** + * Get the base path for paginator generated URLs. + * + * @return string|null + */ + public function path() + { + return $this->path; + } + + /** + * Resolve the current cursor or return the default value. + * + * @param string $cursorName + * @return \Illuminate\Pagination\Cursor|null + */ + public static function resolveCurrentCursor($cursorName = 'cursor', $default = null) + { + if (isset(static::$currentCursorResolver)) { + return call_user_func(static::$currentCursorResolver, $cursorName); + } + + return $default; + } + + /** + * Set the current cursor resolver callback. + * + * @param \Closure $resolver + * @return void + */ + public static function currentCursorResolver(Closure $resolver) + { + static::$currentCursorResolver = $resolver; + } + + /** + * Get an instance of the view factory from the resolver. + * + * @return \Illuminate\Contracts\View\Factory + */ + public static function viewFactory() + { + return Paginator::viewFactory(); + } + + /** + * Get an iterator for the items. + * + * @return \ArrayIterator + */ + public function getIterator() + { + return $this->items->getIterator(); + } + + /** + * Determine if the list of items is empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->items->isEmpty(); + } + + /** + * Determine if the list of items is not empty. + * + * @return bool + */ + public function isNotEmpty() + { + return $this->items->isNotEmpty(); + } + + /** + * Get the number of items for the current page. + * + * @return int + */ + public function count() + { + return $this->items->count(); + } + + /** + * Get the paginator's underlying collection. + * + * @return \Illuminate\Support\Collection + */ + public function getCollection() + { + return $this->items; + } + + /** + * Set the paginator's underlying collection. + * + * @param \Illuminate\Support\Collection $collection + * @return $this + */ + public function setCollection(Collection $collection) + { + $this->items = $collection; + + return $this; + } + + /** + * Get the paginator options. + * + * @return array + */ + public function getOptions() + { + return $this->options; + } + + /** + * Determine if the given item exists. + * + * @param mixed $key + * @return bool + */ + public function offsetExists($key) + { + return $this->items->has($key); + } + + /** + * Get the item at the given offset. + * + * @param mixed $key + * @return mixed + */ + public function offsetGet($key) + { + return $this->items->get($key); + } + + /** + * Set the item at the given offset. + * + * @param mixed $key + * @param mixed $value + * @return void + */ + public function offsetSet($key, $value) + { + $this->items->put($key, $value); + } + + /** + * Unset the item at the given key. + * + * @param mixed $key + * @return void + */ + public function offsetUnset($key) + { + $this->items->forget($key); + } + + /** + * Render the contents of the paginator to HTML. + * + * @return string + */ + public function toHtml() + { + return (string) $this->render(); + } + + /** + * Make dynamic calls into the collection. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->forwardCallTo($this->getCollection(), $method, $parameters); + } + + /** + * Render the contents of the paginator when casting to a string. + * + * @return string + */ + public function __toString() + { + return (string) $this->render(); + } +} diff --git a/src/Illuminate/Pagination/AbstractPaginator.php b/src/Illuminate/Pagination/AbstractPaginator.php index 763091067057..42808bac3199 100644 --- a/src/Illuminate/Pagination/AbstractPaginator.php +++ b/src/Illuminate/Pagination/AbstractPaginator.php @@ -458,6 +458,21 @@ public function path() return $this->path; } + /** + * Resolve the query string or return the default value. + * + * @param string|array|null $default + * @return string + */ + public static function resolveQueryString($default = null) + { + if (isset(static::$queryStringResolver)) { + return call_user_func(static::$queryStringResolver); + } + + return $default; + } + /** * Resolve the current request path or return the default value. * diff --git a/src/Illuminate/Pagination/Cursor.php b/src/Illuminate/Pagination/Cursor.php new file mode 100644 index 000000000000..309ea334600c --- /dev/null +++ b/src/Illuminate/Pagination/Cursor.php @@ -0,0 +1,154 @@ +parameters = $parameters; + $this->isNext = $isNext; + } + + /** + * Get the given parameter from the cursor. + * + * @param string $parameterName + * @return string|null + */ + public function getParam(string $parameterName) + { + if (! isset($this->parameters[$parameterName])) { + throw new UnexpectedValueException("Unable to find parameter [{$parameterName}] in pagination item."); + } + + return $this->parameters[$parameterName]; + } + + /** + * Get the given parameters from the cursor. + * + * @param array $parameterNames + * @return array + */ + public function getParams(array $parameterNames) + { + return collect($parameterNames)->map(function ($parameterName) { + return $this->getParam($parameterName); + })->toArray(); + } + + /** + * Determine whether the cursor points to the next set of items. + * + * @return bool + */ + public function isNext() + { + return $this->isNext; + } + + /** + * Determine whether the cursor points to the next set of items. + * + * @return bool + */ + public function isPrev() + { + return ! $this->isNext; + } + + /** + * Set the cursor to point to the next set of items. + * + * @return $this + */ + public function setNext() + { + $this->isNext = true; + + return $this; + } + + /** + * Set the cursor to point to the previous set of items. + * + * @return $this + */ + public function setPrev() + { + $this->isNext = false; + + return $this; + } + + /** + * Get the array representation of the cursor. + * + * @return array + */ + public function toArray() + { + return array_merge($this->parameters, [ + '_isNext' => $this->isNext, + ]); + } + + /** + * Get the encoded string representation of the cursor to construct a URL. + * + * @return string + */ + public function encode() + { + return str_replace(['+','/','='], ['-','_',''], base64_encode(json_encode($this->toArray()))); + } + + /** + * Get a cursor instance from the encoded string representation. + * + * @param string|null $encodedString + * @return static|null + */ + public static function fromEncoded($encodedString) + { + if (is_null($encodedString) || ! is_string($encodedString)) { + return null; + } + + $parameters = json_decode(base64_decode(str_replace(['-','_'], ['+','/'], $encodedString)), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + $isNext = $parameters['_isNext']; + + unset($parameters['_isNext']); + + return new static($parameters, $isNext); + } +} diff --git a/src/Illuminate/Pagination/CursorPaginator.php b/src/Illuminate/Pagination/CursorPaginator.php new file mode 100644 index 000000000000..37c20a978884 --- /dev/null +++ b/src/Illuminate/Pagination/CursorPaginator.php @@ -0,0 +1,160 @@ +options = $options; + + foreach ($options as $key => $value) { + $this->{$key} = $value; + } + + $this->perPage = $perPage; + $this->cursor = $cursor; + $this->path = $this->path !== '/' ? rtrim($this->path, '/') : $this->path; + + $this->setItems($items); + } + + /** + * Set the items for the paginator. + * + * @param mixed $items + * @return void + */ + protected function setItems($items) + { + $this->items = $items instanceof Collection ? $items : Collection::make($items); + + $this->hasMore = $this->items->count() > $this->perPage; + + $this->items = $this->items->slice(0, $this->perPage); + + if (! is_null($this->cursor) && $this->cursor->isPrev()) { + $this->items = $this->items->reverse(); + } + } + + /** + * Render the paginator using the given view. + * + * @param string|null $view + * @param array $data + * @return \Illuminate\Contracts\Support\Htmlable + */ + public function links($view = null, $data = []) + { + return $this->render($view, $data); + } + + /** + * Render the paginator using the given view. + * + * @param string|null $view + * @param array $data + * @return \Illuminate\Contracts\Support\Htmlable + */ + public function render($view = null, $data = []) + { + return static::viewFactory()->make($view ?: Paginator::$defaultSimpleView, array_merge($data, [ + 'paginator' => $this, + ])); + } + + /** + * Determine if there are more items in the data source. + * + * @return bool + */ + public function hasMorePages() + { + return (is_null($this->cursor) && $this->hasMore) || + (! is_null($this->cursor) && $this->cursor->isNext() && $this->hasMore) || + (! is_null($this->cursor) && $this->cursor->isPrev()); + } + + /** + * Determine if there are enough items to split into multiple pages. + * + * @return bool + */ + public function hasPages() + { + return ! $this->onFirstPage() || $this->hasMorePages(); + } + + /** + * Determine if the paginator is on the first page. + * + * @return bool + */ + public function onFirstPage() + { + return is_null($this->cursor) || ($this->cursor->isPrev() && ! $this->hasMore); + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray() + { + return [ + 'data' => $this->items->toArray(), + 'path' => $this->path(), + 'per_page' => $this->perPage(), + 'next_page_url' => $this->nextPageUrl(), + 'prev_page_url' => $this->previousPageUrl(), + ]; + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * Convert the object to its JSON representation. + * + * @param int $options + * @return string + */ + public function toJson($options = 0) + { + return json_encode($this->jsonSerialize(), $options); + } +} diff --git a/src/Illuminate/Pagination/PaginationState.php b/src/Illuminate/Pagination/PaginationState.php index f71ea13bde94..a6c47f622f11 100644 --- a/src/Illuminate/Pagination/PaginationState.php +++ b/src/Illuminate/Pagination/PaginationState.php @@ -33,5 +33,11 @@ public static function resolveUsing($app) Paginator::queryStringResolver(function () use ($app) { return $app['request']->query(); }); + + CursorPaginator::currentCursorResolver(function ($cursorName = 'cursor') use ($app) { + $encodedCursor = $app['request']->input($cursorName); + + return Cursor::fromEncoded($encodedCursor); + }); } } diff --git a/tests/Pagination/CursorTest.php b/tests/Pagination/CursorTest.php new file mode 100644 index 000000000000..fcf23eed9a84 --- /dev/null +++ b/tests/Pagination/CursorTest.php @@ -0,0 +1,40 @@ + 422, + 'created_at' => Carbon::now()->toDateTimeString(), + ], true); + + $this->assertEquals($cursor, Cursor::fromEncoded($cursor->encode())); + } + + public function testCanGetParams() + { + $cursor = new Cursor([ + 'id' => 422, + 'created_at' => ($now = Carbon::now()->toDateTimeString()), + ], true); + + $this->assertEquals([$now, 422], $cursor->getParams(['created_at', 'id'])); + } + + public function testCanGetParam() + { + $cursor = new Cursor([ + 'id' => 422, + 'created_at' => ($now = Carbon::now()->toDateTimeString()), + ], true); + + $this->assertEquals($now, $cursor->getParam('created_at')); + } +} From 361b1eb5a01231951d07298219fc0413463621aa Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Sun, 2 May 2021 18:11:51 +0530 Subject: [PATCH 02/14] Fix styleci --- src/Illuminate/Database/Eloquent/Builder.php | 4 ++-- src/Illuminate/Pagination/AbstractCursorPaginator.php | 4 ++-- src/Illuminate/Pagination/Cursor.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index ae11d36ca09c..9e1caa8c97ec 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -839,7 +839,7 @@ public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = if (count($parameters) === 1 && ! is_null($cursor)) { $this->where($column = $parameters[0], $comparisonOperator, $cursor->getParam($column)); - } else if (count($parameters) > 1 && ! is_null($cursor)) { + } elseif (count($parameters) > 1 && ! is_null($cursor)) { $this->whereRowValues($parameters, $comparisonOperator, $cursor->getParams($parameters)); } @@ -872,7 +872,7 @@ protected function ensureOrderForCursorPagination($shouldReverse = false) } if ($shouldReverse) { - $this->query->orders = collect($this->query->orders)->map(function($order) { + $this->query->orders = collect($this->query->orders)->map(function ($order) { $order['direction'] = ($order['direction'] === 'asc' ? 'desc' : 'asc'); return $order; diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php index 471e840f76ce..915a9e90aee4 100644 --- a/src/Illuminate/Pagination/AbstractCursorPaginator.php +++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php @@ -145,7 +145,7 @@ public function nextPageUrl() public function previousCursor() { if (is_null($this->cursor) || - ($this->cursor->isPrev() && ! $this->hasMore) ) { + ($this->cursor->isPrev() && ! $this->hasMore)) { return null; } @@ -160,7 +160,7 @@ public function previousCursor() public function nextCursor() { if ((is_null($this->cursor) && ! $this->hasMore) || - (! is_null($this->cursor) && $this->cursor->isNext() && ! $this->hasMore) ) { + (! is_null($this->cursor) && $this->cursor->isNext() && ! $this->hasMore)) { return null; } diff --git a/src/Illuminate/Pagination/Cursor.php b/src/Illuminate/Pagination/Cursor.php index 309ea334600c..b5ea61cfe708 100644 --- a/src/Illuminate/Pagination/Cursor.php +++ b/src/Illuminate/Pagination/Cursor.php @@ -124,7 +124,7 @@ public function toArray() */ public function encode() { - return str_replace(['+','/','='], ['-','_',''], base64_encode(json_encode($this->toArray()))); + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(json_encode($this->toArray()))); } /** @@ -139,7 +139,7 @@ public static function fromEncoded($encodedString) return null; } - $parameters = json_decode(base64_decode(str_replace(['-','_'], ['+','/'], $encodedString)), true); + $parameters = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $encodedString)), true); if (json_last_error() !== JSON_ERROR_NONE) { return null; From a81377f501c05bb21bf0965b959ae35fd0ac903e Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Sun, 2 May 2021 18:51:53 +0530 Subject: [PATCH 03/14] Add cursor paginator tests --- .../CursorPaginatorLoadMorphCountTest.php | 28 +++++++ .../CursorPaginatorLoadMorphTest.php | 28 +++++++ tests/Pagination/CursorPaginatorTest.php | 83 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 tests/Pagination/CursorPaginatorLoadMorphCountTest.php create mode 100644 tests/Pagination/CursorPaginatorLoadMorphTest.php create mode 100644 tests/Pagination/CursorPaginatorTest.php diff --git a/tests/Pagination/CursorPaginatorLoadMorphCountTest.php b/tests/Pagination/CursorPaginatorLoadMorphCountTest.php new file mode 100644 index 000000000000..be019f8175aa --- /dev/null +++ b/tests/Pagination/CursorPaginatorLoadMorphCountTest.php @@ -0,0 +1,28 @@ + 'photos', + 'App\\Company' => ['employees', 'calendars'], + ]; + + $items = m::mock(Collection::class); + $items->shouldReceive('loadMorphCount')->once()->with('parentable', $relations); + + $p = (new class extends AbstractCursorPaginator { + // + })->setCollection($items); + + $this->assertSame($p, $p->loadMorphCount('parentable', $relations)); + } +} diff --git a/tests/Pagination/CursorPaginatorLoadMorphTest.php b/tests/Pagination/CursorPaginatorLoadMorphTest.php new file mode 100644 index 000000000000..69275698c439 --- /dev/null +++ b/tests/Pagination/CursorPaginatorLoadMorphTest.php @@ -0,0 +1,28 @@ + 'photos', + 'App\\Company' => ['employees', 'calendars'], + ]; + + $items = m::mock(Collection::class); + $items->shouldReceive('loadMorph')->once()->with('parentable', $relations); + + $p = (new class extends AbstractCursorPaginator { + // + })->setCollection($items); + + $this->assertSame($p, $p->loadMorph('parentable', $relations)); + } +} diff --git a/tests/Pagination/CursorPaginatorTest.php b/tests/Pagination/CursorPaginatorTest.php new file mode 100644 index 000000000000..ad2def176f0f --- /dev/null +++ b/tests/Pagination/CursorPaginatorTest.php @@ -0,0 +1,83 @@ + 1], ['id' => 2], ['id' => 3]], 2, null, [ + 'parameters' => ['id'], + ]); + + $this->assertTrue($p->hasPages()); + $this->assertTrue($p->hasMorePages()); + $this->assertEquals([['id' => 1], ['id' => 2]], $p->items()); + + $pageInfo = [ + 'data' => [['id' => 1], ['id' => 2]], + 'path' => '/', + 'per_page' => 2, + 'next_page_url' => '/?cursor='.$this->getCursor(['id' => 2]), + 'prev_page_url' => null, + ]; + + $this->assertEquals($pageInfo, $p->toArray()); + } + + public function testPaginatorRemovesTrailingSlashes() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + ['path' => 'http://website.com/test/', 'parameters' => ['id']]); + + $this->assertSame('http://website.com/test?cursor='.$this->getCursor(['id' => 5]), $p->nextPageUrl()); + } + + public function testPaginatorGeneratesUrlsWithoutTrailingSlash() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + ['path' => 'http://website.com/test', 'parameters' => ['id']]); + + $this->assertSame('http://website.com/test?cursor='.$this->getCursor(['id' => 5]), $p->nextPageUrl()); + } + + public function testItRetrievesThePaginatorOptions() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + $options = ['path' => 'http://website.com/test', 'parameters' => ['id']]); + + $this->assertSame($p->getOptions(), $options); + } + + public function testPaginatorReturnsPath() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + $options = ['path' => 'http://website.com/test', 'parameters' => ['id']]); + + $this->assertSame($p->path(), 'http://website.com/test'); + } + + public function testCanTransformPaginatorItems() + { + $p = new CursorPaginator($array = [['id' => 4], ['id' => 5], ['id' => 6]], 2, null, + $options = ['path' => 'http://website.com/test', 'parameters' => ['id']]); + + $p->through(function ($item) { + $item['id'] = $item['id'] + 2; + + return $item; + }); + + $this->assertInstanceOf(CursorPaginator::class, $p); + $this->assertSame([['id' => 6], ['id' => 7]], $p->items()); + } + + protected function getCursor($params, $isNext = true) + { + return (new Cursor($params, $isNext))->encode(); + } +} From fe228664cf02a5c2ff74172dd80ebf316002b32e Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Sun, 2 May 2021 19:26:51 +0530 Subject: [PATCH 04/14] Add support for query builder --- src/Illuminate/Database/Query/Builder.php | 69 +++++++++++++ .../Pagination/AbstractCursorPaginator.php | 16 ++- .../Database/EloquentCursorPaginateTest.php | 99 +++++++++++++++++++ .../Database/EloquentPaginateTest.php | 3 +- 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 tests/Integration/Database/EloquentCursorPaginateTest.php diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index 63f8276946aa..8eb66e94875d 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -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. * diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php index 915a9e90aee4..bbeb9ec0dc67 100644 --- a/src/Illuminate/Pagination/AbstractCursorPaginator.php +++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php @@ -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(); } diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php new file mode 100644 index 000000000000..53136075a8f5 --- /dev/null +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -0,0 +1,99 @@ +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 = []; +} diff --git a/tests/Integration/Database/EloquentPaginateTest.php b/tests/Integration/Database/EloquentPaginateTest.php index 91409cd1cced..2aeb0c2815fe 100644 --- a/tests/Integration/Database/EloquentPaginateTest.php +++ b/tests/Integration/Database/EloquentPaginateTest.php @@ -1,11 +1,10 @@ Date: Sun, 2 May 2021 19:34:29 +0530 Subject: [PATCH 05/14] Fix tests --- .../Pagination/AbstractCursorPaginator.php | 4 +-- .../Database/EloquentCursorPaginateTest.php | 32 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php index bbeb9ec0dc67..796a30425b39 100644 --- a/src/Illuminate/Pagination/AbstractCursorPaginator.php +++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php @@ -189,9 +189,9 @@ public function getParametersForItem($item) return collect($this->parameters) ->flip() ->map(function ($_, $parameterName) use ($item) { - if ($item instanceof ArrayAccess) { + if ($item instanceof ArrayAccess || is_array($item)) { return $item[$parameterName] ?? $item[Str::afterLast($parameterName, '.')]; - } else if ($item instanceof stdClass) { + } elseif ($item instanceof stdClass) { return $item->{$parameterName} ?? $item->{Str::afterLast($parameterName, '.')}; } diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index 53136075a8f5..ea15da4a39e1 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -15,14 +15,14 @@ protected function setUp(): void { parent::setUp(); - Schema::create('posts', function (Blueprint $table) { + Schema::create('test_posts', function (Blueprint $table) { $table->increments('id'); $table->string('title')->nullable(); $table->unsignedInteger('user_id')->nullable(); $table->timestamps(); }); - Schema::create('users', function ($table) { + Schema::create('test_users', function ($table) { $table->increments('id'); $table->timestamps(); }); @@ -31,22 +31,22 @@ protected function setUp(): void public function testCursorPaginationOnTopOfColumns() { for ($i = 1; $i <= 50; $i++) { - Post::create([ + TestPost::create([ 'title' => 'Title '.$i, ]); } - $this->assertCount(15, Post::cursorPaginate(15, ['id', 'title'])); + $this->assertCount(15, TestPost::cursorPaginate(15, ['id', 'title'])); } public function testPaginationWithDistinct() { for ($i = 1; $i <= 3; $i++) { - Post::create(['title' => 'Hello world']); - Post::create(['title' => 'Goodbye world']); + TestPost::create(['title' => 'Hello world']); + TestPost::create(['title' => 'Goodbye world']); } - $query = Post::query()->distinct(); + $query = TestPost::query()->distinct(); $this->assertEquals(6, $query->get()->count()); $this->assertEquals(6, $query->count()); @@ -56,11 +56,11 @@ public function testPaginationWithDistinct() public function testPaginationWithDistinctColumnsAndSelect() { for ($i = 1; $i <= 3; $i++) { - Post::create(['title' => 'Hello world']); - Post::create(['title' => 'Goodbye world']); + TestPost::create(['title' => 'Hello world']); + TestPost::create(['title' => 'Goodbye world']); } - $query = Post::query()->distinct('title')->select('title'); + $query = TestPost::query()->distinct('title')->select('title'); $this->assertEquals(2, $query->get()->count()); $this->assertEquals(2, $query->count()); @@ -70,17 +70,17 @@ public function testPaginationWithDistinctColumnsAndSelect() public function testPaginationWithDistinctColumnsAndSelectAndJoin() { for ($i = 1; $i <= 5; $i++) { - $user = User::create(); + $user = TestUser::create(); for ($j = 1; $j <= 10; $j++) { - Post::create([ + TestPost::create([ 'title' => 'Title '.$i, 'user_id' => $user->id, ]); } } - $query = User::query()->join('posts', 'posts.user_id', '=', 'users.id') - ->distinct('users.id')->select('users.*'); + $query = TestUser::query()->join('test_posts', 'test_posts.user_id', '=', 'test_users.id') + ->distinct('test_users.id')->select('test_users.*'); $this->assertEquals(5, $query->get()->count()); $this->assertEquals(5, $query->count()); @@ -88,12 +88,12 @@ public function testPaginationWithDistinctColumnsAndSelectAndJoin() } } -class Post extends Model +class TestPost extends Model { protected $guarded = []; } -class User extends Model +class TestUser extends Model { protected $guarded = []; } From 3dc3f9c0ef203d27709e8f1849c5aad48c137d16 Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Sun, 2 May 2021 21:12:29 +0530 Subject: [PATCH 06/14] Complete all tests for database and Eloquent builders --- src/Illuminate/Pagination/CursorPaginator.php | 2 +- .../DatabaseEloquentIntegrationTest.php | 82 +++++++++++ ...baseEloquentSoftDeletesIntegrationTest.php | 8 + tests/Database/DatabaseQueryBuilderTest.php | 139 ++++++++++++++++++ 4 files changed, 230 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Pagination/CursorPaginator.php b/src/Illuminate/Pagination/CursorPaginator.php index 37c20a978884..65dc559f3030 100644 --- a/src/Illuminate/Pagination/CursorPaginator.php +++ b/src/Illuminate/Pagination/CursorPaginator.php @@ -59,7 +59,7 @@ protected function setItems($items) $this->items = $this->items->slice(0, $this->perPage); if (! is_null($this->cursor) && $this->cursor->isPrev()) { - $this->items = $this->items->reverse(); + $this->items = $this->items->reverse()->values(); } } diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 4c8f77398733..9ab08aadaecb 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -17,6 +17,8 @@ use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\QueryException; use Illuminate\Pagination\AbstractPaginator as Paginator; +use Illuminate\Pagination\Cursor; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; @@ -319,6 +321,86 @@ public function testCountForPaginationWithGroupingAndSubSelects() $this->assertEquals(4, $query->getCountForPagination()); } + public function testCursorPaginatedModelCollectionRetrieval() + { + EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + EloquentTestUser::create($secondParams = ['id' => 2, 'email' => 'abigailotwell@gmail.com']); + EloquentTestUser::create(['id' => 3, 'email' => 'foo@gmail.com']); + + CursorPaginator::currentCursorResolver(function () { + return null; + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + $this->assertTrue($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + + CursorPaginator::currentCursorResolver(function () use ($secondParams) { + return new Cursor($secondParams); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(1, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertSame('foo@gmail.com', $models[0]->email); + $this->assertFalse($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + } + + public function testPreviousCursorPaginatedModelCollectionRetrieval() + { + EloquentTestUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + EloquentTestUser::create(['id' => 2, 'email' => 'abigailotwell@gmail.com']); + EloquentTestUser::create($thirdParams = ['id' => 3, 'email' => 'foo@gmail.com']); + + CursorPaginator::currentCursorResolver(function () use ($thirdParams) { + return new Cursor($thirdParams, false); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(EloquentTestUser::class, $models[0]); + $this->assertInstanceOf(EloquentTestUser::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + $this->assertTrue($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + } + + public function testCursorPaginatedModelCollectionRetrievalWhenNoElements() + { + CursorPaginator::currentCursorResolver(function () { + return null; + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(0, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + + Paginator::currentPageResolver(function () { + return new Cursor(['id' => 1]); + }); + $models = EloquentTestUser::oldest('id')->cursorPaginate(2); + + $this->assertCount(0, $models); + } + + public function testCursorPaginatedModelCollectionRetrievalWhenNoElementsAndDefaultPerPage() + { + $models = EloquentTestUser::oldest('id')->cursorPaginate(); + + $this->assertCount(0, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + } + public function testFirstOrCreate() { $user1 = EloquentTestUser::firstOrCreate(['email' => 'taylorotwell@gmail.com']); diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php index da1bf880859e..cc290c93a84d 100644 --- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php +++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Query\Builder; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Carbon; use PHPUnit\Framework\TestCase; @@ -136,12 +137,19 @@ public function testSoftDeletesAreNotRetrievedFromBuilderHelpers() return 1; }); + CursorPaginator::currentCursorResolver(function () { + return null; + }); + $query = SoftDeletesTestUser::query(); $this->assertCount(1, $query->paginate(2)->all()); $query = SoftDeletesTestUser::query(); $this->assertCount(1, $query->simplePaginate(2)->all()); + $query = SoftDeletesTestUser::query(); + $this->assertCount(1, $query->cursorPaginate(2)->all()); + $this->assertEquals(0, SoftDeletesTestUser::where('email', 'taylorotwell@gmail.com')->increment('id')); $this->assertEquals(0, SoftDeletesTestUser::where('email', 'taylorotwell@gmail.com')->decrement('id')); } diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 3ad164aae80f..5b882f9ef58a 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -16,6 +16,8 @@ use Illuminate\Database\Query\Processors\MySqlProcessor; use Illuminate\Database\Query\Processors\Processor; use Illuminate\Pagination\AbstractPaginator as Paginator; +use Illuminate\Pagination\Cursor; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use InvalidArgumentException; use Mockery as m; @@ -3484,6 +3486,143 @@ public function testPaginateWithSpecificColumns() ]), $result); } + public function testCursorPaginate() + { + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder()->orderBy('test'); + $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); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateMultipleOrderColumns() + { + $perPage = 16; + $columns = ['test']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['test' => 'bar', 'another' => 'foo']); + $builder = $this->getMockQueryBuilder()->orderBy('test')->orderBy('another'); + $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); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test', 'another'], + ]), $result); + } + + public function testCursorPaginateWithDefaultArguments() + { + $perPage = 15; + $cursorName = 'cursor'; + $cursor = new Cursor(['test' => 'bar']); + $builder = $this->getMockQueryBuilder()->orderBy('test'); + $path = 'http://foo.bar?cursor='.$cursor->encode(); + + $results = collect([['test' => 'foo'], ['test' => 'bar']]); + + $builder->shouldReceive('get')->once()->andReturn($results); + + CursorPaginator::currentCursorResolver(function () use ($cursor) { + return $cursor; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWhenNoResults() + { + $perPage = 15; + $cursorName = 'cursor'; + $builder = $this->getMockQueryBuilder()->orderBy('test'); + $path = 'http://foo.bar?cursor=3'; + + $results = []; + + $builder->shouldReceive('get')->once()->andReturn($results); + + CursorPaginator::currentCursorResolver(function () { + return null; + }); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate(); + + $this->assertEquals(new CursorPaginator($results, $perPage, null, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['test'], + ]), $result); + } + + public function testCursorPaginateWithSpecificColumns() + { + $perPage = 16; + $columns = ['id', 'name']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['id' => 2]); + $builder = $this->getMockQueryBuilder()->orderBy('id'); + $path = 'http://foo.bar?cursor=3'; + + $results = collect([['id' => 3, 'name' => 'Taylor'], ['id' => 5, 'name' => 'Mohamed']]); + + $builder->shouldReceive('get')->once()->andReturn($results); + + Paginator::currentPathResolver(function () use ($path) { + return $path; + }); + + $result = $builder->cursorPaginate($perPage, $columns, $cursorName, $cursor); + + $this->assertEquals(new CursorPaginator($results, $perPage, $cursor, [ + 'path' => $path, + 'cursorName' => $cursorName, + 'parameters' => ['id'], + ]), $result); + } + public function testWhereRowValues() { $builder = $this->getBuilder(); From 2accc89544a9b79b65fe0810076e650d6099f0d7 Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Mon, 3 May 2021 16:35:57 +0530 Subject: [PATCH 07/14] Incorporate suggestions --- src/Illuminate/Database/Eloquent/Builder.php | 13 ++++++++----- src/Illuminate/Database/Query/Builder.php | 13 ++++++++----- .../Pagination/AbstractCursorPaginator.php | 6 +++--- src/Illuminate/Pagination/AbstractPaginator.php | 2 +- .../Pagination/CursorPaginationException.php | 10 ++++++++++ 5 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 src/Illuminate/Pagination/CursorPaginationException.php diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 9e1caa8c97ec..5da11558578f 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -12,6 +12,7 @@ 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; @@ -837,10 +838,12 @@ public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = $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)); + if (! is_null($cursor)) { + if (count($parameters) === 1) { + $this->where($column = $parameters[0], $comparisonOperator, $cursor->getParam($column)); + } elseif (count($parameters) > 1) { + $this->whereRowValues($parameters, $comparisonOperator, $cursor->getParams($parameters)); + } } $this->take($perPage + 1); @@ -864,7 +867,7 @@ protected function ensureOrderForCursorPagination($shouldReverse = false) $orderDirections = collect($this->query->orders)->pluck('direction')->unique(); if ($orderDirections->count() > 1) { - throw new Exception('Only a single order by direction is supported in cursor pagination.'); + throw new CursorPaginationException('Only a single order by direction is supported in cursor pagination.'); } if ($orderDirections->count() === 0) { diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index 8eb66e94875d..adb2c8a420a7 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -13,6 +13,7 @@ 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; @@ -2386,10 +2387,12 @@ public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'c $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)); + if (! is_null($cursor)) { + if (count($parameters) === 1) { + $this->where($column = $parameters[0], $comparisonOperator, $cursor->getParam($column)); + } elseif (count($parameters) > 1) { + $this->whereRowValues($parameters, $comparisonOperator, $cursor->getParams($parameters)); + } } $this->limit($perPage + 1); @@ -2415,7 +2418,7 @@ protected function ensureOrderForCursorPagination($shouldReverse = false) $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.'); + throw new CursorPaginationException('Only a single order by direction is supported in cursor pagination.'); } if ($shouldReverse) { diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php index 796a30425b39..05c0e534daa6 100644 --- a/src/Illuminate/Pagination/AbstractCursorPaginator.php +++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php @@ -4,12 +4,12 @@ use ArrayAccess; use Closure; +use Exception; 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 @@ -191,11 +191,11 @@ public function getParametersForItem($item) ->map(function ($_, $parameterName) use ($item) { if ($item instanceof ArrayAccess || is_array($item)) { return $item[$parameterName] ?? $item[Str::afterLast($parameterName, '.')]; - } elseif ($item instanceof stdClass) { + } elseif (is_object($item)) { return $item->{$parameterName} ?? $item->{Str::afterLast($parameterName, '.')}; } - throw new \Exception('A cursor paginator item must either implement ArrayAccess or be an stdClass instance'); + throw new Exception('Only arrays and objects are supported for pagination items'); })->toArray(); } diff --git a/src/Illuminate/Pagination/AbstractPaginator.php b/src/Illuminate/Pagination/AbstractPaginator.php index 42808bac3199..3b13a2f16349 100644 --- a/src/Illuminate/Pagination/AbstractPaginator.php +++ b/src/Illuminate/Pagination/AbstractPaginator.php @@ -467,7 +467,7 @@ public function path() public static function resolveQueryString($default = null) { if (isset(static::$queryStringResolver)) { - return call_user_func(static::$queryStringResolver); + return (static::$queryStringResolver)(); } return $default; diff --git a/src/Illuminate/Pagination/CursorPaginationException.php b/src/Illuminate/Pagination/CursorPaginationException.php new file mode 100644 index 000000000000..756bac59087a --- /dev/null +++ b/src/Illuminate/Pagination/CursorPaginationException.php @@ -0,0 +1,10 @@ + Date: Mon, 3 May 2021 16:41:25 +0530 Subject: [PATCH 08/14] Fix styleci --- src/Illuminate/Pagination/CursorPaginationException.php | 2 +- tests/Pagination/CursorPaginatorLoadMorphCountTest.php | 3 ++- tests/Pagination/CursorPaginatorLoadMorphTest.php | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Pagination/CursorPaginationException.php b/src/Illuminate/Pagination/CursorPaginationException.php index 756bac59087a..710401751a56 100644 --- a/src/Illuminate/Pagination/CursorPaginationException.php +++ b/src/Illuminate/Pagination/CursorPaginationException.php @@ -6,5 +6,5 @@ class CursorPaginationException extends RuntimeException { - + // } diff --git a/tests/Pagination/CursorPaginatorLoadMorphCountTest.php b/tests/Pagination/CursorPaginatorLoadMorphCountTest.php index be019f8175aa..6e6722f1edd4 100644 --- a/tests/Pagination/CursorPaginatorLoadMorphCountTest.php +++ b/tests/Pagination/CursorPaginatorLoadMorphCountTest.php @@ -19,7 +19,8 @@ public function testCollectionLoadMorphCountCanChainOnThePaginator() $items = m::mock(Collection::class); $items->shouldReceive('loadMorphCount')->once()->with('parentable', $relations); - $p = (new class extends AbstractCursorPaginator { + $p = (new class extends AbstractCursorPaginator + { // })->setCollection($items); diff --git a/tests/Pagination/CursorPaginatorLoadMorphTest.php b/tests/Pagination/CursorPaginatorLoadMorphTest.php index 69275698c439..b127f21f2cd7 100644 --- a/tests/Pagination/CursorPaginatorLoadMorphTest.php +++ b/tests/Pagination/CursorPaginatorLoadMorphTest.php @@ -19,7 +19,8 @@ public function testCollectionLoadMorphCanChainOnThePaginator() $items = m::mock(Collection::class); $items->shouldReceive('loadMorph')->once()->with('parentable', $relations); - $p = (new class extends AbstractCursorPaginator { + $p = (new class extends AbstractCursorPaginator + { // })->setCollection($items); From 6611204f137280adacbe4d1a7b543c6e5f7d91b1 Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Mon, 3 May 2021 17:12:33 +0530 Subject: [PATCH 09/14] Fix docblocks --- src/Illuminate/Contracts/Pagination/CursorPaginator.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Contracts/Pagination/CursorPaginator.php b/src/Illuminate/Contracts/Pagination/CursorPaginator.php index a45a9e1c3855..2d62d3a51e78 100644 --- a/src/Illuminate/Contracts/Pagination/CursorPaginator.php +++ b/src/Illuminate/Contracts/Pagination/CursorPaginator.php @@ -7,7 +7,7 @@ interface CursorPaginator /** * Get the URL for a given cursor. * - * @param string $cursor + * @param \Illuminate\Pagination\Cursor|null $cursor * @return string */ public function url($cursor); @@ -15,7 +15,7 @@ public function url($cursor); /** * Add a set of query string values to the paginator. * - * @param array|string $key + * @param array|string|null $key * @param string|null $value * @return $this */ @@ -25,7 +25,7 @@ public function appends($key, $value = null); * Get / set the URL fragment to be appended to URLs. * * @param string|null $fragment - * @return $this|string + * @return $this|string|null */ public function fragment($fragment = null); From 54c090da358a07e9fc5dfbabd5da230be6977b3d Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Mon, 3 May 2021 20:37:46 -0500 Subject: [PATCH 10/14] move method --- .../Pagination/AbstractPaginator.php | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Illuminate/Pagination/AbstractPaginator.php b/src/Illuminate/Pagination/AbstractPaginator.php index 3b13a2f16349..9684afdff74d 100644 --- a/src/Illuminate/Pagination/AbstractPaginator.php +++ b/src/Illuminate/Pagination/AbstractPaginator.php @@ -458,21 +458,6 @@ public function path() return $this->path; } - /** - * Resolve the query string or return the default value. - * - * @param string|array|null $default - * @return string - */ - public static function resolveQueryString($default = null) - { - if (isset(static::$queryStringResolver)) { - return (static::$queryStringResolver)(); - } - - return $default; - } - /** * Resolve the current request path or return the default value. * @@ -526,6 +511,21 @@ public static function currentPageResolver(Closure $resolver) static::$currentPageResolver = $resolver; } + /** + * Resolve the query string or return the default value. + * + * @param string|array|null $default + * @return string + */ + public static function resolveQueryString($default = null) + { + if (isset(static::$queryStringResolver)) { + return (static::$queryStringResolver)(); + } + + return $default; + } + /** * Set with query string resolver callback. * From 64b9e9d757133c128061fda62eed7be968425979 Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Wed, 5 May 2021 01:12:14 +0530 Subject: [PATCH 11/14] Fix docblock --- src/Illuminate/Database/Eloquent/Builder.php | 4 ++-- src/Illuminate/Database/Query/Builder.php | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 5da11558578f..be8db4549c04 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -822,7 +822,7 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p * @param string $cursorName * @param string|null $cursor * @return \Illuminate\Contracts\Pagination\Paginator - * @throws \Exception + * @throws \Illuminate\Pagination\CursorPaginationException */ public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) { @@ -860,7 +860,7 @@ public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = * * @param bool $shouldReverse * @return \Illuminate\Support\Collection - * @throws \Exception + * @throws \Illuminate\Pagination\CursorPaginationException */ protected function ensureOrderForCursorPagination($shouldReverse = false) { diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index adb2c8a420a7..e06ace9b97bb 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -4,7 +4,6 @@ use Closure; use DateTimeInterface; -use Exception; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Concerns\BuildsQueries; use Illuminate\Database\Concerns\ExplainsQueries; @@ -2373,7 +2372,7 @@ public function simplePaginate($perPage = 15, $columns = ['*'], $pageName = 'pag * @param string $cursorName * @param string|null $cursor * @return \Illuminate\Contracts\Pagination\Paginator - * @throws \Exception + * @throws \Illuminate\Pagination\CursorPaginationException */ public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'cursor', $cursor = null) { @@ -2409,7 +2408,7 @@ public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'c * * @param bool $shouldReverse * @return \Illuminate\Support\Collection - * @throws \Exception + * @throws \Illuminate\Pagination\CursorPaginationException */ protected function ensureOrderForCursorPagination($shouldReverse = false) { From 740b04a272eb9b80cbaccabb50a74c27a1a7b3f0 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Wed, 5 May 2021 08:13:09 -0500 Subject: [PATCH 12/14] Formatting --- src/Illuminate/Pagination/Cursor.php | 2 +- src/Illuminate/Pagination/CursorPaginator.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Illuminate/Pagination/Cursor.php b/src/Illuminate/Pagination/Cursor.php index b5ea61cfe708..124c6c60d880 100644 --- a/src/Illuminate/Pagination/Cursor.php +++ b/src/Illuminate/Pagination/Cursor.php @@ -72,7 +72,7 @@ public function isNext() } /** - * Determine whether the cursor points to the next set of items. + * Determine whether the cursor points to the previous set of items. * * @return bool */ diff --git a/src/Illuminate/Pagination/CursorPaginator.php b/src/Illuminate/Pagination/CursorPaginator.php index 65dc559f3030..1ea1fddb5bc3 100644 --- a/src/Illuminate/Pagination/CursorPaginator.php +++ b/src/Illuminate/Pagination/CursorPaginator.php @@ -14,7 +14,7 @@ class CursorPaginator extends AbstractCursorPaginator implements Arrayable, ArrayAccess, Countable, IteratorAggregate, Jsonable, JsonSerializable, PaginatorContract { /** - * Determine if there are more items in the data source. + * Indicates whether there are more items in the data source. * * @return bool */ From 98accbbab4208a51fe0c78fc71d165b40b9ce605 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Wed, 5 May 2021 10:22:53 -0500 Subject: [PATCH 13/14] Various formatting - method renaming. --- src/Illuminate/Database/Eloquent/Builder.php | 12 ++--- src/Illuminate/Database/Query/Builder.php | 12 ++--- .../Pagination/AbstractCursorPaginator.php | 13 +++-- src/Illuminate/Pagination/Cursor.php | 54 ++++++------------- src/Illuminate/Pagination/CursorPaginator.php | 8 +-- src/Illuminate/Pagination/PaginationState.php | 4 +- tests/Pagination/CursorTest.php | 4 +- 7 files changed, 42 insertions(+), 65 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 5da11558578f..9cd39fb38d57 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -830,19 +830,19 @@ public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = $perPage = $perPage ?: $this->model->getPerPage(); - $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->isPrev()); + $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); $orderDirection = $orders->first()['direction'] ?? 'asc'; - $comparisonOperator = ($orderDirection === 'asc' ? '>' : '<'); + $comparisonOperator = $orderDirection === 'asc' ? '>' : '<'; $parameters = $orders->pluck('column')->toArray(); if (! is_null($cursor)) { if (count($parameters) === 1) { - $this->where($column = $parameters[0], $comparisonOperator, $cursor->getParam($column)); + $this->where($column = $parameters[0], $comparisonOperator, $cursor->parameter($column)); } elseif (count($parameters) > 1) { - $this->whereRowValues($parameters, $comparisonOperator, $cursor->getParams($parameters)); + $this->whereRowValues($parameters, $comparisonOperator, $cursor->parameters($parameters)); } } @@ -867,7 +867,7 @@ 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 in cursor pagination.'); + throw new CursorPaginationException('Only a single order by direction is supported when using cursor pagination.'); } if ($orderDirections->count() === 0) { @@ -876,7 +876,7 @@ protected function ensureOrderForCursorPagination($shouldReverse = false) if ($shouldReverse) { $this->query->orders = collect($this->query->orders)->map(function ($order) { - $order['direction'] = ($order['direction'] === 'asc' ? 'desc' : 'asc'); + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; return $order; })->toArray(); diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index adb2c8a420a7..038f2a2d3aef 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -2379,19 +2379,19 @@ public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'c { $cursor = $cursor ?: CursorPaginator::resolveCurrentCursor($cursorName); - $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->isPrev()); + $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); $orderDirection = $orders->first()['direction'] ?? 'asc'; - $comparisonOperator = ($orderDirection === 'asc' ? '>' : '<'); + $comparisonOperator = $orderDirection === 'asc' ? '>' : '<'; $parameters = $orders->pluck('column')->toArray(); if (! is_null($cursor)) { if (count($parameters) === 1) { - $this->where($column = $parameters[0], $comparisonOperator, $cursor->getParam($column)); + $this->where($column = $parameters[0], $comparisonOperator, $cursor->parameter($column)); } elseif (count($parameters) > 1) { - $this->whereRowValues($parameters, $comparisonOperator, $cursor->getParams($parameters)); + $this->whereRowValues($parameters, $comparisonOperator, $cursor->parameters($parameters)); } } @@ -2418,12 +2418,12 @@ protected function ensureOrderForCursorPagination($shouldReverse = false) $orderDirections = collect($this->orders)->pluck('direction')->unique(); if ($orderDirections->count() > 1) { - throw new CursorPaginationException('Only a single order by direction is supported in cursor pagination.'); + 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'); + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; return $order; })->toArray(); diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php index 05c0e534daa6..cb72d1c047cc 100644 --- a/src/Illuminate/Pagination/AbstractCursorPaginator.php +++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php @@ -140,14 +140,14 @@ public function nextPageUrl() } /** - * Get the "cursor" of the previous set of items. + * Get the "cursor" that points to the previous set of items. * * @return \Illuminate\Pagination\Cursor|null */ public function previousCursor() { if (is_null($this->cursor) || - ($this->cursor->isPrev() && ! $this->hasMore)) { + ($this->cursor->pointsToPreviousItems() && ! $this->hasMore)) { return null; } @@ -155,14 +155,14 @@ public function previousCursor() } /** - * Get the "cursor" of the next set of items. + * Get the "cursor" that points to the next set of items. * * @return \Illuminate\Pagination\Cursor|null */ public function nextCursor() { if ((is_null($this->cursor) && ! $this->hasMore) || - (! is_null($this->cursor) && $this->cursor->isNext() && ! $this->hasMore)) { + (! is_null($this->cursor) && $this->cursor->pointsToNextItems() && ! $this->hasMore)) { return null; } @@ -182,7 +182,10 @@ public function getCursorForItem($item, $isNext = true) } /** + * Get the cursor parameters for a given object. + * * @param \ArrayAccess|\stdClass $item + * @return array */ public function getParametersForItem($item) { @@ -195,7 +198,7 @@ public function getParametersForItem($item) return $item->{$parameterName} ?? $item->{Str::afterLast($parameterName, '.')}; } - throw new Exception('Only arrays and objects are supported for pagination items'); + throw new Exception('Only arrays and objects are supported when cursor paginating items.'); })->toArray(); } diff --git a/src/Illuminate/Pagination/Cursor.php b/src/Illuminate/Pagination/Cursor.php index 124c6c60d880..0f975963f97b 100644 --- a/src/Illuminate/Pagination/Cursor.php +++ b/src/Illuminate/Pagination/Cursor.php @@ -19,18 +19,18 @@ class Cursor implements Arrayable * * @var bool */ - protected $isNext; + protected $pointsToNextItems; /** * Create a new cursor instance. * * @param array $parameters - * @param bool $isNext + * @param bool $pointsToNextItems */ - public function __construct(array $parameters, $isNext = true) + public function __construct(array $parameters, $pointsToNextItems = true) { $this->parameters = $parameters; - $this->isNext = $isNext; + $this->pointsToNextItems = $pointsToNextItems; } /** @@ -39,7 +39,7 @@ public function __construct(array $parameters, $isNext = true) * @param string $parameterName * @return string|null */ - public function getParam(string $parameterName) + public function parameter(string $parameterName) { if (! isset($this->parameters[$parameterName])) { throw new UnexpectedValueException("Unable to find parameter [{$parameterName}] in pagination item."); @@ -54,10 +54,10 @@ public function getParam(string $parameterName) * @param array $parameterNames * @return array */ - public function getParams(array $parameterNames) + public function parameters(array $parameterNames) { return collect($parameterNames)->map(function ($parameterName) { - return $this->getParam($parameterName); + return $this->parameter($parameterName); })->toArray(); } @@ -66,9 +66,9 @@ public function getParams(array $parameterNames) * * @return bool */ - public function isNext() + public function pointsToNextItems() { - return $this->isNext; + return $this->pointsToNextItems; } /** @@ -76,33 +76,9 @@ public function isNext() * * @return bool */ - public function isPrev() + public function pointsToPreviousItems() { - return ! $this->isNext; - } - - /** - * Set the cursor to point to the next set of items. - * - * @return $this - */ - public function setNext() - { - $this->isNext = true; - - return $this; - } - - /** - * Set the cursor to point to the previous set of items. - * - * @return $this - */ - public function setPrev() - { - $this->isNext = false; - - return $this; + return ! $this->pointsToNextItems; } /** @@ -113,7 +89,7 @@ public function setPrev() public function toArray() { return array_merge($this->parameters, [ - '_isNext' => $this->isNext, + '_pointsToNextItems' => $this->pointsToNextItems, ]); } @@ -145,10 +121,10 @@ public static function fromEncoded($encodedString) return null; } - $isNext = $parameters['_isNext']; + $pointsToNextItems = $parameters['_pointsToNextItems']; - unset($parameters['_isNext']); + unset($parameters['_pointsToNextItems']); - return new static($parameters, $isNext); + return new static($parameters, $pointsToNextItems); } } diff --git a/src/Illuminate/Pagination/CursorPaginator.php b/src/Illuminate/Pagination/CursorPaginator.php index 1ea1fddb5bc3..620cce5bc488 100644 --- a/src/Illuminate/Pagination/CursorPaginator.php +++ b/src/Illuminate/Pagination/CursorPaginator.php @@ -58,7 +58,7 @@ protected function setItems($items) $this->items = $this->items->slice(0, $this->perPage); - if (! is_null($this->cursor) && $this->cursor->isPrev()) { + if (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()) { $this->items = $this->items->reverse()->values(); } } @@ -97,8 +97,8 @@ public function render($view = null, $data = []) public function hasMorePages() { return (is_null($this->cursor) && $this->hasMore) || - (! is_null($this->cursor) && $this->cursor->isNext() && $this->hasMore) || - (! is_null($this->cursor) && $this->cursor->isPrev()); + (! is_null($this->cursor) && $this->cursor->pointsToNextItems() && $this->hasMore) || + (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()); } /** @@ -118,7 +118,7 @@ public function hasPages() */ public function onFirstPage() { - return is_null($this->cursor) || ($this->cursor->isPrev() && ! $this->hasMore); + return is_null($this->cursor) || ($this->cursor->pointsToPreviousItems() && ! $this->hasMore); } /** diff --git a/src/Illuminate/Pagination/PaginationState.php b/src/Illuminate/Pagination/PaginationState.php index a6c47f622f11..ff8150ff2a9e 100644 --- a/src/Illuminate/Pagination/PaginationState.php +++ b/src/Illuminate/Pagination/PaginationState.php @@ -35,9 +35,7 @@ public static function resolveUsing($app) }); CursorPaginator::currentCursorResolver(function ($cursorName = 'cursor') use ($app) { - $encodedCursor = $app['request']->input($cursorName); - - return Cursor::fromEncoded($encodedCursor); + return Cursor::fromEncoded($app['request']->input($cursorName)); }); } } diff --git a/tests/Pagination/CursorTest.php b/tests/Pagination/CursorTest.php index fcf23eed9a84..05c2629619b9 100644 --- a/tests/Pagination/CursorTest.php +++ b/tests/Pagination/CursorTest.php @@ -25,7 +25,7 @@ public function testCanGetParams() 'created_at' => ($now = Carbon::now()->toDateTimeString()), ], true); - $this->assertEquals([$now, 422], $cursor->getParams(['created_at', 'id'])); + $this->assertEquals([$now, 422], $cursor->parameters(['created_at', 'id'])); } public function testCanGetParam() @@ -35,6 +35,6 @@ public function testCanGetParam() 'created_at' => ($now = Carbon::now()->toDateTimeString()), ], true); - $this->assertEquals($now, $cursor->getParam('created_at')); + $this->assertEquals($now, $cursor->parameter('created_at')); } } From e0748361253c9b59962ba49693c9e8747b39c99a Mon Sep 17 00:00:00 2001 From: Paras Malhotra Date: Thu, 6 May 2021 01:07:26 +0530 Subject: [PATCH 14/14] Add more tests --- .../Database/EloquentCursorPaginateTest.php | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index ea15da4a39e1..ec0a6a10df6e 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -4,6 +4,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Pagination\Cursor; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; /** @@ -53,6 +55,127 @@ public function testPaginationWithDistinct() $this->assertCount(6, $query->cursorPaginate()->items()); } + public function testPaginationWithWhereClause() + { + for ($i = 1; $i <= 3; $i++) { + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + } + + $query = TestPost::query()->whereNull('user_id'); + + $this->assertEquals(3, $query->get()->count()); + $this->assertEquals(3, $query->count()); + $this->assertCount(3, $query->cursorPaginate()->items()); + } + + public function testPaginationWithHasClause() + { + for ($i = 1; $i <= 3; $i++) { + TestUser::create(['id' => $i]); + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + } + + $query = TestUser::query()->has('posts'); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + } + + public function testPaginationWithWhereHasClause() + { + for ($i = 1; $i <= 3; $i++) { + TestUser::create(['id' => $i]); + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + } + + $query = TestUser::query()->whereHas('posts', function ($query) { + $query->where('title', 'Howdy'); + }); + + $this->assertEquals(1, $query->get()->count()); + $this->assertEquals(1, $query->count()); + $this->assertCount(1, $query->cursorPaginate()->items()); + } + + public function testPaginationWithWhereExistsClause() + { + for ($i = 1; $i <= 3; $i++) { + TestUser::create(['id' => $i]); + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + } + + $query = TestUser::query()->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('test_posts') + ->whereColumn('test_posts.user_id', 'test_users.id'); + }); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + } + + public function testPaginationWithMultipleWhereClauses() + { + for ($i = 1; $i <= 4; $i++) { + TestUser::create(['id' => $i]); + TestPost::create(['title' => 'Hello world', 'user_id' => null]); + TestPost::create(['title' => 'Goodbye world', 'user_id' => 2]); + TestPost::create(['title' => 'Howdy', 'user_id' => 3]); + TestPost::create(['title' => 'Howdy', 'user_id' => 4]); + } + + $query = TestUser::query()->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('test_posts') + ->whereColumn('test_posts.user_id', 'test_users.id'); + })->whereHas('posts', function ($query) { + $query->where('title', 'Howdy'); + })->where('id', '<', 5)->orderBy('id'); + + $clonedQuery = $query->clone(); + $anotherQuery = $query->clone(); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + $this->assertCount(1, $clonedQuery->cursorPaginate(1)->items()); + $this->assertCount( + 1, + $anotherQuery->cursorPaginate(5, ['*'], 'cursor', new Cursor(['id' => 3])) + ->items() + ); + } + + public function testPaginationWithAliasedOrderBy() + { + for ($i = 1; $i <= 6; $i++) { + TestUser::create(['id' => $i]); + } + + $query = TestUser::query()->select('id as user_id')->orderBy('user_id'); + $clonedQuery = $query->clone(); + $anotherQuery = $query->clone(); + + $this->assertEquals(6, $query->get()->count()); + $this->assertEquals(6, $query->count()); + $this->assertCount(6, $query->cursorPaginate()->items()); + $this->assertCount(3, $clonedQuery->cursorPaginate(3)->items()); + $this->assertCount( + 4, + $anotherQuery->cursorPaginate(10, ['*'], 'cursor', new Cursor(['user_id' => 2])) + ->items() + ); + } + public function testPaginationWithDistinctColumnsAndSelect() { for ($i = 1; $i <= 3; $i++) { @@ -96,4 +219,9 @@ class TestPost extends Model class TestUser extends Model { protected $guarded = []; + + public function posts() + { + return $this->hasMany(TestPost::class, 'user_id'); + } }