├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── ServiceProvider.php └── SphinxEngine.php └── tests ├── EmptySearchableModel.php ├── Model └── SearchableModel.php └── SphinxEngineTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.{json,yml,md}] 15 | indent_size = 2 16 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /.env 3 | composer.phar 4 | composer.lock 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.2 5 | - 7.3 6 | - 7.4 7 | - 8.0 8 | 9 | before_script: 10 | - travis_retry composer self-update 11 | - travis_retry composer update --no-interaction --no-suggest --prefer-source 12 | 13 | script: vendor/bin/phpunit --verbose --coverage-text 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Constantable 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 Scout Sphinx Driver 2 | 3 | ## Introduction 4 | This package is fork of constantable/laravel-scout-sphinx. 5 | package offers advanced functionality for searching and filtering data using the [Sphinx full text search server](http://sphinxsearch.com/) for [Laravel Scout](https://laravel.com/docs/master/scout). 6 | 7 | ## Installation 8 | 9 | ### Composer 10 | 11 | Use the following command to install this package via Composer. 12 | 13 | ```bash 14 | composer require constantable/laravel-scout-sphinx 15 | ``` 16 | 17 | ### Configuration 18 | 19 | Publish the Scout configuration using the `vendor:publish` Artisan command. 20 | 21 | ```bash 22 | php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider" 23 | ``` 24 | 25 | This command will publish the `scout.php` configuration file to your config directory, which you can than edit and set `sphinxsearch` as the Scout driver. 26 | 27 | ```php 28 | 'driver' => env('SCOUT_DRIVER', 'sphinxsearch'), 29 | ``` 30 | 31 | 32 | To configure the connection to Sphinx server add the following (i.e. default) connection options. 33 | 34 | ```php 35 | 'sphinxsearch' => [ 36 | 'host' => env('SPHINX_HOST', 'localhost'), 37 | 'port' => env('SPHINX_PORT', '9306'), 38 | 'socket' => env('SPHINX_SOCKET'), 39 | 'charset' => env('SPHINX_CHARSET'), 40 | ], 41 | ``` 42 | 43 | Override these variables in your `.env` file if required. 44 | 45 | ## Usage 46 | 47 | - Add the `Laravel\Scout\Searchable` trait to the model you would like to make searchable. 48 | - Customize the index name and searchable data for the model: 49 | 50 | ```php 51 | 52 | public function searchableAs() 53 | { 54 | return 'posts_index'; 55 | } 56 | 57 | public function toSearchableArray() 58 | { 59 | $array = $this->toArray(); 60 | 61 | // Customize array... 62 | 63 | return $array; 64 | } 65 | ``` 66 | 67 | A basic search: 68 | 69 | ```php 70 | $orders = App\Order::search('Star Trek')->get(); 71 | ``` 72 | 73 | Please refer to the [Scout documentation](https://laravel.com/docs/master/scout#searching) for additional information. You can run more complex queries on the index by using a callback, setting the `where` clause, `orderBy`, or `paginate` threshold. For example: 74 | 75 | ```php 76 | $orders = App\Order::search($keyword, function (SphinxQL $query) { 77 | return $query->groupBy('description'); 78 | }) 79 | ->where('status', 1) 80 | ->orderBy('date', 'DESC') 81 | ->paginate(20); 82 | ``` 83 | 84 | Note: Changes on Sphinx indexes are only allowed for RT (Real-time) indexes. If you have these and need to update/delete records please define `public $isRT = true;` in the model's property. 85 | 86 | ## Credits 87 | - [Hyn](https://github.com/hyn) 88 | 89 | ## License 90 | 91 | Licensed under the MIT license 92 | 93 | [ico-version]: https://img.shields.io/packagist/v/constantable/laravel-scout-sphinx.svg?style=flat 94 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat 95 | [link-packagist]: https://packagist.org/packages/constantable/laravel-scout-sphinx 96 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "constantable/laravel-scout-sphinx", 3 | "description": "Laravel Scout Sphinx Driver", 4 | "license": "MIT", 5 | "minimum-stability": "dev", 6 | "require": { 7 | "php": ">=7.0", 8 | "laravel/scout": ">=5.0", 9 | "foolz/sphinxql-query-builder": "^3.0" 10 | }, 11 | "require-dev": { 12 | "roave/security-advisories": "dev-latest", 13 | "mockery/mockery": "^1.0", 14 | "phpunit/phpunit": "^7.5|^8|^9" 15 | }, 16 | "extra": { 17 | "laravel": { 18 | "providers": [ 19 | "Constantable\\SphinxScout\\ServiceProvider" 20 | ] 21 | } 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Constantable\\SphinxScout\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Constantable\\SphinxScout\\Tests\\": "tests/" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/ 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | extend('sphinxsearch',static function($app){ 16 | $options = Config::get('scout.sphinxsearch'); 17 | if (empty($options['socket'])) 18 | unset($options['socket']); 19 | $connection = new Connection(); 20 | $connection->setParams($options); 21 | 22 | return new SphinxEngine(new SphinxQL($connection)); 23 | }); 24 | Builder::macro('whereIn',static function(string $attribute,array $arrayIn){ 25 | $this->engine()->addWhereIn($attribute, $arrayIn); 26 | return $this; 27 | }); 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /src/SphinxEngine.php: -------------------------------------------------------------------------------- 1 | sphinx = $sphinx; 30 | } 31 | 32 | /** 33 | * Update the given model in the index. 34 | * 35 | * @param \Illuminate\Database\Eloquent\Collection $models 36 | * @return void 37 | */ 38 | public function update($models) 39 | { 40 | if ($models->isEmpty()) { 41 | return; 42 | } 43 | $models->each(function ($model) { 44 | if (!empty($searchableData = $model->toSearchableArray())) { 45 | if (isset($model->isRT)) { // Only RT indexes support replace 46 | $index = $model->searchableAs(); 47 | $searchableData['id'] = (int)$model->getKey(); 48 | $columns = array_keys($searchableData); 49 | 50 | $sphinxQuery = $this->sphinx 51 | ->replace() 52 | ->into($index) 53 | ->columns($columns) 54 | ->values($searchableData); 55 | $sphinxQuery->execute(); 56 | } 57 | } 58 | }); 59 | } 60 | 61 | /** 62 | * Remove the given model from the index. 63 | * 64 | * @param \Illuminate\Database\Eloquent\Collection $models * 65 | * @return void 66 | */ 67 | public function delete($models) 68 | { 69 | if ($models->isEmpty()) { 70 | return; 71 | } 72 | $models->each(function ($model) { 73 | if (isset($model->isRT)) { // Only RT indexes support deletes 74 | $index = $model->searchableAs(); 75 | $key = $model->getKey(); 76 | $sphinxQuery = $this->sphinx 77 | ->delete() 78 | ->from($index) 79 | ->where('id', '=', $key); 80 | $sphinxQuery->execute(); 81 | } 82 | }); 83 | } 84 | 85 | /** 86 | * Perform the given search on the engine. 87 | * 88 | * @param Builder $builder 89 | * @return mixed 90 | * @throws 91 | */ 92 | public function search(Builder $builder) 93 | { 94 | return $this->performSearch($builder)->execute(); 95 | } 96 | 97 | /** 98 | * Perform the given search on the engine. 99 | * 100 | * @param Builder $builder 101 | * @param int $perPage 102 | * @param int $page 103 | * @return mixed 104 | * @throws 105 | */ 106 | public function paginate(Builder $builder, $perPage, $page) 107 | { 108 | return $this->performSearch($builder)->limit($perPage * ($page - 1), $perPage) 109 | ->execute(); 110 | } 111 | 112 | /** 113 | * Map the given results to instances of the given model. 114 | * 115 | * @param Builder $builder 116 | * @param mixed $results 117 | * @param Model|Searchable $model 118 | * @return \Illuminate\Database\Eloquent\Collection 119 | */ 120 | public function map(Builder $builder, $results, $model) 121 | { 122 | if ($results->count() === 0) { 123 | return $model->newCollection(); 124 | } 125 | 126 | $objectIds = collect($results->fetchAllAssoc())->pluck('id')->values()->all(); 127 | 128 | $objectIdPositions = array_flip($objectIds); 129 | 130 | return $model->getScoutModelsByIds( 131 | $builder, $objectIds 132 | )->filter(static function (/** @var Searchable $model */ $model) use ($objectIds) { 133 | return in_array($model->getScoutKey(), $objectIds, false); 134 | })->sortBy(static function (/** @var Searchable $model */ $model) use ($objectIdPositions) { 135 | return $objectIdPositions[$model->getScoutKey()]; 136 | })->values(); 137 | } 138 | 139 | /** 140 | * Map the given results to instances of the given model via a lazy collection. 141 | * 142 | * @param Builder $builder 143 | * @param mixed $results 144 | * @param Model|Searchable $model 145 | * @return LazyCollection 146 | */ 147 | public function lazyMap(Builder $builder, $results, $model) 148 | { 149 | if ($results->count() === 0) { 150 | return LazyCollection::make($model->newCollection()); 151 | } 152 | 153 | $objectIds = collect($results->fetchAllAssoc())->pluck('id')->values()->all(); 154 | 155 | $objectIdPositions = array_flip($objectIds); 156 | 157 | return $model->queryScoutModelsByIds( 158 | $builder, $objectIds 159 | )->cursor()->filter(function (/** @var Searchable $model */ $model) use ($objectIds) { 160 | return in_array($model->getScoutKey(), $objectIds, false); 161 | })->sortBy(function (/** @var Searchable $model */ $model) use ($objectIdPositions) { 162 | return $objectIdPositions[$model->getScoutKey()]; 163 | })->values(); 164 | } 165 | 166 | /** 167 | * Pluck and return the primary keys of the given results. 168 | * 169 | * @param mixed $results 170 | * @return Collection 171 | */ 172 | public function mapIds($results) 173 | { 174 | return collect($results->fetchAllAssoc())->pluck('id')->values(); 175 | } 176 | 177 | /** 178 | * Get the total count from a raw result returned by the engine. 179 | * 180 | * @param mixed $results 181 | * @return int 182 | * @throws 183 | */ 184 | public function getTotalCount($results) 185 | { 186 | $res = (new Helper($this->sphinx->getConnection()))->showMeta()->execute(); 187 | $assoc = $res->fetchAllAssoc(); 188 | $totalCount = $results->count(); 189 | foreach ($assoc as $item => $value) { 190 | if ($value['Variable_name'] === 'total_found') { 191 | $totalCount = $value['Value']; 192 | } 193 | } 194 | 195 | return $totalCount; 196 | } 197 | 198 | /** 199 | * Flush all of the model's records from the engine. 200 | * 201 | * @param Model $model 202 | * @return void 203 | * @throws 204 | */ 205 | public function flush($model) 206 | { 207 | if (isset($model->isRT)) { // Only RT indexes support truncate 208 | $index = $model->searchableAs(); 209 | $res = (new Helper($this->sphinx->getConnection()))->truncateRtIndex($index)->execute(); 210 | } 211 | } 212 | 213 | /** 214 | * Perform the given search on the engine. 215 | * 216 | * @param Builder $builder 217 | * @return SphinxQL 218 | */ 219 | protected function performSearch(Builder $builder) 220 | { 221 | /** 222 | * @var Searchable $model 223 | */ 224 | $model = $builder->model; 225 | $index = $model->searchableAs(); 226 | 227 | $query = $this->sphinx 228 | ->select('*', SphinxQL::expr('WEIGHT() AS weight')) 229 | ->from($index) 230 | ->match('*', SphinxQL::expr($builder->query)) 231 | ->limit($builder->limit ?? 20); 232 | 233 | foreach ($builder->wheres as $clause => $filters) { 234 | $query->where($clause, '=', $filters); 235 | } 236 | 237 | foreach ($this->whereIns as $whereIn) { 238 | $query->where(key($whereIn), 'IN', $whereIn[key($whereIn)]); 239 | } 240 | 241 | if ($builder->callback) { 242 | call_user_func( 243 | $builder->callback, 244 | $query 245 | ); 246 | } 247 | 248 | if (empty($builder->orders)) { 249 | $query->orderBy('weight', 'DESC'); 250 | } else { 251 | foreach ($builder->orders as $order) { 252 | $query->orderBy($order['column'], $order['direction']); 253 | } 254 | } 255 | 256 | return $query; 257 | } 258 | 259 | /** 260 | * @param string $attribute 261 | * @param array $arrayIn 262 | */ 263 | public function addWhereIn(string $attribute, array $arrayIn) 264 | { 265 | $this->whereIns[] = array($attribute => $arrayIn); 266 | } 267 | 268 | /** 269 | * @inheritDoc 270 | * @throws Exception 271 | */ 272 | public function createIndex($name, array $options = []) 273 | { 274 | throw new Exception('Sphinx indexes must be defined in sphinx.conf.'); 275 | } 276 | 277 | /** 278 | * @inheritDoc 279 | * @throws Exception 280 | */ 281 | public function deleteIndex($name) 282 | { 283 | throw new Exception('Sphinx indexes must be removed from sphinx.conf.'); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /tests/EmptySearchableModel.php: -------------------------------------------------------------------------------- 1 | getKey(); 34 | } 35 | 36 | public function getKey() 37 | { 38 | return $this->attributesToArray()["id"]; 39 | } 40 | 41 | public function toSearchableArray() 42 | { 43 | //return ['id' => 1, 'title' => 'Some text']; 44 | return array_merge($this->attributesToArray(), $this->relationsToArray()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/SphinxEngineTest.php: -------------------------------------------------------------------------------- 1 | model = new SearchableModel(['id' => 1, 'title' => 'Some text']); 34 | } 35 | 36 | public function test_update_adds_objects_to_index() 37 | { 38 | $client = m::mock(SphinxQL::class); 39 | 40 | $client->shouldReceive('replace')->once()->andReturn($thisObject = m::mock(SphinxQL::class)); 41 | 42 | $thisObject->shouldReceive('into')->once() 43 | ->with('table') 44 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 45 | 46 | $thisObject->shouldReceive('columns')->once() 47 | ->with(array_keys($this->model->toSearchableArray())) 48 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 49 | 50 | $thisObject->shouldReceive('values')->once() 51 | ->with($this->model->toSearchableArray()) 52 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 53 | 54 | $thisObject->shouldReceive('execute')->once(); 55 | 56 | $engine = new SphinxEngine($client); 57 | $engine->update(Collection::make([$this->model])); 58 | } 59 | 60 | public function test_delete_removes_objects_to_index() 61 | { 62 | $client = m::mock(SphinxQL::class); 63 | $client->shouldReceive('delete')->once()->andReturn($thisObject = m::mock(SphinxQL::class)); 64 | 65 | $thisObject->shouldReceive('from')->once() 66 | ->with('table') 67 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 68 | 69 | $thisObject->shouldReceive('where')->once() 70 | ->with('id', '=', $this->model->getKey()) 71 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 72 | 73 | $thisObject->shouldReceive('execute')->once(); 74 | 75 | $engine = new SphinxEngine($client); 76 | $engine->delete(Collection::make([$this->model])); 77 | } 78 | 79 | public function test_search_sends_correct_parameters_to_sphinx() 80 | { 81 | $qry = 'search query'; 82 | $client = m::mock(SphinxQL::class); 83 | $client->shouldReceive('select')->once() 84 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 85 | 86 | $thisObject->shouldReceive('from')->once() 87 | ->with('table') 88 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 89 | 90 | $expression = SphinxQL::expr('"' . $qry . '"/1'); 91 | $thisObject->shouldReceive('match')->once() 92 | ->withArgs( 93 | function ($arg) { 94 | return $arg == '*'; 95 | }, 96 | function ($arg) use ($expression) { 97 | return $expression->value() === $arg->value(); 98 | }) 99 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 100 | 101 | $thisObject->shouldReceive('limit')->once() 102 | ->withAnyArgs() 103 | ->andReturn($thisObject = m::mock(SphinxQL::class)); 104 | 105 | $thisObject->shouldReceive('where')->once() 106 | ->with('foo', '=', 1); 107 | 108 | $thisObject->shouldReceive('orderBy')->once() 109 | ->withAnyArgs(); 110 | 111 | $thisObject->shouldReceive('execute')->once(); 112 | 113 | $engine = new SphinxEngine($client); 114 | $builder = new Builder($this->model, $qry); 115 | $builder->where('foo', 1); 116 | $engine->search($builder); 117 | } 118 | 119 | public function test_map_correctly_maps_results_to_models() 120 | { 121 | $client = m::mock(SphinxQL::class); 122 | $engine = new SphinxEngine($client); 123 | /**@var Model|MockInterface $model*/ 124 | $model = m::mock(stdClass::class); 125 | $model->shouldReceive('getScoutModelsByIds')->once()->andReturn($models = Collection::make([ 126 | $this->model 127 | ])); 128 | $builder = m::mock(Builder::class); 129 | $resultSet = m::mock(ResultSet::class); 130 | $resultSet->shouldReceive('fetchAllAssoc')->andReturn($arr = [ 131 | ['id' => 1, 'title' => 'Some text'], 132 | ]); 133 | $resultSet->shouldReceive('count')->andReturn($count = 1); 134 | $results = $engine->map($builder, $resultSet, $model); 135 | $this->assertCount(1, $results); 136 | } 137 | 138 | public function test_map_method_respects_order() 139 | { 140 | $client = m::mock(SphinxQL::class); 141 | $engine = new SphinxEngine($client); 142 | /**@var Model|MockInterface $model*/ 143 | $model = m::mock(stdClass::class); 144 | $model->shouldReceive('getScoutModelsByIds')->andReturn($models = Collection::make([ 145 | new SearchableModel(['id' => 1, 'title' => 'Some text']), 146 | new SearchableModel(['id' => 2, 'title' => 'Some text 2']), 147 | new SearchableModel(['id' => 3, 'title' => 'Some text 3']), 148 | new SearchableModel(['id' => 4, 'title' => 'Some text 4']), 149 | ])); 150 | $model->shouldReceive('newQuery'); 151 | $builder = m::mock(Builder::class); 152 | 153 | $resultSet = m::mock(ResultSet::class); 154 | $resultSet->shouldReceive('fetchAllAssoc')->andReturn($arr = [ 155 | ['id' => 1, 'title' => 'Some text'], 156 | ['id' => 2, 'title' => 'Some text 2'], 157 | ['id' => 3, 'title' => 'Some text 3'], 158 | ['id' => 4, 'title' => 'Some text 4'] 159 | ]); 160 | $resultSet->shouldReceive('count')->andReturn($count = 4); 161 | $results = $engine->map($builder, $resultSet, $model); 162 | $this->assertCount(4, $results); 163 | // It's important we assert with array keys to ensure 164 | // they have been reset after sorting. 165 | $this->assertEquals([ 166 | 0 => ['id' => 1, 'title' => 'Some text'], 167 | 1 => ['id' => 2, 'title' => 'Some text 2'], 168 | 2 => ['id' => 3, 'title' => 'Some text 3'], 169 | 3 => ['id' => 4, 'title' => 'Some text 4'] 170 | ], $results->toArray()); 171 | } 172 | 173 | public function test_update_empty_searchable_array_does_not_add_objects_to_index() 174 | { 175 | $client = m::mock(SphinxQL::class); 176 | 177 | $client->shouldNotReceive('replace'); 178 | $engine = new SphinxEngine($client); 179 | $engine->update(Collection::make([new EmptySearchableModel])); 180 | $this->assertTrue(true); 181 | } 182 | } 183 | --------------------------------------------------------------------------------