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()); + } +}