From a94886fafda3333d26f5fb02839bf649aa28b7db Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Mon, 30 Sep 2013 15:12:20 +0200 Subject: [PATCH] elasticsearch AR WIP copied parts from redis implementation --- .travis.yml | 1 + framework/composer.json | 1 + framework/yii/db/elasticsearch/ActiveQuery.php | 343 ---------- framework/yii/db/elasticsearch/ActiveRecord.php | 543 --------------- framework/yii/db/elasticsearch/Connection.php | 144 ---- framework/yii/db/elasticsearch/Node.php | 23 - framework/yii/elasticsearch/ActiveQuery.php | 749 +++++++++++++++++++++ framework/yii/elasticsearch/ActiveRecord.php | 368 ++++++++++ framework/yii/elasticsearch/Connection.php | 186 +++++ framework/yii/elasticsearch/Node.php | 23 + tests/unit/bootstrap.php | 5 + tests/unit/data/ar/elasticsearch/ActiveRecord.php | 24 + tests/unit/data/ar/elasticsearch/Customer.php | 40 ++ tests/unit/data/ar/elasticsearch/Item.php | 22 + tests/unit/data/ar/elasticsearch/Order.php | 59 ++ tests/unit/data/ar/elasticsearch/OrderItem.php | 34 + tests/unit/data/config.php | 3 + .../framework/elasticsearch/ActiveRecordTest.php | 473 +++++++++++++ .../elasticsearch/ElasticSearchConnectionTest.php | 66 ++ .../elasticsearch/ElasticSearchTestCase.php | 48 ++ 20 files changed, 2102 insertions(+), 1053 deletions(-) delete mode 100644 framework/yii/db/elasticsearch/ActiveQuery.php delete mode 100644 framework/yii/db/elasticsearch/ActiveRecord.php delete mode 100644 framework/yii/db/elasticsearch/Connection.php delete mode 100644 framework/yii/db/elasticsearch/Node.php create mode 100644 framework/yii/elasticsearch/ActiveQuery.php create mode 100644 framework/yii/elasticsearch/ActiveRecord.php create mode 100644 framework/yii/elasticsearch/Connection.php create mode 100644 framework/yii/elasticsearch/Node.php create mode 100644 tests/unit/data/ar/elasticsearch/ActiveRecord.php create mode 100644 tests/unit/data/ar/elasticsearch/Customer.php create mode 100644 tests/unit/data/ar/elasticsearch/Item.php create mode 100644 tests/unit/data/ar/elasticsearch/Order.php create mode 100644 tests/unit/data/ar/elasticsearch/OrderItem.php create mode 100644 tests/unit/framework/elasticsearch/ActiveRecordTest.php create mode 100644 tests/unit/framework/elasticsearch/ElasticSearchConnectionTest.php create mode 100644 tests/unit/framework/elasticsearch/ElasticSearchTestCase.php diff --git a/.travis.yml b/.travis.yml index 2add223..b9b15cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ services: before_script: - composer self-update && composer --version - composer require satooshi/php-coveralls 0.6.* + - composer require guzzle/http v3.7.3 - mysql -e 'CREATE DATABASE yiitest;'; - psql -U postgres -c 'CREATE DATABASE yiitest;'; - tests/unit/data/travis/apc-setup.sh diff --git a/framework/composer.json b/framework/composer.json index 89d6064..9752758 100644 --- a/framework/composer.json +++ b/framework/composer.json @@ -74,6 +74,7 @@ "psr-0": { "yii\\": "/" } }, "suggest": { + "guzzle/http": "Required by elasticsearch.", "michelf/php-markdown": "Required by Markdown.", "twig/twig": "Required by TwigViewRenderer.", "smarty/smarty": "Required by SmartyViewRenderer." diff --git a/framework/yii/db/elasticsearch/ActiveQuery.php b/framework/yii/db/elasticsearch/ActiveQuery.php deleted file mode 100644 index a3c5e13..0000000 --- a/framework/yii/db/elasticsearch/ActiveQuery.php +++ /dev/null @@ -1,343 +0,0 @@ - - * @since 2.0 - */ -class ActiveQuery extends \yii\base\Component -{ - /** - * @var string the name of the ActiveRecord class. - */ - public $modelClass; - /** - * @var array list of relations that this query should be performed with - */ - public $with; - /** - * @var string the name of the column by which query results should be indexed by. - * This is only used when the query result is returned as an array when calling [[all()]]. - */ - public $indexBy; - /** - * @var boolean whether to return each record as an array. If false (default), an object - * of [[modelClass]] will be created to represent each record. - */ - public $asArray; - /** - * @var integer maximum number of records to be returned. If not set or less than 0, it means no limit. - */ - public $limit; - /** - * @var integer zero-based offset from where the records are to be returned. - * If not set, it means starting from the beginning. - * If less than zero it means starting n elements from the end. - */ - public $offset; - /** - * @var array array of primary keys of the records to find. - */ - public $primaryKeys; - - /** - * List of multiple pks must be zero based - * - * @param $primaryKeys - * @return ActiveQuery - */ - public function primaryKeys($primaryKeys) { - if (is_array($primaryKeys) && isset($primaryKeys[0])) { - $this->primaryKeys = $primaryKeys; - } else { - $this->primaryKeys = array($primaryKeys); - } - - return $this; - } - - /** - * Executes query and returns all results as an array. - * @return array the query results. If the query results in nothing, an empty array will be returned. - */ - public function all() - { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - if (($primaryKeys = $this->primaryKeys) === null) { - $start = $this->offset === null ? 0 : $this->offset; - $end = $this->limit === null ? -1 : $start + $this->limit; - $primaryKeys = $db->executeCommand('LRANGE', array($modelClass::tableName(), $start, $end)); - } - $rows = array(); - foreach($primaryKeys as $pk) { - $key = $modelClass::tableName() . ':a:' . $modelClass::hashPk($pk); - // get attributes - $data = $db->executeCommand('HGETALL', array($key)); - $row = array(); - for($i=0;$icreateModels($rows); - if (!empty($this->with)) { - $this->populateRelations($models, $this->with); - } - return $models; - } else { - return array(); - } - } - - /** - * Executes query and returns a single row of result. - * @return ActiveRecord|array|null a single row of 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() - { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - if (($primaryKeys = $this->primaryKeys) === null) { - $start = $this->offset === null ? 0 : $this->offset; - $primaryKeys = $db->executeCommand('LRANGE', array($modelClass::tableName(), $start, $start + 1)); - } - $pk = reset($primaryKeys); - $key = $modelClass::tableName() . ':a:' . $modelClass::hashPk($pk); - // get attributes - $data = $db->executeCommand('HGETALL', array($key)); - if ($data === array()) { - return null; - } - $row = array(); - for($i=0;$iasArray) { - /** @var $class ActiveRecord */ - $class = $this->modelClass; - $model = $class::create($row); - if (!empty($this->with)) { - $models = array($model); - $this->populateRelations($models, $this->with); - $model = $models[0]; - } - return $model; - } else { - return $row; - } - } - - /** - * Returns the number of records. - * @param string $q the COUNT expression. Defaults to '*'. - * Make sure you properly quote column names. - * @return integer number of records - */ - public function count() - { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - return $db->executeCommand('LLEN', array($modelClass::tableName())); - } - - /** - * Returns the query result as a scalar value. - * The value returned will be the first column in the first row of the query results. - * @return string|boolean the value of the first column in the first row of the query result. - * False is returned if the query result is empty. - */ - public function scalar($column) - { - $record = $this->one(); - return $record->$column; - } - - /** - * Returns a value indicating whether the query result contains any row of data. - * @return boolean whether the query result contains any row of data. - */ - public function exists() - { - return $this->one() !== null; - } - - - /** - * Sets the [[asArray]] property. - * TODO: refactor, it is duplicated from yii/db/ActiveQuery - * @param boolean $value whether to return the query results in terms of arrays instead of Active Records. - * @return ActiveQuery the query object itself - */ - public function asArray($value = true) - { - $this->asArray = $value; - return $this; - } - - /** - * Sets the LIMIT part of the query. - * TODO: refactor, it is duplicated from yii/db/Query - * @param integer $limit the limit - * @return Query the query object itself - */ - public function limit($limit) - { - $this->limit = $limit; - return $this; - } - - /** - * Sets the OFFSET part of the query. - * TODO: refactor, it is duplicated from yii/db/Query - * @param integer $offset the offset - * @return Query the query object itself - */ - public function offset($offset) - { - $this->offset = $offset; - return $this; - } - - /** - * Specifies the relations with which this query should be performed. - * - * The parameters to this method can be either one or multiple strings, or a single array - * of relation names and the optional callbacks to customize the relations. - * - * The followings are some usage examples: - * - * ~~~ - * // find customers together with their orders and country - * Customer::find()->with('orders', 'country')->all(); - * // find customers together with their country and orders of status 1 - * Customer::find()->with(array( - * 'orders' => function($query) { - * $query->andWhere('status = 1'); - * }, - * 'country', - * ))->all(); - * ~~~ - * - * TODO: refactor, it is duplicated from yii/db/ActiveQuery - * @return ActiveQuery the query object itself - */ - public function with() - { - $this->with = func_get_args(); - if (isset($this->with[0]) && is_array($this->with[0])) { - // the parameter is given as an array - $this->with = $this->with[0]; - } - return $this; - } - - /** - * Sets the [[indexBy]] property. - * TODO: refactor, it is duplicated from yii/db/ActiveQuery - * @param string $column the name of the column by which the query results should be indexed by. - * @return ActiveQuery the query object itself - */ - public function indexBy($column) - { - $this->indexBy = $column; - return $this; - } - - // TODO: refactor, it is duplicated from yii/db/ActiveQuery - private function createModels($rows) - { - $models = array(); - if ($this->asArray) { - if ($this->indexBy === null) { - return $rows; - } - foreach ($rows as $row) { - $models[$row[$this->indexBy]] = $row; - } - } else { - /** @var $class ActiveRecord */ - $class = $this->modelClass; - if ($this->indexBy === null) { - foreach ($rows as $row) { - $models[] = $class::create($row); - } - } else { - foreach ($rows as $row) { - $model = $class::create($row); - $models[$model->{$this->indexBy}] = $model; - } - } - } - return $models; - } - - // TODO: refactor, it is duplicated from yii/db/ActiveQuery - private function populateRelations(&$models, $with) - { - $primaryModel = new $this->modelClass; - $relations = $this->normalizeRelations($primaryModel, $with); - foreach ($relations as $name => $relation) { - if ($relation->asArray === null) { - // inherit asArray from primary query - $relation->asArray = $this->asArray; - } - $relation->findWith($name, $models); - } - } - - /** - * TODO: refactor, it is duplicated from yii/db/ActiveQuery - * @param ActiveRecord $model - * @param array $with - * @return ActiveRelation[] - */ - private function normalizeRelations($model, $with) - { - $relations = array(); - foreach ($with as $name => $callback) { - if (is_integer($name)) { - $name = $callback; - $callback = null; - } - if (($pos = strpos($name, '.')) !== false) { - // with sub-relations - $childName = substr($name, $pos + 1); - $name = substr($name, 0, $pos); - } else { - $childName = null; - } - - $t = strtolower($name); - if (!isset($relations[$t])) { - $relation = $model->getRelation($name); - $relation->primaryModel = null; - $relations[$t] = $relation; - } else { - $relation = $relations[$t]; - } - - if (isset($childName)) { - $relation->with[$childName] = $callback; - } elseif ($callback !== null) { - call_user_func($callback, $relation); - } - } - return $relations; - } -} diff --git a/framework/yii/db/elasticsearch/ActiveRecord.php b/framework/yii/db/elasticsearch/ActiveRecord.php deleted file mode 100644 index 4282e8d..0000000 --- a/framework/yii/db/elasticsearch/ActiveRecord.php +++ /dev/null @@ -1,543 +0,0 @@ - - * @since 2.0 - */ -abstract class ActiveRecord extends \yii\db\ActiveRecord -{ - /** - * 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. - * @return Connection the database connection used by this AR class. - */ - public static function getDb() - { - return \Yii::$app->elasticsearch; - } - - public static function primaryKey() - { - return array('id'); - } - - /** - * Creates an [[ActiveQuery]] instance for query purpose. - * - * @include @yii/db/ActiveRecord-find.md - * - * @param mixed $q the query parameter. This can be one of the followings: - * - * - a scalar value (integer or string): query by a single primary key value and return the - * corresponding record. - * - an array of name-value pairs: query by a set of column values and return a single record matching all of them. - * - null: return a new [[ActiveQuery]] object for further query purpose. - * - * @return ActiveQuery|ActiveRecord|null When `$q` is null, a new [[ActiveQuery]] instance - * is returned; when `$q` is a scalar or an array, an ActiveRecord object matching it will be - * returned (null will be returned if there is no matching). - * @see createQuery() - */ - public static function find($q = null) // TODO optimize API - { - $query = static::createQuery(); - if (is_array($q)) { - return $query->primaryKeys($q)->one(); - } elseif ($q !== null) { - // query by primary key - $primaryKey = static::primaryKey(); - return $query->primaryKeys(array($primaryKey[0] => $q))->one(); - } - return $query; - } - - /** - * @inheritdoc - */ - public static function findBySql($sql, $params = array()) - { - throw new NotSupportedException('findBySql() is not supported by elasticsearch ActiveRecord'); - } - - /** - * Creates an [[ActiveQuery]] instance. - * This method is called by [[find()]], [[findBySql()]] and [[count()]] to start a SELECT query. - * You may override this method to return a customized query (e.g. `CustomerQuery` specified - * written for querying `Customer` purpose.) - * @return ActiveQuery the newly created [[ActiveQuery]] instance. - */ - public static function createQuery() - { - return new ActiveQuery(array( - 'modelClass' => get_called_class(), - )); - } - - /** - * Declares the name of the database table associated with this AR class. - * @return string the table name - */ - public static function tableName() - { - return static::getTableSchema()->name; - } - - public static function indexName() - { - return static::getTableSchema()->name; - } - - /** - * Returns the schema information of the DB table associated with this AR class. - * @return TableSchema the schema information of the DB table associated with this AR class. - */ - public static function getTableSchema() - { - // TODO should be cached - throw new InvalidConfigException(__CLASS__.'::getTableSchema() needs to be overridden in subclasses and return a TableSchema.'); - } - - /** - * Inserts a row into the associated database table 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 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 [[changedAttributes|changed attribute values]] will be inserted into database. - * - * If the table's primary key is auto-incremental and is null during insertion, - * it will be populated with the actual value after insertion. - * - * For example, to insert a customer record: - * - * ~~~ - * $customer = new Customer; - * $customer->name = $name; - * $customer->email = $email; - * $customer->insert(); - * ~~~ - * - * @param boolean $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 that are loaded from DB will be saved. - * @return boolean whether the attributes are valid and the record is inserted successfully. - */ - public function insert($runValidation = true, $attributes = null) - { - if ($runValidation && !$this->validate($attributes)) { - return false; - } - if ($this->beforeSave(true)) { - $db = static::getDb(); - $values = $this->getDirtyAttributes($attributes); - $pk = array(); - foreach ($this->primaryKey() as $key) { - $pk[$key] = $values[$key] = $this->getAttribute($key); - if ($pk[$key] === null) { - $pk[$key] = $values[$key] = 0; // TODO add support for incrementing PK - $this->setAttribute($key, $values[$key]); - } - } - - // TODO store record in index - - $this->setOldAttributes($values); - $this->afterSave(true); - return true; - } - return false; - } - - /** - * Updates the whole table using the provided attribute values and conditions. - * For example, to change the status to be 1 for all customers whose status is 2: - * - * ~~~ - * Customer::updateAll(array('status' => 1), 'status = 2'); - * ~~~ - * - * @param array $attributes attribute values (name-value pairs) to be saved into the table - * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. - * Please refer to [[Query::where()]] on how to specify this parameter. - * @param array $params the parameters (name=>value) to be bound to the query. - * @return integer the number of rows updated - */ - public static function updateAll($attributes, $condition = '', $params = array()) - { - $db = static::getDb(); - - // TODO massive update (do a find and then update each record) - - if (empty($attributes)) { - return 0; - } - $n=0; -// foreach(... as $pk) { -// -// // TODO update records -// -// $n++; -// } - - return $n; - } - - /** - * Updates the whole table using the provided counter changes and conditions. - * For example, to increment all customers' age by 1, - * - * ~~~ - * Customer::updateAllCounters(array('age' => 1)); - * ~~~ - * - * @param array $counters the counters to be updated (attribute name => increment value). - * Use negative values if you want to decrement the counters. - * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. - * Please refer to [[Query::where()]] on how to specify this parameter. - * @param array $params the parameters (name=>value) to be bound to the query. - * Do not name the parameters as `:bp0`, `:bp1`, etc., because they are used internally by this method. - * @return integer the number of rows updated - */ - public static function updateAllCounters($counters, $condition = '', $params = array()) - { - // TODO implement - throw new NotSupportedException('update counters is not supported by elasticsearch.'); - } - - /** - * 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 string|array $condition the conditions that will be put in the WHERE part of the DELETE SQL. - * Please refer to [[Query::where()]] on how to specify this parameter. - * @param array $params the parameters (name=>value) to be bound to the query. - * @return integer the number of rows deleted - */ - public static function deleteAll($condition = '', $params = array()) - { - $db = static::getDb(); - - // TODO massive delete (do a find and then delete each record) - - if (empty($condition)) { - return 0; - } - $n = 0; -// foreach($condition as $pk) { -// -// $n++; -// } - return $n; - } - - /** - * Declares a `has-one` relation. - * The declaration is returned in terms of an [[ActiveRelation]] instance - * through which the related record can be queried and retrieved back. - * - * A `has-one` relation means that there is at most one related record matching - * the criteria set by this relation, e.g., a customer has one country. - * - * For example, to declare the `country` relation for `Customer` class, we can write - * the following code in the `Customer` class: - * - * ~~~ - * public function getCountry() - * { - * return $this->hasOne('Country', array('id' => 'country_id')); - * } - * ~~~ - * - * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name - * in the related class `Country`, while the 'country_id' value refers to an attribute name - * in the current AR class. - * - * Call methods declared in [[ActiveRelation]] to further customize the relation. - * - * @param string $class the class name of the related record - * @param array $link the primary-foreign key constraint. The keys of the array refer to - * the columns in the table associated with the `$class` model, while the values of the - * array refer to the corresponding columns in the table associated with this AR class. - * @return ActiveRelation the relation object. - */ - public function hasOne($class, $link) - { - return new ActiveRelation(array( - 'modelClass' => $this->getNamespacedClass($class), - 'primaryModel' => $this, - 'link' => $link, - 'multiple' => false, - )); - } - - /** - * Declares a `has-many` relation. - * The declaration is returned in terms of an [[ActiveRelation]] instance - * through which the related record can be queried and retrieved back. - * - * A `has-many` relation means that there are multiple related records matching - * the criteria set by this relation, e.g., a customer has many orders. - * - * For example, to declare the `orders` relation for `Customer` class, we can write - * the following code in the `Customer` class: - * - * ~~~ - * public function getOrders() - * { - * return $this->hasMany('Order', array('customer_id' => 'id')); - * } - * ~~~ - * - * Note that in the above, the 'customer_id' key in the `$link` parameter refers to - * an attribute name in the related class `Order`, while the 'id' value refers to - * an attribute name in the current AR class. - * - * @param string $class the class name of the related record - * @param array $link the primary-foreign key constraint. The keys of the array refer to - * the columns in the table associated with the `$class` model, while the values of the - * array refer to the corresponding columns in the table associated with this AR class. - * @return ActiveRelation the relation object. - */ - public function hasMany($class, $link) - { - return new ActiveRelation(array( - 'modelClass' => $this->getNamespacedClass($class), - 'primaryModel' => $this, - 'link' => $link, - 'multiple' => true, - )); - } - - /** - * Returns the relation object with the specified name. - * A relation is defined by a getter method which returns an [[ActiveRelation]] object. - * It can be declared in either the Active Record class itself or one of its behaviors. - * @param string $name the relation name - * @return ActiveRelation the relation object - * @throws InvalidParamException if the named relation does not exist. - */ - public function getRelation($name) - { - $getter = 'get' . $name; - try { - $relation = $this->$getter(); - if ($relation instanceof ActiveRelation) { - return $relation; - } - } catch (UnknownMethodException $e) { - } - throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".'); - } - - /** - * Establishes the relationship between two models. - * - * The relationship is established by setting the foreign key value(s) in one model - * to be the corresponding primary key value(s) in the other model. - * The model with the foreign key will be saved into database without performing validation. - * - * If the relationship involves a pivot table, a new row will be inserted into the - * pivot table which contains the primary key values from both models. - * - * Note that this method requires that the primary key value is not null. - * - * @param string $name the name of the relationship - * @param ActiveRecord $model the model to be linked with the current one. - * @param array $extraColumns additional column values to be saved into the pivot table. - * This parameter is only meaningful for a relationship involving a pivot table - * (i.e., a relation set with `[[ActiveRelation::via()]]` or `[[ActiveRelation::viaTable()]]`.) - * @throws InvalidCallException if the method is unable to link two models. - */ - public function link($name, $model, $extraColumns = array()) - { - $relation = $this->getRelation($name); - - if ($relation->via !== null) { - // TODO - - - } else { - $p1 = $model->isPrimaryKey(array_keys($relation->link)); - $p2 = $this->isPrimaryKey(array_values($relation->link)); - if ($p1 && $p2) { - if ($this->getIsNewRecord() && $model->getIsNewRecord()) { - throw new InvalidCallException('Unable to link models: both models are newly created.'); - } elseif ($this->getIsNewRecord()) { - $this->bindModels(array_flip($relation->link), $this, $model); - } else { - $this->bindModels($relation->link, $model, $this); - } - } elseif ($p1) { - $this->bindModels(array_flip($relation->link), $this, $model); - } elseif ($p2) { - $this->bindModels($relation->link, $model, $this); - } else { - throw new InvalidCallException('Unable to link models: the link does not involve any primary key.'); - } - } - - // update lazily loaded related objects - if (!$relation->multiple) { - $this->_related[$name] = $model; - } elseif (isset($this->_related[$name])) { - if ($relation->indexBy !== null) { - $indexBy = $relation->indexBy; - $this->_related[$name][$model->$indexBy] = $model; - } else { - $this->_related[$name][] = $model; - } - } - } - - /** - * @param array $link - * @param ActiveRecord $foreignModel - * @param ActiveRecord $primaryModel - * @throws InvalidCallException - */ - private function bindModels($link, $foreignModel, $primaryModel) - { - foreach ($link as $fk => $pk) { - $value = $primaryModel->$pk; - if ($value === null) { - throw new InvalidCallException('Unable to link models: the primary key of ' . get_class($primaryModel) . ' is null.'); - } - $foreignModel->$fk = $value; - } - $foreignModel->save(false); - } - - /** - * Destroys the relationship between two models. - * - * The model with the foreign key of the relationship will be deleted if `$delete` is true. - * Otherwise, the foreign key will be set null and the model will be saved without validation. - * - * @param string $name the name of the relationship. - * @param ActiveRecord $model the model to be unlinked from the current one. - * @param boolean $delete whether to delete the model that contains the foreign key. - * If false, the model's foreign key will be set null and saved. - * If true, the model containing the foreign key will be deleted. - * @throws InvalidCallException if the models cannot be unlinked - */ - public function unlink($name, $model, $delete = false) - { - // TODO - $relation = $this->getRelation($name); - - if ($relation->via !== null) { - if (is_array($relation->via)) { - /** @var $viaRelation ActiveRelation */ - list($viaName, $viaRelation) = $relation->via; - /** @var $viaClass ActiveRecord */ - $viaClass = $viaRelation->modelClass; - $viaTable = $viaClass::tableName(); - unset($this->_related[strtolower($viaName)]); - } else { - $viaRelation = $relation->via; - $viaTable = reset($relation->via->from); - } - $columns = array(); - foreach ($viaRelation->link as $a => $b) { - $columns[$a] = $this->$b; - } - foreach ($relation->link as $a => $b) { - $columns[$b] = $model->$a; - } - $command = static::getDb()->createCommand(); - if ($delete) { - $command->delete($viaTable, $columns)->execute(); - } else { - $nulls = array(); - foreach (array_keys($columns) as $a) { - $nulls[$a] = null; - } - $command->update($viaTable, $nulls, $columns)->execute(); - } - } else { - $p1 = $model->isPrimaryKey(array_keys($relation->link)); - $p2 = $this->isPrimaryKey(array_values($relation->link)); - if ($p1 && $p2 || $p2) { - foreach ($relation->link as $a => $b) { - $model->$a = null; - } - $delete ? $model->delete() : $model->save(false); - } elseif ($p1) { - foreach ($relation->link as $b) { - $this->$b = null; - } - $delete ? $this->delete() : $this->save(false); - } else { - throw new InvalidCallException('Unable to unlink models: the link does not involve any primary key.'); - } - } - - if (!$relation->multiple) { - unset($this->_related[$name]); - } elseif (isset($this->_related[$name])) { - /** @var $b ActiveRecord */ - foreach ($this->_related[$name] as $a => $b) { - if ($model->getPrimaryKey() == $b->getPrimaryKey()) { - unset($this->_related[$name][$a]); - } - } - } - } - - /** - * TODO duplicate code, refactor - * @param array $keys - * @return boolean - */ - private function isPrimaryKey($keys) - { - $pks = $this->primaryKey(); - foreach ($keys as $key) { - if (!in_array($key, $pks, true)) { - return false; - } - } - return true; - } - - - - // TODO implement link and unlink -} diff --git a/framework/yii/db/elasticsearch/Connection.php b/framework/yii/db/elasticsearch/Connection.php deleted file mode 100644 index 6524369..0000000 --- a/framework/yii/db/elasticsearch/Connection.php +++ /dev/null @@ -1,144 +0,0 @@ - - * @since 2.0 - */ -class Connection extends Component -{ - /** - * @event Event an event that is triggered after a DB connection is established - */ - const EVENT_AFTER_OPEN = 'afterOpen'; - - // TODO add autodetection of cluster nodes - public $nodes = array(); - - // TODO use timeouts - /** - * @var float timeout to use for connection to redis. If not set the timeout set in php.ini will be used: ini_get("default_socket_timeout") - */ - public $connectionTimeout = null; - /** - * @var float timeout to use for redis socket when reading and writing data. If not set the php default value will be used. - */ - public $dataTimeout = null; - - - - public function init() - { - if ($this->nodes === array()) { - throw new InvalidConfigException('elasticsearch needs at least one node.'); - } - } - - /** - * Closes the connection when this component is being serialized. - * @return array - */ - public function __sleep() - { - $this->close(); - return array_keys(get_object_vars($this)); - } - - /** - * Returns a value indicating whether the DB connection is established. - * @return boolean whether the DB connection is established - */ - public function getIsActive() - { - return false; // TODO implement - } - - /** - * Establishes a DB connection. - * It does nothing if a DB connection has already been established. - * @throws Exception if connection fails - */ - public function open() - { -/* if ($this->_socket === null) { - if (empty($this->dsn)) { - throw new InvalidConfigException('Connection.dsn cannot be empty.'); - } - $dsn = explode('/', $this->dsn); - $host = $dsn[2]; - if (strpos($host, ':')===false) { - $host .= ':6379'; - } - $db = isset($dsn[3]) ? $dsn[3] : 0; - - \Yii::trace('Opening DB connection: ' . $this->dsn, __CLASS__); - $this->_socket = @stream_socket_client( - $host, - $errorNumber, - $errorDescription, - $this->connectionTimeout ? $this->connectionTimeout : ini_get("default_socket_timeout") - ); - if ($this->_socket) { - if ($this->dataTimeout !== null) { - stream_set_timeout($this->_socket, $timeout=(int)$this->dataTimeout, (int) (($this->dataTimeout - $timeout) * 1000000)); - } - if ($this->password !== null) { - $this->executeCommand('AUTH', array($this->password)); - } - $this->executeCommand('SELECT', array($db)); - $this->initConnection(); - } else { - \Yii::error("Failed to open DB connection ({$this->dsn}): " . $errorNumber . ' - ' . $errorDescription, __CLASS__); - $message = YII_DEBUG ? 'Failed to open DB connection: ' . $errorNumber . ' - ' . $errorDescription : 'Failed to open DB connection.'; - throw new Exception($message, $errorDescription, (int)$errorNumber); - } - }*/ - // TODO implement - } - - /** - * Closes the currently active DB connection. - * It does nothing if the connection is already closed. - */ - public function close() - { - // TODO implement -/* if ($this->_socket !== null) { - \Yii::trace('Closing DB connection: ' . $this->dsn, __CLASS__); - $this->executeCommand('QUIT'); - stream_socket_shutdown($this->_socket, STREAM_SHUT_RDWR); - $this->_socket = null; - $this->_transaction = 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() - { - $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() - { - return 'elasticsearch'; - } -} \ No newline at end of file diff --git a/framework/yii/db/elasticsearch/Node.php b/framework/yii/db/elasticsearch/Node.php deleted file mode 100644 index 280d157..0000000 --- a/framework/yii/db/elasticsearch/Node.php +++ /dev/null @@ -1,23 +0,0 @@ - - * @since 2.0 - */ -class Node extends Object -{ - public $host; - public $port; -} \ No newline at end of file diff --git a/framework/yii/elasticsearch/ActiveQuery.php b/framework/yii/elasticsearch/ActiveQuery.php new file mode 100644 index 0000000..58303c7 --- /dev/null +++ b/framework/yii/elasticsearch/ActiveQuery.php @@ -0,0 +1,749 @@ +with('orders')->asArray()->all(); + * ~~~ + * + * @author Carsten Brandt + * @since 2.0 + */ +class ActiveQuery extends \yii\base\Component +{ + /** + * Sort ascending + * @see orderBy + */ + const SORT_ASC = false; + /** + * Sort descending + * @see orderBy + */ + const SORT_DESC = true; + + /** + * @var string the name of the ActiveRecord class. + */ + public $modelClass; + /** + * @var array list of relations that this query should be performed with + */ + public $with; + /** + * @var string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row or model data. For more details, see [[indexBy()]]. + */ + public $indexBy; + /** + * @var boolean whether to return each record as an array. If false (default), an object + * of [[modelClass]] will be created to represent each record. + */ + public $asArray; + /** + * @var array the query condition. + * @see where() + */ + public $where; + /** + * @var integer maximum number of records to be returned. If not set or less than 0, it means no limit. + */ + public $limit; + /** + * @var integer zero-based offset from where the records are to be returned. + * If not set, it means starting from the beginning. + * If less than zero it means starting n elements from the end. + */ + public $offset; + /** + * @var array how to sort the query results. This is used to construct the ORDER BY clause in a SQL statement. + * The array keys are the columns to be sorted by, and the array values are the corresponding sort directions which + * can be either [[ActiveQuery::SORT_ASC]] or [[ActiveQuery::SORT_DESC]]. The array may also contain [[Expression]] objects. + * If that is the case, the expressions will be converted into strings without any change. + */ + public $orderBy; + + /** + * PHP magic method. + * This method allows calling static method defined in [[modelClass]] via this query object. + * It is mainly implemented for supporting the feature of scope. + * @param string $name the method name to be called + * @param array $params the parameters passed to the method + * @return mixed the method return result + */ + public function __call($name, $params) + { + if (method_exists($this->modelClass, $name)) { + array_unshift($params, $this); + call_user_func_array(array($this->modelClass, $name), $params); + return $this; + } else { + return parent::__call($name, $params); + } + } + + /** + * Executes query and returns all results as an array. + * @return array the query results. If the query results in nothing, an empty array will be returned. + */ + public function all() + { + // TODO add support for orderBy + $data = $this->executeScript('All'); + $rows = array(); + foreach($data as $dataRow) { + $row = array(); + $c = count($dataRow); + for($i = 0; $i < $c; ) { + $row[$dataRow[$i++]] = $dataRow[$i++]; + } + $rows[] = $row; + } + if (!empty($rows)) { + $models = $this->createModels($rows); + if (!empty($this->with)) { + $this->populateRelations($models, $this->with); + } + return $models; + } else { + return array(); + } + } + + /** + * Executes query and returns a single row of result. + * @return ActiveRecord|array|null a single row of 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() + { + // TODO add support for orderBy + $data = $this->executeScript('One'); + if ($data === array()) { + return null; + } + $row = array(); + $c = count($data); + for($i = 0; $i < $c; ) { + $row[$data[$i++]] = $data[$i++]; + } + if ($this->asArray) { + $model = $row; + } else { + /** @var $class ActiveRecord */ + $class = $this->modelClass; + $model = $class::create($row); + } + if (!empty($this->with)) { + $models = array($model); + $this->populateRelations($models, $this->with); + $model = $models[0]; + } + return $model; + } + + /** + * Executes the query and returns the first column of the result. + * @param string $column name of the column to select + * @return array the first column of the query result. An empty array is returned if the query results in nothing. + */ + public function column($column) + { + // TODO add support for indexBy and orderBy + return $this->executeScript('Column', $column); + } + + /** + * Returns the number of records. + * @param string $q the COUNT expression. Defaults to '*'. + * Make sure you properly quote column names. + * @return integer number of records + */ + public function count() + { + if ($this->offset === null && $this->limit === null && $this->where === null) { + $modelClass = $this->modelClass; + /** @var Connection $db */ + $db = $modelClass::getDb(); + return $db->executeCommand('LLEN', array($modelClass::tableName())); + } else { + return $this->executeScript('Count'); + } + } + + /** + * Returns the number of records. + * @param string $column the column to sum up + * @return integer number of records + */ + public function sum($column) + { + return $this->executeScript('Sum', $column); + } + + /** + * Returns the average of the specified column values. + * @param string $column the column name or expression. + * Make sure you properly quote column names in the expression. + * @return integer the average of the specified column values. + */ + public function average($column) + { + return $this->executeScript('Average', $column); + } + + /** + * Returns the minimum of the specified column values. + * @param string $column the column name or expression. + * Make sure you properly quote column names in the expression. + * @return integer the minimum of the specified column values. + */ + public function min($column) + { + return $this->executeScript('Min', $column); + } + + /** + * Returns the maximum of the specified column values. + * @param string $column the column name or expression. + * Make sure you properly quote column names in the expression. + * @return integer the maximum of the specified column values. + */ + public function max($column) + { + return $this->executeScript('Max', $column); + } + + /** + * Returns the query result as a scalar value. + * The value returned will be the first column in the first row of the query results. + * @param string $column name of the column to select + * @return string|boolean the value of the first column in the first row of the query result. + * False is returned if the query result is empty. + */ + public function scalar($column) + { + $record = $this->one(); + return $record->$column; + } + + /** + * Returns a value indicating whether the query result contains any row of data. + * @return boolean whether the query result contains any row of data. + */ + public function exists() + { + return $this->one() !== null; + } + + /** + * Executes a script created by [[LuaScriptBuilder]] + * @param string $type + * @param null $column + * @return array|bool|null|string + */ + protected function executeScript($type, $columnName=null) + { + if (($data = $this->findByPk($type)) === false) { + $modelClass = $this->modelClass; + /** @var Connection $db */ + $db = $modelClass::getDb(); + + $method = 'build' . $type; + $script = $db->getLuaScriptBuilder()->$method($this, $columnName); + return $db->executeCommand('EVAL', array($script, 0)); + } + return $data; + } + + /** + * Fetch by pk if possible as this is much faster + */ + private function findByPk($type, $columnName = null) + { + $modelClass = $this->modelClass; + if (is_array($this->where) && !isset($this->where[0]) && $modelClass::isPrimaryKey(array_keys($this->where))) { + /** @var Connection $db */ + $db = $modelClass::getDb(); + + $pks = (array) reset($this->where); + + $start = $this->offset === null ? 0 : $this->offset; + $i = 0; + $data = array(); + $url = '/' . $modelClass::indexName() . '/' . $modelClass::indexType() . '/'; + foreach($pks as $pk) { + if (++$i > $start && ($this->limit === null || $i <= $start + $this->limit)) { + $request = $db->http()->get($url . $pk); + $response = $request->send(); + if ($response->getStatusCode() == 404) { + // ignore? + } else { + $data[] = Json::decode($response->getBody(true)); + if ($type === 'One' && $this->orderBy === null) { + break; + } + } + } + } + // TODO support orderBy + + switch($type) { + case 'All': + return $data; + case 'One': + return reset($data); + case 'Column': + // TODO support indexBy + $column = array(); + foreach($data as $dataRow) { + $row = array(); + $c = count($dataRow); + for($i = 0; $i < $c; ) { + $row[$dataRow[$i++]] = $dataRow[$i++]; + } + $column[] = $row[$columnName]; + } + return $column; + case 'Count': + return count($data); + case 'Sum': + $sum = 0; + foreach($data as $dataRow) { + $c = count($dataRow); + for($i = 0; $i < $c; ) { + if ($dataRow[$i++] == $columnName) { + $sum += $dataRow[$i]; + break; + } + } + } + return $sum; + case 'Average': + $sum = 0; + $count = 0; + foreach($data as $dataRow) { + $count++; + $c = count($dataRow); + for($i = 0; $i < $c; ) { + if ($dataRow[$i++] == $columnName) { + $sum += $dataRow[$i]; + break; + } + } + } + return $sum / $count; + case 'Min': + $min = null; + foreach($data as $dataRow) { + $c = count($dataRow); + for($i = 0; $i < $c; ) { + if ($dataRow[$i++] == $columnName && ($min == null || $dataRow[$i] < $min)) { + $min = $dataRow[$i]; + break; + } + } + } + return $min; + case 'Max': + $max = null; + foreach($data as $dataRow) { + $c = count($dataRow); + for($i = 0; $i < $c; ) { + if ($dataRow[$i++] == $columnName && ($max == null || $dataRow[$i] > $max)) { + $max = $dataRow[$i]; + break; + } + } + } + return $max; + } + } + return false; + } + + // TODO: refactor. code below here is all duplicated from yii/db/ActiveQuery and yii/db/Query + + /** + * Sets the [[asArray]] property. + * @param boolean $value whether to return the query results in terms of arrays instead of Active Records. + * @return ActiveQuery the query object itself + */ + public function asArray($value = true) + { + $this->asArray = $value; + return $this; + } + + /** + * Sets the ORDER BY part of the query. + * @param string|array $columns the columns (and the directions) to be ordered by. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `array('id' => Query::SORT_ASC, 'name' => Query::SORT_DESC)`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return ActiveQuery the query object itself + * @see addOrderBy() + */ + public function orderBy($columns) + { + $this->orderBy = $this->normalizeOrderBy($columns); + return $this; + } + + /** + * Adds additional ORDER BY columns to the query. + * @param string|array $columns the columns (and the directions) to be ordered by. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `array('id' => Query::SORT_ASC, 'name' => Query::SORT_DESC)`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return ActiveQuery the query object itself + * @see orderBy() + */ + public function addOrderBy($columns) + { + $columns = $this->normalizeOrderBy($columns); + if ($this->orderBy === null) { + $this->orderBy = $columns; + } else { + $this->orderBy = array_merge($this->orderBy, $columns); + } + return $this; + } + + protected function normalizeOrderBy($columns) + { + throw new NotSupportedException('orderBy is currently not supported'); + if (is_array($columns)) { + return $columns; + } else { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + $result = array(); + foreach ($columns as $column) { + if (preg_match('/^(.*?)\s+(asc|desc)$/i', $column, $matches)) { + $result[$matches[1]] = strcasecmp($matches[2], 'desc') ? self::SORT_ASC : self::SORT_DESC; + } else { + $result[$column] = self::SORT_ASC; + } + } + return $result; + } + } + + /** + * Sets the LIMIT part of the query. + * @param integer $limit the limit + * @return ActiveQuery the query object itself + */ + public function limit($limit) + { + $this->limit = $limit; + return $this; + } + + /** + * Sets the OFFSET part of the query. + * @param integer $offset the offset + * @return ActiveQuery the query object itself + */ + public function offset($offset) + { + $this->offset = $offset; + return $this; + } + + /** + * Specifies the relations with which this query should be performed. + * + * The parameters to this method can be either one or multiple strings, or a single array + * of relation names and the optional callbacks to customize the relations. + * + * The followings are some usage examples: + * + * ~~~ + * // find customers together with their orders and country + * Customer::find()->with('orders', 'country')->all(); + * // find customers together with their country and orders of status 1 + * Customer::find()->with(array( + * 'orders' => function($query) { + * $query->andWhere('status = 1'); + * }, + * 'country', + * ))->all(); + * ~~~ + * + * @return ActiveQuery the query object itself + */ + public function with() + { + $this->with = func_get_args(); + if (isset($this->with[0]) && is_array($this->with[0])) { + // the parameter is given as an array + $this->with = $this->with[0]; + } + return $this; + } + + /** + * Sets the [[indexBy]] property. + * @param string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row or model data. The signature of the callable should be: + * + * ~~~ + * // $model is an AR instance when `asArray` is false, + * // or an array of column values when `asArray` is true. + * function ($model) + * { + * // return the index value corresponding to $model + * } + * ~~~ + * + * @return ActiveQuery the query object itself + */ + public function indexBy($column) + { + $this->indexBy = $column; + return $this; + } + + /** + * Sets the WHERE part of the query. + * + * The method requires a $condition parameter, and optionally a $params parameter + * specifying the values to be bound to the query. + * + * The $condition parameter should be either a string (e.g. 'id=1') or an array. + * If the latter, it must be in one of the following two formats: + * + * - hash format: `array('column1' => value1, 'column2' => value2, ...)` + * - operator format: `array(operator, operand1, operand2, ...)` + * + * A condition in hash format represents the following SQL expression in general: + * `column1=value1 AND column2=value2 AND ...`. In case when a value is an array, + * an `IN` expression will be generated. And if a value is null, `IS NULL` will be used + * in the generated expression. Below are some examples: + * + * - `array('type' => 1, 'status' => 2)` generates `(type = 1) AND (status = 2)`. + * - `array('id' => array(1, 2, 3), 'status' => 2)` generates `(id IN (1, 2, 3)) AND (status = 2)`. + * - `array('status' => null) generates `status IS NULL`. + * + * A condition in operator format generates the SQL expression according to the specified operator, which + * can be one of the followings: + * + * - `and`: the operands should be concatenated together using `AND`. For example, + * `array('and', 'id=1', 'id=2')` will generate `id=1 AND id=2`. If an operand is an array, + * it will be converted into a string using the rules described here. For example, + * `array('and', 'type=1', array('or', 'id=1', 'id=2'))` will generate `type=1 AND (id=1 OR id=2)`. + * The method will NOT do any quoting or escaping. + * + * - `or`: similar to the `and` operator except that the operands are concatenated using `OR`. + * + * - `between`: operand 1 should be the column name, and operand 2 and 3 should be the + * starting and ending values of the range that the column is in. + * For example, `array('between', 'id', 1, 10)` will generate `id BETWEEN 1 AND 10`. + * + * - `not between`: similar to `between` except the `BETWEEN` is replaced with `NOT BETWEEN` + * in the generated condition. + * + * - `in`: operand 1 should be a column or DB expression, and operand 2 be an array representing + * the range of the values that the column or DB expression should be in. For example, + * `array('in', 'id', array(1, 2, 3))` will generate `id IN (1, 2, 3)`. + * The method will properly quote the column name and escape values in the range. + * + * - `not in`: similar to the `in` operator except that `IN` is replaced with `NOT IN` in the generated condition. + * + * - `like`: operand 1 should be a column or DB expression, and operand 2 be a string or an array representing + * the values that the column or DB expression should be like. + * For example, `array('like', 'name', '%tester%')` will generate `name LIKE '%tester%'`. + * When the value range is given as an array, multiple `LIKE` predicates will be generated and concatenated + * using `AND`. For example, `array('like', 'name', array('%test%', '%sample%'))` will generate + * `name LIKE '%test%' AND name LIKE '%sample%'`. + * The method will properly quote the column name and escape values in the range. + * + * - `or like`: similar to the `like` operator except that `OR` is used to concatenate the `LIKE` + * predicates when operand 2 is an array. + * + * - `not like`: similar to the `like` operator except that `LIKE` is replaced with `NOT LIKE` + * in the generated condition. + * + * - `or not like`: similar to the `not like` operator except that `OR` is used to concatenate + * the `NOT LIKE` predicates. + * + * @param string|array $condition the conditions that should be put in the WHERE part. + * @return ActiveQuery the query object itself + * @see andWhere() + * @see orWhere() + */ + public function where($condition) + { + $this->where = $condition; + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'AND' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @return ActiveQuery the query object itself + * @see where() + * @see orWhere() + */ + public function andWhere($condition) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = array('and', $this->where, $condition); + } + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'OR' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @return ActiveQuery the query object itself + * @see where() + * @see andWhere() + */ + public function orWhere($condition) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = array('or', $this->where, $condition); + } + return $this; + } + + private function createModels($rows) + { + $models = array(); + if ($this->asArray) { + if ($this->indexBy === null) { + return $rows; + } + foreach ($rows as $row) { + if (is_string($this->indexBy)) { + $key = $row[$this->indexBy]; + } else { + $key = call_user_func($this->indexBy, $row); + } + $models[$key] = $row; + } + } else { + /** @var $class ActiveRecord */ + $class = $this->modelClass; + if ($this->indexBy === null) { + foreach ($rows as $row) { + $models[] = $class::create($row); + } + } else { + foreach ($rows as $row) { + $model = $class::create($row); + if (is_string($this->indexBy)) { + $key = $model->{$this->indexBy}; + } else { + $key = call_user_func($this->indexBy, $model); + } + $models[$key] = $model; + } + } + } + return $models; + } + + private function populateRelations(&$models, $with) + { + $primaryModel = new $this->modelClass; + $relations = $this->normalizeRelations($primaryModel, $with); + foreach ($relations as $name => $relation) { + if ($relation->asArray === null) { + // inherit asArray from primary query + $relation->asArray = $this->asArray; + } + $relation->findWith($name, $models); + } + } + + /** + * @param ActiveRecord $model + * @param array $with + * @return ActiveRelation[] + */ + private function normalizeRelations($model, $with) + { + $relations = array(); + foreach ($with as $name => $callback) { + if (is_integer($name)) { + $name = $callback; + $callback = null; + } + if (($pos = strpos($name, '.')) !== false) { + // with sub-relations + $childName = substr($name, $pos + 1); + $name = substr($name, 0, $pos); + } else { + $childName = null; + } + + $t = strtolower($name); + if (!isset($relations[$t])) { + $relation = $model->getRelation($name); + $relation->primaryModel = null; + $relations[$t] = $relation; + } else { + $relation = $relations[$t]; + } + + if (isset($childName)) { + $relation->with[$childName] = $callback; + } elseif ($callback !== null) { + call_user_func($callback, $relation); + } + } + return $relations; + } +} diff --git a/framework/yii/elasticsearch/ActiveRecord.php b/framework/yii/elasticsearch/ActiveRecord.php new file mode 100644 index 0000000..723c162 --- /dev/null +++ b/framework/yii/elasticsearch/ActiveRecord.php @@ -0,0 +1,368 @@ + + * @since 2.0 + */ +abstract class ActiveRecord extends \yii\db\ActiveRecord +{ + /** + * 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. + * @return Connection the database connection used by this AR class. + */ + public static function getDb() + { + return \Yii::$app->elasticsearch; + } + + /** + * @inheritdoc + */ + public static function findBySql($sql, $params = array()) + { + throw new NotSupportedException('findBySql() is not supported by elasticsearch ActiveRecord'); + } + + + /** + * Updates the whole table using the provided attribute values and conditions. + * For example, to change the status to be 1 for all customers whose status is 2: + * + * ~~~ + * Customer::updateAll(array('status' => 1), array('id' => 2)); + * ~~~ + * + * @param array $attributes attribute values (name-value pairs) to be saved into the table + * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. + * @param array $params this parameter is ignored in redis implementation. + * @return integer the number of rows updated + */ + public static function updateAll($attributes, $condition = null, $params = array()) + { + if (empty($attributes)) { + return 0; + } + $db = static::getDb(); + $n=0; + foreach(static::fetchPks($condition) as $pk) { + $newPk = $pk; + $pk = static::buildKey($pk); + $key = static::tableName() . ':a:' . $pk; + // save attributes + $args = array($key); + foreach($attributes as $attribute => $value) { + if (isset($newPk[$attribute])) { + $newPk[$attribute] = $value; + } + $args[] = $attribute; + $args[] = $value; + } + $newPk = static::buildKey($newPk); + $newKey = static::tableName() . ':a:' . $newPk; + // rename index if pk changed + if ($newPk != $pk) { + $db->executeCommand('MULTI'); + $db->executeCommand('HMSET', $args); + $db->executeCommand('LINSERT', array(static::tableName(), 'AFTER', $pk, $newPk)); + $db->executeCommand('LREM', array(static::tableName(), 0, $pk)); + $db->executeCommand('RENAME', array($key, $newKey)); + $db->executeCommand('EXEC'); + } else { + $db->executeCommand('HMSET', $args); + } + $n++; + } + return $n; + } + + /** + * Updates the whole table using the provided counter changes and conditions. + * For example, to increment all customers' age by 1, + * + * ~~~ + * Customer::updateAllCounters(array('age' => 1)); + * ~~~ + * + * @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 put in the WHERE part of the UPDATE SQL. + * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. + * @param array $params this parameter is ignored in redis implementation. + * @return integer the number of rows updated + */ + public static function updateAllCounters($counters, $condition = null, $params = array()) + { + if (empty($counters)) { + return 0; + } + $db = static::getDb(); + $n=0; + foreach(static::fetchPks($condition) as $pk) { + $key = static::tableName() . ':a:' . static::buildKey($pk); + foreach($counters as $attribute => $value) { + $db->executeCommand('HINCRBY', array($key, $attribute, $value)); + } + $n++; + } + return $n; + } + + /** + * 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 put in the WHERE part of the DELETE SQL. + * Please refer to [[ActiveQuery::where()]] on how to specify this parameter. + * @param array $params this parameter is ignored in redis implementation. + * @return integer the number of rows deleted + */ + public static function deleteAll($condition = null, $params = array()) + { + $db = static::getDb(); + $attributeKeys = array(); + $pks = static::fetchPks($condition); + $db->executeCommand('MULTI'); + foreach($pks as $pk) { + $pk = static::buildKey($pk); + $db->executeCommand('LREM', array(static::tableName(), 0, $pk)); + $attributeKeys[] = static::tableName() . ':a:' . $pk; + } + if (empty($attributeKeys)) { + $db->executeCommand('EXEC'); + return 0; + } + $db->executeCommand('DEL', $attributeKeys); + $result = $db->executeCommand('EXEC'); + return end($result); + } + /** + * Creates an [[ActiveQuery]] instance. + * This method is called by [[find()]], [[findBySql()]] and [[count()]] to start a SELECT query. + * You may override this method to return a customized query (e.g. `CustomerQuery` specified + * written for querying `Customer` purpose.) + * @return ActiveQuery the newly created [[ActiveQuery]] instance. + */ + public static function createQuery() + { + return new ActiveQuery(array( + 'modelClass' => get_called_class(), + )); + } + + /** + * Declares the name of the database table associated with this AR class. + * @return string the table name + */ + public static function tableName() + { + return static::getTableSchema()->name; + } + + /** + * This method is ment to be overridden in redis ActiveRecord subclasses to return a [[RecordSchema]] instance. + * @return RecordSchema + * @throws \yii\base\InvalidConfigException + */ + public static function getRecordSchema() + { + throw new InvalidConfigException(__CLASS__.'::getRecordSchema() needs to be overridden in subclasses and return a RecordSchema.'); + } + + public static function primaryKey() + { + return array('id'); + } + + public static function columns() + { + return array('id' => 'integer'); + } + + public static function indexName() + { + return Inflector::pluralize(Inflector::camel2id(StringHelper::basename(get_called_class()), '-')); + } + + public static function indexType() + { + return Inflector::camel2id(StringHelper::basename(get_called_class()), '-'); + } + + private static $_tables; + /** + * Returns the schema information of the DB table associated with this AR class. + * @return TableSchema the schema information of the DB table associated with this AR class. + * @throws InvalidConfigException if the table for the AR class does not exist. + */ + public static function getTableSchema() + { + $class = get_called_class(); + if (isset(self::$_tables[$class])) { + return self::$_tables[$class]; + } + return self::$_tables[$class] = new TableSchema(array( + 'schemaName' => static::indexName(), + 'name' => static::indexType(), + 'primaryKey' => static::primaryKey(), + 'columns' => static::columns(), + )); + } + + /** + * Declares a `has-one` relation. + * The declaration is returned in terms of an [[ActiveRelation]] instance + * through which the related record can be queried and retrieved back. + * + * A `has-one` relation means that there is at most one related record matching + * the criteria set by this relation, e.g., a customer has one country. + * + * For example, to declare the `country` relation for `Customer` class, we can write + * the following code in the `Customer` class: + * + * ~~~ + * public function getCountry() + * { + * return $this->hasOne('Country', array('id' => 'country_id')); + * } + * ~~~ + * + * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name + * in the related class `Country`, while the 'country_id' value refers to an attribute name + * in the current AR class. + * + * Call methods declared in [[ActiveRelation]] to further customize the relation. + * + * @param string $class the class name of the related record + * @param array $link the primary-foreign key constraint. The keys of the array refer to + * the columns in the table associated with the `$class` model, while the values of the + * array refer to the corresponding columns in the table associated with this AR class. + * @return ActiveRelation the relation object. + */ + public function hasOne($class, $link) + { + return new ActiveRelation(array( + 'modelClass' => $this->getNamespacedClass($class), + 'primaryModel' => $this, + 'link' => $link, + 'multiple' => false, + )); + } + + /** + * Declares a `has-many` relation. + * The declaration is returned in terms of an [[ActiveRelation]] instance + * through which the related record can be queried and retrieved back. + * + * A `has-many` relation means that there are multiple related records matching + * the criteria set by this relation, e.g., a customer has many orders. + * + * For example, to declare the `orders` relation for `Customer` class, we can write + * the following code in the `Customer` class: + * + * ~~~ + * public function getOrders() + * { + * return $this->hasMany('Order', array('customer_id' => 'id')); + * } + * ~~~ + * + * Note that in the above, the 'customer_id' key in the `$link` parameter refers to + * an attribute name in the related class `Order`, while the 'id' value refers to + * an attribute name in the current AR class. + * + * @param string $class the class name of the related record + * @param array $link the primary-foreign key constraint. The keys of the array refer to + * the columns in the table associated with the `$class` model, while the values of the + * array refer to the corresponding columns in the table associated with this AR class. + * @return ActiveRelation the relation object. + */ + public function hasMany($class, $link) + { + return new ActiveRelation(array( + 'modelClass' => $this->getNamespacedClass($class), + 'primaryModel' => $this, + 'link' => $link, + 'multiple' => true, + )); + } + + /** + * @inheritDocs + */ + public function insert($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + if ($this->beforeSave(true)) { + $db = static::getDb(); + $values = $this->getDirtyAttributes($attributes); + $key = reset($this->primaryKey()); + $pk = $this->getAttribute($key); + unset($values[$key]); + + // save attributes + if ($pk === null) { + $url = '/' . static::indexName() . '/' . static::indexType(); + $request = $db->http()->post($url, array(), Json::encode($values)); + } else { + $url = '/' . static::indexName() . '/' . static::indexType() . '/' . $pk; + $request = $db->http()->put($url, array(), Json::encode($values)); + } + $response = $request->send(); + $body = Json::decode($response->getBody(true)); + if (!$body['ok']) { + return false; + } + $this->setOldAttributes($values); + if ($pk === null) { + $this->setAttribute($key, $body['_id']); + } + $this->afterSave(true); + return true; + } + return false; + } + + /** + * Returns a value indicating whether the specified operation is transactional in the current [[scenario]]. + * This method will always return false as transactional operations are not supported by elasticsearch. + * @param integer $operation the operation to check. Possible values are [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]]. + * @return boolean whether the specified operation is transactional in the current [[scenario]]. + */ + public function isTransactional($operation) + { + return false; + } +} diff --git a/framework/yii/elasticsearch/Connection.php b/framework/yii/elasticsearch/Connection.php new file mode 100644 index 0000000..9f53062 --- /dev/null +++ b/framework/yii/elasticsearch/Connection.php @@ -0,0 +1,186 @@ + + * @since 2.0 + */ +class Connection extends Component +{ + /** + * @event Event an event that is triggered after a DB connection is established + */ + const EVENT_AFTER_OPEN = 'afterOpen'; + + // TODO add autodetection of cluster nodes + // http://localhost:9200/_cluster/nodes + public $nodes = array( + array( + 'host' => 'localhost', + 'port' => 9200, + ) + ); + + // TODO use timeouts + /** + * @var float timeout to use for connection to redis. If not set the timeout set in php.ini will be used: ini_get("default_socket_timeout") + */ + public $connectionTimeout = null; + /** + * @var float timeout to use for redis socket when reading and writing data. If not set the php default value will be used. + */ + public $dataTimeout = null; + + + + public function init() + { + if ($this->nodes === array()) { + throw new InvalidConfigException('elasticsearch needs at least one node.'); + } + } + + /** + * Closes the connection when this component is being serialized. + * @return array + */ + public function __sleep() + { + $this->close(); + return array_keys(get_object_vars($this)); + } + + /** + * Returns a value indicating whether the DB connection is established. + * @return boolean whether the DB connection is established + */ + public function getIsActive() + { + return false; // TODO implement + } + + /** + * Establishes a DB connection. + * It does nothing if a DB connection has already been established. + * @throws Exception if connection fails + */ + public function open() + { + foreach($this->nodes as $key => $node) { + if (is_array($node)) { + $this->nodes[$key] = new Node($node); + } + } +/* if ($this->_socket === null) { + if (empty($this->dsn)) { + throw new InvalidConfigException('Connection.dsn cannot be empty.'); + } + $dsn = explode('/', $this->dsn); + $host = $dsn[2]; + if (strpos($host, ':')===false) { + $host .= ':6379'; + } + $db = isset($dsn[3]) ? $dsn[3] : 0; + + \Yii::trace('Opening DB connection: ' . $this->dsn, __CLASS__); + $this->_socket = @stream_socket_client( + $host, + $errorNumber, + $errorDescription, + $this->connectionTimeout ? $this->connectionTimeout : ini_get("default_socket_timeout") + ); + if ($this->_socket) { + if ($this->dataTimeout !== null) { + stream_set_timeout($this->_socket, $timeout=(int)$this->dataTimeout, (int) (($this->dataTimeout - $timeout) * 1000000)); + } + if ($this->password !== null) { + $this->executeCommand('AUTH', array($this->password)); + } + $this->executeCommand('SELECT', array($db)); + $this->initConnection(); + } else { + \Yii::error("Failed to open DB connection ({$this->dsn}): " . $errorNumber . ' - ' . $errorDescription, __CLASS__); + $message = YII_DEBUG ? 'Failed to open DB connection: ' . $errorNumber . ' - ' . $errorDescription : 'Failed to open DB connection.'; + throw new Exception($message, $errorDescription, (int)$errorNumber); + } + }*/ + // TODO implement + } + + /** + * Closes the currently active DB connection. + * It does nothing if the connection is already closed. + */ + public function close() + { + // TODO implement +/* if ($this->_socket !== null) { + \Yii::trace('Closing DB connection: ' . $this->dsn, __CLASS__); + $this->executeCommand('QUIT'); + stream_socket_shutdown($this->_socket, STREAM_SHUT_RDWR); + $this->_socket = null; + $this->_transaction = 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() + { + $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() + { + return 'elasticsearch'; + } + + public function getNodeInfo() + { + // TODO HTTP request to localhost:9200/ + } + + public function http() + { + return new \Guzzle\Http\Client('http://localhost:9200/'); + } + + public function get($url) + { + $c = $this->initCurl($url); + + $result = curl_exec($c); + curl_close($c); + } + + private function initCurl($url) + { + $c = curl_init('http://localhost:9200/' . $url); + $fp = fopen("example_homepage.txt", "w"); + + curl_setopt($c, CURLOPT_FOLLOWLOCATION, false); + + curl_setopt($c, CURLOPT_FILE, $fp); + curl_setopt($c, CURLOPT_HEADER, 0); + } +} \ No newline at end of file diff --git a/framework/yii/elasticsearch/Node.php b/framework/yii/elasticsearch/Node.php new file mode 100644 index 0000000..60d5956 --- /dev/null +++ b/framework/yii/elasticsearch/Node.php @@ -0,0 +1,23 @@ + + * @since 2.0 + */ +class Node extends Object +{ + public $host; + public $port; +} \ No newline at end of file diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php index 0580db6..b281dad 100644 --- a/tests/unit/bootstrap.php +++ b/tests/unit/bootstrap.php @@ -5,6 +5,11 @@ define('YII_DEBUG', true); $_SERVER['SCRIPT_NAME'] = '/' . __DIR__; $_SERVER['SCRIPT_FILENAME'] = __FILE__; +// require composer autoloader if available +$composerAutoload = __DIR__ . '/../../vendor/autoload.php'; +if (is_file($composerAutoload)) { + require_once($composerAutoload); +} require_once(__DIR__ . '/../../framework/yii/Yii.php'); Yii::setAlias('@yiiunit', __DIR__); diff --git a/tests/unit/data/ar/elasticsearch/ActiveRecord.php b/tests/unit/data/ar/elasticsearch/ActiveRecord.php new file mode 100644 index 0000000..3309004 --- /dev/null +++ b/tests/unit/data/ar/elasticsearch/ActiveRecord.php @@ -0,0 +1,24 @@ + + * @since 2.0 + */ +class ActiveRecord extends \yii\elasticsearch\ActiveRecord +{ + public static $db; + + public static function getDb() + { + return self::$db; + } +} diff --git a/tests/unit/data/ar/elasticsearch/Customer.php b/tests/unit/data/ar/elasticsearch/Customer.php new file mode 100644 index 0000000..8a54ab6 --- /dev/null +++ b/tests/unit/data/ar/elasticsearch/Customer.php @@ -0,0 +1,40 @@ + 'integer', + 'name' => 'string', + 'email' => 'string', + 'address' => 'string', + 'status' => 'integer', + ); + } + + public function getOrders() + { + return $this->hasMany('Order', array('customer_id' => 'id'))->orderBy('id'); + } + + public static function active($query) + { + $query->andWhere('status=1'); + } +} diff --git a/tests/unit/data/ar/elasticsearch/Item.php b/tests/unit/data/ar/elasticsearch/Item.php new file mode 100644 index 0000000..6109c44 --- /dev/null +++ b/tests/unit/data/ar/elasticsearch/Item.php @@ -0,0 +1,22 @@ + 'integer', + 'name' => 'string', + 'category_id' => 'integer', + ); + } +} diff --git a/tests/unit/data/ar/elasticsearch/Order.php b/tests/unit/data/ar/elasticsearch/Order.php new file mode 100644 index 0000000..dd46930 --- /dev/null +++ b/tests/unit/data/ar/elasticsearch/Order.php @@ -0,0 +1,59 @@ + 'integer', + 'customer_id' => 'integer', + 'create_time' => 'integer', + 'total' => 'integer', + ); + } + + public function getCustomer() + { + return $this->hasOne('Customer', array('id' => 'customer_id')); + } + + public function getOrderItems() + { + return $this->hasMany('OrderItem', array('order_id' => 'id')); + } + + public function getItems() + { + return $this->hasMany('Item', array('id' => 'item_id')) + ->via('orderItems', function ($q) { + // additional query configuration + })->orderBy('id'); + } + + public function getBooks() + { + return $this->hasMany('Item', array('id' => 'item_id')) + ->viaTable('tbl_order_item', array('order_id' => 'id')) + ->where(array('category_id' => 1)); + } + + public function beforeSave($insert) + { + if (parent::beforeSave($insert)) { + $this->create_time = time(); + return true; + } else { + return false; + } + } +} diff --git a/tests/unit/data/ar/elasticsearch/OrderItem.php b/tests/unit/data/ar/elasticsearch/OrderItem.php new file mode 100644 index 0000000..c4292e4 --- /dev/null +++ b/tests/unit/data/ar/elasticsearch/OrderItem.php @@ -0,0 +1,34 @@ + 'integer', + 'item_id' => 'integer', + 'quantity' => 'integer', + 'subtotal' => 'integer', + ); + } + + public function getOrder() + { + return $this->hasOne('Order', array('id' => 'order_id')); + } + + public function getItem() + { + return $this->hasOne('Item', array('id' => 'item_id')); + } +} diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index fda2be1..65d8bd2 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -29,5 +29,8 @@ return array( 'password' => 'postgres', 'fixture' => __DIR__ . '/postgres.sql', ), + 'elasticsearch' => array( + 'dsn' => 'elasticsearch://localhost:9200' + ), ), ); diff --git a/tests/unit/framework/elasticsearch/ActiveRecordTest.php b/tests/unit/framework/elasticsearch/ActiveRecordTest.php new file mode 100644 index 0000000..19dec38 --- /dev/null +++ b/tests/unit/framework/elasticsearch/ActiveRecordTest.php @@ -0,0 +1,473 @@ +getConnection(); + + $customer = new Customer(); + $customer->setAttributes(array('id' => 1, 'email' => 'user1@example.com', 'name' => 'user1', 'address' => 'address1', 'status' => 1), false); + $customer->save(false); + $customer = new Customer(); + $customer->setAttributes(array('id' => 2, 'email' => 'user2@example.com', 'name' => 'user2', 'address' => 'address2', 'status' => 1), false); + $customer->save(false); + $customer = new Customer(); + $customer->setAttributes(array('id' => 3, 'email' => 'user3@example.com', 'name' => 'user3', 'address' => 'address3', 'status' => 2), false); + $customer->save(false); + +// INSERT INTO tbl_category (name) VALUES ('Books'); +// INSERT INTO tbl_category (name) VALUES ('Movies'); + + $item = new Item(); + $item->setAttributes(array('id' => 1, 'name' => 'Agile Web Application Development with Yii1.1 and PHP5', 'category_id' => 1), false); + $item->save(false); + $item = new Item(); + $item->setAttributes(array('id' => 2, 'name' => 'Yii 1.1 Application Development Cookbook', 'category_id' => 1), false); + $item->save(false); + $item = new Item(); + $item->setAttributes(array('id' => 3, 'name' => 'Ice Age', 'category_id' => 2), false); + $item->save(false); + $item = new Item(); + $item->setAttributes(array('id' => 4, 'name' => 'Toy Story', 'category_id' => 2), false); + $item->save(false); + $item = new Item(); + $item->setAttributes(array('id' => 5, 'name' => 'Cars', 'category_id' => 2), false); + $item->save(false); + + $order = new Order(); + $order->setAttributes(array('id' => 1, 'customer_id' => 1, 'create_time' => 1325282384, 'total' => 110.0), false); + $order->save(false); + $order = new Order(); + $order->setAttributes(array('id' => 2, 'customer_id' => 2, 'create_time' => 1325334482, 'total' => 33.0), false); + $order->save(false); + $order = new Order(); + $order->setAttributes(array('id' => 3, 'customer_id' => 2, 'create_time' => 1325502201, 'total' => 40.0), false); + $order->save(false); + +// $orderItem = new OrderItem(); +// $orderItem->setAttributes(array('order_id' => 1, 'item_id' => 1, 'quantity' => 1, 'subtotal' => 30.0), false); +// $orderItem->save(false); +// $orderItem = new OrderItem(); +// $orderItem->setAttributes(array('order_id' => 1, 'item_id' => 2, 'quantity' => 2, 'subtotal' => 40.0), false); +// $orderItem->save(false); +// $orderItem = new OrderItem(); +// $orderItem->setAttributes(array('order_id' => 2, 'item_id' => 4, 'quantity' => 1, 'subtotal' => 10.0), false); +// $orderItem->save(false); +// $orderItem = new OrderItem(); +// $orderItem->setAttributes(array('order_id' => 2, 'item_id' => 5, 'quantity' => 1, 'subtotal' => 15.0), false); +// $orderItem->save(false); +// $orderItem = new OrderItem(); +// $orderItem->setAttributes(array('order_id' => 2, 'item_id' => 3, 'quantity' => 1, 'subtotal' => 8.0), false); +// $orderItem->save(false); +// $orderItem = new OrderItem(); +// $orderItem->setAttributes(array('order_id' => 3, 'item_id' => 2, 'quantity' => 1, 'subtotal' => 40.0), false); +// $orderItem->save(false); + } + + public function testFind() + { + // find one + $result = Customer::find(); + $this->assertTrue($result instanceof ActiveQuery); + $customer = $result->one(); + $this->assertTrue($customer instanceof Customer); + + // find all + $customers = Customer::find()->all(); + $this->assertEquals(3, count($customers)); + $this->assertTrue($customers[0] instanceof Customer); + $this->assertTrue($customers[1] instanceof Customer); + $this->assertTrue($customers[2] instanceof Customer); + + // find by a single primary key + $customer = Customer::find(2); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $customer = Customer::find(5); + $this->assertNull($customer); + + // query scalar + $customerName = Customer::find()->where(array('id' => 2))->scalar('name'); + $this->assertEquals('user2', $customerName); + + // find by column values + $customer = Customer::find(array('id' => 2, 'name' => 'user2')); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $customer = Customer::find(array('id' => 2, 'name' => 'user1')); + $this->assertNull($customer); + $customer = Customer::find(array('id' => 5)); + $this->assertNull($customer); + + // find by attributes + $customer = Customer::find()->where(array('name' => 'user2'))->one(); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals(2, $customer->id); + + // find count, sum, average, min, max, scalar + $this->assertEquals(3, Customer::find()->count()); + $this->assertEquals(6, Customer::find()->sum('id')); + $this->assertEquals(2, Customer::find()->average('id')); + $this->assertEquals(1, Customer::find()->min('id')); + $this->assertEquals(3, Customer::find()->max('id')); + + // scope + $this->assertEquals(2, Customer::find()->active()->count()); + + // asArray + $customer = Customer::find()->where(array('id' => 2))->asArray()->one(); + $this->assertEquals(array( + 'id' => '2', + 'email' => 'user2@example.com', + 'name' => 'user2', + 'address' => 'address2', + 'status' => '1', + ), $customer); + + // indexBy + $customers = Customer::find()->indexBy('name')->all(); + $this->assertEquals(3, count($customers)); + $this->assertTrue($customers['user1'] instanceof Customer); + $this->assertTrue($customers['user2'] instanceof Customer); + $this->assertTrue($customers['user3'] instanceof Customer); + + // indexBy callable + $customers = Customer::find()->indexBy(function ($customer) { + return $customer->id . '-' . $customer->name; +// })->orderBy('id')->all(); + })->all(); + $this->assertEquals(3, count($customers)); + $this->assertTrue($customers['1-user1'] instanceof Customer); + $this->assertTrue($customers['2-user2'] instanceof Customer); + $this->assertTrue($customers['3-user3'] instanceof Customer); + } + + public function testFindCount() + { + $this->assertEquals(3, Customer::find()->count()); + $this->assertEquals(1, Customer::find()->limit(1)->count()); + $this->assertEquals(2, Customer::find()->limit(2)->count()); + $this->assertEquals(1, Customer::find()->offset(2)->limit(2)->count()); + } + + public function testFindLimit() + { + // all() + $customers = Customer::find()->all(); + $this->assertEquals(3, count($customers)); + + $customers = Customer::find()->limit(1)->all(); + $this->assertEquals(1, count($customers)); + $this->assertEquals('user1', $customers[0]->name); + + $customers = Customer::find()->limit(1)->offset(1)->all(); + $this->assertEquals(1, count($customers)); + $this->assertEquals('user2', $customers[0]->name); + + $customers = Customer::find()->limit(1)->offset(2)->all(); + $this->assertEquals(1, count($customers)); + $this->assertEquals('user3', $customers[0]->name); + + $customers = Customer::find()->limit(2)->offset(1)->all(); + $this->assertEquals(2, count($customers)); + $this->assertEquals('user2', $customers[0]->name); + $this->assertEquals('user3', $customers[1]->name); + + $customers = Customer::find()->limit(2)->offset(3)->all(); + $this->assertEquals(0, count($customers)); + + // one() + $customer = Customer::find()->one(); + $this->assertEquals('user1', $customer->name); + + $customer = Customer::find()->offset(0)->one(); + $this->assertEquals('user1', $customer->name); + + $customer = Customer::find()->offset(1)->one(); + $this->assertEquals('user2', $customer->name); + + $customer = Customer::find()->offset(2)->one(); + $this->assertEquals('user3', $customer->name); + + $customer = Customer::find()->offset(3)->one(); + $this->assertNull($customer); + + } + + public function testFindComplexCondition() + { + $this->assertEquals(2, Customer::find()->where(array('OR', array('id' => 1), array('id' => 2)))->count()); + $this->assertEquals(2, count(Customer::find()->where(array('OR', array('id' => 1), array('id' => 2)))->all())); + + $this->assertEquals(2, Customer::find()->where(array('id' => array(1,2)))->count()); + $this->assertEquals(2, count(Customer::find()->where(array('id' => array(1,2)))->all())); + + $this->assertEquals(1, Customer::find()->where(array('AND', array('id' => array(2,3)), array('BETWEEN', 'status', 2, 4)))->count()); + $this->assertEquals(1, count(Customer::find()->where(array('AND', array('id' => array(2,3)), array('BETWEEN', 'status', 2, 4)))->all())); + } + + public function testSum() + { + $this->assertEquals(6, OrderItem::find()->count()); + $this->assertEquals(7, OrderItem::find()->sum('quantity')); + } + + public function testFindColumn() + { + $this->assertEquals(array('user1', 'user2', 'user3'), Customer::find()->column('name')); +// TODO $this->assertEquals(array('user3', 'user2', 'user1'), Customer::find()->orderBy(array('name' => Query::SORT_DESC))->column('name')); + } + + public function testExists() + { + $this->assertTrue(Customer::find()->where(array('id' => 2))->exists()); + $this->assertFalse(Customer::find()->where(array('id' => 5))->exists()); + } + + public function testFindLazy() + { + /** @var $customer Customer */ + $customer = Customer::find(2); + $orders = $customer->orders; + $this->assertEquals(2, count($orders)); + + $orders = $customer->getOrders()->where(array('id' => 3))->all(); + $this->assertEquals(1, count($orders)); + $this->assertEquals(3, $orders[0]->id); + } + + public function testFindEager() + { + $customers = Customer::find()->with('orders')->all(); + $this->assertEquals(3, count($customers)); + $this->assertEquals(1, count($customers[0]->orders)); + $this->assertEquals(2, count($customers[1]->orders)); + } + + public function testFindLazyVia() + { + /** @var $order Order */ + $order = Order::find(1); + $this->assertEquals(1, $order->id); + $this->assertEquals(2, count($order->items)); + $this->assertEquals(1, $order->items[0]->id); + $this->assertEquals(2, $order->items[1]->id); + + $order = Order::find(1); + $order->id = 100; + $this->assertEquals(array(), $order->items); + } + + public function testFindEagerViaRelation() + { + $orders = Order::find()->with('items')->all(); + $this->assertEquals(3, count($orders)); + $order = $orders[0]; + $this->assertEquals(1, $order->id); + $this->assertEquals(2, count($order->items)); + $this->assertEquals(1, $order->items[0]->id); + $this->assertEquals(2, $order->items[1]->id); + } + + public function testFindNestedRelation() + { + $customers = Customer::find()->with('orders', 'orders.items')->all(); + $this->assertEquals(3, count($customers)); + $this->assertEquals(1, count($customers[0]->orders)); + $this->assertEquals(2, count($customers[1]->orders)); + $this->assertEquals(0, count($customers[2]->orders)); + $this->assertEquals(2, count($customers[0]->orders[0]->items)); + $this->assertEquals(3, count($customers[1]->orders[0]->items)); + $this->assertEquals(1, count($customers[1]->orders[1]->items)); + } + + public function testLink() + { + $customer = Customer::find(2); + $this->assertEquals(2, count($customer->orders)); + + // has many + $order = new Order; + $order->total = 100; + $this->assertTrue($order->isNewRecord); + $customer->link('orders', $order); + $this->assertEquals(3, count($customer->orders)); + $this->assertFalse($order->isNewRecord); + $this->assertEquals(3, count($customer->getOrders()->all())); + $this->assertEquals(2, $order->customer_id); + + // belongs to + $order = new Order; + $order->total = 100; + $this->assertTrue($order->isNewRecord); + $customer = Customer::find(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 = Order::find(1); + $this->assertEquals(2, count($order->items)); + $this->assertEquals(2, count($order->orderItems)); + $orderItem = OrderItem::find(array('order_id' => 1, 'item_id' => 3)); + $this->assertNull($orderItem); + $item = Item::find(3); + $order->link('items', $item, array('quantity' => 10, 'subtotal' => 100)); + $this->assertEquals(3, count($order->items)); + $this->assertEquals(3, count($order->orderItems)); + $orderItem = OrderItem::find(array('order_id' => 1, 'item_id' => 3)); + $this->assertTrue($orderItem instanceof OrderItem); + $this->assertEquals(10, $orderItem->quantity); + $this->assertEquals(100, $orderItem->subtotal); + } + + public function testUnlink() + { + // has many + $customer = Customer::find(2); + $this->assertEquals(2, count($customer->orders)); + $customer->unlink('orders', $customer->orders[1], true); + $this->assertEquals(1, count($customer->orders)); + $this->assertNull(Order::find(3)); + + // via model + $order = Order::find(2); + $this->assertEquals(3, count($order->items)); + $this->assertEquals(3, count($order->orderItems)); + $order->unlink('items', $order->items[2], true); + $this->assertEquals(2, count($order->items)); + $this->assertEquals(2, count($order->orderItems)); + } + + public function testInsertNoPk() + { + $customer = new Customer; + $customer->email = 'user4@example.com'; + $customer->name = 'user4'; + $customer->address = 'address4'; + + $this->assertNull($customer->id); + $this->assertTrue($customer->isNewRecord); + + $customer->save(); + + $this->assertNotNull($customer->id); + $this->assertFalse($customer->isNewRecord); + } + + public function testInsertPk() + { + $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->assertFalse($customer->isNewRecord); + } + + public function testUpdate() + { + // save + $customer = Customer::find(2); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $this->assertFalse($customer->isNewRecord); + $customer->name = 'user2x'; + $customer->save(); + $this->assertEquals('user2x', $customer->name); + $this->assertFalse($customer->isNewRecord); + $customer2 = Customer::find(2); + $this->assertEquals('user2x', $customer2->name); + + // updateAll + $customer = Customer::find(3); + $this->assertEquals('user3', $customer->name); + $ret = Customer::updateAll(array( + 'name' => 'temp', + ), array('id' => 3)); + $this->assertEquals(1, $ret); + $customer = Customer::find(3); + $this->assertEquals('temp', $customer->name); + } + + public function testUpdateCounters() + { + // updateCounters + $pk = array('order_id' => 2, 'item_id' => 4); + $orderItem = OrderItem::find($pk); + $this->assertEquals(1, $orderItem->quantity); + $ret = $orderItem->updateCounters(array('quantity' => -1)); + $this->assertTrue($ret); + $this->assertEquals(0, $orderItem->quantity); + $orderItem = OrderItem::find($pk); + $this->assertEquals(0, $orderItem->quantity); + + // updateAllCounters + $pk = array('order_id' => 1, 'item_id' => 2); + $orderItem = OrderItem::find($pk); + $this->assertEquals(2, $orderItem->quantity); + $ret = OrderItem::updateAllCounters(array( + 'quantity' => 3, + 'subtotal' => -10, + ), $pk); + $this->assertEquals(1, $ret); + $orderItem = OrderItem::find($pk); + $this->assertEquals(5, $orderItem->quantity); + $this->assertEquals(30, $orderItem->subtotal); + } + + public function testUpdatePk() + { + // updateCounters + $pk = array('order_id' => 2, 'item_id' => 4); + $orderItem = OrderItem::find($pk); + $this->assertEquals(2, $orderItem->order_id); + $this->assertEquals(4, $orderItem->item_id); + + $orderItem->order_id = 2; + $orderItem->item_id = 10; + $orderItem->save(); + + $this->assertNull(OrderItem::find($pk)); + $this->assertNotNull(OrderItem::find(array('order_id' => 2, 'item_id' => 10))); + } + + public function testDelete() + { + // delete + $customer = Customer::find(2); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $customer->delete(); + $customer = Customer::find(2); + $this->assertNull($customer); + + // deleteAll + $customers = Customer::find()->all(); + $this->assertEquals(2, count($customers)); + $ret = Customer::deleteAll(); + $this->assertEquals(2, $ret); + $customers = Customer::find()->all(); + $this->assertEquals(0, count($customers)); + } +} \ No newline at end of file diff --git a/tests/unit/framework/elasticsearch/ElasticSearchConnectionTest.php b/tests/unit/framework/elasticsearch/ElasticSearchConnectionTest.php new file mode 100644 index 0000000..eb70a37 --- /dev/null +++ b/tests/unit/framework/elasticsearch/ElasticSearchConnectionTest.php @@ -0,0 +1,66 @@ +open(); + } + + /** + * test connection to redis and selection of db + */ + public function testConnect() + { + $db = new Connection(); + $db->dsn = 'redis://localhost:6379'; + $db->open(); + $this->assertTrue($db->ping()); + $db->set('YIITESTKEY', 'YIITESTVALUE'); + $db->close(); + + $db = new Connection(); + $db->dsn = 'redis://localhost:6379/0'; + $db->open(); + $this->assertEquals('YIITESTVALUE', $db->get('YIITESTKEY')); + $db->close(); + + $db = new Connection(); + $db->dsn = 'redis://localhost:6379/1'; + $db->open(); + $this->assertNull($db->get('YIITESTKEY')); + $db->close(); + } + + public function keyValueData() + { + return array( + array(123), + array(-123), + array(0), + array('test'), + array("test\r\ntest"), + array(''), + ); + } + + /** + * @dataProvider keyValueData + */ + public function testStoreGet($data) + { + $db = $this->getConnection(true); + + $db->set('hi', $data); + $this->assertEquals($data, $db->get('hi')); + } +} \ No newline at end of file diff --git a/tests/unit/framework/elasticsearch/ElasticSearchTestCase.php b/tests/unit/framework/elasticsearch/ElasticSearchTestCase.php new file mode 100644 index 0000000..a19f851 --- /dev/null +++ b/tests/unit/framework/elasticsearch/ElasticSearchTestCase.php @@ -0,0 +1,48 @@ +mockApplication(); + + $databases = $this->getParam('databases'); + $params = isset($databases['elasticsearch']) ? $databases['elasticsearch'] : null; + if ($params === null || !isset($params['dsn'])) { + $this->markTestSkipped('No elasticsearch server connection configured.'); + } + $dsn = explode('/', $params['dsn']); + $host = $dsn[2]; + if (strpos($host, ':')===false) { + $host .= ':9200'; + } + if(!@stream_socket_client($host, $errorNumber, $errorDescription, 0.5)) { + $this->markTestSkipped('No elasticsearch server running at ' . $params['dsn'] . ' : ' . $errorNumber . ' - ' . $errorDescription); + } + + parent::setUp(); + } + + /** + * @param bool $reset whether to clean up the test database + * @return Connection + */ + public function getConnection($reset = true) + { + $databases = $this->getParam('databases'); + $params = isset($databases['elasticsearch']) ? $databases['elasticsearch'] : array(); + $db = new Connection; + if ($reset) { + $db->open(); + } + return $db; + } +} \ No newline at end of file