├── .editorconfig ├── .github └── dependabot.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── config ├── elasticsearch.php ├── logging.php └── scout.php ├── phpstan.neon ├── phpunit.xml ├── pint.json ├── psalm.xml ├── src ├── Classes │ ├── Bulk.php │ └── Search.php ├── Collection.php ├── Commands │ ├── CreateIndexCommand.php │ ├── DropIndexCommand.php │ ├── ListIndicesCommand.php │ ├── ReindexCommand.php │ └── UpdateIndexCommand.php ├── Concerns │ ├── AppliesScopes.php │ ├── BuildsFluentQueries.php │ ├── ExecutesQueries.php │ ├── ExplainsQueries.php │ ├── HasGlobalScopes.php │ └── ManagesIndices.php ├── Connection.php ├── ConnectionManager.php ├── ConnectionResolver.php ├── ElasticsearchServiceProvider.php ├── Exceptions │ └── DocumentNotFoundException.php ├── Facades │ └── Elasticsearch.php ├── Factories │ └── ClientFactory.php ├── Index.php ├── Interfaces │ ├── ClientFactoryInterface.php │ ├── ConnectionInterface.php │ ├── ConnectionResolverInterface.php │ └── ScopeInterface.php ├── Model.php ├── Pagination.php ├── Query.php ├── Request.php ├── ScoutEngine.php ├── helpers.php └── pagination │ ├── bootstrap-4.php │ ├── default.php │ ├── simple-bootstrap-4.php │ └── simple-default.php └── tests ├── BodyTest.php ├── ConnectionManagerTest.php ├── ConnectionResolverTest.php ├── ConnectionTest.php ├── DistanceTest.php ├── Factories └── ClientFactoryTest.php ├── GlobalScopeTest.php ├── IgnoreTest.php ├── IndexTest.php ├── ModelTest.php ├── OrderTest.php ├── SearchTest.php ├── SelectTest.php ├── SizeTest.php ├── SkipTest.php ├── Traits ├── ESQueryTrait.php └── ResolvesConnections.php ├── WhereBetweenTest.php ├── WhereInTest.php ├── WhereNotBetweenTest.php ├── WhereNotInTest.php ├── WhereNotTest.php └── WhereTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.yml] 15 | indent_style = space 16 | indent_size = 2 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "composer" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /vendor/ 3 | .gitattributes 4 | .DS_Store 5 | .phpunit.result.cache 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matchory/elasticsearch", 3 | "description": "The missing elasticsearch ORM for Laravel!", 4 | "keywords": [ 5 | "php", 6 | "caching", 7 | "search-engine", 8 | "elasticsearch", 9 | "laravel", 10 | "eloquent", 11 | "orm", 12 | "model", 13 | "indexing", 14 | "query-builder", 15 | "scout" 16 | ], 17 | "license": "MIT", 18 | "type": "package", 19 | "homepage": "https://www.matchory.com", 20 | "support": { 21 | "issues": "https://github.com/matchory/elasticsearch/issues" 22 | }, 23 | "authors": [ 24 | { 25 | "name": "Moritz Friedrich", 26 | "homepage": "https://www.moritzfriedrich.com", 27 | "email": "moritz@matchory.com" 28 | }, 29 | { 30 | "name": "Basem Khirat", 31 | "homepage": "http://basemkhirat.com", 32 | "email": "basemkhirat@gmail.com" 33 | } 34 | ], 35 | "autoload": { 36 | "psr-4": { 37 | "Matchory\\Elasticsearch\\": "src/" 38 | }, 39 | "files": [ 40 | "src/helpers.php" 41 | ] 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Matchory\\Elasticsearch\\Tests\\": "tests/" 46 | } 47 | }, 48 | "require": { 49 | "php": "^8.3", 50 | "ext-json": "*", 51 | "elasticsearch/elasticsearch": "^7.17.2", 52 | "illuminate/pagination": "^12", 53 | "illuminate/support": "^12", 54 | "monolog/monolog": "*", 55 | "symfony/var-dumper": "*" 56 | }, 57 | "require-dev": { 58 | "illuminate/contracts": "^12", 59 | "illuminate/database": "^12", 60 | "jetbrains/phpstorm-attributes": "^1.2.0", 61 | "laravel/framework": "^12", 62 | "laravel/pint": "^1.22.0", 63 | "laravel/scout": "^10.14.1", 64 | "matchory/laravel-server-timing": "^1.3.0", 65 | "orchestra/testbench": "*", 66 | "phpstan/phpstan": "^2.1.14", 67 | "phpunit/phpunit": "^11.5.9", 68 | "sentry/sentry-laravel": "^3.8.2|^4.13.0" 69 | }, 70 | "prefer-stable": true, 71 | "extra": { 72 | "laravel": { 73 | "providers": [ 74 | "Matchory\\Elasticsearch\\ElasticsearchServiceProvider" 75 | ], 76 | "aliases": { 77 | "ES": "Matchory\\Elasticsearch\\Facades\\ES" 78 | } 79 | } 80 | }, 81 | "config": { 82 | "sort-packages": true, 83 | "allow-plugins": { 84 | "composer/package-versions-deprecated": true, 85 | "php-http/discovery": true 86 | } 87 | }, 88 | "replace": { 89 | "basemkhirat/elasticsearch": "*" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /config/elasticsearch.php: -------------------------------------------------------------------------------- 1 | env('ELASTIC_CONNECTION', 'default'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Elasticsearch Connections 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here are each of the Elasticsearch connections setup for your application. 24 | | Of course, examples of configuring each Elasticsearch platform. 25 | | 26 | */ 27 | 'connections' => [ 28 | 'default' => [ 29 | 'hosts' => env('ELASTICSEARCH_HOST', 'http://localhost:9200'), 30 | 'index' => env('ELASTICSEARCH_INDEX', 'my_index'), 31 | ], 32 | 33 | //'authenticated' => [ 34 | // 'hosts' => env('ELASTICSEARCH_HOST', 'https://localhost:9200'), 35 | // 'index' => env('ELASTICSEARCH_INDEX', 'my_index'), 36 | // 'sslVerification' => false, 37 | // 'basicAuthentication' => [ 38 | // 'username' => env('ELASTICSEARCH_USERNAME', 'elastic'), 39 | // 'password' => env('ELASTICSEARCH_PASSWORD'), 40 | // ], 41 | //], 42 | // 43 | //'authenticated_with_apikey' => [ 44 | // 'hosts' => env('ELASTICSEARCH_HOST', 'https://localhost:9200'), 45 | // 'index' => env('ELASTICSEARCH_INDEX', 'my_index'), 46 | // 'apiKey' => [ 47 | // 'id' => env('ELASTICSEARCH_API_KEY_ID'), 48 | // 'apiKey' => env('ELASTICSEARCH_API_KEY'), 49 | // ], 50 | //], 51 | // 52 | //'multiple_hosts' => [ 53 | // 'hosts' => env( 54 | // 'ELASTICSEARCH_HOST', 55 | // 'https://first.host.tld:9200,https://second.host.tld:9200,https://third.host.tld:9200' 56 | // ), 57 | //], 58 | // 59 | //'various_settings' => [ 60 | // 61 | // // Set Elastic Cloud ID to connect to Elastic Cloud 62 | // 'elasticCloudId' => env('ELASTICSEARCH_CLOUD_ID'), 63 | // 64 | // // Set number or retries (default is equal to number of nodes) 65 | // 'retries' => 3, 66 | // 67 | // // Set the selector algorithm 68 | // 'selector' => true, 69 | // 70 | // // Whether to sniff the connection on startup 71 | // 'sniffOnStart' => false, 72 | // 73 | // 'sslCert' => [ 74 | // 75 | // // The name of a file containing a PEM-formatted public TLS certificate 76 | // 'cert' => env('ELASTICSEARCH_TLS_CERT_PATH'), 77 | // 78 | // // Optional passphrase for the certificate 79 | // 'password' => env('ELASTICSEARCH_TLS_CERT_PASSPHRASE'), 80 | // ], 81 | // 82 | // 'sslKey' => [ 83 | // 84 | // // The name of a file containing a private TLS key 85 | // 'key' => env('ELASTICSEARCH_TLS_KEY_PATH'), 86 | // 87 | // // Optional passphrase used to decrypt the private TLS key 88 | // 'password' => env('ELASTICSEARCH_TLS_KEY_PASSPHRASE'), 89 | // ], 90 | // 91 | // // Enable or disable verification of the SSL certificate 92 | // 'sslVerification' => false, 93 | // 94 | // // Set or disable the x-elastic-client-meta header 95 | // 'elasticMetaHeader' => true, 96 | // 97 | // // Include the port in Host header. 98 | // // See: https://github.com/elastic/elasticsearch-php/issues/993 99 | // 'includePortInHostHeader' => true, 100 | //], 101 | ], 102 | 103 | /* 104 | |-------------------------------------------------------------------------- 105 | | Elasticsearch Indices 106 | |-------------------------------------------------------------------------- 107 | | 108 | | Here you can define your indices, with separate settings and mappings. 109 | | Edit settings and mappings and run 'php artisan es:index:update' to update 110 | | indices on elasticsearch server. 111 | | 112 | | 'my_index' is just for test. Replace it with a real index name. 113 | | 114 | */ 115 | 'indices' => [ 116 | 'my_index_1' => [ 117 | 'aliases' => [ 118 | 'my_index', 119 | ], 120 | 121 | 'settings' => [ 122 | 'number_of_shards' => 1, 123 | 'number_of_replicas' => 0, 124 | 'index.mapping.ignore_malformed' => false, 125 | 126 | 'analysis' => [ 127 | 'filter' => [ 128 | 'english_stop' => [ 129 | 'type' => 'stop', 130 | 'stopwords' => '_english_', 131 | ], 132 | 'english_keywords' => [ 133 | 'type' => 'keyword_marker', 134 | 'keywords' => ['example'], 135 | ], 136 | 'english_stemmer' => [ 137 | 'type' => 'stemmer', 138 | 'language' => 'english', 139 | ], 140 | 'english_possessive_stemmer' => [ 141 | 'type' => 'stemmer', 142 | 'language' => 'possessive_english', 143 | ], 144 | ], 145 | 'analyzer' => [ 146 | 'rebuilt_english' => [ 147 | 'tokenizer' => 'standard', 148 | 'filter' => [ 149 | 'english_possessive_stemmer', 150 | 'lowercase', 151 | 'english_stop', 152 | 'english_keywords', 153 | 'english_stemmer', 154 | ], 155 | ], 156 | ], 157 | ], 158 | ], 159 | 160 | 'mappings' => [ 161 | 'posts' => [ 162 | 'properties' => [ 163 | 'title' => [ 164 | 'type' => 'text', 165 | 'analyzer' => 'english', 166 | ], 167 | ], 168 | ], 169 | ], 170 | ], 171 | ], 172 | ]; 173 | -------------------------------------------------------------------------------- /config/logging.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'elasticsearch' => [ 21 | 'driver' => 'stack', 22 | 'channels' => ['stack'], 23 | 'name' => 'elasticsearch', 24 | 'ignore_exceptions' => false, 25 | 'level' => 'info', 26 | ], 27 | ], 28 | ]; 29 | -------------------------------------------------------------------------------- /config/scout.php: -------------------------------------------------------------------------------- 1 | env('SCOUT_DRIVER', 'elasticsearch'), 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Index Prefix 22 | |-------------------------------------------------------------------------- 23 | | 24 | | Here you may specify a prefix that will be applied to all search index 25 | | names used by Scout. This prefix may be useful if you have multiple 26 | | "tenants" or applications sharing the same search infrastructure. 27 | | 28 | */ 29 | 'prefix' => env('SCOUT_PREFIX', ''), 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Queue Data Syncing 34 | |-------------------------------------------------------------------------- 35 | | 36 | | This option allows you to control if the operations that sync your data 37 | | with your search engines are queued. When this is set to "true" then 38 | | all automatic data syncing will get queued for better performance. 39 | | 40 | */ 41 | 'queue' => false, 42 | 43 | /* 44 | |-------------------------------------------------------------------------- 45 | | Algolia Configuration 46 | |-------------------------------------------------------------------------- 47 | | 48 | | Here you may configure your Algolia settings. Algolia is a cloud hosted 49 | | search engine which works great with Scout out of the box. Just plug 50 | | in your application ID and admin API key to get started searching. 51 | | 52 | */ 53 | 'algolia' => [ 54 | 'id' => env('ALGOLIA_APP_ID', ''), 55 | 'secret' => env('ALGOLIA_SECRET', ''), 56 | ], 57 | 58 | 'elasticsearch' => [ 59 | 'connection' => env('ELASTIC_CONNECTION', 'default'), 60 | ], 61 | ]; 62 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 2 3 | paths: 4 | - src/ 5 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | ./tests 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "per" 3 | } 4 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/Classes/Bulk.php: -------------------------------------------------------------------------------- 1 | query = $query; 73 | $this->autocommitAfter = (int) $autocommitAfter; 74 | } 75 | 76 | /** 77 | * Get Bulk body 78 | * 79 | * @return array 80 | */ 81 | public function body(): array 82 | { 83 | return $this->body; 84 | } 85 | 86 | /** 87 | * Add pending document for deletion 88 | * 89 | * @return bool 90 | */ 91 | public function delete(): bool 92 | { 93 | return $this->action('delete'); 94 | } 95 | 96 | /** 97 | * Add pending document abstract action 98 | * 99 | * @param string $actionType 100 | * @param array $data 101 | * 102 | * @return bool 103 | */ 104 | public function action(string $actionType, array $data = []): bool 105 | { 106 | $this->body['body'][] = [ 107 | $actionType => [ 108 | '_index' => $this->getIndex(), 109 | '_type' => $this->getType(), 110 | '_id' => $this->_id, 111 | ], 112 | ]; 113 | 114 | if (!empty($data)) { 115 | $this->body['body'][] = $actionType === 'update' 116 | ? ['doc' => $data] 117 | : $data; 118 | } 119 | 120 | $this->operationCount++; 121 | 122 | $this->reset(); 123 | 124 | if ( 125 | $this->autocommitAfter > 0 && 126 | $this->operationCount >= $this->autocommitAfter 127 | ) { 128 | return (bool) $this->commit(); 129 | } 130 | 131 | return true; 132 | } 133 | 134 | /** 135 | * Get the index name 136 | * 137 | * @return string|null 138 | */ 139 | protected function getIndex(): string|null 140 | { 141 | return $this->index ?: $this->query->getIndex(); 142 | } 143 | 144 | /** 145 | * Get the type name 146 | * 147 | * @return string|null 148 | * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 149 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html 150 | */ 151 | #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')] 152 | protected function getType(): string|null 153 | { 154 | return $this->type; 155 | } 156 | 157 | /** 158 | * Reset names 159 | * 160 | * @return void 161 | */ 162 | public function reset(): void 163 | { 164 | $this->index(); 165 | $this->type(); 166 | } 167 | 168 | /** 169 | * Set the index name 170 | * 171 | * @param string|null $index 172 | * 173 | * @return $this 174 | */ 175 | public function index(string|null $index = null): self 176 | { 177 | $this->index = $index; 178 | 179 | return $this; 180 | } 181 | 182 | /** 183 | * Set the type name 184 | * 185 | * @param string|null $type 186 | * 187 | * @return $this 188 | * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 189 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html 190 | */ 191 | #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')] 192 | public function type(string|null $type = null): self 193 | { 194 | $this->type = $type; 195 | 196 | return $this; 197 | } 198 | 199 | /** 200 | * Commit all pending operations 201 | * 202 | * @return array|null 203 | */ 204 | public function commit(): ?array 205 | { 206 | if (empty($this->body)) { 207 | return null; 208 | } 209 | 210 | $result = $this 211 | ->query 212 | ->getConnection() 213 | ->getClient() 214 | ->bulk($this->body); 215 | 216 | $this->operationCount = 0; 217 | $this->body = []; 218 | 219 | return $result; 220 | } 221 | 222 | /** 223 | * Just an alias for _id() method 224 | * 225 | * @param string|null $_id 226 | * 227 | * @return $this 228 | */ 229 | public function id(string|null $_id = null): self 230 | { 231 | return $this->_id($_id); 232 | } 233 | 234 | /** 235 | * Filter by _id 236 | * 237 | * @param string|null $_id 238 | * 239 | * @return $this 240 | */ 241 | public function _id(string|null $_id = null): self 242 | { 243 | $this->_id = $_id; 244 | 245 | return $this; 246 | } 247 | 248 | /** 249 | * Add pending document for insert 250 | * 251 | * @param array $data 252 | * 253 | * @return bool 254 | */ 255 | public function insert(array $data = []): bool 256 | { 257 | return $this->action('index', $data); 258 | } 259 | 260 | /** 261 | * Add pending document for update 262 | * 263 | * @param array $data 264 | * 265 | * @return bool 266 | */ 267 | public function update(array $data = []): bool 268 | { 269 | return $this->action('update', $data); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/Classes/Search.php: -------------------------------------------------------------------------------- 1 | query = $query; 68 | $this->queryString = $queryString; 69 | 70 | if (is_callable($settings)) { 71 | $settings($this); 72 | } 73 | 74 | // TODO: What is the purpose of this property? 75 | $this->settings = $settings; 76 | } 77 | 78 | /** 79 | * Set search boost factor 80 | * 81 | * @param int $boost 82 | * 83 | * @return $this 84 | */ 85 | public function boost(int $boost = 1): self 86 | { 87 | $this->boost = $boost; 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * Build the native query 94 | */ 95 | public function build(): void 96 | { 97 | $queryParams = [ 98 | self::PARAMETER_QUERY => $this->queryString, 99 | ]; 100 | 101 | if ($this->boost > 1) { 102 | $queryParams[self::PARAMETER_BOOST] = $this->boost; 103 | } 104 | 105 | if (count($this->fields)) { 106 | $queryParams[self::PARAMETER_FIELDS] = $this->fields; 107 | } 108 | 109 | $this->query->must[] = [ 110 | 'query_string' => $queryParams, 111 | ]; 112 | } 113 | 114 | /** 115 | * Set searchable fields 116 | * 117 | * @param array $fields 118 | * 119 | * @return $this 120 | */ 121 | public function fields(array $fields = []): self 122 | { 123 | $searchable = []; 124 | 125 | foreach ($fields as $field => $weight) { 126 | $weightSuffix = $weight > 1 ? "^{$weight}" : ''; 127 | $searchable[] = $field . $weightSuffix; 128 | } 129 | 130 | $this->fields = $searchable; 131 | 132 | return $this; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Collection.php: -------------------------------------------------------------------------------- 1 | 22 | * @package Matchory\Elasticsearch 23 | */ 24 | class Collection extends BaseCollection 25 | { 26 | /** 27 | * Collection constructor. 28 | * 29 | * @param iterable $items 30 | * @param int|null $total 31 | * @param float|null $maxScore 32 | * @param float|null $duration 33 | * @param bool|null $timedOut 34 | * @param string|null $scrollId 35 | * @param stdClass|null $shards 36 | * @param array|null $suggestions 37 | * @param array|null $aggregations 38 | */ 39 | public function __construct( 40 | iterable $items = [], 41 | protected int|null $total = null, 42 | protected float|null $maxScore = null, 43 | protected float|null $duration = null, 44 | protected bool|null $timedOut = null, 45 | protected string|null $scrollId = null, 46 | protected stdClass|null $shards = null, 47 | protected array|null $suggestions = null, 48 | protected array|null $aggregations = null, 49 | ) { 50 | parent::__construct($items); 51 | } 52 | 53 | public static function fromResponse( 54 | array $response, 55 | array|null $items = null, 56 | ): self { 57 | $items = $items ?? $response['hits']['hits'] ?? []; 58 | 59 | $maxScore = (float) $response['hits']['max_score']; 60 | $duration = (float) $response['took']; 61 | $timedOut = (bool) $response['timed_out']; 62 | $scrollId = (string) ($response['_scroll_id'] ?? null); 63 | /** @var stdClass $shards */ 64 | $shards = (object) $response['_shards']; 65 | $suggestions = $response['suggest'] ?? []; 66 | $aggregations = $response['aggregations'] ?? []; 67 | $total = (int) ( 68 | is_array($response['hits']['total']) 69 | ? $response['hits']['total']['value'] 70 | : $response['hits']['total'] 71 | ); 72 | 73 | return new self( 74 | $items, 75 | $total, 76 | $maxScore, 77 | $duration, 78 | $timedOut, 79 | $scrollId, 80 | $shards, 81 | $suggestions, 82 | $aggregations, 83 | ); 84 | } 85 | 86 | public function getAggregations(): BaseCollection 87 | { 88 | return new BaseCollection($this->aggregations); 89 | } 90 | 91 | public function getAllSuggestions(): BaseCollection 92 | { 93 | return BaseCollection::make($this->suggestions) 94 | ->mapInto(BaseCollection::class); 95 | } 96 | 97 | public function getDuration(): float|null 98 | { 99 | return $this->duration; 100 | } 101 | 102 | public function getMaxScore(): float|null 103 | { 104 | return $this->maxScore; 105 | } 106 | 107 | public function getScrollId(): string|null 108 | { 109 | return $this->scrollId; 110 | } 111 | 112 | public function getShards(): stdClass|null 113 | { 114 | return $this->shards; 115 | } 116 | 117 | public function getSuggestions(string $name): BaseCollection 118 | { 119 | return new BaseCollection($this->suggestions[$name] ?? []); 120 | } 121 | 122 | public function getTotal(): int|null 123 | { 124 | return $this->total; 125 | } 126 | 127 | public function isTimedOut(): bool|null 128 | { 129 | return $this->timedOut; 130 | } 131 | 132 | /** 133 | * Get the collection of items as JSON. 134 | * 135 | * @param int $options 136 | * 137 | * @return string 138 | * @throws JsonException 139 | */ 140 | public function toJson($options = 0): string 141 | { 142 | return json_encode( 143 | $this->toArray(), 144 | JSON_THROW_ON_ERROR | $options, 145 | ); 146 | } 147 | 148 | /** 149 | * @inheritDoc 150 | * @psalm-suppress DocblockTypeContradiction 151 | */ 152 | public function toArray(): array 153 | { 154 | return array_map(static function ($item) { 155 | return $item instanceof Arrayable 156 | ? $item->toArray() 157 | : $item; 158 | }, $this->items); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Commands/CreateIndexCommand.php: -------------------------------------------------------------------------------- 1 | option('connection') ?: null; 36 | $connection = $resolver->connection($connectionName)->newQuery(); 37 | $client = $connection->raw(); 38 | 39 | /** @var string[] $indices */ 40 | $indices = !is_null($this->argument('index')) 41 | ? [$this->argument('index')] 42 | : array_keys(config('elasticsearch.indices', config('elasticsearch.indices', config('es.indices', [])))); 43 | 44 | foreach ($indices as $index) { 45 | $config = config("elasticsearch.indices.{$index}", config("es.indices.{$index}")); 46 | 47 | if (is_null($config)) { 48 | $this->warn("Missing configuration for index: {$index}"); 49 | 50 | continue; 51 | } 52 | 53 | if ($client->indices()->exists(['index' => $index])) { 54 | $this->warn("Index {$index} already exists!"); 55 | 56 | continue; 57 | } 58 | 59 | // Create index with settings from config file 60 | 61 | $this->info("Creating index: {$index}"); 62 | 63 | $client->indices()->create([ 64 | 'index' => $index, 65 | 'body' => [ 66 | 'settings' => $config['settings'], 67 | ], 68 | ]); 69 | 70 | if (isset($config['aliases'])) { 71 | foreach ($config['aliases'] as $alias) { 72 | $this->info("Creating alias: {$alias} for index: {$index}"); 73 | 74 | $client->indices()->updateAliases([ 75 | 'body' => [ 76 | 'actions' => [ 77 | [ 78 | 'add' => [ 79 | 'index' => $index, 80 | 'alias' => $alias, 81 | ], 82 | ], 83 | ], 84 | 85 | ], 86 | ]); 87 | } 88 | } 89 | 90 | if (isset($config['mappings'])) { 91 | foreach ($config['mappings'] as $mapping) { 92 | $this->info( 93 | "Creating mapping for index: {$index}", 94 | ); 95 | 96 | // Create mapping for type from config file 97 | $client->indices()->putMapping([ 98 | 'index' => $index, 99 | 'body' => $mapping, 100 | 'include_type_name' => true, 101 | ]); 102 | } 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Commands/DropIndexCommand.php: -------------------------------------------------------------------------------- 1 | option('connection') ?: null; 39 | $connection = $resolver->connection($connectionName); 40 | $force = $this->option('force') || 0; 41 | $client = $connection->getClient(); 42 | $indices = !is_null($this->argument('index')) 43 | ? [$this->argument('index')] 44 | : array_keys(config('elasticsearch.indices', config('es.indices', []))); 45 | 46 | foreach ($indices as $index) { 47 | if (!$client->indices()->exists(['index' => $index])) { 48 | $this->warn("Index '{$index}' does not exist."); 49 | 50 | continue; 51 | } 52 | 53 | if ( 54 | $force || 55 | $this->confirm("Are you sure you want to drop the index '{$index}'?") 56 | ) { 57 | $this->info("Dropping index: {$index}"); 58 | $client->indices()->delete(['index' => $index]); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Commands/ListIndicesCommand.php: -------------------------------------------------------------------------------- 1 | option('connection') ?: null; 62 | $connection = $resolver->connection($connectionName)->newQuery(); 63 | $indices = $connection->raw()->cat()->indices(); 64 | $indices = is_array($indices) 65 | ? $this->getIndicesFromArrayResponse($indices) 66 | : $this->getIndicesFromStringResponse($indices); 67 | 68 | if (count($indices)) { 69 | $this->table($this->headers, $indices); 70 | } else { 71 | $this->warn('No indices found.'); 72 | } 73 | } 74 | 75 | /** 76 | * Get a list of indices data 77 | * Match newer versions of elasticsearch/elasticsearch package (5.1.1 or higher) 78 | */ 79 | public function getIndicesFromArrayResponse(array $indices): array 80 | { 81 | $data = []; 82 | 83 | foreach ($indices as $row) { 84 | $row = array_key_exists($row['index'], config('elasticsearch.indices', config('es.indices', []))) 85 | ? Arr::prepend($row, 'yes') 86 | : Arr::prepend($row, 'no'); 87 | 88 | $data[] = $row; 89 | } 90 | 91 | return $data; 92 | } 93 | 94 | /** 95 | * Get list of indices data 96 | * Match older versions of elasticsearch/elasticsearch package. 97 | */ 98 | public function getIndicesFromStringResponse(string $indices): array 99 | { 100 | $lines = explode(PHP_EOL, trim($indices)); 101 | $data = []; 102 | 103 | foreach ($lines as $line) { 104 | $line_array = explode(' ', trim($line)); 105 | $row = []; 106 | 107 | foreach ($line_array as $item) { 108 | if (trim($item) !== '') { 109 | $row[] = $item; 110 | } 111 | } 112 | 113 | $row = array_key_exists($row[2], config('elasticsearch.indices', config('es.indices', []))) 114 | ? Arr::prepend($row, 'yes') 115 | : Arr::prepend($row, 'no'); 116 | 117 | $data[] = $row; 118 | } 119 | 120 | return $data; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Commands/ReindexCommand.php: -------------------------------------------------------------------------------- 1 | connection = $this->option('connection') ?: null; 66 | $this->size = (int) $this->option('bulk-size'); 67 | $this->scroll = (string) $this->option('scroll'); 68 | 69 | if ($this->size <= 0) { 70 | $this->warn('Invalid size value'); 71 | 72 | return; 73 | } 74 | 75 | $originalIndex = (string) $this->argument('index'); 76 | $newIndex = $this->argument('new_index'); 77 | 78 | if (!array_key_exists($originalIndex, config('elasticsearch.indices', config('es.indices', [])))) { 79 | $this->warn("Missing configuration for index: {$originalIndex}"); 80 | 81 | return; 82 | } 83 | 84 | if (!array_key_exists($newIndex, config('elasticsearch.indices', config('es.indices', [])))) { 85 | $this->warn("Missing configuration for index: {$newIndex}"); 86 | 87 | return; 88 | } 89 | 90 | $this->migrate($resolver, $originalIndex, $newIndex); 91 | } 92 | 93 | /** 94 | * Migrate data with Scroll queries & Bulk API 95 | * 96 | * @throws InvalidArgumentException 97 | * @throws JsonException 98 | * @throws RuntimeException 99 | */ 100 | public function migrate( 101 | ConnectionResolverInterface $resolver, 102 | string $originalIndex, 103 | string $newIndex, 104 | string|null $scrollId = null, 105 | int $errors = 0, 106 | int $page = 1, 107 | ): void { 108 | $connection = $resolver->connection($this->connection); 109 | 110 | if ($page === 1) { 111 | $pages = (int) ceil( 112 | $connection 113 | ->index($originalIndex) 114 | ->count() / $this->size, 115 | ); 116 | 117 | $this->output->progressStart($pages); 118 | 119 | $documents = $connection 120 | ->index($originalIndex) 121 | ->scroll($this->scroll) 122 | ->take($this->size) 123 | ->performSearch(); 124 | } else { 125 | $documents = $connection 126 | ->index($originalIndex) 127 | ->scroll($this->scroll) 128 | ->scrollID($scrollId ?: '') 129 | ->performSearch(); 130 | } 131 | 132 | if ( 133 | isset($documents['hits']['hits']) && 134 | count($documents['hits']['hits']) 135 | ) { 136 | $data = $documents['hits']['hits']; 137 | $params = []; 138 | 139 | foreach ($data as $row) { 140 | $params['body'][] = [ 141 | 142 | 'index' => [ 143 | '_index' => $newIndex, 144 | '_type' => $row['_type'], 145 | '_id' => $row['_id'], 146 | ], 147 | 148 | ]; 149 | 150 | $params['body'][] = $row['_source']; 151 | } 152 | 153 | $response = $connection->getClient()->bulk($params); 154 | 155 | if (isset($response['errors']) && $response['errors']) { 156 | if (!$this->option('hide-errors')) { 157 | $items = json_encode($response['items']); 158 | 159 | if (!$this->option('skip-errors')) { 160 | $this->warn("\n{$items}"); 161 | 162 | return; 163 | } 164 | 165 | $this->warn("\n{$items}"); 166 | } 167 | 168 | $errors++; 169 | } 170 | 171 | $this->output->progressAdvance(); 172 | } else { 173 | // Reindexing finished 174 | $this->output->progressFinish(); 175 | 176 | $total = $connection 177 | ->index($originalIndex) 178 | ->count(); 179 | 180 | if ($errors > 0) { 181 | $this->warn("{$total} documents reindexed with {$errors} errors."); 182 | 183 | return; 184 | } 185 | 186 | $this->info("{$total} documents reindexed successfully."); 187 | 188 | return; 189 | } 190 | 191 | $page++; 192 | 193 | $this->migrate( 194 | $resolver, 195 | $originalIndex, 196 | $newIndex, 197 | $documents['_scroll_id'], 198 | $errors, 199 | $page, 200 | ); 201 | } 202 | 203 | } 204 | -------------------------------------------------------------------------------- /src/Commands/UpdateIndexCommand.php: -------------------------------------------------------------------------------- 1 | option('connection') ?: null; 37 | $connection = $resolver->connection($connectionName); 38 | $client = $connection->getClient(); 39 | $indices = !is_null($this->argument('index')) 40 | ? [$this->argument('index')] 41 | : array_keys(config('elasticsearch.indices', config('es.indices', []))); 42 | 43 | foreach ($indices as $index) { 44 | $config = config("elasticsearch.indices.{$index}", config("es.indices.{$index}")); 45 | 46 | if (is_null($config)) { 47 | $this->warn("Missing configuration for index: {$index}"); 48 | continue; 49 | } 50 | 51 | if (!$client->indices()->exists(['index' => $index])) { 52 | $this->call('es:indices:create', [ 53 | 'index' => $index, 54 | ]); 55 | 56 | return; 57 | } 58 | 59 | $this->info("Removing aliases for index: {$index}"); 60 | 61 | // The index is already exists. update aliases and setting 62 | // Remove all index aliases 63 | $client->indices()->updateAliases([ 64 | 'body' => [ 65 | 'actions' => [ 66 | [ 67 | 'remove' => [ 68 | 'index' => $index, 69 | 'alias' => '*', 70 | ], 71 | ], 72 | ], 73 | 74 | ], 75 | 76 | 'client' => ['ignore' => [404]], 77 | ]); 78 | 79 | // Update index aliases from config 80 | if (isset($config['aliases'])) { 81 | foreach ($config['aliases'] as $alias) { 82 | $this->info( 83 | "Creating alias: {$alias} for index: {$index}", 84 | ); 85 | 86 | $client->indices()->updateAliases([ 87 | 'body' => [ 88 | 'actions' => [ 89 | [ 90 | 'add' => [ 91 | 'index' => $index, 92 | 'alias' => $alias, 93 | ], 94 | ], 95 | ], 96 | 97 | ], 98 | ]); 99 | } 100 | } 101 | 102 | // Create mapping for type from config file 103 | if (isset($config['mappings'])) { 104 | foreach ($config['mappings'] as $mapping) { 105 | $this->info( 106 | "Creating mapping for index: {$index}", 107 | ); 108 | 109 | $client->indices()->putMapping([ 110 | 'index' => $index, 111 | 'body' => $mapping, 112 | ]); 113 | } 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Concerns/AppliesScopes.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | protected array $removedScopes = []; 28 | 29 | /** 30 | * Holds all scopes applied to the query. 31 | * 32 | * @var array 33 | */ 34 | protected array $scopes = []; 35 | 36 | /** 37 | * Apply the scopes to the Elasticsearch query instance and return it. 38 | * 39 | * @return $this 40 | */ 41 | public function applyScopes(): static 42 | { 43 | if (!$this->scopes) { 44 | return $this; 45 | } 46 | 47 | $query = clone $this; 48 | 49 | foreach ($this->scopes as $identifier => $scope) { 50 | if (!isset($query->scopes[$identifier])) { 51 | continue; 52 | } 53 | 54 | $query->callScope(function (Query $query) use ($scope) { 55 | // If the scope is a Closure we will just go ahead and call the 56 | // scope with the builder instance. 57 | if ($scope instanceof Closure) { 58 | $scope($query); 59 | } 60 | 61 | // If the scope is a scope object, we will call the apply method 62 | // on this scope passing in the query and the model instance. 63 | // After we run all of these scopes we will return the query 64 | // instance to the outside caller. 65 | if ($scope instanceof ScopeInterface) { 66 | $scope->apply($query, $this->getModel()); 67 | } 68 | }); 69 | } 70 | 71 | return $query; 72 | } 73 | 74 | /** 75 | * Apply the given scope on the current builder instance. 76 | * 77 | * @param callable $scope 78 | * @param array $parameters 79 | * 80 | * @return $this 81 | */ 82 | protected function callScope(callable $scope, array $parameters = []): static 83 | { 84 | array_unshift($parameters, $this); 85 | $scope(...array_values($parameters)); 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Determine if the given model has a scope. 92 | * 93 | * @param string $scope 94 | * 95 | * @return bool 96 | */ 97 | public function hasNamedScope(string $scope): bool 98 | { 99 | return $this->getModel()->hasNamedScope($scope); 100 | } 101 | 102 | /** 103 | * Get an array of global scopes that were removed from the query. 104 | * 105 | * @return string[] 106 | */ 107 | public function removedScopes(): array 108 | { 109 | return $this->removedScopes; 110 | } 111 | 112 | /** 113 | * Call the given local model scopes. 114 | * 115 | * @param array|string $scopes 116 | * 117 | * @return $this 118 | */ 119 | public function scopes(array|string $scopes): static 120 | { 121 | $query = $this; 122 | 123 | foreach (Arr::wrap($scopes) as $scope => $parameters) { 124 | // If the scope key is an integer, then the scope was passed as the 125 | // value and the parameter list is empty, so we will format the 126 | // scope name and these parameters here. Then, we'll be ready to 127 | // call the scope on the model. 128 | if (is_int($scope)) { 129 | [$scope, $parameters] = [$parameters, []]; 130 | } 131 | 132 | // Next we'll pass the scope callback to the callScope method which 133 | // will take care of grouping the conditions properly so the logical 134 | // order doesn't get messed up when adding scopes. 135 | // Then we'll return out the query. 136 | $query = $query->callNamedScope( 137 | $scope, 138 | (array) $parameters, 139 | ); 140 | } 141 | 142 | return $query; 143 | } 144 | 145 | /** 146 | * Apply the given named scope on the current query instance. 147 | * 148 | * @param string $scope 149 | * @param array $parameters 150 | * 151 | * @return $this 152 | */ 153 | protected function callNamedScope( 154 | string $scope, 155 | array $parameters = [], 156 | ): static { 157 | return $this->callScope(fn(mixed ...$parameters): mixed => $this 158 | ->getModel() 159 | ->callNamedScope( 160 | $scope, 161 | $parameters, 162 | ), $parameters); 163 | } 164 | 165 | /** 166 | * Register a new global scope. 167 | * 168 | * @param string $identifier 169 | * @param Closure|ScopeInterface $scope 170 | * 171 | * @return $this 172 | */ 173 | public function withGlobalScope( 174 | string $identifier, 175 | ScopeInterface|Closure $scope, 176 | ): static { 177 | $this->scopes[$identifier] = $scope; 178 | 179 | if (method_exists($scope, 'extend')) { 180 | $scope->extend($this); 181 | } 182 | 183 | return $this; 184 | } 185 | 186 | /** 187 | * Remove all or passed registered global scopes. 188 | * 189 | * @param ScopeInterface[]|null $scopes 190 | * 191 | * @return $this 192 | */ 193 | public function withoutGlobalScopes(array|null $scopes = null): static 194 | { 195 | if (!is_array($scopes)) { 196 | $scopes = array_keys($this->scopes); 197 | } 198 | 199 | foreach ($scopes as $scope) { 200 | $this->withoutGlobalScope($scope); 201 | } 202 | 203 | return $this; 204 | } 205 | 206 | /** 207 | * Remove a registered global scope. 208 | * 209 | * @param string|ScopeInterface $scope 210 | * 211 | * @return $this 212 | */ 213 | public function withoutGlobalScope(ScopeInterface|string $scope): static 214 | { 215 | if (!is_string($scope)) { 216 | $scope = get_class($scope); 217 | } 218 | 219 | unset($this->scopes[$scope]); 220 | 221 | $this->removedScopes[] = $scope; 222 | 223 | return $this; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/Concerns/ExplainsQueries.php: -------------------------------------------------------------------------------- 1 | **Note:** If the Elasticsearch security features are enabled, you must 18 | * > have the read index privilege for the target index. 19 | * 20 | * @param string|null $id Document ID. Defaults to the ID in the query. 21 | * @param bool $lenient If `true`, format-based query failures (such as 22 | * providing text to a numeric field) will be ignored. 23 | * Defaults to `false`. 24 | * 25 | * @return array|null 26 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-explain.html 27 | */ 28 | public function explain(string|null $id = null, bool $lenient = false): array|null 29 | { 30 | $body = $this->getBody(); 31 | $query = $body['body'] ?? null; 32 | $source = $body['source'] ?? null; 33 | $id = $id ?? $this->getId(); 34 | 35 | if (!$query || !$id) { 36 | return null; 37 | } 38 | 39 | return $this->getConnection()->getClient()->explain([ 40 | 'index' => $this->getIndex(), 41 | 'lenient' => $lenient, 42 | 'id' => $id, 43 | 'body' => ['query' => $query], 44 | '_source' => $source ?? false, 45 | ]); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Concerns/HasGlobalScopes.php: -------------------------------------------------------------------------------- 1 | > 30 | */ 31 | protected static $globalScopes = []; 32 | 33 | /** 34 | * Register a new global scope on the model. 35 | * 36 | * @param string|Closure|ScopeInterface $scope 37 | * @param Closure|null $implementation 38 | * 39 | * @return Closure|ScopeInterface 40 | * 41 | * @throws InvalidArgumentException 42 | */ 43 | public static function addGlobalScope( 44 | ScopeInterface|string|Closure $scope, 45 | Closure|null $implementation = null, 46 | ): ScopeInterface|Closure { 47 | if (is_string($scope) && !is_null($implementation)) { 48 | return static::$globalScopes[static::class][$scope] = $implementation; 49 | } 50 | 51 | if ($scope instanceof Closure) { 52 | return static::$globalScopes[static::class][spl_object_hash($scope)] = $scope; 53 | } 54 | 55 | if ($scope instanceof ScopeInterface) { 56 | return static::$globalScopes[static::class][get_class($scope)] = $scope; 57 | } 58 | 59 | throw new InvalidArgumentException( 60 | 'Global scopes must be callable or implement ScopeInterface', 61 | ); 62 | } 63 | 64 | /** 65 | * Determine if a model has a global scope. 66 | * 67 | * @param ScopeInterface|string $scope 68 | * 69 | * @return bool 70 | */ 71 | public static function hasGlobalScope($scope): bool 72 | { 73 | return (bool) static::getGlobalScope($scope); 74 | } 75 | 76 | /** 77 | * Get a global scope registered with the model. 78 | * 79 | * @param ScopeInterface|string $scope 80 | * 81 | * @return ScopeInterface|Closure|null 82 | */ 83 | public static function getGlobalScope($scope) 84 | { 85 | if (is_string($scope)) { 86 | return static::$globalScopes[static::class][$scope] ?? null; 87 | } 88 | 89 | return static::$globalScopes[static::class][get_class($scope)] ?? null; 90 | } 91 | 92 | /** 93 | * Get the global scopes for this class instance. 94 | * 95 | * @return array 96 | */ 97 | public function getGlobalScopes(): array 98 | { 99 | return static::$globalScopes[static::class] ?? []; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Concerns/ManagesIndices.php: -------------------------------------------------------------------------------- 1 | setConnection($this->getConnection()); 25 | 26 | return $index->create(); 27 | } 28 | 29 | /** 30 | * Create the configured index 31 | * 32 | * @param callable|null $callback 33 | * 34 | * @return array 35 | * @throws RuntimeException 36 | * @see Query::createIndex() 37 | */ 38 | public function create(callable|null $callback = null): array 39 | { 40 | $index = $this->getIndex(); 41 | 42 | if (!$index) { 43 | throw new RuntimeException('No index configured'); 44 | } 45 | 46 | return $this->createIndex($index, $callback); 47 | } 48 | 49 | /** 50 | * Check existence of index 51 | * 52 | * @return bool 53 | * @throws RuntimeException 54 | */ 55 | public function exists(): bool 56 | { 57 | $index = $this->getIndex(); 58 | 59 | if (!$index) { 60 | throw new RuntimeException('No index configured'); 61 | } 62 | 63 | $index = new Index($index); 64 | 65 | $index->setConnection($this->getConnection()); 66 | 67 | return $index->exists(); 68 | } 69 | 70 | /** 71 | * Drop index 72 | * 73 | * @param string $name 74 | * 75 | * @return array 76 | */ 77 | public function dropIndex(string $name): array 78 | { 79 | $index = new Index($name); 80 | $index->connection = $this->getConnection(); 81 | 82 | return $index->drop(); 83 | } 84 | 85 | /** 86 | * Drop the configured index 87 | * 88 | * @return array 89 | * @throws RuntimeException 90 | */ 91 | public function drop(): array 92 | { 93 | $index = $this->getIndex(); 94 | 95 | if (!$index) { 96 | throw new RuntimeException('No index name configured'); 97 | } 98 | 99 | return $this->dropIndex($index); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/ConnectionManager.php: -------------------------------------------------------------------------------- 1 | 39 | */ 40 | protected array $connections = []; 41 | 42 | /** 43 | * Create a new connection resolver instance. 44 | * 45 | * @param array $configuration 46 | * @param ClientFactoryInterface $clientFactory 47 | * @param CacheInterface|null $cache 48 | */ 49 | public function __construct( 50 | protected array $configuration, 51 | protected readonly ClientFactoryInterface $clientFactory, 52 | protected readonly CacheInterface|null $cache = null, 53 | ) {} 54 | 55 | /** 56 | * Dynamically pass methods to the default connection. 57 | * 58 | * @param string $method 59 | * @param array $parameters 60 | * 61 | * @return mixed 62 | * @throws InvalidArgumentException 63 | */ 64 | public function __call(string $method, array $parameters) 65 | { 66 | return $this->connection()->$method(...$parameters); 67 | } 68 | 69 | /** 70 | * Get a connection instance by name. 71 | * 72 | * @param string|null $name 73 | * 74 | * @return ConnectionInterface 75 | * @throws InvalidArgumentException 76 | */ 77 | public function connection(string|null $name = null): ConnectionInterface 78 | { 79 | if (is_null($name)) { 80 | $name = $this->getDefaultConnection(); 81 | } 82 | 83 | if (!isset($this->connections[$name])) { 84 | $this->connections[$name] = $this->makeConnection($name); 85 | } 86 | 87 | return $this->connections[$name]; 88 | } 89 | 90 | /** 91 | * Get the default connection name. 92 | * 93 | * @return string 94 | */ 95 | public function getDefaultConnection(): string 96 | { 97 | return $this->configuration[self::CONFIG_KEY_DEFAULT_CONNECTION] ?? ''; 98 | } 99 | 100 | /** 101 | * @param string $name 102 | * 103 | * @return ConnectionInterface 104 | * @throws InvalidArgumentException 105 | */ 106 | protected function makeConnection(string $name): ConnectionInterface 107 | { 108 | $config = $this->configuration[self::CONFIG_KEY_CONNECTIONS][$name] ?? null; 109 | 110 | if (!$config) { 111 | throw new InvalidArgumentException( 112 | "Elasticsearch connection [{$name}] not configured.", 113 | ); 114 | } 115 | 116 | $client = $this->clientFactory->createClient($config); 117 | 118 | return new Connection( 119 | $client, 120 | $this->cache, 121 | $config[self::CONFIG_KEY_INDEX] ?? null, 122 | $config[self::CONFIG_KEY_REPORT_QUERIES] ?? true, 123 | ); 124 | } 125 | 126 | /** 127 | * Add a connection to the resolver. 128 | * 129 | * @param string $name 130 | * @param ConnectionInterface $connection 131 | * 132 | * @return void 133 | */ 134 | public function addConnection( 135 | string $name, 136 | ConnectionInterface $connection, 137 | ): void { 138 | $this->connections[$name] = $connection; 139 | } 140 | 141 | /** 142 | * Set the default connection name. 143 | * 144 | * @param string $name 145 | * 146 | * @return void 147 | */ 148 | public function setDefaultConnection(string $name): void 149 | { 150 | $this->configuration[self::CONFIG_KEY_DEFAULT_CONNECTION] = $name; 151 | } 152 | 153 | /** 154 | * Check if a connection has been registered. 155 | * 156 | * @param string $name 157 | * 158 | * @return bool 159 | */ 160 | public function hasConnection(string $name): bool 161 | { 162 | return isset($this->connections[$name]); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/ConnectionResolver.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | protected array $connections = []; 26 | 27 | /** 28 | * The default connection name. 29 | * 30 | * @var string|null 31 | */ 32 | protected string|null $default = null; 33 | 34 | /** 35 | * Create a new connection resolver instance. 36 | * 37 | * @param array $connections 38 | */ 39 | public function __construct(array $connections = []) 40 | { 41 | foreach ($connections as $name => $connection) { 42 | $this->addConnection($name, $connection); 43 | } 44 | } 45 | 46 | /** 47 | * Add a connection to the resolver. 48 | * 49 | * @param string $name 50 | * @param ConnectionInterface $connection 51 | * 52 | * @return void 53 | */ 54 | public function addConnection( 55 | string $name, 56 | ConnectionInterface $connection, 57 | ): void { 58 | $this->connections[$name] = $connection; 59 | } 60 | 61 | /** 62 | * Get a connection instance by name. 63 | * 64 | * @param string|null $name 65 | * 66 | * @return ConnectionInterface 67 | */ 68 | public function connection(string|null $name = null): ConnectionInterface 69 | { 70 | if (is_null($name)) { 71 | $name = $this->getDefaultConnection(); 72 | } 73 | 74 | return $this->connections[$name]; 75 | } 76 | 77 | /** 78 | * Get the default connection name. 79 | * 80 | * @return string 81 | */ 82 | public function getDefaultConnection(): string 83 | { 84 | return $this->default ?? ''; 85 | } 86 | 87 | /** 88 | * Set the default connection name. 89 | * 90 | * @param string $name 91 | * 92 | * @return void 93 | */ 94 | public function setDefaultConnection(string $name): void 95 | { 96 | $this->default = $name; 97 | } 98 | 99 | /** 100 | * Check if a connection has been registered. 101 | * 102 | * @param string $name 103 | * 104 | * @return bool 105 | */ 106 | public function hasConnection(string $name): bool 107 | { 108 | return isset($this->connections[$name]); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/ElasticsearchServiceProvider.php: -------------------------------------------------------------------------------- 1 | configure(); 49 | 50 | // Enable automatic connection resolution in all models 51 | Model::setConnectionResolver( 52 | $this->app->make( 53 | ConnectionResolverInterface::class, 54 | ), 55 | ); 56 | 57 | // Enable event dispatching in all models 58 | Model::setEventDispatcher( 59 | $this->app->make( 60 | Dispatcher::class, 61 | ), 62 | ); 63 | 64 | // TODO: Remove in next major version 65 | /** @noinspection PhpDeprecationInspection */ 66 | Connection::setConnectionResolver( 67 | $this->app->make( 68 | ConnectionResolverInterface::class, 69 | ), 70 | ); 71 | 72 | // Register the Laravel Scout Engine 73 | $this->registerScoutEngine(); 74 | } 75 | 76 | 77 | /** 78 | * @throws BindingResolutionException 79 | */ 80 | protected function configure(): void 81 | { 82 | if (file_exists($this->packageConfigPath('es.php'))) { 83 | $configPath = $this->packageConfigPath('es.php'); 84 | @trigger_error( 85 | "Since matchory/elasticsearch 3.0.0: The 'es.php' configuration file is deprecated. " . 86 | "Use 'elasticsearch.php' instead.", 87 | E_USER_DEPRECATED, 88 | ); 89 | } else { 90 | $configPath = $this->packageConfigPath('elasticsearch.php'); 91 | } 92 | 93 | $configKey = basename($configPath, '.php'); 94 | 95 | $this->mergeConfigFrom($configPath, $configKey); 96 | $this->mergeLoggingChannelsFrom($this->packageConfigPath('logging.php')); 97 | $this->publishes([ 98 | $this->packageConfigPath() => config_path(), 99 | ], "{$configKey}.config"); 100 | } 101 | 102 | protected function registerScoutEngine(): void 103 | { 104 | // Resolve Laravel Scout engine. 105 | if (!class_exists(EngineManager::class)) { 106 | return; 107 | } 108 | 109 | try { 110 | $this->app 111 | ->make(EngineManager::class) 112 | ->extend('elasticsearch', function () { 113 | $connectionName = Config::get('scout.elasticsearch.connection'); 114 | $config = Config::get("elasticsearch.connections.{$connectionName}"); 115 | $elastic = ElasticBuilder::create() 116 | ->setHosts($config['servers']) 117 | ->build(); 118 | 119 | return new ScoutEngine($elastic, $config['index']); 120 | }); 121 | } catch (BindingResolutionException) { 122 | // Class is not resolved. 123 | // Laravel Scout service provider was not loaded yet. 124 | } 125 | } 126 | 127 | /** 128 | * Register any application services. 129 | * 130 | * @return void 131 | * @throws LogicException 132 | */ 133 | public function register(): void 134 | { 135 | Model::clearBootedModels(); 136 | 137 | $this->registerCommands(); 138 | $this->registerLogger(); 139 | $this->registerClientFactory(); 140 | $this->registerConnectionResolver(); 141 | $this->registerDefaultConnection(); 142 | } 143 | 144 | protected function registerCommands(): void 145 | { 146 | if (!$this->app->runningInConsole()) { 147 | return; 148 | } 149 | 150 | // Registering commands 151 | $this->commands([ 152 | ListIndicesCommand::class, 153 | CreateIndexCommand::class, 154 | UpdateIndexCommand::class, 155 | DropIndexCommand::class, 156 | ReindexCommand::class, 157 | ]); 158 | } 159 | 160 | /** 161 | * Bind the Elasticsearch logger. 162 | * 163 | * @return void 164 | */ 165 | protected function registerLogger(): void 166 | { 167 | $this->app->bind( 168 | 'elasticsearch.logger', 169 | fn(Application $app) 170 | => $app 171 | ->make(LogManager::class) 172 | ->channel('elasticsearch'), 173 | ); 174 | } 175 | 176 | /** 177 | * @throws LogicException 178 | */ 179 | protected function registerClientFactory(): void 180 | { 181 | // Bind our default client factory on the container, so users may 182 | // override it if they need to build their client in a specific way 183 | $this->app->singleton( 184 | ClientFactoryInterface::class, 185 | ClientFactory::class, 186 | ); 187 | 188 | $this->app->bind(ClientFactory::class, fn(Application $app) => new ClientFactory( 189 | $app->make('elasticsearch.logger'), 190 | )); 191 | 192 | $this->app->alias( 193 | ClientFactoryInterface::class, 194 | 'elasticsearch.factory', 195 | ); 196 | } 197 | 198 | /** 199 | * @throws LogicException 200 | */ 201 | protected function registerConnectionResolver(): void 202 | { 203 | // Bind the connection manager for the resolver interface as a singleton 204 | // on the container, so we have a single instance at all times 205 | $this->app->singleton( 206 | ConnectionResolverInterface::class, 207 | function (Application $app) { 208 | $configuration = Config::get('elasticsearch', Config::get('es', [])); 209 | $factory = $app->make(ClientFactoryInterface::class); 210 | $cache = $app->bound(CacheInterface::class) 211 | ? $app->make(CacheInterface::class) 212 | : null; 213 | 214 | return new ConnectionManager( 215 | $configuration, 216 | $factory, 217 | $cache, 218 | ); 219 | }, 220 | ); 221 | 222 | $this->app->alias( 223 | ConnectionResolverInterface::class, 224 | 'elasticsearch.resolver', 225 | ); 226 | 227 | $this->app->alias( 228 | ConnectionResolverInterface::class, 229 | 'elasticsearch', 230 | ); 231 | 232 | $this->app->alias( 233 | ConnectionResolverInterface::class, 234 | 'es', 235 | ); 236 | 237 | $this->app->beforeResolving('es', function () { 238 | @trigger_error( 239 | "Since matchory/elasticsearch 3.0.0: The 'es' alias is deprecated. " . 240 | "Use 'elasticsearch' instead.", 241 | E_USER_DEPRECATED, 242 | ); 243 | }); 244 | } 245 | 246 | /** 247 | * @throws LogicException 248 | */ 249 | protected function registerDefaultConnection(): void 250 | { 251 | // Bind the default connection separately 252 | $this->app->singleton( 253 | ConnectionInterface::class, 254 | fn(Application $app): ConnectionInterface 255 | => $app 256 | ->make(ConnectionResolverInterface::class) 257 | ->connection(), 258 | ); 259 | 260 | $this->app->alias(ConnectionInterface::class, 'elasticsearch.connection'); 261 | } 262 | 263 | /** 264 | * @throws BindingResolutionException 265 | */ 266 | private function mergeLoggingChannelsFrom(string $file): void 267 | { 268 | if (!($this->app instanceof CachesConfiguration && $this->app->configurationIsCached())) { 269 | $packageLoggingConfig = require $file; 270 | 271 | $config = $this->app->make('config'); 272 | $config->set( 273 | 'logging.channels', 274 | array_merge( 275 | $packageLoggingConfig['channels'] ?? [], 276 | $config->get('logging.channels', []), 277 | ), 278 | ); 279 | } 280 | } 281 | 282 | private function packageConfigPath(string $path = ''): string 283 | { 284 | return dirname(__DIR__) . '/config' . ($path ? '/' . $path : $path); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/Exceptions/DocumentNotFoundException.php: -------------------------------------------------------------------------------- 1 | $model 32 | * @param string|array $ids 33 | * 34 | * @return $this 35 | * @psalm-suppress MoreSpecificImplementedParamType 36 | */ 37 | public function setModel($model, $ids = []): self 38 | { 39 | $this->model = $model; 40 | $this->ids = Arr::wrap($ids); 41 | 42 | $this->message = "No query results for model [{$model}]"; 43 | 44 | $this->message = count($this->ids) > 0 45 | ? $this->message . ' ' . implode(', ', $this->ids) 46 | : $this->message . '.'; 47 | 48 | return $this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Facades/Elasticsearch.php: -------------------------------------------------------------------------------- 1 | logger !== null) { 38 | $config['logger'] = $this->logger; 39 | } 40 | 41 | unset($config['index']); 42 | 43 | return ClientBuilder::fromConfig($config); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Index.php: -------------------------------------------------------------------------------- 1 | |string|ArrayObject> 112 | */ 113 | protected array $aliases = []; 114 | 115 | /** 116 | * Creates a new index instance. 117 | * 118 | * @param string $name Name of the index to create. 119 | * @param callable|null $callback Callback to configure the index before it 120 | * is created. This allows to add additional 121 | * options like shards, replicas or mappings. 122 | */ 123 | public function __construct(public string $name, ?callable $callback = null) 124 | { 125 | $this->callback = $callback; 126 | } 127 | 128 | /** 129 | * Retrieves the name of the new index. 130 | * 131 | * @return string 132 | */ 133 | public function getName(): string 134 | { 135 | return $this->name; 136 | } 137 | 138 | /** 139 | * An index alias is a secondary name used to refer to one or more existing 140 | * indices. Most Elasticsearch APIs accept an index alias in place of 141 | * an index. 142 | * 143 | * APIs in Elasticsearch accept an index name when working against a 144 | * specific index, and several indices when applicable. The index aliases 145 | * API allows aliasing an index with a name, with all APIs automatically 146 | * converting the alias name to the actual index name. An alias can also be 147 | * mapped to more than one index, and when specifying it, the alias will 148 | * automatically expand to the aliased indices. An alias can also be 149 | * associated with a filter that will automatically be applied when 150 | * searching, and routing values. An alias cannot have the same name as 151 | * an index. 152 | * 153 | * @param string $alias Name of the alias to add. 154 | * @param array|ArrayObject|string|null $options Options to pass to 155 | * the alias. 156 | * 157 | * @return $this 158 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-aliases.html 159 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html#create-index-aliases 160 | */ 161 | public function alias(string $alias, mixed $options = null): self 162 | { 163 | if ( 164 | $options !== null && 165 | !is_string($options) && 166 | !is_array($options) 167 | ) { 168 | throw new TypeError( 169 | 'Alias options may be passed as an array, a string ' . 170 | 'routing key, or literal null.', 171 | ); 172 | } 173 | 174 | $this->aliases[$alias] = $options ?? new ArrayObject(); 175 | 176 | return $this; 177 | } 178 | 179 | /** 180 | * Creates a new index 181 | * 182 | * @return array 183 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html 184 | */ 185 | public function create(): array 186 | { 187 | $configuratorCallback = $this->callback; 188 | 189 | // By passing a callback, users have the possibility to optionally set 190 | // index configuration in a single, fluent command. 191 | // This API is a little unfortunate, so we should refactor that in the 192 | // next major release. 193 | if ($configuratorCallback) { 194 | $configuratorCallback($this); 195 | } 196 | 197 | $params = [ 198 | self::PARAM_INDEX => $this->name, 199 | self::PARAM_BODY => [ 200 | self::PARAM_SETTINGS => [ 201 | self::PARAM_SETTINGS_NUMBER_OF_SHARDS => $this->shards, 202 | self::PARAM_SETTINGS_NUMBER_OF_REPLICAS => $this->replicas, 203 | ], 204 | ], 205 | ]; 206 | 207 | if (count($this->ignores) > 0) { 208 | $params[self::PARAM_CLIENT] = [ 209 | self::PARAM_CLIENT_IGNORE => $this->ignores, 210 | ]; 211 | } 212 | 213 | if (count($this->aliases) > 0) { 214 | $params[self::PARAM_BODY][self::PARAM_ALIASES] = $this->aliases; 215 | } 216 | 217 | if (count($this->mappings) > 0) { 218 | $params[self::PARAM_BODY][self::PARAM_MAPPINGS] = $this->mappings; 219 | } 220 | 221 | return $this 222 | ->getConnection() 223 | ->getClient() 224 | ->indices() 225 | ->create($params); 226 | } 227 | 228 | /** 229 | * Retrieves the Elasticsearch client instance. 230 | * 231 | * @return Client 232 | * @internal 233 | */ 234 | public function getClient(): Client 235 | { 236 | return $this->getConnection()->getClient(); 237 | } 238 | 239 | /** 240 | * Retrieves the active connection. 241 | * 242 | * @return ConnectionInterface 243 | * @internal 244 | */ 245 | public function getConnection(): ConnectionInterface 246 | { 247 | assert($this->connection !== null); 248 | 249 | return $this->connection; 250 | } 251 | 252 | /** 253 | * Sets the active connection on the index. 254 | * 255 | * @param ConnectionInterface $connection 256 | * 257 | * @internal 258 | */ 259 | public function setConnection(ConnectionInterface $connection): void 260 | { 261 | $this->connection = $connection; 262 | } 263 | 264 | /** 265 | * Deletes an existing index. 266 | * 267 | * @return array 268 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html 269 | */ 270 | public function drop(): array 271 | { 272 | return $this 273 | ->getConnection() 274 | ->getClient() 275 | ->indices() 276 | ->delete([ 277 | self::PARAM_INDEX => $this->name, 278 | self::PARAM_CLIENT => [ 279 | self::PARAM_CLIENT_IGNORE => $this->ignores, 280 | ], 281 | ]); 282 | } 283 | 284 | /** 285 | * Checks whether an index exists. 286 | * 287 | * @return bool 288 | */ 289 | public function exists(): bool 290 | { 291 | return $this 292 | ->getConnection() 293 | ->getClient() 294 | ->indices() 295 | ->exists([ 296 | 'index' => $this->name, 297 | ]); 298 | } 299 | 300 | /** 301 | * Alias to the {@see Index::ignores()} method. 302 | * 303 | * @param int ...$statusCodes 304 | * 305 | * @return $this 306 | */ 307 | #[Deprecated(replacement: '%class%->ignores(%parametersList%)')] 308 | public function ignore(int ...$statusCodes): self 309 | { 310 | return $this->ignores(...$statusCodes); 311 | } 312 | 313 | /** 314 | * Configures the client to ignore bad HTTP requests. 315 | * 316 | * @param int ...$statusCodes HTTP Status codes to ignore. 317 | * 318 | * @return $this 319 | */ 320 | public function ignores(int ...$statusCodes): self 321 | { 322 | $this->ignores = array_unique($statusCodes); 323 | 324 | return $this; 325 | } 326 | 327 | /** 328 | * Sets the fields mappings. 329 | * 330 | * @param array $mappings 331 | * 332 | * @return $this 333 | */ 334 | public function mapping(array $mappings = []): self 335 | { 336 | $this->mappings = $mappings; 337 | 338 | return $this; 339 | } 340 | 341 | /** 342 | * The number of replicas each primary shard has. Defaults to `1`. 343 | * 344 | * @param int $replicas Number of replicas to configure. 345 | * 346 | * @return $this 347 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/index-modules.html#index-number-of-replicas 348 | */ 349 | public function replicas(int $replicas): self 350 | { 351 | $this->replicas = $replicas; 352 | 353 | return $this; 354 | } 355 | 356 | /** 357 | * The number of primary shards that an index should have. Defaults to `1`. 358 | * This setting can only be set at index creation time. It cannot be changed 359 | * on a closed index. 360 | * 361 | * The number of shards are limited to 1024 per index. This limitation is a 362 | * safety limit to prevent accidental creation of indices that can 363 | * destabilize a cluster due to resource allocation. The limit can be 364 | * modified by specifying 365 | * `export ES_JAVA_OPTS="-Des.index.max_number_of_shards=128"` system 366 | * property on every node that is part of the cluster. 367 | * 368 | * @param int $shards Number of shards to configure. 369 | * 370 | * @return $this 371 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/index-modules.html#index-number-of-shards 372 | */ 373 | public function shards(int $shards): self 374 | { 375 | $this->shards = $shards; 376 | 377 | return $this; 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /src/Interfaces/ClientFactoryInterface.php: -------------------------------------------------------------------------------- 1 | $config 15 | * 16 | * @return Client 17 | */ 18 | public function createClient(array $config): Client; 19 | } 20 | -------------------------------------------------------------------------------- /src/Interfaces/ConnectionInterface.php: -------------------------------------------------------------------------------- 1 | elements(); 35 | $html = ''; 36 | 37 | switch ($view) { 38 | case 'bootstrap-4': 39 | $html = require __DIR__ . '/pagination/bootstrap-4.php'; 40 | break; 41 | 42 | case 'default': 43 | $html = require __DIR__ . '/pagination/default.php'; 44 | break; 45 | 46 | case 'simple-bootstrap-4': 47 | $html = require __DIR__ . '/pagination/simple-bootstrap-4.php'; 48 | break; 49 | 50 | case 'simple-default': 51 | $html = require __DIR__ . '/pagination/simple-default.php'; 52 | break; 53 | } 54 | 55 | return new HtmlString($html); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | */ 41 | use ExecutesQueries; 42 | use AppliesScopes; 43 | use BuildsFluentQueries; 44 | use ForwardsCalls; 45 | use ManagesIndices; 46 | use ExplainsQueries; 47 | 48 | public const DEFAULT_CACHE_PREFIX = 'elasticsearch'; 49 | 50 | public const DEFAULT_LIMIT = 10; 51 | 52 | public const DEFAULT_OFFSET = 0; 53 | 54 | public const EQ = self::OPERATOR_EQUAL; 55 | 56 | public const EXISTS = self::OPERATOR_EXISTS; 57 | 58 | public const FIELD_AGGS = 'aggs'; 59 | 60 | protected const FIELD_HIGHLIGHT = '_highlight'; 61 | 62 | protected const FIELD_HITS = 'hits'; 63 | 64 | protected const FIELD_ID = '_id'; 65 | 66 | protected const FIELD_INDEX = '_index'; 67 | 68 | protected const FIELD_NESTED_HITS = 'hits'; 69 | 70 | protected const FIELD_QUERY = 'query'; 71 | 72 | protected const FIELD_SCORE = '_score'; 73 | 74 | protected const FIELD_SORT = 'sort'; 75 | 76 | protected const FIELD_SOURCE = '_source'; 77 | 78 | protected const FIELD_TYPE = '_type'; 79 | 80 | public const GT = self::OPERATOR_GREATER_THAN; 81 | 82 | public const GTE = self::OPERATOR_GREATER_THAN_OR_EQUAL; 83 | 84 | public const LIKE = self::OPERATOR_LIKE; 85 | 86 | public const LT = self::OPERATOR_LOWER_THAN; 87 | 88 | public const LTE = self::OPERATOR_LOWER_THAN_OR_EQUAL; 89 | 90 | public const NEQ = self::OPERATOR_NOT_EQUAL; 91 | 92 | public const OPERATOR_EQUAL = '='; 93 | 94 | public const OPERATOR_EXISTS = 'exists'; 95 | 96 | public const OPERATOR_GREATER_THAN = '>'; 97 | 98 | public const OPERATOR_GREATER_THAN_OR_EQUAL = '>='; 99 | 100 | public const OPERATOR_LIKE = 'like'; 101 | 102 | public const OPERATOR_LOWER_THAN = '<'; 103 | 104 | public const OPERATOR_LOWER_THAN_OR_EQUAL = '<='; 105 | 106 | public const OPERATOR_NOT_EQUAL = '!='; 107 | 108 | public const PARAM_BODY = 'body'; 109 | 110 | public const PARAM_CLIENT = 'client'; 111 | 112 | public const PARAM_CLIENT_IGNORE = 'ignore'; 113 | 114 | public const PARAM_FROM = 'from'; 115 | 116 | public const PARAM_INDEX = 'index'; 117 | 118 | public const PARAM_SCROLL = 'scroll'; 119 | 120 | public const PARAM_SCROLL_ID = 'scroll_id'; 121 | 122 | public const PARAM_SEARCH_TYPE = 'search_type'; 123 | 124 | public const PARAM_SIZE = 'size'; 125 | 126 | public const REGEXP_FLAG_ALL = 1; 127 | 128 | public const REGEXP_FLAG_ANYSTRING = 16; 129 | 130 | public const REGEXP_FLAG_COMPLEMENT = 2; 131 | 132 | public const REGEXP_FLAG_INTERSECTION = 8; 133 | 134 | public const REGEXP_FLAG_INTERVAL = 4; 135 | 136 | public const SOURCE_EXCLUDES = 'excludes'; 137 | 138 | public const SOURCE_INCLUDES = 'includes'; 139 | 140 | /** 141 | * @var array{ 142 | * includes: list, 143 | * excludes: list, 144 | * } 145 | */ 146 | protected static array $defaultSource = [ 147 | self::SOURCE_INCLUDES => [], 148 | self::SOURCE_EXCLUDES => [], 149 | ]; 150 | 151 | /** 152 | * Elastic model instance. 153 | * 154 | * @psalm-var T 155 | */ 156 | private Model $model; 157 | 158 | /** 159 | * Elasticsearch connection instance 160 | * ================================= 161 | * This connection instance will receive any unresolved method calls from 162 | * the query, effectively acting as a proxy: The connection itself proxies 163 | * to the Elasticsearch client instance. 164 | * 165 | * @var ConnectionInterface 166 | */ 167 | protected ConnectionInterface $connection; 168 | 169 | /** 170 | * Creates a new query builder instance. 171 | * 172 | * @param ConnectionInterface $connection Elasticsearch Connection the query 173 | * builder uses. 174 | * @param T|null $model Model instance the query builder 175 | * @noinspection PhpDocSignatureInspection 176 | */ 177 | public function __construct( 178 | ConnectionInterface $connection, 179 | Model|null $model = null, 180 | ) { 181 | $this->connection = $connection; 182 | 183 | /** 184 | * We set a plain model here so there's always a model instance set. 185 | * This avoids errors in methods that rely on a model. 186 | * 187 | * @psalm-suppress PossiblyInvalidPropertyAssignmentValue 188 | */ 189 | $this->model = $model ?? new Model(); 190 | } 191 | 192 | /** 193 | * Adds a ".keyword" suffix to the given field name. This is useful for 194 | * sorting and aggregating on keyword fields. 195 | * 196 | * @param string $field 197 | * @return string 198 | */ 199 | public static function asKeyword(string $field): string 200 | { 201 | return rtrim($field, '.') . '.keyword'; 202 | } 203 | 204 | /** 205 | * Proxies to the collection iterator, allowing to iterate the query builder 206 | * directly as though it were a result collection. 207 | * 208 | * @inheritDoc 209 | */ 210 | final public function getIterator(): ArrayIterator 211 | { 212 | return $this->get()->getIterator(); 213 | } 214 | 215 | /** 216 | * Forwards calls to the model instance. If the called method is a scope, 217 | * it will be applied to the query. 218 | * 219 | * @param string $method Name of the called method. 220 | * @param array $parameters Parameters passed to the method. 221 | * 222 | * @return $this Query builder instance. 223 | * @throws BadMethodCallException 224 | */ 225 | public function __call(string $method, array $parameters): self 226 | { 227 | if ($this->hasNamedScope($method)) { 228 | return $this->callNamedScope($method, $parameters); 229 | } 230 | 231 | if (!method_exists($this->getModel(), $method)) { 232 | throw new BadMethodCallException( 233 | "Method {$method} does not exist.", 234 | ); 235 | } 236 | 237 | return $this->forwardCallTo( 238 | $this->getModel(), 239 | $method, 240 | $parameters, 241 | ); 242 | } 243 | 244 | /** 245 | * Retrieves the instance of the model the query is scoped to. It is set to 246 | * the model that initiated a query, but defaults to the Model class itself 247 | * if the query builder is used without models. 248 | * 249 | * @return T Model instance used for the current query. 250 | * @noinspection PhpDocSignatureInspection 251 | */ 252 | public function getModel(): Model 253 | { 254 | return $this->model; 255 | } 256 | 257 | /** 258 | * Sets the model the query is based on. Any results will be casted to this 259 | * model. If no model is set, a plain model instance will be used. 260 | * 261 | * @template TModel of Model 262 | * 263 | * @param Model $model Model to use for the current query. 264 | * @psalm-param TModel $model 265 | * 266 | * @return static Query builder instance for chaining. 267 | */ 268 | public function setModel(Model $model): static 269 | { 270 | /** @var static $query */ 271 | $query = clone $this; 272 | $query->connection = $this->getConnection(); 273 | $query->model = $model; 274 | 275 | return $query; 276 | } 277 | 278 | /** 279 | * Retrieves the underlying Elasticsearch connection. 280 | * 281 | * @return ConnectionInterface Connection instance. 282 | * @see ConnectionInterface 283 | * @see Connection 284 | */ 285 | public function getConnection(): ConnectionInterface 286 | { 287 | return $this->connection; 288 | } 289 | 290 | /** 291 | * Retrieves the underlying Elasticsearch client instance. This can be used 292 | * to work with the Elasticsearch library directly. You should check out its 293 | * documentation for more information. 294 | * 295 | * @return Client Elasticsearch Client instance. 296 | * @see https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/overview.html 297 | * @see Client 298 | */ 299 | public function raw(): Client 300 | { 301 | return $this->getConnection()->getClient(); 302 | } 303 | 304 | /** 305 | * Converts the query to a JSON string. 306 | * 307 | * @inheritDoc 308 | * @throws JsonException 309 | */ 310 | public function toJson($options = 0): string 311 | { 312 | return json_encode( 313 | $this->jsonSerialize(), 314 | JSON_THROW_ON_ERROR | $options, 315 | ); 316 | } 317 | 318 | /** 319 | * @inheritDoc 320 | */ 321 | public function jsonSerialize(): array 322 | { 323 | return $this->toArray(); 324 | } 325 | 326 | /** 327 | * Converts the fluent query into an Elasticsearch query array that can be 328 | * converted into JSON. 329 | * 330 | * @inheritDoc 331 | */ 332 | final public function toArray(): array 333 | { 334 | return $this->buildQuery(); 335 | } 336 | 337 | /** 338 | * Converts the query into an Elasticsearch query array. 339 | * 340 | * @return array 341 | */ 342 | protected function buildQuery(): array 343 | { 344 | $query = $this->applyScopes(); 345 | 346 | $params = [ 347 | self::PARAM_BODY => $query->getBody(), 348 | self::PARAM_FROM => $query->getSkip(), 349 | self::PARAM_SIZE => $query->getSize(), 350 | ]; 351 | 352 | if (count($query->getIgnores())) { 353 | $params[self::PARAM_CLIENT] = [ 354 | self::PARAM_CLIENT_IGNORE => $query->ignores, 355 | ]; 356 | } 357 | 358 | if ($searchType = $query->getSearchType()) { 359 | $params[self::PARAM_SEARCH_TYPE] = $searchType; 360 | } 361 | 362 | if ($scroll = $query->getScroll()) { 363 | $params[self::PARAM_SCROLL] = $scroll; 364 | } 365 | 366 | if ($index = $query->getIndex()) { 367 | $params[self::PARAM_INDEX] = $index; 368 | } 369 | 370 | return $params; 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /src/Request.php: -------------------------------------------------------------------------------- 1 | [], 37 | ]; 38 | 39 | $models->each(function (Model $model) use (&$params) { 40 | $params['body'][] = [ 41 | 'delete' => [ 42 | '_id' => $model->getKey(), 43 | '_index' => $this->index, 44 | ], 45 | ]; 46 | }); 47 | 48 | $this->client->bulk($params); 49 | } 50 | 51 | /** 52 | * Flush all of the model's records from the engine. 53 | * 54 | * @param Model $model 55 | * 56 | * @return void 57 | */ 58 | public function flush($model): void 59 | { 60 | $this->client->deleteByQuery([ 61 | 'index' => $this->index, 62 | 'body' => [ 63 | 'query' => [ 64 | 'match_all' => [], 65 | ], 66 | ], 67 | ]); 68 | } 69 | 70 | /** 71 | * Get the total count from a raw result returned by the engine. 72 | * 73 | * @param mixed $results 74 | * 75 | * @return int 76 | */ 77 | public function getTotalCount($results): int 78 | { 79 | return $results['hits']['total']; 80 | } 81 | 82 | /** 83 | * @param mixed $results 84 | * 85 | * @return Collection 86 | */ 87 | public function mapIds($results): Collection 88 | { 89 | return new Collection([]); 90 | } 91 | 92 | /** 93 | * Perform the given search on the engine. 94 | * 95 | * @param Builder $builder 96 | * @param int $perPage 97 | * @param int $page 98 | * 99 | * @return array|callable 100 | */ 101 | public function paginate(Builder $builder, $perPage, $page): array|callable 102 | { 103 | $result = $this->performSearch($builder, [ 104 | 'numericFilters' => $this->filters($builder), 105 | 'from' => (($page * $perPage) - $perPage), 106 | 'size' => $perPage, 107 | ]); 108 | 109 | assert(is_array($result)); 110 | 111 | $result['nbPages'] = $result['hits']['total'] / $perPage; 112 | 113 | return $result; 114 | } 115 | 116 | /** 117 | * Perform the given search on the engine. 118 | * 119 | * @param Builder $builder 120 | * @param array $options 121 | * 122 | * @return array|callable 123 | */ 124 | protected function performSearch( 125 | Builder $builder, 126 | array $options = [], 127 | ): callable|array { 128 | $params = [ 129 | 'index' => $this->index, 130 | 'body' => [ 131 | 'query' => [ 132 | 'bool' => [ 133 | 'must' => [ 134 | [ 135 | 'query_string' => [ 136 | 'query' => $builder->query, 137 | ], 138 | ], 139 | ], 140 | ], 141 | ], 142 | ], 143 | ]; 144 | 145 | if (isset($options['from'])) { 146 | $params['body']['from'] = $options['from']; 147 | } 148 | 149 | if (isset($options['size'])) { 150 | $params['body']['size'] = $options['size']; 151 | } 152 | 153 | if ( 154 | isset($options['numericFilters']) && 155 | count($options['numericFilters']) 156 | ) { 157 | $params['body']['query']['bool']['must'] = array_merge( 158 | $params['body']['query']['bool']['must'], 159 | $options['numericFilters'], 160 | ); 161 | } 162 | 163 | return $this->client->search($params); 164 | } 165 | 166 | /** 167 | * Perform the given search on the engine. 168 | * 169 | * @param Builder $builder 170 | * 171 | * @return array|callable 172 | */ 173 | public function search(Builder $builder): array|callable 174 | { 175 | return $this->performSearch($builder, array_filter([ 176 | 'numericFilters' => $this->filters($builder), 177 | 'size' => $builder->limit, 178 | ])); 179 | } 180 | 181 | /** 182 | * Get the filter array for the query. 183 | * 184 | * @param Builder $builder 185 | * 186 | * @return array 187 | */ 188 | protected function filters(Builder $builder): array 189 | { 190 | return collect($builder->wheres) 191 | ->map( 192 | /** 193 | * @param mixed $value 194 | * @param int|string $key 195 | * 196 | * @return array 197 | */ 198 | static fn(mixed $value, int|string $key): array 199 | => [ 200 | 'match_phrase' => [$key => $value], 201 | ], 202 | ) 203 | ->values() 204 | ->all(); 205 | } 206 | 207 | /** 208 | * Map the given results to instances of the given model. 209 | * 210 | * @param Builder $builder 211 | * @param mixed $results 212 | * @param Model $model 213 | * 214 | * @return Collection 215 | * @throws InvalidArgumentException 216 | */ 217 | public function map(Builder $builder, $results, $model): Collection 218 | { 219 | if ((int) $results['hits']['total'] === 0) { 220 | return Collection::make(); 221 | } 222 | 223 | $keys = collect($results['hits']['hits']) 224 | ->pluck('_id') 225 | ->values() 226 | ->all(); 227 | 228 | $models = $model 229 | ->query() 230 | ->whereIn($model->getKeyName(), $keys) 231 | ->get() 232 | ->keyBy($model->getKeyName()); 233 | 234 | $collection = new Collection($results['hits']['hits']); 235 | 236 | return $collection->map(static fn( 237 | array $hit, 238 | ) 239 | => $models[$hit['_id']]); 240 | } 241 | 242 | /** 243 | * Update the given model in the index. 244 | * 245 | * @param Collection $models 246 | * 247 | * @return void 248 | */ 249 | public function update($models): void 250 | { 251 | $params = [ 252 | 'body' => [], 253 | ]; 254 | 255 | $models->each(function (Model $model) use (&$params) { 256 | $params['body'][] = [ 257 | 'update' => [ 258 | '_id' => $model->getKey(), 259 | '_index' => $this->index, 260 | ], 261 | ]; 262 | 263 | assert(is_callable([$model, 'toSearchableArray'])); 264 | 265 | $params['body'][] = [ 266 | 'doc' => $model->toSearchableArray(), 267 | 'doc_as_upsert' => true, 268 | ]; 269 | }); 270 | 271 | $this->client->bulk($params); 272 | } 273 | 274 | /** 275 | * @throws InvalidArgumentException 276 | */ 277 | public function lazyMap(Builder $builder, $results, $model): LazyCollection 278 | { 279 | if ((int) $results['hits']['total'] === 0) { 280 | return LazyCollection::make(); 281 | } 282 | 283 | $keys = collect($results['hits']['hits']) 284 | ->pluck('_id') 285 | ->values() 286 | ->all(); 287 | 288 | $models = $model 289 | ->newQuery() 290 | ->whereIn($model->getKeyName(), $keys) 291 | ->get() 292 | ->keyBy($model->getKeyName()); 293 | 294 | $collection = new LazyCollection($results['hits']['hits']); 295 | 296 | return $collection->map(static fn(array $hit) => $models[$hit['_id']]); 297 | } 298 | 299 | public function createIndex($name, array $options = []): void 300 | { 301 | $this->client->indices()->create([ 302 | 'index' => $name, 303 | 'body' => $options, 304 | ]); 305 | } 306 | 307 | public function deleteIndex($name): void 308 | { 309 | $this->client->indices()->delete([ 310 | 'index' => $name, 311 | ]); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | basePath() . '/config' . ($path ? '/' . $path : $path); 16 | } 17 | } 18 | 19 | if (!function_exists('base_path')) { 20 | /** 21 | * Get the path to the base of the install. 22 | * 23 | * @param string $path 24 | * 25 | * @return string 26 | */ 27 | function base_path(string $path = ''): string 28 | { 29 | return app()->basePath() . ($path ? '/' . $path : $path); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/pagination/bootstrap-4.php: -------------------------------------------------------------------------------- 1 | 13 | hasPages()): ?> 15 |
    16 | onFirstPage()): ?> 18 |
  • 19 | « 20 |
  • 21 | 22 |
  • 23 | 26 |
  • 27 | 29 | 30 | 32 | 34 |
  • 35 | 36 |
  • 37 | 39 | 40 | 42 | $url): ?> 44 | currentPage()): ?> 46 |
  • 47 | 48 |
  • 49 | 50 |
  • 51 | 52 | 53 | 54 |
  • 55 | 57 | 59 | 61 | 63 | 64 | 65 | hasMorePages()): ?> 67 |
  • 68 | 71 |
  • 72 | 73 |
  • 74 | » 75 |
  • 76 | 78 |
