├── .editorconfig ├── .gitignore ├── Dockerfile.composer ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── docker-compose.yml ├── phpcs.xml ├── phpunit.xml ├── src ├── Aggregation.php ├── Collection.php ├── Config │ ├── database.php │ └── laravel-elasticsearch.php ├── Connection.php ├── Console │ └── Mappings │ │ ├── Command.php │ │ ├── IndexAliasCommand.php │ │ ├── IndexCopyCommand.php │ │ ├── IndexListCommand.php │ │ ├── IndexRemoveCommand.php │ │ ├── IndexSwapCommand.php │ │ └── Traits │ │ └── GetsIndices.php ├── Contracts │ └── SearchArrayable.php ├── Database │ ├── Migrations │ │ └── Migration.php │ └── Schema │ │ ├── Blueprint.php │ │ ├── ElasticsearchBuilder.php │ │ ├── Grammars │ │ └── ElasticsearchGrammar.php │ │ └── PropertyDefinition.php ├── ElasticsearchServiceProvider.php ├── EloquentBuilder.php ├── Exceptions │ ├── BulkInsertQueryException.php │ └── QueryException.php ├── QueryBuilder.php ├── QueryGrammar.php ├── QueryProcessor.php ├── Searchable.php └── Support │ └── ElasticsearchException.php └── tests ├── TestCase.php └── Unit ├── Console └── Mappings │ ├── IndexListCommandTest.php │ └── IndexRemoveCommandTest.php ├── Database └── Schema │ ├── BlueprintTest.php │ └── Grammars │ └── ElasticsearchGrammarTest.php ├── Elasticsearch └── QueryBuilderTest.php └── Support └── ElasticsearchExceptionTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{*.json, *.yml}] 12 | indent_size=2 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor -------------------------------------------------------------------------------- /Dockerfile.composer: -------------------------------------------------------------------------------- 1 | FROM designmynight/php7.1-cli-mongo 2 | 3 | WORKDIR /opt 4 | 5 | RUN apk add --no-cache libpng libpng-dev && docker-php-ext-install gd && apk del libpng-dev 6 | 7 | RUN docker-php-ext-install pcntl 8 | 9 | COPY --from=composer:1.6 /usr/bin/composer /usr/bin/composer 10 | 11 | RUN /usr/bin/composer global require hirak/prestissimo 12 | 13 | COPY composer.json /opt 14 | COPY composer.lock /opt 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 DesignMyNight 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-elasticsearch 2 | Use Elasticsearch as a database in Laravel to retrieve Eloquent models and perform aggregations. 3 | 4 | Build Elasticsearch queries as you're used to with Eloquent, and get Model instances in return, with some nice extras: 5 | - Use `query`, `filter` and `postFilter` query types 6 | - Perform geo searches 7 | - Build and perform complex aggregations on your data 8 | - Use the Elasticsearch scroll API to retrieve large numbers of results 9 | 10 | ## Versions 11 | Depending on your version of Elasticsearch you can use the following version of this package 12 | 13 | | Elasticsearch | Laravel Elasticsearch | 14 | |:--------------|:----------------------| 15 | |>= 7.0, < 7.1 | 6 | 16 | |>= 6.6, < 7.0 | 5 | 17 | |>= 5.0, < 6.0 | 4 | 18 | 19 | ## Setup 20 | Add elasticsearch connection configuration to database.php 21 | ``` 22 | 'elasticsearch' => [ 23 | 'driver' => 'elasticsearch', 24 | 'host' => 'localhost', 25 | 'port' => 9200, 26 | 'database' => 'your_es_index', 27 | 'username' => 'optional_es_username', 28 | 'password' => 'optional_es_username', 29 | 'suffix' => 'optional_es_index_suffix', 30 | ] 31 | ``` 32 | 33 | Create or update your base Model.php class to override `newEloquentBuilder()` and `newBaseQueryBuilder()`: 34 | 35 | ```PHP 36 | /** 37 | * Create a new Eloquent builder for the model. 38 | * 39 | * @param \Illuminate\Database\Query\Builder $query 40 | * @return \Illuminate\Database\Eloquent\Builder|static 41 | */ 42 | public function newEloquentBuilder($query) 43 | { 44 | switch ($this->getConnectionName()) { 45 | case static::getElasticsearchConnectionName(): 46 | $builder = new ElasticsearchEloquentBuilder($query); 47 | break; 48 | 49 | default: 50 | $builder = new Illuminate\Database\Eloquent\Builder($query); 51 | } 52 | 53 | return $builder; 54 | } 55 | 56 | /** 57 | * Get a new query builder instance for the connection. 58 | * 59 | * @return \Illuminate\Database\Query\Builder 60 | */ 61 | protected function newBaseQueryBuilder() 62 | { 63 | $connection = $this->getConnection(); 64 | 65 | switch ($this->getConnectionName()) { 66 | case static::getElasticsearchConnectionName(): 67 | $builder = new ElasticsearchQueryBuilder($connection, $connection->getQueryGrammar(), $connection->getPostProcessor()); 68 | break; 69 | 70 | default: 71 | $builder = new Illuminate\Database\Query\Builder($connection, $connection->getPostProcessor()); 72 | } 73 | 74 | return $builder; 75 | } 76 | ``` 77 | 78 | ## Search 79 | You're now ready to carry out searches on your data. The query will look for an Elasticsearch index with the same name as the database table that your models reside in. 80 | 81 | ```PHP 82 | $documents = MyModel::newElasticsearchQuery() 83 | ->where('date', '>', Carbon\Carbon::now()) 84 | ->get(); 85 | ``` 86 | 87 | ## Aggregations 88 | Aggregations can be added to a query with an approach that's similar to querying Elasticsearch directly, using nested functions rather than nested arrays. The `aggregation()` method takes three or four arguments: 89 | 1. A key to be used for the aggregation 90 | 2. The type of aggregation, such as 'filter' or 'terms' 91 | 3. (Optional) A callback or array providing options for the aggregation 92 | 4. (Optional) A function allowing you to provide further sub-aggregations 93 | 94 | ```PHP 95 | $myQuery = MyModel::newElasticsearchQuery() 96 | ->aggregation( 97 | // The key of the aggregation (used in the Elasticsearch response) 98 | 'my_filter_aggregation', 99 | 100 | // The type of the aggregation 101 | 'filter', 102 | 103 | // A callback providing options to the aggregation, in this case adding filter criteria to a query builder 104 | function ($query) { 105 | $query->where('lost', '!=', true); 106 | $query->where('concierge', true); 107 | }, 108 | 109 | // A callback specifying a sub-aggregation 110 | function ($builder) { 111 | // A simpler aggregation, counting terms in the 'status' field 112 | $builder->aggregation('my_terms_aggregation', 'terms', ['field' => 'status']); 113 | } 114 | ); 115 | 116 | $results = $myQuery->get(); 117 | $aggregations = $myQuery->getQuery()->getAggregationResults(); 118 | ``` 119 | 120 | ## Geo queries 121 | You can filter search results by distance from a geo point or include only those results that fall within given bounds, passing arguments in the format you'd use if querying Elasticsearch directly. 122 | 123 | ```PHP 124 | $withinDistance = MyModel::newElasticsearchQuery() 125 | ->whereGeoDistance('geo_field', [$lat, $lon], $distance); 126 | 127 | $withinBounds= MyModel::newElasticsearchQuery() 128 | ->whereGeoBoundsIn('geo_field', $boundingBox); 129 | ``` 130 | 131 | ## Scroll API 132 | You can use a scroll search to retrieve large numbers of results. Rather than returning a Collection, you'll get a PHP [Generator](http://php.net/manual/en/language.generators.overview.php) function that you can iterate over, where each value is a Model for a single result from Elasticsearch. 133 | 134 | ```PHP 135 | $documents = MyModel::newElasticsearchQuery() 136 | ->limit(100000) 137 | ->usingScroll() 138 | ->get(); 139 | 140 | // $documents is a Generator 141 | foreach ($documents as $document){ 142 | echo $document->id; 143 | } 144 | ``` 145 | 146 | ## Console 147 | This package ships with the following commands to be used as utilities or as part of your deployment process. 148 | 149 | | Command | Arguments | Options | Description | 150 | | ------- | --------- | ------- | ----------- | 151 | | `make:mapping` | `name`: Name of the mapping. This name also determines the name of the index and the alias. | `--update`: Whether the mapping should update an existing index. `--template`: Pass a pre-existing mapping filename to create your new mapping from. | Creates a new mapping migration file. | 152 | | `migrate:mappings` | `index-command`: (Optional) Name of your local Artisan console command that performs the Elasticsearch indexing. If not given, command will be retrieved from `laravel-elasticsearch` config file. | `--index` : Automatically index new mapping.`--swap`: Automatically update the alias after the indexing has finished. | Migrates your mapping files and begins to create the index.| 153 | | `index:rollback` | | | Rollback to the previous index migration. | 154 | | `index:remove` | `index`: (Optional) Name of the index to remove from your Elasticsearch cluster. | | Removes an index from your Elasticsearch cluster. | 155 | | `index:swap` | `alias`: Name of alias to update. `index`: Name of index to update alias to. `old-index`: (Optional) Name of old index. | `--remove-old-index`: Remove old index from your Elasticsearch cluster. | Swap the index your alias points to. | 156 | | `index:list` | | `--alias`: List active aliases. Pass `"*"` to view all. Other values filter the returned aliases. | Display a list of all indexes in your Elasticsearch cluster. | 157 | | `index:copy` | `from`: index to copy from. `to`: the index to copy from | | Populate an index with all documents from another index 158 | 159 | 160 | ### Mappings and Aliases 161 | When creating a new index during a `migrate:mappings` the command will automatically create an alias based on the migration name by removing the date string. For example the migration `2018_08_03_095804_users.json` will create the alias `users`. 162 | 163 | During the first migration an index appears in the `migrate:mappings` command will also switch the alias to the latest index mapping. The above will only happen when the alias does not already exist. 164 | 165 | Future migrations will require you to use the `--swap` option. 166 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "designmynight/laravel-elasticsearch", 3 | "description": "Use Elasticsearch as a database in Laravel to retrieve Eloquent models and perform aggregations.", 4 | "keywords": ["laravel","eloquent","elasticsearch","database","model"], 5 | "homepage": "https://github.com/designmynight/laravel-elasticsearch", 6 | "authors": [ 7 | { 8 | "name": "DesignMyNight team", 9 | "homepage": "https://designmynight.com" 10 | } 11 | ], 12 | "license" : "MIT", 13 | "require": { 14 | "php": "^7.1 || ^8.0", 15 | "ext-json": "*", 16 | "elasticsearch/elasticsearch": "^7.0", 17 | "laravel/framework": "^5.8 || ^6.0 || >=7.0" 18 | }, 19 | "require-dev": { 20 | "phpunit/phpunit": "^7.1", 21 | "mockery/mockery": "^1.1" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "DesignMyNight\\Elasticsearch\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "files": [ 30 | "vendor/laravel/framework/src/Illuminate/Foundation/helpers.php" 31 | ], 32 | "psr-4": { 33 | "Tests\\": "tests/" 34 | } 35 | }, 36 | "extra": { 37 | "laravel": { 38 | "providers": [ 39 | "DesignMyNight\\Elasticsearch\\ElasticsearchServiceProvider" 40 | ] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | unit-tests: 4 | image: designmynight/php7.1-cli-mongo 5 | command: phpdbg -qrr ./vendor/bin/phpunit 6 | volumes: 7 | - .:/opt 8 | working_dir: /opt 9 | 10 | composer: 11 | build: 12 | context: . 13 | dockerfile: Dockerfile.composer 14 | command: composer -o install 15 | volumes: 16 | - .:/opt 17 | working_dir: /opt 18 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | app/ 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Aggregation.php: -------------------------------------------------------------------------------- 1 | arguments; 19 | } 20 | 21 | public function getKey(): string 22 | { 23 | return $this->key; 24 | } 25 | 26 | public function getType(): string 27 | { 28 | return $this->type; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Collection.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 12 | return; 13 | } 14 | 15 | $instance = $this->first(); 16 | $instance->setConnection($instance->getElasticsearchConnectionName()); 17 | $query = $this->first()->newQueryWithoutScopes(); 18 | 19 | $docs = $this->map(function ($model, $i) { 20 | return $model->toSearchableArray(); 21 | }); 22 | 23 | $success = $query->insert($docs->all()); 24 | 25 | unset($docs); 26 | 27 | return $success; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Config/database.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'elasticsearch' => [ 6 | 'driver' => 'elasticsearch', 7 | 'host' => env('ELASTICSEARCH_HOST', 'localhost'), 8 | 'hosts' => env('ELASTICSEARCH_HOSTS'), 9 | 'port' => env('ELASTICSEARCH_PORT', 9200), 10 | 'database' => env('ELASTICSEARCH_DATABASE'), 11 | 'username' => env('ELASTICSEARCH_USERNAME'), 12 | 'password' => env('ELASTICSEARCH_PASSWORD'), 13 | 'suffix' => env('ELASTICSEARCH_INDEX_SUFFIX'), 14 | ] 15 | ] 16 | ]; 17 | -------------------------------------------------------------------------------- /src/Config/laravel-elasticsearch.php: -------------------------------------------------------------------------------- 1 | '', 9 | 10 | /** 11 | * Mappings migration table name. 12 | */ 13 | 'mappings_migration_table' => 'mappings', 14 | 15 | /** 16 | * Default format for dates in elasticsearch 17 | */ 18 | 'date_format' => 'Y-m-d H:i:s', 19 | ]; 20 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | 'setSSLVerification', 38 | 'sniffOnStart' => 'setSniffOnStart', 39 | 'retries' => 'setRetries', 40 | 'httpHandler' => 'setHandler', 41 | 'connectionPool' => 'setConnectionPool', 42 | 'connectionSelector' => 'setSelector', 43 | 'serializer' => 'setSerializer', 44 | 'connectionFactory' => 'setConnectionFactory', 45 | 'endpoint' => 'setEndpoint', 46 | 'namespaces' => 'registerNamespace', 47 | ]; 48 | 49 | /** 50 | * Create a new Elasticsearch connection instance. 51 | * 52 | * @param array $config 53 | */ 54 | public function __construct(array $config) 55 | { 56 | $this->config = $config; 57 | $this->indexSuffix = $config['suffix'] ?? ''; 58 | 59 | // Extract the hosts from config 60 | $hosts = explode(',', $config['hosts'] ?? $config['host']); 61 | 62 | // You can pass options directly to the client 63 | $options = Arr::get($config, 'options', []); 64 | 65 | // Create the connection 66 | $this->connection = $this->createConnection($hosts, $config, $options); 67 | 68 | $this->useDefaultQueryGrammar(); 69 | $this->useDefaultPostProcessor(); 70 | } 71 | 72 | /** 73 | * Dynamically pass methods to the connection. 74 | * 75 | * @param string $method 76 | * @param array $parameters 77 | * 78 | * @return mixed 79 | */ 80 | public function __call($method, $parameters) 81 | { 82 | return call_user_func_array([$this->connection, $method], $parameters); 83 | } 84 | 85 | /** 86 | * Run an SQL statement and get the number of rows affected. 87 | * 88 | * @param string $query 89 | * @param array $bindings 90 | * 91 | * @return int 92 | */ 93 | public function affectingStatement($query, $bindings = []) 94 | { 95 | // 96 | } 97 | 98 | /** 99 | * Start a new database transaction. 100 | * 101 | * @return void 102 | */ 103 | public function beginTransaction() 104 | { 105 | // 106 | } 107 | 108 | /** 109 | * Commit the active database transaction. 110 | * 111 | * @return void 112 | */ 113 | public function commit() 114 | { 115 | // 116 | } 117 | 118 | /** 119 | * @param string $index 120 | * @param string $name 121 | */ 122 | public function createAlias(string $index, string $name): void 123 | { 124 | $this->indices()->putAlias(compact('index', 'name')); 125 | } 126 | 127 | /** 128 | * @param string $index 129 | * @param array $body 130 | */ 131 | public function createIndex(string $index, array $body): void 132 | { 133 | $this->indices()->create(compact('index', 'body')); 134 | } 135 | 136 | /** 137 | * Run a select statement against the database and return a generator. 138 | * 139 | * @param string $query 140 | * @param array $bindings 141 | * @param bool $useReadPdo 142 | * 143 | * @return \Generator 144 | */ 145 | public function cursor($query, $bindings = [], $useReadPdo = false) 146 | { 147 | $scrollTimeout = '30s'; 148 | $limit = $query['size'] ?? 0; 149 | 150 | $scrollParams = [ 151 | 'scroll' => $scrollTimeout, 152 | 'size' => 100, // Number of results per shard 153 | 'index' => $query['index'], 154 | 'body' => $query['body'], 155 | ]; 156 | 157 | $results = $this->select($scrollParams); 158 | 159 | $scrollId = $results['_scroll_id']; 160 | 161 | $numResults = count($results['hits']['hits']); 162 | 163 | foreach ($results['hits']['hits'] as $result) { 164 | yield $result; 165 | } 166 | 167 | if (!$limit || $limit > $numResults) { 168 | $limit = $limit ? $limit - $numResults : $limit; 169 | 170 | foreach ($this->scroll($scrollId, $scrollTimeout, $limit) as $result) { 171 | yield $result; 172 | } 173 | } 174 | } 175 | 176 | /** 177 | * Run a delete statement against the database. 178 | * 179 | * @param string $query 180 | * @param array $bindings 181 | * 182 | * @return array 183 | */ 184 | public function delete($query, $bindings = []) 185 | { 186 | return $this->run( 187 | $query, 188 | $bindings, 189 | Closure::fromCallable([$this->connection, 'deleteByQuery']) 190 | ); 191 | } 192 | 193 | /** 194 | * @param string $index 195 | */ 196 | public function dropIndex(string $index): void 197 | { 198 | $this->indices()->delete(compact('index')); 199 | } 200 | 201 | /** 202 | * Get the timeout for the entire Elasticsearch request 203 | * @return float 204 | */ 205 | public function getRequestTimeout(): float 206 | { 207 | return $this->requestTimeout; 208 | } 209 | 210 | /** 211 | * @return ElasticsearchBuilder|\Illuminate\Database\Schema\Builder 212 | */ 213 | public function getSchemaBuilder() 214 | { 215 | return new ElasticsearchBuilder($this); 216 | } 217 | 218 | /** 219 | * @return ElasticsearchGrammar|\Illuminate\Database\Schema\Grammars\Grammar 220 | */ 221 | public function getSchemaGrammar() 222 | { 223 | return new ElasticsearchGrammar(); 224 | } 225 | 226 | /** 227 | * Get the table prefix for the connection. 228 | * 229 | * @return string 230 | */ 231 | public function getTablePrefix() 232 | { 233 | return $this->indexSuffix; 234 | } 235 | 236 | /** 237 | * Run an insert statement against the database. 238 | * 239 | * @param array $params 240 | * @param array $bindings 241 | * @return bool 242 | * @throws BulkInsertQueryException 243 | */ 244 | public function insert($params, $bindings = []) 245 | { 246 | $result = $this->run( 247 | $this->addClientParams($params), 248 | $bindings, 249 | Closure::fromCallable([$this->connection, 'bulk']) 250 | ); 251 | 252 | if (!empty($result['errors'])) { 253 | throw new BulkInsertQueryException($result); 254 | } 255 | 256 | return true; 257 | } 258 | 259 | /** 260 | * Log a query in the connection's query log. 261 | * 262 | * @param string $query 263 | * @param array $bindings 264 | * @param float|null $time 265 | * 266 | * @return void 267 | */ 268 | public function logQuery($query, $bindings, $time = null) 269 | { 270 | $this->event(new QueryExecuted(json_encode($query), $bindings, $time, $this)); 271 | 272 | if ($this->loggingQueries) { 273 | $this->queryLog[] = compact('query', 'bindings', 'time'); 274 | } 275 | } 276 | 277 | /** 278 | * Prepare the query bindings for execution. 279 | * 280 | * @param array $bindings 281 | * 282 | * @return array 283 | */ 284 | public function prepareBindings(array $bindings) 285 | { 286 | return $bindings; 287 | } 288 | 289 | /** 290 | * Execute the given callback in "dry run" mode. 291 | * 292 | * @param \Closure $callback 293 | * 294 | * @return array 295 | */ 296 | public function pretend(Closure $callback) 297 | { 298 | // 299 | } 300 | 301 | /** 302 | * Get a new raw query expression. 303 | * 304 | * @param mixed $value 305 | * 306 | * @return \Illuminate\Database\Query\Expression 307 | */ 308 | public function raw($value) 309 | { 310 | // 311 | } 312 | 313 | /** 314 | * Rollback the active database transaction. 315 | * 316 | * @return void 317 | */ 318 | public function rollBack($toLevel = null) 319 | { 320 | // 321 | } 322 | 323 | /** 324 | * Run a select statement against the database using an Elasticsearch scroll cursor. 325 | * 326 | * @param string $scrollId 327 | * @param string $scrollTimeout 328 | * @param int $limit 329 | * 330 | * @return \Generator 331 | */ 332 | public function scroll(string $scrollId, string $scrollTimeout = '30s', int $limit = 0) 333 | { 334 | $numResults = 0; 335 | 336 | // Loop until the scroll 'cursors' are exhausted or we have enough results 337 | while (!$limit || $numResults < $limit) { 338 | // Execute a Scroll request 339 | $results = $this->connection->scroll([ 340 | 'scroll_id' => $scrollId, 341 | 'scroll' => $scrollTimeout, 342 | ]); 343 | 344 | // Get new scroll ID in case it's changed 345 | $scrollId = $results['_scroll_id']; 346 | 347 | // Break if no results 348 | if (empty($results['hits']['hits'])) { 349 | break; 350 | } 351 | 352 | foreach ($results['hits']['hits'] as $result) { 353 | $numResults++; 354 | 355 | if ($limit && $numResults > $limit) { 356 | break; 357 | } 358 | 359 | yield $result; 360 | } 361 | } 362 | } 363 | 364 | /** 365 | * Run a select statement against the database. 366 | * 367 | * @param array $params 368 | * @param array $bindings 369 | * @return array 370 | */ 371 | public function select($params, $bindings = [], $useReadPdo = true) 372 | { 373 | return $this->run( 374 | $this->addClientParams($params), 375 | $bindings, 376 | Closure::fromCallable([$this->connection, 'search']) 377 | ); 378 | } 379 | 380 | /** 381 | * Run a select statement and return a single result. 382 | * 383 | * @param string $query 384 | * @param array $bindings 385 | * 386 | * @return mixed 387 | */ 388 | public function selectOne($query, $bindings = [], $useReadPdo = true) 389 | { 390 | // 391 | } 392 | 393 | /** 394 | * Get a new query builder instance. 395 | * 396 | * @return 397 | */ 398 | public function query() 399 | { 400 | return new QueryBuilder( 401 | $this, $this->getQueryGrammar(), $this->getPostProcessor() 402 | ); 403 | } 404 | 405 | /** 406 | * Set the table prefix in use by the connection. 407 | * 408 | * @param string $prefix 409 | * 410 | * @return void 411 | */ 412 | public function setIndexSuffix($suffix) 413 | { 414 | $this->indexSuffix = $suffix; 415 | 416 | $this->getQueryGrammar()->setIndexSuffix($suffix); 417 | } 418 | 419 | /** 420 | * Get the timeout for the entire Elasticsearch request 421 | * 422 | * @param float $requestTimeout seconds 423 | * 424 | * @return self 425 | */ 426 | public function setRequestTimeout(float $requestTimeout): self 427 | { 428 | $this->requestTimeout = $requestTimeout; 429 | 430 | return $this; 431 | } 432 | 433 | /** 434 | * Execute an SQL statement and return the boolean result. 435 | * 436 | * @param string $query 437 | * @param array $bindings 438 | * 439 | * @return bool 440 | */ 441 | public function statement($query, $bindings = [], Blueprint $blueprint = null) 442 | { 443 | // 444 | } 445 | 446 | /** 447 | * Execute a Closure within a transaction. 448 | * 449 | * @param \Closure $callback 450 | * @param int $attempts 451 | * 452 | * @return mixed 453 | * 454 | * @throws \Throwable 455 | */ 456 | public function transaction(Closure $callback, $attempts = 1) 457 | { 458 | // 459 | } 460 | 461 | /** 462 | * Get the number of active transactions. 463 | * 464 | * @return int 465 | */ 466 | public function transactionLevel() 467 | { 468 | // 469 | } 470 | 471 | /** 472 | * Run a raw, unprepared query against the PDO connection. 473 | * 474 | * @param string $query 475 | * 476 | * @return bool 477 | */ 478 | public function unprepared($query) 479 | { 480 | // 481 | } 482 | 483 | /** 484 | * Run an update statement against the database. 485 | * 486 | * @param string $query 487 | * @param array $bindings 488 | * 489 | * @return array 490 | */ 491 | public function update($query, $bindings = []) 492 | { 493 | $updateMethod = isset($query['body']['query']) ? 'updateByQuery' : 'update'; 494 | return $this->run( 495 | $query, 496 | $bindings, 497 | Closure::fromCallable([$this->connection, $updateMethod]) 498 | ); 499 | } 500 | 501 | /** 502 | * @param string $index 503 | * @param array $body 504 | */ 505 | public function updateIndex(string $index, array $body): void 506 | { 507 | $this->indices()->putMapping(compact('index', 'body')); 508 | } 509 | 510 | /** 511 | * Set the table prefix and return the grammar. 512 | * 513 | * @param \Illuminate\Database\Grammar $grammar 514 | * 515 | * @return \Illuminate\Database\Grammar 516 | */ 517 | public function withIndexSuffix(BaseGrammar $grammar) 518 | { 519 | $grammar->setIndexSuffix($this->indexSuffix); 520 | 521 | return $grammar; 522 | } 523 | 524 | /** 525 | * Add client-specific parameters to the request params 526 | * 527 | * @param array $params 528 | * 529 | * @return array 530 | */ 531 | protected function addClientParams(array $params): array 532 | { 533 | if ($this->requestTimeout) { 534 | $params['client']['timeout'] = $this->requestTimeout; 535 | } 536 | 537 | return $params; 538 | } 539 | 540 | /** 541 | * Create a new Elasticsearch connection. 542 | * 543 | * @param array $hosts 544 | * @param array $config 545 | * 546 | * @return \Elasticsearch\Client 547 | */ 548 | protected function createConnection($hosts, array $config, array $options) 549 | { 550 | // apply config to each host 551 | $hosts = array_map(function ($host) use ($config) { 552 | $port = !empty($config['port']) ? $config['port'] : 9200; 553 | 554 | $scheme = !empty($config['scheme']) ? $config['scheme'] : 'http'; 555 | 556 | // force https for port 443 557 | $scheme = (int) $port === 443 ? 'https' : $scheme; 558 | 559 | return [ 560 | 'host' => $host, 561 | 'port' => $port, 562 | 'scheme' => $scheme, 563 | 'user' => !empty($config['username']) ? $config['username'] : null, 564 | 'pass' => !empty($config['password']) ? $config['password'] : null, 565 | ]; 566 | }, $hosts); 567 | 568 | $clientBuilder = ClientBuilder::create() 569 | ->setHosts($hosts); 570 | 571 | $elasticConfig = config('elasticsearch.connections.' . config('elasticsearch.defaultConnection', 'default'), []); 572 | // Set additional client configuration 573 | foreach ($this->configMappings as $key => $method) { 574 | $value = Arr::get($elasticConfig, $key); 575 | if (is_array($value)) { 576 | foreach ($value as $vItem) { 577 | $clientBuilder->$method($vItem); 578 | } 579 | } elseif ($value !== null) { 580 | $clientBuilder->$method($value); 581 | } 582 | } 583 | 584 | return $clientBuilder->build(); 585 | } 586 | 587 | /** 588 | * Get the default post processor instance. 589 | * 590 | * @return QueryProcessor 591 | */ 592 | protected function getDefaultPostProcessor() 593 | { 594 | return new QueryProcessor(); 595 | } 596 | 597 | /** 598 | * Get the default query grammar instance. 599 | * 600 | * @return \Illuminate\Database\Query\Grammars\Grammar 601 | */ 602 | protected function getDefaultQueryGrammar() 603 | { 604 | return $this->withIndexSuffix(new QueryGrammar); 605 | } 606 | 607 | /** 608 | * Run a search query. 609 | * 610 | * @param array $query 611 | * @param array $bindings 612 | * @param \Closure $callback 613 | * 614 | * @return mixed 615 | * 616 | * @throws \DesignMyNight\Elasticsearch\QueryException 617 | */ 618 | protected function runQueryCallback($query, $bindings, Closure $callback) 619 | { 620 | try { 621 | $result = $callback($query, $bindings); 622 | } catch (\Exception $e) { 623 | throw new QueryException($query, $bindings, $e); 624 | } 625 | 626 | return $result; 627 | } 628 | } 629 | -------------------------------------------------------------------------------- /src/Console/Mappings/Command.php: -------------------------------------------------------------------------------- 1 | client = new Connection(Config::get('database.connections.elasticsearch')); 29 | } 30 | 31 | /** 32 | * @return void 33 | */ 34 | abstract public function handle(); 35 | } 36 | -------------------------------------------------------------------------------- /src/Console/Mappings/IndexAliasCommand.php: -------------------------------------------------------------------------------- 1 | argument('name'); 33 | $index = $this->getIndexName(); 34 | 35 | try { 36 | if ($this->client->indices()->existsAlias(['name' => $alias])) { 37 | throw new Exception("Alias $alias already exists"); 38 | } 39 | 40 | $this->client->indices()->putAlias([ 41 | 'index' => $index, 42 | 'name' => $alias 43 | ]); 44 | } catch (Exception $exception) { 45 | $this->error($exception->getMessage()); 46 | 47 | return; 48 | } 49 | 50 | $this->info("Alias $alias created successfully."); 51 | } 52 | 53 | /** 54 | * @return string 55 | */ 56 | protected function getIndexName(): string 57 | { 58 | if (!$index = $this->argument('index')) { 59 | $indices = collect($this->indices()) 60 | ->sortBy('index') 61 | ->pluck('index') 62 | ->toArray(); 63 | 64 | $index = $this->choice('Which index do you want to create an alias for?', $indices); 65 | } 66 | 67 | return $index; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Console/Mappings/IndexCopyCommand.php: -------------------------------------------------------------------------------- 1 | from(); 36 | $to = $this->to(); 37 | 38 | $body = [ 39 | 'source' => ['index' => $from], 40 | 'dest' => ['index' => $to], 41 | ]; 42 | 43 | if ($this->confirm("Would you like to copy {$from} to {$to}?")) { 44 | try { 45 | $this->report( 46 | $this->client->reindex(['body' => json_encode($body)]) 47 | ); 48 | } catch (ElasticsearchExceptionInterface $exception) { 49 | $exception = new ElasticsearchException($exception); 50 | 51 | $this->output->error((string) $exception); 52 | } 53 | } 54 | } 55 | 56 | /** 57 | * @return string 58 | */ 59 | protected function from(): string 60 | { 61 | if ($from = $this->argument('from')) { 62 | return $from; 63 | } 64 | 65 | return $this->choice( 66 | 'Which index would you like to copy from?', 67 | collect($this->indices())->pluck('index')->toArray() 68 | ); 69 | } 70 | 71 | /** 72 | * @return string 73 | */ 74 | protected function to(): string 75 | { 76 | if ($to = $this->argument('to')) { 77 | return $to; 78 | } 79 | 80 | return $this->choice( 81 | 'Which index would you like to copy to?', 82 | collect($this->indices())->pluck('index')->toArray() 83 | ); 84 | } 85 | 86 | /** 87 | * @param array $result 88 | */ 89 | private function report(array $result): void 90 | { 91 | // report any failures 92 | if ($result['failures']) { 93 | $this->output->warning('Failures'); 94 | $this->output->table(array_keys($result['failures'][0]), $result['failures']); 95 | } 96 | 97 | // format results in strings 98 | $result['timed_out'] = $result['timed_out'] ? 'true' : 'false'; 99 | $result['failures'] = count($result['failures']); 100 | 101 | unset($result['retries']); 102 | 103 | // report success 104 | $this->output->success('Copy complete, see results below'); 105 | $this->output->table(array_keys($result), [$result]); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Console/Mappings/IndexListCommand.php: -------------------------------------------------------------------------------- 1 | option('alias')) { 32 | $indices = $this->getIndicesForAlias($alias); 33 | 34 | if (empty($indices)) { 35 | $this->line('No aliases found.'); 36 | 37 | return; 38 | } 39 | 40 | $this->table(array_keys($indices[0]), $indices); 41 | 42 | return; 43 | } 44 | 45 | if ($indices = $this->indices()) { 46 | $this->table(array_keys($indices[0]), $indices); 47 | 48 | return; 49 | } 50 | 51 | $this->line('No indices found.'); 52 | } 53 | 54 | /** 55 | * @param string $alias 56 | * 57 | * @return array 58 | */ 59 | protected function getIndicesForAlias(string $alias = '*'): array 60 | { 61 | try { 62 | $aliases = collect($this->client->cat()->aliases()); 63 | 64 | return $aliases 65 | ->sortBy('alias') 66 | ->when($alias !== '*', function (Collection $aliases) use ($alias) { 67 | return $aliases->filter(function ($item) use ($alias) { 68 | return Str::contains($item['alias'], $alias); 69 | }); 70 | }) 71 | ->values() 72 | ->toArray(); 73 | } catch (\Exception $exception) { 74 | $this->error("Failed to retrieve alias {$alias}"); 75 | } 76 | 77 | return []; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Console/Mappings/IndexRemoveCommand.php: -------------------------------------------------------------------------------- 1 | argument('index')) { 30 | $indices = collect($this->indices())->pluck('index')->toArray(); 31 | $index = $this->choice('Which index would you like to delete?', $indices); 32 | } 33 | 34 | if (!$this->confirm("Are you sure you wish to remove the index {$index}?")) { 35 | return; 36 | } 37 | 38 | $this->removeIndex($index); 39 | } 40 | 41 | /** 42 | * @param string $index 43 | * 44 | * @return bool 45 | */ 46 | protected function removeIndex(string $index): bool 47 | { 48 | $this->info("Removing index: {$index}"); 49 | 50 | try { 51 | $this->client->indices()->delete(['index' => $index]); 52 | } catch (\Exception $exception) { 53 | $message = json_decode($exception->getMessage(), true); 54 | $this->error("Failed to remove index: {$index}. Reason: {$message['error']['root_cause'][0]['reason']}"); 55 | 56 | return false; 57 | } 58 | 59 | $this->info("Removed index: {$index}"); 60 | 61 | return true; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Console/Mappings/IndexSwapCommand.php: -------------------------------------------------------------------------------- 1 | argument('alias')) { 31 | $alias = $this->choice( 32 | 'Which alias would you like to update', 33 | $this->aliases()->pluck('alias')->toArray() 34 | ); 35 | } 36 | 37 | if (!$index = $this->argument('index')) { 38 | $index = $this->choice( 39 | 'Which index would you like the alias to point to', 40 | $this->indices()->pluck('index')->toArray() 41 | ); 42 | } 43 | 44 | $this->line("Updating {$alias} to {$index}..."); 45 | 46 | $body = [ 47 | 'actions' => [ 48 | [ 49 | 'remove' => [ 50 | 'index' => $this->current($alias), 51 | 'alias' => $alias, 52 | ], 53 | ], 54 | [ 55 | 'add' => [ 56 | 'index' => $index, 57 | 'alias' => $alias, 58 | ], 59 | ], 60 | ], 61 | ]; 62 | 63 | if ($this->confirmToProceed()) { 64 | try { 65 | $this->client->indices()->updateAliases(compact('body')); 66 | } catch (\Exception $exception) { 67 | $this->error("Failed to update alias: {$alias}. {$exception->getMessage()}"); 68 | 69 | return; 70 | } 71 | 72 | $this->info("Updated {$alias} to {$index}"); 73 | } 74 | } 75 | 76 | /** 77 | * @return Collection 78 | */ 79 | protected function aliases(): Collection 80 | { 81 | return Cache::store('array')->rememberForever('aliases', function (): Collection { 82 | return collect($this->client->cat()->aliases())->sortBy('alias'); 83 | }); 84 | } 85 | 86 | /** 87 | * @param string $alias 88 | * 89 | * @return string 90 | */ 91 | protected function current(string $alias): string 92 | { 93 | $aliases = $this->aliases(); 94 | 95 | if (!$alias = $aliases->firstWhere('alias', $alias)) { 96 | $index = $this->choice( 97 | 'Which index is the current index', 98 | $aliases->pluck('index')->toArray() 99 | ); 100 | 101 | $alias = $aliases->firstWhere('index', $index); 102 | } 103 | 104 | return $alias['index']; 105 | } 106 | 107 | /** 108 | * @return Collection 109 | */ 110 | protected function indices(): Collection 111 | { 112 | return Cache::store('array')->rememberForever('indices', function (): Collection { 113 | return collect($this->client->cat()->indices())->sortByDesc('index'); 114 | }); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Console/Mappings/Traits/GetsIndices.php: -------------------------------------------------------------------------------- 1 | client->cat()->indices())->sortBy('index')->toArray(); 26 | } catch (\Exception $exception) { 27 | $this->error('Failed to retrieve indices.'); 28 | } 29 | 30 | return []; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Contracts/SearchArrayable.php: -------------------------------------------------------------------------------- 1 | schema = Schema::connection('elasticsearch'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Database/Schema/Blueprint.php: -------------------------------------------------------------------------------- 1 | columns[] = $column = new PropertyDefinition( 41 | array_merge(compact(...$attributes), $parameters) 42 | ); 43 | 44 | return $column; 45 | } 46 | 47 | /** 48 | * @param string $key 49 | * @param array $value 50 | */ 51 | public function addIndexSettings(string $key, array $value): void 52 | { 53 | $this->indexSettings[$key] = $value; 54 | } 55 | 56 | /** 57 | * @param string $key 58 | * @param $value 59 | */ 60 | public function addMetaField(string $key, $value): void 61 | { 62 | $this->meta[$key] = $value; 63 | } 64 | 65 | /** 66 | * @param string $alias 67 | */ 68 | public function alias(string $alias): void 69 | { 70 | $this->alias = $alias; 71 | } 72 | 73 | /** 74 | * @param string $name 75 | * @param array $parameters 76 | * 77 | * @return PropertyDefinition 78 | */ 79 | public function binary($name, array $parameters = []): PropertyDefinition 80 | { 81 | return $this->addColumn('binary', $name, $parameters); 82 | } 83 | 84 | /** 85 | * Execute the blueprint against the database. 86 | * 87 | * @param \Illuminate\Database\Connection $connection 88 | * @param \Illuminate\Database\Schema\Grammars\Grammar $grammar 89 | * 90 | * @return void 91 | */ 92 | public function build(Connection $connection, Grammar $grammar) 93 | { 94 | foreach ($this->toSql($connection, $grammar) as $statement) { 95 | if ($connection->pretending()) { 96 | return; 97 | } 98 | 99 | $statement($this, $connection); 100 | } 101 | } 102 | 103 | /** 104 | * @param string $name 105 | * @param array $parameters 106 | * 107 | * @return PropertyDefinition 108 | */ 109 | public function date($name, array $parameters = []): PropertyDefinition 110 | { 111 | return $this->addColumn('date', $name, $parameters); 112 | } 113 | 114 | /** 115 | * @param string $name 116 | * @param array $parameters 117 | * 118 | * @return PropertyDefinition 119 | */ 120 | public function dateRange(string $name, array $parameters = []): PropertyDefinition 121 | { 122 | return $this->range('date_range', $name, $parameters); 123 | } 124 | 125 | /** 126 | * @param string $name 127 | */ 128 | public function document(string $name): void 129 | { 130 | $this->document = $name; 131 | } 132 | 133 | /** 134 | * @param string $name 135 | * @param array $parameters 136 | * 137 | * @return PropertyDefinition 138 | */ 139 | public function doubleRange(string $name, array $parameters = []): PropertyDefinition 140 | { 141 | return $this->range('double_range', $name, $parameters); 142 | } 143 | 144 | /** 145 | * @param bool|string $value 146 | */ 147 | public function dynamic($value): void 148 | { 149 | $this->addMetaField('dynamic', $value); 150 | } 151 | 152 | /** 153 | * @return void 154 | */ 155 | public function enableAll(): void 156 | { 157 | $this->addMetaField('_all', ['enabled' => true]); 158 | } 159 | 160 | /** 161 | * @return void 162 | */ 163 | public function enableFieldNames(): void 164 | { 165 | $this->addMetaField('_field_names', ['enabled' => true]); 166 | } 167 | 168 | /** 169 | * @param string $name 170 | * 171 | * @return PropertyDefinition 172 | */ 173 | public function float($name, $total = 8, $places = 2): PropertyDefinition 174 | { 175 | return $this->addColumn('float', $name); 176 | } 177 | 178 | /** 179 | * @param string $name 180 | * @param array $parameters 181 | * 182 | * @return PropertyDefinition 183 | */ 184 | public function floatRange(string $name, array $parameters = []): PropertyDefinition 185 | { 186 | return $this->range('float_range', $name, $parameters); 187 | } 188 | 189 | /** 190 | * @param string $name 191 | * @param array $parameters 192 | * 193 | * @return PropertyDefinition 194 | */ 195 | public function geoPoint(string $name, array $parameters = []): PropertyDefinition 196 | { 197 | return $this->addColumn('geo_point', $name, $parameters); 198 | } 199 | 200 | /** 201 | * @param string $name 202 | * @param array $parameters 203 | * 204 | * @return PropertyDefinition 205 | */ 206 | public function geoShape(string $name, array $parameters = []): PropertyDefinition 207 | { 208 | return $this->addColumn('geo_shape', $name, $parameters); 209 | } 210 | 211 | /** 212 | * @return string 213 | */ 214 | public function getAlias(): string 215 | { 216 | return ($this->alias ?? $this->getTable()) . Config::get('database.connections.elasticsearch.suffix'); 217 | } 218 | 219 | /** 220 | * @return string 221 | */ 222 | public function getDocumentType(): string 223 | { 224 | return $this->document ?? Str::singular($this->getTable()); 225 | } 226 | 227 | /** 228 | * @return string 229 | */ 230 | public function getIndex(): string 231 | { 232 | $suffix = Config::get('database.connections.elasticsearch.suffix'); 233 | $timestamp = Carbon::now()->format('Y_m_d_His'); 234 | 235 | return "{$timestamp}_{$this->getTable()}" . $suffix; 236 | } 237 | 238 | /** 239 | * @return array 240 | */ 241 | public function getIndexSettings(): array 242 | { 243 | return $this->indexSettings; 244 | } 245 | 246 | /** 247 | * @return array 248 | */ 249 | public function getMeta(): array 250 | { 251 | return $this->meta; 252 | } 253 | 254 | /** 255 | * @param string $name 256 | * 257 | * @return PropertyDefinition 258 | */ 259 | public function integer($name, $autoIncrement = false, $unsigned = false): PropertyDefinition 260 | { 261 | return $this->addColumn('integer', $name); 262 | } 263 | 264 | /** 265 | * @param string $name 266 | * @param array $parameters 267 | * 268 | * @return PropertyDefinition 269 | */ 270 | public function integerRange(string $name, array $parameters = []): PropertyDefinition 271 | { 272 | return $this->range('integer_range', $name, $parameters); 273 | } 274 | 275 | /** 276 | * @param string $name 277 | * @param array $parameters 278 | * 279 | * @return PropertyDefinition 280 | */ 281 | public function ip(string $name, array $parameters = []): PropertyDefinition 282 | { 283 | return $this->ipAddress($name, $parameters); 284 | } 285 | 286 | /** 287 | * @param string $name 288 | * @param array $parameters 289 | * 290 | * @return PropertyDefinition 291 | */ 292 | public function ipAddress($name, array $parameters = []): PropertyDefinition 293 | { 294 | return $this->addColumn('ip', $name, $parameters); 295 | } 296 | 297 | /** 298 | * @param string $name 299 | * @param array $parameters 300 | * 301 | * @return PropertyDefinition 302 | */ 303 | public function ipRange(string $name, array $parameters = []): PropertyDefinition 304 | { 305 | return $this->range('ip_range', $name, $parameters); 306 | } 307 | 308 | /** 309 | * @param string $name 310 | * @param array $relations 311 | * 312 | * @return PropertyDefinition 313 | */ 314 | public function join(string $name, array $relations): PropertyDefinition 315 | { 316 | return $this->addColumn('join', $name, compact('relations')); 317 | } 318 | 319 | /** 320 | * @param string $name 321 | * 322 | * @return \Illuminate\Database\Schema\ColumnDefinition 323 | */ 324 | public function keyword(string $name, array $parameters = []): PropertyDefinition 325 | { 326 | return $this->addColumn('keyword', $name, $parameters); 327 | } 328 | 329 | /** 330 | * @param string $name 331 | * 332 | * @return PropertyDefinition 333 | */ 334 | public function long(string $name):PropertyDefinition 335 | { 336 | return $this->addColumn('long', $name); 337 | } 338 | 339 | /** 340 | * @param string $name 341 | * @param array $parameters 342 | * 343 | * @return PropertyDefinition 344 | */ 345 | public function longRange(string $name, array $parameters = []): PropertyDefinition 346 | { 347 | return $this->range('long_range', $name, $parameters); 348 | } 349 | 350 | /** 351 | * @param array $meta 352 | */ 353 | public function meta(array $meta): void 354 | { 355 | $this->addMetaField('_meta', $meta); 356 | } 357 | 358 | /** 359 | * @param string $name 360 | * @param \Closure $parameters 361 | * 362 | * @return PropertyDefinition 363 | */ 364 | public function nested(string $name): PropertyDefinition 365 | { 366 | return $this->addColumn('nested', $name); 367 | } 368 | 369 | /** 370 | * @param string $name 371 | * @param \Closure $parameters 372 | * 373 | * @return PropertyDefinition|\Illuminate\Database\Schema\ColumnDefinition 374 | */ 375 | public function object(string $name) 376 | { 377 | return $this->addColumn(null, $name); 378 | } 379 | 380 | /** 381 | * @param string $name 382 | * @param array $parameters 383 | * 384 | * @return PropertyDefinition 385 | */ 386 | public function percolator(string $name, array $parameters = []): PropertyDefinition 387 | { 388 | return $this->addColumn('percolator', $name, $parameters); 389 | } 390 | 391 | /** 392 | * @param string $type 393 | * @param string $name 394 | * @param array $parameters 395 | * 396 | * @return PropertyDefinition 397 | */ 398 | public function range(string $type, string $name, array $parameters = []): PropertyDefinition 399 | { 400 | return $this->addColumn($type, $name, $parameters); 401 | } 402 | 403 | /** 404 | * @return void 405 | */ 406 | public function routingRequired(): void 407 | { 408 | $this->addMetaField('_routing', ['required' => true]); 409 | } 410 | 411 | /** 412 | * @param string $name 413 | * @param array $length 414 | * 415 | * @return PropertyDefinition 416 | */ 417 | public function string($name, $parameters = null): PropertyDefinition 418 | { 419 | return $this->text($name, $parameters ?? []); 420 | } 421 | 422 | /** 423 | * @param string $name 424 | * @param array|null $parameters 425 | * 426 | * @return PropertyDefinition 427 | */ 428 | public function text($name, array $parameters = []): PropertyDefinition 429 | { 430 | return $this->addColumn('text', $name, $parameters); 431 | } 432 | 433 | /** 434 | * @param Connection $connection 435 | * @param Grammar $grammar 436 | * @return \Closure[] 437 | */ 438 | public function toSql(Connection $connection, Grammar $grammar) 439 | { 440 | $this->addImpliedCommands($grammar); 441 | 442 | $statements = []; 443 | 444 | // Each type of command has a corresponding compiler function on the schema 445 | // grammar which is used to build the necessary SQL statements to build 446 | // the blueprint element, so we'll just call that compilers function. 447 | $this->ensureCommandsAreValid($connection); 448 | 449 | foreach ($this->commands as $command) { 450 | $method = 'compile' . ucfirst($command->name); 451 | 452 | if (method_exists($grammar, $method)) { 453 | if (!is_null($statement = $grammar->$method($this, $command, $connection))) { 454 | $statements[] = $statement; 455 | } 456 | } 457 | } 458 | 459 | return $statements; 460 | } 461 | 462 | /** 463 | * @param string $name 464 | * @param array $parameters 465 | * 466 | * @return PropertyDefinition 467 | */ 468 | public function tokenCount(string $name, array $parameters = []): PropertyDefinition 469 | { 470 | return $this->addColumn('token_count', $name, $parameters); 471 | } 472 | 473 | /** 474 | * @return \Illuminate\Support\Fluent 475 | */ 476 | public function update() 477 | { 478 | return $this->addCommand('update'); 479 | } 480 | } 481 | -------------------------------------------------------------------------------- /src/Database/Schema/ElasticsearchBuilder.php: -------------------------------------------------------------------------------- 1 | table($table, $callback); 21 | } 22 | 23 | /** 24 | * @param string $table 25 | * @param Closure $callback 26 | */ 27 | public function table($table, Closure $callback) 28 | { 29 | $this->build(tap($this->createBlueprint($table), function (Blueprint $blueprint) use ($callback) { 30 | $blueprint->update(); 31 | 32 | $callback($blueprint); 33 | })); 34 | } 35 | 36 | /** 37 | * @inheritDoc 38 | */ 39 | protected function createBlueprint($table, Closure $callback = null) 40 | { 41 | return new Blueprint($table, $callback); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Database/Schema/Grammars/ElasticsearchGrammar.php: -------------------------------------------------------------------------------- 1 | array_merge(['properties' => $this->getColumns($blueprint)], $blueprint->getMeta()), 33 | ]; 34 | 35 | if ($settings = $blueprint->getIndexSettings()) { 36 | $body['settings'] = $settings; 37 | } 38 | 39 | $connection->createIndex( 40 | $index = $blueprint->getIndex(), $body 41 | ); 42 | 43 | $alias = $blueprint->getAlias(); 44 | 45 | if (!$connection->indices()->existsAlias(['name' => $alias])) { 46 | $connection->createAlias($index, $alias); 47 | } 48 | }; 49 | } 50 | 51 | /** 52 | * @param Blueprint $blueprint 53 | * @param Fluent $command 54 | * @param Connection $connection 55 | * @return Closure 56 | */ 57 | public function compileDrop(Blueprint $blueprint, Fluent $command, Connection $connection): Closure 58 | { 59 | return function (Blueprint $blueprint, Connection $connection): void { 60 | $connection->dropIndex( 61 | collect($connection->cat()->indices())->sort()->last()['index'] 62 | ); 63 | }; 64 | } 65 | 66 | /** 67 | * @param Blueprint $blueprint 68 | * @param Fluent $command 69 | * @param Connection $connection 70 | * @return Closure 71 | */ 72 | public function compileDropIfExists(Blueprint $blueprint, Fluent $command, Connection $connection): Closure 73 | { 74 | return function (Blueprint $blueprint, Connection $connection): void { 75 | $index = collect($connection->cat()->indices())->sort()->last(); 76 | 77 | if ($index && Str::contains($index['index'], $blueprint->getTable())) { 78 | $connection->dropIndex($index['index']); 79 | } 80 | }; 81 | } 82 | 83 | /** 84 | * @param Blueprint $blueprint 85 | * @param Fluent $command 86 | * @param Connection $connection 87 | * 88 | * @return Closure 89 | */ 90 | public function compileUpdate(Blueprint $blueprint, Fluent $command, Connection $connection): Closure 91 | { 92 | return function (Blueprint $blueprint, Connection $connection): void { 93 | $connection->updateIndex( 94 | $blueprint->getAlias(), 95 | array_merge( 96 | ['properties' => $this->getColumns($blueprint)], 97 | $blueprint->getMeta() 98 | ) 99 | ); 100 | }; 101 | } 102 | 103 | /** 104 | * @inheritDoc 105 | */ 106 | protected function addModifiers($sql, BaseBlueprint $blueprint, Fluent $property) 107 | { 108 | foreach ($this->modifiers as $modifier) { 109 | if (method_exists($this, $method = "modify{$modifier}")) { 110 | $property = $this->{$method}($blueprint, $property); 111 | } 112 | } 113 | 114 | return $property; 115 | } 116 | 117 | /** 118 | * @param Blueprint $blueprint 119 | * @param Fluent $property 120 | * 121 | * @return Fluent 122 | */ 123 | protected function format(Blueprint $blueprint, Fluent $property) 124 | { 125 | if (!is_string($property->format)) { 126 | throw new \InvalidArgumentException('Format modifier must be a string', 400); 127 | } 128 | 129 | return $property; 130 | } 131 | 132 | /** 133 | * @param BaseBlueprint $blueprint 134 | * 135 | * @return array 136 | */ 137 | protected function getColumns(BaseBlueprint $blueprint) 138 | { 139 | $columns = []; 140 | 141 | foreach ($blueprint->getAddedColumns() as $property) { 142 | // Pass empty string as we only need to modify the property and return it. 143 | $column = $this->addModifiers('', $blueprint, $property); 144 | $key = Str::snake($column->name); 145 | unset($column->name); 146 | 147 | $columns[$key] = $column->toArray(); 148 | } 149 | 150 | return $columns; 151 | } 152 | 153 | /** 154 | * @param Blueprint $blueprint 155 | * @param Fluent $property 156 | * 157 | * @return Fluent 158 | */ 159 | protected function modifyBoost(Blueprint $blueprint, Fluent $property) 160 | { 161 | if (!is_null($property->boost) && !is_numeric($property->boost)) { 162 | throw new \InvalidArgumentException('Boost modifier must be numeric', 400); 163 | } 164 | 165 | return $property; 166 | } 167 | 168 | /** 169 | * @param Blueprint $blueprint 170 | * @param Fluent $property 171 | * 172 | * @return Fluent 173 | */ 174 | protected function modifyDynamic(Blueprint $blueprint, Fluent $property) 175 | { 176 | if (!is_null($property->dynamic) && !is_bool($property->dynamic)) { 177 | throw new \InvalidArgumentException('Dynamic modifier must be a boolean', 400); 178 | } 179 | 180 | return $property; 181 | } 182 | 183 | /** 184 | * @param Blueprint $blueprint 185 | * @param Fluent $property 186 | * 187 | * @return Fluent 188 | */ 189 | protected function modifyFields(Blueprint $blueprint, Fluent $property) 190 | { 191 | if (!is_null($property->fields)) { 192 | $fields = $property->fields; 193 | $fields($blueprint = $this->createBlueprint()); 194 | 195 | $property->fields = $this->getColumns($blueprint); 196 | } 197 | 198 | return $property; 199 | } 200 | 201 | /** 202 | * @param Blueprint $blueprint 203 | * @param Fluent $property 204 | * 205 | * @return Fluent 206 | */ 207 | protected function modifyProperties(Blueprint $blueprint, Fluent $property) 208 | { 209 | if (!is_null($property->properties)) { 210 | $properties = $property->properties; 211 | $properties($blueprint = $this->createBlueprint()); 212 | 213 | $property->properties = $this->getColumns($blueprint); 214 | } 215 | 216 | return $property; 217 | } 218 | 219 | /** 220 | * @return Blueprint 221 | */ 222 | private function createBlueprint(): Blueprint 223 | { 224 | return new Blueprint(''); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/Database/Schema/PropertyDefinition.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 38 | $this->commands($this->commands); 39 | } 40 | 41 | $this->publishes([ 42 | __DIR__ . '/Config/laravel-elasticsearch.php' => config_path('laravel-elasticsearch.php') 43 | ]); 44 | } 45 | 46 | /** 47 | * Register the service provider. 48 | * 49 | * @return void 50 | */ 51 | public function register() 52 | { 53 | // Add database driver. 54 | $this->app->resolving('db', function (DatabaseManager $db) { 55 | $db->extend('elasticsearch', function ($config, $name) { 56 | $config['name'] = $name; 57 | return new Connection($config); 58 | }); 59 | }); 60 | 61 | $this->mergeConfigFrom(__DIR__ . '/Config/database.php', 'database'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/EloquentBuilder.php: -------------------------------------------------------------------------------- 1 | model = $model; 30 | 31 | $this->query->from($model->getSearchIndex()); 32 | 33 | $this->query->type($model->getSearchType()); 34 | 35 | return $this; 36 | } 37 | 38 | /** 39 | * Execute the query as a "select" statement. 40 | * 41 | * @param array $columns 42 | * @return \Illuminate\Database\Eloquent\Collection|static[] 43 | */ 44 | public function get($columns = ['*']) 45 | { 46 | $builder = $this->applyScopes(); 47 | 48 | $models = $builder->getModels($columns); 49 | 50 | // If we actually found models we will also eager load any relationships that 51 | // have been specified as needing to be eager loaded, which will solve the 52 | // n+1 query issue for the developers to avoid running a lot of queries. 53 | if (count($models) > 0) { 54 | $models = $builder->eagerLoadRelations($models); 55 | } 56 | 57 | return $builder->getModel()->newCollection($models); 58 | } 59 | 60 | /** 61 | * @param string $columns 62 | * @return int 63 | */ 64 | public function count($columns = '*'): int 65 | { 66 | return $this->toBase()->getCountForPagination($columns); 67 | } 68 | 69 | /** 70 | * @param string $collectionClass 71 | * @return Collection 72 | */ 73 | public function getAggregations(string $collectionClass = ''): Collection 74 | { 75 | $collectionClass = $collectionClass ?: Collection::class; 76 | $aggregations = $this->query->getAggregationResults(); 77 | 78 | return new $collectionClass($aggregations); 79 | } 80 | 81 | /** 82 | * Get the hydrated models without eager loading. 83 | * 84 | * @param array $columns 85 | * @return \Illuminate\Database\Eloquent\Model[] 86 | */ 87 | public function getModels($columns = ['*']) 88 | { 89 | return $this->model->hydrate( 90 | $this->query->get($columns)->all() 91 | )->all(); 92 | } 93 | 94 | /** 95 | * @inheritdoc 96 | */ 97 | public function hydrate(array $items) 98 | { 99 | $instance = $this->newModelInstance(); 100 | 101 | return $instance->newCollection(array_map(function ($item) use ($instance) { 102 | return $instance->newFromBuilder($item, $this->getConnection()->getName()); 103 | }, $items)); 104 | } 105 | 106 | /** 107 | * Get a generator for the given query. 108 | * 109 | * @return Generator 110 | */ 111 | public function cursor() 112 | { 113 | foreach ($this->applyScopes()->query->cursor() as $record) { 114 | yield $this->model->newFromBuilder($record); 115 | } 116 | } 117 | 118 | /** 119 | * Paginate the given query. 120 | * 121 | * @param int $perPage 122 | * @param array $columns 123 | * @param string $pageName 124 | * @param int|null $page 125 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 126 | * 127 | * @throws \InvalidArgumentException 128 | */ 129 | public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) 130 | { 131 | $page = $page ?: Paginator::resolveCurrentPage($pageName); 132 | 133 | $perPage = $perPage ?: $this->model->getPerPage(); 134 | 135 | $results = $this->forPage($page, $perPage)->get($columns); 136 | 137 | $total = $this->toBase()->getCountForPagination($columns); 138 | 139 | return new LengthAwarePaginator($results, $total, $perPage, $page, [ 140 | 'path' => Paginator::resolveCurrentPath(), 141 | 'pageName' => $pageName, 142 | ]); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Exceptions/BulkInsertQueryException.php: -------------------------------------------------------------------------------- 1 | formatMessage($queryResult), 400); 19 | } 20 | 21 | /** 22 | * Format the error message. 23 | * 24 | * Takes the first {$this->errorLimit} bulk issues and concatenates them to a single string message 25 | * 26 | * @param array $result 27 | * @return string 28 | */ 29 | private function formatMessage(array $result): string 30 | { 31 | $message = []; 32 | 33 | $items = array_filter($result['items'] ?? [], function(array $item): bool { 34 | return $item['index'] && !empty($item['index']['error']); 35 | }); 36 | 37 | $items = array_values($items); 38 | 39 | $totalErrors = count($items); 40 | 41 | // reduce to max limit 42 | array_splice($items, 0, $this->errorLimit); 43 | 44 | $message[] = 'Bulk Insert Errors (' . 'Showing ' . count($items) . ' of ' . $totalErrors . '):'; 45 | 46 | foreach ($items as $item) { 47 | $itemError = array_merge([ 48 | '_id' => $item['_id'], 49 | 'reason' => $item['error']['reason'], 50 | ], $item['error']['caused_by'] ?? []); 51 | 52 | $message[] = implode(': ', $itemError); 53 | } 54 | 55 | return implode(PHP_EOL, $message); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Exceptions/QueryException.php: -------------------------------------------------------------------------------- 1 | getMessage(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | false, 23 | 'TRUE' => true, 24 | ]; 25 | 26 | /** @var string[] */ 27 | public const DELETE_CONFLICT = [ 28 | 'ABORT' => 'abort', 29 | 'PROCEED' => 'proceed', 30 | ]; 31 | 32 | public $type; 33 | 34 | public $filters; 35 | 36 | public $postFilters; 37 | 38 | public $aggregations; 39 | 40 | public $includeInnerHits; 41 | 42 | protected $parentId; 43 | 44 | protected $results; 45 | 46 | /** @var int */ 47 | protected $resultsOffset; 48 | 49 | protected $rawResponse; 50 | 51 | protected $routing; 52 | 53 | /** @var mixed[] */ 54 | protected $options; 55 | 56 | /** 57 | * All of the supported clause operators. 58 | * 59 | * @var array 60 | */ 61 | public $operators = ['=', '<', '>', '<=', '>=', '!=', 'exists', 'like']; 62 | 63 | /** 64 | * Set the document type the search is targeting. 65 | * 66 | * @param string $type 67 | * 68 | * @return Builder 69 | */ 70 | public function type($type): self 71 | { 72 | $this->type = $type; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * Set the parent ID to be used when routing queries to Elasticsearch 79 | * 80 | * @param string $id 81 | * @return Builder 82 | */ 83 | public function parentId(string $id): self 84 | { 85 | $this->parentId = $id; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Get the parent ID to be used when routing queries to Elasticsearch 92 | * 93 | * @return string|null 94 | */ 95 | public function getParentId(): ?string 96 | { 97 | return $this->parentId; 98 | } 99 | 100 | /** 101 | * @param string $routing 102 | * @return QueryBuilder 103 | */ 104 | public function routing(string $routing): self 105 | { 106 | $this->routing = $routing; 107 | 108 | return $this; 109 | } 110 | 111 | /** 112 | * @return string|null 113 | */ 114 | public function getRouting(): ?string 115 | { 116 | return $this->routing; 117 | } 118 | 119 | /** 120 | * @return mixed|null 121 | */ 122 | public function getOption(string $option) 123 | { 124 | return $this->options[$option] ?? null; 125 | } 126 | 127 | /** 128 | * Add a where between statement to the query. 129 | * 130 | * @param string $column 131 | * @param array $values 132 | * @param string $boolean 133 | * @param bool $not 134 | * @return self 135 | */ 136 | public function whereBetween($column, array $values, $boolean = 'and', $not = false): self 137 | { 138 | $type = 'Between'; 139 | 140 | $this->wheres[] = compact('column', 'values', 'type', 'boolean', 'not'); 141 | 142 | return $this; 143 | } 144 | 145 | /** 146 | * Add a 'distance from point' statement to the query. 147 | * 148 | * @param string $column 149 | * @param array $coords 150 | * @param string $distance 151 | * @param string $boolean 152 | * @param bool $not 153 | * @return self 154 | */ 155 | public function whereGeoDistance($column, array $location, $distance, $boolean = 'and', bool $not = false): self 156 | { 157 | $type = 'GeoDistance'; 158 | 159 | $this->wheres[] = compact('column', 'location', 'distance', 'type', 'boolean', 'not'); 160 | 161 | return $this; 162 | } 163 | 164 | /** 165 | * Add a 'distance from point' statement to the query. 166 | * 167 | * @param string $column 168 | * @param array $bounds 169 | * @return self 170 | */ 171 | public function whereGeoBoundsIn($column, array $bounds): self 172 | { 173 | $type = 'GeoBoundsIn'; 174 | 175 | $this->wheres[] = [ 176 | 'column' => $column, 177 | 'bounds' => $bounds, 178 | 'type' => 'GeoBoundsIn', 179 | 'boolean' => 'and', 180 | 'not' => false, 181 | ]; 182 | 183 | return $this; 184 | } 185 | 186 | /** 187 | * Add a "where date" statement to the query. 188 | * 189 | * @param string $column 190 | * @param string $operator 191 | * @param mixed $value 192 | * @param string $boolean 193 | * @return \Illuminate\Database\Query\Builder|static 194 | */ 195 | public function whereDate($column, $operator, $value = null, $boolean = 'and', $not = false): self 196 | { 197 | [$value, $operator] = $this->prepareValueAndOperator( 198 | $value, 199 | $operator, 200 | func_num_args() == 2 201 | ); 202 | 203 | $type = 'Date'; 204 | 205 | $this->wheres[] = compact('column', 'operator', 'value', 'type', 'boolean', 'not'); 206 | 207 | return $this; 208 | } 209 | 210 | /** 211 | * Add a 'nested document' statement to the query. 212 | * 213 | * @param string $column 214 | * @param callable|\Illuminate\Database\Query\Builder|static $query 215 | * @param string $boolean 216 | * @return self 217 | */ 218 | public function whereNestedDoc($column, $query, $boolean = 'and'): self 219 | { 220 | $type = 'NestedDoc'; 221 | 222 | if (!is_string($query) && is_callable($query)) { 223 | call_user_func($query, $query = $this->newQuery()); 224 | } 225 | 226 | $this->wheres[] = compact('column', 'query', 'type', 'boolean'); 227 | 228 | return $this; 229 | } 230 | 231 | /** 232 | * Add a 'must not' statement to the query. 233 | * 234 | * @param \Illuminate\Database\Query\Builder|static $query 235 | * @param string $boolean 236 | * @return self 237 | */ 238 | public function whereNot($query, $boolean = 'and'): self 239 | { 240 | $type = 'Not'; 241 | 242 | call_user_func($query, $query = $this->newQuery()); 243 | 244 | $this->wheres[] = compact('query', 'type', 'boolean'); 245 | 246 | return $this; 247 | } 248 | 249 | /** 250 | * Add a prefix query 251 | * 252 | * @param string $column 253 | * @param string $value 254 | * @param string $boolean 255 | * @param boolean $not 256 | * @return self 257 | */ 258 | public function whereStartsWith($column, string $value, $boolean = 'and', $not = false): self 259 | { 260 | $type = 'Prefix'; 261 | 262 | $this->wheres[] = compact('column', 'value', 'type', 'boolean', 'not'); 263 | 264 | return $this; 265 | } 266 | 267 | /** 268 | * Add a script query 269 | * 270 | * @param string $script 271 | * @param array $options 272 | * @param string $boolean 273 | * @return self 274 | */ 275 | public function whereScript(string $script, array $options = [], $boolean = 'and'): self 276 | { 277 | $type = 'Script'; 278 | 279 | $this->wheres[] = compact('script', 'options', 'type', 'boolean'); 280 | 281 | return $this; 282 | } 283 | 284 | /** 285 | * Add a "where weekday" statement to the query. 286 | * 287 | * @param string $column 288 | * @param string $operator 289 | * @param \DateTimeInterface|string $value 290 | * @param string $boolean 291 | * @return \Illuminate\Database\Query\Builder|static 292 | */ 293 | public function whereWeekday($column, $operator, $value = null, $boolean = 'and') 294 | { 295 | [$value, $operator] = $this->prepareValueAndOperator( 296 | $value, 297 | $operator, 298 | func_num_args() === 2 299 | ); 300 | 301 | if ($value instanceof DateTimeInterface) { 302 | $value = $value->format('N'); 303 | } 304 | 305 | return $this->addDateBasedWhere('Weekday', $column, $operator, $value, $boolean); 306 | } 307 | 308 | /** 309 | * Add an "or where weekday" statement to the query. 310 | * 311 | * @param string $column 312 | * @param string $operator 313 | * @param \DateTimeInterface|string $value 314 | * @return \Illuminate\Database\Query\Builder|static 315 | */ 316 | public function orWhereWeekday($column, $operator, $value = null) 317 | { 318 | [$value, $operator] = $this->prepareValueAndOperator( 319 | $value, 320 | $operator, 321 | func_num_args() === 2 322 | ); 323 | 324 | return $this->addDateBasedWhere('Weekday', $column, $operator, $value, 'or'); 325 | } 326 | 327 | /** 328 | * Add a date based (year, month, day, time) statement to the query. 329 | * 330 | * @param string $type 331 | * @param string $column 332 | * @param string $operator 333 | * @param mixed $value 334 | * @param string $boolean 335 | * @return $this 336 | */ 337 | protected function addDateBasedWhere($type, $column, $operator, $value, $boolean = 'and') 338 | { 339 | switch ($type) { 340 | case 'Year': 341 | $dateType = 'year'; 342 | break; 343 | 344 | case 'Month': 345 | $dateType = 'monthOfYear'; 346 | break; 347 | 348 | case 'Day': 349 | $dateType = 'dayOfMonth'; 350 | break; 351 | 352 | case 'Weekday': 353 | $dateType = 'dayOfWeek'; 354 | break; 355 | } 356 | 357 | $type = 'Script'; 358 | 359 | $operator = $operator == '=' ? '==' : $operator; 360 | 361 | $script = "doc.{$column}.size() > 0 && doc.{$column}.date.{$dateType} {$operator} params.value"; 362 | 363 | $options['params'] = ['value' => (int)$value]; 364 | 365 | $this->wheres[] = compact('script', 'options', 'type', 'boolean'); 366 | 367 | return $this; 368 | } 369 | 370 | /** 371 | * Add another query builder as a nested where to the query builder. 372 | * 373 | * @param \Illuminate\Database\Query\Builder|static $query 374 | * @param string $boolean 375 | * @return self 376 | */ 377 | public function addNestedWhereQuery($query, $boolean = 'and'): self 378 | { 379 | $type = 'Nested'; 380 | 381 | $compiled = compact('type', 'query', 'boolean'); 382 | 383 | if (count($query->wheres)) { 384 | $this->wheres[] = $compiled; 385 | } 386 | 387 | if (isset($query->filters) && count($query->filters)) { 388 | $this->filters[] = $compiled; 389 | } 390 | 391 | return $this; 392 | } 393 | 394 | /** 395 | * Add any where clause with given options. 396 | * 397 | * @return self 398 | */ 399 | public function whereWithOptions(...$args): self 400 | { 401 | $options = array_pop($args); 402 | $type = array_shift($args); 403 | $method = $type == 'Basic' ? 'where' : 'where' . $type; 404 | 405 | $this->$method(...$args); 406 | 407 | $this->wheres[count($this->wheres) - 1]['options'] = $options; 408 | 409 | return $this; 410 | } 411 | 412 | /** 413 | * Add a filter query by calling the required 'where' method 414 | * and capturing the added where as a filter 415 | * 416 | * @param string $method 417 | * @param array $args 418 | * @return self 419 | */ 420 | public function dynamicFilter(string $method, array $args): self 421 | { 422 | $method = lcfirst(substr($method, 6)); 423 | 424 | $numWheres = count($this->wheres); 425 | 426 | $this->$method(...$args); 427 | 428 | $filterType = array_pop($args) === 'postFilter' ? 'postFilters' : 'filters'; 429 | 430 | if (count($this->wheres) > $numWheres) { 431 | $this->$filterType[] = array_pop($this->wheres); 432 | } 433 | 434 | return $this; 435 | } 436 | 437 | /** 438 | * Add a text search clause to the query. 439 | * 440 | * @param string $query 441 | * @param array $options 442 | * @param string $boolean 443 | * @return self 444 | */ 445 | public function search($query, $options = [], $boolean = 'and'): self 446 | { 447 | $this->wheres[] = [ 448 | 'type' => 'Search', 449 | 'value' => $query, 450 | 'boolean' => $boolean, 451 | 'options' => $options, 452 | ]; 453 | 454 | return $this; 455 | } 456 | 457 | /** 458 | * @param string $parentType Name of the parent relation from the join mapping 459 | * @param mixed $id 460 | * @param string $boolean 461 | * @return QueryBuilder 462 | */ 463 | public function whereParentId(string $parentType, $id, string $boolean = 'and'): self 464 | { 465 | $this->wheres[] = [ 466 | 'type' => 'ParentId', 467 | 'parentType' => $parentType, 468 | 'id' => $id, 469 | 'boolean' => $boolean, 470 | ]; 471 | 472 | return $this; 473 | } 474 | 475 | /** 476 | * Add a where parent statement to the query. 477 | * 478 | * @param string $documentType 479 | * @param \Closure $callback 480 | * @param array $options 481 | * @param string $boolean 482 | * @return \Illuminate\Database\Query\Builder|static 483 | */ 484 | public function whereParent( 485 | string $documentType, 486 | Closure $callback, 487 | array $options = [], 488 | string $boolean = 'and' 489 | ): self { 490 | return $this->whereRelationship('parent', $documentType, $callback, $options, $boolean); 491 | } 492 | 493 | /** 494 | * Add a where child statement to the query. 495 | * 496 | * @param string $documentType 497 | * @param \Closure $callback 498 | * @param array $options 499 | * @param string $boolean 500 | * @return \Illuminate\Database\Query\Builder|static 501 | */ 502 | public function whereChild( 503 | string $documentType, 504 | Closure $callback, 505 | array $options = [], 506 | string $boolean = 'and' 507 | ): self { 508 | return $this->whereRelationship('child', $documentType, $callback, $options, $boolean); 509 | } 510 | 511 | /** 512 | * Add a where relationship statement to the query. 513 | * 514 | * @param string $relationshipType 515 | * @param string $documentType 516 | * @param \Closure $callback 517 | * @param array $options 518 | * @param string $boolean 519 | * 520 | * @return \Illuminate\Database\Query\Builder|static 521 | */ 522 | protected function whereRelationship( 523 | string $relationshipType, 524 | string $documentType, 525 | Closure $callback, 526 | array $options = [], 527 | string $boolean = 'and' 528 | ): self { 529 | call_user_func($callback, $query = $this->newQuery()); 530 | 531 | $this->wheres[] = [ 532 | 'type' => ucfirst($relationshipType), 533 | 'documentType' => $documentType, 534 | 'value' => $query, 535 | 'options' => $options, 536 | 'boolean' => $boolean, 537 | ]; 538 | 539 | return $this; 540 | } 541 | 542 | /** 543 | * @param string $key 544 | * @param string $type 545 | * @param null $args 546 | * @param null $aggregations 547 | * @return self 548 | */ 549 | public function aggregation($key, $type = null, $args = null, $aggregations = null): self 550 | { 551 | if ($key instanceof Aggregation) { 552 | $aggregation = $key; 553 | 554 | $this->aggregations[] = [ 555 | 'key' => $aggregation->getKey(), 556 | 'type' => $aggregation->getType(), 557 | 'args' => $aggregation->getArguments(), 558 | 'aggregations' => $aggregation($this->newQuery()), 559 | ]; 560 | 561 | return $this; 562 | } 563 | 564 | if (!is_string($args) && is_callable($args)) { 565 | call_user_func($args, $args = $this->newQuery()); 566 | } 567 | 568 | if (!is_string($aggregations) && is_callable($aggregations)) { 569 | call_user_func($aggregations, $aggregations = $this->newQuery()); 570 | } 571 | 572 | $this->aggregations[] = compact( 573 | 'key', 574 | 'type', 575 | 'args', 576 | 'aggregations' 577 | ); 578 | 579 | return $this; 580 | } 581 | 582 | /** 583 | * @param string $column 584 | * @param int $direction 585 | * @param array $options 586 | * @return self 587 | */ 588 | public function orderBy($column, $direction = 1, $options = null): self 589 | { 590 | if (is_string($direction)) { 591 | $direction = strtolower($direction) == 'asc' ? 1 : -1; 592 | } 593 | 594 | $type = isset($options['type']) ? $options['type'] : 'basic'; 595 | 596 | $this->orders[] = compact('column', 'direction', 'type', 'options'); 597 | 598 | return $this; 599 | } 600 | 601 | /** 602 | * Whether to include inner hits in the response 603 | * 604 | * @return self 605 | */ 606 | public function withInnerHits(): self 607 | { 608 | $this->includeInnerHits = true; 609 | 610 | return $this; 611 | } 612 | 613 | /** 614 | * Set whether to refresh during delete by query 615 | * 616 | * @link https://www.elastic.co/guide/en/elasticsearch/reference/7.x/docs-delete-by-query.html#docs-delete-by-query-api-query-params 617 | * @link https://www.elastic.co/guide/en/elasticsearch/reference/7.x/docs-delete-by-query.html#_refreshing_shards 618 | * 619 | * @param string $option 620 | * @return self 621 | * @throws \Exception 622 | */ 623 | public function withRefresh($option = self::DELETE_REFRESH['FALSE']): self 624 | { 625 | if (in_array($option, self::DELETE_REFRESH)) { 626 | $this->options['delete_refresh'] = $option; 627 | 628 | return $this; 629 | } 630 | 631 | throw new \Exception( 632 | "$option is an invalid conflict option, valid options are: " . explode(', ', self::DELETE_CONFLICT) 633 | ); 634 | } 635 | 636 | /** 637 | * Set how to handle conflucts during a delete request 638 | * 639 | * @link https://www.elastic.co/guide/en/elasticsearch/reference/7.x/docs-delete-by-query.html#docs-delete-by-query-api-query-params 640 | * 641 | * @param string $option 642 | * @return self 643 | * @throws \Exception 644 | */ 645 | public function onConflicts(string $option = self::DELETE_CONFLICT['ABORT']): self 646 | { 647 | if (in_array($option, self::DELETE_CONFLICT)) { 648 | $this->options['delete_conflicts'] = $option; 649 | 650 | return $this; 651 | } 652 | 653 | throw new \Exception( 654 | "$option is an invalid conflict option, valid options are: " . explode(', ', self::DELETE_CONFLICT) 655 | ); 656 | } 657 | 658 | /** 659 | * Adds a function score of any type 660 | * 661 | * @param string $field 662 | * @param array $options see elastic search docs for options 663 | * @param string $boolean 664 | * @return self 665 | */ 666 | public function functionScore($functionType, $options = [], $boolean = 'and'): self 667 | { 668 | $where = [ 669 | 'type' => 'FunctionScore', 670 | 'function_type' => $functionType, 671 | 'boolean' => $boolean, 672 | ]; 673 | 674 | $this->wheres[] = array_merge($where, $options); 675 | 676 | return $this; 677 | } 678 | 679 | /** 680 | * Get the aggregations returned from query 681 | * 682 | * @return array 683 | */ 684 | public function getAggregationResults(): array 685 | { 686 | $this->getResultsOnce(); 687 | 688 | return $this->processor->getAggregationResults(); 689 | } 690 | 691 | /** 692 | * Execute the query as a "select" statement. 693 | * 694 | * @param array $columns 695 | * @return \Illuminate\Support\Collection 696 | */ 697 | public function get($columns = ['*']) 698 | { 699 | $original = $this->columns; 700 | 701 | if (is_null($original)) { 702 | $this->columns = $columns; 703 | } 704 | 705 | $results = $this->getResultsOnce(); 706 | 707 | $this->columns = $original; 708 | 709 | return collect($results); 710 | } 711 | 712 | /** 713 | * Get results without re-fetching for subsequent calls. 714 | * 715 | * @return array 716 | */ 717 | protected function getResultsOnce() 718 | { 719 | if (!$this->hasProcessedSelect()) { 720 | $this->results = $this->processor->processSelect($this, $this->runSelect()); 721 | } 722 | 723 | $this->resultsOffset = $this->offset; 724 | 725 | return $this->results; 726 | } 727 | 728 | /** 729 | * Run the query as a "select" statement against the connection. 730 | * 731 | * @return Iterable 732 | */ 733 | protected function runSelect() 734 | { 735 | return $this->connection->select($this->toCompiledQuery()); 736 | } 737 | 738 | /** 739 | * Get the count of the total records for the paginator. 740 | * 741 | * @param array $columns 742 | * @return int 743 | */ 744 | public function getCountForPagination($columns = ['*']) 745 | { 746 | if ($this->results === null) { 747 | $this->runPaginationCountQuery(); 748 | } 749 | 750 | $total = $this->processor->getRawResponse()['hits']['total']; 751 | 752 | return is_array($total) ? $total['value'] : $total; 753 | } 754 | 755 | /** 756 | * Run a pagination count query. 757 | * 758 | * @param array $columns 759 | * @return array 760 | */ 761 | protected function runPaginationCountQuery($columns = ['_id']) 762 | { 763 | return $this->cloneWithout(['columns', 'orders', 'limit', 'offset']) 764 | ->limit(1) 765 | ->get($columns)->all(); 766 | } 767 | 768 | /** 769 | * Get the time it took Elasticsearch to perform the query 770 | * 771 | * @return int time in milliseconds 772 | */ 773 | public function getSearchDuration() 774 | { 775 | if (!$this->hasProcessedSelect()) { 776 | $this->getResultsOnce(); 777 | } 778 | 779 | return $this->processor->getRawResponse()['took']; 780 | } 781 | 782 | /** 783 | * Get the Elasticsearch representation of the query. 784 | * 785 | * @return array 786 | */ 787 | public function toCompiledQuery(): array 788 | { 789 | return $this->toSql(); 790 | } 791 | 792 | /** 793 | * Get a generator for the given query. 794 | * 795 | * @return \Generator 796 | */ 797 | public function cursor() 798 | { 799 | if (is_null($this->columns)) { 800 | $this->columns = ['*']; 801 | } 802 | 803 | foreach ($this->connection->cursor($this->toCompiledQuery()) as $document) { 804 | yield $this->processor->documentFromResult($this, $document); 805 | } 806 | } 807 | 808 | /** 809 | * @inheritdoc 810 | */ 811 | public function insert(array $values): bool 812 | { 813 | // Since every insert gets treated like a batch insert, we will have to detect 814 | // if the user is inserting a single document or an array of documents. 815 | $batch = true; 816 | 817 | foreach ($values as $value) { 818 | // As soon as we find a value that is not an array we assume the user is 819 | // inserting a single document. 820 | if (!is_array($value)) { 821 | $batch = false; 822 | break; 823 | } 824 | } 825 | 826 | if (!$batch) { 827 | $values = [$values]; 828 | } 829 | 830 | return $this->connection->insert($this->grammar->compileInsert($this, $values)); 831 | } 832 | 833 | /** 834 | * @inheritdoc 835 | */ 836 | public function delete($id = null): bool 837 | { 838 | // If an ID is passed to the method, we will set the where clause to check the 839 | // ID to let developers to simply and quickly remove a single row from this 840 | // database without manually specifying the "where" clauses on the query. 841 | if (!is_null($id)) { 842 | $this->where($this->getKeyName(), '=', $id); 843 | } 844 | 845 | $result = $this->connection->delete($this->grammar->compileDelete($this)); 846 | 847 | return !empty($result['deleted']); 848 | } 849 | 850 | public function __call($method, $parameters) 851 | { 852 | if (Str::startsWith($method, 'filterWhere')) { 853 | return $this->dynamicFilter($method, $parameters); 854 | } 855 | 856 | return parent::__call($method, $parameters); 857 | } 858 | 859 | /** 860 | * @return bool 861 | */ 862 | protected function hasProcessedSelect(): bool 863 | { 864 | if ($this->results === null) { 865 | return false; 866 | } 867 | 868 | return $this->offset === $this->resultsOffset; 869 | } 870 | } 871 | -------------------------------------------------------------------------------- /src/QueryGrammar.php: -------------------------------------------------------------------------------- 1 | compileWheres($builder); 31 | 32 | $params = [ 33 | 'index' => $builder->from . $this->indexSuffix, 34 | 'body' => [ 35 | '_source' => $builder->columns && !in_array('*', $builder->columns) ? $builder->columns : true, 36 | 'query' => $query['query'] 37 | ], 38 | ]; 39 | 40 | if ($query['filter']) { 41 | $params['body']['query']['bool']['filter'] = $query['filter']; 42 | } 43 | 44 | if ($query['postFilter']) { 45 | $params['body']['post_filter'] = $query['postFilter']; 46 | } 47 | 48 | if ($builder->aggregations) { 49 | $params['body']['aggregations'] = $this->compileAggregations($builder); 50 | } 51 | 52 | // Apply order, offset and limit 53 | if ($builder->orders) { 54 | $params['body']['sort'] = $this->compileOrders($builder, $builder->orders); 55 | } 56 | 57 | if ($builder->offset) { 58 | $params['body']['from'] = $builder->offset; 59 | } 60 | 61 | if (isset($builder->limit)) { 62 | $params['body']['size'] = $builder->limit; 63 | } 64 | 65 | if (!$params['body']['query']) { 66 | unset($params['body']['query']); 67 | } 68 | 69 | // print "
";
  70 |         // print str_replace('    ', '  ', json_encode($params, JSON_PRETTY_PRINT));
  71 |         // exit;
  72 | 
  73 |         return $params;
  74 |     }
  75 | 
  76 |     /**
  77 |      * Compile where clauses for a query
  78 |      *
  79 |      * @param  Builder  $builder
  80 |      * @return array
  81 |      */
  82 |     public function compileWheres(Builder $builder): array
  83 |     {
  84 |         $queryParts = [
  85 |             'query' => 'wheres',
  86 |             'filter' => 'filters',
  87 |             'postFilter' => 'postFilters'
  88 |         ];
  89 | 
  90 |         $compiled = [];
  91 | 
  92 |         foreach ($queryParts as $queryPart => $builderVar) {
  93 |             $clauses = $builder->$builderVar ?? [];
  94 | 
  95 |             $compiled[$queryPart] = $this->compileClauses($builder, $clauses);
  96 |         }
  97 | 
  98 |         return $compiled;
  99 |     }
 100 | 
 101 |     /**
 102 |      * Compile general clauses for a query
 103 |      *
 104 |      * @param  Builder  $builder
 105 |      * @param  array  $clauses
 106 |      * @return array
 107 |      */
 108 |     protected function compileClauses(Builder $builder, array $clauses): array
 109 |     {
 110 |         $query = [];
 111 |         $isOr  = false;
 112 | 
 113 |         foreach ($clauses as $where) {
 114 | 
 115 |             if(isset($where['column']) && Str::startsWith($where['column'], $builder->from . '.')) {
 116 |                 $where['column'] = Str::replaceFirst($builder->from . '.', '', $where['column']);
 117 |             }
 118 | 
 119 |             // We use different methods to compile different wheres
 120 |             $method = 'compileWhere' . $where['type'];
 121 |             $result = $this->{$method}($builder, $where);
 122 | 
 123 |             // Wrap the result with a bool to make nested wheres work
 124 |             if (count($clauses) > 0 && $where['boolean'] !== 'or') {
 125 |                 $result = ['bool' => ['must' => [$result]]];
 126 |             }
 127 | 
 128 |             // If this is an 'or' query then add all previous parts to a 'should'
 129 |             if (!$isOr && $where['boolean'] == 'or') {
 130 |                 $isOr = true;
 131 | 
 132 |                 if ($query) {
 133 |                     $query = ['bool' => ['should' => [$query]]];
 134 |                 } else {
 135 |                     $query['bool']['should'] = [];
 136 |                 }
 137 |             }
 138 | 
 139 |             // Add the result to the should clause if this is an Or query
 140 |             if ($isOr) {
 141 |                 $query['bool']['should'][] = $result;
 142 |             } else {
 143 |                 // Merge the compiled where with the others
 144 |                 $query = array_merge_recursive($query, $result);
 145 |             }
 146 |         }
 147 | 
 148 |         return $query;
 149 |     }
 150 | 
 151 |     /**
 152 |      * Compile a general where clause
 153 |      *
 154 |      * @param  Builder  $builder
 155 |      * @param  array  $where
 156 |      * @return array
 157 |      */
 158 |     protected function compileWhereBasic(Builder $builder, array $where): array
 159 |     {
 160 |         $value = $this->getValueForWhere($builder, $where);
 161 | 
 162 |         $operatorsMap = [
 163 |             '>'  => 'gt',
 164 |             '>=' => 'gte',
 165 |             '<'  => 'lt',
 166 |             '<=' => 'lte',
 167 |         ];
 168 | 
 169 |         if (is_null($value) || $where['operator'] == 'exists') {
 170 |             $query = [
 171 |                 'exists' => [
 172 |                     'field' => $where['column'],
 173 |                 ],
 174 |             ];
 175 |         } else if ($where['operator'] == 'like') {
 176 |             $query = [
 177 |                 'wildcard' => [
 178 |                     $where['column'] => str_replace('%', '*', $value),
 179 |                 ],
 180 |             ];
 181 |         } else if (in_array($where['operator'], array_keys($operatorsMap))) {
 182 |             $operator = $operatorsMap[$where['operator']];
 183 |             $query = [
 184 |                 'range' => [
 185 |                     $where['column'] => [
 186 |                         $operator => $value,
 187 |                     ],
 188 |                 ],
 189 |             ];
 190 |         } else {
 191 |             $query = [
 192 |                 'term' => [
 193 |                     $where['column'] => $value,
 194 |                 ],
 195 |             ];
 196 |         }
 197 | 
 198 |         $query = $this->applyOptionsToClause($query, $where);
 199 | 
 200 |         if (
 201 |             !empty($where['not'])
 202 |             || ($where['operator'] == '!=' && !is_null($value))
 203 |             || ($where['operator'] == '=' && is_null($value))
 204 |             || ($where['operator'] == 'exists' && !$value)
 205 |         ) {
 206 |             $query = [
 207 |                 'bool' => [
 208 |                     'must_not' => [
 209 |                         $query,
 210 |                     ],
 211 |                 ],
 212 |             ];
 213 |         }
 214 | 
 215 |         return $query;
 216 |     }
 217 | 
 218 |     /**
 219 |      * Compile a date clause
 220 |      *
 221 |      * @param  Builder  $builder
 222 |      * @param  array  $where
 223 |      * @return array
 224 |      */
 225 |     protected function compileWhereDate(Builder $builder, array $where): array
 226 |     {
 227 |         if ($where['operator'] == '=') {
 228 |             $value = $this->getValueForWhere($builder, $where);
 229 | 
 230 |             $where['value'] = [$value, $value];
 231 | 
 232 |             return $this->compileWhereBetween($builder, $where);
 233 |         }
 234 | 
 235 |         return $this->compileWhereBasic($builder, $where);
 236 |     }
 237 | 
 238 |     /**
 239 |      * Compile a nested clause
 240 |      *
 241 |      * @param  Builder  $builder
 242 |      * @param  array  $where
 243 |      * @return array
 244 |      */
 245 |     protected function compileWhereNested(Builder $builder, array $where): array
 246 |     {
 247 |         $compiled = $this->compileWheres($where['query']);
 248 | 
 249 |         foreach ($compiled as $queryPart => $clauses) {
 250 |             $compiled[$queryPart] = array_map(function ($clause) use ($where) {
 251 |                 if ($clause) {
 252 |                     $this->applyOptionsToClause($clause, $where);
 253 |                 }
 254 | 
 255 |                 return $clause;
 256 |             }, $clauses);
 257 |         }
 258 | 
 259 |         $compiled = array_filter($compiled);
 260 | 
 261 |         return reset($compiled);
 262 |     }
 263 | 
 264 |     /**
 265 |      * Compile a relationship clause
 266 |      *
 267 |      * @param  Builder  $builder
 268 |      * @param  array  $where
 269 |      * @return array
 270 |      */
 271 |     protected function applyWhereRelationship(Builder $builder, array $where, string $relationship): array
 272 |     {
 273 |         $compiled = $this->compileWheres($where['value']);
 274 | 
 275 |         $relationshipFilter = "has_{$relationship}";
 276 |         $type = $relationship === 'parent' ? 'parent_type' : 'type';
 277 | 
 278 |         // pass filter to query if empty allowing a filter interface to be used in relation query
 279 |         // otherwise match all in relation query
 280 |         if(empty($compiled['query'])) {
 281 |             $compiled['query'] = empty($compiled['filter']) ? ['match_all' => (object) []] : $compiled['filter'];
 282 |         } else if(!empty($compiled['filter'])) {
 283 |             throw new InvalidArgumentException('Cannot use both filter and query contexts within a relation context');
 284 |         }
 285 | 
 286 |         $query = [
 287 |             $relationshipFilter => [
 288 |                 $type  => $where['documentType'],
 289 |                 'query' => $compiled['query'],
 290 |             ],
 291 |         ];
 292 | 
 293 |         $query = $this->applyOptionsToClause($query, $where);
 294 | 
 295 |         return $query;
 296 |     }
 297 | 
 298 |     /**
 299 |      * Compile a parent clause
 300 |      *
 301 |      * @param  Builder  $builder
 302 |      * @param  array  $where
 303 |      * @return array
 304 |      */
 305 |     protected function compileWhereParent(Builder $builder, array $where): array
 306 |     {
 307 |         return $this->applyWhereRelationship($builder, $where, 'parent');
 308 |     }
 309 | 
 310 |     /**
 311 |      * @param Builder $builder
 312 |      * @param array   $where
 313 |      * @return array
 314 |      */
 315 |     protected function compileWhereParentId(Builder $builder, array $where) {
 316 |         return [
 317 |             'parent_id' => [
 318 |                 'type' => $where['relationType'],
 319 |                 'id'   => $where['id'],
 320 |             ],
 321 |         ];
 322 |     }
 323 | 
 324 |     protected function compileWherePrefix(Builder $builder, array $where): array
 325 |     {
 326 |         $query = [
 327 |             'prefix' => [
 328 |                 $where['column'] => $where['value'],
 329 |             ]
 330 |         ];
 331 | 
 332 |         return $query;
 333 |     }
 334 | 
 335 |     /**
 336 |      * Compile a child clause
 337 |      *
 338 |      * @param  Builder  $builder
 339 |      * @param  array  $where
 340 |      * @return array
 341 |      */
 342 |     protected function compileWhereChild(Builder $builder, array $where): array
 343 |     {
 344 |         return $this->applyWhereRelationship($builder, $where, 'child');
 345 |     }
 346 | 
 347 |     /**
 348 |      * Compile an in clause
 349 |      *
 350 |      * @param  Builder  $builder
 351 |      * @param  array  $where
 352 |      * @return array
 353 |      */
 354 |     protected function compileWhereIn(Builder $builder, array $where, $not = false): array
 355 |     {
 356 |         $column = $where['column'];
 357 |         $values = $this->getValueForWhere($builder, $where);
 358 | 
 359 |         $query = [
 360 |             'terms' => [
 361 |                 $column => array_values($values),
 362 |             ],
 363 |         ];
 364 | 
 365 |         $query = $this->applyOptionsToClause($query, $where);
 366 | 
 367 |         if ($not) {
 368 |             $query = [
 369 |                 'bool' => [
 370 |                     'must_not' => [
 371 |                         $query,
 372 |                     ],
 373 |                 ],
 374 |             ];
 375 |         }
 376 | 
 377 |         return $query;
 378 |     }
 379 | 
 380 |     /**
 381 |      * Compile a not in clause
 382 |      *
 383 |      * @param  Builder  $builder
 384 |      * @param  array  $where
 385 |      * @return array
 386 |      */
 387 |     protected function compileWhereNotIn(Builder $builder, array $where): array
 388 |     {
 389 |         return $this->compileWhereIn($builder, $where, true);
 390 |     }
 391 | 
 392 |     /**
 393 |      * Compile a null clause
 394 |      *
 395 |      * @param  Builder  $builder
 396 |      * @param  array  $where
 397 |      * @return array
 398 |      */
 399 |     protected function compileWhereNull(Builder $builder, array $where): array
 400 |     {
 401 |         $where['operator'] = '=';
 402 |         return $this->compileWhereBasic($builder, $where);
 403 |     }
 404 | 
 405 |     /**
 406 |      * Compile a not null clause
 407 |      *
 408 |      * @param  Builder  $builder
 409 |      * @param  array  $where
 410 |      * @return array
 411 |      */
 412 |     protected function compileWhereNotNull(Builder $builder, array $where): array
 413 |     {
 414 |         $where['operator'] = '!=';
 415 |         return $this->compileWhereBasic($builder, $where);
 416 |     }
 417 | 
 418 |     /**
 419 |      * Compile a where between clause
 420 |      *
 421 |      * @param  Builder  $builder
 422 |      * @param  array  $where
 423 |      * @param  bool  $not
 424 |      * @return array
 425 |      */
 426 |     protected function compileWhereBetween(Builder $builder, array $where): array
 427 |     {
 428 |         $column = $where['column'];
 429 |         $values = $this->getValueForWhere($builder, $where);
 430 | 
 431 |         if ($where['not']) {
 432 |             $query = [
 433 |                 'bool' => [
 434 |                     'should' => [
 435 |                         [
 436 |                             'range' => [
 437 |                                 $column => [
 438 |                                     'lte' => $values[0],
 439 |                                 ],
 440 |                             ],
 441 |                         ],
 442 |                         [
 443 |                             'range' => [
 444 |                                 $column => [
 445 |                                     'gte' => $values[1],
 446 |                                 ],
 447 |                             ],
 448 |                         ],
 449 |                     ],
 450 |                 ],
 451 |             ];
 452 |         } else {
 453 |             $query = [
 454 |                 'range' => [
 455 |                     $column => [
 456 |                         'gte' => $values[0],
 457 |                         'lte' => $values[1]
 458 |                     ],
 459 |                 ],
 460 |             ];
 461 |         }
 462 | 
 463 |         return $query;
 464 |     }
 465 | 
 466 |      /**
 467 |      * Compile where for function score
 468 |      *
 469 |      * @param Builder $builder
 470 |      * @param array $where
 471 |      * @return array
 472 |      */
 473 |     protected function compileWhereFunctionScore(Builder $builder, array $where): array
 474 |     {
 475 |         $cleanWhere = $where;
 476 | 
 477 |         unset(
 478 |             $cleanWhere['function_type'],
 479 |             $cleanWhere['type'],
 480 |             $cleanWhere['boolean']
 481 |         );
 482 | 
 483 |         $query = [
 484 |             'function_score' => [
 485 |                 $where['function_type'] => $cleanWhere
 486 |             ]
 487 |         ];
 488 | 
 489 |         return $query;
 490 |     }
 491 | 
 492 |     /**
 493 |      * Compile a search clause
 494 |      *
 495 |      * @param  Builder  $builder
 496 |      * @param  array  $where
 497 |      * @return array
 498 |      */
 499 |     protected function compileWhereSearch(Builder $builder, array $where): array
 500 |     {
 501 |         $fields = '_all';
 502 | 
 503 |         if (! empty($where['options']['fields'])) {
 504 |             $fields = $where['options']['fields'];
 505 |         }
 506 | 
 507 |         if (is_array($fields) && !is_numeric(array_keys($fields)[0])) {
 508 |             $fieldsWithBoosts = [];
 509 | 
 510 |             foreach ($fields as $field => $boost) {
 511 |                 $fieldsWithBoosts[] = "{$field}^{$boost}";
 512 |             }
 513 | 
 514 |             $fields = $fieldsWithBoosts;
 515 |         }
 516 | 
 517 |         if (is_array($fields) && count($fields) > 1) {
 518 |             $type = isset($where['options']['matchType']) ? $where['options']['matchType'] : 'most_fields';
 519 | 
 520 |             $query = [
 521 |                 'multi_match' => [
 522 |                     'query'  => $where['value'],
 523 |                     'type'   => $type,
 524 |                     'fields' => $fields,
 525 |                 ],
 526 |             ];
 527 |         } else {
 528 |             $field = is_array($fields) ? reset($fields) : $fields;
 529 | 
 530 |             $query = [
 531 |                 'match' => [
 532 |                     $field => [
 533 |                         'query' => $where['value'],
 534 |                     ]
 535 |                 ],
 536 |             ];
 537 |         }
 538 | 
 539 |         if (! empty($where['options']['fuzziness'])) {
 540 |             $matchType = array_keys($query)[0];
 541 | 
 542 |             if ($matchType === 'multi_match') {
 543 |                 $query[$matchType]['fuzziness'] = $where['options']['fuzziness'];
 544 |             } else {
 545 |                 $query[$matchType][$field]['fuzziness'] = $where['options']['fuzziness'];
 546 |             }
 547 |         }
 548 | 
 549 |         if (! empty($where['options']['constant_score'])) {
 550 |             $query = [
 551 |                 'constant_score' => [
 552 |                     'query' => $query,
 553 |                 ],
 554 |             ];
 555 |         }
 556 | 
 557 |         return $query;
 558 |     }
 559 | 
 560 |     /**
 561 |      * Compile a script clause
 562 |      *
 563 |      * @param  Builder  $builder
 564 |      * @param  array  $where
 565 |      * @return array
 566 |      */
 567 |     protected function compileWhereScript(Builder $builder, array $where): array
 568 |     {
 569 |         return [
 570 |             'script' => [
 571 |                 'script' => array_merge($where['options'], ['source' => $where['script']]),
 572 |             ],
 573 |         ];
 574 |     }
 575 | 
 576 |     /**
 577 |      * Compile a geo distance clause
 578 |      *
 579 |      * @param  Builder  $builder
 580 |      * @param  array  $where
 581 |      * @return array
 582 |      */
 583 |     protected function compileWhereGeoDistance($builder, $where): array
 584 |     {
 585 |         $query = [
 586 |             'geo_distance' => [
 587 |                 'distance'       => $where['distance'],
 588 |                 $where['column'] => $where['location'],
 589 |             ],
 590 |         ];
 591 | 
 592 |         return $query;
 593 |     }
 594 | 
 595 |     /**
 596 |      * Compile a where geo bounds clause
 597 |      *
 598 |      * @param  Builder  $builder
 599 |      * @param  array  $where
 600 |      * @return array
 601 |      */
 602 |     protected function compileWhereGeoBoundsIn(Builder $builder, array $where): array
 603 |     {
 604 |         $query = [
 605 |             'geo_bounding_box' => [
 606 |                 $where['column'] => $where['bounds'],
 607 |             ],
 608 |         ];
 609 | 
 610 |         return $query;
 611 |     }
 612 | 
 613 |     /**
 614 |      * Compile a where nested doc clause
 615 |      *
 616 |      * @param  Builder  $builder
 617 |      * @param  array  $where
 618 |      * @return array
 619 |      */
 620 |     protected function compileWhereNestedDoc(Builder $builder, $where): array
 621 |     {
 622 |         $wheres = $this->compileWheres($where['query']);
 623 | 
 624 |         $query = [
 625 |             'nested' => [
 626 |                 'path' => $where['column']
 627 |             ],
 628 |         ];
 629 | 
 630 |         $query['nested'] = array_merge($query['nested'], array_filter($wheres));
 631 | 
 632 |         if (isset($where['operator']) && $where['operator'] === '!=') {
 633 |             $query = [
 634 |                 'bool' => [
 635 |                     'must_not' => [
 636 |                         $query
 637 |                     ]
 638 |                 ]
 639 |             ];
 640 |         }
 641 | 
 642 |         return $query;
 643 |     }
 644 | 
 645 |     /**
 646 |      * Compile a where not clause
 647 |      *
 648 |      * @param  Builder  $builder
 649 |      * @param  array  $where
 650 |      * @return array
 651 |      */
 652 |     protected function compileWhereNot(Builder $builder, $where): array
 653 |     {
 654 |         return [
 655 |             'bool' => [
 656 |                 'must_not' => [
 657 |                     $this->compileWheres($where['query'])['query']
 658 |                 ]
 659 |             ]
 660 |         ];
 661 |     }
 662 | 
 663 |     /**
 664 |      * Get value for the where
 665 |      *
 666 |      * @param  Builder  $builder
 667 |      * @param  array  $where
 668 |      * @return mixed
 669 |      */
 670 |     protected function getValueForWhere(Builder $builder, array $where)
 671 |     {
 672 |         switch ($where['type']) {
 673 |             case 'In':
 674 |             case 'NotIn':
 675 |             case 'Between':
 676 |                 $value = $where['values'];
 677 |                 break;
 678 | 
 679 |             case 'Null':
 680 |             case 'NotNull':
 681 |                 $value = null;
 682 |                 break;
 683 | 
 684 |             default:
 685 |                 $value = $where['value'];
 686 |         }
 687 |         $value = $this->getStringValue($value);
 688 | 
 689 |         return $value;
 690 |     }
 691 | 
 692 |     /**
 693 |      * Apply the given options from a where to a query clause
 694 |      *
 695 |      * @param  array  $clause
 696 |      * @param  array  $where
 697 |      * @return array
 698 |      */
 699 |     protected function applyOptionsToClause(array $clause, array $where)
 700 |     {
 701 |         if (empty($where['options'])) {
 702 |             return $clause;
 703 |         }
 704 | 
 705 |         $optionsToApply = ['boost', 'inner_hits'];
 706 |         $options        = array_intersect_key($where['options'], array_flip($optionsToApply));
 707 | 
 708 |         foreach ($options as $option => $value) {
 709 |             $method = 'apply' . studly_case($option) . 'Option';
 710 | 
 711 |             if (method_exists($this, $method)) {
 712 |                 $clause = $this->$method($clause, $value, $where);
 713 |             }
 714 |         }
 715 | 
 716 |         return $clause;
 717 |     }
 718 | 
 719 |     /**
 720 |      * Apply a boost option to the clause
 721 |      *
 722 |      * @param  array  $clause
 723 |      * @param  mixed  $value
 724 |      * @param  array  $where
 725 |      * @return array
 726 |      */
 727 |     protected function applyBoostOption(array $clause, $value, $where): array
 728 |     {
 729 |         $firstKey = key($clause);
 730 | 
 731 |         if ($firstKey !== 'term') {
 732 |             return $clause[$firstKey]['boost'] = $value;
 733 |         }
 734 | 
 735 |         $key = key($clause['term']);
 736 | 
 737 |         $clause['term'] = [
 738 |             $key => [
 739 |                 'value' => $clause['term'][$key],
 740 |                 'boost' => $value
 741 |             ]
 742 |         ];
 743 | 
 744 |         return  $clause;
 745 |     }
 746 | 
 747 |     /**
 748 |      * Apply inner hits options to the clause
 749 |      *
 750 |      * @param  array $clause
 751 |      * @param  mixed  $value
 752 |      * @param  array  $where
 753 |      * @return array
 754 |      */
 755 |     protected function applyInnerHitsOption(array $clause, $value, $where): array
 756 |     {
 757 |         $firstKey = key($clause);
 758 | 
 759 |         $clause[$firstKey]['inner_hits'] = empty($value) || $value === true ? (object) [] : (array) $value;
 760 | 
 761 |         return $clause;
 762 |     }
 763 | 
 764 |     /**
 765 |      * Compile all aggregations
 766 |      *
 767 |      * @param  Builder  $builder
 768 |      * @return array
 769 |      */
 770 |     protected function compileAggregations(Builder $builder): array
 771 |     {
 772 |         $aggregations = [];
 773 | 
 774 |         foreach ($builder->aggregations as $aggregation) {
 775 |             $result = $this->compileAggregation($builder, $aggregation);
 776 | 
 777 |             $aggregations = array_merge($aggregations, $result);
 778 |         }
 779 | 
 780 |         return $aggregations;
 781 |     }
 782 | 
 783 |     /**
 784 |      * Compile a single aggregation
 785 |      *
 786 |      * @param  Builder  $builder
 787 |      * @param  array  $aggregation
 788 |      * @return array
 789 |      */
 790 |     protected function compileAggregation(Builder $builder, array $aggregation): array
 791 |     {
 792 |         $key = $aggregation['key'];
 793 | 
 794 |         $method = 'compile' . ucfirst(Str::camel($aggregation['type'])) . 'Aggregation';
 795 | 
 796 |         $compiled = [
 797 |             $key => $this->$method($aggregation)
 798 |         ];
 799 | 
 800 |         if (isset($aggregation['aggregations']) && $aggregation['aggregations']->aggregations) {
 801 |             $compiled[$key]['aggregations'] = $this->compileAggregations($aggregation['aggregations']);
 802 |         }
 803 | 
 804 |         return $compiled;
 805 |     }
 806 | 
 807 |     /**
 808 |      * Compile filter aggregation
 809 |      *
 810 |      * @param  array  $aggregation
 811 |      * @return array
 812 |      */
 813 |     protected function compileFilterAggregation(array $aggregation): array
 814 |     {
 815 |         $filter = $this->compileWheres($aggregation['args']);
 816 | 
 817 |         $filters = $filter['filter'] ?? [];
 818 |         $query = $filter['query'] ?? [];
 819 | 
 820 |         $allFilters = array_merge($query, $filters);
 821 | 
 822 |         return [
 823 |             'filter' => $allFilters ?: ['match_all' => (object) []]
 824 |         ];
 825 |     }
 826 | 
 827 |     /**
 828 |      * Compile nested aggregation
 829 |      *
 830 |      * @param  array  $aggregation
 831 |      * @return array
 832 |      */
 833 |     protected function compileNestedAggregation(array $aggregation): array
 834 |     {
 835 |         $path = is_array($aggregation['args']) ? $aggregation['args']['path'] : $aggregation['args'];
 836 | 
 837 |         return [
 838 |             'nested' => [
 839 |                 'path' => $path
 840 |             ]
 841 |         ];
 842 |     }
 843 | 
 844 |     /**
 845 |      * Compile terms aggregation
 846 |      *
 847 |      * @param  array  $aggregation
 848 |      * @return array
 849 |      */
 850 |     protected function compileTermsAggregation(array $aggregation): array
 851 |     {
 852 |         $field = is_array($aggregation['args']) ? $aggregation['args']['field'] : $aggregation['args'];
 853 | 
 854 |         $compiled = [
 855 |             'terms' => [
 856 |                 'field' => $field
 857 |             ]
 858 |         ];
 859 | 
 860 |         $allowedArgs = [
 861 |             'collect_mode',
 862 |             'exclude',
 863 |             'execution_hint',
 864 |             'include',
 865 |             'min_doc_count',
 866 |             'missing',
 867 |             'order',
 868 |             'script',
 869 |             'show_term_doc_count_error',
 870 |             'size',
 871 |         ];
 872 | 
 873 |         if (is_array($aggregation['args'])) {
 874 |             $validArgs = array_intersect_key($aggregation['args'], array_flip($allowedArgs));
 875 |             $compiled['terms'] = array_merge($compiled['terms'], $validArgs);
 876 |         }
 877 | 
 878 |         return $compiled;
 879 |     }
 880 | 
 881 |     /**
 882 |      * Compile date histogram aggregation
 883 |      *
 884 |      * @param  array  $aggregation
 885 |      * @return array
 886 |      */
 887 |     protected function compileDateHistogramAggregation(array $aggregation): array
 888 |     {
 889 |         $field = is_array($aggregation['args']) ? $aggregation['args']['field'] : $aggregation['args'];
 890 | 
 891 |         $compiled = [
 892 |             'date_histogram' => [
 893 |                 'field' => $field
 894 |             ]
 895 |         ];
 896 | 
 897 |         if (is_array($aggregation['args'])) {
 898 |             if (isset($aggregation['args']['interval'])) {
 899 |                 $compiled['date_histogram']['interval'] = $aggregation['args']['interval'];
 900 |             }
 901 |             if (isset($aggregation['args']['calendar_interval'])) {
 902 |                 $compiled['date_histogram']['calendar_interval'] = $aggregation['args']['calendar_interval'];
 903 |             }
 904 | 
 905 |             if (isset($aggregation['args']['min_doc_count'])) {
 906 |                 $compiled['date_histogram']['min_doc_count'] = $aggregation['args']['min_doc_count'];
 907 |             }
 908 | 
 909 |             if (isset($aggregation['args']['extended_bounds']) && is_array($aggregation['args']['extended_bounds'])) {
 910 |                 $compiled['date_histogram']['extended_bounds'] = [];
 911 |                 $compiled['date_histogram']['extended_bounds']['min'] = $this->convertDateTime($aggregation['args']['extended_bounds'][0]);
 912 |                 $compiled['date_histogram']['extended_bounds']['max'] = $this->convertDateTime($aggregation['args']['extended_bounds'][1]);
 913 |             }
 914 |         }
 915 | 
 916 |         return $compiled;
 917 |     }
 918 | 
 919 |     /**
 920 |      * Compile cardinality aggregation
 921 |      *
 922 |      * @param  array  $aggregation
 923 |      * @return array
 924 |      */
 925 |     protected function compileCardinalityAggregation(array $aggregation): array
 926 |     {
 927 |         $compiled = [
 928 |             'cardinality' => $aggregation['args']
 929 |         ];
 930 | 
 931 |         return $compiled;
 932 |     }
 933 | 
 934 |     /**
 935 |      * Compile composite aggregation
 936 |      *
 937 |      * @param  array  $aggregation
 938 |      * @return array
 939 |      */
 940 |     protected function compileCompositeAggregation(array $aggregation): array
 941 |     {
 942 |         $compiled = [
 943 |             'composite' => $aggregation['args']
 944 |         ];
 945 | 
 946 |         return $compiled;
 947 |     }
 948 | 
 949 |     /**
 950 |      * Compile date range aggregation
 951 |      *
 952 |      * @param  array  $aggregation
 953 |      * @return array
 954 |      */
 955 |     protected function compileDateRangeAggregation(array $aggregation): array
 956 |     {
 957 |         $compiled = [
 958 |             'date_range' => $aggregation['args']
 959 |         ];
 960 | 
 961 |         return $compiled;
 962 |     }
 963 | 
 964 |     /**
 965 |      * Compile exists aggregation
 966 |      *
 967 |      * @param  array  $aggregation
 968 |      * @return array
 969 |      */
 970 |     protected function compileExistsAggregation(array $aggregation): array
 971 |     {
 972 |         $field = is_array($aggregation['args']) ? $aggregation['args']['field'] : $aggregation['args'];
 973 | 
 974 |         $compiled = [
 975 |             'exists' => [
 976 |                 'field' => $field
 977 |             ]
 978 |         ];
 979 | 
 980 |         return $compiled;
 981 |     }
 982 | 
 983 |     /**
 984 |      * Compile missing aggregation
 985 |      *
 986 |      * @param  array  $aggregation
 987 |      * @return array
 988 |      */
 989 |     protected function compileMissingAggregation(array $aggregation): array
 990 |     {
 991 |         $field = is_array($aggregation['args']) ? $aggregation['args']['field'] : $aggregation['args'];
 992 | 
 993 |         $compiled = [
 994 |             'missing' => [
 995 |                 'field' => $field
 996 |             ]
 997 |         ];
 998 | 
 999 |         return $compiled;
1000 |     }
1001 | 
1002 |     /**
1003 |      * Compile reverse nested aggregation
1004 |      *
1005 |      * @param  array  $aggregation
1006 |      * @return array
1007 |      */
1008 |     protected function compileReverseNestedAggregation(array $aggregation): array
1009 |     {
1010 |         return [
1011 |             'reverse_nested' => (object) []
1012 |         ];
1013 |     }
1014 | 
1015 |     /**
1016 |      * Compile sum aggregation
1017 |      *
1018 |      * @param  array  $aggregation
1019 |      * @return array
1020 |      */
1021 |     protected function compileSumAggregation(array $aggregation): array
1022 |     {
1023 |         return $this->compileMetricAggregation($aggregation);
1024 |     }
1025 | 
1026 |     /**
1027 |      * Compile avg aggregation
1028 |      *
1029 |      * @param  array  $aggregation
1030 |      * @return array
1031 |      */
1032 |     protected function compileAvgAggregation(array $aggregation): array
1033 |     {
1034 |         return $this->compileMetricAggregation($aggregation);
1035 |     }
1036 | 
1037 |     /**
1038 |      * Compile max aggregation
1039 |      *
1040 |      * @param  array  $aggregation
1041 |      * @return array
1042 |      */
1043 |     protected function compileMaxAggregation(array $aggregation): array
1044 |     {
1045 |         return $this->compileMetricAggregation($aggregation);
1046 |     }
1047 | 
1048 |     /**
1049 |      * Compile min aggregation
1050 |      *
1051 |      * @param  array  $aggregation
1052 |      * @return array
1053 |      */
1054 |     protected function compileMinAggregation(array $aggregation): array
1055 |     {
1056 |         return $this->compileMetricAggregation($aggregation);
1057 |     }
1058 | 
1059 |     /**
1060 |      * Compile metric aggregation
1061 |      *
1062 |      * @param  array  $aggregation
1063 |      * @return array
1064 |      */
1065 |     protected function compileMetricAggregation(array $aggregation): array
1066 |     {
1067 |         $metric = $aggregation['type'];
1068 | 
1069 |         if (is_array($aggregation['args']) && isset($aggregation['args']['script'])) {
1070 |             return [
1071 |                 $metric => [
1072 |                     'script' => $aggregation['args']['script']
1073 |                 ]
1074 |             ];
1075 |         }
1076 |         $field = is_array($aggregation['args']) ? $aggregation['args']['field'] : $aggregation['args'];
1077 | 
1078 |         return [
1079 |             $metric => [
1080 |                 'field' => $field
1081 |             ]
1082 |         ];
1083 |     }
1084 | 
1085 |     /**
1086 |      * Compile children aggregation
1087 |      *
1088 |      * @param  array  $aggregation
1089 |      * @return array
1090 |      */
1091 |     protected function compileChildrenAggregation(array $aggregation): array
1092 |     {
1093 |         $type = is_array($aggregation['args']) ? $aggregation['args']['type'] : $aggregation['args'];
1094 | 
1095 |         return [
1096 |             'children' => [
1097 |                 'type' => $type
1098 |             ]
1099 |         ];
1100 |     }
1101 | 
1102 |     /**
1103 |      * Compile the orders section of a query
1104 |      *
1105 |      * @param  Builder  $builder
1106 |      * @param  array  $orders
1107 |      * @return array
1108 |      */
1109 |     protected function compileOrders(Builder $builder, $orders = []): array
1110 |     {
1111 |         $compiledOrders = [];
1112 | 
1113 |         foreach ($orders as $order) {
1114 |             $column = $order['column'];
1115 |             if(Str::startsWith($column, $builder->from . '.')) {
1116 |                 $column = Str::replaceFirst($builder->from . '.', '', $column);
1117 |             }
1118 | 
1119 |             $type = $order['type'] ?? 'basic';
1120 | 
1121 |             switch ($type) {
1122 |                 case 'geoDistance':
1123 |                     $orderSettings = [
1124 |                         $column         => $order['options']['coordinates'],
1125 |                         'order'         => $order['direction'] < 0 ? 'desc' : 'asc',
1126 |                         'unit'          => $order['options']['unit'] ?? 'km',
1127 |                         'distance_type' => $order['options']['distanceType'] ?? 'plane',
1128 |                     ];
1129 | 
1130 |                     $column = '_geo_distance';
1131 |                     break;
1132 | 
1133 |                 default:
1134 |                     $orderSettings = [
1135 |                         'order' => $order['direction'] < 0 ? 'desc' : 'asc'
1136 |                     ];
1137 | 
1138 |                     $allowedOptions = ['missing', 'mode'];
1139 | 
1140 |                     $options = isset($order['options']) ? array_intersect_key($order['options'], array_flip($allowedOptions)) : [];
1141 | 
1142 |                     $orderSettings = array_merge($options, $orderSettings);
1143 |             }
1144 | 
1145 |             $compiledOrders[] = [
1146 |                 $column => $orderSettings,
1147 |             ];
1148 |         }
1149 | 
1150 |         return $compiledOrders;
1151 |     }
1152 | 
1153 |     /**
1154 |      * Compile the given values to an Elasticsearch insert statement
1155 |      *
1156 |      * @param  Builder|QueryBuilder  $builder
1157 |      * @param  array  $values
1158 |      * @return array
1159 |      */
1160 |     public function compileInsert(Builder $builder, array $values): array
1161 |     {
1162 |         $params = [];
1163 | 
1164 |         if (! is_array(reset($values))) {
1165 |             $values = [$values];
1166 |         }
1167 | 
1168 |         foreach ($values as $doc) {
1169 |             $doc['id'] = $doc['id'] ?? ((string) Str::orderedUuid());
1170 |             if (isset($doc['child_documents'])) {
1171 |                 foreach ($doc['child_documents'] as $childDoc) {
1172 |                     $params['body'][] = [
1173 |                         'index' => [
1174 |                             '_index' => $builder->from . $this->indexSuffix,
1175 |                             '_id'    => $childDoc['id'],
1176 |                             'parent' => $doc['id'],
1177 |                         ]
1178 |                     ];
1179 | 
1180 |                     $params['body'][] = $childDoc['document'];
1181 |                 }
1182 | 
1183 |                 unset($doc['child_documents']);
1184 |             }
1185 | 
1186 |             $index = [
1187 |                 '_index' => $builder->from . $this->indexSuffix,
1188 |                 '_id'    => $doc['id'],
1189 |             ];
1190 | 
1191 |             if(isset($doc['_routing'])) {
1192 |                 $index['routing'] = $doc['_routing'];
1193 |                 unset($doc['_routing']);
1194 |             }
1195 |             else if($routing = $builder->getRouting()) {
1196 |                 $index['routing'] = $routing;
1197 |             }
1198 | 
1199 |             if ($parentId = $builder->getParentId()) {
1200 |                 $index['parent'] = $parentId;
1201 |             } else if (isset($doc['_parent'])) {
1202 |                 $index['parent'] = $doc['_parent'];
1203 |                 unset($doc['_parent']);
1204 |             }
1205 | 
1206 |             $params['body'][] = ['index' => $index];
1207 | 
1208 |             foreach($doc as &$property) {
1209 |                 $property = $this->getStringValue($property);
1210 |             }
1211 | 
1212 |             $params['body'][] = $doc;
1213 |         }
1214 | 
1215 |         return $params;
1216 |     }
1217 | 
1218 |     public function compileUpdate(Builder $builder, $values)
1219 |     {
1220 |         $clause = $this->compileSelect($builder);
1221 |         $clause['body']['conflicts'] = 'proceed';
1222 |         $script = [];
1223 | 
1224 |         foreach($values as $column => $value) {
1225 |             $value = $this->getStringValue($value);
1226 |             if(Str::startsWith($column, $builder->from . '.')) {
1227 |                 $column = Str::replaceFirst($builder->from . '.', '', $column);
1228 |             }
1229 |             $script[] = 'ctx._source.' . $column . ' = "' . addslashes($value) . '";';
1230 |         }
1231 |         $clause['body']['script'] = implode('', $script);
1232 |         return $clause;
1233 |     }
1234 | 
1235 |     /**
1236 |      * Compile a delete query
1237 |      *
1238 |      * @param  Builder|QueryBuilder  $builder
1239 |      * @return array
1240 |      */
1241 |     public function compileDelete(Builder $builder): array
1242 |     {
1243 |         $clause = $this->compileSelect($builder);
1244 | 
1245 |         if ($conflict = $builder->getOption('delete_conflicts')) {
1246 |             $clause['conflicts'] = $conflict;
1247 |         }
1248 | 
1249 |         if ($refresh = $builder->getOption('delete_refresh')) {
1250 |             $clause['refresh'] = $refresh;
1251 |         }
1252 | 
1253 |         return $clause;
1254 |     }
1255 | 
1256 |     /**
1257 |      * Convert a key to an Elasticsearch-friendly format
1258 |      *
1259 |      * @param  mixed  $value
1260 |      * @return string
1261 |      */
1262 |     protected function convertKey($value): string
1263 |     {
1264 |         return (string) $value;
1265 |     }
1266 | 
1267 |     /**
1268 |      * Compile a delete query
1269 |      *
1270 |      * @param  Builder  $builder
1271 |      * @return string
1272 |      */
1273 |     protected function convertDateTime($value): string
1274 |     {
1275 |         if (is_string($value)) {
1276 |             return $value;
1277 |         }
1278 | 
1279 |         return $value->format($this->getDateFormat());
1280 |     }
1281 | 
1282 |     /**
1283 |      * @inheritdoc
1284 |      */
1285 |     public function getDateFormat():string
1286 |     {
1287 |         return Config::get('laravel-elasticsearch.date_format', 'Y-m-d H:i:s');
1288 |     }
1289 | 
1290 |     /**
1291 |      * Get the grammar's index suffix.
1292 |      *
1293 |      * @return string
1294 |      */
1295 |     public function getIndexSuffix(): string
1296 |     {
1297 |         return $this->indexSuffix;
1298 |     }
1299 | 
1300 |     /**
1301 |      * Set the grammar's table suffix.
1302 |      *
1303 |      * @param  string  $suffix
1304 |      * @return $this
1305 |      */
1306 |     public function setIndexSuffix(string $suffix): self
1307 |     {
1308 |         $this->indexSuffix = $suffix;
1309 | 
1310 |         return $this;
1311 |     }
1312 | 
1313 |     /**
1314 |      * @param $value
1315 |      * @return mixed
1316 |      */
1317 |     protected function getStringValue($value)
1318 |     {
1319 |         // Convert DateTime values to UTCDateTime.
1320 |         if ($value instanceof DateTime) {
1321 |             $value = $this->convertDateTime($value);
1322 |         } else {
1323 |             if ($value instanceof ObjectID) {
1324 |                 // Convert DateTime values to UTCDateTime.
1325 |                 $value = $this->convertKey($value);
1326 |             } else {
1327 |                 if (is_array($value)) {
1328 |                     foreach ($value as &$val) {
1329 |                         if ($val instanceof DateTime) {
1330 |                             $val = $this->convertDateTime($val);
1331 |                         } else {
1332 |                             if ($val instanceof ObjectID) {
1333 |                                 $val = $this->convertKey($val);
1334 |                             }
1335 |                         }
1336 |                     }
1337 |                 }
1338 |             }
1339 |         }
1340 |         return $value;
1341 |     }
1342 | }
1343 | 


