From d6c388299d3af58060b39b8d4c0872b0f05e57b5 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 14 Nov 2013 22:12:31 +0200 Subject: [PATCH] Sphinx ActiveRecord added as blank. --- extensions/sphinx/ActiveQuery.php | 207 ++++ extensions/sphinx/ActiveRecord.php | 1187 +++++++++++++++++++++ extensions/sphinx/Connection.php | 4 +- tests/unit/data/sphinx/ar/ActiveRecord.php | 16 + tests/unit/data/sphinx/ar/ArticleIndex.php | 18 + tests/unit/data/sphinx/ar/ItemIndex.php | 11 + tests/unit/data/sphinx/ar/RuntimeIndex.php | 11 + tests/unit/extensions/sphinx/ActiveRecordTest.php | 228 ++++ 8 files changed, 1680 insertions(+), 2 deletions(-) create mode 100644 extensions/sphinx/ActiveQuery.php create mode 100644 extensions/sphinx/ActiveRecord.php create mode 100644 tests/unit/data/sphinx/ar/ActiveRecord.php create mode 100644 tests/unit/data/sphinx/ar/ArticleIndex.php create mode 100644 tests/unit/data/sphinx/ar/ItemIndex.php create mode 100644 tests/unit/data/sphinx/ar/RuntimeIndex.php create mode 100644 tests/unit/extensions/sphinx/ActiveRecordTest.php diff --git a/extensions/sphinx/ActiveQuery.php b/extensions/sphinx/ActiveQuery.php new file mode 100644 index 0000000..26cc7ab --- /dev/null +++ b/extensions/sphinx/ActiveQuery.php @@ -0,0 +1,207 @@ + + * @since 2.0 + */ +class ActiveQuery extends Query +{ + /** + * @var string the name of the ActiveRecord class. + */ + public $modelClass; + /** + * @var array list of relations that this query should be performed with + */ + public $with; + /** + * @var boolean whether to return each record as an array. If false (default), an object + * of [[modelClass]] will be created to represent each record. + */ + public $asArray; + /** + * @var string the SQL statement to be executed for retrieving AR records. + * This is set by [[ActiveRecord::findBySql()]]. + */ + public $sql; + + /** + * PHP magic method. + * This method allows calling static method defined in [[modelClass]] via this query object. + * It is mainly implemented for supporting the feature of scope. + * @param string $name the method name to be called + * @param array $params the parameters passed to the method + * @return mixed the method return result + */ + public function __call($name, $params) + { + if (method_exists($this->modelClass, $name)) { + array_unshift($params, $this); + call_user_func_array([$this->modelClass, $name], $params); + return $this; + } else { + return parent::__call($name, $params); + } + } + + /** + * 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); + // TODO relations + /*if (!empty($this->with)) { + $this->populateRelations($models, $this->with); + }*/ + 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); + } + // TODO relations + /*if (!empty($this->with)) { + $models = [$model]; + $this->populateRelations($models, $this->with); + $model = $models[0]; + }*/ + 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; + if ($db === null) { + $db = $modelClass::getDb(); + } + + $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); + } + + /** + * Sets the [[asArray]] property. + * @param boolean $value whether to return the query results in terms of arrays instead of Active Records. + * @return static the query object itself + */ + public function asArray($value = true) + { + $this->asArray = $value; + return $this; + } + + /** + * Sets the [[indexBy]] property. + * @param string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row or model data. The signature of the callable should be: + * + * ~~~ + * // $model is an AR instance when `asArray` is false, + * // or an array of column values when `asArray` is true. + * function ($model) + * { + * // return the index value corresponding to $model + * } + * ~~~ + * + * @return static the query object itself + */ + public function indexBy($column) + { + return parent::indexBy($column); + } + + private function createModels($rows) + { + $models = []; + if ($this->asArray) { + if ($this->indexBy === null) { + return $rows; + } + foreach ($rows as $row) { + if (is_string($this->indexBy)) { + $key = $row[$this->indexBy]; + } else { + $key = call_user_func($this->indexBy, $row); + } + $models[$key] = $row; + } + } else { + /** @var $class ActiveRecord */ + $class = $this->modelClass; + if ($this->indexBy === null) { + foreach ($rows as $row) { + $models[] = $class::create($row); + } + } else { + foreach ($rows as $row) { + $model = $class::create($row); + if (is_string($this->indexBy)) { + $key = $model->{$this->indexBy}; + } else { + $key = call_user_func($this->indexBy, $model); + } + $models[$key] = $model; + } + } + } + return $models; + } +} \ No newline at end of file diff --git a/extensions/sphinx/ActiveRecord.php b/extensions/sphinx/ActiveRecord.php new file mode 100644 index 0000000..0f9122f --- /dev/null +++ b/extensions/sphinx/ActiveRecord.php @@ -0,0 +1,1187 @@ + + * @since 2.0 + */ +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 = []; + + /** + * 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 = Customer::findBySql('SELECT * FROM tbl_customer')->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 customers whose status is 2: + * + * ~~~ + * Customer::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(); + } + + /** + * Updates the whole table using the provided counter changes and conditions. + * For example, to increment all customers' age by 1, + * + * ~~~ + * Customer::updateAllCounters(['age' => 1]); + * ~~~ + * + * @param array $counters the counters to be updated (attribute name => increment value). + * Use negative values if you want to decrement the counters. + * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * Do not name the parameters as `:bp0`, `:bp1`, etc., because they are used internally by this method. + * @return integer the number of rows updated + */ + public static function updateAllCounters($counters, $condition = '', $params = []) + { + $n = 0; + foreach ($counters as $name => $value) { + $counters[$name] = new Expression("[[$name]]+:bp{$n}", [":bp{$n}" => $value]); + $n++; + } + $command = static::getDb()->createCommand(); + $command->update(static::indexName(), $counters, $condition, $params); + return $command->execute(); + } + + /** + * Deletes rows in the table using the provided conditions. + * WARNING: If you do not specify any condition, this method will delete ALL rows in the table. + * + * For example, to delete all customers whose status is 3: + * + * ~~~ + * Customer::deleteAll('status = 3'); + * ~~~ + * + * @param string|array $condition the conditions that will be put in the WHERE part of the DELETE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $params the parameters (name => value) to be bound to the query. + * @return integer the number of rows deleted + */ + public static function deleteAll($condition = '', $params = []) + { + $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. `CustomerQuery` specified + * written for querying `Customer` purpose.) + * @return ActiveQuery the newly created [[ActiveQuery]] instance. + */ + public static function createQuery() + { + return new ActiveQuery(['modelClass' => get_called_class()]); + } + + /** + * Declares the name of the database table associated with this AR class. + * By default this method returns the class name as the table name by calling [[Inflector::camel2id()]] + * with prefix 'tbl_'. For example, 'Customer' becomes 'tbl_customer', and 'OrderItem' becomes + * 'tbl_order_item'. You may override this method if the table is not named after this convention. + * @return string the table name + */ + public static function indexName() + { + return Inflector::camel2id(StringHelper::basename(get_called_class()), '_'); + } + + /** + * Returns the schema information of the DB table associated with this AR class. + * @return IndexSchema the schema information of the DB table associated with this AR class. + * @throws InvalidConfigException if the table for the AR class does not exist. + */ + public static function 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. + * @return string the primary keys of the associated database table. + */ + public static function primaryKey() + { + return static::getIndexSchema()->primaryKey; + } + + /** + * 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. + * + * Optimized locking is only supported by [[update()]] and [[delete()]]. + * + * To use optimized 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. + * + * @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 DB 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 DB 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); + // TODO: relation + if ($value instanceof ActiveRelation) { + 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); + } + } + } + + /** + * 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 a customer record: + * + * ~~~ + * $customer = new Customer; // or $customer = Customer::find($id); + * $customer->name = $name; + * $customer->email = $email; + * $customer->save(); + * ~~~ + * + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be saved to database. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from DB will be saved. + * @return boolean whether the 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 database table using the attribute values of this record. + * + * This method performs the following steps in order: + * + * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation + * fails, it will skip the rest of the steps; + * 2. call [[afterValidate()]] when `$runValidation` is true. + * 3. call [[beforeSave()]]. If the method returns false, it will skip the + * rest of the steps; + * 4. insert the record into database. If this fails, it will skip the rest of the steps; + * 5. call [[afterSave()]]; + * + * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], + * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]] + * will be raised by the corresponding methods. + * + * Only the [[changedAttributes|changed attribute values]] will be inserted into database. + * + * If the table's primary key is auto-incremental and is null during insertion, + * it will be populated with the actual value after insertion. + * + * For example, to insert a customer record: + * + * ~~~ + * $customer = new Customer; + * $customer->name = $name; + * $customer->email = $email; + * $customer->insert(); + * ~~~ + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be inserted into the database. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from DB will be saved. + * @return boolean whether the attributes are valid and the record is inserted successfully. + * @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)) { + $key = $this->primaryKey(); + $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 database table. + * + * 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 database. If this fails, it will skip the rest of the steps; + * 5. call [[afterSave()]]; + * + * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], + * [[EVENT_BEFORE_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 a customer record: + * + * ~~~ + * $customer = Customer::find($id); + * $customer->name = $name; + * $customer->email = $email; + * $customer->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; + } + $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; + } + + /** + * Updates one or several counter columns for the current AR object. + * Note that this method differs from [[updateAllCounters()]] in that it only + * saves counters for the current AR object. + * + * An example usage is as follows: + * + * ~~~ + * $post = Post::find($id); + * $post->updateCounters(['view_count' => 1]); + * ~~~ + * + * @param array $counters the counters to be updated (attribute name => increment value) + * Use negative values if you want to decrement the counters. + * @return boolean whether the saving is successful + * @see updateAllCounters() + */ + public function updateCounters($counters) + { + if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) { + foreach ($counters as $name => $value) { + $this->_attributes[$name] += $value; + $this->_oldAttributes[$name] = $this->_attributes[$name]; + } + return true; + } else { + return false; + } + } + + /** + * Deletes the table row 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 database; + * 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) + { + $key = $this->primaryKey(); + $value = isset($this->_attributes[$key]) ? $this->_attributes[$key] : null; + if ($asArray) { + return [$key => $value]; + } else { + return $value; + } + } + + /** + * 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) + { + $key = $this->primaryKey(); + $value = isset($this->_oldAttributes[$key]) ? $this->_oldAttributes[$key] : null; + if ($asArray) { + return [$key => $value]; + } else { + return $value; + } + } + + /** + * 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); + $value = array_map([$column, 'typecast'], $value); + } else { + $value = $column->typecast($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. + * For example, by creating a record based on the value of a column, + * you may implement the so-called single-table inheritance mapping. + * @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 [[ActiveRelation]] object. + * It can be declared in either the Active Record class itself or one of its behaviors. + * @param string $name the relation name + * @return ActiveRelation the relation object + * @throws InvalidParamException if the named relation does not exist. + */ + public function getRelation($name) + { + $getter = 'get' . $name; + try { + $relation = $this->$getter(); + if ($relation instanceof ActiveRelation) { + return $relation; + } 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); + } +} \ No newline at end of file diff --git a/extensions/sphinx/Connection.php b/extensions/sphinx/Connection.php index 6009f1f..3bb6d63 100644 --- a/extensions/sphinx/Connection.php +++ b/extensions/sphinx/Connection.php @@ -11,10 +11,10 @@ namespace yii\sphinx; * Class Connection * * @property Schema $schema The schema information for this Sphinx connection. This property is read-only. - * @property QueryBuilder $queryBuilder The query builder for this Sphinx connection. This property is + * @property \yii\sphinx\QueryBuilder $queryBuilder The query builder for this Sphinx connection. This property is * read-only. * @method Schema getSchema() The schema information for this Sphinx connection - * @method QueryBuilder getQueryBuilder() the query builder for this Sphinx connection + * @method \yii\sphinx\QueryBuilder getQueryBuilder() the query builder for this Sphinx connection * * @author Paul Klimov * @since 2.0 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 @@ +andWhere('author_id=1'); + } +} \ No newline at end of file diff --git a/tests/unit/data/sphinx/ar/ItemIndex.php b/tests/unit/data/sphinx/ar/ItemIndex.php new file mode 100644 index 0000000..2322429 --- /dev/null +++ b/tests/unit/data/sphinx/ar/ItemIndex.php @@ -0,0 +1,11 @@ +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); + + // updateCounters + /*$pk = ['id' => 1]; + $record = RuntimeIndex::find($pk); + $this->assertEquals(1, $record->quantity); + $ret = $record->updateCounters(['quantity' => -1]); + $this->assertTrue($ret); + $this->assertEquals(0, $record->quantity); + $record = RuntimeIndex::find($pk); + $this->assertEquals(0, $record->quantity);*/ + + // 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); + + // updateAllCounters + /*$pk = ['order_id' => 1, 'item_id' => 2]; + $record = RuntimeIndex::find($pk); + $this->assertEquals(2, $record->quantity); + $ret = RuntimeIndex::updateAllCounters([ + 'quantity' => 3, + 'subtotal' => -10, + ], $pk); + $this->assertEquals(1, $ret); + $record = RuntimeIndex::find($pk); + $this->assertEquals(5, $record->quantity); + $this->assertEquals(30, $record->subtotal);*/ + } + + /** + * @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)); + } +} \ No newline at end of file