From 571c499e719a58ee86c48f915c896c2e7f14fba8 Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Thu, 23 Dec 2021 11:58:41 +0100 Subject: [PATCH 01/17] Add a DatabaseEngine --- src/EngineManager.php | 11 ++ src/Engines/DatabaseEngine.php | 274 +++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/Engines/DatabaseEngine.php diff --git a/src/EngineManager.php b/src/EngineManager.php index ca8e8c74..1e4e2112 100644 --- a/src/EngineManager.php +++ b/src/EngineManager.php @@ -9,6 +9,7 @@ use Illuminate\Support\Manager; use Laravel\Scout\Engines\AlgoliaEngine; use Laravel\Scout\Engines\CollectionEngine; +use Laravel\Scout\Engines\DatabaseEngine; use Laravel\Scout\Engines\MeiliSearchEngine; use Laravel\Scout\Engines\NullEngine; use MeiliSearch\Client as MeiliSearch; @@ -136,6 +137,16 @@ protected function ensureMeiliSearchClientIsInstalled() throw new Exception('Please install the MeiliSearch client: meilisearch/meilisearch-php.'); } + /** + * Create a database engine instance. + * + * @return \Laravel\Scout\Engines\DatabaseEngine + */ + public function createDatabaseDriver() + { + return new DatabaseEngine; + } + /** * Create a collection engine instance. * diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php new file mode 100644 index 00000000..b480192f --- /dev/null +++ b/src/Engines/DatabaseEngine.php @@ -0,0 +1,274 @@ +searchModels($builder); + + return [ + 'results' => $models->all(), + 'total' => count($models), + ]; + } + + /** + * 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) + { + $models = $this->searchModels($builder); + + return [ + 'results' => $models->forPage($page, $perPage)->all(), + 'total' => count($models), + ]; + } + + /** + * Get the Eloquent models for the given builder. + * + * @param \Laravel\Scout\Builder $builder + * @return \Illuminate\Database\Eloquent\Collection + */ + protected function searchModels(Builder $builder) + { + $columns = array_keys($builder->model->toSearchableArray()); + + $query = $builder->model->query() + ->whereFulltext($columns, $builder->query) + ->when(! is_null($builder->callback), function ($query) use ($builder) { + call_user_func($builder->callback, $query, $builder, $builder->query); + }) + ->when(! $builder->callback && count($builder->wheres) > 0, function ($query) use ($builder) { + foreach ($builder->wheres as $key => $value) { + if ($key !== '__soft_deleted') { + $query->where($key, $value); + } + } + }) + ->when(! $builder->callback && count($builder->whereIns) > 0, function ($query) use ($builder) { + foreach ($builder->whereIns as $key => $values) { + $query->whereIn($key, $values); + } + }) + ->orderBy($builder->model->getKeyName(), 'desc'); + + $models = $this->ensureSoftDeletesAreHandled($builder, $query) + ->get() + ->values(); + + if (count($models) === 0) { + return $models; + } + + return $models->filter(function ($model) use ($builder) { + if (! $model->shouldBeSearchable()) { + return false; + } + + if (! $builder->query) { + return true; + } + + return false; + })->values(); + } + + /** + * Ensure that soft delete handling is properly applied to the query. + * + * @param \Laravel\Scout\Builder $builder + * @param \Illuminate\Database\Query\Builder $query + * @return \Illuminate\Database\Query\Builder + */ + protected function ensureSoftDeletesAreHandled($builder, $query) + { + if (Arr::get($builder->wheres, '__soft_deleted') === 0) { + return $query->withoutTrashed(); + } elseif (Arr::get($builder->wheres, '__soft_deleted') === 1) { + return $query->onlyTrashed(); + } elseif (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model))) && + config('scout.soft_delete', false)) { + return $query->withTrashed(); + } + + return $query; + } + + /** + * Pluck and return the primary keys of the given results. + * + * @param mixed $results + * @return \Illuminate\Support\Collection + */ + public function mapIds($results) + { + $results = $results['results']; + + return count($results) > 0 + ? collect($results)->pluck($results[0]->getKeyName())->values() + : collect(); + } + + /** + * 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) + { + $results = $results['results']; + + if (count($results) === 0) { + return $model->newCollection(); + } + + $objectIds = collect($results) + ->pluck($model->getKeyName()) + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + return $model->getScoutModelsByIds( + $builder, $objectIds + )->filter(function ($model) use ($objectIds) { + return in_array($model->getScoutKey(), $objectIds); + })->sortBy(function ($model) use ($objectIdPositions) { + return $objectIdPositions[$model->getScoutKey()]; + })->values(); + } + + /** + * 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) + { + $results = $results['results']; + + if (count($results) === 0) { + return LazyCollection::empty(); + } + + $objectIds = collect($results) + ->pluck($model->getKeyName()) + ->values()->all(); + + $objectIdPositions = array_flip($objectIds); + + return $model->queryScoutModelsByIds( + $builder, $objectIds + )->cursor()->filter(function ($model) use ($objectIds) { + return in_array($model->getScoutKey(), $objectIds); + })->sortBy(function ($model) use ($objectIdPositions) { + return $objectIdPositions[$model->getScoutKey()]; + })->values(); + } + + /** + * Get the total count from a raw result returned by the engine. + * + * @param mixed $results + * @return int + */ + public function getTotalCount($results) + { + return $results['total']; + } + + /** + * Flush all of the model's records from the engine. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return void + */ + public function flush($model) + { + // + } + + /** + * Create a search index. + * + * @param string $name + * @param array $options + * @return mixed + * + * @throws \Exception + */ + public function createIndex($name, array $options = []) + { + // + } + + /** + * Delete a search index. + * + * @param string $name + * @return mixed + */ + public function deleteIndex($name) + { + // + } +} From a9b6630f15edbdb607bc9b6bb390eff14eaa1092 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 23 Dec 2021 11:00:48 +0000 Subject: [PATCH 02/17] Apply fixes from StyleCI --- src/Engines/DatabaseEngine.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index b480192f..ba188db6 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -5,7 +5,6 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Arr; use Illuminate\Support\LazyCollection; -use Illuminate\Support\Str; use Laravel\Scout\Builder; class DatabaseEngine extends Engine From 460aaa642a9e8621441b658856be61b92ab03bc3 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Wed, 5 Jan 2022 13:40:43 -0600 Subject: [PATCH 03/17] fix filter --- src/Engines/DatabaseEngine.php | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index ba188db6..694295b0 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -112,17 +112,7 @@ protected function searchModels(Builder $builder) return $models; } - return $models->filter(function ($model) use ($builder) { - if (! $model->shouldBeSearchable()) { - return false; - } - - if (! $builder->query) { - return true; - } - - return false; - })->values(); + return $models->filter->shouldBeSearchable()->values(); } /** From 5c2f5a132c148cdfe43666ac6edaf33833356bcb Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Wed, 5 Jan 2022 14:41:24 -0600 Subject: [PATCH 04/17] work on database engine --- src/Engines/DatabaseEngine.php | 54 ++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 694295b0..fb803fad 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -85,24 +85,42 @@ protected function searchModels(Builder $builder) { $columns = array_keys($builder->model->toSearchableArray()); - $query = $builder->model->query() - ->whereFulltext($columns, $builder->query) - ->when(! is_null($builder->callback), function ($query) use ($builder) { - call_user_func($builder->callback, $query, $builder, $builder->query); - }) - ->when(! $builder->callback && count($builder->wheres) > 0, function ($query) use ($builder) { - foreach ($builder->wheres as $key => $value) { - if ($key !== '__soft_deleted') { - $query->where($key, $value); - } - } - }) - ->when(! $builder->callback && count($builder->whereIns) > 0, function ($query) use ($builder) { - foreach ($builder->whereIns as $key => $values) { - $query->whereIn($key, $values); - } - }) - ->orderBy($builder->model->getKeyName(), 'desc'); + $query = $builder->model->query()->where(function ($query) use ($builder, $columns) { + $connectionType = $builder->model->getConnection()->getDriverName(); + + $canSearchPrimaryKey = ctype_digit($builder->query) && + in_array($builder->model->getKeyType(), ['int', 'integer']) && + ($connectionType != 'pgsql' || $builder->query <= PHP_INT_MAX) && + in_array($builder->model->getKeyName(), $columns); + + if ($canSearchPrimaryKey) { + $query->orWhere($builder->model->getQualifiedKeyName(), $builder->query); + } + + $likeOperator = $connectionType == 'pgsql' ? 'ilike' : 'like'; + + foreach ($columns as $column) { + $query->orWhere( + $builder->model->qualifyColumn($column), + $likeOperator, + '%'.$builder->query.'%', + ); + } + }); + + $query = $query->when(! is_null($builder->callback), function ($query) use ($builder) { + call_user_func($builder->callback, $query, $builder, $builder->query); + })->when(! $builder->callback && count($builder->wheres) > 0, function ($query) use ($builder) { + foreach ($builder->wheres as $key => $value) { + if ($key !== '__soft_deleted') { + $query->where($key, '=', $value); + } + } + })->when(! $builder->callback && count($builder->whereIns) > 0, function ($query) use ($builder) { + foreach ($builder->whereIns as $key => $values) { + $query->whereIn($key, $values); + } + }); $models = $this->ensureSoftDeletesAreHandled($builder, $query) ->get() From adc43c1279640200f2ad516fc15e5bec41bb98ce Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Thu, 6 Jan 2022 12:33:15 +0100 Subject: [PATCH 05/17] Fix filter --- src/Engines/DatabaseEngine.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index ba188db6..f58dc0d7 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -112,16 +112,8 @@ protected function searchModels(Builder $builder) return $models; } - return $models->filter(function ($model) use ($builder) { - if (! $model->shouldBeSearchable()) { - return false; - } - - if (! $builder->query) { - return true; - } - - return false; + return $models->filter(function ($model) { + return $model->shouldBeSearchable(); })->values(); } From 663fe517d6bb4e5f82111ce6c509f43d6d8caaad Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 6 Jan 2022 13:31:52 -0600 Subject: [PATCH 06/17] performance improvements - formatting --- src/Engines/DatabaseEngine.php | 112 +++++++++++++++------------------ 1 file changed, 51 insertions(+), 61 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index fb803fad..a4844778 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -2,6 +2,7 @@ namespace Laravel\Scout\Engines; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Arr; use Illuminate\Support\LazyCollection; @@ -52,8 +53,8 @@ public function search(Builder $builder) $models = $this->searchModels($builder); return [ - 'results' => $models->all(), - 'total' => count($models), + 'results' => $models, + 'total' => $models->count(), ]; } @@ -67,11 +68,11 @@ public function search(Builder $builder) */ public function paginate(Builder $builder, $perPage, $page) { - $models = $this->searchModels($builder); + $models = $this->searchModels($builder, $page, $perPage); return [ - 'results' => $models->forPage($page, $perPage)->all(), - 'total' => count($models), + 'results' => $models, + 'total' => $models->count(), ]; } @@ -79,13 +80,39 @@ public function paginate(Builder $builder, $perPage, $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) + protected function searchModels(Builder $builder, $page = null, $perPage = null) { - $columns = array_keys($builder->model->toSearchableArray()); + $query = $this->initializeSearchQuery( + $builder, + $columns = array_keys($builder->model->toSearchableArray()) + ); + + $query = $this->addAdditionalConstraints($builder, $query); + + $models = $this->constrainForSoftDeletes($builder, $query) + ->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { + return $query->forPage($page, $perPage); + })->get(); + + return count($models) > 0 + ? $models->filter->shouldBeSearchable()->values() + : $models; + } - $query = $builder->model->query()->where(function ($query) use ($builder, $columns) { + /** + * Build the initial text search database query for all relevant columns. + * + * @param \Laravel\Scout\Builder $builder + * @param array $columns + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function initializeSearchQuery(Builder $builder, array $columns) + { + return $builder->model->query()->where(function ($query) use ($builder, $columns) { $connectionType = $builder->model->getConnection()->getDriverName(); $canSearchPrimaryKey = ctype_digit($builder->query) && @@ -107,8 +134,18 @@ protected function searchModels(Builder $builder) ); } }); + } - $query = $query->when(! is_null($builder->callback), function ($query) use ($builder) { + /** + * Add additional, developer defined constraints to the serach query. + * + * @param \Laravel\Scout\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function addAdditionalConstraints(Builder $builder, $query) + { + return $query->when(! is_null($builder->callback), function ($query) use ($builder) { call_user_func($builder->callback, $query, $builder, $builder->query); })->when(! $builder->callback && count($builder->wheres) > 0, function ($query) use ($builder) { foreach ($builder->wheres as $key => $value) { @@ -121,26 +158,16 @@ protected function searchModels(Builder $builder) $query->whereIn($key, $values); } }); - - $models = $this->ensureSoftDeletesAreHandled($builder, $query) - ->get() - ->values(); - - if (count($models) === 0) { - return $models; - } - - return $models->filter->shouldBeSearchable()->values(); } /** - * Ensure that soft delete handling is properly applied to the query. + * Ensure that soft delete constraints are properly applied to the query. * * @param \Laravel\Scout\Builder $builder * @param \Illuminate\Database\Query\Builder $query * @return \Illuminate\Database\Query\Builder */ - protected function ensureSoftDeletesAreHandled($builder, $query) + protected function constrainForSoftDeletes($builder, $query) { if (Arr::get($builder->wheres, '__soft_deleted') === 0) { return $query->withoutTrashed(); @@ -165,7 +192,7 @@ public function mapIds($results) $results = $results['results']; return count($results) > 0 - ? collect($results)->pluck($results[0]->getKeyName())->values() + ? $results->modelKeys() : collect(); } @@ -179,26 +206,7 @@ public function mapIds($results) */ public function map(Builder $builder, $results, $model) { - $results = $results['results']; - - if (count($results) === 0) { - return $model->newCollection(); - } - - $objectIds = collect($results) - ->pluck($model->getKeyName()) - ->values() - ->all(); - - $objectIdPositions = array_flip($objectIds); - - return $model->getScoutModelsByIds( - $builder, $objectIds - )->filter(function ($model) use ($objectIds) { - return in_array($model->getScoutKey(), $objectIds); - })->sortBy(function ($model) use ($objectIdPositions) { - return $objectIdPositions[$model->getScoutKey()]; - })->values(); + return $results['results']; } /** @@ -211,25 +219,7 @@ public function map(Builder $builder, $results, $model) */ public function lazyMap(Builder $builder, $results, $model) { - $results = $results['results']; - - if (count($results) === 0) { - return LazyCollection::empty(); - } - - $objectIds = collect($results) - ->pluck($model->getKeyName()) - ->values()->all(); - - $objectIdPositions = array_flip($objectIds); - - return $model->queryScoutModelsByIds( - $builder, $objectIds - )->cursor()->filter(function ($model) use ($objectIds) { - return in_array($model->getScoutKey(), $objectIds); - })->sortBy(function ($model) use ($objectIdPositions) { - return $objectIdPositions[$model->getScoutKey()]; - })->values(); + return new LazyCollection($results['results']->all()); } /** From 3a832ba882ab1767ea128ceed6245b38f22ce7e7 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 6 Jan 2022 13:50:08 -0600 Subject: [PATCH 07/17] fulltext support --- src/Attributes/FullText.php | 28 +++++++++++++++++++ src/Engines/DatabaseEngine.php | 49 ++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 src/Attributes/FullText.php diff --git a/src/Attributes/FullText.php b/src/Attributes/FullText.php new file mode 100644 index 00000000..ca61dff8 --- /dev/null +++ b/src/Attributes/FullText.php @@ -0,0 +1,28 @@ +columns = Arr::wrap($columns); + } +} diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index a4844778..43dc1403 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Arr; use Illuminate\Support\LazyCollection; +use Laravel\Scout\Attributes\FullText; use Laravel\Scout\Builder; +use ReflectionMethod; class DatabaseEngine extends Engine { @@ -88,7 +90,8 @@ protected function searchModels(Builder $builder, $page = null, $perPage = null) { $query = $this->initializeSearchQuery( $builder, - $columns = array_keys($builder->model->toSearchableArray()) + $columns = array_keys($builder->model->toSearchableArray()), + $this->getFullTextColumns($builder) ); $query = $this->addAdditionalConstraints($builder, $query); @@ -103,16 +106,38 @@ protected function searchModels(Builder $builder, $page = null, $perPage = null) : $models; } + /** + * Get the full-text columns for the query. + * + * @param \Laravel\Scout\Builder $builder + * @return array + */ + protected function getFullTextColumns(Builder $builder) + { + $columns = []; + + foreach ((new ReflectionMethod($builder->model, 'toSearchableArray'))->getAttributes() as $attribute) { + if ($attribute->getName() !== FullText::class) { + continue; + } + + $columns = array_merge($columns, Arr::wrap($attribute->getArguments()[0])); + } + + return $columns; + } + /** * Build the initial text search database query for all relevant columns. * * @param \Laravel\Scout\Builder $builder * @param array $columns + * @param array $fullTextColumns * @return \Illuminate\Database\Eloquent\Builder */ - protected function initializeSearchQuery(Builder $builder, array $columns) + protected function initializeSearchQuery(Builder $builder, array $columns, array $fullTextColumns = []) { - return $builder->model->query()->where(function ($query) use ($builder, $columns) { + return $builder->model->query()->where(function ($query) use ($builder, $columns, $fullTextColumns) { $connectionType = $builder->model->getConnection()->getDriverName(); $canSearchPrimaryKey = ctype_digit($builder->query) && @@ -127,11 +152,19 @@ protected function initializeSearchQuery(Builder $builder, array $columns) $likeOperator = $connectionType == 'pgsql' ? 'ilike' : 'like'; foreach ($columns as $column) { - $query->orWhere( - $builder->model->qualifyColumn($column), - $likeOperator, - '%'.$builder->query.'%', - ); + if (in_array($column, $fullTextColumns)) { + $query->orWhereFullText( + $builder->model->qualifyColumn($column), + $builder->query + ); + } else { + $query->orWhere( + $builder->model->qualifyColumn($column), + $likeOperator, + '%'.$builder->query.'%', + ); + } + } }); } From 22e7f87cac2ad62bb0b782c33189397e85af34a2 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 6 Jan 2022 14:01:16 -0600 Subject: [PATCH 08/17] add files --- .../{FullText.php => SearchUsingFullText.php} | 2 +- src/Attributes/SearchUsingPrefix.php | 28 +++++++ src/Engines/DatabaseEngine.php | 78 ++++++++++++------- 3 files changed, 80 insertions(+), 28 deletions(-) rename src/Attributes/{FullText.php => SearchUsingFullText.php} (94%) create mode 100644 src/Attributes/SearchUsingPrefix.php diff --git a/src/Attributes/FullText.php b/src/Attributes/SearchUsingFullText.php similarity index 94% rename from src/Attributes/FullText.php rename to src/Attributes/SearchUsingFullText.php index ca61dff8..df9f6928 100644 --- a/src/Attributes/FullText.php +++ b/src/Attributes/SearchUsingFullText.php @@ -6,7 +6,7 @@ use Illuminate\Support\Arr; #[Attribute] -class FullText +class SearchUsingFullText { /** * The full-text columns. diff --git a/src/Attributes/SearchUsingPrefix.php b/src/Attributes/SearchUsingPrefix.php new file mode 100644 index 00000000..f8f38855 --- /dev/null +++ b/src/Attributes/SearchUsingPrefix.php @@ -0,0 +1,28 @@ +columns = Arr::wrap($columns); + } +} diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 43dc1403..7bd073b4 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -2,11 +2,11 @@ namespace Laravel\Scout\Engines; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Arr; use Illuminate\Support\LazyCollection; -use Laravel\Scout\Attributes\FullText; +use Laravel\Scout\Attributes\SearchUsingFullText; +use Laravel\Scout\Attributes\SearchUsingPrefix; use Laravel\Scout\Builder; use ReflectionMethod; @@ -91,6 +91,7 @@ protected function searchModels(Builder $builder, $page = null, $perPage = null) $query = $this->initializeSearchQuery( $builder, $columns = array_keys($builder->model->toSearchableArray()), + $this->getPrefixColumns($builder), $this->getFullTextColumns($builder) ); @@ -106,38 +107,18 @@ protected function searchModels(Builder $builder, $page = null, $perPage = null) : $models; } - /** - * Get the full-text columns for the query. - * - * @param \Laravel\Scout\Builder $builder - * @return array - */ - protected function getFullTextColumns(Builder $builder) - { - $columns = []; - - foreach ((new ReflectionMethod($builder->model, 'toSearchableArray'))->getAttributes() as $attribute) { - if ($attribute->getName() !== FullText::class) { - continue; - } - - $columns = array_merge($columns, Arr::wrap($attribute->getArguments()[0])); - } - - return $columns; - } - /** * Build the initial text search database query for all relevant columns. * * @param \Laravel\Scout\Builder $builder * @param array $columns + * @param array $prefixColumns * @param array $fullTextColumns * @return \Illuminate\Database\Eloquent\Builder */ - protected function initializeSearchQuery(Builder $builder, array $columns, array $fullTextColumns = []) + protected function initializeSearchQuery(Builder $builder, array $columns, array $prefixColumns = [], array $fullTextColumns = []) { - return $builder->model->query()->where(function ($query) use ($builder, $columns, $fullTextColumns) { + return $builder->model->query()->where(function ($query) use ($builder, $columns, $prefixColumns, $fullTextColumns) { $connectionType = $builder->model->getConnection()->getDriverName(); $canSearchPrimaryKey = ctype_digit($builder->query) && @@ -161,10 +142,9 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array $query->orWhere( $builder->model->qualifyColumn($column), $likeOperator, - '%'.$builder->query.'%', + in_array($column, $prefixColumns) ? $builder->query.'%' : '%'.$builder->query.'%', ); } - } }); } @@ -214,6 +194,50 @@ protected function constrainForSoftDeletes($builder, $query) return $query; } + /** + * Get the full-text columns for the query. + * + * @param \Laravel\Scout\Builder $builder + * @return array + */ + protected function getFullTextColumns(Builder $builder) + { + return $this->getAttributeColumns($builder, SearchUsingFullText::class); + } + + /** + * Get the full-text columns for the query. + * + * @param \Laravel\Scout\Builder $builder + * @return array + */ + protected function getPrefixColumns(Builder $builder) + { + return $this->getAttributeColumns($builder, SearchUsingPrefix::class); + } + + /** + * Get the columns marked with a given attribute. + * + * @param \Laravel\Scout\Builder $builder + * @param string $attributeClass + * @return array + */ + protected function getAttributeColumns(Builder $builder, $attributeClass) + { + $columns = []; + + foreach ((new ReflectionMethod($builder->model, 'toSearchableArray'))->getAttributes() as $attribute) { + if ($attribute->getName() !== $attributeClass) { + continue; + } + + $columns = array_merge($columns, Arr::wrap($attribute->getArguments()[0])); + } + + return $columns; + } + /** * Pluck and return the primary keys of the given results. * From d48b3c90704c5c9721b44585c4e1cf7401a072cb Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 6 Jan 2022 16:30:29 -0600 Subject: [PATCH 09/17] fix pagination --- src/Engines/DatabaseEngine.php | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 7bd073b4..476cc54a 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -74,7 +74,7 @@ public function paginate(Builder $builder, $perPage, $page) return [ 'results' => $models, - 'total' => $models->count(), + 'total' => $this->buildSearchQuery($builder)->toBase()->getCountForPagination(), ]; } @@ -87,6 +87,25 @@ public function paginate(Builder $builder, $perPage, $page) * @return \Illuminate\Database\Eloquent\Collection */ protected function searchModels(Builder $builder, $page = null, $perPage = null) + { + $query = $this->buildSearchQuery($builder); + + $models = $query->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { + return $query->forPage($page, $perPage); + })->get(); + + return count($models) > 0 + ? $models->filter->shouldBeSearchable()->values() + : $models; + } + + /** + * Initialize / build the search query for the given Scout builder. + * + * @param \Laravel\Scout\Builder $builder + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function buildSearchQuery(Builder $builder) { $query = $this->initializeSearchQuery( $builder, @@ -95,16 +114,9 @@ protected function searchModels(Builder $builder, $page = null, $perPage = null) $this->getFullTextColumns($builder) ); - $query = $this->addAdditionalConstraints($builder, $query); - - $models = $this->constrainForSoftDeletes($builder, $query) - ->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { - return $query->forPage($page, $perPage); - })->get(); - - return count($models) > 0 - ? $models->filter->shouldBeSearchable()->values() - : $models; + return $this->constrainForSoftDeletes( + $builder, $this->addAdditionalConstraints($builder, $query) + ); } /** From a164255658f156494551a8047b28e6dc840f6a89 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 6 Jan 2022 16:31:45 -0600 Subject: [PATCH 10/17] simplify method --- src/Engines/DatabaseEngine.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 476cc54a..95162ea3 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -88,9 +88,7 @@ public function paginate(Builder $builder, $perPage, $page) */ protected function searchModels(Builder $builder, $page = null, $perPage = null) { - $query = $this->buildSearchQuery($builder); - - $models = $query->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { + $models = $this->buildSearchQuery($builder)->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { return $query->forPage($page, $perPage); })->get(); From be093981febdb357198fc9b3ea5d42a860190189 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 6 Jan 2022 16:47:17 -0600 Subject: [PATCH 11/17] remove temporary variable --- src/Engines/DatabaseEngine.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 95162ea3..b6727414 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -70,10 +70,8 @@ public function search(Builder $builder) */ public function paginate(Builder $builder, $perPage, $page) { - $models = $this->searchModels($builder, $page, $perPage); - return [ - 'results' => $models, + 'results' => $this->searchModels($builder, $page, $perPage), 'total' => $this->buildSearchQuery($builder)->toBase()->getCountForPagination(), ]; } From 4c92dfd91fe4ec492d1d4a31e632de6de21a31f0 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Thu, 6 Jan 2022 18:45:58 -0600 Subject: [PATCH 12/17] adjust how primary key is searched --- src/Engines/DatabaseEngine.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index b6727414..0ff2f2aa 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -86,9 +86,11 @@ public function paginate(Builder $builder, $perPage, $page) */ protected function searchModels(Builder $builder, $page = null, $perPage = null) { - $models = $this->buildSearchQuery($builder)->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { - return $query->forPage($page, $perPage); - })->get(); + $models = $this->buildSearchQuery($builder)->when( + ! is_null($page) && ! is_null($perPage), + function ($query) use ($page, $perPage) { + return $query->forPage($page, $perPage); + })->get(); return count($models) > 0 ? $models->filter->shouldBeSearchable()->values() @@ -147,6 +149,10 @@ protected function initializeSearchQuery(Builder $builder, array $columns, array $builder->query ); } else { + if ($canSearchPrimaryKey && $column === $builder->model->getKeyName()) { + continue; + } + $query->orWhere( $builder->model->qualifyColumn($column), $likeOperator, From 6b9f33a23982630d7799ecfb1fc17d1a28757784 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Fri, 7 Jan 2022 10:07:18 -0600 Subject: [PATCH 13/17] add order by --- 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 0ff2f2aa..d0aa65d4 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -90,7 +90,7 @@ protected function searchModels(Builder $builder, $page = null, $perPage = null) ! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { return $query->forPage($page, $perPage); - })->get(); + })->orderBy($builder->model->getKeyName(), 'desc')->get(); return count($models) > 0 ? $models->filter->shouldBeSearchable()->values() From 1ad1ef781c50ca36ed5a5c1913e90ee73a7bf3cc Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 7 Jan 2022 16:07:36 +0000 Subject: [PATCH 14/17] 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 d0aa65d4..7bafb2fb 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -90,7 +90,7 @@ protected function searchModels(Builder $builder, $page = null, $perPage = null) ! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { return $query->forPage($page, $perPage); - })->orderBy($builder->model->getKeyName(), 'desc')->get(); + })->orderBy($builder->model->getKeyName(), 'desc')->get(); return count($models) > 0 ? $models->filter->shouldBeSearchable()->values() From 8b4089fbfe87c9c8a2b836734e32d6eb707cb000 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sat, 8 Jan 2022 11:48:46 -0600 Subject: [PATCH 15/17] efficient simple pagination --- src/Builder.php | 37 ++++++++++++++--------- src/Contracts/PaginatesEloquentModels.php | 28 +++++++++++++++++ src/Engines/DatabaseEngine.php | 35 +++++++++++++-------- 3 files changed, 73 insertions(+), 27 deletions(-) create mode 100644 src/Contracts/PaginatesEloquentModels.php diff --git a/src/Builder.php b/src/Builder.php index 973aa43e..be0f8f53 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -7,6 +7,7 @@ use Illuminate\Pagination\Paginator; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Laravel\Scout\Contracts\PaginatesEloquentModels; class Builder { @@ -295,6 +296,10 @@ public function simplePaginate($perPage = null, $pageName = 'page', $page = null { $engine = $this->engine(); + if ($engine instanceof PaginatesEloquentModels) { + return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); + } + $page = $page ?: Paginator::resolveCurrentPage($pageName); $perPage = $perPage ?: $this->model->getPerPage(); @@ -303,8 +308,6 @@ public function simplePaginate($perPage = null, $pageName = 'page', $page = null $this, $rawResults = $engine->paginate($this, $perPage, $page), $this->model )->all()); - $hasMorePages = ($perPage * $page) < $engine->getTotalCount($rawResults); - $paginator = Container::getInstance()->makeWith(Paginator::class, [ 'items' => $results, 'perPage' => $perPage, @@ -313,7 +316,7 @@ public function simplePaginate($perPage = null, $pageName = 'page', $page = null 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ], - ])->hasMorePagesWhen($hasMorePages); + ])->hasMorePagesWhen(($perPage * $page) < $engine->getTotalCount($rawResults)); return $paginator->appends('query', $this->query); } @@ -330,16 +333,16 @@ public function simplePaginateRaw($perPage = null, $pageName = 'page', $page = n { $engine = $this->engine(); + if ($engine instanceof PaginatesEloquentModels) { + return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); + } + $page = $page ?: Paginator::resolveCurrentPage($pageName); $perPage = $perPage ?: $this->model->getPerPage(); $results = $engine->paginate($this, $perPage, $page); - $total = $engine->getTotalCount($rawResults); - - $hasMorePages = ($perPage * $page) < $engine->getTotalCount($rawResults); - $paginator = Container::getInstance()->makeWith(Paginator::class, [ 'items' => $results, 'perPage' => $perPage, @@ -348,7 +351,7 @@ public function simplePaginateRaw($perPage = null, $pageName = 'page', $page = n 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ], - ])->hasMorePagesWhen($hasMorePages); + ])->hasMorePagesWhen(($perPage * $page) < $engine->getTotalCount($results)); return $paginator->appends('query', $this->query); } @@ -365,6 +368,10 @@ public function paginate($perPage = null, $pageName = 'page', $page = null) { $engine = $this->engine(); + if ($engine instanceof PaginatesEloquentModels) { + return $engine->paginate($this, $perPage, $page)->appends('query', $this->query); + } + $page = $page ?: Paginator::resolveCurrentPage($pageName); $perPage = $perPage ?: $this->model->getPerPage(); @@ -373,7 +380,7 @@ public function paginate($perPage = null, $pageName = 'page', $page = null) $this, $rawResults = $engine->paginate($this, $perPage, $page), $this->model )->all()); - $paginator = Container::getInstance()->makeWith(LengthAwarePaginator::class, [ + return Container::getInstance()->makeWith(LengthAwarePaginator::class, [ 'items' => $results, 'total' => $this->getTotalCount($rawResults), 'perPage' => $perPage, @@ -382,9 +389,7 @@ public function paginate($perPage = null, $pageName = 'page', $page = null) 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ], - ]); - - return $paginator->appends('query', $this->query); + ])->appends('query', $this->query); } /** @@ -399,6 +404,10 @@ public function paginateRaw($perPage = null, $pageName = 'page', $page = null) { $engine = $this->engine(); + if ($engine instanceof PaginatesEloquentModels) { + return $engine->paginate($this, $perPage, $page)->appends('query', $this->query); + } + $page = $page ?: Paginator::resolveCurrentPage($pageName); $perPage = $perPage ?: $this->model->getPerPage(); @@ -414,9 +423,7 @@ public function paginateRaw($perPage = null, $pageName = 'page', $page = null) 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ], - ]); - - return $paginator->appends('query', $this->query); + ])->appends('query', $this->query); } /** diff --git a/src/Contracts/PaginatesEloquentModels.php b/src/Contracts/PaginatesEloquentModels.php new file mode 100644 index 00000000..f7df020a --- /dev/null +++ b/src/Contracts/PaginatesEloquentModels.php @@ -0,0 +1,28 @@ + $this->searchModels($builder, $page, $perPage), - 'total' => $this->buildSearchQuery($builder)->toBase()->getCountForPagination(), - ]; + return $this->buildSearchQuery($builder) + ->orderBy($builder->model->getKeyName(), 'desc') + ->paginate($perPage, ['*'], 'page', $page); + } + + /** + * Paginate the given search on the engine using simple pagination. + * + * @param \Laravel\Scout\Builder $builder + * @param int $perPage + * @param int $page + * @return \Illuminate\Contracts\Pagination\Paginator + */ + public function simplePaginate(Builder $builder, $perPage, $page) + { + return $this->buildSearchQuery($builder) + ->orderBy($builder->model->getKeyName(), 'desc') + ->simplePaginate($perPage, ['*'], 'page', $page); } /** @@ -86,15 +101,11 @@ public function paginate(Builder $builder, $perPage, $page) */ protected function searchModels(Builder $builder, $page = null, $perPage = null) { - $models = $this->buildSearchQuery($builder)->when( + return $this->buildSearchQuery($builder)->when( ! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { return $query->forPage($page, $perPage); })->orderBy($builder->model->getKeyName(), 'desc')->get(); - - return count($models) > 0 - ? $models->filter->shouldBeSearchable()->values() - : $models; } /** From fd7d246d4f94173330a88f34998a024131d8b05d Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sat, 8 Jan 2022 11:56:59 -0600 Subject: [PATCH 16/17] add basic tests --- tests/Feature/DatabaseEngineTest.php | 98 +++++++++++++++++++ .../Fixtures/SearchableUserDatabaseModel.php | 27 +++++ 2 files changed, 125 insertions(+) create mode 100644 tests/Feature/DatabaseEngineTest.php create mode 100644 tests/Fixtures/SearchableUserDatabaseModel.php diff --git a/tests/Feature/DatabaseEngineTest.php b/tests/Feature/DatabaseEngineTest.php new file mode 100644 index 00000000..e2afa1b4 --- /dev/null +++ b/tests/Feature/DatabaseEngineTest.php @@ -0,0 +1,98 @@ +make('config')->set('scout.driver', 'database'); + } + + protected function defineDatabaseMigrations() + { + $this->setUpFaker(); + $this->loadLaravelMigrations(); + + UserFactory::new()->create([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + ]); + + UserFactory::new()->create([ + 'name' => 'Abigail Otwell', + 'email' => 'abigail@laravel.com', + ]); + } + + public function test_it_can_retrieve_results_with_empty_search() + { + $models = SearchableUserDatabaseModel::search()->get(); + + $this->assertCount(2, $models); + } + + public function test_it_can_retrieve_results() + { + $models = SearchableUserDatabaseModel::search('Taylor')->where('email', 'taylor@laravel.com')->get(); + $this->assertCount(1, $models); + $this->assertEquals(1, $models[0]->id); + + $models = SearchableUserDatabaseModel::search('Taylor')->query(function ($query) { + $query->where('email', 'like', 'taylor@laravel.com'); + })->get(); + + $this->assertCount(1, $models); + $this->assertEquals(1, $models[0]->id); + + $models = SearchableUserDatabaseModel::search('Abigail')->where('email', 'abigail@laravel.com')->get(); + $this->assertCount(1, $models); + $this->assertEquals(2, $models[0]->id); + + $models = SearchableUserDatabaseModel::search('Taylor')->where('email', 'abigail@laravel.com')->get(); + $this->assertCount(0, $models); + + $models = SearchableUserDatabaseModel::search('Taylor')->where('email', 'taylor@laravel.com')->get(); + $this->assertCount(1, $models); + + $models = SearchableUserDatabaseModel::search('otwell')->get(); + $this->assertCount(2, $models); + + $models = SearchableUserDatabaseModel::search('laravel')->get(); + $this->assertCount(2, $models); + + $models = SearchableUserDatabaseModel::search('foo')->get(); + $this->assertCount(0, $models); + + $models = SearchableUserDatabaseModel::search('Abigail')->where('email', 'taylor@laravel.com')->get(); + $this->assertCount(0, $models); + } + + public function test_it_can_paginate_results() + { + $models = SearchableUserDatabaseModel::search('Taylor')->where('email', 'taylor@laravel.com')->paginate(); + $this->assertCount(1, $models); + + $models = SearchableUserDatabaseModel::search('Taylor')->where('email', 'abigail@laravel.com')->paginate(); + $this->assertCount(0, $models); + + $models = SearchableUserDatabaseModel::search('Taylor')->where('email', 'taylor@laravel.com')->paginate(); + $this->assertCount(1, $models); + + $models = SearchableUserDatabaseModel::search('laravel')->paginate(); + $this->assertCount(2, $models); + } +} diff --git a/tests/Fixtures/SearchableUserDatabaseModel.php b/tests/Fixtures/SearchableUserDatabaseModel.php new file mode 100644 index 00000000..5be01b02 --- /dev/null +++ b/tests/Fixtures/SearchableUserDatabaseModel.php @@ -0,0 +1,27 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + ]; + } +} From 4f18e4ba6cf1b63cea62786edd60684ffbcadfe6 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sat, 8 Jan 2022 12:01:54 -0600 Subject: [PATCH 17/17] adjust for php 8 --- src/Engines/DatabaseEngine.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Engines/DatabaseEngine.php b/src/Engines/DatabaseEngine.php index 7bdcd1ba..d0f5539d 100644 --- a/src/Engines/DatabaseEngine.php +++ b/src/Engines/DatabaseEngine.php @@ -252,6 +252,10 @@ protected function getAttributeColumns(Builder $builder, $attributeClass) { $columns = []; + if (PHP_MAJOR_VERSION < 8) { + return []; + } + foreach ((new ReflectionMethod($builder->model, 'toSearchableArray'))->getAttributes() as $attribute) { if ($attribute->getName() !== $attributeClass) { continue;