├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── searchman.php └── src ├── Breakers ├── CurlyBreaker.php ├── SpecialCharacterBreaker.php └── UnitBreaker.php ├── Console ├── MakeIndex.php └── stubs │ └── searchable_index_migration.stub ├── Contracts ├── IndexBreaker.php └── PriorityHandler.php ├── Engines └── MySqlEngine.php ├── Helpers ├── Constants.php ├── Indexable.php ├── Indexer.php └── Searcher.php ├── PriorityHandlers ├── LocationPriorityHandler.php └── LongTextPriorityHandler.php ├── Providers └── SearchableServiceProvider.php └── Traits └── SearchMan.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Gabriel Nwogu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-searchman 2 | MySql Driver for Laravel Scout 3 | 4 | ## Requirements 5 | * Requires Laravel Installed ^5.6 6 | * Requires Laravel Scout ^7.0 7 | 8 | ## Installation 9 | ```composer require nwogu\laravel-searchman``` 10 | 11 | ## Setup 12 | Searchman Provides a Mysql Driver for Laravel Scout, for full text search 13 | with indexing priorities and sql where expressions. 14 | 15 | [Laravel Scout Documentation](https://laravel.com/docs/5.8/scout) 16 | 17 | After installing Searchman, you can publish the configuration 18 | using the vendor:publish Artisan command. This command will publish the searchman.php 19 | configuration file to your config directory: 20 | 21 | ```php artisan vendor:publish --provider="Nwogu\SearchMan\Provider\SearchableServiceProvider"``` 22 | 23 | Add the ```Nwogu\SearchMan\Traits\SearchMan``` trait to the model you would like to make searchable. 24 | This trait uses Laravel's Scout Searchable and adds extra methods required by the engine: 25 | 26 | ``` 27 | LongTextPriorityHandler::class, 77 | 'email' => LocationPriorityHandler::class 78 | ]; 79 | } 80 | 81 | ``` 82 | By default, the LocationPriorityHandler is used for all indexing. you can 83 | overide this in the searchman config file. 84 | Building your own handlers is easy. Implement the Priority handler Interface and you are good to go. 85 | 86 | ## Searching 87 | Laravel Scout only suports strict where clauses. but with Searchman, you can specify the operation of 88 | your ```where``` statements using ```:```. 89 | 90 | ``` 91 | App\Meeting::search("discussion on health")->where("attendance:>", 10)->get(); 92 | 93 | ``` 94 | For more on search, look up the [Laravel Scout Documentation](https://laravel.com/docs/5.8/scout) 95 | 96 | ## Results 97 | Calling get on your query would return an Eloquent collection of models sorted by the priority attribute. 98 | 99 | ``` 100 | {#3017 101 | +"id": 9, 102 | +"society_id": 1, 103 | +"name": "General Meeting Thursday, 21 Mar, 2019", 104 | +"type": "general meeting", 105 | +"minute": "

tjlkj;km;