--------------------------------------------------------------------------------
/src/QueryProcessor.php:
--------------------------------------------------------------------------------
  1 | rawResponse = $results;
 24 | 
 25 |         $this->aggregations = $results['aggregations'] ?? [];
 26 | 
 27 |         $this->query = $query;
 28 | 
 29 |         $documents = [];
 30 | 
 31 |         foreach ($results['hits']['hits'] as $result) {
 32 |             $documents[] = $this->documentFromResult($query, $result);
 33 |         }
 34 | 
 35 |         return $documents;
 36 |     }
 37 | 
 38 |     /**
 39 |      * Create a document from the given result
 40 |      *
 41 |      * @param  Builder $query
 42 |      * @param  array $result
 43 |      * @return array
 44 |      */
 45 |     public function documentFromResult(Builder $query, array $result): array
 46 |     {
 47 |         $document = $result['_source'];
 48 |         $document['_id'] = $result['_id'];
 49 | 
 50 |         if ($query->includeInnerHits && isset($result['inner_hits'])) {
 51 |             $document = $this->addInnerHitsToDocument($document, $result['inner_hits']);
 52 |         }
 53 | 
 54 |         return $document;
 55 |     }
 56 | 
 57 |     /**
 58 |      * Add inner hits to a document
 59 |      *
 60 |      * @param  array $document
 61 |      * @param  array $innerHits
 62 |      * @return array
 63 |      */
 64 |     protected function addInnerHitsToDocument($document, $innerHits): array
 65 |     {
 66 |         foreach ($innerHits as $documentType => $hitResults) {
 67 |             foreach ($hitResults['hits']['hits'] as $result) {
 68 |                 $document['inner_hits'][$documentType][] = array_merge(['_id' => $result['_id']], $result['_source']);
 69 |             }
 70 |         }
 71 | 
 72 |         return $document;
 73 |     }
 74 | 
 75 |     /**
 76 |      * Get the raw Elasticsearch response
 77 |      *
 78 |      * @param array
 79 |      */
 80 |     public function getRawResponse(): array
 81 |     {
 82 |         return $this->rawResponse;
 83 |     }
 84 | 
 85 |     /**
 86 |      * Get the raw aggregation results
 87 |      *
 88 |      * @param array
 89 |      */
 90 |     public function getAggregationResults(): array
 91 |     {
 92 |         return $this->aggregations;
 93 |     }
 94 | 
 95 |     public function processInsertGetId(Builder $query, $sql, $values, $sequence = null)
 96 |     {
 97 |         $result = $query->getConnection()->insert($sql, $values);
 98 |         $last = collect($result['items'])->last();
 99 |         return $last['index']['_id'] ?? null;
100 |     }
101 | }
102 | 


