diff --git a/framework/yii/base/Model.php b/framework/yii/base/Model.php index b366a9f..caa6b61 100644 --- a/framework/yii/base/Model.php +++ b/framework/yii/base/Model.php @@ -229,14 +229,13 @@ class Model extends Component implements IteratorAggregate, ArrayAccess * You may override this method to change the default behavior. * @return array list of attribute names. */ - public function attributes() + public static function attributes() { - $class = new ReflectionClass($this); + $class = new ReflectionClass(get_called_class()); $names = []; foreach ($class->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { - $name = $property->getName(); if (!$property->isStatic()) { - $names[] = $name; + $names[] = $property->getName(); } } return $names; diff --git a/framework/yii/db/ActiveRecord.php b/framework/yii/db/ActiveRecord.php index e13bf90..3de4b2b 100644 --- a/framework/yii/db/ActiveRecord.php +++ b/framework/yii/db/ActiveRecord.php @@ -568,9 +568,9 @@ class ActiveRecord extends Model * The default implementation will return all column names of the table associated with this AR class. * @return array list of attribute names. */ - public function attributes() + public static function attributes() { - return array_keys($this->getTableSchema()->columns); + return array_keys(static::getTableSchema()->columns); } /** @@ -580,7 +580,7 @@ class ActiveRecord extends Model */ public function hasAttribute($name) { - return isset($this->_attributes[$name]) || isset($this->getTableSchema()->columns[$name]); + return isset($this->_attributes[$name]) || in_array($name, $this->attributes()); } /** @@ -1244,7 +1244,7 @@ class ActiveRecord extends Model public static function create($row) { $record = static::instantiate($row); - $columns = static::getTableSchema()->columns; + $columns = array_flip(static::attributes()); foreach ($row as $name => $value) { if (isset($columns[$name])) { $record->_attributes[$name] = $value; @@ -1299,7 +1299,7 @@ class ActiveRecord extends Model if ($relation instanceof ActiveRelationInterface) { return $relation; } else { - return null; + throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".'); } } catch (UnknownMethodException $e) { throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".', 0, $e); @@ -1336,9 +1336,7 @@ class ActiveRecord extends Model if (is_array($relation->via)) { /** @var ActiveRelation $viaRelation */ list($viaName, $viaRelation) = $relation->via; - /** @var ActiveRecord $viaClass */ $viaClass = $viaRelation->modelClass; - $viaTable = $viaClass::tableName(); // unset $viaName so that it can be reloaded to reflect the change unset($this->_related[$viaName]); } else { @@ -1355,8 +1353,19 @@ class ActiveRecord extends Model foreach ($extraColumns as $k => $v) { $columns[$k] = $v; } - static::getDb()->createCommand() - ->insert($viaTable, $columns)->execute(); + if (is_array($relation->via)) { + /** @var $viaClass ActiveRecord */ + /** @var $record ActiveRecord */ + $record = new $viaClass(); + foreach($columns as $column => $value) { + $record->$column = $value; + } + $record->insert(false); + } else { + /** @var $viaTable string */ + static::getDb()->createCommand() + ->insert($viaTable, $columns)->execute(); + } } else { $p1 = $model->isPrimaryKey(array_keys($relation->link)); $p2 = $this->isPrimaryKey(array_values($relation->link)); @@ -1411,9 +1420,7 @@ class ActiveRecord extends Model if (is_array($relation->via)) { /** @var ActiveRelation $viaRelation */ list($viaName, $viaRelation) = $relation->via; - /** @var ActiveRecord $viaClass */ $viaClass = $viaRelation->modelClass; - $viaTable = $viaClass::tableName(); unset($this->_related[$viaName]); } else { $viaRelation = $relation->via; @@ -1426,15 +1433,29 @@ class ActiveRecord extends Model foreach ($relation->link as $a => $b) { $columns[$b] = $model->$a; } - $command = static::getDb()->createCommand(); - if ($delete) { - $command->delete($viaTable, $columns)->execute(); + if (is_array($relation->via)) { + /** @var $viaClass ActiveRecord */ + if ($delete) { + $viaClass::deleteAll($columns); + } else { + $nulls = []; + foreach (array_keys($columns) as $a) { + $nulls[$a] = null; + } + $viaClass::updateAll($nulls, $columns); + } } else { - $nulls = []; - foreach (array_keys($columns) as $a) { - $nulls[$a] = null; + /** @var $viaTable string */ + $command = static::getDb()->createCommand(); + if ($delete) { + $command->delete($viaTable, $columns)->execute(); + } else { + $nulls = []; + foreach (array_keys($columns) as $a) { + $nulls[$a] = null; + } + $command->update($viaTable, $nulls, $columns)->execute(); } - $command->update($viaTable, $nulls, $columns)->execute(); } } else { $p1 = $model->isPrimaryKey(array_keys($relation->link)); diff --git a/framework/yii/redis/ActiveQuery.php b/framework/yii/redis/ActiveQuery.php new file mode 100644 index 0000000..eabd843 --- /dev/null +++ b/framework/yii/redis/ActiveQuery.php @@ -0,0 +1,382 @@ +with('orders')->asArray()->all(); + * ~~~ + * + * @author Carsten Brandt + * @since 2.0 + */ +class ActiveQuery extends \yii\base\Component implements ActiveQueryInterface +{ + use QueryTrait; + use ActiveQueryTrait; + + /** + * Executes the query and returns all results as an array. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return ActiveRecord[] the query results. If the query results in nothing, an empty array will be returned. + */ + public function all($db = null) + { + // TODO add support for orderBy + $data = $this->executeScript($db, 'All'); + $rows = []; + foreach($data as $dataRow) { + $row = []; + $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->findWith($this->with, $models); + } + return $models; + } else { + return []; + } + } + + /** + * Executes the query and returns a single row of result. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @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($db = null) + { + // TODO add support for orderBy + $data = $this->executeScript($db, 'One'); + if (empty($data)) { + return null; + } + $row = []; + $c = count($data); + for($i = 0; $i < $c; ) { + $row[$data[$i++]] = $data[$i++]; + } + if ($this->asArray) { + $model = $row; + } else { + /** @var ActiveRecord $class */ + $class = $this->modelClass; + $model = $class::create($row); + } + if (!empty($this->with)) { + $models = [$model]; + $this->findWith($this->with, $models); + $model = $models[0]; + } + return $model; + } + + /** + * Returns the number of records. + * @param string $q the COUNT expression. This parameter is ignored by this implementation. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer number of records + */ + public function count($q = '*', $db = null) + { + if ($this->offset === null && $this->limit === null && $this->where === null) { + /** @var ActiveRecord $modelClass */ + $modelClass = $this->modelClass; + if ($db === null) { + $db = $modelClass::getDb(); + } + return $db->executeCommand('LLEN', [$modelClass::tableName()]); + } else { + return $this->executeScript($db, 'Count'); + } + } + + /** + * Returns a value indicating whether the query result contains any row of data. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return boolean whether the query result contains any row of data. + */ + public function exists($db = null) + { + return $this->one($db) !== null; + } + + /** + * Executes the query and returns the first column of the result. + * @param string $column name of the column to select + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return array the first column of the query result. An empty array is returned if the query results in nothing. + */ + public function column($column, $db = null) + { + // TODO add support for orderBy + return $this->executeScript($db, 'Column', $column); + } + + /** + * Returns the number of records. + * @param string $column the column to sum up + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer number of records + */ + public function sum($column, $db = null) + { + return $this->executeScript($db, '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. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer the average of the specified column values. + */ + public function average($column, $db = null) + { + return $this->executeScript($db, '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. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer the minimum of the specified column values. + */ + public function min($column, $db = null) + { + return $this->executeScript($db, '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. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer the maximum of the specified column values. + */ + public function max($column, $db = null) + { + return $this->executeScript($db, '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 + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @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, $db = null) + { + $record = $this->one($db); + if ($record === null) { + return false; + } else { + return $record->$column; + } + } + + + /** + * Executes a script created by [[LuaScriptBuilder]] + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @param string $type the type of the script to generate + * @param string $columnName + * @return array|bool|null|string + */ + protected function executeScript($db, $type, $columnName = null) + { + if (!empty($this->orderBy)) { + throw new NotSupportedException('orderBy is currently not supported by redis ActiveRecord.'); + } + + /** @var ActiveRecord $modelClass */ + $modelClass = $this->modelClass; + + if ($db === null) { + $db = $modelClass::getDb(); + } + + // find by primary key if possible. This is much faster than scanning all records + if (is_array($this->where) && !isset($this->where[0]) && $modelClass::isPrimaryKey(array_keys($this->where))) { + return $this->findByPk($db, $type, $columnName); + } + + $method = 'build' . $type; + $script = $db->getLuaScriptBuilder()->$method($this, $columnName); + return $db->executeCommand('EVAL', [$script, 0]); + } + + /** + * Fetch by pk if possible as this is much faster + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @param string $type the type of the script to generate + * @param string $columnName + * @return array|bool|null|string + * @throws \yii\base\InvalidParamException + * @throws \yii\base\NotSupportedException + */ + private function findByPk($db, $type, $columnName = null) + { + if (count($this->where) == 1) { + $pks = (array) reset($this->where); + } else { + foreach($this->where as $column => $values) { + if (is_array($values)) { + // TODO support composite IN for composite PK + throw new NotSupportedException('Find by composite PK is not supported by redis ActiveRecord.'); + } + } + $pks = [$this->where]; + } + + /** @var ActiveRecord $modelClass */ + $modelClass = $this->modelClass; + + $start = $this->offset === null ? 0 : $this->offset; + $i = 0; + $data = []; + foreach($pks as $pk) { + if (++$i > $start && ($this->limit === null || $i <= $start + $this->limit)) { + $key = $modelClass::tableName() . ':a:' . $modelClass::buildKey($pk); + $result = $db->executeCommand('HGETALL', [$key]); + if (!empty($result)) { + $data[] = $result; + if ($type === 'One' && $this->orderBy === null) { + break; + } + } + } + } + // TODO support orderBy + + switch($type) { + case 'All': + return $data; + case 'One': + return reset($data); + case 'Count': + return count($data); + case 'Column': + $column = []; + foreach($data as $dataRow) { + $row = []; + $c = count($dataRow); + for($i = 0; $i < $c; ) { + $row[$dataRow[$i++]] = $dataRow[$i++]; + } + $column[] = $row[$columnName]; + } + return $column; + 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; + } + throw new InvalidParamException('Unknown fetch type: ' . $type); + } +} diff --git a/framework/yii/redis/ActiveRecord.php b/framework/yii/redis/ActiveRecord.php new file mode 100644 index 0000000..f9fa3f3 --- /dev/null +++ b/framework/yii/redis/ActiveRecord.php @@ -0,0 +1,322 @@ + + * @since 2.0 + */ +class ActiveRecord extends \yii\db\ActiveRecord +{ + /** + * Returns the database connection used by this AR class. + * By default, the "redis" 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->getComponent('redis'); + } + + /** + * @inheritDoc + */ + public static function createQuery() + { + return new ActiveQuery(['modelClass' => get_called_class()]); + } + + /** + * @inheritDoc + */ + protected function createActiveRelation($config = []) + { + return new ActiveRelation($config); + } + + /** + * Returns the primary key name(s) for this AR class. + * This method should be overridden by child classes to define the primary key. + * + * Note that an array should be returned even when it is a single primary key. + * + * @return string[] the primary keys of this record. + */ + public static function primaryKey() + { + return ['id']; + } + + /** + * Returns the list of all attribute names of the model. + * This method must be overridden by child classes to define available attributes. + * @return array list of attribute names. + */ + public static function attributes() + { + throw new InvalidConfigException('The attributes() method of redis ActiveRecord has to be implemented by child classes.'); + } + + /** + * @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); + $pk = []; +// if ($values === []) { + foreach ($this->primaryKey() as $key) { + $pk[$key] = $values[$key] = $this->getAttribute($key); + if ($pk[$key] === null) { + $pk[$key] = $values[$key] = $db->executeCommand('INCR', [static::tableName() . ':s:' . $key]); + $this->setAttribute($key, $values[$key]); + } + } +// } + // save pk in a findall pool + $db->executeCommand('RPUSH', [static::tableName(), static::buildKey($pk)]); + + $key = static::tableName() . ':a:' . static::buildKey($pk); + // save attributes + $args = [$key]; + foreach($values as $attribute => $value) { + $args[] = $attribute; + $args[] = $value; + } + $db->executeCommand('HMSET', $args); + + $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(['status' => 1], ['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 = []) + { + 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 = [$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', [static::tableName(), 'AFTER', $pk, $newPk]); + $db->executeCommand('LREM', [static::tableName(), 0, $pk]); + $db->executeCommand('RENAME', [$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(['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 = []) + { + 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', [$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 = []) + { + $db = static::getDb(); + $attributeKeys = []; + $pks = static::fetchPks($condition); + $db->executeCommand('MULTI'); + foreach($pks as $pk) { + $pk = static::buildKey($pk); + $db->executeCommand('LREM', [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); + } + + private static function fetchPks($condition) + { + $query = static::createQuery(); + $query->where($condition); + $records = $query->asArray()->all(); // TODO limit fetched columns to pk + $primaryKey = static::primaryKey(); + + $pks = []; + foreach($records as $record) { + $pk = []; + foreach($primaryKey as $key) { + $pk[$key] = $record[$key]; + } + $pks[] = $pk; + } + return $pks; + } + + /** + * Builds a normalized key from a given primary key value. + * + * @param mixed $key the key to be normalized + * @return string the generated key + */ + public static function buildKey($key) + { + if (is_numeric($key)) { + return $key; + } elseif (is_string($key)) { + return ctype_alnum($key) && StringHelper::strlen($key) <= 32 ? $key : md5($key); + } elseif (is_array($key)) { + if (count($key) == 1) { + return self::buildKey(reset($key)); + } + ksort($key); // ensure order is always the same + $isNumeric = true; + foreach($key as $value) { + if (!is_numeric($value)) { + $isNumeric = false; + } + } + if ($isNumeric) { + return implode('-', $key); + } + } + return md5(json_encode($key)); + } + + /** + * @inheritdoc + */ + public static function getTableSchema() + { + throw new NotSupportedException('getTableSchema() is not supported by redis ActiveRecord'); + } + + /** + * @inheritdoc + */ + public static function findBySql($sql, $params = []) + { + throw new NotSupportedException('findBySql() is not supported by redis ActiveRecord'); + } + + /** + * 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 redis. + * @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/redis/ActiveRelation.php b/framework/yii/redis/ActiveRelation.php new file mode 100644 index 0000000..b2f5cea --- /dev/null +++ b/framework/yii/redis/ActiveRelation.php @@ -0,0 +1,67 @@ + + * @since 2.0 + */ +class ActiveRelation extends ActiveQuery implements ActiveRelationInterface +{ + use ActiveRelationTrait; + + /** + * Executes a script created by [[LuaScriptBuilder]] + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @param string $type the type of the script to generate + * @param null $column + * @return array|bool|null|string + */ + protected function executeScript($db, $type, $column=null) + { + if ($this->primaryModel !== null) { + // lazy loading + if ($this->via instanceof self) { + // via pivot table + $viaModels = $this->via->findPivotRows([$this->primaryModel]); + $this->filterByModels($viaModels); + } elseif (is_array($this->via)) { + // via relation + /** @var ActiveRelation $viaQuery */ + list($viaName, $viaQuery) = $this->via; + if ($viaQuery->multiple) { + $viaModels = $viaQuery->all(); + $this->primaryModel->populateRelation($viaName, $viaModels); + } else { + $model = $viaQuery->one(); + $this->primaryModel->populateRelation($viaName, $model); + $viaModels = $model === null ? [] : [$model]; + } + $this->filterByModels($viaModels); + } else { + $this->filterByModels([$this->primaryModel]); + } + } + return parent::executeScript($db, $type, $column); + } +} diff --git a/framework/yii/redis/Connection.php b/framework/yii/redis/Connection.php index 140f985..371b8bc 100644 --- a/framework/yii/redis/Connection.php +++ b/framework/yii/redis/Connection.php @@ -1,9 +1,7 @@ * @since 2.0 @@ -203,10 +200,6 @@ class Connection extends Component 'ZUNIONSTORE', // destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX] Add multiple sorted sets and store the resulting sorted set in a new key ]; /** - * @var Transaction the currently active transaction - */ - private $_transaction; - /** * @var resource redis socket connection */ private $_socket; @@ -283,7 +276,6 @@ class Connection extends Component $this->executeCommand('QUIT'); stream_socket_shutdown($this->_socket, STREAM_SHUT_RDWR); $this->_socket = null; - $this->_transaction = null; } } @@ -298,27 +290,6 @@ class Connection extends Component } /** - * Returns the currently active transaction. - * @return Transaction the currently active transaction. Null if no active transaction. - */ - public function getTransaction() - { - return $this->_transaction && $this->_transaction->isActive ? $this->_transaction : null; - } - - /** - * Starts a transaction. - * @return Transaction the transaction initiated - */ - public function beginTransaction() - { - $this->open(); - $this->_transaction = new Transaction(['db' => $this]); - $this->_transaction->begin(); - return $this->_transaction; - } - - /** * Returns the name of the DB driver for the current [[dsn]]. * @return string name of the DB driver */ @@ -332,6 +303,14 @@ class Connection extends Component } /** + * @return LuaScriptBuilder + */ + public function getLuaScriptBuilder() + { + return new LuaScriptBuilder(); + } + + /** * * @param string $name * @param array $params diff --git a/framework/yii/redis/LuaScriptBuilder.php b/framework/yii/redis/LuaScriptBuilder.php new file mode 100644 index 0000000..81dff3f --- /dev/null +++ b/framework/yii/redis/LuaScriptBuilder.php @@ -0,0 +1,365 @@ + + * @since 2.0 + */ +class LuaScriptBuilder extends \yii\base\Object +{ + /** + * Builds a Lua script for finding a list of records + * @param ActiveQuery $query the query used to build the script + * @return string + */ + public function buildAll($query) + { + // TODO add support for orderBy + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+1 pks[n]=redis.call('HGETALL',$key .. pk)", 'pks'); + } + + /** + * Builds a Lua script for finding one record + * @param ActiveQuery $query the query used to build the script + * @return string + */ + public function buildOne($query) + { + // TODO add support for orderBy + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "do return redis.call('HGETALL',$key .. pk) end", 'pks'); + } + + /** + * Builds a Lua script for finding a column + * @param ActiveQuery $query the query used to build the script + * @param string $column name of the column + * @return string + */ + public function buildColumn($query, $column) + { + // TODO add support for orderBy and indexBy + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+1 pks[n]=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'pks'); + } + + /** + * Builds a Lua script for getting count of records + * @param ActiveQuery $query the query used to build the script + * @return string + */ + public function buildCount($query) + { + return $this->build($query, 'n=n+1', 'n'); + } + + /** + * Builds a Lua script for finding the sum of a column + * @param ActiveQuery $query the query used to build the script + * @param string $column name of the column + * @return string + */ + public function buildSum($query, $column) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'n'); + } + + /** + * Builds a Lua script for finding the average of a column + * @param ActiveQuery $query the query used to build the script + * @param string $column name of the column + * @return string + */ + public function buildAverage($query, $column) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+1 if v==nil then v=0 end v=v+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'v/n'); + } + + /** + * Builds a Lua script for finding the min value of a column + * @param ActiveQuery $query the query used to build the script + * @param string $column name of the column + * @return string + */ + public function buildMin($query, $column) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or nmodelClass; + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or n>v then v=n end", 'v'); + } + + /** + * @param ActiveQuery $query the query used to build the script + * @param string $buildResult the lua script for building the result + * @param string $return the lua variable that should be returned + * @return string + */ + private function build($query, $buildResult, $return) + { + if (!empty($query->orderBy)) { + throw new NotSupportedException('orderBy is currently not supported by redis ActiveRecord.'); + } + + $columns = []; + if ($query->where !== null) { + $condition = $this->buildCondition($query->where, $columns); + } else { + $condition = 'true'; + } + + $start = $query->offset === null ? 0 : $query->offset; + $limitCondition = 'i>' . $start . ($query->limit === null ? '' : ' and i<=' . ($start + $query->limit)); + + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + $key = $this->quoteValue($modelClass::tableName()); + $loadColumnValues = ''; + foreach($columns as $column => $alias) { + $loadColumnValues .= "local $alias=redis.call('HGET',$key .. ':a:' .. pk, '$column')\n"; + } + + return << 'buildAndCondition', + 'or' => 'buildAndCondition', + 'between' => 'buildBetweenCondition', + 'not between' => 'buildBetweenCondition', + 'in' => 'buildInCondition', + 'not in' => 'buildInCondition', + 'like' => 'buildLikeCondition', + 'not like' => 'buildLikeCondition', + 'or like' => 'buildLikeCondition', + 'or not like' => 'buildLikeCondition', + ]; + + if (!is_array($condition)) { + throw new NotSupportedException('Where condition must be an array in redis ActiveRecord.'); + } + if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ... + $operator = strtolower($condition[0]); + if (isset($builders[$operator])) { + $method = $builders[$operator]; + array_shift($condition); + return $this->$method($operator, $condition, $columns); + } else { + throw new Exception('Found unknown operator in query: ' . $operator); + } + } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... + return $this->buildHashCondition($condition, $columns); + } + } + + private function buildHashCondition($condition, &$columns) + { + $parts = []; + foreach ($condition as $column => $value) { + if (is_array($value)) { // IN condition + $parts[] = $this->buildInCondition('in', [$column, $value], $columns); + } else { + $column = $this->addColumn($column, $columns); + if ($value === null) { + $parts[] = "$column==nil"; + } elseif ($value instanceof Expression) { + $parts[] = "$column==" . $value->expression; + } else { + $value = $this->quoteValue($value); + $parts[] = "$column==$value"; + } + } + } + return count($parts) === 1 ? $parts[0] : '(' . implode(') and (', $parts) . ')'; + } + + private function buildAndCondition($operator, $operands, &$columns) + { + $parts = []; + foreach ($operands as $operand) { + if (is_array($operand)) { + $operand = $this->buildCondition($operand, $columns); + } + if ($operand !== '') { + $parts[] = $operand; + } + } + if (!empty($parts)) { + return '(' . implode(") $operator (", $parts) . ')'; + } else { + return ''; + } + } + + private function buildBetweenCondition($operator, $operands, &$columns) + { + if (!isset($operands[0], $operands[1], $operands[2])) { + throw new Exception("Operator '$operator' requires three operands."); + } + + list($column, $value1, $value2) = $operands; + + $value1 = $this->quoteValue($value1); + $value2 = $this->quoteValue($value2); + $column = $this->addColumn($column, $columns); + return "$column >= $value1 and $column <= $value2"; + } + + private function buildInCondition($operator, $operands, &$columns) + { + if (!isset($operands[0], $operands[1])) { + throw new Exception("Operator '$operator' requires two operands."); + } + + list($column, $values) = $operands; + + $values = (array)$values; + + if (empty($values) || $column === []) { + return $operator === 'in' ? 'false' : 'true'; + } + + if (count($column) > 1) { + return $this->buildCompositeInCondition($operator, $column, $values, $columns); + } elseif (is_array($column)) { + $column = reset($column); + } + $columnAlias = $this->addColumn($column, $columns); + $parts = []; + foreach ($values as $i => $value) { + if (is_array($value)) { + $value = isset($value[$column]) ? $value[$column] : null; + } + if ($value === null) { + $parts[] = "$columnAlias==nil"; + } elseif ($value instanceof Expression) { + $parts[] = "$columnAlias==" . $value->expression; + } else { + $value = $this->quoteValue($value); + $parts[] = "$columnAlias==$value"; + } + } + $operator = $operator === 'in' ? '' : 'not '; + return "$operator(" . implode(' or ', $parts) . ')'; + } + + protected function buildCompositeInCondition($operator, $inColumns, $values, &$columns) + { + $vss = []; + foreach ($values as $value) { + $vs = []; + foreach ($inColumns as $column) { + $column = $this->addColumn($column, $columns); + if (isset($value[$column])) { + $vs[] = "$column==" . $this->quoteValue($value[$column]); + } else { + $vs[] = "$column==nil"; + } + } + $vss[] = '(' . implode(' and ', $vs) . ')'; + } + $operator = $operator === 'in' ? '' : 'not '; + return "$operator(" . implode(' or ', $vss) . ')'; + } + + private function buildLikeCondition($operator, $operands, &$columns) + { + throw new NotSupportedException('LIKE conditions are not suppoerted by redis ActiveRecord.'); + } +} diff --git a/framework/yii/redis/Transaction.php b/framework/yii/redis/Transaction.php deleted file mode 100644 index 94cff7a..0000000 --- a/framework/yii/redis/Transaction.php +++ /dev/null @@ -1,93 +0,0 @@ - - * @since 2.0 - */ -class Transaction extends \yii\base\Object -{ - /** - * @var Connection the database connection that this transaction is associated with. - */ - public $db; - /** - * @var boolean whether this transaction is active. Only an active transaction - * can [[commit()]] or [[rollBack()]]. This property is set true when the transaction is started. - */ - private $_active = false; - - /** - * Returns a value indicating whether this transaction is active. - * @return boolean whether this transaction is active. Only an active transaction - * can [[commit()]] or [[rollBack()]]. - */ - public function getIsActive() - { - return $this->_active; - } - - /** - * Begins a transaction. - * @throws InvalidConfigException if [[connection]] is null - */ - public function begin() - { - if (!$this->_active) { - if ($this->db === null) { - throw new InvalidConfigException('Transaction::db must be set.'); - } - \Yii::trace('Starting transaction', __CLASS__); - $this->db->open(); - $this->db->createCommand('MULTI')->execute(); - $this->_active = true; - } - } - - /** - * Commits a transaction. - * @throws Exception if the transaction or the DB connection is not active. - */ - public function commit() - { - if ($this->_active && $this->db && $this->db->isActive) { - \Yii::trace('Committing transaction', __CLASS__); - $this->db->createCommand('EXEC')->execute(); - // TODO handle result of EXEC - $this->_active = false; - } else { - throw new Exception('Failed to commit transaction: transaction was inactive.'); - } - } - - /** - * Rolls back a transaction. - * @throws Exception if the transaction or the DB connection is not active. - */ - public function rollback() - { - if ($this->_active && $this->db && $this->db->isActive) { - \Yii::trace('Rolling back transaction', __CLASS__); - $this->db->pdo->commit(); - $this->_active = false; - } else { - throw new Exception('Failed to roll back transaction: transaction was inactive.'); - } - } -} diff --git a/framework/yii/validators/UniqueValidator.php b/framework/yii/validators/UniqueValidator.php index 7006cc4..dedceb9 100644 --- a/framework/yii/validators/UniqueValidator.php +++ b/framework/yii/validators/UniqueValidator.php @@ -64,13 +64,13 @@ class UniqueValidator extends Validator $className = $this->className === null ? get_class($object) : $this->className; $attributeName = $this->attributeName === null ? $attribute : $this->attributeName; - $table = $className::getTableSchema(); - if (($column = $table->getColumn($attributeName)) === null) { - throw new InvalidConfigException("Table '{$table->name}' does not have a column named '$attributeName'."); + $attributes = $className::attributes(); + if (!in_array($attributeName, $attributes)) { + throw new InvalidConfigException("'$className' does not have an attribute named '$attributeName'."); } $query = $className::find(); - $query->where([$column->name => $value]); + $query->where([$attributeName => $value]); if (!$object instanceof ActiveRecord || $object->getIsNewRecord()) { // if current $object isn't in the database yet then it's OK just to call exists() @@ -82,7 +82,7 @@ class UniqueValidator extends Validator $n = count($objects); if ($n === 1) { - if ($column->isPrimaryKey) { + if (in_array($attributeName, $className::primaryKey())) { // primary key is modified and not unique $exists = $object->getOldPrimaryKey() != $object->getPrimaryKey(); } else { diff --git a/tests/unit/data/ar/redis/ActiveRecord.php b/tests/unit/data/ar/redis/ActiveRecord.php new file mode 100644 index 0000000..9f6d526 --- /dev/null +++ b/tests/unit/data/ar/redis/ActiveRecord.php @@ -0,0 +1,26 @@ + + * @since 2.0 + */ +class ActiveRecord extends \yii\redis\ActiveRecord +{ + public static $db; + + public static function getDb() + { + return self::$db; + } +} \ No newline at end of file diff --git a/tests/unit/data/ar/redis/Customer.php b/tests/unit/data/ar/redis/Customer.php new file mode 100644 index 0000000..b48953f --- /dev/null +++ b/tests/unit/data/ar/redis/Customer.php @@ -0,0 +1,29 @@ +hasMany(Order::className(), ['customer_id' => 'id']); + } + + public static function active($query) + { + $query->andWhere(['status' => 1]); + } +} \ No newline at end of file diff --git a/tests/unit/data/ar/redis/Item.php b/tests/unit/data/ar/redis/Item.php new file mode 100644 index 0000000..1163265 --- /dev/null +++ b/tests/unit/data/ar/redis/Item.php @@ -0,0 +1,11 @@ +hasOne(Customer::className(), ['id' => 'customer_id']); + } + + public function getOrderItems() + { + return $this->hasMany(OrderItem::className(), ['order_id' => 'id']); + } + + public function getItems() + { + return $this->hasMany(Item::className(), ['id' => 'item_id']) + ->via('orderItems', function($q) { + // additional query configuration + }); + } + + public function getBooks() + { + return $this->hasMany(Item::className(), ['id' => 'item_id']) + ->via('orderItems', ['order_id' => 'id']); + //->where(['category_id' => 1]); + } + + public function beforeSave($insert) + { + if (parent::beforeSave($insert)) { + $this->create_time = time(); + return true; + } else { + return false; + } + } +} \ No newline at end of file diff --git a/tests/unit/data/ar/redis/OrderItem.php b/tests/unit/data/ar/redis/OrderItem.php new file mode 100644 index 0000000..38def6b --- /dev/null +++ b/tests/unit/data/ar/redis/OrderItem.php @@ -0,0 +1,28 @@ +hasOne(Order::className(), ['id' => 'order_id']); + } + + public function getItem() + { + return $this->hasOne(Item::className(), ['id' => 'item_id']); + } +} \ No newline at end of file diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index 304c3fc..cb3306f 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -29,5 +29,9 @@ return [ 'password' => 'postgres', 'fixture' => __DIR__ . '/postgres.sql', ], + 'redis' => [ + 'dsn' => 'redis://localhost:6379/0', + 'password' => null, + ], ], ]; diff --git a/tests/unit/framework/db/ActiveRecordTest.php b/tests/unit/framework/db/ActiveRecordTest.php index b2981f2..3de40dd 100644 --- a/tests/unit/framework/db/ActiveRecordTest.php +++ b/tests/unit/framework/db/ActiveRecordTest.php @@ -40,6 +40,12 @@ class ActiveRecordTest extends DatabaseTestCase $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))->select('name')->scalar(); + $this->assertEquals('user2', $customerName); // find by column values $customer = Customer::find(['id' => 2, 'name' => 'user2']); diff --git a/tests/unit/framework/redis/ActiveRecordTest.php b/tests/unit/framework/redis/ActiveRecordTest.php new file mode 100644 index 0000000..31907f7 --- /dev/null +++ b/tests/unit/framework/redis/ActiveRecordTest.php @@ -0,0 +1,466 @@ +getConnection(); + + $customer = new Customer(); + $customer->setAttributes(['email' => 'user1@example.com', 'name' => 'user1', 'address' => 'address1', 'status' => 1], false); + $customer->save(false); + $customer = new Customer(); + $customer->setAttributes(['email' => 'user2@example.com', 'name' => 'user2', 'address' => 'address2', 'status' => 1], false); + $customer->save(false); + $customer = new Customer(); + $customer->setAttributes(['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(['name' => 'Agile Web Application Development with Yii1.1 and PHP5', 'category_id' => 1], false); + $item->save(false); + $item = new Item(); + $item->setAttributes(['name' => 'Yii 1.1 Application Development Cookbook', 'category_id' => 1], false); + $item->save(false); + $item = new Item(); + $item->setAttributes(['name' => 'Ice Age', 'category_id' => 2], false); + $item->save(false); + $item = new Item(); + $item->setAttributes(['name' => 'Toy Story', 'category_id' => 2], false); + $item->save(false); + $item = new Item(); + $item->setAttributes(['name' => 'Cars', 'category_id' => 2], false); + $item->save(false); + + $order = new Order(); + $order->setAttributes(['customer_id' => 1, 'create_time' => 1325282384, 'total' => 110.0], false); + $order->save(false); + $order = new Order(); + $order->setAttributes(['customer_id' => 2, 'create_time' => 1325334482, 'total' => 33.0], false); + $order->save(false); + $order = new Order(); + $order->setAttributes(['customer_id' => 2, 'create_time' => 1325502201, 'total' => 40.0], false); + $order->save(false); + + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 1, 'item_id' => 1, 'quantity' => 1, 'subtotal' => 30.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 1, 'item_id' => 2, 'quantity' => 2, 'subtotal' => 40.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 2, 'item_id' => 4, 'quantity' => 1, 'subtotal' => 10.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 2, 'item_id' => 5, 'quantity' => 1, 'subtotal' => 15.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['order_id' => 2, 'item_id' => 3, 'quantity' => 1, 'subtotal' => 8.0], false); + $orderItem->save(false); + $orderItem = new OrderItem(); + $orderItem->setAttributes(['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); + $customer = Customer::find(['id' => [5, 6, 1]]); + $this->assertEquals(1, count($customer)); + $customer = Customer::find()->where(['id' => [5, 6, 1]])->one(); + $this->assertNotNull($customer); + + // query scalar + $customerName = Customer::find()->where(['id' => 2])->scalar('name'); + $this->assertEquals('user2', $customerName); + + // find by column values + $customer = Customer::find(['id' => 2, 'name' => 'user2']); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals('user2', $customer->name); + $customer = Customer::find(['id' => 2, 'name' => 'user1']); + $this->assertNull($customer); + $customer = Customer::find(['id' => 5]); + $this->assertNull($customer); + + // find by attributes + $customer = Customer::find()->where(['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(['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(['OR', ['id' => 1], ['id' => 2]])->count()); + $this->assertEquals(2, count(Customer::find()->where(['OR', ['id' => 1], ['id' => 2]])->all())); + + $this->assertEquals(2, Customer::find()->where(['id' => [1,2]])->count()); + $this->assertEquals(2, count(Customer::find()->where(['id' => [1,2]])->all())); + + $this->assertEquals(1, Customer::find()->where(['AND', ['id' => [2,3]], ['BETWEEN', 'status', 2, 4]])->count()); + $this->assertEquals(1, count(Customer::find()->where(['AND', ['id' => [2,3]], ['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(['user1', 'user2', 'user3'], Customer::find()->column('name')); +// TODO $this->assertEquals(['user3', 'user2', 'user1'], Customer::find()->orderBy(['name' => SORT_DESC])->column('name')); + } + + public function testExists() + { + $this->assertTrue(Customer::find()->where(['id' => 2])->exists()); + $this->assertFalse(Customer::find()->where(['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(['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([], $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(['order_id' => 1, 'item_id' => 3]); + $this->assertNull($orderItem); + $item = Item::find(3); + $order->link('items', $item, ['quantity' => 10, 'subtotal' => 100]); + $this->assertEquals(3, count($order->items)); + $this->assertEquals(3, count($order->orderItems)); + $orderItem = OrderItem::find(['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 testInsert() + { + $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->assertEquals(4, $customer->id); + $this->assertFalse($customer->isNewRecord); + } + + // TODO test serial column incr + + 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', + ), ['id' => 3]); + $this->assertEquals(1, $ret); + $customer = Customer::find(3); + $this->assertEquals('temp', $customer->name); + } + + public function testUpdateCounters() + { + // updateCounters + $pk = ['order_id' => 2, 'item_id' => 4]; + $orderItem = OrderItem::find($pk); + $this->assertEquals(1, $orderItem->quantity); + $ret = $orderItem->updateCounters(['quantity' => -1]); + $this->assertTrue($ret); + $this->assertEquals(0, $orderItem->quantity); + $orderItem = OrderItem::find($pk); + $this->assertEquals(0, $orderItem->quantity); + + // updateAllCounters + $pk = ['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 = ['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(['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/redis/RedisConnectionTest.php b/tests/unit/framework/redis/RedisConnectionTest.php new file mode 100644 index 0000000..af39e0e --- /dev/null +++ b/tests/unit/framework/redis/RedisConnectionTest.php @@ -0,0 +1,69 @@ +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/redis/RedisTestCase.php b/tests/unit/framework/redis/RedisTestCase.php new file mode 100644 index 0000000..12e539d --- /dev/null +++ b/tests/unit/framework/redis/RedisTestCase.php @@ -0,0 +1,51 @@ +mockApplication(); + + $databases = $this->getParam('databases'); + $params = isset($databases['redis']) ? $databases['redis'] : null; + if ($params === null || !isset($params['dsn'])) { + $this->markTestSkipped('No redis server connection configured.'); + } + $dsn = explode('/', $params['dsn']); + $host = $dsn[2]; + if (strpos($host, ':')===false) { + $host .= ':6379'; + } + if(!@stream_socket_client($host, $errorNumber, $errorDescription, 0.5)) { + $this->markTestSkipped('No redis 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['redis']) ? $databases['redis'] : array(); + $db = new Connection; + $db->dsn = $params['dsn']; + $db->password = $params['password']; + if ($reset) { + $db->open(); + $db->flushall(); + } + return $db; + } +} \ No newline at end of file