diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..bb9c6d4
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,7 @@
+Contributing to Yii2
+====================
+
+- [Report an issue](https://github.com/yiisoft/yii2/blob/master/docs/internals/report-an-issue.md)
+- [Translate documentation or messages](https://github.com/yiisoft/yii2/blob/master/docs/internals/translation-workflow.md)
+- [Give us feedback or start a design discussion](https://forum.yiiframework.com/c/yii-2-0/general-discussions/16)
+- [Contribute to the core code or fix bugs](https://github.com/yiisoft/yii2/blob/master/docs/internals/git-workflow.md)
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..0664d3f
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,4 @@
+# These are supported funding model platforms
+
+open_collective: yiisoft
+tidelift: "packagist/yiisoft/yii2-elasticsearch"
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
index 7e17783..cd11ad6 100644
--- a/.github/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE.md
@@ -1,14 +1,18 @@
+
+
### What steps will reproduce the problem?
-### What is the expected result?
+### What's expected?
### What do you get instead?
-
### Additional info
| Q | A
| ---------------- | ---
-| Version | 1.0.?
-| PHP version |
+| Yii version |
+| PHP version |
| Operating system |
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 0000000..405acca
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,6 @@
+# Security Policy
+
+Please use the [security issue form](https://www.yiiframework.com/security) to report to us any security issue you find in Yii.
+DO NOT use the issue tracker or discuss it in the public forum as it will cause more damage than help.
+
+Please note that as a non-commercial OpenSource project we are not able to pay bounties at the moment.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 719d5a9..4ec1362 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -22,10 +22,68 @@ on:
name: build
jobs:
- phpunit:
- uses: yiisoft/actions/.github/workflows/phpunit.yml@master
- with:
- os: >-
- ['ubuntu-latest', 'windows-latest']
- php: >-
- ['8.1', '8.2', '8.3']
+ tests:
+ name: PHP ${{ matrix.php }} / ES ${{ matrix.es }}
+
+ env:
+ extensions: curl, mbstring, dom, intl
+
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ php:
+ - 8.1
+ - 8.2
+ - 8.3
+
+ es:
+ - 8.1.3
+ - 7.14.0
+ - 7.7.0
+ - 6.8.9
+ - 5.6.16
+
+ steps:
+ - name: Service elastisearch 8.1.3.
+ if: matrix.es == '8.1.3'
+ run: |
+ docker network create somenetwork
+ docker run -d --name elasticsearch --net somenetwork -p 9200:9200 -e "http.publish_host=127.0.0.1" -e "transport.host=127.0.0.1" -e "indices.id_field_data.enabled=true" -e "xpack.security.enabled=false" elasticsearch:${{ matrix.es }}
+
+ - name: Service elastisearch < 8.1.3.
+ if: matrix.es != '8.1.3'
+ run: |
+ docker network create somenetwork
+ docker run -d --name elasticsearch --net somenetwork -p 9200:9200 -e "http.publish_host=127.0.0.1" -e "transport.host=127.0.0.1" elasticsearch:${{ matrix.es }}
+
+ - name: Checkout.
+ uses: actions/checkout@v3
+
+ - name: Install PHP with extensions.
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: ${{ env.extensions }}
+ ini-values: date.timezone='UTC'
+
+ - name: Install dependencies with composer.
+ run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader
+
+ - name: Wait for Elasticsearch server to start.
+ run: wget --retry-connrefused --waitretry=3 --timeout=30 -t 10 -O /dev/null http://127.0.0.1:9200
+
+ - name: Run tests with phpunit.
+ if: matrix.php != '8.1'
+ run: ES_VERSION=${{ matrix.es }} vendor/bin/phpunit --colors=always
+
+ - name: Run tests with phpunit and generate coverage.
+ if: matrix.php == '8.1'
+ run: ES_VERSION=${{ matrix.es }} vendor/bin/phpunit --coverage-clover=coverage.xml --colors=always
+
+ - name: Upload coverage to Codecov.
+ if: matrix.php == '8.1'
+ uses: codecov/codecov-action@v3
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ file: ./coverage.xml
diff --git a/README.md b/README.md
index 7fc0a59..6aa1625 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-
+
-
Yii2-Template.
+ Elasticsearch Query and ActiveRecord.
@@ -13,32 +13,67 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
-
-
-
-## Requirements
+This extension provides the [Elasticsearch](https://www.elastic.co/products/elasticsearch) integration for the [Yii framework 2.0](https://www.yiiframework.com).
+It includes basic querying/search support and also implements the `ActiveRecord` pattern that allows you to store active
+records in Elasticsearch.
+
+Requirements
+------------
+
+- PHP 8.1 or higher.
+
+Depending on the version of Elasticsearch you are using you need a different version of this extension.
+
+- For Elasticsearch 1.6.0 to 1.7.6 use extension version 2.0.x
+- For Elasticsearch 5.x or above use extension version 2.1.x
+
+Installation
+------------
+
+The preferred way to install this extension is through [composer](https://getcomposer.org/download/):
+
-The minimun version of `PHP` required by this package is `PHP 8.1`.
+```
+composer require --prefer-dist yiisoft/yii2-elasticsearch:"~2.1.0"
+```
-For install this package, you need [composer](https://getcomposer.org/).
+Configuration
+-------------
-## Usage
+To use this extension, you have to configure the Connection class in your application configuration:
-[Check the documentation docs](/docs/README.md) to learn about usage.
+```php
+return [
+ //....
+ 'components' => [
+ 'elasticsearch' => [
+ 'class' => 'yii\elasticsearch\Connection',
+ 'nodes' => [
+ ['http_address' => '127.0.0.1:9200'],
+ // configure more hosts if you have a cluster
+ ],
+ 'dslVersion' => 7, // default is 5
+ ],
+ ]
+];
+```
## Testing
diff --git a/composer-require-checker.json b/composer-require-checker.json
new file mode 100644
index 0000000..0f177e7
--- /dev/null
+++ b/composer-require-checker.json
@@ -0,0 +1,7 @@
+{
+ "symbol-whitelist": [
+ "YII_BEGIN_TIME",
+ "YII_DEBUG",
+ "YII_ENV"
+ ]
+}
diff --git a/composer.json b/composer.json
index cd5155d..ca988ce 100644
--- a/composer.json
+++ b/composer.json
@@ -1,30 +1,37 @@
{
- "name": "yii2/template",
- "type": "library",
- "description": "_____",
+ "name": "yii2-extensions/elasticsearch",
+ "description": "Elasticsearch integration and ActiveRecord for the Yii framework.",
"keywords": [
- "_____"
+ "yii2",
+ "elasticsearch",
+ "active-record",
+ "search",
+ "fulltext"
],
+ "type": "yii2-extension",
"license": "mit",
"minimum-stability": "dev",
- "prefer-stable": true,
"require": {
"php": ">=8.1",
+ "ext-curl": "*",
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "yii2-extensions/debug": "dev-main",
"yiisoft/yii2": "^2.2"
},
"require-dev": {
"maglnet/composer-require-checker": "^4.6",
- "phpunit/phpunit": "^10.2",
+ "phpunit/phpunit": "^10.4",
"yii2-extensions/phpstan": "dev-main"
},
"autoload": {
"psr-4": {
- "yii\\template\\": "src"
+ "yii\\elasticsearch\\": "src"
}
},
"autoload-dev": {
"psr-4": {
- "yii\\template\\tests\\": "tests"
+ "yiiunit\\extensions\\elasticsearch\\": "tests"
}
},
"extra": {
diff --git a/constants.php b/constants.php
new file mode 100644
index 0000000..8e21bae
--- /dev/null
+++ b/constants.php
@@ -0,0 +1,5 @@
+
-
+
tests
diff --git a/src/ActiveDataProvider.php b/src/ActiveDataProvider.php
new file mode 100644
index 0000000..20b969e
--- /dev/null
+++ b/src/ActiveDataProvider.php
@@ -0,0 +1,224 @@
+
+ */
+class ActiveDataProvider extends \yii\data\ActiveDataProvider
+{
+ /**
+ * @var array|null the full query results.
+ */
+ private array|null $_queryResults = null;
+
+ /**
+ * @param array $results full query results
+ */
+ public function setQueryResults(array $results): void
+ {
+ $this->_queryResults = $results;
+ }
+
+ /**
+ * @return array|null full query results
+ */
+ public function getQueryResults(): array|null
+ {
+ if (!is_array($this->_queryResults)) {
+ $this->prepare();
+ }
+
+ return $this->_queryResults;
+ }
+
+ /**
+ * @return array all aggregations result
+ */
+ public function getAggregations(): array
+ {
+ $results = $this->getQueryResults();
+
+ return $results['aggregations'] ?? [];
+ }
+
+ /**
+ * Returns results of the specified aggregation.
+ *
+ * @param string $name aggregation name.
+ *
+ * @throws InvalidCallException if query results do not contain the requested aggregation.
+ *
+ * @return array aggregation results.
+ */
+ public function getAggregation(string $name): array
+ {
+ $aggregations = $this->getAggregations();
+
+ if (!isset($aggregations[$name])) {
+ throw new InvalidCallException("Aggregation '$name' not found.");
+ }
+
+ return $aggregations[$name];
+ }
+
+ /**
+ * @return array all suggestions result
+ */
+ public function getSuggestions(): array
+ {
+ $results = $this->getQueryResults();
+
+ return $results['suggest'] ?? [];
+ }
+
+ /**
+ * Returns results of the specified suggestion.
+ *
+ * @param string $name suggestion name.
+ *
+ * @throws InvalidCallException if query results do not contain the requested suggestion.
+ *
+ * @return array suggestion results.
+ */
+ public function getSuggestion(string $name): array
+ {
+ $suggestions = $this->getSuggestions();
+
+ if (!isset($suggestions[$name])) {
+ throw new InvalidCallException("Suggestion '$name' not found.");
+ }
+
+ return $suggestions[$name];
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ */
+ protected function prepareModels()
+ {
+ if (!$this->query instanceof Query) {
+ throw new InvalidConfigException('The "query" property must be an instance "' . Query::class . '" or its subclasses.');
+ }
+
+ $query = clone $this->query;
+
+ if (($pagination = $this->getPagination()) !== false) {
+ // pagination fails to validate page number because the total count is unknown at this stage
+ $pagination->validatePage = false;
+ $query->limit($pagination->getLimit())->offset($pagination->getOffset());
+ }
+
+ if (($sort = $this->getSort()) !== false) {
+ $query->addOrderBy($sort->getOrders());
+ }
+
+ if (is_array($results = $query->search($this->db))) {
+ $this->setQueryResults($results);
+ if ($pagination !== false) {
+ $pagination->totalCount = $this->getTotalCount();
+ }
+ return $results['hits']['hits'];
+ }
+
+ $this->setQueryResults([]);
+
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @throws InvalidConfigException
+ */
+ protected function prepareTotalCount(): int
+ {
+ if (!$this->query instanceof Query) {
+ throw new InvalidConfigException(
+ 'The "query" property must be an instance "' . Query::class . '" or its subclasses.'
+ );
+ }
+
+ $results = $this->getQueryResults();
+
+ if (isset($results['hits']['total'])) {
+ return is_array($results['hits']['total'])
+ ? (int) $results['hits']['total']['value']
+ : (int) $results['hits']['total'];
+ }
+
+ return 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function prepareKeys($models): array
+ {
+ $keys = [];
+
+ if ($this->key !== null) {
+ foreach ($models as $model) {
+ if (is_string($this->key)) {
+ $keys[] = $model[$this->key];
+ } else {
+ $keys[] = ($this->key)($model);
+ }
+ }
+
+ return $keys;
+ }
+
+ if ($this->query instanceof ActiveQueryInterface) {
+ foreach ($models as $model) {
+ $keys[] = $model->primaryKey;
+ }
+ return $keys;
+ }
+
+ return array_keys($models);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function refresh(): void
+ {
+ parent::refresh();
+
+ $this->_queryResults = null;
+ }
+}
diff --git a/src/ActiveFixture.php b/src/ActiveFixture.php
new file mode 100644
index 0000000..3b06be5
--- /dev/null
+++ b/src/ActiveFixture.php
@@ -0,0 +1,185 @@
+
+ * @author Qiang Xue
+ */
+class ActiveFixture extends BaseActiveFixture
+{
+ /**
+ * @var Connection|string the DB connection object or the application component ID of the DB connection.
+ * After the DbFixture object is created, if you want to change this property, you should only assign it
+ * with a DB connection object.
+ */
+ public $db = 'elasticsearch';
+ /**
+ * @var string|null the name of the index that this fixture is about. If this property is not set, the name will be
+ * determined via [[modelClass]].
+ *
+ * @see modelClass
+ */
+ public string|null $index = null;
+ /**
+ * @var string|null the name of the type that this fixture is about. If this property is not set, the name will be
+ * determined via [[modelClass]].
+ *
+ * @see modelClass
+ */
+ public string|null $type = null;
+ /**
+ * @var bool|string the file path or path alias of the data file that contains the fixture data to be returned by
+ * [[getData()]].
+ *
+ * If this is not set, it will default to `FixturePath/data/Index/Type.php`, where `FixturePath` stands for the
+ * directory containing this fixture class, `Index` stands for the elasticsearch [[index]] name and `Type` stands
+ * for the [[type]] associated with this fixture.
+ *
+ * You can set this property to be false to prevent loading any data.
+ */
+ public $dataFile;
+
+ /**
+ * @inheritdoc
+ *
+ * @throws InvalidConfigException
+ */
+ public function init(): void
+ {
+ parent::init();
+
+ if (!isset($this->modelClass) && (!isset($this->index, $this->type))) {
+ throw new InvalidConfigException('Either "modelClass" or "index" and "type" must be set.');
+ }
+
+ /* @var $modelClass ActiveRecord */
+ $modelClass = $this->modelClass;
+
+ if ($this->index === null) {
+ $this->index = $modelClass::index();
+ }
+ if ($this->type === null) {
+ $this->type = $modelClass::type();
+ }
+ }
+
+ /**
+ * Loads the fixture.
+ *
+ * The default implementation will first clean up the index by calling [[resetIndex()]].
+ * It will then populate the index with the data returned by [[getData()]].
+ *
+ * If you override this method, you should consider calling the parent implementation
+ * so that the data returned by [[getData()]] can be populated into the index.
+ *
+ * @throws Exception
+ */
+ public function load(): void
+ {
+ $this->resetIndex();
+ $this->data = [];
+
+ $idField = '_id';
+
+ foreach ($this->getData() as $alias => $row) {
+ $options = [];
+ $id = $row[$idField] ?? null;
+
+ unset($row[$idField]);
+
+ if (isset($row['_parent'])) {
+ $options['parent'] = $row['_parent'];
+ unset($row['_parent']);
+ }
+
+ try {
+ $response = $this->db->createCommand()->insert($this->index, $this->type, $row, $id, $options);
+ } catch(\yii\db\Exception $e) {
+ throw new Exception("Failed to insert fixture data \"$alias\": " .
+ $e->getMessage() . "\n" . print_r($e->errorInfo, true), $e->getCode(), $e);
+ }
+
+ if ($id === null) {
+ $row[$idField] = $response['_id'];
+ }
+
+ $this->data[$alias] = $row;
+ }
+
+ // ensure all data is flushed and immediately available in the test
+ $this->db->createCommand()->refreshIndex($this->index);
+ }
+
+ /**
+ * Returns the fixture data.
+ *
+ * The default implementation will try to return the fixture data by including the external file specified by [[dataFile]].
+ * The file should return an array of data rows (column name => column value), each corresponding to a row in the index.
+ *
+ * If the data file does not exist, an empty array will be returned.
+ *
+ * @throws InvalidConfigException
+ *
+ * @return array the data rows to be inserted into the database index.
+ */
+ protected function getData(): array
+ {
+ if ($this->dataFile === null) {
+ $class = new ReflectionClass($this);
+ $dataFile = dirname($class->getFileName()) . "/data/$this->index/$this->type.php";
+
+ return is_file($dataFile) ? require($dataFile) : [];
+ }
+
+ return parent::getData();
+ }
+
+ /**
+ * Removes all existing data from the specified index and type.
+ * This method is called before populating fixture data into the index associated with this fixture.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ */
+ protected function resetIndex(): void
+ {
+ $this->db->createCommand(
+ [
+ 'index' => $this->index,
+ 'type' => $this->type,
+ 'queryParts' => ['query' => ['match_all' => new stdClass()]],
+ ],
+ )->deleteByQuery();
+ }
+}
diff --git a/src/ActiveQuery.php b/src/ActiveQuery.php
new file mode 100644
index 0000000..c03bf9e
--- /dev/null
+++ b/src/ActiveQuery.php
@@ -0,0 +1,374 @@
+with('orders')->asArray()->all();
+ * ```
+ * > NOTE: Elasticsearch limits the number of records returned to 10 records by default.
+ * > If you expect to get more records, you should specify the limit explicitly.
+ *
+ * Relational query
+ * ----------------
+ *
+ * In relational context ActiveQuery represents a relation between two Active Record classes.
+ *
+ * Relational ActiveQuery instances are usually created by calling [[ActiveRecord::hasOne()]] and
+ * [[ActiveRecord::hasMany()]]. An Active Record class declares a relation by defining
+ * a getter method which calls one of the above methods and returns the created ActiveQuery object.
+ *
+ * A relation is specified by [[link]] which represents the association between columns
+ * of different tables; and the multiplicity of the relation is indicated by [[multiple]].
+ *
+ * If a relation involves a junction table, it may be specified by [[via()]].
+ * This methods may only be called in a relational context. The same is true for [[inverseOf()]], which
+ * marks a relation as inverse of another relation.
+ *
+ * > Note: Elasticsearch limits the number of records returned by any query to 10 records by default.
+ * > If you expect to get more records, you should specify limit explicitly in relation definition.
+ * > This is also important for relations that use [[via()]] so that if via records are limited to 10
+ * > the relation records can also not be more than 10.
+ *
+ * > Note: Currently [[with]] is not supported in combination with [[asArray]].
+ *
+ * @author Carsten Brandt
+ */
+class ActiveQuery extends Query implements ActiveQueryInterface
+{
+ use ActiveQueryTrait;
+ use ActiveRelationTrait;
+
+ /**
+ * @event Event an event that is triggered when the query is initialized via [[init()]].
+ */
+ public const EVENT_INIT = 'init';
+
+ /**
+ * Constructor.
+ *
+ * @param string $modelClass the model class associated with this query
+ * @param array $config configurations to be applied to the newly created query object
+ */
+ public function __construct(string $modelClass, array $config = [])
+ {
+ $this->modelClass = $modelClass;
+ parent::__construct($config);
+ }
+
+ /**
+ * Initializes the object.
+ * This method is called at the end of the constructor. The default implementation will trigger
+ * an [[EVENT_INIT]] event. If you override this method, make sure you call the parent implementation at the end
+ * to ensure triggering of the event.
+ */
+ public function init(): void
+ {
+ parent::init();
+
+ $this->trigger(self::EVENT_INIT);
+ }
+
+ /**
+ * Creates a DB command that can be used to execute this query.
+ *
+ * @param Connection|null $db the DB connection used to create the DB command.
+ * If null, the DB connection returned by [[modelClass]] will be used.
+ *
+ * @throws Exception
+ *
+ * @return Command the created DB command instance.
+ */
+ public function createCommand(Connection $db = null): Command
+ {
+ if ($this->primaryModel !== null) {
+ // lazy loading
+ if (is_array($this->via)) {
+ // via relation
+ /* @var $viaQuery ActiveQuery */
+ [$viaName, $viaQuery] = $this->via;
+ if ($viaQuery->multiple) {
+ $viaModels = $viaQuery->all();
+ $this->primaryModel->populateRelation($viaName, $viaModels);
+ } else {
+ $model = $viaQuery->one();
+ $this->primaryModel->populateRelation($viaName, $model);
+ $viaModels = $model === null ? [] : [$model];
+ }
+ $this->filterByModels($viaModels);
+ } else {
+ $this->filterByModels([$this->primaryModel]);
+ }
+ }
+
+ /* @var $modelClass ActiveRecord */
+ $modelClass = $this->modelClass;
+ if ($db === null) {
+ $db = $modelClass::getDb();
+ }
+
+ if ($this->type === null) {
+ $this->type = $modelClass::type();
+ }
+ if ($this->index === null) {
+ $this->index = $modelClass::index();
+ $this->type = $modelClass::type();
+ }
+ $commandConfig = $db->getQueryBuilder()->build($this);
+
+ return $db->createCommand($commandConfig);
+ }
+
+ /**
+ * Executes a query and returns all results as an array.
+ *
+ * @param Connection|null $db the DB connection used to create the DB command.
+ * If null, the DB connection returned by [[modelClass]] will be used.
+ *
+ * @throws Exception
+ *
+ * @return array the query results. If the query results in nothing, an empty array will be returned.
+ */
+ public function all($db = null): array
+ {
+ return parent::all($db);
+ }
+
+ /**
+ * Converts found rows into model instances
+ *
+ * @param array $rows
+ *
+ * @return ActiveRecord[]|array
+ */
+ private function createModels($rows): array
+ {
+ $models = [];
+
+ if ($this->asArray) {
+ if ($this->indexBy === null) {
+ return $rows;
+ }
+ foreach ($rows as $row) {
+ if (is_string($this->indexBy)) {
+ $key = isset($row['fields'][$this->indexBy]) ? reset($row['fields'][$this->indexBy]) : $row['_source'][$this->indexBy];
+ } else {
+ $key = ($this->indexBy)($row);
+ }
+ $models[$key] = $row;
+ }
+ } else {
+ /* @var $class ActiveRecord */
+ $class = $this->modelClass;
+
+ if ($this->indexBy === null) {
+ foreach ($rows as $row) {
+ $model = $class::instantiate($row);
+ $modelClass = get_class($model);
+ $modelClass::populateRecord($model, $row);
+ $models[] = $model;
+ }
+ } else {
+ foreach ($rows as $row) {
+ $model = $class::instantiate($row);
+ $modelClass = get_class($model);
+ $modelClass::populateRecord($model, $row);
+ if (is_string($this->indexBy)) {
+ $key = $model->{$this->indexBy};
+ } else {
+ $key = ($this->indexBy)($model);
+ }
+ $models[$key] = $model;
+ }
+ }
+ }
+
+ return $models;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function populate(array $rows): array
+ {
+ if (empty($rows)) {
+ return [];
+ }
+
+ $models = $this->createModels($rows);
+ if (!empty($this->with)) {
+ $this->findWith($this->with, $models);
+ }
+ if (!$this->asArray) {
+ foreach ($models as $model) {
+ $model->afterFind();
+ }
+ }
+
+ return $models;
+ }
+
+ /**
+ * Executes query and returns a single row of a result.
+ *
+ * @param Connection|null $db the DB connection used to create the DB command.
+ * If null, the DB connection returned by [[modelClass]] will be used.
+ *
+ * @throws Exception
+ *
+ * @return ActiveRecord|array|null a single row of a query result. Depending on the setting of [[asArray]],
+ * the query result may be either an array or an ActiveRecord object. Null will be returned
+ * if the query results in nothing.
+ */
+ public function one($db = null): ActiveRecord|array|null
+ {
+ if (($result = parent::one($db)) === false) {
+ return null;
+ }
+ if ($this->asArray) {
+ // TODO implement with()
+// /* @var $modelClass ActiveRecord */
+// $modelClass = $this->modelClass;
+// $model = $result['_source'];
+// $pk = $modelClass::primaryKey()[0];
+// if ($pk === '_id') {
+// $model['_id'] = $result['_id'];
+// }
+// $model['_score'] = $result['_score'];
+// if (!empty($this->with)) {
+// $models = [$model];
+// $this->findWith($this->with, $models);
+// $model = $models[0];
+// }
+ return $result;
+ }
+ /* @var $class ActiveRecord */
+ $class = $this->modelClass;
+ $model = $class::instantiate($result);
+ $class = get_class($model);
+ $class::populateRecord($model, $result);
+ if (!empty($this->with)) {
+ $models = [$model];
+ $this->findWith($this->with, $models);
+ $model = $models[0];
+ }
+ $model->afterFind();
+ return $model;
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ */
+ public function search(Connection $db = null, array $options = [])
+ {
+ if ($this->emulateExecution) {
+ return [
+ 'hits' => [
+ 'total' => 0,
+ 'hits' => [],
+ ],
+ ];
+ }
+
+ $command = $this->createCommand($db);
+ $result = $command->search($options);
+ if ($result === false) {
+ throw new Exception('Elasticsearch search query failed.', [
+ 'index' => $command->index,
+ 'type' => $command->type,
+ 'query' => $command->queryParts,
+ 'options' => $command->options,
+ ]);
+ }
+
+ // TODO implement with() for asArray
+ if (!empty($result['hits']['hits']) && !$this->asArray) {
+ $models = $this->createModels($result['hits']['hits']);
+ if (!empty($this->with)) {
+ $this->findWith($this->with, $models);
+ }
+ foreach ($models as $model) {
+ $model->afterFind();
+ }
+ $result['hits']['hits'] = $models;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ */
+ public function column(string $field, Connection $db = null): array
+ {
+ if ($field === '_id') {
+ $command = $this->createCommand($db);
+ $command->queryParts['_source'] = false;
+ $result = $command->search();
+ if ($result === false) {
+ throw new Exception('Elasticsearch search query failed.');
+ }
+ if (empty($result['hits']['hits'])) {
+ return [];
+ }
+ $column = [];
+ foreach ($result['hits']['hits'] as $row) {
+ $column[] = $row['_id'];
+ }
+
+ return $column;
+ }
+
+ return parent::column($field, $db);
+ }
+}
diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php
new file mode 100644
index 0000000..572aa11
--- /dev/null
+++ b/src/ActiveRecord.php
@@ -0,0 +1,1026 @@
+
+ */
+class ActiveRecord extends BaseActiveRecord
+{
+ private mixed $_id = null;
+ private float|null $_score = null;
+ private mixed $_version = null;
+ private array|null $_highlight = null;
+ private array|null $_explanation = null;
+
+ /**
+ * Returns the database connection used by this AR class.
+ * By default, the "elasticsearch" application component is used as the database connection.
+ * You may override this method if you want to use a different database connection.
+ *
+ * @throws InvalidConfigException
+ *
+ * @return Connection the database connection used by this AR class.
+ */
+ public static function getDb()
+ {
+ return Yii::$app->get('elasticsearch');
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @throws InvalidConfigException
+ *
+ * @return ActiveQuery the newly created [[ActiveQuery]] instance.
+ */
+ public static function find()
+ {
+ return Yii::createObject(ActiveQuery::className(), [static::class]);
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @throws InvalidConfigException
+ * @throws \yii\elasticsearch\Exception
+ */
+ public static function findOne($condition)
+ {
+ if (!is_array($condition)) {
+ return static::get($condition);
+ }
+
+ if (!ArrayHelper::isAssociative($condition)) {
+ $records = static::mget(array_values($condition));
+ return empty($records) ? null : reset($records);
+ }
+
+ $condition = static::filterCondition($condition);
+
+ return static::find()->andWhere($condition)->one();
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @throws InvalidConfigException
+ * @throws \yii\elasticsearch\Exception
+ */
+ public static function findAll($condition)
+ {
+ if (!ArrayHelper::isAssociative($condition)) {
+ return static::mget(is_array($condition) ? array_values($condition) : [$condition]);
+ }
+
+ $condition = static::filterCondition($condition);
+
+ return static::find()->andWhere($condition)->all();
+ }
+
+ /**
+ * Filter out condition parts that are array valued, to prevent building arbitrary conditions.
+ *
+ * @param array $condition
+ */
+ private static function filterCondition(array $condition): array
+ {
+ foreach ($condition as $k => $v) {
+ if (is_array($v)) {
+ $condition[$k] = array_values($v);
+ foreach ($v as $vv) {
+ if (is_array($vv)) {
+ throw new InvalidArgumentException(
+ 'Nested arrays are not allowed in condition for findAll() and findOne().'
+ );
+ }
+ }
+ }
+ }
+ return $condition;
+ }
+
+ /**
+ * Gets a record by its primary key.
+ *
+ * @param mixed $primaryKey the primaryKey value
+ * @param array $options options given in this parameter are passed to Elasticsearch
+ * as request URI parameters.
+ * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html)
+ * for more details on these options.
+ *
+ * @throws InvalidConfigException
+ * @throws \yii\elasticsearch\Exception
+ *
+ * @return static|null The record instance or null if it was not found.
+ */
+ public static function get(mixed $primaryKey, array $options = []): static|null
+ {
+ if ($primaryKey === null) {
+ return null;
+ }
+
+ $command = static::getDb()->createCommand();
+ $result = $command->get(static::index(), static::type(), $primaryKey, $options);
+
+ if ($result && $result['found']) {
+ $model = static::instantiate($result);
+ static::populateRecord($model, $result);
+ $model->afterFind();
+
+ return $model;
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets a list of records by its primary keys.
+ *
+ * @param array $primaryKeys an array of primaryKey values
+ * @param array $options options given in this parameter are passed to Elasticsearch
+ * as request URI parameters.
+ *
+ * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html)
+ * for more details on these options.
+ *
+ * @throws InvalidConfigException
+ * @throws \yii\elasticsearch\Exception
+ *
+ * @return array The record instances, or empty array if nothing was found
+ */
+ public static function mget(array $primaryKeys, array $options = []): array
+ {
+ if (empty($primaryKeys)) {
+ return [];
+ }
+
+ if (count($primaryKeys) === 1) {
+ $model = static::get(reset($primaryKeys));
+ return $model === null ? [] : [$model];
+ }
+
+ $command = static::getDb()->createCommand();
+ $result = $command->mget(static::index(), static::type(), $primaryKeys, $options);
+ $models = [];
+
+ foreach ($result['docs'] as $doc) {
+ if ($doc['found']) {
+ $model = static::instantiate($doc);
+ static::populateRecord($model, $doc);
+ $model->afterFind();
+ $models[] = $model;
+ }
+ }
+
+ return $models;
+ }
+
+ // TODO add more like this feature https://www.elastic.co/guide/en/elasticsearch/reference/current/search-more-like-this.html
+
+ // TODO add percolate functionality https://www.elastic.co/guide/en/elasticsearch/reference/current/search-percolate.html
+
+ // TODO implement copy and move as pk change is not possible
+
+ /**
+ * @return float|null returns the score of this record when it was retrieved via a [[find()]] query.
+ */
+ public function getScore(): float|null
+ {
+ return $this->_score;
+ }
+
+ /**
+ * @return array|null A list of arrays with highlighted excerpts indexed by field names.
+ */
+ public function getHighlight(): array|null
+ {
+ return $this->_highlight;
+ }
+
+ /**
+ * @return array|null An explanation for each hit on how its score was computed.
+ */
+ public function getExplanation(): array|null
+ {
+ return $this->_explanation;
+ }
+
+ /**
+ * Alias to [[get_id()]]. Returns the primary key value.
+ *
+ * @param bool $asArray
+ *
+ * @return mixed
+ */
+ public function getPrimaryKey($asArray = false): mixed
+ {
+ $pk = static::primaryKey()[0];
+
+ if ($asArray) {
+ return [$pk => $this->$pk];
+ }
+
+ return $this->$pk;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getOldPrimaryKey($asArray = false)
+ {
+ $pk = static::primaryKey()[0];
+
+ if ($this->getIsNewRecord()) {
+ $id = null;
+ } else {
+ $id = $this->_id;
+ }
+
+ if ($asArray) {
+ return [$pk => $id];
+ }
+
+ return $id;
+ }
+
+ /**
+ * Returns the `_id` attribute that holds the primary key (for compatibility with relations)
+ *
+ * @return mixed
+ */
+ public function get_id(): mixed
+ {
+ $pk = static::primaryKey()[0];
+
+ return $this->$pk;
+ }
+
+ /**
+ * Alias to [[set_id()]]. Sets the primary key value.
+ *
+ * @param mixed $value
+ *
+ * @throws InvalidCallException when record is not new.
+ */
+ public function set_id(mixed $value): void
+ {
+ $pk = static::primaryKey()[0];
+
+ if ($this->getIsNewRecord()) {
+ $this->$pk = $value;
+ } else {
+ throw new InvalidCallException('Changing the primaryKey of an already saved record is not allowed.');
+ }
+ }
+
+ /**
+ * This method defines the attribute that uniquely identifies a record.
+ * The name of the primary key attribute is `_id`, and can not be changed.
+ *
+ * Elasticsearch does not support composite primary keys in the traditional sense.
+ *
+ * However, to match the signature of the [[\yii\db\ActiveRecordInterface|ActiveRecordInterface]] this method
+ * returns an array instead of a single string.
+ *
+ * @return string[] array of primary key attributes. Only the first element of the array will be used.
+ */
+ final public static function primaryKey(): array
+ {
+ return ['_id'];
+ }
+
+ /**
+ * Returns the list of all attribute names of the model.
+ *
+ * This method must be overridden by child classes to define available attributes.
+ * IMPORTANT: The primary key (the `_id` attribute) MUST NOT be included in [[attributes()]].
+ *
+ * Attributes are names of fields of the corresponding Elasticsearch document.
+ *
+ * @throws InvalidConfigException if not overridden in a child class.
+ *
+ * @return string[] list of attribute names.
+ */
+ public function attributes()
+ {
+ throw new InvalidConfigException(
+ 'The attributes() method of Elasticsearch ActiveRecord has to be implemented by child classes.'
+ );
+ }
+
+ /**
+ * A list of attributes that should be treated as an array valued when retrieved through [[ActiveQuery::fields]].
+ *
+ * If not listed by this method, attributes retrieved through [[ActiveQuery::fields]] will convert to a scalar value
+ * when the result array contains only one value.
+ *
+ * @return string[] list of attribute names. Must be a subset of [[attributes()]].
+ */
+ public function arrayAttributes(): array
+ {
+ return [];
+ }
+
+ /**
+ * @return string the name of the index this record is stored in.
+ */
+ public static function index(): string
+ {
+ return Inflector::pluralize(Inflector::camel2id(StringHelper::basename(static::class)));
+ }
+
+ /**
+ * Returns the name of the type of this record.
+ * IMPORTANT: For Elasticsearch 7 and later, [[type()]] is ignored.
+ *
+ * @return string the name of the type of this record.
+ */
+ public static function type(): string
+ {
+ return Inflector::camel2id(StringHelper::basename(static::class));
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @param ActiveRecord $record the record to be populated. In most cases, this will be an instance
+ * created by [[instantiate()]] beforehand.
+ * @param array $row attribute values (name => value)
+ */
+ public static function populateRecord($record, $row): void
+ {
+ $attributes = $row['_source'] ?? [];
+
+ if (isset($row['fields'])) {
+ // reset fields in case it is scalar value
+ $arrayAttributes = $record->arrayAttributes();
+ foreach ($row['fields'] as $key => $value) {
+ if (!isset($arrayAttributes[$key]) && count($value) === 1) {
+ $row['fields'][$key] = reset($value);
+ }
+ }
+ $attributes = array_merge($attributes, $row['fields']);
+ }
+
+ parent::populateRecord($record, $attributes);
+
+ $pk = static::primaryKey()[0];
+ $record->_id = $row[$pk];
+ $record->_highlight = $row['highlight'] ?? null;
+ $record->_score = $row['_score'] ?? null;
+ $record->_version = $row['_version'] ?? null; // TODO version should always be available...
+ $record->_explanation = $row['_explanation'] ?? null;
+ }
+
+ /**
+ * Creates an active record instance.
+ *
+ * This method is called together with [[populateRecord()]] by [[ActiveQuery]].
+ * It is not meant to be used for creating new records directly.
+ *
+ * You may override this method if the instance being created
+ * depends on the row data to be populated into the record.
+ * For example, by creating a record based on the value of a column,
+ * you may implement the so-called single-table inheritance mapping.
+ *
+ * @param array $row row data to be populated into the record.
+ * This array consists of the following keys:
+ * - `_source`: refers to the attributes of the record.
+ * - `_type`: the type this record is stored in.
+ * - `_Index`: the index this record is stored in.
+ *
+ * @throws InvalidConfigException
+ *
+ * @return static the newly created active record
+ */
+ public static function instantiate($row): static
+ {
+ return Yii::createObject(static::class);
+ }
+
+ /**
+ * Inserts a document into the associated index using the attribute values of this record.
+ *
+ * This method performs the following steps in order:
+ *
+ * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation
+ * fails, it will skip the rest of the steps;
+ * 2. call [[afterValidate()]] when `$runValidation` is true.
+ * 3. call [[beforeSave()]]. If the method returns false, it will skip the
+ * rest of the steps;
+ * 4. Insert the record into a database. If this fails, it will skip the rest of the steps;
+ * 5. call [[afterSave()]];
+ *
+ * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]],
+ * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]]
+ * will be raised by the corresponding methods.
+ *
+ * Only the [[dirtyAttributes|changed attribute values]] will be inserted into a database.
+ *
+ * If the [[primaryKey|primary key]] is not set (null) during insertion,
+ * it will be populated with a randomly generated value after insertion.
+ *
+ * For example, to insert a customer record:
+ *
+ * ~~~
+ * $customer = new Customer;
+ * $customer->name = $name;
+ * $customer->email = $email;
+ * $customer->insert();
+ * ~~~
+ *
+ * @param bool $runValidation whether to perform validation before saving the record.
+ * If the validation fails, the record will not be inserted into the database.
+ * @param array $attributes list of attributes that need to be saved. Defaults to null,
+ * meaning all attributes will be saved.
+ * @param array $options options given in this parameter are passed to Elasticsearch
+ * as request URI parameters. These are among others:
+ *
+ * - `Routing` define shard placement of this record.
+ * - `parent` by giving the primaryKey of another record this defines a parent-child relation
+ *
+ * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html)
+ * for more details on these options.
+ *
+ * By default, the `op_type` is set to `create` if a model primary key is present.
+ *
+ * @throws InvalidConfigException
+ * @throws \yii\elasticsearch\Exception
+ *
+ * @return bool whether the attributes are valid and the record is inserted successfully.
+ */
+ public function insert($runValidation = true, $attributes = null, array $options = [ ]): bool
+ {
+ if ($runValidation && !$this->validate($attributes)) {
+ return false;
+ }
+
+ if (!$this->beforeSave(true)) {
+ return false;
+ }
+
+ $values = $this->getDirtyAttributes($attributes);
+
+ if ($this->getPrimaryKey() !== null) {
+ $options['op_type'] = $options['op_type'] ?? 'create';
+ }
+
+ $response = static::getDb()->createCommand()->insert(
+ static::index(),
+ static::type(),
+ $values,
+ $this->getPrimaryKey(),
+ $options
+ );
+
+ if ($response === false) {
+ return false;
+ }
+
+ $pk = static::primaryKey()[0];
+ $this->$pk = $response['_id'];
+
+ if ($pk !== '_id') {
+ $values[$pk] = $response['_id'];
+ }
+
+ $this->_version = $response['_version'];
+ $this->_score = null;
+
+ $changedAttributes = array_fill_keys(array_keys($values), null);
+ $this->setOldAttributes($values);
+ $this->afterSave(true, $changedAttributes);
+
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @param bool $runValidation whether to perform validation before saving the record.
+ * If the validation fails, the record will not be inserted into the database.
+ * @param array $attributeNames list of attribute names that need to be saved. Defaults to null,
+ * meaning all attributes that are loaded from DB will be saved.
+ * @param array $options options given in this parameter are passed to Elasticsearch
+ * as request URI parameters. These are among others:
+ *
+ * - `Routing` define shard placement of this record.
+ * - `parent` by giving the primaryKey of another record this defines a parent-child relation
+ * - `timeout` timeout waiting for a shard to become available.
+ * - `Replication` the replication type for the delete/index operation (sync or async).
+ * - `consistency` the written consistency of the index/delete operation.
+ * - `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.
+ * - `Detect_noop` this parameter will become part of the request body and will prevent the index from getting updated when nothing has changed.
+ *
+ * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html#docs-update-api-query-params)
+ * for more details on these options.
+ *
+ * The following parameters are Yii specific:
+ *
+ * - `Optimistic_locking` set this to `true` to enable optimistic locking, avoid updating when the record has changed since it
+ * has been loaded from the database. Yii will set the `version` parameter to the value stored in [[version]].
+ * See the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/guide/current/optimistic-concurrency-control.html) for details.
+ *
+ * Make sure the record has been fetched with a [[version]] before. This is only the case
+ * for records fetched via [[get()]] and [[mget()]] by default. For normal queries, the `_version` field has to be fetched explicitly.
+ *
+ * @throws InvalidArgumentException if no [[version]] is available and optimistic locking is enabled.
+ * @throws Exception
+ * @throws InvalidConfigException in case update failed.
+ * @throws StaleObjectException if optimistic, locking is enabled and the data being updated is outdated.
+ *
+ * @return bool|int the number of rows affected, or false if validation fails
+ * or [[beforeSave()]] stops the updating process.
+ */
+ public function update($runValidation = true, $attributeNames = null, array $options = []): bool|int
+ {
+ if ($runValidation && !$this->validate($attributeNames)) {
+ return false;
+ }
+ return $this->updateInternal($attributeNames, $options);
+ }
+
+ /**
+ * @param null $attributes attributes to update
+ * @param array $options options given in this parameter are passed to Elasticsearch
+ * as request URI parameters. See [[update()]] for details.
+ *
+ * @throws Exception in case update failed.
+ * @throws InvalidConfigException
+ * @throws StaleObjectException if optimistic, locking is enabled and the data being updated is outdated.
+ *
+ * @return false|int the number of rows affected, or false if [[beforeSave()]] stops the updating process.
+ *
+ * @see update()
+ */
+ protected function updateInternal($attributes = null, array $options = []): bool|int
+ {
+ if (!$this->beforeSave(false)) {
+ return false;
+ }
+ $values = $this->getDirtyAttributes($attributes);
+ if (empty($values)) {
+ $this->afterSave(false, $values);
+ return 0;
+ }
+
+ if (isset($options['optimistic_locking']) && $options['optimistic_locking']) {
+ if ($this->_version === null) {
+ 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.');
+ }
+ $options['version'] = $this->_version;
+ unset($options['optimistic_locking']);
+ }
+
+ try {
+ $result = static::getDb()->createCommand()->update(
+ static::index(),
+ static::type(),
+ $this->getOldPrimaryKey(),
+ $values,
+ $options
+ );
+ } catch (Exception $e) {
+ // HTTP 409 is the response in case of failed optimistic locking
+ // https://www.elastic.co/guide/en/elasticsearch/guide/current/optimistic-concurrency-control.html
+ if (isset($e->errorInfo['responseCode']) && $e->errorInfo['responseCode'] === 409) {
+ throw new StaleObjectException(
+ 'The object being updated is outdated.',
+ $e->errorInfo,
+ $e->getCode(),
+ $e,
+ );
+ }
+ throw $e;
+ }
+
+ if (is_array($result) && isset($result['_version'])) {
+ $this->_version = $result['_version'];
+ }
+
+ $changedAttributes = [];
+ foreach ($values as $name => $value) {
+ $changedAttributes[$name] = $this->getOldAttribute($name);
+ $this->setOldAttribute($name, $value);
+ }
+ $this->afterSave(false, $changedAttributes);
+
+ if ($result === false) {
+ return 0;
+ }
+ return 1;
+ }
+
+ /**
+ * Performs a quick and highly efficient scroll/scan query to get the list of primary keys that
+ * satisfy the given condition. If condition is a list of primary keys
+ * (e.g.: `['_id' => ['1', '2', '3']]`), the query is not performed for performance considerations.
+ *
+ * @param array $condition please refer to [[ActiveQuery::where()]] on how to specify this parameter
+ *
+ * @throws InvalidConfigException
+ *
+ * @return array primary keys that correspond to given conditions
+ *
+ * @see updateAllCounters()
+ * @see deleteAll()
+ * @see updateAll()
+ */
+ protected static function primaryKeysByCondition(array $condition): array
+ {
+ $pkName = static::primaryKey()[0];
+
+ if (count($condition) === 1 && isset($condition[$pkName])) {
+ $primaryKeys = (array)$condition[$pkName];
+ } else {
+ //fetch only document metadata (no fields), 1000 documents per shard
+ $query = static::find()->where($condition)->asArray()->source(false)->limit(1000);
+ $primaryKeys = [];
+ foreach ($query->each() as $document) {
+ $primaryKeys[] = $document['_id'];
+ }
+ }
+ return $primaryKeys;
+ }
+
+ /**
+ * Updates all records that match a certain condition.
+ * For example, to change the status to be 1 for all customers whose status is 2:
+ *
+ * ~~~
+ * Customer::updateAll(['status' => 1], ['status' => 2]);
+ * ~~~
+ *
+ * @param array $attributes attribute values (name-value pairs) to be saved into the table
+ * @param array $condition the conditions that will be passed to the `where()` method when building the query.
+ * Please refer to [[ActiveQuery::where()]] on how to specify this parameter.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception on error.*@see [[ActiveRecord::primaryKeysByCondition()]]
+ *
+ * @return int the number of rows updated
+ */
+ public static function updateAll($attributes, $condition = []): int
+ {
+ $primaryKeys = static::primaryKeysByCondition($condition);
+ if (empty($primaryKeys)) {
+ return 0;
+ }
+
+ $bulkCommand = static::getDb()->createBulkCommand([
+ 'index' => static::index(),
+ 'type' => static::type(),
+ ]);
+
+ foreach ($primaryKeys as $pk) {
+ $bulkCommand->addAction(['update' => ['_id' => $pk]], ['doc' => $attributes]);
+ }
+
+ $response = $bulkCommand->execute();
+
+ $n = 0;
+ $errors = [];
+ foreach ($response['items'] as $item) {
+ if (isset($item['update']['status']) && $item['update']['status'] === 200) {
+ $n++;
+ } else {
+ $errors[] = $item['update'];
+ }
+ }
+ if (!empty($errors) || (isset($response['errors']) && $response['errors'])) {
+ throw new Exception(__METHOD__ . ' failed updating records.', $errors);
+ }
+
+ return $n;
+ }
+
+ /**
+ * Updates all matching records using the provided counter changes and conditions.
+ * For example, to add 1 to the age of all customers whose status is 2.
+ *
+ * ~~~
+ * Customer::updateAllCounters(['age' => 1], ['status' => 2]);
+ * ~~~
+ *
+ * @param array $counters the counters to be updated (attribute name => increment value).
+ * Use negative values if you want to decrement the counters.
+ * @param array $condition the conditions that will be passed to the `where()` method when building the query.
+ * Please refer to [[ActiveQuery::where()]] on how to specify this parameter.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception on error.
+ *
+ * @return int the number of rows updated
+ *
+ * @see [[ActiveRecord::primaryKeysByCondition()]]
+ */
+ public static function updateAllCounters($counters, $condition = []): int
+ {
+ $primaryKeys = static::primaryKeysByCondition($condition);
+ if (empty($primaryKeys) || empty($counters)) {
+ return 0;
+ }
+
+ $bulkCommand = static::getDb()->createBulkCommand([
+ 'index' => static::index(),
+ 'type' => static::type(),
+ ]);
+ foreach ($primaryKeys as $pk) {
+ $script = '';
+ foreach ($counters as $counter => $value) {
+ $script .= "ctx._source.$counter += params.$counter;\n";
+ }
+ $bulkCommand->addAction(['update' => ['_id' => $pk]], [
+ 'script' => [
+ 'inline' => $script,
+ 'params' => $counters,
+ 'lang' => 'painless',
+ ],
+ ]);
+ }
+
+ $response = $bulkCommand->execute();
+
+ $n = 0;
+ $errors = [];
+ foreach ($response['items'] as $item) {
+ if (isset($item['update']['status']) && $item['update']['status'] === 200) {
+ $n++;
+ } else {
+ $errors[] = $item['update'];
+ }
+ }
+ if (!empty($errors) || (isset($response['errors']) && $response['errors'])) {
+ throw new Exception(__METHOD__ . ' failed updating records counters.', $errors);
+ }
+
+ return $n;
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @param array $options options given in this parameter are passed to Elasticsearch as request URI parameters.
+ *
+ * These are among others:
+ * - `routing` define shard placement of this record.
+ * - `parent` by giving the primaryKey of another record this defines a parent-child relation
+ * - `timeout` timeout waiting for a shard to become available.
+ * - `Replication` the replication type for the delete/index operation (sync or async).
+ * - `consistency` the written consistency of the index/delete operation.
+ * - `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.
+ *
+ * Please refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html)
+ * for more details on these options.
+ *
+ * The following parameters are Yii specific:
+ *
+ * - `Optimistic_locking` set this to `true` to enable optimistic locking, avoid updating when the record has
+ * changed since it has been loaded from the database.
+ * Yii will set the `version` parameter to the value stored in [[version]].
+ * See the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html#delete-versioning)
+ * for details.
+ *
+ * Make sure the record has been fetched with a [[version]] before.
+ * This is only the case
+ * for records fetched via [[get()]] and [[mget()]] by default.
+ * For normal queries, the `_version` field has to be fetched explicitly.
+ *
+ * @throws StaleObjectException if optimistic, locking is enabled and the data being deleted is outdated.
+ * @throws Exception
+ * @throws InvalidConfigException in case delete failed.
+ *
+ * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful.
+ *
+ * @return bool|int the number of rows deleted, or false if the deletion is unsuccessful for some reason.
+ */
+ public function delete(array $options = []): bool|int
+ {
+ if (!$this->beforeDelete()) {
+ return false;
+ }
+ if (isset($options['optimistic_locking']) && $options['optimistic_locking']) {
+ if ($this->_version === null) {
+ 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.');
+ }
+ $options['version'] = $this->_version;
+ unset($options['optimistic_locking']);
+ }
+
+ try {
+ $result = static::getDb()->createCommand()->delete(
+ static::index(),
+ static::type(),
+ $this->getOldPrimaryKey(),
+ $options
+ );
+ } catch (Exception $e) {
+ // HTTP 409 is the response in case of failed optimistic locking
+ // https://www.elastic.co/guide/en/elasticsearch/guide/current/optimistic-concurrency-control.html
+ if (isset($e->errorInfo['responseCode']) && $e->errorInfo['responseCode'] === 409) {
+ throw new StaleObjectException('The object being deleted is outdated.', $e->errorInfo, $e->getCode(), $e);
+ }
+ throw $e;
+ }
+
+ $this->setOldAttributes(null);
+
+ $this->afterDelete();
+
+ if ($result === false) {
+ return 0;
+ }
+ return 1;
+ }
+
+ /**
+ * Deletes rows in the table using the provided conditions.
+ * WARNING: If you do not specify any condition, this method will delete ALL rows in the table.
+ *
+ * For example, to delete all customers whose status is 3:
+ *
+ * ~~~
+ * Customer::deleteAll(['status' => 3]);
+ * ~~~
+ *
+ * @param array $condition the conditions that will be passed to the `where()` method when building the query.
+ * Please refer to [[ActiveQuery::where()]] on how to specify this parameter.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception on error.
+ *
+ * @return int the number of rows deleted
+ *
+ * @see [[ActiveRecord::primaryKeysByCondition()]]
+ */
+ public static function deleteAll($condition = []): int
+ {
+ $primaryKeys = static::primaryKeysByCondition($condition);
+ if (empty($primaryKeys)) {
+ return 0;
+ }
+
+ $bulkCommand = static::getDb()->createBulkCommand([
+ 'index' => static::index(),
+ 'type' => static::type(),
+ ]);
+ foreach ($primaryKeys as $pk) {
+ $bulkCommand->addDeleteAction($pk);
+ }
+ $response = $bulkCommand->execute();
+
+ $n = 0;
+ $errors = [];
+ foreach ($response['items'] as $item) {
+ if (isset($item['delete']['status']) && $item['delete']['status'] === 200) {
+ if (isset($item['delete']['found']) && $item['delete']['found']) {
+ // ES5 uses "found"
+ $n++;
+ } elseif (isset($item['delete']['result']) && $item['delete']['result'] === 'deleted') {
+ // ES6 uses "result"
+ $n++;
+ }
+ } else {
+ $errors[] = $item['delete'];
+ }
+ }
+ if (!empty($errors) || (isset($response['errors']) && $response['errors'])) {
+ throw new Exception(__METHOD__ . ' failed deleting records.', $errors);
+ }
+
+ return $n;
+ }
+
+ /**
+ * Destroys the relationship in the current model.
+ *
+ * This method is not supported by Elasticsearch.
+ *
+ * @throws NotSupportedException
+ */
+ public function unlinkAll($name, $delete = false): void
+ {
+ throw new NotSupportedException('unlinkAll() is not supported by Elasticsearch, use unlink() instead.');
+ }
+
+ public function link($name, $model, $extraColumns = []): void
+ {
+ $relation = $this->getRelation($name);
+
+ if ($relation !== null && $relation->via === null) {
+ $this->validateViaRelationLink($model, $relation);
+ }
+
+ parent::link($name, $model, $extraColumns);
+ }
+
+ /**
+ * Validates the model so that it does not contain an array as its keys while linking.
+ *
+ * @param ActiveRecordInterface $model the model to be linked with the current one.
+ * @param ActiveQuery|ActiveQueryInterface $relation the relational query object.
+ */
+ protected function validateViaRelationLink(
+ ActiveRecordInterface $model,
+ ActiveQueryInterface|ActiveQuery $relation
+ ): void {
+ $p1 = $model->isPrimaryKey(array_keys($relation->link));
+ $p2 = static::isPrimaryKey(array_values($relation->link));
+
+ $atLeastOneExists = !$this->getIsNewRecord() || !$model->getIsNewRecord();
+
+ $foreign = null;
+ $link = null;
+
+ if ($p1 && $p2 && $atLeastOneExists) {
+ if ($this->getIsNewRecord()) {
+ $foreign = $this;
+ $link = array_flip($relation->link);
+ } else {
+ $foreign = $model;
+ $link = $relation->link;
+ }
+ } elseif ($p1) {
+ $foreign = $this;
+ $link = array_flip($relation->link);
+ } elseif ($p2) {
+ $foreign = $model;
+ $link = $relation->link;
+ }
+
+ if ($foreign && $link) {
+ foreach ($link as $fk => $pk) {
+ if (is_array($foreign->{$fk})) {
+ throw new InvalidCallException('Unable to link models: foreign model cannot be linked if its property is an array.');
+ }
+ }
+ }
+ }
+}
diff --git a/src/BatchQueryResult.php b/src/BatchQueryResult.php
new file mode 100644
index 0000000..fc73689
--- /dev/null
+++ b/src/BatchQueryResult.php
@@ -0,0 +1,237 @@
+from('user');
+ * foreach ($query->batch() as $i => $users) {
+ * // $users represents the rows in the $i-th batch
+ * }
+ * foreach ($query->each() as $user) {
+ * }
+ * ```
+ *
+ * @author Konstantin Sirotkin
+ */
+class BatchQueryResult extends BaseObject implements Iterator
+{
+ /**
+ * @var Connection|null the DB connection to be used when performing a batch query.
+ * If null, the `elasticsearch` application component will be used.
+ */
+ public Connection|null $db = null;
+ /**
+ * @var Query|null the query object associated with this batch query.
+ * Do not modify this property directly unless after [[reset()]] is called explicitly.
+ */
+ public Query|null $query = null;
+ /**
+ * @var bool whether to return a single row during each iteration.
+ * If false, a whole batch of rows will be returned in each iteration.
+ */
+ public bool $each = false;
+ /**
+ * @var DataReader|null the data reader associated with this batch query.
+ */
+ private DataReader|null $_dataReader = null;
+ /**
+ * @var array the data retrieved in the current batch
+ */
+ private array $_batch = [];
+ /**
+ * @var mixed the value for the current iteration
+ */
+ private mixed $_value = null;
+ /**
+ * @var int|string|null the key for the current iteration
+ */
+ private string|int|null $_key = null;
+ /**
+ * @var string the amount of time to keep the scroll window open
+ * (in Elasticsearch [time units](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units).
+ */
+ public string $scrollWindow = '1m';
+
+ /**
+ * @var string|null elasticsearch scroll id.
+ */
+ private string|null $_lastScrollId = null;
+
+ /**
+ * @throws InvalidConfigException
+ * @throws Exception
+ */
+ public function __destruct()
+ {
+ // make sure the cursor is closed
+ $this->reset();
+ }
+
+ /**
+ * Resets the batch query.
+ * This method will clean up the existing batch query so that a new batch query can be performed.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ */
+ public function reset(): void
+ {
+ if (isset($this->_lastScrollId)) {
+ $this->query->createCommand($this->db)->clearScroll(['scroll_id' => $this->_lastScrollId]);
+ }
+
+ $this->_batch = [];
+ $this->_value = null;
+ $this->_key = null;
+ $this->_lastScrollId = null;
+ }
+
+ /**
+ * Resets the iterator to the initial state.
+ * This method is required by the interface [[\Iterator]].
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ */
+ public function rewind(): void
+ {
+ $this->reset();
+ $this->next();
+ }
+
+ /**
+ * Moves the internal pointer to the next dataset.
+ * This method is required by the interface [[\Iterator]].
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ */
+ public function next(): void
+ {
+ if ($this->_batch === [] || !$this->each || (next($this->_batch) === false)) {
+ $this->_batch = $this->fetchData();
+ reset($this->_batch);
+ }
+
+ if ($this->each) {
+ $this->_value = current($this->_batch);
+ if ($this->query->indexBy !== null) {
+ $this->_key = key($this->_batch);
+ } elseif (key($this->_batch) !== null) {
+ $this->_key++;
+ } else {
+ $this->_key = null;
+ }
+ } else {
+ $this->_value = $this->_batch;
+ $this->_key = $this->_key === null ? 0 : $this->_key + 1;
+ }
+ }
+
+ /**
+ * Fetches the next batch of data.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return array the data fetched
+ */
+ protected function fetchData(): array
+ {
+ if (null === $this->_lastScrollId) {
+ //first query - do search
+ $options = ['scroll' => $this->scrollWindow];
+
+ if (!$this->query->orderBy) {
+ $query = clone $this->query;
+ $query->orderBy('_doc');
+ }
+
+ $cmd = $this->query->createCommand($this->db);
+ $result = $cmd->search($options);
+
+ if ($result === false) {
+ throw new Exception('Elasticsearch search query failed.');
+ }
+ } else {
+ //subsequent queries - do scroll
+ $result = $this->query->createCommand($this->db)->scroll([
+ 'scroll_id' => $this->_lastScrollId,
+ 'scroll' => $this->scrollWindow,
+ ]);
+ }
+
+ //get last scroll id
+ $this->_lastScrollId = $result['_scroll_id'];
+
+ //get data
+ return $this->query->populate($result['hits']['hits']);
+ }
+
+ /**
+ * Returns the index of the current dataset.
+ * This method is required by the interface [[\Iterator]].
+ *
+ * @return int|string|null the index of the current row.
+ */
+ public function key(): int|string|null
+ {
+ return $this->_key;
+ }
+
+ /**
+ * Returns the current dataset.
+ * This method is required by the interface [[\Iterator]].
+ *
+ * @return mixed the current dataset.
+ */
+ public function current(): mixed
+ {
+ return $this->_value;
+ }
+
+ /**
+ * Returns whether there is a valid dataset at the current position.
+ * This method is required by the interface [[\Iterator]].
+ *
+ * @return bool whether there is a valid dataset at the current position.
+ */
+ public function valid(): bool
+ {
+ return !empty($this->_batch);
+ }
+}
diff --git a/src/BulkCommand.php b/src/BulkCommand.php
new file mode 100644
index 0000000..00edf71
--- /dev/null
+++ b/src/BulkCommand.php
@@ -0,0 +1,147 @@
+
+ */
+class BulkCommand extends Component
+{
+ public Connection|null $db = null;
+ /**
+ * @var string|null Default index to execute the queries on. Defaults to null meaning that index needs to be
+ * specified in every action.
+ */
+ public string|null $index = null;
+ /**
+ * @var string|null Default type to execute the queries on. Defaults to null meaning that type needs to be specified
+ * in every action.
+ */
+ public string|null $type = null;
+ /**
+ * @var array|string Actions to be executed in this bulk command, given as either an array of arrays or as one
+ * newline-delimited string.
+ *
+ * All actions except delete span two lines.
+ */
+ public string|array $actions = [];
+ /**
+ * @var array Options to be appended to the query URL.
+ */
+ public array $options = [];
+
+ /**
+ * Executes the bulk command.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return mixed
+ */
+ public function execute(): mixed
+ {
+ //valid endpoints are /_bulk, /{index}/_bulk, and {index}/{type}/_bulk
+ //for ES7+ type is omitted
+ if ($this->index === null && $this->type === null) {
+ $endpoint = ['_bulk'];
+ } elseif ($this->index !== null && $this->type === null) {
+ $endpoint = [$this->index, '_bulk'];
+ } elseif ($this->index !== null && $this->type !== null) {
+ if ($this->db->dslVersion >= 7) {
+ $endpoint = [$this->index, '_bulk'];
+ } else {
+ $endpoint = [$this->index, $this->type, '_bulk'];
+ }
+ } else {
+ throw new InvalidCallException('Invalid endpoint: if type is defined, index must be defined too.');
+ }
+
+ if (empty($this->actions)) {
+ $body = '{}';
+ } elseif (is_array($this->actions)) {
+ $body = '';
+ $prettyPrintSupported = property_exists(Json::class, 'prettyPrint');
+ if ($prettyPrintSupported) {
+ $originalPrettyPrint = Json::$prettyPrint;
+ Json::$prettyPrint = false; // ElasticSearch bulk API uses new lines as delimiters.
+ }
+ foreach ($this->actions as $action) {
+ $body .= Json::encode($action) . "\n";
+ }
+ if ($prettyPrintSupported) {
+ Json::$prettyPrint = $originalPrettyPrint;
+ }
+ } else {
+ $body = $this->actions;
+ }
+
+ return $this->db->post($endpoint, $this->options, $body);
+ }
+
+ /**
+ * Adds an action to the command. Will overwrite existing actions if they are specified as a string.
+ *
+ * @param array $line1 First action expressed as an array (will be encoded to JSON automatically).
+ * @param array|null $line2 Second action expressed as an array (will be encoded to JSON automatically).
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.x/docs-bulk.html
+ */
+ public function addAction(array $line1, array $line2 = null): void
+ {
+ if (is_array($this->actions) === false) {
+ $this->actions = [];
+ }
+
+ $this->actions[] = $line1;
+
+ if ($line2 !== null) {
+ $this->actions[] = $line2;
+ }
+ }
+
+ /**
+ * Adds a delete action to the command.
+ *
+ * @param string $id Document ID
+ * @param string|null $index Index that the document belongs to. Can be set to null if the command has
+ * a default index ([[BulkCommand::$index]]) assigned.
+ * @param string|null $type Type that the document belongs to. Can be set to null if the command has
+ * a default type ([[BulkCommand::$type]]) assigned.
+ */
+ public function addDeleteAction(string $id, string $index = null, string $type = null): void
+ {
+ $actionData = ['_id' => $id];
+
+ if (!empty($index)) {
+ $actionData['_index'] = $index;
+ }
+
+ if (!empty($type)) {
+ $actionData['_type'] = $type;
+ }
+
+ $this->addAction(['delete' => $actionData]);
+ }
+}
diff --git a/src/Command.php b/src/Command.php
new file mode 100644
index 0000000..46e66c7
--- /dev/null
+++ b/src/Command.php
@@ -0,0 +1,966 @@
+
+ */
+class Command extends Component
+{
+ public Connection|null $db = null;
+ /**
+ * @var array|string|null the indexes to execute the query on. Defaults to null meaning all indexes
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html#search-multi-index-type
+ */
+ public array|string|null $index = null;
+ /**
+ * @var array|string|null the types to execute the query on. Defaults to null meaning all types
+ */
+ public string|array|null $type = null;
+ /**
+ * @var array list of arrays or json strings that become parts of a query
+ */
+ public array $queryParts = [];
+ /**
+ * @var array options to be appended to the query URL, such as "search_type" for search or "timeout" for deleting
+ */
+ public array $options = [];
+
+ /**
+ * Sends a request to the _search API and returns the result
+ *
+ * @param array $options URL options
+ *
+ *@throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ */
+ public function search(array $options = []): mixed
+ {
+ $query = $this->queryParts;
+
+ if (empty($query)) {
+ $query = '{}';
+ }
+
+ if (is_array($query)) {
+ $query = Json::encode($query);
+ }
+
+ $url = [$this->index ?? '_all'];
+
+ if ($this->db->dslVersion < 7 && $this->type !== null) {
+ $url[] = $this->type;
+ }
+
+ $url[] = '_search';
+
+ return $this->db->get($url, array_merge($this->options, $options), $query);
+ }
+
+ /**
+ * Sends a request to the deleting by query
+ *
+ * @param array $options URL options
+ *
+ *@throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ */
+ public function deleteByQuery(array $options = []): mixed
+ {
+ if (!isset($this->queryParts['query'])) {
+ throw new InvalidCallException('Can not call deleteByQuery when no query is given.');
+ }
+
+ $query = [
+ 'query' => $this->queryParts['query'],
+ ];
+
+ if (isset($this->queryParts['filter'])) {
+ $query['filter'] = $this->queryParts['filter'];
+ }
+
+ $query = Json::encode($query);
+ $url = [$this->index ?? '_all'];
+
+ if ($this->type !== null) {
+ $url[] = $this->type;
+ }
+
+ $url[] = '_delete_by_query';
+
+ return $this->db->post($url, array_merge($this->options, $options), $query);
+ }
+
+ /**
+ * Sends a suggested request to the _search API and returns the result
+ *
+ * @param array|string $suggester the suggester body
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html
+ */
+ public function suggest(array|string $suggester, array $options = []): mixed
+ {
+ if (empty($suggester)) {
+ $suggester = '{}';
+ }
+
+ if (is_array($suggester)) {
+ $suggester = Json::encode($suggester);
+ }
+
+ $body = '{"suggest":' . $suggester . ',"size":0}';
+ $url = [$this->index ?? '_all', '_search'];
+ $result = $this->db->post($url, array_merge($this->options, $options), $body);
+
+ return $result['suggest'];
+ }
+
+ /**
+ * Inserts a document into an index
+ *
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ * @param array|string $data json string or array of data to store
+ * @param int|string|null $id the documents' id. If not specified, I'd will be automatically chosen
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html
+ */
+ public function insert(
+ string $index,
+ string|null $type,
+ array|string $data,
+ string|int $id = null,
+ array $options = []
+ ): mixed {
+ if (empty($data)) {
+ $body = '{}';
+ } else {
+ $body = is_array($data) ? Json::encode($data) : $data;
+ }
+
+ if ($id !== null) {
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->put([$index, '_doc', $id], $options, $body);
+ }
+ return $this->db->put([$index, $type, $id], $options, $body);
+ }
+
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->post([$index, '_doc'], $options, $body);
+ }
+
+ return $this->db->post([$index, $type], $options, $body);
+ }
+
+ /**
+ * gets a document from the index
+ *
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ * @param int|string|null $id the documents' id.
+ * @param array $options URL options
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html
+ */
+ public function get(string $index, string|null $type, string|int $id = null, array $options = []): mixed
+ {
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->get([$index, '_doc', $id], $options);
+ }
+
+ return $this->db->get([$index, $type, $id], $options);
+ }
+
+ /**
+ * gets multiple documents from the index
+ *
+ * TODO allow specifying type and index + fields
+ *
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ * @param string[] $ids the documents ids as values in an array.
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html
+ */
+ public function mget(string $index, ?string $type, array $ids, array $options = []): mixed
+ {
+ $body = Json::encode(['ids' => array_values($ids)]);
+
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->get([$index, '_mget'], $options, $body);
+ }
+
+ return $this->db->get([$index, $type, '_mget'], $options, $body);
+ }
+
+ /**
+ * gets a documents _source from the index (>=v0.90.1)
+ *
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ * @param string $id the documents' id.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html#_source
+ */
+ public function getSource(string $index, ?string $type, string $id): mixed
+ {
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->get([$index, '_doc', $id]);
+ }
+
+ return $this->db->get([$index, $type, $id]);
+ }
+
+ /**
+ * gets a document from the index
+ *
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ * @param string $id the documents' id.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html
+ */
+ public function exists(string $index, string|null $type, string $id): mixed
+ {
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->head([$index, '_doc', $id]);
+ }
+
+ return $this->db->head([$index, $type, $id]);
+ }
+
+ /**
+ * deletes a document from the index
+ *
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ * @param string $id the documents' id.
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html
+ */
+ public function delete(string $index, string|null $type, string $id, array $options = []): mixed
+ {
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->delete([$index, '_doc', $id], $options);
+ }
+
+ return $this->db->delete([$index, $type, $id], $options);
+ }
+
+ /**
+ * updates a document
+ *
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ * @param string $id the documents' id.
+ * @param mixed $data the documents' data.
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html
+ */
+ public function update(string $index, string|null $type, string $id, mixed $data, array $options = []): mixed
+ {
+ $body = ['doc' => empty($data) ? new stdClass() : $data];
+
+ if (isset($options['detect_noop'])) {
+ $body['detect_noop'] = $options['detect_noop'];
+ unset($options['detect_noop']);
+ }
+
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->post([$index, '_update', $id], $options, Json::encode($body));
+ }
+
+ return $this->db->post([$index, $type, $id, '_update'], $options, Json::encode($body));
+ }
+
+ // TODO bulk https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
+
+ /**
+ * creates an index
+ *
+ * @param string $index Index that the document belongs to.
+ * @param array|null $configuration
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html
+ */
+ public function createIndex(string $index, array $configuration = null): mixed
+ {
+ $body = $configuration !== null ? Json::encode($configuration) : null;
+
+ return $this->db->put([$index], [], $body);
+ }
+
+ /**
+ * deletes an index
+ *
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html
+ */
+ public function deleteIndex(string $index): mixed
+ {
+ return $this->db->delete([$index]);
+ }
+
+ /**
+ * deletes all indexes
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html
+ */
+ public function deleteAllIndexes(): mixed
+ {
+ return $this->db->delete(['_all']);
+ }
+
+ /**
+ * checks whether an index exists
+ *
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-exists.html
+ */
+ public function indexExists(string $index): mixed
+ {
+ return $this->db->head([$index]);
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-types-exists.html
+ */
+ public function typeExists(string $index, string|null $type): mixed
+ {
+ if ($this->db->dslVersion >= 7) {
+ return $this->db->head([$index, '_doc']);
+ }
+
+ return $this->db->head([$index, $type]);
+ }
+
+ /**
+ * @param string $alias
+ *
+ *@throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return bool
+ */
+ public function aliasExists(string $alias): bool
+ {
+ $indexes = $this->getIndexesByAlias($alias);
+
+ return !empty($indexes);
+ }
+
+ /**
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return array
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#alias-retrieving
+ */
+ public function getAliasInfo(): array
+ {
+ $aliasInfo = $this->db->get(['_alias', '*']);
+
+ return $aliasInfo ?: [];
+ }
+
+ /**
+ * @param string $alias
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return array
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#alias-retrieving
+ */
+ public function getIndexInfoByAlias(string $alias): array
+ {
+ $responseData = $this->db->get(['_alias', $alias]);
+
+ if (empty($responseData)) {
+ return [];
+ }
+
+ return $responseData;
+ }
+
+ /**
+ * @param string $alias
+ *
+ *@throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return array
+ */
+ public function getIndexesByAlias(string $alias): array
+ {
+ return array_keys($this->getIndexInfoByAlias($alias));
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return array
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#alias-retrieving
+ */
+ public function getIndexAliases(string $index): array
+ {
+ $responseData = $this->db->get([$index, '_alias', '*']);
+
+ if (empty($responseData)) {
+ return [];
+ }
+
+ return $responseData[$index]['aliases'];
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ * @param string $alias
+ * @param array $aliasParameters
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ * @throws JsonException
+ *
+ * @return bool
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#alias-adding
+ */
+ public function addAlias(string $index, string $alias, array $aliasParameters = []): bool
+ {
+ return (bool) $this->db->put(
+ [$index, '_alias', $alias],
+ [],
+ json_encode((object) $aliasParameters, JSON_THROW_ON_ERROR),
+ );
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ * @param string $alias
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return bool
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#deleting
+ */
+ public function removeAlias(string $index, string $alias): bool
+ {
+ return (bool) $this->db->delete([$index, '_alias', $alias]);
+ }
+
+ /**
+ * Runs alias manipulations.
+ * If you want to add alias1 to index1
+ * and remove alias2 from index2 you can use the following commands:
+ * ~~~
+ * $actions = [
+ * ['add' => ['index' => 'index1', 'alias' => 'alias1']],
+ * ['remove' => ['index' => 'index2', 'alias' => 'alias2']],
+ * ];
+ * ~~~
+ *
+ * @param array $actions
+ *
+ * @throws InvalidConfigException
+ * @throws JsonException
+ * @throws Exception
+ *
+ * @return bool
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.0/indices-aliases.html#indices-aliases
+ */
+ public function aliasActions(array $actions): bool
+ {
+ return (bool) $this->db->post(['_aliases'], [], json_encode(['actions' => $actions], JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * Change specific index level settings in real time.
+ * Note that update analyzers required to [[close()]] the index first and [[open()]] it after the changes are made,
+ * use [[updateAnalyzers()]] for it.
+ *
+ * @param string $index Index that the document belongs to.
+ * @param array|string|null $setting
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elasticsearch.org/guide/en/elasticsearch/reference/current/indices-update-settings.html
+ */
+ public function updateSettings(string $index, array|string $setting = null, array $options = []): mixed
+ {
+ if ($setting !== null) {
+ $body = is_string($setting) ? $setting : Json::encode($setting);
+ } else {
+ $body = null;
+ }
+
+ return $this->db->put([$index, '_settings'], $options, $body);
+ }
+
+ /**
+ * Define new analyzers for the index.
+ * For example, if content analyzer hasn’t been defined on "myindex" yet
+ * you can use the following commands to add it:
+ *
+ * ~~~
+ * $setting = [
+ * 'analysis' => [
+ * 'analyzer' => [
+ * 'ngram_analyzer_with_filter' => [
+ * 'tokenizer' => 'ngram_tokenizer',
+ * 'filter' => 'lowercase, snowball'
+ * ],
+ * ],
+ * 'tokenizer' => [
+ * 'ngram_tokenizer' => [
+ * 'type' => 'nGram',
+ * 'min_gram' => 3,
+ * 'max_gram' => 10,
+ * 'token_chars' => ['letter', 'digit', 'whitespace', 'punctuation', 'symbol']
+ * ],
+ * ],
+ * ]
+ * ];
+ * $elasticQuery->createCommand()->updateAnalyzers('myindex', $setting);
+ * ~~~
+ *
+ * @param string $index Index that the document belongs to.
+ * @param array|string $setting
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-update-settings.html#update-settings-analysis
+ */
+ public function updateAnalyzers(string $index, array|string $setting, array $options = []): mixed
+ {
+ $this->closeIndex($index);
+ $result = $this->updateSettings($index, $setting, $options);
+ $this->openIndex($index);
+
+ return $result;
+ }
+
+ // TODO https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-settings.html
+
+ // TODO https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-warmers.html
+
+ /**
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-open-close.html
+ */
+ public function openIndex(string $index): mixed
+ {
+ return $this->db->post([$index, '_open']);
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-open-close.html
+ */
+ public function closeIndex(string $index): mixed
+ {
+ return $this->db->post([$index, '_close']);
+ }
+
+ /**
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-scroll.html
+ */
+ public function scroll(array $options = []): mixed
+ {
+ $body = array_filter(
+ [
+ 'scroll' => ArrayHelper::remove($options, 'scroll'),
+ 'scroll_id' => ArrayHelper::remove($options, 'scroll_id'),
+ ],
+ );
+
+ if (empty($body)) {
+ $body = (object) [];
+ }
+
+ return $this->db->post(['_search', 'scroll'], $options, Json::encode($body));
+ }
+
+ /**
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-scroll.html
+ */
+ public function clearScroll(array $options = []): mixed
+ {
+ $body = array_filter(
+ [
+ 'scroll_id' => ArrayHelper::remove($options, 'scroll_id'),
+ ],
+ );
+
+ if (empty($body)) {
+ $body = (object) [];
+ }
+
+ return $this->db->delete(['_search', 'scroll'], $options, Json::encode($body));
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-stats.html
+ */
+ public function getIndexStats(string $index = '_all'): mixed
+ {
+ return $this->db->get([$index, '_stats']);
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-recovery.html
+ */
+ public function getIndexRecoveryStats(string $index = '_all'): mixed
+ {
+ return $this->db->get([$index, '_recovery']);
+ }
+
+ // https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-segments.html
+
+ /**
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-clearcache.html
+ */
+ public function clearIndexCache(string $index): mixed
+ {
+ return $this->db->post([$index, '_cache', 'clear']);
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-flush.html
+ */
+ public function flushIndex(string $index = '_all'): mixed
+ {
+ return $this->db->post([$index, '_flush']);
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html
+ */
+ public function refreshIndex(string $index): mixed
+ {
+ return $this->db->post([$index, '_refresh']);
+ }
+
+ // TODO https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-optimize.html
+ // TODO https://www.elastic.co/guide/en/elasticsearch/reference/0.90/indices-gateway-snapshot.html
+
+ /**
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ * @param array|string|null $mapping
+ * @param array $options URL options
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-mapping.html
+ */
+ public function setMapping(string $index, string|null $type, array|string|null $mapping, array $options = []): mixed
+ {
+ if ($mapping !== null) {
+ $body = is_string($mapping) ? $mapping : Json::encode($mapping);
+ } else {
+ $body = null;
+ }
+
+ if ($this->db->dslVersion >= 7) {
+ $endpoint = [$index, '_mapping'];
+ } else {
+ $endpoint = [$index, '_mapping', $type];
+ }
+
+ return $this->db->put($endpoint, $options, $body);
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ * @param string|null $type Type that the document belongs to.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-mapping.html
+ */
+ public function getMapping(string $index = '_all', string $type = null): mixed
+ {
+ $url = [$index, '_mapping'];
+
+ if ($this->db->dslVersion < 7 && $type !== null) {
+ $url[] = $type;
+ }
+
+ return $this->db->get($url);
+ }
+
+ /**
+ * @param string $index Index that the document belongs to.
+ * @param string $type
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-field-mapping.html
+ */
+// public function getFieldMapping($index, $type = '_all')
+// {
+ // // TODO implement
+// return $this->db->put([$index, $type, '_mapping']);
+// }
+
+ /**
+ * @param $options
+ * @param string $index Index that the document belongs to.
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-analyze.html
+ */
+ // public function analyze($options, $index = null)
+ // {
+ // // TODO implement
+ //// return $this->db->put([$index]);
+ // }
+
+ /**
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html
+ */
+ public function createTemplate(
+ string $name,
+ string $pattern,
+ string $settings,
+ array|string|null $mappings,
+ int $order = 0
+ ): mixed {
+ $body = Json::encode([
+ 'template' => $pattern,
+ 'order' => $order,
+ 'settings' => (object) $settings,
+ 'mappings' => (object) $mappings,
+ ]);
+
+ return $this->db->put(['_template', $name], [], $body);
+ }
+
+ /**
+ * @param $name
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html
+ */
+ public function deleteTemplate($name): mixed
+ {
+ return $this->db->delete(['_template', $name]);
+ }
+
+ /**
+ * @param $name
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return mixed
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html
+ */
+ public function getTemplate($name): mixed
+ {
+ return $this->db->get(['_template', $name]);
+ }
+}
diff --git a/src/Connection.php b/src/Connection.php
new file mode 100644
index 0000000..5f37743
--- /dev/null
+++ b/src/Connection.php
@@ -0,0 +1,779 @@
+
+ */
+class Connection extends Component
+{
+ /**
+ * @event Event an event that is triggered after a DB connection is established.
+ */
+ public const EVENT_AFTER_OPEN = 'afterOpen';
+
+ /**
+ * @var bool whether to autodetect available cluster nodes on [[open()]].
+ */
+ public bool $autodetectCluster = true;
+ /**
+ * @var array The Elasticsearch cluster nodes to connect to.
+ *
+ * This is populated with the result of a cluster nodes request when [[autodetectCluster]] is true.
+ *
+ * Additional special options:
+ *
+ * - `auth`: overrides [[auth]] property. For example:
+ *
+ * ```php
+ * [
+ * 'http_address' => 'inet[/127.0.0.1:9200]',
+ * 'auth' => [
+ * 'username' => 'yiiuser',
+ * 'password' => 'yiipw'
+ * ], // Overrides the `auth` property of the class with specific login and password
+ * //'auth' => [
+ * 'username' => 'yiiuser',
+ * 'password' => 'yiipw'
+ * ], // Disabled auth regardless of `auth` property of the class
+ * ]
+ * ```
+ *
+ * - `protocol`: explicitly sets the protocol for the current node (useful when manually defining an HTTPS cluster)
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-nodes-info.html#cluster-nodes-info
+ */
+ public array $nodes = [
+ ['http_address' => 'inet[/127.0.0.1:9200]'],
+ ];
+ /**
+ * @var int|string|null the active node. Key of one of the [[nodes]]. Will be randomly selected on [[open()]].
+ */
+ public string|int|null $activeNode = null;
+ /**
+ * @var array Authentication data used to connect to the Elasticsearch node.
+ *
+ * Array elements:
+ *
+ * - `username`: the username for authentication.
+ * - `password`: the password for authentication.
+ *
+ * Array either MUST contain both username and password on not contain any authentication credentials.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-authenticate.html
+ */
+ public array $auth = [];
+ /**
+ * Elasticsearch has no knowledge of the protocol used to access its nodes.
+ * Specifically, cluster autodetect request returns node hosts and ports, but not the protocols to access them.
+ * Therefore, we need to specify a default protocol here, which can be overridden for specific nodes in the
+ * [[nodes]] property.
+ * If [[autodetectCluster]] is true, all nodes received from cluster will be set to use the protocol defined by
+ * [[defaultProtocol]].
+ *
+ * @var string Default protocol to connect to nodes.
+ */
+ public string $defaultProtocol = 'http';
+ /**
+ * @var float|null timeout to use for connecting to an Elasticsearch node.
+ * This value will be used to configure the curl `CURLOPT_CONNECTTIMEOUT` option.
+ * If not set, no explicit timeout will be set for curl.
+ */
+ public float|null $connectionTimeout = null;
+ /**
+ * @var float|null timeout to use when reading the response from an Elasticsearch node.
+ * This value will be used to configure the curl `CURLOPT_TIMEOUT` option.
+ * If not set, no explicit timeout will be set for curl.
+ */
+ public float|null $dataTimeout = null;
+ /**
+ * @var array additional options used to configure curl session.
+ */
+ public array $curlOptions = [];
+ /**
+ * @var int version of the domain-specific language to use with the server.
+ * This must be set to the major version of the Elasticsearch server in use, e.g. `5` for Elasticsearch 5.x.x, `6`
+ * for Elasticsearch 6.x.x, and `7` for Elasticsearch 7.x.x.
+ */
+ public int $dslVersion = 5;
+
+ /**
+ * @var CurlHandle|null the curl instance returned by [curl_init()](https://php.net/manual/en/function.curl-init.php).
+ */
+ private CurlHandle|null $_curl = null;
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function init(): void
+ {
+ foreach ($this->nodes as &$node) {
+ if (!isset($node['http_address'])) {
+ throw new InvalidConfigException(
+ 'Elasticsearch node needs at least a http_address configured.'
+ );
+ }
+
+ if (!isset($node['protocol'])) {
+ $node['protocol'] = $this->defaultProtocol;
+ }
+
+ if (!in_array($node['protocol'], ['http', 'https'])) {
+ throw new InvalidConfigException('Valid node protocol settings are "http" and "https".');
+ }
+ }
+ }
+
+ /**
+ * Closes the connection when this component is being serialized.
+ */
+ public function __sleep()
+ {
+ $this->close();
+
+ return array_keys(get_object_vars($this));
+ }
+
+ /**
+ * Returns a value indicating whether the DB connection is established.
+ *
+ * @return bool whether the DB connection is established.
+ */
+ public function getIsActive(): bool
+ {
+ return $this->activeNode !== null;
+ }
+
+ /**
+ * Establishes a DB connection.
+ * It does nothing if a DB connection has already been established.
+ *
+ * @throws Exception if connection fails.
+ * @throws InvalidConfigException
+ * @throws \Exception
+ */
+ public function open(): void
+ {
+ if ($this->activeNode !== null) {
+ return;
+ }
+
+ if (empty($this->nodes)) {
+ throw new InvalidConfigException('Elasticsearch needs at least one node to operate.');
+ }
+
+ $this->_curl = curl_init();
+
+ if ($this->autodetectCluster) {
+ $this->populateNodes();
+ }
+
+ $this->selectActiveNode();
+
+ Yii::debug(
+ 'Opening connection to Elasticsearch. Nodes in cluster: ' . count($this->nodes) . ', active node: ' .
+ $this->nodes[$this->activeNode]['http_address'],
+ __CLASS__
+ );
+
+ $this->initConnection();
+ }
+
+ /**
+ * Populates [[nodes]] with the result of a cluster nodes request.
+ *
+ * @throws Exception if no active node(s) found.
+ * @throws InvalidConfigException
+ */
+ protected function populateNodes(): void
+ {
+ $node = reset($this->nodes);
+ $host = $node['http_address'];
+ $protocol = $node['protocol'] ?? $this->defaultProtocol;
+
+ if (strncmp($host, 'inet[/', 6) === 0) {
+ $host = substr($host, 6, -1);
+ }
+
+ $response = $this->httpRequest('GET', "$protocol://$host/_nodes/_all/http");
+
+ if (!empty($response['nodes'])) {
+ $nodes = $response['nodes'];
+ } else {
+ $nodes = [];
+ }
+
+ foreach ($nodes as $key => &$node) {
+ // Make sure that nodes have a 'http_address' property, which is not the case if you're using AWS
+ // Elasticsearch service (at least as of Oct., 2015). - TO BE VERIFIED
+ // Temporary workaround - simply ignore all invalid nodes
+ if (!isset($node['http']['publish_address'])) {
+ unset($nodes[$key]);
+ }
+
+ $node['http_address'] = $node['http']['publish_address'];
+
+ // Protocol is not a standard ES node property, so we add it manually
+ $node['protocol'] = $this->defaultProtocol;
+ }
+
+ if (!empty($nodes)) {
+ $this->nodes = array_values($nodes);
+ } else {
+ curl_close($this->_curl);
+
+ 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.'
+ );
+ }
+ }
+
+ /**
+ * Select active node randomly.
+ *
+ * @throws \Exception
+ */
+ protected function selectActiveNode(): void
+ {
+ $keys = array_keys($this->nodes);
+ $this->activeNode = $keys[random_int(0, count($keys) - 1)];
+ }
+
+ /**
+ * Closes the currently active DB connection.
+ * It does nothing if the connection is already closed.
+ */
+ public function close(): void
+ {
+ if ($this->activeNode === null) {
+ return;
+ }
+
+ Yii::debug(
+ 'Closing connection to Elasticsearch. Active node was: ' .
+ $this->nodes[$this->activeNode]['http']['publish_address'],
+ __CLASS__,
+ );
+
+ $this->activeNode = null;
+
+ if ($this->_curl) {
+ curl_close($this->_curl);
+ $this->_curl = null;
+ }
+ }
+
+ /**
+ * Initializes the DB connection.
+ * This method is invoked right after the DB connection is established.
+ * The default implementation triggers an [[EVENT_AFTER_OPEN]] event.
+ */
+ protected function initConnection(): void
+ {
+ $this->trigger(self::EVENT_AFTER_OPEN);
+ }
+
+ /**
+ * Returns the name of the DB driver for the current [[dsn]].
+ *
+ * @return string name of the DB driver.
+ */
+ public function getDriverName(): string
+ {
+ return 'elasticsearch';
+ }
+
+ /**
+ * Creates a command for execution.
+ *
+ * @param array $config the configuration for the Command class.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return Command the DB command.
+ */
+ public function createCommand(array $config = []): Command
+ {
+ $this->open();
+ $config['db'] = $this;
+
+ return new Command($config);
+ }
+
+ /**
+ * Creates a bulk command for execution.
+ *
+ * @param array $config the configuration for the [[BulkCommand]] class.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return BulkCommand the DB command.
+ */
+ public function createBulkCommand(array $config = []): BulkCommand
+ {
+ $this->open();
+ $config['db'] = $this;
+
+ return new BulkCommand($config);
+ }
+
+ /**
+ * Creates new query builder instance.
+ *
+ * @return QueryBuilder
+ */
+ public function getQueryBuilder(): QueryBuilder
+ {
+ return new QueryBuilder($this);
+ }
+
+ /**
+ * Performs GET HTTP request.
+ *
+ * @param array|string $url URL.
+ * @param array $options URL options.
+ * @param string|null $body request body.
+ * @param bool $raw if response body contains JSON and should be decoded.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed response.
+ */
+ public function get(array|string $url, array $options = [], string $body = null, bool $raw = false): mixed
+ {
+ $this->open();
+
+ return $this->httpRequest('GET', $this->createUrl($url, $options), $body, $raw);
+ }
+
+ /**
+ * Performs HEAD HTTP request.
+ *
+ * @param array|string $url URL.
+ * @param array $options URL options.
+ * @param string|null $body request body.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed response.
+ */
+ public function head(array|string $url, array $options = [], string $body = null): mixed
+ {
+ $this->open();
+
+ return $this->httpRequest('HEAD', $this->createUrl($url, $options), $body);
+ }
+
+ /**
+ * Performs POST HTTP request.
+ *
+ * @param array|string $url URL.
+ * @param array $options URL options.
+ * @param string|null $body request body.
+ * @param bool $raw if response body contains JSON and should be decoded.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed response
+ */
+ public function post(array|string $url, array $options = [], string $body = null, bool $raw = false): mixed
+ {
+ $this->open();
+
+ return $this->httpRequest('POST', $this->createUrl($url, $options), $body, $raw);
+ }
+
+ /**
+ * Performs PUT HTTP request.
+ *
+ * @param array|string $url URL.
+ * @param array $options URL options.
+ * @param string|null $body request body.
+ * @param bool $raw if response body contains JSON and should be decoded.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed response
+ */
+ public function put(array|string $url, array $options = [], string $body = null, bool $raw = false): mixed
+ {
+ $this->open();
+
+ return $this->httpRequest('PUT', $this->createUrl($url, $options), $body, $raw);
+ }
+
+ /**
+ * Performs DELETE HTTP request.
+ *
+ * @param array|string $url URL.
+ * @param array $options URL options.
+ * @param string|null $body request body.
+ * @param bool $raw if response body contains JSON and should be decoded.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return mixed response.
+ */
+ public function delete(array|string $url, array $options = [], string $body = null, bool $raw = false): mixed
+ {
+ $this->open();
+
+ return $this->httpRequest('DELETE', $this->createUrl($url, $options), $body, $raw);
+ }
+
+ /**
+ * Creates URL.
+ *
+ * @param array|string $path path.
+ * @param array $options URL options.
+ */
+ private function createUrl(array|string $path, array $options = []): array
+ {
+ if (!is_string($path)) {
+ $url = implode(
+ '/',
+ array_map(
+ static function ($a) {
+ return urlencode(is_array($a) ? implode(',', $a) : (string) $a);
+ },
+ $path,
+ ),
+ );
+
+ if (!empty($options)) {
+ $url .= '?' . http_build_query($options);
+ }
+ } else {
+ $url = $path;
+
+ if (!empty($options)) {
+ $url .= (!str_contains($url, '?') ? '?' : '&') . http_build_query($options);
+ }
+ }
+
+ $node = $this->nodes[$this->activeNode];
+ $protocol = $node['protocol'] ?? $this->defaultProtocol;
+ $host = $node['http_address'];
+
+ return [$protocol, $host, $url];
+ }
+
+ /**
+ * Performs HTTP request.
+ *
+ * @param string $method method name.
+ * @param array|string $url URL.
+ * @param string|null $requestBody request body.
+ * @param bool $raw if response body contains JSON and should be decoded.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception if request failed.
+ *
+ * @return mixed if request failed.
+ */
+ protected function httpRequest(
+ string $method,
+ array|string $url,
+ string $requestBody = null,
+ bool $raw = false
+ ): mixed {
+ $method = strtoupper($method);
+
+ // response body and headers
+ $headers = [];
+ $headersFinished = false;
+ $body = '';
+
+ $options = [
+ CURLOPT_USERAGENT => 'Yii Framework ' . Yii::getVersion() . ' ' . __CLASS__,
+ CURLOPT_RETURNTRANSFER => false,
+ CURLOPT_HEADER => false,
+ // https://www.php.net/manual/en/function.curl-setopt.php#82418
+ CURLOPT_HTTPHEADER => [
+ 'Expect:',
+ 'Content-Type: application/json',
+ ],
+
+ CURLOPT_WRITEFUNCTION => static function ($curl, $data) use (&$body) {
+ $body .= $data;
+ return mb_strlen($data, '8bit');
+ },
+ CURLOPT_HEADERFUNCTION => static function ($curl, $data) use (&$headers, &$headersFinished) {
+ if ($data === '') {
+ $headersFinished = true;
+ } elseif ($headersFinished) {
+ $headersFinished = false;
+ }
+
+ if (!$headersFinished && ($pos = strpos($data, ':')) !== false) {
+ $headers[strtolower(substr($data, 0, $pos))] = trim(substr($data, $pos + 1));
+ }
+
+ return mb_strlen($data, '8bit');
+ },
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_FORBID_REUSE => false,
+ ];
+
+ foreach ($this->curlOptions as $key => $value) {
+ $options[$key] = $value;
+ }
+
+ if (
+ !empty($this->auth) ||
+ (isset($this->nodes[$this->activeNode]['auth']) && $this->nodes[$this->activeNode]['auth'] !== false)
+ ) {
+ $auth = $this->nodes[$this->activeNode]['auth'] ?? $this->auth;
+
+ if (empty($auth['username'])) {
+ throw new InvalidConfigException('Username is required to use authentication');
+ }
+
+ if (empty($auth['password'])) {
+ throw new InvalidConfigException('Password is required to use authentication');
+ }
+
+ $options[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
+ $options[CURLOPT_USERPWD] = $auth['username'] . ':' . $auth['password'];
+ }
+
+ if ($this->connectionTimeout !== null) {
+ $options[CURLOPT_CONNECTTIMEOUT] = $this->connectionTimeout;
+ }
+ if ($this->dataTimeout !== null) {
+ $options[CURLOPT_TIMEOUT] = $this->dataTimeout;
+ }
+ if ($requestBody !== null) {
+ $options[CURLOPT_POSTFIELDS] = $requestBody;
+ }
+ if ($method === 'HEAD') {
+ $options[CURLOPT_NOBODY] = true;
+ unset($options[CURLOPT_WRITEFUNCTION]);
+ } else {
+ $options[CURLOPT_NOBODY] = false;
+ }
+
+ if (is_array($url)) {
+ [$protocol, $host, $q] = $url;
+
+ if (strncmp($host, 'inet[', 5) === 0) {
+ $host = substr($host, 5, -1);
+ if (($pos = strpos($host, '/')) !== false) {
+ $host = substr($host, $pos + 1);
+ }
+ }
+
+ $profile = "$method $q#$requestBody";
+ $url = "$protocol://$host/$q";
+ } else {
+ $profile = false;
+ }
+
+ Yii::debug("Sending request to Elasticsearch node: $method $url\n$requestBody", __METHOD__);
+
+ if ($profile !== false) {
+ Yii::beginProfile($profile, __METHOD__);
+ }
+
+ $this->resetCurlHandle();
+ curl_setopt($this->_curl, CURLOPT_URL, $url);
+ curl_setopt_array($this->_curl, $options);
+
+ if (curl_exec($this->_curl) === false) {
+ throw new Exception(
+ 'Elasticsearch request failed: ' . curl_errno($this->_curl) . ' - ' .
+ curl_error($this->_curl),
+ [
+ 'requestMethod' => $method,
+ 'requestUrl' => $url,
+ 'requestBody' => $requestBody,
+ 'responseHeaders' => $headers,
+ 'responseBody' => $this->decodeErrorBody($body),
+ ],
+ );
+ }
+
+ $responseCode = curl_getinfo($this->_curl, CURLINFO_HTTP_CODE);
+
+ if ($profile !== false) {
+ Yii::endProfile($profile, __METHOD__);
+ }
+
+ if ($responseCode >= 200 && $responseCode < 300) {
+ if ($method === 'HEAD') {
+ return true;
+ }
+ if (isset($headers['content-length']) && ($len = mb_strlen($body, '8bit')) < $headers['content-length']) {
+ throw new Exception(
+ "Incomplete data received from Elasticsearch: $len < {$headers['content-length']}",
+ [
+ 'requestMethod' => $method,
+ 'requestUrl' => $url,
+ 'requestBody' => $requestBody,
+ 'responseCode' => $responseCode,
+ 'responseHeaders' => $headers,
+ 'responseBody' => $body,
+ ],
+ );
+ }
+ if (isset($headers['content-type'])) {
+ if (!strncmp($headers['content-type'], 'application/json', 16)) {
+ return $raw ? $body : Json::decode($body);
+ }
+
+ if (!strncmp($headers['content-type'], 'text/plain', 10)) {
+ return $raw ? $body : array_filter(explode("\n", $body));
+ }
+ }
+
+ throw new Exception(
+ 'Unsupported data received from Elasticsearch: ' . $headers['content-type'],
+ [
+ 'requestMethod' => $method,
+ 'requestUrl' => $url,
+ 'requestBody' => $requestBody,
+ 'responseCode' => $responseCode,
+ 'responseHeaders' => $headers,
+ 'responseBody' => $this->decodeErrorBody($body),
+ ],
+ );
+ }
+
+ if ($responseCode === 404) {
+ return false;
+ }
+
+ throw new Exception(
+ "Elasticsearch request failed with code $responseCode. Response body:\n$body",
+ [
+ 'requestMethod' => $method,
+ 'requestUrl' => $url,
+ 'requestBody' => $requestBody,
+ 'responseCode' => $responseCode,
+ 'responseHeaders' => $headers,
+ 'responseBody' => $this->decodeErrorBody($body),
+ ],
+ );
+ }
+
+ private function resetCurlHandle(): void
+ {
+ // these functions do not get reset by curl automatically
+ static $unsetValues = [
+ CURLOPT_HEADERFUNCTION => null,
+ CURLOPT_WRITEFUNCTION => null,
+ CURLOPT_READFUNCTION => null,
+ CURLOPT_PROGRESSFUNCTION => null,
+ CURLOPT_POSTFIELDS => null,
+ ];
+
+ curl_setopt_array($this->_curl, $unsetValues);
+
+ if (function_exists('curl_reset')) { // since PHP 5.5.0
+ curl_reset($this->_curl);
+ }
+ }
+
+ /**
+ * Try to decode error information if it is valid json, return it if not.
+ */
+ protected function decodeErrorBody(string $body): mixed
+ {
+ try {
+ $decoded = Json::decode($body);
+
+ if (isset($decoded['error']) && !is_array($decoded['error'])) {
+ $decoded['error'] = preg_replace(
+ '/\b\w+?Exception\[/',
+ "\\0\n ",
+ $decoded['error'],
+ );
+ }
+
+ return $decoded;
+ } catch(InvalidArgumentException $e) {
+ return $body;
+ }
+ }
+
+ /**
+ * @throws Exception
+ * @throws InvalidConfigException
+ */
+ public function getNodeInfo()
+ {
+ return $this->get([]);
+ }
+
+ /**
+ * @throws Exception
+ * @throws InvalidConfigException
+ */
+ public function getClusterState()
+ {
+ return $this->get(['_cluster', 'state']);
+ }
+}
diff --git a/src/DebugAction.php b/src/DebugAction.php
new file mode 100644
index 0000000..f9525c9
--- /dev/null
+++ b/src/DebugAction.php
@@ -0,0 +1,102 @@
+
+ */
+class DebugAction extends Action
+{
+ /**
+ * @var string the connection id to use
+ */
+ public string $db = '';
+ /**
+ * @var DebugPanel|null
+ */
+ public DebugPanel|null $panel = null;
+ public $controller;
+
+ /**
+ * @throws NotSupportedException
+ * @throws Exception
+ * @throws InvalidConfigException
+ * @throws HttpException
+ */
+ public function run($logId, $tag): array
+ {
+ $this->controller->loadData($tag);
+
+ $timings = $this->panel->calculateTimings();
+ ArrayHelper::multisort($timings, 3, SORT_DESC);
+
+ if (!isset($timings[$logId])) {
+ throw new HttpException(404, 'Log message not found.');
+ }
+
+ $message = $timings[$logId][1];
+ if (($pos = mb_strpos($message, '#')) !== false) {
+ $url = mb_substr($message, 0, $pos);
+ $body = mb_substr($message, $pos + 1);
+ } else {
+ $url = $message;
+ $body = null;
+ }
+ $method = mb_substr($url, 0, $pos = mb_strpos($url, ' '));
+ $url = mb_substr($url, $pos + 1);
+
+ $options = ['pretty' => 'true'];
+
+ /* @var $db Connection */
+ $db = Yii::$app->get($this->db);
+ $time = microtime(true);
+
+ $result = match ($method) {
+ 'GET' => $db->get($url, $options, $body, true),
+ 'POST' => $db->post($url, $options, $body, true),
+ 'PUT' => $db->put($url, $options, $body, true),
+ 'DELETE' => $db->delete($url, $options, $body, true),
+ 'HEAD' => $db->head($url, $options, $body),
+ default => throw new NotSupportedException("Request method '$method' is not supported by Elasticsearch."),
+ };
+
+ $time = microtime(true) - $time;
+
+ if ($result === true) {
+ $result = 'success';
+ } elseif ($result === false) {
+ $result = 'no success';
+ }
+
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ return [
+ 'time' => sprintf('%.1f ms', $time * 1000),
+ 'result' => $result,
+ ];
+ }
+}
diff --git a/src/DebugPanel.php b/src/DebugPanel.php
new file mode 100644
index 0000000..81806a1
--- /dev/null
+++ b/src/DebugPanel.php
@@ -0,0 +1,230 @@
+
+ */
+class DebugPanel extends Panel
+{
+ public string $db = 'elasticsearch';
+
+ private array|null $_timings = null;
+
+ public function init(): void
+ {
+ $this->actions['elasticsearch-query'] = [
+ 'class' => DebugAction::class,
+ 'panel' => $this,
+ 'db' => $this->db,
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getName(): string
+ {
+ return 'Elasticsearch';
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getSummary(): string
+ {
+ $timings = $this->calculateTimings();
+
+ $queryCount = count($timings);
+ $queryTime = 0;
+
+ foreach ($timings as $timing) {
+ $queryTime += $timing[3];
+ }
+
+ $queryTime = number_format($queryTime * 1000) . ' ms';
+ $url = $this->getUrl();
+
+ $output = <<
+
+ ES $queryCount $queryTime
+
+
+EOD;
+
+ return $queryCount > 0 ? $output : '';
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getDetail(): string
+ {
+ //Register YiiAsset in order to inject csrf token in ajax requests
+ YiiAsset::register(Yii::$app->view);
+
+ $timings = $this->calculateTimings();
+
+ ArrayHelper::multisort($timings, 3, SORT_DESC);
+
+ $rows = [];
+ $i = 0;
+
+ foreach ($timings as $logId => $timing) {
+ $duration = sprintf('%.1f ms', $timing[3] * 1000);
+ $message = $timing[1];
+ $traces = $timing[4];
+
+ if (($pos = mb_strpos($message, '#')) !== false) {
+ $url = mb_substr($message, 0, $pos);
+ $body = mb_substr($message, $pos + 1);
+ } else {
+ $url = $message;
+ $body = null;
+ }
+
+ $traceString = '';
+
+ if (!empty($traces)) {
+ $traceString .= Html::ul($traces, [
+ 'class' => 'trace',
+ 'item' => function ($trace) {
+ return "{$trace['file']}({$trace['line']})";
+ },
+ ]);
+ }
+
+ $ajaxUrl = Url::to(['elasticsearch-query', 'logId' => $logId, 'tag' => $this->tag]);
+
+ Yii::$app->view->registerJs(<<Error: ' + errorThrown + ' - ' + textStatus + '
' + jqXHR.responseText);
+ },
+ dataType: "json"
+ });
+
+ return false;
+});
+JS
+ , View::POS_READY);
+ $runLink = Html::a('run query', '#', ['id' => "elastic-link-$i"]) . '
';
+ $rows[] = <<
+ $duration |
+ |
+ $runLink |
+
+ | |
+HTML;
+ $i++;
+ }
+ $rows = implode("\n", $rows);
+
+ return <<Elasticsearch Queries
+
+
+
+
+ Time |
+ Url / Query |
+ Run Query on node |
+
+
+
+$rows
+
+
+HTML;
+ }
+
+ public function calculateTimings(): array
+ {
+ if ($this->_timings !== null) {
+ return $this->_timings;
+ }
+
+ $messages = $this->data['messages'] ?? [];
+ $timings = [];
+ $stack = [];
+
+ foreach ($messages as $i => $log) {
+ [$token, $level, $category, $timestamp] = $log;
+ $log[5] = $i;
+ if ($level === Logger::LEVEL_PROFILE_BEGIN) {
+ $stack[] = $log;
+ } elseif ($level === Logger::LEVEL_PROFILE_END) {
+ if (($last = array_pop($stack)) !== null && $last[0] === $token) {
+ $timings[$last[5]] = [count($stack), $token, $last[3], $timestamp - $last[3], $last[4]];
+ }
+ }
+ }
+
+ $now = microtime(true);
+
+ while (($last = array_pop($stack)) !== null) {
+ $delta = $now - $last[3];
+ $timings[$last[5]] = [count($stack), $last[0], $last[2], $delta, $last[4]];
+ }
+
+ ksort($timings);
+
+ return $this->_timings = $timings;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function save(): mixed
+ {
+ $target = $this->module->logTarget;
+ $messages = $target->filterMessages(
+ $target->messages,
+ Logger::LEVEL_PROFILE,
+ ['yii\elasticsearch\Connection::httpRequest'],
+ );
+
+ return ['messages' => $messages];
+ }
+}
diff --git a/src/ElasticsearchTarget.php b/src/ElasticsearchTarget.php
new file mode 100644
index 0000000..2131051
--- /dev/null
+++ b/src/ElasticsearchTarget.php
@@ -0,0 +1,194 @@
+
+ */
+class ElasticsearchTarget extends Target
+{
+ /**
+ * @var string Elasticsearch index name.
+ */
+ public string $index = 'yii';
+ /**
+ * @var string Elasticsearch type name.
+ */
+ public string $type = 'log';
+ /**
+ * @var array|Connection|string the Elasticsearch connection object or the application component ID
+ * of the Elasticsearch connection.
+ */
+ public string|array|Connection $db = 'elasticsearch';
+ /**
+ * @var array $options URL options.
+ */
+ public array $options = [];
+ /**
+ * @var bool If true, context will be logged as a separate message after all other messages.
+ */
+ public bool $logContext = true;
+ /**
+ * @var bool If true, context will be included in every message.
+ * This is convenient if you log application errors and analyze them with tools like Kibana.
+ */
+ public bool $includeContext = false;
+ /**
+ * @var bool If true, a context message will cache once it's been created. Makes sense to use with
+ * [[includeContext]].
+ */
+ public bool $cacheContext = false;
+
+ /**
+ * @var array|string|null Context message cache (can be used multiple times if context is appended to every message).
+ */
+ protected array|string|null $_contextMessage = null;
+
+ /**
+ * This method will initialize the [[elasticsearch]] property to make sure it refers to a valid Elasticsearch
+ * connection.
+ *
+ * @throws InvalidConfigException if [[elasticsearch]] is invalid.
+ */
+ public function init(): void
+ {
+ parent::init();
+
+ $this->db = Instance::ensure($this->db, Connection::class);
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ */
+ public function export(): void
+ {
+ $messages = array_map([$this, 'prepareMessage'], $this->messages);
+ $body = implode("\n", $messages) . "\n";
+
+ if ($this->db->dslVersion >= 7) {
+ $this->db->post([$this->index, '_bulk'], $this->options, $body);
+ } else {
+ $this->db->post([$this->index, $this->type, '_bulk'], $this->options, $body);
+ }
+ }
+
+ /**
+ * If [[includeContext]] property is false, returns context message normally.
+ * If [[includeContext]] is true, returns an empty string (so that context message in [[collect]] is not generated),
+ * expecting that context will be appended to every message in [[prepareMessage]].
+ *
+ * @return array|string|null the context information
+ */
+ protected function getContextMessage(): array|string|null
+ {
+ if (null === $this->_contextMessage || !$this->cacheContext) {
+ $this->_contextMessage = ArrayHelper::filter($GLOBALS, $this->logVars);
+ }
+
+ return $this->_contextMessage;
+ }
+
+ /**
+ * Processes the given log messages.
+ * This method will filter the given messages with [[levels]] and [[categories]].
+ * And if requested, it will also export the filtering result to a specific medium (e.g. email).
+ * Depending on the [[includeContext]] attribute, a context message will be either created or ignored.
+ *
+ * @param array $messages log messages to be processed. See [[Logger::messages]] for the structure of each message.
+ * @param bool $final whether this method is called at the end of the current application.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ */
+ public function collect($messages, $final): void
+ {
+ $this->messages = array_merge($this->messages, static::filterMessages($messages, $this->getLevels(), $this->categories, $this->except));
+ $count = count($this->messages);
+ if ($count > 0 && ($final || ($this->exportInterval > 0 && $count >= $this->exportInterval))) {
+ if (!$this->includeContext && $this->logContext) {
+ $context = $this->getContextMessage();
+
+ if (!empty($context)) {
+ $this->messages[] = [$context, Logger::LEVEL_INFO, 'application', YII_BEGIN_TIME];
+ }
+ }
+
+ // set exportInterval to zero to avoid triggering export again while exporting
+ $oldExportInterval = $this->exportInterval;
+ $this->exportInterval = 0;
+ $this->export();
+ $this->exportInterval = $oldExportInterval;
+ $this->messages = [];
+ }
+ }
+
+ /**
+ * Prepares a log message.
+ *
+ * @param array $message The log message to be formatted.
+ *
+ * @return string
+ */
+ public function prepareMessage(array $message): string
+ {
+ [$text, $level, $category, $timestamp] = $message;
+
+ $result = [
+ 'category' => $category,
+ 'level' => Logger::getLevelName($level),
+ '@timestamp' => date('c', (int) $timestamp),
+ ];
+
+ if (isset($message[4])) {
+ $result['trace'] = $message[4];
+ }
+
+ if (!is_string($text)) {
+ // exceptions may not be serializable if in the call stack somewhere is a Closure
+ if ($text instanceof Throwable) {
+ $text = (string) $text;
+ } else {
+ $text = VarDumper::export($text);
+ }
+ }
+ $result['message'] = $text;
+
+ if ($this->includeContext) {
+ $result['context'] = $this->getContextMessage();
+ }
+
+ return implode("\n", [
+ Json::encode([
+ 'index' => new stdClass(),
+ ]),
+ Json::encode($result),
+ ]);
+ }
+}
diff --git a/src/Example.php b/src/Example.php
deleted file mode 100644
index 067eeb8..0000000
--- a/src/Example.php
+++ /dev/null
@@ -1,13 +0,0 @@
-
+ */
+class Exception extends \yii\db\Exception
+{
+ /**
+ * @return string the user-friendly name of this exception
+ */
+ public function getName(): string
+ {
+ return 'Elasticsearch Database Exception';
+ }
+}
diff --git a/src/Query.php b/src/Query.php
new file mode 100644
index 0000000..c5d7e93
--- /dev/null
+++ b/src/Query.php
@@ -0,0 +1,1034 @@
+storedFields('id, name')
+ * ->from('myindex', 'users')
+ * ->limit(10);
+ * // build and execute the query
+ * $command = $query->createCommand();
+ * $rows = $command->search(); // this way you get the raw output of Elasticsearch.
+ * ~~~
+ *
+ * You would normally call `$query->search()` instead of creating a command as
+ * this method adds the `indexBy()` feature and also removes some
+ * inconsistencies from the response.
+ *
+ * Query also provides some methods to easier get some parts of the result only:
+ *
+ * - [[one()]]: returns a single record populated with the first row of data.
+ * - [[all()]]: returns all records based on the query results.
+ * - [[count()]]: returns the number of records.
+ * - [[scalar()]]: returns the value of the first column in the first row of the query result.
+ * - [[column()]]: returns the value of the first column in the query result.
+ * - [[exists()]]: returns a value indicating whether the query result has data or not.
+ *
+ * NOTE: Elasticsearch limits the number of records returned to 10 records by default.
+ *
+ * If you expect to get more records, you should specify the limit explicitly.
+ *
+ * @author Carsten Brandt
+ */
+class Query extends Component implements QueryInterface
+{
+ use QueryTrait;
+
+ /**
+ * @var array|null the fields being retrieved from the documents. For example, `['id', 'name']`.
+ *
+ * If not set, this option will not be applied to the query and no fields will be returned.
+ * In this case, the `_source` field will be returned by default which can be configured using [[source]].
+ * Setting this to an empty array will result in no fields being retrieved, which means that only the primaryKey of
+ * a record will be available in the result.
+ *
+ * > Note: Field values are [always returned as arrays] even if they only have one value.
+ *
+ * [always returned as arrays]: https://www.elastic.co/guide/en/elasticsearch/reference/current/array.html
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-stored-fields.html
+ * @see storedFields()
+ * @see source
+ */
+ public array|null $storedFields = null;
+ /**
+ * @var array|null the scripted fields being retrieved from the documents.
+ *
+ * Example:
+ * ```php
+ * $query->scriptFields = [
+ * 'value_times_two' => [
+ * 'script' => "doc['my_field_name'].value * 2",
+ * ],
+ * 'value_times_factor' => [
+ * 'script' => "doc['my_field_name'].value * factor",
+ * 'params' => [
+ * 'factor' => 2.0
+ * ],
+ * ],
+ * ]
+ * ```
+ *
+ * > Note: Field values are [always returned as arrays] even if they only have one value.
+ *
+ * [always returned as arrays]: https://www.elastic.co/guide/en/elasticsearch/reference/current/array.html
+ * [script field]: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-script-fields.html
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-script-fields.html
+ * @see scriptFields()
+ * @see source
+ */
+ public array|null $scriptFields = null;
+ /**
+ * @var array|null An array of runtime fields evaluated at query time
+ *
+ * Example:
+ * ```php
+ * $query->$runtimeMappings = [
+ * 'value_times_two' => [
+ * 'type' => 'double',
+ * 'script' => "emit(doc['my_field_name'].value * 2)",
+ * ],
+ * 'value_times_factor' => [
+ * 'type' => 'double',
+ * 'script' => "emit(doc['my_field_name'].value * factor)",
+ * 'params' => [
+ * 'factor' => 2.0
+ * ],
+ * ],
+ * ]
+ * ```
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-mapping-fields.html
+ * @see runtimeMappings()
+ * @see source
+ */
+ public array|null $runtimeMappings = null;
+ /**
+ * @var array|null Use the field parameter to retrieve the values of runtime fields. Runtime fields won't be
+ * displayed in _source, but the fields API work for all fields, even those that were not sent as part of the
+ * original _source.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-retrieving-fields.html
+ * @see fields()
+ * @see fields
+ */
+ public array|null $fields = null;
+ /**
+ * @var array|bool|null this option controls how the `_source` field is returned from the documents.
+ * For example, `['id', 'name']` means that only the `id` and `name` field should be returned from `_source`.
+ * If not set, it means retrieving the full `_source` field unless [[fields]] are specified.
+ * Setting this option to `false` will disable return of the `_source` field, this means that only the primaryKey of
+ * a record will be available in the result.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html
+ * @see source()
+ * @see fields
+ */
+ public array|bool|null $source = null;
+ /**
+ * @var array|string|null The index to retrieve data from. This can be a string representing a single index or an
+ * array of multiple indexes.
+ * If this is not set, indexes are being queried.
+ *
+ * @see from()
+ */
+ public string|array|null $index = null;
+ /**
+ * @var array|string|null The type to retrieve data from. This can be a string representing a single type or an
+ * array of multiple types.
+ * If this is not set, all types are being queried.
+ *
+ * @see from()
+ */
+ public string|array|null $type = null;
+ /**
+ * @var int|null A search timeout, bounding the search request to be executed within the specified time value and
+ * bail with the hits accumulated up to that point when expired.
+ *
+ * Defaults to no timeout.
+ *
+ * @see timeout()
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html#global-search-timeout
+ */
+ public int|null $timeout = null;
+ /**
+ * @var array|string The query part of this search query. This is an array or json string that follows the format of
+ * the elasticsearch.
+ * [Query DSL](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html).
+ */
+ public string|array $query = [];
+ /**
+ * @var array|string The filter part of this search query. This is an array or json string that follows the format
+ * of the elasticsearch.
+ * [Query DSL](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html).
+ */
+ public string|array $filter = [];
+ /**
+ * @var array|string The `post_filter` part of the search query for differential filter search results and
+ * aggregations.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/guide/current/_post_filter.html
+ */
+ public string|array $postFilter = [];
+ /**
+ * @var array The highlight part of this search query. This is an array that allows to highlight search results
+ * on one or more fields.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-highlighting.html
+ */
+ public array $highlight = [];
+ /**
+ * @var array List of aggregations to add to this query.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html
+ */
+ public array $aggregations = [];
+ /**
+ * @var array the 'stats' part of the query. An array of groups to maintain a statistics aggregation for.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html#stats-groups
+ */
+ public array $stats = [];
+ /**
+ * @var array list of suggesters to add to this query.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html
+ */
+ public array $suggest = [];
+ /**
+ * @var array list of collapse to add to this query.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html
+ */
+ public array $collapse = [];
+ /**
+ * @var float|null Exclude documents which have a _score less than the minimum specified in min_score
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-min-score.html
+ */
+ public float|null $minScore = null;
+ /**
+ * @var array list of options that will be passed to commands created by this query.
+ *
+ * @see Command::$options
+ */
+ public array $options = [];
+ /**
+ * @var bool Enables explanation for each hit on how its score was computed.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-explain.html
+ */
+ public bool $explain = false;
+
+ /**
+ * @inheritdoc
+ */
+ public function init(): void
+ {
+ parent::init();
+ // setting the default limit according to Elasticsearch defaults
+ // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html#_parameters_5
+ if ($this->limit === null) {
+ $this->limit = 10;
+ }
+ }
+
+ /**
+ * Creates a DB command that can be used to execute this query.
+ *
+ * @param Connection|null $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application
+ * component will be used.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return Command the created DB command instance.
+ */
+ public function createCommand(Connection $db = null): Command
+ {
+ if ($db === null) {
+ $db = Yii::$app->get('elasticsearch');
+ }
+
+ $commandConfig = $db->getQueryBuilder()->build($this);
+
+ return $db->createCommand($commandConfig);
+ }
+
+ /**
+ * Executes the query and returns all results as an array.
+ *
+ * @param Connection $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application component will be used.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return array the query results. If the query results in nothing, an empty array will be returned.
+ */
+ public function all($db = null): array
+ {
+ if ($this->emulateExecution) {
+ return [];
+ }
+
+ $result = $this->createCommand($db)->search();
+
+ if ($result === false) {
+ throw new Exception('Elasticsearch search query failed.');
+ }
+
+ if (empty($result['hits']['hits'])) {
+ return [];
+ }
+
+ $rows = $result['hits']['hits'];
+
+ return $this->populate($rows);
+ }
+
+ /**
+ * Converts the raw query results into the format as specified by this query.
+ * This method is internally used to convert the data fetched from a database into the format as required by this
+ * query.
+ *
+ * @param array $rows the raw query result from a database.
+ *
+ * @return array the converted query result.
+ */
+ public function populate(array $rows): array
+ {
+ if ($this->indexBy === null) {
+ return $rows;
+ }
+
+ $models = [];
+
+ foreach ($rows as $key => $row) {
+ if ($this->indexBy !== null) {
+ if (is_string($this->indexBy)) {
+ $key = isset($row['fields'][$this->indexBy]) ?
+ reset($row['fields'][$this->indexBy]) : $row['_source'][$this->indexBy];
+ } else {
+ $key = ($this->indexBy)($row);
+ }
+ }
+
+ $models[$key] = $row;
+ }
+
+ return $models;
+ }
+
+ /**
+ * Executes the query and returns a single row of a result.
+ *
+ * @param Connection $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application component will be used.
+ *
+ * @throws InvalidConfigException
+ * @throws Exception
+ *
+ * @return ActiveRecord|array|bool|null the first row (in terms of an array) of the query result.
+ * False is returned if the query results in nothing.
+ */
+ public function one($db = null)
+ {
+ if ($this->emulateExecution) {
+ return false;
+ }
+
+ $result = $this->createCommand($db)->search(['size' => 1]);
+
+ if ($result === false) {
+ throw new Exception('Elasticsearch search query failed.');
+ }
+
+ if (empty($result['hits']['hits'])) {
+ return false;
+ }
+
+ return reset($result['hits']['hits']);
+ }
+
+ /**
+ * Executes the query and returns the complete search result including e.g. hits, aggregations, suggesters,
+ * totalCount.
+ *
+ * @param Connection|null $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application component will be used.
+ * @param array $options The options given with this query.
+ * Possible options are:
+ *
+ * - [routing](https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html#search-routing)
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return array the query results.
+ */
+ public function search(Connection $db = null, array $options = [])
+ {
+ if ($this->emulateExecution) {
+ return [
+ 'hits' => [
+ 'total' => 0,
+ 'hits' => [],
+ ],
+ ];
+ }
+
+ $result = $this->createCommand($db)->search($options);
+
+ if ($result === false) {
+ throw new Exception('Elasticsearch search query failed.');
+ }
+
+ if (!empty($result['hits']['hits']) && $this->indexBy !== null) {
+ $rows = [];
+ foreach ($result['hits']['hits'] as $row) {
+ if (is_string($this->indexBy)) {
+ $key = $row['fields'][$this->indexBy] ?? $row['_source'][$this->indexBy];
+ } else {
+ $key = ($this->indexBy)($row);
+ }
+ $rows[$key] = $row;
+ }
+ $result['hits']['hits'] = $rows;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Executes the query and deletes all matching documents.
+ *
+ * Everything except query and filter will be ignored.
+ *
+ * @param Connection|null $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application component will be used.
+ * @param array $options The options given with this query.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return array the query results.
+ */
+ public function delete(Connection $db = null, array $options = []): array
+ {
+ if ($this->emulateExecution) {
+ return [];
+ }
+
+ return $this->createCommand($db)->deleteByQuery($options);
+ }
+
+ /**
+ * Returns the query results as a scalar value.
+ *
+ * The value returned will be the specified field in the first document of the query results.
+ *
+ * @param string $field name of the attribute to select.
+ * @param Connection|null $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application component will be used.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return string|null the value of the specified attribute in the first record of the query result.
+ * Null is returned if the query result is empty or the field does not exist.
+ */
+ public function scalar(string $field, Connection $db = null): string|null
+ {
+ if ($this->emulateExecution) {
+ return null;
+ }
+
+ $record = self::one($db);
+
+ if ($record !== false) {
+ if ($field === '_id') {
+ return $record['_id'];
+ }
+
+ if (isset($record['_source'][$field])) {
+ return $record['_source'][$field];
+ }
+
+ if (isset($record['fields'][$field])) {
+ return count($record['fields'][$field]) === 1
+ ? reset($record['fields'][$field])
+ : $record['fields'][$field];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Executes the query and returns the first column of the result.
+ *
+ * @param string $field the field to query over.
+ * @param Connection|null $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application component will be used.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return array the first column of the query result. An empty array is returned if the query results in nothing.
+ */
+ public function column(string $field, Connection $db = null): array
+ {
+ if ($this->emulateExecution) {
+ return [];
+ }
+
+ $command = $this->createCommand($db);
+ $command->queryParts['_source'] = [$field];
+ $result = $command->search();
+
+ if ($result === false) {
+ throw new Exception('Elasticsearch search query failed.');
+ }
+
+ if (empty($result['hits']['hits'])) {
+ return [];
+ }
+
+ $column = [];
+
+ foreach ($result['hits']['hits'] as $row) {
+ if (isset($row['fields'][$field])) {
+ $column[] = $row['fields'][$field];
+ } elseif (isset($row['_source'][$field])) {
+ $column[] = $row['_source'][$field];
+ } else {
+ $column[] = null;
+ }
+ }
+
+ return $column;
+ }
+
+ /**
+ * Returns the number of records.
+ *
+ * @param string $q the COUNT expression. This parameter is ignored by this implementation.
+ * @param Connection $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application component will be used.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return int number of records.
+ */
+ public function count($q = '*', $db = null): int
+ {
+ if ($this->emulateExecution) {
+ return 0;
+ }
+
+ $command = $this->createCommand($db);
+
+ // performing a query with return size of 0, is equal to getting result stats such as count
+ // https://www.elastic.co/guide/en/elasticsearch/reference/5.6/breaking_50_search_changes.html#_literal_search_type_literal
+ $searchOptions = ['size' => 0];
+
+ // Set track_total_hits to 'true' for ElasticSearch version 6 and up
+ // https://www.elastic.co/guide/en/elasticsearch/reference/master/search-your-data.html#track-total-hits
+ if ($command->db->dslVersion >= 6) {
+ $searchOptions['track_total_hits'] = 'true';
+ }
+
+ $result = $command->search($searchOptions);
+
+ // since ES7 totals are returned as an array (with count and precision values)
+ if (isset($result['hits']['total'])) {
+ return is_array($result['hits']['total']) ? (int)$result['hits']['total']['value'] : (int)$result['hits']['total'];
+ }
+
+ return 0;
+ }
+
+ /**
+ * Returns a value indicating whether the query result contains any row of data.
+ *
+ * @param Connection $db the database connection used to execute the query.
+ * If this parameter is not given, the `elasticsearch` application component will be used.
+ *
+ * @throws Exception
+ * @throws InvalidConfigException
+ *
+ * @return bool whether the query result contains any row of data.
+ */
+ public function exists($db = null): bool
+ {
+ return self::one($db) !== false;
+ }
+
+ /**
+ * Adds a 'stats' part to the query.
+ *
+ * @param array $groups an array of groups to maintain a statistics aggregation for.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html#stats-groups
+ */
+ public function stats(array $groups): static
+ {
+ $this->stats = $groups;
+
+ return $this;
+ }
+
+ /**
+ * Sets a highlight parameters to retrieve from the documents.
+ *
+ * @param array $highlight array of parameters to highlight results.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-highlighting.html
+ */
+ public function highlight(array $highlight): static
+ {
+ $this->highlight = $highlight;
+
+ return $this;
+ }
+
+ /**
+ * Adds an aggregation to this query. Supports nested aggregations.
+ *
+ * @param string $name the name of the aggregation.
+ * @param array|string $options the configuration options for this aggregation. Can be an array or a json string.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.3/search-aggregations.html
+ */
+ public function addAggregate(string $name, array|string $options): static
+ {
+ $this->aggregations[$name] = $options;
+
+ return $this;
+ }
+
+ /**
+ * Adds a suggester to this query.
+ *
+ * @param string $name the name of the suggester.
+ * @param array|string $definition the configuration options for this suggester.
+ * Can be an array or a json string.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html
+ */
+ public function addSuggester(string $name, array|string $definition): static
+ {
+ $this->suggest[$name] = $definition;
+
+ return $this;
+ }
+
+ /**
+ * Adds a collapse to this query.
+ *
+ * @param array $collapse the configuration options for collapse.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.3/search-request-collapse.html#search-request-collapse
+ */
+ public function addCollapse(array $collapse): static
+ {
+ $this->collapse = $collapse;
+
+ return $this;
+ }
+
+ // TODO add validate query https://www.elastic.co/guide/en/elasticsearch/reference/current/search-validate.html
+ // TODO support multi query via static method https://www.elastic.co/guide/en/elasticsearch/reference/current/search-multi-search.html
+
+ /**
+ * Sets the query part of this search query.
+ *
+ * @param array|string $query the query to be set.
+ *
+ * @return static the query object itself.
+ */
+ public function query(array|string $query): static
+ {
+ $this->query = $query;
+
+ return $this;
+ }
+
+ /**
+ * Starts a batch query.
+ *
+ * A batch query supports fetching data in batches, which can keep the memory usage under a limit.
+ * This method will return a [[BatchQueryResult]] object which implements the [[\Iterator]] interface and can be
+ * traversed to retrieve the data in batches.
+ *
+ * For example,
+ *
+ * ```php
+ * $query = (new Query)->from('user');
+ * foreach ($query->batch() as $rows) {
+ * // $rows is an array of 10 or fewer rows from user table
+ * }
+ * ```
+ *
+ * Batch size is determined by the `limit` setting (note that in scan mode batch limit is per shard).
+ *
+ * @param string $scrollWindow how long Elasticsearch should keep the search context alive, in
+ * [time units](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units)
+ * @param Connection|null $db the database connection. If not set, the `elasticsearch` application component will be
+ * used.
+ *
+ * @throws InvalidConfigException
+ *
+ * @return BatchQueryResult the batch query result. It implements the [[\Iterator]] interface and can be traversed
+ * to retrieve the data in batches.
+ */
+ public function batch(string $scrollWindow = '1m', Connection $db = null): BatchQueryResult
+ {
+ return Yii::createObject([
+ 'class' => BatchQueryResult::className(),
+ 'query' => $this,
+ 'scrollWindow' => $scrollWindow,
+ 'db' => $db,
+ 'each' => false,
+ ]);
+ }
+
+ /**
+ * Starts a batch query and retrieves data row by row.
+ *
+ * This method is similar to [[batch()]] except that in each iteration of the result, only one row of data is
+ * returned.
+ *
+ * For example,
+ *
+ * ```php
+ * $query = (new Query)->from('user');
+ * foreach ($query->each() as $row) {
+ * }
+ * ```
+ *
+ * @param string $scrollWindow how long Elasticsearch should keep the search context alive, in
+ * [time units](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units)
+ * @param Connection|null $db the database connection. If not set, the `elasticsearch` application component will be
+ * used.
+ *
+ * @throws InvalidConfigException
+ *
+ * @return BatchQueryResult the batch query result. It implements the [[\Iterator]] interface and can be traversed
+ * to retrieve the data in batches.
+ */
+ public function each(string $scrollWindow = '1m', Connection $db = null): BatchQueryResult
+ {
+ return Yii::createObject([
+ 'class' => BatchQueryResult::className(),
+ 'query' => $this,
+ 'scrollWindow' => $scrollWindow,
+ 'db' => $db,
+ 'each' => true,
+ ]);
+ }
+
+ /**
+ * Sets the index and type to retrieve documents from.
+ *
+ * @param array|string $index The index to retrieve data from. This can be a string representing a single index or a
+ * an array of multiple indexes.
+ * If this is `null`, it means that all indexes are being queried.
+ * @param array|string|null $type The type to retrieve data from. This can be a string representing a single type or
+ * an array of multiple types.
+ * If this is `null`, it means that all types are being queried.
+ *
+ * @return $this the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html#search-multi-index-type
+ */
+ public function from(array|string $index, array|string $type = null): static
+ {
+ $this->index = $index;
+ $this->type = $type;
+
+ return $this;
+ }
+
+ /**
+ * Sets the fields to retrieve from the documents.
+ *
+ * Quote from the Elasticsearch doc:
+ * > The stored_fields parameter is about fields that are explicitly marked
+ * > as stored in the mapping, which is off by default and generally not
+ * > recommended. Use source filtering instead to select subsets of the
+ * > original source document to be returned.
+ *
+ * @param array|string|null $fields the fields to be selected.
+ *
+ * @return static the query object itself
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-stored-fields.html
+ */
+ public function storedFields(array|string|null $fields): static
+ {
+ match (is_array($fields) || $fields === null) {
+ true => $this->storedFields = $fields,
+ default => $this->storedFields = func_get_args(),
+ };
+
+ return $this;
+ }
+
+ /**
+ * Sets the script fields to retrieve from the documents.
+ *
+ * @param array|string|null $fields the fields to be selected.
+ *
+ * @return static the query object itself
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-script-fields.html
+ */
+ public function scriptFields(array|string|null $fields): static
+ {
+ match (is_array($fields) || $fields === null) {
+ true => $this->scriptFields = $fields,
+ default => $this->scriptFields = func_get_args(),
+ };
+
+ return $this;
+ }
+
+ /**
+ * Sets the runtime mappings for this query
+ *
+ * @param array|string|null $mappings the mappings to be set.
+ *
+ * @return static the query object itself
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime.html
+ */
+ public function runtimeMappings(array|string|null $mappings): static
+ {
+ match (is_array($mappings) || $mappings === null) {
+ true => $this->runtimeMappings = $mappings,
+ default => $this->runtimeMappings = func_get_args(),
+ };
+
+ return $this;
+ }
+
+ /**
+ * Sets the runtime fields to retrieve from the documents.
+ *
+ * @param array|string|null $fields the fields to be selected.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-retrieving-fields.html
+ */
+ public function fields(array|string|null $fields): static
+ {
+ match (is_array($fields) || $fields === null) {
+ true => $this->fields = $fields,
+ default => $this->fields = func_get_args(),
+ };
+
+ return $this;
+ }
+
+ /**
+ * Sets the source filtering, specifying how the `_source` field of the document should be returned.
+ *
+ * @param array|bool|string|null $source the source patterns to be selected.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html
+ */
+ public function source(bool|array|string|null $source): static
+ {
+ match (is_array($source) || $source === null || $source === false) {
+ true => $this->source = $source,
+ default => $this->source = func_get_args(),
+ };
+
+ return $this;
+ }
+
+ /**
+ * Sets the search timeout.
+ *
+ * @param int $timeout A search timeout, bounding the search request to be executed within the specified time value
+ * and bail with the hits accumulated up to that point when it expired.
+ *
+ * Defaults to no timeout.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html#_parameters_5
+ */
+ public function timeout(int $timeout): static
+ {
+ $this->timeout = $timeout;
+
+ return $this;
+ }
+
+ /**
+ * @param float $minScore Exclude documents which have a `_score` less than the minimum specified minScore.
+ *
+ * @return static the query object itself.
+ *
+ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-min-score.html
+ */
+ public function minScore(float $minScore): static
+ {
+ $this->minScore = $minScore;
+
+ return $this;
+ }
+
+ /**
+ * Sets the options to be passed to the command created by this query.
+ *
+ * @param array $options the options to be set.
+ *
+ * @throws InvalidArgumentException if $options is not an array.
+ *
+ * @return static the query object itself.
+ *
+ * @see Command::$options
+ */
+ public function options(array $options): static
+ {
+ $this->options = $options;
+
+ return $this;
+ }
+
+ /**
+ * Adds more options, overwriting existing options.
+ *
+ * @param array $options the options to be added.
+ *
+ * @throws InvalidArgumentException if $options is not an array.
+ *
+ * @return static the query object itself.
+ *
+ * @see options()
+ */
+ public function addOptions(array $options): static
+ {
+ $this->options = array_merge($this->options, $options);
+
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function andWhere($condition): QueryInterface
+ {
+ if ($this->where === null) {
+ $this->where = $condition;
+ } elseif (isset($this->where[0]) && $this->where[0] === 'and') {
+ $this->where[] = $condition;
+ } else {
+ $this->where = ['and', $this->where, $condition];
+ }
+
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function orWhere($condition): QueryInterface
+ {
+ if ($this->where === null) {
+ $this->where = $condition;
+ } elseif (isset($this->where[0]) && $this->where[0] === 'or') {
+ $this->where[] = $condition;
+ } else {
+ $this->where = ['or', $this->where, $condition];
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set the `post_filter` part of the search query.
+ *
+ * @param array|string $filter the filter to be set.
+ *
+ * @return static the query object itself.
+ *
+ * @see $postFilter
+ */
+ public function postFilter(array|string $filter): static
+ {
+ $this->postFilter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Explain for how the score of each document was computer.
+ *
+ * @param bool $explain whether to explain the score computation for each hit or not.
+ *
+ * @return static the query object itself.
+ *
+ * @see $explain
+ */
+ public function explain(bool $explain): static
+ {
+ $this->explain = $explain;
+ return $this;
+ }
+}
diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php
new file mode 100644
index 0000000..25e4abb
--- /dev/null
+++ b/src/QueryBuilder.php
@@ -0,0 +1,518 @@
+
+ */
+class QueryBuilder extends BaseObject
+{
+ public function __construct(public Connection $db, array $config = [])
+ {
+ parent::__construct($config);
+ }
+
+ /**
+ * Generates query from a [[Query]] object.
+ *
+ * @param Query $query the [[Query]] object from which the query will be generated.
+ *
+ * @throws NotSupportedException
+ *
+ * @return array the generated SQL statement (the first array element) and the corresponding parameters to be bound
+ * to the SQL statement (the second array element).
+ */
+ public function build(Query $query): array
+ {
+ $parts = [];
+
+ if ($query->storedFields !== null) {
+ $parts['stored_fields'] = $query->storedFields;
+ }
+
+ if ($query->scriptFields !== null) {
+ $parts['script_fields'] = $query->scriptFields;
+ }
+
+ if ($query->runtimeMappings !== null) {
+ $parts['runtime_mappings'] = $query->runtimeMappings;
+ }
+
+ if ($query->fields !== null) {
+ $parts['fields'] = $query->fields;
+ }
+
+ if ($query->source !== null) {
+ $parts['_source'] = $query->source;
+ }
+
+ if ($query->limit !== null && $query->limit >= 0) {
+ $parts['size'] = $query->limit;
+ }
+
+ if ($query->offset > 0) {
+ $parts['from'] = (int)$query->offset;
+ }
+
+ if (isset($query->minScore)) {
+ $parts['min_score'] = (float)$query->minScore;
+ }
+
+ if (isset($query->explain)) {
+ $parts['explain'] = $query->explain;
+ }
+
+ // combine a query with where
+ $conditionals = [];
+ $whereQuery = $this->buildQueryFromWhere($query->where);
+
+ if ($whereQuery) {
+ $conditionals[] = $whereQuery;
+ }
+
+ if ($query->query) {
+ $conditionals[] = $query->query;
+ }
+
+ if (count($conditionals) === 2) {
+ $parts['query'] = ['bool' => ['must' => $conditionals]];
+ } elseif (count($conditionals) === 1) {
+ $parts['query'] = reset($conditionals);
+ }
+
+ if (!empty($query->highlight)) {
+ $parts['highlight'] = $query->highlight;
+ }
+
+ if (!empty($query->aggregations)) {
+ $parts['aggregations'] = $query->aggregations;
+ }
+
+ if (!empty($query->stats)) {
+ $parts['stats'] = $query->stats;
+ }
+
+ if (!empty($query->suggest)) {
+ $parts['suggest'] = $query->suggest;
+ }
+
+ if (!empty($query->postFilter)) {
+ $parts['post_filter'] = $query->postFilter;
+ }
+
+ if (!empty($query->collapse)) {
+ $parts['collapse'] = $query->collapse;
+ }
+
+ $sort = $this->buildOrderBy($query->orderBy);
+
+ if (!empty($sort)) {
+ $parts['sort'] = $sort;
+ }
+
+ $options = $query->options;
+
+ if ($query->timeout !== null) {
+ $options['timeout'] = $query->timeout;
+ }
+
+ return [
+ 'queryParts' => $parts,
+ 'index' => $query->index,
+ 'type' => $query->type,
+ 'options' => $options,
+ ];
+ }
+
+ /**
+ * Adds order by condition to the query.
+ */
+ public function buildOrderBy($columns): array
+ {
+ if (empty($columns)) {
+ return [];
+ }
+
+ $orders = [];
+
+ foreach ($columns as $name => $direction) {
+ if (is_string($direction)) {
+ $column = $direction;
+ $direction = SORT_ASC;
+ } else {
+ $column = $name;
+ }
+
+ if (($this->db->dslVersion < 7) && $column === '_id') {
+ $column = '_uid';
+ }
+
+ // allow Elasticsearch extended syntax as described in https://www.elastic.co/guide/en/elasticsearch/guide/master/_sorting.html
+ if (is_array($direction)) {
+ $orders[] = [$column => $direction];
+ } else {
+ $orders[] = [$column => ($direction === SORT_DESC ? 'desc' : 'asc')];
+ }
+ }
+
+ return $orders;
+ }
+
+ /**
+ * @throws NotSupportedException
+ */
+ public function buildQueryFromWhere($condition): ?array
+ {
+ $where = $this->buildCondition($condition);
+
+ if ($where) {
+ return [
+ 'constant_score' => [
+ 'filter' => $where,
+ ],
+ ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Parses the condition specification and generates the corresponding SQL expression.
+ *
+ * @param array|string|null $condition the condition specification. Please refer to [[Query::where()]] on how to
+ * specify a condition.
+ *
+ * @throws NotSupportedException if string conditions are used in where
+ * @throws InvalidArgumentException if unknown operator is used in a query
+ *
+ * @return array|string the generated SQL expression
+ */
+ public function buildCondition(array|string $condition = null): array|string
+ {
+ static $builders = [
+ 'not' => 'buildNotCondition',
+ 'and' => 'buildBoolCondition',
+ 'or' => 'buildBoolCondition',
+ 'between' => 'buildBetweenCondition',
+ 'not between' => 'buildBetweenCondition',
+ 'in' => 'buildInCondition',
+ 'not in' => 'buildInCondition',
+ 'like' => 'buildLikeCondition',
+ 'not like' => 'buildLikeCondition',
+ 'or like' => 'buildLikeCondition',
+ 'or not like' => 'buildLikeCondition',
+ 'lt' => 'buildHalfBoundedRangeCondition',
+ '<' => 'buildHalfBoundedRangeCondition',
+ 'lte' => 'buildHalfBoundedRangeCondition',
+ '<=' => 'buildHalfBoundedRangeCondition',
+ 'gt' => 'buildHalfBoundedRangeCondition',
+ '>' => 'buildHalfBoundedRangeCondition',
+ 'gte' => 'buildHalfBoundedRangeCondition',
+ '>=' => 'buildHalfBoundedRangeCondition',
+ 'match' => 'buildMatchCondition',
+ 'match_phrase' => 'buildMatchCondition',
+ ];
+
+ if (empty($condition)) {
+ return [];
+ }
+
+ if (!is_array($condition)) {
+ throw new NotSupportedException('String conditions in where() are not supported by Elasticsearch.');
+ }
+
+ if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ...
+ $operator = strtolower($condition[0]);
+ if (isset($builders[$operator])) {
+ $method = $builders[$operator];
+ array_shift($condition);
+
+ return $this->$method($operator, $condition);
+ }
+ throw new InvalidArgumentException('Found unknown operator in query: ' . $operator);
+ }
+
+ // hash format: 'column1' => 'value1', 'column2' => 'value2', ...
+ return $this->buildHashCondition($condition);
+ }
+
+ private function buildHashCondition($condition): array
+ {
+ $parts = $emptyFields = [];
+ foreach ($condition as $attribute => $value) {
+ if ($attribute === '_id') {
+ if ($value === null) { // there is no null pk
+ $parts[] = ['bool' => ['must_not' => [['match_all' => new stdClass()]]]]; // this condition is equal to WHERE false
+ } else {
+ $parts[] = ['ids' => ['values' => is_array($value) ? $value : [$value]]];
+ }
+ } elseif (is_array($value)) { // IN condition
+ $parts[] = ['terms' => [$attribute => $value]];
+ } elseif ($value === null) {
+ $emptyFields[] = [ 'exists' => [ 'field' => $attribute ] ];
+ } else {
+ $parts[] = ['term' => [$attribute => $value]];
+ }
+ }
+
+ $query = [ 'must' => $parts ];
+
+ if ($emptyFields) {
+ $query['must_not'] = $emptyFields;
+ }
+
+ return [ 'bool' => $query ];
+ }
+
+ /**
+ * @throws NotSupportedException
+ */
+ private function buildNotCondition($operator, $operands): array
+ {
+ if (count($operands) !== 1) {
+ throw new InvalidArgumentException("Operator '$operator' requires exactly one operand.");
+ }
+
+ $operand = reset($operands);
+ if (is_array($operand)) {
+ $operand = $this->buildCondition($operand);
+ }
+
+ return [
+ 'bool' => [
+ 'must_not' => $operand,
+ ],
+ ];
+ }
+
+ /**
+ * @throws NotSupportedException
+ */
+ private function buildBoolCondition($operator, $operands): array|null
+ {
+ $parts = [];
+ if ($operator === 'and') {
+ $clause = 'must';
+ } elseif ($operator === 'or') {
+ $clause = 'should';
+ } else {
+ throw new InvalidArgumentException("Operator should be 'or' or 'and'");
+ }
+
+ foreach ($operands as $operand) {
+ if (is_array($operand)) {
+ $operand = $this->buildCondition($operand);
+ }
+
+ if (!empty($operand)) {
+ $parts[] = $operand;
+ }
+ }
+
+ if ($parts) {
+ return [
+ 'bool' => [
+ $clause => $parts,
+ ],
+ ];
+ }
+
+ return null;
+ }
+
+ /**
+ * @throws NotSupportedException
+ */
+ private function buildBetweenCondition($operator, $operands): array
+ {
+ if (!isset($operands[0], $operands[1], $operands[2])) {
+ throw new InvalidArgumentException("Operator '$operator' requires three operands.");
+ }
+
+ [$column, $value1, $value2] = $operands;
+
+ if ($column === '_id') {
+ throw new NotSupportedException('Between condition is not supported for the _id field.');
+ }
+
+ $filter = ['range' => [$column => ['gte' => $value1, 'lte' => $value2]]];
+
+ if ($operator === 'not between') {
+ $filter = ['bool' => ['must_not' => $filter]];
+ }
+
+ return $filter;
+ }
+
+ /**
+ * @throws NotSupportedException
+ */
+ private function buildInCondition($operator, $operands): array
+ {
+ if (!isset($operands[0], $operands[1]) || !is_array($operands)) {
+ throw new InvalidArgumentException(
+ "Operator '$operator' requires array of two operands: column and values"
+ );
+ }
+
+ [$column, $values] = $operands;
+
+ $values = (array)$values;
+
+ if (empty($values) || $column === []) {
+ return $operator === 'in' ? ['bool' => ['must_not' => [['match_all' => new stdClass()]]]] : []; // this condition is equal to WHERE false
+ }
+
+ if (is_array($column)) {
+ if (count($column) > 1) {
+ $this->buildCompositeInCondition($operator, $column, $values);
+ }
+
+ $column = reset($column);
+ }
+
+ $canBeNull = false;
+
+ foreach ($values as $i => $value) {
+ if (is_array($value)) {
+ $values[$i] = $value = $value[$column] ?? null;
+ }
+
+ if ($value === null) {
+ $canBeNull = true;
+ unset($values[$i]);
+ }
+ }
+
+ if ($column === '_id') {
+ if (empty($values) && $canBeNull) { // there is no null pk
+ $filter = ['bool' => ['must_not' => [['match_all' => new stdClass()]]]]; // this condition is equal to WHERE false
+ } else {
+ $filter = ['ids' => ['values' => array_values($values)]];
+ if ($canBeNull) {
+ $filter = [
+ 'bool' => [
+ 'should' => [
+ $filter,
+ 'bool' => ['must_not' => ['exists' => ['field' => $column]]],
+ ],
+ ],
+ ];
+ }
+ }
+ } elseif (empty($values) && $canBeNull) {
+ $filter = [
+ 'bool' => [
+ 'must_not' => [
+ 'exists' => [ 'field' => $column ],
+ ],
+ ],
+ ];
+ } else {
+ $filter = [ 'terms' => [$column => array_values($values)] ];
+ if ($canBeNull) {
+ $filter = [
+ 'bool' => [
+ 'should' => [
+ $filter,
+ 'bool' => ['must_not' => ['exists' => ['field' => $column]]],
+ ],
+ ],
+ ];
+ }
+ }
+
+ if ($operator === 'not in') {
+ $filter = [
+ 'bool' => [
+ 'must_not' => $filter,
+ ],
+ ];
+ }
+
+ return $filter;
+ }
+
+ /**
+ * Builds a half-bounded range condition (for "gt", ">", "gte", ">=", "lt", "<", "lte", "<=" operators)
+ *
+ * @param string $operator
+ * @param array $operands
+ *
+ * @return array Filter expression.
+ */
+ private function buildHalfBoundedRangeCondition(string $operator, array $operands): array
+ {
+ if (!isset($operands[0], $operands[1])) {
+ throw new InvalidArgumentException("Operator '$operator' requires two operands.");
+ }
+
+ [$column, $value] = $operands;
+ if (($this->db->dslVersion < 7) && $column === '_id') {
+ $column = '_uid';
+ }
+
+ $range_operator = null;
+
+ if (in_array($operator, ['gte', '>='])) {
+ $range_operator = 'gte';
+ } elseif (in_array($operator, ['lte', '<='])) {
+ $range_operator = 'lte';
+ } elseif (in_array($operator, ['gt', '>'])) {
+ $range_operator = 'gt';
+ } elseif (in_array($operator, ['lt', '<'])) {
+ $range_operator = 'lt';
+ }
+
+ if ($range_operator === null) {
+ throw new InvalidArgumentException("Operator '$operator' is not implemented.");
+ }
+
+ return [
+ 'range' => [
+ $column => [
+ $range_operator => $value,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @throws NotSupportedException
+ */
+ protected function buildCompositeInCondition($operator, $columns, $values): void
+ {
+ throw new NotSupportedException('composite in is not supported by Elasticsearch.');
+ }
+
+ private function buildMatchCondition($operator, $operands): array
+ {
+ return [
+ $operator => [ $operands[0] => $operands[1] ],
+ ];
+ }
+}
diff --git a/tests/ActiveDataProviderTest.php b/tests/ActiveDataProviderTest.php
new file mode 100644
index 0000000..1e77d3b
--- /dev/null
+++ b/tests/ActiveDataProviderTest.php
@@ -0,0 +1,146 @@
+getConnection();
+
+ // delete index
+ if ($db->createCommand()->indexExists(Customer::index())) {
+ $db->createCommand()->deleteIndex(Customer::index());
+ }
+
+ $db->createCommand()->createIndex(Customer::index());
+
+ $command = $db->createCommand();
+ Customer::setUpMapping($command);
+
+ $db->createCommand()->refreshIndex(Customer::index());
+
+ $customer = new Customer();
+ $customer->_id = 1;
+ $customer->setAttributes(['email' => 'user1@example.com', 'name' => 'user1', 'address' => 'address1', 'status' => 1], false);
+ $customer->save(false);
+
+ $customer = new Customer();
+ $customer->_id = 2;
+ $customer->setAttributes(['email' => 'user2@example.com', 'name' => 'user2', 'address' => 'address2', 'status' => 1], false);
+ $customer->save(false);
+
+ $customer = new Customer();
+ $customer->_id = 3;
+ $customer->setAttributes(['email' => 'user3@example.com', 'name' => 'user3', 'address' => 'address3', 'status' => 2], false);
+ $customer->save(false);
+
+ $db->createCommand()->refreshIndex(Customer::index());
+ }
+
+ // Tests :
+
+ public function testQuery(): void
+ {
+ $query = new Query();
+ $query->from(Customer::index(), 'customer');
+
+ $provider = new ActiveDataProvider(['query' => $query, 'db' => $this->getConnection()]);
+ $models = $provider->getModels();
+ $this->assertCount(3, $models);
+
+ $provider = new ActiveDataProvider(
+ [
+ 'query' => $query,
+ 'db' => $this->getConnection(),
+ 'pagination' => [
+ 'pageSize' => 1,
+ ],
+ ]
+ );
+ $models = $provider->getModels();
+ $this->assertCount(1, $models);
+ }
+
+ public function testGetAggregations(): void
+ {
+ $provider = new ActiveDataProvider(
+ [
+ 'query' => Customer::find()->addAggregate(
+ 'agg_status',
+ [
+ 'terms' => [
+ 'field' => 'status',
+ ],
+ ],
+ ),
+ ],
+ );
+ $models = $provider->getModels();
+ $this->assertCount(3, $models);
+
+ $aggregations = $provider->getAggregations();
+ $buckets = $aggregations['agg_status']['buckets'];
+ $this->assertCount(2, $buckets);
+
+ $status_1 = $buckets[array_search(1, array_column($buckets, 'key'))];
+ $status_2 = $buckets[array_search(2, array_column($buckets, 'key'))];
+ $this->assertEquals(2, $status_1['doc_count']);
+ $this->assertEquals(1, $status_2['doc_count']);
+ }
+
+ public function testActiveQuery(): void
+ {
+ $provider = new ActiveDataProvider(['query' => Customer::find(),]);
+ $models = $provider->getModels();
+ $this->assertCount(3, $models);
+ $this->assertTrue($models[0] instanceof Customer);
+ $this->assertTrue($models[1] instanceof Customer);
+
+ $provider = new ActiveDataProvider(
+ [
+ 'query' => Customer::find(),
+ 'pagination' => [
+ 'pageSize' => 1,
+ ],
+ ],
+ );
+ $models = $provider->getModels();
+ $this->assertCount(1, $models);
+ }
+
+ public function testNonexistentIndex(): void
+ {
+ $query = new Query();
+ $query->from('nonexistent', 'nonexistent');
+
+ $provider = new ActiveDataProvider(['query' => $query, 'db' => $this->getConnection()]);
+
+ // as of ES 2.0 querying a non-existent index returns a 404
+ $this->expectException('\yii\elasticsearch\Exception');
+
+ $provider->getModels();
+ }
+
+ public function testRefresh(): void
+ {
+ $dataProvider = new ActiveDataProvider(['query' => Customer::find()]);
+ $this->assertEquals(3, $dataProvider->getTotalCount());
+
+ // Create new query and set to the same dataprovider
+ $dataProvider->query = Customer::find()->where(['name' => 'user2']);
+ $dataProvider->refresh();
+ $this->assertEquals(1, $dataProvider->getTotalCount());
+ }
+}
diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php
new file mode 100644
index 0000000..8e10e27
--- /dev/null
+++ b/tests/ActiveQueryTest.php
@@ -0,0 +1,50 @@
+getConnection()->createCommand();
+
+ // delete index
+ if ($command->indexExists(Item::index())) {
+ $command->deleteIndex(Item::index());
+ }
+
+ Item::setUpMapping($command);
+
+ $command->insert(Item::index(), Item::type(), ['name' => 'item1', 'category_id' => 17], 1);
+ $command->refreshIndex(Item::index());
+ }
+
+ /**
+ * @throws \yii\elasticsearch\Exception
+ */
+ public function testColumn(): void
+ {
+ $activeQuery = Item::find()->where(['name' => 'item1'])->asArray();
+
+ $result = $activeQuery->column('category_id', $this->getConnection());
+ $this->assertEquals([17], $result);
+
+ $result = $activeQuery->column('_id', $this->getConnection());
+ $this->assertEquals([1], $result);
+
+ $result = $activeQuery->column('noname', $this->getConnection());
+ $this->assertEquals([null], $result);
+
+ $result = $activeQuery->scalar('name', $this->getConnection());
+ $this->assertEquals('item1', $result);
+ }
+}
diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php
new file mode 100644
index 0000000..4a01872
--- /dev/null
+++ b/tests/ActiveRecordTest.php
@@ -0,0 +1,924 @@
+getConnection();
+
+ Record::initIndex(Customer::class, $db);
+ Record::initIndex(Item::class, $db);
+ Record::initIndex(Order::class, $db);
+ Record::initIndex(OrderItem::class, $db);
+ Record::initIndex(Animal::class, $db);
+
+ Record::insertMany(
+ Customer::class,
+ [
+ [
+ '_id' => 1,
+ 'email' => 'user1@example.com',
+ 'name' => 'user1',
+ 'address' => 'address1',
+ 'status' => 1,
+ 'is_active' => true,
+ ],
+ [
+ '_id' => 2,
+ 'email' => 'user2@example.com',
+ 'name' => 'user2',
+ 'address' => 'address2',
+ 'status' => 1,
+ 'is_active' => true,
+ ],
+ [
+ '_id' => 3,
+ 'email' => 'user3@example.com',
+ 'name' => 'user3',
+ 'address' => 'address3',
+ 'status' => 2,
+ 'is_active' => false,
+ ],
+ ],
+ );
+
+ Record::refreshIndex(Customer::class, $db);
+
+ Record::insertMany(
+ Item::class,
+ [
+ [
+ '_id' => 1,
+ 'name' => 'Agile Web Application Development with Yii1.1 and PHP5',
+ 'category_id' => 1,
+ ],
+ [
+ '_id' => 2,
+ 'name' => 'Yii 1.1 Application Development Cookbook',
+ 'category_id' => 1,
+ ],
+ [
+ '_id' => 3,
+ 'name' => 'Ice Age',
+ 'category_id' => 2,
+ ],
+ [
+ '_id' => 4,
+ 'name' => 'Toy Story',
+ 'category_id' => 2,
+ ],
+ [
+ '_id' => 5,
+ 'name' => 'Cars',
+ 'category_id' => 2,
+ ],
+ ],
+ );
+
+ Record::refreshIndex(Item::class, $db);
+
+ Record::insertMany(
+ Order::class,
+ [
+ [
+ '_id' => 1,
+ 'customer_id' => 1,
+ 'created_at' => 1_325_282_384,
+ 'total' => 110.0,
+ 'itemsArray' => [1, 2],
+ ],
+ [
+ '_id' => 2,
+ 'customer_id' => 2,
+ 'created_at' => 1_325_334_482,
+ 'total' => 33.0,
+ 'itemsArray' => [4, 5, 3],
+ ],
+ [
+ '_id' => 3,
+ 'customer_id' => 2,
+ 'created_at' => 1_325_502_201,
+ 'total' => 40.0,
+ 'itemsArray' => [2],
+ ],
+ ],
+ );
+
+ Record::refreshIndex(Order::class, $db);
+
+ Record::insertMany(
+ OrderItem::class,
+ [
+ ['order_id' => 1, 'item_id' => 1, 'quantity' => 1, 'subtotal' => 30.0],
+ ['order_id' => 1, 'item_id' => 2, 'quantity' => 2, 'subtotal' => 40.0],
+ ['order_id' => 2, 'item_id' => 4, 'quantity' => 1, 'subtotal' => 10.0],
+ ['order_id' => 2, 'item_id' => 5, 'quantity' => 1, 'subtotal' => 15.0],
+ ['order_id' => 2, 'item_id' => 3, 'quantity' => 1, 'subtotal' => 8.0],
+ ['order_id' => 3, 'item_id' => 2, 'quantity' => 1, 'subtotal' => 40.0],
+ ],
+ );
+
+ Record::refreshIndex(OrderItem::class, $db);
+ Record::insert(Cat::class, []);
+ Record::insert(Dog::class, []);
+ Record::refreshIndex(Animal::class, $db);
+ }
+
+ public function testSaveNoChanges(): void
+ {
+ // this should not fail with exception
+ $customer = new Customer();
+
+ // insert
+ $this->assertTrue($customer->save(false));
+
+ // update
+ $this->assertTrue($customer->save(false));
+ }
+
+ public function testFindAsArray(): void
+ {
+ // asArray
+ $customer = Customer::find()->where(['_id' => 2])->asArray()->one();
+
+ $this->assertEquals(
+ [
+ 'email' => 'user2@example.com',
+ 'name' => 'user2',
+ 'address' => 'address2',
+ 'status' => 1,
+ 'is_active' => true,
+ ],
+ $customer['_source'],
+ );
+ $this->assertEquals(2, $customer['_id']);
+ }
+
+ public function testSearch(): void
+ {
+ $customers = Customer::find()->search()['hits'];
+
+ $total = is_array($customers['total']) ? $customers['total']['value'] : $customers['total'];
+ $this->assertEquals(3, $total);
+ $this->assertTrue($customers['hits'][0] instanceof Customer);
+ $this->assertTrue($customers['hits'][1] instanceof Customer);
+ $this->assertTrue($customers['hits'][2] instanceof Customer);
+
+ // limit vs. totalcount
+ $customers = Customer::find()->limit(2)->search()['hits'];
+ $total = is_array($customers['total']) ? $customers['total']['value'] : $customers['total'];
+ $this->assertEquals(3, $total);
+ $this->assertCount(2, $customers['hits']);
+
+ // asArray
+ $result = Customer::find()->asArray()->search()['hits'];
+ $total = is_array($result['total']) ? $result['total']['value'] : $result['total'];
+ $this->assertEquals(3, $total);
+
+ $customers = $result['hits'];
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('_id', $customers[0]);
+ $this->assertArrayHasKey('name', $customers[0]['_source']);
+ $this->assertArrayHasKey('email', $customers[0]['_source']);
+ $this->assertArrayHasKey('address', $customers[0]['_source']);
+ $this->assertArrayHasKey('status', $customers[0]['_source']);
+ $this->assertArrayHasKey('_id', $customers[1]);
+ $this->assertArrayHasKey('name', $customers[1]['_source']);
+ $this->assertArrayHasKey('email', $customers[1]['_source']);
+ $this->assertArrayHasKey('address', $customers[1]['_source']);
+ $this->assertArrayHasKey('status', $customers[1]['_source']);
+ $this->assertArrayHasKey('_id', $customers[2]);
+ $this->assertArrayHasKey('name', $customers[2]['_source']);
+ $this->assertArrayHasKey('email', $customers[2]['_source']);
+ $this->assertArrayHasKey('address', $customers[2]['_source']);
+ $this->assertArrayHasKey('status', $customers[2]['_source']);
+
+ // TODO test asArray() + fields() + indexBy()
+ // find by attributes
+ $result = Customer::find()->where(['name' => 'user2'])->search()['hits'];
+ $customer = reset($result['hits']);
+ $this->assertInstanceOf(Customer::class, $customer);
+ $this->assertEquals(2, $customer->_id);
+ }
+
+ public function testSuggestion(): void
+ {
+ $result = Customer::find()
+ ->addSuggester(
+ 'customer_name',
+ [
+ 'text' => 'user',
+ 'term' => [
+ 'field' => 'name',
+ ],
+ ],
+ )
+ ->search();
+
+ $this->assertCount(3, $result['suggest']['customer_name'][0]['options']);
+ }
+
+ public function testGetDb(): void
+ {
+ $this->mockApplication(['components' => ['elasticsearch' => Connection::class]]);
+ $this->assertInstanceOf(Connection::class, ActiveRecord::getDb());
+ }
+
+ public function testGet(): void
+ {
+ $this->assertInstanceOf(Customer::class, Customer::get(1));
+ $this->assertNull(Customer::get(5));
+ }
+
+ public function testMget(): void
+ {
+ $this->assertEquals([], Customer::mget([]));
+
+ $records = Customer::mget([1]);
+ $this->assertCount(1, $records);
+ $this->assertInstanceOf(Customer::class, reset($records));
+
+ $records = Customer::mget([5]);
+ $this->assertCount(0, $records);
+
+ $records = Customer::mget([1, 3, 5]);
+ $this->assertCount(2, $records);
+ $this->assertInstanceOf(Customer::class, $records[0]);
+ $this->assertInstanceOf(Customer::class, $records[1]);
+ }
+
+ public function testFindLazy(): void
+ {
+ /* @var $customer Customer */
+ $customer = Customer::findOne(2);
+ $orders = $customer->orders;
+ $this->assertCount(2, $orders);
+
+ $orders = $customer->getOrders()->where(['between', 'created_at', 1_325_334_000, 1_325_400_000])->all();
+ $this->assertCount(1, $orders);
+ $this->assertEquals(2, $orders[0]->_id);
+ }
+
+ public function testFindEagerViaRelation(): void
+ {
+ $orders = Order::find()->with('items')->orderBy('created_at')->all();
+
+ $this->assertCount(3, $orders);
+
+ $order = $orders[0];
+ $this->assertEquals(1, $order->_id);
+ $this->assertTrue($order->isRelationPopulated('items'));
+ $this->assertCount(2, $order->items);
+ $this->assertEquals(1, $order->items[0]->_id);
+ $this->assertEquals(2, $order->items[1]->_id);
+ }
+
+ public function testInsertNoPk(): void
+ {
+ $this->assertEquals(['_id'], Customer::primaryKey());
+
+ $customer = new Customer();
+ $customer->email = 'user4@example.com';
+ $customer->name = 'user4';
+ $customer->address = 'address4';
+
+ $this->assertNull($customer->_id);
+ $this->assertNull($customer->oldPrimaryKey);
+ $this->assertNull($customer->_id);
+ $this->assertTrue($customer->isNewRecord);
+
+ $customer->save();
+
+ Record::refreshIndex($customer::class, $customer->db);
+
+ $this->assertNotNull($customer->_id);
+ $this->assertNotNull($customer->oldPrimaryKey);
+ $this->assertNotNull($customer->_id);
+ $this->assertEquals($customer->_id, $customer->oldPrimaryKey);
+ $this->assertEquals($customer->_id, $customer->_id);
+ $this->assertFalse($customer->isNewRecord);
+ }
+
+ public function testInsertPk(): void
+ {
+ $customer = new Customer();
+ $customer->_id = 5;
+ $customer->email = 'user5@example.com';
+ $customer->name = 'user5';
+ $customer->address = 'address5';
+
+ $this->assertTrue($customer->isNewRecord);
+
+ $customer->save();
+
+ $this->assertEquals(5, $customer->_id);
+ $this->assertEquals(5, $customer->oldPrimaryKey);
+ $this->assertEquals(5, $customer->_id);
+ $this->assertFalse($customer->isNewRecord);
+ }
+
+ public function testFindLazyVia2(): void
+ {
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ /* @var $order Order */
+ $orderClass = $this->getOrderClass();
+
+ $order = new $orderClass();
+ $order->_id = 100;
+ $this->assertEquals([], $order->items);
+ }
+
+ public function testScriptFields(): void
+ {
+ $orderItems = OrderItem::find()
+ ->source('quantity', 'subtotal')
+ ->scriptFields(
+ [
+ 'total' => [
+ 'script' => [
+ 'lang' => 'painless',
+ 'inline' => "doc['quantity'].value * doc['subtotal'].value",
+ ],
+ ],
+ ],
+ )
+ ->all();
+
+ $this->assertNotEmpty($orderItems);
+
+ foreach ($orderItems as $item) {
+ $this->assertEquals($item->subtotal * $item->quantity, $item->total);
+ }
+ }
+
+ public function testFindAsArrayFields(): void
+ {
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // indexBy + asArray
+ $customers = Customer::find()->asArray()->storedFields(['name'])->all();
+
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('name', $customers[0]['fields']);
+ $this->assertArrayNotHasKey('email', $customers[0]['fields']);
+ $this->assertArrayNotHasKey('address', $customers[0]['fields']);
+ $this->assertArrayNotHasKey('status', $customers[0]['fields']);
+ $this->assertArrayHasKey('name', $customers[1]['fields']);
+ $this->assertArrayNotHasKey('email', $customers[1]['fields']);
+ $this->assertArrayNotHasKey('address', $customers[1]['fields']);
+ $this->assertArrayNotHasKey('status', $customers[1]['fields']);
+ $this->assertArrayHasKey('name', $customers[2]['fields']);
+ $this->assertArrayNotHasKey('email', $customers[2]['fields']);
+ $this->assertArrayNotHasKey('address', $customers[2]['fields']);
+ $this->assertArrayNotHasKey('status', $customers[2]['fields']);
+ }
+
+ public function testFindAsArraySourceFilter(): void
+ {
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // indexBy + asArray
+ $customers = Customer::find()->asArray()->source(['name'])->all();
+
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('name', $customers[0]['_source']);
+ $this->assertArrayNotHasKey('email', $customers[0]['_source']);
+ $this->assertArrayNotHasKey('address', $customers[0]['_source']);
+ $this->assertArrayNotHasKey('status', $customers[0]['_source']);
+ $this->assertArrayHasKey('name', $customers[1]['_source']);
+ $this->assertArrayNotHasKey('email', $customers[1]['_source']);
+ $this->assertArrayNotHasKey('address', $customers[1]['_source']);
+ $this->assertArrayNotHasKey('status', $customers[1]['_source']);
+ $this->assertArrayHasKey('name', $customers[2]['_source']);
+ $this->assertArrayNotHasKey('email', $customers[2]['_source']);
+ $this->assertArrayNotHasKey('address', $customers[2]['_source']);
+ $this->assertArrayNotHasKey('status', $customers[2]['_source']);
+ }
+
+ public function testFindIndexBySource(): void
+ {
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // indexBy + asArray
+ $customers = Customer::find()->indexBy('name')->source('name')->all();
+
+ $this->assertCount(3, $customers);
+ $this->assertTrue($customers['user1'] instanceof $customerClass);
+ $this->assertTrue($customers['user2'] instanceof $customerClass);
+ $this->assertTrue($customers['user3'] instanceof $customerClass);
+ $this->assertNotNull($customers['user1']->name);
+ $this->assertNull($customers['user1']->email);
+ $this->assertNull($customers['user1']->address);
+ $this->assertNull($customers['user1']->status);
+ $this->assertNotNull($customers['user2']->name);
+ $this->assertNull($customers['user2']->email);
+ $this->assertNull($customers['user2']->address);
+ $this->assertNull($customers['user2']->status);
+ $this->assertNotNull($customers['user3']->name);
+ $this->assertNull($customers['user3']->email);
+ $this->assertNull($customers['user3']->address);
+ $this->assertNull($customers['user3']->status);
+
+ // indexBy callable + asArray
+ $customers = Customer::find()
+ ->indexBy(fn($customer) => $customer->_id . '-' . $customer->name)
+ ->storedFields('name')
+ ->all();
+
+ $this->assertCount(3, $customers);
+ $this->assertTrue($customers['1-user1'] instanceof $customerClass);
+ $this->assertTrue($customers['2-user2'] instanceof $customerClass);
+ $this->assertTrue($customers['3-user3'] instanceof $customerClass);
+ $this->assertNotNull($customers['1-user1']->name);
+ $this->assertNull($customers['1-user1']->email);
+ $this->assertNull($customers['1-user1']->address);
+ $this->assertNull($customers['1-user1']->status);
+ $this->assertNotNull($customers['2-user2']->name);
+ $this->assertNull($customers['2-user2']->email);
+ $this->assertNull($customers['2-user2']->address);
+ $this->assertNull($customers['2-user2']->status);
+ $this->assertNotNull($customers['3-user3']->name);
+ $this->assertNull($customers['3-user3']->email);
+ $this->assertNull($customers['3-user3']->address);
+ $this->assertNull($customers['3-user3']->status);
+ }
+
+ public function testFindIndexByAsArrayFields(): void
+ {
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // indexBy + asArray
+ $customers = Customer::find()->indexBy('name')->asArray()->storedFields('name')->all();
+
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('name', $customers['user1']['fields']);
+ $this->assertArrayNotHasKey('email', $customers['user1']['fields']);
+ $this->assertArrayNotHasKey('address', $customers['user1']['fields']);
+ $this->assertArrayNotHasKey('status', $customers['user1']['fields']);
+ $this->assertArrayHasKey('name', $customers['user2']['fields']);
+ $this->assertArrayNotHasKey('email', $customers['user2']['fields']);
+ $this->assertArrayNotHasKey('address', $customers['user2']['fields']);
+ $this->assertArrayNotHasKey('status', $customers['user2']['fields']);
+ $this->assertArrayHasKey('name', $customers['user3']['fields']);
+ $this->assertArrayNotHasKey('email', $customers['user3']['fields']);
+ $this->assertArrayNotHasKey('address', $customers['user3']['fields']);
+ $this->assertArrayNotHasKey('status', $customers['user3']['fields']);
+
+ // indexBy callable + asArray
+ $customers = Customer::find()
+ ->indexBy(fn($customer) => $customer['_id'] . '-' . reset($customer['fields']['name']))
+ ->asArray()
+ ->storedFields('name')
+ ->all();
+
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('name', $customers['1-user1']['fields']);
+ $this->assertArrayNotHasKey('email', $customers['1-user1']['fields']);
+ $this->assertArrayNotHasKey('address', $customers['1-user1']['fields']);
+ $this->assertArrayNotHasKey('status', $customers['1-user1']['fields']);
+ $this->assertArrayHasKey('name', $customers['2-user2']['fields']);
+ $this->assertArrayNotHasKey('email', $customers['2-user2']['fields']);
+ $this->assertArrayNotHasKey('address', $customers['2-user2']['fields']);
+ $this->assertArrayNotHasKey('status', $customers['2-user2']['fields']);
+ $this->assertArrayHasKey('name', $customers['3-user3']['fields']);
+ $this->assertArrayNotHasKey('email', $customers['3-user3']['fields']);
+ $this->assertArrayNotHasKey('address', $customers['3-user3']['fields']);
+ $this->assertArrayNotHasKey('status', $customers['3-user3']['fields']);
+ }
+
+ public function testFindIndexByAsArray(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // indexBy + asArray
+ $customers = $customerClass::find()->asArray()->indexBy('name')->all();
+
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('name', $customers['user1']['_source']);
+ $this->assertArrayHasKey('email', $customers['user1']['_source']);
+ $this->assertArrayHasKey('address', $customers['user1']['_source']);
+ $this->assertArrayHasKey('status', $customers['user1']['_source']);
+ $this->assertArrayHasKey('name', $customers['user2']['_source']);
+ $this->assertArrayHasKey('email', $customers['user2']['_source']);
+ $this->assertArrayHasKey('address', $customers['user2']['_source']);
+ $this->assertArrayHasKey('status', $customers['user2']['_source']);
+ $this->assertArrayHasKey('name', $customers['user3']['_source']);
+ $this->assertArrayHasKey('email', $customers['user3']['_source']);
+ $this->assertArrayHasKey('address', $customers['user3']['_source']);
+ $this->assertArrayHasKey('status', $customers['user3']['_source']);
+
+ // indexBy callable + asArray
+ $customers = $customerClass::find()
+ ->indexBy(fn($customer) => $customer['_id'] . '-' . $customer['_source']['name'])
+ ->asArray()
+ ->all();
+
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('name', $customers['1-user1']['_source']);
+ $this->assertArrayHasKey('email', $customers['1-user1']['_source']);
+ $this->assertArrayHasKey('address', $customers['1-user1']['_source']);
+ $this->assertArrayHasKey('status', $customers['1-user1']['_source']);
+ $this->assertArrayHasKey('name', $customers['2-user2']['_source']);
+ $this->assertArrayHasKey('email', $customers['2-user2']['_source']);
+ $this->assertArrayHasKey('address', $customers['2-user2']['_source']);
+ $this->assertArrayHasKey('status', $customers['2-user2']['_source']);
+ $this->assertArrayHasKey('name', $customers['3-user3']['_source']);
+ $this->assertArrayHasKey('email', $customers['3-user3']['_source']);
+ $this->assertArrayHasKey('address', $customers['3-user3']['_source']);
+ $this->assertArrayHasKey('status', $customers['3-user3']['_source']);
+ }
+
+ public function testAfterFindGet(): void
+ {
+ /* @var $customerClass BaseActiveRecord */
+ $customerClass = $this->getCustomerClass();
+
+ $afterFindCalls = [];
+ Event::on(
+ BaseActiveRecord::class,
+ BaseActiveRecord::EVENT_AFTER_FIND,
+ static function ($event) use (&$afterFindCalls): void {
+ /* @var $ar BaseActiveRecord */
+ $ar = $event->sender;
+ $afterFindCalls[] = [
+ $ar::class,
+ $ar->getIsNewRecord(),
+ $ar->getPrimaryKey(),
+ $ar->isRelationPopulated('orders'),
+ ];
+ },
+ );
+
+ $customer = Customer::get(1);
+ $this->assertNotNull($customer);
+ $this->assertEquals([[$customerClass, false, 1, false]], $afterFindCalls);
+
+ $afterFindCalls = [];
+ $customer = Customer::mget([1, 2]);
+
+ $this->assertNotNull($customer);
+ $this->assertEquals([[$customerClass, false, 1, false], [$customerClass, false, 2, false]], $afterFindCalls);
+
+ $afterFindCalls = [];
+
+ Event::off(BaseActiveRecord::class, BaseActiveRecord::EVENT_AFTER_FIND);
+ }
+
+ public function testFindEmptyPkCondition(): void
+ {
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ /* @var $orderItemClass \yii\db\ActiveRecordInterface */
+ $orderItemClass = $this->getOrderItemClass();
+
+ $orderItem = new $orderItemClass();
+ $orderItem->setAttributes(['order_id' => 1, 'item_id' => 1, 'quantity' => 1, 'subtotal' => 30.0], false);
+ $orderItem->save(false);
+
+ Record::refreshIndex($orderItem::class, $orderItem->db);
+
+ $orderItems = $orderItemClass::find()->where(['_id' => [$orderItem->getPrimaryKey()]])->all();
+ $this->assertCount(1, $orderItems);
+
+ $orderItems = $orderItemClass::find()->where(['_id' => []])->all();
+ $this->assertCount(0, $orderItems);
+
+ $orderItems = $orderItemClass::find()->where(['_id' => null])->all();
+ $this->assertCount(0, $orderItems);
+
+ $orderItems = $orderItemClass::find()->where(['IN', '_id', [$orderItem->getPrimaryKey()]])->all();
+ $this->assertCount(1, $orderItems);
+
+ $orderItems = $orderItemClass::find()->where(['IN', '_id', []])->all();
+ $this->assertCount(0, $orderItems);
+
+ $orderItems = $orderItemClass::find()->where(['IN', '_id', [null]])->all();
+ $this->assertCount(0, $orderItems);
+ }
+
+ public function testArrayAttributes(): void
+ {
+ $this->assertIsArray(Order::findOne(1)->itemsArray);
+ $this->assertIsArray(Order::findOne(2)->itemsArray);
+ $this->assertIsArray(Order::findOne(3)->itemsArray);
+ }
+
+ public function testArrayAttributeRelationLazy(): void
+ {
+ $order = Order::findOne(1);
+ $items = $order->itemsByArrayValue;
+
+ $this->assertCount(2, $items);
+ $this->assertTrue(isset($items[1]));
+ $this->assertTrue(isset($items[2]));
+ $this->assertTrue($items[1] instanceof Item);
+ $this->assertTrue($items[2] instanceof Item);
+
+ $order = Order::findOne(2);
+ $items = $order->itemsByArrayValue;
+
+ $this->assertCount(3, $items);
+ $this->assertTrue(isset($items[3]));
+ $this->assertTrue(isset($items[4]));
+ $this->assertTrue(isset($items[5]));
+ $this->assertTrue($items[3] instanceof Item);
+ $this->assertTrue($items[4] instanceof Item);
+ $this->assertTrue($items[5] instanceof Item);
+ }
+
+ public function testArrayAttributeRelationEager(): void
+ {
+ /* @var $order Order */
+ $order = Order::find()->with('itemsByArrayValue')->where(['_id' => 1])->one();
+
+ $this->assertTrue($order->isRelationPopulated('itemsByArrayValue'));
+
+ $items = $order->itemsByArrayValue;
+
+ $this->assertCount(2, $items);
+ $this->assertTrue(isset($items[1]));
+ $this->assertTrue(isset($items[2]));
+ $this->assertTrue($items[1] instanceof Item);
+ $this->assertTrue($items[2] instanceof Item);
+
+ /* @var $order Order */
+ $order = Order::find()->with('itemsByArrayValue')->where(['_id' => 2])->one();
+
+ $this->assertTrue($order->isRelationPopulated('itemsByArrayValue'));
+
+ $items = $order->itemsByArrayValue;
+
+ $this->assertCount(3, $items);
+ $this->assertTrue(isset($items[3]));
+ $this->assertTrue(isset($items[4]));
+ $this->assertTrue(isset($items[5]));
+ $this->assertTrue($items[3] instanceof Item);
+ $this->assertTrue($items[4] instanceof Item);
+ $this->assertTrue($items[5] instanceof Item);
+ }
+
+ public function testArrayAttributeRelationLink(): void
+ {
+ /* @var $order Order */
+ $order = Order::find()->where(['_id' => 1])->one();
+ $items = $order->itemsByArrayValue;
+
+ $this->assertCount(2, $items);
+ $this->assertTrue(isset($items[1]));
+ $this->assertTrue(isset($items[2]));
+
+ $item = Item::get(5);
+
+ try {
+ $order->link('itemsByArrayValue', $item);
+ } catch (InvalidCallException $e) {
+ $this->assertEquals(
+ $e->getMessage(),
+ 'Unable to link models: foreign model cannot be linked if its property is an array.',
+ );
+ }
+ }
+
+ public function testArrayAttributeRelationUnLink(): void
+ {
+ /* @var $order Order */
+ $order = Order::find()->where(['_id' => 1])->one();
+ $items = $order->itemsByArrayValue;
+
+ $this->assertCount(2, $items);
+ $this->assertTrue(isset($items[1]));
+ $this->assertTrue(isset($items[2]));
+
+ $item = Item::get(2);
+ $order->unlink('itemsByArrayValue', $item);
+ Record::refreshIndex($order::class, $order->db);
+
+ $items = $order->itemsByArrayValue;
+ $this->assertCount(1, $items);
+ $this->assertTrue(isset($items[1]));
+ $this->assertFalse(isset($items[2]));
+
+ // check also after refresh
+ $this->assertTrue($order->refresh());
+ $items = $order->itemsByArrayValue;
+ $this->assertCount(1, $items);
+ $this->assertTrue(isset($items[1]));
+ $this->assertFalse(isset($items[2]));
+ }
+
+ /**
+ * https://github.com/yiisoft/yii2/issues/6065
+ */
+ public function testArrayAttributeRelationUnLinkBrokenArray(): void
+ {
+ /* @var $order Order */
+ $order = Order::find()->where(['_id' => 1])->one();
+
+ $itemIds = $order->itemsArray;
+ $removeId = reset($itemIds);
+ $item = Item::get($removeId);
+ $order->unlink('itemsByArrayValue', $item);
+ Record::refreshIndex($order::class, $order->db);
+
+ $items = $order->itemsByArrayValue;
+ $this->assertCount(1, $items);
+ $this->assertFalse(isset($items[$removeId]));
+
+ // check also after refresh
+ $this->assertTrue($order->refresh());
+ $items = $order->itemsByArrayValue;
+ $this->assertCount(1, $items);
+ $this->assertFalse(isset($items[$removeId]));
+ }
+
+ public function testUnlinkAllNotSupported(): void
+ {
+ try {
+ /* @var $order Order */
+ $order = Order::find()->where(['_id' => 1])->one();
+
+ $items = $order->itemsByArrayValue;
+ $this->assertCount(2, $items);
+ $this->assertTrue(isset($items[1]));
+ $this->assertTrue(isset($items[2]));
+
+ $order->unlinkAll('itemsByArrayValue');
+ } catch (\yii\base\NotSupportedException $e) {
+ $this->assertEquals(
+ $e->getMessage(),
+ 'unlinkAll() is not supported by Elasticsearch, use unlink() instead.',
+ );
+ }
+ }
+
+ public function testPopulateRecordCallWhenQueryingOnParentClass(): void
+ {
+ $animal = Animal::find()->where(['species' => Dog::class])->one();
+ $this->assertEquals('bark', $animal->getDoes());
+
+ $animal = Animal::find()->where(['species' => Cat::class])->one();
+ $this->assertEquals('meow', $animal->getDoes());
+ }
+
+ public function testAttributeAccess(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ $model = new $customerClass();
+
+ $this->assertTrue($model->canSetProperty('name'));
+ $this->assertTrue($model->canGetProperty('name'));
+ $this->assertFalse($model->canSetProperty('unExistingColumn'));
+ $this->assertFalse(isset($model->name));
+
+ $model->name = 'foo';
+ $this->assertTrue(isset($model->name));
+ unset($model->name);
+ $this->assertNull($model->name);
+
+ // @see https://github.com/yiisoft/yii2-gii/issues/190
+ $baseModel = new $customerClass();
+ $this->assertFalse($baseModel->hasProperty('unExistingColumn'));
+
+ /* @var $customer ActiveRecord */
+ $customer = new $customerClass();
+ $this->assertInstanceOf($customerClass, $customer);
+
+ $this->assertTrue($customer->canGetProperty('_id'));
+ $this->assertTrue($customer->canSetProperty('_id'));
+
+ // tests that we really can get and set this property
+ $this->assertNull($customer->_id);
+ $customer->_id = 10;
+ $this->assertNotNull($customer->_id);
+
+ $this->assertFalse($customer->canGetProperty('non_existing_property'));
+ $this->assertFalse($customer->canSetProperty('non_existing_property'));
+ }
+
+ public function testBooleanAttribute2(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ $customers = $customerClass::find()->where(['is_active' => true])->all();
+ $this->assertCount(2, $customers);
+
+ $customers = $customerClass::find()->where(['is_active' => false])->all();
+ $this->assertCount(1, $customers);
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customer = new $customerClass();
+ $customer->name = 'boolean customer';
+ $customer->email = 'mail@example.com';
+ $customer->is_active = true;
+ $customer->save(false);
+ Record::refreshIndex($customer::class, $customer->db);
+
+ $customer->refresh();
+ $this->assertTrue($customer->is_active);
+
+ $customer->is_active = false;
+ $res = $customer->save(false);
+ Record::refreshIndex($customer::class, $customer->db);
+
+ $customer->refresh();
+ $this->assertFalse($customer->is_active);
+ }
+
+ // TODO test AR with not mapped PK
+
+ public static function illegalValuesForFindByCondition()
+ {
+ return [
+ [['_id' => ['`id`=`id` and 1' => 1]], null],
+ [['_id' => [
+ 'legal' => 1,
+ '`id`=`id` and 1' => 1,
+ ]], null],
+ [['_id' => [
+ 'nested_illegal' => [
+ 'false or 1=' => 1,
+ ],
+ ]], null],
+
+ [['_id' => [
+ 'or',
+ '1=1',
+ '_id' => '_id',
+ ]], null],
+ [['_id' => [
+ 'or',
+ '1=1',
+ '_id' => '1',
+ ]], ['_id' => 1]],
+ [['_id' => [
+ 'name' => 'Cars',
+ ]], ['_id' => 5]],
+ ];
+ }
+
+ /**
+ * @dataProvider illegalValuesForFindByCondition
+ */
+ public function testValueEscapingInFindByCondition(array $filterWithInjection, ?array $expectedResult): void
+ {
+ /* @var $itemClass \yii\db\ActiveRecordInterface */
+ $itemClass = $this->getItemClass();
+
+ $result = $itemClass::findOne($filterWithInjection['_id']);
+ if ($expectedResult === null) {
+ $this->assertNull($result);
+ } else {
+ $this->assertNotNull($result);
+ foreach ($expectedResult as $col => $value) {
+ $this->assertEquals($value, $result->$col);
+ }
+ }
+ }
+}
diff --git a/tests/ActiveRecordTestTrait.php b/tests/ActiveRecordTestTrait.php
new file mode 100644
index 0000000..a70b5c8
--- /dev/null
+++ b/tests/ActiveRecordTestTrait.php
@@ -0,0 +1,1160 @@
+getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // find one
+ $result = $customerClass::find();
+ $this->assertInstanceOf('\\yii\\db\\ActiveQueryInterface', $result);
+ $customer = $result->one();
+ $this->assertInstanceOf($customerClass, $customer);
+
+ // find all
+ $customers = $customerClass::find()->all();
+ $this->assertCount(3, $customers);
+ $this->assertInstanceOf($customerClass, $customers[0]);
+ $this->assertInstanceOf($customerClass, $customers[1]);
+ $this->assertInstanceOf($customerClass, $customers[2]);
+
+ // find by a single primary key
+ $customer = $customerClass::findOne(2);
+ $this->assertInstanceOf($customerClass, $customer);
+ $this->assertEquals('user2', $customer->name);
+ $customer = $customerClass::findOne(5);
+ $this->assertNull($customer);
+ $customer = $customerClass::findOne(['_id' => [5, 6, 1]]);
+ $this->assertInstanceOf($customerClass, $customer);
+ $customer = $customerClass::find()->where(['_id' => [5, 6, 1]])->one();
+ $this->assertNotNull($customer);
+
+ // find by column values
+ $customer = $customerClass::findOne(['_id' => 2, 'name' => 'user2']);
+ $this->assertInstanceOf($customerClass, $customer);
+ $this->assertEquals('user2', $customer->name);
+ $customer = $customerClass::findOne(['_id' => 2, 'name' => 'user1']);
+ $this->assertNull($customer);
+ $customer = $customerClass::findOne(['_id' => 5]);
+ $this->assertNull($customer);
+ $customer = $customerClass::findOne(['name' => 'user5']);
+ $this->assertNull($customer);
+
+ // find by attributes
+ $customer = $customerClass::find()->where(['name' => 'user2'])->one();
+ $this->assertInstanceOf($customerClass, $customer);
+ $this->assertEquals(2, $customer->_id);
+
+ // scope
+ $this->assertCount(2, $customerClass::find()->active()->all());
+ $this->assertEquals(2, $customerClass::find()->active()->count());
+ }
+
+ public function testFindAsArray(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ // asArray
+ $customer = $customerClass::find()->where(['_id' => 2])->asArray()->one();
+ $this->assertEquals([
+ '_id' => 2,
+ 'email' => 'user2@example.com',
+ 'name' => 'user2',
+ 'address' => 'address2',
+ 'status' => 1,
+ 'profile_id' => null,
+ ], $customer);
+
+ // find all asArray
+ $customers = $customerClass::find()->asArray()->all();
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('_id', $customers[0]);
+ $this->assertArrayHasKey('name', $customers[0]);
+ $this->assertArrayHasKey('email', $customers[0]);
+ $this->assertArrayHasKey('address', $customers[0]);
+ $this->assertArrayHasKey('status', $customers[0]);
+ $this->assertArrayHasKey('_id', $customers[1]);
+ $this->assertArrayHasKey('name', $customers[1]);
+ $this->assertArrayHasKey('email', $customers[1]);
+ $this->assertArrayHasKey('address', $customers[1]);
+ $this->assertArrayHasKey('status', $customers[1]);
+ $this->assertArrayHasKey('_id', $customers[2]);
+ $this->assertArrayHasKey('name', $customers[2]);
+ $this->assertArrayHasKey('email', $customers[2]);
+ $this->assertArrayHasKey('address', $customers[2]);
+ $this->assertArrayHasKey('status', $customers[2]);
+ }
+
+ public function testHasAttribute(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ $customer = new $customerClass();
+ $this->assertTrue($customer->hasAttribute('email'));
+ $this->assertFalse($customer->hasAttribute(0));
+ $this->assertFalse($customer->hasAttribute(null));
+ $this->assertFalse($customer->hasAttribute(42));
+
+ $customer = $customerClass::findOne(1);
+ $this->assertTrue($customer->hasAttribute('email'));
+ $this->assertFalse($customer->hasAttribute(0));
+ $this->assertFalse($customer->hasAttribute(null));
+ $this->assertFalse($customer->hasAttribute(42));
+ }
+
+ public function testFindScalar(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // query scalar
+ $customerName = $customerClass::find()->where(['_id' => 2])->scalar('name');
+ $this->assertEquals('user2', $customerName);
+ $customerName = $customerClass::find()->where(['status' => 2])->scalar('name');
+ $this->assertEquals('user3', $customerName);
+ $customerName = $customerClass::find()->where(['status' => 2])->scalar('noname');
+ $this->assertNull($customerName);
+ $customerId = $customerClass::find()->where(['status' => 2])->scalar('_id');
+ $this->assertEquals(3, $customerId);
+ }
+
+ public function testFindColumn(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $this->assertEquals(['user1', 'user2', 'user3'], $customerClass::find()->orderBy(['name' => SORT_ASC])->column('name'));
+ $this->assertEquals(['user3', 'user2', 'user1'], $customerClass::find()->orderBy(['name' => SORT_DESC])->column('name'));
+ }
+
+ public function testFindIndexBy(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // indexBy
+ $customers = $customerClass::find()->indexBy('name')->orderBy('_id')->all();
+ $this->assertCount(3, $customers);
+ $this->assertInstanceOf($customerClass, $customers['user1']);
+ $this->assertInstanceOf($customerClass, $customers['user2']);
+ $this->assertInstanceOf($customerClass, $customers['user3']);
+
+ // indexBy callable
+ $customers = $customerClass::find()->indexBy(fn($customer) => $customer->_id . '-' . $customer->name)->orderBy('_id')->all();
+ $this->assertCount(3, $customers);
+ $this->assertInstanceOf($customerClass, $customers['1-user1']);
+ $this->assertInstanceOf($customerClass, $customers['2-user2']);
+ $this->assertInstanceOf($customerClass, $customers['3-user3']);
+ }
+
+ public function testFindIndexByAsArray(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // indexBy + asArray
+ $customers = $customerClass::find()->asArray()->indexBy('name')->all();
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('_id', $customers['user1']);
+ $this->assertArrayHasKey('name', $customers['user1']);
+ $this->assertArrayHasKey('email', $customers['user1']);
+ $this->assertArrayHasKey('address', $customers['user1']);
+ $this->assertArrayHasKey('status', $customers['user1']);
+ $this->assertArrayHasKey('_id', $customers['user2']);
+ $this->assertArrayHasKey('name', $customers['user2']);
+ $this->assertArrayHasKey('email', $customers['user2']);
+ $this->assertArrayHasKey('address', $customers['user2']);
+ $this->assertArrayHasKey('status', $customers['user2']);
+ $this->assertArrayHasKey('_id', $customers['user3']);
+ $this->assertArrayHasKey('name', $customers['user3']);
+ $this->assertArrayHasKey('email', $customers['user3']);
+ $this->assertArrayHasKey('address', $customers['user3']);
+ $this->assertArrayHasKey('status', $customers['user3']);
+
+ // indexBy callable + asArray
+ $customers = $customerClass::find()->indexBy(fn($customer) => $customer['_id'] . '-' . $customer['name'])->asArray()->all();
+ $this->assertCount(3, $customers);
+ $this->assertArrayHasKey('_id', $customers['1-user1']);
+ $this->assertArrayHasKey('name', $customers['1-user1']);
+ $this->assertArrayHasKey('email', $customers['1-user1']);
+ $this->assertArrayHasKey('address', $customers['1-user1']);
+ $this->assertArrayHasKey('status', $customers['1-user1']);
+ $this->assertArrayHasKey('_id', $customers['2-user2']);
+ $this->assertArrayHasKey('name', $customers['2-user2']);
+ $this->assertArrayHasKey('email', $customers['2-user2']);
+ $this->assertArrayHasKey('address', $customers['2-user2']);
+ $this->assertArrayHasKey('status', $customers['2-user2']);
+ $this->assertArrayHasKey('_id', $customers['3-user3']);
+ $this->assertArrayHasKey('name', $customers['3-user3']);
+ $this->assertArrayHasKey('email', $customers['3-user3']);
+ $this->assertArrayHasKey('address', $customers['3-user3']);
+ $this->assertArrayHasKey('status', $customers['3-user3']);
+ }
+
+ public function testRefresh(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customer = new $customerClass();
+ $this->assertFalse($customer->refresh());
+
+ $customer = $customerClass::findOne(1);
+ $customer->name = 'to be refreshed';
+ $this->assertTrue($customer->refresh());
+ $this->assertEquals('user1', $customer->name);
+ }
+
+ public function testEquals(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $itemClass \yii\db\ActiveRecordInterface */
+ $itemClass = $this->getItemClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customerA = new $customerClass();
+ $customerB = new $customerClass();
+ $this->assertFalse($customerA->equals($customerB));
+
+ $customerA = new $customerClass();
+ $customerB = new $itemClass();
+ $this->assertFalse($customerA->equals($customerB));
+
+ $customerA = $customerClass::findOne(1);
+ $customerB = $customerClass::findOne(2);
+ $this->assertFalse($customerA->equals($customerB));
+
+ $customerB = $customerClass::findOne(1);
+ $this->assertTrue($customerA->equals($customerB));
+
+ $customerA = $customerClass::findOne(1);
+ $customerB = $itemClass::findOne(1);
+ $this->assertFalse($customerA->equals($customerB));
+ }
+
+ public function testFindCount(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $this->assertEquals(3, $customerClass::find()->count());
+
+ $this->assertEquals(1, $customerClass::find()->where(['_id' => 1])->count());
+ $this->assertEquals(2, $customerClass::find()->where(['_id' => [1, 2]])->count());
+ $this->assertEquals(2, $customerClass::find()->where(['_id' => [1, 2]])->offset(1)->count());
+ $this->assertEquals(2, $customerClass::find()->where(['_id' => [1, 2]])->offset(2)->count());
+
+ // limit should have no effect on count()
+ $this->assertEquals(3, $customerClass::find()->limit(1)->count());
+ $this->assertEquals(3, $customerClass::find()->limit(2)->count());
+ $this->assertEquals(3, $customerClass::find()->limit(10)->count());
+ $this->assertEquals(3, $customerClass::find()->offset(2)->limit(2)->count());
+ }
+
+ public function testFindLimit(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // all()
+ $customers = $customerClass::find()->all();
+ $this->assertCount(3, $customers);
+
+ $customers = $customerClass::find()->orderBy('_id')->limit(1)->all();
+ $this->assertCount(1, $customers);
+ $this->assertEquals('user1', $customers[0]->name);
+
+ $customers = $customerClass::find()->orderBy('_id')->limit(1)->offset(1)->all();
+ $this->assertCount(1, $customers);
+ $this->assertEquals('user2', $customers[0]->name);
+
+ $customers = $customerClass::find()->orderBy('_id')->limit(1)->offset(2)->all();
+ $this->assertCount(1, $customers);
+ $this->assertEquals('user3', $customers[0]->name);
+
+ $customers = $customerClass::find()->orderBy('_id')->limit(2)->offset(1)->all();
+ $this->assertCount(2, $customers);
+ $this->assertEquals('user2', $customers[0]->name);
+ $this->assertEquals('user3', $customers[1]->name);
+
+ $customers = $customerClass::find()->limit(2)->offset(3)->all();
+ $this->assertCount(0, $customers);
+
+ // one()
+ $customer = $customerClass::find()->orderBy('_id')->one();
+ $this->assertEquals('user1', $customer->name);
+
+ $customer = $customerClass::find()->orderBy('_id')->offset(0)->one();
+ $this->assertEquals('user1', $customer->name);
+
+ $customer = $customerClass::find()->orderBy('_id')->offset(1)->one();
+ $this->assertEquals('user2', $customer->name);
+
+ $customer = $customerClass::find()->orderBy('_id')->offset(2)->one();
+ $this->assertEquals('user3', $customer->name);
+
+ $customer = $customerClass::find()->offset(3)->one();
+ $this->assertNull($customer);
+ }
+
+ public function testFindComplexCondition(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $this->assertEquals(2, $customerClass::find()->where(['OR', ['name' => 'user1'], ['name' => 'user2']])->count());
+ $this->assertCount(2, $customerClass::find()->where(['OR', ['name' => 'user1'], ['name' => 'user2']])->all());
+
+ $this->assertEquals(2, $customerClass::find()->where(['name' => ['user1', 'user2']])->count());
+ $this->assertCount(2, $customerClass::find()->where(['name' => ['user1', 'user2']])->all());
+
+ $this->assertEquals(1, $customerClass::find()->where(['AND', ['name' => ['user2', 'user3']], ['BETWEEN', 'status', 2, 4]])->count());
+ $this->assertCount(1, $customerClass::find()->where(['AND', ['name' => ['user2', 'user3']], ['BETWEEN', 'status', 2, 4]])->all());
+ }
+
+ public function testFindNullValues(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customer = $customerClass::findOne(2);
+ $customer->name = null;
+ $customer->save(false);
+ Record::refreshIndex($customerClass, $customerClass::$db);
+
+ $result = $customerClass::find()->where(['name' => null])->all();
+ $this->assertCount(1, $result);
+ $this->assertEquals(2, reset($result)->_id);
+ }
+
+ public function testExists(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $this->assertTrue($customerClass::find()->where(['_id' => 2])->exists());
+ $this->assertFalse($customerClass::find()->where(['_id' => 5])->exists());
+ $this->assertTrue($customerClass::find()->where(['name' => 'user1'])->exists());
+ $this->assertFalse($customerClass::find()->where(['name' => 'user5'])->exists());
+
+ $this->assertTrue($customerClass::find()->where(['_id' => [2, 3]])->exists());
+ $this->assertTrue($customerClass::find()->where(['_id' => [2, 3]])->offset(1)->exists());
+ $this->assertFalse($customerClass::find()->where(['_id' => [2, 3]])->offset(2)->exists());
+ }
+
+ public function testFindLazy(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customer = $customerClass::findOne(2);
+ $this->assertFalse($customer->isRelationPopulated('orders'));
+ $orders = $customer->orders;
+ $this->assertTrue($customer->isRelationPopulated('orders'));
+ $this->assertCount(2, $orders);
+ $this->assertCount(1, $customer->relatedRecords);
+
+ // unset
+ unset($customer['orders']);
+ $this->assertFalse($customer->isRelationPopulated('orders'));
+
+ /* @var $customer Customer */
+ $customer = $customerClass::findOne(2);
+ $this->assertFalse($customer->isRelationPopulated('orders'));
+ $orders = $customer->getOrders()->where(['_id' => 3])->all();
+ $this->assertFalse($customer->isRelationPopulated('orders'));
+ $this->assertCount(0, $customer->relatedRecords);
+
+ $this->assertCount(1, $orders);
+ $this->assertEquals(3, $orders[0]->_id);
+ }
+
+ public function testFindEager(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ $orderClass = $this->getOrderClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customers = $customerClass::find()->with('orders')->indexBy('_id')->all();
+ ksort($customers);
+ $this->assertCount(3, $customers);
+ $this->assertTrue($customers[1]->isRelationPopulated('orders'));
+ $this->assertTrue($customers[2]->isRelationPopulated('orders'));
+ $this->assertTrue($customers[3]->isRelationPopulated('orders'));
+ $this->assertCount(1, $customers[1]->orders);
+ $this->assertCount(2, $customers[2]->orders);
+ $this->assertCount(0, $customers[3]->orders);
+ // unset
+ unset($customers[1]->orders);
+ $this->assertFalse($customers[1]->isRelationPopulated('orders'));
+
+ $customer = $customerClass::find()->where(['_id' => 1])->with('orders')->one();
+ $this->assertTrue($customer->isRelationPopulated('orders'));
+ $this->assertCount(1, $customer->orders);
+ $this->assertCount(1, $customer->relatedRecords);
+
+ // multiple with() calls
+ $orders = $orderClass::find()->with('customer', 'items')->all();
+ $this->assertCount(3, $orders);
+ $this->assertTrue($orders[0]->isRelationPopulated('customer'));
+ $this->assertTrue($orders[0]->isRelationPopulated('items'));
+ $orders = $orderClass::find()->with('customer')->with('items')->all();
+ $this->assertCount(3, $orders);
+ $this->assertTrue($orders[0]->isRelationPopulated('customer'));
+ $this->assertTrue($orders[0]->isRelationPopulated('items'));
+ }
+
+ public function testFindLazyVia(): void
+ {
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ $orderClass = $this->getOrderClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ /* @var $order Order */
+ $order = $orderClass::findOne(1);
+ $this->assertEquals(1, $order->_id);
+ $this->assertCount(2, $order->items);
+ $this->assertEquals(1, $order->items[0]->_id);
+ $this->assertEquals(2, $order->items[1]->_id);
+ }
+
+ public function testFindLazyVia2(): void
+ {
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ $orderClass = $this->getOrderClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ /* @var $order Order */
+ $order = $orderClass::findOne(1);
+ $order->_id = 100;
+ $this->assertEquals([], $order->items);
+ }
+
+ public function testFindEagerViaRelation(): void
+ {
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ $orderClass = $this->getOrderClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $orders = $orderClass::find()->with('items')->orderBy('_id')->all();
+ $this->assertCount(3, $orders);
+ $order = $orders[0];
+ $this->assertEquals(1, $order->_id);
+ $this->assertTrue($order->isRelationPopulated('items'));
+ $this->assertCount(2, $order->items);
+ $this->assertEquals(1, $order->items[0]->_id);
+ $this->assertEquals(2, $order->items[1]->_id);
+ }
+
+ public function testFindNestedRelation(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customers = $customerClass::find()->with('orders', 'orders.items')->indexBy('_id')->all();
+ ksort($customers);
+ $this->assertCount(3, $customers);
+ $this->assertTrue($customers[1]->isRelationPopulated('orders'));
+ $this->assertTrue($customers[2]->isRelationPopulated('orders'));
+ $this->assertTrue($customers[3]->isRelationPopulated('orders'));
+ $this->assertCount(1, $customers[1]->orders);
+ $this->assertCount(2, $customers[2]->orders);
+ $this->assertCount(0, $customers[3]->orders);
+ $this->assertTrue($customers[1]->orders[0]->isRelationPopulated('items'));
+ $this->assertTrue($customers[2]->orders[0]->isRelationPopulated('items'));
+ $this->assertTrue($customers[2]->orders[1]->isRelationPopulated('items'));
+ $this->assertCount(2, $customers[1]->orders[0]->items);
+ $this->assertCount(3, $customers[2]->orders[0]->items);
+ $this->assertCount(1, $customers[2]->orders[1]->items);
+
+ $customers = $customerClass::find()->where(['_id' => 1])->with('ordersWithItems')->one();
+ $this->assertTrue($customers->isRelationPopulated('ordersWithItems'));
+ $this->assertCount(1, $customers->ordersWithItems);
+
+ /** @var Order $order */
+ $order = $customers->ordersWithItems[0];
+ $this->assertTrue($order->isRelationPopulated('orderItems'));
+ $this->assertCount(2, $order->orderItems);
+ }
+
+ /**
+ * Ensure ActiveRelationTrait does preserve order of items on find via().
+ *
+ * @see https://github.com/yiisoft/yii2/issues/1310.
+ */
+ public function testFindEagerViaRelationPreserveOrder(): void
+ {
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ $orderClass = $this->getOrderClass();
+
+ /* @var $this TestCase|ActiveRecordTestTrait */
+
+ /*
+ Item (name, category_id)
+ Order (customer_id, created_at, total)
+ OrderItem (order_id, item_id, quantity, subtotal)
+
+ Result should be the following:
+
+ Order 1: 1, 1325282384, 110.0
+ - orderItems:
+ OrderItem: 1, 1, 1, 30.0
+ OrderItem: 1, 2, 2, 40.0
+ - itemsInOrder:
+ Item 1: 'Agile Web Application Development with Yii1.1 and PHP5', 1
+ Item 2: 'Yii 1.1 Application Development Cookbook', 1
+
+ Order 2: 2, 1325334482, 33.0
+ - orderItems:
+ OrderItem: 2, 3, 1, 8.0
+ OrderItem: 2, 4, 1, 10.0
+ OrderItem: 2, 5, 1, 15.0
+ - itemsInOrder:
+ Item 5: 'Cars', 2
+ Item 3: 'Ice Age', 2
+ Item 4: 'Toy Story', 2
+ Order 3: 2, 1325502201, 40.0
+ - orderItems:
+ OrderItem: 3, 2, 1, 40.0
+ - itemsInOrder:
+ Item 3: 'Ice Age', 2
+ */
+ $orders = $orderClass::find()->with('itemsInOrder1')->orderBy('created_at')->all();
+ $this->assertCount(3, $orders);
+
+ $order = $orders[0];
+ $this->assertEquals(1, $order->_id);
+ $this->assertTrue($order->isRelationPopulated('itemsInOrder1'));
+ $this->assertCount(2, $order->itemsInOrder1);
+ $this->assertEquals(1, $order->itemsInOrder1[0]->_id);
+ $this->assertEquals(2, $order->itemsInOrder1[1]->_id);
+
+ $order = $orders[1];
+ $this->assertEquals(2, $order->_id);
+ $this->assertTrue($order->isRelationPopulated('itemsInOrder1'));
+ $this->assertCount(3, $order->itemsInOrder1);
+ $this->assertEquals(5, $order->itemsInOrder1[0]->_id);
+ $this->assertEquals(3, $order->itemsInOrder1[1]->_id);
+ $this->assertEquals(4, $order->itemsInOrder1[2]->_id);
+
+ $order = $orders[2];
+ $this->assertEquals(3, $order->_id);
+ $this->assertTrue($order->isRelationPopulated('itemsInOrder1'));
+ $this->assertCount(1, $order->itemsInOrder1);
+ $this->assertEquals(2, $order->itemsInOrder1[0]->_id);
+ }
+
+ // different order in via table
+ public function testFindEagerViaRelationPreserveOrderB(): void
+ {
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ $orderClass = $this->getOrderClass();
+
+ $orders = $orderClass::find()->with('itemsInOrder2')->orderBy('created_at')->all();
+ $this->assertCount(3, $orders);
+
+ $order = $orders[0];
+ $this->assertEquals(1, $order->_id);
+ $this->assertTrue($order->isRelationPopulated('itemsInOrder2'));
+ $this->assertCount(2, $order->itemsInOrder2);
+ $this->assertEquals(1, $order->itemsInOrder2[0]->_id);
+ $this->assertEquals(2, $order->itemsInOrder2[1]->_id);
+
+ $order = $orders[1];
+ $this->assertEquals(2, $order->_id);
+ $this->assertTrue($order->isRelationPopulated('itemsInOrder2'));
+ $this->assertCount(3, $order->itemsInOrder2);
+ $this->assertEquals(5, $order->itemsInOrder2[0]->_id);
+ $this->assertEquals(3, $order->itemsInOrder2[1]->_id);
+ $this->assertEquals(4, $order->itemsInOrder2[2]->_id);
+
+ $order = $orders[2];
+ $this->assertEquals(3, $order->_id);
+ $this->assertTrue($order->isRelationPopulated('itemsInOrder2'));
+ $this->assertCount(1, $order->itemsInOrder2);
+ $this->assertEquals(2, $order->itemsInOrder2[0]->_id);
+ }
+
+ public function testLink(): void
+ {
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ /* @var $itemClass \yii\db\ActiveRecordInterface */
+ /* @var $orderItemClass \yii\db\ActiveRecordInterface */
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ $orderClass = $this->getOrderClass();
+ $orderItemClass = $this->getOrderItemClass();
+ $itemClass = $this->getItemClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customer = $customerClass::findOne(2);
+ $this->assertCount(2, $customer->orders);
+
+ // has many
+ $order = new $orderClass();
+ $order->total = 100;
+ $this->assertTrue($order->isNewRecord);
+ $customer->link('orders', $order);
+ Record::refreshIndex($orderClass, $orderClass::$db);
+
+ $this->assertCount(3, $customer->orders);
+ $this->assertFalse($order->isNewRecord);
+ $this->assertCount(3, $customer->getOrders()->all());
+ $this->assertEquals(2, $order->customer_id);
+
+ // belongs to
+ $order = new $orderClass();
+ $order->total = 100;
+ $this->assertTrue($order->isNewRecord);
+ $customer = $customerClass::findOne(1);
+ $this->assertNull($order->customer);
+ $order->link('customer', $customer);
+ $this->assertFalse($order->isNewRecord);
+ $this->assertEquals(1, $order->customer_id);
+ $this->assertEquals(1, $order->customer->_id);
+
+ // via model
+ $order = $orderClass::findOne(1);
+ $this->assertCount(2, $order->items);
+ $this->assertCount(2, $order->orderItems);
+ $orderItem = $orderItemClass::findOne(['order_id' => 1, 'item_id' => 3]);
+ $this->assertNull($orderItem);
+ $item = $itemClass::findOne(3);
+ $order->link('items', $item, ['quantity' => 10, 'subtotal' => 100]);
+ Record::refreshIndex($orderItemClass, $orderItemClass::$db);
+
+ $this->assertCount(3, $order->items);
+ $this->assertCount(3, $order->orderItems);
+ $orderItem = $orderItemClass::findOne(['order_id' => 1, 'item_id' => 3]);
+ $this->assertInstanceOf($orderItemClass, $orderItem);
+ $this->assertEquals(10, $orderItem->quantity);
+ $this->assertEquals(100, $orderItem->subtotal);
+ }
+
+ public function testUnlinkHasManyWithDelete(): void
+ {
+ $customerClass = $this->getCustomerClass();
+ $orderClass = $this->getOrderClass();
+
+ // has many with delete
+ $customer = $customerClass::findOne(2);
+ $this->assertCount(2, $customer->orders);
+ $customer->unlink('orders', $customer->orders[1], true);
+ Record::refreshIndex($orderClass, $orderClass::$db);
+
+ $this->assertCount(1, $customer->orders);
+ $this->assertNull($orderClass::findOne(3));
+ }
+
+ public function testUnlinkHasManyWithoutDelete(): void
+ {
+ $customerClass = $this->getCustomerClass();
+ $orderClass = $this->getOrderClass();
+
+ // has many without delete
+ $customer = $customerClass::findOne(2);
+ $this->assertCount(2, $customer->orders);
+ $customer->unlink('orders', $customer->orders[1], false);
+
+ $this->assertCount(1, $customer->orders);
+ $order = $orderClass::findOne(3);
+
+ $this->assertEquals(3, $order->_id);
+ $this->assertNull($order->customer_id);
+ }
+
+ public function testUnlinkViaModelWithDelete(): void
+ {
+ $orderClass = $this->getOrderClass();
+ $orderItemClass = $this->getOrderItemClass();
+
+ // via model with delete
+ $order = $orderClass::findOne(2);
+ $this->assertCount(3, $order->items);
+ $this->assertCount(3, $order->orderItems);
+ $order->unlink('items', $order->items[2], true);
+ Record::refreshIndex($orderItemClass, $orderItemClass::$db);
+
+ $this->assertCount(2, $order->items);
+ $this->assertCount(2, $order->orderItems);
+ }
+
+ public function testUnlinkViaModelWithoutDelete(): void
+ {
+ $orderClass = $this->getOrderClass();
+ $orderItemClass = $this->getOrderItemClass();
+
+ // via model without delete
+ $order = $orderClass::findOne(2);
+ $this->assertCount(3, $order->items);
+ $order->unlink('items', $order->items[2], false);
+ Record::refreshIndex($orderItemClass, $orderItemClass::$db);
+
+ $this->assertCount(2, $order->items);
+ $this->assertCount(2, $order->orderItems);
+ }
+
+ public static $afterSaveNewRecord;
+ public static $afterSaveInsert;
+
+ public function testInsert(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customer = new $customerClass();
+ $customer->email = 'user4@example.com';
+ $customer->name = 'user4';
+ $customer->address = 'address4';
+
+ $this->assertNull($customer->_id);
+ $this->assertTrue($customer->isNewRecord);
+ static::$afterSaveNewRecord = null;
+ static::$afterSaveInsert = null;
+
+ $customer->save();
+ Record::refreshIndex($customerClass, $customerClass::$db);
+
+ $this->assertNotNull($customer->_id);
+ $this->assertFalse(static::$afterSaveNewRecord);
+ $this->assertTrue(static::$afterSaveInsert);
+ $this->assertFalse($customer->isNewRecord);
+ }
+
+ public function testExplicitPkOnAutoIncrement(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ $customer = new $customerClass();
+ $customer->_id = 1337;
+ $customer->email = 'user1337@example.com';
+ $customer->name = 'user1337';
+ $customer->address = 'address1337';
+
+ $this->assertTrue($customer->isNewRecord);
+ $customer->save();
+ Record::refreshIndex($customerClass, $customerClass::$db);
+
+ $this->assertEquals(1337, $customer->_id);
+ $this->assertFalse($customer->isNewRecord);
+ }
+
+ public function testUpdate(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // save
+ /* @var $customer Customer */
+ $customer = $customerClass::findOne(2);
+ $this->assertInstanceOf($customerClass, $customer);
+ $this->assertEquals('user2', $customer->name);
+ $this->assertFalse($customer->isNewRecord);
+ static::$afterSaveNewRecord = null;
+ static::$afterSaveInsert = null;
+ $this->assertEmpty($customer->dirtyAttributes);
+
+ $customer->name = 'user2x';
+ $customer->save();
+ Record::refreshIndex($customerClass, $customerClass::$db);
+ $this->assertEquals('user2x', $customer->name);
+ $this->assertFalse($customer->isNewRecord);
+ $this->assertFalse(static::$afterSaveNewRecord);
+ $this->assertFalse(static::$afterSaveInsert);
+ $customer2 = $customerClass::findOne(2);
+ $this->assertEquals('user2x', $customer2->name);
+
+ // updateAll
+ $customer = $customerClass::findOne(3);
+ $this->assertEquals('user3', $customer->name);
+ $ret = $customerClass::updateAll(['name' => 'temp'], ['_id' => 3]);
+ Record::refreshIndex($customerClass, $customerClass::$db);
+ $this->assertEquals(1, $ret);
+ $customer = $customerClass::findOne(3);
+ $this->assertEquals('temp', $customer->name);
+
+ $ret = $customerClass::updateAll(['name' => 'tempX']);
+ Record::refreshIndex($customerClass, $customerClass::$db);
+ $this->assertEquals(3, $ret);
+
+ $ret = $customerClass::updateAll(['name' => 'temp'], ['name' => 'user6']);
+ Record::refreshIndex($customerClass, $customerClass::$db);
+ $this->assertEquals(0, $ret);
+ }
+
+ public function testUpdateAttributes(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ /* @var $customer Customer */
+ $customer = $customerClass::findOne(2);
+ $this->assertInstanceOf($customerClass, $customer);
+ $this->assertEquals('user2', $customer->name);
+ $this->assertFalse($customer->isNewRecord);
+ static::$afterSaveNewRecord = null;
+ static::$afterSaveInsert = null;
+
+ $customer->updateAttributes(['name' => 'user2x']);
+ Record::refreshIndex($customerClass, $customerClass::$db);
+ $this->assertEquals('user2x', $customer->name);
+ $this->assertFalse($customer->isNewRecord);
+ $this->assertNull(static::$afterSaveNewRecord);
+ $this->assertNull(static::$afterSaveInsert);
+ $customer2 = $customerClass::findOne(2);
+ $this->assertEquals('user2x', $customer2->name);
+
+ $customer = $customerClass::findOne(1);
+ $this->assertEquals('user1', $customer->name);
+ $this->assertEquals(1, $customer->status);
+ $customer->name = 'user1x';
+ $customer->status = 2;
+ $customer->updateAttributes(['name']);
+ $this->assertEquals('user1x', $customer->name);
+ $this->assertEquals(2, $customer->status);
+ $customer = $customerClass::findOne(1);
+ $this->assertEquals('user1x', $customer->name);
+ $this->assertEquals(1, $customer->status);
+ }
+
+ public function testUpdateCounters(): void
+ {
+ /* @var $orderItemClass \yii\db\ActiveRecordInterface */
+ $orderItemClass = $this->getOrderItemClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // updateCounters
+ $pk = ['order_id' => 2, 'item_id' => 4];
+ $orderItem = $orderItemClass::findOne($pk);
+ $this->assertEquals(1, $orderItem->quantity);
+ $ret = $orderItem->updateCounters(['quantity' => -1]);
+ Record::refreshIndex($orderItemClass, $orderItemClass::$db);
+ $this->assertEquals(1, $ret);
+ $this->assertEquals(0, $orderItem->quantity);
+ $orderItem = $orderItemClass::findOne($pk);
+ $this->assertEquals(0, $orderItem->quantity);
+
+ // updateAllCounters
+ $pk = ['order_id' => 1, 'item_id' => 2];
+ $orderItem = $orderItemClass::findOne($pk);
+ $this->assertEquals(2, $orderItem->quantity);
+ $ret = $orderItemClass::updateAllCounters([
+ 'quantity' => 3,
+ 'subtotal' => -10,
+ ], $pk);
+ Record::refreshIndex($orderItemClass, $orderItemClass::$db);
+ $this->assertEquals(1, $ret);
+ $orderItem = $orderItemClass::findOne($pk);
+ $this->assertEquals(5, $orderItem->quantity);
+ $this->assertEquals(30, $orderItem->subtotal);
+ }
+
+ public function testDelete(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+ // delete
+ $customer = $customerClass::findOne(2);
+ $this->assertInstanceOf($customerClass, $customer);
+ $this->assertEquals('user2', $customer->name);
+ $customer->delete();
+ Record::refreshIndex($customerClass, $customerClass::$db);
+ $customer = $customerClass::findOne(2);
+ $this->assertNull($customer);
+
+ // deleteAll
+ $customers = $customerClass::find()->all();
+ $this->assertCount(2, $customers);
+ $ret = $customerClass::deleteAll();
+ Record::refreshIndex($customerClass, $customerClass::$db);
+ $this->assertEquals(2, $ret);
+ $customers = $customerClass::find()->all();
+ $this->assertCount(0, $customers);
+
+ $ret = $customerClass::deleteAll();
+ Record::refreshIndex($customerClass, $customerClass::$db);
+ $this->assertEquals(0, $ret);
+ }
+
+ public function testAfterFind(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $orderClass BaseActiveRecord */
+ $orderClass = $this->getOrderClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+
+ $afterFindCalls = [];
+ Event::on(BaseActiveRecord::className(), BaseActiveRecord::EVENT_AFTER_FIND, function ($event) use (&$afterFindCalls): void {
+ /* @var $ar BaseActiveRecord */
+ $ar = $event->sender;
+ $afterFindCalls[] = [$ar::class, $ar->getIsNewRecord(), $ar->getPrimaryKey(), $ar->isRelationPopulated('orders')];
+ });
+
+ $customer = $customerClass::findOne(1);
+ $this->assertNotNull($customer);
+ $this->assertEquals([[$customerClass, false, 1, false]], $afterFindCalls);
+ $afterFindCalls = [];
+
+ $customer = $customerClass::find()->where(['_id' => 1])->one();
+ $this->assertNotNull($customer);
+ $this->assertEquals([[$customerClass, false, 1, false]], $afterFindCalls);
+ $afterFindCalls = [];
+
+ $customer = $customerClass::find()->where(['_id' => 1])->all();
+ $this->assertNotNull($customer);
+ $this->assertEquals([[$customerClass, false, 1, false]], $afterFindCalls);
+ $afterFindCalls = [];
+
+ $customer = $customerClass::find()->where(['_id' => 1])->with('orders')->all();
+ $this->assertNotNull($customer);
+ $this->assertEquals([
+ [$this->getOrderClass(), false, 1, false],
+ [$customerClass, false, 1, true],
+ ], $afterFindCalls);
+ $afterFindCalls = [];
+
+ if ($this instanceof \yiiunit\extensions\redis\ActiveRecordTest) { // TODO redis does not support orderBy() yet
+ $customer = $customerClass::find()->where(['_id' => [1, 2]])->with('orders')->all();
+ } else {
+ // orderBy is needed to avoid random test failure
+ $customer = $customerClass::find()->where(['_id' => [1, 2]])->with('orders')->orderBy('name')->all();
+ }
+ $this->assertNotNull($customer);
+ $this->assertEquals([
+ [$orderClass, false, 1, false],
+ [$orderClass, false, 2, false],
+ [$orderClass, false, 3, false],
+ [$customerClass, false, 1, true],
+ [$customerClass, false, 2, true],
+ ], $afterFindCalls);
+ $afterFindCalls = [];
+
+ Event::off(BaseActiveRecord::className(), BaseActiveRecord::EVENT_AFTER_FIND);
+ }
+
+ public function testAfterRefresh(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+
+ $afterRefreshCalls = [];
+ Event::on(BaseActiveRecord::className(), BaseActiveRecord::EVENT_AFTER_REFRESH, function ($event) use (&$afterRefreshCalls): void {
+ /* @var $ar BaseActiveRecord */
+ $ar = $event->sender;
+ $afterRefreshCalls[] = [$ar::class, $ar->getIsNewRecord(), $ar->getPrimaryKey(), $ar->isRelationPopulated('orders')];
+ });
+
+ $customer = $customerClass::findOne(1);
+ $this->assertNotNull($customer);
+ $customer->refresh();
+ $this->assertEquals([[$customerClass, false, 1, false]], $afterRefreshCalls);
+ $afterRefreshCalls = [];
+ Event::off(BaseActiveRecord::className(), BaseActiveRecord::EVENT_AFTER_REFRESH);
+ }
+
+ public function testFindEmptyInCondition(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ /* @var $this TestCase|ActiveRecordTestTrait */
+
+ $customers = $customerClass::find()->where(['_id' => [1]])->all();
+ $this->assertCount(1, $customers);
+
+ $customers = $customerClass::find()->where(['_id' => []])->all();
+ $this->assertCount(0, $customers);
+
+ $customers = $customerClass::find()->where(['IN', '_id', [1]])->all();
+ $this->assertCount(1, $customers);
+
+ $customers = $customerClass::find()->where(['IN', '_id', []])->all();
+ $this->assertCount(0, $customers);
+ }
+
+ public function testFindEagerIndexBy(): void
+ {
+ /* @var $this TestCase|ActiveRecordTestTrait */
+
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ $orderClass = $this->getOrderClass();
+
+ /* @var $order Order */
+ $order = $orderClass::find()->with('itemsIndexed')->where(['_id' => 1])->one();
+ $this->assertTrue($order->isRelationPopulated('itemsIndexed'));
+ $items = $order->itemsIndexed;
+ $this->assertCount(2, $items);
+ $this->assertTrue(isset($items[1]));
+ $this->assertTrue(isset($items[2]));
+
+ /* @var $order Order */
+ $order = $orderClass::find()->with('itemsIndexed')->where(['_id' => 2])->one();
+ $this->assertTrue($order->isRelationPopulated('itemsIndexed'));
+ $items = $order->itemsIndexed;
+ $this->assertCount(3, $items);
+ $this->assertTrue(isset($items[3]));
+ $this->assertTrue(isset($items[4]));
+ $this->assertTrue(isset($items[5]));
+ }
+
+ public function testAttributeAccess(): void
+ {
+ /* @var $customerClass \yii\db\ActiveRecordInterface */
+ $customerClass = $this->getCustomerClass();
+ $model = new $customerClass();
+
+ $this->assertTrue($model->canSetProperty('name'));
+ $this->assertTrue($model->canGetProperty('name'));
+ $this->assertFalse($model->canSetProperty('unExistingColumn'));
+ $this->assertFalse(isset($model->name));
+
+ $model->name = 'foo';
+ $this->assertTrue(isset($model->name));
+ unset($model->name);
+ $this->assertNull($model->name);
+
+ // @see https://github.com/yiisoft/yii2-gii/issues/190
+ $baseModel = new $customerClass();
+ $this->assertFalse($baseModel->hasProperty('unExistingColumn'));
+
+
+ /* @var $customer ActiveRecord */
+ $customer = new $customerClass();
+ $this->assertInstanceOf($customerClass, $customer);
+
+ $this->assertTrue($customer->canGetProperty('_id'));
+ $this->assertTrue($customer->canSetProperty('_id'));
+
+ // tests that we really can get and set this property
+ $this->assertNull($customer->_id);
+ $customer->_id = 10;
+ $this->assertNotNull($customer->_id);
+
+ // Let's test relations
+ $this->assertTrue($customer->canGetProperty('orderItems'));
+ $this->assertFalse($customer->canSetProperty('orderItems'));
+
+ // Newly created model must have empty relation
+ $this->assertSame([], $customer->orderItems);
+
+ // does it still work after accessing the relation?
+ $this->assertTrue($customer->canGetProperty('orderItems'));
+ $this->assertFalse($customer->canSetProperty('orderItems'));
+
+ try {
+ /* @var $itemClass \yii\db\ActiveRecordInterface */
+ $itemClass = $this->getItemClass();
+ $customer->orderItems = [new $itemClass()];
+ $this->fail('setter call above MUST throw Exception');
+ } catch (\Exception $e) {
+ // catch exception "Setting read-only property"
+ $this->assertInstanceOf('yii\base\InvalidCallException', $e);
+ }
+
+ // related attribute $customer->orderItems didn't change cause it's read-only
+ $this->assertSame([], $customer->orderItems);
+
+ $this->assertFalse($customer->canGetProperty('non_existing_property'));
+ $this->assertFalse($customer->canSetProperty('non_existing_property'));
+ }
+
+ /**
+ * @see https://github.com/yiisoft/yii2/issues/17089
+ */
+ public function testViaWithCallable(): void
+ {
+ /* @var $orderClass \yii\db\ActiveRecordInterface */
+ $orderClass = $this->getOrderClass();
+
+ /* @var Order $order */
+ $order = $orderClass::findOne(2);
+
+ $expensiveItems = $order->expensiveItemsUsingViaWithCallable;
+ $cheapItems = $order->cheapItemsUsingViaWithCallable;
+
+ $this->assertCount(2, $expensiveItems);
+
+ $expensiveItemIds = [
+ $expensiveItems[0]->_id,
+ $expensiveItems[1]->_id,
+ ];
+
+ $this->assertContains('4', $expensiveItemIds);
+ $this->assertContains('5', $expensiveItemIds);
+ $this->assertCount(1, $cheapItems);
+ $this->assertEquals(3, $cheapItems[0]->_id);
+ }
+}
diff --git a/tests/CommandTest.php b/tests/CommandTest.php
new file mode 100644
index 0000000..be363d4
--- /dev/null
+++ b/tests/CommandTest.php
@@ -0,0 +1,526 @@
+command = $this->getConnection()->createCommand();
+ }
+
+ /**
+ * @test
+ */
+ public function aliasExists_noAliasesSet_returnsFalse(): void
+ {
+ $testAlias = 'test';
+ $aliasExists = $this->command->aliasExists($testAlias);
+
+ $this->assertFalse($aliasExists);
+ }
+
+ /**
+ * @test
+ */
+ public function aliasExists_AliasesAreSetButWithDifferentName_returnsFalse(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test';
+ $fooAlias1 = 'alias';
+ $fooAlias2 = 'alias2';
+
+ $this->command->createIndex($index);
+ $this->command->addAlias($index, $fooAlias1);
+ $this->command->addAlias($index, $fooAlias2);
+ $aliasExists = $this->command->aliasExists($testAlias);
+ $this->command->deleteIndex($index);
+
+ $this->assertFalse($aliasExists);
+ }
+
+ /**
+ * @test
+ */
+ public function aliasExists_AliasIsSetWithSameName_returnsTrue(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test';
+
+ $this->command->createIndex($index);
+ $this->command->addAlias($index, $testAlias);
+ $aliasExists = $this->command->aliasExists($testAlias);
+ $this->command->deleteIndex($index);
+
+ $this->assertTrue($aliasExists);
+ }
+
+ /**
+ * @test
+ */
+ public function getAliasInfo_noAliasSet_returnsEmptyArray(): void
+ {
+ $expectedResult = [];
+ $actualResult = $this->command->getAliasInfo();
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideDataForGetAliasInfo
+ */
+ public function getAliasInfo_singleAliasIsSet_returnsInfoForAlias(
+ string $index,
+ string $type,
+ array $mapping,
+ string $alias,
+ array $expectedResult,
+ array $aliasParameters
+ ): void {
+ if ($this->command->indexExists($index)) {
+ $this->command->deleteIndex($index);
+ }
+ $this->command->createIndex($index);
+ if ($mapping) {
+ $this->command->setMapping($index, $type, $mapping);
+ }
+ $this->command->addAlias($index, $alias, $aliasParameters);
+ $actualResult = $this->command->getAliasInfo();
+ $this->command->deleteIndex($index);
+
+ // order is not guaranteed
+ sort($expectedResult);
+ sort($actualResult);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideDataForGetAliasInfo()
+ {
+ $index = 'alias_test';
+ $type = 'alias_test_type';
+ $alias = 'test';
+ $filter = [
+ 'filter' => [
+ 'term' => [
+ 'user' => 'satan',
+ ],
+ ],
+ ];
+ $mapping = [
+ 'properties' => [
+ 'user' => ['type' => 'keyword'],
+ ],
+ ];
+ $singleRouting = [
+ 'routing' => '1',
+ ];
+ $singleExpectedRouting = [
+ 'index_routing' => '1',
+ 'search_routing' => '1',
+ ];
+ $differentRouting = [
+ 'index_routing' => '2',
+ 'search_routing' => '1,2',
+ ];
+
+ return [
+ [
+ $index,
+ $type,
+ $mapping,
+ $alias,
+ [
+ $index => [
+ 'aliases' => [
+ $alias => [],
+ ],
+ ],
+ ],
+ [],
+ ],
+ [
+ $index,
+ $type,
+ $mapping,
+ $alias,
+ [
+ $index => [
+ 'aliases' => [
+ $alias => $filter,
+ ],
+ ],
+ ],
+ $filter,
+ ],
+ [
+ $index,
+ $type,
+ $mapping,
+ $alias,
+ [
+ $index => [
+ 'aliases' => [
+ $alias => $singleExpectedRouting,
+ ],
+ ],
+ ],
+ $singleRouting,
+ ],
+ [
+ $index,
+ $type,
+ $mapping,
+ $alias,
+ [
+ $index => [
+ 'aliases' => [
+ $alias => $differentRouting,
+ ],
+ ],
+ ],
+ $differentRouting,
+ ],
+ [
+ $index,
+ $type,
+ $mapping,
+ $alias,
+ [
+ $index => [
+ 'aliases' => [
+ $alias => [...$filter, ...$singleExpectedRouting],
+ ],
+ ],
+ ],
+ [...$filter, ...$singleRouting],
+ ],
+ [
+ $index,
+ $type,
+ $mapping,
+ $alias,
+ [
+ $index => [
+ 'aliases' => [
+ $alias => [...$filter, ...$differentRouting],
+ ],
+ ],
+ ],
+ [...$filter, ...$differentRouting],
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ */
+ public function getIndexInfoByAlias_noAliasesSet_returnsEmptyArray(): void
+ {
+ $testAlias = 'test';
+ $expectedResult = [];
+
+ $actualResult = $this->command->getIndexInfoByAlias($testAlias);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function getIndexInfoByAlias_oneIndexIsSetToAlias_returnsDataForThatIndex(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test';
+ $expectedResult = [
+ $index => [
+ 'aliases' => [
+ $testAlias => [],
+ ],
+ ],
+ ];
+
+ $this->command->createIndex($index);
+ $this->command->addAlias($index, $testAlias);
+ $actualResult = $this->command->getIndexInfoByAlias($testAlias);
+ $this->command->deleteIndex($index);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function getIndexInfoByAlias_twoIndexesAreSetToSameAlias_returnsDataForBothIndexes(): void
+ {
+ $index1 = 'alias_test1';
+ $index2 = 'alias_test2';
+ $testAlias = 'test';
+ $expectedResult = [
+ $index1 => [
+ 'aliases' => [
+ $testAlias => [],
+ ],
+ ],
+ $index2 => [
+ 'aliases' => [
+ $testAlias => [],
+ ],
+ ],
+ ];
+
+ $this->command->createIndex($index1);
+ $this->command->createIndex($index2);
+ $this->command->addAlias($index1, $testAlias);
+ $this->command->addAlias($index2, $testAlias);
+ $actualResult = $this->command->getIndexInfoByAlias($testAlias);
+ $this->command->deleteIndex($index1);
+ $this->command->deleteIndex($index2);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function getIndexesByAlias_noAliasesSet_returnsEmptyArray(): void
+ {
+ $expectedResult = [];
+ $testAlias = 'test';
+
+ $actualResult = $this->command->getIndexesByAlias($testAlias);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function getIndexesByAlias_oneIndexIsSetToAlias_returnsArrayWithNameOfThatIndex(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test';
+ $expectedResult = [$index];
+
+ $this->command->createIndex($index);
+ $this->command->addAlias($index, $testAlias);
+ $actualResult = $this->command->getIndexesByAlias($testAlias);
+ $this->command->deleteIndex($index);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function getIndexesByAlias_twoIndexesAreSetToSameAlias_returnsArrayWithNamesForBothIndexes(): void
+ {
+ $index1 = 'alias_test1';
+ $index2 = 'alias_test2';
+ $testAlias = 'test';
+ $expectedResult = [
+ $index1,
+ $index2,
+ ];
+
+ $this->command->createIndex($index1);
+ $this->command->createIndex($index2);
+ $this->command->addAlias($index1, $testAlias);
+ $this->command->addAlias($index2, $testAlias);
+ $actualResult = $this->command->getIndexesByAlias($testAlias);
+ $this->command->deleteIndex($index1);
+ $this->command->deleteIndex($index2);
+
+ // order is not guaranteed
+ sort($expectedResult);
+ sort($actualResult);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function getIndexAliases_noAliasesSet_returnsEmptyArray(): void
+ {
+ $index = 'alias_test';
+ $expectedResult = [];
+
+ $actualResult = $this->command->getIndexAliases($index);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ *
+ * @todo maybe add more test with alias settings
+ */
+ public function getIndexAliases_SingleAliasIsSet_returnsDataForThatAlias(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test_alias';
+ $expectedResult = [
+ $testAlias => [],
+ ];
+
+ $this->command->createIndex($index);
+ $this->command->addAlias($index, $testAlias);
+ $actualResult = $this->command->getIndexAliases($index);
+ $this->command->deleteIndex($index);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ *
+ * @todo maybe add more test with alias settings
+ */
+ public function getIndexAliases_MultipleAliasesAreSet_returnsDataForThoseAliases(): void
+ {
+ $index = 'alias_test';
+ $testAlias1 = 'test_alias1';
+ $testAlias2 = 'test_alias2';
+ $expectedResult = [
+ $testAlias1 => [],
+ $testAlias2 => [],
+ ];
+
+ $this->command->createIndex($index);
+ $this->command->addAlias($index, $testAlias1);
+ $this->command->addAlias($index, $testAlias2);
+ $actualResult = $this->command->getIndexAliases($index);
+ $this->command->deleteIndex($index);
+
+ $this->assertEquals($expectedResult, $actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function removeAlias_noAliasIsSetForIndex_returnsFalse(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test_alias';
+
+ $this->command->createIndex($index);
+ $actualResult = $this->command->removeAlias($index, $testAlias);
+ $this->command->deleteIndex($index);
+
+ $this->assertFalse($actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function removeAlias_aliasWasSetForIndex_returnsTrue(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test_alias';
+
+ $this->command->createIndex($index);
+ $this->command->addAlias($index, $testAlias);
+ $actualResult = $this->command->removeAlias($index, $testAlias);
+ $this->command->deleteIndex($index);
+
+ $this->assertTrue($actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function addAlias_aliasNonExistingIndex_returnsFalse(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test_alias';
+
+ $actualResult = $this->command->addAlias($index, $testAlias);
+
+ $this->assertFalse($actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function addAlias_aliasExistingIndex_returnsTrue(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test_alias';
+
+ $this->command->createIndex($index);
+ $actualResult = $this->command->addAlias($index, $testAlias);
+ $this->command->deleteIndex($index);
+
+ $this->assertTrue($actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function aliasActions_makingOperationOverNonExistingIndex_returnsFalse(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test_alias';
+
+ $actualResult = $this->command->aliasActions([
+ ['add' => ['index' => $index, 'alias' => $testAlias]],
+ ['remove' => ['index' => $index, 'alias' => $testAlias]],
+ ]);
+
+ $this->assertFalse($actualResult);
+ }
+
+ /**
+ * @test
+ */
+ public function aliasActions_makingOperationOverExistingIndex_returnsTrue(): void
+ {
+ $index = 'alias_test';
+ $testAlias = 'test_alias';
+
+ $this->command->createIndex($index);
+ $actualResult = $this->command->aliasActions([
+ ['add' => ['index' => $index, 'alias' => $testAlias]],
+ ['remove' => ['index' => $index, 'alias' => $testAlias]],
+ ]);
+ $this->command->deleteIndex($index);
+
+ $this->assertTrue($actualResult);
+ }
+
+ public function testIndexStats(): void
+ {
+ $cmd = $this->command;
+ if (!$cmd->indexExists('command-test')) {
+ $cmd->createIndex('command-test');
+ }
+ $stats = $cmd->getIndexStats();
+ $this->assertArrayHasKey('_all', $stats, print_r(array_keys($stats), true));
+ $this->assertArrayHasKey('indices', $stats, print_r(array_keys($stats), true));
+ $this->assertArrayHasKey('command-test', $stats['indices'], print_r(array_keys($stats['indices']), true));
+
+ $stats = $cmd->getIndexStats('command-test');
+ $this->assertArrayHasKey('_all', $stats, print_r(array_keys($stats), true));
+ $this->assertArrayHasKey('indices', $stats, print_r(array_keys($stats), true));
+ $this->assertArrayHasKey('command-test', $stats['indices'], print_r(array_keys($stats['indices']), true));
+ }
+}
diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php
new file mode 100644
index 0000000..c1ddf07
--- /dev/null
+++ b/tests/ConnectionTest.php
@@ -0,0 +1,54 @@
+connection = $this->getConnection();
+ }
+
+ public function testCreateUrl(): void
+ {
+ $reflectedMethod = new \ReflectionMethod($this->connection, 'createUrl');
+ $reflectedMethod->setAccessible(true);
+
+ $protocol = $this->connection->nodes[$this->connection->activeNode]['protocol'];
+ $httpAddress = $this->connection->nodes[$this->connection->activeNode]['http_address'];
+ $this->assertEquals([$protocol, $httpAddress, ''], $reflectedMethod->invoke($this->connection, []));
+
+ $this->assertEquals(
+ [$protocol, $httpAddress, '_cat/indices'],
+ $reflectedMethod->invoke($this->connection, '_cat/indices')
+ );
+
+ $this->assertEquals(
+ [$protocol, $httpAddress, 'customer'],
+ $reflectedMethod->invoke($this->connection, 'customer')
+ );
+
+ $this->assertEquals(
+ [$protocol, $httpAddress, 'customer/external/1'],
+ $reflectedMethod->invoke($this->connection, ['customer', 'external', '1'])
+ );
+
+ $this->assertEquals(
+ [$protocol, $httpAddress, 'customer/external/1/_update'],
+ $reflectedMethod->invoke($this->connection, ['customer', 'external', 1, '_update',])
+ );
+ }
+}
diff --git a/tests/ElasticsearchConnectionTest.php b/tests/ElasticsearchConnectionTest.php
new file mode 100644
index 0000000..25b755b
--- /dev/null
+++ b/tests/ElasticsearchConnectionTest.php
@@ -0,0 +1,29 @@
+autodetectCluster;
+ $connection->nodes = [
+ ['http_address' => 'inet[/127.0.0.1:9200]'],
+ ];
+ $this->assertNull($connection->activeNode);
+ $connection->open();
+ $this->assertNotNull($connection->activeNode);
+ $this->assertArrayHasKey('name', reset($connection->nodes));
+// $this->assertArrayHasKey('hostname', reset($connection->nodes));
+ $this->assertArrayHasKey('version', reset($connection->nodes));
+ $this->assertArrayHasKey('http_address', reset($connection->nodes));
+ }
+}
diff --git a/tests/ElasticsearchTargetTest.php b/tests/ElasticsearchTargetTest.php
new file mode 100644
index 0000000..2c956c8
--- /dev/null
+++ b/tests/ElasticsearchTargetTest.php
@@ -0,0 +1,74 @@
+
+ */
+
+namespace yiiunit\extensions\elasticsearch;
+
+use yii\elasticsearch\ElasticsearchTarget;
+use yii\elasticsearch\Query;
+use yii\log\Dispatcher;
+use yii\log\Logger;
+
+class ElasticsearchTargetTest extends TestCase
+{
+ public $logger;
+ public $index = 'yiilogtest';
+ public $type = 'log';
+
+ public function testExport(): void
+ {
+ $logger = $this->logger;
+
+ $logger->log('Test message', Logger::LEVEL_INFO, 'test-category');
+ $logger->flush(true);
+ $this->getConnection()->createCommand()->refreshIndex($this->index);
+
+ $query = new Query();
+ $query->from($this->index, $this->type);
+ $message = $query->one($this->getConnection());
+ $this->assertArrayHasKey('_source', $message);
+
+ $source = $message['_source'];
+ $this->assertArrayHasKey('@timestamp', $source);
+ $this->assertArrayHasKey('message', $source);
+ $this->assertArrayHasKey('level', $source);
+ $this->assertArrayHasKey('category', $source);
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $command = $this->getConnection()->createCommand();
+
+ // delete index
+ if ($command->indexExists($this->index)) {
+ $command->deleteIndex($this->index);
+ }
+
+ $this->logger = new Logger();
+ $dispatcher = new Dispatcher([
+ 'logger' => $this->logger,
+ 'targets' => [
+ [
+ 'class' => ElasticsearchTarget::className(),
+ 'db' => $this->getConnection(),
+ 'index' => $this->index,
+ 'type' => $this->type,
+ ],
+ ],
+ ]);
+ }
+
+ protected function tearDown(): void
+ {
+ $command = $this->getConnection()->createCommand();
+ $command->deleteIndex($this->index);
+
+ parent::tearDown();
+ }
+}
diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php
deleted file mode 100644
index 2825a4e..0000000
--- a/tests/ExampleTest.php
+++ /dev/null
@@ -1,18 +0,0 @@
-assertTrue($example->getExample());
- }
-}
diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php
new file mode 100644
index 0000000..788797e
--- /dev/null
+++ b/tests/QueryBuilderTest.php
@@ -0,0 +1,295 @@
+getConnection()->createCommand();
+
+ // delete index
+ if ($command->indexExists('builder-test')) {
+ $command->deleteIndex('builder-test');
+ }
+
+ $info = $command->db->get('/');
+ $this->version = $info['version']['number'];
+
+ $this->prepareDbData();
+ }
+
+ private function prepareDbData(): void
+ {
+ $command = $this->getConnection()->createCommand();
+ $command->setMapping('builder-test', 'article', [
+ 'properties' => [
+ 'title' => ['type' => 'keyword'],
+ 'created_at' => ['type' => 'keyword'],
+ 'weight' => ['type' => 'integer'],
+ ],
+ ]);
+ $command->insert('builder-test', 'article', ['title' => 'I love yii!', 'weight' => 1, 'created_at' => '2010-01-10'], 1);
+ $command->insert('builder-test', 'article', ['title' => 'Symfony2 is another framework', 'weight' => 2, 'created_at' => '2010-01-15'], 2);
+ $command->insert('builder-test', 'article', ['title' => 'Yii2 out now!', 'weight' => 3, 'created_at' => '2010-01-20'], 3);
+ $command->insert('builder-test', 'article', ['title' => 'yii test', 'weight' => 4, 'created_at' => '2012-05-11'], 4);
+
+ $command->refreshIndex('builder-test');
+ }
+
+ public function testQueryBuilderRespectsQuery(): void
+ {
+ $queryParts = ['field' => ['title' => 'yii']];
+ $queryBuilder = new QueryBuilder($this->getConnection());
+ $query = new Query();
+ $query->query = $queryParts;
+ $build = $queryBuilder->build($query);
+ $this->assertArrayHasKey('queryParts', $build);
+ $this->assertArrayHasKey('query', $build['queryParts']);
+ $this->assertSame($queryParts, $build['queryParts']['query']);
+ $this->assertArrayNotHasKey('match_all', $build['queryParts'], 'Match all should not be set');
+ }
+
+ /**
+ * @group postfilter
+ */
+ public function testQueryBuilderPostFilterQuery(): void
+ {
+ $postFilter = [
+ 'bool' => [
+ 'must' => [
+ ['term' => ['title' => 'yii test']],
+ ],
+ ],
+ ];
+ $queryBuilder = new QueryBuilder($this->getConnection());
+ $query = new Query();
+ $query->postFilter($postFilter);
+ $build = $queryBuilder->build($query);
+ $this->assertSame($postFilter, $build['queryParts']['post_filter']);
+ }
+
+ public function testYiiCanBeFoundByQuery(): void
+ {
+ $queryParts = ['term' => ['title' => 'yii']];
+ $query = new Query();
+ $query->from('builder-test', 'article');
+ $query->query = $queryParts;
+ $result = $query->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(2, $total);
+ }
+
+ public function testMinScore(): void
+ {
+ $queryParts = [
+ 'function_score' => [
+ 'boost_mode' => 'replace',
+ 'query' => ['term' => ['title' => 'yii']],
+ 'functions' => [
+ [
+ 'script_score' => [
+ 'script' => "doc['weight'].getValue()",
+ ],
+ ],
+ ],
+ ],
+ ];
+ //without min_score should get 2 documents with weights 1 and 4
+
+ $query = new Query();
+ $query->from('builder-test', 'article');
+ $query->query($queryParts);
+
+ $query->minScore(0.5);
+ $result = $query->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(2, $total);
+
+ $query->minScore(2);
+ $result = $query->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(1, $total);
+
+ $query->minScore(5);
+ $result = $query->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(0, $total);
+ }
+
+ public function testMltSearch(): void
+ {
+ $queryParts = [
+ 'more_like_this' => [
+ 'fields' => ['title'],
+ 'like' => 'Mention YII now',
+ 'min_term_freq' => 1,
+ 'min_doc_freq' => 1,
+ ],
+ ];
+ $query = new Query();
+ $query->from('builder-test', 'article');
+ $query->query = $queryParts;
+ $result = $query->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(3, $total);
+ }
+
+ public function testHalfBoundedRange(): void
+ {
+ // >= 2010-01-15, 3 results
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['>=', 'created_at', '2010-01-15'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(3, $total);
+
+ // >= 2010-01-15, 3 results
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['gte', 'created_at', '2010-01-15'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(3, $total);
+
+ // > 2010-01-15, 2 results
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['>', 'created_at', '2010-01-15'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(2, $total);
+
+ // > 2010-01-15, 2 results
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['gt', 'created_at', '2010-01-15'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(2, $total);
+
+ // <= 2010-01-20, 3 results
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['<=', 'created_at', '2010-01-20'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(3, $total);
+
+ // <= 2010-01-20, 3 results
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['lte', 'created_at', '2010-01-20'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(3, $total);
+
+ // < 2010-01-20, 2 results
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['<', 'created_at', '2010-01-20'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(2, $total);
+
+ // < 2010-01-20, 2 results
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['lt', 'created_at', '2010-01-20'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(2, $total);
+ }
+
+ public function testNotCondition(): void
+ {
+ $titles = [
+ 'yii',
+ 'test',
+ ];
+
+ $query = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['not in', 'title', $titles]);
+
+ $result = $query->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(2, $total);
+ }
+
+ public function testInCondition(): void
+ {
+ $titles = [
+ 'yii',
+ 'out',
+ 'nonexistent',
+ ];
+
+ $query = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['in', 'title', $titles]);
+
+ $result = $query->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(3, $total);
+ }
+
+ public function testBuildNotCondition(): void
+ {
+ $db = $this->getConnection();
+ $qb = new QueryBuilder($db);
+
+ $cond = [ 'title' => 'xyz' ];
+ $operands = [ $cond ];
+
+ $expected = [
+ 'bool' => [
+ 'must_not' => [
+ 'bool' => [ 'must' => [ ['term' => ['title' => 'xyz']] ] ],
+ ],
+ ],
+ ];
+ $result = $this->invokeMethod($qb, 'buildNotCondition', ['not',$operands]);
+ $this->assertEquals($expected, $result);
+ }
+
+ public function testBuildInCondition(): void
+ {
+ $db = $this->getConnection();
+ $qb = new QueryBuilder($db);
+
+ $expected = [
+ 'terms' => ['foo' => ['bar1', 'bar2']],
+ ];
+ $result = $this->invokeMethod($qb, 'buildInCondition', [
+ 'in',
+ ['foo',['bar1','bar2']],
+ ]);
+ $this->assertEquals($expected, $result);
+ }
+
+ public function testBuildMatchCondition(): void
+ {
+ $result = (new Query())
+ ->from('builder-test', 'article')
+ ->where(['match', 'title', 'yii'])
+ ->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(2, $total);
+ }
+}
diff --git a/tests/QueryTest.php b/tests/QueryTest.php
new file mode 100644
index 0000000..6e25701
--- /dev/null
+++ b/tests/QueryTest.php
@@ -0,0 +1,493 @@
+getConnection()->createCommand();
+
+ // delete index
+ if ($command->indexExists('query-test')) {
+ $command->deleteIndex('query-test');
+ }
+ $command->createIndex('query-test');
+
+ $command->setMapping('query-test', 'user', [
+ 'properties' => [
+ 'name' => [ 'type' => 'keyword', 'store' => true ],
+ 'email' => [ 'type' => 'keyword', 'store' => true ],
+ 'status' => [ 'type' => 'integer', 'store' => true ],
+ ],
+ ]);
+
+ $command->insert('query-test', 'user', ['name' => 'user1', 'email' => 'user1@example.com', 'status' => 1], 1);
+ $command->insert('query-test', 'user', ['name' => 'user2', 'email' => 'user2@example.com', 'status' => 1], 2);
+ $command->insert('query-test', 'user', ['name' => 'user3', 'email' => 'user3@example.com', 'status' => 2], 3);
+ $command->insert('query-test', 'user', ['name' => 'user4', 'email' => 'user4@example.com', 'status' => 1], 4);
+ $command->insert('query-test', 'user', ['name' => 'user5', 'email' => 'user5@example.com', 'status' => 1], 5);
+ $command->insert('query-test', 'user', ['name' => 'user6', 'email' => 'user6@example.com', 'status' => 1], 6);
+ $command->insert('query-test', 'user', ['name' => 'user7', 'email' => 'user7@example.com', 'status' => 2], 7);
+ $command->insert('query-test', 'user', ['name' => 'user8', 'email' => 'user8@example.com', 'status' => 1], 8);
+ $command->insert('query-test', 'user', ['name' => 'user9', 'email' => 'user9@example.com', 'status' => 1], 9);
+ $command->insert('query-test', 'user', ['name' => 'usera', 'email' => 'user10@example.com', 'status' => 1], 10);
+ $command->insert('query-test', 'user', ['name' => 'userb', 'email' => 'user11@example.com', 'status' => 2], 11);
+ $command->insert('query-test', 'user', ['name' => 'userc', 'email' => 'user12@example.com', 'status' => 1], 12);
+
+ $command->refreshIndex('query-test');
+ }
+
+ public function testFields(): void
+ {
+ $query = new Query();
+ $query->from('query-test', 'user');
+
+ $query->storedFields(['name', 'status']);
+ $this->assertEquals(['name', 'status'], $query->storedFields);
+
+ $query->storedFields('name', 'status');
+ $this->assertEquals(['name', 'status'], $query->storedFields);
+
+ $result = $query->one($this->getConnection());
+ $this->assertCount(2, $result['fields']);
+ $this->assertArrayHasKey('status', $result['fields']);
+ $this->assertArrayHasKey('name', $result['fields']);
+ $this->assertArrayHasKey('_id', $result);
+
+ $query->storedFields([]);
+ $this->assertEquals([], $query->storedFields);
+
+ $result = $query->one($this->getConnection());
+ $this->assertArrayNotHasKey('fields', $result);
+ $this->assertArrayHasKey('_id', $result);
+
+ $query->storedFields(null);
+ $this->assertNull($query->storedFields);
+
+ $result = $query->one($this->getConnection());
+ $this->assertCount(3, $result['_source']);
+ $this->assertArrayHasKey('status', $result['_source']);
+ $this->assertArrayHasKey('email', $result['_source']);
+ $this->assertArrayHasKey('name', $result['_source']);
+ $this->assertArrayHasKey('_id', $result);
+ }
+
+ public function testOne(): void
+ {
+ $query = new Query();
+ $query->from('query-test', 'user');
+
+ $result = $query->one($this->getConnection());
+ $this->assertCount(3, $result['_source']);
+ $this->assertArrayHasKey('status', $result['_source']);
+ $this->assertArrayHasKey('email', $result['_source']);
+ $this->assertArrayHasKey('name', $result['_source']);
+ $this->assertArrayHasKey('_id', $result);
+
+ $result = $query->where(['name' => 'user1'])->one($this->getConnection());
+ $this->assertCount(3, $result['_source']);
+ $this->assertArrayHasKey('status', $result['_source']);
+ $this->assertArrayHasKey('email', $result['_source']);
+ $this->assertArrayHasKey('name', $result['_source']);
+ $this->assertArrayHasKey('_id', $result);
+ $this->assertEquals(1, $result['_id']);
+
+ $result = $query->where(['name' => 'user15'])->one($this->getConnection());
+ $this->assertFalse($result);
+ }
+
+ public function testAll(): void
+ {
+ $query = new Query();
+ $query->from('query-test', 'user');
+
+ $results = $query->limit(100)->all($this->getConnection());
+ $this->assertCount(12, $results);
+ $result = reset($results);
+ $this->assertCount(3, $result['_source']);
+ $this->assertArrayHasKey('status', $result['_source']);
+ $this->assertArrayHasKey('email', $result['_source']);
+ $this->assertArrayHasKey('name', $result['_source']);
+ $this->assertArrayHasKey('_id', $result);
+
+ $query = new Query();
+ $query->from('query-test', 'user');
+
+ $results = $query->where(['name' => 'user1'])->all($this->getConnection());
+ $this->assertCount(1, $results);
+ $result = reset($results);
+ $this->assertCount(3, $result['_source']);
+ $this->assertArrayHasKey('status', $result['_source']);
+ $this->assertArrayHasKey('email', $result['_source']);
+ $this->assertArrayHasKey('name', $result['_source']);
+ $this->assertArrayHasKey('_id', $result);
+ $this->assertEquals(1, $result['_id']);
+
+ // indexBy
+ $query = new Query();
+ $query->from('query-test', 'user');
+
+ $results = $query->limit(100)->indexBy('name')->all($this->getConnection());
+ $this->assertCount(12, $results);
+ ksort($results);
+ $this->assertEquals([
+ 'user1',
+ 'user2',
+ 'user3',
+ 'user4',
+ 'user5',
+ 'user6',
+ 'user7',
+ 'user8',
+ 'user9',
+ 'usera',
+ 'userb',
+ 'userc',
+ ], array_keys($results));
+ }
+
+ public function testScalar(): void
+ {
+ $query = new Query();
+ $query->from('query-test', 'user');
+
+ $result = $query->where(['name' => 'user1'])->scalar('name', $this->getConnection());
+ $this->assertEquals('user1', $result);
+ $result = $query->where(['name' => 'user1'])->scalar('noname', $this->getConnection());
+ $this->assertNull($result);
+ $result = $query->where(['name' => 'user15'])->scalar('name', $this->getConnection());
+ $this->assertNull($result);
+ }
+
+ public function testColumn(): void
+ {
+ $query = new Query();
+ $query->from('query-test', 'user');
+
+ $result = $query->orderBy(['name' => SORT_ASC])->limit(4)->column('name', $this->getConnection());
+ $this->assertEquals(['user1', 'user2', 'user3', 'user4'], $result);
+ $result = $query->column('noname', $this->getConnection());
+ $this->assertEquals([null, null, null, null], $result);
+ $result = $query->where(['name' => 'user15'])->scalar('name', $this->getConnection());
+ $this->assertNull($result);
+ }
+
+ public function testAndWhere(): void
+ {
+ $query = new Query();
+ $query->where(1)
+ ->andWhere(2)
+ ->andWhere(3);
+
+ $expected = [ 'and', 1, 2, 3 ];
+ $this->assertEquals($expected, $query->where);
+ }
+
+ public function testOrWhere(): void
+ {
+ $query = new Query();
+ $query->where(1)
+ ->orWhere(2)
+ ->orWhere(3);
+
+ $expected = [ 'or', 1, 2, 3 ];
+ $this->assertEquals($expected, $query->where);
+ }
+
+ public function testFilterWhere(): void
+ {
+ // should work with hash format
+ $query = new Query();
+ $query->filterWhere([
+ '_id' => 0,
+ 'title' => ' ',
+ 'author_ids' => [],
+ ]);
+ $this->assertEquals(['_id' => 0], $query->where);
+
+ $query->andFilterWhere(['status' => null]);
+ $this->assertEquals(['_id' => 0], $query->where);
+
+ $query->orFilterWhere(['name' => '']);
+ $this->assertEquals(['_id' => 0], $query->where);
+
+ // should work with operator format
+ $query = new Query();
+ $condition = ['like', 'name', 'Alex'];
+ $query->filterWhere($condition);
+ $this->assertEquals($condition, $query->where);
+
+ $query->andFilterWhere(['between', '_id', null, null]);
+ $this->assertEquals($condition, $query->where);
+
+ $query->orFilterWhere(['not between', '_id', null, null]);
+ $this->assertEquals($condition, $query->where);
+
+ $query->andFilterWhere(['in', '_id', []]);
+ $this->assertEquals($condition, $query->where);
+
+ $query->andFilterWhere(['not in', '_id', []]);
+ $this->assertEquals($condition, $query->where);
+
+ $query->andFilterWhere(['not in', '_id', []]);
+ $this->assertEquals($condition, $query->where);
+
+ $query->andFilterWhere(['like', '_id', '']);
+ $this->assertEquals($condition, $query->where);
+
+ $query->andFilterWhere(['or like', '_id', '']);
+ $this->assertEquals($condition, $query->where);
+
+ $query->andFilterWhere(['not like', '_id', ' ']);
+ $this->assertEquals($condition, $query->where);
+
+ $query->andFilterWhere(['or not like', '_id', null]);
+ $this->assertEquals($condition, $query->where);
+ }
+
+ public function testFilterWhereRecursively(): void
+ {
+ $query = new Query();
+ $query->filterWhere([
+ 'and',
+ ['like', 'name', ''],
+ ['like', 'title', ''],
+ ['_id' => 1],
+ ['not', ['like', 'name', '']],
+ ]);
+ $this->assertEquals(['and', ['_id' => 1]], $query->where);
+ }
+
+ // TODO test facets
+
+ // TODO test complex where() every edge of QueryBuilder
+
+ public function testOrder(): void
+ {
+ $query = new Query();
+ $query->orderBy('team');
+ $this->assertEquals(['team' => SORT_ASC], $query->orderBy);
+
+ $query->addOrderBy('company');
+ $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC], $query->orderBy);
+
+ $query->addOrderBy('age');
+ $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_ASC], $query->orderBy);
+
+ $query->addOrderBy(['age' => SORT_DESC]);
+ $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_DESC], $query->orderBy);
+
+ $query->addOrderBy('age ASC, company DESC');
+ $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_DESC, 'age' => SORT_ASC], $query->orderBy);
+ }
+
+ public function testLimitOffset(): void
+ {
+ $query = new Query();
+ $query->limit(10)->offset(5);
+ $this->assertEquals(10, $query->limit);
+ $this->assertEquals(5, $query->offset);
+ }
+
+ /**
+ * @since 2.0.4
+ */
+ public function testBatch(): void
+ {
+ $names = [
+ 'user1',
+ 'user2',
+ 'user3',
+ 'user4',
+ 'user5',
+ 'user6',
+ 'user7',
+ 'user8',
+ 'user9',
+ 'usera',
+ 'userb',
+ 'userc',
+ ];
+
+ $emails = [
+ 'user1@example.com',
+ 'user2@example.com',
+ 'user3@example.com',
+ 'user4@example.com',
+ 'user5@example.com',
+ 'user6@example.com',
+ 'user7@example.com',
+ 'user8@example.com',
+ 'user9@example.com',
+ 'user10@example.com',
+ 'user11@example.com',
+ 'user12@example.com',
+ ];
+
+ //test each
+ $query = new Query();
+ $query->from('query-test', 'user')->limit(3)->orderBy(['name' => SORT_ASC])->indexBy('name')->options(['preference' => '_local']);
+ //NOTE: preference -> _local has no influence on query result, everything's fine as long as query doesn't fail
+
+ $result_keys = [];
+ $result_values = [];
+ foreach ($query->each('1m', $this->getConnection()) as $key => $value) {
+ $result_keys[] = $key;
+ $result_values[] = $value['_source']['email'];
+ }
+
+ $this->assertCount(12, $result_keys);
+ $this->assertEquals($names, $result_keys);
+
+ $this->assertCount(12, $result_values);
+ $this->assertEquals($emails, $result_values);
+
+ //test batch
+ $query = new Query();
+ $query->from('query-test', 'user')->limit(3)->orderBy(['name' => SORT_ASC])->indexBy('name')->options(['preference' => '_local']);
+ //NOTE: preference -> _local has no influence on query result, everything's fine as long as query doesn't fail
+
+ $results = [];
+ foreach ($query->batch('1m', $this->getConnection()) as $batchId => $batch) {
+ $results = $results + $batch;
+ }
+
+ $this->assertCount(12, $results);
+ $this->assertEquals($names, array_keys($results));
+ foreach ($names as $id => $name) {
+ $this->assertEquals($emails[$id], $results[$name]['_source']['email']);
+ }
+
+ //test scan (no ordering)
+ $query = new Query();
+ $query->from('query-test', 'user')->limit(3);
+
+ $results = [];
+ foreach ($query->each('1m', $this->getConnection()) as $value) {
+ $results[] = $value['_source']['name'];
+ }
+
+ $this->assertCount(12, $results);
+ sort($results);
+ $this->assertEquals($names, $results);
+ }
+
+ /**
+ * @group postfilter
+ *
+ * @since 2.0.5
+ */
+ public function testPostFilter(): void
+ {
+ $postFilter = [
+ 'term' => ['status' => 2],
+ ];
+
+ $query = new Query();
+ $query->from('query-test', 'user');
+ $query->postFilter($postFilter);
+ $query->addAggregate('statuses', ['terms' => ['field' => 'status']]);
+ $result = $query->search($this->getConnection());
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(3, $total);
+ }
+
+ /**
+ * @group explain
+ *
+ * @since 2.0.5
+ */
+ public function testExplain(): void
+ {
+ $query = new Query();
+ $query->from('query-test', 'user');
+ $query->explain(true);
+ $result = $query->search($this->getConnection());
+ $this->assertIsArray($result['hits']['hits'][0]['_explanation']);
+ $this->assertArrayHasKey('_explanation', $result['hits']['hits'][0]);
+ }
+
+ /**
+ * @group explain
+ *
+ * @since 2.0.5
+ */
+ public function testNoExplain(): void
+ {
+ $query = new Query();
+ $query->from('query-test', 'user');
+ $result = $query->search($this->getConnection());
+ $this->assertArrayNotHasKey('_explanation', $result['hits']['hits'][0]);
+ }
+
+ public function testQueryWithWhere(): void
+ {
+ // make sure that both `query()` and `where()` work at the same time
+ $query = new Query();
+ $query->from('query-test', 'user');
+ $query->where(['status' => 2]);
+ $query->query(['term' => ['name' => 'userb']]);
+ $result = $query->search($this->getConnection());
+
+ $total = is_array($result['hits']['total']) ? $result['hits']['total']['value'] : $result['hits']['total'];
+ $this->assertEquals(1, $total);
+ }
+
+ public function testSuggest(): void
+ {
+ $cmd = $this->getConnection()->createCommand();
+ $cmd->index = 'query-test';
+
+ $result = $cmd->suggest(['customer_name' => [
+ 'text' => 'user',
+ 'term' => [
+ 'field' => 'name',
+ ],
+ ]]);
+
+ $this->assertCount(5, $result['customer_name'][0]['options']);
+ }
+
+ public function testRuntimeMappings(): void
+ {
+ // Check that Elasticsearch is version 7.11.0 or later before running this test
+ $elasticsearchInfo = $this->getConnection()->get('/');
+ if (!version_compare($elasticsearchInfo['version']['number'], '7.11.0', '>=')) {
+ $this->expectNotToPerformAssertions();
+ return;
+ }
+
+ $query = new Query();
+ $query->from('query-test', 'user');
+
+ $query->runtimeMappings([
+ 'name_email' => [
+ 'type' => 'keyword',
+ 'script' => "emit(doc['name'].value + ':' + doc['email'].value)",
+ ],
+ ]);
+ $this->assertEquals([
+ 'name_email' => [
+ 'type' => 'keyword',
+ 'script' => "emit(doc['name'].value + ':' + doc['email'].value)",
+ ],
+ ], $query->runtimeMappings);
+
+ $query->fields(['name_email']);
+ $this->assertEquals(['name_email'], $query->fields);
+
+ $result = $query->search($this->getConnection());
+ $this->assertArrayHasKey('name_email', $result['hits']['hits'][0]['fields']);
+ $this->assertEquals($result['hits']['hits'][0]['fields']['name_email'][0], 'user1:user1@example.com');
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..d36067f
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,136 @@
+destroyApplication();
+ }
+
+ /**
+ * Populates Yii::$app with a new application
+ * The application will be destroyed on tearDown() automatically.
+ *
+ * @param array $config The application configuration, if needed
+ * @param string $appClass name of the application class to create
+ */
+ protected function mockApplication($config = [], $appClass = '\yii\console\Application')
+ {
+ new $appClass(ArrayHelper::merge([
+ 'id' => 'testapp',
+ 'basePath' => __DIR__,
+ 'vendorPath' => dirname(__DIR__) . '/vendor',
+ ], $config));
+ }
+
+ protected function mockWebApplication($config = [], $appClass = '\yii\web\Application')
+ {
+ new $appClass(ArrayHelper::merge([
+ 'id' => 'testapp',
+ 'basePath' => __DIR__,
+ 'vendorPath' => dirname(__DIR__) . '/vendor',
+ 'components' => [
+ 'request' => [
+ 'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq',
+ 'scriptFile' => __DIR__ . '/index.php',
+ 'scriptUrl' => '/index.php',
+ ],
+ ],
+ ], $config));
+ }
+
+ /**
+ * Destroys application in Yii::$app by setting it to null.
+ */
+ protected function destroyApplication()
+ {
+ Yii::$app = null;
+ Yii::$container = new Container();
+ }
+
+ protected function setUp(): void
+ {
+ $this->mockApplication();
+
+ $config = self::getParam('elasticsearch');
+ if (empty($config)) {
+ $this->markTestSkipped('No elasticsearch server connection configured.');
+ }
+ parent::setUp();
+ }
+
+ /**
+ * @param bool $reset whether to clean up the test database
+ *
+ * @return Connection
+ */
+ public function getConnection($reset = true)
+ {
+ $config = self::getParam('elasticsearch');
+ $db = new Connection($config);
+ if ($reset) {
+ $db->open();
+ }
+
+ return $db;
+ }
+
+ /**
+ * Invokes a inaccessible method.
+ *
+ * @param $object
+ * @param $method
+ * @param array $args
+ * @param bool $revoke whether to make method inaccessible after execution
+ *
+ * @return mixed
+ */
+ protected function invokeMethod($object, $method, $args = [], $revoke = true)
+ {
+ $reflection = new \ReflectionObject($object);
+ $method = $reflection->getMethod($method);
+ $method->setAccessible(true);
+ $result = $method->invokeArgs($object, $args);
+ if ($revoke) {
+ $method->setAccessible(false);
+ }
+
+ return $result;
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..734e510
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,17 @@
+
+ *
+ * @since 2.0
+ */
+class ActiveRecord extends \yii\elasticsearch\ActiveRecord
+{
+ public static Connection $db;
+
+ /**
+ * @return Connection
+ */
+ public static function getDb(): Connection
+ {
+ return self::$db;
+ }
+}
diff --git a/tests/data/ar/Animal.php b/tests/data/ar/Animal.php
new file mode 100644
index 0000000..7f35798
--- /dev/null
+++ b/tests/data/ar/Animal.php
@@ -0,0 +1,81 @@
+
+ *
+ * @since 2.0
+ */
+class Animal extends ActiveRecord
+{
+ public $does;
+
+ public static function index(): string
+ {
+ return 'animals';
+ }
+
+ public static function type(): string
+ {
+ return 'animal';
+ }
+
+ public function attributes()
+ {
+ return ['species'];
+ }
+
+ /**
+ * sets up the index for this record
+ *
+ * @param Command $command
+ */
+ public static function setUpMapping(Command $command): void
+ {
+ $command->setMapping(
+ static::index(),
+ static::type(),
+ [
+ 'properties' => [
+ 'species' => ['type' => 'keyword'],
+ ],
+ ],
+ );
+ }
+
+ public function init(): void
+ {
+ parent::init();
+ $this->species = static::class;
+ }
+
+ public function getDoes()
+ {
+ return $this->does;
+ }
+
+ /**
+ * @param type $row
+ *
+ * @return static
+ */
+ public static function instantiate($row): static
+ {
+ $class = $row['_source']['species'];
+ return new $class();
+ }
+}
diff --git a/tests/data/ar/Cat.php b/tests/data/ar/Cat.php
new file mode 100644
index 0000000..b6a09f5
--- /dev/null
+++ b/tests/data/ar/Cat.php
@@ -0,0 +1,33 @@
+
+ *
+ * @since 2.0
+ */
+class Cat extends Animal
+{
+ /**
+ * @param self $record
+ * @param array $row
+ */
+ public static function populateRecord($record, $row): void
+ {
+ parent::populateRecord($record, $row);
+
+ $record->does = 'meow';
+ }
+}
diff --git a/tests/data/ar/Customer.php b/tests/data/ar/Customer.php
new file mode 100644
index 0000000..3e5f38a
--- /dev/null
+++ b/tests/data/ar/Customer.php
@@ -0,0 +1,87 @@
+hasMany(Order::class, ['customer_id' => '_id'])->orderBy('created_at');
+ }
+
+ public function getExpensiveOrders(): ActiveQueryInterface
+ {
+ return $this
+ ->hasMany(Order::class, ['customer_id' => '_id'])
+ ->where([ 'gte', 'total', 50 ])
+ ->orderBy('_id');
+ }
+
+ public function getOrdersWithItems(): ActiveQueryInterface
+ {
+ return $this->hasMany(Order::class, ['customer_id' => '_id'])->with('orderItems');
+ }
+
+ public function afterSave($insert, $changedAttributes): void
+ {
+ ActiveRecordTest::$afterSaveInsert = $insert;
+ ActiveRecordTest::$afterSaveNewRecord = $this->isNewRecord;
+ parent::afterSave($insert, $changedAttributes);
+ }
+
+ /**
+ * sets up the index for this record
+ *
+ * @param Command $command
+ */
+ public static function setUpMapping(Command $command): void
+ {
+ $command->setMapping(
+ static::index(),
+ static::type(),
+ [
+ 'properties' => [
+ 'name' => ['type' => 'keyword', 'store' => true],
+ 'email' => ['type' => 'keyword', 'store' => true],
+ 'address' => ['type' => 'text'],
+ 'status' => ['type' => 'integer', 'store' => true],
+ 'is_active' => ['type' => 'boolean', 'store' => true],
+ ],
+ ],
+ );
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @return CustomerQuery
+ */
+ public static function find()
+ {
+ return new CustomerQuery(static::class);
+ }
+}
diff --git a/tests/data/ar/CustomerQuery.php b/tests/data/ar/CustomerQuery.php
new file mode 100644
index 0000000..4eb03c4
--- /dev/null
+++ b/tests/data/ar/CustomerQuery.php
@@ -0,0 +1,20 @@
+andWhere(['status' => 1]);
+
+ return $this;
+ }
+}
diff --git a/tests/data/ar/Dog.php b/tests/data/ar/Dog.php
new file mode 100644
index 0000000..0f9fa57
--- /dev/null
+++ b/tests/data/ar/Dog.php
@@ -0,0 +1,24 @@
+
+ */
+class Dog extends Animal
+{
+ /**
+ * @param self $record
+ * @param array $row
+ */
+ public static function populateRecord($record, $row): void
+ {
+ parent::populateRecord($record, $row);
+
+ $record->does = 'bark';
+ }
+}
diff --git a/tests/data/ar/Item.php b/tests/data/ar/Item.php
new file mode 100644
index 0000000..357ae97
--- /dev/null
+++ b/tests/data/ar/Item.php
@@ -0,0 +1,41 @@
+setMapping(
+ static::index(),
+ static::type(),
+ [
+ 'properties' => [
+ 'name' => ['type' => 'keyword', 'store' => true],
+ 'category_id' => ['type' => 'integer'],
+ ],
+ ],
+ );
+ }
+}
diff --git a/tests/data/ar/Order.php b/tests/data/ar/Order.php
new file mode 100644
index 0000000..a5a8203
--- /dev/null
+++ b/tests/data/ar/Order.php
@@ -0,0 +1,136 @@
+hasOne(Customer::class, ['_id' => 'customer_id']);
+ }
+
+ public function getOrderItems(): ActiveQueryInterface
+ {
+ return $this->hasMany(OrderItem::class, ['order_id' => '_id']);
+ }
+
+ /**
+ * A relation to Item defined via array valued attribute
+ */
+ public function getItemsByArrayValue(): ActiveQueryInterface
+ {
+ return $this->hasMany(Item::class, ['_id' => 'itemsArray'])->indexBy('_id');
+ }
+
+ public function getItems(): ActiveQueryInterface
+ {
+ return $this->hasMany(Item::class, ['_id' => 'item_id'])->via('orderItems')->orderBy('_id');
+ }
+
+ public function getExpensiveItemsUsingViaWithCallable(): ActiveQueryInterface
+ {
+ return $this
+ ->hasMany(Item::class, ['_id' => 'item_id'])
+ ->via(
+ 'orderItems',
+ static function (ActiveQuery $q): void {
+ $q->where(['>=', 'subtotal', 10]);
+ },
+ );
+ }
+
+ public function getCheapItemsUsingViaWithCallable(): ActiveQueryInterface
+ {
+ return $this
+ ->hasMany(Item::class, ['_id' => 'item_id'])
+ ->via(
+ 'orderItems',
+ static function (ActiveQuery $q): void {
+ $q->where(['<', 'subtotal', 10]);
+ },
+ );
+ }
+
+ public function getItemsIndexed(): ActiveQueryInterface
+ {
+ return $this
+ ->hasMany(Item::class, ['_id' => 'item_id'])
+ ->via('orderItems')->indexBy('_id');
+ }
+
+ public function getItemsInOrder1(): ActiveQueryInterface
+ {
+ return $this
+ ->hasMany(Item::class, ['_id' => 'item_id'])
+ ->via(
+ 'orderItems',
+ static function ($q): void {
+ $q->orderBy(['subtotal' => SORT_ASC]);
+ }
+ )
+ ->orderBy('name');
+ }
+
+ public function getItemsInOrder2(): ActiveQueryInterface
+ {
+ return $this
+ ->hasMany(Item::class, ['_id' => 'item_id'])
+ ->via(
+ 'orderItems',
+ static function ($q): void {
+ $q->orderBy(['subtotal' => SORT_DESC]);
+ }
+ )
+ ->orderBy('name');
+ }
+
+ public function getBooks(): ActiveQueryInterface
+ {
+ return $this
+ ->hasMany(Item::class, ['_id' => 'item_id'])
+ ->via('orderItems')
+ ->where(['category_id' => 1]);
+ }
+
+ /**
+ * sets up the index for this record
+ *
+ * @param Command $command
+ */
+ public static function setUpMapping(Command $command): void
+ {
+ $command->setMapping(
+ static::index(),
+ static::type(),
+ [
+ 'properties' => [
+ 'customer_id' => ['type' => 'integer'],
+ 'total' => ['type' => 'integer'],
+ ],
+ ],
+ );
+ }
+}
diff --git a/tests/data/ar/OrderItem.php b/tests/data/ar/OrderItem.php
new file mode 100644
index 0000000..1de3cfe
--- /dev/null
+++ b/tests/data/ar/OrderItem.php
@@ -0,0 +1,57 @@
+hasOne(Order::className(), ['_id' => 'order_id']);
+ }
+
+ public function getItem(): ActiveQueryInterface
+ {
+ return $this->hasOne(Item::className(), ['_id' => 'item_id']);
+ }
+
+ /**
+ * sets up the index for this record
+ *
+ * @param Command $command
+ */
+ public static function setUpMapping(Command $command): void
+ {
+ $command->setMapping(
+ static::index(),
+ static::type(),
+ [
+ 'properties' => [
+ 'order_id' => ['type' => 'integer'],
+ 'item_id' => ['type' => 'integer'],
+ 'quantity' => ['type' => 'integer'],
+ 'subtotal' => ['type' => 'integer'],
+ ],
+ ],
+ );
+ }
+}
diff --git a/tests/data/config.php b/tests/data/config.php
new file mode 100644
index 0000000..d4a83cf
--- /dev/null
+++ b/tests/data/config.php
@@ -0,0 +1,34 @@
+ [
+ 'autodetectCluster' => false,
+ 'nodes' => [
+ ['http_address' => 'inet[/127.0.0.1:9200]'],
+ ],
+ ],
+];
+
+$esVersion = getenv('ES_VERSION');
+if (preg_match('/^\d+/', $esVersion, $matches)) {
+ $config['elasticsearch']['dslVersion'] = $matches[0];
+}
+
+if (is_file(__DIR__ . '/config.local.php')) {
+ include(__DIR__ . '/config.local.php');
+}
+
+return $config;
diff --git a/tests/helpers/Record.php b/tests/helpers/Record.php
new file mode 100644
index 0000000..905d6bf
--- /dev/null
+++ b/tests/helpers/Record.php
@@ -0,0 +1,51 @@
+save(false);
+
+ return $model;
+ }
+
+ public static function insertMany($modelClass, $rows)
+ {
+ $results = [];
+
+ foreach ($rows as $row) {
+ $results[] = static::insert($modelClass, $row);
+ }
+
+ return $results;
+ }
+
+ public static function initIndex($class, $db): void
+ {
+ $index = $class::index();
+
+ if ($db->createCommand()->indexExists($index)) {
+ $db->createCommand()->deleteIndex($index);
+ }
+ $db->createCommand()->createIndex($index);
+
+ $class::setUpMapping($db->createCommand());
+ }
+
+ public static function refreshIndex($class, $db): void
+ {
+ $db->createCommand()->refreshIndex($class::index());
+ }
+}