", 106 | +"start_time": "2019-03-18 14:00:00", 107 | +"end_time": "2019-03-18 17:00:00", 108 | +"presider": 1, 109 | +"total_attendance": 1, 110 | +"created_at": "2019-03-18 19:16:01", 111 | +"updated_at": "2019-03-18 19:16:01", 112 | +"meeting_date": "2019-03-21 20:19:00", 113 | +"priority": 3.0, 114 | +"document_id": 9, 115 | +"priotity": 7.5 116 | } 117 | ``` 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nwogu/laravel-searchman", 3 | "description": "MySql Driver for Laravel Scout", 4 | "authors": [ 5 | { 6 | "name": "Gabriel Nwogu", 7 | "email": "nwogugabriel@gmail.com" 8 | } 9 | ], 10 | "license": "MIT", 11 | "keywords": ["laravel", "scout", "mysql", "indexing", "priorities"], 12 | "require": { 13 | "laravel/scout": "^7.0" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Nwogu\\SearchMan\\": "src/" 18 | } 19 | }, 20 | "extra": { 21 | "laravel": { 22 | "providers": [ 23 | "Nwogu\\SearchMan\\Providers\\SearchableServiceProvider" 24 | ] 25 | } 26 | }, 27 | "minimum-stability": "dev", 28 | "prefer-stable": true 29 | } 30 | -------------------------------------------------------------------------------- /config/searchman.php: -------------------------------------------------------------------------------- 1 | LocationPriorityHandler::class, 8 | 9 | 10 | "indexable_length" => 5, 11 | 12 | 13 | "connection" => null, 14 | 15 | 16 | "suffix" => "_index" 17 | 18 | ]; -------------------------------------------------------------------------------- /src/Breakers/CurlyBreaker.php: -------------------------------------------------------------------------------- 1 | "(", 13 | "closer" => ")" 14 | ]; 15 | 16 | public function break(): string 17 | { 18 | preg_match("/\\" 19 | . self::$characterBreakers['opener'] 20 | . "(.*?)\\" 21 | . self::$characterBreakers['closer'] 22 | . "/", $this->target, $match); 23 | 24 | if (empty($match)) { 25 | return $this->target; 26 | } 27 | 28 | $sanitized = str_replace($match[0], "", $this->target); 29 | 30 | $newIndex = $match[1]; 31 | if (str_contains($newIndex, [self::$characterBreakers["opener"], self::$characterBreakers["closer"]])){ 32 | throw new \Exception(Constants::CURLY_BRACKET_EXCEPTION); 33 | } 34 | return $newIndex . " " . $sanitized; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Breakers/SpecialCharacterBreaker.php: -------------------------------------------------------------------------------- 1 | "/[^A-Za-z0-9\ ]/" 12 | ]; 13 | 14 | public function break(): string 15 | { 16 | return preg_replace(self::$characterBreakers['opener'], '', $this->target); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Breakers/UnitBreaker.php: -------------------------------------------------------------------------------- 1 | "/(([0-9]*\.?[0-9]+).[\s]?(\w+)+[\s]?([x,X][0-9]+)?)/" 12 | ]; 13 | 14 | public function break(): string 15 | { 16 | preg_match(self::$characterBreakers['opener'], $this->target, $match); 17 | 18 | if (empty($match)) { 19 | 20 | return $this->target; 21 | } 22 | $sanitized = str_replace($match[0], "", $this->target); 23 | 24 | return $match[0] . " " . $sanitized; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Console/MakeIndex.php: -------------------------------------------------------------------------------- 1 | files = $files; 57 | $this->composer = $composer; 58 | } 59 | 60 | /** 61 | * Execute the console command. 62 | * 63 | * @return void 64 | */ 65 | public function handle() 66 | { 67 | $searchable = $this->argument('searchable'); 68 | 69 | $searchable = new $searchable;; 70 | 71 | $indexTable = $searchable->searchableAs(); 72 | 73 | $table = "create_{$indexTable}_table"; 74 | 75 | $fullPath = $this->createBaseMigration($table); 76 | 77 | $replaceables = [ 78 | "{CreateClassNameTable}" => $this->getSearchableModelIndexClass($table), 79 | "{table_name}" => $indexTable 80 | ]; 81 | 82 | $basePath = __DIR__.'/stubs/searchable_index_migration.stub'; 83 | 84 | foreach ($replaceables as $search => $replace) { 85 | $this->files->put($fullPath, 86 | str_replace($search, $replace, 87 | $this->files->get($basePath) 88 | )); 89 | $basePath = $fullPath; 90 | } 91 | 92 | 93 | $this->info('Migration created successfully!'); 94 | 95 | $this->composer->dumpAutoloads(); 96 | } 97 | 98 | /** 99 | * Create a base migration file for the indexes. 100 | * 101 | * @return string 102 | */ 103 | protected function createBaseMigration($table) 104 | { 105 | $path = $this->laravel->databasePath().'/migrations'; 106 | 107 | return $this->laravel['migration.creator']->create($table, $path); 108 | } 109 | 110 | /** 111 | * Gets Searchable Model Index Class 112 | */ 113 | protected function getSearchableModelIndexClass($table) 114 | { 115 | return Str::studly(implode('_', array_slice(explode('_', $table), 0))); 116 | } 117 | 118 | } -------------------------------------------------------------------------------- /src/Console/stubs/searchable_index_migration.stub: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('keyword'); 19 | $table->integer('document_id'); 20 | $table->string('column'); 21 | $table->float('priority', 8, 2); 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down() 32 | { 33 | Schema::dropIfExists('{table_name}'); 34 | } 35 | } -------------------------------------------------------------------------------- /src/Contracts/IndexBreaker.php: -------------------------------------------------------------------------------- 1 | target = strtolower($word); 20 | } 21 | 22 | /** 23 | * Break Word 24 | * @return string $this->target 25 | */ 26 | abstract public function break(): string; 27 | } -------------------------------------------------------------------------------- /src/Contracts/PriorityHandler.php: -------------------------------------------------------------------------------- 1 | refresh()->toSearchableArray(); 18 | 19 | $indexer = (new Indexer($model))->delete(); 20 | 21 | foreach ($searchableColumns as $column => $value) { 22 | 23 | $priorityHandler = !in_array($column, array_keys($model->getIndexablePriorities())) 24 | ? config('searchman.default_priority_handler', LocationPriorityHandler::class) 25 | : $model->getIndexablePriorities()[$column]; 26 | 27 | $indexable = new Indexable($column, $value, $priorityHandler); 28 | 29 | $indexer->index($indexable); 30 | } 31 | return $model; 32 | } 33 | 34 | /** 35 | * Pluck and return the primary keys of the given results. 36 | * 37 | * @param mixed $results 38 | * @return \Illuminate\Support\Collection 39 | */ 40 | public function mapIds($results) 41 | { 42 | return collect($results['hits'])->pluck('document_id')->values(); 43 | } 44 | 45 | /** 46 | * Delete Index 47 | */ 48 | public function delete($models) 49 | { 50 | if ($models->isEmpty()) return; 51 | 52 | $models->map(function ($model) { 53 | (new Indexer($model))->delete(); 54 | }); 55 | 56 | return true; 57 | } 58 | 59 | /** 60 | * Update Index 61 | */ 62 | public function update($models) 63 | { 64 | $models->map(function ($model) { 65 | $this->performUpdate($model); 66 | }); 67 | return true; 68 | } 69 | 70 | /** 71 | * Search 72 | * @param Builder $builder 73 | */ 74 | public function search(Builder $builder) 75 | { 76 | return (new Searcher($builder))->search(); 77 | } 78 | 79 | /** 80 | * Perform the given search on the engine. 81 | * 82 | * @param \Laravel\Scout\Builder $builder 83 | * @param int $perPage 84 | * @param int $page 85 | * @return mixed 86 | */ 87 | public function paginate(Builder $builder, $perPage, $page) 88 | { 89 | return (new Searcher($builder))->search((($page * $perPage) - $perPage)); 90 | } 91 | 92 | /** 93 | * Map the given results to instances of the given model. 94 | * 95 | * @param \Laravel\Scout\Builder $builder 96 | * @param mixed $results 97 | * @param \Illuminate\Database\Eloquent\Model $model 98 | * @return \Illuminate\Database\Eloquent\Collection 99 | */ 100 | public function map(Builder $builder, $results, $model) 101 | { 102 | if ($results['hits']->count() === 0) { 103 | return $model->newCollection(); 104 | } 105 | 106 | $objectIds = $results['hits']->pluck('document_id')->values()->all(); 107 | 108 | $priorities = $results['hits']->pluck('priority', 'document_id')->toArray(); 109 | 110 | return $model->getScoutModelsByIds( 111 | $builder, $objectIds 112 | ) 113 | ->filter(function ($model) use ($objectIds) { 114 | return in_array($model->getScoutKey(), $objectIds); 115 | }) 116 | ->map(function ($model) use ($priorities) { 117 | return $model->setAttribute('priority', $priorities[$model->id] ?? 0); 118 | }) 119 | ->sortByDesc('priority')->values(); 120 | } 121 | 122 | /** 123 | * Get the total count from a raw result returned by the engine. 124 | * 125 | * @param mixed $results 126 | * @return int 127 | */ 128 | public function getTotalCount($results) 129 | { 130 | return $results['total']; 131 | } 132 | 133 | /** 134 | * Flush all of the model's records from the engine. 135 | * 136 | * @param \Illuminate\Database\Eloquent\Model $model 137 | * @return void 138 | */ 139 | public function flush($model) 140 | { 141 | return (new Indexer($model))->truncate(); 142 | } 143 | } -------------------------------------------------------------------------------- /src/Helpers/Constants.php: -------------------------------------------------------------------------------- 1 | column = $column; 40 | 41 | $this->collectValues($values); 42 | 43 | $this->validateHandler(new $handler); 44 | } 45 | 46 | /** 47 | * Get Indexable Column 48 | */ 49 | public function column() 50 | { 51 | return $this->column; 52 | } 53 | 54 | /** 55 | * Get Indexable Values 56 | */ 57 | public function values() 58 | { 59 | return $this->values; 60 | } 61 | 62 | /** 63 | * Validates Priority Handler 64 | * @param string $priority 65 | * @return @var Indexable 66 | * @throws Exception 67 | */ 68 | private function validateHandler($handler) 69 | { 70 | if ($handler instanceOf PriorityHandler) { 71 | $this->handler = $handler; 72 | 73 | return true; 74 | } 75 | throw new \Exception(Constants::HANDLER_EXCEPTION); 76 | } 77 | 78 | /** 79 | * Filters Column Values 80 | * @param array $values 81 | * @return array $values 82 | */ 83 | private function collectValues($values) 84 | { 85 | $filter = function ($value, $filterableFunctions) { 86 | foreach ($filterableFunctions as $func) { 87 | $validities[] = $func($value); 88 | } 89 | return in_array(true, $validities); 90 | }; 91 | 92 | $is_date_time = function ($myString) { 93 | return ! \DateTime::createFromFormat('Y-m-d H:i:s', $myString) === FALSE; 94 | }; 95 | 96 | $is_short_string = function ($myString) { 97 | return strlen($myString) < config("searchman.indexable_length", 5); 98 | }; 99 | 100 | if (! is_array($values)) { 101 | if (! $filter($values, ["is_null", $is_date_time, "is_numeric", $is_short_string])) { 102 | $this->values[] = $values; 103 | } 104 | return $this; 105 | } 106 | foreach (array_values($values) as $value) { 107 | $this->collectValues($value); 108 | } 109 | } 110 | 111 | /** 112 | * Break Values 113 | * @return Nwogu\Helpers\Indexable 114 | */ 115 | private function breakValues() 116 | { 117 | foreach ($this->values as $value) { 118 | $this->brokenValues[] = $this->breakFieldUsing($value); 119 | } 120 | return $this; 121 | } 122 | 123 | /** 124 | * Pass Words to Breakers 125 | * @param string $words 126 | * @return string $brokenWords 127 | */ 128 | private function breakFieldUsing(string $words) 129 | { 130 | $brokenWords = null; 131 | 132 | foreach ($this->handler->getBreakers() as $breaker) { 133 | $words = is_null($brokenWords) ? $words : $brokenWords; 134 | 135 | $breakerClass = new $breaker($words); 136 | if (! ($breakerClass instanceof IndexBreaker)) { 137 | throw new \Exception(Constants::BREAKER_EXCEPTION); 138 | } 139 | $brokenWords = $breakerClass->break(); 140 | } 141 | return $brokenWords; 142 | } 143 | 144 | /** 145 | * Get indices of indexable 146 | * @return array $this->indices 147 | */ 148 | public function getIndices() 149 | { 150 | $this->breakValues(); 151 | foreach ($this->brokenValues as $broken) { 152 | foreach (array_filter(explode(" ", $broken)) as $index) { 153 | $indexLoad['index'] = $index; 154 | $indexLoad['priority'] = $this->handler->calculate($index, $broken); 155 | $this->indices[] = $indexLoad; 156 | } 157 | } 158 | return $this->indices; 159 | } 160 | } -------------------------------------------------------------------------------- /src/Helpers/Indexer.php: -------------------------------------------------------------------------------- 1 | model = $model; 23 | 24 | $this->connection = DB::connection(config('searchman.connection')) 25 | ->table($this->model->searchableAs()); 26 | } 27 | 28 | /** 29 | * Call Index 30 | */ 31 | public function index(Indexable $indexable) 32 | { 33 | foreach ($indexable->getIndices() as $index) { 34 | $this->connection->insert([ 35 | 'keyword' => $index['index'], 36 | 'document_id' => $this->model->getScoutKey(), 37 | 'priority' => $index['priority'], 38 | 'column' => $indexable->column() 39 | ]); 40 | } 41 | return $this; 42 | } 43 | 44 | /** 45 | * Remove Indices 46 | */ 47 | public function delete() 48 | { 49 | $removePreviousIndexBuilder = $this->connection->where('document_id', $this->model->getScoutKey()); 50 | if ($removePreviousIndexBuilder->exists()) { 51 | $removePreviousIndexBuilder->delete(); 52 | } 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * Truncate Index data 59 | * @param Model $model 60 | * @return void 61 | */ 62 | public function truncate() 63 | { 64 | return $this->connection->delete(); 65 | } 66 | } -------------------------------------------------------------------------------- /src/Helpers/Searcher.php: -------------------------------------------------------------------------------- 1 | builder = $builder; 49 | 50 | $this->queries = array_filter(explode(" ", $this->builder->query)); 51 | 52 | $this->model = $this->builder->model; 53 | 54 | $this->filters = $this->builder->wheres; 55 | 56 | $this->index = $this->builder->index ?: $this->model->searchableAs(); 57 | 58 | $this->connection = DB::connection(config('searchman.connection')) 59 | ->table($this->index); 60 | } 61 | 62 | public function search($offset = null) 63 | { 64 | if ($this->builder->callback) { 65 | return call_user_func( 66 | $this->builder->callback, 67 | $this, 68 | $this->builder->query, 69 | $this->filters 70 | ); 71 | } 72 | 73 | $searchTable = substr($this->model->getScoutKeyName(), 0, strpos($this->model->getScoutKeyName(), ".")); 74 | 75 | $this->connection->leftJoin( 76 | $searchTable, "{$this->index}.document_id", 77 | "=", "{$this->model->getScoutKeyName()}"); 78 | 79 | $query = implode("," , $this->model->getColumns()); 80 | 81 | $query .= ", sum(priority) as priority, document_id"; 82 | 83 | $this->connection->selectRaw($query) 84 | ->groupBy("document_id"); 85 | 86 | $this->connection->where( function ($query) { 87 | foreach ($this->queries as $searchTerm) { 88 | $query->orWhere('keyword', "like", "%{$searchTerm}%"); 89 | } 90 | }); 91 | 92 | $this->connection->where( function ($query) { 93 | foreach ($this->filters as $where => $value) { 94 | 95 | $column = $where; 96 | $action = "="; 97 | 98 | if (strpos($where, ":")) { 99 | $where = explode(":", $where); 100 | $column = $where[0]; 101 | $action = $where[1]; 102 | } 103 | $column = $this->model->qualifyColumn($column); 104 | 105 | $query->where($column, $action, $value); 106 | } 107 | }); 108 | 109 | if ($this->builder->limit) { 110 | $this->connection->limit($this->builder->limit); 111 | } 112 | 113 | if ($offset) { 114 | $this->connection->offset($offset); 115 | } 116 | 117 | foreach ($this->builder->orders as $order) { 118 | $column = $this->model->qualifyColumn($order['column']); 119 | $this->connection->orderBy($column, $order['direction']); 120 | } 121 | 122 | $this->connection->orderBy("priority", "desc"); 123 | 124 | return [ 125 | "hits" => $this->connection->get(), 126 | "total" => $this->connection->get()->count(), 127 | ]; 128 | 129 | } 130 | } -------------------------------------------------------------------------------- /src/PriorityHandlers/LocationPriorityHandler.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 21 | $this->commands([ 22 | MakeIndex::class, 23 | ]); 24 | } 25 | 26 | Builder::macro('count', function () { 27 | return $this->engine()->getTotalCount( 28 | $this->engine()->search($this) 29 | ); 30 | }); 31 | 32 | $this->publishes([ 33 | base_path() . '/vendor/nwogu/laravel-searchman/config/searchman.php' => config_path('searchman.php') 34 | ], 'searchman-config'); 35 | 36 | resolve(EngineManager::class)->extend('mysql', function () { 37 | return new MySqlEngine; 38 | }); 39 | 40 | } 41 | 42 | /** 43 | * Register any application services. 44 | * 45 | * @return void 46 | */ 47 | public function register() 48 | { 49 | // 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/Traits/SearchMan.php: -------------------------------------------------------------------------------- 1 | getTable() . config('searchman.suffix'); 29 | } 30 | 31 | /** 32 | * Get Table Columns 33 | */ 34 | public function getColumns() 35 | { 36 | return array_map(function ($column) { 37 | return $this->qualifyColumn($column); 38 | } , 39 | $this->getConnection() 40 | ->getSchemaBuilder() 41 | ->getColumnListing($this->getTable()) 42 | ); 43 | } 44 | 45 | } --------------------------------------------------------------------------------