├── .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 |
"; 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 | --------------------------------------------------------------------------------