├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── ActiveDataProvider.php ├── ActiveFixture.php ├── ActiveQuery.php ├── ActiveRecord.php ├── BatchQueryResult.php ├── BulkCommand.php ├── Command.php ├── Connection.php ├── DebugAction.php ├── DebugPanel.php ├── ElasticsearchTarget.php ├── Exception.php ├── Query.php └── QueryBuilder.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Yii Framework 2 Elasticsearch extension Change Log 2 | ================================================== 3 | 4 | 2.1.6 under development 5 | ----------------------- 6 | 7 | - no changes in this release. 8 | 9 | 10 | 2.1.5 February 13, 2025 11 | ----------------------- 12 | 13 | - Bug #344: Disabled JSON pretty print for ElasticSearch bulk API (rhertogh) 14 | - Bug #350: Remove deprecated code, set $pagination->totalCount (lav45) 15 | - Bug #352: Add support for query parameter `search_after` to paginate over large datasets (lubosdz) 16 | 17 | 18 | 2.1.4 May 22, 2023 19 | ------------------ 20 | 21 | - Bug #330: Fix `curlOptions` merging (yuniorsk) 22 | - Bug #332: Added `[\ReturnTypeWillChange]` attribute for BatchQueryResult methods to be compatible with Iterator interface (warton) 23 | - Enh #329: Added `curlOptions` attribute for advanced configuration of curl session (yuniorsk) 24 | 25 | 26 | 2.1.3 August 07, 2022 27 | ----------------------- 28 | 29 | - Enh #311: Added support for runtime mappings in Elasticsearch 7.11+ (mabentley85) 30 | - Enh #323: Updated API calls for compatibility with Elastcisearch 8+ (tehmaestro) 31 | - Enh #323: Improved github testing workflow (terabytesoftw) 32 | - Enh #318: Added "match" and "match_phrase" queries to query builder (Barton0403) 33 | 34 | 35 | 2.1.2 August 09, 2021 36 | --------------------- 37 | 38 | - Enh #18817: Use `random_int()` when choosing connection (samdark) 39 | 40 | 41 | 2.1.1 May 06, 2021 42 | ------------------ 43 | 44 | - Bug #297: Fix `Query::count()` when index contains more than 10,000 documents (rhertogh) 45 | - Bug #298: Fix ElasticSearch performance when passing `false` to `Query::source()` (rhertogh) 46 | 47 | 48 | 2.1.0 July 21, 2020 49 | ------------------- 50 | 51 | - Bug #161: Changed yii\base\Object to yii\base\BaseObject (sashsvamir) 52 | - Bug #171: Allow to have both `query()` and `where()` in a query (beowulfenator) 53 | - Bug #176: Allow very long scroll id by passing scroll id in request body (russianlagman) 54 | - Bug #180: Fixed `count()` compatibility with PHP 7.2 to not call it on scalar values (cebe) 55 | - Bug #191: Fixed error when calling `column('_id')` in `ActiveQuery` (pvassiliou) 56 | - Bug #216: Updated `suggest()` command to support Elasticsearch 6.5+ (beowulfenator) 57 | - Bug #239: Make sure that `ElasticsearchTarget` consistently logs message as text (beowufenator) 58 | - Bug: (CVE-2018-8074): Fixed possibility of manipulated condition when unfiltered input is passed to `ActiveRecord::findOne()` or `findAll()` (cebe) 59 | - Enh #112: Added support for Elasticsearch 5.0. Minimum requirement is also now Elasticsearch 5.0 (holycheater, beowulfenator, i-lie) 60 | - Enh #136: Added docs on error handling in queries (beowulfenator) 61 | - Enh #156: Added suggester support to `ActiveDataProvider` (Julian-B90) 62 | - Enh #222: Added collapse support (walkskyer) 63 | - Enh #272: Add Elasticsearch 7 compatibility (beowulfenator) 64 | - Chg #269: Replace InvalidParamException with InvalidArgumentException (Julian-B90) 65 | - Chg: Removed `Command::getIndexStatus()` and added `getIndexStats()` and `getIndexRecoveryStats()` to reflect changes in Elasticsearch 5.0 (cebe) 66 | - Chg: Search queries that result in a 404 error due to missing indices are now no longer silently interpreted as empty result, but will throw an exception (cebe) 67 | 68 | 69 | 2.0.7 June 01, 2020 70 | ------------------- 71 | 72 | - Bug #199: Fixed `ActiveRecord::insert()` check if insert was indeed successful (rhertogh) 73 | - Bug #248: Fix 'run query' in debugger tool (tunecino) 74 | - Bug #257: ActiveRecord::get() for non-existent ID now works in PHP 7.4 (trifonovivan) 75 | - Enh #56: Added docs regarding updates to attributes with "object" mapping (beowulfenator) 76 | 77 | 78 | 2.0.6 May 27, 2020 79 | ------------------ 80 | 81 | - Bug #180: Fixed `count()` compatibility with PHP 7.2 to not call it on scalar values (cebe) 82 | - Bug #227: Fixed `Bad Request (#400): Unable to verify your data submission.` in debug details panel 'run query' (rhertogh) 83 | - Enh #117: Add support for `QueryInterface::emulateExecution()` (cebe) 84 | 85 | 86 | 2.0.5 March 20, 2018 87 | -------------------- 88 | 89 | - Bug #120: Fix debug panel markup to be compatible with Yii 2.0.10 (drdim) 90 | - Bug #125: Fixed `ActiveDataProvider::refresh()` to also reset `$queryResults` data (sizeg) 91 | - Bug #134: Fix infinite query loop "ActiveDataProvider" when the index does not exist (eolitich) 92 | - Bug #149: Changed `yii\base\Object` to `yii\base\BaseObject` (dmirogin) 93 | - Bug: (CVE-2018-8074): Fixed possibility of manipulated condition when unfiltered input is passed to `ActiveRecord::findOne()` or `findAll()` (cebe) 94 | - Bug: Updated debug panel classes to be consistent with yii 2.0.7 (beowulfenator) 95 | - Bug: Added accessor method for the default Elasticsearch primary key (kyle-mccarthy) 96 | - Enh #15: Special data provider `yii\elasticsearch\ActiveDataProvider` created (klimov-paul) 97 | - Enh #43: Elasticsearch log target (trntv, beowulfenator) 98 | - Enh #47: Added support for post_filter option in search queries (mxkh) 99 | - Enh #60: Minor updates to guide (devypt, beowulfenator) 100 | - Enh #82: Support HTTPS protocol (dor-denis, beowulfenator) 101 | - Enh #83: Support for "gt", ">", "gte", ">=", "lt", "<", "lte", "<=" operators in query (i-lie, beowulfenator) 102 | - Enh #119: Added support for explanation on query (kyle-mccarthy) 103 | - Enh #150: Explicitily send `Content-Type` header in HTTP requests to Elasticsearch (lubobill1990) 104 | - Enh: Bulk API implemented and used in AR (tibee, beowulfenator) 105 | - Enh: Deserialization of raw response when text/plain is supported (Tezd) 106 | - Enh: Added ability to work with aliases through Command class (Tezd) 107 | 108 | 109 | 2.0.4 March 17, 2016 110 | -------------------- 111 | 112 | - Bug #8: Fixed issue with running out of sockets when running a large number of requests by reusing curl handles (cebe) 113 | - Bug #13: Fixed wrong API call for getting all types or searching all types, `_all` works only for indexes (cebe) 114 | - Bug #19: `DeleteAll` now deletes all entries, not first 10 (beowulfenator) 115 | - Bug #48: `UpdateAll` now updates all entries, not first 10 (beowulfenator) 116 | - Bug #65: Fixed warning `array to string conversion` when parsing error response (rhertogh, silverfire) 117 | - Bug #73: Fixed debug panel exception when no data was recorded for Elasticsearch panel (jafaripur) 118 | - Enh #2: Added `min_score` option to query (knut) 119 | - Enh #28: AWS Elasticsearch service compatibility (andrey-bahrachev) 120 | - Enh #33: Implemented `Command::updateSettings()` and `Command::updateAnalyzers()` (githubjeka) 121 | - Enh #50: Implemented HTTP auth (silverfire) 122 | - Enh #62: Added support for scroll API in `batch()` and `each()` (beowulfenator, 13leaf) 123 | - Enh #70: `Query` and `ActiveQuery` now have `$options` attribute that is passed to commands generated by queries (beowulfenator) 124 | - Enh: Unified model creation from result set in `Query` and `ActiveQuery` with `populate()` (beowulfenator) 125 | 126 | 127 | 2.0.3 March 01, 2015 128 | -------------------- 129 | 130 | - no changes in this release. 131 | 132 | 133 | 2.0.2 January 11, 2015 134 | ---------------------- 135 | 136 | - Enh: Added `ActiveFixture` class for testing fixture support for Elasticsearch (cebe, viilveer) 137 | 138 | 139 | 2.0.1 December 07, 2014 140 | ----------------------- 141 | 142 | - Bug #5662: Elasticsearch AR updateCounters() now uses explicitly `groovy` script for updating making it compatible with ES >1.3.0 (cebe) 143 | - Bug #6065: `ActiveRecord::unlink()` was failing in some situations when working with relations via array valued attributes (cebe) 144 | - Enh #5758: Allow passing custom options to `ActiveRecord::update()` and `::delete()` including support for routing needed for updating records with parent relation (cebe) 145 | - Enh: Add support for optimistic locking (cebe) 146 | 147 | 148 | 2.0.0 October 12, 2014 149 | ---------------------- 150 | 151 | - Enh #3381: Added ActiveRecord::arrayAttributes() to define attributes that should be treated as array when retrieved via `fields` (cebe) 152 | 153 | 154 | 2.0.0-rc September 27, 2014 155 | --------------------------- 156 | 157 | - Bug #3587: Fixed an issue with storing empty records (cebe) 158 | - Bug #4187: Elasticsearch dynamic scripting is disabled in 1.2.0, so do not use it in query builder (cebe) 159 | - Enh #3527: Added `highlight` property to Query and ActiveRecord. (Borales) 160 | - Enh #4048: Added `init` event to `ActiveQuery` classes (qiangxue) 161 | - Enh #4086: changedAttributes of afterSave Event now contain old values (dizews) 162 | - Enh: Make error messages more readable in HTML output (cebe) 163 | - Enh: Added support for query stats (cebe) 164 | - Enh: Added support for query suggesters (cebe, tvdavid) 165 | - Enh: Added support for delete by query (cebe, tvdavid) 166 | - Chg #4451: Removed support for facets and replaced them with aggregations (cebe, tadaszelvys) 167 | - Chg: asArray in ActiveQuery is now equal to using the normal Query. This means, that the output structure has changed and `with` is supported anymore. (cebe) 168 | - Chg: Deletion of a record is now also considered successful if the record did not exist. (cebe) 169 | - Chg: Requirement changes: Yii now requires Elasticsearch version 1.0 or higher (cebe) 170 | 171 | 172 | 2.0.0-beta April 13, 2014 173 | ------------------------- 174 | 175 | - Bug #1993: afterFind event in AR is now called after relations have been populated (cebe, creocoder) 176 | - Bug #2324: Fixed QueryBuilder bug when building a query with "query" option (mintao) 177 | - Enh #1313: made index and type available in `ActiveRecord::instantiate()` to allow creating records based on Elasticsearch type when doing cross index/type search (cebe) 178 | - Enh #1382: Added a debug toolbar panel for Elasticsearch (cebe) 179 | - Enh #1765: Added support for primary key path mapping, pk can now be part of the attributes when mapping is defined (cebe) 180 | - Enh #2002: Added filterWhere() method to yii\elasticsearch\Query to allow easy addition of search filter conditions by ignoring empty search fields (samdark, cebe) 181 | - Enh #2892: ActiveRecord dirty attributes are now reset after call to `afterSave()` so information about changed attributes is available in `afterSave`-event (cebe) 182 | - Chg #1765: Changed handling of ActiveRecord primary keys, removed getId(), use getPrimaryKey() instead (cebe) 183 | - Chg #2281: Renamed `ActiveRecord::create()` to `populateRecord()` and changed signature. This method will not call instantiate() anymore (cebe) 184 | - Chg #2146: Removed `ActiveRelation` class and moved the functionality to `ActiveQuery`. 185 | All relational queries are now directly served by `ActiveQuery` allowing to use 186 | custom scopes in relations (cebe) 187 | 188 | 189 | 2.0.0-alpha, December 1, 2013 190 | ----------------------------- 191 | 192 | - Initial release. 193 | 194 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2008 by Yii Software LLC (http://www.yiisoft.com) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software LLC nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

Elasticsearch Query and ActiveRecord for Yii 2

6 |
7 |