--------------------------------------------------------------------------------
/src/Searchable.php:
--------------------------------------------------------------------------------
  1 | searchIndex ?? $this->getTable();
 22 |     }
 23 | 
 24 |     /**
 25 |      * Set the index this model is to be added to
 26 |      *
 27 |      * @param  string
 28 |      * @return self
 29 |      */
 30 |     public function setSearchIndex(string $index)
 31 |     {
 32 |         $this->searchIndex = $index;
 33 | 
 34 |         return $this;
 35 |     }
 36 | 
 37 |     /**
 38 |      * Get the search type associated with the model.
 39 |      *
 40 |      * @return string
 41 |      */
 42 |     public function getSearchType()
 43 |     {
 44 |         return $this->searchType ?? Str::singular($this->getTable());
 45 |     }
 46 | 
 47 |     /**
 48 |      * Carry out the given function on the search connection
 49 |      *
 50 |      * @param  Closure $callback
 51 |      * @return mixed
 52 |      */
 53 |     public function onSearchConnection(\Closure $callback)
 54 |     {
 55 |         $arguments = array_slice(func_get_args(), 1);
 56 | 
 57 |         $elasticModel = clone $arguments[0];
 58 |         $elasticModel->setConnection(static::getElasticsearchConnectionName());
 59 | 
 60 |         $arguments[0] = $elasticModel;
 61 | 
 62 |         return $callback(...$arguments);
 63 |     }
 64 | 
 65 |     /**
 66 |      * Implementing models can override this method to set additional query
 67 |      * parameters to be used when searching
 68 |      *
 69 |      * @param  \Illuminate\Database\Eloquent\Builder $query
 70 |      * @return \Illuminate\Database\Eloquent\Builder
 71 |      */
 72 |     public function setKeysForSearch($query)
 73 |     {
 74 |         return $query;
 75 |     }
 76 | 
 77 |     /**
 78 |      * Add to search index
 79 |      *
 80 |      * @throws Exception
 81 |      * @return bool
 82 |      */
 83 |     public function addToIndex()
 84 |     {
 85 |         return $this->onSearchConnection(function ($model) {
 86 |             $query = $model->setKeysForSaveQuery($model->newQueryWithoutScopes());
 87 | 
 88 |             $model->setKeysForSearch($query);
 89 | 
 90 |             return $query->insert($model->toSearchableArray());
 91 |         }, $this);
 92 |     }
 93 | 
 94 |     /**
 95 |      * Update indexed document
 96 |      *
 97 |      * @return bool
 98 |      */
 99 |     public function updateIndex()
