From 130b63461c395fb36a37a4dd601403bc4c252049 Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Tue, 24 Sep 2013 15:27:54 +0200 Subject: [PATCH] redis WIP - relation support - completed and refactored lua script builder --- framework/yii/redis/ActiveQuery.php | 76 ++++----- framework/yii/redis/ActiveRelation.php | 77 ++++++++- framework/yii/redis/LuaScriptBuilder.php | 200 +++++++++++++++--------- tests/unit/framework/redis/ActiveRecordTest.php | 22 +-- 4 files changed, 237 insertions(+), 138 deletions(-) diff --git a/framework/yii/redis/ActiveQuery.php b/framework/yii/redis/ActiveQuery.php index 2a693cf..c1acf11 100644 --- a/framework/yii/redis/ActiveQuery.php +++ b/framework/yii/redis/ActiveQuery.php @@ -9,6 +9,7 @@ */ namespace yii\redis; +use yii\base\NotSupportedException; /** * ActiveQuery represents a DB query associated with an Active Record class. @@ -89,18 +90,30 @@ class ActiveQuery extends \yii\base\Component public $indexBy; /** - * 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. + * Executes a script created by [[LuaScriptBuilder]] + * @param $type + * @param null $column + * @return array|bool|null|string */ - public function all() + private function executeScript($type, $column=null) { $modelClass = $this->modelClass; /** @var Connection $db */ $db = $modelClass::getDb(); - $script = $db->luaScriptBuilder->buildAll($this); - $data = $db->executeCommand('EVAL', array($script, 0)); + $method = 'build' . $type; + $script = $db->getLuaScriptBuilder()->$method($this, $column); + return $db->executeCommand('EVAL', array($script, 0)); + } + /** + * 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(); @@ -130,13 +143,8 @@ class ActiveQuery extends \yii\base\Component */ public function one() { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - - $script = $db->luaScriptBuilder->buildOne($this); - $data = $db->executeCommand('EVAL', array($script, 0)); - + // TODO add support for orderBy + $data = $this->executeScript('One'); if ($data === array()) { return null; } @@ -168,11 +176,7 @@ class ActiveQuery extends \yii\base\Component public function column($column) { // TODO add support for indexBy and orderBy - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - $script = $db->luaScriptBuilder->buildColumn($this, $column); - return $db->executeCommand('EVAL', array($script, 0)); + return $this->executeScript('Column', $column); } /** @@ -183,14 +187,13 @@ class ActiveQuery extends \yii\base\Component */ public function count() { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); 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 { - $script = $db->luaScriptBuilder->buildCount($this); - return $db->executeCommand('EVAL', array($script, 0)); + return $this->executeScript('Count'); } } @@ -201,11 +204,7 @@ class ActiveQuery extends \yii\base\Component */ public function sum($column) { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - $script = $db->luaScriptBuilder->buildSum($this, $column); - return $db->executeCommand('EVAL', array($script, 0)); + return $this->executeScript('Sum', $column); } /** @@ -216,11 +215,7 @@ class ActiveQuery extends \yii\base\Component */ public function average($column) { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - $script = $db->luaScriptBuilder->buildAverage($this, $column); - return $db->executeCommand('EVAL', array($script, 0)); + return $this->executeScript('Average', $column); } /** @@ -231,11 +226,7 @@ class ActiveQuery extends \yii\base\Component */ public function min($column) { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - $script = $db->luaScriptBuilder->buildMin($this, $column); - return $db->executeCommand('EVAL', array($script, 0)); + return $this->executeScript('Min', $column); } /** @@ -246,11 +237,7 @@ class ActiveQuery extends \yii\base\Component */ public function max($column) { - $modelClass = $this->modelClass; - /** @var Connection $db */ - $db = $modelClass::getDb(); - $script = $db->luaScriptBuilder->buildMax($this, $column); - return $db->executeCommand('EVAL', array($script, 0)); + return $this->executeScript('Max', $column); } /** @@ -294,7 +281,7 @@ class ActiveQuery extends \yii\base\Component * (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 Query the query object itself + * @return ActiveQuery the query object itself * @see addOrderBy() */ public function orderBy($columns) @@ -310,7 +297,7 @@ class ActiveQuery extends \yii\base\Component * (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 Query the query object itself + * @return ActiveQuery the query object itself * @see orderBy() */ public function addOrderBy($columns) @@ -326,6 +313,7 @@ class ActiveQuery extends \yii\base\Component protected function normalizeOrderBy($columns) { + throw new NotSupportedException('orderBy is currently not supported'); if (is_array($columns)) { return $columns; } else { diff --git a/framework/yii/redis/ActiveRelation.php b/framework/yii/redis/ActiveRelation.php index b78c200..d890720 100644 --- a/framework/yii/redis/ActiveRelation.php +++ b/framework/yii/redis/ActiveRelation.php @@ -9,6 +9,7 @@ */ namespace yii\redis; +use yii\base\InvalidConfigException; /** * ActiveRecord is the base class for classes representing relational data in terms of objects. @@ -42,20 +43,70 @@ class ActiveRelation extends \yii\redis\ActiveQuery public $via; /** + * Clones internal objects. + */ + public function __clone() + { + if (is_object($this->via)) { + // make a clone of "via" object so that the same query object can be reused multiple times + $this->via = clone $this->via; + } + } + + /** * Specifies the relation associated with the pivot table. * @param string $relationName the relation name. This refers to a relation declared in [[primaryModel]]. + * @param callable $callable a PHP callback for customizing the relation associated with the pivot table. + * Its signature should be `function($query)`, where `$query` is the query to be customized. * @return ActiveRelation the relation object itself. */ - public function via($relationName) + public function via($relationName, $callable = null) { $relation = $this->primaryModel->getRelation($relationName); $this->via = array($relationName, $relation); + if ($callable !== null) { + call_user_func($callable, $relation); + } return $this; } /** + * Creates a DB command that can be used to execute this query. + * @param Connection $db the DB connection used to create the DB command. + * If null, the DB connection returned by [[modelClass]] will be used. + * @return Command the created DB command instance. + */ + protected function executeScript($type, $column=null) + { + if ($this->primaryModel !== null) { + // lazy loading + if ($this->via instanceof self) { + // via pivot table + $viaModels = $this->via->findPivotRows(array($this->primaryModel)); + $this->filterByModels($viaModels); + } elseif (is_array($this->via)) { + // via relation + /** @var $viaQuery ActiveRelation */ + 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 ? array() : array($model); + } + $this->filterByModels($viaModels); + } else { + $this->filterByModels(array($this->primaryModel)); + } + } + return parent::executeScript($type, $column); + } + + /** * Finds the related records and populates them into the primary models. - * This method is internally by [[ActiveQuery]]. Do not call it directly. + * This method is internally used by [[ActiveQuery]]. Do not call it directly. * @param string $name the relation name * @param array $primaryModels primary models * @return array the related models @@ -68,14 +119,12 @@ class ActiveRelation extends \yii\redis\ActiveQuery } if ($this->via instanceof self) { - // TODO // via pivot table /** @var $viaQuery ActiveRelation */ $viaQuery = $this->via; $viaModels = $viaQuery->findPivotRows($primaryModels); $this->filterByModels($viaModels); } elseif (is_array($this->via)) { - // TODO // via relation /** @var $viaQuery ActiveRelation */ list($viaName, $viaQuery) = $this->via; @@ -185,7 +234,6 @@ class ActiveRelation extends \yii\redis\ActiveQuery } } - /** * @param array $models */ @@ -193,7 +241,7 @@ class ActiveRelation extends \yii\redis\ActiveQuery { $attributes = array_keys($this->link); $values = array(); - if (count($attributes) ===1) { + if (count($attributes) === 1) { // single key $attribute = reset($this->link); foreach ($models as $model) { @@ -209,7 +257,22 @@ class ActiveRelation extends \yii\redis\ActiveQuery $values[] = $v; } } - $this->primaryKeys($values); + $this->andWhere(array('in', $attributes, array_unique($values, SORT_REGULAR))); } + /** + * @param ActiveRecord[] $primaryModels + * @return array + */ + private function findPivotRows($primaryModels) + { + if (empty($primaryModels)) { + return array(); + } + $this->filterByModels($primaryModels); + /** @var $primaryModel ActiveRecord */ + $primaryModel = reset($primaryModels); + $db = $primaryModel->getDb(); // TODO use different db in db overlapping relations + return $this->all(); + } } diff --git a/framework/yii/redis/LuaScriptBuilder.php b/framework/yii/redis/LuaScriptBuilder.php index f6ebeb5..d0ddaea 100644 --- a/framework/yii/redis/LuaScriptBuilder.php +++ b/framework/yii/redis/LuaScriptBuilder.php @@ -8,6 +8,8 @@ namespace yii\redis; use yii\base\NotSupportedException; +use yii\db\Exception; +use yii\db\Expression; /** * LuaScriptBuilder builds lua scripts used for retrieving data from redis. @@ -17,67 +19,115 @@ use yii\base\NotSupportedException; */ 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 $modelClass = $query->modelClass; - $key = $modelClass::tableName(); - return $this->build($query, "n=n+1 pks[n]=redis.call('HGETALL','$key:a:' .. pk)", 'pks'); // TODO quote + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+1 pks[n]=redis.call('HGETALL',$key .. pk)", 'pks'); // TODO properly hash pk } + /** + * 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 $modelClass = $query->modelClass; - $key = $modelClass::tableName(); - return $this->build($query, "do return redis.call('HGETALL','$key:a:' .. pk) end", 'pks'); // TODO quote + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "do return redis.call('HGETALL',$key .. pk) end", 'pks'); // TODO properly hash pk } - public function buildColumn($query, $field) + /** + * 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 $modelClass = $query->modelClass; - $key = $modelClass::tableName(); - return $this->build($query, "n=n+1 pks[n]=redis.call('HGET','$key:a:' .. pk,'$field')", 'pks'); // TODO quote + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+1 pks[n]=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'pks'); // TODO properly hash pk } + /** + * 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'); } - public function buildSum($query, $field) + /** + * 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) { $modelClass = $query->modelClass; - $key = $modelClass::tableName(); - return $this->build($query, "n=n+redis.call('HGET','$key:a:' .. pk,'$field')", 'n'); // TODO quote + $key = $this->quoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=n+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'n'); // TODO properly hash pk } - public function buildAverage($query, $field) + /** + * 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) { $modelClass = $query->modelClass; - $key = $modelClass::tableName(); - return $this->build($query, "n=n+1 if v==nil then v=0 end v=v+redis.call('HGET','$key:a:' .. pk,'$field')", 'v/n'); // TODO quote + $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'); // TODO properly hash pk } - public function buildMin($query, $field) + /** + * 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) { $modelClass = $query->modelClass; - $key = $modelClass::tableName(); - return $this->build($query, "n=redis.call('HGET','$key:a:' .. pk,'$field') if v==nil or nquoteValue($modelClass::tableName() . ':a:'); + return $this->build($query, "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or nmodelClass; - $key = $modelClass::tableName(); - return $this->build($query, "n=redis.call('HGET','$key:a:' .. pk,'$field') if v==nil or n>v then v=n end", 'v'); // TODO quote + $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'); // TODO properly hash pk } /** - * @param ActiveQuery $query + * @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 */ - public function build($query, $buildResult, $return) + private function build($query, $buildResult, $return) { $columns = array(); if ($query->where !== null) { @@ -90,10 +140,10 @@ class LuaScriptBuilder extends \yii\base\Object $limitCondition = 'i>' . $start . ($query->limit === null ? '' : ' and i<=' . ($start + $query->limit)); $modelClass = $query->modelClass; - $key = $modelClass::tableName(); + $key = $this->quoteValue($modelClass::tableName() . ':a:'); $loadColumnValues = ''; - foreach($columns as $column) { - $loadColumnValues .= "local $column=redis.call('HGET','$key:a:' .. pk, '$column')\n"; // TODO properly hash pk + foreach($columns as $column => $alias) { + $loadColumnValues .= "local $alias=redis.call('HGET',$key .. pk, '$column')\n"; // TODO properly hash pk } return << $value) { - // TODO replace special chars and keywords in column name - $columns[$column] = $column; if (is_array($value)) { // IN condition - $parts[] = $this->buildInCondition('IN', array($column, $value), $columns); + $parts[] = $this->buildInCondition('in', array($column, $value), $columns); } else { + $column = $this->addColumn($column, $columns); if ($value === null) { - $parts[] = $column.'==nil'; + $parts[] = "$column==nil"; } elseif ($value instanceof Expression) { $parts[] = "$column==" . $value->expression; } else { @@ -219,16 +283,14 @@ EOF; list($column, $value1, $value2) = $operands; - // TODO replace special chars and keywords in column name $value1 = $this->quoteValue($value1); $value2 = $this->quoteValue($value2); - $columns[$column] = $column; + $column = $this->addColumn($column, $columns); return "$column > $value1 and $column < $value2"; } private function buildInCondition($operator, $operands, &$columns) { - // TODO adjust implementation to respect NOT IN operator if (!isset($operands[0], $operands[1])) { throw new Exception("Operator '$operator' requires two operands."); } @@ -238,7 +300,7 @@ EOF; $values = (array)$values; if (empty($values) || $column === array()) { - return $operator === 'IN' ? '0==1' : ''; + return $operator === 'in' ? 'false' : 'true'; } if (count($column) > 1) { @@ -246,59 +308,47 @@ EOF; } elseif (is_array($column)) { $column = reset($column); } + $columnAlias = $this->addColumn($column, $columns); $parts = array(); foreach ($values as $i => $value) { if (is_array($value)) { $value = isset($value[$column]) ? $value[$column] : null; } - // TODO replace special chars and keywords in column name if ($value === null) { - $parts[] = 'type('.$column.')=="nil"'; + $parts[] = "$columnAlias==nil"; } elseif ($value instanceof Expression) { - $parts[] = "$column==" . $value->expression; + $parts[] = "$columnAlias==" . $value->expression; } else { $value = $this->quoteValue($value); - $parts[] = "$column==$value"; + $parts[] = "$columnAlias==$value"; } } - if (count($parts) > 1) { - return "(" . implode(' or ', $parts) . ')'; - } else { - $operator = $operator === 'IN' ? '' : '!'; - return "$operator({$values[0]})"; - } + $operator = $operator === 'in' ? '' : 'not '; + return "$operator(" . implode(' or ', $parts) . ')'; } - protected function buildCompositeInCondition($operator, $columns, $values, &$params) + protected function buildCompositeInCondition($operator, $inColumns, $values, &$columns) { - throw new NotSupportedException('composie IN is not yet supported.'); - // TODO implement correclty $vss = array(); foreach ($values as $value) { $vs = array(); - foreach ($columns as $column) { + foreach ($inColumns as $column) { + $column = $this->addColumn($column, $columns); if (isset($value[$column])) { - $phName = self::PARAM_PREFIX . count($params); - $params[$phName] = $value[$column]; - $vs[] = $phName; + $vs[] = "$column==" . $this->quoteValue($value[$column]); } else { - $vs[] = 'NULL'; + $vs[] = "$column==nil"; } } - $vss[] = '(' . implode(', ', $vs) . ')'; + $vss[] = '(' . implode(' and ', $vs) . ')'; } - foreach ($columns as $i => $column) { - if (strpos($column, '(') === false) { - $columns[$i] = $this->db->quoteColumnName($column); - } - } - return '(' . implode(', ', $columns) . ") $operator (" . implode(', ', $vss) . ')'; + $operator = $operator === 'in' ? '' : 'not '; + return "$operator(" . implode(' or ', $vss) . ')'; } - private function buildLikeCondition($operator, $operands, &$params) + private function buildLikeCondition($operator, $operands, &$columns) { throw new NotSupportedException('LIKE is not yet supported.'); - // TODO implement correclty if (!isset($operands[0], $operands[1])) { throw new Exception("Operator '$operator' requires two operands."); } @@ -308,25 +358,23 @@ EOF; $values = (array)$values; if (empty($values)) { - return $operator === 'LIKE' || $operator === 'OR LIKE' ? '0=1' : ''; + return $operator === 'like' || $operator === 'or like' ? 'false' : 'true'; } - if ($operator === 'LIKE' || $operator === 'NOT LIKE') { - $andor = ' AND '; + if ($operator === 'like' || $operator === 'not like') { + $andor = ' and '; } else { - $andor = ' OR '; - $operator = $operator === 'OR LIKE' ? 'LIKE' : 'NOT LIKE'; + $andor = ' or '; + $operator = $operator === 'or like' ? 'like' : 'not like'; } - if (strpos($column, '(') === false) { - $column = $this->db->quoteColumnName($column); - } + $column = $this->addColumn($column, $columns); $parts = array(); foreach ($values as $value) { - $phName = self::PARAM_PREFIX . count($params); - $params[$phName] = $value; - $parts[] = "$column $operator $phName"; + // TODO implement matching here correctly + $value = $this->quoteValue($value); + $parts[] = "$column $operator $value"; } return implode($andor, $parts); diff --git a/tests/unit/framework/redis/ActiveRecordTest.php b/tests/unit/framework/redis/ActiveRecordTest.php index e5a5762..acaa5f4 100644 --- a/tests/unit/framework/redis/ActiveRecordTest.php +++ b/tests/unit/framework/redis/ActiveRecordTest.php @@ -239,17 +239,17 @@ class ActiveRecordTest extends RedisTestCase $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()->primaryKeys(array(3))->all(); -// $this->assertEquals(1, count($orders)); -// $this->assertEquals(3, $orders[0]->id); -// } + 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() // {