diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml old mode 100644 new mode 100755 index 3991ffa..a063547 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,52 +1,47 @@ name: tests on: - push: - branches: - - master - pull_request: + push: + branches: + - master + - 9.x + pull_request: jobs: - tests: - - runs-on: ubuntu-latest - strategy: - fail-fast: true - matrix: - php: [7.2, 7.3, 7.4] - laravel: [^6.0, ^7.0, ^8.0] - scout: [^7.0, ^8.0] - exclude: - - php: 7.2 - laravel: ^8.0 - - laravel: ^8.0 - scout: ^7.0 - include: - - php: 8.0 - laravel: ^8.0 - scout: ^8.0 - - name: Test PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - Scout ${{ matrix.scout }} - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip - tools: composer:v2 - coverage: none - - - name: Install dependencies - run: | - composer require "illuminate/contracts=${{ matrix.laravel }}" --no-update - composer require "illuminate/database=${{ matrix.laravel }}" --no-update - composer require "illuminate/support=${{ matrix.laravel }}" --no-update - composer require "laravel/scout=${{ matrix.scout }}" --no-update - composer update --prefer-dist --no-interaction --no-progress - - - name: Run tests - run: vendor/bin/phpunit --verbose \ No newline at end of file + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + php: [8.3, 8.4] + laravel: [^11, ^12] + scout: [^10] + + name: Test PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - Scout ${{ matrix.scout }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: | + composer remove larastan/larastan --dev --no-update + composer remove orchestra/testbench --dev --no-update + composer remove tightenco/duster --dev --no-update + composer remove laravel/pint --dev --no-update + composer require "illuminate/contracts=${{ matrix.laravel }}" --no-update + composer require "illuminate/database=${{ matrix.laravel }}" --no-update + composer require "illuminate/support=${{ matrix.laravel }}" --no-update + composer require "laravel/scout=${{ matrix.scout }}" --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 382f5a0..1335ee0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /vendor .phpunit.result.cache -composer.lock \ No newline at end of file +composer.lock +.DS_Store +.idea diff --git a/README.md b/README.md index 065c6ac..511a028 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,15 @@ # PostgreSQL Full Text Search Engine for Laravel Scout -[![Latest Version on Packagist](https://img.shields.io/packagist/v/pmatseykanets/laravel-scout-postgres.svg?style=flat-square)](https://packagist.org/packages/pmatseykanets/laravel-scout-postgres) -[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) -![tests](https://github.com/pmatseykanets/laravel-scout-postgres/workflows/tests/badge.svg) -[![StyleCI](https://styleci.io/repos/67233265/shield)](https://styleci.io/repos/67233265) -[![Total Downloads](https://img.shields.io/packagist/dt/pmatseykanets/laravel-scout-postgres.svg?style=flat-square)](https://packagist.org/packages/pmatseykanets/laravel-scout-postgres) -[![License](https://poser.pugx.org/pmatseykanets/laravel-scout-postgres/license)](https://github.com/pmatseykanets/laravel-scout-postgres/blob/master/LICENSE.md) +![Build Status](https://github.com/devnoiseconsulting/laravel-scout-postgres-tsvector/workflows/tests/badge.svg) +[![Latest Stable Version](https://img.shields.io/packagist/v/devnoiseconsulting/laravel-scout-postgres-tsvector.svg)](https://packagist.org/packages/devnoiseconsulting/laravel-scout-postgres-tsvector) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) This package makes it easy to use native PostgreSQL Full Text Search capabilities with [Laravel Scout](http://laravel.com/docs/master/scout). -If you find this package usefull, please consider bying me a coffee. - -Buy Me a Coffee at ko-fi.com - ## Contents - [Installation](#installation) - [Laravel](#laravel) - - [Lumen](#lumen) - [Configuration](#configuration) - [Configuring the Engine](#configuring-the-engine) - [Configuring PostgreSQL](#configuring-postgresql) @@ -36,8 +28,8 @@ If you find this package usefull, please consider bying me a coffee. You can install the package via composer: -``` bash -composer require pmatseykanets/laravel-scout-postgres +```bash +composer require devnoiseconsulting/laravel-scout-postgres-tsvector ``` ### Laravel @@ -52,52 +44,6 @@ If you're using Laravel < 5.5 or if you have package auto-discovery turned off y ], ``` -### Lumen - -Scout service provider uses `config_path` helper that is not included in Lumen. -To fix this include the following snippet either directly in `bootstrap.app` or in your autoloaded helpers file i.e. `app/helpers.php`. - -```php -if (! function_exists('config_path')) { - /** - * Get the configuration path. - * - * @param string $path - * @return string - */ - function config_path($path = '') - { - return app()->basePath() . '/config'.($path ? DIRECTORY_SEPARATOR.$path : $path); - } -} -``` - -Create the `scout.php` config file in `app/config` folder with the following contents - -```php - env('SCOUT_DRIVER', 'pgsql'), - 'prefix' => env('SCOUT_PREFIX', ''), - 'queue' => false, - 'pgsql' => [ - 'connection' => 'pgsql', - 'maintain_index' => true, - 'config' => 'english', - ], -]; -``` - -Register service providers: - -```php -// bootstrap/app.php -$app->register(Laravel\Scout\ScoutServiceProvider::class); -$app->configure('scout'); -$app->register(ScoutEngines\Postgres\PostgresEngineServiceProvider::class); -``` - ## Configuration ### Configuring the Engine @@ -126,7 +72,7 @@ Specify the database connection that should be used to access indexed documents ### Configuring PostgreSQL -Make sure that an appropriate [default text search configuration](https://www.postgresql.org/docs/9.5/static/runtime-config-client.html#GUC-DEFAULT-TEXT-SEARCH-CONFIG) is set globbaly (in `postgresql.conf`), for a particular database (`ALTER DATABASE ... SET default_text_search_config TO ...`) or alternatively set `default_text_search_config` in each session. +Make sure that an appropriate [default text search configuration](https://www.postgresql.org/docs/14/runtime-config-client.html#GUC-DEFAULT-TEXT-SEARCH-CONFIG) is set globbaly (in `postgresql.conf`), for a particular database (`ALTER DATABASE ... SET default_text_search_config TO ...`) or alternatively set `default_text_search_config` in each session. To check the current value @@ -265,7 +211,7 @@ $posts = App\Post::search('fat & (cat | rat)') ->usingTsQuery()->get() // websearch_to_tsquery() -// uses web search syntax +// uses web search syntax $posts = App\Post::search('"sad cat" or "fat rat" -mouse') ->usingWebSearchQuery()->get() @@ -281,13 +227,13 @@ Please see the [official documentation](http://laravel.com/docs/master/scout) on ## Testing -``` bash +```bash composer test ``` ## Security -If you discover any security related issues, please email pmatseykanets@gmail.com instead of using the issue tracker. +If you discover any security related issues, please email flynnmj@devnoise.com instead of using the issue tracker. ## Changelog @@ -299,6 +245,7 @@ Please see [CONTRIBUTING](CONTRIBUTING.md) for details. ## Credits +- [Michael Flynn](https://github.com/devNoiseConsulting) - [Peter Matseykanets](https://github.com/pmatseykanets) - [All Contributors](../../contributors) diff --git a/composer.json b/composer.json old mode 100644 new mode 100755 index 305a86c..b109621 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "pmatseykanets/laravel-scout-postgres", + "name": "devnoiseconsulting/laravel-scout-postgres-tsvector", "description": "PostgreSQL Full Text Search Driver for Laravel Scout", "keywords": [ "laravel", @@ -9,13 +9,18 @@ "full text search", "FTS" ], - "homepage": "https://github.com/pmatseykanets/laravel-scout-postgres", + "homepage": "https://github.com/devNoiseConsulting/laravel-scout-postgres-tsvector", "license": "MIT", "support": { - "issues": "https://github.com/pmatseykanets/laravel-scout-postgres/issues", - "source": "https://github.com/pmatseykanets/laravel-scout-postgres" + "issues": "https://github.com/devNoiseConsulting/laravel-scout-postgres-tsvector/issues", + "source": "https://github.com/devNoiseConsulting/laravel-scout-postgres-tsvector" }, "authors": [ + { + "name": "Michael Flynn", + "email": "flynnmj@devnoise.com", + "homepage": "https://github.com/devNoiseConsulting" + }, { "name": "Peter Matseykanets", "email": "pmatseykanets@gmail.com", @@ -23,15 +28,19 @@ } ], "require": { - "php": "^7.2|^8.0", - "illuminate/contracts": "~6.0|~7.0|~8.0", - "illuminate/database": "~6.0|~7.0|~8.0", - "illuminate/support": "~6.0|~7.0|~8.0", - "laravel/scout": "~7.0|~8.0" + "php": "^8.1|^8.2|^8.3|^8.4", + "illuminate/contracts": "^10|^11|^12", + "illuminate/database": "^10|^11|^12", + "illuminate/support": "^10|^11|^12", + "laravel/scout": "^10|^11|^12" }, "require-dev": { - "phpunit/phpunit": "^8.3", - "mockery/mockery": "^1.2.3" + "larastan/larastan": "^3.3", + "laravel/pint": "^1.22", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^10.2", + "phpunit/phpunit": "^12.1", + "tightenco/duster": "^2.7" }, "autoload": { "psr-4": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..00726a5 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,21 @@ +parameters: + ignoreErrors: + - + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:searchableAs\\(\\)\\.$#" + count: 2 + path: src/PostgresEngine.php + + - + message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:toSearchableArray\\(\\)\\.$#" + count: 1 + path: src/PostgresEngine.php + - + message: "#^Call to an undefined method TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:searchableAs\\(\\)\\.$#" + count: 2 + path: src/PostgresEngine.php + + - + message: "#^Call to an undefined method TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:getDeletedAtColumn\\(\\)\\.$#" + count: 2 + path: src/PostgresEngine.php + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..2aeb4fe --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - phpstan-baseline.neon + +parameters: + + paths: + - src/ + + # Level 9 is the highest level + level: 9 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f2eebe0..530b980 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,22 +1,13 @@ - + + + + src/ + + tests - - - src/ - - diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..2964efd --- /dev/null +++ b/pint.json @@ -0,0 +1,45 @@ +{ + "preset": "laravel", + "rules": { + "blank_line_between_import_groups": true, + "concat_space": { + "spacing": "one" + }, + "class_attributes_separation": { + "elements": { + "method": "one" + } + }, + "curly_braces_position": { + "control_structures_opening_brace": "same_line", + "functions_opening_brace": "next_line_unless_newline_at_signature_end", + "anonymous_functions_opening_brace": "same_line", + "classes_opening_brace": "next_line_unless_newline_at_signature_end", + "anonymous_classes_opening_brace": "next_line_unless_newline_at_signature_end", + "allow_single_line_empty_anonymous_classes": true, + "allow_single_line_anonymous_functions": false + }, + "explicit_string_variable": true, + "global_namespace_import": { + "import_classes": true, + "import_constants": true, + "import_functions": true + }, + "new_with_braces": { + "named_class": false, + "anonymous_class": false + }, + "ordered_imports": { + "sort_algorithm": "alpha", + "imports_order": [ + "const", + "class", + "function" + ] + }, + "php_unit_test_annotation": { + "style": "annotation" + }, + "simple_to_complex_string_variable": true + } +} diff --git a/src/PostgresEngine.php b/src/PostgresEngine.php old mode 100644 new mode 100755 index b92b3e0..d1fd1cd --- a/src/PostgresEngine.php +++ b/src/PostgresEngine.php @@ -2,22 +2,30 @@ namespace ScoutEngines\Postgres; +use Exception; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\PostgresConnection; use Illuminate\Support\Arr; +use Illuminate\Support\LazyCollection; +use InvalidArgumentException; use Laravel\Scout\Builder; use Laravel\Scout\Engines\Engine; use ScoutEngines\Postgres\TsQuery\PhraseToTsQuery; use ScoutEngines\Postgres\TsQuery\PlainToTsQuery; use ScoutEngines\Postgres\TsQuery\ToTsQuery; +/** + * @template TModel of \Illuminate\Database\Eloquent\Model + * @template TID of int|string + */ class PostgresEngine extends Engine { /** * Database connection. * - * @var \Illuminate\Database\Connection + * @var \Illuminate\Database\PostgresConnection */ protected $database; @@ -31,7 +39,7 @@ class PostgresEngine extends Engine /** * Config values. * - * @var array + * @var array */ protected $config = []; @@ -43,8 +51,7 @@ class PostgresEngine extends Engine /** * Create a new instance of PostgresEngine. * - * @param \Illuminate\Database\ConnectionResolverInterface $resolver - * @param $config + * @param array $config */ public function __construct(ConnectionResolverInterface $resolver, $config) { @@ -57,7 +64,7 @@ public function __construct(ConnectionResolverInterface $resolver, $config) /** * Update the given models in the index. * - * @param \Illuminate\Database\Eloquent\Collection $models + * @param \Illuminate\Database\Eloquent\Collection $models * @return void */ public function update($models) @@ -71,11 +78,207 @@ public function update($models) } } + /** + * Remove the given model from the index. + * + * @param \Illuminate\Database\Eloquent\Collection $models + * @return void + */ + public function delete($models) + { + $model = $models->first(); + + if ($model) { + if (! $this->shouldMaintainIndex($model)) { + return; + } + + $indexColumn = $this->getIndexColumn($model); + $key = $model->getKeyName(); + + $ids = $models->pluck($key)->all(); + + $this->database + ->table($model->searchableAs()) + ->whereIn($key, $ids) + ->update([$indexColumn => null]); + + } + } + + /** + * Perform the given search on the engine. + * + * @param \Laravel\Scout\Builder $builder + * @return mixed + */ + public function search(Builder $builder) + { + return $this->performSearch($builder, $builder->limit); + } + + /** + * Perform the given search on the engine. + * + * @param \Laravel\Scout\Builder $builder + * @param int $perPage + * @param int $page + * @return mixed + */ + public function paginate(Builder $builder, $perPage, $page) + { + return $this->performSearch($builder, $perPage, $page); + } + + /** + * Get the total count from a raw result returned by the engine. + * + * @param mixed $results + * @return int + */ + public function getTotalCount($results) + { + if (empty($results)) { + return 0; + } + + /** @var array $results */ + /** @var object{'id': mixed, 'rank': string, 'total_count': int} $result */ + $result = Arr::first($results); + + return (int) $result->total_count; + } + + /** + * Returns the default query method. + * + * @param string $query + * @param string $config + * @return \ScoutEngines\Postgres\TsQuery\TsQueryable + */ + public function defaultQueryMethod($query, $config) + { + switch (strtolower($this->stringConfig('search_using', 'plainquery'))) { + case 'tsquery': + return new ToTsQuery($query, $config); + case 'phrasequery': + return new PhraseToTsQuery($query, $config); + case 'plainquery': + default: + return new PlainToTsQuery($query, $config); + } + } + + /** + * Pluck and return the primary keys of the given results. + * + * @param mixed $results + * @return \Illuminate\Support\Collection + */ + public function mapIds($results) + { + $keyName = $this->model !== null ? $this->model->getKeyName() : 'id'; + + /** @var array $results */ + return collect($results) + ->pluck($keyName) + ->values(); + } + + /** + * Map the given results to instances of the given model. + * + * @param \Laravel\Scout\Builder $builder + * @param mixed $results + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Database\Eloquent\Collection + */ + public function map(Builder $builder, $results, $model) + { + $resultModels = Collection::make(); + + if (empty($results)) { + return $resultModels; + } + + $keys = $this->mapIds($results); + + $models = $model->whereIn($model->getKeyName(), $keys->all()) + ->get() + ->keyBy($model->getKeyName()); + + // The models didn't come out of the database in the correct order. + // This will map the models into the resultsModel based on the results order. + /** @var int $key */ + foreach ($keys as $key) { + if ($models->has($key)) { + $resultModels->push($models[$key]); + } + } + + return $resultModels; + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param \Laravel\Scout\Builder $builder + * @param mixed $results + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\LazyCollection + */ + public function lazyMap(Builder $builder, $results, $model) + { + // return LazyCollection::make($model->newCollection()); + return LazyCollection::make($this->map($builder, $results, $model)->all()); + } + + /** + * Create a search index. + * + * @param string $name + * @param array $options + * @return mixed + */ + public function createIndex($name, $options = []) + { + throw new Exception('PostgreSQL indexes should be created through Laravel database migrations.'); + } + + /** + * Delete a search index. + * + * @param string $name + * @return mixed + */ + public function deleteIndex($name) + { + throw new Exception('PostgreSQL indexes should be deleted through Laravel database migrations.'); + } + + /** + * Flush all of the model's records from the engine. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return void + */ + public function flush($model) + { + if (! $this->shouldMaintainIndex($model)) { + return; + } + + $indexColumn = $this->getIndexColumn($model); + + $this->database + ->table($model->searchableAs()) + ->update([$indexColumn => null]); + } + /** * Perform update of the given model. * - * @param \Illuminate\Database\Eloquent\Model $model - * @return bool + * @return bool|int */ protected function performUpdate(Model $model) { @@ -93,22 +296,21 @@ protected function performUpdate(Model $model) return $query->update($data->all()); } + $modelKeyInfo = collect([$model->getKeyName() => $model->getKey()]); + return $query->insert( - $data->merge([ - $model->getKeyName() => $model->getKey(), - ])->all() + $data->merge($modelKeyInfo)->all() ); } /** * Get the indexed value for a given model. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return string */ - protected function toVector(Model $model) + protected function toVector(Model $model): mixed { - $fields = collect($model->toSearchableArray()) + /** @var array $searchableArray */ + $searchableArray = $model->toSearchableArray(); + $fields = collect($searchableArray) ->map(function ($value) { return $value === null ? '' : $value; }); @@ -127,7 +329,7 @@ protected function toVector(Model $model) // Set a field weight if it was specified in Model's searchableOptions() if ($label = $this->rankFieldWeightLabel($model, $key)) { - $vector = "setweight($vector, ?)"; + $vector = "setweight({$vector}, ?)"; $bindings->push($label); } @@ -136,84 +338,17 @@ protected function toVector(Model $model) return $this->database ->query() - ->selectRaw("$select AS tsvector", $bindings->all()) + ->selectRaw("{$select} AS tsvector", $bindings->all()) ->value('tsvector'); } - /** - * Remove the given model from the index. - * - * @param \Illuminate\Database\Eloquent\Collection $models - * @return void - */ - public function delete($models) - { - $model = $models->first(); - - if (! $this->shouldMaintainIndex($model)) { - return; - } - - $indexColumn = $this->getIndexColumn($model); - $key = $model->getKeyName(); - - $ids = $models->pluck($key)->all(); - - $this->database - ->table($model->searchableAs()) - ->whereIn($key, $ids) - ->update([$indexColumn => null]); - } - - /** - * Perform the given search on the engine. - * - * @param \Laravel\Scout\Builder $builder - * @return mixed - */ - public function search(Builder $builder) - { - return $this->performSearch($builder, $builder->limit); - } - /** * Perform the given search on the engine. * - * @param \Laravel\Scout\Builder $builder - * @param int $perPage - * @param int $page - * @return mixed - */ - public function paginate(Builder $builder, $perPage, $page) - { - return $this->performSearch($builder, $perPage, $page); - } - - /** - * Get the total count from a raw result returned by the engine. - * - * @param mixed $results - * @return int + * @param \Laravel\Scout\Builder $builder + * @return array */ - public function getTotalCount($results) - { - if (empty($results)) { - return 0; - } - - return (int) Arr::first($results) - ->total_count; - } - - /** - * Perform the given search on the engine. - * - * @param \Laravel\Scout\Builder $builder - * @param int|null $perPage - * @param int $page - * @return array - */ - protected function performSearch(Builder $builder, $perPage = 0, $page = 1) + protected function performSearch(Builder $builder, ?int $perPage = 0, int $page = 1): ?array { // We have to preserve the model in order to allow for // correct behavior of mapIds() method which currently @@ -228,19 +363,37 @@ protected function performSearch(Builder $builder, $perPage = 0, $page = 1) ->select($builder->model->getKeyName()) ->selectRaw("{$this->rankingExpression($builder->model, $indexColumn)} AS rank") ->selectRaw('COUNT(*) OVER () AS total_count') - ->whereRaw("$indexColumn @@ \"tsquery\""); + ->whereRaw("{$indexColumn} @@ \"tsquery\""); + + // Apply query callback if set + if ($builder->queryCallback) { + call_user_func($builder->queryCallback, $builder); + } // Apply where clauses that were set on the builder instance if any foreach ($builder->wheres as $key => $value) { + if ($key == '__soft_deleted') { + if ($this->usesSoftDeletes($builder->model)) { + if ($value == 1) { + $query->whereNotNull($builder->model->getDeletedAtColumn()); + } else { + $query->whereNull($builder->model->getDeletedAtColumn()); + } + } + + continue; + } $query->where($key, $value); } - // If parsed documents are being stored in the model's table - if (! $this->isExternalIndex($builder->model)) { - // and the model uses soft deletes we need to exclude trashed rows - if ($this->usesSoftDeletes($builder->model)) { - $query->whereNull($builder->model->getDeletedAtColumn()); - } + // Apply whereIn clauses that were set on the builder instance if any + foreach ($builder->whereIns as $key => $value) { + $query->whereIn($key, $value); + } + + // Apply whereNoIn clauses that were set on the builder instance if any + foreach ($builder->whereNotIns as $key => $value) { + $query->whereNotIn($key, $value); } // Apply order by clauses that were set on the builder instance if any @@ -267,7 +420,8 @@ protected function performSearch(Builder $builder, $perPage = 0, $page = 1) ? call_user_func($builder->callback, $builder, $this->searchConfig($builder->model), $query) : $this->defaultQueryMethod($builder->query, $this->searchConfig($builder->model)); - $query->crossJoin($this->database->raw($tsQuery->sql().' AS "tsquery"')); + /** @var \ScoutEngines\Postgres\TsQuery\BaseTsQueryable $tsQuery */ + $query->crossJoin($this->database->raw($tsQuery->sql() . ' AS "tsquery"')); // Add TS bindings to the query $query->addBinding($tsQuery->bindings(), 'join'); @@ -275,74 +429,10 @@ protected function performSearch(Builder $builder, $perPage = 0, $page = 1) ->select($query->toSql(), $query->getBindings()); } - /** - * Returns the default query method. - * - * @param string $query - * @param string $config - * @return \ScoutEngines\Postgres\TsQuery\TsQueryable - */ - public function defaultQueryMethod($query, $config) - { - switch (strtolower($this->config('search_using', 'plain'))) { - case 'tsquery': - return new ToTsQuery($query, $config); - case 'phrasequery': - return new PhraseToTsQuery($query, $config); - case 'plainquery': - default: - return new PlainToTsQuery($query, $config); - } - } - - /** - * Pluck and return the primary keys of the given results. - * - * @param mixed $results - * @return \Illuminate\Support\Collection - */ - public function mapIds($results) - { - $keyName = $this->model ? $this->model->getKeyName() : 'id'; - - return collect($results) - ->pluck($keyName) - ->values(); - } - - /** - * Map the given results to instances of the given model. - * - * @param \Laravel\Scout\Builder $builder - * @param mixed $results - * @param \Illuminate\Database\Eloquent\Model $model - * @return \Illuminate\Database\Eloquent\Collection - */ - public function map(Builder $builder, $results, $model) - { - if (empty($results)) { - return Collection::make(); - } - - $keys = $this->mapIds($results); - - $results = collect($results); - - $models = $model->whereIn($model->getKeyName(), $keys->all()) - ->get() - ->keyBy($model->getKeyName()); - - return $results->pluck($model->getKeyName()) - ->intersect($models->keys()) // Filter out no longer existing models (i.e. soft deleted) - ->map(function ($key) use ($models) { - return $models[$key]; - }); - } - /** * Connect to the database. */ - protected function connect() + protected function connect(): void { // Already connected if ($this->database !== null) { @@ -350,66 +440,55 @@ protected function connect() } $connection = $this->resolver - ->connection($this->config('connection')); + ->connection($this->stringConfig('connection')); - if ($connection->getDriverName() !== 'pgsql') { - throw new \InvalidArgumentException('Connection should use pgsql driver.'); + if ($connection instanceof PostgresConnection) { + $this->database = $connection; + } else { + throw new InvalidArgumentException('Connection should use pgsql driver.'); } - - $this->database = $connection; } /** * Build ranking expression that will be used in a search. * ts_rank([ weights, ] vector, query [, normalization ]) * ts_rank_cd([ weights, ] vector, query [, normalization ]). - * - * @param \Illuminate\Database\Eloquent\Model $model - * @param string $indexColumn - * @return string */ - protected function rankingExpression(Model $model, $indexColumn) + protected function rankingExpression(Model $model, string $indexColumn): string { $args = collect([$indexColumn, '"tsquery"']); if ($weights = $this->rankWeights($model)) { - $args->prepend("'$weights'"); + $args->prepend("'{$weights}'"); } if ($norm = $this->rankNormalization($model)) { - $args->push($norm); + $args->push((string) $norm); } $fn = $this->rankFunction($model); - return "$fn({$args->implode(',')})"; + return "{$fn}({$args->implode(',')})"; } /** * Get rank function. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return int */ - protected function rankFunction(Model $model) + protected function rankFunction(Model $model): string { $default = 'ts_rank'; - $function = $this->option($model, 'rank.function', $default); + $function = $this->stringOption($model, 'rank.function', $default); return collect(['ts_rank', 'ts_rank_cd'])->contains($function) ? $function : $default; } /** * Get the rank weight label for a given field. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @param string $field - * @return string */ - protected function rankFieldWeightLabel(Model $model, $field) + protected function rankFieldWeightLabel(Model $model, string $field): string { - $label = $this->option($model, "rank.fields.$field"); + $label = $this->stringOption($model, "rank.fields.{$field}"); return collect(['A', 'B', 'C', 'D']) ->contains($label) ? $label : ''; @@ -417,11 +496,8 @@ protected function rankFieldWeightLabel(Model $model, $field) /** * Get rank weights. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return string */ - protected function rankWeights(Model $model) + protected function rankWeights(Model $model): string { $weights = $this->option($model, 'rank.weights'); @@ -429,68 +505,53 @@ protected function rankWeights(Model $model) return ''; } - return '{'.implode(',', $weights).'}'; + return '{' . implode(',', $weights) . '}'; } /** * Get rank normalization. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return int */ - protected function rankNormalization(Model $model) + protected function rankNormalization(Model $model): int { - return $this->option($model, 'rank.normalization', 0); + return $this->intOption($model, 'rank.normalization', 0); } /** * See if the index should be maintained for a given model. - * - * @param \Illuminate\Database\Eloquent\Model|null $model - * @return bool */ - protected function shouldMaintainIndex(Model $model = null) + protected function shouldMaintainIndex(?Model $model = null): bool { if ((bool) $this->config('maintain_index', true) === false) { return false; } if ($model !== null) { - return $this->option($model, 'maintain_index', true); + return (bool) $this->option($model, 'maintain_index', true); } + + return false; } /** * Get the name of the column that holds indexed documents. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return string */ - protected function getIndexColumn(Model $model) + protected function getIndexColumn(Model $model): string { - return $this->option($model, 'column', 'searchable'); + return $this->stringOption($model, 'column', 'searchable'); } /** * See if indexed documents are stored in a external table. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return mixed */ - protected function isExternalIndex(Model $model) + protected function isExternalIndex(Model $model): mixed { return $this->option($model, 'external', false); } /** * Get the model specific option value or a default. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @param string $key - * @param mixed $default - * @return mixed */ - protected function option(Model $model, $key, $default = null) + protected function option(Model $model, string $key, mixed $default = null): mixed { if (! method_exists($model, 'searchableOptions')) { return $default; @@ -502,63 +563,73 @@ protected function option(Model $model, $key, $default = null) } /** - * Get the config value or a default. - * - * @param string $key - * @param mixed $default - * @return mixed + * Get the model specific option value or a default as an int. */ - protected function config($key, $default = null) + protected function intOption(Model $model, string $key, int $default): int { - return Arr::get($this->config, $key, $default); + $value = $this->option($model, $key, $default); + + if (is_int($value)) { + return $value; + } else { + return $default; + } } /** - * @param \Illuminate\Database\Eloquent\Model $model + * Get the model specific option value or a default as a string. */ - protected function preserveModel(Model $model) + protected function stringOption(Model $model, string $key, string $default = ''): string { - $this->model = $model; + $value = $this->option($model, $key, $default); + + if (is_string($value)) { + return $value; + } else { + return $default; + } } /** - * Returns a search config name for a model. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return string + * Get the config value or a default. */ - protected function searchConfig(Model $model) + protected function config(string $key, mixed $default = null): mixed { - return $this->option($model, 'config', $this->config('config', '')) ?: null; + return Arr::get($this->config, $key, $default); } /** - * Checks if the model uses the SoftDeletes trait. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return bool + * Get the config value or a default as a string. */ - protected function usesSoftDeletes(Model $model) + protected function stringConfig(string $key, string $default = ''): string { - return method_exists($model, 'getDeletedAtColumn'); + $value = $this->config($key, $default); + + if (is_string($value)) { + return $value; + } else { + return $default; + } + } + + protected function preserveModel(Model $model): void + { + $this->model = $model; } /** - * Flush all of the model's records from the engine. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return void + * Returns a search config name for a model. */ - public function flush($model) + protected function searchConfig(Model $model): string { - if (! $this->shouldMaintainIndex($model)) { - return; - } - - $indexColumn = $this->getIndexColumn($model); + return $this->stringOption($model, 'config', $this->stringConfig('config', '')) ?: ''; + } - $this->database - ->table($model->searchableAs()) - ->update([$indexColumn => null]); + /** + * Checks if the model uses the SoftDeletes trait. + */ + protected function usesSoftDeletes(Model $model): bool + { + return method_exists($model, 'getDeletedAtColumn'); } } diff --git a/src/PostgresEngineServiceProvider.php b/src/PostgresEngineServiceProvider.php index 3946ffd..81d3cd1 100644 --- a/src/PostgresEngineServiceProvider.php +++ b/src/PostgresEngineServiceProvider.php @@ -10,9 +10,16 @@ use ScoutEngines\Postgres\TsQuery\ToTsQuery; use ScoutEngines\Postgres\TsQuery\WebSearchToTsQuery; +/** + * @template TModel of \Illuminate\Database\Eloquent\Model + * @template TID of int|string + */ class PostgresEngineServiceProvider extends ServiceProvider { - public static function builderMacros() + /** + * @return array + */ + public static function builderMacros(): array { return [ 'usingPhraseQuery' => PhraseToTsQuery::class, @@ -22,13 +29,20 @@ public static function builderMacros() ]; } - public function boot() + /** + * Bootstrap the application events. + */ + public function boot(): void { $this->app->make(EngineManager::class)->extend('pgsql', function () { - return new PostgresEngine( - $this->app->get('db'), - $this->app->get('config')->get('scout.pgsql', []) - ); + /** @var \Illuminate\Database\ConnectionResolverInterface $db */ + $db = $this->app->get('db'); + /** @var \Illuminate\Support\Facades\Config $config */ + $config = $this->app->get('config'); + /** @var array $pgScoutConfig */ + $pgScoutConfig = $config->get('scout.pgsql', []); + + return new PostgresEngine($db, $pgScoutConfig); }); foreach (self::builderMacros() as $macro => $class) { @@ -36,10 +50,11 @@ public function boot() } } - protected function registerBuilderMacro($name, $class) + protected function registerBuilderMacro(string $name, string $class): void { if (! Builder::hasMacro($name)) { Builder::macro($name, function () use ($class) { + /** @var Builder $this */ $this->callback = function ($builder, $config) use ($class) { return new $class($builder->query, $config); }; diff --git a/src/TsQuery/BaseTsQueryable.php b/src/TsQuery/BaseTsQueryable.php index 48dddad..fb02a75 100644 --- a/src/TsQuery/BaseTsQueryable.php +++ b/src/TsQuery/BaseTsQueryable.php @@ -28,8 +28,8 @@ abstract class BaseTsQueryable implements TsQueryable /** * Create a new instance. * - * @param string $query - * @param string $config + * @param string $query + * @param string $config */ public function __construct($query, $config = null) { @@ -50,7 +50,7 @@ public function sql() /** * Return value bindings for the SQL representation. * - * @return array + * @return array */ public function bindings() { diff --git a/src/TsQuery/TsQueryable.php b/src/TsQuery/TsQueryable.php index 836a340..4f53439 100644 --- a/src/TsQuery/TsQueryable.php +++ b/src/TsQuery/TsQueryable.php @@ -14,7 +14,7 @@ public function sql(); /** * Return value bindings for the SQL representation. * - * @return array + * @return array */ public function bindings(); } diff --git a/tests/PostgresEngineTest.php b/tests/PostgresEngineTest.php index f3a060e..f475d36 100644 --- a/tests/PostgresEngineTest.php +++ b/tests/PostgresEngineTest.php @@ -2,84 +2,106 @@ namespace ScoutEngines\Postgres\Test; -use Illuminate\Database\Connection; +use Exception; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\PostgresConnection; use Laravel\Scout\Builder; use Mockery; use ScoutEngines\Postgres\PostgresEngine; class PostgresEngineTest extends TestCase { - public function test_it_can_be_instantiated() + /** + * @test + */ + public function it_can_be_instantiated() { [$engine] = $this->getEngine(); $this->assertInstanceOf(PostgresEngine::class, $engine); } - public function test_update_adds_object_to_index() + /** + * @test + */ + public function update_adds_object_to_index() { [$engine, $db] = $this->getEngine(); $db->shouldReceive('query') - ->andReturn($query = Mockery::mock('stdClass')); + ->andReturn($query = Mockery::mock('stdClass'))->once(); $query->shouldReceive('selectRaw') ->with( 'to_tsvector(COALESCE(?, get_current_ts_config()), ?) || setweight(to_tsvector(COALESCE(?, get_current_ts_config()), ?), ?) AS tsvector', [null, 'Foo', null, '', 'B'] ) - ->andReturnSelf(); + ->andReturnSelf()->once(); $query->shouldReceive('value') ->with('tsvector') - ->andReturn('foo'); + ->andReturn('foo')->once(); $db->shouldReceive('table') ->andReturn($table = Mockery::mock('stdClass')); $table->shouldReceive('where') ->with('id', '=', 1) - ->andReturnSelf(); + ->andReturnSelf()->once(); $table->shouldReceive('update') - ->with(['searchable' => 'foo']); + ->with(['searchable' => 'foo'])->once(); - $engine->update(Collection::make([new TestModel()])); + $engine->update(Collection::make([new TestModel])); } - public function test_update_do_nothing_if_index_maintenance_turned_off_globally() + /** + * @test + */ + public function update_do_nothing_if_index_maintenance_turned_off_globally() { [$engine] = $this->getEngine(['maintain_index' => false]); - $engine->update(Collection::make([new TestModel()])); + $this->assertNull($engine->update(Collection::make([new TestModel]))); } - public function test_delete_removes_object_from_index() + /** + * @test + */ + public function delete_removes_object_from_index() { [$engine, $db] = $this->getEngine(); $db->shouldReceive('table') - ->andReturn($table = Mockery::mock('stdClass')); + ->andReturn($table = Mockery::mock('stdClass')) + ->once(); $table->shouldReceive('whereIn') ->with('id', [1]) - ->andReturnSelf(); + ->andReturnSelf() + ->once(); $table->shouldReceive('update') - ->with(['searchable' => null]); + ->with(['searchable' => null]) + ->once(); - $engine->delete(Collection::make([new TestModel()])); + $engine->delete(Collection::make([new TestModel])); } - public function test_delete_do_nothing_if_index_maintenance_turned_off_globally() + /** + * @test + */ + public function delete_do_nothing_if_index_maintenance_turned_off_globally() { [$engine, $db] = $this->getEngine(['maintain_index' => false]); $db->shouldNotReceive('table'); - $engine->delete(Collection::make([new TestModel()])); + $engine->delete(Collection::make([new TestModel])); } - public function test_flush_removes_all_objects_from_index() + /** + * @test + */ + public function flush_removes_all_objects_from_index() { [$engine, $db] = $this->getEngine(); @@ -90,19 +112,25 @@ public function test_flush_removes_all_objects_from_index() ->once() ->with(['searchable' => null]); - $engine->flush(new TestModel()); + $engine->flush(new TestModel); } - public function test_flush_does_nothing_if_index_maintenance_turned_off_globally() + /** + * @test + */ + public function flush_does_nothing_if_index_maintenance_turned_off_globally() { [$engine, $db] = $this->getEngine(['maintain_index' => false]); $db->shouldNotReceive('table'); - $engine->flush(new TestModel()); + $engine->flush(new TestModel); } - public function test_search() + /** + * @test + */ + public function search() { [$engine, $db] = $this->getEngine(); @@ -117,9 +145,10 @@ public function test_search() ->shouldReceive('getBindings')->andReturn([null, 'foo', 1, 'qux']); $db->shouldReceive('select') - ->with(null, $table->getBindings()); + ->with(null, $table->getBindings()) + ->once(); - $builder = new Builder(new TestModel(), 'foo'); + $builder = new Builder(new TestModel, 'foo'); $builder->where('bar', 1) ->where('baz', 'qux') ->take(5); @@ -127,7 +156,10 @@ public function test_search() $engine->search($builder); } - public function test_search_with_order_by() + /** + * @test + */ + public function search_with_order_by() { [$engine, $db] = $this->getEngine(); @@ -138,16 +170,99 @@ public function test_search_with_order_by() ->shouldReceive('getBindings')->andReturn([null, 'foo']); $db->shouldReceive('select') - ->with(null, $table->getBindings()); + ->with(null, $table->getBindings()) + ->once(); - $builder = new Builder(new TestModel(), 'foo'); + $builder = new Builder(new TestModel, 'foo'); $builder->orderBy('bar', 'desc') ->orderBy('baz', 'asc'); $engine->search($builder); } - public function test_search_with_global_config() + /** + * @test + */ + public function search_with_queryCallback() + { + [$engine, $db] = $this->getEngine(); + + $skip = 0; + $limit = 5; + $table = $this->setDbExpectations($db); + + $table->shouldReceive('skip')->with($skip)->andReturnSelf() + ->shouldReceive('limit')->with($limit)->andReturnSelf() + ->shouldReceive('where')->with('bar', 1)->andReturnSelf() + ->shouldReceive('where')->with('baz', 'qux') + ->shouldReceive('getBindings')->andReturn([null, 'foo', 1, 'qux']); + + $db->shouldReceive('select') + ->with(null, $table->getBindings()) + ->once(); + + $builder = new Builder(new TestModel, 'foo'); + $builder->query(function ($q) { + $q->where('bar', 1) + ->where('baz', 'qux') + ->take(5); + }); + + $engine->search($builder); + } + + /** + * @test + */ + public function search_with_whereIn() + { + [$engine, $db] = $this->getEngine(); + + $skip = 0; + $limit = 5; + $table = $this->setDbExpectations($db); + + $table->shouldReceive('whereIn')->with('bar', [1])->andReturnSelf() + ->shouldReceive('getBindings')->andReturn([null, 'foo', [1]]); + + $db->shouldReceive('select') + ->with(null, $table->getBindings()) + ->once(); + + $builder = new Builder(new TestModel, 'foo'); + $builder->whereIn('bar', [1]); + + $engine->search($builder); + } + + /** + * @test + */ + public function search_with_whereNotIn() + { + [$engine, $db] = $this->getEngine(); + + $skip = 0; + $limit = 5; + $table = $this->setDbExpectations($db); + + $table->shouldReceive('whereNotIn')->with('bar', [1])->andReturnSelf() + ->shouldReceive('getBindings')->andReturn([null, 'foo', [1]]); + + $db->shouldReceive('select') + ->with(null, $table->getBindings()) + ->once(); + + $builder = new Builder(new TestModel, 'foo'); + $builder->whereNotIn('bar', [1]); + + $engine->search($builder); + } + + /** + * @test + */ + public function search_with_global_config() { [$engine, $db] = $this->getEngine(['config' => 'simple']); @@ -160,15 +275,18 @@ public function test_search_with_global_config() ->shouldReceive('where')->with('bar', 1) ->shouldReceive('getBindings')->andReturn(['simple', 'foo', 1]); - $db->shouldReceive('select')->with(null, $table->getBindings()); + $db->shouldReceive('select')->with(null, $table->getBindings())->once(); - $builder = new Builder(new TestModel(), 'foo'); + $builder = new Builder(new TestModel, 'foo'); $builder->where('bar', 1)->take(5); $engine->search($builder); } - public function test_search_with_model_config() + /** + * @test + */ + public function search_with_model_config() { [$engine, $db] = $this->getEngine(['config' => 'simple']); @@ -181,9 +299,9 @@ public function test_search_with_model_config() ->shouldReceive('where')->with('bar', 1) ->shouldReceive('getBindings')->andReturn(['english', 'foo', 1]); - $db->shouldReceive('select')->with(null, $table->getBindings()); + $db->shouldReceive('select')->with(null, $table->getBindings())->once(); - $model = new TestModel(); + $model = new TestModel; $model->searchableOptions['config'] = 'english'; $builder = new Builder($model, 'foo'); @@ -192,7 +310,10 @@ public function test_search_with_model_config() $engine->search($builder); } - public function test_search_with_soft_deletes() + /** + * @test + */ + public function search_with_soft_deletes() { [$engine, $db] = $this->getEngine(); @@ -204,22 +325,25 @@ public function test_search_with_soft_deletes() ->shouldReceive('whereNull')->with('deleted_at') ->shouldReceive('getBindings')->andReturn([null, 'foo', 1]); - $db->shouldReceive('select')->with(null, $table->getBindings()); + $db->shouldReceive('select')->with(null, $table->getBindings())->once(); - $builder = new Builder(new SoftDeletableTestModel(), 'foo'); + $builder = new Builder(new SoftDeletableTestModel, 'foo'); $builder->where('bar', 1)->take(5); $engine->search($builder); } - public function test_maps_results_to_models() + /** + * @test + */ + public function maps_results_to_models() { [$engine] = $this->getEngine(); $model = Mockery::mock('StdClass'); $model->shouldReceive('getKeyName')->andReturn('id'); $model->shouldReceive('whereIn')->once()->with('id', [1])->andReturn($model); - $model->shouldReceive('get')->once()->andReturn(Collection::make([new TestModel()])); + $model->shouldReceive('get')->once()->andReturn(Collection::make([new TestModel])); $results = $engine->map( new Builder(new TestModel, 'foo'), @@ -230,7 +354,10 @@ public function test_maps_results_to_models() $this->assertCount(1, $results); } - public function test_map_filters_out_no_longer_existing_models() + /** + * @test + */ + public function map_filters_out_no_longer_existing_models() { [$engine] = $this->getEngine(); @@ -238,7 +365,7 @@ public function test_map_filters_out_no_longer_existing_models() $model->shouldReceive('getKeyName')->andReturn('id'); $model->shouldReceive('whereIn')->once()->with('id', [1, 2])->andReturn($model); - $expectedModel = new SoftDeletableTestModel(); + $expectedModel = new SoftDeletableTestModel; $expectedModel->id = 2; $model->shouldReceive('get')->once()->andReturn(Collection::make([$expectedModel])); @@ -253,7 +380,10 @@ public function test_map_filters_out_no_longer_existing_models() $this->assertEquals(2, $models->first()->id); } - public function test_it_returns_total_count() + /** + * @test + */ + public function it_returns_total_count() { [$engine] = $this->getEngine(); @@ -264,7 +394,10 @@ public function test_it_returns_total_count() $this->assertEquals(100, $count); } - public function test_map_ids_returns_right_key() + /** + * @test + */ + public function map_ids_returns_right_key() { [$engine, $db] = $this->getEngine(); @@ -282,11 +415,37 @@ public function test_map_ids_returns_right_key() $this->assertEquals([1, 2], $ids->all()); } + /** + * @test + */ + public function create_index() + { + [$engine, $db] = $this->getEngine(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('PostgreSQL indexes should be created through Laravel database migrations.'); + + $engine->createIndex('bad_index'); + } + + /** + * @test + */ + public function delete_index() + { + [$engine, $db] = $this->getEngine(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('PostgreSQL indexes should be deleted through Laravel database migrations.'); + + $engine->deleteIndex('bad_index'); + } + protected function getEngine($config = []) { $resolver = Mockery::mock(ConnectionResolverInterface::class); $resolver->shouldReceive('connection') - ->andReturn($db = Mockery::mock(Connection::class)); + ->andReturn($db = Mockery::mock(PostgresConnection::class)); $db->shouldReceive('getDriverName')->andReturn('pgsql'); @@ -302,30 +461,30 @@ protected function setDbExpectations($db, $withDefaultOrderBy = true) ->andReturn('plainto_tsquery(COALESCE(?, get_current_ts_config()), ?) AS "tsquery"'); $table->shouldReceive('crossJoin') - ->with('plainto_tsquery(COALESCE(?, get_current_ts_config()), ?) AS "tsquery"') - ->andReturnSelf() + ->with('plainto_tsquery(COALESCE(?, get_current_ts_config()), ?) AS "tsquery"') + ->andReturnSelf() ->shouldReceive('addBinding') - ->with(Mockery::type('array'), 'join') - ->andReturnSelf() + ->with(Mockery::type('array'), 'join') + ->andReturnSelf() ->shouldReceive('select') - ->with('id') - ->andReturnSelf() + ->with('id') + ->andReturnSelf() ->shouldReceive('selectRaw') - ->with('ts_rank(searchable,"tsquery") AS rank') - ->andReturnSelf() + ->with('ts_rank(searchable,"tsquery") AS rank') + ->andReturnSelf() ->shouldReceive('selectRaw') - ->with('COUNT(*) OVER () AS total_count') - ->andReturnSelf() + ->with('COUNT(*) OVER () AS total_count') + ->andReturnSelf() ->shouldReceive('whereRaw') - ->andReturnSelf(); + ->andReturnSelf(); if ($withDefaultOrderBy) { $table->shouldReceive('orderBy') - ->with('rank', 'desc') - ->andReturnSelf() + ->with('rank', 'desc') + ->andReturnSelf() ->shouldReceive('orderBy') - ->with('id') - ->andReturnSelf(); + ->with('id') + ->andReturnSelf(); } $table->shouldReceive('toSql');