100 |     {
101 |         return $this->addToIndex();
102 |     }
103 | 
104 |     /**
105 |      * Remove from search index
106 |      *
107 |      * @return bool
108 |      */
109 |     public function removeFromIndex()
110 |     {
111 |         return $this->onSearchConnection(function ($model) {
112 |             $query = $model->setKeysForSaveQuery($model->newQueryWithoutScopes());
113 | 
114 |             $model->setKeysForSearch($query);
115 | 
116 |             return $query->delete();
117 |         }, $this);
118 |     }
119 | 
120 |     /**
121 |      * Create a searchable version of this model
122 |      *
123 |      * @return array
124 |      */
125 |     public function toSearchableArray()
126 |     {
127 |         // Run this on the search connection if it's not the current connection
128 |         if ($this->getConnectionName() !== static::getElasticsearchConnectionName()) {
129 |             return $this->onSearchConnection(function ($model) {
130 |                 return $model->toSearchableArray();
131 |             }, $this);
132 |         }
133 | 
134 |         $array = $this->toArray();
135 | 
136 |         foreach ($this->getArrayableRelations() as $key => $relation) {
137 |             $attributeName = snake_case($key);
138 | 
139 |             if (isset($array[$attributeName]) && method_exists($relation, 'toSearchableArray')) {
140 |                 $array[$attributeName] = $relation->onSearchConnection(function ($model) {
141 |                     return $model->toSearchableArray();
142 |                 }, $relation);
143 |             } elseif (isset($array[$attributeName]) && $relation instanceof \Illuminate\Support\Collection) {
144 |                 $array[$attributeName] = $relation->map(function ($item, $i) {
145 |                     if (method_exists($item, 'toSearchableArray')) {
146 |                         return $item->onSearchConnection(function ($model) {
147 |                             return $model->toSearchableArray();
148 |                         }, $item);
149 |                     }
150 | 
151 |                     return $item;
152 |                 })->all();
153 |             }
154 |         }
155 | 
156 |         $array['id'] = $this->id;
157 | 
158 |         unset($array['_id']);
159 | 
160 |         foreach ((array) $this->indexAsChildDocuments as $field) {
161 |             $subDocuments = $this->$field ?? [];
162 | 
163 |             foreach ($subDocuments as $subDocument) {
164 |                 $array['child_documents'][] = $this->onSearchConnection(function ($model) {
165 |                     return $model->getSubDocumentIndexData($model);
166 |                 }, $subDocument);
167 |             }
168 |         }
169 | 
170 |         return $array;
171 |     }
172 | 
173 |     /**
174 |      * Build index details for a sub document
175 |      *
176 |      * @param  \Illuminate\Database\Eloquent\Model $document
177 |      * @return array
178 |      */
179 |     public function getSubDocumentIndexData($document)
180 |     {
181 |         return [
182 |             'type' => $document->getSearchType(),
183 |             'id' => $document->id,
184 |             'document' => $document->toSearchableArray()
185 |         ];
186 |     }
187 | 
188 |     /**
189 |      * New Collection
190 |      *
191 |      * @param array $models
192 |      * @return Collection
193 |      */
194 |     public function newCollection(array $models = [])
195 |     {
196 |         return new Collection($models);
197 |     }
198 | 
199 |     /**
200 |      * @return EloquentBuilder
201 |      */
202 |     public static function newElasticsearchQuery(): EloquentBuilder
203 |     {
204 |         return static::on(static::getElasticsearchConnectionName());
205 |     }
206 | }
207 | 


