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 @@ yii2-version - - PHPUnit + + PHPUnit - - Codecov + + Codecov - - PHPStan + + PHPStan - - PHPStan level + + PHPStan level + + + Code style - - Code style -

-## 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 +
    $url

    $body

    $traceString
    + $runLink + + +HTML; + $i++; + } + $rows = implode("\n", $rows); + + return <<Elasticsearch Queries + + + + + + + + + + +$rows + +
    TimeUrl / QueryRun Query on node
    +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()); + } +}