diff --git a/extensions/mongo/ActiveQuery.php b/extensions/mongo/ActiveQuery.php new file mode 100644 index 00000000000..fc02df93632 --- /dev/null +++ b/extensions/mongo/ActiveQuery.php @@ -0,0 +1,107 @@ +with('orders')->asArray()->all(); + * ~~~ + * + * @author Paul Klimov + * @since 2.0 + */ +class ActiveQuery extends Query implements ActiveQueryInterface +{ + use ActiveQueryTrait; + + /** + * Executes query and returns all results as an array. + * @param Connection $db the Mongo connection used to execute the query. + * If null, the Mongo 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) + { + $cursor = $this->buildCursor($db); + $rows = $this->fetchRows($cursor); + if (!empty($rows)) { + $models = $this->createModels($rows); + if (!empty($this->with)) { + $this->findWith($this->with, $models); + } + return $models; + } else { + return []; + } + } + + /** + * Executes query and returns a single row of result. + * @param Connection $db the Mongo connection used to execute the query. + * If null, the Mongo 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) + { + $row = parent::one($db); + if ($row !== false) { + if ($this->asArray) { + $model = $row; + } else { + /** @var ActiveRecord $class */ + $class = $this->modelClass; + $model = $class::create($row); + } + if (!empty($this->with)) { + $models = [$model]; + $this->findWith($this->with, $models); + $model = $models[0]; + } + return $model; + } else { + return null; + } + } + + /** + * Returns the Mongo collection for this query. + * @param Connection $db Mongo connection. + * @return Collection collection instance. + */ + public function getCollection($db = null) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $this->modelClass; + if ($db === null) { + $db = $modelClass::getDb(); + } + if ($this->from === null) { + $this->from = $modelClass::collectionName(); + } + return $db->getCollection($this->from); + } +} \ No newline at end of file diff --git a/extensions/mongo/ActiveRecord.php b/extensions/mongo/ActiveRecord.php new file mode 100644 index 00000000000..b0a360e9bf4 --- /dev/null +++ b/extensions/mongo/ActiveRecord.php @@ -0,0 +1,353 @@ + + * @since 2.0 + */ +abstract class ActiveRecord extends BaseActiveRecord +{ + /** + * Returns the Mongo connection used by this AR class. + * By default, the "mongo" application component is used as the Mongo connection. + * You may override this method if you want to use a different database connection. + * @return Connection the database connection used by this AR class. + */ + public static function getDb() + { + return \Yii::$app->getComponent('mongo'); + } + + /** + * Updates all documents in the collection 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 collection + * @param array $condition description of the objects to update. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $options list of options in format: optionName => optionValue. + * @return integer the number of documents updated. + */ + public static function updateAll($attributes, $condition = [], $options = []) + { + return static::getCollection()->update($condition, $attributes, $options); + } + + /** + * Updates all documents in the collection using the provided counter changes and conditions. + * For example, to increment all customers' age by 1, + * + * ~~~ + * Customer::updateAllCounters(['age' => 1]); + * ~~~ + * + * @param array $counters the counters to be updated (attribute name => increment value). + * Use negative values if you want to decrement the counters. + * @param array $condition description of the objects to update. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $options list of options in format: optionName => optionValue. + * @return integer the number of documents updated. + */ + public static function updateAllCounters($counters, $condition = [], $options = []) + { + return static::getCollection()->update($condition, ['$inc' => $counters], $options); + } + + /** + * Deletes documents in the collection using the provided conditions. + * WARNING: If you do not specify any condition, this method will delete documents rows in the collection. + * + * For example, to delete all customers whose status is 3: + * + * ~~~ + * Customer::deleteAll('status = 3'); + * ~~~ + * + * @param array $condition description of the objects to delete. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $options list of options in format: optionName => optionValue. + * @return integer the number of documents deleted. + */ + public static function deleteAll($condition = [], $options = []) + { + $options['w'] = 1; + if (!array_key_exists('multiple', $options)) { + $options['multiple'] = true; + } + return static::getCollection()->remove($condition, $options); + } + + /** + * Creates an [[ActiveQuery]] instance. + * This method is called by [[find()]] to start a "find" command. + * 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 Mongo collection associated with this AR class. + * Collection name can be either a string or array: + * - if string considered as the name of the collection inside the default database. + * - if array - first element considered as the name of the database, second - as + * name of collection inside that database + * By default this method returns the class name as the collection name by calling [[Inflector::camel2id()]]. + * For example, 'Customer' becomes 'customer', and 'OrderItem' becomes + * 'order_item'. You may override this method if the table is not named after this convention. + * @return string|array the collection name + */ + public static function collectionName() + { + return Inflector::camel2id(StringHelper::basename(get_called_class()), '_'); + } + + /** + * Return the Mongo collection instance for this AR class. + * @return Collection collection instance. + */ + public static function getCollection() + { + return static::getDb()->getCollection(static::collectionName()); + } + + /** + * Returns the primary key name(s) for this AR class. + * The default implementation will return ['_id']. + * + * Note that an array should be returned even for a collection with single primary key. + * + * @return string[] the primary keys of the associated Mongo collection. + */ + public static function primaryKey() + { + return ['_id']; + } + + /** + * Creates an [[ActiveRelation]] 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 array $config the configuration passed to the ActiveRelation class. + * @return ActiveRelation the newly created [[ActiveRelation]] instance. + */ + public static function createActiveRelation($config = []) + { + return new ActiveRelation($config); + } + + /** + * Returns the list of all attribute names of the model. + * This method must be overridden by child classes to define available attributes. + * Note: primary key attribute "_id" should be always present in returned array. + * For example: + * ~~~ + * public function attributes() + * { + * return ['_id', 'name', 'address', 'status']; + * } + * ~~~ + * @return array list of attribute names. + */ + public function attributes() + { + throw new InvalidConfigException('The attributes() method of mongo ActiveRecord has to be implemented by child classes.'); + } + + /** + * Inserts a row into the associated Mongo collection 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 collection. 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 [[dirtyAttributes|changed attribute values]] will be inserted into database. + * + * If the primary key 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 collection. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded 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; + } + $result = $this->insertInternal($attributes); + return $result; + } + + /** + * @see ActiveRecord::insert() + */ + protected function insertInternal($attributes = null) + { + if (!$this->beforeSave(true)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $currentAttributes = $this->getAttributes(); + foreach ($this->primaryKey() as $key) { + $values[$key] = isset($currentAttributes[$key]) ? $currentAttributes[$key] : null; + } + } + $collection = static::getCollection(); + $newId = $collection->insert($values); + $this->setAttribute('_id', $newId); + foreach ($values as $name => $value) { + $this->setOldAttribute($name, $value); + } + $this->afterSave(true); + return true; + } + + /** + * @see ActiveRecord::update() + * @throws StaleObjectException + */ + protected 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 update() because it's possible + // that it doesn't change anything and thus returns 0. + $rows = static::getCollection()->update($condition, $values); + + if ($lock !== null && !$rows) { + throw new StaleObjectException('The object being updated is outdated.'); + } + + foreach ($values as $name => $value) { + $this->setOldAttribute($name, $this->getAttribute($name)); + } + $this->afterSave(false); + return $rows; + } + + /** + * Deletes the document corresponding to this active record from the collection. + * + * 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 document from the collection; + * 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 documents deleted, or false if the deletion is unsuccessful for some reason. + * Note that it is possible the number of documents 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() + { + $result = false; + if ($this->beforeDelete()) { + $result = $this->deleteInternal(); + $this->afterDelete(); + } + return $result; + } + + /** + * @see ActiveRecord::delete() + * @throws StaleObjectException + */ + protected function deleteInternal() + { + // 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 = static::getCollection()->remove($condition); + if ($lock !== null && !$result) { + throw new StaleObjectException('The object being deleted is outdated.'); + } + $this->setOldAttributes(null); + return $result; + } + + /** + * Returns a value indicating whether the given active record is the same as the current one. + * The comparison is made by comparing the table names and the primary key values of the two active records. + * If one of the records [[isNewRecord|is new]] they are also considered not equal. + * @param ActiveRecord $record record to compare to + * @return boolean whether the two active records refer to the same row in the same Mongo collection. + */ + public function equals($record) + { + if ($this->isNewRecord || $record->isNewRecord) { + return false; + } + return $this->collectionName() === $record->collectionName() && $this->getPrimaryKey() === $record->getPrimaryKey(); + } +} \ No newline at end of file diff --git a/extensions/mongo/ActiveRelation.php b/extensions/mongo/ActiveRelation.php new file mode 100644 index 00000000000..539dc7b186f --- /dev/null +++ b/extensions/mongo/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/mongo/Collection.php b/extensions/mongo/Collection.php new file mode 100644 index 00000000000..e9c37b8e0b8 --- /dev/null +++ b/extensions/mongo/Collection.php @@ -0,0 +1,899 @@ +mongo->getCollection('customer'); + * $collection->insert(['name' => 'John Smith', 'status' => 1]); + * ~~~ + * + * To perform "find" queries, please use [[Query]] instead. + * + * Mongo uses JSON format to specify query conditions with quite specific syntax. + * However Collection class provides the ability of "translating" common condition format used "yii\db\*" + * into Mongo condition. + * For example: + * ~~~ + * $condition = [ + * [ + * 'OR', + * ['AND', ['first_name' => 'John'], ['last_name' => 'Smith']], + * ['status' => [1, 2, 3]] + * ], + * ]; + * print_r($collection->buildCondition($condition)); + * // outputs : + * [ + * '$or' => [ + * [ + * 'first_name' => 'John', + * 'last_name' => 'John', + * ], + * [ + * 'status' => ['$in' => [1, 2, 3]], + * ] + * ] + * ] + * ~~~ + * + * Note: condition values for the key '_id' will be automatically cast to [[\MongoId]] instance, + * even if they are plain strings. However if you have other columns, containing [[\MongoId]], you + * should take care of possible typecast on your own. + * + * @property string $name name of this collection. This property is read-only. + * @property string $fullName full name of this collection, including database name. This property is read-only. + * @property array $lastError last error information. This property is read-only. + * + * @author Paul Klimov + * @since 2.0 + */ +class Collection extends Object +{ + /** + * @var \MongoCollection Mongo collection instance. + */ + public $mongoCollection; + + /** + * @return string name of this collection. + */ + public function getName() + { + return $this->mongoCollection->getName(); + } + + /** + * @return string full name of this collection, including database name. + */ + public function getFullName() + { + return $this->mongoCollection->__toString(); + } + + /** + * @return array last error information. + */ + public function getLastError() + { + return $this->mongoCollection->db->lastError(); + } + + /** + * Composes log/profile token. + * @param string $command command name + * @param array $arguments command arguments. + * @return string token. + */ + protected function composeLogToken($command, $arguments = []) + { + $parts = []; + foreach ($arguments as $argument) { + $parts[] = is_scalar($argument) ? $argument : Json::encode($argument); + } + return $this->getFullName() . '.' . $command . '(' . implode(', ', $parts) . ')'; + } + + /** + * Drops this collection. + * @throws Exception on failure. + * @return boolean whether the operation successful. + */ + public function drop() + { + $token = $this->composeLogToken('drop'); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->drop(); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + return true; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Creates an index on the collection and the specified fields. + * @param array|string $columns column name or list of column names. + * If array is given, each element in the array has as key the field name, and as + * value either 1 for ascending sort, or -1 for descending sort. + * You can specify field using native numeric key with the field name as a value, + * in this case ascending sort will be used. + * For example: + * ~~~ + * [ + * 'name', + * 'status' => -1, + * ] + * ~~~ + * @param array $options list of options in format: optionName => optionValue. + * @throws Exception on failure. + * @return boolean whether the operation successful. + */ + public function createIndex($columns, $options = []) + { + if (!is_array($columns)) { + $columns = [$columns]; + } + $keys = $this->normalizeIndexKeys($columns); + $token = $this->composeLogToken('createIndex', [$keys, $options]); + $options = array_merge(['w' => 1], $options); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->ensureIndex($keys, $options); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + return true; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Drop indexes for specified column(s). + * @param string|array $columns column name or list of column names. + * If array is given, each element in the array has as key the field name, and as + * value either 1 for ascending sort, or -1 for descending sort. + * Use value 'text' to specify text index. + * You can specify field using native numeric key with the field name as a value, + * in this case ascending sort will be used. + * For example: + * ~~~ + * [ + * 'name', + * 'status' => -1, + * 'description' => 'text', + * ] + * ~~~ + * @throws Exception on failure. + * @return boolean whether the operation successful. + */ + public function dropIndex($columns) + { + if (!is_array($columns)) { + $columns = [$columns]; + } + $keys = $this->normalizeIndexKeys($columns); + $token = $this->composeLogToken('dropIndex', [$keys]); + Yii::info($token, __METHOD__); + try { + $result = $this->mongoCollection->deleteIndex($keys); + $this->tryResultError($result); + return true; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Compose index keys from given columns/keys list. + * @param array $columns raw columns/keys list. + * @return array normalizes index keys array. + */ + protected function normalizeIndexKeys($columns) + { + $keys = []; + foreach ($columns as $key => $value) { + if (is_numeric($key)) { + $keys[$value] = \MongoCollection::ASCENDING; + } else { + $keys[$key] = $value; + } + } + return $keys; + } + + /** + * Drops all indexes for this collection. + * @throws Exception on failure. + * @return integer count of dropped indexes. + */ + public function dropAllIndexes() + { + $token = $this->composeLogToken('dropIndexes'); + Yii::info($token, __METHOD__); + try { + $result = $this->mongoCollection->deleteIndexes(); + $this->tryResultError($result); + return $result['nIndexesWas']; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Returns a cursor for the search results. + * In order to perform "find" queries use [[Query]] class. + * @param array $condition query condition + * @param array $fields fields to be selected + * @return \MongoCursor cursor for the search results + * @see Query + */ + public function find($condition = [], $fields = []) + { + return $this->mongoCollection->find($this->buildCondition($condition), $fields); + } + + /** + * Inserts new data into collection. + * @param array|object $data data to be inserted. + * @param array $options list of options in format: optionName => optionValue. + * @return \MongoId new record id instance. + * @throws Exception on failure. + */ + public function insert($data, $options = []) + { + $token = $this->composeLogToken('insert', [$data]); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $options = array_merge(['w' => 1], $options); + $this->tryResultError($this->mongoCollection->insert($data, $options)); + Yii::endProfile($token, __METHOD__); + return is_array($data) ? $data['_id'] : $data->_id; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Inserts several new rows into collection. + * @param array $rows array of arrays or objects to be inserted. + * @param array $options list of options in format: optionName => optionValue. + * @return array inserted data, each row will have "_id" key assigned to it. + * @throws Exception on failure. + */ + public function batchInsert($rows, $options = []) + { + $token = $this->composeLogToken('batchInsert', [$rows]); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $options = array_merge(['w' => 1], $options); + $this->tryResultError($this->mongoCollection->batchInsert($rows, $options)); + Yii::endProfile($token, __METHOD__); + return $rows; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Updates the rows, which matches given criteria by given data. + * Note: for "multiple" mode Mongo requires explicit strategy "$set" or "$inc" + * to be specified for the "newData". If no strategy is passed "$set" will be used. + * @param array $condition description of the objects to update. + * @param array $newData the object with which to update the matching records. + * @param array $options list of options in format: optionName => optionValue. + * @return integer|boolean number of updated documents or whether operation was successful. + * @throws Exception on failure. + */ + public function update($condition, $newData, $options = []) + { + $condition = $this->buildCondition($condition); + $options = array_merge(['w' => 1, 'multiple' => true], $options); + if ($options['multiple']) { + $keys = array_keys($newData); + if (!empty($keys) && strncmp('$', $keys[0], 1) !== 0) { + $newData = ['$set' => $newData]; + } + } + $token = $this->composeLogToken('update', [$condition, $newData, $options]); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->update($condition, $newData, $options); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + if (is_array($result) && array_key_exists('n', $result)) { + return $result['n']; + } else { + return true; + } + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Update the existing database data, otherwise insert this data + * @param array|object $data data to be updated/inserted. + * @param array $options list of options in format: optionName => optionValue. + * @return \MongoId updated/new record id instance. + * @throws Exception on failure. + */ + public function save($data, $options = []) + { + $token = $this->composeLogToken('save', [$data]); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $options = array_merge(['w' => 1], $options); + $this->tryResultError($this->mongoCollection->save($data, $options)); + Yii::endProfile($token, __METHOD__); + return is_array($data) ? $data['_id'] : $data->_id; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Removes data from the collection. + * @param array $condition description of records to remove. + * @param array $options list of options in format: optionName => optionValue. + * @return integer|boolean number of updated documents or whether operation was successful. + * @throws Exception on failure. + */ + public function remove($condition = [], $options = []) + { + $condition = $this->buildCondition($condition); + $options = array_merge(['w' => 1, 'multiple' => true], $options); + $token = $this->composeLogToken('remove', [$condition, $options]); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->remove($condition, $options); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + if (is_array($result) && array_key_exists('n', $result)) { + return $result['n']; + } else { + return true; + } + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Returns a list of distinct values for the given column across a collection. + * @param string $column column to use. + * @param array $condition query parameters. + * @return array|boolean array of distinct values, or "false" on failure. + * @throws Exception on failure. + */ + public function distinct($column, $condition = []) + { + $condition = $this->buildCondition($condition); + $token = $this->composeLogToken('distinct', [$column, $condition]); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->distinct($column, $condition); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Performs aggregation using Mongo Aggregation Framework. + * @param array $pipeline list of pipeline operators, or just the first operator + * @param array $pipelineOperator additional pipeline operator. You can specify additional + * pipelines via third argument, fourth argument etc. + * @return array the result of the aggregation. + * @throws Exception on failure. + * @see http://docs.mongodb.org/manual/applications/aggregation/ + */ + public function aggregate($pipeline, $pipelineOperator = []) + { + $args = func_get_args(); + $token = $this->composeLogToken('aggregate', $args); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = call_user_func_array([$this->mongoCollection, 'aggregate'], $args); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + return $result['result']; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Performs aggregation using Mongo "group" command. + * @param mixed $keys fields to group by. If an array or non-code object is passed, + * it will be the key used to group results. If instance of [[\MongoCode]] passed, + * it will be treated as a function that returns the key to group by. + * @param array $initial Initial value of the aggregation counter object. + * @param \MongoCode|string $reduce function that takes two arguments (the current + * document and the aggregation to this point) and does the aggregation. + * Argument will be automatically cast to [[\MongoCode]]. + * @param array $options optional parameters to the group command. Valid options include: + * - condition - criteria for including a document in the aggregation. + * - finalize - function called once per unique key that takes the final output of the reduce function. + * @return array the result of the aggregation. + * @throws Exception on failure. + * @see http://docs.mongodb.org/manual/reference/command/group/ + */ + public function group($keys, $initial, $reduce, $options = []) + { + if (!($reduce instanceof \MongoCode)) { + $reduce = new \MongoCode((string)$reduce); + } + if (array_key_exists('condition', $options)) { + $options['condition'] = $this->buildCondition($options['condition']); + } + if (array_key_exists('finalize', $options)) { + if (!($options['finalize'] instanceof \MongoCode)) { + $options['finalize'] = new \MongoCode((string)$options['finalize']); + } + } + $token = $this->composeLogToken('group', [$keys, $initial, $reduce, $options]); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + // Avoid possible E_DEPRECATED for $options: + if (empty($options)) { + $result = $this->mongoCollection->group($keys, $initial, $reduce); + } else { + $result = $this->mongoCollection->group($keys, $initial, $reduce, $options); + } + $this->tryResultError($result); + + Yii::endProfile($token, __METHOD__); + if (array_key_exists('retval', $result)) { + return $result['retval']; + } else { + return []; + } + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Performs aggregation using Mongo "map reduce" mechanism. + * Note: this function will not return the aggregation result, instead it will + * write it inside the another Mongo collection specified by "out" parameter. + * For example: + * + * ~~~ + * $customerCollection = Yii::$app->mongo->getCollection('customer'); + * $resultCollectionName = $customerCollection->mapReduce( + * 'function () {emit(this.status, this.amount)}', + * 'function (key, values) {return Array.sum(values)}', + * 'mapReduceOut', + * ['status' => 3] + * ); + * $query = new Query(); + * $results = $query->from($resultCollectionName)->all(); + * ~~~ + * + * @param \MongoCode|string $map function, which emits map data from collection. + * Argument will be automatically cast to [[\MongoCode]]. + * @param \MongoCode|string $reduce function that takes two arguments (the map key + * and the map values) and does the aggregation. + * Argument will be automatically cast to [[\MongoCode]]. + * @param string|array $out output collection name. It could be a string for simple output + * ('outputCollection'), or an array for parametrized output (['merge' => 'outputCollection']) + * @param array $condition criteria for including a document in the aggregation. + * @return string the map reduce output collection name. + * @throws Exception on failure. + */ + public function mapReduce($map, $reduce, $out, $condition = []) + { + if (!($map instanceof \MongoCode)) { + $map = new \MongoCode((string)$map); + } + if (!($reduce instanceof \MongoCode)) { + $reduce = new \MongoCode((string)$reduce); + } + $command = [ + 'mapReduce' => $this->getName(), + 'map' => $map, + 'reduce' => $reduce, + 'out' => $out + ]; + if (!empty($condition)) { + $command['query'] = $this->buildCondition($condition); + } + $token = $this->composeLogToken('mapReduce', [$map, $reduce, $out]); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $command = array_merge(['mapReduce' => $this->getName()], $command); + $result = $this->mongoCollection->db->command($command); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + return $result['result']; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Performs full text search. + * @param string $search string of terms that MongoDB parses and uses to query the text index. + * @param array $condition criteria for filtering a results list. + * @param array $fields list of fields to be returned in result. + * @param integer $limit the maximum number of documents to include in the response (by default 100). + * @param string $language he language that determines the list of stop words for the search + * and the rules for the stemmer and tokenizer. If not specified, the search uses the default + * language of the index. + * @return array the highest scoring documents, in descending order by score. + * @throws Exception on failure. + */ + public function fullTextSearch($search, $condition = [], $fields = [], $limit = null, $language = null) { + $command = [ + 'search' => $search + ]; + if (!empty($condition)) { + $command['filter'] = $this->buildCondition($condition); + } + if (!empty($fields)) { + $command['project'] = $fields; + } + if ($limit !== null) { + $command['limit'] = $limit; + } + if ($language !== null) { + $command['language'] = $language; + } + $token = $this->composeLogToken('text', $command); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $command = array_merge(['text' => $this->getName()], $command); + $result = $this->mongoCollection->db->command($command); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + return $result['results']; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Checks if command execution result ended with an error. + * @param mixed $result raw command execution result. + * @throws Exception if an error occurred. + */ + protected function tryResultError($result) + { + if (is_array($result)) { + if (!empty($result['errmsg'])) { + $errorMessage = $result['errmsg']; + } elseif (!empty($result['err'])) { + $errorMessage = $result['err']; + } + if (isset($errorMessage)) { + if (array_key_exists('code', $result)) { + $errorCode = (int)$result['code']; + } elseif (array_key_exists('ok', $result)) { + $errorCode = (int)$result['ok']; + } else { + $errorCode = 0; + } + throw new Exception($errorMessage, $errorCode); + } + } elseif (!$result) { + throw new Exception('Unknown error, use "w=1" option to enable error tracking'); + } + } + + /** + * Throws an exception if there was an error on the last operation. + * @throws Exception if an error occurred. + */ + protected function tryLastError() + { + $this->tryResultError($this->getLastError()); + } + + /** + * Converts user friendly condition keyword into actual Mongo condition keyword. + * @param string $key raw condition key. + * @return string actual key. + */ + protected function normalizeConditionKeyword($key) + { + static $map = [ + 'OR' => '$or', + '>' => '$gt', + '>=' => '$gte', + '<' => '$lt', + '<=' => '$lte', + '!=' => '$ne', + '<>' => '$ne', + 'IN' => '$in', + 'NOT IN' => '$nin', + 'ALL' => '$all', + 'SIZE' => '$size', + 'TYPE' => '$type', + 'EXISTS' => '$exists', + 'NOTEXISTS' => '$exists', + 'ELEMMATCH' => '$elemMatch', + 'MOD' => '$mod', + '%' => '$mod', + '=' => '$$eq', + '==' => '$$eq', + 'WHERE' => '$where' + ]; + $matchKey = strtoupper($key); + if (array_key_exists($matchKey, $map)) { + return $map[$matchKey]; + } else { + return $key; + } + } + + /** + * Converts given value into [[MongoId]] instance. + * If array given, each element of it will be processed. + * @param mixed $rawId raw id(s). + * @return array|\MongoId normalized id(s). + */ + protected function ensureMongoId($rawId) + { + if (is_array($rawId)) { + $result = []; + foreach ($rawId as $key => $value) { + $result[$key] = $this->ensureMongoId($value); + } + return $result; + } elseif (is_object($rawId)) { + if ($rawId instanceof \MongoId) { + return $rawId; + } else { + $rawId = (string)$rawId; + } + } + return new \MongoId($rawId); + } + + /** + * Parses the condition specification and generates the corresponding Mongo condition. + * @param array $condition the condition specification. Please refer to [[Query::where()]] + * on how to specify a condition. + * @return array the generated Mongo condition + * @throws InvalidParamException if the condition is in bad format + */ + public function buildCondition($condition) + { + static $builders = [ + 'AND' => 'buildAndCondition', + 'OR' => 'buildOrCondition', + 'BETWEEN' => 'buildBetweenCondition', + 'NOT BETWEEN' => 'buildBetweenCondition', + 'IN' => 'buildInCondition', + 'NOT IN' => 'buildInCondition', + 'LIKE' => 'buildLikeCondition', + ]; + + if (!is_array($condition)) { + throw new InvalidParamException('Condition should be an array.'); + } elseif (empty($condition)) { + return []; + } + if (isset($condition[0])) { // operator format: operator, operand 1, operand 2, ... + $operator = strtoupper($condition[0]); + if (isset($builders[$operator])) { + $method = $builders[$operator]; + array_shift($condition); + return $this->$method($operator, $condition); + } else { + throw new InvalidParamException('Found unknown operator in query: ' . $operator); + } + } else { + // hash format: 'column1' => 'value1', 'column2' => 'value2', ... + return $this->buildHashCondition($condition); + } + } + + /** + * Creates a condition based on column-value pairs. + * @param array $condition the condition specification. + * @return array the generated Mongo condition. + */ + public function buildHashCondition($condition) + { + $result = []; + foreach ($condition as $name => $value) { + $name = $this->normalizeConditionKeyword($name); + if (strncmp('$', $name, 1) === 0) { + // Native Mongo condition: + $result[$name] = $value; + } else { + if (is_array($value)) { + if (array_key_exists(0, $value)) { + // Quick IN condition: + $result = array_merge($result, $this->buildInCondition('IN', [$name, $value])); + } else { + // Normalize possible verbose condition: + $actualValue = []; + foreach ($value as $k => $v) { + $actualValue[$this->normalizeConditionKeyword($k)] = $v; + } + $result[$name] = $actualValue; + } + } else { + // Direct match: + if ($name == '_id') { + $value = $this->ensureMongoId($value); + } + $result[$name] = $value; + } + } + } + return $result; + } + + /** + * Connects two or more conditions with the `AND` operator. + * @param string $operator the operator to use for connecting the given operands + * @param array $operands the Mongo conditions to connect. + * @return array the generated Mongo condition. + */ + public function buildAndCondition($operator, $operands) + { + $result = []; + foreach ($operands as $operand) { + $condition = $this->buildCondition($operand); + $result = array_merge_recursive($result, $condition); + } + return $result; + } + + /** + * Connects two or more conditions with the `OR` operator. + * @param string $operator the operator to use for connecting the given operands + * @param array $operands the Mongo conditions to connect. + * @return array the generated Mongo condition. + */ + public function buildOrCondition($operator, $operands) + { + $operator = $this->normalizeConditionKeyword($operator); + $parts = []; + foreach ($operands as $operand) { + $parts[] = $this->buildCondition($operand); + } + return [$operator => $parts]; + } + + /** + * Creates an Mongo condition, which emulates the `BETWEEN` operator. + * @param string $operator the operator to use + * @param array $operands the first operand is the column name. The second and third operands + * describe the interval that column value should be in. + * @return array the generated Mongo condition. + * @throws InvalidParamException if wrong number of operands have been given. + */ + public function buildBetweenCondition($operator, $operands) + { + if (!isset($operands[0], $operands[1], $operands[2])) { + throw new InvalidParamException("Operator '$operator' requires three operands."); + } + list($column, $value1, $value2) = $operands; + if (strncmp('NOT', $operator, 3) === 0) { + return [ + $column => [ + '$lt' => $value1, + '$gt' => $value2, + ] + ]; + } else { + return [ + $column => [ + '$gte' => $value1, + '$lte' => $value2, + ] + ]; + } + } + + /** + * Creates an Mongo condition with the `IN` operator. + * @param string $operator the operator to use (e.g. `IN` or `NOT IN`) + * @param array $operands the first operand is the column name. If it is an array + * a composite IN condition will be generated. + * The second operand is an array of values that column value should be among. + * @return array the generated Mongo condition. + * @throws InvalidParamException if wrong number of operands have been given. + */ + public function buildInCondition($operator, $operands) + { + if (!isset($operands[0], $operands[1])) { + throw new InvalidParamException("Operator '$operator' requires two operands."); + } + + list($column, $values) = $operands; + + $values = (array)$values; + + if (!is_array($column)) { + $columns = [$column]; + $values = [$column => $values]; + } elseif (count($column) < 2) { + $columns = $column; + $values = [$column[0] => $values]; + } else { + $columns = $column; + } + + $operator = $this->normalizeConditionKeyword($operator); + $result = []; + foreach ($columns as $column) { + if ($column == '_id') { + $inValues = $this->ensureMongoId($values[$column]); + } else { + $inValues = $values[$column]; + } + $result[$column][$operator] = $inValues; + } + return $result; + } + + /** + * Creates a Mongo condition, which emulates the `LIKE` operator. + * @param string $operator the operator to use + * @param array $operands the first operand is the column name. + * The second operand is a single value that column value should be compared with. + * @return array the generated Mongo condition. + * @throws InvalidParamException if wrong number of operands have been given. + */ + public function buildLikeCondition($operator, $operands) + { + if (!isset($operands[0], $operands[1])) { + throw new InvalidParamException("Operator '$operator' requires two operands."); + } + list($column, $value) = $operands; + return [$column => '/' . $value . '/']; + } +} \ No newline at end of file diff --git a/extensions/mongo/Connection.php b/extensions/mongo/Connection.php new file mode 100644 index 00000000000..8c19cb484cc --- /dev/null +++ b/extensions/mongo/Connection.php @@ -0,0 +1,253 @@ + $dsn, + * ]); + * $connection->open(); + * ~~~ + * + * After the Mongo connection is established, one can access Mongo databases and collections: + * + * ~~~ + * $database = $connection->getDatabase('my_mongo_db'); + * $collection = $database->getCollection('customer'); + * $collection->insert(['name' => 'John Smith', 'status' => 1]); + * ~~~ + * + * You can work with several different databases at the same server using this class. + * However, while it is unlikely your application will actually need it, the Connection class + * provides ability to use [[defaultDatabaseName]] as well as a shortcut method [[getCollection()]] + * to retrieve a particular collection instance: + * + * ~~~ + * // get collection 'customer' from default database: + * $collection = $connection->getCollection('customer'); + * // get collection 'customer' from database 'mydatabase': + * $collection = $connection->getCollection(['mydatabase', 'customer']); + * ~~~ + * + * Connection is often used as an application component and configured in the application + * configuration like the following: + * + * ~~~ + * [ + * 'components' => [ + * 'mongo' => [ + * 'class' => '\yii\mongo\Connection', + * 'dsn' => 'mongodb://developer:password@localhost:27017/mydatabase', + * ], + * ], + * ] + * ~~~ + * + * @property boolean $isActive Whether the Mongo connection is established. This property is read-only. + * is read-only. + * + * @author Paul Klimov + * @since 2.0 + */ +class Connection extends Component +{ + /** + * @var string host:port + * + * Correct syntax is: + * mongodb://[username:password@]host1[:port1][,host2[:port2:],...][/dbname] + * For example: + * mongodb://localhost:27017 + * mongodb://developer:password@localhost:27017 + * mongodb://developer:password@localhost:27017/mydatabase + */ + public $dsn; + /** + * @var array connection options. + * for example: + * ~~~ + * [ + * 'persist' => true, // use persistent connection + * 'socketTimeoutMS' => 1000, // how long a send or receive on a socket can take before timing out + * 'journal' => true // block write operations until the journal be flushed the to disk + * ] + * ~~~ + */ + public $options = []; + /** + * @var string name of the Mongo database to use by default. + * If this field left blank, connection instance will attempt to determine it from + * [[options]] and [[dsn]] automatically, if needed. + */ + public $defaultDatabaseName; + /** + * @var \MongoClient mongo client instance. + */ + public $mongoClient; + /** + * @var Database[] list of Mongo databases + */ + private $_databases = []; + + /** + * Returns the Mongo collection with the given name. + * @param string|null $name collection name, if null default one will be used. + * @param boolean $refresh whether to reload the table schema even if it is found in the cache. + * @return Database database instance. + */ + public function getDatabase($name = null, $refresh = false) + { + if ($name === null) { + $name = $this->fetchDefaultDatabaseName(); + } + if ($refresh || !array_key_exists($name, $this->_databases)) { + $this->_databases[$name] = $this->selectDatabase($name); + } + return $this->_databases[$name]; + } + + /** + * Returns [[defaultDatabaseName]] value, if it is not set, + * attempts to determine it from [[dsn]] value. + * @return string default database name + * @throws \yii\base\InvalidConfigException if unable to determine default database name. + */ + protected function fetchDefaultDatabaseName() + { + if ($this->defaultDatabaseName === null) { + if (isset($this->options['db'])) { + $this->defaultDatabaseName = $this->options['db']; + } elseif (preg_match('/^mongodb:\\/\\/.+\\/(.+)$/s', $this->dsn, $matches)) { + $this->defaultDatabaseName = $matches[1]; + } else { + throw new InvalidConfigException("Unable to determine default database name from dsn."); + } + } + return $this->defaultDatabaseName; + } + + /** + * Selects the database with given name. + * @param string $name database name. + * @return Database database instance. + */ + protected function selectDatabase($name) + { + $this->open(); + return Yii::createObject([ + 'class' => 'yii\mongo\Database', + 'mongoDb' => $this->mongoClient->selectDB($name) + ]); + } + + /** + * Returns the Mongo collection with the given name. + * @param string|array $name collection name. If string considered as the name of the collection + * inside the default database. If array - first element considered as the name of the database, + * second - as name of collection inside that database + * @param boolean $refresh whether to reload the collection instance even if it is found in the cache. + * @return Collection Mongo collection instance. + */ + public function getCollection($name, $refresh = false) + { + if (is_array($name)) { + list ($dbName, $collectionName) = $name; + return $this->getDatabase($dbName)->getCollection($collectionName, $refresh); + } else { + return $this->getDatabase()->getCollection($name, $refresh); + } + } + + /** + * Returns the Mongo GridFS collection. + * @param string|array $prefix collection prefix. If string considered as the prefix of the GridFS + * collection inside the default database. If array - first element considered as the name of the database, + * second - as prefix of the GridFS collection inside that database, if no second element present + * default "fs" prefix will be used. + * @param boolean $refresh whether to reload the collection instance even if it is found in the cache. + * @return file\Collection Mongo GridFS collection instance. + */ + public function getFileCollection($prefix = 'fs', $refresh = false) + { + if (is_array($prefix)) { + list ($dbName, $collectionPrefix) = $prefix; + if (!isset($collectionPrefix)) { + $collectionPrefix = 'fs'; + } + return $this->getDatabase($dbName)->getFileCollection($collectionPrefix, $refresh); + } else { + return $this->getDatabase()->getFileCollection($prefix, $refresh); + } + } + + /** + * Returns a value indicating whether the Mongo connection is established. + * @return boolean whether the Mongo connection is established + */ + public function getIsActive() + { + return is_object($this->mongoClient) && $this->mongoClient->connected; + } + + /** + * Establishes a Mongo connection. + * It does nothing if a Mongo connection has already been established. + * @throws Exception if connection fails + */ + public function open() + { + if ($this->mongoClient === null) { + if (empty($this->dsn)) { + throw new InvalidConfigException($this->className() . '::dsn cannot be empty.'); + } + $token = 'Opening Mongo connection: ' . $this->dsn; + try { + Yii::trace($token, __METHOD__); + Yii::beginProfile($token, __METHOD__); + $options = $this->options; + $options['connect'] = true; + if ($this->defaultDatabaseName !== null) { + $options['db'] = $this->defaultDatabaseName; + } + $this->mongoClient = new \MongoClient($this->dsn, $options); + Yii::endProfile($token, __METHOD__); + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + } + + /** + * Closes the currently active DB connection. + * It does nothing if the connection is already closed. + */ + public function close() + { + if ($this->mongoClient !== null) { + Yii::trace('Closing Mongo connection: ' . $this->dsn, __METHOD__); + $this->mongoClient = null; + $this->_databases = []; + } + } +} \ No newline at end of file diff --git a/extensions/mongo/Database.php b/extensions/mongo/Database.php new file mode 100644 index 00000000000..bb919b545a8 --- /dev/null +++ b/extensions/mongo/Database.php @@ -0,0 +1,172 @@ + + * @since 2.0 + */ +class Database extends Object +{ + /** + * @var \MongoDB Mongo database instance. + */ + public $mongoDb; + /** + * @var Collection[] list of collections. + */ + private $_collections = []; + /** + * @var file\Collection[] list of GridFS collections. + */ + private $_fileCollections = []; + + /** + * @return string name of this database. + */ + public function getName() + { + return $this->mongoDb->__toString(); + } + + /** + * Returns the Mongo collection with the given name. + * @param string $name collection name + * @param boolean $refresh whether to reload the collection instance even if it is found in the cache. + * @return Collection mongo collection instance. + */ + public function getCollection($name, $refresh = false) + { + if ($refresh || !array_key_exists($name, $this->_collections)) { + $this->_collections[$name] = $this->selectCollection($name); + } + return $this->_collections[$name]; + } + + /** + * Returns Mongo GridFS collection with given prefix. + * @param string $prefix collection prefix. + * @param boolean $refresh whether to reload the collection instance even if it is found in the cache. + * @return file\Collection mongo GridFS collection. + */ + public function getFileCollection($prefix = 'fs', $refresh = false) + { + if ($refresh || !array_key_exists($prefix, $this->_fileCollections)) { + $this->_fileCollections[$prefix] = $this->selectFileCollection($prefix); + } + return $this->_fileCollections[$prefix]; + } + + /** + * Selects collection with given name. + * @param string $name collection name. + * @return Collection collection instance. + */ + protected function selectCollection($name) + { + return Yii::createObject([ + 'class' => 'yii\mongo\Collection', + 'mongoCollection' => $this->mongoDb->selectCollection($name) + ]); + } + + /** + * Selects GridFS collection with given prefix. + * @param string $prefix file collection prefix. + * @return file\Collection file collection instance. + */ + protected function selectFileCollection($prefix) + { + return Yii::createObject([ + 'class' => 'yii\mongo\file\Collection', + 'mongoCollection' => $this->mongoDb->getGridFS($prefix) + ]); + } + + /** + * Creates new collection. + * Note: Mongo creates new collections automatically on the first demand, + * this method makes sense only for the migration script or for the case + * you need to create collection with the specific options. + * @param string $name name of the collection + * @param array $options collection options in format: "name" => "value" + * @return \MongoCollection new mongo collection instance. + * @throws Exception on failure. + */ + public function createCollection($name, $options = []) + { + $token = $this->getName() . '.create(' . $name . ', ' . Json::encode($options) . ')'; + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoDb->createCollection($name, $options); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Executes Mongo command. + * @param array $command command specification. + * @param array $options options in format: "name" => "value" + * @return array database response. + * @throws Exception on failure. + */ + public function executeCommand($command, $options = []) + { + $token = $this->getName() . '.$cmd(' . Json::encode($command) . ', ' . Json::encode($options) . ')'; + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoDb->command($command, $options); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Checks if command execution result ended with an error. + * @param mixed $result raw command execution result. + * @throws Exception if an error occurred. + */ + protected function tryResultError($result) + { + if (is_array($result)) { + if (!empty($result['errmsg'])) { + $errorMessage = $result['errmsg']; + } elseif (!empty($result['err'])) { + $errorMessage = $result['err']; + } + if (isset($errorMessage)) { + if (array_key_exists('ok', $result)) { + $errorCode = (int)$result['ok']; + } else { + $errorCode = 0; + } + throw new Exception($errorMessage, $errorCode); + } + } elseif (!$result) { + throw new Exception('Unknown error, use "w=1" option to enable error tracking'); + } + } +} \ No newline at end of file diff --git a/extensions/mongo/Exception.php b/extensions/mongo/Exception.php new file mode 100644 index 00000000000..0687e4893c3 --- /dev/null +++ b/extensions/mongo/Exception.php @@ -0,0 +1,25 @@ + + * @since 2.0 + */ +class Exception extends \yii\base\Exception +{ + /** + * @return string the user-friendly name of this exception + */ + public function getName() + { + return \Yii::t('yii', 'Mongo Exception'); + } +} \ No newline at end of file diff --git a/extensions/mongo/LICENSE.md b/extensions/mongo/LICENSE.md new file mode 100644 index 00000000000..0bb1a8dca88 --- /dev/null +++ b/extensions/mongo/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/mongo/Query.php b/extensions/mongo/Query.php new file mode 100644 index 00000000000..cce6645beeb --- /dev/null +++ b/extensions/mongo/Query.php @@ -0,0 +1,344 @@ +select(['name', 'status']) + * ->from('customer') + * ->limit(10); + * // execute the query + * $rows = $query->all(); + * ~~~ + * + * @author Paul Klimov + * @since 2.0 + */ +class Query extends Component implements QueryInterface +{ + use QueryTrait; + + /** + * @var array the fields of the results to return. For example, `['name', 'group_id']`. + * The "_id" field is always returned. If not set, if means selecting all columns. + * @see select() + */ + public $select = []; + /** + * @var string|array the collection to be selected from. If string considered as the name of the collection + * inside the default database. If array - first element considered as the name of the database, + * second - as name of collection inside that database + * @see from() + */ + public $from; + + /** + * Returns the Mongo collection for this query. + * @param Connection $db Mongo connection. + * @return Collection collection instance. + */ + public function getCollection($db = null) + { + if ($db === null) { + $db = Yii::$app->getComponent('mongo'); + } + return $db->getCollection($this->from); + } + + /** + * Sets the list of fields of the results to return. + * @param array $fields fields of the results to return. + * @return static the query object itself. + */ + public function select(array $fields) + { + $this->select = $fields; + return $this; + } + + /** + * Sets the collection to be selected from. + * @param string|array the collection to be selected from. If string considered as the name of the collection + * inside the default database. If array - first element considered as the name of the database, + * second - as name of collection inside that database + * @return static the query object itself. + */ + public function from($collection) + { + $this->from = $collection; + return $this; + } + + /** + * Builds the Mongo cursor for this query. + * @param Connection $db the database connection used to execute the query. + * @return \MongoCursor mongo cursor instance. + */ + protected function buildCursor($db = null) + { + if ($this->where === null) { + $where = []; + } else { + $where = $this->where; + } + $selectFields = []; + if (!empty($this->select)) { + foreach ($this->select as $fieldName) { + $selectFields[$fieldName] = true; + } + } + $cursor = $this->getCollection($db)->find($where, $selectFields); + if (!empty($this->orderBy)) { + $sort = []; + foreach ($this->orderBy as $fieldName => $sortOrder) { + $sort[$fieldName] = $sortOrder === SORT_DESC ? \MongoCollection::DESCENDING : \MongoCollection::ASCENDING; + } + $cursor->sort($sort); + } + $cursor->limit($this->limit); + $cursor->skip($this->offset); + return $cursor; + } + + /** + * Fetches rows from the given Mongo cursor. + * @param \MongoCursor $cursor Mongo cursor instance to fetch data from. + * @param boolean $all whether to fetch all rows or only first one. + * @param string|callable $indexBy the column name or PHP callback, + * by which the query results should be indexed by. + * @throws Exception on failure. + * @return array|boolean result. + */ + protected function fetchRows($cursor, $all = true, $indexBy = null) + { + $token = 'find(' . Json::encode($cursor->info()) . ')'; + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->fetchRowsInternal($cursor, $all, $indexBy); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * @param \MongoCursor $cursor Mongo cursor instance to fetch data from. + * @param boolean $all whether to fetch all rows or only first one. + * @param string|callable $indexBy value to index by. + * @return array|boolean result. + * @see Query::fetchRows() + */ + protected function fetchRowsInternal($cursor, $all, $indexBy) + { + $result = []; + if ($all) { + foreach ($cursor as $row) { + if ($indexBy !== null) { + if (is_string($indexBy)) { + $key = $row[$indexBy]; + } else { + $key = call_user_func($indexBy, $row); + } + $result[$key] = $row; + } else { + $result[] = $row; + } + } + } else { + if ($cursor->hasNext()) { + $result = $cursor->getNext(); + } else { + $result = false; + } + } + return $result; + } + + /** + * Executes the query and returns all results as an array. + * @param Connection $db the Mongo connection used to execute the query. + * If this parameter is not given, the `mongo` 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) + { + $cursor = $this->buildCursor($db); + return $this->fetchRows($cursor, true, $this->indexBy); + } + + /** + * Executes the query and returns a single row of result. + * @param Connection $db the Mongo connection used to execute the query. + * If this parameter is not given, the `mongo` 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) + { + $cursor = $this->buildCursor($db); + return $this->fetchRows($cursor, false); + } + + /** + * Returns the number of records. + * @param string $q kept to match [[QueryInterface]], its value is ignored. + * @param Connection $db the Mongo connection used to execute the query. + * If this parameter is not given, the `mongo` application component will be used. + * @return integer number of records + * @throws Exception on failure. + */ + public function count($q = '*', $db = null) + { + $cursor = $this->buildCursor($db); + $token = 'find.count(' . Json::encode($cursor->info()) . ')'; + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $cursor->count(); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Returns a value indicating whether the query result contains any row of data. + * @param Connection $db the Mongo connection used to execute the query. + * If this parameter is not given, the `mongo` application component will be used. + * @return boolean whether the query result contains any row of data. + */ + public function exists($db = null) + { + return $this->one($db) !== null; + } + + /** + * Returns the sum of the specified column values. + * @param string $q the column name. + * Make sure you properly quote column names in the expression. + * @param Connection $db the Mongo connection used to execute the query. + * If this parameter is not given, the `mongo` application component will be used. + * @return integer the sum of the specified column values + */ + public function sum($q, $db = null) + { + return $this->aggregate($q, 'sum', $db); + } + + /** + * Returns the average of the specified column values. + * @param string $q the column name. + * Make sure you properly quote column names in the expression. + * @param Connection $db the Mongo connection used to execute the query. + * If this parameter is not given, the `mongo` application component will be used. + * @return integer the average of the specified column values. + */ + public function average($q, $db = null) + { + return $this->aggregate($q, 'avg', $db); + } + + /** + * Returns the minimum of the specified column values. + * @param string $q the column name. + * Make sure you properly quote column names in the expression. + * @param Connection $db the database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return integer the minimum of the specified column values. + */ + public function min($q, $db = null) + { + return $this->aggregate($q, 'min', $db); + } + + /** + * Returns the maximum of the specified column values. + * @param string $q the column name. + * Make sure you properly quote column names in the expression. + * @param Connection $db the Mongo connection used to execute the query. + * If this parameter is not given, the `mongo` application component will be used. + * @return integer the maximum of the specified column values. + */ + public function max($q, $db = null) + { + return $this->aggregate($q, 'max', $db); + } + + /** + * Performs the aggregation for the given column. + * @param string $column column name. + * @param string $operator aggregation operator. + * @param Connection $db the database connection used to execute the query. + * @return integer aggregation result. + */ + protected function aggregate($column, $operator, $db) + { + $collection = $this->getCollection($db); + $pipelines = []; + if ($this->where !== null) { + $pipelines[] = ['$match' => $collection->buildCondition($this->where)]; + } + $pipelines[] = [ + '$group' => [ + '_id' => '1', + 'total' => [ + '$' . $operator => '$' . $column + ], + ] + ]; + $result = $collection->aggregate($pipelines); + if (array_key_exists(0, $result)) { + return $result[0]['total']; + } else { + return 0; + } + } + + /** + * Returns a list of distinct values for the given column across a collection. + * @param string $q column to use. + * @param Connection $db the Mongo connection used to execute the query. + * If this parameter is not given, the `mongo` application component will be used. + * @return array array of distinct values + */ + public function distinct($q, $db = null) + { + $collection = $this->getCollection($db); + if ($this->where !== null) { + $condition = $this->where; + } else { + $condition = []; + } + $result = $collection->distinct($q, $condition); + if ($result === false) { + return []; + } else { + return $result; + } + } +} \ No newline at end of file diff --git a/extensions/mongo/README.md b/extensions/mongo/README.md new file mode 100644 index 00000000000..7f5ef709f65 --- /dev/null +++ b/extensions/mongo/README.md @@ -0,0 +1,116 @@ +Yii 2.0 Public Preview - MongoDb 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-mongo "*" +``` + +or add +``` +"yiisoft/yii2-mongo": "*" +``` +to the require section of your composer.json. + + +*Note: You might have to run `php composer.phar selfupdate`* + + +Usage & Documentation +--------------------- + +This extension adds [MongoDB](http://www.mongodb.org/) data storage support for the Yii2 framework. + +Note: extension requires [MongoDB PHP Extension](http://us1.php.net/manual/en/book.mongo.php) version 1.3.0 or higher. + +To use this extension, simply add the following code in your application configuration: + +```php +return [ + //.... + 'components' => [ + 'mongo' => [ + 'class' => '\yii\mongo\Connection', + 'dsn' => 'mongodb://developer:password@localhost:27017/mydatabase', + ], + ], +]; +``` + +This extension provides ActiveRecord solution similar ot the [[\yii\db\ActiveRecord]]. +To declare an ActiveRecord class you need to extend [[\yii\mongo\ActiveRecord]] and +implement the `collectionName` and 'attributes' methods: + +```php +use yii\mongo\ActiveRecord; + +class Customer extends ActiveRecord +{ + /** + * @return string the name of the index associated with this ActiveRecord class. + */ + public static function collectionName() + { + return 'customer'; + } + + /** + * @return array list of attribute names. + */ + public function attributes() + { + return ['name', 'email', 'address', 'status']; + } +} +``` + +You can use [[\yii\data\ActiveDataProvider]] with the [[\yii\mongo\Query]] and [[\yii\mongo\ActiveQuery]]: + +```php +use yii\data\ActiveDataProvider; +use yii\mongo\Query; + +$query = new Query; +$query->from('customer')->where(['status' => 2]); +$provider = new ActiveDataProvider([ + 'query' => $query, + 'pagination' => [ + 'pageSize' => 10, + ] +]); +$models = $provider->getModels(); +``` + +```php +use yii\data\ActiveDataProvider; +use app\models\Customer; + +$provider = new ActiveDataProvider([ + 'query' => Customer::find(), + 'pagination' => [ + 'pageSize' => 10, + ] +]); +$models = $provider->getModels(); +``` + +This extension supports [MongoGridFS](http://docs.mongodb.org/manual/core/gridfs/) via +classes at namespace "\yii\mongo\file". \ No newline at end of file diff --git a/extensions/mongo/composer.json b/extensions/mongo/composer.json new file mode 100644 index 00000000000..a9dd06e1da4 --- /dev/null +++ b/extensions/mongo/composer.json @@ -0,0 +1,28 @@ +{ + "name": "yiisoft/yii2-mongo", + "description": "MongoDb extension for the Yii framework", + "keywords": ["yii", "mongo", "mongodb", "active-record"], + "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-mongo": ">=1.3.0" + }, + "autoload": { + "psr-0": { "yii\\mongo\\": "" } + } +} diff --git a/extensions/mongo/file/ActiveQuery.php b/extensions/mongo/file/ActiveQuery.php new file mode 100644 index 00000000000..91661d69c31 --- /dev/null +++ b/extensions/mongo/file/ActiveQuery.php @@ -0,0 +1,107 @@ +with('tags')->asArray()->all(); + * ~~~ + * + * @author Paul Klimov + * @since 2.0 + */ +class ActiveQuery extends Query implements ActiveQueryInterface +{ + use ActiveQueryTrait; + + /** + * Executes query and returns all results as an array. + * @param \yii\mongo\Connection $db the Mongo connection used to execute the query. + * If null, the Mongo 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) + { + $cursor = $this->buildCursor($db); + $rows = $this->fetchRows($cursor); + if (!empty($rows)) { + $models = $this->createModels($rows); + if (!empty($this->with)) { + $this->findWith($this->with, $models); + } + return $models; + } else { + return []; + } + } + + /** + * Executes query and returns a single row of result. + * @param \yii\mongo\Connection $db the Mongo connection used to execute the query. + * If null, the Mongo 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) + { + $row = parent::one($db); + if ($row !== false) { + if ($this->asArray) { + $model = $row; + } else { + /** @var ActiveRecord $class */ + $class = $this->modelClass; + $model = $class::create($row); + } + if (!empty($this->with)) { + $models = [$model]; + $this->findWith($this->with, $models); + $model = $models[0]; + } + return $model; + } else { + return null; + } + } + + /** + * Returns the Mongo collection for this query. + * @param \yii\mongo\Connection $db Mongo connection. + * @return Collection collection instance. + */ + public function getCollection($db = null) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $this->modelClass; + if ($db === null) { + $db = $modelClass::getDb(); + } + if ($this->from === null) { + $this->from = $modelClass::collectionName(); + } + return $db->getFileCollection($this->from); + } +} \ No newline at end of file diff --git a/extensions/mongo/file/ActiveRecord.php b/extensions/mongo/file/ActiveRecord.php new file mode 100644 index 00000000000..c7ca7525a05 --- /dev/null +++ b/extensions/mongo/file/ActiveRecord.php @@ -0,0 +1,340 @@ +file = '/path/to/some/file.jpg'; + * $record->save(); + * ~~~ + * + * You can also specify file content via [[newFileContent]] attribute: + * + * ~~~ + * $record = new ImageFile(); + * $record->newFileContent = 'New file content'; + * $record->save(); + * ~~~ + * + * Note: [[newFileContent]] always takes precedence over [[file]]. + * + * @property \MongoId|string $_id primary key. + * @property string $filename name of stored file. + * @property \MongoDate $uploadDate file upload date. + * @property integer $length file size. + * @property integer $chunkSize file chunk size. + * @property string $md5 file md5 hash. + * @property \MongoGridFSFile|\yii\web\UploadedFile|string $file associated file. + * @property string $newFileContent new file content. + * + * @author Paul Klimov + * @since 2.0 + */ +abstract class ActiveRecord extends \yii\mongo\ActiveRecord +{ + /** + * Creates an [[ActiveQuery]] instance. + * This method is called by [[find()]] to start a "find" command. + * You may override this method to return a customized query (e.g. `ImageFileQuery` specified + * written for querying `ImageFile` purpose.) + * @return ActiveQuery the newly created [[ActiveQuery]] instance. + */ + public static function createQuery() + { + return new ActiveQuery(['modelClass' => get_called_class()]); + } + + /** + * Return the Mongo GridFS collection instance for this AR class. + * @return Collection collection instance. + */ + public static function getCollection() + { + return static::getDb()->getFileCollection(static::collectionName()); + } + + /** + * Creates an [[ActiveRelation]] 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 array $config the configuration passed to the ActiveRelation class. + * @return ActiveRelation the newly created [[ActiveRelation]] instance. + */ + public static function createActiveRelation($config = []) + { + return new ActiveRelation($config); + } + + /** + * Returns the list of all attribute names of the model. + * This method could be overridden by child classes to define available attributes. + * Note: all attributes defined in base Active Record class should be always present + * in returned array. + * For example: + * ~~~ + * public function attributes() + * { + * return array_merge( + * parent::attributes(), + * ['tags', 'status'] + * ); + * } + * ~~~ + * @return array list of attribute names. + */ + public function attributes() + { + return [ + '_id', + 'filename', + 'uploadDate', + 'length', + 'chunkSize', + 'md5', + 'file', + 'newFileContent' + ]; + } + + /** + * @see ActiveRecord::insert() + */ + protected function insertInternal($attributes = null) + { + if (!$this->beforeSave(true)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $currentAttributes = $this->getAttributes(); + foreach ($this->primaryKey() as $key) { + $values[$key] = isset($currentAttributes[$key]) ? $currentAttributes[$key] : null; + } + } + $collection = static::getCollection(); + if (isset($values['newFileContent'])) { + $newFileContent = $values['newFileContent']; + unset($values['newFileContent']); + } + if (isset($values['file'])) { + $newFile = $values['file']; + unset($values['file']); + } + if (isset($newFileContent)) { + $newId = $collection->insertFileContent($newFileContent, $values); + } elseif (isset($newFile)) { + $fileName = $this->extractFileName($newFile); + $newId = $collection->insertFile($fileName, $values); + } else { + $newId = $collection->insert($values); + } + $this->setAttribute('_id', $newId); + foreach ($values as $name => $value) { + $this->setOldAttribute($name, $value); + } + $this->afterSave(true); + return true; + } + + /** + * @see ActiveRecord::update() + * @throws StaleObjectException + */ + protected function updateInternal($attributes = null) + { + if (!$this->beforeSave(false)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $this->afterSave(false); + return 0; + } + + $collection = static::getCollection(); + if (isset($values['newFileContent'])) { + $newFileContent = $values['newFileContent']; + unset($values['newFileContent']); + } + if (isset($values['file'])) { + $newFile = $values['file']; + unset($values['file']); + } + if (isset($newFileContent) || isset($newFile)) { + $rows = $this->deleteInternal(); + $insertValues = $values; + $insertValues['_id'] = $this->getAttribute('_id'); + if (isset($newFileContent)) { + $collection->insertFileContent($newFileContent, $insertValues); + } else { + $fileName = $this->extractFileName($newFile); + $collection->insertFile($fileName, $insertValues); + } + $this->setAttribute('newFileContent', null); + $this->setAttribute('file', null); + } 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 update() because it's possible + // that it doesn't change anything and thus returns 0. + $rows = $collection->update($condition, $values); + if ($lock !== null && !$rows) { + throw new StaleObjectException('The object being updated is outdated.'); + } + } + + foreach ($values as $name => $value) { + $this->setOldAttribute($name, $this->getAttribute($name)); + } + $this->afterSave(false); + return $rows; + } + + /** + * Extracts filename from given raw file value. + * @param mixed $file raw file value. + * @return string file name. + * @throws \yii\base\InvalidParamException on invalid file value. + */ + protected function extractFileName($file) + { + if ($file instanceof UploadedFile) { + return $file->tempName; + } elseif (is_string($file)) { + if (file_exists($file)) { + return $file; + } else { + throw new InvalidParamException("File '{$file}' does not exist."); + } + } else { + throw new InvalidParamException('Unsupported type of "file" attribute.'); + } + } + + /** + * Refreshes the [[file]] attribute from file collection, using current primary key. + * @return \MongoGridFSFile|null refreshed file value. + */ + public function refreshFile() + { + $mongoFile = $this->getCollection()->get($this->getPrimaryKey()); + $this->setAttribute('file', $mongoFile); + return $mongoFile; + } + + /** + * Returns the associated file content. + * @return null|string file content. + * @throws \yii\base\InvalidParamException on invalid file attribute value. + */ + public function getFileContent() + { + $file = $this->getAttribute('file'); + if (empty($file) && !$this->getIsNewRecord()) { + $file = $this->refreshFile(); + } + if (empty($file)) { + return null; + } elseif ($file instanceof \MongoGridFSFile) { + $fileSize = $file->getSize(); + if (empty($fileSize)) { + return null; + } else { + return $file->getBytes(); + } + } elseif ($file instanceof UploadedFile) { + return file_get_contents($file->tempName); + } elseif (is_string($file)) { + if (file_exists($file)) { + return file_get_contents($file); + } else { + throw new InvalidParamException("File '{$file}' does not exist."); + } + } else { + throw new InvalidParamException('Unsupported type of "file" attribute.'); + } + } + + /** + * Writes the the internal file content into the given filename. + * @param string $filename full filename to be written. + * @return boolean whether the operation was successful. + * @throws \yii\base\InvalidParamException on invalid file attribute value. + */ + public function writeFile($filename) + { + $file = $this->getAttribute('file'); + if (empty($file) && !$this->getIsNewRecord()) { + $file = $this->refreshFile(); + } + if (empty($file)) { + throw new InvalidParamException('There is no file associated with this object.'); + } elseif ($file instanceof \MongoGridFSFile) { + return ($file->write($filename) == $file->getSize()); + } elseif ($file instanceof UploadedFile) { + return copy($file->tempName, $filename); + } elseif (is_string($file)) { + if (file_exists($file)) { + return copy($file, $filename); + } else { + throw new InvalidParamException("File '{$file}' does not exist."); + } + } else { + throw new InvalidParamException('Unsupported type of "file" attribute.'); + } + } + + /** + * This method returns a stream resource that can be used with all file functions in PHP, + * which deal with reading files. The contents of the file are pulled out of MongoDB on the fly, + * so that the whole file does not have to be loaded into memory first. + * @return resource file stream resource. + * @throws \yii\base\InvalidParamException on invalid file attribute value. + */ + public function getFileResource() + { + $file = $this->getAttribute('file'); + if (empty($file) && !$this->getIsNewRecord()) { + $file = $this->refreshFile(); + } + if (empty($file)) { + throw new InvalidParamException('There is no file associated with this object.'); + } elseif ($file instanceof \MongoGridFSFile) { + return $file->getResource(); + } elseif ($file instanceof UploadedFile) { + return fopen($file->tempName, 'r'); + } elseif (is_string($file)) { + if (file_exists($file)) { + return fopen($file, 'r'); + } else { + throw new InvalidParamException("File '{$file}' does not exist."); + } + } else { + throw new InvalidParamException('Unsupported type of "file" attribute.'); + } + } +} \ No newline at end of file diff --git a/extensions/mongo/file/ActiveRelation.php b/extensions/mongo/file/ActiveRelation.php new file mode 100644 index 00000000000..6ea0831cc9b --- /dev/null +++ b/extensions/mongo/file/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/mongo/file/Collection.php b/extensions/mongo/file/Collection.php new file mode 100644 index 00000000000..b3c722b4c06 --- /dev/null +++ b/extensions/mongo/file/Collection.php @@ -0,0 +1,186 @@ + + * @since 2.0 + */ +class Collection extends \yii\mongo\Collection +{ + /** + * @var \MongoGridFS Mongo GridFS collection instance. + */ + public $mongoCollection; + /** + * @var \yii\mongo\Collection file chunks Mongo collection. + */ + private $_chunkCollection; + + /** + * Returns the Mongo collection for the file chunks. + * @param boolean $refresh whether to reload the collection instance even if it is found in the cache. + * @return \yii\mongo\Collection mongo collection instance. + */ + public function getChunkCollection($refresh = false) + { + if ($refresh || !is_object($this->_chunkCollection)) { + $this->_chunkCollection = Yii::createObject([ + 'class' => 'yii\mongo\Collection', + 'mongoCollection' => $this->mongoCollection->chunks + ]); + } + return $this->_chunkCollection; + } + + /** + * Removes data from the collection. + * @param array $condition description of records to remove. + * @param array $options list of options in format: optionName => optionValue. + * @return integer|boolean number of updated documents or whether operation was successful. + * @throws Exception on failure. + */ + public function remove($condition = [], $options = []) + { + $result = parent::remove($condition, $options); + $this->tryLastError(); // MongoGridFS::remove will return even if the remove failed + return $result; + } + + /** + * Creates new file in GridFS collection from given local filesystem file. + * Additional attributes can be added file document using $metadata. + * @param string $filename name of the file to store. + * @param array $metadata other metadata fields to include in the file document. + * @param array $options list of options in format: optionName => optionValue + * @return mixed the "_id" of the saved file document. This will be a generated [[\MongoId]] + * unless an "_id" was explicitly specified in the metadata. + * @throws Exception on failure. + */ + public function insertFile($filename, $metadata = [], $options = []) + { + $token = 'Inserting file into ' . $this->getFullName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $options = array_merge(['w' => 1], $options); + $result = $this->mongoCollection->storeFile($filename, $metadata, $options); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Creates new file in GridFS collection with specified content. + * Additional attributes can be added file document using $metadata. + * @param string $bytes string of bytes to store. + * @param array $metadata other metadata fields to include in the file document. + * @param array $options list of options in format: optionName => optionValue + * @return mixed the "_id" of the saved file document. This will be a generated [[\MongoId]] + * unless an "_id" was explicitly specified in the metadata. + * @throws Exception on failure. + */ + public function insertFileContent($bytes, $metadata = [], $options = []) + { + $token = 'Inserting file content into ' . $this->getFullName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $options = array_merge(['w' => 1], $options); + $result = $this->mongoCollection->storeBytes($bytes, $metadata, $options); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Creates new file in GridFS collection from uploaded file. + * Additional attributes can be added file document using $metadata. + * @param string $name name of the uploaded file to store. This should correspond to + * the file field's name attribute in the HTML form. + * @param array $metadata other metadata fields to include in the file document. + * @return mixed the "_id" of the saved file document. This will be a generated [[\MongoId]] + * unless an "_id" was explicitly specified in the metadata. + * @throws Exception on failure. + */ + public function insertUploads($name, $metadata = []) + { + $token = 'Inserting file uploads into ' . $this->getFullName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->storeUpload($name, $metadata); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Retrieves the file with given _id. + * @param mixed $id _id of the file to find. + * @return \MongoGridFSFile|null found file, or null if file does not exist + * @throws Exception on failure. + */ + public function get($id) + { + $token = 'Inserting file uploads into ' . $this->getFullName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->get($id); + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Deletes the file with given _id. + * @param mixed $id _id of the file to find. + * @return boolean whether the operation was successful. + * @throws Exception on failure. + */ + public function delete($id) + { + $token = 'Inserting file uploads into ' . $this->getFullName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->delete($id); + $this->tryResultError($result); + Yii::endProfile($token, __METHOD__); + return true; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } +} \ No newline at end of file diff --git a/extensions/mongo/file/Query.php b/extensions/mongo/file/Query.php new file mode 100644 index 00000000000..c15d4f1b4b5 --- /dev/null +++ b/extensions/mongo/file/Query.php @@ -0,0 +1,75 @@ + + * @since 2.0 + */ +class Query extends \yii\mongo\Query +{ + /** + * Returns the Mongo collection for this query. + * @param \yii\mongo\Connection $db Mongo connection. + * @return Collection collection instance. + */ + public function getCollection($db = null) + { + if ($db === null) { + $db = Yii::$app->getComponent('mongo'); + } + return $db->getFileCollection($this->from); + } + + /** + * @param \MongoGridFSCursor $cursor Mongo cursor instance to fetch data from. + * @param boolean $all whether to fetch all rows or only first one. + * @param string|callable $indexBy value to index by. + * @return array|boolean result. + * @see Query::fetchRows() + */ + protected function fetchRowsInternal($cursor, $all, $indexBy) + { + $result = []; + if ($all) { + foreach ($cursor as $file) { + $row = $file->file; + $row['file'] = $file; + if ($indexBy !== null) { + if (is_string($indexBy)) { + $key = $row[$indexBy]; + } else { + $key = call_user_func($indexBy, $row); + } + $result[$key] = $row; + } else { + $result[] = $row; + } + } + } else { + if ($cursor->hasNext()) { + $file = $cursor->getNext(); + $result = $file->file; + $result['file'] = $file; + } else { + $result = false; + } + } + return $result; + } +} \ No newline at end of file diff --git a/framework/yii/db/ActiveRelationTrait.php b/framework/yii/db/ActiveRelationTrait.php index 832bb62d2e1..c477df9e97b 100644 --- a/framework/yii/db/ActiveRelationTrait.php +++ b/framework/yii/db/ActiveRelationTrait.php @@ -203,7 +203,8 @@ private function getModelKey($model, $attributes) return serialize($key); } else { $attribute = reset($attributes); - return $model[$attribute]; + $key = $model[$attribute]; + return is_scalar($key) ? $key : serialize($key); } } diff --git a/tests/unit/data/ar/mongo/ActiveRecord.php b/tests/unit/data/ar/mongo/ActiveRecord.php new file mode 100644 index 00000000000..6f5bc49ac98 --- /dev/null +++ b/tests/unit/data/ar/mongo/ActiveRecord.php @@ -0,0 +1,16 @@ +andWhere(['status' => 2]); + } + + public function getOrders() + { + return $this->hasMany(CustomerOrder::className(), ['customer_id' => '_id']); + } +} \ No newline at end of file diff --git a/tests/unit/data/ar/mongo/CustomerOrder.php b/tests/unit/data/ar/mongo/CustomerOrder.php new file mode 100644 index 00000000000..a01e47fe5e2 --- /dev/null +++ b/tests/unit/data/ar/mongo/CustomerOrder.php @@ -0,0 +1,27 @@ +hasOne(Customer::className(), ['_id' => 'customer_id']); + } +} \ No newline at end of file diff --git a/tests/unit/data/ar/mongo/file/ActiveRecord.php b/tests/unit/data/ar/mongo/file/ActiveRecord.php new file mode 100644 index 00000000000..70ebeb23ac4 --- /dev/null +++ b/tests/unit/data/ar/mongo/file/ActiveRecord.php @@ -0,0 +1,16 @@ +andWhere(['status' => 2]); + } +} \ No newline at end of file diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index 0bd5e474820..7b23a8dd806 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -51,5 +51,10 @@ 'password' => '', 'fixture' => __DIR__ . '/sphinx/source.sql', ], + ], + 'mongo' => [ + 'dsn' => 'mongodb://travis:test@localhost:27017', + 'defaultDatabaseName' => 'yii2test', + 'options' => [], ] ]; diff --git a/tests/unit/extensions/mongo/ActiveDataProviderTest.php b/tests/unit/extensions/mongo/ActiveDataProviderTest.php new file mode 100644 index 00000000000..366051613c1 --- /dev/null +++ b/tests/unit/extensions/mongo/ActiveDataProviderTest.php @@ -0,0 +1,91 @@ +getConnection(); + $this->setUpTestRows(); + } + + protected function tearDown() + { + $this->dropCollection(Customer::collectionName()); + parent::tearDown(); + } + + /** + * Sets up test rows. + */ + protected function setUpTestRows() + { + $collection = $this->getConnection()->getCollection('customer'); + $rows = []; + for ($i = 1; $i <= 10; $i++) { + $rows[] = [ + 'name' => 'name' . $i, + 'email' => 'email' . $i, + 'address' => 'address' . $i, + 'status' => $i, + ]; + } + $collection->batchInsert($rows); + } + + // Tests : + + public function testQuery() + { + $query = new Query; + $query->from('customer'); + + $provider = new ActiveDataProvider([ + 'query' => $query, + 'db' => $this->getConnection(), + ]); + $models = $provider->getModels(); + $this->assertEquals(10, count($models)); + + $provider = new ActiveDataProvider([ + 'query' => $query, + 'db' => $this->getConnection(), + 'pagination' => [ + 'pageSize' => 5, + ] + ]); + $models = $provider->getModels(); + $this->assertEquals(5, count($models)); + } + + public function testActiveQuery() + { + $provider = new ActiveDataProvider([ + 'query' => Customer::find()->orderBy('id ASC'), + ]); + $models = $provider->getModels(); + $this->assertEquals(10, count($models)); + $this->assertTrue($models[0] instanceof Customer); + $keys = $provider->getKeys(); + $this->assertTrue($keys[0] instanceof \MongoId); + + $provider = new ActiveDataProvider([ + 'query' => Customer::find(), + 'pagination' => [ + 'pageSize' => 5, + ] + ]); + $models = $provider->getModels(); + $this->assertEquals(5, count($models)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/ActiveRecordTest.php b/tests/unit/extensions/mongo/ActiveRecordTest.php new file mode 100644 index 00000000000..8467ba0b6e8 --- /dev/null +++ b/tests/unit/extensions/mongo/ActiveRecordTest.php @@ -0,0 +1,246 @@ +getConnection(); + $this->setUpTestRows(); + } + + protected function tearDown() + { + $this->dropCollection(Customer::collectionName()); + parent::tearDown(); + } + + /** + * Sets up test rows. + */ + protected function setUpTestRows() + { + $collection = $this->getConnection()->getCollection('customer'); + $rows = []; + for ($i = 1; $i <= 10; $i++) { + $rows[] = [ + 'name' => 'name' . $i, + 'email' => 'email' . $i, + 'address' => 'address' . $i, + 'status' => $i, + ]; + } + $collection->batchInsert($rows); + $this->testRows = $rows; + } + + // Tests : + + public function testFind() + { + // find one + $result = Customer::find(); + $this->assertTrue($result instanceof ActiveQuery); + $customer = $result->one(); + $this->assertTrue($customer instanceof Customer); + + // find all + $customers = Customer::find()->all(); + $this->assertEquals(10, count($customers)); + $this->assertTrue($customers[0] instanceof Customer); + $this->assertTrue($customers[1] instanceof Customer); + + // find by _id + $testId = $this->testRows[0]['_id']; + $customer = Customer::find($testId); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals($testId, $customer->_id); + + // find by column values + $customer = Customer::find(['name' => 'name5']); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals($this->testRows[4]['_id'], $customer->_id); + $this->assertEquals('name5', $customer->name); + $customer = Customer::find(['name' => 'unexisting name']); + $this->assertNull($customer); + + // find by attributes + $customer = Customer::find()->where(['status' => 4])->one(); + $this->assertTrue($customer instanceof Customer); + $this->assertEquals(4, $customer->status); + + // find count, sum, average, min, max, distinct + $this->assertEquals(10, Customer::find()->count()); + $this->assertEquals(1, Customer::find()->where(['status' => 2])->count()); + $this->assertEquals((1+10)/2*10, Customer::find()->sum('status')); + $this->assertEquals((1+10)/2, Customer::find()->average('status')); + $this->assertEquals(1, Customer::find()->min('status')); + $this->assertEquals(10, Customer::find()->max('status')); + $this->assertEquals(range(1, 10), Customer::find()->distinct('status')); + + // scope + $this->assertEquals(1, Customer::find()->activeOnly()->count()); + + // asArray + $testRow = $this->testRows[2]; + $customer = Customer::find()->where(['_id' => $testRow['_id']])->asArray()->one(); + $this->assertEquals($testRow, $customer); + + // indexBy + $customers = Customer::find()->indexBy('name')->all(); + $this->assertTrue($customers['name1'] instanceof Customer); + $this->assertTrue($customers['name2'] instanceof Customer); + + // indexBy callable + $customers = Customer::find()->indexBy(function ($customer) { + return $customer->status . '-' . $customer->status; + })->all(); + $this->assertTrue($customers['1-1'] instanceof Customer); + $this->assertTrue($customers['2-2'] instanceof Customer); + } + + public function testInsert() + { + $record = new Customer; + $record->name = 'new name'; + $record->email = 'new email'; + $record->address = 'new address'; + $record->status = 7; + + $this->assertTrue($record->isNewRecord); + + $record->save(); + + $this->assertTrue($record->_id instanceof \MongoId); + $this->assertFalse($record->isNewRecord); + } + + /** + * @depends testInsert + */ + public function testUpdate() + { + $record = new Customer; + $record->name = 'new name'; + $record->email = 'new email'; + $record->address = 'new address'; + $record->status = 7; + $record->save(); + + // save + $record = Customer::find($record->_id); + $this->assertTrue($record instanceof Customer); + $this->assertEquals(7, $record->status); + $this->assertFalse($record->isNewRecord); + + $record->status = 9; + $record->save(); + $this->assertEquals(9, $record->status); + $this->assertFalse($record->isNewRecord); + $record2 = Customer::find($record->_id); + $this->assertEquals(9, $record2->status); + + // updateAll + $pk = ['_id' => $record->_id]; + $ret = Customer::updateAll(['status' => 55], $pk); + $this->assertEquals(1, $ret); + $record = Customer::find($pk); + $this->assertEquals(55, $record->status); + } + + /** + * @depends testInsert + */ + public function testDelete() + { + // delete + $record = new Customer; + $record->name = 'new name'; + $record->email = 'new email'; + $record->address = 'new address'; + $record->status = 7; + $record->save(); + + $record = Customer::find($record->_id); + $record->delete(); + $record = Customer::find($record->_id); + $this->assertNull($record); + + // deleteAll + $record = new Customer; + $record->name = 'new name'; + $record->email = 'new email'; + $record->address = 'new address'; + $record->status = 7; + $record->save(); + + $ret = Customer::deleteAll(['name' => 'new name']); + $this->assertEquals(1, $ret); + $records = Customer::find()->where(['name' => 'new name'])->all(); + $this->assertEquals(0, count($records)); + } + + public function testUpdateAllCounters() + { + $this->assertEquals(1, Customer::updateAllCounters(['status' => 10], ['status' => 10])); + + $record = Customer::find(['status' => 10]); + $this->assertNull($record); + } + + /** + * @depends testUpdateAllCounters + */ + public function testUpdateCounters() + { + $record = Customer::find($this->testRows[9]); + + $originalCounter = $record->status; + $counterIncrement = 20; + $record->updateCounters(['status' => $counterIncrement]); + $this->assertEquals($originalCounter + $counterIncrement, $record->status); + + $refreshedRecord = Customer::find($record->_id); + $this->assertEquals($originalCounter + $counterIncrement, $refreshedRecord->status); + } + + /** + * @depends testUpdate + */ + public function testUpdateNestedAttribute() + { + $record = new Customer; + $record->name = 'new name'; + $record->email = 'new email'; + $record->address = [ + 'city' => 'SomeCity', + 'street' => 'SomeStreet', + ]; + $record->status = 7; + $record->save(); + + // save + $record = Customer::find($record->_id); + $newAddress = [ + 'city' => 'AnotherCity' + ]; + $record->address = $newAddress; + $record->save(); + $record2 = Customer::find($record->_id); + $this->assertEquals($newAddress, $record2->address); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/ActiveRelationTest.php b/tests/unit/extensions/mongo/ActiveRelationTest.php new file mode 100644 index 00000000000..26cf63e534e --- /dev/null +++ b/tests/unit/extensions/mongo/ActiveRelationTest.php @@ -0,0 +1,83 @@ +getConnection(); + $this->setUpTestRows(); + } + + protected function tearDown() + { + $this->dropCollection(Customer::collectionName()); + $this->dropCollection(CustomerOrder::collectionName()); + parent::tearDown(); + } + + /** + * Sets up test rows. + */ + protected function setUpTestRows() + { + $customerCollection = $this->getConnection()->getCollection('customer'); + + $customers = []; + for ($i = 1; $i <= 5; $i++) { + $customers[] = [ + 'name' => 'name' . $i, + 'email' => 'email' . $i, + 'address' => 'address' . $i, + 'status' => $i, + ]; + } + $customerCollection->batchInsert($customers); + + $customerOrderCollection = $this->getConnection()->getCollection('customer_order'); + $customerOrders = []; + foreach ($customers as $customer) { + $customerOrders[] = [ + 'customer_id' => $customer['_id'], + 'number' => $customer['status'], + ]; + $customerOrders[] = [ + 'customer_id' => $customer['_id'], + 'number' => $customer['status'] + 1, + ]; + } + $customerOrderCollection->batchInsert($customerOrders); + } + + // Tests : + + public function testFindLazy() + { + /** @var CustomerOrder $order */ + $order = CustomerOrder::find(['number' => 2]); + $this->assertFalse($order->isRelationPopulated('customer')); + $index = $order->customer; + $this->assertTrue($order->isRelationPopulated('customer')); + $this->assertTrue($index instanceof Customer); + $this->assertEquals(1, count($order->populatedRelations)); + } + + public function testFindEager() + { + $orders = CustomerOrder::find()->with('customer')->all(); + $this->assertEquals(10, count($orders)); + $this->assertTrue($orders[0]->isRelationPopulated('customer')); + $this->assertTrue($orders[1]->isRelationPopulated('customer')); + $this->assertTrue($orders[0]->customer instanceof Customer); + $this->assertTrue($orders[1]->customer instanceof Customer); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php new file mode 100644 index 00000000000..153ffa9033b --- /dev/null +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -0,0 +1,313 @@ +dropCollection('customer'); + $this->dropCollection('mapReduceOut'); + parent::tearDown(); + } + + // Tests : + + public function testGetName() + { + $collectionName = 'customer'; + $collection = $this->getConnection()->getCollection($collectionName); + $this->assertEquals($collectionName, $collection->getName()); + $this->assertEquals($this->mongoConfig['defaultDatabaseName'] . '.' . $collectionName, $collection->getFullName()); + } + + public function testFind() + { + $collection = $this->getConnection()->getCollection('customer'); + $cursor = $collection->find(); + $this->assertTrue($cursor instanceof \MongoCursor); + } + + public function testInsert() + { + $collection = $this->getConnection()->getCollection('customer'); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $collection->insert($data); + $this->assertTrue($id instanceof \MongoId); + $this->assertNotEmpty($id->__toString()); + } + + /** + * @depends testInsert + * @depends testFind + */ + public function testFindAll() + { + $collection = $this->getConnection()->getCollection('customer'); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $collection->insert($data); + + $cursor = $collection->find(); + $rows = []; + foreach ($cursor as $row) { + $rows[] = $row; + } + $this->assertEquals(1, count($rows)); + $this->assertEquals($id, $rows[0]['_id']); + } + + /** + * @depends testFind + */ + public function testBatchInsert() + { + $collection = $this->getConnection()->getCollection('customer'); + $rows = [ + [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ], + [ + 'name' => 'customer 2', + 'address' => 'customer 2 address', + ], + ]; + $insertedRows = $collection->batchInsert($rows); + $this->assertTrue($insertedRows[0]['_id'] instanceof \MongoId); + $this->assertTrue($insertedRows[1]['_id'] instanceof \MongoId); + $this->assertEquals(count($rows), $collection->find()->count()); + } + + public function testSave() + { + $collection = $this->getConnection()->getCollection('customer'); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $collection->save($data); + $this->assertTrue($id instanceof \MongoId); + $this->assertNotEmpty($id->__toString()); + } + + /** + * @depends testSave + */ + public function testUpdateBySave() + { + $collection = $this->getConnection()->getCollection('customer'); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $newId = $collection->save($data); + + $updatedId = $collection->save($data); + $this->assertEquals($newId, $updatedId, 'Unable to update data!'); + + $data['_id'] = $newId->__toString(); + $updatedId = $collection->save($data); + $this->assertEquals($newId, $updatedId, 'Unable to updated data by string id!'); + } + + /** + * @depends testFindAll + */ + public function testRemove() + { + $collection = $this->getConnection()->getCollection('customer'); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $collection->insert($data); + + $count = $collection->remove(['_id' => $id]); + $this->assertEquals(1, $count); + + $rows = $this->findAll($collection); + $this->assertEquals(0, count($rows)); + } + + /** + * @depends testFindAll + */ + public function testUpdate() + { + $collection = $this->getConnection()->getCollection('customer'); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $collection->insert($data); + + $newData = [ + 'name' => 'new name' + ]; + $count = $collection->update(['_id' => $id], $newData); + $this->assertEquals(1, $count); + + list($row) = $this->findAll($collection); + $this->assertEquals($newData['name'], $row['name']); + } + + /** + * @depends testBatchInsert + */ + public function testGroup() + { + $collection = $this->getConnection()->getCollection('customer'); + $rows = [ + [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ], + [ + 'name' => 'customer 2', + 'address' => 'customer 2 address', + ], + ]; + $collection->batchInsert($rows); + + $keys = ['address' => 1]; + $initial = ['items' => []]; + $reduce = "function (obj, prev) { prev.items.push(obj.name); }"; + $result = $collection->group($keys, $initial, $reduce); + $this->assertEquals(2, count($result)); + $this->assertNotEmpty($result[0]['address']); + $this->assertNotEmpty($result[0]['items']); + } + + /** + * @depends testBatchInsert + */ + public function testMapReduce() + { + $collection = $this->getConnection()->getCollection('customer'); + $rows = [ + [ + 'name' => 'customer 1', + 'status' => 1, + 'amount' => 100, + ], + [ + 'name' => 'customer 2', + 'status' => 1, + 'amount' => 200, + ], + [ + 'name' => 'customer 2', + 'status' => 2, + 'amount' => 400, + ], + [ + 'name' => 'customer 2', + 'status' => 3, + 'amount' => 500, + ], + ]; + $collection->batchInsert($rows); + + $result = $collection->mapReduce( + 'function () {emit(this.status, this.amount)}', + 'function (key, values) {return Array.sum(values)}', + 'mapReduceOut', + ['status' => ['$lt' => 3]] + ); + $this->assertEquals('mapReduceOut', $result); + + $outputCollection = $this->getConnection()->getCollection($result); + $rows = $this->findAll($outputCollection); + $expectedRows = [ + [ + '_id' => 1, + 'value' => 300, + ], + [ + '_id' => 2, + 'value' => 400, + ], + ]; + $this->assertEquals($expectedRows, $rows); + } + + public function testCreateIndex() + { + $collection = $this->getConnection()->getCollection('customer'); + $columns = [ + 'name', + 'status' => \MongoCollection::DESCENDING, + ]; + $this->assertTrue($collection->createIndex($columns)); + $indexInfo = $collection->mongoCollection->getIndexInfo(); + $this->assertEquals(2, count($indexInfo)); + } + + /** + * @depends testCreateIndex + */ + public function testDropIndex() + { + $collection = $this->getConnection()->getCollection('customer'); + + $collection->createIndex('name'); + $this->assertTrue($collection->dropIndex('name')); + $indexInfo = $collection->mongoCollection->getIndexInfo(); + $this->assertEquals(1, count($indexInfo)); + + $this->setExpectedException('\yii\mongo\Exception'); + $collection->dropIndex('name'); + } + + /** + * @depends testCreateIndex + */ + public function testDropAllIndexes() + { + $collection = $this->getConnection()->getCollection('customer'); + $collection->createIndex('name'); + $this->assertEquals(2, $collection->dropAllIndexes()); + $indexInfo = $collection->mongoCollection->getIndexInfo(); + $this->assertEquals(1, count($indexInfo)); + } + + /** + * @depends testBatchInsert + * @depends testCreateIndex + */ + public function testFullTextSearch() + { + if (version_compare('2.4', $this->getServerVersion(), '>')) { + $this->markTestSkipped("Mongo Server 2.4 required."); + } + + $collection = $this->getConnection()->getCollection('customer'); + + $rows = [ + [ + 'name' => 'customer 1', + 'status' => 1, + 'amount' => 100, + ], + [ + 'name' => 'some customer', + 'status' => 1, + 'amount' => 200, + ], + ]; + $collection->batchInsert($rows); + $collection->createIndex(['name' => 'text']); + + $result = $collection->fullTextSearch('some'); + $this->assertNotEmpty($result); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/ConnectionTest.php b/tests/unit/extensions/mongo/ConnectionTest.php new file mode 100644 index 00000000000..04d5351e01e --- /dev/null +++ b/tests/unit/extensions/mongo/ConnectionTest.php @@ -0,0 +1,119 @@ +getConnection(false); + $params = $this->mongoConfig; + + $connection->open(); + + $this->assertEquals($params['dsn'], $connection->dsn); + $this->assertEquals($params['defaultDatabaseName'], $connection->defaultDatabaseName); + $this->assertEquals($params['options'], $connection->options); + } + + public function testOpenClose() + { + $connection = $this->getConnection(false, false); + + $this->assertFalse($connection->isActive); + $this->assertEquals(null, $connection->mongoClient); + + $connection->open(); + $this->assertTrue($connection->isActive); + $this->assertTrue(is_object($connection->mongoClient)); + + $connection->close(); + $this->assertFalse($connection->isActive); + $this->assertEquals(null, $connection->mongoClient); + + $connection = new Connection; + $connection->dsn = 'unknown::memory:'; + $this->setExpectedException('yii\mongo\Exception'); + $connection->open(); + } + + public function testGetDatabase() + { + $connection = $this->getConnection(); + + $database = $connection->getDatabase($connection->defaultDatabaseName); + $this->assertTrue($database instanceof Database); + $this->assertTrue($database->mongoDb instanceof \MongoDB); + + $database2 = $connection->getDatabase($connection->defaultDatabaseName); + $this->assertTrue($database === $database2); + + $databaseRefreshed = $connection->getDatabase($connection->defaultDatabaseName, true); + $this->assertFalse($database === $databaseRefreshed); + } + + /** + * @depends testGetDatabase + */ + public function testGetDefaultDatabase() + { + $connection = new Connection(); + $connection->dsn = $this->mongoConfig['dsn']; + $connection->defaultDatabaseName = $this->mongoConfig['defaultDatabaseName']; + $database = $connection->getDatabase(); + $this->assertTrue($database instanceof Database, 'Unable to get default database!'); + + $connection = new Connection(); + $connection->dsn = $this->mongoConfig['dsn']; + $connection->options = ['db' => $this->mongoConfig['defaultDatabaseName']]; + $database = $connection->getDatabase(); + $this->assertTrue($database instanceof Database, 'Unable to determine default database from options!'); + + $connection = new Connection(); + $connection->dsn = $this->mongoConfig['dsn'] . '/' . $this->mongoConfig['defaultDatabaseName']; + $database = $connection->getDatabase(); + $this->assertTrue($database instanceof Database, 'Unable to determine default database from dsn!'); + } + + /** + * @depends testGetDefaultDatabase + */ + public function testGetCollection() + { + $connection = $this->getConnection(); + + $collection = $connection->getCollection('customer'); + $this->assertTrue($collection instanceof Collection); + + $collection2 = $connection->getCollection('customer'); + $this->assertTrue($collection === $collection2); + + $collection2 = $connection->getCollection('customer', true); + $this->assertFalse($collection === $collection2); + } + + /** + * @depends testGetDefaultDatabase + */ + public function testGetFileCollection() + { + $connection = $this->getConnection(); + + $collection = $connection->getFileCollection('testfs'); + $this->assertTrue($collection instanceof FileCollection); + + $collection2 = $connection->getFileCollection('testfs'); + $this->assertTrue($collection === $collection2); + + $collection2 = $connection->getFileCollection('testfs', true); + $this->assertFalse($collection === $collection2); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/DatabaseTest.php b/tests/unit/extensions/mongo/DatabaseTest.php new file mode 100644 index 00000000000..6847d2e6a18 --- /dev/null +++ b/tests/unit/extensions/mongo/DatabaseTest.php @@ -0,0 +1,70 @@ +dropCollection('customer'); + $this->dropFileCollection('testfs'); + parent::tearDown(); + } + + // Tests : + + public function testGetCollection() + { + $database = $connection = $this->getConnection()->getDatabase(); + + $collection = $database->getCollection('customer'); + $this->assertTrue($collection instanceof Collection); + $this->assertTrue($collection->mongoCollection instanceof \MongoCollection); + + $collection2 = $database->getCollection('customer'); + $this->assertTrue($collection === $collection2); + + $collectionRefreshed = $database->getCollection('customer', true); + $this->assertFalse($collection === $collectionRefreshed); + } + + public function testGetFileCollection() + { + $database = $connection = $this->getConnection()->getDatabase(); + + $collection = $database->getFileCollection('testfs'); + $this->assertTrue($collection instanceof FileCollection); + $this->assertTrue($collection->mongoCollection instanceof \MongoGridFS); + + $collection2 = $database->getFileCollection('testfs'); + $this->assertTrue($collection === $collection2); + + $collectionRefreshed = $database->getFileCollection('testfs', true); + $this->assertFalse($collection === $collectionRefreshed); + } + + public function testExecuteCommand() + { + $database = $connection = $this->getConnection()->getDatabase(); + + $result = $database->executeCommand([ + 'distinct' => 'customer', + 'key' => 'name' + ]); + $this->assertTrue(array_key_exists('ok', $result)); + $this->assertTrue(array_key_exists('values', $result)); + } + + public function testCreateCollection() + { + $database = $connection = $this->getConnection()->getDatabase(); + $collection = $database->createCollection('customer'); + $this->assertTrue($collection instanceof \MongoCollection); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php new file mode 100644 index 00000000000..291debdee57 --- /dev/null +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -0,0 +1,149 @@ + 'mongodb://localhost:27017', + 'defaultDatabaseName' => 'yii2test', + 'options' => [], + ]; + /** + * @var Connection Mongo connection instance. + */ + protected $mongo; + + public static function setUpBeforeClass() + { + static::loadClassMap(); + } + + protected function setUp() + { + parent::setUp(); + if (!extension_loaded('mongo')) { + $this->markTestSkipped('mongo extension required.'); + } + $config = $this->getParam('mongo'); + if (!empty($config)) { + $this->mongoConfig = $config; + } + $this->mockApplication(); + static::loadClassMap(); + } + + protected function tearDown() + { + if ($this->mongo) { + $this->mongo->close(); + } + $this->destroyApplication(); + } + + /** + * Adds sphinx extension files to [[Yii::$classPath]], + * avoiding the necessity of usage Composer autoloader. + */ + protected static function loadClassMap() + { + $baseNameSpace = 'yii/mongo'; + $basePath = realpath(__DIR__. '/../../../../extensions/mongo'); + $files = FileHelper::findFiles($basePath); + foreach ($files as $file) { + $classRelativePath = str_replace($basePath, '', $file); + $classFullName = str_replace(['/', '.php'], ['\\', ''], $baseNameSpace . $classRelativePath); + Yii::$classMap[$classFullName] = $file; + } + } + + /** + * @param boolean $reset whether to clean up the test database + * @param boolean $open whether to open test database + * @return \yii\mongo\Connection + */ + public function getConnection($reset = false, $open = true) + { + if (!$reset && $this->mongo) { + return $this->mongo; + } + $db = new Connection; + $db->dsn = $this->mongoConfig['dsn']; + $db->defaultDatabaseName = $this->mongoConfig['defaultDatabaseName']; + if (isset($this->mongoConfig['options'])) { + $db->options = $this->mongoConfig['options']; + } + if ($open) { + $db->open(); + } + $this->mongo = $db; + return $db; + } + + /** + * Drops the specified collection. + * @param string $name collection name. + */ + protected function dropCollection($name) + { + if ($this->mongo) { + try { + $this->mongo->getCollection($name)->drop(); + } catch (Exception $e) { + // shut down exception + } + } + } + + /** + * Drops the specified file collection. + * @param string $name file collection name. + */ + protected function dropFileCollection($name = 'fs') + { + if ($this->mongo) { + try { + $this->mongo->getFileCollection($name)->drop(); + } catch (Exception $e) { + // shut down exception + } + } + } + + /** + * Finds all records in collection. + * @param \yii\mongo\Collection $collection + * @param array $condition + * @param array $fields + * @return array rows + */ + protected function findAll($collection, $condition = [], $fields = []) + { + $cursor = $collection->find($condition, $fields); + $result = []; + foreach ($cursor as $data) { + $result[] = $data; + } + return $result; + } + + /** + * Returns the Mongo server version. + * @return string Mongo server version. + */ + protected function getServerVersion() + { + $connection = $this->getConnection(); + $buildInfo = $connection->getDatabase()->executeCommand(['buildinfo' => true]); + return $buildInfo['version']; + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/QueryRunTest.php b/tests/unit/extensions/mongo/QueryRunTest.php new file mode 100644 index 00000000000..7fe481235df --- /dev/null +++ b/tests/unit/extensions/mongo/QueryRunTest.php @@ -0,0 +1,132 @@ +setUpTestRows(); + } + + protected function tearDown() + { + $this->dropCollection('customer'); + parent::tearDown(); + } + + /** + * Sets up test rows. + */ + protected function setUpTestRows() + { + $collection = $this->getConnection()->getCollection('customer'); + $rows = []; + for ($i = 1; $i <= 10; $i++) { + $rows[] = [ + 'name' => 'name' . $i, + 'address' => 'address' . $i, + 'avatar' => [ + 'width' => 50 + $i, + 'height' => 100 + $i, + 'url' => 'http://some.url/' . $i, + ], + ]; + } + $collection->batchInsert($rows); + } + + // Tests : + + public function testAll() + { + $connection = $this->getConnection(); + $query = new Query; + $rows = $query->from('customer')->all($connection); + $this->assertEquals(10, count($rows)); + } + + public function testDirectMatch() + { + $connection = $this->getConnection(); + $query = new Query; + $rows = $query->from('customer') + ->where(['name' => 'name1']) + ->all($connection); + $this->assertEquals(1, count($rows)); + $this->assertEquals('name1', $rows[0]['name']); + } + + public function testIndexBy() + { + $connection = $this->getConnection(); + $query = new Query; + $rows = $query->from('customer') + ->indexBy('name') + ->all($connection); + $this->assertEquals(10, count($rows)); + $this->assertNotEmpty($rows['name1']); + } + + public function testInCondition() + { + $connection = $this->getConnection(); + $query = new Query; + $rows = $query->from('customer') + ->where([ + 'name' => ['name1', 'name5'] + ]) + ->all($connection); + $this->assertEquals(2, count($rows)); + $this->assertEquals('name1', $rows[0]['name']); + $this->assertEquals('name5', $rows[1]['name']); + } + + public function testOrCondition() + { + $connection = $this->getConnection(); + $query = new Query; + $rows = $query->from('customer') + ->where(['name' => 'name1']) + ->orWhere(['address' => 'address5']) + ->all($connection); + $this->assertEquals(2, count($rows)); + $this->assertEquals('name1', $rows[0]['name']); + $this->assertEquals('address5', $rows[1]['address']); + } + + public function testOrder() + { + $connection = $this->getConnection(); + + $query = new Query; + $rows = $query->from('customer') + ->orderBy(['name' => SORT_DESC]) + ->all($connection); + $this->assertEquals('name9', $rows[0]['name']); + + $query = new Query; + $rows = $query->from('customer') + ->orderBy(['avatar.height' => SORT_DESC]) + ->all($connection); + $this->assertEquals('name10', $rows[0]['name']); + } + + public function testMatchPlainId() + { + $connection = $this->getConnection(); + $query = new Query; + $row = $query->from('customer')->one($connection); + $query = new Query; + $rows = $query->from('customer') + ->where(['_id' => $row['_id']->__toString()]) + ->all($connection); + $this->assertEquals(1, count($rows)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/QueryTest.php b/tests/unit/extensions/mongo/QueryTest.php new file mode 100644 index 00000000000..35d45e01098 --- /dev/null +++ b/tests/unit/extensions/mongo/QueryTest.php @@ -0,0 +1,97 @@ +select($select); + $this->assertEquals($select, $query->select); + + $query = new Query; + $select = ['name', 'something']; + $query->select($select); + $this->assertEquals($select, $query->select); + } + + public function testFrom() + { + $query = new Query; + $from = 'customer'; + $query->from($from); + $this->assertEquals($from, $query->from); + + $query = new Query; + $from = ['', 'customer']; + $query->from($from); + $this->assertEquals($from, $query->from); + } + + public function testWhere() + { + $query = new Query; + $query->where(['name' => 'name1']); + $this->assertEquals(['name' => 'name1'], $query->where); + + $query->andWhere(['address' => 'address1']); + $this->assertEquals( + [ + 'and', + ['name' => 'name1'], + ['address' => 'address1'] + ], + $query->where + ); + + $query->orWhere(['name' => 'name2']); + $this->assertEquals( + [ + 'or', + [ + 'and', + ['name' => 'name1'], + ['address' => 'address1'] + ], + ['name' => 'name2'] + + ], + $query->where + ); + } + + 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); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/file/ActiveRecordTest.php b/tests/unit/extensions/mongo/file/ActiveRecordTest.php new file mode 100644 index 00000000000..93fb5520bcf --- /dev/null +++ b/tests/unit/extensions/mongo/file/ActiveRecordTest.php @@ -0,0 +1,323 @@ +getConnection(); + $this->setUpTestRows(); + $filePath = $this->getTestFilePath(); + if (!file_exists($filePath)) { + FileHelper::createDirectory($filePath); + } + } + + protected function tearDown() + { + $filePath = $this->getTestFilePath(); + if (file_exists($filePath)) { + FileHelper::removeDirectory($filePath); + } + $this->dropFileCollection(CustomerFile::collectionName()); + parent::tearDown(); + } + + /** + * @return string test file path. + */ + protected function getTestFilePath() + { + return Yii::getAlias('@yiiunit/runtime') . DIRECTORY_SEPARATOR . basename(get_class($this)) . '_' . getmypid(); + } + + /** + * Sets up test rows. + */ + protected function setUpTestRows() + { + $collection = $this->getConnection()->getFileCollection(CustomerFile::collectionName()); + $rows = []; + for ($i = 1; $i <= 10; $i++) { + $record = [ + 'tag' => 'tag' . $i, + 'status' => $i, + ]; + $content = 'content' . $i; + $record['_id'] = $collection->insertFileContent($content, $record); + $record['content'] = $content; + $rows[] = $record; + } + $this->testRows = $rows; + } + + // Tests : + + public function testFind() + { + // find one + $result = CustomerFile::find(); + $this->assertTrue($result instanceof ActiveQuery); + $customer = $result->one(); + $this->assertTrue($customer instanceof CustomerFile); + + // find all + $customers = CustomerFile::find()->all(); + $this->assertEquals(10, count($customers)); + $this->assertTrue($customers[0] instanceof CustomerFile); + $this->assertTrue($customers[1] instanceof CustomerFile); + + // find by _id + $testId = $this->testRows[0]['_id']; + $customer = CustomerFile::find($testId); + $this->assertTrue($customer instanceof CustomerFile); + $this->assertEquals($testId, $customer->_id); + + // find by column values + $customer = CustomerFile::find(['tag' => 'tag5']); + $this->assertTrue($customer instanceof CustomerFile); + $this->assertEquals($this->testRows[4]['_id'], $customer->_id); + $this->assertEquals('tag5', $customer->tag); + $customer = CustomerFile::find(['tag' => 'unexisting tag']); + $this->assertNull($customer); + + // find by attributes + $customer = CustomerFile::find()->where(['status' => 4])->one(); + $this->assertTrue($customer instanceof CustomerFile); + $this->assertEquals(4, $customer->status); + + // find count, sum, average, min, max, distinct + $this->assertEquals(10, CustomerFile::find()->count()); + $this->assertEquals(1, CustomerFile::find()->where(['status' => 2])->count()); + $this->assertEquals((1+10)/2*10, CustomerFile::find()->sum('status')); + $this->assertEquals((1+10)/2, CustomerFile::find()->average('status')); + $this->assertEquals(1, CustomerFile::find()->min('status')); + $this->assertEquals(10, CustomerFile::find()->max('status')); + $this->assertEquals(range(1, 10), CustomerFile::find()->distinct('status')); + + // scope + $this->assertEquals(1, CustomerFile::find()->activeOnly()->count()); + + // asArray + $testRow = $this->testRows[2]; + $customer = CustomerFile::find()->where(['_id' => $testRow['_id']])->asArray()->one(); + $this->assertEquals($testRow['_id'], $customer['_id']); + $this->assertEquals($testRow['tag'], $customer['tag']); + $this->assertEquals($testRow['status'], $customer['status']); + + // indexBy + $customers = CustomerFile::find()->indexBy('tag')->all(); + $this->assertTrue($customers['tag1'] instanceof CustomerFile); + $this->assertTrue($customers['tag2'] instanceof CustomerFile); + + // indexBy callable + $customers = CustomerFile::find()->indexBy(function ($customer) { + return $customer->status . '-' . $customer->status; + })->all(); + $this->assertTrue($customers['1-1'] instanceof CustomerFile); + $this->assertTrue($customers['2-2'] instanceof CustomerFile); + } + + public function testInsert() + { + $record = new CustomerFile; + $record->tag = 'new new'; + $record->status = 7; + + $this->assertTrue($record->isNewRecord); + + $record->save(); + + $this->assertTrue($record->_id instanceof \MongoId); + $this->assertFalse($record->isNewRecord); + + $fileContent = $record->getFileContent(); + $this->assertEmpty($fileContent); + } + + /** + * @depends testInsert + */ + public function testInsertFile() + { + $record = new CustomerFile; + $record->tag = 'new new'; + $record->status = 7; + + $fileName = __FILE__; + $record->setAttribute('file', $fileName); + + $record->save(); + + $this->assertTrue($record->_id instanceof \MongoId); + $this->assertFalse($record->isNewRecord); + + $fileContent = $record->getFileContent(); + $this->assertEquals(file_get_contents($fileName), $fileContent); + } + + /** + * @depends testInsert + */ + public function testInsertFileContent() + { + $record = new CustomerFile; + $record->tag = 'new new'; + $record->status = 7; + + $newFileContent = 'Test new file content'; + $record->setAttribute('newFileContent', $newFileContent); + + $record->save(); + + $this->assertTrue($record->_id instanceof \MongoId); + $this->assertFalse($record->isNewRecord); + + $fileContent = $record->getFileContent(); + $this->assertEquals($newFileContent, $fileContent); + } + + /** + * @depends testInsert + */ + public function testUpdate() + { + $record = new CustomerFile; + $record->tag = 'new new'; + $record->status = 7; + $record->save(); + + // save + $record = CustomerFile::find($record->_id); + $this->assertTrue($record instanceof CustomerFile); + $this->assertEquals(7, $record->status); + $this->assertFalse($record->isNewRecord); + + $record->status = 9; + $record->save(); + $this->assertEquals(9, $record->status); + $this->assertFalse($record->isNewRecord); + $record2 = CustomerFile::find($record->_id); + $this->assertEquals(9, $record2->status); + + // updateAll + $pk = ['_id' => $record->_id]; + $ret = CustomerFile::updateAll(['status' => 55], $pk); + $this->assertEquals(1, $ret); + $record = CustomerFile::find($pk); + $this->assertEquals(55, $record->status); + } + + /** + * @depends testUpdate + * @depends testInsertFileContent + */ + public function testUpdateFile() + { + $record = new CustomerFile; + $record->tag = 'new new'; + $record->status = 7; + $newFileContent = 'Test new file content'; + $record->setAttribute('newFileContent', $newFileContent); + $record->save(); + + $updateFileName = __FILE__; + $record = CustomerFile::find($record->_id); + $record->setAttribute('file', $updateFileName); + $record->status = 55; + $record->save(); + $this->assertEquals(file_get_contents($updateFileName), $record->getFileContent()); + + $record2 = CustomerFile::find($record->_id); + $this->assertEquals($record->status, $record2->status); + $this->assertEquals(file_get_contents($updateFileName), $record2->getFileContent()); + } + + /** + * @depends testUpdate + * @depends testInsertFileContent + */ + public function testUpdateFileContent() + { + $record = new CustomerFile; + $record->tag = 'new new'; + $record->status = 7; + $newFileContent = 'Test new file content'; + $record->setAttribute('newFileContent', $newFileContent); + $record->save(); + + $updateFileContent = 'New updated file content'; + $record = CustomerFile::find($record->_id); + $record->setAttribute('newFileContent', $updateFileContent); + $record->status = 55; + $record->save(); + $this->assertEquals($updateFileContent, $record->getFileContent()); + + $record2 = CustomerFile::find($record->_id); + $this->assertEquals($record->status, $record2->status); + $this->assertEquals($updateFileContent, $record2->getFileContent()); + } + + /** + * @depends testInsertFileContent + */ + public function testWriteFile() + { + $record = new CustomerFile; + $record->tag = 'new new'; + $record->status = 7; + $newFileContent = 'Test new file content'; + $record->setAttribute('newFileContent', $newFileContent); + $record->save(); + + $outputFileName = $this->getTestFilePath() . DIRECTORY_SEPARATOR . 'out.txt'; + $this->assertTrue($record->writeFile($outputFileName)); + $this->assertEquals($newFileContent, file_get_contents($outputFileName)); + + $record2 = CustomerFile::find($record->_id); + $outputFileName = $this->getTestFilePath() . DIRECTORY_SEPARATOR . 'out_refreshed.txt'; + $this->assertTrue($record2->writeFile($outputFileName)); + $this->assertEquals($newFileContent, file_get_contents($outputFileName)); + } + + /** + * @depends testInsertFileContent + */ + public function testGetFileResource() + { + $record = new CustomerFile; + $record->tag = 'new new'; + $record->status = 7; + $newFileContent = 'Test new file content'; + $record->setAttribute('newFileContent', $newFileContent); + $record->save(); + + $fileResource = $record->getFileResource(); + $contents = stream_get_contents($fileResource); + fclose($fileResource); + $this->assertEquals($newFileContent, $contents); + + $record2 = CustomerFile::find($record->_id); + $fileResource = $record2->getFileResource(); + $contents = stream_get_contents($fileResource); + fclose($fileResource); + $this->assertEquals($newFileContent, $contents); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/file/CollectionTest.php b/tests/unit/extensions/mongo/file/CollectionTest.php new file mode 100644 index 00000000000..58a5864defa --- /dev/null +++ b/tests/unit/extensions/mongo/file/CollectionTest.php @@ -0,0 +1,98 @@ +dropFileCollection('fs'); + parent::tearDown(); + } + + // Tests : + + public function testGetChunkCollection() + { + $collection = $this->getConnection()->getFileCollection(); + $chunkCollection = $collection->getChunkCollection(); + $this->assertTrue($chunkCollection instanceof \yii\mongo\Collection); + $this->assertTrue($chunkCollection->mongoCollection instanceof \MongoCollection); + } + + public function testFind() + { + $collection = $this->getConnection()->getFileCollection(); + $cursor = $collection->find(); + $this->assertTrue($cursor instanceof \MongoGridFSCursor); + } + + public function testInsertFile() + { + $collection = $this->getConnection()->getFileCollection(); + + $filename = __FILE__; + $id = $collection->insertFile($filename); + $this->assertTrue($id instanceof \MongoId); + + $files = $this->findAll($collection); + $this->assertEquals(1, count($files)); + + /** @var $file \MongoGridFSFile */ + $file = $files[0]; + $this->assertEquals($filename, $file->getFilename()); + $this->assertEquals(file_get_contents($filename), $file->getBytes()); + } + + public function testInsertFileContent() + { + $collection = $this->getConnection()->getFileCollection(); + + $bytes = 'Test file content'; + $id = $collection->insertFileContent($bytes); + $this->assertTrue($id instanceof \MongoId); + + $files = $this->findAll($collection); + $this->assertEquals(1, count($files)); + + /** @var $file \MongoGridFSFile */ + $file = $files[0]; + $this->assertEquals($bytes, $file->getBytes()); + } + + /** + * @depends testInsertFileContent + */ + public function testGet() + { + $collection = $this->getConnection()->getFileCollection(); + + $bytes = 'Test file content'; + $id = $collection->insertFileContent($bytes); + + $file = $collection->get($id); + $this->assertTrue($file instanceof \MongoGridFSFile); + $this->assertEquals($bytes, $file->getBytes()); + } + + /** + * @depends testGet + */ + public function testDelete() + { + $collection = $this->getConnection()->getFileCollection(); + + $bytes = 'Test file content'; + $id = $collection->insertFileContent($bytes); + + $this->assertTrue($collection->delete($id)); + + $file = $collection->get($id); + $this->assertNull($file); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/file/QueryTest.php b/tests/unit/extensions/mongo/file/QueryTest.php new file mode 100644 index 00000000000..2f9ec67db61 --- /dev/null +++ b/tests/unit/extensions/mongo/file/QueryTest.php @@ -0,0 +1,70 @@ +setUpTestRows(); + } + + protected function tearDown() + { + $this->dropFileCollection(); + parent::tearDown(); + } + + /** + * Sets up test rows. + */ + protected function setUpTestRows() + { + $collection = $this->getConnection()->getFileCollection(); + for ($i = 1; $i <= 10; $i++) { + $collection->insertFileContent('content' . $i, [ + 'filename' => 'name' . $i, + 'file_index' => $i, + ]); + } + } + + // Tests : + + public function testAll() + { + $connection = $this->getConnection(); + $query = new Query; + $rows = $query->from('fs')->all($connection); + $this->assertEquals(10, count($rows)); + } + + public function testOne() + { + $connection = $this->getConnection(); + $query = new Query; + $row = $query->from('fs')->one($connection); + $this->assertTrue(is_array($row)); + $this->assertTrue($row['file'] instanceof \MongoGridFSFile); + } + + public function testDirectMatch() + { + $connection = $this->getConnection(); + $query = new Query; + $rows = $query->from('fs') + ->where(['file_index' => 5]) + ->all($connection); + $this->assertEquals(1, count($rows)); + /** @var $file \MongoGridFSFile */ + $file = $rows[0]; + $this->assertEquals('name5', $file['filename']); + } +} \ No newline at end of file