--------------------------------------------------------------------------------
/src/Support/ElasticsearchException.php:
--------------------------------------------------------------------------------
 1 | parseException($exception);
21 |     }
22 | 
23 |     /**
24 |      * @return array
25 |      */
26 |     public function getRaw(): array
27 |     {
28 |         return $this->raw;
29 |     }
30 | 
31 |     /**
32 |      * @return string
33 |      */
34 |     public function __toString()
35 |     {
36 |         return "{$this->getCode()}: {$this->getMessage()}";
37 |     }
38 | 
39 |     /**
40 |      * @param BaseElasticsearchException $exception
41 |      */
42 |     private function parseException(BaseElasticsearchException $exception): void
43 |     {
44 |         $body = json_decode($exception->getMessage(), true);
45 | 
46 |         $this->message = $body['error']['reason'];
47 |         $this->code = $body['error']['type'];
48 | 
49 |         $this->raw = $body;
50 |     }
51 | }
52 | 


--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
 1 | set('database.connections.elasticsearch.suffix', '_dev');
33 |     }
34 | }
35 | 


--------------------------------------------------------------------------------
/tests/Unit/Console/Mappings/IndexListCommandTest.php:
--------------------------------------------------------------------------------
  1 | command = m::mock(IndexListCommand::class)
 31 |             ->shouldAllowMockingProtectedMethods()
 32 |             ->makePartial();
 33 |     }
 34 | 
 35 |     /**
 36 |      * It gets a list of indices on the Elasticsearch cluster.
 37 |      *
 38 |      * @test
 39 |      * @covers       IndexListCommand::getIndices()
 40 |      */
 41 |     public function it_gets_a_list_of_indices_on_the_elasticsearch_cluster()
 42 |     {
 43 |         $catNamespace = m::mock(CatNamespace::class);
 44 |         $catNamespace->shouldReceive('indices')->andReturn([]);
 45 | 
 46 |         $client = m::mock(Client::class);
 47 |         $client->shouldReceive('cat')->andReturn($catNamespace);
 48 | 
 49 |         $this->command->client = $client;
 50 | 
 51 |         $this->assertEquals([], $this->command->getIndices());
 52 |     }
 53 | 
 54 |     /**
 55 |      * It returns a formatted array of active aliases and their corresponding indices.
 56 |      *
 57 |      * @test
 58 |      * @covers       IndexListCommand::getIndicesForAlias()
 59 |      */
 60 |     public function it_returns_a_formatted_array_of_active_aliases_and_their_corresponding_indices()
 61 |     {
 62 |         $expected = [
 63 |             [
 64 |                 'index' => '2017_05_21_111500_test_dev',
 65 |                 'alias' => 'test_dev',
 66 |             ],
 67 |             [
 68 |                 'index' => '2018_05_21_111500_test_dev',
 69 |                 'alias' => 'test_dev',
 70 |             ],
 71 |             [
 72 |                 'index' => '2018_05_21_111500_test_production',
 73 |                 'alias' => 'test_production',
 74 |             ],
 75 |         ];
 76 |         $body = [
 77 |             [
 78 |                 'index' => '2018_05_21_111500_test_production',
 79 |                 'alias' => 'test_production',
 80 |             ],
 81 |             [
 82 |                 'index' => '2018_05_21_111500_test_dev',
 83 |                 'alias' => 'test_dev',
 84 |             ],
 85 |             [
 86 |                 'index' => '2017_05_21_111500_test_dev',
 87 |                 'alias' => 'test_dev',
 88 |             ],
 89 |         ];
 90 | 
 91 |         $catNamespace = m::mock(CatNamespace::class);
 92 |         $catNamespace->shouldReceive('aliases')->andReturn($body);
 93 | 
 94 |         $client = m::mock(Client::class);
 95 |         $client->shouldReceive('cat')->andReturn($catNamespace);
 96 | 
 97 |         $this->command->client = $client;
 98 | 
 99 |         $this->assertEquals($expected, $this->command->getIndicesForAlias());
100 |     }
101 | 
102 |     /**
103 |      * It handles the console command when an alias is given.
104 |      *
105 |      * @test
106 |      * @covers IndexListCommand::handle()
107 |      */
108 |     public function it_handles_the_console_command_when_an_alias_is_given()
109 |     {
110 |         $alias = 'some_alias';
111 |         $this->command->shouldReceive('option')->once()->with('alias')->andReturn($alias);
112 | 
113 |         $this->command->shouldReceive('getIndicesForAlias')->once()->with($alias)->andReturn([
114 |             ['index' => 'index1', 'alias' => 'alias1'],
115 |             ['index' => 'index2', 'alias' => 'alias2'],
116 |             ['index' => 'index3', 'alias' => 'alias3'],
117 |         ]);
118 | 
119 |         $this->command->shouldReceive('table')->once()->withAnyArgs();
120 | 
121 |         $this->command->handle();
122 |     }
123 | 
124 |     /**
125 |      * It handles the console command call.
126 |      *
127 |      * @test
128 |      * @covers IndexListCommand::handle()
129 |      */
130 |     public function it_handles_the_console_command_call()
131 |     {
132 |         $this->command->shouldReceive('option')->once()->with('alias')->andReturnNull();
133 | 
134 |         $indices = [
135 |             [
136 |                 'index' => 'name of index',
137 |             ],
138 |         ];
139 |         $this->command->shouldReceive('getIndices')->once()->andReturn($indices);
140 |         $this->command->shouldReceive('table')->once()->withAnyArgs();
141 | 
142 |         $this->command->handle();
143 |     }
144 | }
145 | 


