diff --git a/src/Illuminate/Contracts/Pagination/CursorPaginator.php b/src/Illuminate/Contracts/Pagination/CursorPaginator.php new file mode 100644 index 000000000000..2d62d3a51e78 --- /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..f9415a9613a1 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\RecordsNotFoundException; +use Illuminate\Pagination\CursorPaginationException; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -812,6 +814,77 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p ]); } + /** + * Paginate the given query into a cursor paginator. + * + * @param int|null $perPage + * @param array $columns + * @param string $cursorName + * @param string|null $cursor + * @return \Illuminate\Contracts\Pagination\Paginator + * @throws \Illuminate\Pagination\CursorPaginationException + */ + public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $cursor = $cursor ?: CursorPaginator::resolveCurrentCursor($cursorName); + + $perPage = $perPage ?: $this->model->getPerPage(); + + $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); + + $orderDirection = $orders->first()['direction'] ?? 'asc'; + + $comparisonOperator = $orderDirection === 'asc' ? '>' : '<'; + + $parameters = $orders->pluck('column')->toArray(); + + if (! is_null($cursor)) { + if (count($parameters) === 1) { + $this->where($column = $parameters[0], $comparisonOperator, $cursor->parameter($column)); + } elseif (count($parameters) > 1) { + $this->whereRowValues($parameters, $comparisonOperator, $cursor->parameters($parameters)); + } + } + + $this->take($perPage + 1); + + return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [ + 'path' => Paginator::resolveCurrentPath(), + 'cursorName' => $cursorName, + 'parameters' => $parameters, + ]); + } + + /** + * Ensure the proper order by required for cursor pagination. + * + * @param bool $shouldReverse + * @return \Illuminate\Support\Collection + * @throws \Illuminate\Pagination\CursorPaginationException + */ + protected function ensureOrderForCursorPagination($shouldReverse = false) + { + $orderDirections = collect($this->query->orders)->pluck('direction')->unique(); + + if ($orderDirections->count() > 1) { + throw new CursorPaginationException('Only a single order by direction is supported when using cursor pagination.'); + } + + if ($orderDirections->count() === 0) { + $this->enforceOrderBy(); + } + + if ($shouldReverse) { + $this->query->orders = collect($this->query->orders)->map(function ($order) { + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + + return $order; + })->toArray(); + } + + return collect($this->query->orders); + } + /** * Save a new model and return the instance. * diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index 63f8276946aa..9f4e83a2887c 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Grammars\Grammar; use Illuminate\Database\Query\Processors\Processor; +use Illuminate\Pagination\CursorPaginationException; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -2360,6 +2362,75 @@ public function simplePaginate($perPage = 15, $columns = ['*'], $pageName = 'pag ]); } + /** + * Get a paginator only supporting simple next and previous links. + * + * This is more efficient on larger data-sets, etc. + * + * @param int|null $perPage + * @param array $columns + * @param string $cursorName + * @param string|null $cursor + * @return \Illuminate\Contracts\Pagination\Paginator + * @throws \Illuminate\Pagination\CursorPaginationException + */ + public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $cursor = $cursor ?: CursorPaginator::resolveCurrentCursor($cursorName); + + $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); + + $orderDirection = $orders->first()['direction'] ?? 'asc'; + + $comparisonOperator = $orderDirection === 'asc' ? '>' : '<'; + + $parameters = $orders->pluck('column')->toArray(); + + if (! is_null($cursor)) { + if (count($parameters) === 1) { + $this->where($column = $parameters[0], $comparisonOperator, $cursor->parameter($column)); + } elseif (count($parameters) > 1) { + $this->whereRowValues($parameters, $comparisonOperator, $cursor->parameters($parameters)); + } + } + + $this->limit($perPage + 1); + + return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [ + 'path' => Paginator::resolveCurrentPath(), + 'cursorName' => $cursorName, + 'parameters' => $parameters, + ]); + } + + /** + * Ensure the proper order by required for cursor pagination. + * + * @param bool $shouldReverse + * @return \Illuminate\Support\Collection + * @throws \Illuminate\Pagination\CursorPaginationException + */ + protected function ensureOrderForCursorPagination($shouldReverse = false) + { + $this->enforceOrderBy(); + + $orderDirections = collect($this->orders)->pluck('direction')->unique(); + + if ($orderDirections->count() > 1) { + throw new CursorPaginationException('Only a single order by direction is supported when using cursor pagination.'); + } + + if ($shouldReverse) { + $this->orders = collect($this->orders)->map(function ($order) { + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + + return $order; + })->toArray(); + } + + return collect($this->orders); + } + /** * Get the count of the total records for the paginator. * diff --git a/src/Illuminate/Pagination/AbstractCursorPaginator.php b/src/Illuminate/Pagination/AbstractCursorPaginator.php new file mode 100644 index 000000000000..cb72d1c047cc --- /dev/null +++ b/src/Illuminate/Pagination/AbstractCursorPaginator.php @@ -0,0 +1,610 @@ +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" that points to the previous set of items. + * + * @return \Illuminate\Pagination\Cursor|null + */ + public function previousCursor() + { + if (is_null($this->cursor) || + ($this->cursor->pointsToPreviousItems() && ! $this->hasMore)) { + return null; + } + + return $this->getCursorForItem($this->items->first(), false); + } + + /** + * 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->pointsToNextItems() && ! $this->hasMore)) { + return null; + } + + return $this->getCursorForItem($this->items->last(), true); + } + + /** + * Get a cursor instance for the given item. + * + * @param \ArrayAccess|\stdClass $item + * @param bool $isNext + * @return \Illuminate\Pagination\Cursor + */ + public function getCursorForItem($item, $isNext = true) + { + return new Cursor($this->getParametersForItem($item), $isNext); + } + + /** + * Get the cursor parameters for a given object. + * + * @param \ArrayAccess|\stdClass $item + * @return array + */ + public function getParametersForItem($item) + { + return collect($this->parameters) + ->flip() + ->map(function ($_, $parameterName) use ($item) { + if ($item instanceof ArrayAccess || is_array($item)) { + return $item[$parameterName] ?? $item[Str::afterLast($parameterName, '.')]; + } elseif (is_object($item)) { + return $item->{$parameterName} ?? $item->{Str::afterLast($parameterName, '.')}; + } + + throw new Exception('Only arrays and objects are supported when cursor paginating items.'); + })->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..9684afdff74d 100644 --- a/src/Illuminate/Pagination/AbstractPaginator.php +++ b/src/Illuminate/Pagination/AbstractPaginator.php @@ -511,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. * diff --git a/src/Illuminate/Pagination/Cursor.php b/src/Illuminate/Pagination/Cursor.php new file mode 100644 index 000000000000..0f975963f97b --- /dev/null +++ b/src/Illuminate/Pagination/Cursor.php @@ -0,0 +1,130 @@ +parameters = $parameters; + $this->pointsToNextItems = $pointsToNextItems; + } + + /** + * Get the given parameter from the cursor. + * + * @param string $parameterName + * @return string|null + */ + public function parameter(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 parameters(array $parameterNames) + { + return collect($parameterNames)->map(function ($parameterName) { + return $this->parameter($parameterName); + })->toArray(); + } + + /** + * Determine whether the cursor points to the next set of items. + * + * @return bool + */ + public function pointsToNextItems() + { + return $this->pointsToNextItems; + } + + /** + * Determine whether the cursor points to the previous set of items. + * + * @return bool + */ + public function pointsToPreviousItems() + { + return ! $this->pointsToNextItems; + } + + /** + * Get the array representation of the cursor. + * + * @return array + */ + public function toArray() + { + return array_merge($this->parameters, [ + '_pointsToNextItems' => $this->pointsToNextItems, + ]); + } + + /** + * 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; + } + + $pointsToNextItems = $parameters['_pointsToNextItems']; + + unset($parameters['_pointsToNextItems']); + + return new static($parameters, $pointsToNextItems); + } +} diff --git a/src/Illuminate/Pagination/CursorPaginationException.php b/src/Illuminate/Pagination/CursorPaginationException.php new file mode 100644 index 000000000000..710401751a56 --- /dev/null +++ b/src/Illuminate/Pagination/CursorPaginationException.php @@ -0,0 +1,10 @@ +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->pointsToPreviousItems()) { + $this->items = $this->items->reverse()->values(); + } + } + + /** + * 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->pointsToNextItems() && $this->hasMore) || + (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()); + } + + /** + * 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->pointsToPreviousItems() && ! $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..ff8150ff2a9e 100644 --- a/src/Illuminate/Pagination/PaginationState.php +++ b/src/Illuminate/Pagination/PaginationState.php @@ -33,5 +33,9 @@ public static function resolveUsing($app) Paginator::queryStringResolver(function () use ($app) { return $app['request']->query(); }); + + CursorPaginator::currentCursorResolver(function ($cursorName = 'cursor') use ($app) { + return Cursor::fromEncoded($app['request']->input($cursorName)); + }); } } 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(); diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php new file mode 100644 index 000000000000..ec0a6a10df6e --- /dev/null +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -0,0 +1,227 @@ +increments('id'); + $table->string('title')->nullable(); + $table->unsignedInteger('user_id')->nullable(); + $table->timestamps(); + }); + + Schema::create('test_users', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + } + + public function testCursorPaginationOnTopOfColumns() + { + for ($i = 1; $i <= 50; $i++) { + TestPost::create([ + 'title' => 'Title '.$i, + ]); + } + + $this->assertCount(15, TestPost::cursorPaginate(15, ['id', 'title'])); + } + + public function testPaginationWithDistinct() + { + for ($i = 1; $i <= 3; $i++) { + TestPost::create(['title' => 'Hello world']); + TestPost::create(['title' => 'Goodbye world']); + } + + $query = TestPost::query()->distinct(); + + $this->assertEquals(6, $query->get()->count()); + $this->assertEquals(6, $query->count()); + $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++) { + TestPost::create(['title' => 'Hello world']); + TestPost::create(['title' => 'Goodbye world']); + } + + $query = TestPost::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 = TestUser::create(); + for ($j = 1; $j <= 10; $j++) { + TestPost::create([ + 'title' => 'Title '.$i, + 'user_id' => $user->id, + ]); + } + } + + $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()); + $this->assertCount(5, $query->cursorPaginate()->items()); + } +} + +class TestPost extends Model +{ + protected $guarded = []; +} + +class TestUser extends Model +{ + protected $guarded = []; + + public function posts() + { + return $this->hasMany(TestPost::class, 'user_id'); + } +} 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 @@ '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..b127f21f2cd7 --- /dev/null +++ b/tests/Pagination/CursorPaginatorLoadMorphTest.php @@ -0,0 +1,29 @@ + '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(); + } +} diff --git a/tests/Pagination/CursorTest.php b/tests/Pagination/CursorTest.php new file mode 100644 index 000000000000..05c2629619b9 --- /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->parameters(['created_at', 'id'])); + } + + public function testCanGetParam() + { + $cursor = new Cursor([ + 'id' => 422, + 'created_at' => ($now = Carbon::now()->toDateTimeString()), + ], true); + + $this->assertEquals($now, $cursor->parameter('created_at')); + } +}