├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── ClientAdapter.php ├── RediSearch.php ├── RediSearchServiceProvider.php └── Scout │ ├── Console │ └── ImportCommand.php │ └── Engines │ └── RediSearchEngine.php └── tests ├── LaravelRediSearch └── RediSearchTest.php └── bootstrap.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | node_modules/ 4 | 5 | # Laravel 4 specific 6 | bootstrap/compiled.php 7 | app/storage/ 8 | 9 | # Laravel 5 & Lumen specific 10 | bootstrap/cache/ 11 | .env.*.php 12 | .env.php 13 | .env 14 | 15 | # Rocketeer PHP task runner and deployment package. https://github.com/rocketeers/rocketeer 16 | .rocketeer/ 17 | 18 | composer.lock 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ethan Hann 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-RediSearch 2 | 3 | An experimental [Laravel Scout](https://laravel.com/docs/5.6/scout) driver for [RediSearch](http://redisearch.io) that uses [RediSearch-PHP](https://github.com/ethanhann/redisearch-php) under the hood. 4 | 5 | Documentation: http://www.ethanhann.com/redisearch-php/laravel-support/ 6 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ethanhann/laravel-redisearch", 3 | "type": "library", 4 | "description": "RediSearch integration for Laravel", 5 | "keywords": [ 6 | "redis", "redisearch", "laravel", "scout", "search", "fulltext", "geosearch", "php" 7 | ], 8 | "homepage": "https://github.com/ethanhann/Laravel-RediSearch", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Ethan Hann", 13 | "email": "ethanhann@gmail.com" 14 | } 15 | ], 16 | "autoload": { 17 | "psr-4": { 18 | "Ehann\\LaravelRediSearch\\": "src" 19 | } 20 | }, 21 | "require": { 22 | "laravel/scout": "^3.0|^4.0|^5.0|^6.0|^7.0", 23 | "ethanhann/redisearch-php": "^1.0.0", 24 | "ethanhann/redis-raw": "^0.3.1" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^7.1", 28 | "mockery/mockery": "^1.0" 29 | }, 30 | "extra": { 31 | "laravel": { 32 | "providers": [ 33 | "Ehann\\LaravelRediSearch\\RediSearchServiceProvider" 34 | ] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./tests 17 | 18 | 19 | 20 | 21 | ./src 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/ClientAdapter.php: -------------------------------------------------------------------------------- 1 | redis->pipeline() : $this->redis->multi(); 17 | } 18 | 19 | public function rawCommand(string $command, array $arguments) 20 | { 21 | $arguments = $this->prepareRawCommandArguments($command, $arguments); 22 | $rawResult = null; 23 | try { 24 | $rawResult = $this->redis->executeRaw($arguments); 25 | } catch (Exception $exception) { 26 | $this->validateRawCommandResults($exception); 27 | } 28 | return $this->normalizeRawCommandResult($rawResult); 29 | } 30 | } -------------------------------------------------------------------------------- /src/RediSearch.php: -------------------------------------------------------------------------------- 1 | redis = $redis; 16 | } 17 | 18 | public function makeDocumentIndex(string $name) 19 | { 20 | return new Index($this->redis, $name); 21 | } 22 | 23 | public function makeSuggestionIndex(string $name) 24 | { 25 | return new Suggestion($this->redis, $name); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/RediSearchServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(RedisRawClientInterface::class, function ($app) { 16 | $clientAdapter = new ClientAdapter(); 17 | $clientAdapter->redis = $app->make('redis'); 18 | return $clientAdapter; 19 | }); 20 | 21 | $this->app->singleton(RediSearch::class, function ($app) { 22 | return new RediSearch($app->make(RedisRawClientInterface::class)); 23 | }); 24 | } 25 | 26 | public function boot() 27 | { 28 | $this->app[EngineManager::class]->extend('ehann-redisearch', function ($app) { 29 | return new RediSearchEngine($app[RedisRawClientInterface::class]); 30 | }); 31 | 32 | if ($this->app->runningInConsole()) { 33 | $this->commands([ 34 | ImportCommand::class, 35 | ]); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Scout/Console/ImportCommand.php: -------------------------------------------------------------------------------- 1 | argument('model'); 27 | $chunk_size = $this->option('chunk-size') ?? 1000; 28 | $model = new $class(); 29 | $index = new Index($redisClient, $model->searchableAs()); 30 | 31 | $fields = array_keys($model->searchableSchema()); 32 | if (!$this->option('no-id')) { 33 | $fields[] = $model->getKeyName(); 34 | $query = implode(', ', array_unique($fields)); 35 | } 36 | 37 | if ($this->option('no-id') || $query === '') { 38 | $query = '*'; 39 | } 40 | 41 | // Define Schema 42 | foreach ($model->searchableSchema() as $name => $value) { 43 | 44 | if ($name !== $model->getKeyName()) { 45 | $value = $value ?? ''; 46 | 47 | if ($value === NumericField::class) { 48 | $index->addNumericField($name); 49 | continue; 50 | } 51 | if ($value === GeoField::class) { 52 | $index->addGeoField($name); 53 | continue; 54 | } 55 | if ($value === TagField::class) { 56 | $index->addTagField($name); 57 | continue; 58 | } 59 | 60 | $index->addTextField($name); 61 | } 62 | } 63 | 64 | if ($this->option('recreate-index')) { 65 | $index->drop(); 66 | } 67 | 68 | if (!$index->create()) { 69 | $this->warn('The index already exists. Use --recreate-index to recreate the index before importing.'); 70 | } 71 | 72 | if (!$this->option('no-import-models')) { 73 | $records_total = $class::count(); 74 | if (!$records_total) { 75 | $this->warn('There are no models to import.'); 76 | } 77 | $bar = $this->output->createProgressBar($records_total); 78 | $records = $class::select(DB::raw($query)); 79 | $records 80 | ->chunk($chunk_size, function ($models) use ($index, $model, $bar) { 81 | foreach($models as $item) { 82 | $document = $index->makeDocument( 83 | $item->getKey() 84 | ); 85 | foreach ($item->toSearchableArray() as $name => $value) { 86 | if ($name !== $model->getKeyName()) { 87 | $value = $value ?? ''; 88 | $document->$name->setValue($value); 89 | } 90 | } 91 | $index->add($document); 92 | $bar->advance(); 93 | } 94 | }); 95 | $bar->finish(); 96 | 97 | $this->info(PHP_EOL."[$class] models imported created successfully"); 98 | } else { 99 | $this->info("$class index created successfully"); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Scout/Engines/RediSearchEngine.php: -------------------------------------------------------------------------------- 1 | redisRawClient = $redisRawClient; 28 | } 29 | 30 | /** 31 | * Update the given model in the index. 32 | * 33 | * @param \Illuminate\Database\Eloquent\Collection $models 34 | * @return void 35 | */ 36 | public function update($models) 37 | { 38 | $model = $models->first(); 39 | $index = new Index($this->redisRawClient, $model->first()->searchableAs()); 40 | 41 | foreach ($model->searchableSchema() as $name => $value) { 42 | 43 | if ($name !== $model->getKeyName()) { 44 | $value = $value ?? ''; 45 | 46 | if ($value === NumericField::class) { 47 | $index->addNumericField($name); 48 | continue; 49 | } 50 | if ($value === GeoField::class) { 51 | $index->addGeoField($name); 52 | continue; 53 | } 54 | if ($value === TagField::class) { 55 | $index->addTagField($name); 56 | continue; 57 | } 58 | 59 | $index->addTextField($name); 60 | } 61 | } 62 | 63 | $models 64 | ->each(function ($item) use ($index, $model) { 65 | $document = $index->makeDocument($item->getKey()); 66 | foreach ($item->toSearchableArray() as $name => $value) { 67 | if ($name !== $model->getKeyName()) { 68 | $value = $value ?? ''; 69 | $document->$name->setValue($value); 70 | } 71 | } 72 | try { 73 | $index->add($document); 74 | } catch (\Throwable $th) { 75 | if ($th->getMessage() == "Document already exists") { 76 | $index->replace($document); 77 | } 78 | } 79 | 80 | }); 81 | } 82 | 83 | /** 84 | * Remove the given model from the index. 85 | * 86 | * @param \Illuminate\Database\Eloquent\Collection $models 87 | * @return void 88 | */ 89 | public function delete($models) 90 | { 91 | $index = new Index($this->redisRawClient, $models->first()->searchableAs()); 92 | $models 93 | ->map(function ($model) { 94 | return $model->getKey(); 95 | }) 96 | ->values() 97 | ->each(function ($key) use ($index) { 98 | $index->delete($key); 99 | }); 100 | } 101 | 102 | /** 103 | * Perform the given search on the engine. 104 | * 105 | * @param \Laravel\Scout\Builder $builder 106 | * @return mixed 107 | */ 108 | public function search(Builder $builder) 109 | { 110 | $index = (new Index($this->redisRawClient, $builder->index ?? $builder->model->searchableAs())); 111 | 112 | if ($builder->callback) { 113 | $advanced_search = (call_user_func($builder->callback, $index)); 114 | 115 | return $advanced_search->search($builder->query); 116 | } 117 | 118 | return $index 119 | ->search($builder->query); 120 | } 121 | 122 | /** 123 | * Perform the given search on the engine. 124 | * 125 | * @param \Laravel\Scout\Builder $builder 126 | * @param int $perPage 127 | * @param int $page 128 | * @return mixed 129 | */ 130 | public function paginate(Builder $builder, $perPage, $page) 131 | { 132 | $index = (new Index($this->redisRawClient, $builder->index ?? $builder->model->searchableAs())); 133 | 134 | if ($builder->callback) { 135 | $advanced_search = (call_user_func($builder->callback, $index)); 136 | return collect(($advanced_search) 137 | ->limit($page, $perPage) 138 | ->search($builder->query)); 139 | } 140 | 141 | return collect(($index) 142 | ->limit($page, $perPage) 143 | ->search($builder->query)); 144 | } 145 | 146 | /** 147 | * Pluck and return the primary keys of the given results. 148 | * 149 | * @param mixed $results 150 | * @return \Illuminate\Support\Collection 151 | */ 152 | public function mapIds($results) 153 | { 154 | return collect($results->getDocuments())->pluck('id')->values(); 155 | } 156 | 157 | public function map(Builder $builder, $results, $model) 158 | { 159 | $results = collect($results); 160 | 161 | $count = $results->first(); 162 | if ($count === 0) { 163 | return Collection::make(); 164 | } 165 | $documents = $results->last(); 166 | $keys = collect($documents) 167 | ->pluck('id') 168 | ->values() 169 | ->all(); 170 | $models = $model 171 | ->whereIn($model->getQualifiedKeyName(), $keys) 172 | ->get() 173 | ->keyBy($model->getKeyName()); 174 | 175 | return Collection::make($documents) 176 | ->map(function ($hit) use ($model, $models) { 177 | $key = $hit->id; 178 | if (isset($models[$key])) { 179 | return $models[$key]; 180 | } 181 | })->filter(); 182 | } 183 | 184 | /** 185 | * Get the total count from a raw result returned by the engine. 186 | * 187 | * @param mixed $results 188 | * @return int 189 | */ 190 | public function getTotalCount($results) 191 | { 192 | return $results->getCount(); 193 | } 194 | 195 | public function flush($model) 196 | { 197 | $index = new Index($this->redisRawClient, (new $model())->searchableAs()); 198 | $index->drop(); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /tests/LaravelRediSearch/RediSearchTest.php: -------------------------------------------------------------------------------- 1 | subject = new RediSearch(); 15 | } 16 | 17 | public function tearDown() 18 | { 19 | m::close(); 20 | } 21 | 22 | public function testShouldCreateDocumentIndex() 23 | { 24 | $expected = 'foo'; 25 | $redis = m::mock(ClientAdapter::class); 26 | $this->subject->redis = $redis; 27 | 28 | $result = $this->subject->makeDocumentIndex($expected); 29 | 30 | $this->assertEquals($expected, $result->getIndexName()); 31 | } 32 | 33 | public function testShouldCreateSuggestionIndex() 34 | { 35 | $expected = 0; 36 | $redis = m::mock(ClientAdapter::class); 37 | $redis->shouldReceive('rawCommand')->once()->andReturns(0); 38 | $this->subject->redis = $redis; 39 | 40 | $result = $this->subject->makeSuggestionIndex('foo'); 41 | 42 | $this->assertEquals($expected, $result->length()); 43 | } 44 | 45 | protected function getClientAdapter() 46 | { 47 | $clientAdapter = new ClientAdapter(); 48 | $clientAdapter->redis = m::mock(\Ehann\RedisRaw\PhpRedisAdapter::class); 49 | return $clientAdapter; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |