├── LICENSE.md ├── README.md ├── composer.json ├── config └── scout.php ├── src ├── Attributes │ ├── SearchUsingFullText.php │ └── SearchUsingPrefix.php ├── Builder.php ├── Console │ ├── DeleteAllIndexesCommand.php │ ├── DeleteIndexCommand.php │ ├── FlushCommand.php │ ├── ImportCommand.php │ ├── IndexCommand.php │ ├── QueueImportCommand.php │ └── SyncIndexSettingsCommand.php ├── Contracts │ ├── PaginatesEloquentModels.php │ ├── PaginatesEloquentModelsUsingDatabase.php │ └── UpdatesIndexSettings.php ├── EngineManager.php ├── Engines │ ├── Algolia3Engine.php │ ├── Algolia4Engine.php │ ├── AlgoliaEngine.php │ ├── CollectionEngine.php │ ├── DatabaseEngine.php │ ├── Engine.php │ ├── MeilisearchEngine.php │ ├── NullEngine.php │ └── TypesenseEngine.php ├── Events │ ├── ModelsFlushed.php │ └── ModelsImported.php ├── Exceptions │ ├── NotSupportedException.php │ └── ScoutException.php ├── Jobs │ ├── MakeRangeSearchable.php │ ├── MakeSearchable.php │ ├── RemoveFromSearch.php │ └── RemoveableScoutCollection.php ├── ModelObserver.php ├── Scout.php ├── ScoutServiceProvider.php ├── Searchable.php └── SearchableScope.php └── testbench.yaml /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Logo Laravel Scout

2 | 3 |

4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 |

9 | 10 | ## Introduction 11 | 12 | Laravel Scout provides a simple, driver-based solution for adding full-text search to your Eloquent models. Once Scout is installed and configured, it will automatically sync your model changes to your search indexes. Currently, Scout supports: 13 | 14 | - [Algolia](https://www.algolia.com/) 15 | - [Meilisearch](https://github.com/meilisearch/meilisearch) 16 | - [Typesense](https://github.com/typesense/typesense) 17 | 18 | ## Official Documentation 19 | 20 | Documentation for Scout can be found on the [Laravel website](https://laravel.com/docs/master/scout). 21 | 22 | ## Contributing 23 | 24 | Thank you for considering contributing to Scout! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 25 | 26 | ## Code of Conduct 27 | 28 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 29 | 30 | ## Security Vulnerabilities 31 | 32 | Please review [our security policy](https://github.com/laravel/scout/security/policy) on how to report security vulnerabilities. 33 | 34 | ## License 35 | 36 | Laravel Scout is open-sourced software licensed under the [MIT license](LICENSE.md). 37 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/scout", 3 | "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", 4 | "keywords": ["algolia", "laravel", "search"], 5 | "license": "MIT", 6 | "support": { 7 | "issues": "https://github.com/laravel/scout/issues", 8 | "source": "https://github.com/laravel/scout" 9 | }, 10 | "authors": [ 11 | { 12 | "name": "Taylor Otwell", 13 | "email": "taylor@laravel.com" 14 | } 15 | ], 16 | "require": { 17 | "php": "^8.0", 18 | "illuminate/bus": "^9.0|^10.0|^11.0|^12.0", 19 | "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", 20 | "illuminate/database": "^9.0|^10.0|^11.0|^12.0", 21 | "illuminate/http": "^9.0|^10.0|^11.0|^12.0", 22 | "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0", 23 | "illuminate/queue": "^9.0|^10.0|^11.0|^12.0", 24 | "illuminate/support": "^9.0|^10.0|^11.0|^12.0", 25 | "symfony/console": "^6.0|^7.0" 26 | }, 27 | "require-dev": { 28 | "algolia/algoliasearch-client-php": "^3.2|^4.0", 29 | "typesense/typesense-php": "^4.9.3", 30 | "meilisearch/meilisearch-php": "^1.0", 31 | "mockery/mockery": "^1.0", 32 | "orchestra/testbench": "^7.31|^8.11|^9.0|^10.0", 33 | "php-http/guzzle7-adapter": "^1.0", 34 | "phpstan/phpstan": "^1.10", 35 | "phpunit/phpunit": "^9.3|^10.4|^11.5" 36 | }, 37 | "conflict": { 38 | "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Laravel\\Scout\\": "src/" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Laravel\\Scout\\Tests\\": "tests/", 48 | "Workbench\\App\\": "workbench/app/", 49 | "Workbench\\Database\\Factories\\": "workbench/database/factories/" 50 | } 51 | }, 52 | "extra": { 53 | "branch-alias": { 54 | "dev-master": "10.x-dev" 55 | }, 56 | "laravel": { 57 | "providers": [ 58 | "Laravel\\Scout\\ScoutServiceProvider" 59 | ] 60 | } 61 | }, 62 | "suggest": { 63 | "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", 64 | "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", 65 | "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." 66 | }, 67 | "config": { 68 | "sort-packages": true, 69 | "allow-plugins": { 70 | "php-http/discovery": true 71 | } 72 | }, 73 | "minimum-stability": "dev", 74 | "prefer-stable": true 75 | } 76 | -------------------------------------------------------------------------------- /config/scout.php: -------------------------------------------------------------------------------- 1 | env('SCOUT_DRIVER', 'collection'), 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Index Prefix 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Here you may specify a prefix that will be applied to all search index 27 | | names used by Scout. This prefix may be useful if you have multiple 28 | | "tenants" or applications sharing the same search infrastructure. 29 | | 30 | */ 31 | 32 | 'prefix' => env('SCOUT_PREFIX', ''), 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Queue Data Syncing 37 | |-------------------------------------------------------------------------- 38 | | 39 | | This option allows you to control if the operations that sync your data 40 | | with your search engines are queued. When this is set to "true" then 41 | | all automatic data syncing will get queued for better performance. 42 | | 43 | */ 44 | 45 | 'queue' => env('SCOUT_QUEUE', false), 46 | 47 | /* 48 | |-------------------------------------------------------------------------- 49 | | Database Transactions 50 | |-------------------------------------------------------------------------- 51 | | 52 | | This configuration option determines if your data will only be synced 53 | | with your search indexes after every open database transaction has 54 | | been committed, thus preventing any discarded data from syncing. 55 | | 56 | */ 57 | 58 | 'after_commit' => false, 59 | 60 | /* 61 | |-------------------------------------------------------------------------- 62 | | Chunk Sizes 63 | |-------------------------------------------------------------------------- 64 | | 65 | | These options allow you to control the maximum chunk size when you are 66 | | mass importing data into the search engine. This allows you to fine 67 | | tune each of these chunk sizes based on the power of the servers. 68 | | 69 | */ 70 | 71 | 'chunk' => [ 72 | 'searchable' => 500, 73 | 'unsearchable' => 500, 74 | ], 75 | 76 | /* 77 | |-------------------------------------------------------------------------- 78 | | Soft Deletes 79 | |-------------------------------------------------------------------------- 80 | | 81 | | This option allows to control whether to keep soft deleted records in 82 | | the search indexes. Maintaining soft deleted records can be useful 83 | | if your application still needs to search for the records later. 84 | | 85 | */ 86 | 87 | 'soft_delete' => false, 88 | 89 | /* 90 | |-------------------------------------------------------------------------- 91 | | Identify User 92 | |-------------------------------------------------------------------------- 93 | | 94 | | This option allows you to control whether to notify the search engine 95 | | of the user performing the search. This is sometimes useful if the 96 | | engine supports any analytics based on this application's users. 97 | | 98 | | Supported engines: "algolia" 99 | | 100 | */ 101 | 102 | 'identify' => env('SCOUT_IDENTIFY', false), 103 | 104 | /* 105 | |-------------------------------------------------------------------------- 106 | | Algolia Configuration 107 | |-------------------------------------------------------------------------- 108 | | 109 | | Here you may configure your Algolia settings. Algolia is a cloud hosted 110 | | search engine which works great with Scout out of the box. Just plug 111 | | in your application ID and admin API key to get started searching. 112 | | 113 | */ 114 | 115 | 'algolia' => [ 116 | 'id' => env('ALGOLIA_APP_ID', ''), 117 | 'secret' => env('ALGOLIA_SECRET', ''), 118 | 'index-settings' => [ 119 | // 'users' => [ 120 | // 'searchableAttributes' => ['id', 'name', 'email'], 121 | // 'attributesForFaceting'=> ['filterOnly(email)'], 122 | // ], 123 | ], 124 | ], 125 | 126 | /* 127 | |-------------------------------------------------------------------------- 128 | | Meilisearch Configuration 129 | |-------------------------------------------------------------------------- 130 | | 131 | | Here you may configure your Meilisearch settings. Meilisearch is an open 132 | | source search engine with minimal configuration. Below, you can state 133 | | the host and key information for your own Meilisearch installation. 134 | | 135 | | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options 136 | | 137 | */ 138 | 139 | 'meilisearch' => [ 140 | 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), 141 | 'key' => env('MEILISEARCH_KEY'), 142 | 'index-settings' => [ 143 | // 'users' => [ 144 | // 'filterableAttributes'=> ['id', 'name', 'email'], 145 | // ], 146 | ], 147 | ], 148 | 149 | /* 150 | |-------------------------------------------------------------------------- 151 | | Typesense Configuration 152 | |-------------------------------------------------------------------------- 153 | | 154 | | Here you may configure your Typesense settings. Typesense is an open 155 | | source search engine using minimal configuration. Below, you will 156 | | state the host, key, and schema configuration for the instance. 157 | | 158 | */ 159 | 160 | 'typesense' => [ 161 | 'client-settings' => [ 162 | 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), 163 | 'nodes' => [ 164 | [ 165 | 'host' => env('TYPESENSE_HOST', 'localhost'), 166 | 'port' => env('TYPESENSE_PORT', '8108'), 167 | 'path' => env('TYPESENSE_PATH', ''), 168 | 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), 169 | ], 170 | ], 171 | 'nearest_node' => [ 172 | 'host' => env('TYPESENSE_HOST', 'localhost'), 173 | 'port' => env('TYPESENSE_PORT', '8108'), 174 | 'path' => env('TYPESENSE_PATH', ''), 175 | 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), 176 | ], 177 | 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), 178 | 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), 179 | 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), 180 | 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), 181 | ], 182 | // 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), 183 | 'model-settings' => [ 184 | // User::class => [ 185 | // 'collection-schema' => [ 186 | // 'fields' => [ 187 | // [ 188 | // 'name' => 'id', 189 | // 'type' => 'string', 190 | // ], 191 | // [ 192 | // 'name' => 'name', 193 | // 'type' => 'string', 194 | // ], 195 | // [ 196 | // 'name' => 'created_at', 197 | // 'type' => 'int64', 198 | // ], 199 | // ], 200 | // 'default_sorting_field' => 'created_at', 201 | // ], 202 | // 'search-parameters' => [ 203 | // 'query_by' => 'name' 204 | // ], 205 | // ], 206 | ], 207 | ], 208 | 209 | ]; 210 | -------------------------------------------------------------------------------- /src/Attributes/SearchUsingFullText.php: -------------------------------------------------------------------------------- 1 | columns = Arr::wrap($columns); 33 | $this->options = Arr::wrap($options); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Attributes/SearchUsingPrefix.php: -------------------------------------------------------------------------------- 1 | columns = Arr::wrap($columns); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | model = $model; 118 | $this->query = $query; 119 | $this->callback = $callback; 120 | 121 | if ($softDelete) { 122 | $this->wheres['__soft_deleted'] = 0; 123 | } 124 | } 125 | 126 | /** 127 | * Specify a custom index to perform this search on. 128 | * 129 | * @param string $index 130 | * @return $this 131 | */ 132 | public function within($index) 133 | { 134 | $this->index = $index; 135 | 136 | return $this; 137 | } 138 | 139 | /** 140 | * Add a constraint to the search query. 141 | * 142 | * @param string $field 143 | * @param mixed $value 144 | * @return $this 145 | */ 146 | public function where($field, $value) 147 | { 148 | $this->wheres[$field] = $value; 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * Add a "where in" constraint to the search query. 155 | * 156 | * @param string $field 157 | * @param \Illuminate\Contracts\Support\Arrayable|array $values 158 | * @return $this 159 | */ 160 | public function whereIn($field, $values) 161 | { 162 | if ($values instanceof Arrayable) { 163 | $values = $values->toArray(); 164 | } 165 | 166 | $this->whereIns[$field] = $values; 167 | 168 | return $this; 169 | } 170 | 171 | /** 172 | * Add a "where not in" constraint to the search query. 173 | * 174 | * @param string $field 175 | * @param \Illuminate\Contracts\Support\Arrayable|array $values 176 | * @return $this 177 | */ 178 | public function whereNotIn($field, $values) 179 | { 180 | if ($values instanceof Arrayable) { 181 | $values = $values->toArray(); 182 | } 183 | 184 | $this->whereNotIns[$field] = $values; 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * Include soft deleted records in the results. 191 | * 192 | * @return $this 193 | */ 194 | public function withTrashed() 195 | { 196 | unset($this->wheres['__soft_deleted']); 197 | 198 | return $this; 199 | } 200 | 201 | /** 202 | * Include only soft deleted records in the results. 203 | * 204 | * @return $this 205 | */ 206 | public function onlyTrashed() 207 | { 208 | return tap($this->withTrashed(), function () { 209 | $this->wheres['__soft_deleted'] = 1; 210 | }); 211 | } 212 | 213 | /** 214 | * Set the "limit" for the search query. 215 | * 216 | * @param int $limit 217 | * @return $this 218 | */ 219 | public function take($limit) 220 | { 221 | $this->limit = $limit; 222 | 223 | return $this; 224 | } 225 | 226 | /** 227 | * Add an "order" for the search query. 228 | * 229 | * @param string $column 230 | * @param string $direction 231 | * @return $this 232 | */ 233 | public function orderBy($column, $direction = 'asc') 234 | { 235 | $this->orders[] = [ 236 | 'column' => $column, 237 | 'direction' => strtolower($direction) == 'asc' ? 'asc' : 'desc', 238 | ]; 239 | 240 | return $this; 241 | } 242 | 243 | /** 244 | * Add a descending "order by" clause to the search query. 245 | * 246 | * @param string $column 247 | * @return $this 248 | */ 249 | public function orderByDesc($column) 250 | { 251 | return $this->orderBy($column, 'desc'); 252 | } 253 | 254 | /** 255 | * Add an "order by" clause for a timestamp to the query. 256 | * 257 | * @param string $column 258 | * @return $this 259 | */ 260 | public function latest($column = null) 261 | { 262 | if (is_null($column)) { 263 | $column = $this->model->getCreatedAtColumn() ?? 'created_at'; 264 | } 265 | 266 | return $this->orderBy($column, 'desc'); 267 | } 268 | 269 | /** 270 | * Add an "order by" clause for a timestamp to the query. 271 | * 272 | * @param string $column 273 | * @return $this 274 | */ 275 | public function oldest($column = null) 276 | { 277 | if (is_null($column)) { 278 | $column = $this->model->getCreatedAtColumn() ?? 'created_at'; 279 | } 280 | 281 | return $this->orderBy($column, 'asc'); 282 | } 283 | 284 | /** 285 | * Set extra options for the search query. 286 | * 287 | * @param array $options 288 | * @return $this 289 | */ 290 | public function options(array $options) 291 | { 292 | $this->options = $options; 293 | 294 | return $this; 295 | } 296 | 297 | /** 298 | * Set the callback that should have an opportunity to modify the database query. 299 | * 300 | * @param callable $callback 301 | * @return $this 302 | */ 303 | public function query($callback) 304 | { 305 | $this->queryCallback = $callback; 306 | 307 | return $this; 308 | } 309 | 310 | /** 311 | * Get the raw results of the search. 312 | * 313 | * @return mixed 314 | */ 315 | public function raw() 316 | { 317 | return $this->engine()->search($this); 318 | } 319 | 320 | /** 321 | * Set the callback that should have an opportunity to inspect and modify the raw result returned by the search engine. 322 | * 323 | * @param callable $callback 324 | * @return $this 325 | */ 326 | public function withRawResults($callback) 327 | { 328 | $this->afterRawSearchCallback = $callback; 329 | 330 | return $this; 331 | } 332 | 333 | /** 334 | * Get the keys of search results. 335 | * 336 | * @return \Illuminate\Support\Collection 337 | */ 338 | public function keys() 339 | { 340 | return $this->engine()->keys($this); 341 | } 342 | 343 | /** 344 | * Get the first result from the search. 345 | * 346 | * @return TModel 347 | */ 348 | public function first() 349 | { 350 | return $this->get()->first(); 351 | } 352 | 353 | /** 354 | * Get the results of the search. 355 | * 356 | * @return \Illuminate\Database\Eloquent\Collection 357 | */ 358 | public function get() 359 | { 360 | return $this->engine()->get($this); 361 | } 362 | 363 | /** 364 | * Get the results of the search as a "lazy collection" instance. 365 | * 366 | * @return \Illuminate\Support\LazyCollection 367 | */ 368 | public function cursor() 369 | { 370 | return $this->engine()->cursor($this); 371 | } 372 | 373 | /** 374 | * Paginate the given query into a simple paginator. 375 | * 376 | * @param int $perPage 377 | * @param string $pageName 378 | * @param int|null $page 379 | * @return \Illuminate\Contracts\Pagination\Paginator 380 | */ 381 | public function simplePaginate($perPage = null, $pageName = 'page', $page = null) 382 | { 383 | $engine = $this->engine(); 384 | 385 | if ($engine instanceof PaginatesEloquentModels) { 386 | return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); 387 | } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) { 388 | return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); 389 | } 390 | 391 | $page = $page ?: Paginator::resolveCurrentPage($pageName); 392 | 393 | $perPage = $perPage ?: $this->model->getPerPage(); 394 | 395 | $results = $this->model->newCollection($engine->map( 396 | $this, 397 | $this->applyAfterRawSearchCallback($rawResults = $engine->paginate($this, $perPage, $page)), 398 | $this->model 399 | )->all()); 400 | 401 | $paginator = Container::getInstance()->makeWith(Paginator::class, [ 402 | 'items' => $results, 403 | 'perPage' => $perPage, 404 | 'currentPage' => $page, 405 | 'options' => [ 406 | 'path' => Paginator::resolveCurrentPath(), 407 | 'pageName' => $pageName, 408 | ], 409 | ])->hasMorePagesWhen(($perPage * $page) < $engine->getTotalCount($rawResults)); 410 | 411 | return $paginator->appends('query', $this->query); 412 | } 413 | 414 | /** 415 | * Paginate the given query into a simple paginator with raw data. 416 | * 417 | * @param int $perPage 418 | * @param string $pageName 419 | * @param int|null $page 420 | * @return \Illuminate\Contracts\Pagination\Paginator 421 | */ 422 | public function simplePaginateRaw($perPage = null, $pageName = 'page', $page = null) 423 | { 424 | $engine = $this->engine(); 425 | 426 | if ($engine instanceof PaginatesEloquentModels) { 427 | return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); 428 | } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) { 429 | return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); 430 | } 431 | 432 | $page = $page ?: Paginator::resolveCurrentPage($pageName); 433 | 434 | $perPage = $perPage ?: $this->model->getPerPage(); 435 | 436 | $results = $this->applyAfterRawSearchCallback($engine->paginate($this, $perPage, $page)); 437 | 438 | $paginator = Container::getInstance()->makeWith(Paginator::class, [ 439 | 'items' => $results, 440 | 'perPage' => $perPage, 441 | 'currentPage' => $page, 442 | 'options' => [ 443 | 'path' => Paginator::resolveCurrentPath(), 444 | 'pageName' => $pageName, 445 | ], 446 | ])->hasMorePagesWhen(($perPage * $page) < $engine->getTotalCount($results)); 447 | 448 | return $paginator->appends('query', $this->query); 449 | } 450 | 451 | /** 452 | * Paginate the given query into a paginator. 453 | * 454 | * @param int $perPage 455 | * @param string $pageName 456 | * @param int|null $page 457 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 458 | */ 459 | public function paginate($perPage = null, $pageName = 'page', $page = null) 460 | { 461 | $engine = $this->engine(); 462 | 463 | if ($engine instanceof PaginatesEloquentModels) { 464 | return $engine->paginate($this, $perPage, $page)->appends('query', $this->query); 465 | } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) { 466 | return $engine->paginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); 467 | } 468 | 469 | $page = $page ?: Paginator::resolveCurrentPage($pageName); 470 | 471 | $perPage = $perPage ?: $this->model->getPerPage(); 472 | 473 | $results = $this->model->newCollection($engine->map( 474 | $this, 475 | $this->applyAfterRawSearchCallback($rawResults = $engine->paginate($this, $perPage, $page)), 476 | $this->model 477 | )->all()); 478 | 479 | return Container::getInstance()->makeWith(LengthAwarePaginator::class, [ 480 | 'items' => $results, 481 | 'total' => $this->getTotalCount($rawResults), 482 | 'perPage' => $perPage, 483 | 'currentPage' => $page, 484 | 'options' => [ 485 | 'path' => Paginator::resolveCurrentPath(), 486 | 'pageName' => $pageName, 487 | ], 488 | ])->appends('query', $this->query); 489 | } 490 | 491 | /** 492 | * Paginate the given query into a paginator with raw data. 493 | * 494 | * @param int $perPage 495 | * @param string $pageName 496 | * @param int|null $page 497 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 498 | */ 499 | public function paginateRaw($perPage = null, $pageName = 'page', $page = null) 500 | { 501 | $engine = $this->engine(); 502 | 503 | if ($engine instanceof PaginatesEloquentModels) { 504 | return $engine->paginate($this, $perPage, $page)->appends('query', $this->query); 505 | } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) { 506 | return $engine->paginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); 507 | } 508 | 509 | $page = $page ?: Paginator::resolveCurrentPage($pageName); 510 | 511 | $perPage = $perPage ?: $this->model->getPerPage(); 512 | 513 | $results = $this->applyAfterRawSearchCallback($engine->paginate($this, $perPage, $page)); 514 | 515 | return Container::getInstance()->makeWith(LengthAwarePaginator::class, [ 516 | 'items' => $results, 517 | 'total' => $this->getTotalCount($results), 518 | 'perPage' => $perPage, 519 | 'currentPage' => $page, 520 | 'options' => [ 521 | 'path' => Paginator::resolveCurrentPath(), 522 | 'pageName' => $pageName, 523 | ], 524 | ])->appends('query', $this->query); 525 | } 526 | 527 | /** 528 | * Get the total number of results from the Scout engine, or fallback to query builder. 529 | * 530 | * @param mixed $results 531 | * @return int 532 | */ 533 | protected function getTotalCount($results) 534 | { 535 | $engine = $this->engine(); 536 | 537 | $totalCount = $engine->getTotalCount($results); 538 | 539 | if (is_null($this->queryCallback)) { 540 | return $totalCount; 541 | } 542 | 543 | $ids = $engine->mapIdsFrom($results, $this->model->getScoutKeyName())->all(); 544 | 545 | if (count($ids) < $totalCount) { 546 | $ids = $engine->keys(tap(clone $this, function ($builder) use ($totalCount) { 547 | $builder->take( 548 | is_null($this->limit) ? $totalCount : min($this->limit, $totalCount) 549 | ); 550 | }))->all(); 551 | } 552 | 553 | return $this->model->queryScoutModelsByIds( 554 | $this, $ids 555 | )->toBase()->getCountForPagination(); 556 | } 557 | 558 | /** 559 | * Invoke the "after raw search" callback. 560 | * 561 | * @param mixed $results 562 | * @return mixed 563 | */ 564 | public function applyAfterRawSearchCallback($results) 565 | { 566 | if ($this->afterRawSearchCallback) { 567 | $results = call_user_func($this->afterRawSearchCallback, $results) ?: $results; 568 | } 569 | 570 | return $results; 571 | } 572 | 573 | /** 574 | * Get the engine that should handle the query. 575 | * 576 | * @return mixed 577 | */ 578 | protected function engine() 579 | { 580 | return $this->model->searchableUsing(); 581 | } 582 | 583 | /** 584 | * Get the connection type for the underlying model. 585 | */ 586 | public function modelConnectionType(): string 587 | { 588 | return $this->model->getConnection()->getDriverName(); 589 | } 590 | } 591 | -------------------------------------------------------------------------------- /src/Console/DeleteAllIndexesCommand.php: -------------------------------------------------------------------------------- 1 | engine(); 36 | 37 | $driver = config('scout.driver'); 38 | 39 | if (! method_exists($engine, 'deleteAllIndexes')) { 40 | return $this->error('The ['.$driver.'] engine does not support deleting all indexes.'); 41 | } 42 | 43 | try { 44 | $manager->engine()->deleteAllIndexes(); 45 | 46 | $this->info('All indexes deleted successfully.'); 47 | } catch (Exception $exception) { 48 | $this->error($exception->getMessage()); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Console/DeleteIndexCommand.php: -------------------------------------------------------------------------------- 1 | engine()->deleteIndex($name = $this->indexName($this->argument('name'))); 38 | 39 | $this->info('Index "'.$name.'" deleted.'); 40 | } catch (Exception $exception) { 41 | $this->error($exception->getMessage()); 42 | } 43 | } 44 | 45 | /** 46 | * Get the fully-qualified index name for the given index. 47 | * 48 | * @param string $name 49 | * @return string 50 | */ 51 | protected function indexName($name) 52 | { 53 | if (class_exists($name)) { 54 | return (new $name)->indexableAs(); 55 | } 56 | 57 | $prefix = config('scout.prefix'); 58 | 59 | return ! Str::startsWith($name, $prefix) ? $prefix.$name : $name; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Console/FlushCommand.php: -------------------------------------------------------------------------------- 1 | argument('model'); 33 | 34 | $model = new $class; 35 | 36 | $model::removeAllFromSearch(); 37 | 38 | $this->info('All ['.$class.'] records have been flushed.'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Console/ImportCommand.php: -------------------------------------------------------------------------------- 1 | argument('model'); 39 | 40 | $model = new $class; 41 | 42 | $events->listen(ModelsImported::class, function ($event) use ($class) { 43 | $key = $event->models->last()->getScoutKey(); 44 | 45 | $this->line('Imported ['.$class.'] models up to ID: '.$key); 46 | }); 47 | 48 | if ($this->option('fresh')) { 49 | $model::removeAllFromSearch(); 50 | } 51 | 52 | $model::makeAllSearchable($this->option('chunk')); 53 | 54 | $events->forget(ModelsImported::class); 55 | 56 | $this->info('All ['.$class.'] records have been imported.'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Console/IndexCommand.php: -------------------------------------------------------------------------------- 1 | engine(); 43 | 44 | try { 45 | $options = []; 46 | 47 | if ($this->option('key')) { 48 | $options = ['primaryKey' => $this->option('key')]; 49 | } 50 | 51 | if (class_exists($modelName = $this->argument('name'))) { 52 | $model = new $modelName; 53 | } 54 | 55 | $name = $this->indexName($this->argument('name')); 56 | 57 | $this->createIndex($engine, $name, $options); 58 | 59 | if ($engine instanceof UpdatesIndexSettings) { 60 | $driver = config('scout.driver'); 61 | 62 | $class = isset($model) ? get_class($model) : null; 63 | 64 | $settings = config('scout.'.$driver.'.index-settings.'.$name) 65 | ?? config('scout.'.$driver.'.index-settings.'.$class) 66 | ?? []; 67 | 68 | if (isset($model) && 69 | config('scout.soft_delete', false) && 70 | in_array(SoftDeletes::class, class_uses_recursive($model))) { 71 | $settings = $engine->configureSoftDeleteFilter($settings); 72 | } 73 | 74 | if ($settings) { 75 | $engine->updateIndexSettings($name, $settings); 76 | } 77 | } 78 | 79 | $this->info('Synchronised index ["'.$name.'"] successfully.'); 80 | } catch (Exception $exception) { 81 | $this->error($exception->getMessage()); 82 | } 83 | } 84 | 85 | /** 86 | * Create a search index. 87 | * 88 | * @param \Laravel\Scout\Engines\Engine $engine 89 | * @param string $name 90 | * @param array $options 91 | * @return void 92 | */ 93 | protected function createIndex(Engine $engine, $name, $options): void 94 | { 95 | try { 96 | $engine->createIndex($name, $options); 97 | } catch (NotSupportedException) { 98 | return; 99 | } 100 | } 101 | 102 | /** 103 | * Get the fully-qualified index name for the given index. 104 | * 105 | * @param string $name 106 | * @return string 107 | */ 108 | protected function indexName($name) 109 | { 110 | if (class_exists($name)) { 111 | return (new $name)->indexableAs(); 112 | } 113 | 114 | $prefix = config('scout.prefix'); 115 | 116 | return ! Str::startsWith($name, $prefix) ? $prefix.$name : $name; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Console/QueueImportCommand.php: -------------------------------------------------------------------------------- 1 | argument('model'); 39 | 40 | $model = new $class; 41 | 42 | $query = $model::makeAllSearchableQuery(); 43 | 44 | $min = $this->option('min') ?? $query->min($model->getScoutKeyName()); 45 | $max = $this->option('max') ?? $query->max($model->getScoutKeyName()); 46 | 47 | $chunk = max(1, (int) ($this->option('chunk') ?? config('scout.chunk.searchable', 500))); 48 | 49 | if (! $min || ! $max) { 50 | $this->info('No records found for ['.$class.'].'); 51 | 52 | return; 53 | } 54 | 55 | if (! is_numeric($min) || ! is_numeric($max)) { 56 | $this->error('The primary key for ['.$class.'] is not numeric.'); 57 | 58 | return; 59 | } 60 | 61 | for ($start = $min; $start <= $max; $start += $chunk) { 62 | $end = min($start + $chunk - 1, $max); 63 | 64 | dispatch(new MakeRangeSearchable($class, $start, $end)) 65 | ->onQueue($this->option('queue') ?? $model->syncWithSearchUsingQueue()) 66 | ->onConnection($model->syncWithSearchUsing()); 67 | 68 | $this->line('Queued ['.$class.'] models up to ID: '.$end); 69 | } 70 | 71 | $this->info('All ['.$class.'] records have been queued for importing.'); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Console/SyncIndexSettingsCommand.php: -------------------------------------------------------------------------------- 1 | option('driver') ?: config('scout.driver'); 39 | 40 | $engine = $manager->engine($driver); 41 | 42 | if (! $engine instanceof UpdatesIndexSettings) { 43 | return $this->error('The "'.$driver.'" engine does not support updating index settings.'); 44 | } 45 | 46 | try { 47 | $indexes = (array) config('scout.'.$driver.'.index-settings', []); 48 | 49 | if (count($indexes)) { 50 | foreach ($indexes as $name => $settings) { 51 | if (! is_array($settings)) { 52 | $name = $settings; 53 | 54 | $settings = []; 55 | } 56 | 57 | if (class_exists($name)) { 58 | $model = new $name; 59 | } 60 | 61 | if (isset($model) && 62 | config('scout.soft_delete', false) && 63 | in_array(SoftDeletes::class, class_uses_recursive($model))) { 64 | $settings = $engine->configureSoftDeleteFilter($settings); 65 | } 66 | 67 | $engine->updateIndexSettings($indexName = $this->indexName($name), $settings); 68 | 69 | $this->info('Settings for the ['.$indexName.'] index synced successfully.'); 70 | } 71 | } else { 72 | $this->info('No index settings found for the "'.$driver.'" engine.'); 73 | } 74 | } catch (Exception $exception) { 75 | $this->error($exception->getMessage()); 76 | } 77 | } 78 | 79 | /** 80 | * Get the fully-qualified index name for the given index. 81 | * 82 | * @param string $name 83 | * @return string 84 | */ 85 | protected function indexName($name) 86 | { 87 | if (class_exists($name)) { 88 | return (new $name)->indexableAs(); 89 | } 90 | 91 | $prefix = config('scout.prefix'); 92 | 93 | return ! Str::startsWith($name, $prefix) ? $prefix.$name : $name; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Contracts/PaginatesEloquentModels.php: -------------------------------------------------------------------------------- 1 | driver($name); 32 | } 33 | 34 | /** 35 | * Create an Algolia engine instance. 36 | * 37 | * @return \Laravel\Scout\Engines\AlgoliaEngine 38 | */ 39 | public function createAlgoliaDriver() 40 | { 41 | $this->ensureAlgoliaClientIsInstalled(); 42 | 43 | return version_compare(Algolia::VERSION, '4.0.0', '>=') 44 | ? $this->configureAlgolia4Driver() 45 | : $this->configureAlgolia3Driver(); 46 | } 47 | 48 | /** 49 | * Create an Algolia v3 engine instance. 50 | * 51 | * @return \Laravel\Scout\Engines\Algolia3Engine 52 | */ 53 | protected function configureAlgolia3Driver() 54 | { 55 | Algolia3UserAgent::addCustomUserAgent('Laravel Scout', Scout::VERSION); // @phpstan-ignore class.notFound 56 | 57 | return Algolia3Engine::make( 58 | config: config('scout.algolia'), 59 | headers: $this->defaultAlgoliaHeaders(), 60 | softDelete: config('scout.soft_delete') 61 | ); 62 | } 63 | 64 | /** 65 | * Create an Algolia v4 engine instance. 66 | * 67 | * @return \Laravel\Scout\Engines\Algolia4Engine 68 | */ 69 | protected function configureAlgolia4Driver() 70 | { 71 | Algolia4UserAgent::addAlgoliaAgent('Laravel Scout', 'Laravel Scout', Scout::VERSION); 72 | 73 | return Algolia4Engine::make( 74 | config: config('scout.algolia'), 75 | headers: $this->defaultAlgoliaHeaders(), 76 | softDelete: config('scout.soft_delete') 77 | ); 78 | } 79 | 80 | /** 81 | * Ensure the Algolia API client is installed. 82 | * 83 | * @return void 84 | * 85 | * @throws \Exception 86 | */ 87 | protected function ensureAlgoliaClientIsInstalled() 88 | { 89 | if (class_exists(Algolia::class)) { 90 | return; 91 | } 92 | 93 | throw new Exception('Please install the suggested Algolia client: algolia/algoliasearch-client-php.'); 94 | } 95 | 96 | /** 97 | * Set the default Algolia configuration headers. 98 | * 99 | * @return array 100 | */ 101 | protected function defaultAlgoliaHeaders() 102 | { 103 | if (! config('scout.identify')) { 104 | return []; 105 | } 106 | 107 | $headers = []; 108 | 109 | if (! config('app.debug') && 110 | filter_var($ip = request()->ip(), FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) 111 | ) { 112 | $headers['X-Forwarded-For'] = $ip; 113 | } 114 | 115 | if (($user = request()->user()) && method_exists($user, 'getKey')) { 116 | $headers['X-Algolia-UserToken'] = $user->getKey(); 117 | } 118 | 119 | return $headers; 120 | } 121 | 122 | /** 123 | * Create a Meilisearch engine instance. 124 | * 125 | * @return \Laravel\Scout\Engines\MeilisearchEngine 126 | */ 127 | public function createMeilisearchDriver() 128 | { 129 | $this->ensureMeilisearchClientIsInstalled(); 130 | 131 | return new MeilisearchEngine( 132 | $this->container->make(MeilisearchClient::class), 133 | config('scout.soft_delete', false) 134 | ); 135 | } 136 | 137 | /** 138 | * Ensure the Meilisearch client is installed. 139 | * 140 | * @return void 141 | * 142 | * @throws \Exception 143 | */ 144 | protected function ensureMeilisearchClientIsInstalled() 145 | { 146 | if (class_exists(Meilisearch::class) && version_compare(Meilisearch::VERSION, '1.0.0') >= 0) { 147 | return; 148 | } 149 | 150 | throw new Exception('Please install the suggested Meilisearch client: meilisearch/meilisearch-php.'); 151 | } 152 | 153 | /** 154 | * Create a Typesense engine instance. 155 | * 156 | * @return \Laravel\Scout\Engines\TypesenseEngine 157 | * 158 | * @throws \Typesense\Exceptions\ConfigError 159 | */ 160 | public function createTypesenseDriver() 161 | { 162 | $config = config('scout.typesense'); 163 | $this->ensureTypesenseClientIsInstalled(); 164 | 165 | return new TypesenseEngine(new Typesense($config['client-settings']), $config['max_total_results'] ?? 1000); 166 | } 167 | 168 | /** 169 | * Ensure the Typesense client is installed. 170 | * 171 | * @return void 172 | * 173 | * @throws Exception 174 | */ 175 | protected function ensureTypesenseClientIsInstalled() 176 | { 177 | if (! class_exists(Typesense::class)) { 178 | throw new Exception('Please install the suggested Typesense client: typesense/typesense-php.'); 179 | } 180 | } 181 | 182 | /** 183 | * Create a database engine instance. 184 | * 185 | * @return \Laravel\Scout\Engines\DatabaseEngine 186 | */ 187 | public function createDatabaseDriver() 188 | { 189 | return new DatabaseEngine; 190 | } 191 | 192 | /** 193 | * Create a collection engine instance. 194 | * 195 | * @return \Laravel\Scout\Engines\CollectionEngine 196 | */ 197 | public function createCollectionDriver() 198 | { 199 | return new CollectionEngine; 200 | } 201 | 202 | /** 203 | * Create a null engine instance. 204 | * 205 | * @return \Laravel\Scout\Engines\NullEngine 206 | */ 207 | public function createNullDriver() 208 | { 209 | return new NullEngine; 210 | } 211 | 212 | /** 213 | * Forget all of the resolved engine instances. 214 | * 215 | * @return $this 216 | */ 217 | public function forgetEngines() 218 | { 219 | $this->drivers = []; 220 | 221 | return $this; 222 | } 223 | 224 | /** 225 | * Get the default Scout driver name. 226 | * 227 | * @return string 228 | */ 229 | public function getDefaultDriver() 230 | { 231 | if (is_null($driver = config('scout.driver'))) { 232 | return 'null'; 233 | } 234 | 235 | return $driver; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/Engines/Algolia3Engine.php: -------------------------------------------------------------------------------- 1 | setDefaultHeaders($headers); 41 | 42 | if (is_int($connectTimeout = Arr::get($config, 'connect_timeout'))) { 43 | $configuration->setConnectTimeout($connectTimeout); 44 | } 45 | 46 | if (is_int($readTimeout = Arr::get($config, 'read_timeout'))) { 47 | $configuration->setReadTimeout($readTimeout); 48 | } 49 | 50 | if (is_int($writeTimeout = Arr::get($config, 'write_timeout'))) { 51 | $configuration->setWriteTimeout($writeTimeout); 52 | } 53 | 54 | if (is_int($batchSize = Arr::get($config, 'batch_size'))) { 55 | $configuration->setBatchSize($batchSize); 56 | } 57 | 58 | return new static(Algolia3SearchClient::createWithConfig($configuration), $softDelete); 59 | } 60 | 61 | /** 62 | * Update the given model in the index. 63 | * 64 | * @param \Illuminate\Database\Eloquent\Collection $models 65 | * @return void 66 | * 67 | * @throws \Algolia\AlgoliaSearch\Exceptions\AlgoliaException 68 | */ 69 | public function update($models) 70 | { 71 | if ($models->isEmpty()) { 72 | return; 73 | } 74 | 75 | $index = $this->algolia->initIndex($models->first()->indexableAs()); 76 | 77 | if ($this->usesSoftDelete($models->first()) && $this->softDelete) { 78 | $models->each->pushSoftDeleteMetadata(); 79 | } 80 | 81 | $objects = $models->map(function ($model) { 82 | if (empty($searchableData = $model->toSearchableArray())) { 83 | return; 84 | } 85 | 86 | return array_merge( 87 | $searchableData, 88 | $model->scoutMetadata(), 89 | ['objectID' => $model->getScoutKey()], 90 | ); 91 | }) 92 | ->filter() 93 | ->values() 94 | ->all(); 95 | 96 | if (! empty($objects)) { 97 | $index->saveObjects($objects); 98 | } 99 | } 100 | 101 | /** 102 | * Remove the given model from the index. 103 | * 104 | * @param \Illuminate\Database\Eloquent\Collection $models 105 | * @return void 106 | */ 107 | public function delete($models) 108 | { 109 | if ($models->isEmpty()) { 110 | return; 111 | } 112 | 113 | $index = $this->algolia->initIndex($models->first()->indexableAs()); 114 | 115 | $keys = $models instanceof RemoveableScoutCollection 116 | ? $models->pluck($models->first()->getScoutKeyName()) 117 | : $models->map->getScoutKey(); 118 | 119 | $index->deleteObjects($keys->all()); 120 | } 121 | 122 | /** 123 | * Delete a search index. 124 | * 125 | * @param string $name 126 | * @return mixed 127 | */ 128 | public function deleteIndex($name) 129 | { 130 | return $this->algolia->initIndex($name)->delete(); 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 | $index = $this->algolia->initIndex($model->indexableAs()); 142 | 143 | $index->clearObjects(); 144 | } 145 | 146 | /** 147 | * Perform the given search on the engine. 148 | * 149 | * @param \Laravel\Scout\Builder $builder 150 | * @param array $options 151 | * @return mixed 152 | */ 153 | protected function performSearch(Builder $builder, array $options = []) 154 | { 155 | $algolia = $this->algolia->initIndex( 156 | $builder->index ?: $builder->model->searchableAs() 157 | ); 158 | 159 | $options = array_merge($builder->options, $options); 160 | 161 | if ($builder->callback) { 162 | return call_user_func( 163 | $builder->callback, 164 | $algolia, 165 | $builder->query, 166 | $options 167 | ); 168 | } 169 | 170 | return $algolia->search($builder->query, $options); 171 | } 172 | 173 | /** 174 | * Update the index settings for the given index. 175 | * 176 | * @return void 177 | */ 178 | public function updateIndexSettings(string $name, array $settings = []) 179 | { 180 | $this->algolia->initIndex($name)->setSettings($settings); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Engines/Algolia4Engine.php: -------------------------------------------------------------------------------- 1 | $config['id'], 40 | 'apiKey' => $config['secret'], 41 | ]), array_filter([ 42 | 'batchSize' => transform(Arr::get($config, 'batch_size'), fn ($batchSize) => is_int($batchSize) ? $batchSize : null), 43 | ])))->setDefaultHeaders($headers); 44 | 45 | if (is_int($connectTimeout = Arr::get($config, 'connect_timeout'))) { 46 | $configuration->setConnectTimeout($connectTimeout); 47 | } 48 | 49 | if (is_int($readTimeout = Arr::get($config, 'read_timeout'))) { 50 | $configuration->setReadTimeout($readTimeout); 51 | } 52 | 53 | if (is_int($writeTimeout = Arr::get($config, 'write_timeout'))) { 54 | $configuration->setWriteTimeout($writeTimeout); 55 | } 56 | 57 | return new static(Algolia4SearchClient::createWithConfig($configuration), $softDelete); 58 | } 59 | 60 | /** 61 | * Update the given model in the index. 62 | * 63 | * @param \Illuminate\Database\Eloquent\Collection $models 64 | * @return void 65 | * 66 | * @throws \Algolia\AlgoliaSearch\Exceptions\AlgoliaException 67 | */ 68 | public function update($models) 69 | { 70 | if ($models->isEmpty()) { 71 | return; 72 | } 73 | 74 | $index = $models->first()->indexableAs(); 75 | 76 | if ($this->usesSoftDelete($models->first()) && $this->softDelete) { 77 | $models->each->pushSoftDeleteMetadata(); 78 | } 79 | 80 | $objects = $models->map(function ($model) { 81 | if (empty($searchableData = $model->toSearchableArray())) { 82 | return; 83 | } 84 | 85 | return array_merge( 86 | $searchableData, 87 | $model->scoutMetadata(), 88 | ['objectID' => $model->getScoutKey()], 89 | ); 90 | }) 91 | ->filter() 92 | ->values() 93 | ->all(); 94 | 95 | if (! empty($objects)) { 96 | $this->algolia->saveObjects($index, $objects); 97 | } 98 | } 99 | 100 | /** 101 | * Remove the given model from the index. 102 | * 103 | * @param \Illuminate\Database\Eloquent\Collection $models 104 | * @return void 105 | */ 106 | public function delete($models) 107 | { 108 | if ($models->isEmpty()) { 109 | return; 110 | } 111 | 112 | $keys = $models instanceof RemoveableScoutCollection 113 | ? $models->pluck($models->first()->getScoutKeyName()) 114 | : $models->map->getScoutKey(); 115 | 116 | $this->algolia->deleteObjects($models->first()->indexableAs(), $keys->all()); 117 | } 118 | 119 | /** 120 | * Delete a search index. 121 | * 122 | * @param string $name 123 | * @return mixed 124 | */ 125 | public function deleteIndex($name) 126 | { 127 | return $this->algolia->deleteIndex($name); 128 | } 129 | 130 | /** 131 | * Flush all of the model's records from the engine. 132 | * 133 | * @param \Illuminate\Database\Eloquent\Model $model 134 | * @return void 135 | */ 136 | public function flush($model) 137 | { 138 | $this->algolia->clearObjects($model->indexableAs()); 139 | } 140 | 141 | /** 142 | * Perform the given search on the engine. 143 | * 144 | * @param \Laravel\Scout\Builder $builder 145 | * @param array $options 146 | * @return mixed 147 | */ 148 | protected function performSearch(Builder $builder, array $options = []) 149 | { 150 | $options = array_merge($builder->options, $options); 151 | 152 | if ($builder->callback) { 153 | return call_user_func( 154 | $builder->callback, 155 | $this->algolia, 156 | $builder->query, 157 | $options 158 | ); 159 | } 160 | 161 | $queryParams = array_merge(['query' => $builder->query], $options); 162 | 163 | return $this->algolia->searchSingleIndex( 164 | $builder->index ?: $builder->model->searchableAs(), 165 | $queryParams 166 | ); 167 | } 168 | 169 | /** 170 | * Update the index settings for the given index. 171 | * 172 | * @return void 173 | */ 174 | public function updateIndexSettings(string $name, array $settings = []) 175 | { 176 | $this->algolia->setSettings($name, $settings); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Engines/AlgoliaEngine.php: -------------------------------------------------------------------------------- 1 | algolia = $algolia; 40 | $this->softDelete = $softDelete; 41 | } 42 | 43 | /** 44 | * Perform the given search on the engine. 45 | * 46 | * @param \Laravel\Scout\Builder $builder 47 | * @param array $options 48 | * @return mixed 49 | */ 50 | abstract protected function performSearch(Builder $builder, array $options = []); 51 | 52 | /** 53 | * Update the given model in the index. 54 | * 55 | * @param \Illuminate\Database\Eloquent\Collection $models 56 | * @return void 57 | * 58 | * @throws \Algolia\AlgoliaSearch\Exceptions\AlgoliaException 59 | */ 60 | abstract public function update($models); 61 | 62 | /** 63 | * Remove the given model from the index. 64 | * 65 | * @param \Illuminate\Database\Eloquent\Collection $models 66 | * @return void 67 | */ 68 | abstract public function delete($models); 69 | 70 | /** 71 | * Delete a search index. 72 | * 73 | * @param string $name 74 | * @return mixed 75 | */ 76 | abstract public function deleteIndex($name); 77 | 78 | /** 79 | * Flush all of the model's records from the engine. 80 | * 81 | * @param \Illuminate\Database\Eloquent\Model $model 82 | * @return void 83 | */ 84 | abstract public function flush($model); 85 | 86 | /** 87 | * Update the index settings for the given index. 88 | * 89 | * @return void 90 | */ 91 | abstract public function updateIndexSettings(string $name, array $settings = []); 92 | 93 | /** 94 | * Perform the given search on the engine. 95 | * 96 | * @param \Laravel\Scout\Builder $builder 97 | * @return mixed 98 | */ 99 | public function search(Builder $builder) 100 | { 101 | return $this->performSearch($builder, array_filter([ 102 | 'numericFilters' => $this->filters($builder), 103 | 'hitsPerPage' => $builder->limit, 104 | ])); 105 | } 106 | 107 | /** 108 | * Perform the given search on the engine. 109 | * 110 | * @param \Laravel\Scout\Builder $builder 111 | * @param int $perPage 112 | * @param int $page 113 | * @return mixed 114 | */ 115 | public function paginate(Builder $builder, $perPage, $page) 116 | { 117 | return $this->performSearch($builder, [ 118 | 'numericFilters' => $this->filters($builder), 119 | 'hitsPerPage' => $perPage, 120 | 'page' => $page - 1, 121 | ]); 122 | } 123 | 124 | /** 125 | * Get the filter array for the query. 126 | * 127 | * @param \Laravel\Scout\Builder $builder 128 | * @return array 129 | */ 130 | protected function filters(Builder $builder) 131 | { 132 | $wheres = collect($builder->wheres) 133 | ->map(fn ($value, $key) => $key.'='.$value) 134 | ->values(); 135 | 136 | return $wheres->merge(collect($builder->whereIns)->map(function ($values, $key) { 137 | if (empty($values)) { 138 | return '0=1'; 139 | } 140 | 141 | return collect($values) 142 | ->map(fn ($value) => $key.'='.$value) 143 | ->all(); 144 | })->values())->values()->all(); 145 | } 146 | 147 | /** 148 | * Pluck and return the primary keys of the given results. 149 | * 150 | * @param mixed $results 151 | * @return \Illuminate\Support\Collection 152 | */ 153 | public function mapIds($results) 154 | { 155 | return collect($results['hits'])->pluck('objectID')->values(); 156 | } 157 | 158 | /** 159 | * Map the given results to instances of the given model. 160 | * 161 | * @param \Laravel\Scout\Builder $builder 162 | * @param mixed $results 163 | * @param \Illuminate\Database\Eloquent\Model $model 164 | * @return \Illuminate\Database\Eloquent\Collection 165 | */ 166 | public function map(Builder $builder, $results, $model) 167 | { 168 | if (count($results['hits']) === 0) { 169 | return $model->newCollection(); 170 | } 171 | 172 | $objectIds = collect($results['hits'])->pluck('objectID')->values()->all(); 173 | 174 | $objectIdPositions = array_flip($objectIds); 175 | 176 | return $model->getScoutModelsByIds($builder, $objectIds) 177 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds)) 178 | ->map(function ($model) use ($results, $objectIdPositions) { 179 | $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? []; 180 | 181 | foreach ($result as $key => $value) { 182 | if (substr($key, 0, 1) === '_') { 183 | $model->withScoutMetadata($key, $value); 184 | } 185 | } 186 | 187 | return $model; 188 | }) 189 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()]) 190 | ->values(); 191 | } 192 | 193 | /** 194 | * Map the given results to instances of the given model via a lazy collection. 195 | * 196 | * @param \Laravel\Scout\Builder $builder 197 | * @param mixed $results 198 | * @param \Illuminate\Database\Eloquent\Model $model 199 | * @return \Illuminate\Support\LazyCollection 200 | */ 201 | public function lazyMap(Builder $builder, $results, $model) 202 | { 203 | if (count($results['hits']) === 0) { 204 | return LazyCollection::make($model->newCollection()); 205 | } 206 | 207 | $objectIds = collect($results['hits'])->pluck('objectID')->values()->all(); 208 | $objectIdPositions = array_flip($objectIds); 209 | 210 | return $model->queryScoutModelsByIds($builder, $objectIds) 211 | ->cursor() 212 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds)) 213 | ->map(function ($model) use ($results, $objectIdPositions) { 214 | $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? []; 215 | 216 | foreach ($result as $key => $value) { 217 | if (substr($key, 0, 1) === '_') { 218 | $model->withScoutMetadata($key, $value); 219 | } 220 | } 221 | 222 | return $model; 223 | }) 224 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()]) 225 | ->values(); 226 | } 227 | 228 | /** 229 | * Get the total count from a raw result returned by the engine. 230 | * 231 | * @param mixed $results 232 | * @return int 233 | */ 234 | public function getTotalCount($results) 235 | { 236 | return $results['nbHits']; 237 | } 238 | 239 | /** 240 | * Create a search index. 241 | * 242 | * @param string $name 243 | * @param array $options 244 | * @return mixed 245 | * 246 | * @throws NotSupportedException 247 | */ 248 | public function createIndex($name, array $options = []) 249 | { 250 | throw new NotSupportedException('Algolia indexes are created automatically upon adding objects.'); 251 | } 252 | 253 | /** 254 | * Configure the soft delete filter within the given settings. 255 | * 256 | * @return array 257 | */ 258 | public function configureSoftDeleteFilter(array $settings = []) 259 | { 260 | $settings['attributesForFaceting'][] = 'filterOnly(__soft_deleted)'; 261 | 262 | return $settings; 263 | } 264 | 265 | /** 266 | * Determine if the given model uses soft deletes. 267 | * 268 | * @param \Illuminate\Database\Eloquent\Model $model 269 | * @return bool 270 | */ 271 | protected function usesSoftDelete($model) 272 | { 273 | return in_array(SoftDeletes::class, class_uses_recursive($model)); 274 | } 275 | 276 | /** 277 | * Dynamically call the Algolia client instance. 278 | * 279 | * @param string $method 280 | * @param array $parameters 281 | * @return mixed 282 | */ 283 | public function __call($method, $parameters) 284 | { 285 | return $this->algolia->$method(...$parameters); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/Engines/CollectionEngine.php: -------------------------------------------------------------------------------- 1 | searchModels($builder)->take($builder->limit); 54 | 55 | return [ 56 | 'results' => $models->all(), 57 | 'total' => count($models), 58 | ]; 59 | } 60 | 61 | /** 62 | * Perform the given search on the engine. 63 | * 64 | * @param \Laravel\Scout\Builder $builder 65 | * @param int $perPage 66 | * @param int $page 67 | * @return mixed 68 | */ 69 | public function paginate(Builder $builder, $perPage, $page) 70 | { 71 | $models = $this->searchModels($builder); 72 | 73 | return [ 74 | 'results' => $models->forPage($page, $perPage)->all(), 75 | 'total' => count($models), 76 | ]; 77 | } 78 | 79 | /** 80 | * Get the Eloquent models for the given builder. 81 | * 82 | * @param \Laravel\Scout\Builder $builder 83 | * @return \Illuminate\Database\Eloquent\Collection 84 | */ 85 | protected function searchModels(Builder $builder) 86 | { 87 | $query = $builder->model->query() 88 | ->when(! is_null($builder->callback), function ($query) use ($builder) { 89 | call_user_func($builder->callback, $query, $builder, $builder->query); 90 | }) 91 | ->when(! $builder->callback && count($builder->wheres) > 0, function ($query) use ($builder) { 92 | foreach ($builder->wheres as $key => $value) { 93 | if ($key !== '__soft_deleted') { 94 | $query->where($key, $value); 95 | } 96 | } 97 | }) 98 | ->when(! $builder->callback && count($builder->whereIns) > 0, function ($query) use ($builder) { 99 | foreach ($builder->whereIns as $key => $values) { 100 | $query->whereIn($key, $values); 101 | } 102 | }) 103 | ->when(! $builder->callback && count($builder->whereNotIns) > 0, function ($query) use ($builder) { 104 | foreach ($builder->whereNotIns as $key => $values) { 105 | $query->whereNotIn($key, $values); 106 | } 107 | }) 108 | ->when($builder->orders, function ($query) use ($builder) { 109 | foreach ($builder->orders as $order) { 110 | $query->orderBy($order['column'], $order['direction']); 111 | } 112 | }, function ($query) use ($builder) { 113 | $query->orderBy($builder->model->qualifyColumn($builder->model->getScoutKeyName()), 'desc'); 114 | }); 115 | 116 | $models = $this->ensureSoftDeletesAreHandled($builder, $query) 117 | ->get() 118 | ->values(); 119 | 120 | if (count($models) === 0) { 121 | return $models; 122 | } 123 | 124 | return $models->first()->makeSearchableUsing($models)->filter(function ($model) use ($builder) { 125 | if (! $model->shouldBeSearchable()) { 126 | return false; 127 | } 128 | 129 | if (! $builder->query) { 130 | return true; 131 | } 132 | 133 | $searchables = $model->toSearchableArray(); 134 | 135 | foreach ($searchables as $value) { 136 | if (! is_scalar($value)) { 137 | $value = json_encode($value); 138 | } 139 | 140 | if (Str::contains(Str::lower($value), Str::lower($builder->query))) { 141 | return true; 142 | } 143 | } 144 | 145 | return false; 146 | })->values(); 147 | } 148 | 149 | /** 150 | * Ensure that soft delete handling is properly applied to the query. 151 | * 152 | * @param \Laravel\Scout\Builder $builder 153 | * @param \Illuminate\Database\Query\Builder $query 154 | * @return \Illuminate\Database\Query\Builder 155 | */ 156 | protected function ensureSoftDeletesAreHandled($builder, $query) 157 | { 158 | if (Arr::get($builder->wheres, '__soft_deleted') === 0) { 159 | return $query->withoutTrashed(); 160 | } elseif (Arr::get($builder->wheres, '__soft_deleted') === 1) { 161 | return $query->onlyTrashed(); 162 | } elseif (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model))) && 163 | config('scout.soft_delete', false)) { 164 | return $query->withTrashed(); 165 | } 166 | 167 | return $query; 168 | } 169 | 170 | /** 171 | * Pluck and return the primary keys of the given results. 172 | * 173 | * @param mixed $results 174 | * @return \Illuminate\Support\Collection 175 | */ 176 | public function mapIds($results) 177 | { 178 | $results = array_values($results['results']); 179 | 180 | return count($results) > 0 181 | ? collect($results)->pluck($results[0]->getScoutKeyName()) 182 | : collect(); 183 | } 184 | 185 | /** 186 | * Map the given results to instances of the given model. 187 | * 188 | * @param \Laravel\Scout\Builder $builder 189 | * @param mixed $results 190 | * @param \Illuminate\Database\Eloquent\Model $model 191 | * @return \Illuminate\Database\Eloquent\Collection 192 | */ 193 | public function map(Builder $builder, $results, $model) 194 | { 195 | $results = $results['results']; 196 | 197 | if (count($results) === 0) { 198 | return $model->newCollection(); 199 | } 200 | 201 | $objectIds = collect($results) 202 | ->pluck($model->getScoutKeyName()) 203 | ->values() 204 | ->all(); 205 | 206 | $objectIdPositions = array_flip($objectIds); 207 | 208 | return $model->getScoutModelsByIds($builder, $objectIds) 209 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds)) 210 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()]) 211 | ->values(); 212 | } 213 | 214 | /** 215 | * Map the given results to instances of the given model via a lazy collection. 216 | * 217 | * @param \Laravel\Scout\Builder $builder 218 | * @param mixed $results 219 | * @param \Illuminate\Database\Eloquent\Model $model 220 | * @return \Illuminate\Support\LazyCollection 221 | */ 222 | public function lazyMap(Builder $builder, $results, $model) 223 | { 224 | $results = $results['results']; 225 | 226 | if (count($results) === 0) { 227 | return LazyCollection::empty(); 228 | } 229 | 230 | $objectIds = collect($results) 231 | ->pluck($model->getScoutKeyName()) 232 | ->values()->all(); 233 | 234 | $objectIdPositions = array_flip($objectIds); 235 | 236 | return $model->queryScoutModelsByIds($builder, $objectIds) 237 | ->cursor() 238 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds)) 239 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()]) 240 | ->values(); 241 | } 242 | 243 | /** 244 | * Get the total count from a raw result returned by the engine. 245 | * 246 | * @param mixed $results 247 | * @return int 248 | */ 249 | public function getTotalCount($results) 250 | { 251 | return $results['total']; 252 | } 253 | 254 | /** 255 | * Flush all of the model's records from the engine. 256 | * 257 | * @param \Illuminate\Database\Eloquent\Model $model 258 | * @return void 259 | */ 260 | public function flush($model) 261 | { 262 | // 263 | } 264 | 265 | /** 266 | * Create a search index. 267 | * 268 | * @param string $name 269 | * @param array $options 270 | * @return mixed 271 | * 272 | * @throws \Exception 273 | */ 274 | public function createIndex($name, array $options = []) 275 | { 276 | // 277 | } 278 | 279 | /** 280 | * Delete a search index. 281 | * 282 | * @param string $name 283 | * @return mixed 284 | */ 285 | public function deleteIndex($name) 286 | { 287 | // 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/Engines/DatabaseEngine.php: -------------------------------------------------------------------------------- 1 | searchModels($builder); 57 | 58 | return [ 59 | 'results' => $models, 60 | 'total' => $models->count(), 61 | ]; 62 | } 63 | 64 | /** 65 | * Get the Eloquent models for the given builder. 66 | * 67 | * @param \Laravel\Scout\Builder $builder 68 | * @param int|null $page 69 | * @param int|null $perPage 70 | * @return \Illuminate\Database\Eloquent\Collection 71 | */ 72 | protected function searchModels(Builder $builder, $page = null, $perPage = null) 73 | { 74 | return $this->buildSearchQuery($builder) 75 | ->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) { 76 | $query->forPage($page, $perPage); 77 | }) 78 | ->when($builder->orders, function ($query) use ($builder) { 79 | foreach ($builder->orders as $order) { 80 | $query->orderBy($order['column'], $order['direction']); 81 | } 82 | }) 83 | ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) { 84 | $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc'); 85 | }) 86 | ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) { 87 | $this->orderByRelevance($builder, $query); 88 | }) 89 | ->get(); 90 | } 91 | 92 | /** 93 | * Paginate the given search on the engine. 94 | * 95 | * @param \Laravel\Scout\Builder $builder 96 | * @param int $perPage 97 | * @param int $page 98 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 99 | */ 100 | public function paginate(Builder $builder, $perPage, $page) 101 | { 102 | return $this->paginateUsingDatabase($builder, $perPage, 'page', $page); 103 | } 104 | 105 | /** 106 | * Paginate the given search on the engine. 107 | * 108 | * @param \Laravel\Scout\Builder $builder 109 | * @param int $perPage 110 | * @param string $pageName 111 | * @param int $page 112 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 113 | */ 114 | public function paginateUsingDatabase(Builder $builder, $perPage, $pageName, $page) 115 | { 116 | return $this->buildSearchQuery($builder) 117 | ->when($builder->orders, function ($query) use ($builder) { 118 | foreach ($builder->orders as $order) { 119 | $query->orderBy($order['column'], $order['direction']); 120 | } 121 | }) 122 | ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) { 123 | $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc'); 124 | }) 125 | ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) { 126 | $this->orderByRelevance($builder, $query); 127 | }) 128 | ->paginate($perPage, ['*'], $pageName, $page); 129 | } 130 | 131 | /** 132 | * Paginate the given search on the engine using simple pagination. 133 | * 134 | * @param \Laravel\Scout\Builder $builder 135 | * @param int $perPage 136 | * @param int $page 137 | * @return \Illuminate\Contracts\Pagination\Paginator 138 | */ 139 | public function simplePaginate(Builder $builder, $perPage, $page) 140 | { 141 | return $this->simplePaginateUsingDatabase($builder, $perPage, 'page', $page); 142 | } 143 | 144 | /** 145 | * Paginate the given query into a simple paginator. 146 | * 147 | * @param int $perPage 148 | * @param string $pageName 149 | * @param int|null $page 150 | * @return \Illuminate\Contracts\Pagination\Paginator 151 | */ 152 | public function simplePaginateUsingDatabase(Builder $builder, $perPage, $pageName, $page) 153 | { 154 | return $this->buildSearchQuery($builder) 155 | ->when($builder->orders, function ($query) use ($builder) { 156 | foreach ($builder->orders as $order) { 157 | $query->orderBy($order['column'], $order['direction']); 158 | } 159 | }) 160 | ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) { 161 | $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc'); 162 | }) 163 | ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) { 164 | $this->orderByRelevance($builder, $query); 165 | }) 166 | ->simplePaginate($perPage, ['*'], $pageName, $page); 167 | } 168 | 169 | /** 170 | * Initialize / build the search query for the given Scout builder. 171 | * 172 | * @param \Laravel\Scout\Builder $builder 173 | * @return \Illuminate\Database\Eloquent\Builder 174 | */ 175 | protected function buildSearchQuery(Builder $builder) 176 | { 177 | $query = $this->initializeSearchQuery( 178 | $builder, 179 | array_keys($builder->model->toSearchableArray()), 180 | $this->getPrefixColumns($builder), 181 | $this->getFullTextColumns($builder) 182 | ); 183 | 184 | return $this->constrainForSoftDeletes( 185 | $builder, $this->addAdditionalConstraints($builder, $query->take($builder->limit)) 186 | ); 187 | } 188 | 189 | /** 190 | * Build the initial text search database query for all relevant columns. 191 | * 192 | * @param \Laravel\Scout\Builder $builder 193 | * @param array $columns 194 | * @param array $prefixColumns 195 | * @param array $fullTextColumns 196 | * @return \Illuminate\Database\Eloquent\Builder 197 | */ 198 | protected function initializeSearchQuery(Builder $builder, array $columns, array $prefixColumns = [], array $fullTextColumns = []) 199 | { 200 | $query = method_exists($builder->model, 'newScoutQuery') 201 | ? $builder->model->newScoutQuery($builder) 202 | : $builder->model->newQuery(); 203 | 204 | if (blank($builder->query)) { 205 | return $query; 206 | } 207 | 208 | [$connectionType] = [ 209 | $builder->modelConnectionType(), 210 | ]; 211 | 212 | return $query->where(function ($query) use ($connectionType, $builder, $columns, $prefixColumns, $fullTextColumns) { 213 | $canSearchPrimaryKey = ctype_digit($builder->query) && 214 | in_array($builder->model->getKeyType(), ['int', 'integer']) && 215 | ($connectionType != 'pgsql' || $builder->query <= PHP_INT_MAX) && 216 | in_array($builder->model->getScoutKeyName(), $columns); 217 | 218 | if ($canSearchPrimaryKey) { 219 | $query->orWhere($builder->model->getQualifiedKeyName(), $builder->query); 220 | } 221 | 222 | $likeOperator = $connectionType == 'pgsql' ? 'ilike' : 'like'; 223 | 224 | foreach ($columns as $column) { 225 | if (in_array($column, $fullTextColumns)) { 226 | continue; 227 | } else { 228 | if ($canSearchPrimaryKey && $column === $builder->model->getScoutKeyName()) { 229 | continue; 230 | } 231 | 232 | $query->orWhere( 233 | $builder->model->qualifyColumn($column), 234 | $likeOperator, 235 | in_array($column, $prefixColumns) ? $builder->query.'%' : '%'.$builder->query.'%', 236 | ); 237 | } 238 | } 239 | 240 | if (count($fullTextColumns) > 0) { 241 | $query->orWhereFullText( 242 | array_map(fn ($column) => $builder->model->qualifyColumn($column), $fullTextColumns), 243 | $builder->query, 244 | $this->getFullTextOptions($builder) 245 | ); 246 | } 247 | }); 248 | } 249 | 250 | /** 251 | * Determine if the query should be ordered by relevance. 252 | */ 253 | protected function shouldOrderByRelevance(Builder $builder): bool 254 | { 255 | // MySQL orders by relevance by default, so we will only order by relevance on 256 | // Postgres with no developer-defined orders. If there is developer defined 257 | // order by clauses we will let those take precedence over the relevance. 258 | return $builder->modelConnectionType() === 'pgsql' && 259 | count($this->getFullTextColumns($builder)) > 0 && 260 | empty($builder->orders); 261 | } 262 | 263 | /** 264 | * Add an "order by" clause that orders by relevance (Postgres only). 265 | * 266 | * @param \Laravel\Scout\Builder $builder 267 | * @param \Illuminate\Database\Eloquent\Builder $query 268 | * @return \Illuminate\Database\Eloquent\Builder 269 | */ 270 | protected function orderByRelevance(Builder $builder, $query) 271 | { 272 | $fullTextColumns = $this->getFullTextColumns($builder); 273 | 274 | $language = $this->getFullTextOptions($builder)['language'] ?? 'english'; 275 | 276 | $vectors = collect($fullTextColumns)->map(function ($column) use ($builder, $language) { 277 | return sprintf("to_tsvector('%s', %s)", $language, $builder->model->qualifyColumn($column)); 278 | })->implode(' || '); 279 | 280 | return $query->orderByRaw( 281 | sprintf( 282 | 'ts_rank('.$vectors.', %s(?)) desc', 283 | match ($this->getFullTextOptions($builder)['mode'] ?? 'plainto_tsquery') { 284 | 'phrase' => 'phraseto_tsquery', 285 | 'websearch' => 'websearch_to_tsquery', 286 | default => 'plainto_tsquery', 287 | }, 288 | ), 289 | [$builder->query] 290 | ); 291 | } 292 | 293 | /** 294 | * Add additional, developer defined constraints to the search query. 295 | * 296 | * @param \Laravel\Scout\Builder $builder 297 | * @param \Illuminate\Database\Eloquent\Builder $query 298 | * @return \Illuminate\Database\Eloquent\Builder 299 | */ 300 | protected function addAdditionalConstraints(Builder $builder, $query) 301 | { 302 | return $query->when(! is_null($builder->callback), function ($query) use ($builder) { 303 | call_user_func($builder->callback, $query, $builder, $builder->query); 304 | })->when(! $builder->callback && count($builder->wheres) > 0, function ($query) use ($builder) { 305 | foreach ($builder->wheres as $key => $value) { 306 | if ($key !== '__soft_deleted') { 307 | $query->where($key, '=', $value); 308 | } 309 | } 310 | })->when(! $builder->callback && count($builder->whereIns) > 0, function ($query) use ($builder) { 311 | foreach ($builder->whereIns as $key => $values) { 312 | $query->whereIn($key, $values); 313 | } 314 | })->when(! $builder->callback && count($builder->whereNotIns) > 0, function ($query) use ($builder) { 315 | foreach ($builder->whereNotIns as $key => $values) { 316 | $query->whereNotIn($key, $values); 317 | } 318 | })->when(! is_null($builder->queryCallback), function ($query) use ($builder) { 319 | call_user_func($builder->queryCallback, $query); 320 | }); 321 | } 322 | 323 | /** 324 | * Ensure that soft delete constraints are properly applied to the query. 325 | * 326 | * @param \Laravel\Scout\Builder $builder 327 | * @param \Illuminate\Database\Eloquent\Builder $query 328 | * @return \Illuminate\Database\Eloquent\Builder 329 | */ 330 | protected function constrainForSoftDeletes($builder, $query) 331 | { 332 | if (Arr::get($builder->wheres, '__soft_deleted') === 0) { 333 | return $query->withoutTrashed(); 334 | } elseif (Arr::get($builder->wheres, '__soft_deleted') === 1) { 335 | return $query->onlyTrashed(); 336 | } elseif (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model))) && 337 | config('scout.soft_delete', false)) { 338 | return $query->withTrashed(); 339 | } 340 | 341 | return $query; 342 | } 343 | 344 | /** 345 | * Get the full-text columns for the query. 346 | * 347 | * @param \Laravel\Scout\Builder $builder 348 | * @return array 349 | */ 350 | protected function getFullTextColumns(Builder $builder) 351 | { 352 | return $this->getAttributeColumns($builder, SearchUsingFullText::class); 353 | } 354 | 355 | /** 356 | * Get the prefix search columns for the query. 357 | * 358 | * @param \Laravel\Scout\Builder $builder 359 | * @return array 360 | */ 361 | protected function getPrefixColumns(Builder $builder) 362 | { 363 | return $this->getAttributeColumns($builder, SearchUsingPrefix::class); 364 | } 365 | 366 | /** 367 | * Get the columns marked with a given attribute. 368 | * 369 | * @param \Laravel\Scout\Builder $builder 370 | * @param string $attributeClass 371 | * @return array 372 | */ 373 | protected function getAttributeColumns(Builder $builder, $attributeClass) 374 | { 375 | $columns = []; 376 | 377 | foreach ((new ReflectionMethod($builder->model, 'toSearchableArray'))->getAttributes() as $attribute) { 378 | if ($attribute->getName() !== $attributeClass) { 379 | continue; 380 | } 381 | 382 | $columns = array_merge($columns, Arr::wrap($attribute->getArguments()[0])); 383 | } 384 | 385 | return $columns; 386 | } 387 | 388 | /** 389 | * Get the full-text search options for the query. 390 | * 391 | * @param \Laravel\Scout\Builder $builder 392 | * @return array 393 | */ 394 | protected function getFullTextOptions(Builder $builder) 395 | { 396 | $options = []; 397 | 398 | foreach ((new ReflectionMethod($builder->model, 'toSearchableArray'))->getAttributes(SearchUsingFullText::class) as $attribute) { 399 | $arguments = $attribute->getArguments()[1] ?? []; 400 | 401 | $options = array_merge($options, Arr::wrap($arguments)); 402 | } 403 | 404 | return $options; 405 | } 406 | 407 | /** 408 | * Pluck and return the primary keys of the given results. 409 | * 410 | * @param mixed $results 411 | * @return \Illuminate\Support\Collection 412 | */ 413 | public function mapIds($results) 414 | { 415 | $results = $results['results']; 416 | 417 | return count($results) > 0 418 | ? collect($results->modelKeys()) 419 | : collect(); 420 | } 421 | 422 | /** 423 | * Map the given results to instances of the given model. 424 | * 425 | * @param \Laravel\Scout\Builder $builder 426 | * @param mixed $results 427 | * @param \Illuminate\Database\Eloquent\Model $model 428 | * @return \Illuminate\Database\Eloquent\Collection 429 | */ 430 | public function map(Builder $builder, $results, $model) 431 | { 432 | return $results['results']; 433 | } 434 | 435 | /** 436 | * Map the given results to instances of the given model via a lazy collection. 437 | * 438 | * @param \Laravel\Scout\Builder $builder 439 | * @param mixed $results 440 | * @param \Illuminate\Database\Eloquent\Model $model 441 | * @return \Illuminate\Support\LazyCollection 442 | */ 443 | public function lazyMap(Builder $builder, $results, $model) 444 | { 445 | return new LazyCollection($results['results']->all()); 446 | } 447 | 448 | /** 449 | * Get the total count from a raw result returned by the engine. 450 | * 451 | * @param mixed $results 452 | * @return int 453 | */ 454 | public function getTotalCount($results) 455 | { 456 | return $results['total']; 457 | } 458 | 459 | /** 460 | * Flush all of the model's records from the engine. 461 | * 462 | * @param \Illuminate\Database\Eloquent\Model $model 463 | * @return void 464 | */ 465 | public function flush($model) 466 | { 467 | // 468 | } 469 | 470 | /** 471 | * Create a search index. 472 | * 473 | * @param string $name 474 | * @param array $options 475 | * @return mixed 476 | * 477 | * @throws \Exception 478 | */ 479 | public function createIndex($name, array $options = []) 480 | { 481 | // 482 | } 483 | 484 | /** 485 | * Delete a search index. 486 | * 487 | * @param string $name 488 | * @return mixed 489 | */ 490 | public function deleteIndex($name) 491 | { 492 | // 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /src/Engines/Engine.php: -------------------------------------------------------------------------------- 1 | mapIds($results); 114 | } 115 | 116 | /** 117 | * Get the results of the query as a Collection of primary keys. 118 | * 119 | * @param \Laravel\Scout\Builder $builder 120 | * @return \Illuminate\Support\Collection 121 | */ 122 | public function keys(Builder $builder) 123 | { 124 | return $this->mapIds($this->search($builder)); 125 | } 126 | 127 | /** 128 | * Get the results of the given query mapped onto models. 129 | * 130 | * @param \Laravel\Scout\Builder $builder 131 | * @return \Illuminate\Database\Eloquent\Collection 132 | */ 133 | public function get(Builder $builder) 134 | { 135 | return $this->map( 136 | $builder, 137 | $builder->applyAfterRawSearchCallback($this->search($builder)), 138 | $builder->model 139 | ); 140 | } 141 | 142 | /** 143 | * Get a lazy collection for the given query mapped onto models. 144 | * 145 | * @param \Laravel\Scout\Builder $builder 146 | * @return \Illuminate\Database\Eloquent\Collection 147 | */ 148 | public function cursor(Builder $builder) 149 | { 150 | return $this->lazyMap( 151 | $builder, 152 | $builder->applyAfterRawSearchCallback($this->search($builder)), 153 | $builder->model 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Engines/MeilisearchEngine.php: -------------------------------------------------------------------------------- 1 | meilisearch = $meilisearch; 42 | $this->softDelete = $softDelete; 43 | } 44 | 45 | /** 46 | * Update the given model in the index. 47 | * 48 | * @param \Illuminate\Database\Eloquent\Collection $models 49 | * @return void 50 | * 51 | * @throws \Meilisearch\Exceptions\ApiException 52 | */ 53 | public function update($models) 54 | { 55 | if ($models->isEmpty()) { 56 | return; 57 | } 58 | 59 | $index = $this->meilisearch->index($models->first()->indexableAs()); 60 | 61 | if ($this->usesSoftDelete($models->first()) && $this->softDelete) { 62 | $models->each->pushSoftDeleteMetadata(); 63 | } 64 | 65 | $objects = $models->map(function ($model) { 66 | if (empty($searchableData = $model->toSearchableArray())) { 67 | return; 68 | } 69 | 70 | return array_merge( 71 | $searchableData, 72 | $model->scoutMetadata(), 73 | [$model->getScoutKeyName() => $model->getScoutKey()], 74 | ); 75 | }) 76 | ->filter() 77 | ->values() 78 | ->all(); 79 | 80 | if (! empty($objects)) { 81 | $index->addDocuments($objects, $models->first()->getScoutKeyName()); 82 | } 83 | } 84 | 85 | /** 86 | * Remove the given model from the index. 87 | * 88 | * @param \Illuminate\Database\Eloquent\Collection $models 89 | * @return void 90 | */ 91 | public function delete($models) 92 | { 93 | if ($models->isEmpty()) { 94 | return; 95 | } 96 | 97 | $index = $this->meilisearch->index($models->first()->indexableAs()); 98 | 99 | $keys = $models instanceof RemoveableScoutCollection 100 | ? $models->pluck($models->first()->getScoutKeyName()) 101 | : $models->map->getScoutKey(); 102 | 103 | $index->deleteDocuments($keys->values()->all()); 104 | } 105 | 106 | /** 107 | * Perform the given search on the engine. 108 | * 109 | * @return mixed 110 | */ 111 | public function search(Builder $builder) 112 | { 113 | return $this->performSearch($builder, array_filter([ 114 | 'filter' => $this->filters($builder), 115 | 'hitsPerPage' => $builder->limit, 116 | 'sort' => $this->buildSortFromOrderByClauses($builder), 117 | ])); 118 | } 119 | 120 | /** 121 | * Perform the given search on the engine. 122 | * 123 | * page/hitsPerPage ensures that the search is exhaustive. 124 | * 125 | * @param int $perPage 126 | * @param int $page 127 | * @return mixed 128 | */ 129 | public function paginate(Builder $builder, $perPage, $page) 130 | { 131 | return $this->performSearch($builder, array_filter([ 132 | 'filter' => $this->filters($builder), 133 | 'hitsPerPage' => (int) $perPage, 134 | 'page' => $page, 135 | 'sort' => $this->buildSortFromOrderByClauses($builder), 136 | ])); 137 | } 138 | 139 | /** 140 | * Perform the given search on the engine. 141 | * 142 | * @return mixed 143 | */ 144 | protected function performSearch(Builder $builder, array $searchParams = []) 145 | { 146 | $meilisearch = $this->meilisearch->index($builder->index ?: $builder->model->searchableAs()); 147 | 148 | $searchParams = array_merge($builder->options, $searchParams); 149 | 150 | if (array_key_exists('attributesToRetrieve', $searchParams)) { 151 | $searchParams['attributesToRetrieve'] = array_merge( 152 | [$builder->model->getScoutKeyName()], 153 | $searchParams['attributesToRetrieve'], 154 | ); 155 | } 156 | 157 | if ($builder->callback) { 158 | $result = call_user_func( 159 | $builder->callback, 160 | $meilisearch, 161 | $builder->query, 162 | $searchParams 163 | ); 164 | 165 | $searchResultClass = class_exists(SearchResult::class) 166 | ? SearchResult::class 167 | : \Meilisearch\Search\SearchResult; 168 | 169 | return $result instanceof $searchResultClass ? $result->getRaw() : $result; 170 | } 171 | 172 | return $meilisearch->rawSearch($builder->query, $searchParams); 173 | } 174 | 175 | /** 176 | * Get the filter array for the query. 177 | * 178 | * @return string 179 | */ 180 | protected function filters(Builder $builder) 181 | { 182 | $filters = collect($builder->wheres) 183 | ->map(function ($value, $key) { 184 | if (is_bool($value)) { 185 | return sprintf('%s=%s', $key, $value ? 'true' : 'false'); 186 | } 187 | 188 | if (is_null($value)) { 189 | return sprintf('%s %s', $key, 'IS NULL'); 190 | } 191 | 192 | return is_numeric($value) 193 | ? sprintf('%s=%s', $key, $value) 194 | : sprintf('%s="%s"', $key, $value); 195 | }); 196 | 197 | $whereInOperators = [ 198 | 'whereIns' => 'IN', 199 | 'whereNotIns' => 'NOT IN', 200 | ]; 201 | 202 | foreach ($whereInOperators as $property => $operator) { 203 | if (property_exists($builder, $property)) { 204 | foreach ($builder->{$property} as $key => $values) { 205 | $filters->push(sprintf('%s %s [%s]', $key, $operator, collect($values)->map(function ($value) { 206 | if (is_bool($value)) { 207 | return sprintf('%s', $value ? 'true' : 'false'); 208 | } 209 | 210 | return filter_var($value, FILTER_VALIDATE_INT) !== false 211 | ? sprintf('%s', $value) 212 | : sprintf('"%s"', $value); 213 | })->values()->implode(', '))); 214 | } 215 | } 216 | } 217 | 218 | return $filters->values()->implode(' AND '); 219 | } 220 | 221 | /** 222 | * Get the sort array for the query. 223 | */ 224 | protected function buildSortFromOrderByClauses(Builder $builder): array 225 | { 226 | return collect($builder->orders) 227 | ->map(fn (array $order) => $order['column'].':'.$order['direction']) 228 | ->toArray(); 229 | } 230 | 231 | /** 232 | * Pluck and return the primary keys of the given results. 233 | * 234 | * This expects the first item of each search item array to be the primary key. 235 | * 236 | * @param mixed $results 237 | * @return \Illuminate\Support\Collection 238 | */ 239 | public function mapIds($results) 240 | { 241 | if (count($results['hits']) === 0) { 242 | return collect(); 243 | } 244 | 245 | $hits = collect($results['hits']); 246 | 247 | $key = key($hits->first()); 248 | 249 | return $hits->pluck($key)->values(); 250 | } 251 | 252 | /** 253 | * Pluck the given results with the given primary key name. 254 | * 255 | * @param mixed $results 256 | * @param string $key 257 | * @return \Illuminate\Support\Collection 258 | */ 259 | public function mapIdsFrom($results, $key) 260 | { 261 | return count($results['hits']) === 0 262 | ? collect() 263 | : collect($results['hits'])->pluck($key)->values(); 264 | } 265 | 266 | /** 267 | * Get the results of the query as a Collection of primary keys. 268 | * 269 | * @return \Illuminate\Support\Collection 270 | */ 271 | public function keys(Builder $builder) 272 | { 273 | $scoutKey = $builder->model->getScoutKeyName(); 274 | 275 | return $this->mapIdsFrom($this->search($builder), $scoutKey); 276 | } 277 | 278 | /** 279 | * Map the given results to instances of the given model. 280 | * 281 | * @param mixed $results 282 | * @param \Illuminate\Database\Eloquent\Model $model 283 | * @return \Illuminate\Database\Eloquent\Collection 284 | */ 285 | public function map(Builder $builder, $results, $model) 286 | { 287 | if (is_null($results) || count($results['hits']) === 0) { 288 | return $model->newCollection(); 289 | } 290 | 291 | $objectIds = collect($results['hits'])->pluck($model->getScoutKeyName())->values()->all(); 292 | 293 | $objectIdPositions = array_flip($objectIds); 294 | 295 | return $model->getScoutModelsByIds($builder, $objectIds) 296 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds)) 297 | ->map(function ($model) use ($results, $objectIdPositions) { 298 | $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? []; 299 | 300 | foreach ($result as $key => $value) { 301 | if (substr($key, 0, 1) === '_') { 302 | $model->withScoutMetadata($key, $value); 303 | } 304 | } 305 | 306 | return $model; 307 | }) 308 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()]) 309 | ->values(); 310 | } 311 | 312 | /** 313 | * Map the given results to instances of the given model via a lazy collection. 314 | * 315 | * @param mixed $results 316 | * @param \Illuminate\Database\Eloquent\Model $model 317 | * @return \Illuminate\Support\LazyCollection 318 | */ 319 | public function lazyMap(Builder $builder, $results, $model) 320 | { 321 | if (count($results['hits']) === 0) { 322 | return LazyCollection::make($model->newCollection()); 323 | } 324 | 325 | $objectIds = collect($results['hits'])->pluck($model->getScoutKeyName())->values()->all(); 326 | $objectIdPositions = array_flip($objectIds); 327 | 328 | return $model->queryScoutModelsByIds($builder, $objectIds) 329 | ->cursor() 330 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds)) 331 | ->map(function ($model) use ($results, $objectIdPositions) { 332 | $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? []; 333 | 334 | foreach ($result as $key => $value) { 335 | if (substr($key, 0, 1) === '_') { 336 | $model->withScoutMetadata($key, $value); 337 | } 338 | } 339 | 340 | return $model; 341 | }) 342 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()]) 343 | ->values(); 344 | } 345 | 346 | /** 347 | * Get the total count from a raw result returned by the engine. 348 | * 349 | * @param mixed $results 350 | * @return int 351 | */ 352 | public function getTotalCount($results) 353 | { 354 | return $results['totalHits'] ?? $results['estimatedTotalHits']; 355 | } 356 | 357 | /** 358 | * Flush all of the model's records from the engine. 359 | * 360 | * @param \Illuminate\Database\Eloquent\Model $model 361 | * @return void 362 | */ 363 | public function flush($model) 364 | { 365 | $index = $this->meilisearch->index($model->indexableAs()); 366 | 367 | $index->deleteAllDocuments(); 368 | } 369 | 370 | /** 371 | * Create a search index. 372 | * 373 | * @param string $name 374 | * @return mixed 375 | * 376 | * @throws \Meilisearch\Exceptions\ApiException 377 | */ 378 | public function createIndex($name, array $options = []) 379 | { 380 | try { 381 | $index = $this->meilisearch->getIndex($name); 382 | } catch (ApiException $e) { 383 | $index = null; 384 | } 385 | 386 | if ($index?->getUid() !== null) { 387 | return $index; 388 | } 389 | 390 | return $this->meilisearch->createIndex($name, $options); 391 | } 392 | 393 | /** 394 | * Update the index settings for the given index. 395 | * 396 | * @return void 397 | */ 398 | public function updateIndexSettings($name, array $settings = []) 399 | { 400 | $index = $this->meilisearch->index($name); 401 | 402 | $index->updateSettings(Arr::except($settings, 'embedders')); 403 | 404 | if (! empty($settings['embedders'])) { 405 | $index->updateEmbedders($settings['embedders']); 406 | } 407 | } 408 | 409 | /** 410 | * Configure the soft delete filter within the given settings. 411 | * 412 | * @return array 413 | */ 414 | public function configureSoftDeleteFilter(array $settings = []) 415 | { 416 | $settings['filterableAttributes'][] = '__soft_deleted'; 417 | 418 | return $settings; 419 | } 420 | 421 | /** 422 | * Delete a search index. 423 | * 424 | * @param string $name 425 | * @return mixed 426 | * 427 | * @throws \Meilisearch\Exceptions\ApiException 428 | */ 429 | public function deleteIndex($name) 430 | { 431 | return $this->meilisearch->deleteIndex($name); 432 | } 433 | 434 | /** 435 | * Delete all search indexes. 436 | * 437 | * @return mixed 438 | */ 439 | public function deleteAllIndexes() 440 | { 441 | $tasks = []; 442 | $limit = 1000000; 443 | 444 | $query = new IndexesQuery; 445 | $query->setLimit($limit); 446 | 447 | $indexes = $this->meilisearch->getIndexes($query); 448 | 449 | foreach ($indexes->getResults() as $index) { 450 | $tasks[] = $index->delete(); 451 | } 452 | 453 | return $tasks; 454 | } 455 | 456 | /** 457 | * Determine if the given model uses soft deletes. 458 | * 459 | * @param \Illuminate\Database\Eloquent\Model $model 460 | * @return bool 461 | */ 462 | protected function usesSoftDelete($model) 463 | { 464 | return in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses_recursive($model)); 465 | } 466 | 467 | /** 468 | * Dynamically call the Meilisearch client instance. 469 | * 470 | * @param string $method 471 | * @param array $parameters 472 | * @return mixed 473 | */ 474 | public function __call($method, $parameters) 475 | { 476 | return $this->meilisearch->$method(...$parameters); 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /src/Engines/NullEngine.php: -------------------------------------------------------------------------------- 1 | typesense = $typesense; 57 | $this->maxTotalResults = $maxTotalResults; 58 | } 59 | 60 | /** 61 | * Update the given model in the index. 62 | * 63 | * @param \Illuminate\Database\Eloquent\Collection|Model[] $models 64 | * 65 | * @throws \Http\Client\Exception 66 | * @throws \JsonException 67 | * @throws \Typesense\Exceptions\TypesenseClientError 68 | * 69 | * @noinspection NotOptimalIfConditionsInspection 70 | */ 71 | public function update($models) 72 | { 73 | if ($models->isEmpty()) { 74 | return; 75 | } 76 | 77 | $collection = $this->getOrCreateCollectionFromModel($models->first()); 78 | 79 | if ($this->usesSoftDelete($models->first()) && config('scout.soft_delete', false)) { 80 | $models->each->pushSoftDeleteMetadata(); 81 | } 82 | 83 | $objects = $models->map(function ($model) { 84 | if (empty($searchableData = $model->toSearchableArray())) { 85 | return null; 86 | } 87 | 88 | return array_merge( 89 | $searchableData, 90 | $model->scoutMetadata(), 91 | ); 92 | }) 93 | ->filter() 94 | ->values() 95 | ->all(); 96 | 97 | if (! empty($objects)) { 98 | $this->importDocuments( 99 | $collection, 100 | $objects 101 | ); 102 | } 103 | } 104 | 105 | /** 106 | * Import the given documents into the index. 107 | * 108 | * @param TypesenseCollection $collectionIndex 109 | * @param array $documents 110 | * @param string $action 111 | * @return \Illuminate\Support\Collection 112 | * 113 | * @throws \JsonException 114 | * @throws \Typesense\Exceptions\TypesenseClientError 115 | * @throws \Http\Client\Exception 116 | */ 117 | protected function importDocuments(TypesenseCollection $collectionIndex, array $documents, string $action = 'emplace'): Collection 118 | { 119 | $importedDocuments = $collectionIndex->getDocuments()->import($documents, ['action' => $action]); 120 | 121 | $results = []; 122 | 123 | foreach ($importedDocuments as $importedDocument) { 124 | if (! $importedDocument['success']) { 125 | throw new TypesenseClientError("Error importing document: {$importedDocument['error']}"); 126 | } 127 | 128 | $results[] = $this->createImportSortingDataObject( 129 | $importedDocument 130 | ); 131 | } 132 | 133 | return collect($results); 134 | } 135 | 136 | /** 137 | * Create an import sorting data object for a given document. 138 | * 139 | * @param array $document 140 | * @return \stdClass 141 | * 142 | * @throws \JsonException 143 | */ 144 | protected function createImportSortingDataObject($document) 145 | { 146 | $data = new stdClass; 147 | 148 | $data->code = $document['code'] ?? 0; 149 | $data->success = $document['success']; 150 | $data->error = $document['error'] ?? null; 151 | $data->document = json_decode($document['document'] ?? '[]', true, 512, JSON_THROW_ON_ERROR); 152 | 153 | return $data; 154 | } 155 | 156 | /** 157 | * Remove the given model from the index. 158 | * 159 | * @param \Illuminate\Database\Eloquent\Collection $models 160 | * @return void 161 | * 162 | * @throws \Http\Client\Exception 163 | * @throws \Typesense\Exceptions\TypesenseClientError 164 | */ 165 | public function delete($models) 166 | { 167 | $models->each(function (Model $model) { 168 | $this->deleteDocument( 169 | $this->getOrCreateCollectionFromModel($model), 170 | $model->getScoutKey() 171 | ); 172 | }); 173 | } 174 | 175 | /** 176 | * Delete a document from the index. 177 | * 178 | * @param TypesenseCollection $collectionIndex 179 | * @param mixed $modelId 180 | * @return array 181 | * 182 | * @throws \Typesense\Exceptions\ObjectNotFound 183 | * @throws \Typesense\Exceptions\TypesenseClientError 184 | * @throws \Http\Client\Exception 185 | */ 186 | protected function deleteDocument(TypesenseCollection $collectionIndex, $modelId): array 187 | { 188 | $document = $collectionIndex->getDocuments()[(string) $modelId]; 189 | 190 | try { 191 | $document->retrieve(); 192 | 193 | return $document->delete(); 194 | } catch (Exception $exception) { 195 | return []; 196 | } 197 | } 198 | 199 | /** 200 | * Perform the given search on the engine. 201 | * 202 | * @param \Laravel\Scout\Builder $builder 203 | * @return mixed 204 | * 205 | * @throws \Http\Client\Exception 206 | * @throws \Typesense\Exceptions\TypesenseClientError 207 | */ 208 | public function search(Builder $builder) 209 | { 210 | // If the limit exceeds Typesense's capabilities, perform a paginated search... 211 | if ($builder->limit >= $this->maxPerPage) { 212 | return $this->performPaginatedSearch($builder); 213 | } 214 | 215 | return $this->performSearch( 216 | $builder, 217 | $this->buildSearchParameters($builder, 1, $builder->limit ?? $this->maxPerPage) 218 | ); 219 | } 220 | 221 | /** 222 | * Perform the given search on the engine with pagination. 223 | * 224 | * @param \Laravel\Scout\Builder $builder 225 | * @param int $perPage 226 | * @param int $page 227 | * @return mixed 228 | * 229 | * @throws \Http\Client\Exception 230 | * @throws \Typesense\Exceptions\TypesenseClientError 231 | */ 232 | public function paginate(Builder $builder, $perPage, $page) 233 | { 234 | $maxInt = 4294967295; 235 | 236 | $page = max(1, (int) $page); 237 | $perPage = max(1, (int) $perPage); 238 | 239 | if ($page * $perPage > $maxInt) { 240 | $page = floor($maxInt / $perPage); 241 | } 242 | 243 | return $this->performSearch( 244 | $builder, 245 | $this->buildSearchParameters($builder, $page, $perPage) 246 | ); 247 | } 248 | 249 | /** 250 | * Perform the given search on the engine. 251 | * 252 | * @param \Laravel\Scout\Builder $builder 253 | * @param array $options 254 | * @return mixed 255 | * 256 | * @throws \Http\Client\Exception 257 | * @throws \Typesense\Exceptions\TypesenseClientError 258 | */ 259 | protected function performSearch(Builder $builder, array $options = []): mixed 260 | { 261 | $documents = $this->getOrCreateCollectionFromModel( 262 | $builder->model, 263 | $builder->index, 264 | false, 265 | )->getDocuments(); 266 | 267 | if ($builder->callback) { 268 | return call_user_func($builder->callback, $documents, $builder->query, $options); 269 | } 270 | 271 | try { 272 | return $documents->search($options); 273 | } catch (ObjectNotFound) { 274 | $this->getOrCreateCollectionFromModel($builder->model, $builder->index, true); 275 | 276 | return $documents->search($options); 277 | } 278 | } 279 | 280 | /** 281 | * Perform a paginated search on the engine. 282 | * 283 | * @param \Laravel\Scout\Builder $builder 284 | * @return mixed 285 | * 286 | * @throws \Http\Client\Exception 287 | * @throws \Typesense\Exceptions\TypesenseClientError 288 | */ 289 | protected function performPaginatedSearch(Builder $builder) 290 | { 291 | $page = 1; 292 | $limit = min($builder->limit ?? $this->maxPerPage, $this->maxPerPage, $this->maxTotalResults); 293 | $remainingResults = min($builder->limit ?? $this->maxTotalResults, $this->maxTotalResults); 294 | 295 | $results = new Collection; 296 | 297 | while ($remainingResults > 0) { 298 | $searchResults = $this->performSearch( 299 | $builder, 300 | $this->buildSearchParameters($builder, $page, $limit) 301 | ); 302 | 303 | $results = $results->concat($searchResults['hits'] ?? []); 304 | 305 | if ($page === 1) { 306 | $totalFound = $searchResults['found'] ?? 0; 307 | } 308 | 309 | $remainingResults -= $limit; 310 | $page++; 311 | 312 | if (count($searchResults['hits'] ?? []) < $limit) { 313 | break; 314 | } 315 | } 316 | 317 | return [ 318 | 'hits' => $results->all(), 319 | 'found' => $results->count(), 320 | 'out_of' => $totalFound, 321 | 'page' => 1, 322 | 'request_params' => $this->buildSearchParameters($builder, 1, $builder->limit ?? $this->maxPerPage), 323 | ]; 324 | } 325 | 326 | /** 327 | * Build the search parameters for a given Scout query builder. 328 | * 329 | * @param \Laravel\Scout\Builder $builder 330 | * @param int $page 331 | * @param int|null $perPage 332 | * @return array 333 | */ 334 | public function buildSearchParameters(Builder $builder, int $page, ?int $perPage): array 335 | { 336 | $parameters = [ 337 | 'q' => $builder->query, 338 | 'query_by' => config('scout.typesense.model-settings.'.get_class($builder->model).'.search-parameters.query_by') ?? '', 339 | 'filter_by' => $this->filters($builder), 340 | 'per_page' => $perPage, 341 | 'page' => $page, 342 | 'highlight_start_tag' => '', 343 | 'highlight_end_tag' => '', 344 | 'snippet_threshold' => 30, 345 | 'exhaustive_search' => false, 346 | 'use_cache' => false, 347 | 'cache_ttl' => 60, 348 | 'prioritize_exact_match' => true, 349 | 'enable_overrides' => true, 350 | 'highlight_affix_num_tokens' => 4, 351 | 'prefix' => config('scout.typesense.model-settings.'.get_class($builder->model).'.search-parameters.prefix') ?? true, 352 | ]; 353 | 354 | if (method_exists($builder->model, 'typesenseSearchParameters')) { 355 | $parameters = array_merge($parameters, $builder->model->typesenseSearchParameters()); 356 | } 357 | 358 | if (! empty($builder->options)) { 359 | $parameters = array_merge($parameters, $builder->options); 360 | } 361 | 362 | if (! empty($builder->orders)) { 363 | if (! empty($parameters['sort_by'])) { 364 | $parameters['sort_by'] .= ','; 365 | } else { 366 | $parameters['sort_by'] = ''; 367 | } 368 | 369 | $parameters['sort_by'] .= $this->parseOrderBy($builder->orders); 370 | } 371 | 372 | return $parameters; 373 | } 374 | 375 | /** 376 | * Prepare the filters for a given search query. 377 | * 378 | * @param \Laravel\Scout\Builder $builder 379 | * @return string 380 | */ 381 | protected function filters(Builder $builder): string 382 | { 383 | $whereFilter = collect($builder->wheres) 384 | ->map(fn ($value, $key) => $this->parseWhereFilter($this->parseFilterValue($value), $key)) 385 | ->values() 386 | ->implode(' && '); 387 | 388 | $whereInFilter = collect($builder->whereIns) 389 | ->map(fn ($value, $key) => $this->parseWhereInFilter($this->parseFilterValue($value), $key)) 390 | ->values() 391 | ->implode(' && '); 392 | 393 | $whereNotInFilter = collect($builder->whereNotIns) 394 | ->map(fn ($value, $key) => $this->parseWhereNotInFilter($this->parseFilterValue($value), $key)) 395 | ->values() 396 | ->implode(' && '); 397 | 398 | $filters = collect([$whereFilter, $whereInFilter, $whereNotInFilter]) 399 | ->filter() 400 | ->implode(' && '); 401 | 402 | return $filters; 403 | } 404 | 405 | /** 406 | * Parse the given filter value. 407 | * 408 | * @param array|string|bool|int|float $value 409 | * @return array|bool|float|int|string 410 | */ 411 | protected function parseFilterValue(array|string|bool|int|float $value) 412 | { 413 | if (is_array($value)) { 414 | return array_map([$this, 'parseFilterValue'], $value); 415 | } 416 | 417 | if (gettype($value) == 'boolean') { 418 | return $value ? 'true' : 'false'; 419 | } 420 | 421 | return $value; 422 | } 423 | 424 | /** 425 | * Create a "where" filter string. 426 | * 427 | * @param array|string $value 428 | * @param string $key 429 | * @return string 430 | */ 431 | protected function parseWhereFilter(array|string $value, string $key): string 432 | { 433 | return is_array($value) 434 | ? sprintf('%s:%s', $key, implode('', $value)) 435 | : sprintf('%s:=%s', $key, $value); 436 | } 437 | 438 | /** 439 | * Create a "where in" filter string. 440 | * 441 | * @param array $value 442 | * @param string $key 443 | * @return string 444 | */ 445 | protected function parseWhereInFilter(array $value, string $key): string 446 | { 447 | return sprintf('%s:=[%s]', $key, implode(', ', $value)); 448 | } 449 | 450 | /** 451 | * Create a "where not in" filter string. 452 | * 453 | * @param array|string $value 454 | * @param string $key 455 | * @return string 456 | */ 457 | protected function parseWhereNotInFilter(array $value, string $key): string 458 | { 459 | return sprintf('%s:!=[%s]', $key, implode(', ', $value)); 460 | } 461 | 462 | /** 463 | * Parse the order by fields for the query. 464 | * 465 | * @param array $orders 466 | * @return string 467 | */ 468 | protected function parseOrderBy(array $orders): string 469 | { 470 | $orderBy = []; 471 | 472 | foreach ($orders as $order) { 473 | $orderBy[] = $order['column'].':'.$order['direction']; 474 | } 475 | 476 | return implode(',', $orderBy); 477 | } 478 | 479 | /** 480 | * Pluck and return the primary keys of the given results. 481 | * 482 | * @param mixed $results 483 | * @return \Illuminate\Support\Collection 484 | */ 485 | public function mapIds($results) 486 | { 487 | return collect($results['hits']) 488 | ->pluck('document.id') 489 | ->values(); 490 | } 491 | 492 | /** 493 | * Map the given results to instances of the given model. 494 | * 495 | * @param \Laravel\Scout\Builder $builder 496 | * @param mixed $results 497 | * @param \Illuminate\Database\Eloquent\Model $model 498 | * @return \Illuminate\Database\Eloquent\Collection 499 | */ 500 | public function map(Builder $builder, $results, $model) 501 | { 502 | if ($this->getTotalCount($results) === 0) { 503 | return $model->newCollection(); 504 | } 505 | 506 | $hits = isset($results['grouped_hits']) && ! empty($results['grouped_hits']) 507 | ? $results['grouped_hits'] 508 | : $results['hits']; 509 | 510 | $pluck = isset($results['grouped_hits']) && ! empty($results['grouped_hits']) 511 | ? 'hits.0.document.id' 512 | : 'document.id'; 513 | 514 | $objectIds = collect($hits) 515 | ->pluck($pluck) 516 | ->values() 517 | ->all(); 518 | 519 | $objectIdPositions = array_flip($objectIds); 520 | 521 | return $model->getScoutModelsByIds($builder, $objectIds) 522 | ->filter(static fn ($model) => in_array($model->getScoutKey(), $objectIds, false)) 523 | ->sortBy(static fn ($model) => $objectIdPositions[$model->getScoutKey()]) 524 | ->values(); 525 | } 526 | 527 | /** 528 | * Map the given results to instances of the given model via a lazy collection. 529 | * 530 | * @param \Laravel\Scout\Builder $builder 531 | * @param mixed $results 532 | * @param \Illuminate\Database\Eloquent\Model $model 533 | * @return \Illuminate\Support\LazyCollection 534 | */ 535 | public function lazyMap(Builder $builder, $results, $model) 536 | { 537 | if ((int) ($results['found'] ?? 0) === 0) { 538 | return LazyCollection::make($model->newCollection()); 539 | } 540 | 541 | $objectIds = collect($results['hits']) 542 | ->pluck('document.id') 543 | ->values() 544 | ->all(); 545 | 546 | $objectIdPositions = array_flip($objectIds); 547 | 548 | return $model->queryScoutModelsByIds($builder, $objectIds) 549 | ->cursor() 550 | ->filter(static fn ($model) => in_array($model->getScoutKey(), $objectIds, false)) 551 | ->sortBy(static fn ($model) => $objectIdPositions[$model->getScoutKey()]) 552 | ->values(); 553 | } 554 | 555 | /** 556 | * Get the total count from a raw result returned by the engine. 557 | * 558 | * @param mixed $results 559 | * @return int 560 | */ 561 | public function getTotalCount($results) 562 | { 563 | return (int) ($results['found'] ?? 0); 564 | } 565 | 566 | /** 567 | * Flush all the model's records from the engine. 568 | * 569 | * @param \Illuminate\Database\Eloquent\Model $model 570 | * 571 | * @throws \Http\Client\Exception 572 | * @throws \Typesense\Exceptions\TypesenseClientError 573 | */ 574 | public function flush($model) 575 | { 576 | $this->getOrCreateCollectionFromModel($model)->delete(); 577 | } 578 | 579 | /** 580 | * Create a search index. 581 | * 582 | * @param string $name 583 | * @param array $options 584 | * @return void 585 | * 586 | * @throws NotSupportedException 587 | */ 588 | public function createIndex($name, array $options = []) 589 | { 590 | throw new NotSupportedException('Typesense indexes are created automatically upon adding objects.'); 591 | } 592 | 593 | /** 594 | * Delete a search index. 595 | * 596 | * @param string $name 597 | * @return array 598 | * 599 | * @throws \Typesense\Exceptions\TypesenseClientError 600 | * @throws \Http\Client\Exception 601 | * @throws \Typesense\Exceptions\ObjectNotFound 602 | */ 603 | public function deleteIndex($name) 604 | { 605 | return $this->typesense->getCollections()->{$name}->delete(); 606 | } 607 | 608 | /** 609 | * Get collection from model or create new one. 610 | * 611 | * @param \Illuminate\Database\Eloquent\Model $model 612 | * @return \Typesense\Collection 613 | * 614 | * @throws \Typesense\Exceptions\TypesenseClientError 615 | * @throws \Http\Client\Exception 616 | */ 617 | protected function getOrCreateCollectionFromModel($model, ?string $collectionName = null, bool $indexOperation = true): TypesenseCollection 618 | { 619 | if (! $indexOperation) { 620 | $collectionName = $collectionName ?? $model->searchableAs(); 621 | } else { 622 | $collectionName = $model->indexableAs(); 623 | } 624 | 625 | $collection = $this->typesense->getCollections()->{$collectionName}; 626 | 627 | if (! $indexOperation) { 628 | return $collection; 629 | } 630 | 631 | // Determine if the collection exists in Typesense... 632 | try { 633 | $collection->retrieve(); 634 | 635 | // No error means this collection exists on the server... 636 | $collection->setExists(true); 637 | 638 | return $collection; 639 | } catch (TypesenseClientError $e) { 640 | // 641 | } 642 | 643 | $schema = config('scout.typesense.model-settings.'.get_class($model).'.collection-schema') ?? []; 644 | 645 | if (method_exists($model, 'typesenseCollectionSchema')) { 646 | $schema = $model->typesenseCollectionSchema(); 647 | } 648 | 649 | if (! isset($schema['name'])) { 650 | $schema['name'] = $model->searchableAs(); 651 | } 652 | 653 | try { 654 | // Create the collection in Typesense... 655 | $this->typesense->getCollections()->create($schema); 656 | } catch (ObjectAlreadyExists $e) { 657 | // Collection already exists... 658 | } 659 | 660 | $collection->setExists(true); 661 | 662 | return $collection; 663 | } 664 | 665 | /** 666 | * Determine if model uses soft deletes. 667 | * 668 | * @param \Illuminate\Database\Eloquent\Model $model 669 | * @return bool 670 | */ 671 | protected function usesSoftDelete($model): bool 672 | { 673 | return in_array(SoftDeletes::class, class_uses_recursive($model), true); 674 | } 675 | 676 | /** 677 | * Dynamically proxy missing methods to the Typesense client instance. 678 | * 679 | * @param string $method 680 | * @param array $parameters 681 | * @return mixed 682 | */ 683 | public function __call($method, $parameters) 684 | { 685 | return $this->typesense->$method(...$parameters); 686 | } 687 | } 688 | -------------------------------------------------------------------------------- /src/Events/ModelsFlushed.php: -------------------------------------------------------------------------------- 1 | models = $models; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Events/ModelsImported.php: -------------------------------------------------------------------------------- 1 | models = $models; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exceptions/NotSupportedException.php: -------------------------------------------------------------------------------- 1 | class = $class; 45 | $this->start = $start; 46 | $this->end = $end; 47 | } 48 | 49 | /** 50 | * Handle the job. 51 | * 52 | * @return void 53 | */ 54 | public function handle() 55 | { 56 | $model = new $this->class; 57 | 58 | $models = $model::makeAllSearchableQuery() 59 | ->whereBetween($model->getScoutKeyName(), [$this->start, $this->end]) 60 | ->get() 61 | ->filter 62 | ->shouldBeSearchable(); 63 | 64 | if ($models->isEmpty()) { 65 | return; 66 | } 67 | 68 | dispatch(new Scout::$makeSearchableJob($models)) 69 | ->onQueue($model->syncWithSearchUsingQueue()) 70 | ->onConnection($model->syncWithSearchUsing()); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Jobs/MakeSearchable.php: -------------------------------------------------------------------------------- 1 | models = $models; 29 | } 30 | 31 | /** 32 | * Handle the job. 33 | * 34 | * @return void 35 | */ 36 | public function handle() 37 | { 38 | if ($this->models->isEmpty()) { 39 | return; 40 | } 41 | 42 | $this->models->first()->makeSearchableUsing($this->models)->first()->searchableUsing()->update($this->models); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Jobs/RemoveFromSearch.php: -------------------------------------------------------------------------------- 1 | models = RemoveableScoutCollection::make($models); 29 | } 30 | 31 | /** 32 | * Handle the job. 33 | * 34 | * @return void 35 | */ 36 | public function handle() 37 | { 38 | if ($this->models->isNotEmpty()) { 39 | $this->models->first()->searchableUsing()->delete($this->models); 40 | } 41 | } 42 | 43 | /** 44 | * Restore a queueable collection instance. 45 | * 46 | * @param \Illuminate\Contracts\Database\ModelIdentifier $value 47 | * @return \Laravel\Scout\Jobs\RemoveableScoutCollection 48 | */ 49 | protected function restoreCollection($value) 50 | { 51 | if (! $value->class || count($value->id) === 0) { 52 | return new RemoveableScoutCollection; 53 | } 54 | 55 | return new RemoveableScoutCollection( 56 | collect($value->id)->map(function ($id) use ($value) { 57 | return tap(new $value->class, function ($model) use ($id) { 58 | $model->setKeyType( 59 | is_string($id) ? 'string' : 'int' 60 | )->forceFill([ 61 | $model->getScoutKeyName() => $id, 62 | ]); 63 | }); 64 | }) 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Jobs/RemoveableScoutCollection.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 18 | return []; 19 | } 20 | 21 | return in_array(Searchable::class, class_uses_recursive($this->first())) 22 | ? $this->map->getScoutKey()->all() 23 | : parent::getQueueableIds(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ModelObserver.php: -------------------------------------------------------------------------------- 1 | afterCommit = Config::get('scout.after_commit', false); 47 | $this->usingSoftDeletes = Config::get('scout.soft_delete', false); 48 | } 49 | 50 | /** 51 | * Enable syncing for the given class. 52 | * 53 | * @param string $class 54 | * @return void 55 | */ 56 | public static function enableSyncingFor($class) 57 | { 58 | unset(static::$syncingDisabledFor[$class]); 59 | } 60 | 61 | /** 62 | * Disable syncing for the given class. 63 | * 64 | * @param string $class 65 | * @return void 66 | */ 67 | public static function disableSyncingFor($class) 68 | { 69 | static::$syncingDisabledFor[$class] = true; 70 | } 71 | 72 | /** 73 | * Determine if syncing is disabled for the given class or model. 74 | * 75 | * @param object|string $class 76 | * @return bool 77 | */ 78 | public static function syncingDisabledFor($class) 79 | { 80 | $class = is_object($class) ? get_class($class) : $class; 81 | 82 | return isset(static::$syncingDisabledFor[$class]); 83 | } 84 | 85 | /** 86 | * Handle the saved event for the model. 87 | * 88 | * @param \Illuminate\Database\Eloquent\Model $model 89 | * @return void 90 | */ 91 | public function saved($model) 92 | { 93 | if (static::syncingDisabledFor($model)) { 94 | return; 95 | } 96 | 97 | if (! $this->forceSaving && ! $model->searchIndexShouldBeUpdated()) { 98 | return; 99 | } 100 | 101 | if (! $model->shouldBeSearchable()) { 102 | if ($model->wasSearchableBeforeUpdate()) { 103 | $model->unsearchable(); 104 | } 105 | 106 | return; 107 | } 108 | 109 | $model->searchable(); 110 | } 111 | 112 | /** 113 | * Handle the deleted event for the model. 114 | * 115 | * @param \Illuminate\Database\Eloquent\Model $model 116 | * @return void 117 | */ 118 | public function deleted($model) 119 | { 120 | if (static::syncingDisabledFor($model)) { 121 | return; 122 | } 123 | 124 | if (! $model->wasSearchableBeforeDelete()) { 125 | return; 126 | } 127 | 128 | if ($this->usingSoftDeletes && $this->usesSoftDelete($model)) { 129 | $this->whileForcingUpdate(function () use ($model) { 130 | $this->saved($model); 131 | }); 132 | } else { 133 | $model->unsearchable(); 134 | } 135 | } 136 | 137 | /** 138 | * Handle the force deleted event for the model. 139 | * 140 | * @param \Illuminate\Database\Eloquent\Model $model 141 | * @return void 142 | */ 143 | public function forceDeleted($model) 144 | { 145 | if (static::syncingDisabledFor($model)) { 146 | return; 147 | } 148 | 149 | $model->unsearchable(); 150 | } 151 | 152 | /** 153 | * Handle the restored event for the model. 154 | * 155 | * @param \Illuminate\Database\Eloquent\Model $model 156 | * @return void 157 | */ 158 | public function restored($model) 159 | { 160 | $this->whileForcingUpdate(function () use ($model) { 161 | $this->saved($model); 162 | }); 163 | } 164 | 165 | /** 166 | * Execute the given callback while forcing updates. 167 | * 168 | * @param \Closure $callback 169 | * @return mixed 170 | */ 171 | protected function whileForcingUpdate(Closure $callback) 172 | { 173 | $this->forceSaving = true; 174 | 175 | $result = $callback(); 176 | 177 | $this->forceSaving = false; 178 | 179 | return $result; 180 | } 181 | 182 | /** 183 | * Determine if the given model uses soft deletes. 184 | * 185 | * @param \Illuminate\Database\Eloquent\Model $model 186 | * @return bool 187 | */ 188 | protected function usesSoftDelete($model) 189 | { 190 | return in_array(SoftDeletes::class, class_uses_recursive($model)); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/Scout.php: -------------------------------------------------------------------------------- 1 | engine($engine); 38 | } 39 | 40 | /** 41 | * Specify the job class that should make models searchable. 42 | * 43 | * @param string $class 44 | * @return void 45 | */ 46 | public static function makeSearchableUsing(string $class) 47 | { 48 | static::$makeSearchableJob = $class; 49 | } 50 | 51 | /** 52 | * Specify the job class that should remove models from the search index. 53 | * 54 | * @param string $class 55 | * @return void 56 | */ 57 | public static function removeFromSearchUsing(string $class) 58 | { 59 | static::$removeFromSearchJob = $class; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ScoutServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/scout.php', 'scout'); 25 | 26 | if (class_exists(Meilisearch::class)) { 27 | $this->app->singleton(Meilisearch::class, function ($app) { 28 | $config = $app['config']->get('scout.meilisearch'); 29 | 30 | return new Meilisearch( 31 | $config['host'], 32 | $config['key'], 33 | clientAgents: [sprintf('Meilisearch Laravel Scout (v%s)', Scout::VERSION)], 34 | ); 35 | }); 36 | } 37 | 38 | $this->app->singleton(EngineManager::class, function ($app) { 39 | return new EngineManager($app); 40 | }); 41 | } 42 | 43 | /** 44 | * Bootstrap any application services. 45 | * 46 | * @return void 47 | */ 48 | public function boot() 49 | { 50 | if ($this->app->runningInConsole()) { 51 | $this->commands([ 52 | QueueImportCommand::class, 53 | FlushCommand::class, 54 | ImportCommand::class, 55 | IndexCommand::class, 56 | SyncIndexSettingsCommand::class, 57 | DeleteIndexCommand::class, 58 | DeleteAllIndexesCommand::class, 59 | ]); 60 | 61 | $this->publishes([ 62 | __DIR__.'/../config/scout.php' => $this->app['path.config'].DIRECTORY_SEPARATOR.'scout.php', 63 | ]); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Searchable.php: -------------------------------------------------------------------------------- 1 | registerSearchableMacros(); 30 | } 31 | 32 | /** 33 | * Register the searchable macros. 34 | * 35 | * @return void 36 | */ 37 | public function registerSearchableMacros() 38 | { 39 | $self = $this; 40 | 41 | BaseCollection::macro('searchable', function () use ($self) { 42 | $self->queueMakeSearchable($this); 43 | }); 44 | 45 | BaseCollection::macro('unsearchable', function () use ($self) { 46 | $self->queueRemoveFromSearch($this); 47 | }); 48 | 49 | BaseCollection::macro('searchableSync', function () use ($self) { 50 | $self->syncMakeSearchable($this); 51 | }); 52 | 53 | BaseCollection::macro('unsearchableSync', function () use ($self) { 54 | $self->syncRemoveFromSearch($this); 55 | }); 56 | } 57 | 58 | /** 59 | * Dispatch the job to make the given models searchable. 60 | * 61 | * @param \Illuminate\Database\Eloquent\Collection $models 62 | * @return void 63 | */ 64 | public function queueMakeSearchable($models) 65 | { 66 | if ($models->isEmpty()) { 67 | return; 68 | } 69 | 70 | if (! config('scout.queue')) { 71 | return $this->syncMakeSearchable($models); 72 | } 73 | 74 | dispatch((new Scout::$makeSearchableJob($models)) 75 | ->onQueue($models->first()->syncWithSearchUsingQueue()) 76 | ->onConnection($models->first()->syncWithSearchUsing())); 77 | } 78 | 79 | /** 80 | * Synchronously make the given models searchable. 81 | * 82 | * @param \Illuminate\Database\Eloquent\Collection $models 83 | * @return void 84 | */ 85 | public function syncMakeSearchable($models) 86 | { 87 | if ($models->isEmpty()) { 88 | return; 89 | } 90 | 91 | return $models->first()->makeSearchableUsing($models)->first()->searchableUsing()->update($models); 92 | } 93 | 94 | /** 95 | * Dispatch the job to make the given models unsearchable. 96 | * 97 | * @param \Illuminate\Database\Eloquent\Collection $models 98 | * @return void 99 | */ 100 | public function queueRemoveFromSearch($models) 101 | { 102 | if ($models->isEmpty()) { 103 | return; 104 | } 105 | 106 | if (! config('scout.queue')) { 107 | return $this->syncRemoveFromSearch($models); 108 | } 109 | 110 | dispatch(new Scout::$removeFromSearchJob($models)) 111 | ->onQueue($models->first()->syncWithSearchUsingQueue()) 112 | ->onConnection($models->first()->syncWithSearchUsing()); 113 | } 114 | 115 | /** 116 | * Synchronously make the given models unsearchable. 117 | * 118 | * @param \Illuminate\Database\Eloquent\Collection $models 119 | * @return void 120 | */ 121 | public function syncRemoveFromSearch($models) 122 | { 123 | if ($models->isEmpty()) { 124 | return; 125 | } 126 | 127 | return $models->first()->searchableUsing()->delete($models); 128 | } 129 | 130 | /** 131 | * Determine if the model should be searchable. 132 | * 133 | * @return bool 134 | */ 135 | public function shouldBeSearchable() 136 | { 137 | return true; 138 | } 139 | 140 | /** 141 | * When updating a model, this method determines if we should update the search index. 142 | * 143 | * @return bool 144 | */ 145 | public function searchIndexShouldBeUpdated() 146 | { 147 | return true; 148 | } 149 | 150 | /** 151 | * Perform a search against the model's indexed data. 152 | * 153 | * @param string $query 154 | * @param \Closure $callback 155 | * @return \Laravel\Scout\Builder 156 | */ 157 | public static function search($query = '', $callback = null) 158 | { 159 | return app(static::$scoutBuilder ?? Builder::class, [ 160 | 'model' => new static, 161 | 'query' => $query, 162 | 'callback' => $callback, 163 | 'softDelete' => static::usesSoftDelete() && config('scout.soft_delete', false), 164 | ]); 165 | } 166 | 167 | /** 168 | * Make all instances of the model searchable. 169 | * 170 | * @param int $chunk 171 | * @return void 172 | */ 173 | public static function makeAllSearchable($chunk = null) 174 | { 175 | static::makeAllSearchableQuery()->searchable($chunk); 176 | } 177 | 178 | /** 179 | * Get a query builder for making all instances of the model searchable. 180 | * 181 | * @return \Illuminate\Database\Eloquent\Builder 182 | */ 183 | public static function makeAllSearchableQuery() 184 | { 185 | $self = new static; 186 | 187 | $softDelete = static::usesSoftDelete() && config('scout.soft_delete', false); 188 | 189 | return $self->newQuery() 190 | ->when(true, function ($query) use ($self) { 191 | $self->makeAllSearchableUsing($query); 192 | }) 193 | ->when($softDelete, function ($query) { 194 | $query->withTrashed(); 195 | }) 196 | ->orderBy( 197 | $self->qualifyColumn($self->getScoutKeyName()) 198 | ); 199 | } 200 | 201 | /** 202 | * Modify the collection of models being made searchable. 203 | * 204 | * @param \Illuminate\Support\Collection $models 205 | * @return \Illuminate\Support\Collection 206 | */ 207 | public function makeSearchableUsing(BaseCollection $models) 208 | { 209 | return $models; 210 | } 211 | 212 | /** 213 | * Modify the query used to retrieve models when making all of the models searchable. 214 | * 215 | * @param \Illuminate\Database\Eloquent\Builder $query 216 | * @return \Illuminate\Database\Eloquent\Builder 217 | */ 218 | protected function makeAllSearchableUsing(EloquentBuilder $query) 219 | { 220 | return $query; 221 | } 222 | 223 | /** 224 | * Make the given model instance searchable. 225 | * 226 | * @return void 227 | */ 228 | public function searchable() 229 | { 230 | $this->newCollection([$this])->searchable(); 231 | } 232 | 233 | /** 234 | * Synchronously make the given model instance searchable. 235 | * 236 | * @return void 237 | */ 238 | public function searchableSync() 239 | { 240 | $this->newCollection([$this])->searchableSync(); 241 | } 242 | 243 | /** 244 | * Remove all instances of the model from the search index. 245 | * 246 | * @return void 247 | */ 248 | public static function removeAllFromSearch() 249 | { 250 | $self = new static; 251 | 252 | $self->searchableUsing()->flush($self); 253 | } 254 | 255 | /** 256 | * Remove the given model instance from the search index. 257 | * 258 | * @return void 259 | */ 260 | public function unsearchable() 261 | { 262 | $this->newCollection([$this])->unsearchable(); 263 | } 264 | 265 | /** 266 | * Synchronously remove the given model instance from the search index. 267 | * 268 | * @return void 269 | */ 270 | public function unsearchableSync() 271 | { 272 | $this->newCollection([$this])->unsearchableSync(); 273 | } 274 | 275 | /** 276 | * Determine if the model existed in the search index prior to an update. 277 | * 278 | * @return bool 279 | */ 280 | public function wasSearchableBeforeUpdate() 281 | { 282 | return true; 283 | } 284 | 285 | /** 286 | * Determine if the model existed in the search index prior to deletion. 287 | * 288 | * @return bool 289 | */ 290 | public function wasSearchableBeforeDelete() 291 | { 292 | return true; 293 | } 294 | 295 | /** 296 | * Get the requested models from an array of object IDs. 297 | * 298 | * @param \Laravel\Scout\Builder $builder 299 | * @param array $ids 300 | * @return mixed 301 | */ 302 | public function getScoutModelsByIds(Builder $builder, array $ids) 303 | { 304 | return $this->queryScoutModelsByIds($builder, $ids)->get(); 305 | } 306 | 307 | /** 308 | * Get a query builder for retrieving the requested models from an array of object IDs. 309 | * 310 | * @param \Laravel\Scout\Builder $builder 311 | * @param array $ids 312 | * @return mixed 313 | */ 314 | public function queryScoutModelsByIds(Builder $builder, array $ids) 315 | { 316 | $query = static::usesSoftDelete() 317 | ? $this->withTrashed() 318 | : $this->newQuery(); 319 | 320 | if ($builder->queryCallback) { 321 | call_user_func($builder->queryCallback, $query); 322 | } 323 | 324 | $whereIn = in_array($this->getScoutKeyType(), ['int', 'integer']) ? 325 | 'whereIntegerInRaw' : 326 | 'whereIn'; 327 | 328 | return $query->{$whereIn}( 329 | $this->qualifyColumn($this->getScoutKeyName()), $ids 330 | ); 331 | } 332 | 333 | /** 334 | * Enable search syncing for this model. 335 | * 336 | * @return void 337 | */ 338 | public static function enableSearchSyncing() 339 | { 340 | ModelObserver::enableSyncingFor(get_called_class()); 341 | } 342 | 343 | /** 344 | * Disable search syncing for this model. 345 | * 346 | * @return void 347 | */ 348 | public static function disableSearchSyncing() 349 | { 350 | ModelObserver::disableSyncingFor(get_called_class()); 351 | } 352 | 353 | /** 354 | * Temporarily disable search syncing for the given callback. 355 | * 356 | * @param callable $callback 357 | * @return mixed 358 | */ 359 | public static function withoutSyncingToSearch($callback) 360 | { 361 | static::disableSearchSyncing(); 362 | 363 | try { 364 | return $callback(); 365 | } finally { 366 | static::enableSearchSyncing(); 367 | } 368 | } 369 | 370 | /** 371 | * Get the index name for the model when searching. 372 | * 373 | * @return string 374 | */ 375 | public function searchableAs() 376 | { 377 | return config('scout.prefix').$this->getTable(); 378 | } 379 | 380 | /** 381 | * Get the index name for the model when indexing. 382 | * 383 | * @return string 384 | */ 385 | public function indexableAs() 386 | { 387 | return $this->searchableAs(); 388 | } 389 | 390 | /** 391 | * Get the indexable data array for the model. 392 | * 393 | * @return array 394 | */ 395 | public function toSearchableArray() 396 | { 397 | return $this->toArray(); 398 | } 399 | 400 | /** 401 | * Get the Scout engine for the model. 402 | * 403 | * @return mixed 404 | */ 405 | public function searchableUsing() 406 | { 407 | return app(EngineManager::class)->engine(); 408 | } 409 | 410 | /** 411 | * Get the queue connection that should be used when syncing. 412 | * 413 | * @return string 414 | */ 415 | public function syncWithSearchUsing() 416 | { 417 | return config('scout.queue.connection') ?: config('queue.default'); 418 | } 419 | 420 | /** 421 | * Get the queue that should be used with syncing. 422 | * 423 | * @return string 424 | */ 425 | public function syncWithSearchUsingQueue() 426 | { 427 | return config('scout.queue.queue'); 428 | } 429 | 430 | /** 431 | * Sync the soft deleted status for this model into the metadata. 432 | * 433 | * @return $this 434 | */ 435 | public function pushSoftDeleteMetadata() 436 | { 437 | return $this->withScoutMetadata('__soft_deleted', $this->trashed() ? 1 : 0); 438 | } 439 | 440 | /** 441 | * Get all Scout related metadata. 442 | * 443 | * @return array 444 | */ 445 | public function scoutMetadata() 446 | { 447 | return $this->scoutMetadata; 448 | } 449 | 450 | /** 451 | * Set a Scout related metadata. 452 | * 453 | * @param string $key 454 | * @param mixed $value 455 | * @return $this 456 | */ 457 | public function withScoutMetadata($key, $value) 458 | { 459 | $this->scoutMetadata[$key] = $value; 460 | 461 | return $this; 462 | } 463 | 464 | /** 465 | * Get the value used to index the model. 466 | * 467 | * @return mixed 468 | */ 469 | public function getScoutKey() 470 | { 471 | return $this->getKey(); 472 | } 473 | 474 | /** 475 | * Get the auto-incrementing key type for querying models. 476 | * 477 | * @return string 478 | */ 479 | public function getScoutKeyType() 480 | { 481 | return $this->getKeyType(); 482 | } 483 | 484 | /** 485 | * Get the key name used to index the model. 486 | * 487 | * @return mixed 488 | */ 489 | public function getScoutKeyName() 490 | { 491 | return $this->getKeyName(); 492 | } 493 | 494 | /** 495 | * Determine if the current class should use soft deletes with searching. 496 | * 497 | * @return bool 498 | */ 499 | protected static function usesSoftDelete() 500 | { 501 | return in_array(SoftDeletes::class, class_uses_recursive(get_called_class())); 502 | } 503 | } 504 | -------------------------------------------------------------------------------- /src/SearchableScope.php: -------------------------------------------------------------------------------- 1 | macro('searchable', function (EloquentBuilder $builder, $chunk = null) { 35 | $scoutKeyName = $builder->getModel()->getScoutKeyName(); 36 | 37 | $builder->chunkById($chunk ?: config('scout.chunk.searchable', 500), function ($models) { 38 | $models->filter->shouldBeSearchable()->searchable(); 39 | 40 | event(new ModelsImported($models)); 41 | }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); 42 | }); 43 | 44 | $builder->macro('unsearchable', function (EloquentBuilder $builder, $chunk = null) { 45 | $scoutKeyName = $builder->getModel()->getScoutKeyName(); 46 | 47 | $builder->chunkById($chunk ?: config('scout.chunk.unsearchable', 500), function ($models) { 48 | $models->unsearchable(); 49 | 50 | event(new ModelsFlushed($models)); 51 | }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); 52 | }); 53 | 54 | HasManyThrough::macro('searchable', function ($chunk = null) { 55 | /** @var HasManyThrough $this */ 56 | $this->chunkById($chunk ?: config('scout.chunk.searchable', 500), function ($models) { 57 | $models->filter->shouldBeSearchable()->searchable(); 58 | 59 | event(new ModelsImported($models)); 60 | }); 61 | }); 62 | 63 | HasManyThrough::macro('unsearchable', function ($chunk = null) { 64 | /** @var HasManyThrough $this */ 65 | $this->chunkById($chunk ?: config('scout.chunk.unsearchable', 500), function ($models) { 66 | $models->unsearchable(); 67 | 68 | event(new ModelsFlushed($models)); 69 | }); 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | providers: 2 | - Workbench\App\Providers\WorkbenchServiceProvider 3 | - Laravel\Scout\ScoutServiceProvider 4 | 5 | migrations: 6 | - workbench/database/migrations 7 | 8 | workbench: 9 | install: true 10 | --------------------------------------------------------------------------------