--------------------------------------------------------------------------------
/tests/Unit/Console/Mappings/IndexRemoveCommandTest.php:
--------------------------------------------------------------------------------
 1 | command = m::mock(IndexRemoveCommand::class)
29 |             ->shouldAllowMockingProtectedMethods()
30 |             ->makePartial();
31 |     }
32 | 
33 |     /**
34 |      * It removes the given index.
35 |      *
36 |      * @test
37 |      * @covers       IndexRemoveCommand::removeIndex()
38 |      */
39 |     public function it_removes_the_given_index()
40 |     {
41 |         $indicesNamespace = m::mock(IndicesNamespace::class);
42 |         $indicesNamespace->shouldReceive('delete')->once()->with(['index' => 'test_index']);
43 | 
44 |         $client = m::mock(Client::class);
45 |         $client->shouldReceive('indices')->andReturn($indicesNamespace);
46 | 
47 |         $this->command->client = $client;
48 | 
49 |         $this->command->shouldReceive('info');
50 | 
51 |         $this->assertTrue($this->command->removeIndex('test_index'));
52 |     }
53 | 
54 |     /**
55 |      * It handles the console command call.
56 |      *
57 |      * @test
58 |      * @covers IndexRemoveCommand::handle()
59 |      * @dataProvider handle_data_provider
60 |      */
61 |     public function it_handles_the_console_command_call($index)
62 |     {
63 |         $catNamespace = m::mock(CatNamespace::class);
64 |         $catNamespace->shouldReceive('indices')->andReturn([['index' => $index]]);
65 | 
66 |         $client = m::mock(Client::class);
67 |         $client->shouldReceive('cat')->andReturn($catNamespace);
68 | 
69 |         $this->command->client = $client;
70 | 
71 |         $this->command->shouldReceive('argument')->once()->with('index')->andReturn($index);
72 |         $this->command->shouldReceive('choice')->with('Which index would you like to delete?', [$index]);
73 |         $this->command->shouldReceive('confirm')->withAnyArgs()->andReturn(!!$index);
74 |         $this->command->shouldReceive('removeIndex')->with($index);
75 | 
76 |         $this->command->handle();
77 |     }
78 | 
79 |     /**
80 |      * @return array
81 |      */
82 |     public function handle_data_provider():array
83 |     {
84 |         return [
85 |             'index given'    => ['test_index'],
86 |             'no index given' => [null],
87 |         ];
88 |     }
89 | }
90 | 


