diff --git a/src/Builder.php b/src/Builder.php index c4fa0ebf..a2abbb35 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -579,4 +579,12 @@ protected function engine() { return $this->model->searchableUsing(); } + + /** + * Get the connection type for the underlying model. + */ + public function modelConnectionType(): string + { + return $this->model->getConnection()->getDriverName(); + } } diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index e05be059..21877ddd 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -61,6 +61,34 @@ public function search(Builder $builder) ]; } + /** + * Get the Eloquent models for the given builder. + * + * @param \Laravel\Scout\Builder $builder + * @param int|null $page + * @param int|null $perPage + * @return \Illuminate\Database\Eloquent\Collection + */ + protected function searchModels(Builder $builder, $page = null, $perPage = null) + { + return $this->buildSearchQuery($builder) + ->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { + $query->forPage($page, $perPage); + }) + ->when($builder->orders, function ($query) use ($builder) { + foreach ($builder->orders as $order) { + $query->orderBy($order['column'], $order['direction']); + } + }) + ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) { + $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc'); + }) + ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) { + $this->orderByRelevance($builder, $query); + }) + ->get(); + } + /** * Paginate the given search on the engine. * @@ -94,6 +122,9 @@ public function paginateUsingDatabase(Builder $builder, $perPage, $pageName, $pa ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) { $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc'); }) + ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) { + $this->orderByRelevance($builder, $query); + }) ->paginate($perPage, ['*'], $pageName, $page); } @@ -129,32 +160,10 @@ public function simplePaginateUsingDatabase(Builder $builder, $perPage, $pageNam ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) { $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc'); }) - ->simplePaginate($perPage, ['*'], $pageName, $page); - } - - /** - * Get the Eloquent models for the given builder. - * - * @param \Laravel\Scout\Builder $builder - * @param int|null $page - * @param int|null $perPage - * @return \Illuminate\Database\Eloquent\Collection - */ - protected function searchModels(Builder $builder, $page = null, $perPage = null) - { - return $this->buildSearchQuery($builder) - ->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { - $query->forPage($page, $perPage); - }) - ->when($builder->orders, function ($query) use ($builder) { - foreach ($builder->orders as $order) { - $query->orderBy($order['column'], $order['direction']); - } - }) - ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) { - $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc'); + ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) { + $this->orderByRelevance($builder, $query); }) - ->get(); + ->simplePaginate($perPage, ['*'], $pageName, $page); } /** @@ -196,9 +205,11 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array return $query; } - return $query->where(function ($query) use ($builder, $columns, $prefixColumns, $fullTextColumns) { - $connectionType = $builder->model->getConnection()->getDriverName(); + [$connectionType] = [ + $builder->modelConnectionType(), + ]; + return $query->where(function ($query) use ($connectionType, $builder, $columns, $prefixColumns, $fullTextColumns) { $canSearchPrimaryKey = ctype_digit($builder->query) && in_array($builder->model->getKeyType(), ['int', 'integer']) && ($connectionType != 'pgsql' || $builder->query <= PHP_INT_MAX) && @@ -212,11 +223,7 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array foreach ($columns as $column) { if (in_array($column, $fullTextColumns)) { - $query->orWhereFullText( - $builder->model->qualifyColumn($column), - $builder->query, - $this->getFullTextOptions($builder) - ); + continue; } else { if ($canSearchPrimaryKey && $column === $builder->model->getScoutKeyName()) { continue; @@ -229,9 +236,60 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array ); } } + + if (count($fullTextColumns) > 0) { + $query->orWhereFullText( + array_map(fn ($column) => $builder->model->qualifyColumn($column), $fullTextColumns), + $builder->query, + $this->getFullTextOptions($builder) + ); + } }); } + /** + * Determine if the query should be ordered by relevance. + */ + protected function shouldOrderByRelevance(Builder $builder): bool + { + // MySQL orders by relevance by default, so we will only order by relevance on + // Postgres with no developer-defined orders. If there is developer defined + // order by clauses we will let those take precedence over the relevance. + return $builder->modelConnectionType() === 'pgsql' && + count($this->getFullTextColumns($builder)) > 0 && + empty($builder->orders); + } + + /** + * Add an "order by" clause that orders by relevance (Postgres only). + * + * @param \Laravel\Scout\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function orderByRelevance(Builder $builder, $query) + { + $fullTextColumns = $this->getFullTextColumns($builder); + + $language = $this->getFullTextOptions($builder)['language'] ?? 'english'; + + $vectors = collect($fullTextColumns)->map(function ($column) use ($builder, $language) { + return sprintf("to_tsvector('%s', %s)", $language, $builder->model->qualifyColumn($column)); + })->implode(' || '); + + return $query->orderByRaw( + sprintf( + 'ts_rank('.$vectors.', %s(?)) desc', + match ($this->getFullTextOptions($builder)['mode'] ?? 'plainto_tsquery') { + 'phrase' => 'phraseto_tsquery', + 'websearch' => 'websearch_to_tsquery', + default => 'plainto_tsquery', + }, + ), + [$builder->query] + ); + } + /** * Add additional, developer defined constraints to the search query. * diff --git a/src/Scout.php b/src/Scout.php index b83632ca..dcc61dd4 100644 --- a/src/Scout.php +++ b/src/Scout.php @@ -2,6 +2,7 @@ namespace Laravel\Scout; +use Laravel\Scout\Engines\Engine; use Laravel\Scout\Jobs\MakeSearchable; use Laravel\Scout\Jobs\RemoveFromSearch; @@ -28,6 +29,14 @@ class Scout */ public static $removeFromSearchJob = RemoveFromSearch::class; + /** + * Get a Scout engine instance. + */ + public static function engine(string $engine): Engine + { + return app(EngineManager::class)->engine($engine); + } + /** * Specify the job class that should make models searchable. *