8 | 9 | This extension provides the [Elasticsearch](https://www.elastic.co/products/elasticsearch) integration for the [Yii framework 2.0](https://www.yiiframework.com). 10 | It includes basic querying/search support and also implements the `ActiveRecord` pattern that allows you to store active 11 | records in Elasticsearch. 12 | 13 | For license information check the [LICENSE](LICENSE.md)-file. 14 | 15 | Documentation is at [docs/guide/README.md](docs/guide/README.md). 16 | 17 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/yii2-elasticsearch/v/stable.png)](https://packagist.org/packages/yiisoft/yii2-elasticsearch) 18 | [![Total Downloads](https://poser.pugx.org/yiisoft/yii2-elasticsearch/downloads.png)](https://packagist.org/packages/yiisoft/yii2-elasticsearch) 19 | [![Build Status](https://travis-ci.com/yiisoft/yii2-elasticsearch.svg?branch=master)](https://travis-ci.com/yiisoft/yii2-elasticsearch) 20 | [![codecov](https://codecov.io/gh/yiisoft/yii2-elasticsearch/graph/badge.svg?token=oi71bPc1SU)](https://codecov.io/gh/yiisoft/yii2-elasticsearch) 21 | 22 | Requirements 23 | ------------ 24 | 25 | - PHP 7.3 or higher. 26 | 27 | Depending on the version of Elasticsearch you are using you need a different version of this extension. 28 | 29 | - For Elasticsearch 1.6.0 to 1.7.6 use extension version 2.0.x 30 | - For Elasticsearch 5.x or above use extension version 2.1.x 31 | 32 | Installation 33 | ------------ 34 | 35 | The preferred way to install this extension is through [composer](https://getcomposer.org/download/): 36 | 37 | 38 | ``` 39 | composer require --prefer-dist yiisoft/yii2-elasticsearch:"~2.1.0" 40 | ``` 41 | 42 | Configuration 43 | ------------- 44 | 45 | To use this extension, you have to configure the Connection class in your application configuration: 46 | 47 | ```php 48 | return [ 49 | //.... 50 | 'components' => [ 51 | 'elasticsearch' => [ 52 | 'class' => 'yii\elasticsearch\Connection', 53 | 'nodes' => [ 54 | ['http_address' => '127.0.0.1:9200'], 55 | // configure more hosts if you have a cluster 56 | ], 57 | 'dslVersion' => 7, // default is 5 58 | ], 59 | ] 60 | ]; 61 | ``` 62 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/yii2-elasticsearch", 3 | "description": "Elasticsearch integration and ActiveRecord for the Yii framework", 4 | "keywords": ["yii2", "elasticsearch", "active-record", "search", "fulltext"], 5 | "type": "yii2-extension", 6 | "license": "BSD-3-Clause", 7 | "support": { 8 | "issues": "https://github.com/yiisoft/yii2-elasticsearch/issues", 9 | "forum": "https://www.yiiframework.com/forum/", 10 | "wiki": "https://www.yiiframework.com/wiki/", 11 | "irc": "ircs://irc.libera.chat:6697/yii", 12 | "source": "https://github.com/yiisoft/yii2-elasticsearch" 13 | }, 14 | "authors": [ 15 | { 16 | "name": "Carsten Brandt", 17 | "email": "mail@cebe.cc" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=7.3", 22 | "ext-curl": "*", 23 | "ext-json": "*", 24 | "ext-mbstring": "*", 25 | "yiisoft/yii2": "~2.0.14" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "^9.6" 29 | }, 30 | "autoload": { 31 | "psr-4": { "yii\\elasticsearch\\": "src" } 32 | }, 33 | "config": { 34 | "allow-plugins": { 35 | "yiisoft/yii2-composer": true 36 | } 37 | }, 38 | "repositories": [ 39 | { 40 | "type": "composer", 41 | "url": "https://asset-packagist.org" 42 | } 43 | ], 44 | "extra": { 45 | "branch-alias": { 46 | "dev-master": "2.1.x-dev" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ActiveDataProvider.php: -------------------------------------------------------------------------------- 1 | 27 | * @since 2.0.5 28 | */ 29 | class ActiveDataProvider extends \yii\data\ActiveDataProvider 30 | { 31 | /** 32 | * @var array the full query results. 33 | */ 34 | private $_queryResults; 35 | 36 | 37 | /** 38 | * @param array $results full query results 39 | */ 40 | public function setQueryResults($results) 41 | { 42 | $this->_queryResults = $results; 43 | } 44 | 45 | /** 46 | * @return array full query results 47 | */ 48 | public function getQueryResults() 49 | { 50 | if (!is_array($this->_queryResults)) { 51 | $this->prepare(); 52 | } 53 | return $this->_queryResults; 54 | } 55 | 56 | /** 57 | * @return array all aggregations results 58 | */ 59 | public function getAggregations() 60 | { 61 | $results = $this->getQueryResults(); 62 | return isset($results['aggregations']) ? $results['aggregations'] : []; 63 | } 64 | 65 | /** 66 | * Returns results of the specified aggregation. 67 | * @param string $name aggregation name. 68 | * @return array aggregation results. 69 | * @throws InvalidCallException if query results do not contain the requested aggregation. 70 | */ 71 | public function getAggregation($name) 72 | { 73 | $aggregations = $this->getAggregations(); 74 | if (!isset($aggregations[$name])) { 75 | throw new InvalidCallException("Aggregation '{$name}' not found."); 76 | } 77 | return $aggregations[$name]; 78 | } 79 | 80 | /** 81 | * @return array all suggestions results 82 | */ 83 | public function getSuggestions() 84 | { 85 | $results = $this->getQueryResults(); 86 | return isset($results['suggest']) ? $results['suggest'] : []; 87 | } 88 | 89 | /** 90 | * Returns results of the specified suggestion. 91 | * @param string $name suggestion name. 92 | * @return array suggestion results. 93 | * @throws InvalidCallException if query results do not contain the requested suggestion. 94 | */ 95 | public function getSuggestion($name) 96 | { 97 | $suggestions = $this->getSuggestions(); 98 | if (!isset($suggestions[$name])) { 99 | throw new InvalidCallException("Suggestion '{$name}' not found."); 100 | } 101 | return $suggestions[$name]; 102 | } 103 | 104 | /** 105 | * {@inheritdoc} 106 | */ 107 | protected function prepareModels() 108 | { 109 | if (!$this->query instanceof Query) { 110 | throw new InvalidConfigException('The "query" property must be an instance "' . Query::className() . '" or its subclasses.'); 111 | } 112 | 113 | $query = clone $this->query; 114 | if (($pagination = $this->getPagination()) !== false) { 115 | // pagination fails to validate page number, because total count is unknown at this stage 116 | $pagination->validatePage = false; 117 | $query->limit($pagination->getLimit())->offset($pagination->getOffset()); 118 | } 119 | if (($sort = $this->getSort()) !== false) { 120 | $query->addOrderBy($sort->getOrders()); 121 | } 122 | 123 | if (is_array(($results = $query->search($this->db)))) { 124 | $this->setQueryResults($results); 125 | return $results['hits']['hits']; 126 | } 127 | $this->setQueryResults([]); 128 | return []; 129 | } 130 | 131 | /** 132 | * {@inheritdoc} 133 | */ 134 | protected function prepareTotalCount() 135 | { 136 | if (!$this->query instanceof Query) { 137 | throw new InvalidConfigException('The "query" property must be an instance "' . Query::className() . '" or its subclasses.'); 138 | } 139 | 140 | $results = $this->getQueryResults(); 141 | if (isset($results['hits']['total'])) { 142 | return is_array($results['hits']['total']) ? (int) $results['hits']['total']['value'] : (int) $results['hits']['total']; 143 | } 144 | return 0; 145 | } 146 | 147 | /** 148 | * {@inheritdoc} 149 | */ 150 | protected function prepareKeys($models) 151 | { 152 | $keys = []; 153 | if ($this->key !== null) { 154 | foreach ($models as $model) { 155 | if (is_string($this->key)) { 156 | $keys[] = $model[$this->key]; 157 | } else { 158 | $keys[] = call_user_func($this->key, $model); 159 | } 160 | } 161 | 162 | return $keys; 163 | } elseif ($this->query instanceof ActiveQueryInterface) { 164 | /* @var $class \yii\elasticsearch\ActiveRecord */ 165 | $class = $this->query->modelClass; 166 | foreach ($models as $model) { 167 | $keys[] = $model->primaryKey; 168 | } 169 | return $keys; 170 | } else { 171 | return array_keys($models); 172 | } 173 | } 174 | 175 | /** 176 | * {@inheritdoc} 177 | */ 178 | public function refresh() 179 | { 180 | parent::refresh(); 181 | $this->_queryResults = null; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/ActiveFixture.php: -------------------------------------------------------------------------------- 1 | 27 | * @author Qiang Xue 28 | * @since 2.0.2 29 | */ 30 | class ActiveFixture extends BaseActiveFixture 31 | { 32 | /** 33 | * @var Connection|string the DB connection object or the application component ID of the DB connection. 34 | * After the DbFixture object is created, if you want to change this property, you should only assign it 35 | * with a DB connection object. 36 | */ 37 | public $db = 'elasticsearch'; 38 | /** 39 | * @var string the name of the index that this fixture is about. If this property is not set, 40 | * the name will be determined via [[modelClass]]. 41 | * @see modelClass 42 | */ 43 | public $index; 44 | /** 45 | * @var string the name of the type that this fixture is about. If this property is not set, 46 | * the name will be determined via [[modelClass]]. 47 | * @see modelClass 48 | */ 49 | public $type; 50 | /** 51 | * @var string|boolean the file path or path alias of the data file that contains the fixture data 52 | * to be returned by [[getData()]]. If this is not set, it will default to `FixturePath/data/Index/Type.php`, 53 | * where `FixturePath` stands for the directory containing this fixture class, `Index` stands for the elasticsearch [[index]] name 54 | * and `Type` stands for the [[type]] associated with this fixture. 55 | * You can set this property to be false to prevent loading any data. 56 | */ 57 | public $dataFile; 58 | 59 | 60 | /** 61 | * @inheritdoc 62 | */ 63 | public function init() 64 | { 65 | parent::init(); 66 | if (!isset($this->modelClass) && (!isset($this->index) || !isset($this->type))) { 67 | throw new InvalidConfigException('Either "modelClass" or "index" and "type" must be set.'); 68 | } 69 | /* @var $modelClass ActiveRecord */ 70 | $modelClass = $this->modelClass; 71 | if ($this->index === null) { 72 | $this->index = $modelClass::index(); 73 | } 74 | if ($this->type === null) { 75 | $this->type = $modelClass::type(); 76 | } 77 | } 78 | 79 | /** 80 | * Loads the fixture. 81 | * 82 | * The default implementation will first clean up the index by calling [[resetIndex()]]. 83 | * It will then populate the index with the data returned by [[getData()]]. 84 | * 85 | * If you override this method, you should consider calling the parent implementation 86 | * so that the data returned by [[getData()]] can be populated into the index. 87 | */ 88 | public function load() 89 | { 90 | $this->resetIndex(); 91 | $this->data = []; 92 | 93 | $idField = '_id'; 94 | 95 | foreach ($this->getData() as $alias => $row) { 96 | $options = []; 97 | $id = isset($row[$idField]) ? $row[$idField] : null; 98 | unset($row[$idField]); 99 | if (isset($row['_parent'])) { 100 | $options['parent'] = $row['_parent']; 101 | unset($row['_parent']); 102 | } 103 | 104 | try { 105 | $response = $this->db->createCommand()->insert($this->index, $this->type, $row, $id, $options); 106 | } catch(\yii\db\Exception $e) { 107 | throw new \yii\base\Exception("Failed to insert fixture data \"$alias\": " . $e->getMessage() . "\n" . print_r($e->errorInfo, true), $e->getCode(), $e); 108 | } 109 | if ($id === null) { 110 | $row[$idField] = $response['_id']; 111 | } 112 | $this->data[$alias] = $row; 113 | } 114 | // ensure all data is flushed and immediately available in the test 115 | $this->db->createCommand()->refreshIndex($this->index); 116 | } 117 | 118 | /** 119 | * Returns the fixture data. 120 | * 121 | * The default implementation will try to return the fixture data by including the external file specified by [[dataFile]]. 122 | * The file should return an array of data rows (column name => column value), each corresponding to a row in the index. 123 | * 124 | * If the data file does not exist, an empty array will be returned. 125 | * 126 | * @return array the data rows to be inserted into the database index. 127 | */ 128 | protected function getData() 129 | { 130 | if ($this->dataFile === null) { 131 | $class = new \ReflectionClass($this); 132 | $dataFile = dirname($class->getFileName()) . "/data/{$this->index}/{$this->type}.php"; 133 | return is_file($dataFile) ? require($dataFile) : []; 134 | } else { 135 | return parent::getData(); 136 | } 137 | } 138 | 139 | /** 140 | * Removes all existing data from the specified index and type. 141 | * This method is called before populating fixture data into the index associated with this fixture. 142 | */ 143 | protected function resetIndex() 144 | { 145 | $this->db->createCommand([ 146 | 'index' => $this->index, 147 | 'type' => $this->type, 148 | 'queryParts' => ['query' => ['match_all' => new \stdClass()]], 149 | ])->deleteByQuery(); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/ActiveQuery.php: -------------------------------------------------------------------------------- 1 | with('orders')->asArray()->all(); 47 | * ``` 48 | * > NOTE: Elasticsearch limits the number of records returned to 10 records by default. 49 | * > If you expect to get more records you should specify limit explicitly. 50 | * 51 | * Relational query 52 | * ---------------- 53 | * 54 | * In relational context ActiveQuery represents a relation between two Active Record classes. 55 | * 56 | * Relational ActiveQuery instances are usually created by calling [[ActiveRecord::hasOne()]] and 57 | * [[ActiveRecord::hasMany()]]. An Active Record class declares a relation by defining 58 | * a getter method which calls one of the above methods and returns the created ActiveQuery object. 59 | * 60 | * A relation is specified by [[link]] which represents the association between columns 61 | * of different tables; and the multiplicity of the relation is indicated by [[multiple]]. 62 | * 63 | * If a relation involves a junction table, it may be specified by [[via()]]. 64 | * This methods may only be called in a relational context. Same is true for [[inverseOf()]], which 65 | * marks a relation as inverse of another relation. 66 | * 67 | * > Note: Elasticsearch limits the number of records returned by any query to 10 records by default. 68 | * > If you expect to get more records you should specify limit explicitly in relation definition. 69 | * > This is also important for relations that use [[via()]] so that if via records are limited to 10 70 | * > the relations records can also not be more than 10. 71 | * 72 | * > Note: Currently [[with]] is not supported in combination with [[asArray]]. 73 | * 74 | * @author Carsten Brandt 75 | * @since 2.0 76 | */ 77 | class ActiveQuery extends Query implements ActiveQueryInterface 78 | { 79 | use ActiveQueryTrait; 80 | use ActiveRelationTrait; 81 | 82 | /** 83 | * @event Event an event that is triggered when the query is initialized via [[init()]]. 84 | */ 85 | const EVENT_INIT = 'init'; 86 | 87 | 88 | /** 89 | * Constructor. 90 | * @param string $modelClass the model class associated with this query 91 | * @param array $config configurations to be applied to the newly created query object 92 | */ 93 | public function __construct($modelClass, $config = []) 94 | { 95 | $this->modelClass = $modelClass; 96 | parent::__construct($config); 97 | } 98 | 99 | /** 100 | * Initializes the object. 101 | * This method is called at the end of the constructor. The default implementation will trigger 102 | * an [[EVENT_INIT]] event. If you override this method, make sure you call the parent implementation at the end 103 | * to ensure triggering of the event. 104 | */ 105 | public function init() 106 | { 107 | parent::init(); 108 | $this->trigger(self::EVENT_INIT); 109 | } 110 | 111 | /** 112 | * Creates a DB command that can be used to execute this query. 113 | * @param Connection $db the DB connection used to create the DB command. 114 | * If null, the DB connection returned by [[modelClass]] will be used. 115 | * @return Command the created DB command instance. 116 | */ 117 | public function createCommand($db = null) 118 | { 119 | if ($this->primaryModel !== null) { 120 | // lazy loading 121 | if (is_array($this->via)) { 122 | // via relation 123 | /* @var $viaQuery ActiveQuery */ 124 | list($viaName, $viaQuery) = $this->via; 125 | if ($viaQuery->multiple) { 126 | $viaModels = $viaQuery->all(); 127 | $this->primaryModel->populateRelation($viaName, $viaModels); 128 | } else { 129 | $model = $viaQuery->one(); 130 | $this->primaryModel->populateRelation($viaName, $model); 131 | $viaModels = $model === null ? [] : [$model]; 132 | } 133 | $this->filterByModels($viaModels); 134 | } else { 135 | $this->filterByModels([$this->primaryModel]); 136 | } 137 | } 138 | 139 | /* @var $modelClass ActiveRecord */ 140 | $modelClass = $this->modelClass; 141 | if ($db === null) { 142 | $db = $modelClass::getDb(); 143 | } 144 | 145 | if ($this->type === null) { 146 | $this->type = $modelClass::type(); 147 | } 148 | if ($this->index === null) { 149 | $this->index = $modelClass::index(); 150 | $this->type = $modelClass::type(); 151 | } 152 | $commandConfig = $db->getQueryBuilder()->build($this); 153 | 154 | return $db->createCommand($commandConfig); 155 | } 156 | 157 | /** 158 | * Executes query and returns all results as an array. 159 | * @param Connection $db the DB connection used to create the DB command. 160 | * If null, the DB connection returned by [[modelClass]] will be used. 161 | * @return array the query results. If the query results in nothing, an empty array will be returned. 162 | */ 163 | public function all($db = null) 164 | { 165 | return parent::all($db); 166 | } 167 | 168 | /** 169 | * Converts found rows into model instances 170 | * @param array $rows 171 | * @return array|ActiveRecord[] 172 | * @since 2.0.4 173 | */ 174 | private function createModels($rows) 175 | { 176 | $models = []; 177 | if ($this->asArray) { 178 | if ($this->indexBy === null) { 179 | return $rows; 180 | } 181 | foreach ($rows as $row) { 182 | if (is_string($this->indexBy)) { 183 | $key = isset($row['fields'][$this->indexBy]) ? reset($row['fields'][$this->indexBy]) : $row['_source'][$this->indexBy]; 184 | } else { 185 | $key = call_user_func($this->indexBy, $row); 186 | } 187 | $models[$key] = $row; 188 | } 189 | } else { 190 | /* @var $class ActiveRecord */ 191 | $class = $this->modelClass; 192 | if ($this->indexBy === null) { 193 | foreach ($rows as $row) { 194 | $model = $class::instantiate($row); 195 | $modelClass = get_class($model); 196 | $modelClass::populateRecord($model, $row); 197 | $models[] = $model; 198 | } 199 | } else { 200 | foreach ($rows as $row) { 201 | $model = $class::instantiate($row); 202 | $modelClass = get_class($model); 203 | $modelClass::populateRecord($model, $row); 204 | if (is_string($this->indexBy)) { 205 | $key = $model->{$this->indexBy}; 206 | } else { 207 | $key = call_user_func($this->indexBy, $model); 208 | } 209 | $models[$key] = $model; 210 | } 211 | } 212 | } 213 | 214 | return $models; 215 | } 216 | 217 | /** 218 | * @inheritdoc 219 | * @since 2.0.4 220 | */ 221 | public function populate($rows) 222 | { 223 | if (empty($rows)) { 224 | return []; 225 | } 226 | 227 | $models = $this->createModels($rows); 228 | if (!empty($this->with)) { 229 | $this->findWith($this->with, $models); 230 | } 231 | if (!$this->asArray) { 232 | foreach ($models as $model) { 233 | $model->afterFind(); 234 | } 235 | } 236 | 237 | return $models; 238 | } 239 | 240 | /** 241 | * Executes query and returns a single row of result. 242 | * @param Connection $db the DB connection used to create the DB command. 243 | * If null, the DB connection returned by [[modelClass]] will be used. 244 | * @return ActiveRecord|array|null a single row of query result. Depending on the setting of [[asArray]], 245 | * the query result may be either an array or an ActiveRecord object. Null will be returned 246 | * if the query results in nothing. 247 | */ 248 | public function one($db = null) 249 | { 250 | if (($result = parent::one($db)) === false) { 251 | return null; 252 | } 253 | if ($this->asArray) { 254 | // TODO implement with() 255 | // /* @var $modelClass ActiveRecord */ 256 | // $modelClass = $this->modelClass; 257 | // $model = $result['_source']; 258 | // $pk = $modelClass::primaryKey()[0]; 259 | // if ($pk === '_id') { 260 | // $model['_id'] = $result['_id']; 261 | // } 262 | // $model['_score'] = $result['_score']; 263 | // if (!empty($this->with)) { 264 | // $models = [$model]; 265 | // $this->findWith($this->with, $models); 266 | // $model = $models[0]; 267 | // } 268 | return $result; 269 | } else { 270 | /* @var $class ActiveRecord */ 271 | $class = $this->modelClass; 272 | $model = $class::instantiate($result); 273 | $class = get_class($model); 274 | $class::populateRecord($model, $result); 275 | if (!empty($this->with)) { 276 | $models = [$model]; 277 | $this->findWith($this->with, $models); 278 | $model = $models[0]; 279 | } 280 | $model->afterFind(); 281 | return $model; 282 | } 283 | } 284 | 285 | /** 286 | * @inheritdoc 287 | */ 288 | public function search($db = null, $options = []) 289 | { 290 | if ($this->emulateExecution) { 291 | return [ 292 | 'hits' => [ 293 | 'total' => 0, 294 | 'hits' => [], 295 | ], 296 | ]; 297 | } 298 | 299 | $command = $this->createCommand($db); 300 | $result = $command->search($options); 301 | if ($result === false) { 302 | throw new Exception('Elasticsearch search query failed.', [ 303 | 'index' => $command->index, 304 | 'type' => $command->type, 305 | 'query' => $command->queryParts, 306 | 'options' => $command->options, 307 | ]); 308 | } 309 | 310 | // TODO implement with() for asArray 311 | if (!empty($result['hits']['hits']) && !$this->asArray) { 312 | $models = $this->createModels($result['hits']['hits']); 313 | if (!empty($this->with)) { 314 | $this->findWith($this->with, $models); 315 | } 316 | foreach ($models as $model) { 317 | $model->afterFind(); 318 | } 319 | $result['hits']['hits'] = $models; 320 | } 321 | 322 | return $result; 323 | } 324 | 325 | /** 326 | * @inheritdoc 327 | */ 328 | public function column($field, $db = null) 329 | { 330 | if ($field === '_id') { 331 | $command = $this->createCommand($db); 332 | $command->queryParts['_source'] = false; 333 | $result = $command->search(); 334 | if ($result === false) { 335 | throw new Exception('Elasticsearch search query failed.'); 336 | } 337 | if (empty($result['hits']['hits'])) { 338 | return []; 339 | } 340 | $column = []; 341 | foreach ($result['hits']['hits'] as $row) { 342 | $column[] = $row['_id']; 343 | } 344 | 345 | return $column; 346 | } 347 | 348 | return parent::column($field, $db); 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /src/ActiveRecord.php: -------------------------------------------------------------------------------- 1 | 59 | * @since 2.0 60 | */ 61 | class ActiveRecord extends BaseActiveRecord 62 | { 63 | private $_id; 64 | private $_score; 65 | private $_version; 66 | private $_highlight; 67 | private $_explanation; 68 | 69 | 70 | /** 71 | * Returns the database connection used by this AR class. 72 | * By default, the "elasticsearch" application component is used as the database connection. 73 | * You may override this method if you want to use a different database connection. 74 | * @return Connection the database connection used by this AR class. 75 | */ 76 | public static function getDb() 77 | { 78 | return \Yii::$app->get('elasticsearch'); 79 | } 80 | 81 | /** 82 | * @inheritdoc 83 | * @return ActiveQuery the newly created [[ActiveQuery]] instance. 84 | */ 85 | public static function find() 86 | { 87 | return Yii::createObject(ActiveQuery::className(), [get_called_class()]); 88 | } 89 | 90 | /** 91 | * @inheritdoc 92 | */ 93 | public static function findOne($condition) 94 | { 95 | if (!is_array($condition)) { 96 | return static::get($condition); 97 | } 98 | if (!ArrayHelper::isAssociative($condition)) { 99 | $records = static::mget(array_values($condition)); 100 | return empty($records) ? null : reset($records); 101 | } 102 | 103 | $condition = static::filterCondition($condition); 104 | return static::find()->andWhere($condition)->one(); 105 | } 106 | 107 | /** 108 | * @inheritdoc 109 | */ 110 | public static function findAll($condition) 111 | { 112 | if (!ArrayHelper::isAssociative($condition)) { 113 | return static::mget(is_array($condition) ? array_values($condition) : [$condition]); 114 | } 115 | 116 | $condition = static::filterCondition($condition); 117 | return static::find()->andWhere($condition)->all(); 118 | } 119 | 120 | /** 121 | * Filter out condition parts that are array valued, to prevent building arbitrary conditions. 122 | * @param array $condition 123 | */ 124 | private static function filterCondition($condition) 125 | { 126 | foreach ($condition as $k => $v) { 127 | if (is_array($v)) { 128 | $condition[$k] = array_values($v); 129 | foreach ($v as $vv) { 130 | if (is_array($vv)) { 131 | throw new InvalidArgumentException('Nested arrays are not allowed in condition for findAll() and findOne().'); 132 | } 133 | } 134 | } 135 | } 136 | return $condition; 137 | } 138 | 139 | /** 140 | * Gets a record by its primary key. 141 | * 142 | * @param mixed $primaryKey the primaryKey value 143 | * @param array $options options given in this parameter are passed to Elasticsearch 144 | * as request URI parameters. 145 | * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html) 146 | * for more details on these options. 147 | * @return static|null The record instance or null if it was not found. 148 | */ 149 | public static function get($primaryKey, $options = []) 150 | { 151 | if ($primaryKey === null) { 152 | return null; 153 | } 154 | $command = static::getDb()->createCommand(); 155 | $result = $command->get(static::index(), static::type(), $primaryKey, $options); 156 | if ($result && $result['found']) { 157 | $model = static::instantiate($result); 158 | static::populateRecord($model, $result); 159 | $model->afterFind(); 160 | 161 | return $model; 162 | } 163 | 164 | return null; 165 | } 166 | 167 | /** 168 | * Gets a list of records by its primary keys. 169 | * 170 | * @param array $primaryKeys an array of primaryKey values 171 | * @param array $options options given in this parameter are passed to Elasticsearch 172 | * as request URI parameters. 173 | * 174 | * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html) 175 | * for more details on these options. 176 | * @return array The record instances, or empty array if nothing was found 177 | */ 178 | public static function mget(array $primaryKeys, $options = []) 179 | { 180 | if (empty($primaryKeys)) { 181 | return []; 182 | } 183 | if (count($primaryKeys) === 1) { 184 | $model = static::get(reset($primaryKeys)); 185 | return $model === null ? [] : [$model]; 186 | } 187 | 188 | $command = static::getDb()->createCommand(); 189 | $result = $command->mget(static::index(), static::type(), $primaryKeys, $options); 190 | $models = []; 191 | foreach ($result['docs'] as $doc) { 192 | if ($doc['found']) { 193 | $model = static::instantiate($doc); 194 | static::populateRecord($model, $doc); 195 | $model->afterFind(); 196 | $models[] = $model; 197 | } 198 | } 199 | 200 | return $models; 201 | } 202 | 203 | // TODO add more like this feature https://www.elastic.co/guide/en/elasticsearch/reference/current/search-more-like-this.html 204 | 205 | // TODO add percolate functionality https://www.elastic.co/guide/en/elasticsearch/reference/current/search-percolate.html 206 | 207 | // TODO implement copy and move as pk change is not possible 208 | 209 | /** 210 | * @return float returns the score of this record when it was retrieved via a [[find()]] query. 211 | */ 212 | public function getScore() 213 | { 214 | return $this->_score; 215 | } 216 | 217 | /** 218 | * @return array|null A list of arrays with highlighted excerpts indexed by field names. 219 | */ 220 | public function getHighlight() 221 | { 222 | return $this->_highlight; 223 | } 224 | 225 | /** 226 | * @return array|null An explanation for each hit on how its score was computed. 227 | * @since 2.0.5 228 | */ 229 | public function getExplanation() 230 | { 231 | return $this->_explanation; 232 | } 233 | 234 | /** 235 | * Alias to [[get_id()]]. Returns the primary key value. 236 | * @param bool $asArray 237 | * @return mixed 238 | * @deprecated since 2.1.0 239 | */ 240 | public function getPrimaryKey($asArray = false) 241 | { 242 | $pk = static::primaryKey()[0]; 243 | if ($asArray) { 244 | return [$pk => $this->$pk]; 245 | } else { 246 | return $this->$pk; 247 | } 248 | } 249 | 250 | /** 251 | * Alias to [[set_id()]]. Sets the primary key value. 252 | * @param mixed $value 253 | * @throws \yii\base\InvalidCallException when record is not new 254 | * @deprecated since 2.1.0 255 | */ 256 | public function setPrimaryKey($value) 257 | { 258 | $pk = static::primaryKey()[0]; 259 | $this->$pk = $value; 260 | } 261 | 262 | /** 263 | * Sets the `_id` attribute that holds the primary key (for compatibility with relations) 264 | * @param mixed $value 265 | * @throws \yii\base\InvalidCallException when record is not new 266 | */ 267 | public function set_id($value) 268 | { 269 | $pk = static::primaryKey()[0]; 270 | if ($this->getIsNewRecord()) { 271 | $this->$pk = $value; 272 | } else { 273 | throw new InvalidCallException('Changing the primaryKey of an already saved record is not allowed.'); 274 | } 275 | } 276 | 277 | /** 278 | * Returns the `_id` attribute that holds the primary key (for compatibility with relations) 279 | * @return mixed 280 | */ 281 | public function get_id() 282 | { 283 | $pk = static::primaryKey()[0]; 284 | return $this->$pk; 285 | } 286 | 287 | /** 288 | * @inheritdoc 289 | */ 290 | public function getOldPrimaryKey($asArray = false) 291 | { 292 | $pk = static::primaryKey()[0]; 293 | if ($this->getIsNewRecord()) { 294 | $id = null; 295 | } else { 296 | $id = $this->_id; 297 | } 298 | if ($asArray) { 299 | return [$pk => $id]; 300 | } else { 301 | return $id; 302 | } 303 | } 304 | 305 | /** 306 | * This method defines the attribute that uniquely identifies a record. 307 | * The name of the primary key attribute is `_id`, and can not be changed. 308 | * 309 | * Elasticsearch does not support composite primary keys in the traditional sense. However to match the signature 310 | * of the [[\yii\db\ActiveRecordInterface|ActiveRecordInterface]] this methods returns an array instead of a 311 | * single string. 312 | * 313 | * @return string[] array of primary key attributes. Only the first element of the array will be used. 314 | */ 315 | final public static function primaryKey() 316 | { 317 | return ['_id']; 318 | } 319 | 320 | /** 321 | * Returns the list of all attribute names of the model. 322 | * 323 | * This method must be overridden by child classes to define available attributes. 324 | * IMPORTANT: The primary key (the `_id` attribute) MUST NOT be included in [[attributes()]]. 325 | * 326 | * Attributes are names of fields of the corresponding Elasticsearch document. 327 | * 328 | * @return string[] list of attribute names. 329 | * @throws \yii\base\InvalidConfigException if not overridden in a child class. 330 | */ 331 | public function attributes() 332 | { 333 | throw new InvalidConfigException('The attributes() method of Elasticsearch ActiveRecord has to be implemented by child classes.'); 334 | } 335 | 336 | /** 337 | * A list of attributes that should be treated as array valued when retrieved through [[ActiveQuery::fields]]. 338 | * 339 | * If not listed by this method, attributes retrieved through [[ActiveQuery::fields]] will converted to a scalar value 340 | * when the result array contains only one value. 341 | * 342 | * @return string[] list of attribute names. Must be a subset of [[attributes()]]. 343 | */ 344 | public function arrayAttributes() 345 | { 346 | return []; 347 | } 348 | 349 | /** 350 | * @return string the name of the index this record is stored in. 351 | */ 352 | public static function index() 353 | { 354 | return Inflector::pluralize(Inflector::camel2id(StringHelper::basename(get_called_class()), '-')); 355 | } 356 | 357 | /** 358 | * Returns the name of the type of this record. 359 | * IMPORTANT: For Elasticsearch 7 and later, [[type()]] is ignored. 360 | * @return string the name of the type of this record. 361 | */ 362 | public static function type() 363 | { 364 | return Inflector::camel2id(StringHelper::basename(get_called_class()), '-'); 365 | } 366 | 367 | /** 368 | * @inheritdoc 369 | * 370 | * @param ActiveRecord $record the record to be populated. In most cases this will be an instance 371 | * created by [[instantiate()]] beforehand. 372 | * @param array $row attribute values (name => value) 373 | */ 374 | public static function populateRecord($record, $row) 375 | { 376 | $attributes = []; 377 | if (isset($row['_source'])) { 378 | $attributes = $row['_source']; 379 | } 380 | if (isset($row['fields'])) { 381 | // reset fields in case it is scalar value 382 | $arrayAttributes = $record->arrayAttributes(); 383 | foreach ($row['fields'] as $key => $value) { 384 | if (!isset($arrayAttributes[$key]) && count($value) === 1) { 385 | $row['fields'][$key] = reset($value); 386 | } 387 | } 388 | $attributes = array_merge($attributes, $row['fields']); 389 | } 390 | 391 | parent::populateRecord($record, $attributes); 392 | 393 | $pk = static::primaryKey()[0]; 394 | $record->_id = $row[$pk]; 395 | $record->_highlight = isset($row['highlight']) ? $row['highlight'] : null; 396 | $record->_score = isset($row['_score']) ? $row['_score'] : null; 397 | $record->_version = isset($row['_version']) ? $row['_version'] : null; // TODO version should always be available... 398 | $record->_explanation = isset($row['_explanation']) ? $row['_explanation'] : null; 399 | } 400 | 401 | /** 402 | * Creates an active record instance. 403 | * 404 | * This method is called together with [[populateRecord()]] by [[ActiveQuery]]. 405 | * It is not meant to be used for creating new records directly. 406 | * 407 | * You may override this method if the instance being created 408 | * depends on the row data to be populated into the record. 409 | * For example, by creating a record based on the value of a column, 410 | * you may implement the so-called single-table inheritance mapping. 411 | * @param array $row row data to be populated into the record. 412 | * This array consists of the following keys: 413 | * - `_source`: refers to the attributes of the record. 414 | * - `_type`: the type this record is stored in. 415 | * - `_index`: the index this record is stored in. 416 | * @return static the newly created active record 417 | */ 418 | public static function instantiate($row) 419 | { 420 | return new static; 421 | } 422 | 423 | /** 424 | * Inserts a document into the associated index using the attribute values of this record. 425 | * 426 | * This method performs the following steps in order: 427 | * 428 | * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation 429 | * fails, it will skip the rest of the steps; 430 | * 2. call [[afterValidate()]] when `$runValidation` is true. 431 | * 3. call [[beforeSave()]]. If the method returns false, it will skip the 432 | * rest of the steps; 433 | * 4. insert the record into database. If this fails, it will skip the rest of the steps; 434 | * 5. call [[afterSave()]]; 435 | * 436 | * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], 437 | * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]] 438 | * will be raised by the corresponding methods. 439 | * 440 | * Only the [[dirtyAttributes|changed attribute values]] will be inserted into database. 441 | * 442 | * If the [[primaryKey|primary key]] is not set (null) during insertion, 443 | * it will be populated with a randomly generated value after insertion. 444 | * 445 | * For example, to insert a customer record: 446 | * 447 | * ~~~ 448 | * $customer = new Customer; 449 | * $customer->name = $name; 450 | * $customer->email = $email; 451 | * $customer->insert(); 452 | * ~~~ 453 | * 454 | * @param bool $runValidation whether to perform validation before saving the record. 455 | * If the validation fails, the record will not be inserted into the database. 456 | * @param array $attributes list of attributes that need to be saved. Defaults to null, 457 | * meaning all attributes will be saved. 458 | * @param array $options options given in this parameter are passed to Elasticsearch 459 | * as request URI parameters. These are among others: 460 | * 461 | * - `routing` define shard placement of this record. 462 | * - `parent` by giving the primaryKey of another record this defines a parent-child relation 463 | * 464 | * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html) 465 | * for more details on these options. 466 | * 467 | * By default the `op_type` is set to `create` if model primary key is present. 468 | * @return bool whether the attributes are valid and the record is inserted successfully. 469 | */ 470 | public function insert($runValidation = true, $attributes = null, $options = [ ]) 471 | { 472 | if ($runValidation && !$this->validate($attributes)) { 473 | return false; 474 | } 475 | if (!$this->beforeSave(true)) { 476 | return false; 477 | } 478 | $values = $this->getDirtyAttributes($attributes); 479 | 480 | if ($this->getPrimaryKey() !== null) { 481 | $options['op_type'] = isset($options['op_type']) ? $options['op_type'] : 'create'; 482 | } 483 | 484 | $response = static::getDb()->createCommand()->insert( 485 | static::index(), 486 | static::type(), 487 | $values, 488 | $this->getPrimaryKey(), 489 | $options 490 | ); 491 | 492 | if ($response === false) { 493 | return false; 494 | } 495 | 496 | $pk = static::primaryKey()[0]; 497 | $this->$pk = $response['_id']; 498 | if ($pk != '_id') { 499 | $values[$pk] = $response['_id']; 500 | } 501 | $this->_version = $response['_version']; 502 | $this->_score = null; 503 | 504 | $changedAttributes = array_fill_keys(array_keys($values), null); 505 | $this->setOldAttributes($values); 506 | $this->afterSave(true, $changedAttributes); 507 | 508 | return true; 509 | } 510 | 511 | /** 512 | * @inheritdoc 513 | * 514 | * @param bool $runValidation whether to perform validation before saving the record. 515 | * If the validation fails, the record will not be inserted into the database. 516 | * @param array $attributeNames list of attribute names that need to be saved. Defaults to null, 517 | * meaning all attributes that are loaded from DB will be saved. 518 | * @param array $options options given in this parameter are passed to Elasticsearch 519 | * as request URI parameters. These are among others: 520 | * 521 | * - `routing` define shard placement of this record. 522 | * - `parent` by giving the primaryKey of another record this defines a parent-child relation 523 | * - `timeout` timeout waiting for a shard to become available. 524 | * - `replication` the replication type for the delete/index operation (sync or async). 525 | * - `consistency` the write consistency of the index/delete operation. 526 | * - `refresh` refresh the relevant primary and replica shards (not the whole index) immediately after the operation occurs, so that the updated document appears in search results immediately. 527 | * - `detect_noop` this parameter will become part of the request body and will prevent the index from getting updated when nothing has changed. 528 | * 529 | * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html#docs-update-api-query-params) 530 | * for more details on these options. 531 | * 532 | * The following parameters are Yii specific: 533 | * 534 | * - `optimistic_locking` set this to `true` to enable optimistic locking, avoid updating when the record has changed since it 535 | * has been loaded from the database. Yii will set the `version` parameter to the value stored in [[version]]. 536 | * See the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/guide/current/optimistic-concurrency-control.html) for details. 537 | * 538 | * Make sure the record has been fetched with a [[version]] before. This is only the case 539 | * for records fetched via [[get()]] and [[mget()]] by default. For normal queries, the `_version` field has to be fetched explicitly. 540 | * 541 | * @return int|bool the number of rows affected, or false if validation fails 542 | * or [[beforeSave()]] stops the updating process. 543 | * @throws StaleObjectException if optimistic locking is enabled and the data being updated is outdated. 544 | * @throws InvalidParamException if no [[version]] is available and optimistic locking is enabled. 545 | * @throws Exception in case update failed. 546 | */ 547 | public function update($runValidation = true, $attributeNames = null, $options = []) 548 | { 549 | if ($runValidation && !$this->validate($attributeNames)) { 550 | return false; 551 | } 552 | return $this->updateInternal($attributeNames, $options); 553 | } 554 | 555 | /** 556 | * @param array $attributes attributes to update 557 | * @param array $options options given in this parameter are passed to Elasticsearch 558 | * as request URI parameters. See [[update()]] for details. 559 | * @return int|false the number of rows affected, or false if [[beforeSave()]] stops the updating process. 560 | * @throws StaleObjectException if optimistic locking is enabled and the data being updated is outdated. 561 | * @throws InvalidParamException if no [[version]] is available and optimistic locking is enabled. 562 | * @throws Exception in case update failed. 563 | * @see update() 564 | */ 565 | protected function updateInternal($attributes = null, $options = []) 566 | { 567 | if (!$this->beforeSave(false)) { 568 | return false; 569 | } 570 | $values = $this->getDirtyAttributes($attributes); 571 | if (empty($values)) { 572 | $this->afterSave(false, $values); 573 | return 0; 574 | } 575 | 576 | if (isset($options['optimistic_locking']) && $options['optimistic_locking']) { 577 | if ($this->_version === null) { 578 | throw new InvalidArgumentException('Unable to use optimistic locking on a record that has no version set. Refer to the docs of ActiveRecord::update() for details.'); 579 | } 580 | $options['version'] = $this->_version; 581 | unset($options['optimistic_locking']); 582 | } 583 | 584 | try { 585 | $result = static::getDb()->createCommand()->update( 586 | static::index(), 587 | static::type(), 588 | $this->getOldPrimaryKey(false), 589 | $values, 590 | $options 591 | ); 592 | } catch (Exception $e) { 593 | // HTTP 409 is the response in case of failed optimistic locking 594 | // https://www.elastic.co/guide/en/elasticsearch/guide/current/optimistic-concurrency-control.html 595 | if (isset($e->errorInfo['responseCode']) && $e->errorInfo['responseCode'] == 409) { 596 | throw new StaleObjectException('The object being updated is outdated.', $e->errorInfo, $e->getCode(), $e); 597 | } 598 | throw $e; 599 | } 600 | 601 | if (is_array($result) && isset($result['_version'])) { 602 | $this->_version = $result['_version']; 603 | } 604 | 605 | $changedAttributes = []; 606 | foreach ($values as $name => $value) { 607 | $changedAttributes[$name] = $this->getOldAttribute($name); 608 | $this->setOldAttribute($name, $value); 609 | } 610 | $this->afterSave(false, $changedAttributes); 611 | 612 | if ($result === false) { 613 | return 0; 614 | } else { 615 | return 1; 616 | } 617 | } 618 | 619 | /** 620 | * Performs a quick and highly efficient scroll/scan query to get the list of primary keys that 621 | * satisfy the given condition. If condition is a list of primary keys 622 | * (e.g.: `['_id' => ['1', '2', '3']]`), the query is not performed for performance considerations. 623 | * @param array $condition please refer to [[ActiveQuery::where()]] on how to specify this parameter 624 | * @return array primary keys that correspond to given conditions 625 | * @see updateAll() 626 | * @see updateAllCounters() 627 | * @see deleteAll() 628 | * @since 2.0.4 629 | */ 630 | protected static function primaryKeysByCondition($condition) 631 | { 632 | $pkName = static::primaryKey()[0]; 633 | if (count($condition) == 1 && isset($condition[$pkName])) { 634 | $primaryKeys = (array)$condition[$pkName]; 635 | } else { 636 | //fetch only document metadata (no fields), 1000 documents per shard 637 | $query = static::find()->where($condition)->asArray()->source(false)->limit(1000); 638 | $primaryKeys = []; 639 | foreach ($query->each('1m') as $document) { 640 | $primaryKeys[] = $document['_id']; 641 | } 642 | } 643 | return $primaryKeys; 644 | } 645 | 646 | /** 647 | * Updates all records that match a certain condition. 648 | * For example, to change the status to be 1 for all customers whose status is 2: 649 | * 650 | * ~~~ 651 | * Customer::updateAll(['status' => 1], ['status' => 2]); 652 | * ~~~ 653 | * 654 | * @param array $attributes attribute values (name-value pairs) to be saved into the table 655 | * @param array $condition the conditions that will be passed to the `where()` method when building the query. 656 | * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. 657 | * @return int the number of rows updated 658 | * @throws Exception on error. 659 | * @see [[ActiveRecord::primaryKeysByCondition()]] 660 | */ 661 | public static function updateAll($attributes, $condition = []) 662 | { 663 | $primaryKeys = static::primaryKeysByCondition($condition); 664 | if (empty($primaryKeys)) { 665 | return 0; 666 | } 667 | 668 | $bulkCommand = static::getDb()->createBulkCommand([ 669 | "index" => static::index(), 670 | "type" => static::type(), 671 | ]); 672 | foreach ($primaryKeys as $pk) { 673 | $bulkCommand->addAction(["update" => ["_id" => $pk]], ["doc" => $attributes]); 674 | } 675 | $response = $bulkCommand->execute(); 676 | 677 | $n = 0; 678 | $errors = []; 679 | foreach ($response['items'] as $item) { 680 | if (isset($item['update']['status']) && $item['update']['status'] == 200) { 681 | $n++; 682 | } else { 683 | $errors[] = $item['update']; 684 | } 685 | } 686 | if (!empty($errors) || isset($response['errors']) && $response['errors']) { 687 | throw new Exception(__METHOD__ . ' failed updating records.', $errors); 688 | } 689 | 690 | return $n; 691 | } 692 | 693 | /** 694 | * Updates all matching records using the provided counter changes and conditions. 695 | * For example, to add 1 to age of all customers whose status is 2, 696 | * 697 | * ~~~ 698 | * Customer::updateAllCounters(['age' => 1], ['status' => 2]); 699 | * ~~~ 700 | * 701 | * @param array $counters the counters to be updated (attribute name => increment value). 702 | * Use negative values if you want to decrement the counters. 703 | * @param array $condition the conditions that will be passed to the `where()` method when building the query. 704 | * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. 705 | * @return int the number of rows updated 706 | * @throws Exception on error. 707 | * @see [[ActiveRecord::primaryKeysByCondition()]] 708 | */ 709 | public static function updateAllCounters($counters, $condition = []) 710 | { 711 | $primaryKeys = static::primaryKeysByCondition($condition); 712 | if (empty($primaryKeys) || empty($counters)) { 713 | return 0; 714 | } 715 | 716 | $bulkCommand = static::getDb()->createBulkCommand([ 717 | "index" => static::index(), 718 | "type" => static::type(), 719 | ]); 720 | foreach ($primaryKeys as $pk) { 721 | $script = ''; 722 | foreach ($counters as $counter => $value) { 723 | $script .= "ctx._source.{$counter} += params.{$counter};\n"; 724 | } 725 | $bulkCommand->addAction(["update" => ["_id" => $pk]], [ 726 | 'script' => [ 727 | 'inline' => $script, 728 | 'params' => $counters, 729 | 'lang' => 'painless', 730 | ], 731 | ]); 732 | } 733 | $response = $bulkCommand->execute(); 734 | 735 | $n = 0; 736 | $errors = []; 737 | foreach ($response['items'] as $item) { 738 | if (isset($item['update']['status']) && $item['update']['status'] == 200) { 739 | $n++; 740 | } else { 741 | $errors[] = $item['update']; 742 | } 743 | } 744 | if (!empty($errors) || isset($response['errors']) && $response['errors']) { 745 | throw new Exception(__METHOD__ . ' failed updating records counters.', $errors); 746 | } 747 | 748 | return $n; 749 | } 750 | 751 | /** 752 | * @inheritdoc 753 | * 754 | * @param array $options options given in this parameter are passed to Elasticsearch 755 | * as request URI parameters. These are among others: 756 | * 757 | * - `routing` define shard placement of this record. 758 | * - `parent` by giving the primaryKey of another record this defines a parent-child relation 759 | * - `timeout` timeout waiting for a shard to become available. 760 | * - `replication` the replication type for the delete/index operation (sync or async). 761 | * - `consistency` the write consistency of the index/delete operation. 762 | * - `refresh` refresh the relevant primary and replica shards (not the whole index) immediately after the operation occurs, so that the updated document appears in search results immediately. 763 | * 764 | * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html) 765 | * for more details on these options. 766 | * 767 | * The following parameters are Yii specific: 768 | * 769 | * - `optimistic_locking` set this to `true` to enable optimistic locking, avoid updating when the record has changed since it 770 | * has been loaded from the database. Yii will set the `version` parameter to the value stored in [[version]]. 771 | * See the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html#delete-versioning) for details. 772 | * 773 | * Make sure the record has been fetched with a [[version]] before. This is only the case 774 | * for records fetched via [[get()]] and [[mget()]] by default. For normal queries, the `_version` field has to be fetched explicitly. 775 | * 776 | * @return int|bool the number of rows deleted, or false if the deletion is unsuccessful for some reason. 777 | * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful. 778 | * @throws StaleObjectException if optimistic locking is enabled and the data being deleted is outdated. 779 | * @throws Exception in case delete failed. 780 | */ 781 | public function delete($options = []) 782 | { 783 | if (!$this->beforeDelete()) { 784 | return false; 785 | } 786 | if (isset($options['optimistic_locking']) && $options['optimistic_locking']) { 787 | if ($this->_version === null) { 788 | throw new InvalidArgumentException('Unable to use optimistic locking on a record that has no version set. Refer to the docs of ActiveRecord::delete() for details.'); 789 | } 790 | $options['version'] = $this->_version; 791 | unset($options['optimistic_locking']); 792 | } 793 | 794 | try { 795 | $result = static::getDb()->createCommand()->delete( 796 | static::index(), 797 | static::type(), 798 | $this->getOldPrimaryKey(false), 799 | $options 800 | ); 801 | } catch (Exception $e) { 802 | // HTTP 409 is the response in case of failed optimistic locking 803 | // https://www.elastic.co/guide/en/elasticsearch/guide/current/optimistic-concurrency-control.html 804 | if (isset($e->errorInfo['responseCode']) && $e->errorInfo['responseCode'] == 409) { 805 | throw new StaleObjectException('The object being deleted is outdated.', $e->errorInfo, $e->getCode(), $e); 806 | } 807 | throw $e; 808 | } 809 | 810 | $this->setOldAttributes(null); 811 | 812 | $this->afterDelete(); 813 | 814 | if ($result === false) { 815 | return 0; 816 | } else { 817 | return 1; 818 | } 819 | } 820 | 821 | /** 822 | * Deletes rows in the table using the provided conditions. 823 | * WARNING: If you do not specify any condition, this method will delete ALL rows in the table. 824 | * 825 | * For example, to delete all customers whose status is 3: 826 | * 827 | * ~~~ 828 | * Customer::deleteAll(['status' => 3]); 829 | * ~~~ 830 | * 831 | * @param array $condition the conditions that will be passed to the `where()` method when building the query. 832 | * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. 833 | * @return int the number of rows deleted 834 | * @throws Exception on error. 835 | * @see [[ActiveRecord::primaryKeysByCondition()]] 836 | */ 837 | public static function deleteAll($condition = []) 838 | { 839 | $primaryKeys = static::primaryKeysByCondition($condition); 840 | if (empty($primaryKeys)) { 841 | return 0; 842 | } 843 | 844 | $bulkCommand = static::getDb()->createBulkCommand([ 845 | "index" => static::index(), 846 | "type" => static::type(), 847 | ]); 848 | foreach ($primaryKeys as $pk) { 849 | $bulkCommand->addDeleteAction($pk); 850 | } 851 | $response = $bulkCommand->execute(); 852 | 853 | $n = 0; 854 | $errors = []; 855 | foreach ($response['items'] as $item) { 856 | if (isset($item['delete']['status']) && $item['delete']['status'] == 200) { 857 | if (isset($item['delete']['found']) && $item['delete']['found']) { 858 | # ES5 uses "found" 859 | $n++; 860 | } elseif (isset($item['delete']['result']) && $item['delete']['result'] == "deleted") { 861 | # ES6 uses "result" 862 | $n++; 863 | } 864 | } else { 865 | $errors[] = $item['delete']; 866 | } 867 | } 868 | if (!empty($errors) || isset($response['errors']) && $response['errors']) { 869 | throw new Exception(__METHOD__ . ' failed deleting records.', $errors); 870 | } 871 | 872 | return $n; 873 | } 874 | 875 | /** 876 | * This method has no effect in Elasticsearch ActiveRecord. 877 | * 878 | * Elasticsearch ActiveRecord uses [native Optimistic locking](https://www.elastic.co/guide/en/elasticsearch/guide/current/optimistic-concurrency-control.html). 879 | * See [[update()]] for more details. 880 | */ 881 | public function optimisticLock() 882 | { 883 | return null; 884 | } 885 | 886 | /** 887 | * Destroys the relationship in current model. 888 | * 889 | * This method is not supported by Elasticsearch. 890 | */ 891 | public function unlinkAll($name, $delete = false) 892 | { 893 | throw new NotSupportedException('unlinkAll() is not supported by Elasticsearch, use unlink() instead.'); 894 | } 895 | 896 | public function link($name, $model, $extraColumns = []) 897 | { 898 | $relation = $this->getRelation($name); 899 | 900 | if ($relation->via === null) { 901 | $this->validateViaRelationLink($model, $relation); 902 | } 903 | 904 | parent::link($name, $model, $extraColumns); 905 | } 906 | 907 | /** 908 | * Validates model so that it does not contain array as its keys while linking. 909 | * 910 | * @param ActiveRecordInterface $model the model to be linked with the current one. 911 | * @param ActiveQueryInterface|ActiveQuery the relational query object. 912 | */ 913 | protected function validateViaRelationLink($model, $relation) 914 | { 915 | $p1 = $model->isPrimaryKey(array_keys($relation->link)); 916 | $p2 = static::isPrimaryKey(array_values($relation->link)); 917 | 918 | $atLeastOneExists = !$this->getIsNewRecord() || !$model->getIsNewRecord(); 919 | 920 | $foreign = null; 921 | $link = null; 922 | 923 | if ($p1 && $p2 && $atLeastOneExists) { 924 | 925 | if ($this->getIsNewRecord()) { 926 | $foreign = $this; 927 | $link = array_flip($relation->link); 928 | } else { 929 | $foreign = $model; 930 | $link = $relation->link; 931 | } 932 | } elseif ($p1) { 933 | $foreign = $this; 934 | $link = array_flip($relation->link); 935 | } elseif ($p2) { 936 | $foreign = $model; 937 | $link = $relation->link; 938 | } 939 | 940 | if ($foreign && $link) { 941 | foreach ($link as $fk => $pk) { 942 | if (is_array($foreign->{$fk})) { 943 | throw new InvalidCallException('Unable to link models: foreign model cannot be linked if its property is an array.'); 944 | } 945 | } 946 | } 947 | } 948 | } 949 | -------------------------------------------------------------------------------- /src/BatchQueryResult.php: -------------------------------------------------------------------------------- 1 | from('user'); 31 | * foreach ($query->batch() as $i => $users) { 32 | * // $users represents the rows in the $i-th batch 33 | * } 34 | * foreach ($query->each() as $user) { 35 | * } 36 | * ``` 37 | * 38 | * @author Konstantin Sirotkin 39 | * @since 2.0.4 40 | */ 41 | class BatchQueryResult extends BaseObject implements \Iterator 42 | { 43 | /** 44 | * @var Connection the DB connection to be used when performing batch query. 45 | * If null, the `elasticsearch` application component will be used. 46 | */ 47 | public $db; 48 | /** 49 | * @var Query the query object associated with this batch query. 50 | * Do not modify this property directly unless after [[reset()]] is called explicitly. 51 | */ 52 | public $query; 53 | /** 54 | * @var boolean whether to return a single row during each iteration. 55 | * If false, a whole batch of rows will be returned in each iteration. 56 | */ 57 | public $each = false; 58 | /** 59 | * @var DataReader the data reader associated with this batch query. 60 | */ 61 | private $_dataReader; 62 | /** 63 | * @var array the data retrieved in the current batch 64 | */ 65 | private $_batch; 66 | /** 67 | * @var mixed the value for the current iteration 68 | */ 69 | private $_value; 70 | /** 71 | * @var string|integer the key for the current iteration 72 | */ 73 | private $_key; 74 | /** 75 | * @var string the amount of time to keep the scroll window open 76 | * (in Elasticsearch [time units](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units). 77 | */ 78 | public $scrollWindow = '1m'; 79 | 80 | /* 81 | * @var string internal Elasticsearch scroll id 82 | */ 83 | private $_lastScrollId = null; 84 | 85 | 86 | /** 87 | * Destructor. 88 | */ 89 | public function __destruct() 90 | { 91 | // make sure cursor is closed 92 | $this->reset(); 93 | } 94 | 95 | /** 96 | * Resets the batch query. 97 | * This method will clean up the existing batch query so that a new batch query can be performed. 98 | */ 99 | public function reset() 100 | { 101 | if(isset($this->_lastScrollId)) { 102 | $this->query->createCommand($this->db)->clearScroll(['scroll_id' => $this->_lastScrollId]); 103 | } 104 | 105 | $this->_batch = null; 106 | $this->_value = null; 107 | $this->_key = null; 108 | $this->_lastScrollId = null; 109 | } 110 | 111 | /** 112 | * Resets the iterator to the initial state. 113 | * This method is required by the interface [[\Iterator]]. 114 | */ 115 | #[ReturnTypeWillChange] 116 | public function rewind() 117 | { 118 | $this->reset(); 119 | $this->next(); 120 | } 121 | 122 | /** 123 | * Moves the internal pointer to the next dataset. 124 | * This method is required by the interface [[\Iterator]]. 125 | */ 126 | #[ReturnTypeWillChange] 127 | public function next() 128 | { 129 | if ($this->_batch === null || !$this->each || $this->each && next($this->_batch) === false) { 130 | $this->_batch = $this->fetchData(); 131 | reset($this->_batch); 132 | } 133 | 134 | if ($this->each) { 135 | $this->_value = current($this->_batch); 136 | if ($this->query->indexBy !== null) { 137 | $this->_key = key($this->_batch); 138 | } elseif (key($this->_batch) !== null) { 139 | $this->_key++; 140 | } else { 141 | $this->_key = null; 142 | } 143 | } else { 144 | $this->_value = $this->_batch; 145 | $this->_key = $this->_key === null ? 0 : $this->_key + 1; 146 | } 147 | } 148 | 149 | /** 150 | * Fetches the next batch of data. 151 | * @return array the data fetched 152 | */ 153 | protected function fetchData() 154 | { 155 | if (null === $this->_lastScrollId) { 156 | //first query - do search 157 | $options = ['scroll' => $this->scrollWindow]; 158 | if(!$this->query->orderBy) { 159 | $query = clone $this->query; 160 | $query->orderBy('_doc'); 161 | $cmd = $this->query->createCommand($this->db); 162 | } else { 163 | $cmd = $this->query->createCommand($this->db); 164 | } 165 | $result = $cmd->search($options); 166 | if ($result === false) { 167 | throw new Exception('Elasticsearch search query failed.'); 168 | } 169 | } else { 170 | //subsequent queries - do scroll 171 | $result = $this->query->createCommand($this->db)->scroll([ 172 | 'scroll_id' => $this->_lastScrollId, 173 | 'scroll' => $this->scrollWindow, 174 | ]); 175 | } 176 | 177 | //get last scroll id 178 | $this->_lastScrollId = $result['_scroll_id']; 179 | 180 | //get data 181 | return $this->query->populate($result['hits']['hits']); 182 | } 183 | 184 | /** 185 | * Returns the index of the current dataset. 186 | * This method is required by the interface [[\Iterator]]. 187 | * @return int the index of the current row. 188 | */ 189 | #[ReturnTypeWillChange] 190 | public function key() 191 | { 192 | return $this->_key; 193 | } 194 | 195 | /** 196 | * Returns the current dataset. 197 | * This method is required by the interface [[\Iterator]]. 198 | * @return mixed the current dataset. 199 | */ 200 | #[ReturnTypeWillChange] 201 | public function current() 202 | { 203 | return $this->_value; 204 | } 205 | 206 | /** 207 | * Returns whether there is a valid dataset at the current position. 208 | * This method is required by the interface [[\Iterator]]. 209 | * @return bool whether there is a valid dataset at the current position. 210 | */ 211 | #[ReturnTypeWillChange] 212 | public function valid() 213 | { 214 | return !empty($this->_batch); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/BulkCommand.php: -------------------------------------------------------------------------------- 1 | 21 | * @since 2.0.5 22 | */ 23 | class BulkCommand extends Component 24 | { 25 | /** 26 | * @var Connection 27 | */ 28 | public $db; 29 | /** 30 | * @var string Default index to execute the queries on. Defaults to null meaning that index needs to be specified in every action. 31 | */ 32 | public $index; 33 | /** 34 | * @var string Default type to execute the queries on. Defaults to null meaning that type needs to be specified in every action. 35 | */ 36 | public $type; 37 | /** 38 | * @var array|string Actions to be executed in this bulk command, given as either an array of arrays or as one newline-delimited string. 39 | * All actions except delete span two lines. 40 | */ 41 | public $actions; 42 | /** 43 | * @var array Options to be appended to the query URL. 44 | */ 45 | public $options = []; 46 | 47 | 48 | /** 49 | * Executes the bulk command. 50 | * @return mixed 51 | * @throws \yii\base\InvalidCallException 52 | */ 53 | public function execute() 54 | { 55 | //valid endpoints are /_bulk, /{index}/_bulk, and {index}/{type}/_bulk 56 | //for ES7+ type is omitted 57 | if ($this->index === null && $this->type === null) { 58 | $endpoint = ['_bulk']; 59 | } elseif ($this->index !== null && $this->type === null) { 60 | $endpoint = [$this->index, '_bulk']; 61 | } elseif ($this->index !== null && $this->type !== null) { 62 | if ($this->db->dslVersion >= 7) { 63 | $endpoint = [$this->index, '_bulk']; 64 | } else { 65 | $endpoint = [$this->index, $this->type, '_bulk']; 66 | } 67 | } else { 68 | throw new InvalidCallException('Invalid endpoint: if type is defined, index must be defined too.'); 69 | } 70 | 71 | if (empty($this->actions)) { 72 | $body = '{}'; 73 | } elseif (is_array($this->actions)) { 74 | $body = ''; 75 | $prettyPrintSupported = property_exists('yii\\helpers\\Json', 'prettyPrint'); 76 | if ($prettyPrintSupported) { 77 | $originalPrettyPrint = Json::$prettyPrint; 78 | Json::$prettyPrint = false; // ElasticSearch bulk API uses new lines as delimiters. 79 | } 80 | foreach ($this->actions as $action) { 81 | $body .= Json::encode($action) . "\n"; 82 | } 83 | if ($prettyPrintSupported) { 84 | Json::$prettyPrint = $originalPrettyPrint; 85 | } 86 | } else { 87 | $body = $this->actions; 88 | } 89 | 90 | return $this->db->post($endpoint, $this->options, $body); 91 | } 92 | 93 | /** 94 | * Adds an action to the command. Will overwrite existing actions if they are specified as a string. 95 | * @param array $line1 First action expressed as an array (will be encoded to JSON automatically). 96 | * @param array|null $line2 Second action expressed as an array (will be encoded to JSON automatically). 97 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.x/docs-bulk.html 98 | */ 99 | public function addAction($line1, $line2 = null) 100 | { 101 | if (!is_array($this->actions)) { 102 | $this->actions = []; 103 | } 104 | 105 | $this->actions[] = $line1; 106 | 107 | if ($line2 !== null) { 108 | $this->actions[] = $line2; 109 | } 110 | } 111 | 112 | /** 113 | * Adds a delete action to the command. 114 | * @param string $id Document ID 115 | * @param string|null $index Index that the document belongs to. Can be set to null if the command has 116 | * a default index ([[BulkCommand::$index]]) assigned. 117 | * @param string|null $type Type that the document belongs to. Can be set to null if the command has 118 | * a default type ([[BulkCommand::$type]]) assigned. 119 | */ 120 | public function addDeleteAction($id, $index = null, $type = null) 121 | { 122 | $actionData = ['_id' => $id]; 123 | 124 | if (!empty($index)) { 125 | $actionData['_index'] = $index; 126 | } 127 | 128 | if (!empty($type)) { 129 | $actionData['_type'] = $type; 130 | } 131 | 132 | $this->addAction(['delete' => $actionData]); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Command.php: -------------------------------------------------------------------------------- 1 | 22 | * @since 2.0 23 | */ 24 | class Command extends Component 25 | { 26 | /** 27 | * @var Connection 28 | */ 29 | public $db; 30 | /** 31 | * @var string|array the indexes to execute the query on. Defaults to null meaning all indexes 32 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html#search-multi-index-type 33 | */ 34 | public $index; 35 | /** 36 | * @var string|array|null the types to execute the query on. Defaults to null meaning all types 37 | */ 38 | public $type; 39 | /** 40 | * @var array list of arrays or json strings that become parts of a query 41 | */ 42 | public $queryParts; 43 | /** 44 | * @var array options to be appended to the query URL, such as "search_type" for search or "timeout" for delete 45 | */ 46 | public $options = []; 47 | 48 | 49 | /** 50 | * Sends a request to the _search API and returns the result 51 | * @param array $options URL options 52 | * @return mixed 53 | * @throws Exception 54 | * @throws \yii\base\InvalidConfigException 55 | */ 56 | public function search($options = []) 57 | { 58 | $query = $this->queryParts; 59 | if (empty($query)) { 60 | $query = '{}'; 61 | } 62 | if (is_array($query)) { 63 | $query = Json::encode($query); 64 | } 65 | $url = [$this->index !== null ? $this->index : '_all']; 66 | 67 | if ($this->db->dslVersion < 7 && $this->type !== null) { 68 | $url[] = $this->type; 69 | } 70 | 71 | $url[] = '_search'; 72 | 73 | return $this->db->get($url, array_merge($this->options, $options), $query); 74 | } 75 | 76 | /** 77 | * Sends a request to the delete by query 78 | * @param array $options URL options 79 | * @return mixed 80 | * @throws Exception 81 | * @throws \yii\base\InvalidConfigException 82 | */ 83 | public function deleteByQuery($options = []) 84 | { 85 | if (!isset($this->queryParts['query'])) { 86 | throw new InvalidCallException('Can not call deleteByQuery when no query is given.'); 87 | } 88 | $query = [ 89 | 'query' => $this->queryParts['query'], 90 | ]; 91 | if (isset($this->queryParts['filter'])) { 92 | $query['filter'] = $this->queryParts['filter']; 93 | } 94 | $query = Json::encode($query); 95 | $url = [$this->index !== null ? $this->index : '_all']; 96 | if ($this->type !== null) { 97 | $url[] = $this->type; 98 | } 99 | $url[] = '_delete_by_query'; 100 | 101 | return $this->db->post($url, array_merge($this->options, $options), $query); 102 | } 103 | 104 | /** 105 | * Sends a suggest request to the _search API and returns the result 106 | * @param string|array $suggester the suggester body 107 | * @param array $options URL options 108 | * @return mixed 109 | * @throws Exception 110 | * @throws \yii\base\InvalidConfigException 111 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html 112 | */ 113 | public function suggest($suggester, $options = []) 114 | { 115 | if (empty($suggester)) { 116 | $suggester = '{}'; 117 | } 118 | if (is_array($suggester)) { 119 | $suggester = Json::encode($suggester); 120 | } 121 | $body = '{"suggest":'.$suggester.',"size":0}'; 122 | $url = [ 123 | $this->index !== null ? $this->index : '_all', 124 | '_search' 125 | ]; 126 | 127 | $result = $this->db->post($url, array_merge($this->options, $options), $body); 128 | 129 | return $result['suggest']; 130 | } 131 | 132 | /** 133 | * Inserts a document into an index 134 | * @param string $index Index that the document belongs to. 135 | * @param string|null $type Type that the document belongs to. 136 | * @param string|array $data json string or array of data to store 137 | * @param string|null $id the documents id. If not specified Id will be automatically chosen 138 | * @param array $options URL options 139 | * @return mixed 140 | * @throws Exception 141 | * @throws \yii\base\InvalidConfigException 142 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html 143 | */ 144 | public function insert($index, $type, $data, $id = null, $options = []) 145 | { 146 | if (empty($data)) { 147 | $body = '{}'; 148 | } else { 149 | $body = is_array($data) ? Json::encode($data) : $data; 150 | } 151 | 152 | if ($id !== null) { 153 | if ($this->db->dslVersion >= 7) { 154 | return $this->db->put([$index, '_doc', $id], $options, $body); 155 | } else { 156 | return $this->db->put([$index, $type, $id], $options, $body); 157 | } 158 | } else { 159 | if ($this->db->dslVersion >= 7) { 160 | return $this->db->post([$index, '_doc'], $options, $body); 161 | } else { 162 | return $this->db->post([$index, $type], $options, $body); 163 | } 164 | } 165 | } 166 | 167 | /** 168 | * gets a document from the index 169 | * @param string $index Index that the document belongs to. 170 | * @param string|null $type Type that the document belongs to. 171 | * @param string $id the documents id. 172 | * @param array $options URL options 173 | * @return mixed 174 | * @throws Exception 175 | * @throws \yii\base\InvalidConfigException 176 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html 177 | */ 178 | public function get($index, $type, $id, $options = []) 179 | { 180 | if ($this->db->dslVersion >= 7) { 181 | return $this->db->get([$index, '_doc', $id], $options); 182 | } else { 183 | return $this->db->get([$index, $type, $id], $options); 184 | } 185 | } 186 | 187 | /** 188 | * gets multiple documents from the index 189 | * 190 | * TODO allow specifying type and index + fields 191 | * @param string $index Index that the document belongs to. 192 | * @param string|null $type Type that the document belongs to. 193 | * @param string[] $ids the documents ids as values in array. 194 | * @param array $options URL options 195 | * @return mixed 196 | * @throws Exception 197 | * @throws \yii\base\InvalidConfigException 198 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html 199 | */ 200 | public function mget($index, $type, $ids, $options = []) 201 | { 202 | $body = Json::encode(['ids' => array_values($ids)]); 203 | 204 | if ($this->db->dslVersion >= 7) { 205 | return $this->db->get([$index, '_mget'], $options, $body); 206 | } else { 207 | return $this->db->get([$index, $type, '_mget'], $options, $body); 208 | } 209 | } 210 | 211 | /** 212 | * gets a documents _source from the index (>=v0.90.1) 213 | * @param string $index Index that the document belongs to. 214 | * @param string|null $type Type that the document belongs to. 215 | * @param string $id the documents id. 216 | * @return mixed 217 | * @throws Exception 218 | * @throws \yii\base\InvalidConfigException 219 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html#_source 220 | */ 221 | public function getSource($index, $type, $id) 222 | { 223 | if ($this->db->dslVersion >= 7) { 224 | return $this->db->get([$index, '_doc', $id]); 225 | } else { 226 | return $this->db->get([$index, $type, $id]); 227 | } 228 | } 229 | 230 | /** 231 | * gets a document from the index 232 | * @param string $index Index that the document belongs to. 233 | * @param string|null $type Type that the document belongs to. 234 | * @param string $id the documents id. 235 | * @return mixed 236 | * @throws Exception 237 | * @throws \yii\base\InvalidConfigException 238 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html 239 | */ 240 | public function exists($index, $type, $id) 241 | { 242 | if ($this->db->dslVersion >= 7) { 243 | return $this->db->head([$index, '_doc', $id]); 244 | } else { 245 | return $this->db->head([$index, $type, $id]); 246 | } 247 | } 248 | 249 | /** 250 | * deletes a document from the index 251 | * @param string $index Index that the document belongs to. 252 | * @param string|null $type Type that the document belongs to. 253 | * @param string $id the documents id. 254 | * @param array $options URL options 255 | * @return mixed 256 | * @throws Exception 257 | * @throws \yii\base\InvalidConfigException 258 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html 259 | */ 260 | public function delete($index, $type, $id, $options = []) 261 | { 262 | if ($this->db->dslVersion >= 7) { 263 | return $this->db->delete([$index, '_doc', $id], $options); 264 | } else { 265 | return $this->db->delete([$index, $type, $id], $options); 266 | } 267 | } 268 | 269 | /** 270 | * updates a document 271 | * @param string $index Index that the document belongs to. 272 | * @param string|null $type Type that the document belongs to. 273 | * @param string $id the documents id. 274 | * @param mixed $data 275 | * @param array $options URL options 276 | * @return mixed 277 | * @throws Exception 278 | * @throws \yii\base\InvalidConfigException 279 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html 280 | */ 281 | public function update($index, $type, $id, $data, $options = []) 282 | { 283 | $body = [ 284 | 'doc' => empty($data) ? new \stdClass() : $data, 285 | ]; 286 | if (isset($options["detect_noop"])) { 287 | $body["detect_noop"] = $options["detect_noop"]; 288 | unset($options["detect_noop"]); 289 | } 290 | 291 | if ($this->db->dslVersion >= 7) { 292 | return $this->db->post([$index, '_update', $id], $options, Json::encode($body)); 293 | } else { 294 | return $this->db->post([$index, $type, $id, '_update'], $options, Json::encode($body)); 295 | } 296 | } 297 | 298 | // TODO bulk https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html 299 | 300 | /** 301 | * creates an index 302 | * @param string $index Index that the document belongs to. 303 | * @param null|array $configuration 304 | * @return mixed 305 | * @throws Exception 306 | * @throws \yii\base\InvalidConfigException 307 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html 308 | */ 309 | public function createIndex($index, $configuration = null) 310 | { 311 | $body = $configuration !== null ? Json::encode($configuration) : null; 312 | 313 | return $this->db->put([$index], [], $body); 314 | } 315 | 316 | /** 317 | * deletes an index 318 | * @param string $index Index that the document belongs to. 319 | * @return mixed 320 | * @throws Exception 321 | * @throws \yii\base\InvalidConfigException 322 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html 323 | */ 324 | public function deleteIndex($index) 325 | { 326 | return $this->db->delete([$index]); 327 | } 328 | 329 | /** 330 | * deletes all indexes 331 | * @return mixed 332 | * @throws Exception 333 | * @throws \yii\base\InvalidConfigException 334 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html 335 | */ 336 | public function deleteAllIndexes() 337 | { 338 | return $this->db->delete(['_all']); 339 | } 340 | 341 | /** 342 | * checks whether an index exists 343 | * @param string $index Index that the document belongs to. 344 | * @return mixed 345 | * @throws Exception 346 | * @throws \yii\base\InvalidConfigException 347 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-exists.html 348 | */ 349 | public function indexExists($index) 350 | { 351 | return $this->db->head([$index]); 352 | } 353 | 354 | /** 355 | * @param string $index Index that the document belongs to. 356 | * @param string|null $type Type that the document belongs to. 357 | * @return mixed 358 | * @throws Exception 359 | * @throws \yii\base\InvalidConfigException 360 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-types-exists.html 361 | */ 362 | public function typeExists($index, $type) 363 | { 364 | if ($this->db->dslVersion >= 7) { 365 | return $this->db->head([$index, '_doc']); 366 | } else { 367 | return $this->db->head([$index, $type]); 368 | } 369 | } 370 | 371 | /** 372 | * @param string $alias 373 | * 374 | * @return bool 375 | * @throws Exception 376 | * @throws \yii\base\InvalidConfigException 377 | */ 378 | public function aliasExists($alias) 379 | { 380 | $indexes = $this->getIndexesByAlias($alias); 381 | 382 | return !empty($indexes); 383 | } 384 | 385 | /** 386 | * @return array 387 | * @throws Exception 388 | * @throws \yii\base\InvalidConfigException 389 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#alias-retrieving 390 | */ 391 | public function getAliasInfo() 392 | { 393 | $aliasInfo = $this->db->get(['_alias', '*']); 394 | return $aliasInfo ?: []; 395 | } 396 | 397 | /** 398 | * @param string $alias 399 | * 400 | * @return array 401 | * @throws Exception 402 | * @throws \yii\base\InvalidConfigException 403 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#alias-retrieving 404 | */ 405 | public function getIndexInfoByAlias($alias) 406 | { 407 | $responseData = $this->db->get(['_alias', $alias]); 408 | if (empty($responseData)) { 409 | return []; 410 | } 411 | 412 | return $responseData; 413 | } 414 | 415 | /** 416 | * @param string $alias 417 | * 418 | * @return array 419 | * @throws Exception 420 | * @throws \yii\base\InvalidConfigException 421 | */ 422 | public function getIndexesByAlias($alias) 423 | { 424 | return array_keys($this->getIndexInfoByAlias($alias)); 425 | } 426 | 427 | /** 428 | * @param string $index Index that the document belongs to. 429 | * 430 | * @return array 431 | * @throws Exception 432 | * @throws \yii\base\InvalidConfigException 433 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#alias-retrieving 434 | */ 435 | public function getIndexAliases($index) 436 | { 437 | $responseData = $this->db->get([$index, '_alias', '*']); 438 | if (empty($responseData)) { 439 | return []; 440 | } 441 | 442 | return $responseData[$index]['aliases']; 443 | } 444 | 445 | /** 446 | * @param string $index Index that the document belongs to. 447 | * @param string $alias 448 | * @param array $aliasParameters 449 | * 450 | * @return bool 451 | * @throws Exception 452 | * @throws \yii\base\InvalidConfigException 453 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#alias-adding 454 | */ 455 | public function addAlias($index, $alias, $aliasParameters = []) 456 | { 457 | return (bool)$this->db->put([$index, '_alias', $alias], [], json_encode((object)$aliasParameters)); 458 | } 459 | 460 | /** 461 | * @param string $index Index that the document belongs to. 462 | * @param string $alias 463 | * 464 | * @return bool 465 | * @throws Exception 466 | * @throws \yii\base\InvalidConfigException 467 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#deleting 468 | */ 469 | public function removeAlias($index, $alias) 470 | { 471 | return (bool)$this->db->delete([$index, '_alias', $alias]); 472 | } 473 | 474 | /** 475 | * Runs alias manipulations. 476 | * If you want to add alias1 to index1 477 | * and remove alias2 from index2 you can use following commands: 478 | * ~~~ 479 | * $actions = [ 480 | * ['add' => ['index' => 'index1', 'alias' => 'alias1']], 481 | * ['remove' => ['index' => 'index2', 'alias' => 'alias2']], 482 | * ]; 483 | * ~~~ 484 | * @param array $actions 485 | * 486 | * @return bool 487 | * @throws Exception 488 | * @throws \yii\base\InvalidConfigException 489 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#indices-aliases 490 | */ 491 | public function aliasActions(array $actions) 492 | { 493 | return (bool)$this->db->post(['_aliases'], [], json_encode(['actions' => $actions])); 494 | } 495 | 496 | /** 497 | * Change specific index level settings in real time. 498 | * Note that update analyzers required to [[close()]] the index first and [[open()]] it after the changes are made, 499 | * use [[updateAnalyzers()]] for it. 500 | * 501 | * @param string $index Index that the document belongs to. 502 | * @param string|array $setting 503 | * @param array $options URL options 504 | * @return mixed 505 | * @throws Exception 506 | * @throws \yii\base\InvalidConfigException 507 | * @see https://www.elasticsearch.org/guide/en/elasticsearch/reference/current/indices-update-settings.html 508 | * @since 2.0.4 509 | */ 510 | public function updateSettings($index, $setting, $options = []) 511 | { 512 | $body = $setting !== null ? (is_string($setting) ? $setting : Json::encode($setting)) : null; 513 | return $this->db->put([$index, '_settings'], $options, $body); 514 | } 515 | 516 | /** 517 | * Define new analyzers for the index. 518 | * For example if content analyzer hasn’t been defined on "myindex" yet 519 | * you can use the following commands to add it: 520 | * 521 | * ~~~ 522 | * $setting = [ 523 | * 'analysis' => [ 524 | * 'analyzer' => [ 525 | * 'ngram_analyzer_with_filter' => [ 526 | * 'tokenizer' => 'ngram_tokenizer', 527 | * 'filter' => 'lowercase, snowball' 528 | * ], 529 | * ], 530 | * 'tokenizer' => [ 531 | * 'ngram_tokenizer' => [ 532 | * 'type' => 'nGram', 533 | * 'min_gram' => 3, 534 | * 'max_gram' => 10, 535 | * 'token_chars' => ['letter', 'digit', 'whitespace', 'punctuation', 'symbol'] 536 | * ], 537 | * ], 538 | * ] 539 | * ]; 540 | * $elasticQuery->createCommand()->updateAnalyzers('myindex', $setting); 541 | * ~~~ 542 | * 543 | * @param string $index Index that the document belongs to. 544 | * @param string|array $setting 545 | * @param array $options URL options 546 | * @return mixed 547 | * @throws Exception 548 | * @throws \yii\base\InvalidConfigException 549 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-update-settings.html#update-settings-analysis 550 | * @since 2.0.4 551 | */ 552 | public function updateAnalyzers($index, $setting, $options = []) 553 | { 554 | $this->closeIndex($index); 555 | $result = $this->updateSettings($index, $setting, $options); 556 | $this->openIndex($index); 557 | return $result; 558 | } 559 | 560 | // TODO https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-settings.html 561 | 562 | // TODO https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-warmers.html 563 | 564 | /** 565 | * @param string $index Index that the document belongs to. 566 | * @return mixed 567 | * @throws Exception 568 | * @throws \yii\base\InvalidConfigException 569 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-open-close.html 570 | */ 571 | public function openIndex($index) 572 | { 573 | return $this->db->post([$index, '_open']); 574 | } 575 | 576 | /** 577 | * @param string $index Index that the document belongs to. 578 | * @return mixed 579 | * @throws Exception 580 | * @throws \yii\base\InvalidConfigException 581 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-open-close.html 582 | */ 583 | public function closeIndex($index) 584 | { 585 | return $this->db->post([$index, '_close']); 586 | } 587 | 588 | /** 589 | * @param array $options URL options 590 | * @return mixed 591 | * @throws Exception 592 | * @throws \yii\base\InvalidConfigException 593 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-scroll.html 594 | * @since 2.0.4 595 | */ 596 | public function scroll($options = []) 597 | { 598 | $body = array_filter([ 599 | 'scroll' => ArrayHelper::remove($options, 'scroll', null), 600 | 'scroll_id' => ArrayHelper::remove($options, 'scroll_id', null), 601 | ]); 602 | if (empty($body)) { 603 | $body = (object) []; 604 | } 605 | 606 | return $this->db->post(['_search', 'scroll'], $options, Json::encode($body)); 607 | } 608 | 609 | /** 610 | * @param array $options URL options 611 | * @return mixed 612 | * @throws Exception 613 | * @throws \yii\base\InvalidConfigException 614 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-scroll.html 615 | * @since 2.0.4 616 | */ 617 | public function clearScroll($options = []) 618 | { 619 | $body = array_filter([ 620 | 'scroll_id' => ArrayHelper::remove($options, 'scroll_id', null), 621 | ]); 622 | if (empty($body)) { 623 | $body = (object) []; 624 | } 625 | 626 | return $this->db->delete(['_search', 'scroll'], $options, Json::encode($body)); 627 | } 628 | 629 | /** 630 | * @param string $index Index that the document belongs to. 631 | * @return mixed 632 | * @throws Exception 633 | * @throws \yii\base\InvalidConfigException 634 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-stats.html 635 | */ 636 | public function getIndexStats($index = '_all') 637 | { 638 | return $this->db->get([$index, '_stats']); 639 | } 640 | 641 | /** 642 | * @param string $index Index that the document belongs to. 643 | * @return mixed 644 | * @throws Exception 645 | * @throws \yii\base\InvalidConfigException 646 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-recovery.html 647 | */ 648 | public function getIndexRecoveryStats($index = '_all') 649 | { 650 | return $this->db->get([$index, '_recovery']); 651 | } 652 | 653 | // https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-segments.html 654 | 655 | /** 656 | * @param string $index Index that the document belongs to. 657 | * @return mixed 658 | * @throws Exception 659 | * @throws \yii\base\InvalidConfigException 660 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-clearcache.html 661 | */ 662 | public function clearIndexCache($index) 663 | { 664 | return $this->db->post([$index, '_cache', 'clear']); 665 | } 666 | 667 | /** 668 | * @param string $index Index that the document belongs to. 669 | * @return mixed 670 | * @throws Exception 671 | * @throws \yii\base\InvalidConfigException 672 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-flush.html 673 | */ 674 | public function flushIndex($index = '_all') 675 | { 676 | return $this->db->post([$index, '_flush']); 677 | } 678 | 679 | /** 680 | * @param string $index Index that the document belongs to. 681 | * @return mixed 682 | * @throws Exception 683 | * @throws \yii\base\InvalidConfigException 684 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html 685 | */ 686 | public function refreshIndex($index) 687 | { 688 | return $this->db->post([$index, '_refresh']); 689 | } 690 | 691 | // TODO https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-optimize.html 692 | 693 | // TODO https://www.elastic.co/guide/en/elasticsearch/reference/0.90/indices-gateway-snapshot.html 694 | 695 | /** 696 | * @param string $index Index that the document belongs to. 697 | * @param string|null $type Type that the document belongs to. 698 | * @param string|array $mapping 699 | * @param array $options URL options 700 | * @return mixed 701 | * @throws Exception 702 | * @throws \yii\base\InvalidConfigException 703 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-mapping.html 704 | */ 705 | public function setMapping($index, $type, $mapping, $options = []) 706 | { 707 | $body = $mapping !== null ? (is_string($mapping) ? $mapping : Json::encode($mapping)) : null; 708 | 709 | if ($this->db->dslVersion >= 7) { 710 | $endpoint = [$index, '_mapping']; 711 | } else { 712 | $endpoint = [$index, '_mapping', $type]; 713 | } 714 | return $this->db->put($endpoint, $options, $body); 715 | } 716 | 717 | /** 718 | * @param string $index Index that the document belongs to. 719 | * @param string|null $type Type that the document belongs to. 720 | * @return mixed 721 | * @throws Exception 722 | * @throws \yii\base\InvalidConfigException 723 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-mapping.html 724 | */ 725 | public function getMapping($index = '_all', $type = null) 726 | { 727 | $url = [$index, '_mapping']; 728 | if ($this->db->dslVersion < 7 && $type !== null) { 729 | $url[] = $type; 730 | } 731 | return $this->db->get($url); 732 | } 733 | 734 | /** 735 | * @param string $index Index that the document belongs to. 736 | * @param string $type 737 | * @return mixed 738 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-field-mapping.html 739 | */ 740 | // public function getFieldMapping($index, $type = '_all') 741 | // { 742 | // // TODO implement 743 | // return $this->db->put([$index, $type, '_mapping']); 744 | // } 745 | 746 | /** 747 | * @param $options 748 | * @param string $index Index that the document belongs to. 749 | * @return mixed 750 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-analyze.html 751 | */ 752 | // public function analyze($options, $index = null) 753 | // { 754 | // // TODO implement 755 | //// return $this->db->put([$index]); 756 | // } 757 | 758 | /** 759 | * @param $name 760 | * @param $pattern 761 | * @param $settings 762 | * @param $mappings 763 | * @param int $order 764 | * @return mixed 765 | * @throws Exception 766 | * @throws \yii\base\InvalidConfigException 767 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html 768 | */ 769 | public function createTemplate($name, $pattern, $settings, $mappings, $order = 0) 770 | { 771 | $body = Json::encode([ 772 | 'template' => $pattern, 773 | 'order' => $order, 774 | 'settings' => (object) $settings, 775 | 'mappings' => (object) $mappings, 776 | ]); 777 | 778 | return $this->db->put(['_template', $name], [], $body); 779 | 780 | } 781 | 782 | /** 783 | * @param $name 784 | * @return mixed 785 | * @throws Exception 786 | * @throws \yii\base\InvalidConfigException 787 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html 788 | */ 789 | public function deleteTemplate($name) 790 | { 791 | return $this->db->delete(['_template', $name]); 792 | 793 | } 794 | 795 | /** 796 | * @param $name 797 | * @return mixed 798 | * @throws Exception 799 | * @throws \yii\base\InvalidConfigException 800 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html 801 | */ 802 | public function getTemplate($name) 803 | { 804 | return $this->db->get(['_template', $name]); 805 | } 806 | } 807 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | 24 | * @since 2.0 25 | */ 26 | class Connection extends Component 27 | { 28 | /** 29 | * @event Event an event that is triggered after a DB connection is established 30 | */ 31 | const EVENT_AFTER_OPEN = 'afterOpen'; 32 | 33 | /** 34 | * @var boolean whether to autodetect available cluster nodes on [[open()]] 35 | */ 36 | public $autodetectCluster = true; 37 | /** 38 | * @var array The Elasticsearch cluster nodes to connect to. 39 | * 40 | * This is populated with the result of a cluster nodes request when [[autodetectCluster]] is true. 41 | * 42 | * Additional special options: 43 | * 44 | * - `auth`: overrides [[auth]] property. For example: 45 | * 46 | * ```php 47 | * [ 48 | * 'http_address' => 'inet[/127.0.0.1:9200]', 49 | * 'auth' => ['username' => 'yiiuser', 'password' => 'yiipw'], // Overrides the `auth` property of the class with specific login and password 50 | * //'auth' => ['username' => 'yiiuser', 'password' => 'yiipw'], // Disabled auth regardless of `auth` property of the class 51 | * ] 52 | * ``` 53 | * 54 | * - `protocol`: explicitly sets the protocol for the current node (useful when manually defining a HTTPS cluster) 55 | * 56 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-nodes-info.html#cluster-nodes-info 57 | */ 58 | public $nodes = [ 59 | ['http_address' => 'inet[/127.0.0.1:9200]'], 60 | ]; 61 | /** 62 | * @var string the active node. Key of one of the [[nodes]]. Will be randomly selected on [[open()]]. 63 | */ 64 | public $activeNode; 65 | /** 66 | * @var array Authentication data used to connect to the Elasticsearch node. 67 | * 68 | * Array elements: 69 | * 70 | * - `username`: the username for authentication. 71 | * - `password`: the password for authentication. 72 | * 73 | * Array either MUST contain both username and password on not contain any authentication credentials. 74 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-authenticate.html 75 | */ 76 | public $auth = []; 77 | /** 78 | * Elasticsearch has no knowledge of protocol used to access its nodes. Specifically, cluster autodetection request 79 | * returns node hosts and ports, but not the protocols to access them. Therefore we need to specify a default protocol here, 80 | * which can be overridden for specific nodes in the [[nodes]] property. 81 | * If [[autodetectCluster]] is true, all nodes received from cluster will be set to use the protocol defined by [[defaultProtocol]] 82 | * @var string Default protocol to connect to nodes 83 | * @since 2.0.5 84 | */ 85 | public $defaultProtocol = 'http'; 86 | /** 87 | * @var float timeout to use for connecting to an Elasticsearch node. 88 | * This value will be used to configure the curl `CURLOPT_CONNECTTIMEOUT` option. 89 | * If not set, no explicit timeout will be set for curl. 90 | */ 91 | public $connectionTimeout = null; 92 | /** 93 | * @var float timeout to use when reading the response from an Elasticsearch node. 94 | * This value will be used to configure the curl `CURLOPT_TIMEOUT` option. 95 | * If not set, no explicit timeout will be set for curl. 96 | */ 97 | public $dataTimeout = null; 98 | /** 99 | * @var array additional options used to configure curl session 100 | * @since 2.1.4 101 | */ 102 | public $curlOptions = []; 103 | /** 104 | * @var integer version of the domain-specific language to use with the server. 105 | * This must be set to the major version of the Elasticsearch server in use, e.g. `5` for Elasticsearch 5.x.x, 106 | * `6` for Elasticsearch 6.x.x, and `7` for Elasticsearch 7.x.x. 107 | */ 108 | public $dslVersion = 5; 109 | 110 | /** 111 | * @var resource the curl instance returned by [curl_init()](https://php.net/manual/en/function.curl-init.php). 112 | */ 113 | private $_curl; 114 | 115 | 116 | public function init() 117 | { 118 | foreach ($this->nodes as &$node) { 119 | if (!isset($node['http_address'])) { 120 | throw new InvalidConfigException('Elasticsearch node needs at least a http_address configured.'); 121 | } 122 | if (!isset($node['protocol'])) { 123 | $node['protocol'] = $this->defaultProtocol; 124 | } 125 | if (!in_array($node['protocol'], ['http', 'https'])) { 126 | throw new InvalidConfigException('Valid node protocol settings are "http" and "https".'); 127 | } 128 | } 129 | } 130 | 131 | /** 132 | * Closes the connection when this component is being serialized. 133 | * @return array 134 | */ 135 | public function __sleep() 136 | { 137 | $this->close(); 138 | 139 | return array_keys(get_object_vars($this)); 140 | } 141 | 142 | /** 143 | * Returns a value indicating whether the DB connection is established. 144 | * @return bool whether the DB connection is established 145 | */ 146 | public function getIsActive() 147 | { 148 | return $this->activeNode !== null; 149 | } 150 | 151 | /** 152 | * Establishes a DB connection. 153 | * It does nothing if a DB connection has already been established. 154 | * @throws Exception if connection fails 155 | */ 156 | public function open() 157 | { 158 | if ($this->activeNode !== null) { 159 | return; 160 | } 161 | if (empty($this->nodes)) { 162 | throw new InvalidConfigException('Elasticsearch needs at least one node to operate.'); 163 | } 164 | $this->_curl = curl_init(); 165 | if ($this->autodetectCluster) { 166 | $this->populateNodes(); 167 | } 168 | $this->selectActiveNode(); 169 | Yii::trace('Opening connection to Elasticsearch. Nodes in cluster: ' . count($this->nodes) 170 | . ', active node: ' . $this->nodes[$this->activeNode]['http_address'], __CLASS__); 171 | $this->initConnection(); 172 | } 173 | 174 | /** 175 | * Populates [[nodes]] with the result of a cluster nodes request. 176 | * @throws Exception if no active node(s) found 177 | * @since 2.0.4 178 | */ 179 | protected function populateNodes() 180 | { 181 | $node = reset($this->nodes); 182 | $host = $node['http_address']; 183 | $protocol = isset($node['protocol']) ? $node['protocol'] : $this->defaultProtocol; 184 | if (strncmp($host, 'inet[/', 6) === 0) { 185 | $host = substr($host, 6, -1); 186 | } 187 | $response = $this->httpRequest('GET', "$protocol://$host/_nodes/_all/http"); 188 | if (!empty($response['nodes'])) { 189 | $nodes = $response['nodes']; 190 | } else { 191 | $nodes = []; 192 | } 193 | 194 | foreach ($nodes as $key => &$node) { 195 | // Make sure that nodes have an 'http_address' property, which is not the case if you're using AWS 196 | // Elasticsearch service (at least as of Oct., 2015). - TO BE VERIFIED 197 | // Temporary workaround - simply ignore all invalid nodes 198 | if (!isset($node['http']['publish_address'])) { 199 | unset($nodes[$key]); 200 | } 201 | $node['http_address'] = $node['http']['publish_address']; 202 | 203 | // Protocol is not a standard ES node property, so we add it manually 204 | $node['protocol'] = $this->defaultProtocol; 205 | } 206 | 207 | if (!empty($nodes)) { 208 | $this->nodes = array_values($nodes); 209 | } else { 210 | curl_close($this->_curl); 211 | throw new Exception('Cluster autodetection did not find any active node. Make sure a GET /_nodes reguest on the hosts defined in the config returns the "http_address" field for each node.'); 212 | } 213 | } 214 | 215 | /** 216 | * select active node randomly 217 | */ 218 | protected function selectActiveNode() 219 | { 220 | $keys = array_keys($this->nodes); 221 | $this->activeNode = $keys[random_int(0, count($keys) - 1)]; 222 | } 223 | 224 | /** 225 | * Closes the currently active DB connection. 226 | * It does nothing if the connection is already closed. 227 | */ 228 | public function close() 229 | { 230 | if ($this->activeNode === null) { 231 | return; 232 | } 233 | Yii::trace('Closing connection to Elasticsearch. Active node was: ' 234 | . $this->nodes[$this->activeNode]['http']['publish_address'], __CLASS__); 235 | $this->activeNode = null; 236 | if ($this->_curl) { 237 | curl_close($this->_curl); 238 | $this->_curl = null; 239 | } 240 | } 241 | 242 | /** 243 | * Initializes the DB connection. 244 | * This method is invoked right after the DB connection is established. 245 | * The default implementation triggers an [[EVENT_AFTER_OPEN]] event. 246 | */ 247 | protected function initConnection() 248 | { 249 | $this->trigger(self::EVENT_AFTER_OPEN); 250 | } 251 | 252 | /** 253 | * Returns the name of the DB driver for the current [[dsn]]. 254 | * @return string name of the DB driver 255 | */ 256 | public function getDriverName() 257 | { 258 | return 'elasticsearch'; 259 | } 260 | 261 | /** 262 | * Creates a command for execution. 263 | * @param array $config the configuration for the Command class 264 | * @return Command the DB command 265 | */ 266 | public function createCommand($config = []) 267 | { 268 | $this->open(); 269 | $config['db'] = $this; 270 | $command = new Command($config); 271 | 272 | return $command; 273 | } 274 | 275 | /** 276 | * Creates a bulk command for execution. 277 | * @param array $config the configuration for the [[BulkCommand]] class 278 | * @return BulkCommand the DB command 279 | * @since 2.0.5 280 | */ 281 | public function createBulkCommand($config = []) 282 | { 283 | $this->open(); 284 | $config['db'] = $this; 285 | $command = new BulkCommand($config); 286 | 287 | return $command; 288 | } 289 | 290 | /** 291 | * Creates new query builder instance 292 | * @return QueryBuilder 293 | */ 294 | public function getQueryBuilder() 295 | { 296 | return new QueryBuilder($this); 297 | } 298 | 299 | /** 300 | * Performs GET HTTP request 301 | * 302 | * @param string|array $url URL 303 | * @param array $options URL options 304 | * @param string $body request body 305 | * @param bool $raw if response body contains JSON and should be decoded 306 | * @return mixed response 307 | * @throws Exception 308 | * @throws InvalidConfigException 309 | */ 310 | public function get($url, $options = [], $body = null, $raw = false) 311 | { 312 | $this->open(); 313 | return $this->httpRequest('GET', $this->createUrl($url, $options), $body, $raw); 314 | } 315 | 316 | /** 317 | * Performs HEAD HTTP request 318 | * 319 | * @param string|array $url URL 320 | * @param array $options URL options 321 | * @param string $body request body 322 | * @return mixed response 323 | * @throws Exception 324 | * @throws InvalidConfigException 325 | */ 326 | public function head($url, $options = [], $body = null) 327 | { 328 | $this->open(); 329 | return $this->httpRequest('HEAD', $this->createUrl($url, $options), $body); 330 | } 331 | 332 | /** 333 | * Performs POST HTTP request 334 | * 335 | * @param string|array $url URL 336 | * @param array $options URL options 337 | * @param string $body request body 338 | * @param bool $raw if response body contains JSON and should be decoded 339 | * @return mixed response 340 | * @throws Exception 341 | * @throws InvalidConfigException 342 | */ 343 | public function post($url, $options = [], $body = null, $raw = false) 344 | { 345 | $this->open(); 346 | return $this->httpRequest('POST', $this->createUrl($url, $options), $body, $raw); 347 | } 348 | 349 | /** 350 | * Performs PUT HTTP request 351 | * 352 | * @param string|array $url URL 353 | * @param array $options URL options 354 | * @param string $body request body 355 | * @param bool $raw if response body contains JSON and should be decoded 356 | * @return mixed response 357 | * @throws Exception 358 | * @throws InvalidConfigException 359 | */ 360 | public function put($url, $options = [], $body = null, $raw = false) 361 | { 362 | $this->open(); 363 | return $this->httpRequest('PUT', $this->createUrl($url, $options), $body, $raw); 364 | } 365 | 366 | /** 367 | * Performs DELETE HTTP request 368 | * 369 | * @param string|array $url URL 370 | * @param array $options URL options 371 | * @param string $body request body 372 | * @param bool $raw if response body contains JSON and should be decoded 373 | * @return mixed response 374 | * @throws Exception 375 | * @throws InvalidConfigException 376 | */ 377 | public function delete($url, $options = [], $body = null, $raw = false) 378 | { 379 | $this->open(); 380 | return $this->httpRequest('DELETE', $this->createUrl($url, $options), $body, $raw); 381 | } 382 | 383 | /** 384 | * Creates URL 385 | * 386 | * @param string|array $path path 387 | * @param array $options URL options 388 | * @return array 389 | */ 390 | private function createUrl($path, $options = []) 391 | { 392 | if (!is_string($path)) { 393 | $url = implode('/', array_map(function ($a) { 394 | return urlencode(is_array($a) ? implode(',', $a) : $a); 395 | }, $path)); 396 | if (!empty($options)) { 397 | $url .= '?' . http_build_query($options); 398 | } 399 | } else { 400 | $url = $path; 401 | if (!empty($options)) { 402 | $url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($options); 403 | } 404 | } 405 | 406 | $node = $this->nodes[$this->activeNode]; 407 | $protocol = isset($node['protocol']) ? $node['protocol'] : $this->defaultProtocol; 408 | $host = $node['http_address']; 409 | 410 | return [$protocol, $host, $url]; 411 | } 412 | 413 | /** 414 | * Performs HTTP request 415 | * 416 | * @param string $method method name 417 | * @param string $url URL 418 | * @param string $requestBody request body 419 | * @param bool $raw if response body contains JSON and should be decoded 420 | * @return mixed if request failed 421 | * @throws Exception if request failed 422 | * @throws InvalidConfigException 423 | */ 424 | protected function httpRequest($method, $url, $requestBody = null, $raw = false) 425 | { 426 | $method = strtoupper($method); 427 | 428 | // response body and headers 429 | $headers = []; 430 | $headersFinished = false; 431 | $body = ''; 432 | 433 | $options = [ 434 | CURLOPT_USERAGENT => 'Yii Framework ' . Yii::getVersion() . ' ' . __CLASS__, 435 | CURLOPT_RETURNTRANSFER => false, 436 | CURLOPT_HEADER => false, 437 | // https://www.php.net/manual/en/function.curl-setopt.php#82418 438 | CURLOPT_HTTPHEADER => [ 439 | 'Expect:', 440 | 'Content-Type: application/json', 441 | ], 442 | 443 | CURLOPT_WRITEFUNCTION => function ($curl, $data) use (&$body) { 444 | $body .= $data; 445 | return mb_strlen($data, '8bit'); 446 | }, 447 | CURLOPT_HEADERFUNCTION => function ($curl, $data) use (&$headers, &$headersFinished) { 448 | if ($data === '') { 449 | $headersFinished = true; 450 | } elseif ($headersFinished) { 451 | $headersFinished = false; 452 | } 453 | if (!$headersFinished && ($pos = strpos($data, ':')) !== false) { 454 | $headers[strtolower(substr($data, 0, $pos))] = trim(substr($data, $pos + 1)); 455 | } 456 | return mb_strlen($data, '8bit'); 457 | }, 458 | CURLOPT_CUSTOMREQUEST => $method, 459 | CURLOPT_FORBID_REUSE => false, 460 | ]; 461 | 462 | foreach ($this->curlOptions as $key => $value) { 463 | $options[$key] = $value; 464 | } 465 | 466 | if (!empty($this->auth) || isset($this->nodes[$this->activeNode]['auth']) && $this->nodes[$this->activeNode]['auth'] !== false) { 467 | $auth = isset($this->nodes[$this->activeNode]['auth']) ? $this->nodes[$this->activeNode]['auth'] : $this->auth; 468 | if (empty($auth['username'])) { 469 | throw new InvalidConfigException('Username is required to use authentication'); 470 | } 471 | if (empty($auth['password'])) { 472 | throw new InvalidConfigException('Password is required to use authentication'); 473 | } 474 | 475 | $options[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC; 476 | $options[CURLOPT_USERPWD] = $auth['username'] . ':' . $auth['password']; 477 | } 478 | 479 | if ($this->connectionTimeout !== null) { 480 | $options[CURLOPT_CONNECTTIMEOUT] = $this->connectionTimeout; 481 | } 482 | if ($this->dataTimeout !== null) { 483 | $options[CURLOPT_TIMEOUT] = $this->dataTimeout; 484 | } 485 | if ($requestBody !== null) { 486 | $options[CURLOPT_POSTFIELDS] = $requestBody; 487 | } 488 | if ($method == 'HEAD') { 489 | $options[CURLOPT_NOBODY] = true; 490 | unset($options[CURLOPT_WRITEFUNCTION]); 491 | } else { 492 | $options[CURLOPT_NOBODY] = false; 493 | } 494 | 495 | if (is_array($url)) { 496 | list($protocol, $host, $q) = $url; 497 | if (strncmp($host, 'inet[', 5) == 0) { 498 | $host = substr($host, 5, -1); 499 | if (($pos = strpos($host, '/')) !== false) { 500 | $host = substr($host, $pos + 1); 501 | } 502 | } 503 | $profile = "$method $q#$requestBody"; 504 | $url = "$protocol://$host/$q"; 505 | } else { 506 | $profile = false; 507 | } 508 | 509 | Yii::trace("Sending request to Elasticsearch node: $method $url\n$requestBody", __METHOD__); 510 | if ($profile !== false) { 511 | Yii::beginProfile($profile, __METHOD__); 512 | } 513 | 514 | $this->resetCurlHandle(); 515 | curl_setopt($this->_curl, CURLOPT_URL, $url); 516 | curl_setopt_array($this->_curl, $options); 517 | if (curl_exec($this->_curl) === false) { 518 | throw new Exception('Elasticsearch request failed: ' . curl_errno($this->_curl) . ' - ' . curl_error($this->_curl), [ 519 | 'requestMethod' => $method, 520 | 'requestUrl' => $url, 521 | 'requestBody' => $requestBody, 522 | 'responseHeaders' => $headers, 523 | 'responseBody' => $this->decodeErrorBody($body), 524 | ]); 525 | } 526 | 527 | $responseCode = curl_getinfo($this->_curl, CURLINFO_HTTP_CODE); 528 | 529 | if ($profile !== false) { 530 | Yii::endProfile($profile, __METHOD__); 531 | } 532 | 533 | if ($responseCode >= 200 && $responseCode < 300) { 534 | if ($method === 'HEAD') { 535 | return true; 536 | } else { 537 | if (isset($headers['content-length']) && ($len = mb_strlen($body, '8bit')) < $headers['content-length']) { 538 | throw new Exception("Incomplete data received from Elasticsearch: $len < {$headers['content-length']}", [ 539 | 'requestMethod' => $method, 540 | 'requestUrl' => $url, 541 | 'requestBody' => $requestBody, 542 | 'responseCode' => $responseCode, 543 | 'responseHeaders' => $headers, 544 | 'responseBody' => $body, 545 | ]); 546 | } 547 | if (isset($headers['content-type'])) { 548 | if (!strncmp($headers['content-type'], 'application/json', 16)) { 549 | return $raw ? $body : Json::decode($body); 550 | } 551 | if (!strncmp($headers['content-type'], 'text/plain', 10)) { 552 | return $raw ? $body : array_filter(explode("\n", $body)); 553 | } 554 | } 555 | throw new Exception('Unsupported data received from Elasticsearch: ' . $headers['content-type'], [ 556 | 'requestMethod' => $method, 557 | 'requestUrl' => $url, 558 | 'requestBody' => $requestBody, 559 | 'responseCode' => $responseCode, 560 | 'responseHeaders' => $headers, 561 | 'responseBody' => $this->decodeErrorBody($body), 562 | ]); 563 | } 564 | } elseif ($responseCode == 404) { 565 | return false; 566 | } else { 567 | throw new Exception("Elasticsearch request failed with code $responseCode. Response body:\n{$body}", [ 568 | 'requestMethod' => $method, 569 | 'requestUrl' => $url, 570 | 'requestBody' => $requestBody, 571 | 'responseCode' => $responseCode, 572 | 'responseHeaders' => $headers, 573 | 'responseBody' => $this->decodeErrorBody($body), 574 | ]); 575 | } 576 | } 577 | 578 | private function resetCurlHandle() 579 | { 580 | // these functions do not get reset by curl automatically 581 | static $unsetValues = [ 582 | CURLOPT_HEADERFUNCTION => null, 583 | CURLOPT_WRITEFUNCTION => null, 584 | CURLOPT_READFUNCTION => null, 585 | CURLOPT_PROGRESSFUNCTION => null, 586 | CURLOPT_POSTFIELDS => null, 587 | ]; 588 | curl_setopt_array($this->_curl, $unsetValues); 589 | if (function_exists('curl_reset')) { // since PHP 5.5.0 590 | curl_reset($this->_curl); 591 | } 592 | } 593 | 594 | /** 595 | * Try to decode error information if it is valid json, return it if not. 596 | * @param $body 597 | * @return mixed 598 | */ 599 | protected function decodeErrorBody($body) 600 | { 601 | try { 602 | $decoded = Json::decode($body); 603 | if (isset($decoded['error']) && !is_array($decoded['error'])) { 604 | $decoded['error'] = preg_replace('/\b\w+?Exception\[/', "\\0\n ", $decoded['error']); 605 | } 606 | return $decoded; 607 | } catch(InvalidArgumentException $e) { 608 | return $body; 609 | } 610 | } 611 | 612 | public function getNodeInfo() 613 | { 614 | return $this->get([]); 615 | } 616 | 617 | public function getClusterState() 618 | { 619 | return $this->get(['_cluster', 'state']); 620 | } 621 | } 622 | -------------------------------------------------------------------------------- /src/DebugAction.php: -------------------------------------------------------------------------------- 1 | 21 | * @since 2.0 22 | */ 23 | class DebugAction extends Action 24 | { 25 | /** 26 | * @var string the connection id to use 27 | */ 28 | public $db; 29 | /** 30 | * @var DebugPanel 31 | */ 32 | public $panel; 33 | /** 34 | * @var \yii\debug\controllers\DefaultController 35 | */ 36 | public $controller; 37 | 38 | 39 | public function run($logId, $tag) 40 | { 41 | $this->controller->loadData($tag); 42 | 43 | $timings = $this->panel->calculateTimings(); 44 | ArrayHelper::multisort($timings, 3, SORT_DESC); 45 | if (!isset($timings[$logId])) { 46 | throw new HttpException(404, 'Log message not found.'); 47 | } 48 | $message = $timings[$logId][1]; 49 | if (($pos = mb_strpos($message, "#")) !== false) { 50 | $url = mb_substr($message, 0, $pos); 51 | $body = mb_substr($message, $pos + 1); 52 | } else { 53 | $url = $message; 54 | $body = null; 55 | } 56 | $method = mb_substr($url, 0, $pos = mb_strpos($url, ' ')); 57 | $url = mb_substr($url, $pos + 1); 58 | 59 | $options = ['pretty' => 'true']; 60 | 61 | /* @var $db Connection */ 62 | $db = \Yii::$app->get($this->db); 63 | $time = microtime(true); 64 | switch ($method) { 65 | case 'GET': $result = $db->get($url, $options, $body, true); break; 66 | case 'POST': $result = $db->post($url, $options, $body, true); break; 67 | case 'PUT': $result = $db->put($url, $options, $body, true); break; 68 | case 'DELETE': $result = $db->delete($url, $options, $body, true); break; 69 | case 'HEAD': $result = $db->head($url, $options, $body); break; 70 | default: 71 | throw new NotSupportedException("Request method '$method' is not supported by Elasticsearch."); 72 | } 73 | $time = microtime(true) - $time; 74 | 75 | if ($result === true) { 76 | $result = 'success'; 77 | } elseif ($result === false) { 78 | $result = 'no success'; 79 | } 80 | 81 | Yii::$app->response->format = Response::FORMAT_JSON; 82 | 83 | return [ 84 | 'time' => sprintf('%.1f ms', $time * 1000), 85 | 'result' => $result, 86 | ]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/DebugPanel.php: -------------------------------------------------------------------------------- 1 | 22 | * @since 2.0 23 | */ 24 | class DebugPanel extends Panel 25 | { 26 | public $db = 'elasticsearch'; 27 | 28 | 29 | public function init() 30 | { 31 | $this->actions['elasticsearch-query'] = [ 32 | 'class' => 'yii\\elasticsearch\\DebugAction', 33 | 'panel' => $this, 34 | 'db' => $this->db, 35 | ]; 36 | } 37 | 38 | /** 39 | * @inheritdoc 40 | */ 41 | public function getName() 42 | { 43 | return 'Elasticsearch'; 44 | } 45 | 46 | /** 47 | * @inheritdoc 48 | */ 49 | public function getSummary() 50 | { 51 | $timings = $this->calculateTimings(); 52 | $queryCount = count($timings); 53 | $queryTime = 0; 54 | foreach ($timings as $timing) { 55 | $queryTime += $timing[3]; 56 | } 57 | $queryTime = number_format($queryTime * 1000) . ' ms'; 58 | $url = $this->getUrl(); 59 | $output = << 61 | 62 | ES $queryCount $queryTime 63 | 64 | 65 | EOD; 66 | 67 | return $queryCount > 0 ? $output : ''; 68 | } 69 | 70 | /** 71 | * @inheritdoc 72 | */ 73 | public function getDetail() 74 | { 75 | //Register YiiAsset in order to inject csrf token in ajax requests 76 | YiiAsset::register(\Yii::$app->view); 77 | 78 | $timings = $this->calculateTimings(); 79 | ArrayHelper::multisort($timings, 3, SORT_DESC); 80 | $rows = []; 81 | $i = 0; 82 | foreach ($timings as $logId => $timing) { 83 | $duration = sprintf('%.1f ms', $timing[3] * 1000); 84 | $message = $timing[1]; 85 | $traces = $timing[4]; 86 | if (($pos = mb_strpos($message, "#")) !== false) { 87 | $url = mb_substr($message, 0, $pos); 88 | $body = mb_substr($message, $pos + 1); 89 | } else { 90 | $url = $message; 91 | $body = null; 92 | } 93 | $traceString = ''; 94 | if (!empty($traces)) { 95 | $traceString .= Html::ul($traces, [ 96 | 'class' => 'trace', 97 | 'item' => function ($trace) { 98 | return "
  • {$trace['file']}({$trace['line']})
  • "; 99 | }, 100 | ]); 101 | } 102 | $ajaxUrl = Url::to(['elasticsearch-query', 'logId' => $logId, 'tag' => $this->tag]); 103 | \Yii::$app->view->registerJs(<<Error: ' + errorThrown + ' - ' + textStatus + '
    ' + jqXHR.responseText); 118 | }, 119 | dataType: "json" 120 | }); 121 | 122 | return false; 123 | }); 124 | JS 125 | , View::POS_READY); 126 | $runLink = Html::a('run query', '#', ['id' => "elastic-link-$i"]) . '
    '; 127 | $rows[] = << 129 | $duration 130 |
    $url

    $body

    $traceString
    131 | $runLink 132 | 133 | 134 | HTML; 135 | $i++; 136 | } 137 | $rows = implode("\n", $rows); 138 | 139 | return <<Elasticsearch Queries 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | $rows 152 | 153 |
    TimeUrl / QueryRun Query on node
    154 | HTML; 155 | } 156 | 157 | private $_timings; 158 | 159 | public function calculateTimings() 160 | { 161 | if ($this->_timings !== null) { 162 | return $this->_timings; 163 | } 164 | $messages = isset($this->data['messages']) ? $this->data['messages'] : []; 165 | $timings = []; 166 | $stack = []; 167 | foreach ($messages as $i => $log) { 168 | list($token, $level, $category, $timestamp) = $log; 169 | $log[5] = $i; 170 | if ($level == Logger::LEVEL_PROFILE_BEGIN) { 171 | $stack[] = $log; 172 | } elseif ($level == Logger::LEVEL_PROFILE_END) { 173 | if (($last = array_pop($stack)) !== null && $last[0] === $token) { 174 | $timings[$last[5]] = [count($stack), $token, $last[3], $timestamp - $last[3], $last[4]]; 175 | } 176 | } 177 | } 178 | 179 | $now = microtime(true); 180 | while (($last = array_pop($stack)) !== null) { 181 | $delta = $now - $last[3]; 182 | $timings[$last[5]] = [count($stack), $last[0], $last[2], $delta, $last[4]]; 183 | } 184 | ksort($timings); 185 | 186 | return $this->_timings = $timings; 187 | } 188 | 189 | /** 190 | * @inheritdoc 191 | */ 192 | public function save() 193 | { 194 | $target = $this->module->logTarget; 195 | $messages = $target->filterMessages($target->messages, Logger::LEVEL_PROFILE, ['yii\elasticsearch\Connection::httpRequest']); 196 | 197 | return ['messages' => $messages]; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/ElasticsearchTarget.php: -------------------------------------------------------------------------------- 1 | 23 | * @since 2.0.5 24 | */ 25 | class ElasticsearchTarget extends Target 26 | { 27 | /** 28 | * @var string Elasticsearch index name. 29 | */ 30 | public $index = 'yii'; 31 | /** 32 | * @var string Elasticsearch type name. 33 | */ 34 | public $type = 'log'; 35 | /** 36 | * @var Connection|array|string the Elasticsearch connection object or the application component ID 37 | * of the Elasticsearch connection. 38 | */ 39 | public $db = 'elasticsearch'; 40 | /** 41 | * @var array $options URL options. 42 | */ 43 | public $options = []; 44 | /** 45 | * @var boolean If true, context will be logged as a separate message after all other messages. 46 | */ 47 | public $logContext = true; 48 | /** 49 | * @var boolean If true, context will be included in every message. 50 | * This is convenient if you log application errors and analyze them with tools like Kibana. 51 | */ 52 | public $includeContext = false; 53 | /** 54 | * @var boolean If true, context message will cached once it's been created. Makes sense to use with [[includeContext]]. 55 | */ 56 | public $cacheContext = false; 57 | 58 | /** 59 | * @var string Context message cache (can be used multiple times if context is appended to every message) 60 | */ 61 | protected $_contextMessage = null; 62 | 63 | 64 | /** 65 | * This method will initialize the [[elasticsearch]] property to make sure it refers to a valid Elasticsearch connection. 66 | * @throws InvalidConfigException if [[elasticsearch]] is invalid. 67 | */ 68 | public function init() 69 | { 70 | parent::init(); 71 | $this->db = Instance::ensure($this->db, Connection::className()); 72 | } 73 | 74 | /** 75 | * @inheritdoc 76 | */ 77 | public function export() 78 | { 79 | $messages = array_map([$this, 'prepareMessage'], $this->messages); 80 | $body = implode("\n", $messages) . "\n"; 81 | if ($this->db->dslVersion >= 7) { 82 | $this->db->post([$this->index, '_bulk'], $this->options, $body); 83 | } else { 84 | $this->db->post([$this->index, $this->type, '_bulk'], $this->options, $body); 85 | } 86 | } 87 | 88 | /** 89 | * If [[includeContext]] property is false, returns context message normally. 90 | * If [[includeContext]] is true, returns an empty string (so that context message in [[collect]] is not generated), 91 | * expecting that context will be appended to every message in [[prepareMessage]]. 92 | * @return array the context information 93 | */ 94 | protected function getContextMessage() 95 | { 96 | if (null === $this->_contextMessage || !$this->cacheContext) { 97 | $this->_contextMessage = ArrayHelper::filter($GLOBALS, $this->logVars); 98 | } 99 | 100 | return $this->_contextMessage; 101 | } 102 | 103 | /** 104 | * Processes the given log messages. 105 | * This method will filter the given messages with [[levels]] and [[categories]]. 106 | * And if requested, it will also export the filtering result to specific medium (e.g. email). 107 | * Depending on the [[includeContext]] attribute, a context message will be either created or ignored. 108 | * @param array $messages log messages to be processed. See [[Logger::messages]] for the structure 109 | * of each message. 110 | * @param bool $final whether this method is called at the end of the current application 111 | */ 112 | public function collect($messages, $final) 113 | { 114 | $this->messages = array_merge($this->messages, static::filterMessages($messages, $this->getLevels(), $this->categories, $this->except)); 115 | $count = count($this->messages); 116 | if ($count > 0 && ($final || $this->exportInterval > 0 && $count >= $this->exportInterval)) { 117 | if (!$this->includeContext && $this->logContext) { 118 | $context = $this->getContextMessage(); 119 | if (!empty($context)) { 120 | $this->messages[] = [$context, Logger::LEVEL_INFO, 'application', YII_BEGIN_TIME]; 121 | } 122 | } 123 | 124 | // set exportInterval to 0 to avoid triggering export again while exporting 125 | $oldExportInterval = $this->exportInterval; 126 | $this->exportInterval = 0; 127 | $this->export(); 128 | $this->exportInterval = $oldExportInterval; 129 | 130 | $this->messages = []; 131 | } 132 | } 133 | 134 | /** 135 | * Prepares a log message. 136 | * @param array $message The log message to be formatted. 137 | * @return string 138 | */ 139 | public function prepareMessage($message) 140 | { 141 | list($text, $level, $category, $timestamp) = $message; 142 | 143 | $result = [ 144 | 'category' => $category, 145 | 'level' => Logger::getLevelName($level), 146 | '@timestamp' => date('c', $timestamp), 147 | ]; 148 | 149 | if (isset($message[4])) { 150 | $result['trace'] = $message[4]; 151 | } 152 | 153 | if (!is_string($text)) { 154 | // exceptions may not be serializable if in the call stack somewhere is a Closure 155 | if ($text instanceof \Throwable || $text instanceof \Exception) { 156 | $text = (string) $text; 157 | } else { 158 | $text = VarDumper::export($text); 159 | } 160 | } 161 | $result['message'] = $text; 162 | 163 | if ($this->includeContext) { 164 | $result['context'] = $this->getContextMessage(); 165 | } 166 | 167 | $message = implode("\n", [ 168 | Json::encode([ 169 | 'index' => new \stdClass() 170 | ]), 171 | Json::encode($result) 172 | ]); 173 | 174 | return $message; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | 14 | * @since 2.0 15 | */ 16 | class Exception extends \yii\db\Exception 17 | { 18 | /** 19 | * @return string the user-friendly name of this exception 20 | */ 21 | public function getName() 22 | { 23 | return 'Elasticsearch Database Exception'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | storedFields('id, name') 30 | * ->from('myindex', 'users') 31 | * ->limit(10); 32 | * // build and execute the query 33 | * $command = $query->createCommand(); 34 | * $rows = $command->search(); // this way you get the raw output of Elasticsearch. 35 | * ~~~ 36 | * 37 | * You would normally call `$query->search()` instead of creating a command as 38 | * this method adds the `indexBy()` feature and also removes some 39 | * inconsistencies from the response. 40 | * 41 | * Query also provides some methods to easier get some parts of the result only: 42 | * 43 | * - [[one()]]: returns a single record populated with the first row of data. 44 | * - [[all()]]: returns all records based on the query results. 45 | * - [[count()]]: returns the number of records. 46 | * - [[scalar()]]: returns the value of the first column in the first row of the query result. 47 | * - [[column()]]: returns the value of the first column in the query result. 48 | * - [[exists()]]: returns a value indicating whether the query result has data or not. 49 | * 50 | * NOTE: Elasticsearch limits the number of records returned to 10 records by 51 | * default. If you expect to get more records you should specify limit 52 | * explicitly. 53 | * 54 | * @author Carsten Brandt 55 | * @since 2.0 56 | */ 57 | class Query extends Component implements QueryInterface 58 | { 59 | use QueryTrait; 60 | 61 | /** 62 | * @var array the fields being retrieved from the documents. For example, 63 | * `['id', 'name']`. If not set, this option will not be applied to the 64 | * query and no fields will be returned. In this case the `_source` field 65 | * will be returned by default which can be configured using [[source]]. 66 | * Setting this to an empty array will result in no fields being retrieved, 67 | * which means that only the primaryKey of a record will be available in 68 | * the result. 69 | * > Note: Field values are [always returned as arrays] even if they only 70 | * > have one value. 71 | * 72 | * [always returned as arrays]: https://www.elastic.co/guide/en/elasticsearch/reference/current/array.html 73 | * 74 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-stored-fields.html 75 | * @see storedFields() 76 | * @see source 77 | */ 78 | public $storedFields; 79 | /** 80 | * @var array the scripted fields being retrieved from the documents. 81 | * Example: 82 | * ```php 83 | * $query->scriptFields = [ 84 | * 'value_times_two' => [ 85 | * 'script' => "doc['my_field_name'].value * 2", 86 | * ], 87 | * 'value_times_factor' => [ 88 | * 'script' => "doc['my_field_name'].value * factor", 89 | * 'params' => [ 90 | * 'factor' => 2.0 91 | * ], 92 | * ], 93 | * ] 94 | * ``` 95 | * 96 | * > Note: Field values are [always returned as arrays] even if they only have one value. 97 | * 98 | * [always returned as arrays]: https://www.elastic.co/guide/en/elasticsearch/reference/current/array.html 99 | * [script field]: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-script-fields.html 100 | * 101 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-script-fields.html 102 | * @see scriptFields() 103 | * @see source 104 | */ 105 | public $scriptFields; 106 | /** 107 | * @var array An array of runtime fields evaluated at query time 108 | * Example: 109 | * ```php 110 | * $query->$runtimeMappings = [ 111 | * 'value_times_two' => [ 112 | * 'type' => 'double', 113 | * 'script' => "emit(doc['my_field_name'].value * 2)", 114 | * ], 115 | * 'value_times_factor' => [ 116 | * 'type' => 'double', 117 | * 'script' => "emit(doc['my_field_name'].value * factor)", 118 | * 'params' => [ 119 | * 'factor' => 2.0 120 | * ], 121 | * ], 122 | * ] 123 | * ``` 124 | * 125 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-mapping-fields.html 126 | * @see runtimeMappings() 127 | * @see source 128 | */ 129 | public $runtimeMappings; 130 | /** 131 | * @var array Use the fields parameter to retrieve the values of runtime fields. Runtime fields won’t display in 132 | * _source, but the fields API works for all fields, even those that were not sent as part of the original _source. 133 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-retrieving-fields.html 134 | * @see fields() 135 | * @see fields 136 | */ 137 | public $fields; 138 | /** 139 | * @var array this option controls how the `_source` field is returned from 140 | * the documents. For example, `['id', 'name']` means that only the `id` 141 | * and `name` field should be returned from `_source`. If not set, it 142 | * means retrieving the full `_source` field unless [[fields]] are 143 | * specified. Setting this option to `false` will disable return of the 144 | * `_source` field, this means that only the primaryKey of a record will be 145 | * available in the result. 146 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html 147 | * @see source() 148 | * @see fields 149 | */ 150 | public $source; 151 | /** 152 | * @var string|array The index to retrieve data from. This can be a string 153 | * representing a single index or a an array of multiple indexes. If this 154 | * is not set, indexes are being queried. 155 | * @see from() 156 | */ 157 | public $index; 158 | /** 159 | * @var string|array The type to retrieve data from. This can be a string 160 | * representing a single type or a an array of multiple types. If this is 161 | * not set, all types are being queried. 162 | * @see from() 163 | */ 164 | public $type; 165 | /** 166 | * @var integer A search timeout, bounding the search request to be 167 | * executed within the specified time value and bail with the hits 168 | * accumulated up to that point when expired. Defaults to no timeout. 169 | * @see timeout() 170 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html#global-search-timeout 171 | */ 172 | public $timeout; 173 | /** 174 | * @var array|string The query part of this search query. This is an array 175 | * or json string that follows the format of the elasticsearch 176 | * [Query DSL](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html). 177 | */ 178 | public $query; 179 | /** 180 | * @var array|string The filter part of this search query. This is an array 181 | * or json string that follows the format of the elasticsearch 182 | * [Query DSL](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html). 183 | */ 184 | public $filter; 185 | /** 186 | * @var string|array The `post_filter` part of the search query for 187 | * differentially filter search results and aggregations. 188 | * @see https://www.elastic.co/guide/en/elasticsearch/guide/current/_post_filter.html 189 | * @since 2.0.5 190 | */ 191 | public $postFilter; 192 | /** 193 | * @var array The highlight part of this search query. This is an array that allows to highlight search results 194 | * on one or more fields. 195 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-highlighting.html 196 | */ 197 | public $highlight; 198 | /** 199 | * @var array List of aggregations to add to this query. 200 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html 201 | */ 202 | public $aggregations = []; 203 | /** 204 | * @var array the 'stats' part of the query. An array of groups to maintain 205 | * a statistics aggregation for. 206 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html#stats-groups 207 | */ 208 | public $stats = []; 209 | /** 210 | * @var array list of suggesters to add to this query. 211 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html 212 | */ 213 | public $suggest = []; 214 | /** 215 | * @var array list of collapse to add to this query. 216 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html 217 | * @since 2.1.0 218 | */ 219 | public $collapse = []; 220 | /** 221 | * @var float Exclude documents which have a _score less than the minimum 222 | * specified in min_score 223 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-min-score.html 224 | * @since 2.0.4 225 | */ 226 | public $minScore; 227 | /** 228 | * @var array list of options that will passed to commands created by this query. 229 | * @see Command::$options 230 | * @since 2.0.4 231 | */ 232 | public $options = []; 233 | /** 234 | * @var bool Enables explanation for each hit on how its score was computed. 235 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-explain.html 236 | * @since 2.0.5 237 | */ 238 | public $explain; 239 | /** 240 | * @var array Allows pagination of large datasets. 241 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#search-after 242 | * @since 2.1.5 243 | */ 244 | public $search_after = []; 245 | 246 | 247 | /** 248 | * @inheritdoc 249 | */ 250 | public function init() 251 | { 252 | parent::init(); 253 | // setting the default limit according to Elasticsearch defaults 254 | // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html#_parameters_5 255 | if ($this->limit === null) { 256 | $this->limit = 10; 257 | } 258 | } 259 | 260 | /** 261 | * Creates a DB command that can be used to execute this query. 262 | * @param Connection $db the database connection used to execute the query. 263 | * If this parameter is not given, the `elasticsearch` application 264 | * component will be used. 265 | * @return Command the created DB command instance. 266 | */ 267 | public function createCommand($db = null) 268 | { 269 | if ($db === null) { 270 | $db = Yii::$app->get('elasticsearch'); 271 | } 272 | $commandConfig = $db->getQueryBuilder()->build($this); 273 | return $db->createCommand($commandConfig); 274 | } 275 | 276 | /** 277 | * Executes the query and returns all results as an array. 278 | * @param Connection $db the database connection used to execute the query. 279 | * If this parameter is not given, the `elasticsearch` application component will be used. 280 | * @return array the query results. If the query results in nothing, an empty array will be returned. 281 | */ 282 | public function all($db = null) 283 | { 284 | if ($this->emulateExecution) { 285 | return []; 286 | } 287 | $result = $this->createCommand($db)->search(); 288 | if ($result === false) { 289 | throw new Exception('Elasticsearch search query failed.'); 290 | } 291 | if (empty($result['hits']['hits'])) { 292 | return []; 293 | } 294 | $rows = $result['hits']['hits']; 295 | return $this->populate($rows); 296 | } 297 | 298 | /** 299 | * Converts the raw query results into the format as specified by this 300 | * query. This method is internally used to convert the data fetched from 301 | * database into the format as required by this query. 302 | * @param array $rows the raw query result from database 303 | * @return array the converted query result 304 | * @since 2.0.4 305 | */ 306 | public function populate($rows) 307 | { 308 | if ($this->indexBy === null) { 309 | return $rows; 310 | } 311 | $models = []; 312 | foreach ($rows as $key => $row) { 313 | if ($this->indexBy !== null) { 314 | if (is_string($this->indexBy)) { 315 | $key = isset($row['fields'][$this->indexBy]) ? 316 | reset($row['fields'][$this->indexBy]) : $row['_source'][$this->indexBy]; 317 | } else { 318 | $key = call_user_func($this->indexBy, $row); 319 | } 320 | } 321 | $models[$key] = $row; 322 | } 323 | return $models; 324 | } 325 | 326 | /** 327 | * Executes the query and returns a single row of result. 328 | * @param Connection $db the database connection used to execute the query. 329 | * If this parameter is not given, the `elasticsearch` application 330 | * component will be used. 331 | * @return array|bool the first row (in terms of an array) of the query 332 | * result. False is returned if the query results in nothing. 333 | */ 334 | public function one($db = null) 335 | { 336 | if ($this->emulateExecution) { 337 | return false; 338 | } 339 | $result = $this->createCommand($db)->search(['size' => 1]); 340 | if ($result === false) { 341 | throw new Exception('Elasticsearch search query failed.'); 342 | } 343 | if (empty($result['hits']['hits'])) { 344 | return false; 345 | } 346 | $record = reset($result['hits']['hits']); 347 | 348 | return $record; 349 | } 350 | 351 | /** 352 | * Executes the query and returns the complete search result including e.g. 353 | * hits, aggregations, suggesters, totalCount. 354 | * @param Connection $db the database connection used to execute the query. 355 | * If this parameter is not given, the `elasticsearch` application 356 | * component will be used. 357 | * @param array $options The options given with this query. Possible 358 | * options are: 359 | * 360 | * - [routing](https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html#search-routing) 361 | * 362 | * @return array the query results. 363 | */ 364 | public function search($db = null, $options = []) 365 | { 366 | if ($this->emulateExecution) { 367 | return [ 368 | 'hits' => [ 369 | 'total' => 0, 370 | 'hits' => [], 371 | ], 372 | ]; 373 | } 374 | $result = $this->createCommand($db)->search($options); 375 | if ($result === false) { 376 | throw new Exception('Elasticsearch search query failed.'); 377 | } 378 | if (!empty($result['hits']['hits']) && $this->indexBy !== null) { 379 | $rows = []; 380 | foreach ($result['hits']['hits'] as $key => $row) { 381 | if (is_string($this->indexBy)) { 382 | $key = isset($row['fields'][$this->indexBy]) ? 383 | $row['fields'][$this->indexBy] : $row['_source'][$this->indexBy]; 384 | } else { 385 | $key = call_user_func($this->indexBy, $row); 386 | } 387 | $rows[$key] = $row; 388 | } 389 | $result['hits']['hits'] = $rows; 390 | } 391 | return $result; 392 | } 393 | 394 | /** 395 | * Executes the query and deletes all matching documents. 396 | * 397 | * Everything except query and filter will be ignored. 398 | * 399 | * @param Connection $db the database connection used to execute the query. 400 | * If this parameter is not given, the `elasticsearch` application 401 | * component will be used. 402 | * @param array $options The options given with this query. 403 | * @return array the query results. 404 | */ 405 | public function delete($db = null, $options = []) 406 | { 407 | if ($this->emulateExecution) { 408 | return []; 409 | } 410 | return $this->createCommand($db)->deleteByQuery($options); 411 | } 412 | 413 | /** 414 | * Returns the query result as a scalar value. The value returned will be 415 | * the specified field in the first document of the query results. 416 | * @param string $field name of the attribute to select 417 | * @param Connection $db the database connection used to execute the query. 418 | * If this parameter is not given, the `elasticsearch` application 419 | * component will be used. 420 | * @return string the value of the specified attribute in the first record 421 | * of the query result. Null is returned if the query result is empty or 422 | * the field does not exist. 423 | */ 424 | public function scalar($field, $db = null) 425 | { 426 | if ($this->emulateExecution) { 427 | return null; 428 | } 429 | $record = self::one($db); 430 | if ($record !== false) { 431 | if ($field === '_id') { 432 | return $record['_id']; 433 | } elseif (isset($record['_source'][$field])) { 434 | return $record['_source'][$field]; 435 | } elseif (isset($record['fields'][$field])) { 436 | return count($record['fields'][$field]) == 1 ? reset($record['fields'][$field]) : $record['fields'][$field]; 437 | } 438 | } 439 | return null; 440 | } 441 | 442 | /** 443 | * Executes the query and returns the first column of the result. 444 | * @param string $field the field to query over 445 | * @param Connection $db the database connection used to execute the query. 446 | * If this parameter is not given, the `elasticsearch` application 447 | * component will be used. 448 | * @return array the first column of the query result. An empty array is 449 | * returned if the query results in nothing. 450 | */ 451 | public function column($field, $db = null) 452 | { 453 | if ($this->emulateExecution) { 454 | return []; 455 | } 456 | $command = $this->createCommand($db); 457 | $command->queryParts['_source'] = [$field]; 458 | $result = $command->search(); 459 | if ($result === false) { 460 | throw new Exception('Elasticsearch search query failed.'); 461 | } 462 | if (empty($result['hits']['hits'])) { 463 | return []; 464 | } 465 | $column = []; 466 | foreach ($result['hits']['hits'] as $row) { 467 | if (isset($row['fields'][$field])) { 468 | $column[] = $row['fields'][$field]; 469 | } elseif (isset($row['_source'][$field])) { 470 | $column[] = $row['_source'][$field]; 471 | } else { 472 | $column[] = null; 473 | } 474 | } 475 | return $column; 476 | } 477 | 478 | /** 479 | * Returns the number of records. 480 | * @param string $q the COUNT expression. This parameter is ignored by this implementation. 481 | * @param Connection $db the database connection used to execute the query. 482 | * If this parameter is not given, the `elasticsearch` application 483 | * component will be used. 484 | * @return int number of records 485 | */ 486 | public function count($q = '*', $db = null) 487 | { 488 | if ($this->emulateExecution) { 489 | return 0; 490 | } 491 | 492 | $command = $this->createCommand($db); 493 | 494 | // performing a query with return size of 0, is equal to getting result stats such as count 495 | // https://www.elastic.co/guide/en/elasticsearch/reference/5.6/breaking_50_search_changes.html#_literal_search_type_literal 496 | $searchOptions = ['size' => 0]; 497 | 498 | // Set track_total_hits to 'true' for ElasticSearch version 6 and up 499 | // https://www.elastic.co/guide/en/elasticsearch/reference/master/search-your-data.html#track-total-hits 500 | if ($command->db->dslVersion >= 6) { 501 | $searchOptions['track_total_hits'] = 'true'; 502 | } 503 | 504 | $result = $command->search($searchOptions); 505 | 506 | // since ES7 totals are returned as array (with count and precision values) 507 | if (isset($result['hits']['total'])) { 508 | return is_array($result['hits']['total']) ? (int)$result['hits']['total']['value'] : (int)$result['hits']['total']; 509 | } 510 | return 0; 511 | } 512 | 513 | /** 514 | * Returns a value indicating whether the query result contains any row of 515 | * data. 516 | * @param Connection $db the database connection used to execute the query. 517 | * If this parameter is not given, the `elasticsearch` application 518 | * component will be used. 519 | * @return bool whether the query result contains any row of data. 520 | */ 521 | public function exists($db = null) 522 | { 523 | return self::one($db) !== false; 524 | } 525 | 526 | /** 527 | * Adds a 'stats' part to the query. 528 | * @param array $groups an array of groups to maintain a statistics aggregation for. 529 | * @return $this the query object itself 530 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html#stats-groups 531 | */ 532 | public function stats($groups) 533 | { 534 | $this->stats = $groups; 535 | return $this; 536 | } 537 | 538 | /** 539 | * Sets a highlight parameters to retrieve from the documents. 540 | * @param array $highlight array of parameters to highlight results. 541 | * @return $this the query object itself 542 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-highlighting.html 543 | */ 544 | public function highlight($highlight) 545 | { 546 | $this->highlight = $highlight; 547 | return $this; 548 | } 549 | 550 | /** 551 | * @deprecated since 2.0.5 use addAggragate() instead 552 | * 553 | * Adds an aggregation to this query. 554 | * @param string $name the name of the aggregation 555 | * @param string $type the aggregation type. e.g. `terms`, `range`, 556 | * `histogram`, ... 557 | * @param string|array $options the configuration options for this 558 | * aggregation. Can be an array or a json string. 559 | * @return $this the query object itself 560 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html 561 | */ 562 | public function addAggregation($name, $type, $options) 563 | { 564 | return $this->addAggregate($name, [$type => $options]); 565 | } 566 | 567 | /** 568 | * @deprecated since 2.0.5 use addAggregate() instead 569 | * 570 | * Adds an aggregation to this query. 571 | * 572 | * This is an alias for [[addAggregation]]. 573 | * 574 | * @param string $name the name of the aggregation 575 | * @param string $type the aggregation type. e.g. `terms`, `range`, `histogram`... 576 | * @param string|array $options the configuration options for this 577 | * aggregation. Can be an array or a json string. 578 | * @return $this the query object itself 579 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html 580 | */ 581 | public function addAgg($name, $type, $options) 582 | { 583 | return $this->addAggregate($name, [$type => $options]); 584 | } 585 | 586 | /** 587 | * Adds an aggregation to this query. Supports nested aggregations. 588 | * @param string $name the name of the aggregation 589 | * @param string|array $options the configuration options for this 590 | * aggregation. Can be an array or a json string. 591 | * @return $this the query object itself 592 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.3/search-aggregations.html 593 | */ 594 | public function addAggregate($name, $options) 595 | { 596 | $this->aggregations[$name] = $options; 597 | return $this; 598 | } 599 | 600 | /** 601 | * Adds a suggester to this query. 602 | * @param string $name the name of the suggester 603 | * @param string|array $definition the configuration options for this 604 | * suggester. Can be an array or a json string. 605 | * @return $this the query object itself 606 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html 607 | */ 608 | public function addSuggester($name, $definition) 609 | { 610 | $this->suggest[$name] = $definition; 611 | return $this; 612 | } 613 | 614 | /** 615 | * Adds a collapse to this query. 616 | * @param array $collapse the configuration options for collapse. 617 | * @return $this the query object itself 618 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.3/search-request-collapse.html#search-request-collapse 619 | * @since 2.1.0 620 | */ 621 | public function addCollapse($collapse) 622 | { 623 | $this->collapse = $collapse; 624 | return $this; 625 | } 626 | 627 | // TODO add validate query https://www.elastic.co/guide/en/elasticsearch/reference/current/search-validate.html 628 | 629 | // TODO support multi query via static method https://www.elastic.co/guide/en/elasticsearch/reference/current/search-multi-search.html 630 | 631 | /** 632 | * Sets the query part of this search query. 633 | * @param string|array $query 634 | * @return $this the query object itself 635 | */ 636 | public function query($query) 637 | { 638 | $this->query = $query; 639 | return $this; 640 | } 641 | 642 | /** 643 | * Starts a batch query. 644 | * 645 | * A batch query supports fetching data in batches, which can keep the 646 | * memory usage under a limit. This method will return a [[BatchQueryResult]] 647 | * object which implements the [[\Iterator]] interface and can be traversed 648 | * to retrieve the data in batches. 649 | * 650 | * For example, 651 | * 652 | * ```php 653 | * $query = (new Query)->from('user'); 654 | * foreach ($query->batch() as $rows) { 655 | * // $rows is an array of 10 or fewer rows from user table 656 | * } 657 | * ``` 658 | * 659 | * Batch size is determined by the `limit` setting (note that in scan mode 660 | * batch limit is per shard). 661 | * 662 | * @param string $scrollWindow how long Elasticsearch should keep the 663 | * search context alive, in 664 | * [time units](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units) 665 | * @param Connection $db the database connection. If not set, the 666 | * `elasticsearch` application component will be used. 667 | * @return BatchQueryResult the batch query result. It implements the 668 | * [[\Iterator]] interface and can be traversed to retrieve the data in 669 | * batches. 670 | * @since 2.0.4 671 | */ 672 | public function batch($scrollWindow = '1m', $db = null) 673 | { 674 | return Yii::createObject([ 675 | 'class' => BatchQueryResult::className(), 676 | 'query' => $this, 677 | 'scrollWindow' => $scrollWindow, 678 | 'db' => $db, 679 | 'each' => false, 680 | ]); 681 | } 682 | 683 | /** 684 | * Starts a batch query and retrieves data row by row. 685 | * 686 | * This method is similar to [[batch()]] except that in each iteration of 687 | * the result, only one row of data is returned. For example, 688 | * 689 | * ```php 690 | * $query = (new Query)->from('user'); 691 | * foreach ($query->each() as $row) { 692 | * } 693 | * ``` 694 | * 695 | * @param string $scrollWindow how long Elasticsearch should keep the 696 | * search context alive, in 697 | * [time units](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units) 698 | * @param Connection $db the database connection. If not set, the 699 | * `elasticsearch` application component will be used. 700 | * @return BatchQueryResult the batch query result. It implements the 701 | * [[\Iterator]] interface and can be traversed to retrieve the data in 702 | * batches. 703 | * @since 2.0.4 704 | */ 705 | public function each($scrollWindow = '1m', $db = null) 706 | { 707 | return Yii::createObject([ 708 | 'class' => BatchQueryResult::className(), 709 | 'query' => $this, 710 | 'scrollWindow' => $scrollWindow, 711 | 'db' => $db, 712 | 'each' => true, 713 | ]); 714 | } 715 | 716 | /** 717 | * Sets the index and type to retrieve documents from. 718 | * @param string|array $index The index to retrieve data from. This can be 719 | * a string representing a single index or a an array of multiple indexes. 720 | * If this is `null` it means that all indexes are being queried. 721 | * @param string|array $type The type to retrieve data from. This can be a 722 | * string representing a single type or a an array of multiple types. If 723 | * this is `null` it means that all types are being queried. 724 | * @return $this the query object itself 725 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html#search-multi-index-type 726 | */ 727 | public function from($index, $type = null) 728 | { 729 | $this->index = $index; 730 | $this->type = $type; 731 | return $this; 732 | } 733 | 734 | /** 735 | * Sets the fields to retrieve from the documents. 736 | * 737 | * Quote from the Elasticsearch doc: 738 | * > The stored_fields parameter is about fields that are explicitly marked 739 | * > as stored in the mapping, which is off by default and generally not 740 | * > recommended. Use source filtering instead to select subsets of the 741 | * > original source document to be returned. 742 | * 743 | * @param array $fields the fields to be selected. 744 | * @return $this the query object itself 745 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-stored-fields.html 746 | */ 747 | public function storedFields($fields) 748 | { 749 | if (is_array($fields) || $fields === null) { 750 | $this->storedFields = $fields; 751 | } else { 752 | $this->storedFields = func_get_args(); 753 | } 754 | return $this; 755 | } 756 | 757 | /** 758 | * Sets the script fields to retrieve from the documents. 759 | * @param array $fields the fields to be selected. 760 | * @return $this the query object itself 761 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-script-fields.html 762 | */ 763 | public function scriptFields($fields) 764 | { 765 | if (is_array($fields) || $fields === null) { 766 | $this->scriptFields = $fields; 767 | } else { 768 | $this->scriptFields = func_get_args(); 769 | } 770 | return $this; 771 | } 772 | 773 | /** 774 | * Sets the runtime mappings for this query 775 | * @param $mappings 776 | * @return $this the query object itself 777 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime.html 778 | */ 779 | public function runtimeMappings($mappings) 780 | { 781 | if (is_array($mappings) || $mappings === null) { 782 | $this->runtimeMappings = $mappings; 783 | } else { 784 | $this->runtimeMappings = func_get_args(); 785 | } 786 | return $this; 787 | } 788 | 789 | /** 790 | * Sets the runtime fields to retrieve from the documents. 791 | * @param array $fields the fields to be selected. 792 | * @return $this the query object itself 793 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-retrieving-fields.html 794 | */ 795 | public function fields($fields) 796 | { 797 | if (is_array($fields) || $fields === null) { 798 | $this->fields = $fields; 799 | } else { 800 | $this->fields = func_get_args(); 801 | } 802 | return $this; 803 | } 804 | 805 | /** 806 | * Sets the source filtering, specifying how the `_source` field of the 807 | * document should be returned. 808 | * @param array|string|null|false $source the source patterns to be selected. 809 | * @return $this the query object itself 810 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html 811 | */ 812 | public function source($source) 813 | { 814 | if (is_array($source) || $source === null || $source === false) { 815 | $this->source = $source; 816 | } else { 817 | $this->source = func_get_args(); 818 | } 819 | return $this; 820 | } 821 | 822 | /** 823 | * Sets the search timeout. 824 | * @param int $timeout A search timeout, bounding the search request to 825 | * be executed within the specified time value and bail with the hits 826 | * accumulated up to that point when expired. Defaults to no timeout. 827 | * @return $this the query object itself 828 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html#_parameters_5 829 | */ 830 | public function timeout($timeout) 831 | { 832 | $this->timeout = $timeout; 833 | return $this; 834 | } 835 | 836 | /** 837 | * @param float $minScore Exclude documents which have a `_score` less than 838 | * the minimum specified minScore 839 | * @return $this the query object itself 840 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-min-score.html 841 | * @since 2.0.4 842 | */ 843 | public function minScore($minScore) 844 | { 845 | $this->minScore = $minScore; 846 | return $this; 847 | } 848 | 849 | /** 850 | * Sets the options to be passed to the command created by this query. 851 | * @param array $options the options to be set. 852 | * @return $this the query object itself 853 | * @throws InvalidArgumentException if $options is not an array 854 | * @see Command::$options 855 | * @since 2.0.4 856 | */ 857 | public function options($options) 858 | { 859 | if (!is_array($options)) { 860 | throw new InvalidArgumentException('Array parameter expected, ' . gettype($options) . ' received.'); 861 | } 862 | 863 | $this->options = $options; 864 | return $this; 865 | } 866 | 867 | /** 868 | * Adds more options, overwriting existing options. 869 | * @param array $options the options to be added. 870 | * @return $this the query object itself 871 | * @throws InvalidArgumentException if $options is not an array 872 | * @see options() 873 | * @since 2.0.4 874 | */ 875 | public function addOptions($options) 876 | { 877 | if (!is_array($options)) { 878 | throw new InvalidArgumentException('Array parameter expected, ' . gettype($options) . ' received.'); 879 | } 880 | 881 | $this->options = array_merge($this->options, $options); 882 | return $this; 883 | } 884 | 885 | /** 886 | * @inheritdoc 887 | */ 888 | public function andWhere($condition) 889 | { 890 | if ($this->where === null) { 891 | $this->where = $condition; 892 | } else if (isset($this->where[0]) && $this->where[0] === 'and') { 893 | $this->where[] = $condition; 894 | } else { 895 | $this->where = ['and', $this->where, $condition]; 896 | } 897 | return $this; 898 | } 899 | 900 | /** 901 | * @inheritdoc 902 | */ 903 | public function orWhere($condition) 904 | { 905 | if ($this->where === null) { 906 | $this->where = $condition; 907 | } else if (isset($this->where[0]) && $this->where[0] === 'or') { 908 | $this->where[] = $condition; 909 | } else { 910 | $this->where = ['or', $this->where, $condition]; 911 | } 912 | return $this; 913 | } 914 | 915 | /** 916 | * Set the `post_filter` part of the search query. 917 | * @param string|array $filter 918 | * @return $this the query object itself 919 | * @see $postFilter 920 | * @since 2.0.5 921 | */ 922 | public function postFilter($filter) 923 | { 924 | $this->postFilter = $filter; 925 | return $this; 926 | } 927 | 928 | /** 929 | * Explain for how the score of each document was computer 930 | * @param $explain 931 | * @return $this 932 | * @see $explain 933 | * @since 2.0.5 934 | */ 935 | public function explain($explain) 936 | { 937 | $this->explain = $explain; 938 | return $this; 939 | } 940 | } 941 | -------------------------------------------------------------------------------- /src/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | 19 | * @since 2.0 20 | */ 21 | class QueryBuilder extends BaseObject 22 | { 23 | /** 24 | * @var Connection the database connection. 25 | */ 26 | public $db; 27 | 28 | 29 | /** 30 | * Constructor. 31 | * @param Connection $connection the database connection. 32 | * @param array $config name-value pairs that will be used to initialize the object properties 33 | */ 34 | public function __construct($connection, $config = []) 35 | { 36 | $this->db = $connection; 37 | parent::__construct($config); 38 | } 39 | 40 | /** 41 | * Generates query from a [[Query]] object. 42 | * @param Query $query the [[Query]] object from which the query will be generated 43 | * @return array the generated SQL statement (the first array element) and the corresponding 44 | * parameters to be bound to the SQL statement (the second array element). 45 | */ 46 | public function build($query) 47 | { 48 | $parts = []; 49 | 50 | if ($query->storedFields !== null) { 51 | $parts['stored_fields'] = $query->storedFields; 52 | } 53 | if ($query->scriptFields !== null) { 54 | $parts['script_fields'] = $query->scriptFields; 55 | } 56 | if ($query->runtimeMappings !== null) { 57 | $parts['runtime_mappings'] = $query->runtimeMappings; 58 | } 59 | if ($query->fields !== null) { 60 | $parts['fields'] = $query->fields; 61 | } 62 | 63 | if ($query->source !== null) { 64 | $parts['_source'] = $query->source; 65 | } 66 | if ($query->limit !== null && $query->limit >= 0) { 67 | $parts['size'] = $query->limit; 68 | } 69 | if ($query->offset > 0) { 70 | $parts['from'] = (int)$query->offset; 71 | } 72 | if (isset($query->minScore)) { 73 | $parts['min_score'] = (float)$query->minScore; 74 | } 75 | if (isset($query->explain)) { 76 | $parts['explain'] = $query->explain; 77 | } 78 | 79 | // combine query with where 80 | $conditionals = []; 81 | $whereQuery = $this->buildQueryFromWhere($query->where); 82 | if ($whereQuery) { 83 | $conditionals[] = $whereQuery; 84 | } 85 | if ($query->query) { 86 | $conditionals[] = $query->query; 87 | } 88 | if (count($conditionals) === 2) { 89 | $parts['query'] = ['bool' => ['must' => $conditionals]]; 90 | } elseif (count($conditionals) === 1) { 91 | $parts['query'] = reset($conditionals); 92 | } 93 | 94 | if (!empty($query->highlight)) { 95 | $parts['highlight'] = $query->highlight; 96 | } 97 | if (!empty($query->aggregations)) { 98 | $parts['aggregations'] = $query->aggregations; 99 | } 100 | if (!empty($query->stats)) { 101 | $parts['stats'] = $query->stats; 102 | } 103 | if (!empty($query->suggest)) { 104 | $parts['suggest'] = $query->suggest; 105 | } 106 | if (!empty($query->postFilter)) { 107 | $parts['post_filter'] = $query->postFilter; 108 | } 109 | if (!empty($query->collapse)) { 110 | $parts['collapse'] = $query->collapse; 111 | } 112 | if ($query->search_after) { 113 | $parts['search_after'] = $query->search_after; 114 | } 115 | 116 | $sort = $this->buildOrderBy($query->orderBy); 117 | if (!empty($sort)) { 118 | $parts['sort'] = $sort; 119 | } 120 | 121 | $options = $query->options; 122 | if ($query->timeout !== null) { 123 | $options['timeout'] = $query->timeout; 124 | } 125 | 126 | return [ 127 | 'queryParts' => $parts, 128 | 'index' => $query->index, 129 | 'type' => $query->type, 130 | 'options' => $options, 131 | ]; 132 | } 133 | 134 | /** 135 | * adds order by condition to the query 136 | */ 137 | public function buildOrderBy($columns) 138 | { 139 | if (empty($columns)) { 140 | return []; 141 | } 142 | $orders = []; 143 | foreach ($columns as $name => $direction) { 144 | if (is_string($direction)) { 145 | $column = $direction; 146 | $direction = SORT_ASC; 147 | } else { 148 | $column = $name; 149 | } 150 | if ($this->db->dslVersion < 7) { 151 | if ($column == '_id') { 152 | $column = '_uid'; 153 | } 154 | } 155 | 156 | // allow Elasticsearch extended syntax as described in https://www.elastic.co/guide/en/elasticsearch/guide/master/_sorting.html 157 | if (is_array($direction)) { 158 | $orders[] = [$column => $direction]; 159 | } else { 160 | $orders[] = [$column => ($direction === SORT_DESC ? 'desc' : 'asc')]; 161 | } 162 | } 163 | 164 | return $orders; 165 | } 166 | 167 | public function buildQueryFromWhere($condition) { 168 | $where = $this->buildCondition($condition); 169 | if ($where) { 170 | $query = [ 171 | 'constant_score' => [ 172 | 'filter' => $where, 173 | ], 174 | ]; 175 | return $query; 176 | } else { 177 | return null; 178 | } 179 | } 180 | 181 | /** 182 | * Parses the condition specification and generates the corresponding SQL expression. 183 | * 184 | * @param string|array $condition the condition specification. Please refer to [[Query::where()]] on how to specify a condition. 185 | * @throws \yii\base\InvalidArgumentException if unknown operator is used in query 186 | * @throws \yii\base\NotSupportedException if string conditions are used in where 187 | * @return string the generated SQL expression 188 | */ 189 | public function buildCondition($condition) 190 | { 191 | static $builders = [ 192 | 'not' => 'buildNotCondition', 193 | 'and' => 'buildBoolCondition', 194 | 'or' => 'buildBoolCondition', 195 | 'between' => 'buildBetweenCondition', 196 | 'not between' => 'buildBetweenCondition', 197 | 'in' => 'buildInCondition', 198 | 'not in' => 'buildInCondition', 199 | 'like' => 'buildLikeCondition', 200 | 'not like' => 'buildLikeCondition', 201 | 'or like' => 'buildLikeCondition', 202 | 'or not like' => 'buildLikeCondition', 203 | 'lt' => 'buildHalfBoundedRangeCondition', 204 | '<' => 'buildHalfBoundedRangeCondition', 205 | 'lte' => 'buildHalfBoundedRangeCondition', 206 | '<=' => 'buildHalfBoundedRangeCondition', 207 | 'gt' => 'buildHalfBoundedRangeCondition', 208 | '>' => 'buildHalfBoundedRangeCondition', 209 | 'gte' => 'buildHalfBoundedRangeCondition', 210 | '>=' => 'buildHalfBoundedRangeCondition', 211 | 'match' => 'buildMatchCondition', 212 | 'match_phrase' => 'buildMatchCondition', 213 | ]; 214 | 215 | if (empty($condition)) { 216 | return []; 217 | } 218 | if (!is_array($condition)) { 219 | throw new NotSupportedException('String conditions in where() are not supported by Elasticsearch.'); 220 | } 221 | if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ... 222 | $operator = strtolower($condition[0]); 223 | if (isset($builders[$operator])) { 224 | $method = $builders[$operator]; 225 | array_shift($condition); 226 | 227 | return $this->$method($operator, $condition); 228 | } else { 229 | throw new InvalidArgumentException('Found unknown operator in query: ' . $operator); 230 | } 231 | } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... 232 | 233 | return $this->buildHashCondition($condition); 234 | } 235 | } 236 | 237 | private function buildHashCondition($condition) 238 | { 239 | $parts = $emptyFields = []; 240 | foreach ($condition as $attribute => $value) { 241 | if ($attribute == '_id') { 242 | if ($value === null) { // there is no null pk 243 | $parts[] = ['bool' => ['must_not' => [['match_all' => new \stdClass()]]]]; // this condition is equal to WHERE false 244 | } else { 245 | $parts[] = ['ids' => ['values' => is_array($value) ? $value : [$value]]]; 246 | } 247 | } else { 248 | if (is_array($value)) { // IN condition 249 | $parts[] = ['terms' => [$attribute => $value]]; 250 | } else { 251 | if ($value === null) { 252 | $emptyFields[] = [ 'exists' => [ 'field' => $attribute ] ]; 253 | } else { 254 | $parts[] = ['term' => [$attribute => $value]]; 255 | } 256 | } 257 | } 258 | } 259 | 260 | $query = [ 'must' => $parts ]; 261 | if ($emptyFields) { 262 | $query['must_not'] = $emptyFields; 263 | } 264 | return [ 'bool' => $query ]; 265 | } 266 | 267 | private function buildNotCondition($operator, $operands) 268 | { 269 | if (count($operands) != 1) { 270 | throw new InvalidArgumentException("Operator '$operator' requires exactly one operand."); 271 | } 272 | 273 | $operand = reset($operands); 274 | if (is_array($operand)) { 275 | $operand = $this->buildCondition($operand); 276 | } 277 | 278 | return [ 279 | 'bool' => [ 280 | 'must_not' => $operand, 281 | ], 282 | ]; 283 | } 284 | 285 | private function buildBoolCondition($operator, $operands) 286 | { 287 | $parts = []; 288 | if ($operator === 'and') { 289 | $clause = 'must'; 290 | } else if ($operator === 'or') { 291 | $clause = 'should'; 292 | } else { 293 | throw new InvalidArgumentException("Operator should be 'or' or 'and'"); 294 | } 295 | 296 | foreach ($operands as $operand) { 297 | if (is_array($operand)) { 298 | $operand = $this->buildCondition($operand); 299 | } 300 | if (!empty($operand)) { 301 | $parts[] = $operand; 302 | } 303 | } 304 | if ($parts) { 305 | return [ 306 | 'bool' => [ 307 | $clause => $parts, 308 | ] 309 | ]; 310 | } else { 311 | return null; 312 | } 313 | } 314 | 315 | private function buildBetweenCondition($operator, $operands) 316 | { 317 | if (!isset($operands[0], $operands[1], $operands[2])) { 318 | throw new InvalidArgumentException("Operator '$operator' requires three operands."); 319 | } 320 | 321 | list($column, $value1, $value2) = $operands; 322 | if ($column === '_id') { 323 | throw new NotSupportedException('Between condition is not supported for the _id field.'); 324 | } 325 | $filter = ['range' => [$column => ['gte' => $value1, 'lte' => $value2]]]; 326 | if ($operator === 'not between') { 327 | $filter = ['bool' => ['must_not'=>$filter]]; 328 | } 329 | 330 | return $filter; 331 | } 332 | 333 | private function buildInCondition($operator, $operands) 334 | { 335 | if (!isset($operands[0], $operands[1]) || !is_array($operands)) { 336 | throw new InvalidArgumentException("Operator '$operator' requires array of two operands: column and values"); 337 | } 338 | 339 | list($column, $values) = $operands; 340 | 341 | $values = (array)$values; 342 | 343 | if (empty($values) || $column === []) { 344 | return $operator === 'in' ? ['bool' => ['must_not' => [['match_all' => new \stdClass()]]]] : []; // this condition is equal to WHERE false 345 | } 346 | 347 | if (is_array($column)) { 348 | if (count($column) > 1) { 349 | return $this->buildCompositeInCondition($operator, $column, $values); 350 | } 351 | $column = reset($column); 352 | } 353 | $canBeNull = false; 354 | foreach ($values as $i => $value) { 355 | if (is_array($value)) { 356 | $values[$i] = $value = isset($value[$column]) ? $value[$column] : null; 357 | } 358 | if ($value === null) { 359 | $canBeNull = true; 360 | unset($values[$i]); 361 | } 362 | } 363 | if ($column === '_id') { 364 | if (empty($values) && $canBeNull) { // there is no null pk 365 | $filter = ['bool' => ['must_not' => [['match_all' => new \stdClass()]]]]; // this condition is equal to WHERE false 366 | } else { 367 | $filter = ['ids' => ['values' => array_values($values)]]; 368 | if ($canBeNull) { 369 | $filter = [ 370 | 'bool' => [ 371 | 'should' => [ 372 | $filter, 373 | 'bool' => ['must_not' => ['exists' => ['field'=>$column]]], 374 | ], 375 | ], 376 | ]; 377 | } 378 | } 379 | } else { 380 | if (empty($values) && $canBeNull) { 381 | $filter = [ 382 | 'bool' => [ 383 | 'must_not' => [ 384 | 'exists' => [ 'field' => $column ], 385 | ] 386 | ] 387 | ]; 388 | } else { 389 | $filter = [ 'terms' => [$column => array_values($values)] ]; 390 | if ($canBeNull) { 391 | $filter = [ 392 | 'bool' => [ 393 | 'should' => [ 394 | $filter, 395 | 'bool' => ['must_not' => ['exists' => ['field'=>$column]]], 396 | ], 397 | ], 398 | ]; 399 | } 400 | } 401 | } 402 | 403 | if ($operator === 'not in') { 404 | $filter = [ 405 | 'bool' => [ 406 | 'must_not' => $filter, 407 | ], 408 | ]; 409 | } 410 | 411 | return $filter; 412 | } 413 | 414 | /** 415 | * Builds a half-bounded range condition 416 | * (for "gt", ">", "gte", ">=", "lt", "<", "lte", "<=" operators) 417 | * @param string $operator 418 | * @param array $operands 419 | * @return array Filter expression 420 | */ 421 | private function buildHalfBoundedRangeCondition($operator, $operands) 422 | { 423 | if (!isset($operands[0], $operands[1])) { 424 | throw new InvalidArgumentException("Operator '$operator' requires two operands."); 425 | } 426 | 427 | list($column, $value) = $operands; 428 | if ($this->db->dslVersion < 7) { 429 | if ($column === '_id') { 430 | $column = '_uid'; 431 | } 432 | } 433 | 434 | $range_operator = null; 435 | 436 | if (in_array($operator, ['gte', '>='])) { 437 | $range_operator = 'gte'; 438 | } elseif (in_array($operator, ['lte', '<='])) { 439 | $range_operator = 'lte'; 440 | } elseif (in_array($operator, ['gt', '>'])) { 441 | $range_operator = 'gt'; 442 | } elseif (in_array($operator, ['lt', '<'])) { 443 | $range_operator = 'lt'; 444 | } 445 | 446 | if ($range_operator === null) { 447 | throw new InvalidArgumentException("Operator '$operator' is not implemented."); 448 | } 449 | 450 | $filter = [ 451 | 'range' => [ 452 | $column => [ 453 | $range_operator => $value 454 | ] 455 | ] 456 | ]; 457 | 458 | return $filter; 459 | } 460 | 461 | protected function buildCompositeInCondition($operator, $columns, $values) 462 | { 463 | throw new NotSupportedException('composite in is not supported by Elasticsearch.'); 464 | } 465 | 466 | private function buildLikeCondition($operator, $operands) 467 | { 468 | throw new NotSupportedException('like conditions are not supported by Elasticsearch.'); 469 | } 470 | 471 | private function buildMatchCondition($operator, $operands) 472 | { 473 | return [ 474 | $operator => [ $operands[0] => $operands[1] ] 475 | ]; 476 | } 477 | } 478 | --------------------------------------------------------------------------------