--------------------------------------------------------------------------------
/tests/Unit/Database/Schema/BlueprintTest.php:
--------------------------------------------------------------------------------
  1 | blueprint = new Blueprint('indices');
 19 |     }
 20 | 
 21 |     /**
 22 |      * It gets the index alias.
 23 |      *
 24 |      * @test
 25 |      * @covers       \DesignMyNight\Elasticsearch\Database\Schema\Blueprint::getAlias
 26 |      * @dataProvider get_alias_data_provider
 27 |      */
 28 |     public function it_gets_the_index_alias(string $expected, $alias = null)
 29 |     {
 30 |         if (isset($alias)) {
 31 |             $this->blueprint->alias($alias);
 32 |         }
 33 | 
 34 |         $this->assertEquals($expected, $this->blueprint->getAlias());
 35 |     }
 36 | 
 37 |     /**
 38 |      * getAlias data provider.
 39 |      */
 40 |     public function get_alias_data_provider():array
 41 |     {
 42 |         return [
 43 |             'alias not provided' => ['indices_dev'],
 44 |             'alias provided'     => ['alias_dev', 'alias'],
 45 |         ];
 46 |     }
 47 | 
 48 |     /**
 49 |      * It gets the document type.
 50 |      *
 51 |      * @test
 52 |      * @covers       \DesignMyNight\Elasticsearch\Database\Schema\Blueprint::getDocumentType
 53 |      * @dataProvider get_document_type_data_provider
 54 |      */
 55 |     public function it_gets_the_document_type(string $expected, $documentType = null)
 56 |     {
 57 |         if (isset($documentType)) {
 58 |             $this->blueprint->document($documentType);
 59 |         }
 60 | 
 61 |         $this->assertEquals($expected, $this->blueprint->getDocumentType());
 62 |     }
 63 | 
 64 |     /**
 65 |      * getDocumentType data provider.
 66 |      */
 67 |     public function get_document_type_data_provider():array
 68 |     {
 69 |         return [
 70 |             'document not provided' => ['index'],
 71 |             'document provided'     => ['document', 'document'],
 72 |         ];
 73 |     }
 74 | 
 75 |     /**
 76 |      * It generates an index name.
 77 |      *
 78 |      * @test
 79 |      * @covers \DesignMyNight\Elasticsearch\Database\Schema\Blueprint::getIndex
 80 |      */
 81 |     public function it_generates_an_index_name()
 82 |     {
 83 |         Carbon::setTestNow(Carbon::create(2019, 7, 2, 12));
 84 | 
 85 |         $this->assertEquals('2019_07_02_120000_indices_dev', $this->blueprint->getIndex());
 86 |     }
 87 | 
 88 |     /**
 89 |      * adds settings ready to be used
 90 |      *
 91 |      * @test
 92 |      */
 93 |     public function adds_settings_ready_to_be_used():void
 94 |     {
 95 |         $settings = [
 96 |             'filter' => [
 97 |                 'autocomplete_filter' => [
 98 |                     'type'     => 'edge_ngram',
 99 |                     'min_gram' => 1,
100 |                     'max_gram' => 20,
101 |                 ],
102 |             ],
103 |             'analyzer' => [
104 |                 'autocomplete' => [
105 |                     'type'      => 'custom',
106 |                     'tokenizer' => 'standard',
107 |                     'filter' => [
108 |                         'lowercase',
109 |                         'autocomplete_filter',
110 |                     ],
111 |                 ],
112 |             ],
113 |         ];
114 | 
115 |         $this->blueprint->addIndexSettings('analysis', $settings);
116 | 
117 |         $this->assertSame(
118 |             [
119 |                 'analysis' => $settings,
120 |             ],
121 |             $this->blueprint->getIndexSettings()
122 |         );
123 |     }
124 | }
125 | 


