diff --git a/.coveralls.yml b/.coveralls.yml
new file mode 100644
index 0000000..30b618e
--- /dev/null
+++ b/.coveralls.yml
@@ -0,0 +1,2 @@
+coverage_clover: build/logs/clover.xml
+json_path: build/logs/coveralls-upload.json
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f02227b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+composer.lock
+/vendor/
+/build/logs/
diff --git a/.scrutinizer.yml b/.scrutinizer.yml
new file mode 100644
index 0000000..b37c763
--- /dev/null
+++ b/.scrutinizer.yml
@@ -0,0 +1,26 @@
+checks:
+ php:
+ code_rating: true
+
+filter:
+ excluded_paths:
+ - tests/*
+ - vendor/*
+
+build:
+
+ environment:
+ php: '7.1'
+
+ dependencies:
+ before:
+ - composer install
+ - mkdir -p build/logs
+
+ tests:
+ override:
+ -
+ command: 'vendor/bin/phpunit --coverage-clover build/logs/clover.xml'
+ coverage:
+ file: 'build/logs/clover.xml'
+ format: 'clover'
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..cec9fe8
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,33 @@
+language: php
+
+matrix:
+ exclude:
+ - php: 5.6
+ env: LARAVEL_VERSION=5.5.* TESTBENCH_VERSION=3.5.*
+
+php:
+ - 5.6
+ - 7.0
+ - 7.1
+
+cache:
+ directories:
+ - ./vendor
+ - $HOME/.composer/cache
+
+env:
+ - LARAVEL_VERSION=5.4.* TESTBENCH_VERSION=3.4.* PHPUNIT_VERSION=5.7.*
+ - LARAVEL_VERSION=5.5.* TESTBENCH_VERSION=3.5.*
+
+before_script:
+ - composer self-update
+ - composer require "laravel/framework:${LARAVEL_VERSION}" "orchestra/testbench:${TESTBENCH_VERSION}" --no-update
+ - if [ "$PHPUNIT_VERSION" != "" ]; then composer require "phpunit/phpunit:${PHPUNIT_VERSION}" --no-update; fi;
+ - composer update
+ - mkdir -p build/logs
+
+script:
+ - vendor/bin/phpunit --coverage-clover build/logs/clover.xml
+
+after_success:
+ - travis_retry php vendor/bin/coveralls
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5c8ac9d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 mpyw
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..83fa931
--- /dev/null
+++ b/README.md
@@ -0,0 +1,466 @@
+# Lampage [![Build Status](https://travis-ci.org/mpyw/lampage.svg?branch=master)](https://travis-ci.org/mpyw/lampage) [![Coverage Status](https://coveralls.io/repos/github/mpyw/lampage/badge.svg?branch=master)](https://coveralls.io/github/mpyw/lampage?branch=master) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/mpyw/lampage/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/mpyw/lampage/?branch=master)
+
+Rapid pagination without using OFFSET
+
+## Requirements
+
+- PHP: ^5.6 || ^7.0
+- Laravel: ^5.4 (Optional)
+
+## Installing
+
+```bash
+composer require mpyw/lampage:^0.3.0
+```
+
+## Basic Usage for Laravel
+
+Register service provider.
+
+`config/app.php`:
+
+```php
+ /*
+ * Package Service Providers...
+ */
+ mpyw\Lampage\Provider\IlluminateMacroServiceProvider::class,
+```
+
+Then you can chain `->lampage()` method from Query Builder, Eloquent Builder and Relation.
+
+```php
+$cursor = [
+ 'id' => 3,
+ 'created_at' => '2017-01-10 00:00:00',
+ 'updated_at' => '2017-01-20 00:00:00',
+];
+
+$result = App\Post::whereUserId(1)
+ ->lampage()
+ ->forward()
+ ->limit(5)
+ ->orderByDesc('updated_at') // ORDER BY `updated_at` DESC, `created_at` DESC, `id` DESC
+ ->orderByDesc('created_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->paginate($cursor);
+```
+
+It will run the optimized query.
+
+
+```sql
+(
+
+ SELECT * FROM `posts`
+ WHERE `user_id` = 1
+ AND (
+ `updated_at` = '2017-01-20 00:00:00' AND `created_at` = '2017-01-10 00:00:00' AND `id` > 3
+ OR
+ `updated_at` = '2017-01-20 00:00:00' AND `created_at` > '2017-01-10 00:00:00'
+ OR
+ `updated_at` > '2017-01-20 00:00:00'
+ )
+ ORDER BY `updated_at` ASC, `created_at` ASC, `id` ASC
+ LIMIT 1
+
+) UNION ALL (
+
+ SELECT * FROM `posts`
+ WHERE `user_id` = 1
+ AND (
+ `updated_at` = '2017-01-20 00:00:00' AND `created_at` = '2017-01-10 00:00:00' AND `id` <= 3
+ OR
+ `updated_at` = '2017-01-20 00:00:00' AND `created_at` < '2017-01-10 00:00:00'
+ OR
+ `updated_at` < '2017-01-20 00:00:00'
+ )
+ ORDER BY `updated_at` DESC, `created_at` DESC, `id` DESC
+ LIMIT 4
+
+)
+```
+
+And you'll get
+
+
+```json
+{
+ "records": [
+ {
+ "id": 3,
+ "user_id": 1,
+ "text": "foo",
+ "created_at": "2017-01-10 00:00:00",
+ "updated_at": "2017-01-20 00:00:00"
+ },
+ {
+ "id": 5,
+ "user_id": 1,
+ "text": "bar",
+ "created_at": "2017-01-05 00:00:00",
+ "updated_at": "2017-01-20 00:00:00"
+ },
+ {
+ "id": 4,
+ "user_id": 1,
+ "text": "baz",
+ "created_at": "2017-01-05 00:00:00",
+ "updated_at": "2017-01-20 00:00:00"
+ },
+ {
+ "id": 2,
+ "user_id": 1,
+ "text": "qux",
+ "created_at": "2017-01-17 00:00:00",
+ "updated_at": "2017-01-18 00:00:00"
+ },
+ {
+ "id": 1,
+ "user_id": 1,
+ "text": "quux",
+ "created_at": "2017-01-16 00:00:00",
+ "updated_at": "2017-01-18 00:00:00"
+ }
+ ],
+ "meta": {
+ "previous_cursor": null,
+ "next_cursor": {
+ "id": 6,
+ "created_at": "2017-01-14 00:00:00",
+ "updated_at": "2017-01-18 00:00:00"
+ }
+ }
+}
+```
+
+## Important Paginator API
+
+### Paginator::orderBy()
Paginator::orderByDesc()
Paginator::clearOrderBy()
+
+Add or clear cursor parameter name for `ORDER BY` statement.
+At least one parameter required.
+
+```php
+Paginator::orderBy(string $column, string $direction = 'asc'): $this
+Paginator::orderByDesc(string $column): $this
+Paginator::clearOrderBy(): $this
+```
+
+**IMPORTANT**: The last key MUST be the primary key.
+
+e.g. `$paginator->orderBy('updated_at')->orderBy('id')`
+
+#### Arguments
+
+- **`(string)`** __*$column*__
Table column name.
+- **`(string)`** __*$direction*__
`"asc"` or `"desc"`.
+
+### Paginator::limit()
+
+Define the pagination limit.
+
+```php
+Paginator::limit(int $limit): $this
+```
+
+#### Arguments
+
+- **`(int)`** __*$limit*__
Positive integer.
+
+### Paginator::forward()
Paginator::backward()
+
+Define the pagination direction.
+
+```php
+Paginator::forward(): $this
+Paginator::backward(): $this
+```
+
+#### Forward (Default)
+
+```
+ ===============>
+[2] [ 3, 4, 5, 6, 7] [8]
+ | | └ next cursor
+ | └ current cursor
+ └ previous cursor
+```
+
+```
+ ===============>
+[8] [ 7, 6, 5, 4, 3] [2]
+ | | └ next cursor
+ | └ current cursor
+ └ previous cursor
+```
+
+#### Backward
+
+```
+ <===============
+[2] [ 3, 4, 5, 6, 7] [8]
+ | | └ next cursor
+ | └ current cursor
+ └ previous cursor
+```
+
+```
+ <===============
+[8] [ 7, 6, 5, 4, 3] [2]
+ | | └ next cursor
+ | └ current cursor
+ └ previous cursor
+```
+
+**IMPORTANT**: You need **previous** cursor to retrieve more results.
+
+### Paginator::inclusive()
Paginator::exclusive()
+
+```php
+Paginator::inclusive(): $this
+Paginator::exclusive(): $this
+```
+
+Change the behavior of handling cursor.
+
+#### Inclusive (Default)
+
+Current cursor is included in the current page.
+
+```
+ ===============>
+[2] [ 3, 4, 5, 6, 7] [8]
+ | | └ next cursor
+ | └ current cursor
+ └ previous cursor
+```
+
+```
+ <===============
+[2] [ 3, 4, 5, 6, 7] [8]
+ | | └ next cursor
+ | └ current cursor
+ └ previous cursor
+```
+
+#### Exclusive
+
+Current cursor is not included in the current page.
+
+```
+ ===============>
+[2] [ 3, 4, 5, 6, 7] [8]
+ | └ next cursor
+ └ current cursor
+```
+
+```
+ <===============
+[2] [ 3, 4, 5, 6, 7] [8]
+ | |
+ | └ current cursor
+ └ previous cursor
+```
+
+### Paginator::unseekable()
Paginator::seekable()
+
+```php
+Paginator::unseekable(): $this
+Paginator::seekable(): $this
+```
+
+Define that the pagination result should contain both of the next cursor and the previous cursor.
+
+- `unseekable()` always requires simple one `SELECT` query. (Default)
+- `seekable()` may require `SELECT ... UNION ALL SELECT ...` query when the cursor parameters are not empty.
+
+#### Unseekable (Default)
+
+```
+ ===============>
+[?] [ 3, 4, 5, 6, 7] [8]
+ | └ next cursor
+ └ current cursor
+
+```
+
+#### Seekable
+
+```
+ ===============>
+[2] [ 3, 4, 5, 6, 7] [8]
+ | | └ next cursor
+ | └ current cursor
+ └ previous cursor
+```
+
+#### When always the current cursor parameters are empty
+
+```
+===============>
+[ 1, 2, 3, 4, 5] [6]
+ └ next cursor
+```
+
+### IlluminatePaginator::paginate()
+
+Return the pagination result corresponding to the current cursor.
+
+```php
+IlluminatePaginator::paginate(array $cursor = []): \Illuminate\Support\Collection
+```
+
+### Arguments
+
+- **`(array)`** __*$cursor*__
Associative array that contains `$column => $value`.
It must be **all-or-nothing**. Partial parameters are not allowed.
+
+
+### Return Value
+
+Default format when using `Illuminate\Database\Eloquent\Builder`:
+
+```php
+new \Illuminate\Support\Collection([
+ 'records' => new \Illuminate\Database\Eloquent\Collection([
+ new \Illuminate\Database\Eloquent\Model([...]),
+ new \Illuminate\Database\Eloquent\Model([...]),
+ new \Illuminate\Database\Eloquent\Model([...]),
+ ...,
+ ]),
+ 'meta' => new \Illuminate\Support\Collection([
+ // IMPORTANT: Either of cursor does not exist when UNION ALL query is not executed.
+ 'previous_cursor' => null,
+ 'next_cursor => [
+ 'updated_at' => '2017-01-01 00:02:00',
+ 'created_at' => '2017-01-01 00:01:00',
+ 'id' => '1',
+ ],
+ ])
+])
+```
+
+## Other API
+
+### IlluminatePaginator::__construct()
IlluminatePaginator::create()
+
+Create a new paginator instance.
+If you use Laravel macros, however, you don't need to directly instantiate.
+
+```php
+static IlluminatePaginator create(Builder $builder): static
+IlluminatePaginator::__construct(Builder $builder): void
+```
+
+Note: Both `Illuminate\Database\Eloquent\Builder` and `Illuminate\Database\Query\Builder` can be accepted as `$builder`.
+
+### IlluminatePaginator::useFormatter()
IlluminatePaginator::restoreFormatter()
+
+Override or restore the formatter for the pagination result.
+
+```php
+IlluminatePaginator::useFormatter(Formatter|callable $formatter): $this
+IlluminatePaginator::restoreFormatter(): $this
+```
+
+#### Callable Formatter Example
+
+```php
+ $rows,
+ 'meta' => $newMeta,
+ ];
+};
+
+$result = Post::lampage()->orderBy('created_at')->orderBy('id')->useFormatter($formatter)->paginate();
+```
+
+#### Class Formatter Example
+
+```php
+ $rows,
+ 'meta' => $newMeta,
+ ];
+ }
+}
+
+$result = Post::lampage()->orderBy('created_at')->orderBy('id')->useFormatter(PostFormatter::class)->paginate();
+```
+
+### IlluminatePaginator::setDefaultFormatter()
IlluminatePaginator::restoreDefaultFormatter()
+
+Globally Override or restore the formatter.
+
+```php
+static IlluminatePaginator::setDefaultFormatter(Formatter|callable $formatter): $this
+static IlluminatePaginator::restoreDefaultFormatter(): $this
+```
+
+#### Example
+
+```php
+builder();
+
+ switch ($builder instanceof Builder ? $builder->getModel() : null) {
+
+ case Post::class:
+ return (new PostFormatter())->format($rows, $meta, $query);
+
+ case Comment::class:
+ return (new CommentFormatter())->format($rows, $meta, $query);
+
+ default:
+ return new BasicCollection([
+ 'records' => $rows,
+ 'meta' => new BasicCollection($meta),
+ ]);
+ }
+});
+
+$result = Post::lampage()->orderBy('created_at')->orderBy('id')->paginate();
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..16fddcc
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,33 @@
+{
+ "name": "mpyw/lampage",
+ "description": "Rapid pagination without using OFFSET",
+ "type": "library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "mpyw",
+ "email": "ryosuke_i_628@yahoo.co.jp"
+ }
+ ],
+ "keywords": ["pagination", "paginator", "offset", "limit"],
+ "autoload": {
+ "psr-4": {
+ "mpyw\\Lampage\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "App\\": "tests/App/",
+ "": "tests/"
+ }
+ },
+ "require": {
+ "php": "^5.6 || ^7.0"
+ },
+ "require-dev": {
+ "orchestra/testbench": "^3.5",
+ "laravel/framework": "^5.4",
+ "php-coveralls/php-coveralls": "^1.0",
+ "nilportugues/sql-query-formatter": "^1.2"
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..65ae86d
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ ./tests/
+
+
+
+
+ src
+
+
+
diff --git a/src/Concerns/HasBuilder.php b/src/Concerns/HasBuilder.php
new file mode 100644
index 0000000..44aef4b
--- /dev/null
+++ b/src/Concerns/HasBuilder.php
@@ -0,0 +1,24 @@
+orders, $cursor, $this->limit, $this->forward, $this->exclusive, $this->seekable, $this->builder);
+ }
+}
\ No newline at end of file
diff --git a/src/Concerns/HasProcessor.php b/src/Concerns/HasProcessor.php
new file mode 100644
index 0000000..698a4d9
--- /dev/null
+++ b/src/Concerns/HasProcessor.php
@@ -0,0 +1,76 @@
+processor->useFormatter($formatter);
+ return $this;
+ }
+
+ /**
+ * Restore default formatter.
+ *
+ * @return $this
+ */
+ public function restoreFormatter()
+ {
+ $this->processor->restoreFormatter();
+ return $this;
+ }
+
+ /**
+ * Get result from external resources.
+ *
+ * @param Query $query
+ * @param mixed $rows
+ * @return mixed
+ */
+ public function process(Query $query, $rows)
+ {
+ return $this->processor->process($query, $rows);
+ }
+
+ /**
+ * Use custom processor.
+ *
+ * @param AbstractProcessor|string $processor
+ * @return $this
+ */
+ public function useProcessor($processor)
+ {
+ $this->processor = static::validateProcessor($processor);
+ return $this;
+ }
+
+ /**
+ * Validate processor and return in normalized form.
+ *
+ * @param mixed $processor
+ * @return AbstractProcessor
+ */
+ protected static function validateProcessor($processor)
+ {
+ if (!is_subclass_of($processor, AbstractProcessor::class)) {
+ throw new \InvalidArgumentException('Processor must be an instanceof ' . AbstractProcessor::class);
+ }
+ return is_string($processor) ? new $processor : $processor;
+ }
+}
\ No newline at end of file
diff --git a/src/Contracts/Formatter.php b/src/Contracts/Formatter.php
new file mode 100644
index 0000000..0f28005
--- /dev/null
+++ b/src/Contracts/Formatter.php
@@ -0,0 +1,21 @@
+builder = $builder;
+ $this->processor = new IlluminateProcessor;
+ }
+
+ /**
+ * Build Illuminate Builder instance from Query config.
+ *
+ * @param Query $query
+ * @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation|\Illuminate\Database\Query\Builder
+ */
+ public function transform(Query $query)
+ {
+ foreach ($query->selectOrUnionAll() as $select) {
+ $builder = clone $this->builder;
+ $builder->where(function ($where) use ($select) {
+ foreach ($select->where() ?: [] as $i => $group) {
+ foreach ($group as $j => $condition) {
+ $where->{$i !== 0 && $j === 0 ? 'orWhere' : 'where'}(...$condition->toArray());
+ }
+ }
+ });
+ foreach ($select->orders() as $order) {
+ $builder->orderBy(...$order->toArray());
+ }
+ $builder->limit($select->limit()->toInteger());
+ $queries[] = $builder;
+ }
+
+ // $queries[0] is the main query, $queries[1] is the support query.
+ if (!isset($queries[1])) {
+ return $queries[0];
+ }
+ $queries[1]->unionAll($queries[0]);
+ return $queries[1];
+ }
+
+ /**
+ * Configure -> Transform.
+ *
+ * @param string[]|int[] $cursor
+ * @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation|\Illuminate\Database\Query\Builder
+ */
+ public function build(array $cursor = [])
+ {
+ return $this->transform($this->configure($cursor));
+ }
+
+ /**
+ * Execute query and paginate them.
+ *
+ * @param string[]|int[] $cursor
+ * @return Collection
+ */
+ public function paginate(array $cursor = [])
+ {
+ $query = $this->configure($cursor);
+ return $this->process($query, $this->transform($query)->get());
+ }
+}
diff --git a/src/Paginator/Paginator.php b/src/Paginator/Paginator.php
new file mode 100644
index 0000000..f08a2c7
--- /dev/null
+++ b/src/Paginator/Paginator.php
@@ -0,0 +1,177 @@
+orderBy('created_at')->orderBy('id') // <--- PRIMARY KEY
+ *
+ * @param string $column
+ * @param null|string $order
+ * @return $this
+ */
+ public function orderBy($column, $order = ORDER::ASC)
+ {
+ $this->orders[] = [$column, $order];
+ return $this;
+ }
+
+ /**
+ * Add cursor parameter name for ORDER BY statement.
+ *
+ * @param string $column
+ * @return $this
+ */
+ public function orderByDesc($column)
+ {
+ return $this->orderBy($column, ORDER::DESC);
+ }
+
+ /**
+ * Clear all cursor parameters.
+ *
+ * @return $this
+ */
+ public function clearOrderBy()
+ {
+ $this->orders = [];
+ return $this;
+ }
+
+ /**
+ * Define limit.
+ *
+ * @param int $limit
+ * @return $this
+ */
+ public function limit($limit)
+ {
+ $this->limit = $limit;
+ return $this;
+ }
+
+ /**
+ * Define that the current pagination is going forward.
+ *
+ * @return $this
+ */
+ public function forward()
+ {
+ $this->forward = true;
+ return $this;
+ }
+
+ /**
+ * Define that the current pagination is going backward.
+ *
+ * @return $this
+ */
+ public function backward()
+ {
+ $this->forward = false;
+ return $this;
+ }
+
+ /**
+ * Define that the cursor value is not included in the previous/next result.
+ *
+ * @return $this
+ */
+ public function exclusive()
+ {
+ $this->exclusive = true;
+ return $this;
+ }
+
+ /**
+ * Define that the cursor value is included in the previous/next result.
+ *
+ * @return $this
+ */
+ public function inclusive()
+ {
+ $this->exclusive = false;
+ return $this;
+ }
+
+ /**
+ * Define that the query can detect both "has_previous" and "has_next".
+ *
+ * @return $this
+ */
+ public function seekable()
+ {
+ $this->seekable = true;
+ return $this;
+ }
+
+ /**
+ * Define that the query can detect only either "has_previous" or "has_next".
+ *
+ * @return $this
+ */
+ public function unseekable()
+ {
+ $this->seekable = false;
+ return $this;
+ }
+
+ /**
+ * Build Query instance.
+ *
+ * @param string[]|int[] $cursor
+ * @return Query
+ */
+ public function configure(array $cursor = [])
+ {
+ return Query::create($this->orders, $cursor, $this->limit, $this->forward, $this->exclusive, $this->seekable);
+ }
+
+ /**
+ * Paginator can be directly used standalone but cannot produce pagination result.
+ *
+ * @param string[]|int[] $cursor
+ * @return mixed
+ * @codeCoverageIgnore
+ */
+ public function paginate(array $cursor = [])
+ {
+ throw new \BadMethodCallException('The method paginate() MUST be overridden.');
+ }
+}
diff --git a/src/Processor/AbstractProcessor.php b/src/Processor/AbstractProcessor.php
new file mode 100644
index 0000000..277c8d7
--- /dev/null
+++ b/src/Processor/AbstractProcessor.php
@@ -0,0 +1,316 @@
+formatter = static::validateFormatter($formatter);
+ return $this;
+ }
+
+ /**
+ * Restore default formatter.
+ *
+ * @return $this
+ */
+ public function restoreFormatter()
+ {
+ $this->formatter = null;
+ return $this;
+ }
+
+ /**
+ * Get result.
+ *
+ * @param Query $query
+ * @param mixed $rows
+ * @return mixed
+ */
+ public function process(Query $query, $rows)
+ {
+ $meta = [
+ 'previous_cursor' => null,
+ 'next_cursor' => null,
+ ];
+
+ if ($this->shouldLtrim($query, $rows)) {
+ $meta[$query->direction()->forward() ? 'previous_cursor' : 'next_cursor'] = $this->makeCursor(
+ $query,
+ $this->offset($rows, (int)$query->exclusive())
+ );
+ $rows = $this->slice($rows, 1);
+ }
+ if ($this->shouldRtrim($query, $rows)) {
+ $meta[$query->direction()->backward() ? 'previous_cursor' : 'next_cursor'] = $this->makeCursor(
+ $query,
+ $this->offset($rows, $query->limit() - $query->exclusive())
+ );
+ $rows = $this->slice($rows, 0, $query->limit());
+ }
+
+ // If we are not using UNION ALL...
+ if (!$query->selectOrUnionAll() instanceof UnionAll) {
+ unset($meta[$query->direction()->forward() ? 'previous_cursor' : 'next_cursor']);
+ }
+
+ return $this->invokeFormatter($this->shouldReverse($query) ? $this->reverse($rows) : $rows, $meta, $query);
+ }
+
+ /**
+ * Invoke formatter.
+ *
+ * @param mixed $rows
+ * @param array $meta
+ * @param Query $query
+ */
+ public function invokeFormatter($rows, array $meta, Query $query)
+ {
+ $formatter = static::callableFromFormatter($this->formatter ?: static::$defaultFormatter ?: [$this, 'defaultFormat']);
+ return $formatter($rows, $meta, $query);
+ }
+
+ /**
+ * Validate formatter and return in normalized form.
+ *
+ * @param mixed $formatter
+ * @return Formatter|callable
+ */
+ protected static function validateFormatter($formatter)
+ {
+ if (!is_subclass_of($formatter, Formatter::class) && !is_callable($formatter)) {
+ throw new \InvalidArgumentException('Formatter must be an instanceof ' . Formatter::class . ' or callable.');
+ }
+ return is_string($formatter) ? new $formatter : $formatter;
+ }
+
+ /**
+ * Return formatter in callable form.
+ *
+ * @param mixed $formatter
+ * @return callable
+ */
+ protected static function callableFromFormatter($formatter)
+ {
+ return is_callable($formatter) ? $formatter : [$formatter, 'format'];
+ }
+
+ /**
+ * Format result with default format.
+ *
+ * @param mixed $rows
+ * @param array $meta
+ * @param Query $query
+ * @return mixed
+ */
+ protected function defaultFormat($rows, array $meta, Query $query)
+ {
+ return [
+ 'records' => $rows,
+ 'meta' => $meta,
+ ];
+ }
+
+ /**
+ * Determine if the rows should be replaced in reverse order.
+ *
+ * @param Query $query
+ * @return bool
+ */
+ protected function shouldReverse(Query $query)
+ {
+ return $query->direction()->backward();
+ }
+
+ /**
+ * Determine if the first row should be dropped.
+ *
+ * @param Query $query
+ * @param mixed $rows
+ * @return bool
+ */
+ protected function shouldLtrim(Query $query, $rows)
+ {
+ $first = $this->offset($rows, 0);
+
+ $selectOrUnionAll = $query->selectOrUnionAll();
+
+ // If we are not using UNION ALL or the elements are empty...
+ if (!$selectOrUnionAll instanceof UnionAll || !$first) {
+ return false;
+ }
+
+ foreach ($selectOrUnionAll->supportQuery()->orders() as $order) {
+
+ // Retrieve raw values
+ $field = $this->rawField($first, $order->column());
+ $cursor = $query->cursor()[$order->column()];
+
+ // Compare the first row and the cursor
+ if (!$diff = $this->compareField($field, $cursor)) {
+ continue;
+ }
+
+ //
+ // Drop the first row if ...
+ //
+ //
+ // - the support query is descending && $field < $cursor
+ //
+ // --------------------> Main query, ascending
+ // [4, <5>, 6, 7, 8, 9, 10]
+ // <----- Support query, descending
+ //
+ // ----> Support query, descending
+ // [10, 9, 8, 7, 6, <5>, 4]
+ // <--------------------- Main query, ascending
+ //
+ //
+ // - the support query is ascending && $field > $cursor
+ //
+ // -----> Support query, ascending
+ // [4, 5, 6, 7, 8, <9>, 10]
+ // <-------------------- Main query, descending
+ //
+ // --------------------> Main query, descending
+ // [10, <9>, 8, 7, 6, 5, 4]
+ // <---- Support query, ascending
+ //
+ return $diff === ($order->descending() ? -1 : 1);
+ }
+
+ // If the first row and the cursor are identical, we should drop the first row only if exclusive.
+ return $query->exclusive();
+ }
+
+ /**
+ * Determine if the last row should be dropped.
+ *
+ * @param Query $query
+ * @param $rows
+ * @return bool
+ */
+ protected function shouldRtrim(Query $query, $rows)
+ {
+ return $query->limit() < $this->count($rows);
+ }
+
+ /**
+ * Make a cursor from the specific row.
+ *
+ * @param Query $query
+ * @param mixed $row
+ * @return string[]|int[]
+ */
+ protected function makeCursor(Query $query, $row)
+ {
+ $fields = [];
+ foreach ($query->orders() as $order) {
+ $fields[$order->column()] = $this->rawField($row, $order->column());
+ }
+ return $fields;
+ }
+
+ /**
+ * Return comparable raw value from a row.
+ *
+ * @param mixed $row
+ * @param string $column
+ * @return string|int
+ */
+ abstract protected function rawField($row, $column);
+
+ /**
+ * Compare the values.
+ *
+ * "$field < $cursor" should return -1.
+ * "$field > $cursor" should return 1.
+ * "$field == $cursor" should return 0.
+ *
+ * @param string|int $field
+ * @param string|int $cursor
+ * @return int
+ */
+ protected function compareField($field, $cursor)
+ {
+ return ($field > $cursor) - ($field < $cursor);
+ }
+
+ /**
+ * Return the n-th element of collection.
+ * Must return null if not exists.
+ *
+ * @param mixed $rows
+ * @param int $offset
+ * @return mixed
+ */
+ abstract protected function offset($rows, $offset);
+
+ /**
+ * Slice rows, like PHP function array_slice().
+ *
+ * @param mixed $rows
+ * @param int $offset
+ * @param null|int $length
+ * @return mixed
+ */
+ abstract protected function slice($rows, $offset, $length = null);
+
+ /**
+ * Count rows, like PHP function count().
+ *
+ * @param mixed $rows
+ * @return int
+ */
+ abstract protected function count($rows);
+
+ /**
+ * Reverse rows, like PHP function array_reverse().
+ *
+ * @param $rows
+ * @return mixed
+ */
+ abstract protected function reverse($rows);
+}
diff --git a/src/Processor/IlluminateProcessor.php b/src/Processor/IlluminateProcessor.php
new file mode 100644
index 0000000..611952e
--- /dev/null
+++ b/src/Processor/IlluminateProcessor.php
@@ -0,0 +1,93 @@
+$column;
+ return is_object($value) ? (string)$value : $value;
+ }
+
+ /**
+ * Return the n-th element of collection.
+ * Must return null if not exists.
+ *
+ * @param mixed $rows
+ * @param int $offset
+ * @return mixed
+ */
+ protected function offset($rows, $offset)
+ {
+ return isset($rows[$offset]) ? $rows[$offset] : null;
+ }
+
+ /**
+ * Slice rows, like PHP function array_slice().
+ *
+ * @param Collection|Model[]|object[] $rows
+ * @param int $offset
+ * @param null|int $length
+ * @return mixed
+ */
+ protected function slice($rows, $offset, $length = null)
+ {
+ return $rows->slice($offset, $length)->values();
+ }
+
+ /**
+ * Count rows, like PHP function count().
+ *
+ * @param Collection|Model[]|object[] $rows
+ * @return int
+ */
+ protected function count($rows)
+ {
+ return $rows->count();
+ }
+
+ /**
+ * Reverse rows, like PHP function array_reverse().
+ *
+ * @param Collection|Model[]|object[] $rows
+ * @return mixed
+ */
+ protected function reverse($rows)
+ {
+ return $rows->reverse()->values();
+ }
+
+ /**
+ * Format result.
+ *
+ * @param Collection|Model[]|object[] $rows
+ * @param array $meta
+ * @param Query $query
+ * @return mixed
+ */
+ protected function defaultFormat($rows, array $meta, Query $query)
+ {
+ return new BasicCollection([
+ 'records' => $rows,
+ 'meta' => new BasicCollection($meta),
+ ]);
+ }
+}
diff --git a/src/Provider/IlluminateMacroServiceProvider.php b/src/Provider/IlluminateMacroServiceProvider.php
new file mode 100644
index 0000000..d930034
--- /dev/null
+++ b/src/Provider/IlluminateMacroServiceProvider.php
@@ -0,0 +1,34 @@
+';
+ const EQ = '=';
+ const LE = '<=';
+ const GE = '>=';
+
+ protected static $comparatorInverseMap = [
+ // inverse map for non-primary key condition
+ [
+ self::LT => self::GT,
+ self::EQ => self::EQ,
+ self::GT => self::LT,
+ ],
+ // inverse map for primary key condition
+ [
+ self::LT => self::GE,
+ self::GT => self::LE,
+ self::LE => self::GT,
+ self::GE => self::LT,
+ ],
+ ];
+
+ protected static $comparatorOrderDirectionMap = [
+ Order::ASCENDING => [
+ Direction::FORWARD => self::GT,
+ Direction::BACKWARD => self::LT,
+ ],
+ Order::DESCENDING => [
+ Direction::FORWARD => self::LT,
+ Direction::BACKWARD => self::GT,
+ ],
+ ];
+
+ /**
+ * @var string
+ */
+ protected $left;
+
+ /**
+ * @var string
+ */
+ protected $comparator;
+
+ /**
+ * @var string
+ */
+ protected $right;
+
+ /**
+ * @var bool
+ */
+ protected $isPrimaryKey;
+
+ /**
+ * @param Order $order
+ * @param string|int $value
+ * @param Direction $direction
+ * @param bool $exclusive
+ * @param bool $isPrimaryKey
+ * @param bool $isLastKey
+ * @param bool $isSupportQuery
+ * @return Condition
+ */
+ public static function create(Order $order, $value, Direction $direction, $exclusive, $isPrimaryKey, $isLastKey, $isSupportQuery = false)
+ {
+ return new Condition(
+ $order->column(),
+ static::compileComparator(
+ $order,
+ $direction,
+ $exclusive,
+ $isPrimaryKey,
+ $isLastKey,
+ $isSupportQuery
+ ),
+ $value,
+ $isPrimaryKey
+ );
+ }
+
+ /**
+ * @param Order $order
+ * @param Direction $direction
+ * @param bool $exclusive
+ * @param bool $isPrimaryKey
+ * @param bool $isLastKey
+ * @param bool $isSupportQuery
+ * @return string
+ */
+ protected static function compileComparator(Order $order, Direction $direction, $exclusive, $isPrimaryKey, $isLastKey, $isSupportQuery)
+ {
+ if (!$isLastKey) {
+ // Comparator for keys except the last one is always "=".
+ // e.g. updated_at = ? AND created_at = ? AND id > ?
+ return static::EQ;
+ }
+
+ // e.g. Ascending forward uses the condition "column > ?"
+ $base = static::$comparatorOrderDirectionMap[$order->order()][(string)$direction];
+
+ // For main query, we append "=" to the primary key condition when it is inclusive.
+ // For support query, we append "=" to the primary key condition when the main query is exclusive.
+ $shouldAppendEquals = $isPrimaryKey && (!$isSupportQuery && !$exclusive || $isSupportQuery && $exclusive);
+
+ // Comparators for support query is inverse.
+ // e.g. The inverse of "updated_at > ?" is "updated_at < ?"
+ // e.g. The inverse of "id > ?" is "id <= ?"
+ $base = $isSupportQuery ? static::$comparatorInverseMap[$isPrimaryKey][$base] : $base;
+
+ return $base . ($shouldAppendEquals ? self::EQ : '');
+ }
+
+ /**
+ * Condition constructor.
+ *
+ * @param string $left
+ * @param string $comparator
+ * @param string $right
+ * @param bool $isPrimaryKey
+ */
+ public function __construct($left, $comparator, $right, $isPrimaryKey = false)
+ {
+ $this->left = (string)$left;
+ $this->comparator = (string)static::validate($comparator, $isPrimaryKey);
+ $this->right = (string)$right;
+ $this->isPrimaryKey = (bool)$isPrimaryKey;
+ }
+
+ /**
+ * @param string $comparator
+ * @param bool $isPrimaryKey
+ * @return string
+ */
+ protected static function validate($comparator, $isPrimaryKey)
+ {
+ if (!isset(static::$comparatorInverseMap[$isPrimaryKey][$comparator])) {
+ throw new \DomainException(
+ $isPrimaryKey
+ ? 'Comparator for primary key condition must be "<", ">", "<=" or ">="'
+ : 'Comparator for non-primary key condition must be "<", ">" or "="'
+ );
+ }
+ return $comparator;
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray()
+ {
+ return [$this->left, $this->comparator, $this->right];
+ }
+
+ /**
+ * @return string
+ */
+ public function left()
+ {
+ return $this->left;
+ }
+
+ /**
+ * @return string
+ */
+ public function comparator()
+ {
+ return $this->comparator;
+ }
+
+ /**
+ * @return string
+ */
+ public function right()
+ {
+ return $this->right;
+ }
+
+ /**
+ * @return string
+ */
+ public function isPrimaryKey()
+ {
+ return $this->isPrimaryKey;
+ }
+
+ /**
+ * @return static
+ */
+ public function inverse()
+ {
+ return new static(
+ $this->left,
+ static::$comparatorInverseMap[$this->isPrimaryKey][$this->comparator],
+ $this->right,
+ $this->isPrimaryKey
+ );
+ }
+}
diff --git a/src/Query/Conditions/Group.php b/src/Query/Conditions/Group.php
new file mode 100644
index 0000000..22bab1d
--- /dev/null
+++ b/src/Query/Conditions/Group.php
@@ -0,0 +1,111 @@
+column()])) {
+ // All parameters must be specified.
+ throw new \UnexpectedValueException("Missing cursor parameter: {$order->column()}");
+ }
+ $isLastKey = ++$i === $count;
+ $conditions[] = Condition::create(
+ $order,
+ $cursor[$order->column()],
+ $direction,
+ $exclusive,
+ $isLastKey && $hasPrimaryKey, // When it is the last key and we have a primary key, it is also the primary key.
+ $isLastKey,
+ $isSupportQuery
+ );
+ }
+ return new static($conditions);
+ }
+
+ /**
+ * Group constructor.
+ *
+ * @param Condition[] $conditions
+ */
+ public function __construct(array $conditions)
+ {
+ $this->conditions = static::validate(...$conditions);
+ }
+
+ /**
+ * @param Condition[] $conditions
+ * @return Condition[]
+ */
+ protected static function validate(Condition ...$conditions)
+ {
+ return $conditions;
+ }
+
+ /**
+ * @return Condition[]
+ */
+ public function conditions()
+ {
+ return $this->conditions;
+ }
+
+ /**
+ * @return static
+ */
+ public function inverse()
+ {
+ return new static(array_map(static function (Condition $condition) {
+ return $condition->inverse();
+ }, $this->conditions));
+ }
+
+ /**
+ * Clone Group.
+ */
+ public function __clone()
+ {
+ $this->conditions = array_map(static function (Condition $condition) {
+ return clone $condition;
+ }, $this->conditions);
+ }
+
+ /**
+ * Retrieve an external iterator.
+ *
+ * @return \Generator|Condition[]
+ */
+ public function getIterator()
+ {
+ foreach ($this->conditions as $i => $condition) {
+ yield $i => $condition;
+ }
+ }
+}
diff --git a/src/Query/Direction.php b/src/Query/Direction.php
new file mode 100644
index 0000000..ec3d0b5
--- /dev/null
+++ b/src/Query/Direction.php
@@ -0,0 +1,72 @@
+direction = static::validate($direction);
+ }
+
+ /**
+ * @param $direction
+ * @return string
+ */
+ protected static function validate($direction)
+ {
+ $direction = strtolower($direction);
+ if (!preg_match('/\A(forward|backward)\z/', $direction, $m)) {
+ throw new \DomainException('Direction must be "forward" or "backward"');
+ }
+ return $m[1];
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->direction;
+ }
+
+ /**
+ * @return bool
+ */
+ public function forward()
+ {
+ return $this->direction === static::FORWARD;
+ }
+
+ /**
+ * @return bool
+ */
+ public function backward()
+ {
+ return $this->direction === static::BACKWARD;
+ }
+
+ /**
+ * @return static
+ */
+ public function inverse()
+ {
+ return new static($this->direction === static::FORWARD ? static::BACKWARD : static::FORWARD);
+ }
+}
diff --git a/src/Query/Limit.php b/src/Query/Limit.php
new file mode 100644
index 0000000..230a836
--- /dev/null
+++ b/src/Query/Limit.php
@@ -0,0 +1,85 @@
+limit = static::validate($originalLimit, $isSupportQuery);
+ $this->originalLimit = (int)$originalLimit;
+ $this->isSupportQuery = (bool)$isSupportQuery;
+ }
+
+ /**
+ * @param int $limit
+ * @param bool $isSupportQuery
+ * @return int
+ */
+ protected static function validate($limit, $isSupportQuery)
+ {
+ if (!ctype_digit("$limit")) {
+ throw new \DomainException("Limit must be integer");
+ }
+ if ($limit < 1) {
+ throw new \OutOfRangeException("Limit must be positive integer");
+ }
+ return $isSupportQuery ? 1 : ($limit + 1);
+ }
+
+ /**
+ * @return int
+ */
+ public function toInteger()
+ {
+ return $this->limit;
+ }
+
+ /**
+ * @return int
+ */
+ public function original()
+ {
+ return $this->originalLimit;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isSupportQuery()
+ {
+ return $this->isSupportQuery;
+ }
+
+ /**
+ * @return static
+ */
+ public function inverse()
+ {
+ return new static($this->originalLimit, !$this->isSupportQuery);
+ }
+}
diff --git a/src/Query/Order.php b/src/Query/Order.php
new file mode 100644
index 0000000..5daf14d
--- /dev/null
+++ b/src/Query/Order.php
@@ -0,0 +1,108 @@
+column = (string)$column;
+ $this->order = static::validate($order);
+ }
+
+ /**
+ * @param $order
+ * @return string
+ */
+ protected static function validate($order)
+ {
+ $order = strtolower($order);
+ if (!preg_match('/\A(asc|desc)(?:ending)?\z/', $order, $m)) {
+ throw new \DomainException('Order must be "asc", "ascending", "desc" or "descending"');
+ }
+ return $m[1];
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray()
+ {
+ return [$this->column, $this->order];
+ }
+
+ /**
+ * @return string
+ */
+ public function column()
+ {
+ return $this->column;
+ }
+
+ /**
+ * @return string
+ */
+ public function order()
+ {
+ return $this->order;
+ }
+
+ /**
+ * @return bool
+ */
+ public function ascending()
+ {
+ return $this->order === static::ASCENDING;
+ }
+
+ /**
+ * @return bool
+ */
+ public function descending()
+ {
+ return $this->order === static::DESCENDING;
+ }
+
+ /**
+ * @return static
+ */
+ public function inverse()
+ {
+ return new static($this->column, $this->order === static::ASCENDING ? static::DESCENDING : static::ASCENDING);
+ }
+}
diff --git a/src/Query/Query.php b/src/Query/Query.php
new file mode 100644
index 0000000..bfde08d
--- /dev/null
+++ b/src/Query/Query.php
@@ -0,0 +1,171 @@
+selectOrUnionAll = $selectOrUnionAll;
+ $this->orders = $orders;
+ $this->cursor = $cursor;
+ $this->limit = $limit->original();
+ $this->direction = $direction;
+ $this->exclusive = $exclusive;
+ $this->seekable = $seekable;
+ $this->builder = $builder;
+ }
+
+ /**
+ * @return Select|UnionAll|SelectOrUnionAll|Select[]
+ */
+ public function selectOrUnionAll()
+ {
+ return $this->selectOrUnionAll;
+ }
+
+ /**
+ * @return Order[]
+ */
+ public function orders()
+ {
+ return $this->orders;
+ }
+
+ /**
+ * @return array
+ */
+ public function cursor()
+ {
+ return $this->cursor;
+ }
+
+ /**
+ * @return int
+ */
+ public function limit()
+ {
+ return $this->limit;
+ }
+
+ /**
+ * @return Direction
+ */
+ public function direction()
+ {
+ return $this->direction;
+ }
+
+ /**
+ * @return bool
+ */
+ public function exclusive()
+ {
+ return $this->exclusive;
+ }
+
+ /**
+ * @return bool
+ */
+ public function seekable()
+ {
+ return $this->seekable;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function builder()
+ {
+ return $this->builder;
+ }
+
+ /**
+ * Clone Query.
+ */
+ public function __clone()
+ {
+ $this->selectOrUnionAll = clone $this->selectOrUnionAll;
+ $this->orders = array_map(static function (Order $order) {
+ return clone $order;
+ }, $this->orders);
+ $this->direction = clone $this->direction;
+ }
+}
diff --git a/src/Query/Select.php b/src/Query/Select.php
new file mode 100644
index 0000000..4df9388
--- /dev/null
+++ b/src/Query/Select.php
@@ -0,0 +1,109 @@
+where = $where;
+ $this->orders = static::validate(...$orders);
+ $this->limit = $limit;
+ }
+
+ /**
+ * @param Order[] ...$orders
+ * @return Order[]
+ */
+ protected static function validate(Order ...$orders)
+ {
+ return $orders;
+ }
+
+ /**
+ * @return null|Where
+ */
+ public function where()
+ {
+ return $this->where;
+ }
+
+ /**
+ * @return Order[]
+ */
+ public function orders()
+ {
+ return $this->orders;
+ }
+
+ /**
+ * @return Limit
+ */
+ public function limit()
+ {
+ return $this->limit;
+ }
+
+ /**
+ * @return static
+ */
+ public function inverse()
+ {
+ return new static(
+ $this->where ? $this->where->inverse() : null,
+ array_map(static function (Order $order) {
+ return $order->inverse();
+ }, $this->orders),
+ $this->limit->inverse()
+ );
+ }
+
+ /**
+ * Clone Select.
+ */
+ public function __clone()
+ {
+ if ($this->where) {
+ $this->where = clone $this->where;
+ }
+ $this->orders = array_map(static function (Order $order) {
+ return clone $order;
+ }, $this->orders);
+ $this->limit = clone $this->limit;
+ }
+
+ /**
+ * Retrieve an external iterator.
+ *
+ * @return \Generator|Select[]
+ */
+ public function getIterator()
+ {
+ yield $this;
+ }
+}
diff --git a/src/Query/SelectOrUnionAll.php b/src/Query/SelectOrUnionAll.php
new file mode 100644
index 0000000..f41a391
--- /dev/null
+++ b/src/Query/SelectOrUnionAll.php
@@ -0,0 +1,39 @@
+backward()
+ ? array_map(static function (Order $order) {
+ return $order->inverse();
+ }, $orders)
+ : $orders,
+ $limit
+ );
+
+ if (!$cursor || !$seekable) {
+ // We don't need UNION ALL and support query when cursor parameters are empty,
+ // or it does not need to be seekable.
+ return $mainQuery;
+ }
+
+ return new UnionAll($mainQuery);
+ }
+}
diff --git a/src/Query/UnionAll.php b/src/Query/UnionAll.php
new file mode 100644
index 0000000..f6f5e97
--- /dev/null
+++ b/src/Query/UnionAll.php
@@ -0,0 +1,66 @@
+mainQuery = $select;
+ $this->supportQuery = $select->inverse();
+ }
+
+ /**
+ * @return Select
+ */
+ public function mainQuery()
+ {
+ return $this->mainQuery;
+ }
+
+ /**
+ * @return Select
+ */
+ public function supportQuery()
+ {
+ return $this->supportQuery;
+ }
+
+ /**
+ * Clone Select.
+ */
+ public function __clone()
+ {
+ $this->mainQuery = clone $this->mainQuery;
+ $this->supportQuery = clone $this->supportQuery;
+ }
+
+ /**
+ * Retrieve an external iterator.
+ *
+ * @return \Generator|Select[]
+ */
+ public function getIterator()
+ {
+ yield $this->mainQuery;
+ yield $this->supportQuery;
+ }
+}
diff --git a/src/Query/Where.php b/src/Query/Where.php
new file mode 100644
index 0000000..34de8f2
--- /dev/null
+++ b/src/Query/Where.php
@@ -0,0 +1,111 @@
+ ?
+ * 2nd: updated_at = ? AND created_at > ?
+ * 3rd: updated_at > ?
+ */
+ $groups[] = Group::create(
+ array_slice($orders, 0, $count - $i),
+ $cursor,
+ $direction,
+ $exclusive,
+ $i === 0, // First row has a primary key
+ $isSupportQuery
+ );
+ }
+ return new static($groups);
+ }
+
+ /**
+ * Where constructor.
+ *
+ * @param Group[] $groups
+ */
+ public function __construct(array $groups)
+ {
+ $this->groups = static::validate(...$groups);
+ }
+
+ /**
+ * @param Group[] $groups
+ * @return Group[]
+ */
+ protected static function validate(Group ...$groups)
+ {
+ return $groups;
+ }
+
+ /**
+ * @return Group[]
+ */
+ public function groups()
+ {
+ return $this->groups;
+ }
+
+ /**
+ * @return static
+ */
+ public function inverse()
+ {
+ return new static(array_map(static function (Group $group) {
+ return $group->inverse();
+ }, $this->groups));
+ }
+
+ /**
+ * Clone Where.
+ */
+ public function __clone()
+ {
+ $this->groups = array_map(static function (Group $group) {
+ return clone $group;
+ }, $this->groups);
+ }
+
+ /**
+ * Retrieve an external iterator.
+ *
+ * @return \Generator|Group[]
+ */
+ public function getIterator()
+ {
+ foreach ($this->groups as $i => $group) {
+ yield $i => $group;
+ }
+ }
+}
diff --git a/tests/App/Post.php b/tests/App/Post.php
new file mode 100644
index 0000000..6e68e5d
--- /dev/null
+++ b/tests/App/Post.php
@@ -0,0 +1,23 @@
+ 'int',
+ 'updated_at' => 'datetime',
+ ];
+}
diff --git a/tests/App/SimpleIlluminateProcessor.php b/tests/App/SimpleIlluminateProcessor.php
new file mode 100644
index 0000000..a03640d
--- /dev/null
+++ b/tests/App/SimpleIlluminateProcessor.php
@@ -0,0 +1,25 @@
+set('database.default', 'test');
+ $app['config']->set('database.connections.test', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ ]);
+ }
+
+ /**
+ * @param \Illuminate\Foundation\Application $app
+ * @return array
+ */
+ protected function getPackageProviders($app)
+ {
+ return [
+ IlluminateMacroServiceProvider::class,
+ ];
+ }
+
+ protected function setUp()
+ {
+ parent::setUp();
+
+ Schema::create('posts', function (Blueprint $table) {
+ $table->increments('id');
+ $table->datetime('updated_at');
+ });
+
+ Post::create(['id' => 1, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 3, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 5, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 2, 'updated_at' => '2017-01-01 11:00:00']);
+ Post::create(['id' => 4, 'updated_at' => '2017-01-01 11:00:00']);
+ }
+
+ /**
+ * @test
+ */
+ public function defaultFormatter()
+ {
+ $result = Post::lampage()->orderBy('id')->useProcessor(SimpleIlluminateProcessor::class)->paginate();
+ $this->assertInternalType('array', $result);
+ }
+
+ /**
+ * @test
+ */
+ public function illuminateStaticCustomFormatter()
+ {
+ try {
+ IlluminateProcessor::setDefaultFormatter(function ($rows, $meta, Query $query) {
+ $this->assertInstanceOf(Post::class, $query->builder()->getModel());
+ $meta['foo'] = 'bar';
+ return new BaseCollection([
+ 'records' => $rows,
+ 'meta' => $meta,
+ ]);
+ });
+ $result = Post::lampage()->orderBy('id')->paginate();
+ $this->assertInstanceOf(BaseCollection::class, $result);
+ $this->assertEquals('bar', $result['meta']['foo']);
+ } finally {
+ IlluminateProcessor::restoreDefaultFormatter();
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function illuminateCustomFormatter()
+ {
+ $lampage = Post::lampage();
+ try {
+ $result = $lampage->orderBy('id')->useFormatter(function ($rows, $meta, Query $query) {
+ $this->assertInstanceOf(Post::class, $query->builder()->getModel());
+ $meta['foo'] = 'bar';
+ return new BaseCollection([
+ 'records' => $rows,
+ 'meta' => $meta,
+ ]);
+ })->paginate();
+ $this->assertInstanceOf(BaseCollection::class, $result);
+ $this->assertEquals('bar', $result['meta']['foo']);
+ } finally {
+ $lampage->restoreFormatter();
+ }
+ }
+
+ /**
+ * @test
+ * @expectedException \InvalidArgumentException
+ */
+ public function invalidFormatter()
+ {
+ Post::lampage()->useProcessor(function () {});
+ }
+
+ /**
+ * @test
+ * @expectedException \InvalidArgumentException
+ */
+ public function invalidProcessor()
+ {
+ Post::lampage()->useFormatter(__CLASS__);
+ }
+}
diff --git a/tests/MacroTest.php b/tests/MacroTest.php
new file mode 100644
index 0000000..beeb354
--- /dev/null
+++ b/tests/MacroTest.php
@@ -0,0 +1,60 @@
+set('database.default', 'test');
+ $app['config']->set('database.connections.test', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ ]);
+ }
+
+ /**
+ * @param \Illuminate\Foundation\Application $app
+ * @return array
+ */
+ protected function getPackageProviders($app)
+ {
+ return [
+ IlluminateMacroServiceProvider::class,
+ ];
+ }
+
+ protected function setUp()
+ {
+ parent::setUp();
+
+ Schema::create('posts', function (Blueprint $table) {
+ $table->increments('id');
+ $table->datetime('updated_at');
+ });
+
+ Post::create(['id' => 1, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 3, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 5, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 2, 'updated_at' => '2017-01-01 11:00:00']);
+ Post::create(['id' => 4, 'updated_at' => '2017-01-01 11:00:00']);
+ }
+
+ /**
+ * @test
+ */
+ public function registerAllIlluminateMacros()
+ {
+ (new Post())->belongsTo(Post::class)->lampage()->orderBy('id')->build()->toSql();
+ $x = (new Post())->lampage()->orderBy('id')->build()->toSql();
+ $y = (new Post())->newQuery()->getQuery()->lampage()->orderBy('id')->build()->toSql();
+ $this->assertEquals($x, $y);
+ }
+}
diff --git a/tests/ProcessorTest.php b/tests/ProcessorTest.php
new file mode 100644
index 0000000..a1b29a0
--- /dev/null
+++ b/tests/ProcessorTest.php
@@ -0,0 +1,471 @@
+set('database.default', 'test');
+ $app['config']->set('database.connections.test', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ ]);
+ }
+
+ /**
+ * @param \Illuminate\Foundation\Application $app
+ * @return array
+ */
+ protected function getPackageProviders($app)
+ {
+ return [
+ IlluminateMacroServiceProvider::class,
+ ];
+ }
+
+ protected function setUp()
+ {
+ parent::setUp();
+
+ Schema::create('posts', function (Blueprint $table) {
+ $table->increments('id');
+ $table->datetime('updated_at');
+ });
+
+ Post::create(['id' => 1, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 3, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 5, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 2, 'updated_at' => '2017-01-01 11:00:00']);
+ Post::create(['id' => 4, 'updated_at' => '2017-01-01 11:00:00']);
+ }
+
+ /**
+ * @param $expected
+ * @param $actual
+ */
+ protected function assertResultEquals($expected, $actual)
+ {
+ $this->assertEquals(
+ json_decode(json_encode($expected)),
+ json_decode(json_encode($actual))
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function asc_forward_start_inclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'next_cursor' => ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->forward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('id')
+ ->seekable()
+ ->paginate()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function asc_forward_start_exclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'next_cursor' => ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->forward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('id')
+ ->seekable()
+ ->exclusive()
+ ->paginate()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function asc_forward_inclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ 'next_cursor' => ['id' => 4, 'updated_at' => '2017-01-01 11:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->forward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('id')
+ ->seekable()
+ ->paginate(['id' => 3, 'updated_at' => '2017-01-01 10:00:00'])
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function asc_forward_exclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 4, 'updated_at' => '2017-01-01 11:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ 'next_cursor' => null,
+ ],
+ ],
+ Post::lampage()
+ ->forward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('id')
+ ->seekable()
+ ->exclusive()
+ ->paginate(['id' => 3, 'updated_at' => '2017-01-01 10:00:00'])
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function asc_backward_start_inclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 4, 'updated_at' => '2017-01-01 11:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->backward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('id')
+ ->seekable()
+ ->paginate()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function asc_backward_start_exclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 4, 'updated_at' => '2017-01-01 11:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->backward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('id')
+ ->seekable()
+ ->exclusive()
+ ->paginate()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function asc_backward_inclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => null,
+ 'next_cursor' => ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->backward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('id')
+ ->seekable()
+ ->paginate(['id' => 3, 'updated_at' => '2017-01-01 10:00:00'])
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function asc_backward_exclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => null,
+ 'next_cursor' => ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->backward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('id')
+ ->seekable()
+ ->exclusive()
+ ->paginate(['id' => 3, 'updated_at' => '2017-01-01 10:00:00'])
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function desc_forward_start_inclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 4, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'next_cursor' => ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->forward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->paginate()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function desc_forward_start_exclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 4, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'next_cursor' => ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->forward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->exclusive()
+ ->paginate()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function desc_forward_inclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ 'next_cursor' => null,
+ ],
+ ],
+ Post::lampage()
+ ->forward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->paginate(['id' => 3, 'updated_at' => '2017-01-01 10:00:00'])
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function desc_forward_exclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ 'next_cursor' => null,
+ ],
+ ],
+ Post::lampage()
+ ->forward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->exclusive()
+ ->paginate(['id' => 3, 'updated_at' => '2017-01-01 10:00:00'])
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function desc_backward_start_inclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->backward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->paginate()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function desc_backward_start_exclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->backward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->exclusive()
+ ->paginate()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function desc_backward_inclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ['id' => 3, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => ['id' => 4, 'updated_at' => '2017-01-01 11:00:00'],
+ 'next_cursor' => ['id' => 1, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->backward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->paginate(['id' => 3, 'updated_at' => '2017-01-01 10:00:00'])
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function desc_backward_exclusive()
+ {
+ $this->assertResultEquals(
+ [
+ 'records' => [
+ ['id' => 4, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 2, 'updated_at' => '2017-01-01 11:00:00'],
+ ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ 'meta' => [
+ 'previous_cursor' => null,
+ 'next_cursor' => ['id' => 5, 'updated_at' => '2017-01-01 10:00:00'],
+ ],
+ ],
+ Post::lampage()
+ ->backward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->exclusive()
+ ->paginate(['id' => 3, 'updated_at' => '2017-01-01 10:00:00'])
+ );
+ }
+}
diff --git a/tests/QueryComponentTest.php b/tests/QueryComponentTest.php
new file mode 100644
index 0000000..b0752b4
--- /dev/null
+++ b/tests/QueryComponentTest.php
@@ -0,0 +1,163 @@
+set('database.default', 'test');
+ $app['config']->set('database.connections.test', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ ]);
+ }
+
+ /**
+ * @param \Illuminate\Foundation\Application $app
+ * @return array
+ */
+ protected function getPackageProviders($app)
+ {
+ return [
+ IlluminateMacroServiceProvider::class,
+ ];
+ }
+
+ protected function setUp()
+ {
+ parent::setUp();
+
+ Schema::create('posts', function (Blueprint $table) {
+ $table->increments('id');
+ $table->datetime('updated_at');
+ });
+
+ Post::create(['id' => 1, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 3, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 5, 'updated_at' => '2017-01-01 10:00:00']);
+ Post::create(['id' => 2, 'updated_at' => '2017-01-01 11:00:00']);
+ Post::create(['id' => 4, 'updated_at' => '2017-01-01 11:00:00']);
+ }
+
+ /**
+ * @test
+ * @expectedException \DomainException
+ */
+ public function validateLimitType()
+ {
+ new Limit("foo");
+ }
+
+ /**
+ * @test
+ * @expectedException \OutOfRangeException
+ */
+ public function validateLimitRange()
+ {
+ new Limit(0);
+ }
+
+ /**
+ * @test
+ * @expectedException \DomainException
+ */
+ public function validateOrderVerb()
+ {
+ new Order('id', 'dascending');
+ }
+
+ /**
+ * @test
+ * @expectedException \DomainException
+ */
+ public function validateDirectionVerb()
+ {
+ new Direction('forbackward');
+ }
+
+ /**
+ * @test
+ * @expectedException \DomainException
+ */
+ public function validateComparator()
+ {
+ new Condition('user_id', '>=', 1, false);
+ }
+
+ /**
+ * @test
+ * @expectedException \DomainException
+ */
+ public function validatePrimaryKeyComparator()
+ {
+ new Condition('id', '=', 1, true);
+ }
+
+ /**
+ * @test
+ * @expectedException \UnexpectedValueException
+ */
+ public function validateAllParametersToBeFilled()
+ {
+ $orders = [
+ new Order('user_id', 'asc'),
+ new Order('id', 'asc'),
+ ];
+ $cursor = ['id' => 1];
+ $direction = (new Direction('forward'))->inverse();
+ Group::create($orders, $cursor, $direction, false, true, false);
+ }
+
+ /**
+ * @test
+ * @expectedException \OutOfRangeException
+ */
+ public function validateAtLeastOneConstraintIsPassed()
+ {
+ Query::create([], [], 1, true, false, true);
+ }
+
+ /**
+ * Let's fill coverage
+ *
+ * @test
+ */
+ public function stupidTest()
+ {
+ $query = clone IlluminatePaginator::create(Post::query())->orderBy('id')->seekable()->configure(['id' => 1]);
+
+ $this->assertInternalType('bool', $query->seekable());
+ $this->assertTrue($query->selectOrUnionAll()->mainQuery() !== clone $query->selectOrUnionAll()->mainQuery());
+ $this->assertTrue($query->orders()[0]->ascending());
+
+ $mainQuery = $query->selectOrUnionAll()->mainQuery();
+ $supportQuery = $query->selectOrUnionAll()->supportQuery();
+
+ $this->assertTrue($supportQuery->limit()->isSupportQuery());
+
+ $condition = $mainQuery->where()->groups()[0]->conditions()[0];
+
+ $this->assertEquals($condition->left(), 'id');
+ $this->assertEquals($condition->comparator(), '>=');
+ $this->assertEquals($condition->right(), 1);
+ $this->assertTrue($condition->isPrimaryKey());
+
+ (new Paginator())->clearOrderBy()->orderByDesc('id')->inclusive()->unseekable()->configure(['id' => 1]);
+ }
+}
diff --git a/tests/SQLTest.php b/tests/SQLTest.php
new file mode 100644
index 0000000..259560e
--- /dev/null
+++ b/tests/SQLTest.php
@@ -0,0 +1,431 @@
+assertEquals($formatter->format($expected), $formatter->format($actual));
+ }
+
+ /**
+ * @test
+ */
+ public function asc_forward_start()
+ {
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->forward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('created_at')
+ ->orderBy('id')
+ ->seekable()
+ ->build();
+ $this->assertSqlEquals('
+ select * from `posts`
+ where `user_id` = ?
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 4
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function asc_forward_inclusive()
+ {
+ $cursor = ['updated_at' => '', 'created_at' => '', 'id' => ''];
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->forward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('created_at')
+ ->orderBy('id')
+ ->seekable()
+ ->build($cursor);
+ $this->assertSqlEquals('
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` < ? OR
+ `updated_at` = ? AND `created_at` < ? OR
+ `updated_at` < ?
+ )
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 1
+ )
+ union all
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` >= ? OR
+ `updated_at` = ? AND `created_at` > ? OR
+ `updated_at` > ?
+ )
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 4
+ )
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function asc_forward_exclusive()
+ {
+ $cursor = ['updated_at' => '', 'created_at' => '', 'id' => ''];
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->forward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('created_at')
+ ->orderBy('id')
+ ->seekable()
+ ->exclusive()
+ ->build($cursor);
+ $this->assertSqlEquals('
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` <= ? OR
+ `updated_at` = ? AND `created_at` < ? OR
+ `updated_at` < ?
+ )
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 1
+ )
+ union all
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` > ? OR
+ `updated_at` = ? AND `created_at` > ? OR
+ `updated_at` > ?
+ )
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 4
+ )
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function asc_backward_start()
+ {
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->backward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('created_at')
+ ->orderBy('id')
+ ->seekable()
+ ->build();
+ $this->assertSqlEquals('
+ select * from `posts`
+ where `user_id` = ?
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 4
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function asc_backward_inclusive()
+ {
+ $cursor = ['updated_at' => '', 'created_at' => '', 'id' => ''];
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->backward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('created_at')
+ ->orderBy('id')
+ ->seekable()
+ ->build($cursor);
+ $this->assertSqlEquals('
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` > ? OR
+ `updated_at` = ? AND `created_at` > ? OR
+ `updated_at` > ?
+ )
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 1
+ )
+ union all
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` <= ? OR
+ `updated_at` = ? AND `created_at` < ? OR
+ `updated_at` < ?
+ )
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 4
+ )
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function asc_backward_exclusive()
+ {
+ $cursor = ['updated_at' => '', 'created_at' => '', 'id' => ''];
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->backward()->limit(3)
+ ->orderBy('updated_at')
+ ->orderBy('created_at')
+ ->orderBy('id')
+ ->seekable()
+ ->exclusive()
+ ->build($cursor);
+ $this->assertSqlEquals('
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` >= ? OR
+ `updated_at` = ? AND `created_at` > ? OR
+ `updated_at` > ?
+ )
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 1
+ )
+ union all
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` < ? OR
+ `updated_at` = ? AND `created_at` < ? OR
+ `updated_at` < ?
+ )
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 4
+ )
+ ', $builder->toSql());
+ }
+
+
+ /**
+ * @test
+ */
+ public function desc_forward_start()
+ {
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->forward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('created_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->build();
+ $this->assertSqlEquals('
+ select * from `posts`
+ where `user_id` = ?
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 4
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function desc_forward_inclusive()
+ {
+ $cursor = ['updated_at' => '', 'created_at' => '', 'id' => ''];
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->forward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('created_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->build($cursor);
+ $this->assertSqlEquals('
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` > ? OR
+ `updated_at` = ? AND `created_at` > ? OR
+ `updated_at` > ?
+ )
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 1
+ )
+ union all
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` <= ? OR
+ `updated_at` = ? AND `created_at` < ? OR
+ `updated_at` < ?
+ )
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 4
+ )
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function desc_forward_exclusive()
+ {
+ $cursor = ['updated_at' => '', 'created_at' => '', 'id' => ''];
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->forward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('created_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->exclusive()
+ ->build($cursor);
+ $this->assertSqlEquals('
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` >= ? OR
+ `updated_at` = ? AND `created_at` > ? OR
+ `updated_at` > ?
+ )
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 1
+ )
+ union all
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` < ? OR
+ `updated_at` = ? AND `created_at` < ? OR
+ `updated_at` < ?
+ )
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 4
+ )
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function desc_backward_start()
+ {
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->backward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('created_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->build();
+ $this->assertSqlEquals('
+ select * from `posts`
+ where `user_id` = ?
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 4
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function desc_backward_inclusive()
+ {
+ $cursor = ['updated_at' => '', 'created_at' => '', 'id' => ''];
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->backward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('created_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->build($cursor);
+ $this->assertSqlEquals('
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` < ? OR
+ `updated_at` = ? AND `created_at` < ? OR
+ `updated_at` < ?
+ )
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 1
+ )
+ union all
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` >= ? OR
+ `updated_at` = ? AND `created_at` > ? OR
+ `updated_at` > ?
+ )
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 4
+ )
+ ', $builder->toSql());
+ }
+
+ /**
+ * @test
+ */
+ public function desc_backward_exclusive()
+ {
+ $cursor = ['updated_at' => '', 'created_at' => '', 'id' => ''];
+ $builder = Post::whereUserId(2)
+ ->lampage()
+ ->backward()->limit(3)
+ ->orderByDesc('updated_at')
+ ->orderByDesc('created_at')
+ ->orderByDesc('id')
+ ->seekable()
+ ->exclusive()
+ ->build($cursor);
+ $this->assertSqlEquals('
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` <= ? OR
+ `updated_at` = ? AND `created_at` < ? OR
+ `updated_at` < ?
+ )
+ order by `updated_at` desc, `created_at` desc, `id` desc
+ limit 1
+ )
+ union all
+ (
+ select * from `posts`
+ where `user_id` = ? AND (
+ `updated_at` = ? AND `created_at` = ? AND `id` > ? OR
+ `updated_at` = ? AND `created_at` > ? OR
+ `updated_at` > ?
+ )
+ order by `updated_at` asc, `created_at` asc, `id` asc
+ limit 4
+ )
+ ', $builder->toSql());
+ }
+}