From 63505c4441ad0a682511a347fb12a5d2e627b023 Mon Sep 17 00:00:00 2001 From: Aaron Huisinga Date: Wed, 28 Apr 2021 14:46:06 -0500 Subject: [PATCH] Add Searchable trait --- .gitignore | 3 + README.md | 74 +++- composer.json | 25 ++ config/searchable.php | 16 + src/Searchable.php | 662 ++++++++++++++++++++++++++++++ src/SearchableServiceProvider.php | 15 + 6 files changed, 793 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 config/searchable.php create mode 100644 src/Searchable.php create mode 100644 src/SearchableServiceProvider.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f38912 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +vendor +composer.lock diff --git a/README.md b/README.md index 16ca06d..48b3c5f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,72 @@ -# laravel-searchable - Searchable trait for Laravel Eloquent models +# Laravel Searchable + +This package makes it easy to search your Laravel models. + +## Installation + +You can install the package via composer: + +```bash +composer require craftcodery/laravel-searchable +``` + +## Usage + +### Preparing your models + +In order to search through models you'll have to use the `Searchable` trait and add the `toSearchableArray` method. + +```php +namespace App\Models; + +use Illuminate\Database\Eloquent\Model; +use CraftCodery\Searchable; + +class User extends Model +{ + use Searchable; + + /** + * Get the searchable data array for the model. + * + * @return array + */ + public function toSearchableArray() + { + return [ + 'columns' => [ + 'users.name' => 60, + 'users.email' => 60, + 'locations.city' => 40, + ], + 'joins' => [ + 'locations' => [ + 'users.location_id', + 'locations.id' + ], + ], + 'groupBy' => 'users.id' + ]; + } +} +``` + +### Searching models + +To search your models, just use the `search` method. + +```php +$users = User::search('john')->get(); +``` + +### Configuring search matchers + +You can configure the different search matchers and weights given to each used by the package. + +``` +php artisan vendor:publish --provider=CraftCodery\Searchable\SearchableServiceProvider --tag="config" +``` + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6b92faf --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "craftcodery/laravel-searchable", + "description": "Searchable trait for Laravel Eloquent models", + "keywords": [ + "laravel", + "eloquent", + "search" + ], + "homepage": "https://github.com/craftcodery/laravel-searchable", + "license": "MIT", + "authors": [ + { + "name": "Craft Codery" + } + ], + "require": { + "php": "^7.2|^8.0", + "laravel/framework": "^6.0|^7.0|^8.0" + }, + "autoload": { + "psr-4": { + "CraftCodery\\Searchable\\": "src/" + } + } +} diff --git a/config/searchable.php b/config/searchable.php new file mode 100644 index 0000000..fd2d719 --- /dev/null +++ b/config/searchable.php @@ -0,0 +1,16 @@ + [ + 'exactFullMatcher' => 100, + 'exactInStringMatcher' => 100, + 'exactMatcher' => 60, + 'startOfStringMatcher' => 50, + 'acronymMatcher' => 42, + 'consecutiveCharactersMatcher' => 40, + 'startOfWordsMatcher' => 35, + 'inStringMatcher' => 30, + 'similarStringMatcher' => 30, + 'timesInStringMatcher' => 8, + ], +]; diff --git a/src/Searchable.php b/src/Searchable.php new file mode 100644 index 0000000..ee7930f --- /dev/null +++ b/src/Searchable.php @@ -0,0 +1,662 @@ +searchable = $this->toSearchableArray(); + + $cloned_query = clone $query; + $cloned_query->select($this->getTable() . '.*'); + $this->makeJoins($cloned_query); + + $search = $this->formatSearchString($search); + if ($search === '') { + return $query; + } + + $this->getWords($search); + + $selects = []; + $this->search_bindings = []; + $relevance_count = array_sum($this->getMatchers()); + + foreach ($this->getColumns() as $column => $relevance) { + $relevance_count += $relevance; + foreach ($this->getSearchQueriesForColumn($column, $relevance) as $select) { + $selects[] = $select; + } + } + + foreach ($this->getFullTextColumns() as $column => $relevance) { + $relevance_count += $relevance; + $this->fullTextMatcher($column, $relevance); + $selects[] = $this->getSearchQuery($column, $relevance * 100); + } + + $this->addSelectsToQuery($cloned_query, $selects); + $this->filterQueryWithRelevance($cloned_query, $relevance_count); + $this->makeWhere($cloned_query); + $this->makeGroupBy($cloned_query); + + if ($restriction instanceof Closure) { + $cloned_query = $restriction($cloned_query); + } + + $clone_bindings = $cloned_query->getBindings(); + $cloned_query->setBindings([]); + $cloned_query->setBindings([], 'join'); + + $this->addBindingsToQuery($cloned_query, $this->search_bindings); + $this->addBindingsToQuery($cloned_query, $clone_bindings); + + $this->mergeQueries($cloned_query, $query); + + return $query; + } + + /** + * Get the indexable data array for the model. + * + * @return array + */ + abstract public function toSearchableArray(); + + /** + * Adds the sql joins to the query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + */ + protected function makeJoins(Builder $query) + { + foreach ($this->getJoins() as $table => $keys) { + $query->leftJoin($table, function ($join) use ($keys) { + $join->on($keys[0], '=', $keys[1]); + if (array_key_exists('whereIn', $keys)) { + $join->whereIn($keys['whereIn'][0], $keys['whereIn'][1]); + } elseif (array_key_exists('where', $keys)) { + $join->where($keys['where'][0], $keys['where'][1], $keys['where'][2]); + } + }); + } + } + + /** + * Returns the tables that are to be joined. + * + * @return array + */ + protected function getJoins() + { + return Arr::get($this->searchable, 'joins', []); + } + + /** + * Format the search string for our query. + * + * @param string $search + * + * @return string + */ + protected function formatSearchString($search) + { + $search = mb_strtolower(trim($search)); + + // Determine if we're attempting to search for a phone number. + $alphanumeric = preg_replace('/[\W_]/', '', $search); + + if (ctype_digit($alphanumeric)) { + return $alphanumeric; + } + + return $search; + } + + /** + * Get the words that will be used in the search query. + * + * @param string $search + * + * @return bool + */ + protected function getWords($search) + { + preg_match_all('/"((?:\\\\.|[^\\\\"])*)"|(\S+)/', $search, $matches); + $words = $matches[1]; + $number_of_matches = count($matches); + for ($i = 2; $i < $number_of_matches; $i++) { + $words = array_filter($words) + $matches[$i]; + } + + $this->words = array_slice($words, 0, 5); + + usort($words, fn($a, $b) => strlen($b) <=> strlen($a)); + + $this->orderedWords = array_slice($words, 0, 5); + + return true; + } + + /** + * Get the matchers to use to determine the relevance score. + * + * @return array + */ + protected function getMatchers() + { + $matchers = $this->getSearchableConfig()['matchers']; + + if (count($this->words) === 1) { + unset($matchers['exactFullMatcher'], $matchers['exactInStringMatcher']); + } + + if ($this->getDatabaseDriver() === 'sqlite') { + unset($matchers['similarStringMatcher']); + } + + return $matchers; + } + + /** + * Returns database driver Ex: mysql, pgsql, sqlite. + * + * @return string + */ + protected function getDatabaseDriver() + { + $key = $this->connection ?? config('database.default'); + + return config('database.connections.' . $key . '.driver'); + } + + /** + * Returns the search columns. + * + * @return array + */ + protected function getColumns() + { + if (array_key_exists('columns', $this->searchable)) { + $driver = $this->getDatabaseDriver(); + $prefix = config('database.connections' . $driver . 'prefix'); + $columns = []; + foreach ($this->searchable['columns'] as $column => $priority) { + $columns[$prefix . $column] = $priority; + } + + if ($driver === 'sqlite' && array_key_exists('fulltext', $this->searchable)) { + foreach ($this->searchable['fulltext'] as $column => $priority) { + $columns[$prefix . $column] = $priority; + } + } + + return $columns; + } + + return []; + } + + /** + * Returns the search queries for the specified column. + * + * @param string $column + * @param float $relevance + * + * @return array + */ + protected function getSearchQueriesForColumn($column, $relevance) + { + $queries = []; + + foreach ($this->getMatchers() as $matcher => $score) { + if ($matcher === 'exactFullMatcher') { + $this->exactMatcher(implode(' ', $this->words)); + $queries[] = $this->getSearchQuery($column, $relevance * $score); + + continue; + } + + if ($matcher === 'exactInStringMatcher') { + $this->inStringMatcher(implode(' ', $this->words)); + $queries[] = $this->getSearchQuery($column, $relevance * $score); + + continue; + } + + foreach ($this->words as $word) { + $this->$matcher($word, $column, $relevance * $score); + $queries[] = $this->getSearchQuery($column, $relevance * $score); + } + } + + return $queries; + } + + /** + * Matches an exact string and applies a high multiplier to bring any exact matches to the top + * When sanitize is on, if the expression strips some of the characters from the search query + * then this may not be able to match against a string despite entering in an exact match. + * + * @param string $query + */ + protected function exactMatcher($query) + { + $this->operator = '='; + $this->searchString = "$query"; + $this->search_bindings[] = $this->searchString; + $this->matcherQuery = null; + } + + /** + * Returns the sql string for the given parameters. + * + * @param string $column + * @param float $relevance + * + * @return string + */ + protected function getSearchQuery($column, $relevance) + { + $cases = []; + $cases[] = $this->createMatcher($column, $relevance); + + return implode(' + ', $cases); + } + + /** + * Returns the comparison string. + * + * @param string $column + * @param float $relevance + * + * @return string + */ + protected function createMatcher($column, $relevance) + { + $formatted = '`' . str_replace('.', '`.`', $column) . '`'; + + if (isset($this->searchable['mutations'][$column])) { + $formatted = $this->searchable['mutations'][$column] . '(' . $formatted . ')'; + } + + return $this->matcherQuery ?? "CASE WHEN $formatted $this->operator ? THEN $relevance ELSE 0 END"; + } + + /** + * Matches against any occurrences of a string within a string and is case-insensitive. + * + * For example, a search for 'smi' would match; 'John Smith' or 'Smiley Face' + * + * @param string $query + */ + protected function inStringMatcher($query) + { + $this->operator = 'LIKE'; + $this->searchString = "%$query%"; + $this->search_bindings[] = $this->searchString; + $this->matcherQuery = null; + } + + /** + * Returns the full text search columns. + * + * @return array + */ + protected function getFullTextColumns() + { + if (array_key_exists('fulltext', $this->searchable)) { + $driver = $this->getDatabaseDriver(); + + if ($driver === 'sqlite') { + return []; + } + + $prefix = config('database.connections' . $driver . 'prefix'); + $columns = []; + foreach ($this->searchable['fulltext'] as $column => $priority) { + $columns[$prefix . $column] = $priority; + } + + return $columns; + } + + return []; + } + + /** + * Matches a full text column against a search query + * + * @param string $column + * @param int|float $relevance + */ + protected function fullTextMatcher($column, $relevance) + { + $this->search_bindings[] = implode(' ', $this->orderedWords); + $column = str_replace('.', '`.`', $column); + + $this->matcherQuery = "(MATCH(`$column`) AGAINST (?) * $relevance * 2)"; + } + + /** + * Puts all the select clauses to the main query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param array $selects + */ + protected function addSelectsToQuery(Builder $query, array $selects) + { + $query->addSelect(new Expression('(' . implode(' + ', $selects) . ') as relevance')); + } + + /** + * Adds the relevance filter to the query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param int $relevance_count + */ + protected function filterQueryWithRelevance(Builder $query, $relevance_count) + { + $this->threshold = $relevance_count * (count($this->getColumns()) / 6) * (count($this->getMatchers()) / 6); + + $query->havingRaw('relevance >= ' . number_format($this->threshold, 2, '.', '')); + $query->orderBy('relevance', 'desc'); + } + + /** + * Adds where clause to query to deal with fulltext columns. + * + * @param \Illuminate\Database\Eloquent\Builder $query + */ + protected function makeWhere(Builder $query) + { + if ($this->getDatabaseDriver() === 'sqlite') { + return; + } + + foreach ($this->getFullTextColumns() as $column => $relevance) { + $column = str_replace('.', '`.`', $column); + + $query->whereRaw("MATCH (`$column`) AGAINST (?)", [implode(' ', $this->orderedWords)]); + } + } + + /** + * Makes the query not repeat the results. + * + * @param \Illuminate\Database\Eloquent\Builder $query + */ + protected function makeGroupBy(Builder $query) + { + if ($groupBy = $this->getGroupBy()) { + $query->groupBy($groupBy); + } else { + $columns = $this->getTable() . '.' . $this->primaryKey; + + $query->groupBy($columns); + + $joins = array_keys($this->getJoins()); + + foreach ($this->getColumns() as $column => $relevance) { + array_map(function ($join) use ($column, $query) { + if (Str::contains($column, $join)) { + $query->groupBy($column); + } + }, $joins); + } + } + } + + /** + * Returns whether or not to keep duplicates. + * + * @return array|bool + */ + protected function getGroupBy() + { + if (array_key_exists('groupBy', $this->searchable)) { + return $this->searchable['groupBy']; + } + + return false; + } + + /** + * Adds the bindings to the query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param array $bindings + * @param string $type + */ + protected function addBindingsToQuery(Builder $query, array $bindings, $type = 'having') + { + foreach ($bindings as $binding) { + $query->addBinding($binding, $type); + } + } + + /** + * Merge our cloned query builder with the original one. + * + * @param \Illuminate\Database\Eloquent\Builder $clone + * @param \Illuminate\Database\Eloquent\Builder $original + */ + protected function mergeQueries(Builder $clone, Builder $original) + { + $tableName = DB::connection($this->connection)->getTablePrefix() . $this->getTable(); + $original->fromSub($clone, $tableName); + } + + /** + * Returns the table columns. + * + * @return array + */ + public function getTableColumns() + { + return $this->searchable['table_columns']; + } + + /** + * Matches Strings that begin with the search string. + * + * For example, a search for 'hel' would match; 'Hello World' or 'helping hand' + * + * @param string $query + */ + protected function startofStringMatcher($query) + { + $this->operator = 'LIKE'; + $this->searchString = "$query%"; + $this->search_bindings[] = $this->searchString; + $this->matcherQuery = null; + } + + /** + * Matches strings for Acronym 'like' matches but does NOT return Studly Case Matches. + * + * for example, a search for 'fb' would match; 'foo bar' or 'Fred Brown' but not 'FreeBeer'. + * + * @param string $query + */ + protected function acronymMatcher($query) + { + $this->operator = 'LIKE'; + + $query = preg_replace('/[^0-9a-zA-Z]/', '', $query); + $this->searchString = implode('% ', str_split(strtoupper($query))) . '%'; + $this->search_bindings[] = $this->searchString; + + $this->matcherQuery = null; + } + + /** + * Matches strings that include all the characters in the search relatively position within the string. + * It also calculates the percentage of characters in the string that are matched and applies the multiplier + * accordingly. + * + * For Example, a search for 'fba' would match; 'Foo Bar' or 'Afraid of bats' + * + * @param string $query + * @param string $column + * @param int|float $relevance + */ + protected function consecutiveCharactersMatcher($query, $column, $relevance) + { + $this->operator = 'LIKE'; + $this->searchString = '%' . implode('%', str_split(preg_replace('/[^0-9a-zA-Z]/', '', $query))) . '%'; + $this->search_bindings[] = $this->searchString; + $this->search_bindings[] = $query; + $column = str_replace('.', '`.`', $column); + + $this->matcherQuery = "CASE WHEN REPLACE(`$column`, '\.', '') $this->operator ? THEN ROUND($relevance * ( {$this->getLengthMethod()}( ? ) / {$this->getLengthMethod()}( REPLACE(`$column`, ' ', '') ))) ELSE 0 END"; + } + + /** + * Get the proper length method depending on driver. + * + * @return string + */ + protected function getLengthMethod() + { + if ($this->getDatabaseDriver() === 'sqlite') { + return 'LENGTH'; + } + + return 'CHAR_LENGTH'; + } + + /** + * Matches the start of each word against each word in a search. + * + * For example, a search for 'jo ta' would match; 'John Taylor' or 'Joshua B. Takashi' + * + * @param string $query + */ + protected function startOfWordsMatcher($query) + { + $this->operator = 'LIKE'; + $this->searchString = str_replace(' ', '% ', $query) . '%'; + $this->search_bindings[] = $this->searchString; + $this->matcherQuery = null; + } + + /** + * Matches against occurrences of a string that sounds like another string. + * + * For example, a search for 'aarron' would match 'Aaron' + * + * @param string $query + */ + protected function similarStringMatcher($query) + { + $this->operator = 'SOUNDS LIKE'; + $this->searchString = "$query"; + $this->search_bindings[] = $this->searchString; + $this->matcherQuery = null; + } + + /** + * Matches a string based on how many times the search string appears inside + * the string. It then applies the multiplier for each occurrence. + * + * For example, a search for 'tha' would match; 'I hope that that cat has caught that mouse' (3 x multiplier) or + * 'Thanks, it was great!' (1 x multiplier) + * + * @param string $query + * @param string $column + * @param int|float $relevance + */ + protected function timesInStringMatcher($query, $column, $relevance) + { + $this->search_bindings[] = $query; + $this->search_bindings[] = $query; + $column = str_replace('.', '`.`', $column); + + $this->matcherQuery = "($relevance * ROUND(({$this->getLengthMethod()}(COALESCE(`$column`, '')) - {$this->getLengthMethod()}( REPLACE( LOWER(COALESCE(`$column`, '')), lower(?), ''))) / LENGTH(?)))"; + } + + /** + * Get the config for the package. + * + * @return array + */ + protected function getSearchableConfig() + { + return app('config')->get('searchable'); + } +} diff --git a/src/SearchableServiceProvider.php b/src/SearchableServiceProvider.php new file mode 100644 index 0000000..4fc64fd --- /dev/null +++ b/src/SearchableServiceProvider.php @@ -0,0 +1,15 @@ +publishes([$configPath => config_path('searchable.php')]); + $this->mergeConfigFrom($configPath, 'searchable'); + } +}