79 | 81 | -------------------------------------------------------------------------------- /src/pagination/default.php: -------------------------------------------------------------------------------- 1 | 10 | hasPages()): ?> 12 |
    13 | 14 | onFirstPage()): ?> 16 |
  • «
  • 17 | 18 |
  • 19 | 20 |
  • 21 | 23 | 24 | 26 | 27 | 29 |
  • 30 | 32 | 33 | 35 | $url): ?> 37 | currentPage()): ?> 39 |
  • 40 | 41 |
  • 42 | 43 |
  • 44 | 45 |
  • 46 | 48 | 50 | 52 | 54 | 55 | hasMorePages()): ?> 57 |
  • 58 | 59 |
  • 60 | 61 |
  • »
  • 62 | 64 |
65 | 67 | -------------------------------------------------------------------------------- /src/pagination/simple-bootstrap-4.php: -------------------------------------------------------------------------------- 1 | hasPages()): ?> 11 |
    12 | 13 | onFirstPage()): ?> 15 |
  • 16 | « 17 |
  • 18 | 19 |
  • 20 | 24 |
  • 25 | 27 | 28 | hasMorePages()): ?> 30 |
  • 31 | 35 |
  • 36 | 37 |
  • 38 | » 39 |
  • 40 | 42 |
43 | 45 | -------------------------------------------------------------------------------- /src/pagination/simple-default.php: -------------------------------------------------------------------------------- 1 | hasPages()): ?> 11 |
    12 | 13 | onFirstPage()): ?> 15 |
  • 16 | « 17 |
  • 18 | 19 |
  • 20 | 22 |
  • 23 | 25 | 26 | hasMorePages()): ?> 28 |
  • 29 | 31 |
  • 32 | 33 |
  • 34 | » 35 |
  • 36 | 38 |
