diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b2181a..b8603cb 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,11 +13,13 @@ jobs: strategy: fail-fast: true matrix: - php: [8.0, 8.1, 8.2] - laravel: [^9, ^10] - scout: [^9, ^10] + php: [8.1, 8.2, 8.3, 8.4] + laravel: [^10, ^11] + scout: [^10] exclude: - - php: 8.0 + - php: 8.1 + laravel: ^11 + - php: 8.4 laravel: ^10 name: Test PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - Scout ${{ matrix.scout }} diff --git a/.gitignore b/.gitignore index 382f5a0..60718bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor .phpunit.result.cache -composer.lock \ No newline at end of file +composer.lock +.DS_Store diff --git a/composer.json b/composer.json index 9f434f1..d0f5908 100755 --- a/composer.json +++ b/composer.json @@ -28,19 +28,19 @@ } ], "require": { - "php": "^8.0|^8.1|^8.2", - "illuminate/contracts": "^9|^10", - "illuminate/database": "^9|^10", - "illuminate/support": "^9|^10", - "laravel/scout": "^9|^10" + "php": "^8.1|^8.2|^8.3|^8.4", + "illuminate/contracts": "^10|^11", + "illuminate/database": "^10|^11", + "illuminate/support": "^10|^11", + "laravel/scout": "^10|^11" }, "require-dev": { - "laravel/pint": "^1.10", - "mockery/mockery": "^1.5", - "nunomaduro/larastan": "^2.6", - "orchestra/testbench": "^8.5", + "laravel/pint": "^1.18", + "mockery/mockery": "^1.6", + "nunomaduro/larastan": "^2.9", + "orchestra/testbench": "^8.28", "phpunit/phpunit": "^9.6", - "tightenco/duster": "^2.0" + "tightenco/duster": "^2.7" }, "autoload": { "psr-4": { @@ -62,4 +62,4 @@ ] } } -} \ No newline at end of file +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0ec50ee..ba86bd9 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,17 +1,21 @@ parameters: ignoreErrors: - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:getDeletedAtColumn\\(\\)\\.$#" - count: 1 + 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\\:\\:searchableAs\\(\\)\\.$#" - count: 4 + 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 Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:toSearchableArray\\(\\)\\.$#" + message: "#^Call to an undefined method TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:getDeletedAtColumn\\(\\)\\.$#" count: 1 path: src/PostgresEngine.php diff --git a/phpstan.neon b/phpstan.neon index 762c86b..557ca91 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,8 +7,6 @@ parameters: paths: - src/ - checkGenericClassInNonGenericObjectType: false - # Level 9 is the highest level level: 9 diff --git a/src/PostgresEngine.php b/src/PostgresEngine.php index 438982b..48cbf2d 100755 --- a/src/PostgresEngine.php +++ b/src/PostgresEngine.php @@ -16,6 +16,10 @@ 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 { /** @@ -60,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) @@ -74,77 +78,10 @@ public function update($models) } } - /** - * Perform update of the given model. - * - * @return bool|int - */ - protected function performUpdate(Model $model) - { - $data = collect([$this->getIndexColumn($model) => $this->toVector($model)]); - - $query = $this->database - ->table($model->searchableAs()) - ->where($model->getKeyName(), '=', $model->getKey()); - - if (method_exists($model, 'searchableAdditionalArray')) { - $data = $data->merge($model->searchableAdditionalArray() ?: []); - } - - if (! $this->isExternalIndex($model) || $query->exists()) { - return $query->update($data->all()); - } - - $modelKeyInfo = collect([$model->getKeyName() => $model->getKey()]); - - return $query->insert( - $data->merge($modelKeyInfo)->all() - ); - } - - /** - * Get the indexed value for a given model. - */ - protected function toVector(Model $model): mixed - { - /** @var array $searchableArray */ - $searchableArray = $model->toSearchableArray(); - $fields = collect($searchableArray) - ->map(function ($value) { - return $value === null ? '' : $value; - }); - - $bindings = collect([]); - - // The choices of parser, dictionaries and which types of tokens to index are determined - // by the selected text search configuration which can be set globally in config/scout.php - // file or individually for each model in searchableOptions() - // See https://www.postgresql.org/docs/current/static/textsearch-controls.html - $vector = 'to_tsvector(COALESCE(?, get_current_ts_config()), ?)'; - - $select = $fields->map(function ($value, $key) use ($model, $vector, $bindings) { - $bindings->push($this->searchConfig($model) ?: null) - ->push($value); - - // Set a field weight if it was specified in Model's searchableOptions() - if ($label = $this->rankFieldWeightLabel($model, $key)) { - $vector = "setweight({$vector}, ?)"; - $bindings->push($label); - } - - return $vector; - })->implode(' || '); - - return $this->database - ->query() - ->selectRaw("{$select} AS tsvector", $bindings->all()) - ->value('tsvector'); - } - /** * Remove the given model from the index. * - * @param \Illuminate\Database\Eloquent\Collection $models + * @param \Illuminate\Database\Eloquent\Collection $models * @return void */ public function delete($models) @@ -172,6 +109,7 @@ public function delete($models) /** * Perform the given search on the engine. * + * @param \Laravel\Scout\Builder $builder * @return mixed */ public function search(Builder $builder) @@ -182,6 +120,7 @@ public function search(Builder $builder) /** * Perform the given search on the engine. * + * @param \Laravel\Scout\Builder $builder * @param int $perPage * @param int $page * @return mixed @@ -204,82 +143,12 @@ public function getTotalCount($results) } /** @var array $results */ - /** @var object{'id': int, 'rank': string, 'total_count': int} $result */ + /** @var object{'id': mixed, 'rank': string, 'total_count': int} $result */ $result = Arr::first($results); return (int) $result->total_count; } - /** - * Perform the given search on the engine. - * - * @param int|null $perPage - * @param int $page - * @return array - */ - protected function performSearch(Builder $builder, $perPage = 0, $page = 1) - { - // We have to preserve the model in order to allow for - // correct behavior of mapIds() method which currently - // does not receive a model instance - $this->preserveModel($builder->model); - - $indexColumn = $this->getIndexColumn($builder->model); - - // Build the SQL query - $query = $this->database - ->table($builder->index ?: $builder->model->searchableAs()) - ->select($builder->model->getKeyName()) - ->selectRaw("{$this->rankingExpression($builder->model, $indexColumn)} AS rank") - ->selectRaw('COUNT(*) OVER () AS total_count') - ->whereRaw("{$indexColumn} @@ \"tsquery\""); - - // Apply where clauses that were set on the builder instance if any - foreach ($builder->wheres as $key => $value) { - $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 order by clauses that were set on the builder instance if any - foreach ($builder->orders as $order) { - $query->orderBy($order['column'], $order['direction']); - } - - // Apply default order by clauses (rank and id) - if (empty($builder->orders)) { - $query->orderBy('rank', 'desc') - ->orderBy($builder->model->getKeyName()); - } - - if ($perPage > 0) { - $query->skip(($page - 1) * $perPage) - ->limit($perPage); - } - - // The choices of parser, dictionaries and which types of tokens to index are determined - // by the selected text search configuration which can be set globally in config/scout.php - // file or individually for each model in searchableOptions() - // See https://www.postgresql.org/docs/current/static/textsearch-controls.html - $tsQuery = $builder->callback - ? call_user_func($builder->callback, $builder, $this->searchConfig($builder->model), $query) - : $this->defaultQueryMethod($builder->query, $this->searchConfig($builder->model)); - - /** @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'); - - return $this->database - ->select($query->toSql(), $query->getBindings()); - } - /** * Returns the default query method. * @@ -304,7 +173,7 @@ public function defaultQueryMethod($query, $config) * Pluck and return the primary keys of the given results. * * @param mixed $results - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ public function mapIds($results) { @@ -319,9 +188,10 @@ public function mapIds($results) /** * 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 + * @return \Illuminate\Database\Eloquent\Collection */ public function map(Builder $builder, $results, $model) { @@ -352,13 +222,15 @@ public function map(Builder $builder, $results, $model) /** * 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 + * @return \Illuminate\Support\LazyCollection */ public function lazyMap(Builder $builder, $results, $model) { - return LazyCollection::make($model->newCollection()); + // return LazyCollection::make($model->newCollection()); + return LazyCollection::make($this->map($builder, $results, $model)->all()); } /** @@ -385,11 +257,164 @@ public function deleteIndex($name) } /** - * Connect to the database. + * Flush all of the model's records from the engine. * + * @param \Illuminate\Database\Eloquent\Model $model * @return void */ - protected function connect() + 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. + * + * @return bool|int + */ + protected function performUpdate(Model $model) + { + $data = collect([$this->getIndexColumn($model) => $this->toVector($model)]); + + $query = $this->database + ->table($model->searchableAs()) + ->where($model->getKeyName(), '=', $model->getKey()); + + if (method_exists($model, 'searchableAdditionalArray')) { + $data = $data->merge($model->searchableAdditionalArray() ?: []); + } + + if (! $this->isExternalIndex($model) || $query->exists()) { + return $query->update($data->all()); + } + + $modelKeyInfo = collect([$model->getKeyName() => $model->getKey()]); + + return $query->insert( + $data->merge($modelKeyInfo)->all() + ); + } + + /** + * Get the indexed value for a given model. + */ + protected function toVector(Model $model): mixed + { + /** @var array $searchableArray */ + $searchableArray = $model->toSearchableArray(); + $fields = collect($searchableArray) + ->map(function ($value) { + return $value === null ? '' : $value; + }); + + $bindings = collect([]); + + // The choices of parser, dictionaries and which types of tokens to index are determined + // by the selected text search configuration which can be set globally in config/scout.php + // file or individually for each model in searchableOptions() + // See https://www.postgresql.org/docs/current/static/textsearch-controls.html + $vector = 'to_tsvector(COALESCE(?, get_current_ts_config()), ?)'; + + $select = $fields->map(function ($value, $key) use ($model, $vector, $bindings) { + $bindings->push($this->searchConfig($model) ?: null) + ->push($value); + + // Set a field weight if it was specified in Model's searchableOptions() + if ($label = $this->rankFieldWeightLabel($model, $key)) { + $vector = "setweight({$vector}, ?)"; + $bindings->push($label); + } + + return $vector; + })->implode(' || '); + + return $this->database + ->query() + ->selectRaw("{$select} AS tsvector", $bindings->all()) + ->value('tsvector'); + } + + /** + * Perform the given search on the engine. + * + * @param \Laravel\Scout\Builder $builder + * @return array + */ + protected function performSearch(Builder $builder, ?int $perPage = 0, int $page = 1): array|null + { + // We have to preserve the model in order to allow for + // correct behavior of mapIds() method which currently + // does not receive a model instance + $this->preserveModel($builder->model); + + $indexColumn = $this->getIndexColumn($builder->model); + + // Build the SQL query + $query = $this->database + ->table($builder->index ?: $builder->model->searchableAs()) + ->select($builder->model->getKeyName()) + ->selectRaw("{$this->rankingExpression($builder->model, $indexColumn)} AS rank") + ->selectRaw('COUNT(*) OVER () AS total_count') + ->whereRaw("{$indexColumn} @@ \"tsquery\""); + + // Apply where clauses that were set on the builder instance if any + foreach ($builder->wheres as $key => $value) { + $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 order by clauses that were set on the builder instance if any + foreach ($builder->orders as $order) { + $query->orderBy($order['column'], $order['direction']); + } + + // Apply default order by clauses (rank and id) + if (empty($builder->orders)) { + $query->orderBy('rank', 'desc') + ->orderBy($builder->model->getKeyName()); + } + + if ($perPage > 0) { + $query->skip(($page - 1) * $perPage) + ->limit($perPage); + } + + // The choices of parser, dictionaries and which types of tokens to index are determined + // by the selected text search configuration which can be set globally in config/scout.php + // file or individually for each model in searchableOptions() + // See https://www.postgresql.org/docs/current/static/textsearch-controls.html + $tsQuery = $builder->callback + ? call_user_func($builder->callback, $builder, $this->searchConfig($builder->model), $query) + : $this->defaultQueryMethod($builder->query, $this->searchConfig($builder->model)); + + /** @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'); + + return $this->database + ->select($query->toSql(), $query->getBindings()); + } + + /** + * Connect to the database. + */ + protected function connect(): void { // Already connected if ($this->database !== null) { @@ -476,7 +501,7 @@ protected function rankNormalization(Model $model): int /** * See if the index should be maintained for a given model. */ - protected function shouldMaintainIndex(Model $model = null): bool + protected function shouldMaintainIndex(?Model $model = null): bool { if ((bool) $this->config('maintain_index', true) === false) { return false; @@ -507,8 +532,6 @@ protected function isExternalIndex(Model $model): mixed /** * Get the model specific option value or a default. - * - * @param mixed $default */ protected function option(Model $model, string $key, mixed $default = null): mixed { @@ -551,8 +574,6 @@ protected function stringOption(Model $model, string $key, string $default = '') /** * Get the config value or a default. - * - * @param mixed $default */ protected function config(string $key, mixed $default = null): mixed { @@ -573,50 +594,24 @@ protected function stringConfig(string $key, string $default = ''): string } } - /** - * @return void - */ - protected function preserveModel(Model $model) + protected function preserveModel(Model $model): void { $this->model = $model; } /** * Returns a search config name for a model. - * - * @return string */ - protected function searchConfig(Model $model) + protected function searchConfig(Model $model): string { return $this->stringOption($model, 'config', $this->stringConfig('config', '')) ?: ''; } /** * Checks if the model uses the SoftDeletes trait. - * - * @return bool */ - protected function usesSoftDeletes(Model $model) + protected function usesSoftDeletes(Model $model): bool { return method_exists($model, 'getDeletedAtColumn'); } - - /** - * 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]); - } } diff --git a/src/PostgresEngineServiceProvider.php b/src/PostgresEngineServiceProvider.php index b62390e..81d3cd1 100644 --- a/src/PostgresEngineServiceProvider.php +++ b/src/PostgresEngineServiceProvider.php @@ -10,6 +10,10 @@ 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 { /** @@ -50,7 +54,7 @@ protected function registerBuilderMacro(string $name, string $class): void { if (! Builder::hasMacro($name)) { Builder::macro($name, function () use ($class) { - /** @var Builder $this */ + /** @var Builder $this */ $this->callback = function ($builder, $config) use ($class) { return new $class($builder->query, $config); };