From cbfa7e6129116e400f419357a3a5af1f63cac481 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 12 Nov 2013 16:15:37 +0200 Subject: [PATCH] "yii\sphinx\Command" and "yii\sphinx\QueryBuilder" extracted. --- extensions/sphinx/Command.php | 483 +++++++++++++++++++- extensions/sphinx/Connection.php | 26 ++ extensions/sphinx/QueryBuilder.php | 648 +++++++++++++++++++++++++-- tests/unit/extensions/sphinx/CommandTest.php | 2 +- 4 files changed, 1113 insertions(+), 46 deletions(-) diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php index b457b9a..d22d055 100644 --- a/extensions/sphinx/Command.php +++ b/extensions/sphinx/Command.php @@ -7,13 +7,492 @@ namespace yii\sphinx; +use Yii; +use yii\base\Component; +use yii\caching\Cache; +use yii\db\DataReader; +use yii\db\Exception; + /** * Class Command * * @author Paul Klimov * @since 2.0 */ -class Command extends \yii\db\Command +class Command extends Component { - // + /** + * @var Connection the Sphinx connection that this command is associated with + */ + public $db; + /** + * @var \PDOStatement the PDOStatement object that this command is associated with + */ + public $pdoStatement; + /** + * @var integer the default fetch mode for this command. + * @see http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php + */ + public $fetchMode = \PDO::FETCH_ASSOC; + /** + * @var array the parameters (name => value) that are bound to the current PDO statement. + * This property is maintained by methods such as [[bindValue()]]. + * Do not modify it directly. + */ + public $params = []; + /** + * @var string the SphinxQL statement that this command represents + */ + private $_sql; + + /** + * Returns the SQL statement for this command. + * @return string the SQL statement to be executed + */ + public function getSql() + { + return $this->_sql; + } + + /** + * Specifies the SQL statement to be executed. + * The previous SQL execution (if any) will be cancelled, and [[params]] will be cleared as well. + * @param string $sql the SQL statement to be set. + * @return static this command instance + */ + public function setSql($sql) + { + if ($sql !== $this->_sql) { + $this->cancel(); + $this->_sql = $this->db->quoteSql($sql); + $this->params = []; + } + return $this; + } + + /** + * Returns the raw SQL by inserting parameter values into the corresponding placeholders in [[sql]]. + * Note that the return value of this method should mainly be used for logging purpose. + * It is likely that this method returns an invalid SQL due to improper replacement of parameter placeholders. + * @return string the raw SQL with parameter values inserted into the corresponding placeholders in [[sql]]. + */ + public function getRawSql() + { + if (empty($this->params)) { + return $this->_sql; + } else { + $params = []; + foreach ($this->params as $name => $value) { + if (is_string($value)) { + $params[$name] = $this->db->quoteValue($value); + } elseif ($value === null) { + $params[$name] = 'NULL'; + } else { + $params[$name] = $value; + } + } + if (isset($params[1])) { + $sql = ''; + foreach (explode('?', $this->_sql) as $i => $part) { + $sql .= (isset($params[$i]) ? $params[$i] : '') . $part; + } + return $sql; + } else { + return strtr($this->_sql, $params); + } + } + } + + /** + * Prepares the SQL statement to be executed. + * For complex SQL statement that is to be executed multiple times, + * this may improve performance. + * For SQL statement with binding parameters, this method is invoked + * automatically. + * @throws Exception if there is any DB error + */ + public function prepare() + { + if ($this->pdoStatement == null) { + $sql = $this->getSql(); + try { + $this->pdoStatement = $this->db->pdo->prepare($sql); + } catch (\Exception $e) { + $message = $e->getMessage() . "\nFailed to prepare SQL: $sql"; + $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; + throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); + } + } + } + + /** + * Cancels the execution of the SQL statement. + * This method mainly sets [[pdoStatement]] to be null. + */ + public function cancel() + { + $this->pdoStatement = null; + } + + /** + * Binds a parameter to the SQL statement to be executed. + * @param string|integer $name parameter identifier. For a prepared statement + * using named placeholders, this will be a parameter name of + * the form `:name`. For a prepared statement using question mark + * placeholders, this will be the 1-indexed position of the parameter. + * @param mixed $value Name of the PHP variable to bind to the SQL statement parameter + * @param integer $dataType SQL data type of the parameter. If null, the type is determined by the PHP type of the value. + * @param integer $length length of the data type + * @param mixed $driverOptions the driver-specific options + * @return static the current command being executed + * @see http://www.php.net/manual/en/function.PDOStatement-bindParam.php + */ + public function bindParam($name, &$value, $dataType = null, $length = null, $driverOptions = null) + { + $this->prepare(); + if ($dataType === null) { + $dataType = $this->db->getSchema()->getPdoType($value); + } + if ($length === null) { + $this->pdoStatement->bindParam($name, $value, $dataType); + } elseif ($driverOptions === null) { + $this->pdoStatement->bindParam($name, $value, $dataType, $length); + } else { + $this->pdoStatement->bindParam($name, $value, $dataType, $length, $driverOptions); + } + $this->params[$name] =& $value; + return $this; + } + + /** + * Binds a value to a parameter. + * @param string|integer $name Parameter identifier. For a prepared statement + * using named placeholders, this will be a parameter name of + * the form `:name`. For a prepared statement using question mark + * placeholders, this will be the 1-indexed position of the parameter. + * @param mixed $value The value to bind to the parameter + * @param integer $dataType SQL data type of the parameter. If null, the type is determined by the PHP type of the value. + * @return static the current command being executed + * @see http://www.php.net/manual/en/function.PDOStatement-bindValue.php + */ + public function bindValue($name, $value, $dataType = null) + { + $this->prepare(); + if ($dataType === null) { + $dataType = $this->db->getSchema()->getPdoType($value); + } + $this->pdoStatement->bindValue($name, $value, $dataType); + $this->params[$name] = $value; + return $this; + } + + /** + * Binds a list of values to the corresponding parameters. + * This is similar to [[bindValue()]] except that it binds multiple values at a time. + * Note that the SQL data type of each value is determined by its PHP type. + * @param array $values the values to be bound. This must be given in terms of an associative + * array with array keys being the parameter names, and array values the corresponding parameter values, + * e.g. `[':name' => 'John', ':age' => 25]`. By default, the PDO type of each value is determined + * by its PHP type. You may explicitly specify the PDO type by using an array: `[value, type]`, + * e.g. `[':name' => 'John', ':profile' => [$profile, \PDO::PARAM_LOB]]`. + * @return static the current command being executed + */ + public function bindValues($values) + { + if (!empty($values)) { + $this->prepare(); + foreach ($values as $name => $value) { + if (is_array($value)) { + $type = $value[1]; + $value = $value[0]; + } else { + $type = $this->db->getSchema()->getPdoType($value); + } + $this->pdoStatement->bindValue($name, $value, $type); + $this->params[$name] = $value; + } + } + return $this; + } + + /** + * Executes the SQL statement. + * This method should only be used for executing non-query SQL statement, such as `INSERT`, `DELETE`, `UPDATE` SQLs. + * No result set will be returned. + * @return integer number of rows affected by the execution. + * @throws Exception execution failed + */ + public function execute() + { + $sql = $this->getSql(); + + $rawSql = $this->getRawSql(); + + Yii::trace($rawSql, __METHOD__); + + if ($sql == '') { + return 0; + } + + $token = $rawSql; + try { + Yii::beginProfile($token, __METHOD__); + + $this->prepare(); + $this->pdoStatement->execute(); + $n = $this->pdoStatement->rowCount(); + + Yii::endProfile($token, __METHOD__); + return $n; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + $message = $e->getMessage() . "\nThe SQL being executed was: $rawSql"; + $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; + throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); + } + } + + /** + * Executes the SQL statement and returns query result. + * This method is for executing a SQL query that returns result set, such as `SELECT`. + * @return DataReader the reader object for fetching the query result + * @throws Exception execution failed + */ + public function query() + { + return $this->queryInternal(''); + } + + /** + * Executes the SQL statement and returns ALL rows at once. + * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) + * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. + * @return array all rows of the query result. Each array element is an array representing a row of data. + * An empty array is returned if the query results in nothing. + * @throws Exception execution failed + */ + public function queryAll($fetchMode = null) + { + return $this->queryInternal('fetchAll', $fetchMode); + } + + /** + * Executes the SQL statement and returns the first row of the result. + * This method is best used when only the first row of result is needed for a query. + * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) + * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. + * @return array|boolean the first row (in terms of an array) of the query result. False is returned if the query + * results in nothing. + * @throws Exception execution failed + */ + public function queryOne($fetchMode = null) + { + return $this->queryInternal('fetch', $fetchMode); + } + + /** + * Executes the SQL statement and returns the value of the first column in the first row of data. + * This method is best used when only a single value is needed for a query. + * @return string|boolean the value of the first column in the first row of the query result. + * False is returned if there is no value. + * @throws Exception execution failed + */ + public function queryScalar() + { + $result = $this->queryInternal('fetchColumn', 0); + if (is_resource($result) && get_resource_type($result) === 'stream') { + return stream_get_contents($result); + } else { + return $result; + } + } + + /** + * Executes the SQL statement and returns the first column of the result. + * This method is best used when only the first column of result (i.e. the first element in each row) + * is needed for a query. + * @return array the first column of the query result. Empty array is returned if the query results in nothing. + * @throws Exception execution failed + */ + public function queryColumn() + { + return $this->queryInternal('fetchAll', \PDO::FETCH_COLUMN); + } + + /** + * Performs the actual DB query of a SQL statement. + * @param string $method method of PDOStatement to be called + * @param integer $fetchMode the result fetch mode. Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) + * for valid fetch modes. If this parameter is null, the value set in [[fetchMode]] will be used. + * @return mixed the method execution result + * @throws Exception if the query causes any problem + */ + private function queryInternal($method, $fetchMode = null) + { + $db = $this->db; + $rawSql = $this->getRawSql(); + + Yii::trace($rawSql, __METHOD__); + + /** @var $cache \yii\caching\Cache */ + if ($db->enableQueryCache && $method !== '') { + $cache = is_string($db->queryCache) ? Yii::$app->getComponent($db->queryCache) : $db->queryCache; + } + + if (isset($cache) && $cache instanceof Cache) { + $cacheKey = [ + __CLASS__, + $db->dsn, + $db->username, + $rawSql, + ]; + if (($result = $cache->get($cacheKey)) !== false) { + Yii::trace('Query result served from cache', __METHOD__); + return $result; + } + } + + $token = $rawSql; + try { + Yii::beginProfile($token, __METHOD__); + + $this->prepare(); + $this->pdoStatement->execute(); + + if ($method === '') { + $result = new DataReader($this); + } else { + if ($fetchMode === null) { + $fetchMode = $this->fetchMode; + } + $result = call_user_func_array([$this->pdoStatement, $method], (array)$fetchMode); + $this->pdoStatement->closeCursor(); + } + + Yii::endProfile($token, __METHOD__); + + if (isset($cache, $cacheKey) && $cache instanceof Cache) { + $cache->set($cacheKey, $result, $db->queryCacheDuration, $db->queryCacheDependency); + Yii::trace('Saved query result in cache', __METHOD__); + } + + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + $message = $e->getMessage() . "\nThe SQL being executed was: $rawSql"; + $errorInfo = $e instanceof \PDOException ? $e->errorInfo : null; + throw new Exception($message, $errorInfo, (int)$e->getCode(), $e); + } + } + + /** + * Creates an INSERT command. + * For example, + * + * ~~~ + * $connection->createCommand()->insert('idx_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * ])->execute(); + * ~~~ + * + * The method will properly escape the column names, and bind the values to be inserted. + * + * Note that the created command is not executed until [[execute()]] is called. + * + * @param string $index the index that new rows will be inserted into. + * @param array $columns the column data (name => value) to be inserted into the index. + * @return static the command object itself + */ + public function insert($index, $columns) + { + $params = []; + $sql = $this->db->getQueryBuilder()->insert($index, $columns, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates a batch INSERT command. + * For example, + * + * ~~~ + * $connection->createCommand()->batchInsert('idx_user', ['name', 'age'], [ + * ['Tom', 30], + * ['Jane', 20], + * ['Linda', 25], + * ])->execute(); + * ~~~ + * + * Note that the values in each row must match the corresponding column names. + * + * @param string $index the index that new rows will be inserted into. + * @param array $columns the column names + * @param array $rows the rows to be batch inserted into the index + * @return static the command object itself + */ + public function batchInsert($index, $columns, $rows) + { + $sql = $this->db->getQueryBuilder()->batchInsert($index, $columns, $rows); + return $this->setSql($sql); + } + + /** + * Creates an UPDATE command. + * For example, + * + * ~~~ + * $connection->createCommand()->update('tbl_user', ['status' => 1], 'age > 30')->execute(); + * ~~~ + * + * The method will properly escape the column names and bind the values to be updated. + * + * Note that the created command is not executed until [[execute()]] is called. + * + * @param string $index the index to be updated. + * @param array $columns the column data (name => value) to be updated. + * @param string|array $condition the condition that will be put in the WHERE part. Please + * refer to [[Query::where()]] on how to specify condition. + * @param array $params the parameters to be bound to the command + * @return static the command object itself + */ + public function update($index, $columns, $condition = '', $params = []) + { + $sql = $this->db->getQueryBuilder()->update($index, $columns, $condition, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates a DELETE command. + * For example, + * + * ~~~ + * $connection->createCommand()->delete('tbl_user', 'status = 0')->execute(); + * ~~~ + * + * The method will properly escape the index and column names. + * + * Note that the created command is not executed until [[execute()]] is called. + * + * @param string $index the index where the data will be deleted from. + * @param string|array $condition the condition that will be put in the WHERE part. Please + * refer to [[Query::where()]] on how to specify condition. + * @param array $params the parameters to be bound to the command + * @return static the command object itself + */ + public function delete($index, $condition = '', $params = []) + { + $sql = $this->db->getQueryBuilder()->delete($index, $condition, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates a SQL command for truncating a runtime index. + * @param string $index the index to be truncated. The name will be properly quoted by the method. + * @return static the command object itself + */ + public function truncateIndex($index) + { + $sql = $this->db->getQueryBuilder()->truncateIndex($index); + return $this->setSql($sql); + } } \ No newline at end of file diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index a36afb9..59ce4b6 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -10,6 +10,8 @@ namespace yii\sphinx; /** * Class Connection * + * @method Schema getSchema() The schema information for this Sphinx connection + * * @author Paul Klimov * @since 2.0 */ @@ -22,4 +24,28 @@ class Connection extends \yii\db\Connection 'mysqli' => 'yii\sphinx\Schema', // MySQL 'mysql' => 'yii\sphinx\Schema', // MySQL ]; + + /** + * Obtains the schema information for the named index. + * @param string $name index name. + * @param boolean $refresh whether to reload the table schema even if it is found in the cache. + * @return IndexSchema index schema information. Null if the named index does not exist. + */ + public function getIndexSchema($name, $refresh = false) + { + return $this->getSchema()->getIndexSchema($name, $refresh); + } + + /** + * Quotes a index name for use in a query. + * If the index name contains schema prefix, the prefix will also be properly quoted. + * If the index name is already quoted or contains special characters including '(', '[[' and '{{', + * then this method will do nothing. + * @param string $name index name + * @return string the properly quoted index name + */ + public function quoteIndexName($name) + { + return $this->getSchema()->quoteIndexName($name); + } } \ No newline at end of file diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php index b0502e6..02f0145 100644 --- a/extensions/sphinx/QueryBuilder.php +++ b/extensions/sphinx/QueryBuilder.php @@ -6,6 +6,9 @@ */ namespace yii\sphinx; + +use yii\base\Object; +use yii\db\Exception; use yii\db\Expression; /** @@ -14,9 +17,35 @@ use yii\db\Expression; * @author Paul Klimov * @since 2.0 */ -class QueryBuilder extends \yii\db\mysql\QueryBuilder +class QueryBuilder extends Object { /** + * The prefix for automatically generated query binding parameters. + */ + const PARAM_PREFIX = ':sp'; + + /** + * @var Connection the Sphinx connection. + */ + public $db; + /** + * @var string the separator between different fragments of a SQL statement. + * Defaults to an empty space. This is mainly used by [[build()]] when generating a SQL statement. + */ + public $separator = " "; + + /** + * Constructor. + * @param Connection $connection the Sphinx connection. + * @param array $config name-value pairs that will be used to initialize the object properties + */ + public function __construct($connection, $config = []) + { + $this->db = $connection; + parent::__construct($config); + } + + /** * Generates a SELECT SQL statement from a [[Query]] object. * @param Query $query the [[Query]] object from which the SQL statement will be generated * @return array the generated SQL statement (the first array element) and the corresponding @@ -39,85 +68,618 @@ class QueryBuilder extends \yii\db\mysql\QueryBuilder } /** - * @param array $columns - * @return string the ORDER BY clause built from [[query]]. + * Creates an INSERT SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->insert('idx_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * ], $params); + * ~~~ + * + * The method will properly escape the index and column names. + * + * @param string $index the index that new rows will be inserted into. + * @param array $columns the column data (name => value) to be inserted into the index. + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the DB command later. + * @return string the INSERT SQL */ - public function buildWithin($columns) + public function insert($index, $columns, &$params) { - if (empty($columns)) { - return ''; + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $columnSchemas = $indexSchema->columns; + } else { + $columnSchemas = []; } - $orders = []; - foreach ($columns as $name => $direction) { - if (is_object($direction)) { - $orders[] = (string)$direction; + $names = []; + $placeholders = []; + foreach ($columns as $name => $value) { + $names[] = $this->db->quoteColumnName($name); + if ($value instanceof Expression) { + $placeholders[] = $value->expression; + foreach ($value->params as $n => $v) { + $params[$n] = $v; + } } else { - $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + $phName = self::PARAM_PREFIX . count($params); + $placeholders[] = $phName; + $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; } } - return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); + + return 'INSERT INTO ' . $this->db->quoteIndexName($index) + . ' (' . implode(', ', $names) . ') VALUES (' + . implode(', ', $placeholders) . ')'; } /** - * @param array $options - * @return string the OPTION clause build from [[query]] + * Generates a batch INSERT SQL statement. + * For example, + * + * ~~~ + * $connection->createCommand()->batchInsert('idx_user', ['name', 'age'], [ + * ['Tom', 30], + * ['Jane', 20], + * ['Linda', 25], + * ])->execute(); + * ~~~ + * + * Note that the values in each row must match the corresponding column names. + * + * @param string $index the index that new rows will be inserted into. + * @param array $columns the column names + * @param array $rows the rows to be batch inserted into the index + * @return string the batch INSERT SQL statement */ - public function buildOption(array $options) + public function batchInsert($index, $columns, $rows) { - if (empty($options)) { - return ''; + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $columnSchemas = $indexSchema->columns; + } else { + $columnSchemas = []; } - $optionLines = []; - foreach ($options as $name => $value) { - $optionLines[] = $name . ' = ' . $value; + + foreach ($columns as $i => $name) { + $columns[$i] = $this->db->quoteColumnName($name); } - return 'OPTION ' . implode(', ', $optionLines); + + $values = []; + foreach ($rows as $row) { + $vs = []; + foreach ($row as $i => $value) { + if (!is_array($value) && isset($columnSchemas[$columns[$i]])) { + $value = $columnSchemas[$columns[$i]]->typecast($value); + } + $vs[] = is_string($value) ? $this->db->quoteValue($value) : $value; + } + $values[] = '(' . implode(', ', $vs) . ')'; + } + + return 'INSERT INTO ' . $this->db->quoteIndexName($index) + . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values); } /** - * Creates an INSERT SQL statement. + * Creates an UPDATE SQL statement. * For example, * * ~~~ - * $sql = $queryBuilder->insert('tbl_user', [ - * 'name' => 'Sam', - * 'age' => 30, - * ], $params); + * $params = []; + * $sql = $queryBuilder->update('idx_user', ['status' => 1], 'age > 30', $params); * ~~~ * - * The method will properly escape the table and column names. + * The method will properly escape the index and column names. * - * @param string $table the table that new rows will be inserted into. - * @param array $columns the column data (name => value) to be inserted into the table. - * @param array $params the binding parameters that will be generated by this method. - * They should be bound to the DB command later. - * @return string the INSERT SQL + * @param string $index the index to be updated. + * @param array $columns the column data (name => value) to be updated. + * @param array|string $condition the condition that will be put in the WHERE part. Please + * refer to [[Query::where()]] on how to specify condition. + * @param array $params the binding parameters that will be modified by this method + * so that they can be bound to the DB command later. + * @return string the UPDATE SQL */ - public function insert($table, $columns, &$params) + public function update($index, $columns, $condition, &$params) { - if (($tableSchema = $this->db->getTableSchema($table)) !== null) { - $columnSchemas = $tableSchema->columns; + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $columnSchemas = $indexSchema->columns; } else { $columnSchemas = []; } - $names = []; - $placeholders = []; + + $lines = []; foreach ($columns as $name => $value) { - $names[] = $this->db->quoteColumnName($name); if ($value instanceof Expression) { - $placeholders[] = $value->expression; + $lines[] = $this->db->quoteColumnName($name) . '=' . $value->expression; foreach ($value->params as $n => $v) { $params[$n] = $v; } } else { $phName = self::PARAM_PREFIX . count($params); - $placeholders[] = $phName; + $lines[] = $this->db->quoteColumnName($name) . '=' . $phName; $params[$phName] = !is_array($value) && isset($columnSchemas[$name]) ? $columnSchemas[$name]->typecast($value) : $value; } } - return 'INSERT INTO ' . $this->db->quoteTableName($table) - . ' (' . implode(', ', $names) . ') VALUES (' - . implode(', ', $placeholders) . ')'; + $sql = 'UPDATE ' . $this->db->quoteIndexName($index) . ' SET ' . implode(', ', $lines); + $where = $this->buildWhere($condition, $params); + return $where === '' ? $sql : $sql . ' ' . $where; + } + + /** + * Creates a DELETE SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->delete('tbl_user', 'status = 0'); + * ~~~ + * + * The method will properly escape the index and column names. + * + * @param string $index the index where the data will be deleted from. + * @param array|string $condition the condition that will be put in the WHERE part. Please + * refer to [[Query::where()]] on how to specify condition. + * @param array $params the binding parameters that will be modified by this method + * so that they can be bound to the DB command later. + * @return string the DELETE SQL + */ + public function delete($index, $condition, &$params) + { + $sql = 'DELETE FROM ' . $this->db->quoteIndexName($index); + $where = $this->buildWhere($condition, $params); + return $where === '' ? $sql : $sql . ' ' . $where; + } + + /** + * Builds a SQL statement for truncating a DB index. + * @param string $index the index to be truncated. The name will be properly quoted by the method. + * @return string the SQL statement for truncating a DB index. + */ + public function truncateIndex($index) + { + return 'TRUNCATE RTINDEX ' . $this->db->quoteIndexName($index); + } + + /** + * @param array $columns + * @param boolean $distinct + * @param string $selectOption + * @return string the SELECT clause built from [[query]]. + */ + public function buildSelect($columns, $distinct = false, $selectOption = null) + { + $select = $distinct ? 'SELECT DISTINCT' : 'SELECT'; + if ($selectOption !== null) { + $select .= ' ' . $selectOption; + } + + if (empty($columns)) { + return $select . ' *'; + } + + foreach ($columns as $i => $column) { + if (is_object($column)) { + $columns[$i] = (string)$column; + } elseif (strpos($column, '(') === false) { + if (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_\.]+)$/', $column, $matches)) { + $columns[$i] = $this->db->quoteColumnName($matches[1]) . ' AS ' . $this->db->quoteColumnName($matches[2]); + } else { + $columns[$i] = $this->db->quoteColumnName($column); + } + } + } + + if (is_array($columns)) { + $columns = implode(', ', $columns); + } + + return $select . ' ' . $columns; + } + + /** + * @param array $indexes + * @return string the FROM clause built from [[query]]. + */ + public function buildFrom($indexes) + { + if (empty($indexes)) { + return ''; + } + + foreach ($indexes as $i => $index) { + if (strpos($index, '(') === false) { + if (preg_match('/^(.*?)(?i:\s+as|)\s+([^ ]+)$/', $index, $matches)) { // with alias + $indexes[$i] = $this->db->quoteIndexName($matches[1]) . ' ' . $this->db->quoteIndexName($matches[2]); + } else { + $indexes[$i] = $this->db->quoteIndexName($index); + } + } + } + + if (is_array($indexes)) { + $indexes = implode(', ', $indexes); + } + + return 'FROM ' . $indexes; + } + + /** + * @param string|array $condition + * @param array $params the binding parameters to be populated + * @return string the WHERE clause built from [[query]]. + */ + public function buildWhere($condition, &$params) + { + $where = $this->buildCondition($condition, $params); + return $where === '' ? '' : 'WHERE ' . $where; + } + + /** + * @param array $columns + * @return string the GROUP BY clause + */ + public function buildGroupBy($columns) + { + return empty($columns) ? '' : 'GROUP BY ' . $this->buildColumns($columns); + } + + /** + * @param array $columns + * @return string the ORDER BY clause built from [[query]]. + */ + public function buildOrderBy($columns) + { + if (empty($columns)) { + return ''; + } + $orders = []; + foreach ($columns as $name => $direction) { + if (is_object($direction)) { + $orders[] = (string)$direction; + } else { + $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + } + } + + return 'ORDER BY ' . implode(', ', $orders); + } + + /** + * @param integer $limit + * @param integer $offset + * @return string the LIMIT and OFFSET clauses built from [[query]]. + */ + public function buildLimit($limit, $offset) + { + $sql = ''; + if ($limit !== null && $limit >= 0) { + $sql = 'LIMIT ' . (int)$limit; + } + if ($offset > 0) { + $sql .= ' OFFSET ' . (int)$offset; + } + return ltrim($sql); + } + + /** + * Processes columns and properly quote them if necessary. + * It will join all columns into a string with comma as separators. + * @param string|array $columns the columns to be processed + * @return string the processing result + */ + public function buildColumns($columns) + { + if (!is_array($columns)) { + if (strpos($columns, '(') !== false) { + return $columns; + } else { + $columns = preg_split('/\s*,\s*/', $columns, -1, PREG_SPLIT_NO_EMPTY); + } + } + foreach ($columns as $i => $column) { + if (is_object($column)) { + $columns[$i] = (string)$column; + } elseif (strpos($column, '(') === false) { + $columns[$i] = $this->db->quoteColumnName($column); + } + } + return is_array($columns) ? implode(', ', $columns) : $columns; + } + + + /** + * Parses the condition specification and generates the corresponding SQL expression. + * @param string|array $condition the condition specification. Please refer to [[Query::where()]] + * on how to specify a condition. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + * @throws \yii\db\Exception if the condition is in bad format + */ + public function buildCondition($condition, &$params) + { + static $builders = [ + 'AND' => '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)) { + return (string)$condition; + } elseif (empty($condition)) { + return ''; + } + if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ... + $operator = strtoupper($condition[0]); + if (isset($builders[$operator])) { + $method = $builders[$operator]; + array_shift($condition); + return $this->$method($operator, $condition, $params); + } else { + throw new Exception('Found unknown operator in query: ' . $operator); + } + } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... + return $this->buildHashCondition($condition, $params); + } + } + + /** + * Creates a condition based on column-value pairs. + * @param array $condition the condition specification. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + */ + public function buildHashCondition($condition, &$params) + { + $parts = []; + foreach ($condition as $column => $value) { + if (is_array($value)) { // IN condition + $parts[] = $this->buildInCondition('IN', [$column, $value], $params); + } else { + if (strpos($column, '(') === false) { + $column = $this->db->quoteColumnName($column); + } + if ($value === null) { + $parts[] = "$column IS NULL"; + } elseif ($value instanceof Expression) { + $parts[] = "$column=" . $value->expression; + foreach ($value->params as $n => $v) { + $params[$n] = $v; + } + } else { + $phName = self::PARAM_PREFIX . count($params); + $parts[] = "$column=$phName"; + $params[$phName] = $value; + } + } + } + return count($parts) === 1 ? $parts[0] : '(' . implode(') AND (', $parts) . ')'; + } + + /** + * Connects two or more SQL expressions with the `AND` or `OR` operator. + * @param string $operator the operator to use for connecting the given operands + * @param array $operands the SQL expressions to connect. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + */ + public function buildAndCondition($operator, $operands, &$params) + { + $parts = []; + foreach ($operands as $operand) { + if (is_array($operand)) { + $operand = $this->buildCondition($operand, $params); + } + if ($operand !== '') { + $parts[] = $operand; + } + } + if (!empty($parts)) { + return '(' . implode(") $operator (", $parts) . ')'; + } else { + return ''; + } + } + + /** + * Creates an SQL expressions with the `BETWEEN` operator. + * @param string $operator the operator to use (e.g. `BETWEEN` or `NOT BETWEEN`) + * @param array $operands the first operand is the column name. The second and third operands + * describe the interval that column value should be in. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + * @throws Exception if wrong number of operands have been given. + */ + public function buildBetweenCondition($operator, $operands, &$params) + { + if (!isset($operands[0], $operands[1], $operands[2])) { + throw new Exception("Operator '$operator' requires three operands."); + } + + list($column, $value1, $value2) = $operands; + + if (strpos($column, '(') === false) { + $column = $this->db->quoteColumnName($column); + } + $phName1 = self::PARAM_PREFIX . count($params); + $params[$phName1] = $value1; + $phName2 = self::PARAM_PREFIX . count($params); + $params[$phName2] = $value2; + + return "$column $operator $phName1 AND $phName2"; + } + + /** + * Creates an SQL expressions with the `IN` operator. + * @param string $operator the operator to use (e.g. `IN` or `NOT IN`) + * @param array $operands the first operand is the column name. If it is an array + * a composite IN condition will be generated. + * The second operand is an array of values that column value should be among. + * If it is an empty array the generated expression will be a `false` value if + * operator is `IN` and empty if operator is `NOT IN`. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + * @throws Exception if wrong number of operands have been given. + */ + public function buildInCondition($operator, $operands, &$params) + { + 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' ? '0=1' : ''; + } + + if (count($column) > 1) { + return $this->buildCompositeInCondition($operator, $column, $values, $params); + } elseif (is_array($column)) { + $column = reset($column); + } + foreach ($values as $i => $value) { + if (is_array($value)) { + $value = isset($value[$column]) ? $value[$column] : null; + } + if ($value === null) { + $values[$i] = 'NULL'; + } elseif ($value instanceof Expression) { + $values[$i] = $value->expression; + foreach ($value->params as $n => $v) { + $params[$n] = $v; + } + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $value; + $values[$i] = $phName; + } + } + if (strpos($column, '(') === false) { + $column = $this->db->quoteColumnName($column); + } + + if (count($values) > 1) { + return "$column $operator (" . implode(', ', $values) . ')'; + } else { + $operator = $operator === 'IN' ? '=' : '<>'; + return "$column$operator{$values[0]}"; + } + } + + protected function buildCompositeInCondition($operator, $columns, $values, &$params) + { + $vss = []; + foreach ($values as $value) { + $vs = []; + foreach ($columns as $column) { + if (isset($value[$column])) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $value[$column]; + $vs[] = $phName; + } else { + $vs[] = 'NULL'; + } + } + $vss[] = '(' . implode(', ', $vs) . ')'; + } + foreach ($columns as $i => $column) { + if (strpos($column, '(') === false) { + $columns[$i] = $this->db->quoteColumnName($column); + } + } + return '(' . implode(', ', $columns) . ") $operator (" . implode(', ', $vss) . ')'; + } + + /** + * Creates an SQL expressions with the `LIKE` operator. + * @param string $operator the operator to use (e.g. `LIKE`, `NOT LIKE`, `OR LIKE` or `OR NOT LIKE`) + * @param array $operands the first operand is the column name. + * The second operand is a single value or an array of values that column value + * should be compared with. + * If it is an empty array the generated expression will be a `false` value if + * operator is `LIKE` or `OR LIKE` and empty if operator is `NOT LIKE` or `OR NOT LIKE`. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + * @throws Exception if wrong number of operands have been given. + */ + public function buildLikeCondition($operator, $operands, &$params) + { + if (!isset($operands[0], $operands[1])) { + throw new Exception("Operator '$operator' requires two operands."); + } + + list($column, $values) = $operands; + + $values = (array)$values; + + if (empty($values)) { + return $operator === 'LIKE' || $operator === 'OR LIKE' ? '0=1' : ''; + } + + if ($operator === 'LIKE' || $operator === 'NOT LIKE') { + $andor = ' AND '; + } else { + $andor = ' OR '; + $operator = $operator === 'OR LIKE' ? 'LIKE' : 'NOT LIKE'; + } + + if (strpos($column, '(') === false) { + $column = $this->db->quoteColumnName($column); + } + + $parts = []; + foreach ($values as $value) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $value; + $parts[] = "$column $operator $phName"; + } + + return implode($andor, $parts); + } + + /** + * @param array $columns + * @return string the ORDER BY clause built from [[query]]. + */ + public function buildWithin($columns) + { + if (empty($columns)) { + return ''; + } + $orders = []; + foreach ($columns as $name => $direction) { + if (is_object($direction)) { + $orders[] = (string)$direction; + } else { + $orders[] = $this->db->quoteColumnName($name) . ($direction === Query::SORT_DESC ? ' DESC' : ''); + } + } + return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); + } + + /** + * @param array $options + * @return string the OPTION clause build from [[query]] + */ + public function buildOption(array $options) + { + if (empty($options)) { + return ''; + } + $optionLines = []; + foreach ($options as $name => $value) { + $optionLines[] = $name . ' = ' . $value; + } + return 'OPTION ' . implode(', ', $optionLines); } } \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php index 0a15bf8..32a6ffa 100644 --- a/tests/unit/extensions/sphinx/CommandTest.php +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -103,7 +103,7 @@ class CommandTest extends SphinxTestCase 'title' => 'Test title', 'content' => 'Test content', 'type_id' => 2, - 'category' => [41, 42], + //'category' => [41, 42], 'id' => 1, ]); $this->assertEquals(1, $command->execute(), 'Unable to execute insert!');