39 | 41 | -------------------------------------------------------------------------------- /tests/BodyTest.php: -------------------------------------------------------------------------------- 1 | [ 33 | "bool" => [ 34 | "must" => [ 35 | ["match" => ["address" => "mill"]], 36 | ], 37 | ], 38 | ], 39 | ]; 40 | 41 | self::assertEquals( 42 | $this->getExpected($body), 43 | $this->getActual($body), 44 | ); 45 | } 46 | 47 | /** 48 | * @param $body array 49 | * 50 | * @return array 51 | */ 52 | protected function getExpected(array $body = []): array 53 | { 54 | return $this->getQueryArray($body); 55 | } 56 | 57 | /** 58 | * @param $body array 59 | * 60 | * @return array 61 | * @throws \PHPUnit\Framework\InvalidArgumentException 62 | * @throws ClassAlreadyExistsException 63 | * @throws ClassIsFinalException 64 | * @throws DuplicateMethodException 65 | * @throws InvalidMethodNameException 66 | * @throws OriginalConstructorInvocationRequiredException 67 | * @throws ReflectionException 68 | * @throws RuntimeException 69 | * @throws UnknownTypeException 70 | */ 71 | protected function getActual(array $body = []): array 72 | { 73 | return $this 74 | ->getQueryObject() 75 | ->body($body) 76 | ->toArray(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/ConnectionManagerTest.php: -------------------------------------------------------------------------------- 1 | getDefaultConnection()); 41 | 42 | $instance->setDefaultConnection('foo'); 43 | 44 | self::assertSame('foo', $instance->getDefaultConnection()); 45 | } 46 | 47 | /** 48 | * 49 | * @noinspection PhpParamsInspection 50 | */ 51 | public function testResolvesDefaultConnectionIfNameNotSpecified(): void 52 | { 53 | $instance = new ConnectionManager( 54 | [], 55 | new ClientFactory(), 56 | ); 57 | $connection = $this->mock(ConnectionInterface::class); 58 | $instance->addConnection('', $connection); 59 | 60 | self::assertSame($connection, $instance->connection()); 61 | } 62 | 63 | /** 64 | * 65 | * @noinspection PhpParamsInspection 66 | */ 67 | public function testResolvesConnectionsByName(): void 68 | { 69 | $manager = new ConnectionManager( 70 | [], 71 | new ClientFactory(), 72 | ); 73 | $c1 = $this->mock(ConnectionInterface::class); 74 | $c2 = $this->mock(ConnectionInterface::class); 75 | 76 | $manager->addConnection('foo', $c1); 77 | $manager->addConnection('bar', $c2); 78 | 79 | self::assertSame($c1, $manager->connection('foo')); 80 | self::assertSame($c2, $manager->connection('bar')); 81 | } 82 | 83 | public function testCreatesConnectionsWithCacheInstance(): void 84 | { 85 | /** @var CacheInterface&Mock $cache */ 86 | $cache = $this->mock(CacheInterface::class); 87 | $instance = new ConnectionManager( 88 | [ 89 | 'connections' => [ 90 | 'foo' => [ 91 | 'servers' => [ 92 | '0.0.0.0', 93 | ], 94 | ], 95 | ], 96 | ], 97 | new ClientFactory(), 98 | $cache, 99 | ); 100 | 101 | $connection = $instance->connection('foo'); 102 | self::assertSame($cache, $connection->getCache()); 103 | } 104 | 105 | /** 106 | * @throws ExpectationFailedException 107 | * @throws InvalidArgumentException 108 | * @noinspection PhpParamsInspection 109 | */ 110 | public function testAddsConnection(): void 111 | { 112 | $instance = new ConnectionManager( 113 | [], 114 | new ClientFactory(), 115 | ); 116 | 117 | self::assertFalse($instance->hasConnection('foo')); 118 | 119 | $instance->addConnection('foo', $this->mock( 120 | ConnectionInterface::class, 121 | )); 122 | 123 | self::assertTrue($instance->hasConnection('foo')); 124 | } 125 | 126 | /** @noinspection PhpUndefinedMethodInspection */ 127 | public function testProxiesCallsToDefaultConnection(): void 128 | { 129 | $manager = new ConnectionManager( 130 | [], 131 | new ClientFactory(), 132 | ); 133 | 134 | $expected = 42; 135 | $connection = $this 136 | ->getMockBuilder(Connection::class) 137 | ->disableOriginalConstructor() 138 | ->getMock(); 139 | 140 | $connection 141 | ->expects(self::any()) 142 | ->method('__call') 143 | ->with('test') 144 | ->willReturnCallback(function ($method, $args) use ($expected) { 145 | self::assertSame($expected, $args[0]); 146 | }); 147 | 148 | $manager->addConnection('', $connection); 149 | // @phpstan-ignore-next-line 150 | $manager->test($expected); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/ConnectionResolverTest.php: -------------------------------------------------------------------------------- 1 | createClient([]); 21 | $connection = new Connection($client); 22 | 23 | self::assertFalse($resolver->hasConnection('foo')); 24 | 25 | $resolver->addConnection('foo', $connection); 26 | 27 | self::assertTrue($resolver->hasConnection('foo')); 28 | } 29 | 30 | public function testConnection(): void 31 | { 32 | $resolver = new ConnectionResolver(); 33 | $clientFactory = new ClientFactory(); 34 | $client = $clientFactory->createClient([]); 35 | $connection = new Connection($client); 36 | 37 | self::assertFalse($resolver->hasConnection('foo')); 38 | 39 | $resolver->addConnection('foo', $connection); 40 | 41 | self::assertTrue($resolver->hasConnection('foo')); 42 | self::assertSame( 43 | $connection, 44 | $resolver->connection('foo'), 45 | ); 46 | } 47 | 48 | public function testSetDefaultConnection(): void 49 | { 50 | $resolver = new ConnectionResolver(); 51 | $clientFactory = new ClientFactory(); 52 | $client = $clientFactory->createClient([]); 53 | $connection = new Connection($client); 54 | 55 | self::assertFalse($resolver->hasConnection('foo')); 56 | 57 | $resolver->addConnection('foo', $connection); 58 | $resolver->setDefaultConnection('foo'); 59 | 60 | self::assertSame('foo', $resolver->getDefaultConnection()); 61 | self::assertTrue($resolver->hasConnection('foo')); 62 | self::assertSame($connection, $resolver->connection()); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/ConnectionTest.php: -------------------------------------------------------------------------------- 1 | app->make(ConnectionInterface::class); 22 | $query = $connection->index('foo'); 23 | 24 | self::assertSame('foo', $query->getIndex()); 25 | } 26 | 27 | protected function getEnvironmentSetUp($app): void 28 | { 29 | $this->registerResolver($app); 30 | } 31 | 32 | protected function getPackageProviders($app): array 33 | { 34 | return [ 35 | ElasticsearchServiceProvider::class, 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/DistanceTest.php: -------------------------------------------------------------------------------- 1 | getExpected('location', ['lat' => -33.8688197, 'lon' => 151.20929550000005], '10km'), 45 | $this->getActual('location', ['lat' => -33.8688197, 'lon' => 151.20929550000005], '10km'), 46 | ); 47 | 48 | self::assertNotEquals( 49 | $this->getExpected('location', ['lat' => -33.8688197, 'lon' => 151.20929550000005], '10km'), 50 | $this->getActual('location', ['lat' => -33.8688197, 'lon' => 151.20929550000005], '15km'), 51 | ); 52 | } 53 | 54 | 55 | protected function getExpected(string $field, array $value, string $distance): array 56 | { 57 | $query = $this->getQueryArray(); 58 | 59 | $query['body']['query']['bool']['filter'][] = [ 60 | 'geo_distance' => [ 61 | $field => $value, 62 | 'distance' => $distance, 63 | ], 64 | ]; 65 | 66 | return $query; 67 | } 68 | 69 | /** 70 | * @throws \PHPUnit\Framework\InvalidArgumentException 71 | * @throws ClassAlreadyExistsException 72 | * @throws ClassIsFinalException 73 | * @throws ClassIsReadonlyException 74 | * @throws DuplicateMethodException 75 | * @throws InvalidMethodNameException 76 | * @throws OriginalConstructorInvocationRequiredException 77 | * @throws ReflectionException 78 | * @throws RuntimeException 79 | * @throws UnknownTypeException 80 | */ 81 | protected function getActual($field, $geo_point, $distance): array 82 | { 83 | return $this->getQueryObject()->distance($field, $geo_point, $distance)->toArray(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Factories/ClientFactoryTest.php: -------------------------------------------------------------------------------- 1 | createClient([]); 19 | 20 | self::assertInstanceOf(Client::class, $client); 21 | } 22 | 23 | public function testCreateClientWithHosts(): void 24 | { 25 | $config = [ 26 | 'hosts' => ['foo', 'bar', 'baz'], 27 | ]; 28 | $factory = new ClientFactory(); 29 | $client = $factory->createClient($config); 30 | 31 | self::assertContains( 32 | $client->transport->getConnection()->getHost(), 33 | $config['hosts'], 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/GlobalScopeTest.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Matchory\Elasticsearch\Tests; 16 | 17 | use InvalidArgumentException; 18 | use Matchory\Elasticsearch\Interfaces\ConnectionInterface; 19 | use Matchory\Elasticsearch\Model; 20 | use Matchory\Elasticsearch\Query; 21 | use Matchory\Elasticsearch\Tests\Traits\ESQueryTrait; 22 | use PHPUnit\Framework\Exception; 23 | use PHPUnit\Framework\ExpectationFailedException; 24 | use PHPUnit\Framework\MockObject\ClassAlreadyExistsException; 25 | use PHPUnit\Framework\MockObject\ClassIsFinalException; 26 | use PHPUnit\Framework\MockObject\ClassIsReadonlyException; 27 | use PHPUnit\Framework\MockObject\DuplicateMethodException; 28 | use PHPUnit\Framework\MockObject\InvalidMethodNameException; 29 | use PHPUnit\Framework\MockObject\OriginalConstructorInvocationRequiredException; 30 | use PHPUnit\Framework\MockObject\ReflectionException; 31 | use PHPUnit\Framework\MockObject\RuntimeException; 32 | use PHPUnit\Framework\MockObject\UnknownTypeException; 33 | use PHPUnit\Framework\TestCase; 34 | 35 | use function assert; 36 | 37 | class GlobalScopeTest extends TestCase 38 | { 39 | use ESQueryTrait; 40 | 41 | /** 42 | * @test 43 | * @throws ClassAlreadyExistsException 44 | * @throws ClassIsFinalException 45 | * @throws ClassIsReadonlyException 46 | * @throws DuplicateMethodException 47 | * @throws ExpectationFailedException 48 | * @throws InvalidArgumentException 49 | * @throws InvalidMethodNameException 50 | * @throws OriginalConstructorInvocationRequiredException 51 | * @throws ReflectionException 52 | * @throws RuntimeException 53 | * @throws UnknownTypeException 54 | * @throws \PHPUnit\Framework\InvalidArgumentException 55 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException 56 | */ 57 | public function getGlobalScope(): void 58 | { 59 | $model = new class extends Model { 60 | public static ConnectionInterface|null $connection = null; 61 | 62 | public static function resolveConnection( 63 | string|null $connection = null, 64 | ): ConnectionInterface { 65 | assert(static::$connection !== null); 66 | 67 | return static::$connection; 68 | } 69 | }; 70 | $model::$connection = $this->getConnection(); 71 | 72 | $scope = static function (Query $query): void {}; 73 | 74 | $model::addGlobalScope('foo', $scope); 75 | 76 | self::assertSame($scope, $model::getGlobalScope( 77 | 'foo', 78 | )); 79 | 80 | self::assertNull($model::getGlobalScope('bar')); 81 | } 82 | 83 | /** 84 | * @test 85 | * @throws ClassAlreadyExistsException 86 | * @throws ClassIsFinalException 87 | * @throws ClassIsReadonlyException 88 | * @throws DuplicateMethodException 89 | * @throws Exception 90 | * @throws ExpectationFailedException 91 | * @throws InvalidArgumentException 92 | * @throws InvalidMethodNameException 93 | * @throws OriginalConstructorInvocationRequiredException 94 | * @throws ReflectionException 95 | * @throws RuntimeException 96 | * @throws UnknownTypeException 97 | * @throws \PHPUnit\Framework\InvalidArgumentException 98 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException 99 | */ 100 | public function getGlobalScopes(): void 101 | { 102 | $model = new class extends Model { 103 | public static ConnectionInterface|null $connection = null; 104 | 105 | public static function resolveConnection( 106 | string|null $connection = null, 107 | ): ConnectionInterface { 108 | assert(static::$connection !== null); 109 | 110 | return static::$connection; 111 | } 112 | }; 113 | $model::$connection = $this->getConnection(); 114 | 115 | $scope = static function (Query $query): void {}; 116 | 117 | $model::addGlobalScope('foo', $scope); 118 | 119 | self::assertContains($scope, $model->getGlobalScopes()); 120 | } 121 | 122 | /** 123 | * @throws ClassAlreadyExistsException 124 | * @throws ClassIsFinalException 125 | * @throws ClassIsReadonlyException 126 | * @throws DuplicateMethodException 127 | * @throws ExpectationFailedException 128 | * @throws InvalidArgumentException 129 | * @throws InvalidMethodNameException 130 | * @throws OriginalConstructorInvocationRequiredException 131 | * @throws ReflectionException 132 | * @throws RuntimeException 133 | * @throws UnknownTypeException 134 | * @throws \PHPUnit\Framework\InvalidArgumentException 135 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException 136 | * @test 137 | */ 138 | public function addGlobalScope(): void 139 | { 140 | self::assertEquals( 141 | $this->getExpected('views', 500), 142 | $this->getActual('views', 500), 143 | ); 144 | } 145 | 146 | /** 147 | * @test 148 | * @throws ClassAlreadyExistsException 149 | * @throws ClassIsFinalException 150 | * @throws ClassIsReadonlyException 151 | * @throws DuplicateMethodException 152 | * @throws ExpectationFailedException 153 | * @throws InvalidArgumentException 154 | * @throws InvalidMethodNameException 155 | * @throws OriginalConstructorInvocationRequiredException 156 | * @throws ReflectionException 157 | * @throws RuntimeException 158 | * @throws UnknownTypeException 159 | * @throws \PHPUnit\Framework\InvalidArgumentException 160 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException 161 | */ 162 | public function hasGlobalScope(): void 163 | { 164 | $model = new class extends Model { 165 | public static ConnectionInterface|null $connection = null; 166 | 167 | public static function resolveConnection( 168 | string|null $connection = null, 169 | ): ConnectionInterface { 170 | assert(static::$connection !== null); 171 | 172 | return static::$connection; 173 | } 174 | }; 175 | $model::$connection = $this->getConnection(); 176 | $model::addGlobalScope('foo', static function ( 177 | Query $query, 178 | ) {}); 179 | 180 | self::assertTrue($model::hasGlobalScope('foo')); 181 | self::assertFalse($model::hasGlobalScope('bar')); 182 | } 183 | 184 | /** 185 | * @test 186 | * @throws ClassAlreadyExistsException 187 | * @throws ClassIsFinalException 188 | * @throws ClassIsReadonlyException 189 | * @throws DuplicateMethodException 190 | * @throws Exception 191 | * @throws ExpectationFailedException 192 | * @throws InvalidArgumentException 193 | * @throws InvalidMethodNameException 194 | * @throws OriginalConstructorInvocationRequiredException 195 | * @throws ReflectionException 196 | * @throws RuntimeException 197 | * @throws UnknownTypeException 198 | * @throws \PHPUnit\Framework\InvalidArgumentException 199 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException 200 | */ 201 | public function withoutGlobalScope(): void 202 | { 203 | $model = new class extends Model { 204 | public static ConnectionInterface|null $connection = null; 205 | 206 | public static function resolveConnection( 207 | string|null $connection = null, 208 | ): ConnectionInterface { 209 | assert(static::$connection !== null); 210 | 211 | return static::$connection; 212 | } 213 | }; 214 | $model::$connection = $this->getConnection(); 215 | 216 | $scope = static function (Query $query): void {}; 217 | $model::addGlobalScope('foo', $scope); 218 | 219 | self::assertTrue($model::hasGlobalScope('foo')); 220 | $query = $model->newQuery(); 221 | self::assertNotContains('foo', $query->removedScopes()); 222 | $query = $query->withoutGlobalScope('foo'); 223 | self::assertContains('foo', $query->removedScopes()); 224 | } 225 | 226 | /** 227 | * @test 228 | * @throws ClassAlreadyExistsException 229 | * @throws ClassIsFinalException 230 | * @throws ClassIsReadonlyException 231 | * @throws DuplicateMethodException 232 | * @throws Exception 233 | * @throws ExpectationFailedException 234 | * @throws InvalidArgumentException 235 | * @throws InvalidMethodNameException 236 | * @throws OriginalConstructorInvocationRequiredException 237 | * @throws ReflectionException 238 | * @throws RuntimeException 239 | * @throws UnknownTypeException 240 | * @throws \PHPUnit\Framework\InvalidArgumentException 241 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException 242 | */ 243 | public function withoutGlobalScopes(): void 244 | { 245 | $model = new class extends Model { 246 | public static ConnectionInterface|null $connection = null; 247 | 248 | public static function resolveConnection( 249 | string|null $connection = null, 250 | ): ConnectionInterface { 251 | assert(static::$connection !== null); 252 | 253 | return static::$connection; 254 | } 255 | }; 256 | $model::$connection = $this->getConnection(); 257 | 258 | $foo = static function (Query $query): void {}; 259 | $bar = static function (Query $query): void {}; 260 | $model::addGlobalScope('foo', $foo); 261 | $model::addGlobalScope('bar', $bar); 262 | 263 | self::assertTrue($model::hasGlobalScope('foo')); 264 | $query = $model->newQuery(); 265 | self::assertNotContains('foo', $query->removedScopes()); 266 | self::assertNotContains('bar', $query->removedScopes()); 267 | $query = $query->withoutGlobalScopes(); 268 | self::assertContains('foo', $query->removedScopes()); 269 | self::assertContains('bar', $query->removedScopes()); 270 | } 271 | 272 | /** 273 | * @param string $name 274 | * @param mixed $value 275 | * 276 | * @return array 277 | * @throws InvalidArgumentException 278 | * @throws \PHPUnit\Framework\InvalidArgumentException 279 | * @throws ClassAlreadyExistsException 280 | * @throws ClassIsFinalException 281 | * @throws ClassIsReadonlyException 282 | * @throws DuplicateMethodException 283 | * @throws InvalidMethodNameException 284 | * @throws OriginalConstructorInvocationRequiredException 285 | * @throws ReflectionException 286 | * @throws RuntimeException 287 | * @throws UnknownTypeException 288 | */ 289 | protected function getActual(string $name, mixed $value): array 290 | { 291 | $model = new class extends Model { 292 | public static ConnectionInterface|null $connection = null; 293 | 294 | public static function resolveConnection( 295 | string|null $connection = null, 296 | ): ConnectionInterface { 297 | assert(static::$connection !== null); 298 | 299 | return static::$connection; 300 | } 301 | }; 302 | $model::$connection = $this->getConnection(); 303 | $model::addGlobalScope('foo', fn( 304 | Query $query, 305 | ) => $query->where($name, $value)); 306 | 307 | return $this->getQueryObject($model->newQuery())->toArray(); 308 | } 309 | 310 | protected function getExpected(string $name, mixed $value): array 311 | { 312 | return $this->getQueryArray([ 313 | 'query' => [ 314 | 'bool' => [ 315 | 'filter' => [ 316 | [ 317 | 'term' => [ 318 | $name => $value, 319 | ], 320 | ], 321 | ], 322 | ], 323 | ], 324 | ]); 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /tests/IgnoreTest.php: -------------------------------------------------------------------------------- 1 | getExpected(404), 44 | $this->getActual(404), 45 | ); 46 | self::assertEquals( 47 | $this->getExpected(500, 404), 48 | $this->getActual(500, 404), 49 | ); 50 | } 51 | 52 | /** 53 | * @throws ClassAlreadyExistsException 54 | * @throws ClassIsFinalException 55 | * @throws DuplicateMethodException 56 | * @throws InvalidMethodNameException 57 | * @throws OriginalConstructorInvocationRequiredException 58 | * @throws ReflectionException 59 | * @throws RuntimeException 60 | * @throws UnknownTypeException 61 | * @throws \PHPUnit\Framework\InvalidArgumentException 62 | * @throws ClassIsReadonlyException 63 | */ 64 | protected function getActual(int ...$args): array 65 | { 66 | return $this->getQueryObject() 67 | ->ignore($args) 68 | ->toArray(); 69 | } 70 | 71 | protected function getExpected(int ...$args): array 72 | { 73 | $query = $this->getQueryArray(); 74 | $query['client']['ignore'] = $args; 75 | 76 | return $query; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/IndexTest.php: -------------------------------------------------------------------------------- 1 | getExpected('index_1'), $this->getActual('index_1')); 44 | } 45 | 46 | 47 | protected function getExpected(string $index): array 48 | { 49 | $query = $this->getQueryArray(); 50 | 51 | $query['index'] = $index; 52 | 53 | return $query; 54 | } 55 | 56 | /** 57 | * @throws InvalidArgumentException 58 | * @throws ClassAlreadyExistsException 59 | * @throws ClassIsFinalException 60 | * @throws ClassIsReadonlyException 61 | * @throws DuplicateMethodException 62 | * @throws InvalidMethodNameException 63 | * @throws OriginalConstructorInvocationRequiredException 64 | * @throws ReflectionException 65 | * @throws RuntimeException 66 | * @throws UnknownTypeException 67 | */ 68 | protected function getActual(string $index): array 69 | { 70 | return $this->getQueryObject()->index($index)->toArray(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/OrderTest.php: -------------------------------------------------------------------------------- 1 | getActual('created_at', 'asc'), 25 | $this->getExpected('created_at', 'asc'), 26 | ); 27 | 28 | self::assertEquals( 29 | $this->getExpected('_score'), 30 | $this->getActual('_score'), 31 | ); 32 | } 33 | 34 | /** 35 | * @param string $field 36 | * @param string $direction 37 | * 38 | * @return array 39 | */ 40 | protected function getExpected( 41 | string $field, 42 | string $direction = 'desc', 43 | ): array { 44 | $query = $this->getQueryArray(); 45 | 46 | $query["body"]["sort"][] = [$field => $direction]; 47 | 48 | return $query; 49 | } 50 | 51 | /** 52 | * @param string $field 53 | * @param string $direction 54 | * 55 | * @return array 56 | */ 57 | protected function getActual( 58 | string $field, 59 | string $direction = 'desc', 60 | ): array { 61 | return $this 62 | ->getQueryObject() 63 | ->orderBy($field, $direction) 64 | ->toArray(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/SearchTest.php: -------------------------------------------------------------------------------- 1 | getExpected('foo'), 46 | $this->getActual('foo'), 47 | ); 48 | } 49 | 50 | /** 51 | * Get The actual results. 52 | * 53 | * @throws \PHPUnit\Framework\InvalidArgumentException 54 | * @throws ClassAlreadyExistsException 55 | * @throws ClassIsFinalException 56 | * @throws ClassIsReadonlyException 57 | * @throws DuplicateMethodException 58 | * @throws InvalidMethodNameException 59 | * @throws OriginalConstructorInvocationRequiredException 60 | * @throws ReflectionException 61 | * @throws RuntimeException 62 | * @throws UnknownTypeException 63 | */ 64 | protected function getActual(string $q, int $boost = 1): array 65 | { 66 | return $this->getQueryObject() 67 | ->search($q, boost: $boost) 68 | ->toArray(); 69 | } 70 | 71 | /** 72 | * Get The expected results. 73 | */ 74 | protected function getExpected(array|string $body, int|float $boost = 1): array 75 | { 76 | $query = $this->getQueryArray(); 77 | $search_params = []; 78 | $search_params['query'] = $body; 79 | 80 | if ($boost > 1) { 81 | $search_params['boost'] = $boost; 82 | } 83 | 84 | $query['body']['query']['bool']['must'][] = [ 85 | 'query_string' => $search_params, 86 | ]; 87 | 88 | return $query; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/SelectTest.php: -------------------------------------------------------------------------------- 1 | getExpected('foo', 'bar'), 44 | $this->getActual('foo', 'bar'), 45 | ); 46 | } 47 | 48 | /** 49 | * @throws ClassAlreadyExistsException 50 | * @throws ClassIsFinalException 51 | * @throws DuplicateMethodException 52 | * @throws InvalidMethodNameException 53 | * @throws OriginalConstructorInvocationRequiredException 54 | * @throws ReflectionException 55 | * @throws RuntimeException 56 | * @throws UnknownTypeException 57 | * @throws \PHPUnit\Framework\InvalidArgumentException 58 | * @throws ClassIsReadonlyException 59 | */ 60 | protected function getActual(string ...$fields): array 61 | { 62 | return $this 63 | ->getQueryObject() 64 | ->select($fields) 65 | ->toArray(); 66 | } 67 | 68 | /** 69 | * @param string ...$fields 70 | * 71 | * @return array 72 | */ 73 | protected function getExpected(string ...$fields): array 74 | { 75 | $query = $this->getQueryArray(); 76 | 77 | $query['body']['_source']['includes'] = $fields; 78 | $query['body']['_source']['excludes'] = []; 79 | 80 | return $query; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/SizeTest.php: -------------------------------------------------------------------------------- 1 | getExpected(15), $this->getActual(15)); 44 | } 45 | 46 | 47 | /** 48 | * Get The expected results. 49 | */ 50 | protected function getExpected(int $size): array 51 | { 52 | $query = $this->getQueryArray(); 53 | $query['size'] = $size; 54 | 55 | return $query; 56 | } 57 | 58 | 59 | /** 60 | * Get The actual results. 61 | * 62 | * @throws \PHPUnit\Framework\InvalidArgumentException 63 | * @throws ClassAlreadyExistsException 64 | * @throws ClassIsFinalException 65 | * @throws ClassIsReadonlyException 66 | * @throws DuplicateMethodException 67 | * @throws InvalidMethodNameException 68 | * @throws OriginalConstructorInvocationRequiredException 69 | * @throws ReflectionException 70 | * @throws RuntimeException 71 | * @throws UnknownTypeException 72 | */ 73 | protected function getActual(int $size): array 74 | { 75 | return $this->getQueryObject()->take($size)->toArray(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/SkipTest.php: -------------------------------------------------------------------------------- 1 | getExpected(10), 45 | $this->getActual(10), 46 | ); 47 | } 48 | 49 | protected function getExpected(int $from): array 50 | { 51 | $query = $this->getQueryArray(); 52 | $query['from'] = $from; 53 | 54 | return $query; 55 | } 56 | 57 | /** 58 | * @throws \PHPUnit\Framework\InvalidArgumentException 59 | * @throws ClassAlreadyExistsException 60 | * @throws ClassIsFinalException 61 | * @throws ClassIsReadonlyException 62 | * @throws DuplicateMethodException 63 | * @throws InvalidMethodNameException 64 | * @throws OriginalConstructorInvocationRequiredException 65 | * @throws ReflectionException 66 | * @throws RuntimeException 67 | * @throws UnknownTypeException 68 | */ 69 | protected function getActual(int $from): array 70 | { 71 | return $this->getQueryObject()->skip($from)->toArray(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/Traits/ESQueryTrait.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Client::class) 64 | ->disableOriginalConstructor() 65 | ->getMock(); 66 | } 67 | 68 | /** 69 | * @return Connection 70 | * @throws ClassAlreadyExistsException 71 | * @throws ClassIsFinalException 72 | * @throws ClassIsReadonlyException 73 | * @throws DuplicateMethodException 74 | * @throws InvalidArgumentException 75 | * @throws InvalidMethodNameException 76 | * @throws OriginalConstructorInvocationRequiredException 77 | * @throws ReflectionException 78 | * @throws RuntimeException 79 | * @throws UnknownTypeException 80 | */ 81 | protected function getConnection(): Connection 82 | { 83 | return new Connection($this->getClient()); 84 | } 85 | 86 | /** 87 | * Expected query array 88 | * 89 | * @param array $body 90 | * 91 | * @return array 92 | */ 93 | protected function getQueryArray(array $body = []): array 94 | { 95 | return [ 96 | 'index' => $this->index, 97 | 'body' => $body, 98 | 'from' => $this->skip, 99 | 'size' => $this->take, 100 | ]; 101 | } 102 | 103 | /** 104 | * ES query object 105 | * 106 | * @param Query|null $query 107 | * 108 | * @return Query 109 | * @throws ClassAlreadyExistsException 110 | * @throws ClassIsFinalException 111 | * @throws ClassIsReadonlyException 112 | * @throws DuplicateMethodException 113 | * @throws InvalidArgumentException 114 | * @throws InvalidMethodNameException 115 | * @throws OriginalConstructorInvocationRequiredException 116 | * @throws ReflectionException 117 | * @throws RuntimeException 118 | * @throws UnknownTypeException 119 | */ 120 | protected function getQueryObject(?Query $query = null): Query 121 | { 122 | return ($query ?? new Query($this->getConnection())) 123 | ->index($this->index) 124 | ->take($this->take) 125 | ->skip($this->skip); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/Traits/ResolvesConnections.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected $elasticsearchClient; 29 | 30 | /** 31 | * @return MockObject 32 | * @throws ClassAlreadyExistsException 33 | * @throws ClassIsFinalException 34 | * @throws DuplicateMethodException 35 | * @throws InvalidArgumentException 36 | * @throws InvalidMethodNameException 37 | * @throws OriginalConstructorInvocationRequiredException 38 | * @throws ReflectionException 39 | * @throws RuntimeException 40 | * @throws UnknownTypeException 41 | */ 42 | public function mockClient(): MockObject 43 | { 44 | if (! $this->elasticsearchClient) { 45 | $this->elasticsearchClient = $this 46 | ->getMockBuilder(Client::class) 47 | ->disableOriginalConstructor() 48 | ->getMock(); 49 | } 50 | 51 | return $this->elasticsearchClient; 52 | } 53 | 54 | /** 55 | * @return ConnectionResolver 56 | * @throws InvalidArgumentException 57 | * @throws ClassAlreadyExistsException 58 | * @throws ClassIsFinalException 59 | * @throws DuplicateMethodException 60 | * @throws InvalidMethodNameException 61 | * @throws OriginalConstructorInvocationRequiredException 62 | * @throws ReflectionException 63 | * @throws RuntimeException 64 | * @throws UnknownTypeException 65 | */ 66 | public function createConnectionResolver(): ConnectionResolver 67 | { 68 | /** @var Client $mock */ 69 | $mock = $this->mockClient(); 70 | 71 | $connection = new Connection($mock); 72 | $connectionName = $this->getDefaultConnectionName(); 73 | 74 | $resolver = new ConnectionResolver([ 75 | $connectionName => $connection, 76 | ]); 77 | 78 | $resolver->setDefaultConnection($connectionName); 79 | 80 | return $resolver; 81 | } 82 | 83 | protected function getDefaultConnectionName(): string 84 | { 85 | return 'default'; 86 | } 87 | 88 | /** 89 | * @param Application $application 90 | * 91 | * @throws ClassAlreadyExistsException 92 | * @throws ClassIsFinalException 93 | * @throws DuplicateMethodException 94 | * @throws InvalidArgumentException 95 | * @throws InvalidMethodNameException 96 | * @throws OriginalConstructorInvocationRequiredException 97 | * @throws ReflectionException 98 | * @throws RuntimeException 99 | * @throws UnknownTypeException 100 | */ 101 | protected function registerResolver(Application $application): void 102 | { 103 | $resolver = $this->createConnectionResolver(); 104 | 105 | $application->instance( 106 | ConnectionResolverInterface::class, 107 | $resolver, 108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/WhereBetweenTest.php: -------------------------------------------------------------------------------- 1 | getExpected('views', 500, 1000), 44 | $this->getActual('views', 500, 1000), 45 | ); 46 | 47 | self::assertEquals( 48 | $this->getExpected('views', [500, 1000]), 49 | $this->getActual('views', [500, 1000]), 50 | ); 51 | } 52 | 53 | /** 54 | * @param int|array{int, int} $first 55 | * 56 | * @throws ClassAlreadyExistsException 57 | * @throws ClassIsFinalException 58 | * @throws DuplicateMethodException 59 | * @throws InvalidArgumentException 60 | * @throws InvalidMethodNameException 61 | * @throws OriginalConstructorInvocationRequiredException 62 | * @throws ReflectionException 63 | * @throws RuntimeException 64 | * @throws UnknownTypeException 65 | * @throws ClassIsReadonlyException 66 | */ 67 | protected function getActual(string $name, int|array $first, int|null $last = null): array 68 | { 69 | return $this 70 | ->getQueryObject() 71 | ->whereBetween($name, $first, $last) 72 | ->toArray(); 73 | } 74 | 75 | protected function getExpected(string $name, int|array $first, int|null $last = null): array 76 | { 77 | $query = $this->getQueryArray(); 78 | 79 | if (is_array($first) && count($first) === 2) { 80 | [$first, $last] = $first; 81 | } 82 | 83 | $query['body']['query']['bool']['filter'][] = [ 84 | 'range' => [ 85 | $name => [ 86 | 'gte' => $first, 87 | 'lte' => $last, 88 | ], 89 | ], 90 | ]; 91 | 92 | return $query; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/WhereInTest.php: -------------------------------------------------------------------------------- 1 | getExpected('status', ['pending', 'draft']), 44 | $this->getActual('status', ['pending', 'draft']), 45 | ); 46 | } 47 | 48 | /** 49 | * @throws ClassAlreadyExistsException 50 | * @throws ClassIsFinalException 51 | * @throws DuplicateMethodException 52 | * @throws InvalidMethodNameException 53 | * @throws OriginalConstructorInvocationRequiredException 54 | * @throws ReflectionException 55 | * @throws RuntimeException 56 | * @throws UnknownTypeException 57 | * @throws \PHPUnit\Framework\InvalidArgumentException 58 | * @throws ClassIsReadonlyException 59 | */ 60 | protected function getActual(string $name, array $value = []): array 61 | { 62 | return $this 63 | ->getQueryObject() 64 | ->whereIn($name, $value) 65 | ->toArray(); 66 | } 67 | 68 | /** 69 | * @param string $name 70 | * @param array $value 71 | * 72 | * @return array 73 | */ 74 | protected function getExpected(string $name, array $value = []): array 75 | { 76 | $query = $this->getQueryArray(); 77 | 78 | $query['body']['query']['bool']['filter'][] = [ 79 | 'terms' => [ 80 | $name => $value, 81 | ], 82 | ]; 83 | 84 | return $query; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/WhereNotBetweenTest.php: -------------------------------------------------------------------------------- 1 | getExpected('views', 500, 1000), 46 | $this->getActual('views', 500, 1000), 47 | ); 48 | 49 | self::assertEquals( 50 | $this->getExpected('views', [500, 1000]), 51 | $this->getActual('views', [500, 1000]), 52 | ); 53 | 54 | } 55 | 56 | 57 | /** 58 | * Get The expected results. 59 | */ 60 | protected function getExpected($name, $first_value, $second_value = null): array 61 | { 62 | $query = $this->getQueryArray(); 63 | 64 | if (is_array($first_value) && count($first_value) == 2) { 65 | $second_value = $first_value[1]; 66 | $first_value = $first_value[0]; 67 | } 68 | 69 | $query['body']['query']['bool']['must_not'][] = ['range' => [$name => ['gte' => $first_value, 'lte' => $second_value]]]; 70 | 71 | return $query; 72 | } 73 | 74 | 75 | /** 76 | * Get The actual results. 77 | * 78 | * @throws \PHPUnit\Framework\InvalidArgumentException 79 | * @throws ClassAlreadyExistsException 80 | * @throws ClassIsFinalException 81 | * @throws ClassIsReadonlyException 82 | * @throws DuplicateMethodException 83 | * @throws InvalidMethodNameException 84 | * @throws OriginalConstructorInvocationRequiredException 85 | * @throws ReflectionException 86 | * @throws RuntimeException 87 | * @throws UnknownTypeException 88 | */ 89 | protected function getActual($name, $first_value, $second_value = null): array 90 | { 91 | return $this->getQueryObject()->whereNotBetween($name, $first_value, $second_value)->toArray(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/WhereNotInTest.php: -------------------------------------------------------------------------------- 1 | getExpected('status', ['pending', 'draft']), 45 | $this->getActual('status', ['pending', 'draft']), 46 | ); 47 | } 48 | 49 | /** 50 | * @param string $name 51 | * @param array $value 52 | * 53 | * @return array 54 | */ 55 | protected function getExpected(string $name, array $value = []): array 56 | { 57 | $query = $this->getQueryArray(); 58 | 59 | $query['body']['query']['bool']['must_not'][] = [ 60 | 'terms' => [$name => $value], 61 | ]; 62 | 63 | return $query; 64 | } 65 | 66 | /** 67 | * @throws \PHPUnit\Framework\InvalidArgumentException 68 | * @throws ClassAlreadyExistsException 69 | * @throws ClassIsFinalException 70 | * @throws ClassIsReadonlyException 71 | * @throws DuplicateMethodException 72 | * @throws InvalidMethodNameException 73 | * @throws OriginalConstructorInvocationRequiredException 74 | * @throws ReflectionException 75 | * @throws RuntimeException 76 | * @throws UnknownTypeException 77 | */ 78 | protected function getActual(string $name, array $value = []): array 79 | { 80 | return $this 81 | ->getQueryObject() 82 | ->whereNotIn($name, $value) 83 | ->toArray(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/WhereNotTest.php: -------------------------------------------------------------------------------- 1 | ', 29 | '>=', 30 | '<', 31 | '<=', 32 | 'like', 33 | 'exists', 34 | ]; 35 | 36 | /** 37 | * Test the whereNot() method. 38 | * 39 | * @throws ClassAlreadyExistsException 40 | * @throws ClassIsFinalException 41 | * @throws ClassIsReadonlyException 42 | * @throws DuplicateMethodException 43 | * @throws ExpectationFailedException 44 | * @throws InvalidArgumentException 45 | * @throws InvalidMethodNameException 46 | * @throws OriginalConstructorInvocationRequiredException 47 | * @throws ReflectionException 48 | * @throws RuntimeException 49 | * @throws UnknownTypeException 50 | * @throws \PHPUnit\Framework\InvalidArgumentException 51 | */ 52 | public function testWhereNotMethod(): void 53 | { 54 | self::assertEquals( 55 | $this->getExpected('status', 'published'), 56 | $this->getActual('status', 'published'), 57 | ); 58 | 59 | self::assertEquals( 60 | $this->getExpected('status', '=', 'published'), 61 | $this->getActual('status', '=', 'published'), 62 | ); 63 | 64 | self::assertEquals( 65 | $this->getExpected('views', '>', 1000), 66 | $this->getActual('views', '>', 1000), 67 | ); 68 | 69 | self::assertEquals( 70 | $this->getExpected('views', '>=', 1000), 71 | $this->getActual('views', '>=', 1000), 72 | ); 73 | 74 | self::assertEquals( 75 | $this->getExpected('views', '<=', 1000), 76 | $this->getActual('views', '<=', 1000), 77 | ); 78 | 79 | self::assertEquals( 80 | $this->getExpected('content', 'like', 'hello'), 81 | $this->getActual('content', 'like', 'hello'), 82 | ); 83 | 84 | self::assertEquals( 85 | $this->getExpected('website', 'exists', true), 86 | $this->getActual('website', 'exists', true), 87 | ); 88 | 89 | self::assertEquals( 90 | $this->getExpected('website', 'exists', false), 91 | $this->getActual('website', 'exists', false), 92 | ); 93 | } 94 | 95 | protected function getExpected( 96 | string $name, 97 | string $operator = '=', 98 | mixed $value = null, 99 | ): array { 100 | $query = $this->getQueryArray(); 101 | 102 | if (!in_array( 103 | $operator, 104 | $this->operators, 105 | true, 106 | )) { 107 | $value = $operator; 108 | $operator = '='; 109 | } 110 | 111 | $must = []; 112 | $must_not = []; 113 | 114 | if ($operator === '=') { 115 | $must_not[] = ['term' => [$name => $value]]; 116 | } 117 | 118 | if ($operator === '>') { 119 | $must_not[] = ['range' => [$name => ['gt' => $value]]]; 120 | } 121 | 122 | if ($operator === '>=') { 123 | $must_not[] = ['range' => [$name => ['gte' => $value]]]; 124 | } 125 | 126 | if ($operator === '<') { 127 | $must_not[] = ['range' => [$name => ['lt' => $value]]]; 128 | } 129 | 130 | if ($operator === '<=') { 131 | $must_not[] = ['range' => [$name => ['lte' => $value]]]; 132 | } 133 | 134 | if ($operator === 'like') { 135 | $must_not[] = ['match' => [$name => $value]]; 136 | } 137 | 138 | if ($operator === 'exists') { 139 | if ($value) { 140 | $must_not[] = ['exists' => ['field' => $name]]; 141 | } else { 142 | $must[] = ['exists' => ['field' => $name]]; 143 | } 144 | } 145 | 146 | // Build query body 147 | 148 | $bool = []; 149 | 150 | if (count($must)) { 151 | $bool['must'] = $must; 152 | } 153 | 154 | if (count($must_not)) { 155 | $bool['must_not'] = $must_not; 156 | } 157 | 158 | $query['body']['query']['bool'] = $bool; 159 | 160 | return $query; 161 | } 162 | 163 | /** 164 | * @throws \PHPUnit\Framework\InvalidArgumentException 165 | * @throws ClassAlreadyExistsException 166 | * @throws ClassIsFinalException 167 | * @throws ClassIsReadonlyException 168 | * @throws DuplicateMethodException 169 | * @throws InvalidMethodNameException 170 | * @throws OriginalConstructorInvocationRequiredException 171 | * @throws ReflectionException 172 | * @throws RuntimeException 173 | * @throws UnknownTypeException 174 | */ 175 | protected function getActual(string $name, string|null $operator = '=', mixed $value = null): array 176 | { 177 | return $this->getQueryObject()->whereNot($name, $operator, $value)->toArray(); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /tests/WhereTest.php: -------------------------------------------------------------------------------- 1 | ', 29 | '>=', 30 | '<', 31 | '<=', 32 | 'like', 33 | 'exists', 34 | ]; 35 | 36 | /** 37 | * Test the where() method. 38 | * 39 | * @throws ClassAlreadyExistsException 40 | * @throws ClassIsFinalException 41 | * @throws ClassIsReadonlyException 42 | * @throws DuplicateMethodException 43 | * @throws ExpectationFailedException 44 | * @throws InvalidArgumentException 45 | * @throws InvalidMethodNameException 46 | * @throws OriginalConstructorInvocationRequiredException 47 | * @throws ReflectionException 48 | * @throws RuntimeException 49 | * @throws UnknownTypeException 50 | * @throws \InvalidArgumentException 51 | * @throws \PHPUnit\Framework\InvalidArgumentException 52 | */ 53 | public function testWhereMethod(): void 54 | { 55 | self::assertEquals( 56 | $this->getExpected('status', 'published'), 57 | $this->getActual('status', 'published'), 58 | ); 59 | 60 | self::assertEquals( 61 | $this->getExpected('status', '=', 'published'), 62 | $this->getActual('status', '=', 'published'), 63 | ); 64 | 65 | self::assertEquals( 66 | $this->getExpected('views', '>', 1000), 67 | $this->getActual('views', '>', 1000), 68 | ); 69 | 70 | self::assertEquals( 71 | $this->getExpected('views', '>=', 1000), 72 | $this->getActual('views', '>=', 1000), 73 | ); 74 | 75 | self::assertEquals( 76 | $this->getExpected('views', '<=', 1000), 77 | $this->getActual('views', '<=', 1000), 78 | ); 79 | 80 | self::assertEquals( 81 | $this->getExpected('content', 'like', 'hello'), 82 | $this->getActual('content', 'like', 'hello'), 83 | ); 84 | 85 | self::assertEquals( 86 | $this->getExpected('website', 'exists', true), 87 | $this->getActual('website', 'exists', true), 88 | ); 89 | 90 | self::assertEquals( 91 | $this->getExpected('website', 'exists', false), 92 | $this->getActual('website', 'exists', false), 93 | ); 94 | } 95 | 96 | /** 97 | * @param mixed|null $value 98 | * @throws ClassAlreadyExistsException 99 | * @throws ClassIsFinalException 100 | * @throws DuplicateMethodException 101 | * @throws InvalidMethodNameException 102 | * @throws OriginalConstructorInvocationRequiredException 103 | * @throws ReflectionException 104 | * @throws RuntimeException 105 | * @throws UnknownTypeException 106 | * @throws \InvalidArgumentException 107 | * @throws \PHPUnit\Framework\InvalidArgumentException 108 | * @throws ClassIsReadonlyException 109 | */ 110 | protected function getActual( 111 | string $name, 112 | string $operator = '=', 113 | mixed $value = null, 114 | ): array { 115 | return $this 116 | ->getQueryObject() 117 | ->where($name, $operator, $value) 118 | ->toArray(); 119 | } 120 | 121 | /** 122 | * @param mixed|null $value 123 | * 124 | */ 125 | protected function getExpected( 126 | string $name, 127 | string $operator = '=', 128 | mixed $value = null, 129 | ): array { 130 | $query = $this->getQueryArray(); 131 | 132 | if (!in_array( 133 | $operator, 134 | $this->operators, 135 | true, 136 | )) { 137 | $value = $operator; 138 | $operator = '='; 139 | } 140 | 141 | $filter = []; 142 | $must = []; 143 | $must_not = []; 144 | 145 | if ($operator === '=') { 146 | $filter[] = ['term' => [$name => $value]]; 147 | } 148 | 149 | if ($operator === '>') { 150 | $filter[] = ['range' => [$name => ['gt' => $value]]]; 151 | } 152 | 153 | if ($operator === '>=') { 154 | $filter[] = ['range' => [$name => ['gte' => $value]]]; 155 | } 156 | 157 | if ($operator === '<') { 158 | $filter[] = ['range' => [$name => ['lt' => $value]]]; 159 | } 160 | 161 | if ($operator === '<=') { 162 | $filter[] = ['range' => [$name => ['lte' => $value]]]; 163 | } 164 | 165 | if ($operator === 'like') { 166 | $must[] = ['match' => [$name => $value]]; 167 | } 168 | 169 | if ($operator === 'exists') { 170 | if ($value) { 171 | $must[] = ['exists' => ['field' => $name]]; 172 | } else { 173 | $must_not[] = ['exists' => ['field' => $name]]; 174 | } 175 | } 176 | 177 | // Build query body 178 | 179 | $bool = []; 180 | 181 | if (count($must)) { 182 | $bool['must'] = $must; 183 | } 184 | 185 | if (count($must_not)) { 186 | $bool['must_not'] = $must_not; 187 | } 188 | 189 | if (count($filter)) { 190 | $bool['filter'] = $filter; 191 | } 192 | 193 | $query['body']['query']['bool'] = $bool; 194 | 195 | return $query; 196 | } 197 | } 198 | --------------------------------------------------------------------------------