From cd31822f1fec248088cc6daaf778df413af4ffa2 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 9 Oct 2025 15:40:45 -0500 Subject: [PATCH 01/11] better full text search in database engine, automatic relevance ranking --- src/Engines/DatabaseEngine.php | 53 +++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index e05be059..5b291135 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -196,9 +196,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->model->getConnection()->getDriverName(), + ]; + $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 +214,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,7 +227,48 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array ); } } + + $query->orWhereFullText( + array_map(fn ($column) => $builder->model->qualifyColumn($column), $fullTextColumns), + $builder->query, + $this->getFullTextOptions($builder) + ); + }); + + if ($connectionType === 'pgsql' && empty($builder->orders)) { + $query = $this->addOrderByRelevance($query, $builder, $fullTextColumns); + } + + return $query; + } + + /** + * Add an "order by" clause that orders by relevance (Postgres only). + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Laravel\Scout\Builder $builder + * @param array $fullTextColumns + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function addOrderByRelevance($query, Builder $builder, array $fullTextColumns) + { + $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)); }); + + return $query->orderByRaw( + sprintf( + "ts_rank(".$vectors->implode(' || ').", %s(?)) desc", + match ($this->getFullTextOptions($builder)['mode'] ?? 'plainto_tsquery') { + 'phrase' => 'phraseto_tsquery', + 'websearch' => 'websearch_to_tsquery', + default => 'plainto_tsquery', + }, + ), + [$builder->query] + ); } /** From d7397aeed9612a06209e7608038c20aa9f7b3666 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 9 Oct 2025 15:41:35 -0500 Subject: [PATCH 02/11] simplify code --- src/Engines/DatabaseEngine.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 5b291135..1a4eb34d 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -256,11 +256,11 @@ protected function addOrderByRelevance($query, Builder $builder, array $fullText $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->implode(' || ').", %s(?)) desc", + "ts_rank(".$vectors.", %s(?)) desc", match ($this->getFullTextOptions($builder)['mode'] ?? 'plainto_tsquery') { 'phrase' => 'phraseto_tsquery', 'websearch' => 'websearch_to_tsquery', From d9ea8c67f09b907d7198736be8f2819a6fee57e2 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Thu, 9 Oct 2025 20:42:46 +0000 Subject: [PATCH 03/11] Apply fixes from StyleCI --- src/Engines/DatabaseEngine.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 1a4eb34d..bbafcf55 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -260,7 +260,7 @@ protected function addOrderByRelevance($query, Builder $builder, array $fullText return $query->orderByRaw( sprintf( - "ts_rank(".$vectors.", %s(?)) desc", + 'ts_rank('.$vectors.', %s(?)) desc', match ($this->getFullTextOptions($builder)['mode'] ?? 'plainto_tsquery') { 'phrase' => 'phraseto_tsquery', 'websearch' => 'websearch_to_tsquery', From 31e824fe8a6d49ebcbd04fc8cbcf4476fc28b6c7 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 9 Oct 2025 15:42:56 -0500 Subject: [PATCH 04/11] fix check --- src/Engines/DatabaseEngine.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index bbafcf55..9e2ffa71 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -228,11 +228,13 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array } } - $query->orWhereFullText( - array_map(fn ($column) => $builder->model->qualifyColumn($column), $fullTextColumns), - $builder->query, - $this->getFullTextOptions($builder) - ); + if (count($fullTextColumns) > 0) { + $query->orWhereFullText( + array_map(fn ($column) => $builder->model->qualifyColumn($column), $fullTextColumns), + $builder->query, + $this->getFullTextOptions($builder) + ); + } }); if ($connectionType === 'pgsql' && empty($builder->orders)) { From fe60841ef6fa1acb0d39d809907cff885f4ca25c Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 9 Oct 2025 15:46:31 -0500 Subject: [PATCH 05/11] update method name --- src/Engines/DatabaseEngine.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 9e2ffa71..764669ba 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -238,7 +238,7 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array }); if ($connectionType === 'pgsql' && empty($builder->orders)) { - $query = $this->addOrderByRelevance($query, $builder, $fullTextColumns); + $query = $this->orderByRelevance($query, $builder, $fullTextColumns); } return $query; @@ -252,7 +252,7 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array * @param array $fullTextColumns * @return \Illuminate\Database\Eloquent\Builder */ - protected function addOrderByRelevance($query, Builder $builder, array $fullTextColumns) + protected function orderByRelevance($query, Builder $builder, array $fullTextColumns) { $language = $this->getFullTextOptions($builder)['language'] ?? 'english'; From c976992a5202465743d47880ebde9dcac198dff5 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 9 Oct 2025 16:02:11 -0500 Subject: [PATCH 06/11] add engine helper --- src/Scout.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Scout.php b/src/Scout.php index b83632ca..4031b2e5 100644 --- a/src/Scout.php +++ b/src/Scout.php @@ -2,6 +2,8 @@ namespace Laravel\Scout; +use Laravel\Scout\EngineManager; +use Laravel\Scout\Engines\Engine; use Laravel\Scout\Jobs\MakeSearchable; use Laravel\Scout\Jobs\RemoveFromSearch; @@ -28,6 +30,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. * From 23dc50e34e70a67195045d250b32cf5834677d65 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Thu, 9 Oct 2025 21:02:24 +0000 Subject: [PATCH 07/11] Apply fixes from StyleCI --- src/Scout.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Scout.php b/src/Scout.php index 4031b2e5..dcc61dd4 100644 --- a/src/Scout.php +++ b/src/Scout.php @@ -2,7 +2,6 @@ namespace Laravel\Scout; -use Laravel\Scout\EngineManager; use Laravel\Scout\Engines\Engine; use Laravel\Scout\Jobs\MakeSearchable; use Laravel\Scout\Jobs\RemoveFromSearch; From 38fadc45751533d45736ffcefc47842b295b73be Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 9 Oct 2025 16:04:01 -0500 Subject: [PATCH 08/11] fix conditional --- src/Engines/DatabaseEngine.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 764669ba..a38a9070 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -237,7 +237,9 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array } }); - if ($connectionType === 'pgsql' && empty($builder->orders)) { + if ($connectionType === 'pgsql' && + count($fullTextColumns) > 0 && + empty($builder->orders)) { $query = $this->orderByRelevance($query, $builder, $fullTextColumns); } From 64a17487c240de71704e374fde3a75d85638266c Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 9 Oct 2025 16:27:09 -0500 Subject: [PATCH 09/11] restructure code --- src/Builder.php | 8 ++++ src/Engines/DatabaseEngine.php | 84 +++++++++++++++++++--------------- 2 files changed, 56 insertions(+), 36 deletions(-) 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 a38a9070..7b9ef6ff 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); } /** @@ -200,7 +209,7 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array $builder->model->getConnection()->getDriverName(), ]; - $query->where(function ($query) use ($connectionType, $builder, $columns, $prefixColumns, $fullTextColumns) { + 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) && @@ -236,26 +245,29 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array ); } }); + } - if ($connectionType === 'pgsql' && - count($fullTextColumns) > 0 && - empty($builder->orders)) { - $query = $this->orderByRelevance($query, $builder, $fullTextColumns); - } - - return $query; + /** + * Determine if the query should be ordered by relevance. + */ + protected function shouldOrderByRelevance(Builder $builder): bool + { + return $builder->modelConnectionType() === 'pgsql' && + count($this->getFullTextColumns($builder)) > 0 && + empty($builder->orders); } /** * Add an "order by" clause that orders by relevance (Postgres only). * - * @param \Illuminate\Database\Eloquent\Builder $query * @param \Laravel\Scout\Builder $builder - * @param array $fullTextColumns + * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ - protected function orderByRelevance($query, Builder $builder, array $fullTextColumns) + 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) { From 1d1f04241dc1435e3a255cfaedd6f9438647b9b6 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 9 Oct 2025 16:31:07 -0500 Subject: [PATCH 10/11] comment --- src/Engines/DatabaseEngine.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 7b9ef6ff..8c254269 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -252,6 +252,9 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array */ 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); From 31ad3ccf659b979a2f80567077213dc0ac50ca25 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Fri, 10 Oct 2025 08:38:26 -0500 Subject: [PATCH 11/11] Update DatabaseEngine.php --- src/Engines/DatabaseEngine.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 8c254269..21877ddd 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -206,7 +206,7 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array } [$connectionType] = [ - $builder->model->getConnection()->getDriverName(), + $builder->modelConnectionType(), ]; return $query->where(function ($query) use ($connectionType, $builder, $columns, $prefixColumns, $fullTextColumns) {