diff --git a/.travis.yml b/.travis.yml index 7c1f188..7e5a002 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,7 @@ before_script: - tests/unit/data/travis/cubrid-setup.sh script: - - phpunit --coverage-clover tests/unit/runtime/coveralls/clover.xml --verbose --exclude-group mssql,oci,wincache,xcache,zenddata,vendor + - phpunit --coverage-clover tests/unit/runtime/coveralls/clover.xml --verbose --exclude-group mssql,oci,wincache,xcache,zenddata,vendor,sphinx after_script: - php vendor/bin/coveralls diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php new file mode 100644 index 0000000..62a6ef0 --- /dev/null +++ b/extensions/sphinx/ActiveQuery.php @@ -0,0 +1,204 @@ +with('source')->asArray()->all(); + * ~~~ + * + * ActiveQuery allows to build the snippets using sources provided by ActiveRecord. + * You can use [[snippetByModel()]] method to enable this. + * For example: + * + * ~~~ + * class Article extends ActiveRecord + * { + * public function getSource() + * { + * return $this->hasOne('db', ArticleDb::className(), ['id' => 'id']); + * } + * + * public function getSnippetSource() + * { + * return $this->source->content; + * } + * + * ... + * } + * + * $articles = Article::find()->with('source')->snippetByModel()->all(); + * ~~~ + * + * @author Paul Klimov + * @since 2.0 + */ +class ActiveQuery extends Query implements ActiveQueryInterface +{ + use ActiveQueryTrait; + + /** + * @var string the SQL statement to be executed for retrieving AR records. + * This is set by [[ActiveRecord::findBySql()]]. + */ + public $sql; + + /** + * Sets the [[snippetCallback]] to [[fetchSnippetSourceFromModels()]], which allows to + * fetch the snippet source strings from the Active Record models, using method + * [[ActiveRecord::getSnippetSource()]]. + * For example: + * + * ~~~ + * class Article extends ActiveRecord + * { + * public function getSnippetSource() + * { + * return file_get_contents('/path/to/source/files/' . $this->id . '.txt');; + * } + * } + * + * $articles = Article::find()->snippetByModel()->all(); + * ~~~ + * + * Warning: this option should NOT be used with [[asArray]] at the same time! + * @return static the query object itself + */ + public function snippetByModel() + { + $this->snippetCallback([$this, 'fetchSnippetSourceFromModels']); + return $this; + } + + /** + * Executes query and returns all results as an array. + * @param Connection $db the DB connection used to create the DB command. + * If null, the DB connection returned by [[modelClass]] will be used. + * @return array the query results. If the query results in nothing, an empty array will be returned. + */ + public function all($db = null) + { + $command = $this->createCommand($db); + $rows = $command->queryAll(); + if (!empty($rows)) { + $models = $this->createModels($rows); + if (!empty($this->with)) { + $this->findWith($this->with, $models); + } + $models = $this->fillUpSnippets($models); + return $models; + } else { + return []; + } + } + + /** + * Executes query and returns a single row of result. + * @param Connection $db the DB connection used to create the DB command. + * If null, the DB connection returned by [[modelClass]] 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) + { + $command = $this->createCommand($db); + $row = $command->queryOne(); + if ($row !== false) { + if ($this->asArray) { + $model = $row; + } else { + /** @var $class ActiveRecord */ + $class = $this->modelClass; + $model = $class::create($row); + } + if (!empty($this->with)) { + $models = [$model]; + $this->findWith($this->with, $models); + $model = $models[0]; + } + list ($model) = $this->fillUpSnippets([$model]); + return $model; + } else { + return null; + } + } + + /** + * 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. + */ + public function createCommand($db = null) + { + /** @var $modelClass ActiveRecord */ + $modelClass = $this->modelClass; + $this->setConnection($db); + $db = $this->getConnection(); + + $params = $this->params; + if ($this->sql === null) { + if ($this->from === null) { + $tableName = $modelClass::indexName(); + if ($this->select === null && !empty($this->join)) { + $this->select = ["$tableName.*"]; + } + $this->from = [$tableName]; + } + list ($this->sql, $params) = $db->getQueryBuilder()->build($this); + } + return $db->createCommand($this->sql, $params); + } + + /** + * @inheritdoc + */ + protected function defaultConnection() + { + $modelClass = $this->modelClass; + return $modelClass::getDb(); + } + + /** + * Fetches the source for the snippets using [[ActiveRecord::getSnippetSource()]] method. + * @param ActiveRecord[] $models raw query result rows. + * @throws \yii\base\InvalidCallException if [[asArray]] enabled. + * @return array snippet source strings + */ + protected function fetchSnippetSourceFromModels($models) + { + if ($this->asArray) { + throw new InvalidCallException('"' . __METHOD__ . '" unable to determine snippet source from plain array. Either disable "asArray" option or use regular "snippetCallback"'); + } + $result = []; + foreach ($models as $model) { + $result[] = $model->getSnippetSource(); + } + return $result; + } +} \ No newline at end of file diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php new file mode 100644 index 0000000..d83db62 --- /dev/null +++ b/extensions/sphinx/ActiveRecord.php @@ -0,0 +1,1392 @@ + + * @since 2.0 + */ +abstract class ActiveRecord extends Model +{ + /** + * @event Event an event that is triggered when the record is initialized via [[init()]]. + */ + const EVENT_INIT = 'init'; + /** + * @event Event an event that is triggered after the record is created and populated with query result. + */ + const EVENT_AFTER_FIND = 'afterFind'; + /** + * @event ModelEvent an event that is triggered before inserting a record. + * You may set [[ModelEvent::isValid]] to be false to stop the insertion. + */ + const EVENT_BEFORE_INSERT = 'beforeInsert'; + /** + * @event Event an event that is triggered after a record is inserted. + */ + const EVENT_AFTER_INSERT = 'afterInsert'; + /** + * @event ModelEvent an event that is triggered before updating a record. + * You may set [[ModelEvent::isValid]] to be false to stop the update. + */ + const EVENT_BEFORE_UPDATE = 'beforeUpdate'; + /** + * @event Event an event that is triggered after a record is updated. + */ + const EVENT_AFTER_UPDATE = 'afterUpdate'; + /** + * @event ModelEvent an event that is triggered before deleting a record. + * You may set [[ModelEvent::isValid]] to be false to stop the deletion. + */ + const EVENT_BEFORE_DELETE = 'beforeDelete'; + /** + * @event Event an event that is triggered after a record is deleted. + */ + const EVENT_AFTER_DELETE = 'afterDelete'; + + /** + * The insert operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. + */ + const OP_INSERT = 0x01; + /** + * The update operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. + */ + const OP_UPDATE = 0x02; + /** + * The delete operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional. + */ + const OP_DELETE = 0x04; + /** + * All three operations: insert, update, delete. + * This is a shortcut of the expression: OP_INSERT | OP_UPDATE | OP_DELETE. + */ + const OP_ALL = 0x07; + + /** + * @var array attribute values indexed by attribute names + */ + private $_attributes = []; + /** + * @var array old attribute values indexed by attribute names. + */ + private $_oldAttributes; + /** + * @var array related models indexed by the relation names + */ + private $_related = []; + /** + * @var string current snippet value for this Active Record instance. + * It will be filled up automatically when instance found using [[Query::snippetCallback]] + * or [[ActiveQuery::snippetByModel()]]. + */ + private $_snippet; + + /** + * Returns the Sphinx connection used by this AR class. + * By default, the "sphinx" application component is used as the Sphinx connection. + * You may override this method if you want to use a different Sphinx connection. + * @return Connection the Sphinx connection used by this AR class. + */ + public static function getDb() + { + return \Yii::$app->getComponent('sphinx'); + } + + /** + * Creates an [[ActiveQuery]] instance for query purpose. + * + * @param mixed $q the query parameter. This can be one of the followings: + * + * - a string: fulltext query by a query string and return the list + * of matching records. + * - an array of name-value pairs: query by a set of column values and return a single record matching all of them. + * - null: return a new [[ActiveQuery]] object for further query purpose. + * + * @return ActiveQuery|ActiveRecord[]|ActiveRecord|null When `$q` is null, a new [[ActiveQuery]] instance + * is returned; when `$q` is a string, an array of ActiveRecord objects matching it will be returned; + * when `$q` is an array, an ActiveRecord object matching it will be returned (null + * will be returned if there is no matching). + * @see createQuery() + */ + public static function find($q = null) + { + $query = static::createQuery(); + if (is_array($q)) { + return $query->where($q)->one(); + } elseif ($q !== null) { + return $query->match($q)->all(); + } + return $query; + } + + /** + * Creates an [[ActiveQuery]] instance with a given SQL statement. + * + * Note that because the SQL statement is already specified, calling additional + * query modification methods (such as `where()`, `order()`) on the created [[ActiveQuery]] + * instance will have no effect. However, calling `with()`, `asArray()` or `indexBy()` is + * still fine. + * + * Below is an example: + * + * ~~~ + * $customers = Article::findBySql("SELECT * FROM `idx_article` WHERE MATCH('development')")->all(); + * ~~~ + * + * @param string $sql the SQL statement to be executed + * @param array $params parameters to be bound to the SQL statement during execution. + * @return ActiveQuery the newly created [[ActiveQuery]] instance + */ + public static function findBySql($sql, $params = []) + { + $query = static::createQuery(); + $query->sql = $sql; + return $query->params($params); + } + + /** + * Updates the whole table using the provided attribute values and conditions. + * For example, to change the status to be 1 for all articles which status is 2: + * + * ~~~ + * Article::updateAll(['status' => 1], 'status = 2'); + * ~~~ + * + * @param array $attributes attribute values (name-value pairs) to be saved into the table + * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return integer the number of rows updated + */ + public static function updateAll($attributes, $condition = '', $params = []) + { + $command = static::getDb()->createCommand(); + $command->update(static::indexName(), $attributes, $condition, $params); + return $command->execute(); + } + + /** + * Deletes rows in the index using the provided conditions. + * + * For example, to delete all articles whose status is 3: + * + * ~~~ + * Article::deleteAll('status = 3'); + * ~~~ + * + * @param string|array $condition the conditions that will be put in the WHERE part of the DELETE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return integer the number of rows deleted + */ + public static function deleteAll($condition = '', $params = []) + { + $command = static::getDb()->createCommand(); + $command->delete(static::indexName(), $condition, $params); + return $command->execute(); + } + + /** + * Creates an [[ActiveQuery]] instance. + * This method is called by [[find()]], [[findBySql()]] and [[count()]] to start a SELECT query. + * You may override this method to return a customized query (e.g. `ArticleQuery` specified + * written for querying `Article` purpose.) + * @return ActiveQuery the newly created [[ActiveQuery]] instance. + */ + public static function createQuery() + { + return new ActiveQuery(['modelClass' => get_called_class()]); + } + + /** + * Declares the name of the Sphinx index associated with this AR class. + * By default this method returns the class name as the index name by calling [[Inflector::camel2id()]]. + * For example, 'Article' becomes 'article', and 'StockItem' becomes + * 'stock_item'. You may override this method if the index is not named after this convention. + * @return string the index name + */ + public static function indexName() + { + return Inflector::camel2id(StringHelper::basename(get_called_class()), '_'); + } + + /** + * Returns the schema information of the Sphinx index associated with this AR class. + * @return IndexSchema the schema information of the Sphinx index associated with this AR class. + * @throws InvalidConfigException if the index for the AR class does not exist. + */ + public static function getIndexSchema() + { + $schema = static::getDb()->getIndexSchema(static::indexName()); + if ($schema !== null) { + return $schema; + } else { + throw new InvalidConfigException("The index does not exist: " . static::indexName()); + } + } + + /** + * Returns the primary key name for this AR class. + * The default implementation will return the primary key as declared + * in the Sphinx index, which is associated with this AR class. + * + * Note that an array should be returned even for a table with single primary key. + * + * @return string[] the primary keys of the associated Sphinx index. + */ + public static function primaryKey() + { + return [static::getIndexSchema()->primaryKey]; + } + + /** + * Builds a snippet from provided data and query, using specified index settings. + * @param string|array $source is the source data to extract a snippet from. + * It could be either a single string or array of strings. + * @param string $match the full-text query to build snippets for. + * @param array $options list of options in format: optionName => optionValue + * @return string|array built snippet in case "source" is a string, list of built snippets + * in case "source" is an array. + */ + public static function callSnippets($source, $match, $options = []) + { + $command = static::getDb()->createCommand(); + $command->callSnippets(static::indexName(), $source, $match, $options); + if (is_array($source)) { + return $command->queryColumn(); + } else { + return $command->queryScalar(); + } + } + + /** + * Returns tokenized and normalized forms of the keywords, and, optionally, keyword statistics. + * @param string $text the text to break down to keywords. + * @param boolean $fetchStatistic whether to return document and hit occurrence statistics + * @return array keywords and statistics + */ + public static function callKeywords($text, $fetchStatistic = false) + { + $command = static::getDb()->createCommand(); + $command->callKeywords(static::indexName(), $text, $fetchStatistic); + return $command->queryAll(); + } + + /** + * @param string $snippet + */ + public function setSnippet($snippet) + { + $this->_snippet = $snippet; + } + + /** + * Returns current snippet value or generates new one from given match. + * @param string $match snippet source query + * @param array $options list of options in format: optionName => optionValue + * @return string snippet value + */ + public function getSnippet($match = null, $options = []) + { + if ($match !== null) { + $this->_snippet = $this->fetchSnippet($match, $options); + } + return $this->_snippet; + } + + /** + * Builds up the snippet value from the given query. + * @param string $match the full-text query to build snippets for. + * @param array $options list of options in format: optionName => optionValue + * @return string snippet value. + */ + protected function fetchSnippet($match, $options = []) + { + return static::callSnippets($this->getSnippetSource(), $match, $options); + } + + /** + * Returns the string, which should be used as a source to create snippet for this + * Active Record instance. + * Child classes must implement this method to return the actual snippet source text. + * For example: + * ~~~ + * public function getSnippetSource() + * { + * return $this->snippetSourceRelation->content; + * } + * ~~~ + * @return string snippet source string. + * @throws \yii\base\NotSupportedException if this is not supported by the Active Record class + */ + public function getSnippetSource() + { + throw new NotSupportedException($this->className() . ' does not provide snippet source.'); + } + + /** + * Returns the name of the column that stores the lock version for implementing optimistic locking. + * + * Optimistic locking allows multiple users to access the same record for edits and avoids + * potential conflicts. In case when a user attempts to save the record upon some staled data + * (because another user has modified the data), a [[StaleObjectException]] exception will be thrown, + * and the update or deletion is skipped. + * + * Optimistic locking is only supported by [[update()]] and [[delete()]]. + * + * To use optimistic locking: + * + * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`. + * Override this method to return the name of this column. + * 2. In the Web form that collects the user input, add a hidden field that stores + * the lock version of the recording being updated. + * 3. In the controller action that does the data updating, try to catch the [[StaleObjectException]] + * and implement necessary business logic (e.g. merging the changes, prompting stated data) + * to resolve the conflict. + * + * Warning: optimistic lock will NOT work in case of updating fields (not attributes) for the + * runtime indexes! + * + * @return string the column name that stores the lock version of a table row. + * If null is returned (default implemented), optimistic locking will not be supported. + */ + public function optimisticLock() + { + return null; + } + + /** + * Declares which operations should be performed within a transaction in different scenarios. + * The supported DB operations are: [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]], + * which correspond to the [[insert()]], [[update()]] and [[delete()]] methods, respectively. + * By default, these methods are NOT enclosed in a transaction. + * + * In some scenarios, to ensure data consistency, you may want to enclose some or all of them + * in transactions. You can do so by overriding this method and returning the operations + * that need to be transactional. For example, + * + * ~~~ + * return [ + * 'admin' => self::OP_INSERT, + * 'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE, + * // the above is equivalent to the following: + * // 'api' => self::OP_ALL, + * + * ]; + * ~~~ + * + * The above declaration specifies that in the "admin" scenario, the insert operation ([[insert()]]) + * should be done in a transaction; and in the "api" scenario, all the operations should be done + * in a transaction. + * + * @return array the declarations of transactional operations. The array keys are scenarios names, + * and the array values are the corresponding transaction operations. + */ + public function transactions() + { + return []; + } + + /** + * PHP getter magic method. + * This method is overridden so that attributes and related objects can be accessed like properties. + * @param string $name property name + * @return mixed property value + * @see getAttribute + */ + public function __get($name) + { + if (isset($this->_attributes[$name]) || array_key_exists($name, $this->_attributes)) { + return $this->_attributes[$name]; + } elseif ($this->hasAttribute($name)) { + return null; + } else { + if (isset($this->_related[$name]) || array_key_exists($name, $this->_related)) { + return $this->_related[$name]; + } + $value = parent::__get($name); + if ($value instanceof ActiveRelationInterface) { + return $this->_related[$name] = $value->multiple ? $value->all() : $value->one(); + } else { + return $value; + } + } + } + + /** + * PHP setter magic method. + * This method is overridden so that AR attributes can be accessed like properties. + * @param string $name property name + * @param mixed $value property value + */ + public function __set($name, $value) + { + if ($this->hasAttribute($name)) { + $this->_attributes[$name] = $value; + } else { + parent::__set($name, $value); + } + } + + /** + * Checks if a property value is null. + * This method overrides the parent implementation by checking if the named attribute is null or not. + * @param string $name the property name or the event name + * @return boolean whether the property value is null + */ + public function __isset($name) + { + try { + return $this->__get($name) !== null; + } catch (\Exception $e) { + return false; + } + } + + /** + * Sets a component property to be null. + * This method overrides the parent implementation by clearing + * the specified attribute value. + * @param string $name the property name or the event name + */ + public function __unset($name) + { + if ($this->hasAttribute($name)) { + unset($this->_attributes[$name]); + } else { + if (isset($this->_related[$name])) { + unset($this->_related[$name]); + } else { + parent::__unset($name); + } + } + } + + /** + * Declares a `has-one` relation. + * The declaration is returned in terms of an [[ActiveRelationInterface]] instance + * through which the related record can be queried and retrieved back. + * + * A `has-one` relation means that there is at most one related record matching + * the criteria set by this relation, e.g., a particular index has one source. + * + * For example, to declare the `source` relation for `ArticleIndex` class, we can write + * the following code in the `ArticleIndex` class: + * + * ~~~ + * public function getSource() + * { + * return $this->hasOne('db', ArticleContent::className(), ['article_id' => 'id']); + * } + * ~~~ + * + * Note that in the above, the 'article_id' key in the `$link` parameter refers to an attribute name + * in the related class `ArticleContent`, while the 'id' value refers to an attribute name + * in the current AR class. + * + * @param string $type relation type or class name. + * - if value contains backslash ("\"), it is treated as full active relation class name, + * for example: "app\mydb\ActiveRelation" + * - if value does not contain backslash ("\"), the active relation class name will be composed + * by pattern: "yii\{type}\ActiveRelation", for example: type "db" refers "yii\db\ActiveRelation", + * type "sphinx" - "yii\sphinx\ActiveRelation" + * @param string $class the class name of the related record + * @param array $link the primary-foreign key constraint. The keys of the array refer to + * the attributes in the `$class` model, while the values of the array refer to the corresponding + * attributes in the index associated with this AR class. + * @return ActiveRelationInterface the relation object. + */ + public function hasOne($type, $class, $link) + { + return $this->createActiveRelation($type, [ + 'modelClass' => $class, + 'primaryModel' => $this, + 'link' => $link, + 'multiple' => false, + ]); + } + + /** + * Declares a `has-many` relation. + * The declaration is returned in terms of an [[ActiveRelationInterface]] instance + * through which the related record can be queried and retrieved back. + * + * A `has-many` relation means that there are multiple related records matching + * the criteria set by this relation, e.g., an article has many tags. + * + * For example, to declare the `tags` relation for `ArticleIndex` class, we can write + * the following code in the `ArticleIndex` class: + * + * ~~~ + * public function getOrders() + * { + * return $this->hasMany('db', Tag::className(), ['id' => 'tag_id']); + * } + * ~~~ + * + * Note that in the above, the 'id' key in the `$link` parameter refers to + * an attribute name in the related class `Tag`, while the 'tag_id' value refers to + * a multi value attribute name in the current AR class. + * + * @param string $type relation type or class name. + * - if value contains backslash ("\"), it is treated as full active relation class name, + * for example: "app\mydb\ActiveRelation" + * - if value does not contain backslash ("\"), the active relation class name will be composed + * by pattern: "yii\{type}\ActiveRelation", for example: type "db" refers "yii\db\ActiveRelation", + * type "sphinx" - "yii\sphinx\ActiveRelation" + * @param string $class the class name of the related record + * @param array $link the primary-foreign key constraint. The keys of the array refer to + * the columns in the table associated with the `$class` model, while the values of the + * array refer to the corresponding columns in the table associated with this AR class. + * @return ActiveRelationInterface the relation object. + */ + public function hasMany($type, $class, $link) + { + return $this->createActiveRelation($type, [ + 'modelClass' => $class, + 'primaryModel' => $this, + 'link' => $link, + 'multiple' => true, + ]); + } + + /** + * Creates an [[ActiveRelationInterface]] instance. + * This method is called by [[hasOne()]] and [[hasMany()]] to create a relation instance. + * You may override this method to return a customized relation. + * @param string $type relation type or class name. + * @param array $config the configuration passed to the ActiveRelation class. + * @return ActiveRelationInterface the newly created [[ActiveRelation]] instance. + */ + protected function createActiveRelation($type, $config = []) + { + if (strpos($type, '\\') === false) { + $class = "yii\\{$type}\\ActiveRelation"; + } else { + $class = $type; + } + $config['class'] = $class; + return Yii::createObject($config); + } + + /** + * Populates the named relation with the related records. + * Note that this method does not check if the relation exists or not. + * @param string $name the relation name (case-sensitive) + * @param ActiveRecord|array|null the related records to be populated into the relation. + */ + public function populateRelation($name, $records) + { + $this->_related[$name] = $records; + } + + /** + * Check whether the named relation has been populated with records. + * @param string $name the relation name (case-sensitive) + * @return bool whether relation has been populated with records. + */ + public function isRelationPopulated($name) + { + return array_key_exists($name, $this->_related); + } + + /** + * Returns all populated relations. + * @return array an array of relation data indexed by relation names. + */ + public function getPopulatedRelations() + { + return $this->_related; + } + + /** + * Returns the list of all attribute names of the 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() + { + return array_keys($this->getIndexSchema()->columns); + } + + /** + * Returns a value indicating whether the model has an attribute with the specified name. + * @param string $name the name of the attribute + * @return boolean whether the model has an attribute with the specified name. + */ + public function hasAttribute($name) + { + return isset($this->_attributes[$name]) || isset($this->getIndexSchema()->columns[$name]); + } + + /** + * Returns the named attribute value. + * If this record is the result of a query and the attribute is not loaded, + * null will be returned. + * @param string $name the attribute name + * @return mixed the attribute value. Null if the attribute is not set or does not exist. + * @see hasAttribute + */ + public function getAttribute($name) + { + return isset($this->_attributes[$name]) ? $this->_attributes[$name] : null; + } + + /** + * Sets the named attribute value. + * @param string $name the attribute name + * @param mixed $value the attribute value. + * @throws InvalidParamException if the named attribute does not exist. + * @see hasAttribute + */ + public function setAttribute($name, $value) + { + if ($this->hasAttribute($name)) { + $this->_attributes[$name] = $value; + } else { + throw new InvalidParamException(get_class($this) . ' has no attribute named "' . $name . '".'); + } + } + + /** + * Returns the old attribute values. + * @return array the old attribute values (name-value pairs) + */ + public function getOldAttributes() + { + return $this->_oldAttributes === null ? [] : $this->_oldAttributes; + } + + /** + * Sets the old attribute values. + * All existing old attribute values will be discarded. + * @param array $values old attribute values to be set. + */ + public function setOldAttributes($values) + { + $this->_oldAttributes = $values; + } + + /** + * Returns the old value of the named attribute. + * If this record is the result of a query and the attribute is not loaded, + * null will be returned. + * @param string $name the attribute name + * @return mixed the old attribute value. Null if the attribute is not loaded before + * or does not exist. + * @see hasAttribute + */ + public function getOldAttribute($name) + { + return isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; + } + + /** + * Sets the old value of the named attribute. + * @param string $name the attribute name + * @param mixed $value the old attribute value. + * @throws InvalidParamException if the named attribute does not exist. + * @see hasAttribute + */ + public function setOldAttribute($name, $value) + { + if (isset($this->_oldAttributes[$name]) || $this->hasAttribute($name)) { + $this->_oldAttributes[$name] = $value; + } else { + throw new InvalidParamException(get_class($this) . ' has no attribute named "' . $name . '".'); + } + } + + /** + * Returns a value indicating whether the named attribute has been changed. + * @param string $name the name of the attribute + * @return boolean whether the attribute has been changed + */ + public function isAttributeChanged($name) + { + if (isset($this->_attributes[$name], $this->_oldAttributes[$name])) { + return $this->_attributes[$name] !== $this->_oldAttributes[$name]; + } else { + return isset($this->_attributes[$name]) || isset($this->_oldAttributes[$name]); + } + } + + /** + * Returns the attribute values that have been modified since they are loaded or saved most recently. + * @param string[]|null $names the names of the attributes whose values may be returned if they are + * changed recently. If null, [[attributes()]] will be used. + * @return array the changed attribute values (name-value pairs) + */ + public function getDirtyAttributes($names = null) + { + if ($names === null) { + $names = $this->attributes(); + } + $names = array_flip($names); + $attributes = []; + if ($this->_oldAttributes === null) { + foreach ($this->_attributes as $name => $value) { + if (isset($names[$name])) { + $attributes[$name] = $value; + } + } + } else { + foreach ($this->_attributes as $name => $value) { + if (isset($names[$name]) && (!array_key_exists($name, $this->_oldAttributes) || $value !== $this->_oldAttributes[$name])) { + $attributes[$name] = $value; + } + } + } + return $attributes; + } + + /** + * Saves the current record. + * + * This method will call [[insert()]] when [[isNewRecord]] is true, or [[update()]] + * when [[isNewRecord]] is false. + * + * For example, to save an article record: + * + * ~~~ + * $customer = new Article; // or $customer = Article::find(['id' => $id]); + * $customer->id = $id; + * $customer->genre_id = $genreId; + * $customer->content = $email; + * $customer->save(); + * ~~~ + * + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be saved. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from index will be saved. + * @return boolean whether the saving succeeds + */ + public function save($runValidation = true, $attributes = null) + { + if ($this->getIsNewRecord()) { + return $this->insert($runValidation, $attributes); + } else { + return $this->update($runValidation, $attributes) !== false; + } + } + + /** + * Inserts a row into the associated Sphinx index using the attribute values of this record. + * + * This method performs the following steps in order: + * + * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation + * fails, it will skip the rest of the steps; + * 2. call [[afterValidate()]] when `$runValidation` is true. + * 3. call [[beforeSave()]]. If the method returns false, it will skip the + * rest of the steps; + * 4. insert the record into index. If this fails, it will skip the rest of the steps; + * 5. call [[afterSave()]]; + * + * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], + * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]] + * will be raised by the corresponding methods. + * + * Only the [[changedAttributes|changed attribute values]] will be inserted. + * + * For example, to insert an article record: + * + * ~~~ + * $article = new Article; + * $article->id = $id; + * $article->genre_id = $genreId; + * $article->content = $content; + * $article->insert(); + * ~~~ + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be inserted. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from index will be saved. + * @return boolean whether the attributes are valid and the record is inserted successfully. + * @throws \Exception in case insert failed. + */ + public function insert($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + $db = static::getDb(); + if ($this->isTransactional(self::OP_INSERT) && $db->getTransaction() === null) { + $transaction = $db->beginTransaction(); + try { + $result = $this->insertInternal($attributes); + if ($result === false) { + $transaction->rollback(); + } else { + $transaction->commit(); + } + } catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + } else { + $result = $this->insertInternal($attributes); + } + return $result; + } + + /** + * @see ActiveRecord::insert() + */ + private function insertInternal($attributes = null) + { + if (!$this->beforeSave(true)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + foreach ($this->primaryKey() as $key) { + $values[$key] = isset($this->_attributes[$key]) ? $this->_attributes[$key] : null; + } + } + $db = static::getDb(); + $command = $db->createCommand()->insert($this->indexName(), $values); + if (!$command->execute()) { + return false; + } + foreach ($values as $name => $value) { + $this->_oldAttributes[$name] = $value; + } + $this->afterSave(true); + return true; + } + + /** + * Saves the changes to this active record into the associated Sphinx index. + * + * This method performs the following steps in order: + * + * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation + * fails, it will skip the rest of the steps; + * 2. call [[afterValidate()]] when `$runValidation` is true. + * 3. call [[beforeSave()]]. If the method returns false, it will skip the + * rest of the steps; + * 4. save the record into index. If this fails, it will skip the rest of the steps; + * 5. call [[afterSave()]]; + * + * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], + * [[EVENT_BEFORE_UPDATE]], [[EVENT_AFTER_UPDATE]] and [[EVENT_AFTER_VALIDATE]] + * will be raised by the corresponding methods. + * + * Only the [[changedAttributes|changed attribute values]] will be saved into database. + * + * For example, to update an article record: + * + * ~~~ + * $article = Article::find(['id' => $id]); + * $article->genre_id = $genreId; + * $article->group_id = $groupId; + * $article->update(); + * ~~~ + * + * Note that it is possible the update does not affect any row in the table. + * In this case, this method will return 0. For this reason, you should use the following + * code to check if update() is successful or not: + * + * ~~~ + * if ($this->update() !== false) { + * // update successful + * } else { + * // update failed + * } + * ~~~ + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be inserted into the database. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from DB will be saved. + * @return integer|boolean the number of rows affected, or false if validation fails + * or [[beforeSave()]] stops the updating process. + * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data + * being updated is outdated. + * @throws \Exception in case update failed. + */ + public function update($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + $db = static::getDb(); + if ($this->isTransactional(self::OP_UPDATE) && $db->getTransaction() === null) { + $transaction = $db->beginTransaction(); + try { + $result = $this->updateInternal($attributes); + if ($result === false) { + $transaction->rollback(); + } else { + $transaction->commit(); + } + } catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + } else { + $result = $this->updateInternal($attributes); + } + return $result; + } + + /** + * @see CActiveRecord::update() + * @throws StaleObjectException + */ + private function updateInternal($attributes = null) + { + if (!$this->beforeSave(false)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $this->afterSave(false); + return 0; + } + + // Replace is supported only by runtime indexes and necessary only for field update + $useReplace = false; + $indexSchema = $this->getIndexSchema(); + if ($this->getIndexSchema()->isRuntime) { + foreach ($values as $name => $value) { + $columnSchema = $indexSchema->getColumn($name); + if ($columnSchema->isField) { + $useReplace = true; + break; + } + } + } + + if ($useReplace) { + $values = array_merge($values, $this->getOldPrimaryKey(true)); + $command = static::getDb()->createCommand(); + $command->replace(static::indexName(), $values); + // We do not check the return value of replace because it's possible + // that the REPLACE statement doesn't change anything and thus returns 0. + $rows = $command->execute(); + } else { + $condition = $this->getOldPrimaryKey(true); + $lock = $this->optimisticLock(); + if ($lock !== null) { + if (!isset($values[$lock])) { + $values[$lock] = $this->$lock + 1; + } + $condition[$lock] = $this->$lock; + } + // We do not check the return value of updateAll() because it's possible + // that the UPDATE statement doesn't change anything and thus returns 0. + $rows = $this->updateAll($values, $condition); + + if ($lock !== null && !$rows) { + throw new StaleObjectException('The object being updated is outdated.'); + } + } + + foreach ($values as $name => $value) { + $this->_oldAttributes[$name] = $this->_attributes[$name]; + } + $this->afterSave(false); + return $rows; + } + + /** + * Deletes the index entry corresponding to this active record. + * + * This method performs the following steps in order: + * + * 1. call [[beforeDelete()]]. If the method returns false, it will skip the + * rest of the steps; + * 2. delete the record from the index; + * 3. call [[afterDelete()]]. + * + * In the above step 1 and 3, events named [[EVENT_BEFORE_DELETE]] and [[EVENT_AFTER_DELETE]] + * will be raised by the corresponding methods. + * + * @return integer|boolean the number of rows deleted, or false if the deletion is unsuccessful for some reason. + * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful. + * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data + * being deleted is outdated. + * @throws \Exception in case delete failed. + */ + public function delete() + { + $db = static::getDb(); + $transaction = $this->isTransactional(self::OP_DELETE) && $db->getTransaction() === null ? $db->beginTransaction() : null; + try { + $result = false; + if ($this->beforeDelete()) { + // we do not check the return value of deleteAll() because it's possible + // the record is already deleted in the database and thus the method will return 0 + $condition = $this->getOldPrimaryKey(true); + $lock = $this->optimisticLock(); + if ($lock !== null) { + $condition[$lock] = $this->$lock; + } + $result = $this->deleteAll($condition); + if ($lock !== null && !$result) { + throw new StaleObjectException('The object being deleted is outdated.'); + } + $this->_oldAttributes = null; + $this->afterDelete(); + } + if ($transaction !== null) { + if ($result === false) { + $transaction->rollback(); + } else { + $transaction->commit(); + } + } + } catch (\Exception $e) { + if ($transaction !== null) { + $transaction->rollback(); + } + throw $e; + } + return $result; + } + + /** + * Returns a value indicating whether the current record is new. + * @return boolean whether the record is new and should be inserted when calling [[save()]]. + */ + public function getIsNewRecord() + { + return $this->_oldAttributes === null; + } + + /** + * Sets the value indicating whether the record is new. + * @param boolean $value whether the record is new and should be inserted when calling [[save()]]. + * @see getIsNewRecord + */ + public function setIsNewRecord($value) + { + $this->_oldAttributes = $value ? null : $this->_attributes; + } + + /** + * Initializes the object. + * This method is called at the end of the constructor. + * The default implementation will trigger an [[EVENT_INIT]] event. + * If you override this method, make sure you call the parent implementation at the end + * to ensure triggering of the event. + */ + public function init() + { + parent::init(); + $this->trigger(self::EVENT_INIT); + } + + /** + * This method is called when the AR object is created and populated with the query result. + * The default implementation will trigger an [[EVENT_AFTER_FIND]] event. + * When overriding this method, make sure you call the parent implementation to ensure the + * event is triggered. + */ + public function afterFind() + { + $this->trigger(self::EVENT_AFTER_FIND); + } + + /** + * This method is called at the beginning of inserting or updating a record. + * The default implementation will trigger an [[EVENT_BEFORE_INSERT]] event when `$insert` is true, + * or an [[EVENT_BEFORE_UPDATE]] event if `$insert` is false. + * When overriding this method, make sure you call the parent implementation like the following: + * + * ~~~ + * public function beforeSave($insert) + * { + * if (parent::beforeSave($insert)) { + * // ...custom code here... + * return true; + * } else { + * return false; + * } + * } + * ~~~ + * + * @param boolean $insert whether this method called while inserting a record. + * If false, it means the method is called while updating a record. + * @return boolean whether the insertion or updating should continue. + * If false, the insertion or updating will be cancelled. + */ + public function beforeSave($insert) + { + $event = new ModelEvent; + $this->trigger($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event); + return $event->isValid; + } + + /** + * This method is called at the end of inserting or updating a record. + * The default implementation will trigger an [[EVENT_AFTER_INSERT]] event when `$insert` is true, + * or an [[EVENT_AFTER_UPDATE]] event if `$insert` is false. + * When overriding this method, make sure you call the parent implementation so that + * the event is triggered. + * @param boolean $insert whether this method called while inserting a record. + * If false, it means the method is called while updating a record. + */ + public function afterSave($insert) + { + $this->trigger($insert ? self::EVENT_AFTER_INSERT : self::EVENT_AFTER_UPDATE); + } + + /** + * This method is invoked before deleting a record. + * The default implementation raises the [[EVENT_BEFORE_DELETE]] event. + * When overriding this method, make sure you call the parent implementation like the following: + * + * ~~~ + * public function beforeDelete() + * { + * if (parent::beforeDelete()) { + * // ...custom code here... + * return true; + * } else { + * return false; + * } + * } + * ~~~ + * + * @return boolean whether the record should be deleted. Defaults to true. + */ + public function beforeDelete() + { + $event = new ModelEvent; + $this->trigger(self::EVENT_BEFORE_DELETE, $event); + return $event->isValid; + } + + /** + * This method is invoked after deleting a record. + * The default implementation raises the [[EVENT_AFTER_DELETE]] event. + * You may override this method to do postprocessing after the record is deleted. + * Make sure you call the parent implementation so that the event is raised properly. + */ + public function afterDelete() + { + $this->trigger(self::EVENT_AFTER_DELETE); + } + + /** + * Repopulates this active record with the latest data. + * @return boolean whether the row still exists in the database. If true, the latest data + * will be populated to this active record. Otherwise, this record will remain unchanged. + */ + public function refresh() + { + $record = $this->find($this->getPrimaryKey(true)); + if ($record === null) { + return false; + } + foreach ($this->attributes() as $name) { + $this->_attributes[$name] = $record->_attributes[$name]; + } + $this->_oldAttributes = $this->_attributes; + $this->_related = []; + return true; + } + + /** + * Returns a value indicating whether the given active record is the same as the current one. + * The comparison is made by comparing the index names and the primary key values of the two active records. + * @param ActiveRecord $record record to compare to + * @return boolean whether the two active records refer to the same row in the same index. + */ + public function equals($record) + { + return $this->indexName() === $record->indexName() && $this->getPrimaryKey() === $record->getPrimaryKey(); + } + + /** + * Returns the primary key value. + * @param boolean $asArray whether to return the primary key value as an array. If true, + * the return value will be an array with column names as keys and column values as values. + * @return mixed the primary key value. An array (column name => column value) is returned + * if `$asArray` is true. A string is returned otherwise (null will be returned if + * the key value is null). + */ + public function getPrimaryKey($asArray = false) + { + $keys = $this->primaryKey(); + if (count($keys) === 1 && !$asArray) { + return isset($this->_attributes[$keys[0]]) ? $this->_attributes[$keys[0]] : null; + } else { + $values = []; + foreach ($keys as $name) { + $values[$name] = isset($this->_attributes[$name]) ? $this->_attributes[$name] : null; + } + return $values; + } + } + + /** + * Returns the old primary key value. + * This refers to the primary key value that is populated into the record + * after executing a find method (e.g. find(), findAll()). + * The value remains unchanged even if the primary key attribute is manually assigned with a different value. + * @param boolean $asArray whether to return the primary key value as an array. If true, + * the return value will be an array with column name as key and column value as value. + * If this is false (default), a scalar value will be returned. + * @return mixed the old primary key value. An array (column name => column value) is returned if + * `$asArray` is true. A string is returned otherwise (null will be returned if + * the key value is null). + */ + public function getOldPrimaryKey($asArray = false) + { + $keys = $this->primaryKey(); + if (count($keys) === 1 && !$asArray) { + return isset($this->_oldAttributes[$keys[0]]) ? $this->_oldAttributes[$keys[0]] : null; + } else { + $values = []; + foreach ($keys as $name) { + $values[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; + } + return $values; + } + } + + /** + * Creates an active record object using a row of data. + * This method is called by [[ActiveQuery]] to populate the query results + * into Active Records. It is not meant to be used to create new records. + * @param array $row attribute values (name => value) + * @return ActiveRecord the newly created active record. + */ + public static function create($row) + { + $record = static::instantiate($row); + $columns = static::getIndexSchema()->columns; + foreach ($row as $name => $value) { + if (isset($columns[$name])) { + $column = $columns[$name]; + if ($column->isMva) { + $value = explode(',', $value); + } + $record->_attributes[$name] = $value; + } else { + $record->$name = $value; + } + } + $record->_oldAttributes = $record->_attributes; + $record->afterFind(); + return $record; + } + + /** + * Creates an active record instance. + * This method is called by [[create()]]. + * You may override this method if the instance being created + * depends on the row data to be populated into the record. + * @param array $row row data to be populated into the record. + * @return ActiveRecord the newly created active record + */ + public static function instantiate($row) + { + return new static; + } + + /** + * Returns whether there is an element at the specified offset. + * This method is required by the interface ArrayAccess. + * @param mixed $offset the offset to check on + * @return boolean whether there is an element at the specified offset. + */ + public function offsetExists($offset) + { + return $this->__isset($offset); + } + + /** + * Returns the relation object with the specified name. + * A relation is defined by a getter method which returns an [[ActiveRelationInterface]] object. + * It can be declared in either the Active Record class itself or one of its behaviors. + * @param string $name the relation name + * @return ActiveRelationInterface the relation object + * @throws InvalidParamException if the named relation does not exist. + */ + public function getRelation($name) + { + $getter = 'get' . $name; + try { + $relation = $this->$getter(); + if ($relation instanceof ActiveRelationInterface) { + return $relation; + } else { + return null; + } + } catch (UnknownMethodException $e) { + throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".', 0, $e); + } + } + + /** + * Returns a value indicating whether the specified operation is transactional in the current [[scenario]]. + * @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) + { + $scenario = $this->getScenario(); + $transactions = $this->transactions(); + return isset($transactions[$scenario]) && ($transactions[$scenario] & $operation); + } + + /** + * Sets the element at the specified offset. + * This method is required by the SPL interface `ArrayAccess`. + * It is implicitly called when you use something like `$model[$offset] = $item;`. + * @param integer $offset the offset to set element + * @param mixed $item the element value + * @throws \Exception on failure + */ + public function offsetSet($offset, $item) + { + // Bypass relation owner restriction to 'yii\db\ActiveRecord' at [[yii\db\ActiveRelationTrait::findWith()]]: + try { + $relation = $this->getRelation($offset); + if (is_object($relation)) { + $this->populateRelation($offset, $item); + return; + } + } catch (UnknownMethodException $e) { + throw $e->getPrevious(); + } + parent::offsetSet($offset, $item); + } +} \ No newline at end of file diff --git a/extensions/sphinx/ActiveRelation.php b/extensions/sphinx/ActiveRelation.php new file mode 100644 index 0000000..c0dd0ca --- /dev/null +++ b/extensions/sphinx/ActiveRelation.php @@ -0,0 +1,22 @@ + + * @since 2.0 + */ +class ActiveRelation extends ActiveQuery implements ActiveRelationInterface +{ + use ActiveRelationTrait; +} \ No newline at end of file diff --git a/extensions/sphinx/ColumnSchema.php b/extensions/sphinx/ColumnSchema.php new file mode 100644 index 0000000..5edca85 --- /dev/null +++ b/extensions/sphinx/ColumnSchema.php @@ -0,0 +1,81 @@ + + * @since 2.0 + */ +class ColumnSchema extends Object +{ + /** + * @var string name of this column (without quotes). + */ + public $name; + /** + * @var string abstract type of this column. Possible abstract types include: + * string, text, boolean, smallint, integer, bigint, float, decimal, datetime, + * timestamp, time, date, binary, and money. + */ + public $type; + /** + * @var string the PHP type of this column. Possible PHP types include: + * string, boolean, integer, double. + */ + public $phpType; + /** + * @var string the DB type of this column. Possible DB types vary according to the type of DBMS. + */ + public $dbType; + /** + * @var boolean whether this column is a primary key + */ + public $isPrimaryKey; + /** + * @var boolean whether this column is an attribute + */ + public $isAttribute; + /** + * @var boolean whether this column is a indexed field + */ + public $isField; + /** + * @var boolean whether this column is a multi value attribute (MVA) + */ + public $isMva; + + /** + * Converts the input value according to [[phpType]]. + * If the value is null or an [[Expression]], it will not be converted. + * @param mixed $value input value + * @return mixed converted value + */ + public function typecast($value) + { + if ($value === null || gettype($value) === $this->phpType || $value instanceof Expression) { + return $value; + } + if ($value === '' && $this->type !== Schema::TYPE_STRING) { + return null; + } + switch ($this->phpType) { + case 'string': + return (string)$value; + case 'integer': + return (integer)$value; + case 'boolean': + return (boolean)$value; + } + return $value; + } +} \ No newline at end of file diff --git a/extensions/sphinx/Command.php b/extensions/sphinx/Command.php new file mode 100644 index 0000000..93c02f8 --- /dev/null +++ b/extensions/sphinx/Command.php @@ -0,0 +1,323 @@ +createCommand("SELECT * FROM `idx_article` WHERE MATCH('programming')")->queryAll(); + * ~~~ + * + * Command supports SQL statement preparation and parameter binding just as [[\yii\db\Command]] does. + * + * Command also supports building SQL statements by providing methods such as [[insert()]], + * [[update()]], etc. For example, + * + * ~~~ + * $connection->createCommand()->update('idx_article', [ + * 'genre_id' => 15, + * 'author_id' => 157, + * ])->execute(); + * ~~~ + * + * To build SELECT SQL statements, please use [[Query]] and [[QueryBuilder]] instead. + * + * @property \yii\sphinx\Connection $db the Sphinx connection that this command is associated with. + * + * @author Paul Klimov + * @since 2.0 + */ +class Command extends \yii\db\Command +{ + /** + * 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) + { + $params = []; + $sql = $this->db->getQueryBuilder()->batchInsert($index, $columns, $rows, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates an REPLACE 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 replaced. + * + * Note that the created command is not executed until [[execute()]] is called. + * + * @param string $index the index that new rows will be replaced into. + * @param array $columns the column data (name => value) to be replaced into the index. + * @return static the command object itself + */ + public function replace($index, $columns) + { + $params = []; + $sql = $this->db->getQueryBuilder()->replace($index, $columns, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Creates a batch REPLACE 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 replaced. + * @param array $columns the column names + * @param array $rows the rows to be batch replaced in the index + * @return static the command object itself + */ + public function batchReplace($index, $columns, $rows) + { + $params = []; + $sql = $this->db->getQueryBuilder()->batchReplace($index, $columns, $rows, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * 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 + * @param array $options list of options in format: optionName => optionValue + * @return static the command object itself + */ + public function update($index, $columns, $condition = '', $params = [], $options = []) + { + $sql = $this->db->getQueryBuilder()->update($index, $columns, $condition, $params, $options); + 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); + } + + /** + * Builds a snippet from provided data and query, using specified index settings. + * @param string $index name of the index, from which to take the text processing settings. + * @param string|array $source is the source data to extract a snippet from. + * It could be either a single string or array of strings. + * @param string $match the full-text query to build snippets for. + * @param array $options list of options in format: optionName => optionValue + * @return static the command object itself + */ + public function callSnippets($index, $source, $match, $options = []) + { + $params = []; + $sql = $this->db->getQueryBuilder()->callSnippets($index, $source, $match, $options, $params); + return $this->setSql($sql)->bindValues($params); + } + + /** + * Returns tokenized and normalized forms of the keywords, and, optionally, keyword statistics. + * @param string $index the name of the index from which to take the text processing settings + * @param string $text the text to break down to keywords. + * @param boolean $fetchStatistic whether to return document and hit occurrence statistics + * @return string the SQL statement for call keywords. + */ + public function callKeywords($index, $text, $fetchStatistic = false) + { + $params = []; + $sql = $this->db->getQueryBuilder()->callKeywords($index, $text, $fetchStatistic, $params); + return $this->setSql($sql)->bindValues($params); + } + + // Not Supported : + + /** + * @inheritdoc + */ + public function createTable($table, $columns, $options = null) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function renameTable($table, $newName) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropTable($table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function truncateTable($table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function addColumn($table, $column, $type) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropColumn($table, $column) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function renameColumn($table, $oldName, $newName) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function alterColumn($table, $column, $type) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function addPrimaryKey($name, $table, $columns) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropPrimaryKey($name, $table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function addForeignKey($name, $table, $columns, $refTable, $refColumns, $delete = null, $update = null) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropForeignKey($name, $table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function createIndex($name, $table, $columns, $unique = false) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function dropIndex($name, $table) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function resetSequence($table, $value = null) + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } + + /** + * @inheritdoc + */ + public function checkIntegrity($check = true, $schema = '') + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } +} \ No newline at end of file diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php new file mode 100644 index 0000000..a43b0c2 --- /dev/null +++ b/extensions/sphinx/Connection.php @@ -0,0 +1,129 @@ + 'mysql:host=127.0.0.1;port=9306;', + * 'username' => $username, + * 'password' => $password, + * ]); + * $connection->open(); + * ~~~ + * + * After the Sphinx connection is established, one can execute SQL statements like the following: + * ~~~ + * $command = $connection->createCommand("SELECT * FROM idx_article WHERE MATCH('programming')"); + * $articles = $command->queryAll(); + * $command = $connection->createCommand('UPDATE idx_article SET status=2 WHERE id=1'); + * $command->execute(); + * ~~~ + * + * For more information about how to perform various DB queries, please refer to [[Command]]. + * + * This class supports transactions exactly as "yii\db\Connection". + * + * Note: while this class extends "yii\db\Connection" some of its methods are not supported. + * + * @property Schema $schema The schema information for this Sphinx connection. This property is read-only. + * @property \yii\sphinx\QueryBuilder $queryBuilder The query builder for this Sphinx connection. This property is + * read-only. + * @method \yii\sphinx\Schema getSchema() The schema information for this Sphinx connection + * @method \yii\sphinx\QueryBuilder getQueryBuilder() the query builder for this Sphinx connection + * + * @author Paul Klimov + * @since 2.0 + */ +class Connection extends \yii\db\Connection +{ + /** + * @inheritdoc + */ + public $schemaMap = [ + '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); + } + + /** + * Alias of [[quoteIndexName()]]. + * @param string $name table name + * @return string the properly quoted table name + */ + public function quoteTableName($name) + { + return $this->quoteIndexName($name); + } + + /** + * Creates a command for execution. + * @param string $sql the SQL statement to be executed + * @param array $params the parameters to be bound to the SQL statement + * @return Command the Sphinx command + */ + public function createCommand($sql = null, $params = []) + { + $this->open(); + $command = new Command([ + 'db' => $this, + 'sql' => $sql, + ]); + return $command->bindValues($params); + } + + /** + * This method is not supported by Sphinx. + * @param string $sequenceName name of the sequence object + * @return string the row ID of the last row inserted, or the last value retrieved from the sequence object + * @throws \yii\base\NotSupportedException always. + */ + public function getLastInsertID($sequenceName = '') + { + throw new NotSupportedException('"' . __METHOD__ . '" is not supported.'); + } +} \ No newline at end of file diff --git a/extensions/sphinx/IndexSchema.php b/extensions/sphinx/IndexSchema.php new file mode 100644 index 0000000..2908e82 --- /dev/null +++ b/extensions/sphinx/IndexSchema.php @@ -0,0 +1,63 @@ + + * @since 2.0 + */ +class IndexSchema extends Object +{ + /** + * @var string name of this index. + */ + public $name; + /** + * @var string type of the index. + */ + public $type; + /** + * @var boolean whether this index is a runtime index. + */ + public $isRuntime; + /** + * @var string primary key of this index. + */ + public $primaryKey; + /** + * @var ColumnSchema[] column metadata of this index. Each array element is a [[ColumnSchema]] object, indexed by column names. + */ + public $columns = []; + + /** + * Gets the named column metadata. + * This is a convenient method for retrieving a named column even if it does not exist. + * @param string $name column name + * @return ColumnSchema metadata of the named column. Null if the named column does not exist. + */ + public function getColumn($name) + { + return isset($this->columns[$name]) ? $this->columns[$name] : null; + } + + /** + * Returns the names of all columns in this table. + * @return array list of column names + */ + public function getColumnNames() + { + return array_keys($this->columns); + } +} \ No newline at end of file diff --git a/extensions/sphinx/LICENSE.md b/extensions/sphinx/LICENSE.md new file mode 100644 index 0000000..0bb1a8d --- /dev/null +++ b/extensions/sphinx/LICENSE.md @@ -0,0 +1,32 @@ +The Yii framework is free software. It is released under the terms of +the following BSD License. + +Copyright © 2008-2013 by Yii Software LLC (http://www.yiisoft.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Yii Software LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/extensions/sphinx/Query.php b/extensions/sphinx/Query.php new file mode 100644 index 0000000..ff0dcba --- /dev/null +++ b/extensions/sphinx/Query.php @@ -0,0 +1,701 @@ +select('id, groupd_id') + * ->from('idx_item') + * ->limit(10); + * // build and execute the query + * $command = $query->createCommand(); + * // $command->sql returns the actual SQL + * $rows = $command->queryAll(); + * ~~~ + * + * Since Sphinx does not store the original indexed text, the snippets for the rows in query result + * should be build separately via another query. You can simplify this workflow using [[snippetCallback]]. + * + * Warning: even if you do not set any query limit, implicit LIMIT 0,20 is present by default! + * + * @author Paul Klimov + * @since 2.0 + */ +class Query extends Component implements QueryInterface +{ + use QueryTrait; + + /** + * @var array the columns being selected. For example, `['id', 'group_id']`. + * This is used to construct the SELECT clause in a SQL statement. If not set, if means selecting all columns. + * @see select() + */ + public $select; + /** + * @var string additional option that should be appended to the 'SELECT' keyword. + */ + public $selectOption; + /** + * @var boolean whether to select distinct rows of data only. If this is set true, + * the SELECT clause would be changed to SELECT DISTINCT. + */ + public $distinct; + /** + * @var array the index(es) to be selected from. For example, `['idx_user', 'idx_user_delta']`. + * This is used to construct the FROM clause in a SQL statement. + * @see from() + */ + public $from; + /** + * @var string text, which should be searched in fulltext mode. + * This value will be composed into MATCH operator inside the WHERE clause. + */ + public $match; + /** + * @var array how to group the query results. For example, `['company', 'department']`. + * This is used to construct the GROUP BY clause in a SQL statement. + */ + public $groupBy; + /** + * @var string WITHIN GROUP ORDER BY clause. This is a Sphinx specific extension + * that lets you control how the best row within a group will to be selected. + * The possible value matches the [[orderBy]] one. + */ + public $within; + /** + * @var array per-query options in format: optionName => optionValue + * They will compose OPTION clause. This is a Sphinx specific extension + * that lets you control a number of per-query options. + */ + public $options; + /** + * @var array list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + */ + public $params; + /** + * @var callback PHP callback, which should be used to fetch source data for the snippets. + * Such callback will receive array of query result rows as an argument and must return the + * array of snippet source strings in the order, which match one of incoming rows. + * For example: + * ~~~ + * $query = new Query; + * $query->from('idx_item') + * ->match('pencil') + * ->snippetCallback(function ($rows) { + * $result = []; + * foreach ($rows as $row) { + * $result[] = file_get_contents('/path/to/index/files/' . $row['id'] . '.txt'); + * } + * return $result; + * }) + * ->all(); + * ~~~ + */ + public $snippetCallback; + /** + * @var array query options for the call snippet. + */ + public $snippetOptions; + /** + * @var Connection the Sphinx connection used to generate the SQL statements. + */ + private $_connection; + + /** + * @param Connection $connection Sphinx connection instance + * @return static the query object itself + */ + public function setConnection($connection) + { + $this->_connection = $connection; + return $this; + } + + /** + * @return Connection Sphinx connection instance + */ + public function getConnection() + { + if ($this->_connection === null) { + $this->_connection = $this->defaultConnection(); + } + return $this->_connection; + } + + /** + * @return Connection default connection value. + */ + protected function defaultConnection() + { + return Yii::$app->getComponent('sphinx'); + } + + /** + * Creates a Sphinx command that can be used to execute this query. + * @param Connection $connection the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return Command the created Sphinx command instance. + */ + public function createCommand($connection = null) + { + $this->setConnection($connection); + $connection = $this->getConnection(); + list ($sql, $params) = $connection->getQueryBuilder()->build($this); + return $connection->createCommand($sql, $params); + } + + /** + * Executes the query and returns all results as an array. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return array the query results. If the query results in nothing, an empty array will be returned. + */ + public function all($db = null) + { + $rows = $this->createCommand($db)->queryAll(); + $rows = $this->fillUpSnippets($rows); + if ($this->indexBy === null) { + return $rows; + } + $result = []; + foreach ($rows as $row) { + if (is_string($this->indexBy)) { + $key = $row[$this->indexBy]; + } else { + $key = call_user_func($this->indexBy, $row); + } + $result[$key] = $row; + } + return $result; + } + + /** + * Executes the query and returns a single row of result. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component 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. + */ + public function one($db = null) + { + $row = $this->createCommand($db)->queryOne(); + if ($row !== false) { + list ($row) = $this->fillUpSnippets([$row]); + } + return $row; + } + + /** + * 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 Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` 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($db = null) + { + return $this->createCommand($db)->queryScalar(); + } + + /** + * Executes the query and returns the first column of the result. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` 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($db = null) + { + return $this->createCommand($db)->queryColumn(); + } + + /** + * Returns the number of records. + * @param string $q the COUNT expression. Defaults to '*'. + * Make sure you properly quote column names in the expression. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return integer number of records + */ + public function count($q = '*', $db = null) + { + $this->select = ["COUNT($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the sum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return integer the sum of the specified column values + */ + public function sum($q, $db = null) + { + $this->select = ["SUM($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the average of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return integer the average of the specified column values. + */ + public function average($q, $db = null) + { + $this->select = ["AVG($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the minimum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return integer the minimum of the specified column values. + */ + public function min($q, $db = null) + { + $this->select = ["MIN($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns the maximum of the specified column values. + * @param string $q the column name or expression. + * Make sure you properly quote column names in the expression. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return integer the maximum of the specified column values. + */ + public function max($q, $db = null) + { + $this->select = ["MAX($q)"]; + return $this->createCommand($db)->queryScalar(); + } + + /** + * Returns a value indicating whether the query result contains any row of data. + * @param Connection $db the Sphinx connection used to generate the SQL statement. + * If this parameter is not given, the `sphinx` application component will be used. + * @return boolean whether the query result contains any row of data. + */ + public function exists($db = null) + { + $this->select = [new Expression('1')]; + return $this->scalar($db) !== false; + } + + /** + * Sets the SELECT part of the query. + * @param string|array $columns the columns to be selected. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a Sphinx expression). + * @param string $option additional option that should be appended to the 'SELECT' keyword. + * @return static the query object itself + */ + public function select($columns, $option = null) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + $this->select = $columns; + $this->selectOption = $option; + return $this; + } + + /** + * Sets the value indicating whether to SELECT DISTINCT or not. + * @param bool $value whether to SELECT DISTINCT or not. + * @return static the query object itself + */ + public function distinct($value = true) + { + $this->distinct = $value; + return $this; + } + + /** + * Sets the FROM part of the query. + * @param string|array $tables the table(s) to be selected from. This can be either a string (e.g. `'idx_user'`) + * or an array (e.g. `['idx_user', 'idx_user_delta']`) specifying one or several index names. + * The method will automatically quote the table names unless it contains some parenthesis + * (which means the table is given as a sub-query or Sphinx expression). + * @return static the query object itself + */ + public function from($tables) + { + if (!is_array($tables)) { + $tables = preg_split('/\s*,\s*/', trim($tables), -1, PREG_SPLIT_NO_EMPTY); + } + $this->from = $tables; + return $this; + } + + /** + * Sets the fulltext query text. This text will be composed into + * MATCH operator inside the WHERE clause. + * @param string $query fulltext query text. + * @return static the query object itself + */ + public function match($query) + { + $this->match = $query; + return $this; + } + + /** + * Sets the WHERE part of the query. + * + * The method requires a $condition parameter, and optionally a $params parameter + * specifying the values to be bound to the query. + * + * The $condition parameter should be either a string (e.g. 'id=1') or an array. + * If the latter, it must be in one of the following two formats: + * + * - hash format: `['column1' => value1, 'column2' => value2, ...]` + * - operator format: `[operator, operand1, operand2, ...]` + * + * A condition in hash format represents the following SQL expression in general: + * `column1=value1 AND column2=value2 AND ...`. In case when a value is an array, + * an `IN` expression will be generated. And if a value is null, `IS NULL` will be used + * in the generated expression. Below are some examples: + * + * - `['type' => 1, 'status' => 2]` generates `(type = 1) AND (status = 2)`. + * - `['id' => [1, 2, 3], 'status' => 2]` generates `(id IN (1, 2, 3)) AND (status = 2)`. + * - `['status' => null] generates `status IS NULL`. + * + * A condition in operator format generates the SQL expression according to the specified operator, which + * can be one of the followings: + * + * - `and`: the operands should be concatenated together using `AND`. For example, + * `['and', 'id=1', 'id=2']` will generate `id=1 AND id=2`. If an operand is an array, + * it will be converted into a string using the rules described here. For example, + * `['and', 'type=1', ['or', 'id=1', 'id=2']]` will generate `type=1 AND (id=1 OR id=2)`. + * The method will NOT do any quoting or escaping. + * + * - `or`: similar to the `and` operator except that the operands are concatenated using `OR`. + * + * - `between`: operand 1 should be the column name, and operand 2 and 3 should be the + * starting and ending values of the range that the column is in. + * For example, `['between', 'id', 1, 10]` will generate `id BETWEEN 1 AND 10`. + * + * - `not between`: similar to `between` except the `BETWEEN` is replaced with `NOT BETWEEN` + * in the generated condition. + * + * - `in`: operand 1 should be a column or DB expression, and operand 2 be an array representing + * the range of the values that the column or DB expression should be in. For example, + * `['in', 'id', [1, 2, 3]]` will generate `id IN (1, 2, 3)`. + * The method will properly quote the column name and escape values in the range. + * + * - `not in`: similar to the `in` operator except that `IN` is replaced with `NOT IN` in the generated condition. + * + * - `like`: operand 1 should be a column or DB expression, and operand 2 be a string or an array representing + * the values that the column or DB expression should be like. + * For example, `['like', 'name', '%tester%']` will generate `name LIKE '%tester%'`. + * When the value range is given as an array, multiple `LIKE` predicates will be generated and concatenated + * using `AND`. For example, `['like', 'name', ['%test%', '%sample%']]` will generate + * `name LIKE '%test%' AND name LIKE '%sample%'`. + * The method will properly quote the column name and escape values in the range. + * + * - `or like`: similar to the `like` operator except that `OR` is used to concatenate the `LIKE` + * predicates when operand 2 is an array. + * + * - `not like`: similar to the `like` operator except that `LIKE` is replaced with `NOT LIKE` + * in the generated condition. + * + * - `or not like`: similar to the `not like` operator except that `OR` is used to concatenate + * the `NOT LIKE` predicates. + * + * @param string|array $condition the conditions that should be put in the WHERE part. + * @param array $params the parameters (name => value) to be bound to the query. + * @return static the query object itself + * @see andWhere() + * @see orWhere() + */ + public function where($condition, $params = []) + { + $this->where = $condition; + $this->addParams($params); + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'AND' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return static the query object itself + * @see where() + * @see orWhere() + */ + public function andWhere($condition, $params = []) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = ['and', $this->where, $condition]; + } + $this->addParams($params); + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'OR' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return static the query object itself + * @see where() + * @see andWhere() + */ + public function orWhere($condition, $params = []) + { + if ($this->where === null) { + $this->where = $condition; + } else { + $this->where = ['or', $this->where, $condition]; + } + $this->addParams($params); + return $this; + } + + /** + * Sets the GROUP BY part of the query. + * @param string|array $columns the columns to be grouped by. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see addGroupBy() + */ + public function groupBy($columns) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + $this->groupBy = $columns; + return $this; + } + + /** + * Adds additional group-by columns to the existing ones. + * @param string|array $columns additional columns to be grouped by. + * Columns can be specified in either a string (e.g. "id, name") or an array (e.g. ['id', 'name']). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see groupBy() + */ + public function addGroupBy($columns) + { + if (!is_array($columns)) { + $columns = preg_split('/\s*,\s*/', trim($columns), -1, PREG_SPLIT_NO_EMPTY); + } + if ($this->groupBy === null) { + $this->groupBy = $columns; + } else { + $this->groupBy = array_merge($this->groupBy, $columns); + } + return $this; + } + + /** + * Sets the parameters to be bound to the query. + * @param array $params list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + * @return static the query object itself + * @see addParams() + */ + public function params($params) + { + $this->params = $params; + return $this; + } + + /** + * Adds additional parameters to be bound to the query. + * @param array $params list of query parameter values indexed by parameter placeholders. + * For example, `[':name' => 'Dan', ':age' => 31]`. + * @return static the query object itself + * @see params() + */ + public function addParams($params) + { + if (!empty($params)) { + if ($this->params === null) { + $this->params = $params; + } else { + foreach ($params as $name => $value) { + if (is_integer($name)) { + $this->params[] = $value; + } else { + $this->params[$name] = $value; + } + } + } + } + return $this; + } + + /** + * Sets the query options. + * @param array $options query options in format: optionName => optionValue + * @return static the query object itself + * @see addOptions() + */ + public function options($options) + { + $this->options = $options; + return $this; + } + + /** + * Adds additional query options. + * @param array $options query options in format: optionName => optionValue + * @return static the query object itself + * @see options() + */ + public function addOptions($options) + { + if (is_array($this->options)) { + $this->options = array_merge($this->options, $options); + } else { + $this->options = $options; + } + return $this; + } + + /** + * Sets the WITHIN GROUP ORDER BY part of the query. + * @param string|array $columns the columns (and the directions) to find best row within a group. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['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 static the query object itself + * @see addWithin() + */ + public function within($columns) + { + $this->within = $this->normalizeOrderBy($columns); + return $this; + } + + /** + * Adds additional WITHIN GROUP ORDER BY columns to the query. + * @param string|array $columns the columns (and the directions) to find best row within a group. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['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 static the query object itself + * @see within() + */ + public function addWithin($columns) + { + $columns = $this->normalizeOrderBy($columns); + if ($this->within === null) { + $this->within = $columns; + } else { + $this->within = array_merge($this->within, $columns); + } + return $this; + } + + /** + * Sets the PHP callback, which should be used to retrieve the source data + * for the snippets building. + * @param callback $callback PHP callback, which should be used to fetch source data for the snippets. + * @return static the query object itself + * @see snippetCallback + */ + public function snippetCallback($callback) + { + $this->snippetCallback = $callback; + return $this; + } + + /** + * Sets the call snippets query options. + * @param array $options call snippet options in format: option_name => option_value + * @return static the query object itself + * @see snippetCallback + */ + public function snippetOptions($options) + { + $this->snippetOptions = $options; + return $this; + } + + /** + * Fills the query result rows with the snippets built from source determined by + * [[snippetCallback]] result. + * @param array $rows raw query result rows. + * @return array query result rows with filled up snippets. + */ + protected function fillUpSnippets($rows) + { + if ($this->snippetCallback === null) { + return $rows; + } + $snippetSources = call_user_func($this->snippetCallback, $rows); + $snippets = $this->callSnippets($snippetSources); + $snippetKey = 0; + foreach ($rows as $key => $row) { + $rows[$key]['snippet'] = $snippets[$snippetKey]; + $snippetKey++; + } + return $rows; + } + + /** + * Builds a snippets from provided source data. + * @param array $source the source data to extract a snippet from. + * @throws InvalidCallException in case [[match]] is not specified. + * @return array snippets list. + */ + protected function callSnippets(array $source) + { + $connection = $this->getConnection(); + $match = $this->match; + if ($match === null) { + throw new InvalidCallException('Unable to call snippets: "' . $this->className() . '::match" should be specified.'); + } + return $connection->createCommand() + ->callSnippets($this->from[0], $source, $match, $this->snippetOptions) + ->queryColumn(); + } +} \ No newline at end of file diff --git a/extensions/sphinx/QueryBuilder.php b/extensions/sphinx/QueryBuilder.php new file mode 100644 index 0000000..e21e620 --- /dev/null +++ b/extensions/sphinx/QueryBuilder.php @@ -0,0 +1,904 @@ + + * @since 2.0 + */ +class QueryBuilder extends Object +{ + /** + * The prefix for automatically generated query binding parameters. + */ + const PARAM_PREFIX = ':qp'; + + /** + * @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 + * parameters to be bound to the SQL statement (the second array element). + */ + public function build($query) + { + $params = $query->params; + if ($query->match !== null) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = (string)$query->match; + $query->andWhere('MATCH(' . $phName . ')'); + } + $clauses = [ + $this->buildSelect($query->select, $query->distinct, $query->selectOption), + $this->buildFrom($query->from), + $this->buildWhere($query->from, $query->where, $params), + $this->buildGroupBy($query->groupBy), + $this->buildWithin($query->within), + $this->buildOrderBy($query->orderBy), + $this->buildLimit($query->limit, $query->offset), + $this->buildOption($query->options, $params), + ]; + return [implode($this->separator, array_filter($clauses)), $params]; + } + + /** + * Creates an INSERT SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->insert('idx_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * 'id' => 10, + * ], $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 Sphinx command later. + * @return string the INSERT SQL + */ + public function insert($index, $columns, &$params) + { + return $this->generateInsertReplace('INSERT', $index, $columns, $params); + } + + /** + * Creates an REPLACE SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->replace('idx_user', [ + * 'name' => 'Sam', + * 'age' => 30, + * 'id' => 10, + * ], $params); + * ~~~ + * + * The method will properly escape the index and column names. + * + * @param string $index the index that new rows will be replaced. + * @param array $columns the column data (name => value) to be replaced in the index. + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the Sphinx command later. + * @return string the INSERT SQL + */ + public function replace($index, $columns, &$params) + { + return $this->generateInsertReplace('REPLACE', $index, $columns, $params); + } + + /** + * Generates INSERT/REPLACE SQL statement. + * @param string $statement statement ot be generated. + * @param string $index the affected index name. + * @param array $columns the column data (name => value). + * @param array $params the binding parameters that will be generated by this method. + * @return string generated SQL + */ + protected function generateInsertReplace($statement, $index, $columns, &$params) + { + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $indexSchemas = [$indexSchema]; + } else { + $indexSchemas = []; + } + $names = []; + $placeholders = []; + foreach ($columns as $name => $value) { + $names[] = $this->db->quoteColumnName($name); + $placeholders[] = $this->composeColumnValue($indexSchemas, $name, $value, $params); + } + return $statement . ' INTO ' . $this->db->quoteIndexName($index) + . ' (' . implode(', ', $names) . ') VALUES (' + . implode(', ', $placeholders) . ')'; + } + + /** + * Generates a batch INSERT SQL statement. + * For example, + * + * ~~~ + * $connection->createCommand()->batchInsert('idx_user', ['id', 'name', 'age'], [ + * [1, 'Tom', 30], + * [2, 'Jane', 20], + * [3, '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 + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the Sphinx command later. + * @return string the batch INSERT SQL statement + */ + public function batchInsert($index, $columns, $rows, &$params) + { + return $this->generateBatchInsertReplace('INSERT', $index, $columns, $rows, $params); + } + + /** + * Generates a batch REPLACE SQL statement. + * For example, + * + * ~~~ + * $connection->createCommand()->batchReplace('idx_user', ['id', 'name', 'age'], [ + * [1, 'Tom', 30], + * [2, 'Jane', 20], + * [3, '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 replaced. + * @param array $columns the column names + * @param array $rows the rows to be batch replaced in the index + * @param array $params the binding parameters that will be generated by this method. + * They should be bound to the Sphinx command later. + * @return string the batch INSERT SQL statement + */ + public function batchReplace($index, $columns, $rows, &$params) + { + return $this->generateBatchInsertReplace('REPLACE', $index, $columns, $rows, $params); + } + + /** + * Generates a batch INSERT/REPLACE SQL statement. + * @param string $statement statement ot be generated. + * @param string $index the affected index name. + * @param array $columns the column data (name => value). + * @param array $rows the rows to be batch inserted into the index + * @param array $params the binding parameters that will be generated by this method. + * @return string generated SQL + */ + protected function generateBatchInsertReplace($statement, $index, $columns, $rows, &$params) + { + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $indexSchemas = [$indexSchema]; + } else { + $indexSchemas = []; + } + + foreach ($columns as $i => $name) { + $columns[$i] = $this->db->quoteColumnName($name); + } + + $values = []; + foreach ($rows as $row) { + $vs = []; + foreach ($row as $i => $value) { + $vs[] = $this->composeColumnValue($indexSchemas, $columns[$i], $value, $params); + } + $values[] = '(' . implode(', ', $vs) . ')'; + } + + return $statement . ' INTO ' . $this->db->quoteIndexName($index) + . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values); + } + + /** + * Creates an UPDATE SQL statement. + * For example, + * + * ~~~ + * $params = []; + * $sql = $queryBuilder->update('idx_user', ['status' => 1], 'age > 30', $params); + * ~~~ + * + * The method will properly escape the index and column names. + * + * @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 Sphinx command later. + * @param array $options list of options in format: optionName => optionValue + * @return string the UPDATE SQL + */ + public function update($index, $columns, $condition, &$params, $options) + { + if (($indexSchema = $this->db->getIndexSchema($index)) !== null) { + $indexSchemas = [$indexSchema]; + } else { + $indexSchemas = []; + } + + $lines = []; + foreach ($columns as $name => $value) { + $lines[] = $this->db->quoteColumnName($name) . '=' . $this->composeColumnValue($indexSchemas, $name, $value, $params); + } + + $sql = 'UPDATE ' . $this->db->quoteIndexName($index) . ' SET ' . implode(', ', $lines); + $where = $this->buildWhere([$index], $condition, $params); + if ($where !== '') { + $sql = $sql . ' ' . $where; + } + $option = $this->buildOption($options, $params); + if ($option !== '') { + $sql = $sql . ' ' . $option; + } + return $sql; + } + + /** + * Creates a DELETE SQL statement. + * For example, + * + * ~~~ + * $sql = $queryBuilder->delete('idx_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 Sphinx command later. + * @return string the DELETE SQL + */ + public function delete($index, $condition, &$params) + { + $sql = 'DELETE FROM ' . $this->db->quoteIndexName($index); + $where = $this->buildWhere([$index], $condition, $params); + return $where === '' ? $sql : $sql . ' ' . $where; + } + + /** + * Builds a SQL statement for truncating an 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 an index. + */ + public function truncateIndex($index) + { + return 'TRUNCATE RTINDEX ' . $this->db->quoteIndexName($index); + } + + /** + * Builds a SQL statement for call snippet from provided data and query, using specified index settings. + * @param string $index name of the index, from which to take the text processing settings. + * @param string|array $source is the source data to extract a snippet from. + * It could be either a single string or array of strings. + * @param string $match the full-text query to build snippets for. + * @param array $options list of options in format: optionName => optionValue + * @param array $params the binding parameters that will be modified by this method + * so that they can be bound to the Sphinx command later. + * @return string the SQL statement for call snippets. + */ + public function callSnippets($index, $source, $match, $options, &$params) + { + if (is_array($source)) { + $dataSqlParts = []; + foreach ($source as $sourceRow) { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $sourceRow; + $dataSqlParts[] = $phName; + } + $dataSql = '(' . implode(',', $dataSqlParts) . ')'; + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $source; + $dataSql = $phName; + } + $indexParamName = self::PARAM_PREFIX . count($params); + $params[$indexParamName] = $index; + $matchParamName = self::PARAM_PREFIX . count($params); + $params[$matchParamName] = $match; + if (!empty($options)) { + $optionParts = []; + foreach ($options as $name => $value) { + if ($value instanceof Expression) { + $actualValue = $value->expression; + } else { + $actualValue = self::PARAM_PREFIX . count($params); + $params[$actualValue] = $value; + } + $optionParts[] = $actualValue . ' AS ' . $name; + } + $optionSql = ', ' . implode(', ', $optionParts); + } else { + $optionSql = ''; + } + return 'CALL SNIPPETS(' . $dataSql. ', ' . $indexParamName . ', ' . $matchParamName . $optionSql. ')'; + } + + /** + * Builds a SQL statement for returning tokenized and normalized forms of the keywords, and, + * optionally, keyword statistics. + * @param string $index the name of the index from which to take the text processing settings + * @param string $text the text to break down to keywords. + * @param boolean $fetchStatistic whether to return document and hit occurrence statistics + * @param array $params the binding parameters that will be modified by this method + * so that they can be bound to the Sphinx command later. + * @return string the SQL statement for call keywords. + */ + public function callKeywords($index, $text, $fetchStatistic, &$params) + { + $indexParamName = self::PARAM_PREFIX . count($params); + $params[$indexParamName] = $index; + $textParamName = self::PARAM_PREFIX . count($params); + $params[$textParamName] = $text; + return 'CALL KEYWORDS(' . $textParamName . ', ' . $indexParamName . ($fetchStatistic ? ', 1' : '') . ')'; + } + + /** + * @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[] $indexes list of index names, which affected by query + * @param string|array $condition + * @param array $params the binding parameters to be populated + * @return string the WHERE clause built from [[query]]. + */ + public function buildWhere($indexes, $condition, &$params) + { + if (empty($condition)) { + return ''; + } + $indexSchemas = []; + if (!empty($indexes)) { + foreach ($indexes as $indexName) { + $index = $this->db->getIndexSchema($indexName); + if ($index !== null) { + $indexSchemas[] = $index; + } + } + } + $where = $this->buildCondition($indexSchemas, $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 === SORT_DESC ? ' DESC' : 'ASC'); + } + } + + 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 IndexSchema[] $indexes list of indexes, which affected by query + * @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($indexes, $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($indexes, $operator, $condition, $params); + } else { + throw new Exception('Found unknown operator in query: ' . $operator); + } + } else { // hash format: 'column1' => 'value1', 'column2' => 'value2', ... + return $this->buildHashCondition($indexes, $condition, $params); + } + } + + /** + * Creates a condition based on column-value pairs. + * @param IndexSchema[] $indexes list of indexes, which affected by query + * @param array $condition the condition specification. + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + */ + public function buildHashCondition($indexes, $condition, &$params) + { + $parts = []; + foreach ($condition as $column => $value) { + if (is_array($value)) { // IN condition + $parts[] = $this->buildInCondition($indexes, 'IN', [$column, $value], $params); + } else { + if (strpos($column, '(') === false) { + $quotedColumn = $this->db->quoteColumnName($column); + } else { + $quotedColumn = $column; + } + if ($value === null) { + $parts[] = "$quotedColumn IS NULL"; + } else { + $parts[] = $quotedColumn . '=' . $this->composeColumnValue($indexes, $column, $value, $params); + } + } + } + return count($parts) === 1 ? $parts[0] : '(' . implode(') AND (', $parts) . ')'; + } + + /** + * Connects two or more SQL expressions with the `AND` or `OR` operator. + * @param IndexSchema[] $indexes list of indexes, which affected by query + * @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($indexes, $operator, $operands, &$params) + { + $parts = []; + foreach ($operands as $operand) { + if (is_array($operand)) { + $operand = $this->buildCondition($indexes, $operand, $params); + } + if ($operand !== '') { + $parts[] = $operand; + } + } + if (!empty($parts)) { + return '(' . implode(") $operator (", $parts) . ')'; + } else { + return ''; + } + } + + /** + * Creates an SQL expressions with the `BETWEEN` operator. + * @param IndexSchema[] $indexes list of indexes, which affected by query + * @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($indexes, $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) { + $quotedColumn = $this->db->quoteColumnName($column); + } else { + $quotedColumn = $column; + } + $phName1 = $this->composeColumnValue($indexes, $column, $value1, $params); + $phName2 = $this->composeColumnValue($indexes, $column, $value2, $params); + + return "$quotedColumn $operator $phName1 AND $phName2"; + } + + /** + * Creates an SQL expressions with the `IN` operator. + * @param IndexSchema[] $indexes list of indexes, which affected by query + * @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($indexes, $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($indexes, $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; + } + $values[$i] = $this->composeColumnValue($indexes, $column, $value, $params); + } + 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]}"; + } + } + + /** + * @param IndexSchema[] $indexes list of indexes, which affected by query + * @param string $operator the operator to use (e.g. `IN` or `NOT IN`) + * @param array $columns + * @param array $values + * @param array $params the binding parameters to be populated + * @return string the generated SQL expression + */ + protected function buildCompositeInCondition($indexes, $operator, $columns, $values, &$params) + { + $vss = []; + foreach ($values as $value) { + $vs = []; + foreach ($columns as $column) { + if (isset($value[$column])) { + $vs[] = $this->composeColumnValue($indexes, $column, $value[$column], $params); + } 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 IndexSchema[] $indexes list of indexes, which affected by query + * @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($indexes, $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 === SORT_DESC ? ' DESC' : ''); + } + } + return 'WITHIN GROUP ORDER BY ' . implode(', ', $orders); + } + + /** + * @param array $options query options in format: optionName => optionValue + * @param array $params the binding parameters to be populated + * @return string the OPTION clause build from [[query]] + */ + public function buildOption($options, &$params) + { + if (empty($options)) { + return ''; + } + $optionLines = []; + foreach ($options as $name => $value) { + if ($value instanceof Expression) { + $actualValue = $value->expression; + } else { + if (is_array($value)) { + $actualValueParts = []; + foreach ($value as $key => $valuePart) { + if (is_numeric($key)) { + $actualValuePart = ''; + } else { + $actualValuePart = $key . ' = '; + } + if ($valuePart instanceof Expression) { + $actualValuePart .= $valuePart->expression; + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = $valuePart; + $actualValuePart .= $phName; + } + $actualValueParts[] = $actualValuePart; + } + $actualValue = '(' . implode(', ', $actualValueParts) . ')'; + } else { + $actualValue = self::PARAM_PREFIX . count($params); + $params[$actualValue] = $value; + } + } + $optionLines[] = $name . ' = ' . $actualValue; + } + return 'OPTION ' . implode(', ', $optionLines); + } + + /** + * Composes column value for SQL, taking in account the column type. + * @param IndexSchema[] $indexes list of indexes, which affected by query + * @param string $columnName name of the column + * @param mixed $value raw column value + * @param array $params the binding parameters to be populated + * @return string SQL expression, which represents column value + */ + protected function composeColumnValue($indexes, $columnName, $value, &$params) { + if ($value === null) { + return 'NULL'; + } elseif ($value instanceof Expression) { + $params = array_merge($params, $value->params); + return $value->expression; + } + foreach ($indexes as $index) { + $columnSchema = $index->getColumn($columnName); + if ($columnSchema !== null) { + break; + } + } + if (is_array($value)) { + // MVA : + $lineParts = []; + foreach ($value as $subValue) { + if ($subValue instanceof Expression) { + $params = array_merge($params, $subValue->params); + $lineParts[] = $subValue->expression; + } else { + $phName = self::PARAM_PREFIX . count($params); + $lineParts[] = $phName; + $params[$phName] = (isset($columnSchema)) ? $columnSchema->typecast($subValue) : $subValue; + } + } + return '(' . implode(',', $lineParts) . ')'; + } else { + $phName = self::PARAM_PREFIX . count($params); + $params[$phName] = (isset($columnSchema)) ? $columnSchema->typecast($value) : $value; + return $phName; + } + } +} \ No newline at end of file diff --git a/extensions/sphinx/README.md b/extensions/sphinx/README.md new file mode 100644 index 0000000..ae7c285 --- /dev/null +++ b/extensions/sphinx/README.md @@ -0,0 +1,118 @@ +Yii 2.0 Public Preview - Sphinx Extension +========================================= + +Thank you for choosing Yii - a high-performance component-based PHP framework. + +If you are looking for a production-ready PHP framework, please use +[Yii v1.1](https://github.com/yiisoft/yii). + +Yii 2.0 is still under heavy development. We may make significant changes +without prior notices. **Yii 2.0 is not ready for production use yet.** + +[![Build Status](https://secure.travis-ci.org/yiisoft/yii2.png)](http://travis-ci.org/yiisoft/yii2) + +This is the yii2-sphinx extension. + + +Installation +------------ + +The preferred way to install this extension is through [composer](http://getcomposer.org/download/). + +Either run +``` +php composer.phar require yiisoft/yii2-sphinx "*" +``` + +or add +``` +"yiisoft/yii2-sphinx": "*" +``` +to the require section of your composer.json. + + +*Note: You might have to run `php composer.phar selfupdate`* + + +Usage & Documentation +--------------------- + +This extension adds [Sphinx](http://sphinxsearch.com/docs) full text search engine extension for the Yii framework. +This extension interact with Sphinx search daemon using MySQL protocol and [SphinxQL](http://sphinxsearch.com/docs/current.html#sphinxql) query language. +In order to setup Sphinx "searchd" to support MySQL protocol following configuration should be added: +``` +searchd +{ + listen = localhost:9306:mysql41 + ... +} +``` + +This extension supports all Sphinx features including [Runtime Indexes](http://sphinxsearch.com/docs/current.html#rt-indexes). +Since this extension uses MySQL protocol to access Sphinx, it shares base approach and much code from the +regular "yii\db" package. + +To use this extension, simply add the following code in your application configuration: + +```php +return [ + //.... + 'components' => [ + 'sphinx' => [ + 'class' => 'yii\sphinx\Connection', + 'dsn' => 'mysql:host=127.0.0.1;port=9306;', + 'username' => '', + 'password' => '', + ], + ], +]; +``` + +This extension provides ActiveRecord solution similar ot the [[\yii\db\ActiveRecord]]. +To declare an ActiveRecord class you need to extend [[\yii\sphinx\ActiveRecord]] and +implement the `indexName` method: + +```php +use yii\sphinx\ActiveRecord; + +class Article extends ActiveRecord +{ + /** + * @return string the name of the index associated with this ActiveRecord class. + */ + public static function indexName() + { + return 'idx_article'; + } +} +``` + +You can use [[\yii\data\ActiveDataProvider]] with the [[\yii\sphinx\Query]] and [[\yii\sphinx\ActiveQuery]]: + +```php +use yii\data\ActiveDataProvider; +use yii\sphinx\Query; + +$query = new Query; +$query->from('yii2_test_article_index')->match('development'); +$provider = new ActiveDataProvider([ + 'query' => $query, + 'pagination' => [ + 'pageSize' => 10, + ] +]); +$models = $provider->getModels(); +``` + +```php +use yii\data\ActiveDataProvider; +use app\models\Article; + +$provider = new ActiveDataProvider([ + 'query' => Article::find(), + 'pagination' => [ + 'pageSize' => 10, + ] +]); +$models = $provider->getModels(); +``` \ No newline at end of file diff --git a/extensions/sphinx/Schema.php b/extensions/sphinx/Schema.php new file mode 100644 index 0000000..6c9571c --- /dev/null +++ b/extensions/sphinx/Schema.php @@ -0,0 +1,489 @@ + index type). + * This property is read-only. + * @property IndexSchema[] $tableSchemas The metadata for all indexes in the Sphinx. Each array element is an + * instance of [[IndexSchema]] or its child class. This property is read-only. + * + * @author Paul Klimov + * @since 2.0 + */ +class Schema extends Object +{ + /** + * The followings are the supported abstract column data types. + */ + const TYPE_PK = 'pk'; + const TYPE_STRING = 'string'; + const TYPE_INTEGER = 'integer'; + const TYPE_BIGINT = 'bigint'; + const TYPE_FLOAT = 'float'; + const TYPE_TIMESTAMP = 'timestamp'; + const TYPE_BOOLEAN = 'boolean'; + + /** + * @var Connection the Sphinx connection + */ + public $db; + /** + * @var array list of ALL index names in the Sphinx + */ + private $_indexNames; + /** + * @var array list of ALL index types in the Sphinx (index name => index type) + */ + private $_indexTypes; + /** + * @var array list of loaded index metadata (index name => IndexSchema) + */ + private $_indexes = []; + /** + * @var QueryBuilder the query builder for this Sphinx connection + */ + private $_builder; + + /** + * @var array mapping from physical column types (keys) to abstract column types (values) + */ + public $typeMap = [ + 'field' => self::TYPE_STRING, + 'string' => self::TYPE_STRING, + 'ordinal' => self::TYPE_STRING, + 'integer' => self::TYPE_INTEGER, + 'int' => self::TYPE_INTEGER, + 'uint' => self::TYPE_INTEGER, + 'bigint' => self::TYPE_BIGINT, + 'timestamp' => self::TYPE_TIMESTAMP, + 'bool' => self::TYPE_BOOLEAN, + 'float' => self::TYPE_FLOAT, + 'mva' => self::TYPE_INTEGER, + ]; + + /** + * Loads the metadata for the specified index. + * @param string $name index name + * @return IndexSchema driver dependent index metadata. Null if the index does not exist. + */ + protected function loadIndexSchema($name) + { + $index = new IndexSchema; + $this->resolveIndexNames($index, $name); + $this->resolveIndexType($index); + + if ($this->findColumns($index)) { + return $index; + } else { + return null; + } + } + + /** + * Resolves the index name. + * @param IndexSchema $index the index metadata object + * @param string $name the index name + */ + protected function resolveIndexNames($index, $name) + { + $index->name = str_replace('`', '', $name); + } + + /** + * Resolves the index name. + * @param IndexSchema $index the index metadata object + */ + protected function resolveIndexType($index) + { + $indexTypes = $this->getIndexTypes(); + $index->type = array_key_exists($index->name, $indexTypes) ? $indexTypes[$index->name] : 'unknown'; + $index->isRuntime = ($index->type == 'rt'); + } + + /** + * Obtains the metadata for the named index. + * @param string $name index name. The index name may contain schema name if any. Do not quote the index name. + * @param boolean $refresh whether to reload the index schema even if it is found in the cache. + * @return IndexSchema index metadata. Null if the named index does not exist. + */ + public function getIndexSchema($name, $refresh = false) + { + if (isset($this->_indexes[$name]) && !$refresh) { + return $this->_indexes[$name]; + } + + $db = $this->db; + $realName = $this->getRawIndexName($name); + + if ($db->enableSchemaCache && !in_array($name, $db->schemaCacheExclude, true)) { + /** @var $cache Cache */ + $cache = is_string($db->schemaCache) ? Yii::$app->getComponent($db->schemaCache) : $db->schemaCache; + if ($cache instanceof Cache) { + $key = $this->getCacheKey($name); + if ($refresh || ($index = $cache->get($key)) === false) { + $index = $this->loadIndexSchema($realName); + if ($index !== null) { + $cache->set($key, $index, $db->schemaCacheDuration, new GroupDependency([ + 'group' => $this->getCacheGroup(), + ])); + } + } + return $this->_indexes[$name] = $index; + } + } + return $this->_indexes[$name] = $index = $this->loadIndexSchema($realName); + } + + /** + * Returns the cache key for the specified index name. + * @param string $name the index name + * @return mixed the cache key + */ + protected function getCacheKey($name) + { + return [ + __CLASS__, + $this->db->dsn, + $this->db->username, + $name, + ]; + } + + /** + * Returns the cache group name. + * This allows [[refresh()]] to invalidate all cached index schemas. + * @return string the cache group name + */ + protected function getCacheGroup() + { + return md5(serialize([ + __CLASS__, + $this->db->dsn, + $this->db->username, + ])); + } + + /** + * Returns the metadata for all indexes in the database. + * @param boolean $refresh whether to fetch the latest available index schemas. If this is false, + * cached data may be returned if available. + * @return IndexSchema[] the metadata for all indexes in the Sphinx. + * Each array element is an instance of [[IndexSchema]] or its child class. + */ + public function getIndexSchemas($refresh = false) + { + $indexes = []; + foreach ($this->getIndexNames($refresh) as $name) { + if (($index = $this->getIndexSchema($name, $refresh)) !== null) { + $indexes[] = $index; + } + } + return $indexes; + } + + /** + * Returns all index names in the Sphinx. + * @param boolean $refresh whether to fetch the latest available index names. If this is false, + * index names fetched previously (if available) will be returned. + * @return string[] all index names in the Sphinx. + */ + public function getIndexNames($refresh = false) + { + if (!isset($this->_indexNames) || $refresh) { + $this->initIndexesInfo(); + } + return $this->_indexNames; + } + + /** + * Returns all index types in the Sphinx. + * @param boolean $refresh whether to fetch the latest available index types. If this is false, + * index types fetched previously (if available) will be returned. + * @return array all index types in the Sphinx in format: index name => index type. + */ + public function getIndexTypes($refresh = false) + { + if (!isset($this->_indexTypes) || $refresh) { + $this->initIndexesInfo(); + } + return $this->_indexTypes; + } + + /** + * Initializes information about name and type of all index in the Sphinx. + */ + protected function initIndexesInfo() + { + $this->_indexNames = []; + $this->_indexTypes = []; + $indexes = $this->findIndexes(); + foreach ($indexes as $index) { + $indexName = $index['Index']; + $this->_indexNames[] = $indexName; + $this->_indexTypes[$indexName] = $index['Type']; + } + } + + /** + * Returns all index names in the Sphinx. + * @return array all index names in the Sphinx. + */ + protected function findIndexes() + { + $sql = 'SHOW TABLES'; + return $this->db->createCommand($sql)->queryAll(); + } + + /** + * @return QueryBuilder the query builder for this connection. + */ + public function getQueryBuilder() + { + if ($this->_builder === null) { + $this->_builder = $this->createQueryBuilder(); + } + return $this->_builder; + } + + /** + * Determines the PDO type for the given PHP data value. + * @param mixed $data the data whose PDO type is to be determined + * @return integer the PDO type + * @see http://www.php.net/manual/en/pdo.constants.php + */ + public function getPdoType($data) + { + static $typeMap = [ + // php type => PDO type + 'boolean' => \PDO::PARAM_BOOL, + 'integer' => \PDO::PARAM_INT, + 'string' => \PDO::PARAM_STR, + 'resource' => \PDO::PARAM_LOB, + 'NULL' => \PDO::PARAM_NULL, + ]; + $type = gettype($data); + return isset($typeMap[$type]) ? $typeMap[$type] : \PDO::PARAM_STR; + } + + /** + * Refreshes the schema. + * This method cleans up all cached index schemas so that they can be re-created later + * to reflect the Sphinx schema change. + */ + public function refresh() + { + /** @var $cache Cache */ + $cache = is_string($this->db->schemaCache) ? Yii::$app->getComponent($this->db->schemaCache) : $this->db->schemaCache; + if ($this->db->enableSchemaCache && $cache instanceof Cache) { + GroupDependency::invalidate($cache, $this->getCacheGroup()); + } + $this->_indexNames = []; + $this->_indexes = []; + } + + /** + * Creates a query builder for the Sphinx. + * @return QueryBuilder query builder instance + */ + public function createQueryBuilder() + { + return new QueryBuilder($this->db); + } + + /** + * Quotes a string value for use in a query. + * Note that if the parameter is not a string, it will be returned without change. + * @param string $str string to be quoted + * @return string the properly quoted string + * @see http://www.php.net/manual/en/function.PDO-quote.php + */ + public function quoteValue($str) + { + if (!is_string($str)) { + return $str; + } + $this->db->open(); + return $this->db->pdo->quote($str); + } + + /** + * 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 '(' or '{{', + * then this method will do nothing. + * @param string $name index name + * @return string the properly quoted index name + * @see quoteSimpleTableName + */ + public function quoteIndexName($name) + { + if (strpos($name, '(') !== false || strpos($name, '{{') !== false) { + return $name; + } + return $this->quoteSimpleIndexName($name); + } + + /** + * Quotes a column name for use in a query. + * If the column name contains prefix, the prefix will also be properly quoted. + * If the column name is already quoted or contains '(', '[[' or '{{', + * then this method will do nothing. + * @param string $name column name + * @return string the properly quoted column name + * @see quoteSimpleColumnName + */ + public function quoteColumnName($name) + { + if (strpos($name, '(') !== false || strpos($name, '[[') !== false || strpos($name, '{{') !== false) { + return $name; + } + if (($pos = strrpos($name, '.')) !== false) { + $prefix = $this->quoteIndexName(substr($name, 0, $pos)) . '.'; + $name = substr($name, $pos + 1); + } else { + $prefix = ''; + } + return $prefix . $this->quoteSimpleColumnName($name); + } + + /** + * Quotes a index name for use in a query. + * A simple index name has no schema prefix. + * @param string $name index name + * @return string the properly quoted index name + */ + public function quoteSimpleIndexName($name) + { + return strpos($name, "`") !== false ? $name : "`" . $name . "`"; + } + + /** + * Quotes a column name for use in a query. + * A simple column name has no prefix. + * @param string $name column name + * @return string the properly quoted column name + */ + public function quoteSimpleColumnName($name) + { + return strpos($name, '`') !== false || $name === '*' ? $name : '`' . $name . '`'; + } + + /** + * Returns the actual name of a given index name. + * This method will strip off curly brackets from the given index name + * and replace the percentage character '%' with [[Connection::indexPrefix]]. + * @param string $name the index name to be converted + * @return string the real name of the given index name + */ + public function getRawIndexName($name) + { + if (strpos($name, '{{') !== false) { + $name = preg_replace('/\\{\\{(.*?)\\}\\}/', '\1', $name); + return str_replace('%', $this->db->tablePrefix, $name); + } else { + return $name; + } + } + + /** + * Extracts the PHP type from abstract DB type. + * @param ColumnSchema $column the column schema information + * @return string PHP type name + */ + protected function getColumnPhpType($column) + { + static $typeMap = [ // abstract type => php type + 'smallint' => 'integer', + 'integer' => 'integer', + 'bigint' => 'integer', + 'boolean' => 'boolean', + 'float' => 'double', + ]; + if (isset($typeMap[$column->type])) { + if ($column->type === 'bigint') { + return PHP_INT_SIZE == 8 ? 'integer' : 'string'; + } elseif ($column->type === 'integer') { + return PHP_INT_SIZE == 4 ? 'string' : 'integer'; + } else { + return $typeMap[$column->type]; + } + } else { + return 'string'; + } + } + + /** + * Collects the metadata of index columns. + * @param IndexSchema $index the index metadata + * @return boolean whether the index exists in the database + * @throws \Exception if DB query fails + */ + protected function findColumns($index) + { + $sql = 'DESCRIBE ' . $this->quoteSimpleIndexName($index->name); + try { + $columns = $this->db->createCommand($sql)->queryAll(); + } catch (\Exception $e) { + $previous = $e->getPrevious(); + if ($previous instanceof \PDOException && $previous->getCode() == '42S02') { + // index does not exist + return false; + } + throw $e; + } + foreach ($columns as $info) { + $column = $this->loadColumnSchema($info); + $index->columns[$column->name] = $column; + if ($column->isPrimaryKey) { + $index->primaryKey = $column->name; + } + } + return true; + } + + /** + * Loads the column information into a [[ColumnSchema]] object. + * @param array $info column information + * @return ColumnSchema the column schema object + */ + protected function loadColumnSchema($info) + { + $column = new ColumnSchema; + + $column->name = $info['Field']; + $column->dbType = $info['Type']; + + $column->isPrimaryKey = ($column->name == 'id'); + + $type = $info['Type']; + if (isset($this->typeMap[$type])) { + $column->type = $this->typeMap[$type]; + } else { + $column->type = self::TYPE_STRING; + } + + $column->isField = ($type == 'field'); + $column->isAttribute = !$column->isField; + + $column->isMva = ($type == 'mva'); + + $column->phpType = $this->getColumnPhpType($column); + + return $column; + } +} \ No newline at end of file diff --git a/extensions/sphinx/composer.json b/extensions/sphinx/composer.json new file mode 100644 index 0000000..0331667 --- /dev/null +++ b/extensions/sphinx/composer.json @@ -0,0 +1,29 @@ +{ + "name": "yiisoft/yii2-sphinx", + "description": "Sphinx full text search engine extension for the Yii framework", + "keywords": ["yii", "sphinx", "search", "fulltext"], + "type": "yii2-extension", + "license": "BSD-3-Clause", + "support": { + "issues": "https://github.com/yiisoft/yii2/issues?state=open", + "forum": "http://www.yiiframework.com/forum/", + "wiki": "http://www.yiiframework.com/wiki/", + "irc": "irc://irc.freenode.net/yii", + "source": "https://github.com/yiisoft/yii2" + }, + "authors": [ + { + "name": "Paul Klimov", + "email": "klimov.paul@gmail.com" + } + ], + "minimum-stability": "dev", + "require": { + "yiisoft/yii2": "*", + "ext-pdo": "*", + "ext-pdo_mysql": "*" + }, + "autoload": { + "psr-0": { "yii\\sphinx\\": "" } + } +} diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index cb3306f..13a1026 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -34,4 +34,17 @@ return [ 'password' => null, ], ], + 'sphinx' => [ + 'sphinx' => [ + 'dsn' => 'mysql:host=127.0.0.1;port=9306;', + 'username' => '', + 'password' => '', + ], + 'db' => [ + 'dsn' => 'mysql:host=127.0.0.1;dbname=yiitest', + 'username' => 'travis', + 'password' => '', + 'fixture' => __DIR__ . '/sphinx/source.sql', + ], + ] ]; diff --git a/tests/unit/data/sphinx/ar/ActiveRecord.php b/tests/unit/data/sphinx/ar/ActiveRecord.php new file mode 100644 index 0000000..12150b2 --- /dev/null +++ b/tests/unit/data/sphinx/ar/ActiveRecord.php @@ -0,0 +1,16 @@ + ArticleIndex::className(), + 'primaryModel' => $this, + 'link' => ['id' => 'id'], + 'multiple' => false, + ]; + return new ActiveRelation($config); + } +} \ No newline at end of file diff --git a/tests/unit/data/sphinx/ar/ArticleIndex.php b/tests/unit/data/sphinx/ar/ArticleIndex.php new file mode 100644 index 0000000..767fdea --- /dev/null +++ b/tests/unit/data/sphinx/ar/ArticleIndex.php @@ -0,0 +1,35 @@ +andWhere('author_id=1'); + } + + public function getSource() + { + return $this->hasOne('db', ArticleDb::className(), ['id' => 'id']); + } + + public function getTags() + { + return $this->hasMany('db', TagDb::className(), ['id' => 'tag']); + } + + public function getSnippetSource() + { + return $this->source->content; + } +} \ No newline at end of file diff --git a/tests/unit/data/sphinx/ar/ItemDb.php b/tests/unit/data/sphinx/ar/ItemDb.php new file mode 100644 index 0000000..ddf7eea --- /dev/null +++ b/tests/unit/data/sphinx/ar/ItemDb.php @@ -0,0 +1,13 @@ + 100 +} + + +index yii2_test_article_index +{ + source = yii2_test_article_src + path = /var/lib/sphinx/yii2_test_article + docinfo = extern + charset_type = sbcs +} + + +index yii2_test_item_index +{ + source = yii2_test_item_src + path = /var/lib/sphinx/yii2_test_item + docinfo = extern + charset_type = sbcs +} + + +index yii2_test_item_delta_index : yii2_test_item_index +{ + source = yii2_test_item_delta_src + path = /var/lib/sphinx/yii2_test_item_delta +} + + +index yii2_test_rt_index +{ + type = rt + path = /var/lib/sphinx/yii2_test_rt + rt_field = title + rt_field = content + rt_attr_uint = type_id + rt_attr_multi = category +} + + +indexer +{ + mem_limit = 32M +} + + +searchd +{ + listen = 127.0.0.1:9312 + listen = 9306:mysql41 + log = /var/log/sphinx/searchd.log + query_log = /var/log/sphinx/query.log + read_timeout = 5 + max_children = 30 + pid_file = /var/run/sphinx/searchd.pid + max_matches = 1000 + seamless_rotate = 1 + preopen_indexes = 1 + unlink_old = 1 + workers = threads # for RT to work + binlog_path = /var/lib/sphinx +} diff --git a/tests/unit/extensions/sphinx/ActiveDataProviderTest.php b/tests/unit/extensions/sphinx/ActiveDataProviderTest.php new file mode 100644 index 0000000..6a81900 --- /dev/null +++ b/tests/unit/extensions/sphinx/ActiveDataProviderTest.php @@ -0,0 +1,66 @@ +getConnection(); + } + + // Tests : + + public function testQuery() + { + $query = new Query; + $query->from('yii2_test_article_index'); + + $provider = new ActiveDataProvider([ + 'query' => $query, + 'db' => $this->getConnection(), + ]); + $models = $provider->getModels(); + $this->assertEquals(2, count($models)); + + $provider = new ActiveDataProvider([ + 'query' => $query, + 'db' => $this->getConnection(), + 'pagination' => [ + 'pageSize' => 1, + ] + ]); + $models = $provider->getModels(); + $this->assertEquals(1, count($models)); + } + + public function testActiveQuery() + { + $provider = new ActiveDataProvider([ + 'query' => ArticleIndex::find()->orderBy('id ASC'), + ]); + $models = $provider->getModels(); + $this->assertEquals(2, count($models)); + $this->assertTrue($models[0] instanceof ArticleIndex); + $this->assertTrue($models[1] instanceof ArticleIndex); + $this->assertEquals([1, 2], $provider->getKeys()); + + $provider = new ActiveDataProvider([ + 'query' => ArticleIndex::find(), + 'pagination' => [ + 'pageSize' => 1, + ] + ]); + $models = $provider->getModels(); + $this->assertEquals(1, count($models)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ActiveRecordTest.php b/tests/unit/extensions/sphinx/ActiveRecordTest.php new file mode 100644 index 0000000..0bb9dbb --- /dev/null +++ b/tests/unit/extensions/sphinx/ActiveRecordTest.php @@ -0,0 +1,238 @@ +getConnection(); + } + + protected function tearDown() + { + $this->truncateRuntimeIndex('yii2_test_rt_index'); + parent::tearDown(); + } + + // Tests : + + public function testFind() + { + // find one + $result = ArticleIndex::find(); + $this->assertTrue($result instanceof ActiveQuery); + $article = $result->one(); + $this->assertTrue($article instanceof ArticleIndex); + + // find all + $articles = ArticleIndex::find()->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0] instanceof ArticleIndex); + $this->assertTrue($articles[1] instanceof ArticleIndex); + + // find fulltext + $articles = ArticleIndex::find('cats'); + $this->assertEquals(1, count($articles)); + $this->assertTrue($articles[0] instanceof ArticleIndex); + $this->assertEquals(1, $articles[0]->id); + + // find by column values + $article = ArticleIndex::find(['id' => 2, 'author_id' => 2]); + $this->assertTrue($article instanceof ArticleIndex); + $this->assertEquals(2, $article->id); + $this->assertEquals(2, $article->author_id); + $article = ArticleIndex::find(['id' => 2, 'author_id' => 1]); + $this->assertNull($article); + + // find by attributes + $article = ArticleIndex::find()->where(['author_id' => 2])->one(); + $this->assertTrue($article instanceof ArticleIndex); + $this->assertEquals(2, $article->id); + + // find custom column + $article = ArticleIndex::find()->select(['*', '(5*2) AS custom_column']) + ->where(['author_id' => 1])->one(); + $this->assertEquals(1, $article->id); + $this->assertEquals(10, $article->custom_column); + + // find count, sum, average, min, max, scalar + $this->assertEquals(2, ArticleIndex::find()->count()); + $this->assertEquals(1, ArticleIndex::find()->where('id=1')->count()); + $this->assertEquals(3, ArticleIndex::find()->sum('id')); + $this->assertEquals(1.5, ArticleIndex::find()->average('id')); + $this->assertEquals(1, ArticleIndex::find()->min('id')); + $this->assertEquals(2, ArticleIndex::find()->max('id')); + $this->assertEquals(2, ArticleIndex::find()->select('COUNT(*)')->scalar()); + + // scope + $this->assertEquals(1, ArticleIndex::find()->favoriteAuthor()->count()); + + // asArray + $article = ArticleIndex::find()->where('id=2')->asArray()->one(); + $this->assertEquals([ + 'id' => '2', + 'author_id' => '2', + 'add_date' => '1384466400', + 'tag' => '3,4', + ], $article); + + // indexBy + $articles = ArticleIndex::find()->indexBy('author_id')->orderBy('id DESC')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles['1'] instanceof ArticleIndex); + $this->assertTrue($articles['2'] instanceof ArticleIndex); + + // indexBy callable + $articles = ArticleIndex::find()->indexBy(function ($article) { + return $article->id . '-' . $article->author_id; + })->orderBy('id DESC')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles['1-1'] instanceof ArticleIndex); + $this->assertTrue($articles['2-2'] instanceof ArticleIndex); + } + + public function testFindBySql() + { + // find one + $article = ArticleIndex::findBySql('SELECT * FROM yii2_test_article_index ORDER BY id DESC')->one(); + $this->assertTrue($article instanceof ArticleIndex); + $this->assertEquals(2, $article->author_id); + + // find all + $articles = ArticleIndex::findBySql('SELECT * FROM yii2_test_article_index')->all(); + $this->assertEquals(2, count($articles)); + + // find with parameter binding + $article = ArticleIndex::findBySql('SELECT * FROM yii2_test_article_index WHERE id=:id', [':id' => 2])->one(); + $this->assertTrue($article instanceof ArticleIndex); + $this->assertEquals(2, $article->author_id); + } + + public function testInsert() + { + $record = new RuntimeIndex; + $record->id = 15; + $record->title = 'test title'; + $record->content = 'test content'; + $record->type_id = 7; + $record->category = [1, 2]; + + $this->assertTrue($record->isNewRecord); + + $record->save(); + + $this->assertEquals(15, $record->id); + $this->assertFalse($record->isNewRecord); + } + + /** + * @depends testInsert + */ + public function testUpdate() + { + $record = new RuntimeIndex; + $record->id = 2; + $record->title = 'test title'; + $record->content = 'test content'; + $record->type_id = 7; + $record->category = [1, 2]; + $record->save(); + + // save + $record = RuntimeIndex::find(['id' => 2]); + $this->assertTrue($record instanceof RuntimeIndex); + $this->assertEquals(7, $record->type_id); + $this->assertFalse($record->isNewRecord); + + $record->type_id = 9; + $record->save(); + $this->assertEquals(9, $record->type_id); + $this->assertFalse($record->isNewRecord); + $record2 = RuntimeIndex::find(['id' => 2]); + $this->assertEquals(9, $record2->type_id); + + // replace + $query = 'replace'; + $rows = RuntimeIndex::find($query); + $this->assertEmpty($rows); + $record = RuntimeIndex::find(['id' => 2]); + $record->content = 'Test content with ' . $query; + $record->save(); + $rows = RuntimeIndex::find($query); + $this->assertNotEmpty($rows); + + // updateAll + $pk = ['id' => 2]; + $ret = RuntimeIndex::updateAll(['type_id' => 55], $pk); + $this->assertEquals(1, $ret); + $record = RuntimeIndex::find($pk); + $this->assertEquals(55, $record->type_id); + } + + /** + * @depends testInsert + */ + public function testDelete() + { + // delete + $record = new RuntimeIndex; + $record->id = 2; + $record->title = 'test title'; + $record->content = 'test content'; + $record->type_id = 7; + $record->category = [1, 2]; + $record->save(); + + $record = RuntimeIndex::find(['id' => 2]); + $record->delete(); + $record = RuntimeIndex::find(['id' => 2]); + $this->assertNull($record); + + // deleteAll + $record = new RuntimeIndex; + $record->id = 2; + $record->title = 'test title'; + $record->content = 'test content'; + $record->type_id = 7; + $record->category = [1, 2]; + $record->save(); + + $ret = RuntimeIndex::deleteAll('id = 2'); + $this->assertEquals(1, $ret); + $records = RuntimeIndex::find()->all(); + $this->assertEquals(0, count($records)); + } + + public function testCallSnippets() + { + $query = 'pencil'; + $source = 'Some data sentence about ' . $query; + + $snippet = ArticleIndex::callSnippets($source, $query); + $this->assertNotEmpty($snippet, 'Unable to call snippets!'); + $this->assertContains('' . $query . '', $snippet, 'Query not present in the snippet!'); + + $rows = ArticleIndex::callSnippets([$source], $query); + $this->assertNotEmpty($rows, 'Unable to call snippets!'); + $this->assertContains('' . $query . '', $rows[0], 'Query not present in the snippet!'); + } + + public function testCallKeywords() + { + $text = 'table pencil'; + $rows = ArticleIndex::callKeywords($text); + $this->assertNotEmpty($rows, 'Unable to call keywords!'); + $this->assertArrayHasKey('tokenized', $rows[0], 'No tokenized keyword!'); + $this->assertArrayHasKey('normalized', $rows[0], 'No normalized keyword!'); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ActiveRelationTest.php b/tests/unit/extensions/sphinx/ActiveRelationTest.php new file mode 100644 index 0000000..05c48c9 --- /dev/null +++ b/tests/unit/extensions/sphinx/ActiveRelationTest.php @@ -0,0 +1,44 @@ +getConnection(); + ActiveRecordDb::$db = $this->getDbConnection(); + } + + // Tests : + + public function testFindLazy() + { + /** @var ArticleDb $article */ + $article = ArticleDb::find(['id' => 2]); + $this->assertFalse($article->isRelationPopulated('index')); + $index = $article->index; + $this->assertTrue($article->isRelationPopulated('index')); + $this->assertTrue($index instanceof ArticleIndex); + $this->assertEquals(1, count($article->populatedRelations)); + } + + public function testFindEager() + { + $articles = ArticleDb::find()->with('index')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0]->isRelationPopulated('index')); + $this->assertTrue($articles[1]->isRelationPopulated('index')); + $this->assertTrue($articles[0]->index instanceof ArticleIndex); + $this->assertTrue($articles[1]->index instanceof ArticleIndex); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ColumnSchemaTest.php b/tests/unit/extensions/sphinx/ColumnSchemaTest.php new file mode 100644 index 0000000..7d62c1d --- /dev/null +++ b/tests/unit/extensions/sphinx/ColumnSchemaTest.php @@ -0,0 +1,55 @@ +type = $type; + $columnSchema->phpType = $phpType; + $this->assertEquals($expectedResult, $columnSchema->typecast($value)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/CommandTest.php b/tests/unit/extensions/sphinx/CommandTest.php new file mode 100644 index 0000000..b2346ef --- /dev/null +++ b/tests/unit/extensions/sphinx/CommandTest.php @@ -0,0 +1,409 @@ +truncateRuntimeIndex('yii2_test_rt_index'); + parent::tearDown(); + } + + // Tests : + + public function testConstruct() + { + $db = $this->getConnection(false); + + // null + $command = $db->createCommand(); + $this->assertEquals(null, $command->sql); + + // string + $sql = 'SELECT * FROM yii2_test_item_index'; + $params = [ + 'name' => 'value' + ]; + $command = $db->createCommand($sql, $params); + $this->assertEquals($sql, $command->sql); + $this->assertEquals($params, $command->params); + } + + public function testGetSetSql() + { + $db = $this->getConnection(false); + + $sql = 'SELECT * FROM yii2_test_item_index'; + $command = $db->createCommand($sql); + $this->assertEquals($sql, $command->sql); + + $sql2 = 'SELECT * FROM yii2_test_item_index'; + $command->sql = $sql2; + $this->assertEquals($sql2, $command->sql); + } + + public function testAutoQuoting() + { + $db = $this->getConnection(false); + + $sql = 'SELECT [[id]], [[t.name]] FROM {{yii2_test_item_index}} t'; + $command = $db->createCommand($sql); + $this->assertEquals("SELECT `id`, `t`.`name` FROM `yii2_test_item_index` t", $command->sql); + } + + public function testPrepareCancel() + { + $db = $this->getConnection(false); + + $command = $db->createCommand('SELECT * FROM yii2_test_item_index'); + $this->assertEquals(null, $command->pdoStatement); + $command->prepare(); + $this->assertNotEquals(null, $command->pdoStatement); + $command->cancel(); + $this->assertEquals(null, $command->pdoStatement); + } + + public function testExecute() + { + $db = $this->getConnection(); + + $sql = 'SELECT COUNT(*) FROM yii2_test_item_index WHERE MATCH(\'wooden\')'; + $command = $db->createCommand($sql); + $this->assertEquals(1, $command->queryScalar()); + + $command = $db->createCommand('bad SQL'); + $this->setExpectedException('\yii\db\Exception'); + $command->execute(); + } + + public function testQuery() + { + $db = $this->getConnection(); + + // query + $sql = 'SELECT * FROM yii2_test_item_index'; + $reader = $db->createCommand($sql)->query(); + $this->assertTrue($reader instanceof DataReader); + + // queryAll + $rows = $db->createCommand('SELECT * FROM yii2_test_item_index')->queryAll(); + $this->assertEquals(2, count($rows)); + $row = $rows[1]; + $this->assertEquals(2, $row['id']); + $this->assertEquals(2, $row['category_id']); + + $rows = $db->createCommand('SELECT * FROM yii2_test_item_index WHERE id=10')->queryAll(); + $this->assertEquals([], $rows); + + // queryOne + $sql = 'SELECT * FROM yii2_test_item_index ORDER BY id ASC'; + $row = $db->createCommand($sql)->queryOne(); + $this->assertEquals(1, $row['id']); + $this->assertEquals(1, $row['category_id']); + + $sql = 'SELECT * FROM yii2_test_item_index ORDER BY id ASC'; + $command = $db->createCommand($sql); + $command->prepare(); + $row = $command->queryOne(); + $this->assertEquals(1, $row['id']); + $this->assertEquals(1, $row['category_id']); + + $sql = 'SELECT * FROM yii2_test_item_index WHERE id=10'; + $command = $db->createCommand($sql); + $this->assertFalse($command->queryOne()); + + // queryColumn + $sql = 'SELECT * FROM yii2_test_item_index'; + $column = $db->createCommand($sql)->queryColumn(); + $this->assertEquals(range(1, 2), $column); + + $command = $db->createCommand('SELECT id FROM yii2_test_item_index WHERE id=10'); + $this->assertEquals([], $command->queryColumn()); + + // queryScalar + $sql = 'SELECT * FROM yii2_test_item_index ORDER BY id ASC'; + $this->assertEquals($db->createCommand($sql)->queryScalar(), 1); + + $sql = 'SELECT id FROM yii2_test_item_index ORDER BY id ASC'; + $command = $db->createCommand($sql); + $command->prepare(); + $this->assertEquals(1, $command->queryScalar()); + + $command = $db->createCommand('SELECT id FROM yii2_test_item_index WHERE id=10'); + $this->assertFalse($command->queryScalar()); + + $command = $db->createCommand('bad SQL'); + $this->setExpectedException('\yii\db\Exception'); + $command->query(); + } + + /** + * @depends testQuery + */ + public function testInsert() + { + $db = $this->getConnection(); + + $command = $db->createCommand()->insert('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'category' => [1, 2], + 'id' => 1, + ]); + $this->assertEquals(1, $command->execute(), 'Unable to execute insert!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(1, count($rows), 'No row inserted!'); + } + + /** + * @depends testInsert + */ + public function testBatchInsert() + { + $db = $this->getConnection(); + + $command = $db->createCommand()->batchInsert( + 'yii2_test_rt_index', + [ + 'title', + 'content', + 'type_id', + 'category', + 'id', + ], + [ + [ + 'Test title 1', + 'Test content 1', + 1, + [1, 2], + 1, + ], + [ + 'Test title 2', + 'Test content 2', + 2, + [3, 4], + 2, + ], + ] + ); + $this->assertEquals(2, $command->execute(), 'Unable to execute batch insert!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(2, count($rows), 'No rows inserted!'); + } + + /** + * @depends testInsert + */ + public function testReplace() + { + $db = $this->getConnection(); + + $command = $db->createCommand()->replace('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'category' => [1, 2], + 'id' => 1, + ]); + $this->assertEquals(1, $command->execute(), 'Unable to execute replace!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(1, count($rows), 'No row inserted!'); + + $newTypeId = 5; + $command = $db->createCommand()->replace('yii2_test_rt_index',[ + 'type_id' => $newTypeId, + 'category' => [3, 4], + 'id' => 1, + ]); + $this->assertEquals(1, $command->execute(), 'Unable to update via replace!'); + + list($row) = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals($newTypeId, $row['type_id'], 'Unable to update attribute value!'); + } + + /** + * @depends testReplace + */ + public function testBatchReplace() + { + $db = $this->getConnection(); + + $command = $db->createCommand()->batchReplace( + 'yii2_test_rt_index', + [ + 'title', + 'content', + 'type_id', + 'category', + 'id', + ], + [ + [ + 'Test title 1', + 'Test content 1', + 1, + [1, 2], + 1, + ], + [ + 'Test title 2', + 'Test content 2', + 2, + [3, 4], + 2, + ], + ] + ); + $this->assertEquals(2, $command->execute(), 'Unable to execute batch replace!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(2, count($rows), 'No rows inserted!'); + + $newTypeId = 5; + $command = $db->createCommand()->replace('yii2_test_rt_index',[ + 'type_id' => $newTypeId, + 'id' => 1, + ]); + $this->assertEquals(1, $command->execute(), 'Unable to update via replace!'); + list($row) = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals($newTypeId, $row['type_id'], 'Unable to update attribute value!'); + } + + /** + * @depends testInsert + */ + public function testUpdate() + { + $db = $this->getConnection(); + + $db->createCommand()->insert('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'id' => 1, + ])->execute(); + + $newTypeId = 5; + $command = $db->createCommand()->update( + 'yii2_test_rt_index', + [ + 'type_id' => $newTypeId, + 'category' => [3, 4], + ], + 'id = 1' + ); + $this->assertEquals(1, $command->execute(), 'Unable to execute update!'); + + list($row) = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals($newTypeId, $row['type_id'], 'Unable to update attribute value!'); + } + + /** + * @depends testUpdate + */ + public function testUpdateWithOptions() + { + $db = $this->getConnection(); + + $db->createCommand()->insert('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'id' => 1, + ])->execute(); + + $newTypeId = 5; + $command = $db->createCommand()->update( + 'yii2_test_rt_index', + [ + 'type_id' => $newTypeId, + 'non_existing_attribute' => 10, + ], + 'id = 1', + [], + [ + 'ignore_nonexistent_columns' => 1 + ] + ); + $this->assertEquals(1, $command->execute(), 'Unable to execute update!'); + } + + /** + * @depends testInsert + */ + public function testDelete() + { + $db = $this->getConnection(); + + $db->createCommand()->insert('yii2_test_rt_index', [ + 'title' => 'Test title', + 'content' => 'Test content', + 'type_id' => 2, + 'id' => 1, + ])->execute(); + + $command = $db->createCommand()->delete('yii2_test_rt_index', 'id = 1'); + $this->assertEquals(1, $command->execute(), 'Unable to execute delete!'); + + $rows = $db->createCommand('SELECT * FROM yii2_test_rt_index')->queryAll(); + $this->assertEquals(0, count($rows), 'Unable to delete record!'); + } + + /** + * @depends testQuery + */ + public function testCallSnippets() + { + $db = $this->getConnection(); + + $query = 'pencil'; + $source = 'Some data sentence about ' . $query; + + $rows = $db->createCommand()->callSnippets('yii2_test_item_index', $source, $query)->queryColumn(); + $this->assertNotEmpty($rows, 'Unable to call snippets!'); + $this->assertContains('' . $query . '', $rows[0], 'Query not present in the snippet!'); + + $rows = $db->createCommand()->callSnippets('yii2_test_item_index', [$source], $query)->queryColumn(); + $this->assertNotEmpty($rows, 'Unable to call snippets for array source!'); + + $options = [ + 'before_match' => '[', + 'after_match' => ']', + 'limit' => 20, + ]; + $snippet = $db->createCommand()->callSnippets('yii2_test_item_index', $source, $query, $options)->queryScalar(); + $this->assertContains($options['before_match'] . $query . $options['after_match'], $snippet, 'Unable to apply options!'); + } + + /** + * @depends testQuery + */ + public function testCallKeywords() + { + $db = $this->getConnection(); + + $text = 'table pencil'; + $rows = $db->createCommand()->callKeywords('yii2_test_item_index', $text)->queryAll(); + $this->assertNotEmpty($rows, 'Unable to call keywords!'); + $this->assertArrayHasKey('tokenized', $rows[0], 'No tokenized keyword!'); + $this->assertArrayHasKey('normalized', $rows[0], 'No normalized keyword!'); + + $text = 'table pencil'; + $rows = $db->createCommand()->callKeywords('yii2_test_item_index', $text, true)->queryAll(); + $this->assertNotEmpty($rows, 'Unable to call keywords with statistic!'); + $this->assertArrayHasKey('docs', $rows[0], 'No docs!'); + $this->assertArrayHasKey('hits', $rows[0], 'No hits!'); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ConnectionTest.php b/tests/unit/extensions/sphinx/ConnectionTest.php new file mode 100644 index 0000000..803f627 --- /dev/null +++ b/tests/unit/extensions/sphinx/ConnectionTest.php @@ -0,0 +1,42 @@ +getConnection(false); + $params = $this->sphinxConfig; + + $this->assertEquals($params['dsn'], $connection->dsn); + $this->assertEquals($params['username'], $connection->username); + $this->assertEquals($params['password'], $connection->password); + } + + public function testOpenClose() + { + $connection = $this->getConnection(false, false); + + $this->assertFalse($connection->isActive); + $this->assertEquals(null, $connection->pdo); + + $connection->open(); + $this->assertTrue($connection->isActive); + $this->assertTrue($connection->pdo instanceof \PDO); + + $connection->close(); + $this->assertFalse($connection->isActive); + $this->assertEquals(null, $connection->pdo); + + $connection = new Connection; + $connection->dsn = 'unknown::memory:'; + $this->setExpectedException('yii\db\Exception'); + $connection->open(); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php b/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php new file mode 100644 index 0000000..0893944 --- /dev/null +++ b/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php @@ -0,0 +1,74 @@ +getConnection(); + ActiveRecordDb::$db = $this->getDbConnection(); + } + + // Tests : + + public function testFindLazy() + { + /** @var ArticleIndex $article */ + $article = ArticleIndex::find(['id' => 2]); + + // has one : + $this->assertFalse($article->isRelationPopulated('source')); + $source = $article->source; + $this->assertTrue($article->isRelationPopulated('source')); + $this->assertTrue($source instanceof ArticleDb); + $this->assertEquals(1, count($article->populatedRelations)); + + // has many : + /*$this->assertFalse($article->isRelationPopulated('tags')); + $tags = $article->tags; + $this->assertTrue($article->isRelationPopulated('tags')); + $this->assertEquals(3, count($tags)); + $this->assertTrue($tags[0] instanceof TagDb);*/ + } + + public function testFindEager() + { + // has one : + $articles = ArticleIndex::find()->with('source')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0]->isRelationPopulated('source')); + $this->assertTrue($articles[1]->isRelationPopulated('source')); + $this->assertTrue($articles[0]->source instanceof ArticleDb); + $this->assertTrue($articles[1]->source instanceof ArticleDb); + + // has many : + /*$articles = ArticleIndex::find()->with('tags')->all(); + $this->assertEquals(2, count($articles)); + $this->assertTrue($articles[0]->isRelationPopulated('tags')); + $this->assertTrue($articles[1]->isRelationPopulated('tags'));*/ + } + + /** + * @depends testFindEager + */ + public function testFindWithSnippets() + { + $articles = ArticleIndex::find() + ->match('about') + ->with('source') + ->snippetByModel() + ->all(); + $this->assertEquals(2, count($articles)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/QueryTest.php b/tests/unit/extensions/sphinx/QueryTest.php new file mode 100644 index 0000000..59a8595 --- /dev/null +++ b/tests/unit/extensions/sphinx/QueryTest.php @@ -0,0 +1,187 @@ +select('*'); + $this->assertEquals(['*'], $query->select); + $this->assertNull($query->distinct); + $this->assertEquals(null, $query->selectOption); + + $query = new Query; + $query->select('id, name', 'something')->distinct(true); + $this->assertEquals(['id', 'name'], $query->select); + $this->assertTrue($query->distinct); + $this->assertEquals('something', $query->selectOption); + } + + public function testFrom() + { + $query = new Query; + $query->from('tbl_user'); + $this->assertEquals(['tbl_user'], $query->from); + } + + public function testMatch() + { + $query = new Query; + $match = 'test match'; + $query->match($match); + $this->assertEquals($match, $query->match); + + $command = $query->createCommand($this->getConnection(false)); + $this->assertContains('MATCH(', $command->getSql(), 'No MATCH operator present!'); + $this->assertContains($match, $command->params, 'No match query among params!'); + } + + public function testWhere() + { + $query = new Query; + $query->where('id = :id', [':id' => 1]); + $this->assertEquals('id = :id', $query->where); + $this->assertEquals([':id' => 1], $query->params); + + $query->andWhere('name = :name', [':name' => 'something']); + $this->assertEquals(['and', 'id = :id', 'name = :name'], $query->where); + $this->assertEquals([':id' => 1, ':name' => 'something'], $query->params); + + $query->orWhere('age = :age', [':age' => '30']); + $this->assertEquals(['or', ['and', 'id = :id', 'name = :name'], 'age = :age'], $query->where); + $this->assertEquals([':id' => 1, ':name' => 'something', ':age' => '30'], $query->params); + } + + public function testGroup() + { + $query = new Query; + $query->groupBy('team'); + $this->assertEquals(['team'], $query->groupBy); + + $query->addGroupBy('company'); + $this->assertEquals(['team', 'company'], $query->groupBy); + + $query->addGroupBy('age'); + $this->assertEquals(['team', 'company', 'age'], $query->groupBy); + } + + public function testOrder() + { + $query = new Query; + $query->orderBy('team'); + $this->assertEquals(['team' => SORT_ASC], $query->orderBy); + + $query->addOrderBy('company'); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC], $query->orderBy); + + $query->addOrderBy('age'); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_ASC], $query->orderBy); + + $query->addOrderBy(['age' => SORT_DESC]); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_DESC], $query->orderBy); + + $query->addOrderBy('age ASC, company DESC'); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_DESC, 'age' => SORT_ASC], $query->orderBy); + } + + public function testLimitOffset() + { + $query = new Query; + $query->limit(10)->offset(5); + $this->assertEquals(10, $query->limit); + $this->assertEquals(5, $query->offset); + } + + public function testWithin() + { + $query = new Query; + $query->within('team'); + $this->assertEquals(['team' => SORT_ASC], $query->within); + + $query->addWithin('company'); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC], $query->within); + + $query->addWithin('age'); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_ASC], $query->within); + + $query->addWithin(['age' => SORT_DESC]); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_ASC, 'age' => SORT_DESC], $query->within); + + $query->addWithin('age ASC, company DESC'); + $this->assertEquals(['team' => SORT_ASC, 'company' => SORT_DESC, 'age' => SORT_ASC], $query->within); + } + + public function testOptions() + { + $query = new Query; + $options = [ + 'cutoff' => 50, + 'max_matches' => 50, + ]; + $query->options($options); + $this->assertEquals($options, $query->options); + + $newMaxMatches = $options['max_matches'] + 10; + $query->addOptions(['max_matches' => $newMaxMatches]); + $this->assertEquals($newMaxMatches, $query->options['max_matches']); + } + + public function testRun() + { + $connection = $this->getConnection(); + + $query = new Query; + $rows = $query->from('yii2_test_article_index') + ->match('about') + ->options([ + 'cutoff' => 50, + 'field_weights' => [ + 'title' => 10, + 'content' => 3, + ], + ]) + ->all($connection); + $this->assertNotEmpty($rows); + } + + /** + * @depends testRun + */ + public function testSnippet() + { + $connection = $this->getConnection(); + + $match = 'about'; + $snippetPrefix = 'snippet#'; + $snippetCallback = function() use ($match, $snippetPrefix) { + return [ + $snippetPrefix . '1: ' . $match, + $snippetPrefix . '2: ' . $match, + ]; + }; + $snippetOptions = [ + 'before_match' => '[', + 'after_match' => ']', + ]; + + $query = new Query; + $rows = $query->from('yii2_test_article_index') + ->match($match) + ->snippetCallback($snippetCallback) + ->snippetOptions($snippetOptions) + ->all($connection); + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertContains($snippetPrefix, $row['snippet'], 'Snippet source not present!'); + $this->assertContains($snippetOptions['before_match'] . $match, $row['snippet'] . $snippetOptions['after_match'], 'Options not applied!'); + } + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/SchemaTest.php b/tests/unit/extensions/sphinx/SchemaTest.php new file mode 100644 index 0000000..2cc3ff9 --- /dev/null +++ b/tests/unit/extensions/sphinx/SchemaTest.php @@ -0,0 +1,84 @@ +getConnection()->schema; + + $indexes = $schema->getIndexNames(); + $this->assertContains('yii2_test_article_index', $indexes); + $this->assertContains('yii2_test_item_index', $indexes); + $this->assertContains('yii2_test_rt_index', $indexes); + } + + public function testGetIndexSchemas() + { + $schema = $this->getConnection()->schema; + + $indexes = $schema->getIndexSchemas(); + $this->assertEquals(count($schema->getIndexNames()), count($indexes)); + foreach($indexes as $index) { + $this->assertInstanceOf('yii\sphinx\IndexSchema', $index); + } + } + + public function testGetNonExistingIndexSchema() + { + $this->assertNull($this->getConnection()->schema->getIndexSchema('non_existing_index')); + } + + public function testSchemaRefresh() + { + $schema = $this->getConnection()->schema; + + $schema->db->enableSchemaCache = true; + $schema->db->schemaCache = new FileCache(); + $noCacheIndex = $schema->getIndexSchema('yii2_test_rt_index', true); + $cachedIndex = $schema->getIndexSchema('yii2_test_rt_index', true); + $this->assertEquals($noCacheIndex, $cachedIndex); + } + + public function testGetPDOType() + { + $values = [ + [null, \PDO::PARAM_NULL], + ['', \PDO::PARAM_STR], + ['hello', \PDO::PARAM_STR], + [0, \PDO::PARAM_INT], + [1, \PDO::PARAM_INT], + [1337, \PDO::PARAM_INT], + [true, \PDO::PARAM_BOOL], + [false, \PDO::PARAM_BOOL], + [$fp=fopen(__FILE__, 'rb'), \PDO::PARAM_LOB], + ]; + + $schema = $this->getConnection()->schema; + + foreach($values as $value) { + $this->assertEquals($value[1], $schema->getPdoType($value[0])); + } + fclose($fp); + } + + public function testIndexType() + { + $schema = $this->getConnection()->schema; + + $index = $schema->getIndexSchema('yii2_test_article_index'); + $this->assertEquals('local', $index->type); + $this->assertFalse($index->isRuntime); + + $index = $schema->getIndexSchema('yii2_test_rt_index'); + $this->assertEquals('rt', $index->type); + $this->assertTrue($index->isRuntime); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/sphinx/SphinxTestCase.php b/tests/unit/extensions/sphinx/SphinxTestCase.php new file mode 100644 index 0000000..899ea72 --- /dev/null +++ b/tests/unit/extensions/sphinx/SphinxTestCase.php @@ -0,0 +1,154 @@ + 'mysql:host=127.0.0.1;port=9306;', + 'username' => '', + 'password' => '', + ]; + /** + * @var Connection Sphinx connection instance. + */ + protected $sphinx; + /** + * @var array Database connection configuration. + */ + protected $dbConfig = [ + 'dsn' => 'mysql:host=127.0.0.1;', + 'username' => '', + 'password' => '', + ]; + /** + * @var \yii\db\Connection database connection instance. + */ + protected $db; + + public static function setUpBeforeClass() + { + static::loadClassMap(); + } + + protected function setUp() + { + parent::setUp(); + if (!extension_loaded('pdo') || !extension_loaded('pdo_mysql')) { + $this->markTestSkipped('pdo and pdo_mysql extension are required.'); + } + $config = $this->getParam('sphinx'); + if (!empty($config)) { + $this->sphinxConfig = $config['sphinx']; + $this->dbConfig = $config['db']; + } + $this->mockApplication(); + static::loadClassMap(); + } + + protected function tearDown() + { + if ($this->sphinx) { + $this->sphinx->close(); + } + $this->destroyApplication(); + } + + /** + * Adds sphinx extension files to [[Yii::$classPath]], + * avoiding the necessity of usage Composer autoloader. + */ + protected static function loadClassMap() + { + $baseNameSpace = 'yii/sphinx'; + $basePath = realpath(__DIR__. '/../../../../extensions/sphinx'); + $files = FileHelper::findFiles($basePath); + foreach ($files as $file) { + $classRelativePath = str_replace($basePath, '', $file); + $classFullName = str_replace(['/', '.php'], ['\\', ''], $baseNameSpace . $classRelativePath); + Yii::$classMap[$classFullName] = $file; + } + } + + /** + * @param bool $reset whether to clean up the test database + * @param bool $open whether to open test database + * @return \yii\sphinx\Connection + */ + public function getConnection($reset = false, $open = true) + { + if (!$reset && $this->sphinx) { + return $this->sphinx; + } + $db = new Connection; + $db->dsn = $this->sphinxConfig['dsn']; + if (isset($this->sphinxConfig['username'])) { + $db->username = $this->sphinxConfig['username']; + $db->password = $this->sphinxConfig['password']; + } + if (isset($this->sphinxConfig['attributes'])) { + $db->attributes = $this->sphinxConfig['attributes']; + } + if ($open) { + $db->open(); + } + $this->sphinx = $db; + return $db; + } + + /** + * Truncates the runtime index. + * @param string $indexName index name. + */ + protected function truncateRuntimeIndex($indexName) + { + if ($this->sphinx) { + $this->sphinx->createCommand('TRUNCATE RTINDEX ' . $indexName)->execute(); + } + } + + /** + * @param bool $reset whether to clean up the test database + * @param bool $open whether to open and populate test database + * @return \yii\db\Connection + */ + public function getDbConnection($reset = true, $open = true) + { + if (!$reset && $this->db) { + return $this->db; + } + $db = new \yii\db\Connection; + $db->dsn = $this->dbConfig['dsn']; + if (isset($this->dbConfig['username'])) { + $db->username = $this->dbConfig['username']; + $db->password = $this->dbConfig['password']; + } + if (isset($this->dbConfig['attributes'])) { + $db->attributes = $this->dbConfig['attributes']; + } + if ($open) { + $db->open(); + if (!empty($this->dbConfig['fixture'])) { + $lines = explode(';', file_get_contents($this->dbConfig['fixture'])); + foreach ($lines as $line) { + if (trim($line) !== '') { + $db->pdo->exec($line); + } + } + } + } + $this->db = $db; + return $db; + } +} \ No newline at end of file