--------------------------------------------------------------------------------
/tests/Unit/Database/Schema/Grammars/ElasticsearchGrammarTest.php:
--------------------------------------------------------------------------------
  1 | shouldReceive('indices')->andReturn([]);
 35 | 
 36 |         /** @var IndicesNamespace|m\CompositeExpectation $indicesNamespace */
 37 |         $indicesNamespace = m::mock(IndicesNamespace::class);
 38 |         $indicesNamespace->shouldReceive('existsAlias')->andReturnFalse();
 39 | 
 40 |         /** @var Client|m\CompositeExpectation $client */
 41 |         $client = m::mock(Client::class);
 42 |         $client->shouldReceive('cat')->andReturn($catNamespace);
 43 |         $client->shouldReceive('indices')->andReturn($indicesNamespace);
 44 | 
 45 |         /** @var Connection|m\CompositeExpectation $connection */
 46 |         $connection = m::mock(Connection::class)->makePartial()->shouldAllowMockingProtectedMethods();
 47 |         $connection->shouldReceive('createConnection')->andReturn($client);
 48 | 
 49 |         Carbon::setTestNow(
 50 |             Carbon::create(2019, 7, 2, 12)
 51 |         );
 52 | 
 53 |         $this->blueprint = new Blueprint('indices');
 54 |         $this->connection = $connection;
 55 |         $this->grammar = new ElasticsearchGrammar();
 56 |     }
 57 | 
 58 |     /**
 59 |      * It returns a closure that will create an index.
 60 |      * @test
 61 |      * @covers \DesignMyNight\Elasticsearch\Database\Schema\Grammars\ElasticsearchGrammar::compileCreate
 62 |      */
 63 |     public function it_returns_a_closure_that_will_create_an_index()
 64 |     {
 65 |         $alias = 'indices_dev';
 66 |         $index = '2019_07_02_120000_indices_dev';
 67 |         $mapping = [
 68 |             'mappings' => [
 69 |                 'properties' => [
 70 |                     'title' => [
 71 |                         'type' => 'text',
 72 |                         'fields' => [
 73 |                             'raw' => [
 74 |                                 'type' => 'keyword'
 75 |                             ]
 76 |                         ]
 77 |                     ],
 78 |                     'date' => [
 79 |                         'type' => 'date'
 80 |                     ]
 81 |                 ]
 82 |             ]
 83 |         ];
 84 |         $blueprint = clone($this->blueprint);
 85 | 
 86 |         $blueprint->text('title')->fields(function (Blueprint $mapping): void {
 87 |             $mapping->keyword('raw');
 88 |         });
 89 |         $blueprint->date('date');
 90 | 
 91 |         /** @var IndicesNamespace|m\CompositeExpectation $indicesNamespace */
 92 |         $indicesNamespace = m::mock(IndicesNamespace::class);
 93 |         $indicesNamespace->shouldReceive('create')->once()->with(['index' => $index, 'body' => $mapping]);
 94 |         $indicesNamespace->shouldReceive('existsAlias')->once()->with(['name' => $alias])->andReturnFalse();
 95 |         $indicesNamespace->shouldReceive('putAlias')->once()->with(['index' => $index, 'name' => $alias]);
 96 | 
 97 |         $this->connection->shouldReceive('indices')->andReturn($indicesNamespace);
 98 |         $this->connection->shouldReceive('createAlias')->once()->with($index, $alias)->passthru();
 99 | 
100 |         $this->connection->shouldReceive('createIndex')->once()->with($index, $mapping)->passthru();
101 | 
102 |         $executable = $this->grammar->compileCreate(new Blueprint(''), new Fluent(), $this->connection);
103 | 
104 |         $this->assertInstanceOf(Closure::class, $executable);
105 | 
106 |         $executable($blueprint, $this->connection);
107 |     }
108 | 
109 |     /**
110 |      * It returns a closure that will drop an index.
111 |      * @test
112 |      * @covers \DesignMyNight\Elasticsearch\Database\Schema\Grammars\ElasticsearchGrammar::compileDrop
113 |      */
114 |     public function it_returns_a_closure_that_will_drop_an_index()
115 |     {
116 |         $index = '2019_06_03_120000_indices_dev';
117 | 
118 |         /** @var CatNamespace|m\CompositeExpectation $catNamespace */
119 |         $catNamespace = m::mock(CatNamespace::class);
120 |         $catNamespace->shouldReceive('indices')->andReturn([
121 |             ['index' => $index]
122 |         ]);
123 | 
124 |         /** @var IndicesNamespace|m\CompositeExpectation $indicesNamespace */
125 |         $indicesNamespace = m::mock(IndicesNamespace::class);
126 |         $indicesNamespace->shouldReceive('delete')->once()->with(['index' => $index]);
127 | 
128 |         $this->connection->shouldReceive('cat')->andReturn($catNamespace);
129 |         $this->connection->shouldReceive('indices')->andReturn($indicesNamespace);
130 |         $this->connection->shouldReceive('dropIndex')->once()->with($index)->passthru();
131 | 
132 |         $executable = $this->grammar->compileDrop(new Blueprint(''), new Fluent(), $this->connection);
133 | 
134 |         $this->assertInstanceOf(Closure::class, $executable);
135 | 
136 |         $executable($this->blueprint, $this->connection);
137 |     }
138 | 
139 |     /**
140 |      * It returns a closure that will drop an index if it exists.
141 |      * @test
142 |      * @covers       \DesignMyNight\Elasticsearch\Database\Schema\Grammars\ElasticsearchGrammar::compileDropIfExists
143 |      * @dataProvider compile_drop_if_exists_data_provider
144 |      */
145 |     public function it_returns_a_closure_that_will_drop_an_index_if_it_exists($table, $times)
146 |     {
147 |         $index = '2019_06_03_120000_indices_dev';
148 |         $this->blueprint = new Blueprint($table);
149 | 
150 |         /** @var CatNamespace|m\CompositeExpectation $catNamespace */
151 |         $catNamespace = m::mock(CatNamespace::class);
152 |         $catNamespace->shouldReceive('indices')->andReturn([
153 |             ['index' => $index]
154 |         ]);
155 | 
156 |         /** @var IndicesNamespace|m\CompositeExpectation $indicesNamespace */
157 |         $indicesNamespace = m::mock(IndicesNamespace::class);
158 |         $indicesNamespace->shouldReceive('delete')->times($times)->with(['index' => $index]);
159 | 
160 |         $this->connection->shouldReceive('indices')->andReturn($indicesNamespace);
161 |         $this->connection->shouldReceive('cat')->once()->andReturn($catNamespace);
162 |         $this->connection->shouldReceive('dropIndex')->times($times)->with($index)->passthru();
163 | 
164 |         $executable = $this->grammar->compileDropIfExists(new Blueprint(''), new Fluent(), $this->connection);
165 | 
166 |         $this->assertInstanceOf(Closure::class, $executable);
167 | 
168 |         $executable($this->blueprint, $this->connection);
169 |     }
170 | 
171 |     /**
172 |      * compileDropIfExists data provider.
173 |      */
174 |     public function compile_drop_if_exists_data_provider(): array
175 |     {
176 |         return [
177 |             'it exists' => ['indices', 1],
178 |             'it does not exists' => ['books', 0]
179 |         ];
180 |     }
181 | 
182 |     /**
183 |      * It returns a closure that will update an index mapping.
184 |      * @test
185 |      * @covers \DesignMyNight\Elasticsearch\Database\Schema\Grammars\ElasticsearchGrammar::compileUpdate
186 |      */
187 |     public function it_returns_a_closure_that_will_update_an_index_mapping()
188 |     {
189 |         $this->blueprint->text('title');
190 |         $this->blueprint->date('date');
191 |         $this->blueprint->keyword('status');
192 | 
193 |         $this->connection->shouldReceive('updateIndex')->once()->with('indices_dev', 'index', [
194 |             'index' => [
195 |                 'properties' => [
196 |                     'title' => [
197 |                         'type' => 'text'
198 |                     ],
199 |                     'date' => [
200 |                         'type' => 'date'
201 |                     ],
202 |                     'status' => [
203 |                         'type' => 'keyword'
204 |                     ]
205 |                 ]
206 |             ]
207 |         ]);
208 | 
209 |         $executable = $this->grammar->compileUpdate(new Blueprint(''), new Fluent(), $this->connection);
210 | 
211 |         $this->assertInstanceOf(Closure::class, $executable);
212 | 
213 |         $executable($this->blueprint, $this->connection);
214 |     }
215 | 
216 |     /**
217 |      * It generates a mapping.
218 |      * @test
219 |      * @covers \DesignMyNight\Elasticsearch\Database\Schema\Grammars\ElasticsearchGrammar::getColumns
220 |      */
221 |     public function it_generates_a_mapping()
222 |     {
223 |         $this->blueprint->join('joins', ['parent' => 'child']);
224 |         $this->blueprint->text('title')->fields(function (Blueprint $field) {
225 |             $field->keyword('raw');
226 |         });
227 |         $this->blueprint->date('start_date');
228 |         $this->blueprint->boolean('is_closed');
229 |         $this->blueprint->keyword('status');
230 |         $this->blueprint->float('price');
231 |         $this->blueprint->integer('total_reviews');
232 |         $this->blueprint->object('location')->properties(function (Blueprint $mapping) {
233 |             $mapping->text('address');
234 |             $mapping->text('postcode');
235 |             $mapping->geoPoint('coordinates');
236 |         });
237 | 
238 |         $expected = [
239 |             'joins' => [
240 |                 'type' => 'join',
241 |                 'relations' => [
242 |                     'parent' => 'child'
243 |                 ],
244 |             ],
245 |             'title' => [
246 |                 'type' => 'text',
247 |                 'fields' => [
248 |                     'raw' => [
249 |                         'type' => 'keyword'
250 |                     ]
251 |                 ]
252 |             ],
253 |             'start_date' => [
254 |                 'type' => 'date'
255 |             ],
256 |             'is_closed' => [
257 |                 'type' => 'boolean'
258 |             ],
259 |             'status' => [
260 |                 'type' => 'keyword'
261 |             ],
262 |             'price' => [
263 |                 'type' => 'float'
264 |             ],
265 |             'total_reviews' => [
266 |                 'type' => 'integer'
267 |             ],
268 |             'location' => [
269 |                 'properties' => [
270 |                     'address' => [
271 |                         'type' => 'text',
272 |                     ],
273 |                     'postcode' => [
274 |                         'type' => 'text'
275 |                     ],
276 |                     'coordinates' => [
277 |                         'type' => 'geo_point'
278 |                     ]
279 |                 ]
280 |             ]
281 |         ];
282 | 
283 |         $grammar = new class extends ElasticsearchGrammar
284 |         {
285 |             public function outputMapping(Blueprint $blueprint)
286 |             {
287 |                 return $this->getColumns($blueprint);
288 |             }
289 |         };
290 | 
291 |         $this->assertEquals($expected, $grammar->outputMapping($this->blueprint));
292 |     }
293 | }
294 | 


--------------------------------------------------------------------------------
/tests/Unit/Elasticsearch/QueryBuilderTest.php:
--------------------------------------------------------------------------------
 1 | builder = new QueryBuilder($connection, $queryGrammar, $queryProcessor);
31 |     }
32 | 
33 |     /**
34 |      * @test
35 |      * @dataProvider whereParentIdProvider
36 |      */
37 |     public function adds_parent_id_to_wheres_clause(string $parentType, $id, string $boolean):void
38 |     {
39 |         $this->builder->whereParentId($parentType, $id, $boolean);
40 | 
41 |         $this->assertEquals([
42 |             'type' => 'ParentId',
43 |             'name' => $parentType,
44 |             'id' => $id,
45 |             'boolean' => $boolean,
46 |         ], $this->builder->wheres[0]);
47 |     }
48 | 
49 |     /**
50 |      * @return array
51 |      */
52 |     public function whereParentIdProvider():array
53 |     {
54 |         return [
55 |             'boolean and' => ['my_parent', 1, 'and'],
56 |             'boolean or' => ['my_parent', 1, 'or'],
57 |         ];
58 |     }
59 | }
60 | 


--------------------------------------------------------------------------------
/tests/Unit/Support/ElasticsearchExceptionTest.php:
--------------------------------------------------------------------------------
  1 | assertSame($code, $exception->getCode());
 21 |     }
 22 | 
 23 |     /**
 24 |      * @test
 25 |      * @dataProvider errorMessagesProvider
 26 |      */
 27 |     public function returns_the_error_message(
 28 |         BaseElasticsearchException $exception,
 29 |         string $code,
 30 |         string $message
 31 |     ): void {
 32 |         $exception = new ElasticsearchException($exception);
 33 | 
 34 |         $this->assertSame($message, $exception->getMessage());
 35 |     }
 36 | 
 37 |     /**
 38 |      * @test
 39 |      * @dataProvider errorMessagesProvider
 40 |      */
 41 |     public function converts_the_error_to_string(
 42 |         BaseElasticsearchException $exception,
 43 |         string $code,
 44 |         string $message
 45 |     ): void {
 46 |         $exception = new ElasticsearchException($exception);
 47 | 
 48 |         $this->assertSame("$code: $message", (string)$exception);
 49 |     }
 50 | 
 51 |     /**
 52 |      * @test
 53 |      * @dataProvider errorMessagesProvider
 54 |      */
 55 |     public function returns_the_raw_error_message_as_an_array(
 56 |         BaseElasticsearchException $exception,
 57 |         string $code,
 58 |         string $message,
 59 |         array $raw
 60 |     ): void
 61 |     {
 62 |         $exception = new ElasticsearchException($exception);
 63 | 
 64 |         $this->assertSame($raw, $exception->getRaw());
 65 |     }
 66 | 
 67 |     public function errorMessagesProvider(): array
 68 |     {
 69 |         $missingIndexError = json_encode(
 70 |             [
 71 |                 "error"  => [
 72 |                     "root_cause"    => [
 73 |                         [
 74 |                             "type"          => "index_not_found_exception",
 75 |                             "reason"        => "no such index [bob]",
 76 |                             "resource.type" => "index_or_alias",
 77 |                             "resource.id"   => "bob",
 78 |                             "index_uuid"    => "_na_",
 79 |                             "index"         => "bob",
 80 |                         ],
 81 |                     ],
 82 |                     "type"          => "index_not_found_exception",
 83 |                     "reason"        => "no such index [bob]",
 84 |                     "resource.type" => "index_or_alias",
 85 |                     "resource.id"   => "bob",
 86 |                     "index_uuid"    => "_na_",
 87 |                     "index"         => "bob",
 88 |                 ],
 89 |                 "status" => 404,
 90 |             ]
 91 |         );
 92 | 
 93 |         return [
 94 |             'missing_index' => [
 95 |                 'error'   => new Missing404Exception($missingIndexError),
 96 |                 'code'    => 'index_not_found_exception',
 97 |                 'message' => 'no such index [bob]',
 98 |                 'raw'     => json_decode($missingIndexError, true),
 99 |             ],
100 |         ];
101 |     }
102 | }
103 | 


--------------------------------------------------------------------------------