From ae0f04be99b7e12099f0ec369e97927ed69e1a6a Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 25 Nov 2013 14:05:22 +0200 Subject: [PATCH 01/49] Mongo extension created as blank. --- extensions/mongo/Connection.php | 90 +++++++++++++++++++++++++ extensions/mongo/LICENSE.md | 32 +++++++++ extensions/mongo/README.md | 40 +++++++++++ extensions/mongo/composer.json | 28 ++++++++ tests/unit/data/config.php | 5 ++ tests/unit/extensions/mongo/ConnectionTest.php | 19 ++++++ tests/unit/extensions/mongo/MongoTestCase.php | 91 ++++++++++++++++++++++++++ 7 files changed, 305 insertions(+) create mode 100644 extensions/mongo/Connection.php create mode 100644 extensions/mongo/LICENSE.md create mode 100644 extensions/mongo/README.md create mode 100644 extensions/mongo/composer.json create mode 100644 tests/unit/extensions/mongo/ConnectionTest.php create mode 100644 tests/unit/extensions/mongo/MongoTestCase.php diff --git a/extensions/mongo/Connection.php b/extensions/mongo/Connection.php new file mode 100644 index 0000000..ff8093d --- /dev/null +++ b/extensions/mongo/Connection.php @@ -0,0 +1,90 @@ + + * @since 2.0 + */ +class Connection extends Component +{ + /** + * @var \MongoClient mongo client instance. + */ + public $client; + /** + * @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 host:port + * + * Correct syntax is: + * mongodb://[username:password@]host1[:port1][,host2[:port2:],...] + * For example: mongodb://localhost:27017 + */ + public $dsn; + /** + * @var string name of the Mongo database to use + */ + public $dbName; + + /** + * 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->client === null) { + if (empty($this->dsn)) { + throw new InvalidConfigException('Connection::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; + $this->client = new \MongoClient($this->dsn, $options); + $this->client->selectDB($this->dbName); + 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->client !== null) { + Yii::trace('Closing Mongo connection: ' . $this->dsn, __METHOD__); + $this->client = null; + } + } +} \ No newline at end of file diff --git a/extensions/mongo/LICENSE.md b/extensions/mongo/LICENSE.md new file mode 100644 index 0000000..0bb1a8d --- /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/README.md b/extensions/mongo/README.md new file mode 100644 index 0000000..b04e1b9 --- /dev/null +++ b/extensions/mongo/README.md @@ -0,0 +1,40 @@ +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. diff --git a/extensions/mongo/composer.json b/extensions/mongo/composer.json new file mode 100644 index 0000000..324b497 --- /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": "*" + }, + "autoload": { + "psr-0": { "yii\\mongo\\": "" } + } +} diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index 28e5abe..1cd036d 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -48,5 +48,10 @@ return [ 'password' => '', 'fixture' => __DIR__ . '/sphinx/source.sql', ], + ], + 'mongo' => [ + 'dsn' => 'mongodb://localhost:27017', + 'dbName' => 'yii2test', + 'options' => [], ] ]; diff --git a/tests/unit/extensions/mongo/ConnectionTest.php b/tests/unit/extensions/mongo/ConnectionTest.php new file mode 100644 index 0000000..a5b246a --- /dev/null +++ b/tests/unit/extensions/mongo/ConnectionTest.php @@ -0,0 +1,19 @@ +getConnection(false); + $params = $this->mongoConfig; + + $connection->open(); + + $this->assertEquals($params['dsn'], $connection->dsn); + //$this->assertEquals($params['username'], $connection->username); + //$this->assertEquals($params['password'], $connection->password); + } +} \ 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 0000000..1b11bd1 --- /dev/null +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -0,0 +1,91 @@ + 'mongodb://localhost:27017', + 'dbName' => 'yii2test', + ]; + /** + * @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']; + if (isset($this->mongoConfig['dbName'])) { + $db->dbName = $this->mongoConfig['dbName']; + } + if (isset($this->mongoConfig['options'])) { + $db->options = $this->mongoConfig['options']; + } + if ($open) { + $db->open(); + } + $this->mongo = $db; + return $db; + } +} \ No newline at end of file From c61ebcc5b7b1e43d01f0520f23e05e6fa07dc5a4 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 25 Nov 2013 14:34:22 +0200 Subject: [PATCH 02/49] Mongo connection advanced. --- extensions/mongo/Connection.php | 11 +++++++++++ tests/unit/extensions/mongo/ConnectionTest.php | 27 ++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/extensions/mongo/Connection.php b/extensions/mongo/Connection.php index ff8093d..5ee3d40 100644 --- a/extensions/mongo/Connection.php +++ b/extensions/mongo/Connection.php @@ -15,6 +15,8 @@ use Yii; /** * Class Connection * + * @property boolean $isActive Whether the Mongo connection is established. This property is read-only. + * * @author Paul Klimov * @since 2.0 */ @@ -50,6 +52,15 @@ class Connection extends Component public $dbName; /** + * 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->client) && $this->client->connected; + } + + /** * Establishes a Mongo connection. * It does nothing if a Mongo connection has already been established. * @throws Exception if connection fails diff --git a/tests/unit/extensions/mongo/ConnectionTest.php b/tests/unit/extensions/mongo/ConnectionTest.php index a5b246a..f6a47f0 100644 --- a/tests/unit/extensions/mongo/ConnectionTest.php +++ b/tests/unit/extensions/mongo/ConnectionTest.php @@ -3,6 +3,8 @@ namespace yiiunit\extensions\mongo; +use yii\mongo\Connection; + class ConnectionTest extends MongoTestCase { public function testConstruct() @@ -13,7 +15,28 @@ class ConnectionTest extends MongoTestCase $connection->open(); $this->assertEquals($params['dsn'], $connection->dsn); - //$this->assertEquals($params['username'], $connection->username); - //$this->assertEquals($params['password'], $connection->password); + $this->assertEquals($params['dbName'], $connection->dbName); + $this->assertEquals($params['options'], $connection->options); + } + + public function testOpenClose() + { + $connection = $this->getConnection(false, false); + + $this->assertFalse($connection->isActive); + $this->assertEquals(null, $connection->client); + + $connection->open(); + $this->assertTrue($connection->isActive); + $this->assertTrue(is_object($connection->client)); + + $connection->close(); + $this->assertFalse($connection->isActive); + $this->assertEquals(null, $connection->client); + + $connection = new Connection; + $connection->dsn = 'unknown::memory:'; + $this->setExpectedException('yii\db\Exception'); + $connection->open(); } } \ No newline at end of file From c929268b2511fed413c00e8020b432624378d2be Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 25 Nov 2013 17:06:31 +0200 Subject: [PATCH 03/49] Mongo classes created as blank. --- extensions/mongo/Command.php | 32 ++++ extensions/mongo/Connection.php | 52 +++++- extensions/mongo/Query.php | 237 +++++++++++++++++++++++++ extensions/mongo/QueryBuilder.php | 37 ++++ tests/unit/extensions/mongo/ConnectionTest.php | 9 + 5 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 extensions/mongo/Command.php create mode 100644 extensions/mongo/Query.php create mode 100644 extensions/mongo/QueryBuilder.php diff --git a/extensions/mongo/Command.php b/extensions/mongo/Command.php new file mode 100644 index 0000000..fcaf591 --- /dev/null +++ b/extensions/mongo/Command.php @@ -0,0 +1,32 @@ + + * @since 2.0 + */ +class Command extends Component +{ + /** + * @var Connection the Mongo connection that this command is associated with + */ + public $db; + + /** + * Drop the current database + */ + public function dropDb() + { + $this->db->db->drop(); + } +} \ No newline at end of file diff --git a/extensions/mongo/Connection.php b/extensions/mongo/Connection.php index 5ee3d40..2566f07 100644 --- a/extensions/mongo/Connection.php +++ b/extensions/mongo/Connection.php @@ -16,6 +16,8 @@ use Yii; * Class Connection * * @property boolean $isActive Whether the Mongo connection is established. This property is read-only. + * @property QueryBuilder $queryBuilder The query builder for the current Mongo connection. This property + * is read-only. * * @author Paul Klimov * @since 2.0 @@ -23,6 +25,11 @@ use Yii; class Connection extends Component { /** + * @var \MongoCollection[] list of Mongo collection available in database. + */ + private $_collections = []; + + /** * @var \MongoClient mongo client instance. */ public $client; @@ -50,6 +57,24 @@ class Connection extends Component * @var string name of the Mongo database to use */ public $dbName; + /** + * @var \MongoDb Mongo database instance. + */ + public $db; + + /** + * Returns the Mongo collection with the given name. + * @param string $name collection name + * @param boolean $refresh whether to reload the table schema even if it is found in the cache. + * @return \MongoCollection mongo collection instance. + */ + public function getCollection($name, $refresh = false) + { + if ($refresh || !array_key_exists($name, $this->_collections)) { + $this->_collections[$name] = $this->client->selectCollection($this->dbName, $name); + } + return $this->_collections[$name]; + } /** * Returns a value indicating whether the Mongo connection is established. @@ -69,7 +94,7 @@ class Connection extends Component { if ($this->client === null) { if (empty($this->dsn)) { - throw new InvalidConfigException('Connection::dsn cannot be empty.'); + throw new InvalidConfigException($this->className() . '::dsn cannot be empty.'); } $token = 'Opening Mongo connection: ' . $this->dsn; try { @@ -78,7 +103,7 @@ class Connection extends Component $options = $this->options; $options['connect'] = true; $this->client = new \MongoClient($this->dsn, $options); - $this->client->selectDB($this->dbName); + $this->db = $this->client->selectDB($this->dbName); Yii::endProfile($token, __METHOD__); } catch (\Exception $e) { Yii::endProfile($token, __METHOD__); @@ -96,6 +121,29 @@ class Connection extends Component if ($this->client !== null) { Yii::trace('Closing Mongo connection: ' . $this->dsn, __METHOD__); $this->client = null; + $this->db = null; } } + + /** + * Returns the query builder for the current DB connection. + * @return QueryBuilder the query builder for the current DB connection. + */ + public function getQueryBuilder() + { + return new QueryBuilder($this); + } + + /** + * Creates a command for execution. + * @return Command the Mongo command + */ + public function createCommand() + { + $this->open(); + $command = new Command([ + 'db' => $this, + ]); + return $command; + } } \ No newline at end of file diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php new file mode 100644 index 0000000..e2cc622 --- /dev/null +++ b/extensions/mongo/Query.php @@ -0,0 +1,237 @@ + + * @since 2.0 + */ +class Query extends Component implements QueryInterface +{ + + /** + * Executes the query and returns all results as an array. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` 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) + { + // TODO: Implement all() method. + } + + /** + * Executes the query and returns a single row of result. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` 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) + { + // TODO: Implement one() method. + } + + /** + * Returns the number of records. + * @param string $q the COUNT expression. Defaults to '*'. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer number of records + */ + public function count($q = '*', $db = null) + { + // TODO: Implement count() method. + } + + /** + * Returns a value indicating whether the query result contains any row of data. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return boolean whether the query result contains any row of data. + */ + public function exists($db = null) + { + // TODO: Implement exists() method. + } + + /** + * Sets the [[indexBy]] property. + * @param string|callable $column the name of the column by which the query results should be indexed by. + * This can also be a callable (e.g. anonymous function) that returns the index value based on the given + * row data. The signature of the callable should be: + * + * ~~~ + * function ($row) + * { + * // return the index value corresponding to $row + * } + * ~~~ + * + * @return static the query object itself + */ + public function indexBy($column) + { + // TODO: Implement indexBy() method. + } + + /** + * Sets the WHERE part of the query. + * + * The method requires a $condition parameter. + * + * The $condition parameter should be an array in one of the following two formats: + * + * - hash format: `['column1' => value1, 'column2' => value2, ...]` + * - operator format: `[operator, operand1, operand2, ...]` + * + * A condition in hash format represents the following SQL expression in general: + * `column1=value1 AND column2=value2 AND ...`. In case when a value is an array, + * an `IN` expression will be generated. And if a value is null, `IS NULL` will be used + * in the generated expression. Below are some examples: + * + * - `['type' => 1, 'status' => 2]` generates `(type = 1) AND (status = 2)`. + * - `['id' => [1, 2, 3], 'status' => 2]` generates `(id IN (1, 2, 3)) AND (status = 2)`. + * - `['status' => null] generates `status IS NULL`. + * + * A condition in operator format generates the SQL expression according to the specified operator, which + * can be one of the followings: + * + * - `and`: the operands should be concatenated together using `AND`. For example, + * `['and', 'id=1', 'id=2']` will generate `id=1 AND id=2`. If an operand is an array, + * it will be converted into a string using the rules described here. For example, + * `['and', 'type=1', ['or', 'id=1', 'id=2']]` will generate `type=1 AND (id=1 OR id=2)`. + * The method will NOT do any quoting or escaping. + * + * - `or`: similar to the `and` operator except that the operands are concatenated using `OR`. + * + * - `between`: operand 1 should be the column name, and operand 2 and 3 should be the + * starting and ending values of the range that the column is in. + * For example, `['between', 'id', 1, 10]` will generate `id BETWEEN 1 AND 10`. + * + * - `not between`: similar to `between` except the `BETWEEN` is replaced with `NOT BETWEEN` + * in the generated condition. + * + * - `in`: operand 1 should be a column or DB expression, and operand 2 be an array representing + * the range of the values that the column or DB expression should be in. For example, + * `['in', 'id', [1, 2, 3]]` will generate `id IN (1, 2, 3)`. + * The method will properly quote the column name and escape values in the range. + * + * - `not in`: similar to the `in` operator except that `IN` is replaced with `NOT IN` in the generated condition. + * + * - `like`: operand 1 should be a column or DB expression, and operand 2 be a string or an array representing + * the values that the column or DB expression should be like. + * For example, `['like', 'name', '%tester%']` will generate `name LIKE '%tester%'`. + * When the value range is given as an array, multiple `LIKE` predicates will be generated and concatenated + * using `AND`. For example, `['like', 'name', ['%test%', '%sample%']]` will generate + * `name LIKE '%test%' AND name LIKE '%sample%'`. + * The method will properly quote the column name and escape values in the range. + * + * - `or like`: similar to the `like` operator except that `OR` is used to concatenate the `LIKE` + * predicates when operand 2 is an array. + * + * - `not like`: similar to the `like` operator except that `LIKE` is replaced with `NOT LIKE` + * in the generated condition. + * + * - `or not like`: similar to the `not like` operator except that `OR` is used to concatenate + * the `NOT LIKE` predicates. + * + * @param array $condition the conditions that should be put in the WHERE part. + * @return static the query object itself + * @see andWhere() + * @see orWhere() + */ + public function where($condition) + { + // TODO: Implement where() method. + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'AND' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @return static the query object itself + * @see where() + * @see orWhere() + */ + public function andWhere($condition) + { + // TODO: Implement andWhere() method. + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'OR' operator. + * @param string|array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @return static the query object itself + * @see where() + * @see andWhere() + */ + public function orWhere($condition) + { + // TODO: Implement orWhere() method. + } + + /** + * Sets the ORDER BY part of the query. + * @param string|array $columns the columns (and the directions) to be ordered by. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['id' => SORT_ASC, 'name' => SORT_DESC]`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see addOrderBy() + */ + public function orderBy($columns) + { + // TODO: Implement orderBy() method. + } + + /** + * Adds additional ORDER BY columns to the query. + * @param string|array $columns the columns (and the directions) to be ordered by. + * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array + * (e.g. `['id' => SORT_ASC, 'name' => SORT_DESC]`). + * The method will automatically quote the column names unless a column contains some parenthesis + * (which means the column contains a DB expression). + * @return static the query object itself + * @see orderBy() + */ + public function addOrderBy($columns) + { + // TODO: Implement addOrderBy() method. + } + + /** + * Sets the LIMIT part of the query. + * @param integer $limit the limit. Use null or negative value to disable limit. + * @return static the query object itself + */ + public function limit($limit) + { + // TODO: Implement limit() method. + } + + /** + * Sets the OFFSET part of the query. + * @param integer $offset the offset. Use null or negative value to disable offset. + * @return static the query object itself + */ + public function offset($offset) + { + // TODO: Implement offset() method. + } +} \ No newline at end of file diff --git a/extensions/mongo/QueryBuilder.php b/extensions/mongo/QueryBuilder.php new file mode 100644 index 0000000..5c7181c --- /dev/null +++ b/extensions/mongo/QueryBuilder.php @@ -0,0 +1,37 @@ + + * @since 2.0 + */ +class QueryBuilder extends Object +{ + /** + * @var Connection the Mongo connection. + */ + public $db; + + /** + * Constructor. + * @param Connection $connection the Mongo connection. + * @param array $config name-value pairs that will be used to initialize the object properties + */ + public function __construct($connection, $config = []) + { + $this->db = $connection; + parent::__construct($config); + } + + // TODO +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/ConnectionTest.php b/tests/unit/extensions/mongo/ConnectionTest.php index f6a47f0..11d2d0f 100644 --- a/tests/unit/extensions/mongo/ConnectionTest.php +++ b/tests/unit/extensions/mongo/ConnectionTest.php @@ -29,14 +29,23 @@ class ConnectionTest extends MongoTestCase $connection->open(); $this->assertTrue($connection->isActive); $this->assertTrue(is_object($connection->client)); + $this->assertTrue(is_object($connection->db)); $connection->close(); $this->assertFalse($connection->isActive); $this->assertEquals(null, $connection->client); + $this->assertEquals(null, $connection->db); $connection = new Connection; $connection->dsn = 'unknown::memory:'; $this->setExpectedException('yii\db\Exception'); $connection->open(); } + + public function testGetCollection() + { + $connection = $this->getConnection(false); + $collection = $connection->getCollection('customer'); + $this->assertTrue($collection instanceof \MongoCollection); + } } \ No newline at end of file From 3b5ee4fbe91f71d5b2e58150f06bd777a2303627 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 26 Nov 2013 17:03:08 +0200 Subject: [PATCH 04/49] Mongo Command created. --- extensions/mongo/Command.php | 129 ++++++++++++++++++++++++- extensions/mongo/Connection.php | 4 +- extensions/mongo/Exception.php | 25 +++++ tests/unit/extensions/mongo/CommandTest.php | 96 ++++++++++++++++++ tests/unit/extensions/mongo/ConnectionTest.php | 6 +- tests/unit/extensions/mongo/MongoTestCase.php | 11 +++ 6 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 extensions/mongo/Exception.php create mode 100644 tests/unit/extensions/mongo/CommandTest.php diff --git a/extensions/mongo/Command.php b/extensions/mongo/Command.php index fcaf591..7a21177 100644 --- a/extensions/mongo/Command.php +++ b/extensions/mongo/Command.php @@ -8,6 +8,7 @@ namespace yii\mongo; use \yii\base\Component; +use Yii; /** * Class Command @@ -23,10 +24,136 @@ class Command extends Component public $db; /** - * Drop the current database + * Drops the current database */ public function dropDb() { $this->db->db->drop(); } + + /** + * Drops the specified collection. + * @param string $name collection name. + */ + public function dropCollection($name) + { + $collection = $this->db->getCollection($name); + $collection->drop(); + } + + /** + * @param $collection + * @param array $query + * @param array $fields + * @return \MongoCursor + */ + public function find($collection, $query = [], $fields = []) + { + $collection = $this->db->getCollection($collection); + return $collection->find($query, $fields); + } + + /** + * @param $collection + * @param array $query + * @param array $fields + * @return array + */ + public function findAll($collection, $query = [], $fields = []) + { + $cursor = $this->find($collection, $query, $fields); + $result = []; + foreach ($cursor as $data) { + $result[] = $data; + } + return $result; + } + + /** + * Inserts new data into collection. + * @param string $collection name of the 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($collection, $data, $options = []) + { + $token = 'Inserting data into ' . $collection; + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $collection = $this->db->getCollection($collection); + $this->tryResultError($collection->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); + } + } + + /** + * Update the existing database data, otherwise insert this data + * @param string $collection name of the collection. + * @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($collection, $data, $options = []) + { + $token = 'Saving data into ' . $collection; + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $collection = $this->db->getCollection($collection); + $this->tryResultError($collection->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 string $collection name of the collection. + * @param array $criteria description of records to remove. + * @param array $options list of options in format: optionName => optionValue. + * @return boolean whether operation was successful. + * @throws Exception on failure. + */ + public function remove($collection, $criteria = [], $options = []) + { + $token = 'Removing data from ' . $collection; + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $collection = $this->db->getCollection($collection); + $this->tryResultError($collection->remove($criteria, $options)); + Yii::endProfile($token, __METHOD__); + return true; + } 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['err'])) { + throw new Exception($result['errmsg'], (int)$result['code']); + } + } 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/Connection.php b/extensions/mongo/Connection.php index 2566f07..8158b15 100644 --- a/extensions/mongo/Connection.php +++ b/extensions/mongo/Connection.php @@ -9,7 +9,6 @@ namespace yii\mongo; use yii\base\Component; use yii\base\InvalidConfigException; -use yii\db\Exception; use Yii; /** @@ -102,12 +101,13 @@ class Connection extends Component Yii::beginProfile($token, __METHOD__); $options = $this->options; $options['connect'] = true; + $options['db'] = $this->dbName; $this->client = new \MongoClient($this->dsn, $options); $this->db = $this->client->selectDB($this->dbName); Yii::endProfile($token, __METHOD__); } catch (\Exception $e) { Yii::endProfile($token, __METHOD__); - throw new Exception($e->getMessage(), [], (int)$e->getCode(), $e); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); } } } diff --git a/extensions/mongo/Exception.php b/extensions/mongo/Exception.php new file mode 100644 index 0000000..0687e48 --- /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/tests/unit/extensions/mongo/CommandTest.php b/tests/unit/extensions/mongo/CommandTest.php new file mode 100644 index 0000000..c9621d1 --- /dev/null +++ b/tests/unit/extensions/mongo/CommandTest.php @@ -0,0 +1,96 @@ +dropCollection('customer'); + parent::tearDown(); + } + + // Tests : + + public function testInsert() + { + $command = $this->getConnection()->createCommand(); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $command->insert('customer', $data); + $this->assertTrue($id instanceof \MongoId); + $this->assertNotEmpty($id->__toString()); + } + + /** + * @depends testInsert + */ + public function testFindAll() + { + $command = $this->getConnection()->createCommand(); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $command->insert('customer', $data); + + $rows = $command->findAll('customer'); + $this->assertEquals(1, count($rows)); + $this->assertEquals($id, $rows[0]['_id']); + } + + public function testSave() + { + $command = $this->getConnection()->createCommand(); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $command->save('customer', $data); + $this->assertTrue($id instanceof \MongoId); + $this->assertNotEmpty($id->__toString()); + } + + /** + * @depends testSave + */ + public function testUpdate() + { + $command = $this->getConnection()->createCommand(); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $newId = $command->save('customer', $data); + + $updatedId = $command->save('customer', $data); + $this->assertEquals($newId, $updatedId, 'Unable to update data!'); + + $data['_id'] = $newId->__toString(); + $updatedId = $command->save('customer', $data); + $this->assertEquals($newId, $updatedId, 'Unable to updated data by string id!'); + } + + /** + * @depends testFindAll + */ + public function testRemove() + { + $command = $this->getConnection()->createCommand(); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $command->insert('customer', $data); + + $command->remove('customer', ['_id' => $id]); + + $rows = $command->findAll('customer'); + $this->assertEquals(0, count($rows)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/ConnectionTest.php b/tests/unit/extensions/mongo/ConnectionTest.php index 11d2d0f..175a665 100644 --- a/tests/unit/extensions/mongo/ConnectionTest.php +++ b/tests/unit/extensions/mongo/ConnectionTest.php @@ -2,9 +2,11 @@ namespace yiiunit\extensions\mongo; - use yii\mongo\Connection; +/** + * @group mongo + */ class ConnectionTest extends MongoTestCase { public function testConstruct() @@ -38,7 +40,7 @@ class ConnectionTest extends MongoTestCase $connection = new Connection; $connection->dsn = 'unknown::memory:'; - $this->setExpectedException('yii\db\Exception'); + $this->setExpectedException('yii\mongo\Exception'); $connection->open(); } diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index 1b11bd1..a4f20ac 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -88,4 +88,15 @@ class MongoTestCase extends TestCase $this->mongo = $db; return $db; } + + /** + * Drops the specified collection. + * @param string $name collection name. + */ + protected function dropCollection($name) + { + if ($this->mongo) { + $this->mongo->getCollection($name)->drop(); + } + } } \ No newline at end of file From 529af8edc48c7f2e852df45118f681b8dc0a991b Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Tue, 26 Nov 2013 21:16:55 +0200 Subject: [PATCH 05/49] Mongo "Database" and "Collection" classes introduced. --- extensions/mongo/Collection.php | 140 ++++++++++++++++++++++ extensions/mongo/Command.php | 159 ------------------------- extensions/mongo/Connection.php | 134 +++++++++++++-------- extensions/mongo/Database.php | 64 ++++++++++ tests/unit/extensions/mongo/CollectionTest.php | 96 +++++++++++++++ tests/unit/extensions/mongo/CommandTest.php | 96 --------------- tests/unit/extensions/mongo/ConnectionTest.php | 58 +++++++-- tests/unit/extensions/mongo/DatabaseTest.php | 34 ++++++ tests/unit/extensions/mongo/MongoTestCase.php | 6 +- 9 files changed, 469 insertions(+), 318 deletions(-) create mode 100644 extensions/mongo/Collection.php delete mode 100644 extensions/mongo/Command.php create mode 100644 extensions/mongo/Database.php create mode 100644 tests/unit/extensions/mongo/CollectionTest.php delete mode 100644 tests/unit/extensions/mongo/CommandTest.php create mode 100644 tests/unit/extensions/mongo/DatabaseTest.php diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php new file mode 100644 index 0000000..fc15b89 --- /dev/null +++ b/extensions/mongo/Collection.php @@ -0,0 +1,140 @@ + + * @since 2.0 + */ +class Collection extends Object +{ + /** + * @var \MongoCollection Mongo collection instance. + */ + public $mongoCollection; + + /** + * Drops this collection. + */ + public function drop() + { + $this->mongoCollection->drop(); + } + + /** + * @param array $query + * @param array $fields + * @return \MongoCursor + */ + public function find($query = [], $fields = []) + { + return $this->mongoCollection->find($query, $fields); + } + + /** + * @param array $query + * @param array $fields + * @return array + */ + public function findAll($query = [], $fields = []) + { + $cursor = $this->find($query, $fields); + $result = []; + foreach ($cursor as $data) { + $result[] = $data; + } + return $result; + } + + /** + * 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 = 'Inserting data into ' . $this->mongoCollection->getName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $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); + } + } + + /** + * 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 = 'Saving data into ' . $this->mongoCollection->getName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $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 $criteria description of records to remove. + * @param array $options list of options in format: optionName => optionValue. + * @return boolean whether operation was successful. + * @throws Exception on failure. + */ + public function remove($criteria = [], $options = []) + { + $token = 'Removing data from ' . $this->mongoCollection->getName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $this->tryResultError($this->mongoCollection->remove($criteria, $options)); + Yii::endProfile($token, __METHOD__); + return true; + } 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['err'])) { + throw new Exception($result['errmsg'], (int)$result['code']); + } + } 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/Command.php b/extensions/mongo/Command.php deleted file mode 100644 index 7a21177..0000000 --- a/extensions/mongo/Command.php +++ /dev/null @@ -1,159 +0,0 @@ - - * @since 2.0 - */ -class Command extends Component -{ - /** - * @var Connection the Mongo connection that this command is associated with - */ - public $db; - - /** - * Drops the current database - */ - public function dropDb() - { - $this->db->db->drop(); - } - - /** - * Drops the specified collection. - * @param string $name collection name. - */ - public function dropCollection($name) - { - $collection = $this->db->getCollection($name); - $collection->drop(); - } - - /** - * @param $collection - * @param array $query - * @param array $fields - * @return \MongoCursor - */ - public function find($collection, $query = [], $fields = []) - { - $collection = $this->db->getCollection($collection); - return $collection->find($query, $fields); - } - - /** - * @param $collection - * @param array $query - * @param array $fields - * @return array - */ - public function findAll($collection, $query = [], $fields = []) - { - $cursor = $this->find($collection, $query, $fields); - $result = []; - foreach ($cursor as $data) { - $result[] = $data; - } - return $result; - } - - /** - * Inserts new data into collection. - * @param string $collection name of the 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($collection, $data, $options = []) - { - $token = 'Inserting data into ' . $collection; - Yii::info($token, __METHOD__); - try { - Yii::beginProfile($token, __METHOD__); - $collection = $this->db->getCollection($collection); - $this->tryResultError($collection->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); - } - } - - /** - * Update the existing database data, otherwise insert this data - * @param string $collection name of the collection. - * @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($collection, $data, $options = []) - { - $token = 'Saving data into ' . $collection; - Yii::info($token, __METHOD__); - try { - Yii::beginProfile($token, __METHOD__); - $collection = $this->db->getCollection($collection); - $this->tryResultError($collection->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 string $collection name of the collection. - * @param array $criteria description of records to remove. - * @param array $options list of options in format: optionName => optionValue. - * @return boolean whether operation was successful. - * @throws Exception on failure. - */ - public function remove($collection, $criteria = [], $options = []) - { - $token = 'Removing data from ' . $collection; - Yii::info($token, __METHOD__); - try { - Yii::beginProfile($token, __METHOD__); - $collection = $this->db->getCollection($collection); - $this->tryResultError($collection->remove($criteria, $options)); - Yii::endProfile($token, __METHOD__); - return true; - } 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['err'])) { - throw new Exception($result['errmsg'], (int)$result['code']); - } - } 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/Connection.php b/extensions/mongo/Connection.php index 8158b15..ce99ee4 100644 --- a/extensions/mongo/Connection.php +++ b/extensions/mongo/Connection.php @@ -24,14 +24,16 @@ use Yii; class Connection extends Component { /** - * @var \MongoCollection[] list of Mongo collection available in database. - */ - private $_collections = []; - - /** - * @var \MongoClient mongo client instance. + * @var string host:port + * + * Correct syntax is: + * mongodb://[username:password@]host1[:port1][,host2[:port2:],...][/dbname] + * For example: + * mongodb://localhost:27017 + * mongodb://developer:somepassword@localhost:27017 + * mongodb://developer:somepassword@localhost:27017/mydatabase */ - public $client; + public $dsn; /** * @var array connection options. * for example: @@ -45,34 +47,83 @@ class Connection extends Component */ public $options = []; /** - * @var string host:port - * - * Correct syntax is: - * mongodb://[username:password@]host1[:port1][,host2[:port2:],...] - * For example: mongodb://localhost:27017 + * @var string name of the Mongo database to use by default. */ - public $dsn; + 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]; + } + /** - * @var string name of the Mongo database to use + * 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. */ - public $dbName; + protected function fetchDefaultDatabaseName() + { + if ($this->defaultDatabaseName === null) { + if (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; + } + /** - * @var \MongoDb Mongo database instance. + * Selects the database with given name. + * @param string $name database name. + * @return Database database instance. */ - public $db; + 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 $name collection 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 table schema even if it is found in the cache. - * @return \MongoCollection mongo collection instance. + * @return Collection Mongo collection instance. */ public function getCollection($name, $refresh = false) { - if ($refresh || !array_key_exists($name, $this->_collections)) { - $this->_collections[$name] = $this->client->selectCollection($this->dbName, $name); + if (is_array($name)) { + list ($dbName, $collectionName) = $name; + return $this->getDatabase($dbName)->getCollection($collectionName, $refresh); + } else { + return $this->getDatabase()->getCollection($name, $refresh); } - return $this->_collections[$name]; } /** @@ -81,7 +132,7 @@ class Connection extends Component */ public function getIsActive() { - return is_object($this->client) && $this->client->connected; + return is_object($this->mongoClient) && $this->mongoClient->connected; } /** @@ -91,7 +142,7 @@ class Connection extends Component */ public function open() { - if ($this->client === null) { + if ($this->mongoClient === null) { if (empty($this->dsn)) { throw new InvalidConfigException($this->className() . '::dsn cannot be empty.'); } @@ -101,9 +152,10 @@ class Connection extends Component Yii::beginProfile($token, __METHOD__); $options = $this->options; $options['connect'] = true; - $options['db'] = $this->dbName; - $this->client = new \MongoClient($this->dsn, $options); - $this->db = $this->client->selectDB($this->dbName); + 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__); @@ -118,32 +170,10 @@ class Connection extends Component */ public function close() { - if ($this->client !== null) { + if ($this->mongoClient !== null) { Yii::trace('Closing Mongo connection: ' . $this->dsn, __METHOD__); - $this->client = null; - $this->db = null; + $this->mongoClient = null; + $this->_databases = []; } } - - /** - * Returns the query builder for the current DB connection. - * @return QueryBuilder the query builder for the current DB connection. - */ - public function getQueryBuilder() - { - return new QueryBuilder($this); - } - - /** - * Creates a command for execution. - * @return Command the Mongo command - */ - public function createCommand() - { - $this->open(); - $command = new Command([ - 'db' => $this, - ]); - return $command; - } } \ No newline at end of file diff --git a/extensions/mongo/Database.php b/extensions/mongo/Database.php new file mode 100644 index 0000000..4954a20 --- /dev/null +++ b/extensions/mongo/Database.php @@ -0,0 +1,64 @@ + + * @since 2.0 + */ +class Database extends Object +{ + /** + * @var \MongoDB Mongo database instance. + */ + public $mongoDb; + /** + * @var Collection[] list of collections. + */ + private $_collections = []; + + /** + * Returns the Mongo collection with the given name. + * @param string $name collection name + * @param boolean $refresh whether to reload the table schema 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]; + } + + /** + * 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) + ]); + } + + /** + * Drops this database. + */ + public function drop() + { + $this->mongoDb->drop(); + } +} \ 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 0000000..5dd7d59 --- /dev/null +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -0,0 +1,96 @@ +dropCollection('customer'); + parent::tearDown(); + } + + // Tests : + + 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 + */ + public function testFindAll() + { + $collection = $this->getConnection()->getCollection('customer'); + $data = [ + 'name' => 'customer 1', + 'address' => 'customer 1 address', + ]; + $id = $collection->insert($data); + + $rows = $collection->findAll(); + $this->assertEquals(1, count($rows)); + $this->assertEquals($id, $rows[0]['_id']); + } + + 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 testUpdate() + { + $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); + + $collection->remove(['_id' => $id]); + + $rows = $collection->findAll(); + $this->assertEquals(0, count($rows)); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/CommandTest.php b/tests/unit/extensions/mongo/CommandTest.php deleted file mode 100644 index c9621d1..0000000 --- a/tests/unit/extensions/mongo/CommandTest.php +++ /dev/null @@ -1,96 +0,0 @@ -dropCollection('customer'); - parent::tearDown(); - } - - // Tests : - - public function testInsert() - { - $command = $this->getConnection()->createCommand(); - $data = [ - 'name' => 'customer 1', - 'address' => 'customer 1 address', - ]; - $id = $command->insert('customer', $data); - $this->assertTrue($id instanceof \MongoId); - $this->assertNotEmpty($id->__toString()); - } - - /** - * @depends testInsert - */ - public function testFindAll() - { - $command = $this->getConnection()->createCommand(); - $data = [ - 'name' => 'customer 1', - 'address' => 'customer 1 address', - ]; - $id = $command->insert('customer', $data); - - $rows = $command->findAll('customer'); - $this->assertEquals(1, count($rows)); - $this->assertEquals($id, $rows[0]['_id']); - } - - public function testSave() - { - $command = $this->getConnection()->createCommand(); - $data = [ - 'name' => 'customer 1', - 'address' => 'customer 1 address', - ]; - $id = $command->save('customer', $data); - $this->assertTrue($id instanceof \MongoId); - $this->assertNotEmpty($id->__toString()); - } - - /** - * @depends testSave - */ - public function testUpdate() - { - $command = $this->getConnection()->createCommand(); - $data = [ - 'name' => 'customer 1', - 'address' => 'customer 1 address', - ]; - $newId = $command->save('customer', $data); - - $updatedId = $command->save('customer', $data); - $this->assertEquals($newId, $updatedId, 'Unable to update data!'); - - $data['_id'] = $newId->__toString(); - $updatedId = $command->save('customer', $data); - $this->assertEquals($newId, $updatedId, 'Unable to updated data by string id!'); - } - - /** - * @depends testFindAll - */ - public function testRemove() - { - $command = $this->getConnection()->createCommand(); - $data = [ - 'name' => 'customer 1', - 'address' => 'customer 1 address', - ]; - $id = $command->insert('customer', $data); - - $command->remove('customer', ['_id' => $id]); - - $rows = $command->findAll('customer'); - $this->assertEquals(0, count($rows)); - } -} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/ConnectionTest.php b/tests/unit/extensions/mongo/ConnectionTest.php index 175a665..3b8a1a2 100644 --- a/tests/unit/extensions/mongo/ConnectionTest.php +++ b/tests/unit/extensions/mongo/ConnectionTest.php @@ -2,7 +2,9 @@ namespace yiiunit\extensions\mongo; +use yii\mongo\Collection; use yii\mongo\Connection; +use yii\mongo\Database; /** * @group mongo @@ -17,7 +19,7 @@ class ConnectionTest extends MongoTestCase $connection->open(); $this->assertEquals($params['dsn'], $connection->dsn); - $this->assertEquals($params['dbName'], $connection->dbName); + $this->assertEquals($params['defaultDatabaseName'], $connection->defaultDatabaseName); $this->assertEquals($params['options'], $connection->options); } @@ -26,17 +28,15 @@ class ConnectionTest extends MongoTestCase $connection = $this->getConnection(false, false); $this->assertFalse($connection->isActive); - $this->assertEquals(null, $connection->client); + $this->assertEquals(null, $connection->mongoClient); $connection->open(); $this->assertTrue($connection->isActive); - $this->assertTrue(is_object($connection->client)); - $this->assertTrue(is_object($connection->db)); + $this->assertTrue(is_object($connection->mongoClient)); $connection->close(); $this->assertFalse($connection->isActive); - $this->assertEquals(null, $connection->client); - $this->assertEquals(null, $connection->db); + $this->assertEquals(null, $connection->mongoClient); $connection = new Connection; $connection->dsn = 'unknown::memory:'; @@ -44,10 +44,52 @@ class ConnectionTest extends MongoTestCase $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'] . '/' . $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(false); + $connection = $this->getConnection(); + $collection = $connection->getCollection('customer'); - $this->assertTrue($collection instanceof \MongoCollection); + $this->assertTrue($collection instanceof Collection); + + $collection2 = $connection->getCollection('customer'); + $this->assertTrue($collection === $collection2); + + $collection2 = $connection->getCollection('customer', 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 0000000..ec0bf2d --- /dev/null +++ b/tests/unit/extensions/mongo/DatabaseTest.php @@ -0,0 +1,34 @@ +dropCollection('customer'); + 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); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index a4f20ac..61f35c8 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -14,7 +14,7 @@ class MongoTestCase extends TestCase */ protected $mongoConfig = [ 'dsn' => 'mongodb://localhost:27017', - 'dbName' => 'yii2test', + 'defaultDatabaseName' => 'yii2test', ]; /** * @var Connection Mongo connection instance. @@ -76,8 +76,8 @@ class MongoTestCase extends TestCase } $db = new Connection; $db->dsn = $this->mongoConfig['dsn']; - if (isset($this->mongoConfig['dbName'])) { - $db->dbName = $this->mongoConfig['dbName']; + if (isset($this->mongoConfig['defaultDatabaseName'])) { + $db->defaultDatabaseName = $this->mongoConfig['defaultDatabaseName']; } if (isset($this->mongoConfig['options'])) { $db->options = $this->mongoConfig['options']; From 38df36840f8b81b79ae572ccb49a303680bddf56 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Tue, 26 Nov 2013 21:19:16 +0200 Subject: [PATCH 06/49] Mongo test config updated. --- tests/unit/data/config.php | 2 +- tests/unit/extensions/mongo/MongoTestCase.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index 1cd036d..d12df7f 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -50,7 +50,7 @@ return [ ], ], 'mongo' => [ - 'dsn' => 'mongodb://localhost:27017', + 'dsn' => 'mongodb://travis:test@localhost:27017', 'dbName' => 'yii2test', 'options' => [], ] diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index 61f35c8..439afb2 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -15,6 +15,7 @@ class MongoTestCase extends TestCase protected $mongoConfig = [ 'dsn' => 'mongodb://localhost:27017', 'defaultDatabaseName' => 'yii2test', + 'options' => [], ]; /** * @var Connection Mongo connection instance. From 6eeeb6d169cfebe59e8a052ada7fe245ea58e17e Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 27 Nov 2013 11:54:09 +0200 Subject: [PATCH 07/49] Mongo test config fixed. --- tests/unit/data/config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/data/config.php b/tests/unit/data/config.php index d12df7f..e8deebb 100644 --- a/tests/unit/data/config.php +++ b/tests/unit/data/config.php @@ -51,7 +51,7 @@ return [ ], 'mongo' => [ 'dsn' => 'mongodb://travis:test@localhost:27017', - 'dbName' => 'yii2test', + 'defaultDatabaseName' => 'yii2test', 'options' => [], ] ]; From ec2df146a8500a9dfecf85bba62807db3dc142aa Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 27 Nov 2013 12:00:32 +0200 Subject: [PATCH 08/49] Mongo Connection updated allowing to fetch default database name from options. --- extensions/mongo/Connection.php | 4 +++- tests/unit/extensions/mongo/ConnectionTest.php | 6 ++++++ tests/unit/extensions/mongo/MongoTestCase.php | 4 +--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/extensions/mongo/Connection.php b/extensions/mongo/Connection.php index ce99ee4..725c757 100644 --- a/extensions/mongo/Connection.php +++ b/extensions/mongo/Connection.php @@ -85,7 +85,9 @@ class Connection extends Component protected function fetchDefaultDatabaseName() { if ($this->defaultDatabaseName === null) { - if (preg_match('/^mongodb:\\/\\/.+\\/(.+)$/s', $this->dsn, $matches)) { + 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."); diff --git a/tests/unit/extensions/mongo/ConnectionTest.php b/tests/unit/extensions/mongo/ConnectionTest.php index 3b8a1a2..b3252b9 100644 --- a/tests/unit/extensions/mongo/ConnectionTest.php +++ b/tests/unit/extensions/mongo/ConnectionTest.php @@ -71,6 +71,12 @@ class ConnectionTest extends MongoTestCase $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!'); diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index 439afb2..0bdcd74 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -77,9 +77,7 @@ class MongoTestCase extends TestCase } $db = new Connection; $db->dsn = $this->mongoConfig['dsn']; - if (isset($this->mongoConfig['defaultDatabaseName'])) { - $db->defaultDatabaseName = $this->mongoConfig['defaultDatabaseName']; - } + $db->defaultDatabaseName = $this->mongoConfig['defaultDatabaseName']; if (isset($this->mongoConfig['options'])) { $db->options = $this->mongoConfig['options']; } From 4f5f5bb691552b26b3058a20d9f13860e2904b99 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 27 Nov 2013 14:29:05 +0200 Subject: [PATCH 09/49] Mongo Query implemented as draft. --- extensions/mongo/Query.php | 269 +++++++++++------------------- tests/unit/extensions/mongo/QueryTest.php | 65 ++++++++ 2 files changed, 163 insertions(+), 171 deletions(-) create mode 100644 tests/unit/extensions/mongo/QueryTest.php diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index e2cc622..1bdaf40 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -8,8 +8,9 @@ namespace yii\mongo; use yii\base\Component; -use yii\db\Connection; use yii\db\QueryInterface; +use yii\db\QueryTrait; +use Yii; /** * Class Query @@ -19,219 +20,145 @@ use yii\db\QueryInterface; */ class Query extends Component implements QueryInterface { + use QueryTrait; /** - * Executes the query and returns all results as an array. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` 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) - { - // TODO: Implement all() method. - } - - /** - * Executes the query and returns a single row of result. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` 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. + * @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 function one($db = null) - { - // TODO: Implement one() method. - } - - /** - * Returns the number of records. - * @param string $q the COUNT expression. Defaults to '*'. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return integer number of records - */ - public function count($q = '*', $db = null) - { - // TODO: Implement count() method. - } - + public $select = []; /** - * Returns a value indicating whether the query result contains any row of data. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. - * @return boolean whether the query result contains any row of data. + * @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 function exists($db = null) - { - // TODO: Implement exists() method. - } + public $from; /** - * Sets the [[indexBy]] property. - * @param string|callable $column the name of the column by which the query results should be indexed by. - * This can also be a callable (e.g. anonymous function) that returns the index value based on the given - * row data. The signature of the callable should be: - * - * ~~~ - * function ($row) - * { - * // return the index value corresponding to $row - * } - * ~~~ - * - * @return static the query object itself + * Returns the Mongo collection for this query. + * @param Connection $db Mongo connection. + * @return Collection collection instance. */ - public function indexBy($column) + public function getCollection($db = null) { - // TODO: Implement indexBy() method. + if ($db === null) { + $db = Yii::$app->getComponent('mongo'); + } + return $db->getCollection($this->from); } /** - * Sets the WHERE part of the query. - * - * The method requires a $condition parameter. - * - * The $condition parameter should be an array in one of the following two formats: - * - * - hash format: `['column1' => value1, 'column2' => value2, ...]` - * - operator format: `[operator, operand1, operand2, ...]` - * - * A condition in hash format represents the following SQL expression in general: - * `column1=value1 AND column2=value2 AND ...`. In case when a value is an array, - * an `IN` expression will be generated. And if a value is null, `IS NULL` will be used - * in the generated expression. Below are some examples: - * - * - `['type' => 1, 'status' => 2]` generates `(type = 1) AND (status = 2)`. - * - `['id' => [1, 2, 3], 'status' => 2]` generates `(id IN (1, 2, 3)) AND (status = 2)`. - * - `['status' => null] generates `status IS NULL`. - * - * A condition in operator format generates the SQL expression according to the specified operator, which - * can be one of the followings: - * - * - `and`: the operands should be concatenated together using `AND`. For example, - * `['and', 'id=1', 'id=2']` will generate `id=1 AND id=2`. If an operand is an array, - * it will be converted into a string using the rules described here. For example, - * `['and', 'type=1', ['or', 'id=1', 'id=2']]` will generate `type=1 AND (id=1 OR id=2)`. - * The method will NOT do any quoting or escaping. - * - * - `or`: similar to the `and` operator except that the operands are concatenated using `OR`. - * - * - `between`: operand 1 should be the column name, and operand 2 and 3 should be the - * starting and ending values of the range that the column is in. - * For example, `['between', 'id', 1, 10]` will generate `id BETWEEN 1 AND 10`. - * - * - `not between`: similar to `between` except the `BETWEEN` is replaced with `NOT BETWEEN` - * in the generated condition. - * - * - `in`: operand 1 should be a column or DB expression, and operand 2 be an array representing - * the range of the values that the column or DB expression should be in. For example, - * `['in', 'id', [1, 2, 3]]` will generate `id IN (1, 2, 3)`. - * The method will properly quote the column name and escape values in the range. - * - * - `not in`: similar to the `in` operator except that `IN` is replaced with `NOT IN` in the generated condition. - * - * - `like`: operand 1 should be a column or DB expression, and operand 2 be a string or an array representing - * the values that the column or DB expression should be like. - * For example, `['like', 'name', '%tester%']` will generate `name LIKE '%tester%'`. - * When the value range is given as an array, multiple `LIKE` predicates will be generated and concatenated - * using `AND`. For example, `['like', 'name', ['%test%', '%sample%']]` will generate - * `name LIKE '%test%' AND name LIKE '%sample%'`. - * The method will properly quote the column name and escape values in the range. - * - * - `or like`: similar to the `like` operator except that `OR` is used to concatenate the `LIKE` - * predicates when operand 2 is an array. - * - * - `not like`: similar to the `like` operator except that `LIKE` is replaced with `NOT LIKE` - * in the generated condition. - * - * - `or not like`: similar to the `not like` operator except that `OR` is used to concatenate - * the `NOT LIKE` predicates. - * - * @param array $condition the conditions that should be put in the WHERE part. - * @return static the query object itself - * @see andWhere() - * @see orWhere() + * 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 where($condition) + public function select(array $fields) { - // TODO: Implement where() method. + $this->select = $fields; + return $this; } /** - * Adds an additional WHERE condition to the existing one. - * The new condition and the existing one will be joined using the 'AND' operator. - * @param string|array $condition the new WHERE condition. Please refer to [[where()]] - * on how to specify this parameter. - * @return static the query object itself - * @see where() - * @see orWhere() + * 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 andWhere($condition) + public function from($collection) { - // TODO: Implement andWhere() method. + $this->from = $collection; + return $this; } /** - * Adds an additional WHERE condition to the existing one. - * The new condition and the existing one will be joined using the 'OR' operator. - * @param string|array $condition the new WHERE condition. Please refer to [[where()]] - * on how to specify this parameter. - * @return static the query object itself - * @see where() - * @see andWhere() + * @param Connection $db the database connection used to execute the query. + * @return \MongoCursor mongo cursor instance. */ - public function orWhere($condition) + protected function buildCursor($db = null) { - // TODO: Implement orWhere() method. + // TODO: compose query + $query = []; + $selectFields = []; + if (!empty($this->select)) { + foreach ($this->select as $fieldName) { + $selectFields[$fieldName] = true; + } + } + $cursor = $this->getCollection($db)->find($query, $selectFields); + if (!empty($this->orderBy)) { + $sort = []; + foreach ($this->orderBy as $fieldName => $sortOrder) { + $sort[$fieldName] = $sortOrder === SORT_DESC ? -1 : 1; + } + $cursor->sort($this->orderBy); + } + $cursor->limit($this->limit); + $cursor->skip($this->offset); + return $cursor; } /** - * Sets the ORDER BY part of the query. - * @param string|array $columns the columns (and the directions) to be ordered by. - * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array - * (e.g. `['id' => SORT_ASC, 'name' => SORT_DESC]`). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @return static the query object itself - * @see addOrderBy() + * Executes the query and returns all results as an array. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return array the query results. If the query results in nothing, an empty array will be returned. */ - public function orderBy($columns) + public function all($db = null) { - // TODO: Implement orderBy() method. + $cursor = $this->buildCursor($db); + if ($this->indexBy === null) { + return iterator_to_array($cursor); + } else { + $result = []; + foreach ($cursor as $row) { + if (is_string($this->indexBy)) { + $key = $row[$this->indexBy]; + } else { + $key = call_user_func($this->indexBy, $row); + } + $result[$key] = $row; + } + } + return $result; } /** - * Adds additional ORDER BY columns to the query. - * @param string|array $columns the columns (and the directions) to be ordered by. - * Columns can be specified in either a string (e.g. "id ASC, name DESC") or an array - * (e.g. `['id' => SORT_ASC, 'name' => SORT_DESC]`). - * The method will automatically quote the column names unless a column contains some parenthesis - * (which means the column contains a DB expression). - * @return static the query object itself - * @see orderBy() + * Executes the query and returns a single row of result. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` 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 addOrderBy($columns) + public function one($db = null) { - // TODO: Implement addOrderBy() method. + $cursor = $this->buildCursor($db); + return $cursor->getNext(); } /** - * Sets the LIMIT part of the query. - * @param integer $limit the limit. Use null or negative value to disable limit. - * @return static the query object itself + * Returns the number of records. + * @param string $q the COUNT expression. Defaults to '*'. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return integer number of records */ - public function limit($limit) + public function count($q = '*', $db = null) { - // TODO: Implement limit() method. + $cursor = $this->buildCursor($db); + return $cursor->count(); } /** - * Sets the OFFSET part of the query. - * @param integer $offset the offset. Use null or negative value to disable offset. - * @return static the query object itself + * Returns a value indicating whether the query result contains any row of data. + * @param Connection $db the database connection used to execute the query. + * If this parameter is not given, the `db` application component will be used. + * @return boolean whether the query result contains any row of data. */ - public function offset($offset) + public function exists($db = null) { - // TODO: Implement offset() method. + return $this->one($db) !== null; } } \ 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 0000000..0fce3fa --- /dev/null +++ b/tests/unit/extensions/mongo/QueryTest.php @@ -0,0 +1,65 @@ +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 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 From d42a942aedc1cf0d31822c41768b01cbd6e7907f Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 27 Nov 2013 16:40:29 +0200 Subject: [PATCH 10/49] Mongo Command 'update' and 'insertBatch' methods added. --- extensions/mongo/Collection.php | 45 ++++++++++++++++++++++ extensions/mongo/Query.php | 2 +- tests/unit/extensions/mongo/CollectionTest.php | 52 +++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index fc15b89..fd4f332 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -79,6 +79,51 @@ class Collection extends Object } /** + * 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 = 'Inserting batch data into ' . $this->mongoCollection->getName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $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. + * @param array $criteria 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 boolean whether operation was successful. + * @throws Exception on failure. + */ + public function update($criteria, $newData, $options = []) + { + $token = 'Updating data in ' . $this->mongoCollection->getName(); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $this->mongoCollection->update($criteria, $newData, $options); + Yii::endProfile($token, __METHOD__); + 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. diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index 1bdaf40..7d9920d 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -91,7 +91,7 @@ class Query extends Component implements QueryInterface if (!empty($this->orderBy)) { $sort = []; foreach ($this->orderBy as $fieldName => $sortOrder) { - $sort[$fieldName] = $sortOrder === SORT_DESC ? -1 : 1; + $sort[$fieldName] = $sortOrder === SORT_DESC ? \MongoCollection::DESCENDING : \MongoCollection::ASCENDING; } $cursor->sort($this->orderBy); } diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index 5dd7d59..053ee7e 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -15,6 +15,13 @@ class CollectionTest extends MongoTestCase // Tests : + public function testFind() + { + $collection = $this->getConnection()->getCollection('customer'); + $cursor = $collection->find(); + $this->assertTrue($cursor instanceof \MongoCursor); + } + public function testInsert() { $collection = $this->getConnection()->getCollection('customer'); @@ -44,6 +51,28 @@ class CollectionTest extends MongoTestCase $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'); @@ -59,7 +88,7 @@ class CollectionTest extends MongoTestCase /** * @depends testSave */ - public function testUpdate() + public function testUpdateBySave() { $collection = $this->getConnection()->getCollection('customer'); $data = [ @@ -93,4 +122,25 @@ class CollectionTest extends MongoTestCase $rows = $collection->findAll(); $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' + ]; + $collection->update(['_id' => $id], $newData); + + list($row) = $collection->findAll(); + $this->assertEquals($newData['name'], $row['name']); + } } \ No newline at end of file From 9da7a80f0f8143ccd03f89ca8443055ac6bd8148 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 28 Nov 2013 17:21:47 +0200 Subject: [PATCH 11/49] Mongo query condition composition composed. --- extensions/mongo/Collection.php | 89 +++++++++++++++++++--- extensions/mongo/Query.php | 64 +++++++++++++--- tests/unit/extensions/mongo/QueryRunTest.php | 110 +++++++++++++++++++++++++++ tests/unit/extensions/mongo/QueryTest.php | 82 ++++++++++++++++++++ 4 files changed, 324 insertions(+), 21 deletions(-) create mode 100644 tests/unit/extensions/mongo/QueryRunTest.php diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index fd4f332..7292348 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -7,6 +7,7 @@ namespace yii\mongo; +use yii\base\InvalidParamException; use yii\base\Object; use Yii; @@ -32,23 +33,23 @@ class Collection extends Object } /** - * @param array $query + * @param array $condition * @param array $fields * @return \MongoCursor */ - public function find($query = [], $fields = []) + public function find($condition = [], $fields = []) { - return $this->mongoCollection->find($query, $fields); + return $this->mongoCollection->find($this->buildCondition($condition), $fields); } /** - * @param array $query + * @param array $condition * @param array $fields * @return array */ - public function findAll($query = [], $fields = []) + public function findAll($condition = [], $fields = []) { - $cursor = $this->find($query, $fields); + $cursor = $this->find($condition, $fields); $result = []; foreach ($cursor as $data) { $result[] = $data; @@ -102,19 +103,19 @@ class Collection extends Object /** * Updates the rows, which matches given criteria by given data. - * @param array $criteria description of the objects to update. + * @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 boolean whether operation was successful. * @throws Exception on failure. */ - public function update($criteria, $newData, $options = []) + public function update($condition, $newData, $options = []) { $token = 'Updating data in ' . $this->mongoCollection->getName(); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); - $this->mongoCollection->update($criteria, $newData, $options); + $this->mongoCollection->update($this->buildCondition($condition), $newData, $options); Yii::endProfile($token, __METHOD__); return true; } catch (\Exception $e) { @@ -147,18 +148,18 @@ class Collection extends Object /** * Removes data from the collection. - * @param array $criteria description of records to remove. + * @param array $condition description of records to remove. * @param array $options list of options in format: optionName => optionValue. * @return boolean whether operation was successful. * @throws Exception on failure. */ - public function remove($criteria = [], $options = []) + public function remove($condition = [], $options = []) { $token = 'Removing data from ' . $this->mongoCollection->getName(); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); - $this->tryResultError($this->mongoCollection->remove($criteria, $options)); + $this->tryResultError($this->mongoCollection->remove($this->buildCondition($condition), $options)); Yii::endProfile($token, __METHOD__); return true; } catch (\Exception $e) { @@ -182,4 +183,68 @@ class Collection extends Object throw new Exception('Unknown error, use "w=1" option to enable error tracking'); } } + + /** + * 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' + ]; + $key = strtolower($key); + if (array_key_exists($key, $map)) { + return $map[$key]; + } else { + return $key; + } + } + + /** + * Builds up Mongo condition from user friendly condition. + * @param array $condition raw condition. + * @return array normalized Mongo condition. + * @throws \yii\base\InvalidParamException on invalid condition given. + */ + public function buildCondition($condition) + { + if (!is_array($condition)) { + throw new InvalidParamException('Condition should be an array.'); + } + $result = []; + foreach ($condition as $key => $value) { + if (is_array($value)) { + $actualValue = $this->buildCondition($value); + } else { + $actualValue = $value; + } + if (is_numeric($key)) { + $result[] = $actualValue; + } else { + $result[$this->normalizeConditionKeyword($key)] = $actualValue; + } + } + return $result; + } } \ No newline at end of file diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index 7d9920d..75bea4e 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -74,26 +74,72 @@ class Query extends Component implements QueryInterface } /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'AND' operator. + * @param array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @return static the query object itself + * @see where() + * @see orWhere() + */ + public function andWhere($condition) + { + if (is_array($this->where)) { + $this->where = array_merge($this->where, $condition); + } else { + $this->where = $condition; + } + return $this; + } + + /** + * Adds an additional WHERE condition to the existing one. + * The new condition and the existing one will be joined using the 'OR' operator. + * @param array $condition the new WHERE condition. Please refer to [[where()]] + * on how to specify this parameter. + * @return static the query object itself + * @see where() + * @see andWhere() + */ + public function orWhere($condition) + { + if (is_array($this->where)) { + if (array_key_exists('or', $this->where) && count($this->where) == 1) { + $this->where['or'][] = $condition; + } else { + $this->where = [ + 'or' => [$this->where, $condition] + ]; + } + } else { + $this->where = $condition; + } + return $this; + } + + /** * @param Connection $db the database connection used to execute the query. * @return \MongoCursor mongo cursor instance. */ protected function buildCursor($db = null) { - // TODO: compose query - $query = []; + $where = $this->where; + if (!is_array($where)) { + $where = []; + } $selectFields = []; if (!empty($this->select)) { foreach ($this->select as $fieldName) { $selectFields[$fieldName] = true; } } - $cursor = $this->getCollection($db)->find($query, $selectFields); + $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($this->orderBy); + $cursor->sort($sort); } $cursor->limit($this->limit); $cursor->skip($this->offset); @@ -109,17 +155,17 @@ class Query extends Component implements QueryInterface public function all($db = null) { $cursor = $this->buildCursor($db); - if ($this->indexBy === null) { - return iterator_to_array($cursor); - } else { - $result = []; - foreach ($cursor as $row) { + $result = []; + foreach ($cursor as $row) { + if ($this->indexBy !== null) { if (is_string($this->indexBy)) { $key = $row[$this->indexBy]; } else { $key = call_user_func($this->indexBy, $row); } $result[$key] = $row; + } else { + $result[] = $row; } } return $result; diff --git a/tests/unit/extensions/mongo/QueryRunTest.php b/tests/unit/extensions/mongo/QueryRunTest.php new file mode 100644 index 0000000..b36851a --- /dev/null +++ b/tests/unit/extensions/mongo/QueryRunTest.php @@ -0,0 +1,110 @@ +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, + ]; + } + $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' => [ + 'in' => ['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']); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/QueryTest.php b/tests/unit/extensions/mongo/QueryTest.php index 0fce3fa..ac14fbe 100644 --- a/tests/unit/extensions/mongo/QueryTest.php +++ b/tests/unit/extensions/mongo/QueryTest.php @@ -36,6 +36,88 @@ class QueryTest extends MongoTestCase $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( + [ + 'name' => 'name1', + 'address' => 'address1' + ], + $query->where + ); + + $query->orWhere(['name' => 'name2']); + $this->assertEquals( + [ + 'or' => [ + [ + 'name' => 'name1', + 'address' => 'address1' + ], + ['name' => 'name2'] + ] + ], + $query->where + ); + + $query->orWhere(['name' => 'name3']); + $this->assertEquals( + [ + 'or' => [ + [ + 'name' => 'name1', + 'address' => 'address1' + ], + ['name' => 'name2'], + ['name' => 'name3'] + ] + ], + $query->where + ); + + $query->andWhere(['address' => 'address2']); + $this->assertEquals( + [ + 'or' => [ + [ + 'name' => 'name1', + 'address' => 'address1' + ], + ['name' => 'name2'], + ['name' => 'name3'] + ], + 'address' => 'address2' + ], + $query->where + ); + + $query->orWhere(['name' => 'name4']); + $this->assertEquals( + [ + 'or' => [ + [ + 'or' => [ + [ + 'name' => 'name1', + 'address' => 'address1' + ], + ['name' => 'name2'], + ['name' => 'name3'] + ], + 'address' => 'address2' + ], + ['name' => 'name4'] + ], + ], + $query->where + ); + } + public function testOrder() { $query = new Query; From dc3ada65a5ccff8809b88a8fe98ea3a76fa4ba58 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 28 Nov 2013 20:36:38 +0200 Subject: [PATCH 12/49] Mongo query "IN" condition shortcut syntax added. --- extensions/mongo/Collection.php | 8 +++++++- tests/unit/extensions/mongo/QueryRunTest.php | 4 +--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 7292348..5f7246c 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -242,7 +242,13 @@ class Collection extends Object if (is_numeric($key)) { $result[] = $actualValue; } else { - $result[$this->normalizeConditionKeyword($key)] = $actualValue; + $key = $this->normalizeConditionKeyword($key); + if (strncmp('$', $key, 1) !== 0 && array_key_exists(0, $actualValue)) { + // shortcut for IN condition + $result[$key]['$in'] = $actualValue; + } else { + $result[$key] = $actualValue; + } } } return $result; diff --git a/tests/unit/extensions/mongo/QueryRunTest.php b/tests/unit/extensions/mongo/QueryRunTest.php index b36851a..f8821b6 100644 --- a/tests/unit/extensions/mongo/QueryRunTest.php +++ b/tests/unit/extensions/mongo/QueryRunTest.php @@ -75,9 +75,7 @@ class QueryRunTest extends MongoTestCase $query = new Query; $rows = $query->from('customer') ->where([ - 'name' => [ - 'in' => ['name1', 'name5'] - ] + 'name' => ['name1', 'name5'] ]) ->all($connection); $this->assertEquals(2, count($rows)); From 37664fff9bc13788c557014c8538b053dc2f1f8a Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 28 Nov 2013 21:25:17 +0200 Subject: [PATCH 13/49] Mongo Active Record created as draft. --- extensions/mongo/ActiveQuery.php | 22 + extensions/mongo/ActiveRecord.php | 1165 +++++++++++++++++++++++++++++++++++ extensions/mongo/ActiveRelation.php | 22 + extensions/mongo/Collection.php | 22 +- extensions/mongo/QueryBuilder.php | 37 -- 5 files changed, 1225 insertions(+), 43 deletions(-) create mode 100644 extensions/mongo/ActiveQuery.php create mode 100644 extensions/mongo/ActiveRecord.php create mode 100644 extensions/mongo/ActiveRelation.php delete mode 100644 extensions/mongo/QueryBuilder.php diff --git a/extensions/mongo/ActiveQuery.php b/extensions/mongo/ActiveQuery.php new file mode 100644 index 0000000..f30eff4 --- /dev/null +++ b/extensions/mongo/ActiveQuery.php @@ -0,0 +1,22 @@ + + * @since 2.0 + */ +class ActiveQuery extends Query implements ActiveQueryInterface +{ + use ActiveQueryTrait; +} \ No newline at end of file diff --git a/extensions/mongo/ActiveRecord.php b/extensions/mongo/ActiveRecord.php new file mode 100644 index 0000000..beab2ef --- /dev/null +++ b/extensions/mongo/ActiveRecord.php @@ -0,0 +1,1165 @@ + + * @since 2.0 + */ +abstract class ActiveRecord extends Model +{ + /** + * @event Event an event that is triggered when the record is initialized via [[init()]]. + */ + const EVENT_INIT = 'init'; + /** + * @event Event an event that is triggered after the record is created and populated with query result. + */ + const EVENT_AFTER_FIND = 'afterFind'; + /** + * @event ModelEvent an event that is triggered before inserting a record. + * You may set [[ModelEvent::isValid]] to be false to stop the insertion. + */ + const EVENT_BEFORE_INSERT = 'beforeInsert'; + /** + * @event Event an event that is triggered after a record is inserted. + */ + const EVENT_AFTER_INSERT = 'afterInsert'; + /** + * @event ModelEvent an event that is triggered before updating a record. + * You may set [[ModelEvent::isValid]] to be false to stop the update. + */ + const EVENT_BEFORE_UPDATE = 'beforeUpdate'; + /** + * @event Event an event that is triggered after a record is updated. + */ + const EVENT_AFTER_UPDATE = 'afterUpdate'; + /** + * @event ModelEvent an event that is triggered before deleting a record. + * You may set [[ModelEvent::isValid]] to be false to stop the deletion. + */ + const EVENT_BEFORE_DELETE = 'beforeDelete'; + /** + * @event Event an event that is triggered after a record is deleted. + */ + const EVENT_AFTER_DELETE = 'afterDelete'; + + /** + * @var array attribute values indexed by attribute names + */ + private $_attributes = []; + /** + * @var array old attribute values indexed by attribute names. + */ + private $_oldAttributes; + /** + * @var array related models indexed by the relation names + */ + private $_related = []; + + /** + * Returns the database connection used by this AR class. + * By default, the "db" application component is used as the database 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'); + } + + /** + * Creates an [[ActiveQuery]] instance for query purpose. + * + * @param mixed $q the query parameter. This can be one of the followings: + * + * - a scalar value (integer or string): query by a single primary key value and return the + * corresponding record. + * - an array of name-value pairs: query by a set of column values and return a single record matching all of them. + * - null: return a new [[ActiveQuery]] object for further query purpose. + * + * @return ActiveQuery|ActiveRecord|null When `$q` is null, a new [[ActiveQuery]] instance + * is returned; when `$q` is a scalar or an array, an ActiveRecord object matching it will be + * returned (null will be returned if there is no matching). + * @throws InvalidConfigException if the AR class does not have a primary key + * @see createQuery() + */ + public static function find($q = null) + { + $query = static::createQuery(); + if (is_array($q)) { + return $query->where($q)->one(); + } elseif ($q !== null) { + // query by primary key + $primaryKey = static::primaryKey(); + if (isset($primaryKey[0])) { + return $query->where([$primaryKey[0] => $q])->one(); + } else { + throw new InvalidConfigException(get_called_class() . ' must have a primary key.'); + } + } + return $query; + } + + /** + * Updates the whole table using the provided attribute values and conditions. + * For example, to change the status to be 1 for all customers whose status is 2: + * + * ~~~ + * Customer::updateAll(['status' => 1], ['status' = 2]); + * ~~~ + * + * @param array $attributes attribute values (name-value pairs) to be saved into the table + * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $options list of options in format: optionName => optionValue. + * @return integer the number of rows updated. + */ + public static function updateAll($attributes, $condition = [], $options = []) + { + $options['w'] = 1; + if (!array_key_exists('multiple', $options)) { + $options['multiple'] = true; + } + return static::getCollection()->update($condition, $attributes, $options); + } + + /** + * Updates the whole table using the provided counter changes and conditions. + * For example, to increment all customers' age by 1, + * + * ~~~ + * Customer::updateAllCounters(['age' => 1]); + * ~~~ + * + * @param array $counters the counters to be updated (attribute name => increment value). + * Use negative values if you want to decrement the counters. + * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $options list of options in format: optionName => optionValue. + * @return integer the number of rows updated. + */ + public static function updateAllCounters($counters, $condition = [], $options = []) + { + $options['w'] = 1; + if (!array_key_exists('multiple', $options)) { + $options['multiple'] = true; + } + $data = []; + foreach ($counters as $name => $value) { + $data[$name]['$inc'] = $value; + } + return static::getCollection()->update($condition, $data, $options); + } + + /** + * Deletes rows in the table using the provided conditions. + * WARNING: If you do not specify any condition, this method will delete ALL rows in the table. + * + * For example, to delete all customers whose status is 3: + * + * ~~~ + * Customer::deleteAll('status = 3'); + * ~~~ + * + * @param array $condition the conditions that will be put in the WHERE part of the DELETE SQL. + * Please refer to [[Query::where()]] on how to specify this parameter. + * @param array $options list of options in format: optionName => optionValue. + * @return integer the number of rows updated. + */ + 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 SELECT query. + * You may override this method to return a customized query (e.g. `CustomerQuery` specified + * written for querying `Customer` purpose.) + * @return ActiveQuery the newly created [[ActiveQuery]] instance. + */ + public static function createQuery() + { + return new ActiveQuery(['modelClass' => get_called_class()]); + } + + /** + * Declares the name of the 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 the table 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 table with single primary key. + * + * @return string[] the primary keys of the associated database table. + */ + public static function primaryKey() + { + return ['_id']; + } + + /** + * Returns the name of the column that stores the lock version for implementing optimistic locking. + * + * Optimistic locking allows multiple users to access the same record for edits and avoids + * potential conflicts. In case when a user attempts to save the record upon some staled data + * (because another user has modified the data), a [[StaleObjectException]] exception will be thrown, + * and the update or deletion is skipped. + * + * Optimistic locking is only supported by [[update()]] and [[delete()]]. + * + * To use Optimistic locking: + * + * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`. + * Override this method to return the name of this column. + * 2. In the Web form that collects the user input, add a hidden field that stores + * the lock version of the recording being updated. + * 3. In the controller action that does the data updating, try to catch the [[StaleObjectException]] + * and implement necessary business logic (e.g. merging the changes, prompting stated data) + * to resolve the conflict. + * + * @return string the column name that stores the lock version of a table row. + * If null is returned (default implemented), optimistic locking will not be supported. + */ + public function optimisticLock() + { + return null; + } + + /** + * PHP getter magic method. + * This method is overridden so that attributes and related objects can be accessed like properties. + * @param string $name property name + * @return mixed property value + * @see getAttribute() + */ + public function __get($name) + { + if (isset($this->_attributes[$name]) || array_key_exists($name, $this->_attributes)) { + return $this->_attributes[$name]; + } elseif ($this->hasAttribute($name)) { + return null; + } else { + if (isset($this->_related[$name]) || array_key_exists($name, $this->_related)) { + return $this->_related[$name]; + } + $value = parent::__get($name); + if ($value instanceof ActiveRelationInterface) { + return $this->_related[$name] = $value->multiple ? $value->all() : $value->one(); + } else { + return $value; + } + } + } + + /** + * PHP setter magic method. + * This method is overridden so that AR attributes can be accessed like properties. + * @param string $name property name + * @param mixed $value property value + */ + public function __set($name, $value) + { + if ($this->hasAttribute($name)) { + $this->_attributes[$name] = $value; + } else { + parent::__set($name, $value); + } + } + + /** + * Checks if a property value is null. + * This method overrides the parent implementation by checking if the named attribute is null or not. + * @param string $name the property name or the event name + * @return boolean whether the property value is null + */ + public function __isset($name) + { + try { + return $this->__get($name) !== null; + } catch (\Exception $e) { + return false; + } + } + + /** + * Sets a component property to be null. + * This method overrides the parent implementation by clearing + * the specified attribute value. + * @param string $name the property name or the event name + */ + public function __unset($name) + { + if ($this->hasAttribute($name)) { + unset($this->_attributes[$name]); + } else { + if (isset($this->_related[$name])) { + unset($this->_related[$name]); + } else { + parent::__unset($name); + } + } + } + + /** + * Declares a `has-one` relation. + * The declaration is returned in terms of an [[ActiveRelation]] instance + * through which the related record can be queried and retrieved back. + * + * A `has-one` relation means that there is at most one related record matching + * the criteria set by this relation, e.g., a customer has one country. + * + * For example, to declare the `country` relation for `Customer` class, we can write + * the following code in the `Customer` class: + * + * ~~~ + * public function getCountry() + * { + * return $this->hasOne(Country::className(), ['id' => 'country_id']); + * } + * ~~~ + * + * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name + * in the related class `Country`, while the 'country_id' value refers to an attribute name + * in the current AR class. + * + * Call methods declared in [[ActiveRelation]] to further customize the relation. + * + * @param string $class the class name of the related record + * @param array $link the primary-foreign key constraint. The keys of the array refer to + * the attributes of the record associated with the `$class` model, while the values of the + * array refer to the corresponding attributes in **this** AR class. + * @return ActiveRelationInterface the relation object. + */ + public function hasOne($class, $link) + { + /** @var ActiveRecord $class */ + return $class::createActiveRelation([ + 'modelClass' => $class, + 'primaryModel' => $this, + 'link' => $link, + 'multiple' => false, + ]); + } + + /** + * Declares a `has-many` relation. + * The declaration is returned in terms of an [[ActiveRelation]] instance + * through which the related record can be queried and retrieved back. + * + * A `has-many` relation means that there are multiple related records matching + * the criteria set by this relation, e.g., a customer has many orders. + * + * For example, to declare the `orders` relation for `Customer` class, we can write + * the following code in the `Customer` class: + * + * ~~~ + * public function getOrders() + * { + * return $this->hasMany(Order::className(), ['customer_id' => 'id']); + * } + * ~~~ + * + * Note that in the above, the 'customer_id' key in the `$link` parameter refers to + * an attribute name in the related class `Order`, while the 'id' value refers to + * an attribute name in the current AR class. + * + * @param string $class the class name of the related record + * @param array $link the primary-foreign key constraint. The keys of the array refer to + * the attributes of the record associated with the `$class` model, while the values of the + * array refer to the corresponding attributes in **this** AR class. + * @return ActiveRelationInterface the relation object. + */ + public function hasMany($class, $link) + { + /** @var ActiveRecord $class */ + return $class::createActiveRelation([ + 'modelClass' => $class, + 'primaryModel' => $this, + 'link' => $link, + 'multiple' => true, + ]); + } + + /** + * 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); + } + + /** + * Populates the named relation with the related records. + * Note that this method does not check if the relation exists or not. + * @param string $name the relation name (case-sensitive) + * @param ActiveRecord|array|null the related records to be populated into the relation. + */ + public function populateRelation($name, $records) + { + $this->_related[$name] = $records; + } + + /** + * Check whether the named relation has been populated with records. + * @param string $name the relation name (case-sensitive) + * @return bool whether relation has been populated with records. + */ + public function isRelationPopulated($name) + { + return array_key_exists($name, $this->_related); + } + + /** + * Returns all populated relations. + * @return array an array of relation data indexed by relation names. + */ + public function getPopulatedRelations() + { + return $this->_related; + } + + /** + * Returns the list of all attribute names of the model. + * The default implementation will return all column names of the table associated with this AR class. + * @return array list of attribute names. + */ + public function attributes() + { + // TODO: declare attributes + return []; + } + + /** + * Returns a value indicating whether the model has an attribute with the specified name. + * @param string $name the name of the attribute + * @return boolean whether the model has an attribute with the specified name. + */ + public function hasAttribute($name) + { + return isset($this->_attributes[$name]) || in_array($name, $this->attributes()); + } + + /** + * Returns the named attribute value. + * If this record is the result of a query and the attribute is not loaded, + * null will be returned. + * @param string $name the attribute name + * @return mixed the attribute value. Null if the attribute is not set or does not exist. + * @see hasAttribute() + */ + public function getAttribute($name) + { + return isset($this->_attributes[$name]) ? $this->_attributes[$name] : null; + } + + /** + * Sets the named attribute value. + * @param string $name the attribute name + * @param mixed $value the attribute value. + * @throws InvalidParamException if the named attribute does not exist. + * @see hasAttribute() + */ + public function setAttribute($name, $value) + { + if ($this->hasAttribute($name)) { + $this->_attributes[$name] = $value; + } else { + throw new InvalidParamException(get_class($this) . ' has no attribute named "' . $name . '".'); + } + } + + /** + * Returns the old attribute values. + * @return array the old attribute values (name-value pairs) + */ + public function getOldAttributes() + { + return $this->_oldAttributes === null ? [] : $this->_oldAttributes; + } + + /** + * Sets the old attribute values. + * All existing old attribute values will be discarded. + * @param array $values old attribute values to be set. + */ + public function setOldAttributes($values) + { + $this->_oldAttributes = $values; + } + + /** + * Returns the old value of the named attribute. + * If this record is the result of a query and the attribute is not loaded, + * null will be returned. + * @param string $name the attribute name + * @return mixed the old attribute value. Null if the attribute is not loaded before + * or does not exist. + * @see hasAttribute() + */ + public function getOldAttribute($name) + { + return isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; + } + + /** + * Sets the old value of the named attribute. + * @param string $name the attribute name + * @param mixed $value the old attribute value. + * @throws InvalidParamException if the named attribute does not exist. + * @see hasAttribute() + */ + public function setOldAttribute($name, $value) + { + if (isset($this->_oldAttributes[$name]) || $this->hasAttribute($name)) { + $this->_oldAttributes[$name] = $value; + } else { + throw new InvalidParamException(get_class($this) . ' has no attribute named "' . $name . '".'); + } + } + + /** + * Returns a value indicating whether the named attribute has been changed. + * @param string $name the name of the attribute + * @return boolean whether the attribute has been changed + */ + public function isAttributeChanged($name) + { + if (isset($this->_attributes[$name], $this->_oldAttributes[$name])) { + return $this->_attributes[$name] !== $this->_oldAttributes[$name]; + } else { + return isset($this->_attributes[$name]) || isset($this->_oldAttributes[$name]); + } + } + + /** + * Returns the attribute values that have been modified since they are loaded or saved most recently. + * @param string[]|null $names the names of the attributes whose values may be returned if they are + * changed recently. If null, [[attributes()]] will be used. + * @return array the changed attribute values (name-value pairs) + */ + public function getDirtyAttributes($names = null) + { + if ($names === null) { + $names = $this->attributes(); + } + $names = array_flip($names); + $attributes = []; + if ($this->_oldAttributes === null) { + foreach ($this->_attributes as $name => $value) { + if (isset($names[$name])) { + $attributes[$name] = $value; + } + } + } else { + foreach ($this->_attributes as $name => $value) { + if (isset($names[$name]) && (!array_key_exists($name, $this->_oldAttributes) || $value !== $this->_oldAttributes[$name])) { + $attributes[$name] = $value; + } + } + } + return $attributes; + } + + /** + * Saves the current record. + * + * This method will call [[insert()]] when [[isNewRecord]] is true, or [[update()]] + * when [[isNewRecord]] is false. + * + * For example, to save a customer record: + * + * ~~~ + * $customer = new Customer; // or $customer = Customer::find($id); + * $customer->name = $name; + * $customer->email = $email; + * $customer->save(); + * ~~~ + * + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be saved to database. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from DB will be saved. + * @return boolean whether the saving succeeds + */ + public function save($runValidation = true, $attributes = null) + { + if ($this->getIsNewRecord()) { + return $this->insert($runValidation, $attributes); + } else { + return $this->update($runValidation, $attributes) !== false; + } + } + + /** + * Inserts a row into the associated database table using the attribute values of this record. + * + * This method performs the following steps in order: + * + * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation + * fails, it will skip the rest of the steps; + * 2. call [[afterValidate()]] when `$runValidation` is true. + * 3. call [[beforeSave()]]. If the method returns false, it will skip the + * rest of the steps; + * 4. insert the record into database. If this fails, it will skip the rest of the steps; + * 5. call [[afterSave()]]; + * + * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], + * [[EVENT_BEFORE_INSERT]], [[EVENT_AFTER_INSERT]] and [[EVENT_AFTER_VALIDATE]] + * will be raised by the corresponding methods. + * + * Only the [[dirtyAttributes|changed attribute values]] will be inserted into database. + * + * If the table's primary key is auto-incremental and is null during insertion, + * it will be populated with the actual value after insertion. + * + * For example, to insert a customer record: + * + * ~~~ + * $customer = new Customer; + * $customer->name = $name; + * $customer->email = $email; + * $customer->insert(); + * ~~~ + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be inserted into the database. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from DB will be saved. + * @return boolean whether the attributes are valid and the record is inserted successfully. + * @throws \Exception in case insert failed. + */ + public function insert($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + $result = $this->insertInternal($attributes); + return $result; + } + + /** + * @see ActiveRecord::insert() + */ + private function insertInternal($attributes = null) + { + if (!$this->beforeSave(true)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + foreach ($this->primaryKey() as $key) { + $values[$key] = isset($this->_attributes[$key]) ? $this->_attributes[$key] : null; + } + } + $collection = static::getCollection(); + $newId = $collection->insert($values); + $this->setAttribute('_id', $newId); + foreach ($values as $name => $value) { + $this->_oldAttributes[$name] = $value; + } + $this->afterSave(true); + return true; + } + + /** + * Saves the changes to this active record into the associated database table. + * + * This method performs the following steps in order: + * + * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation + * fails, it will skip the rest of the steps; + * 2. call [[afterValidate()]] when `$runValidation` is true. + * 3. call [[beforeSave()]]. If the method returns false, it will skip the + * rest of the steps; + * 4. save the record into database. If this fails, it will skip the rest of the steps; + * 5. call [[afterSave()]]; + * + * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], + * [[EVENT_BEFORE_UPDATE]], [[EVENT_AFTER_UPDATE]] and [[EVENT_AFTER_VALIDATE]] + * will be raised by the corresponding methods. + * + * Only the [[changedAttributes|changed attribute values]] will be saved into database. + * + * For example, to update a customer record: + * + * ~~~ + * $customer = Customer::find($id); + * $customer->name = $name; + * $customer->email = $email; + * $customer->update(); + * ~~~ + * + * Note that it is possible the update does not affect any row in the table. + * In this case, this method will return 0. For this reason, you should use the following + * code to check if update() is successful or not: + * + * ~~~ + * if ($this->update() !== false) { + * // update successful + * } else { + * // update failed + * } + * ~~~ + * + * @param boolean $runValidation whether to perform validation before saving the record. + * If the validation fails, the record will not be inserted into the database. + * @param array $attributes list of attributes that need to be saved. Defaults to null, + * meaning all attributes that are loaded from DB will be saved. + * @return integer|boolean the number of rows affected, or false if validation fails + * or [[beforeSave()]] stops the updating process. + * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data + * being updated is outdated. + * @throws \Exception in case update failed. + */ + public function update($runValidation = true, $attributes = null) + { + if ($runValidation && !$this->validate($attributes)) { + return false; + } + $result = $this->updateInternal($attributes); + return $result; + } + + /** + * @see CActiveRecord::update() + * @throws StaleObjectException + */ + private function updateInternal($attributes = null) + { + if (!$this->beforeSave(false)) { + return false; + } + $values = $this->getDirtyAttributes($attributes); + if (empty($values)) { + $this->afterSave(false); + return 0; + } + $condition = $this->getOldPrimaryKey(true); + $lock = $this->optimisticLock(); + if ($lock !== null) { + if (!isset($values[$lock])) { + $values[$lock] = $this->$lock + 1; + } + $condition[$lock] = $this->$lock; + } + // We do not check the return value of update() because it's possible + // that it doesn't change anything and thus returns 0. + $rows = static::getCollection()->update($condition, $values, ['w' => 1]); + + if ($lock !== null && !$rows) { + throw new StaleObjectException('The object being updated is outdated.'); + } + + foreach ($values as $name => $value) { + $this->_oldAttributes[$name] = $this->_attributes[$name]; + } + $this->afterSave(false); + return $rows; + } + + /** + * Updates one or several counter columns for the current AR object. + * Note that this method differs from [[updateAllCounters()]] in that it only + * saves counters for the current AR object. + * + * An example usage is as follows: + * + * ~~~ + * $post = Post::find($id); + * $post->updateCounters(['view_count' => 1]); + * ~~~ + * + * @param array $counters the counters to be updated (attribute name => increment value) + * Use negative values if you want to decrement the counters. + * @return boolean whether the saving is successful + * @see updateAllCounters() + */ + public function updateCounters($counters) + { + if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) { + foreach ($counters as $name => $value) { + $this->_attributes[$name] += $value; + $this->_oldAttributes[$name] = $this->_attributes[$name]; + } + return true; + } else { + return false; + } + } + + /** + * Deletes the table row corresponding to this active record. + * + * This method performs the following steps in order: + * + * 1. call [[beforeDelete()]]. If the method returns false, it will skip the + * rest of the steps; + * 2. delete the record from the database; + * 3. call [[afterDelete()]]. + * + * In the above step 1 and 3, events named [[EVENT_BEFORE_DELETE]] and [[EVENT_AFTER_DELETE]] + * will be raised by the corresponding methods. + * + * @return integer|boolean the number of rows deleted, or false if the deletion is unsuccessful for some reason. + * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful. + * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data + * being deleted is outdated. + * @throws \Exception in case delete failed. + */ + public function delete() + { + $result = false; + if ($this->beforeDelete()) { + // we do not check the return value of deleteAll() because it's possible + // the record is already deleted in the database and thus the method will return 0 + $condition = $this->getOldPrimaryKey(true); + $lock = $this->optimisticLock(); + if ($lock !== null) { + $condition[$lock] = $this->$lock; + } + $result = static::getCollection()->remove($condition, ['w' => 1]); + if ($lock !== null && !$result) { + throw new StaleObjectException('The object being deleted is outdated.'); + } + $this->_oldAttributes = null; + $this->afterDelete(); + } + return $result; + } + + /** + * Returns a value indicating whether the current record is new. + * @return boolean whether the record is new and should be inserted when calling [[save()]]. + */ + public function getIsNewRecord() + { + return $this->_oldAttributes === null; + } + + /** + * Sets the value indicating whether the record is new. + * @param boolean $value whether the record is new and should be inserted when calling [[save()]]. + * @see getIsNewRecord() + */ + public function setIsNewRecord($value) + { + $this->_oldAttributes = $value ? null : $this->_attributes; + } + + /** + * Initializes the object. + * This method is called at the end of the constructor. + * The default implementation will trigger an [[EVENT_INIT]] event. + * If you override this method, make sure you call the parent implementation at the end + * to ensure triggering of the event. + */ + public function init() + { + parent::init(); + $this->trigger(self::EVENT_INIT); + } + + /** + * This method is called when the AR object is created and populated with the query result. + * The default implementation will trigger an [[EVENT_AFTER_FIND]] event. + * When overriding this method, make sure you call the parent implementation to ensure the + * event is triggered. + */ + public function afterFind() + { + $this->trigger(self::EVENT_AFTER_FIND); + } + + /** + * This method is called at the beginning of inserting or updating a record. + * The default implementation will trigger an [[EVENT_BEFORE_INSERT]] event when `$insert` is true, + * or an [[EVENT_BEFORE_UPDATE]] event if `$insert` is false. + * When overriding this method, make sure you call the parent implementation like the following: + * + * ~~~ + * public function beforeSave($insert) + * { + * if (parent::beforeSave($insert)) { + * // ...custom code here... + * return true; + * } else { + * return false; + * } + * } + * ~~~ + * + * @param boolean $insert whether this method called while inserting a record. + * If false, it means the method is called while updating a record. + * @return boolean whether the insertion or updating should continue. + * If false, the insertion or updating will be cancelled. + */ + public function beforeSave($insert) + { + $event = new ModelEvent; + $this->trigger($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event); + return $event->isValid; + } + + /** + * This method is called at the end of inserting or updating a record. + * The default implementation will trigger an [[EVENT_AFTER_INSERT]] event when `$insert` is true, + * or an [[EVENT_AFTER_UPDATE]] event if `$insert` is false. + * When overriding this method, make sure you call the parent implementation so that + * the event is triggered. + * @param boolean $insert whether this method called while inserting a record. + * If false, it means the method is called while updating a record. + */ + public function afterSave($insert) + { + $this->trigger($insert ? self::EVENT_AFTER_INSERT : self::EVENT_AFTER_UPDATE); + } + + /** + * This method is invoked before deleting a record. + * The default implementation raises the [[EVENT_BEFORE_DELETE]] event. + * When overriding this method, make sure you call the parent implementation like the following: + * + * ~~~ + * public function beforeDelete() + * { + * if (parent::beforeDelete()) { + * // ...custom code here... + * return true; + * } else { + * return false; + * } + * } + * ~~~ + * + * @return boolean whether the record should be deleted. Defaults to true. + */ + public function beforeDelete() + { + $event = new ModelEvent; + $this->trigger(self::EVENT_BEFORE_DELETE, $event); + return $event->isValid; + } + + /** + * This method is invoked after deleting a record. + * The default implementation raises the [[EVENT_AFTER_DELETE]] event. + * You may override this method to do postprocessing after the record is deleted. + * Make sure you call the parent implementation so that the event is raised properly. + */ + public function afterDelete() + { + $this->trigger(self::EVENT_AFTER_DELETE); + } + + /** + * Repopulates this active record with the latest data. + * @return boolean whether the row still exists in the database. If true, the latest data + * will be populated to this active record. Otherwise, this record will remain unchanged. + */ + public function refresh() + { + $record = $this->find($this->getPrimaryKey(true)); + if ($record === null) { + return false; + } + foreach ($this->attributes() as $name) { + $this->_attributes[$name] = isset($record->_attributes[$name]) ? $record->_attributes[$name] : null; + } + $this->_oldAttributes = $this->_attributes; + $this->_related = []; + return true; + } + + /** + * Returns a value indicating whether the given active record is the same as the current one. + * The comparison is made by comparing the 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 database table. + */ + public function equals($record) + { + if ($this->isNewRecord || $record->isNewRecord) { + return false; + } + return $this->collectionName() === $record->collectionName() && $this->getPrimaryKey() === $record->getPrimaryKey(); + } + + /** + * Returns the primary key value(s). + * @param boolean $asArray whether to return the primary key value as an array. If true, + * the return value will be an array with column names as keys and column values as values. + * Note that for composite primary keys, an array will always be returned regardless of this parameter value. + * @property mixed The primary key value. An array (column name => column value) is returned if + * the primary key is composite. A string is returned otherwise (null will be returned if + * the key value is null). + * @return mixed the primary key value. An array (column name => column value) is returned if the primary key + * is composite or `$asArray` is true. A string is returned otherwise (null will be returned if + * the key value is null). + */ + public function getPrimaryKey($asArray = false) + { + $keys = $this->primaryKey(); + if (count($keys) === 1 && !$asArray) { + return isset($this->_attributes[$keys[0]]) ? $this->_attributes[$keys[0]] : null; + } else { + $values = []; + foreach ($keys as $name) { + $values[$name] = isset($this->_attributes[$name]) ? $this->_attributes[$name] : null; + } + return $values; + } + } + + /** + * Returns the old primary key value(s). + * This refers to the primary key value that is populated into the record + * after executing a find method (e.g. find(), findAll()). + * The value remains unchanged even if the primary key attribute is manually assigned with a different value. + * @param boolean $asArray whether to return the primary key value as an array. If true, + * the return value will be an array with column name as key and column value as value. + * If this is false (default), a scalar value will be returned for non-composite primary key. + * @property mixed The old primary key value. An array (column name => column value) is + * returned if the primary key is composite. A string is returned otherwise (null will be + * returned if the key value is null). + * @return mixed the old primary key value. An array (column name => column value) is returned if the primary key + * is composite or `$asArray` is true. A string is returned otherwise (null will be returned if + * the key value is null). + */ + public function getOldPrimaryKey($asArray = false) + { + $keys = $this->primaryKey(); + if (count($keys) === 1 && !$asArray) { + return isset($this->_oldAttributes[$keys[0]]) ? $this->_oldAttributes[$keys[0]] : null; + } else { + $values = []; + foreach ($keys as $name) { + $values[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; + } + return $values; + } + } + + /** + * Creates an active record object using a row of data. + * This method is called by [[ActiveQuery]] to populate the query results + * into Active Records. It is not meant to be used to create new records. + * @param array $row attribute values (name => value) + * @return ActiveRecord the newly created active record. + */ + public static function create($row) + { + $record = static::instantiate($row); + $columns = array_flip($record->attributes()); + foreach ($row as $name => $value) { + if (isset($columns[$name])) { + $record->_attributes[$name] = $value; + } else { + $record->$name = $value; + } + } + $record->_oldAttributes = $record->_attributes; + $record->afterFind(); + return $record; + } + + /** + * Creates an active record instance. + * This method is called by [[create()]]. + * You may override this method if the instance being created + * depends on the row data to be populated into the record. + * For example, by creating a record based on the value of a column, + * you may implement the so-called single-table inheritance mapping. + * @param array $row row data to be populated into the record. + * @return ActiveRecord the newly created active record + */ + public static function instantiate($row) + { + return new static; + } + + /** + * Returns whether there is an element at the specified offset. + * This method is required by the interface ArrayAccess. + * @param mixed $offset the offset to check on + * @return boolean whether there is an element at the specified offset. + */ + public function offsetExists($offset) + { + return $this->__isset($offset); + } + + /** + * Returns the relation object with the specified name. + * A relation is defined by a getter method which returns an [[ActiveRelation]] object. + * It can be declared in either the Active Record class itself or one of its behaviors. + * @param string $name the relation name + * @return ActiveRelation the relation object + * @throws InvalidParamException if the named relation does not exist. + */ + public function getRelation($name) + { + $getter = 'get' . $name; + try { + $relation = $this->$getter(); + if ($relation instanceof ActiveRelationInterface) { + return $relation; + } else { + throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".'); + } + } catch (UnknownMethodException $e) { + throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".', 0, $e); + } + } +} \ No newline at end of file diff --git a/extensions/mongo/ActiveRelation.php b/extensions/mongo/ActiveRelation.php new file mode 100644 index 0000000..539dc7b --- /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 index 5f7246c..1de370e 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -106,7 +106,7 @@ class Collection extends Object * @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 boolean whether operation was successful. + * @return integer|boolean number of updated documents or whether operation was successful. * @throws Exception on failure. */ public function update($condition, $newData, $options = []) @@ -115,9 +115,14 @@ class Collection extends Object Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); - $this->mongoCollection->update($this->buildCondition($condition), $newData, $options); + $result = $this->mongoCollection->update($this->buildCondition($condition), $newData, $options); + $this->tryResultError($result); Yii::endProfile($token, __METHOD__); - return true; + 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); @@ -150,7 +155,7 @@ class Collection extends Object * Removes data from the collection. * @param array $condition description of records to remove. * @param array $options list of options in format: optionName => optionValue. - * @return boolean whether operation was successful. + * @return integer|boolean number of updated documents or whether operation was successful. * @throws Exception on failure. */ public function remove($condition = [], $options = []) @@ -159,9 +164,14 @@ class Collection extends Object Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); - $this->tryResultError($this->mongoCollection->remove($this->buildCondition($condition), $options)); + $result = $this->mongoCollection->remove($this->buildCondition($condition), $options); + $this->tryResultError($result); Yii::endProfile($token, __METHOD__); - return true; + 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); diff --git a/extensions/mongo/QueryBuilder.php b/extensions/mongo/QueryBuilder.php deleted file mode 100644 index 5c7181c..0000000 --- a/extensions/mongo/QueryBuilder.php +++ /dev/null @@ -1,37 +0,0 @@ - - * @since 2.0 - */ -class QueryBuilder extends Object -{ - /** - * @var Connection the Mongo connection. - */ - public $db; - - /** - * Constructor. - * @param Connection $connection the Mongo connection. - * @param array $config name-value pairs that will be used to initialize the object properties - */ - public function __construct($connection, $config = []) - { - $this->db = $connection; - parent::__construct($config); - } - - // TODO -} \ No newline at end of file From 0f7ded8f539293352192cd09467a2c8705833f75 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 29 Nov 2013 12:27:22 +0200 Subject: [PATCH 14/49] Mongo query unit test fixed. --- extensions/mongo/Collection.php | 2 +- extensions/mongo/Query.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 1de370e..6e1fe3c 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -253,7 +253,7 @@ class Collection extends Object $result[] = $actualValue; } else { $key = $this->normalizeConditionKeyword($key); - if (strncmp('$', $key, 1) !== 0 && array_key_exists(0, $actualValue)) { + if (strncmp('$', $key, 1) !== 0 && is_array($actualValue) && array_key_exists(0, $actualValue)) { // shortcut for IN condition $result[$key]['$in'] = $actualValue; } else { diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index 75bea4e..e8f3416 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -123,9 +123,10 @@ class Query extends Component implements QueryInterface */ protected function buildCursor($db = null) { - $where = $this->where; - if (!is_array($where)) { + if ($this->where === null) { $where = []; + } else { + $where = $this->where; } $selectFields = []; if (!empty($this->select)) { From 9c7d2b23c259c5115eabec0ef9b7e32b5e97edc4 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 29 Nov 2013 16:40:57 +0200 Subject: [PATCH 15/49] Mongo Active Record and Active Query fixed. --- extensions/mongo/ActiveQuery.php | 72 ++++++++ extensions/mongo/ActiveRecord.php | 19 +- extensions/mongo/Query.php | 6 +- tests/unit/data/ar/mongo/ActiveRecord.php | 16 ++ tests/unit/data/ar/mongo/Customer.php | 27 +++ tests/unit/extensions/mongo/ActiveRecordTest.php | 220 +++++++++++++++++++++++ 6 files changed, 351 insertions(+), 9 deletions(-) create mode 100644 tests/unit/data/ar/mongo/ActiveRecord.php create mode 100644 tests/unit/data/ar/mongo/Customer.php create mode 100644 tests/unit/extensions/mongo/ActiveRecordTest.php diff --git a/extensions/mongo/ActiveQuery.php b/extensions/mongo/ActiveQuery.php index f30eff4..9b7e207 100644 --- a/extensions/mongo/ActiveQuery.php +++ b/extensions/mongo/ActiveQuery.php @@ -19,4 +19,76 @@ use yii\db\ActiveQueryTrait; class ActiveQuery extends Query implements ActiveQueryInterface { use ActiveQueryTrait; + + /** + * Executes query and returns all results as an array. + * @param Connection $db the DB connection used to create the DB command. + * If null, the DB connection returned by [[modelClass]] will be used. + * @return array the query results. If the query results in nothing, an empty array will be returned. + */ + public function all($db = null) + { + $cursor = $this->buildCursor($db); + $rows = []; + foreach ($cursor as $row) { + $rows[] = $row; + } + 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 DB connection used to create the DB command. + * If null, the DB connection returned by [[modelClass]] will be used. + * @return ActiveRecord|array|null a single row of query result. Depending on the setting of [[asArray]], + * the query result may be either an array or an ActiveRecord object. Null will be returned + * if the query results in nothing. + */ + public function one($db = null) + { + $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 index beab2ef..d6788fc 100644 --- a/extensions/mongo/ActiveRecord.php +++ b/extensions/mongo/ActiveRecord.php @@ -162,11 +162,7 @@ abstract class ActiveRecord extends Model if (!array_key_exists('multiple', $options)) { $options['multiple'] = true; } - $data = []; - foreach ($counters as $name => $value) { - $data[$name]['$inc'] = $value; - } - return static::getCollection()->update($condition, $data, $options); + return static::getCollection()->update($condition, ['$inc' => $counters], $options); } /** @@ -470,13 +466,20 @@ abstract class ActiveRecord extends Model /** * Returns the list of all attribute names of the model. - * The default implementation will return all column names of the table associated with this AR class. + * 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() { - // TODO: declare attributes - return []; + throw new InvalidConfigException('The attributes() method of mongo ActiveRecord has to be implemented by child classes.'); } /** diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index e8f3416..42b3403 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -182,7 +182,11 @@ class Query extends Component implements QueryInterface public function one($db = null) { $cursor = $this->buildCursor($db); - return $cursor->getNext(); + if ($cursor->hasNext()) { + return $cursor->getNext(); + } else { + return false; + } } /** diff --git a/tests/unit/data/ar/mongo/ActiveRecord.php b/tests/unit/data/ar/mongo/ActiveRecord.php new file mode 100644 index 0000000..6f5bc49 --- /dev/null +++ b/tests/unit/data/ar/mongo/ActiveRecord.php @@ -0,0 +1,16 @@ +andWhere(['status' => 2]); + } +} \ 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 0000000..e96ea30 --- /dev/null +++ b/tests/unit/extensions/mongo/ActiveRecordTest.php @@ -0,0 +1,220 @@ +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, scalar + $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'));*/ + + // 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); + $ret = Customer::updateAll(['$set' => ['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); + } +} \ No newline at end of file From a39b2d3799b0dbf9edaed722355abf542c87cd9a Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 29 Nov 2013 16:59:38 +0200 Subject: [PATCH 16/49] Default options setup added to Mongo Collection operations. --- extensions/mongo/ActiveRecord.php | 12 ++---------- extensions/mongo/Collection.php | 14 +++++++++++++- tests/unit/extensions/mongo/CollectionTest.php | 6 ++++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/extensions/mongo/ActiveRecord.php b/extensions/mongo/ActiveRecord.php index d6788fc..5e2d9bc 100644 --- a/extensions/mongo/ActiveRecord.php +++ b/extensions/mongo/ActiveRecord.php @@ -134,10 +134,6 @@ abstract class ActiveRecord extends Model */ public static function updateAll($attributes, $condition = [], $options = []) { - $options['w'] = 1; - if (!array_key_exists('multiple', $options)) { - $options['multiple'] = true; - } return static::getCollection()->update($condition, $attributes, $options); } @@ -158,10 +154,6 @@ abstract class ActiveRecord extends Model */ public static function updateAllCounters($counters, $condition = [], $options = []) { - $options['w'] = 1; - if (!array_key_exists('multiple', $options)) { - $options['multiple'] = true; - } return static::getCollection()->update($condition, ['$inc' => $counters], $options); } @@ -798,7 +790,7 @@ abstract class ActiveRecord extends Model } // 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, ['w' => 1]); + $rows = static::getCollection()->update($condition, $values); if ($lock !== null && !$rows) { throw new StaleObjectException('The object being updated is outdated.'); @@ -871,7 +863,7 @@ abstract class ActiveRecord extends Model if ($lock !== null) { $condition[$lock] = $this->$lock; } - $result = static::getCollection()->remove($condition, ['w' => 1]); + $result = static::getCollection()->remove($condition); if ($lock !== null && !$result) { throw new StaleObjectException('The object being deleted is outdated.'); } diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 6e1fe3c..7959186 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -70,6 +70,7 @@ class Collection extends Object 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; @@ -92,6 +93,7 @@ class Collection extends Object 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; @@ -115,7 +117,15 @@ class Collection extends Object Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); - $result = $this->mongoCollection->update($this->buildCondition($condition), $newData, $options); + $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]; + } + } + $condition = $this->buildCondition($condition); + $result = $this->mongoCollection->update($condition, $newData, $options); $this->tryResultError($result); Yii::endProfile($token, __METHOD__); if (is_array($result) && array_key_exists('n', $result)) { @@ -142,6 +152,7 @@ class Collection extends Object 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; @@ -164,6 +175,7 @@ class Collection extends Object Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); + $options = array_merge(['w' => 1, 'multiple' => true], $options); $result = $this->mongoCollection->remove($this->buildCondition($condition), $options); $this->tryResultError($result); Yii::endProfile($token, __METHOD__); diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index 053ee7e..1e86236 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -117,7 +117,8 @@ class CollectionTest extends MongoTestCase ]; $id = $collection->insert($data); - $collection->remove(['_id' => $id]); + $count = $collection->remove(['_id' => $id]); + $this->assertEquals(1, $count); $rows = $collection->findAll(); $this->assertEquals(0, count($rows)); @@ -138,7 +139,8 @@ class CollectionTest extends MongoTestCase $newData = [ 'name' => 'new name' ]; - $collection->update(['_id' => $id], $newData); + $count = $collection->update(['_id' => $id], $newData); + $this->assertEquals(1, $count); list($row) = $collection->findAll(); $this->assertEquals($newData['name'], $row['name']); From 479fbf77ef874798c42d811cdaa85159e81270ca Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 29 Nov 2013 17:12:13 +0200 Subject: [PATCH 17/49] Unit test for Mongo ActiveDataProvider added. --- .../extensions/mongo/ActiveDataProviderTest.php | 91 ++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/unit/extensions/mongo/ActiveDataProviderTest.php diff --git a/tests/unit/extensions/mongo/ActiveDataProviderTest.php b/tests/unit/extensions/mongo/ActiveDataProviderTest.php new file mode 100644 index 0000000..3660516 --- /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 From 3fd6d95aff244b8fbbbac31a6966c280bd93dba8 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Fri, 29 Nov 2013 17:25:17 +0200 Subject: [PATCH 18/49] Unit test for Mongo Active Relation added. --- tests/unit/data/ar/mongo/Customer.php | 5 ++ tests/unit/data/ar/mongo/CustomerOrder.php | 27 +++++++ tests/unit/extensions/mongo/ActiveRelationTest.php | 83 ++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 tests/unit/data/ar/mongo/CustomerOrder.php create mode 100644 tests/unit/extensions/mongo/ActiveRelationTest.php diff --git a/tests/unit/data/ar/mongo/Customer.php b/tests/unit/data/ar/mongo/Customer.php index 16819ca..83c4255 100644 --- a/tests/unit/data/ar/mongo/Customer.php +++ b/tests/unit/data/ar/mongo/Customer.php @@ -24,4 +24,9 @@ class Customer extends ActiveRecord { $query->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 0000000..2192be3 --- /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/extensions/mongo/ActiveRelationTest.php b/tests/unit/extensions/mongo/ActiveRelationTest.php new file mode 100644 index 0000000..ae12d72 --- /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]->index instanceof ArticleIndex); + $this->assertTrue($orders[1]->index instanceof ArticleIndex); + } +} \ No newline at end of file From 27a1c63e26264135b2649c4781ffba640bd3251e Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Fri, 29 Nov 2013 20:55:41 +0200 Subject: [PATCH 19/49] Mongo "_id" processing advanced. --- extensions/mongo/ActiveRecord.php | 25 ++++++++++++++++++++++ extensions/mongo/Collection.php | 10 +++++++++ tests/unit/extensions/mongo/ActiveRelationTest.php | 4 ++-- tests/unit/extensions/mongo/QueryRunTest.php | 12 +++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/extensions/mongo/ActiveRecord.php b/extensions/mongo/ActiveRecord.php index 5e2d9bc..ddbfd2a 100644 --- a/extensions/mongo/ActiveRecord.php +++ b/extensions/mongo/ActiveRecord.php @@ -1157,4 +1157,29 @@ abstract class ActiveRecord extends Model throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".', 0, $e); } } + + /** + * Sets the element at the specified offset. + * This method is required by the SPL interface `ArrayAccess`. + * It is implicitly called when you use something like `$model[$offset] = $item;`. + * @param integer $offset the offset to set element + * @param mixed $item the element value + * @throws \Exception on failure + */ + public function offsetSet($offset, $item) + { + // Bypass relation owner restriction to 'yii\db\ActiveRecord' at [[yii\db\ActiveRelationTrait::findWith()]]: + try { + $relation = $this->getRelation($offset); + if (is_object($relation)) { + $this->populateRelation($offset, $item); + return; + } + } catch (InvalidParamException $e) { + // shut down exception : has getter, but not relation + } catch (UnknownMethodException $e) { + throw $e->getPrevious(); + } + parent::offsetSet($offset, $item); + } } \ No newline at end of file diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 7959186..17a4329 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -267,8 +267,18 @@ class Collection extends Object $key = $this->normalizeConditionKeyword($key); if (strncmp('$', $key, 1) !== 0 && is_array($actualValue) && array_key_exists(0, $actualValue)) { // shortcut for IN condition + if ($key == '_id') { + foreach ($actualValue as &$actualValuePart) { + if (!is_object($actualValuePart)) { + $actualValuePart = new \MongoId($actualValuePart); + } + } + } $result[$key]['$in'] = $actualValue; } else { + if ($key == '_id' && !is_object($actualValue)) { + $actualValue = new \MongoId($actualValue); + } $result[$key] = $actualValue; } } diff --git a/tests/unit/extensions/mongo/ActiveRelationTest.php b/tests/unit/extensions/mongo/ActiveRelationTest.php index ae12d72..26cf63e 100644 --- a/tests/unit/extensions/mongo/ActiveRelationTest.php +++ b/tests/unit/extensions/mongo/ActiveRelationTest.php @@ -77,7 +77,7 @@ class ActiveRelationTest extends MongoTestCase $this->assertEquals(10, count($orders)); $this->assertTrue($orders[0]->isRelationPopulated('customer')); $this->assertTrue($orders[1]->isRelationPopulated('customer')); - $this->assertTrue($orders[0]->index instanceof ArticleIndex); - $this->assertTrue($orders[1]->index instanceof ArticleIndex); + $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/QueryRunTest.php b/tests/unit/extensions/mongo/QueryRunTest.php index f8821b6..078e1c7 100644 --- a/tests/unit/extensions/mongo/QueryRunTest.php +++ b/tests/unit/extensions/mongo/QueryRunTest.php @@ -105,4 +105,16 @@ class QueryRunTest extends MongoTestCase ->all($connection); $this->assertEquals('name9', $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 From 139450dad176ceac9e2c87bdc83b40aec75485ff Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sun, 1 Dec 2013 17:18:53 +0200 Subject: [PATCH 20/49] Mongo condition composition reworked to match original DB interface. --- extensions/mongo/Collection.php | 254 +++++++++++++++++++++++++----- extensions/mongo/Query.php | 44 ------ tests/unit/extensions/mongo/QueryTest.php | 68 ++------ 3 files changed, 226 insertions(+), 140 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 17a4329..7700233 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -214,75 +214,255 @@ class Collection extends Object protected function normalizeConditionKeyword($key) { static $map = [ - 'or' => '$or', + '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', + 'IN' => '$in', + 'NOT IN' => '$nin', + 'ALL' => '$all', + 'SIZE' => '$size', + 'TYPE' => '$type', + 'EXISTS' => '$exists', + 'NOTEXISTS' => '$exists', + 'ELEMMATCH' => '$elemMatch', + 'MOD' => '$mod', '%' => '$mod', '=' => '$$eq', '==' => '$$eq', - 'where' => '$where' + 'WHERE' => '$where' ]; - $key = strtolower($key); - if (array_key_exists($key, $map)) { - return $map[$key]; + $matchKey = strtoupper($key); + if (array_key_exists($matchKey, $map)) { + return $map[$matchKey]; } else { return $key; } } /** - * Builds up Mongo condition from user friendly condition. - * @param array $condition raw condition. - * @return array normalized Mongo condition. - * @throws \yii\base\InvalidParamException on invalid condition given. + * 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 []; } - $result = []; - foreach ($condition as $key => $value) { - if (is_array($value)) { - $actualValue = $this->buildCondition($value); + 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 { - $actualValue = $value; + throw new InvalidParamException('Found unknown operator in query: ' . $operator); } - if (is_numeric($key)) { - $result[] = $actualValue; + } 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 { - $key = $this->normalizeConditionKeyword($key); - if (strncmp('$', $key, 1) !== 0 && is_array($actualValue) && array_key_exists(0, $actualValue)) { - // shortcut for IN condition - if ($key == '_id') { - foreach ($actualValue as &$actualValuePart) { - if (!is_object($actualValuePart)) { - $actualValuePart = new \MongoId($actualValuePart); - } + 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; } - $result[$key]['$in'] = $actualValue; } else { - if ($key == '_id' && !is_object($actualValue)) { - $actualValue = new \MongoId($actualValue); + // Direct match: + if ($name == '_id') { + $value = $this->ensureMongoId($value); } - $result[$key] = $actualValue; + $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/Query.php b/extensions/mongo/Query.php index 42b3403..13be87d 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -74,50 +74,6 @@ class Query extends Component implements QueryInterface } /** - * Adds an additional WHERE condition to the existing one. - * The new condition and the existing one will be joined using the 'AND' operator. - * @param array $condition the new WHERE condition. Please refer to [[where()]] - * on how to specify this parameter. - * @return static the query object itself - * @see where() - * @see orWhere() - */ - public function andWhere($condition) - { - if (is_array($this->where)) { - $this->where = array_merge($this->where, $condition); - } else { - $this->where = $condition; - } - return $this; - } - - /** - * Adds an additional WHERE condition to the existing one. - * The new condition and the existing one will be joined using the 'OR' operator. - * @param array $condition the new WHERE condition. Please refer to [[where()]] - * on how to specify this parameter. - * @return static the query object itself - * @see where() - * @see andWhere() - */ - public function orWhere($condition) - { - if (is_array($this->where)) { - if (array_key_exists('or', $this->where) && count($this->where) == 1) { - $this->where['or'][] = $condition; - } else { - $this->where = [ - 'or' => [$this->where, $condition] - ]; - } - } else { - $this->where = $condition; - } - return $this; - } - - /** * @param Connection $db the database connection used to execute the query. * @return \MongoCursor mongo cursor instance. */ diff --git a/tests/unit/extensions/mongo/QueryTest.php b/tests/unit/extensions/mongo/QueryTest.php index ac14fbe..35d45e0 100644 --- a/tests/unit/extensions/mongo/QueryTest.php +++ b/tests/unit/extensions/mongo/QueryTest.php @@ -45,8 +45,9 @@ class QueryTest extends MongoTestCase $query->andWhere(['address' => 'address1']); $this->assertEquals( [ - 'name' => 'name1', - 'address' => 'address1' + 'and', + ['name' => 'name1'], + ['address' => 'address1'] ], $query->where ); @@ -54,65 +55,14 @@ class QueryTest extends MongoTestCase $query->orWhere(['name' => 'name2']); $this->assertEquals( [ - 'or' => [ - [ - 'name' => 'name1', - 'address' => 'address1' - ], - ['name' => 'name2'] - ] - ], - $query->where - ); - - $query->orWhere(['name' => 'name3']); - $this->assertEquals( - [ - 'or' => [ - [ - 'name' => 'name1', - 'address' => 'address1' - ], - ['name' => 'name2'], - ['name' => 'name3'] - ] - ], - $query->where - ); - - $query->andWhere(['address' => 'address2']); - $this->assertEquals( - [ - 'or' => [ - [ - 'name' => 'name1', - 'address' => 'address1' - ], - ['name' => 'name2'], - ['name' => 'name3'] + 'or', + [ + 'and', + ['name' => 'name1'], + ['address' => 'address1'] ], - 'address' => 'address2' - ], - $query->where - ); + ['name' => 'name2'] - $query->orWhere(['name' => 'name4']); - $this->assertEquals( - [ - 'or' => [ - [ - 'or' => [ - [ - 'name' => 'name1', - 'address' => 'address1' - ], - ['name' => 'name2'], - ['name' => 'name3'] - ], - 'address' => 'address2' - ], - ['name' => 'name4'] - ], ], $query->where ); From deffc7f2de70308b69b02c9dfafbabf26019e054 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sun, 1 Dec 2013 17:39:50 +0200 Subject: [PATCH 21/49] Mongo Active Record reworked to extend BaseActiveRecord. --- extensions/mongo/ActiveRecord.php | 860 +------------------------------------- 1 file changed, 9 insertions(+), 851 deletions(-) diff --git a/extensions/mongo/ActiveRecord.php b/extensions/mongo/ActiveRecord.php index ddbfd2a..9c83080 100644 --- a/extensions/mongo/ActiveRecord.php +++ b/extensions/mongo/ActiveRecord.php @@ -9,10 +9,8 @@ namespace yii\mongo; use yii\base\InvalidConfigException; use yii\base\InvalidParamException; -use yii\base\Model; -use yii\base\ModelEvent; +use yii\db\BaseActiveRecord; use yii\base\UnknownMethodException; -use yii\db\ActiveRelationInterface; use yii\db\StaleObjectException; use yii\helpers\Inflector; use yii\helpers\StringHelper; @@ -23,58 +21,9 @@ use yii\helpers\StringHelper; * @author Paul Klimov * @since 2.0 */ -abstract class ActiveRecord extends Model +abstract class ActiveRecord extends BaseActiveRecord { /** - * @event Event an event that is triggered when the record is initialized via [[init()]]. - */ - const EVENT_INIT = 'init'; - /** - * @event Event an event that is triggered after the record is created and populated with query result. - */ - const EVENT_AFTER_FIND = 'afterFind'; - /** - * @event ModelEvent an event that is triggered before inserting a record. - * You may set [[ModelEvent::isValid]] to be false to stop the insertion. - */ - const EVENT_BEFORE_INSERT = 'beforeInsert'; - /** - * @event Event an event that is triggered after a record is inserted. - */ - const EVENT_AFTER_INSERT = 'afterInsert'; - /** - * @event ModelEvent an event that is triggered before updating a record. - * You may set [[ModelEvent::isValid]] to be false to stop the update. - */ - const EVENT_BEFORE_UPDATE = 'beforeUpdate'; - /** - * @event Event an event that is triggered after a record is updated. - */ - const EVENT_AFTER_UPDATE = 'afterUpdate'; - /** - * @event ModelEvent an event that is triggered before deleting a record. - * You may set [[ModelEvent::isValid]] to be false to stop the deletion. - */ - const EVENT_BEFORE_DELETE = 'beforeDelete'; - /** - * @event Event an event that is triggered after a record is deleted. - */ - const EVENT_AFTER_DELETE = 'afterDelete'; - - /** - * @var array attribute values indexed by attribute names - */ - private $_attributes = []; - /** - * @var array old attribute values indexed by attribute names. - */ - private $_oldAttributes; - /** - * @var array related models indexed by the relation names - */ - private $_related = []; - - /** * Returns the database connection used by this AR class. * By default, the "db" application component is used as the database connection. * You may override this method if you want to use a different database connection. @@ -86,39 +35,6 @@ abstract class ActiveRecord extends Model } /** - * Creates an [[ActiveQuery]] instance for query purpose. - * - * @param mixed $q the query parameter. This can be one of the followings: - * - * - a scalar value (integer or string): query by a single primary key value and return the - * corresponding record. - * - an array of name-value pairs: query by a set of column values and return a single record matching all of them. - * - null: return a new [[ActiveQuery]] object for further query purpose. - * - * @return ActiveQuery|ActiveRecord|null When `$q` is null, a new [[ActiveQuery]] instance - * is returned; when `$q` is a scalar or an array, an ActiveRecord object matching it will be - * returned (null will be returned if there is no matching). - * @throws InvalidConfigException if the AR class does not have a primary key - * @see createQuery() - */ - public static function find($q = null) - { - $query = static::createQuery(); - if (is_array($q)) { - return $query->where($q)->one(); - } elseif ($q !== null) { - // query by primary key - $primaryKey = static::primaryKey(); - if (isset($primaryKey[0])) { - return $query->where([$primaryKey[0] => $q])->one(); - } else { - throw new InvalidConfigException(get_called_class() . ' must have a primary key.'); - } - } - return $query; - } - - /** * Updates the whole table using the provided attribute values and conditions. * For example, to change the status to be 1 for all customers whose status is 2: * @@ -232,189 +148,6 @@ abstract class ActiveRecord extends Model } /** - * Returns the name of the column that stores the lock version for implementing optimistic locking. - * - * Optimistic locking allows multiple users to access the same record for edits and avoids - * potential conflicts. In case when a user attempts to save the record upon some staled data - * (because another user has modified the data), a [[StaleObjectException]] exception will be thrown, - * and the update or deletion is skipped. - * - * Optimistic locking is only supported by [[update()]] and [[delete()]]. - * - * To use Optimistic locking: - * - * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`. - * Override this method to return the name of this column. - * 2. In the Web form that collects the user input, add a hidden field that stores - * the lock version of the recording being updated. - * 3. In the controller action that does the data updating, try to catch the [[StaleObjectException]] - * and implement necessary business logic (e.g. merging the changes, prompting stated data) - * to resolve the conflict. - * - * @return string the column name that stores the lock version of a table row. - * If null is returned (default implemented), optimistic locking will not be supported. - */ - public function optimisticLock() - { - return null; - } - - /** - * PHP getter magic method. - * This method is overridden so that attributes and related objects can be accessed like properties. - * @param string $name property name - * @return mixed property value - * @see getAttribute() - */ - public function __get($name) - { - if (isset($this->_attributes[$name]) || array_key_exists($name, $this->_attributes)) { - return $this->_attributes[$name]; - } elseif ($this->hasAttribute($name)) { - return null; - } else { - if (isset($this->_related[$name]) || array_key_exists($name, $this->_related)) { - return $this->_related[$name]; - } - $value = parent::__get($name); - if ($value instanceof ActiveRelationInterface) { - return $this->_related[$name] = $value->multiple ? $value->all() : $value->one(); - } else { - return $value; - } - } - } - - /** - * PHP setter magic method. - * This method is overridden so that AR attributes can be accessed like properties. - * @param string $name property name - * @param mixed $value property value - */ - public function __set($name, $value) - { - if ($this->hasAttribute($name)) { - $this->_attributes[$name] = $value; - } else { - parent::__set($name, $value); - } - } - - /** - * Checks if a property value is null. - * This method overrides the parent implementation by checking if the named attribute is null or not. - * @param string $name the property name or the event name - * @return boolean whether the property value is null - */ - public function __isset($name) - { - try { - return $this->__get($name) !== null; - } catch (\Exception $e) { - return false; - } - } - - /** - * Sets a component property to be null. - * This method overrides the parent implementation by clearing - * the specified attribute value. - * @param string $name the property name or the event name - */ - public function __unset($name) - { - if ($this->hasAttribute($name)) { - unset($this->_attributes[$name]); - } else { - if (isset($this->_related[$name])) { - unset($this->_related[$name]); - } else { - parent::__unset($name); - } - } - } - - /** - * Declares a `has-one` relation. - * The declaration is returned in terms of an [[ActiveRelation]] instance - * through which the related record can be queried and retrieved back. - * - * A `has-one` relation means that there is at most one related record matching - * the criteria set by this relation, e.g., a customer has one country. - * - * For example, to declare the `country` relation for `Customer` class, we can write - * the following code in the `Customer` class: - * - * ~~~ - * public function getCountry() - * { - * return $this->hasOne(Country::className(), ['id' => 'country_id']); - * } - * ~~~ - * - * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name - * in the related class `Country`, while the 'country_id' value refers to an attribute name - * in the current AR class. - * - * Call methods declared in [[ActiveRelation]] to further customize the relation. - * - * @param string $class the class name of the related record - * @param array $link the primary-foreign key constraint. The keys of the array refer to - * the attributes of the record associated with the `$class` model, while the values of the - * array refer to the corresponding attributes in **this** AR class. - * @return ActiveRelationInterface the relation object. - */ - public function hasOne($class, $link) - { - /** @var ActiveRecord $class */ - return $class::createActiveRelation([ - 'modelClass' => $class, - 'primaryModel' => $this, - 'link' => $link, - 'multiple' => false, - ]); - } - - /** - * Declares a `has-many` relation. - * The declaration is returned in terms of an [[ActiveRelation]] instance - * through which the related record can be queried and retrieved back. - * - * A `has-many` relation means that there are multiple related records matching - * the criteria set by this relation, e.g., a customer has many orders. - * - * For example, to declare the `orders` relation for `Customer` class, we can write - * the following code in the `Customer` class: - * - * ~~~ - * public function getOrders() - * { - * return $this->hasMany(Order::className(), ['customer_id' => 'id']); - * } - * ~~~ - * - * Note that in the above, the 'customer_id' key in the `$link` parameter refers to - * an attribute name in the related class `Order`, while the 'id' value refers to - * an attribute name in the current AR class. - * - * @param string $class the class name of the related record - * @param array $link the primary-foreign key constraint. The keys of the array refer to - * the attributes of the record associated with the `$class` model, while the values of the - * array refer to the corresponding attributes in **this** AR class. - * @return ActiveRelationInterface the relation object. - */ - public function hasMany($class, $link) - { - /** @var ActiveRecord $class */ - return $class::createActiveRelation([ - 'modelClass' => $class, - 'primaryModel' => $this, - 'link' => $link, - 'multiple' => true, - ]); - } - - /** * 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. @@ -427,36 +160,6 @@ abstract class ActiveRecord extends Model } /** - * Populates the named relation with the related records. - * Note that this method does not check if the relation exists or not. - * @param string $name the relation name (case-sensitive) - * @param ActiveRecord|array|null the related records to be populated into the relation. - */ - public function populateRelation($name, $records) - { - $this->_related[$name] = $records; - } - - /** - * Check whether the named relation has been populated with records. - * @param string $name the relation name (case-sensitive) - * @return bool whether relation has been populated with records. - */ - public function isRelationPopulated($name) - { - return array_key_exists($name, $this->_related); - } - - /** - * Returns all populated relations. - * @return array an array of relation data indexed by relation names. - */ - public function getPopulatedRelations() - { - return $this->_related; - } - - /** * Returns the list of all attribute names of the model. * This method must be overridden by child classes to define available attributes. * Note: primary key attribute "_id" should be always present in returned array. @@ -475,168 +178,6 @@ abstract class ActiveRecord extends Model } /** - * Returns a value indicating whether the model has an attribute with the specified name. - * @param string $name the name of the attribute - * @return boolean whether the model has an attribute with the specified name. - */ - public function hasAttribute($name) - { - return isset($this->_attributes[$name]) || in_array($name, $this->attributes()); - } - - /** - * Returns the named attribute value. - * If this record is the result of a query and the attribute is not loaded, - * null will be returned. - * @param string $name the attribute name - * @return mixed the attribute value. Null if the attribute is not set or does not exist. - * @see hasAttribute() - */ - public function getAttribute($name) - { - return isset($this->_attributes[$name]) ? $this->_attributes[$name] : null; - } - - /** - * Sets the named attribute value. - * @param string $name the attribute name - * @param mixed $value the attribute value. - * @throws InvalidParamException if the named attribute does not exist. - * @see hasAttribute() - */ - public function setAttribute($name, $value) - { - if ($this->hasAttribute($name)) { - $this->_attributes[$name] = $value; - } else { - throw new InvalidParamException(get_class($this) . ' has no attribute named "' . $name . '".'); - } - } - - /** - * Returns the old attribute values. - * @return array the old attribute values (name-value pairs) - */ - public function getOldAttributes() - { - return $this->_oldAttributes === null ? [] : $this->_oldAttributes; - } - - /** - * Sets the old attribute values. - * All existing old attribute values will be discarded. - * @param array $values old attribute values to be set. - */ - public function setOldAttributes($values) - { - $this->_oldAttributes = $values; - } - - /** - * Returns the old value of the named attribute. - * If this record is the result of a query and the attribute is not loaded, - * null will be returned. - * @param string $name the attribute name - * @return mixed the old attribute value. Null if the attribute is not loaded before - * or does not exist. - * @see hasAttribute() - */ - public function getOldAttribute($name) - { - return isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; - } - - /** - * Sets the old value of the named attribute. - * @param string $name the attribute name - * @param mixed $value the old attribute value. - * @throws InvalidParamException if the named attribute does not exist. - * @see hasAttribute() - */ - public function setOldAttribute($name, $value) - { - if (isset($this->_oldAttributes[$name]) || $this->hasAttribute($name)) { - $this->_oldAttributes[$name] = $value; - } else { - throw new InvalidParamException(get_class($this) . ' has no attribute named "' . $name . '".'); - } - } - - /** - * Returns a value indicating whether the named attribute has been changed. - * @param string $name the name of the attribute - * @return boolean whether the attribute has been changed - */ - public function isAttributeChanged($name) - { - if (isset($this->_attributes[$name], $this->_oldAttributes[$name])) { - return $this->_attributes[$name] !== $this->_oldAttributes[$name]; - } else { - return isset($this->_attributes[$name]) || isset($this->_oldAttributes[$name]); - } - } - - /** - * Returns the attribute values that have been modified since they are loaded or saved most recently. - * @param string[]|null $names the names of the attributes whose values may be returned if they are - * changed recently. If null, [[attributes()]] will be used. - * @return array the changed attribute values (name-value pairs) - */ - public function getDirtyAttributes($names = null) - { - if ($names === null) { - $names = $this->attributes(); - } - $names = array_flip($names); - $attributes = []; - if ($this->_oldAttributes === null) { - foreach ($this->_attributes as $name => $value) { - if (isset($names[$name])) { - $attributes[$name] = $value; - } - } - } else { - foreach ($this->_attributes as $name => $value) { - if (isset($names[$name]) && (!array_key_exists($name, $this->_oldAttributes) || $value !== $this->_oldAttributes[$name])) { - $attributes[$name] = $value; - } - } - } - return $attributes; - } - - /** - * Saves the current record. - * - * This method will call [[insert()]] when [[isNewRecord]] is true, or [[update()]] - * when [[isNewRecord]] is false. - * - * For example, to save a customer record: - * - * ~~~ - * $customer = new Customer; // or $customer = Customer::find($id); - * $customer->name = $name; - * $customer->email = $email; - * $customer->save(); - * ~~~ - * - * - * @param boolean $runValidation whether to perform validation before saving the record. - * If the validation fails, the record will not be saved to database. - * @param array $attributes list of attributes that need to be saved. Defaults to null, - * meaning all attributes that are loaded from DB will be saved. - * @return boolean whether the saving succeeds - */ - public function save($runValidation = true, $attributes = null) - { - if ($this->getIsNewRecord()) { - return $this->insert($runValidation, $attributes); - } else { - return $this->update($runValidation, $attributes) !== false; - } - } - - /** * Inserts a row into the associated database table using the attribute values of this record. * * This method performs the following steps in order: @@ -686,91 +227,33 @@ abstract class ActiveRecord extends Model /** * @see ActiveRecord::insert() */ - private function insertInternal($attributes = null) + 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($this->_attributes[$key]) ? $this->_attributes[$key] : null; + $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->_oldAttributes[$name] = $value; + $this->setOldAttribute($name, $value); } $this->afterSave(true); return true; } /** - * Saves the changes to this active record into the associated database table. - * - * This method performs the following steps in order: - * - * 1. call [[beforeValidate()]] when `$runValidation` is true. If validation - * fails, it will skip the rest of the steps; - * 2. call [[afterValidate()]] when `$runValidation` is true. - * 3. call [[beforeSave()]]. If the method returns false, it will skip the - * rest of the steps; - * 4. save the record into database. If this fails, it will skip the rest of the steps; - * 5. call [[afterSave()]]; - * - * In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]], - * [[EVENT_BEFORE_UPDATE]], [[EVENT_AFTER_UPDATE]] and [[EVENT_AFTER_VALIDATE]] - * will be raised by the corresponding methods. - * - * Only the [[changedAttributes|changed attribute values]] will be saved into database. - * - * For example, to update a customer record: - * - * ~~~ - * $customer = Customer::find($id); - * $customer->name = $name; - * $customer->email = $email; - * $customer->update(); - * ~~~ - * - * Note that it is possible the update does not affect any row in the table. - * In this case, this method will return 0. For this reason, you should use the following - * code to check if update() is successful or not: - * - * ~~~ - * if ($this->update() !== false) { - * // update successful - * } else { - * // update failed - * } - * ~~~ - * - * @param boolean $runValidation whether to perform validation before saving the record. - * If the validation fails, the record will not be inserted into the database. - * @param array $attributes list of attributes that need to be saved. Defaults to null, - * meaning all attributes that are loaded from DB will be saved. - * @return integer|boolean the number of rows affected, or false if validation fails - * or [[beforeSave()]] stops the updating process. - * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data - * being updated is outdated. - * @throws \Exception in case update failed. - */ - public function update($runValidation = true, $attributes = null) - { - if ($runValidation && !$this->validate($attributes)) { - return false; - } - $result = $this->updateInternal($attributes); - return $result; - } - - /** * @see CActiveRecord::update() * @throws StaleObjectException */ - private function updateInternal($attributes = null) + protected function updateInternal($attributes = null) { if (!$this->beforeSave(false)) { return false; @@ -797,43 +280,13 @@ abstract class ActiveRecord extends Model } foreach ($values as $name => $value) { - $this->_oldAttributes[$name] = $this->_attributes[$name]; + $this->setOldAttribute($name, $this->getAttribute($name)); } $this->afterSave(false); return $rows; } /** - * Updates one or several counter columns for the current AR object. - * Note that this method differs from [[updateAllCounters()]] in that it only - * saves counters for the current AR object. - * - * An example usage is as follows: - * - * ~~~ - * $post = Post::find($id); - * $post->updateCounters(['view_count' => 1]); - * ~~~ - * - * @param array $counters the counters to be updated (attribute name => increment value) - * Use negative values if you want to decrement the counters. - * @return boolean whether the saving is successful - * @see updateAllCounters() - */ - public function updateCounters($counters) - { - if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) { - foreach ($counters as $name => $value) { - $this->_attributes[$name] += $value; - $this->_oldAttributes[$name] = $this->_attributes[$name]; - } - return true; - } else { - return false; - } - } - - /** * Deletes the table row corresponding to this active record. * * This method performs the following steps in order: @@ -867,156 +320,13 @@ abstract class ActiveRecord extends Model if ($lock !== null && !$result) { throw new StaleObjectException('The object being deleted is outdated.'); } - $this->_oldAttributes = null; + $this->setOldAttributes(null); $this->afterDelete(); } return $result; } /** - * Returns a value indicating whether the current record is new. - * @return boolean whether the record is new and should be inserted when calling [[save()]]. - */ - public function getIsNewRecord() - { - return $this->_oldAttributes === null; - } - - /** - * Sets the value indicating whether the record is new. - * @param boolean $value whether the record is new and should be inserted when calling [[save()]]. - * @see getIsNewRecord() - */ - public function setIsNewRecord($value) - { - $this->_oldAttributes = $value ? null : $this->_attributes; - } - - /** - * Initializes the object. - * This method is called at the end of the constructor. - * The default implementation will trigger an [[EVENT_INIT]] event. - * If you override this method, make sure you call the parent implementation at the end - * to ensure triggering of the event. - */ - public function init() - { - parent::init(); - $this->trigger(self::EVENT_INIT); - } - - /** - * This method is called when the AR object is created and populated with the query result. - * The default implementation will trigger an [[EVENT_AFTER_FIND]] event. - * When overriding this method, make sure you call the parent implementation to ensure the - * event is triggered. - */ - public function afterFind() - { - $this->trigger(self::EVENT_AFTER_FIND); - } - - /** - * This method is called at the beginning of inserting or updating a record. - * The default implementation will trigger an [[EVENT_BEFORE_INSERT]] event when `$insert` is true, - * or an [[EVENT_BEFORE_UPDATE]] event if `$insert` is false. - * When overriding this method, make sure you call the parent implementation like the following: - * - * ~~~ - * public function beforeSave($insert) - * { - * if (parent::beforeSave($insert)) { - * // ...custom code here... - * return true; - * } else { - * return false; - * } - * } - * ~~~ - * - * @param boolean $insert whether this method called while inserting a record. - * If false, it means the method is called while updating a record. - * @return boolean whether the insertion or updating should continue. - * If false, the insertion or updating will be cancelled. - */ - public function beforeSave($insert) - { - $event = new ModelEvent; - $this->trigger($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event); - return $event->isValid; - } - - /** - * This method is called at the end of inserting or updating a record. - * The default implementation will trigger an [[EVENT_AFTER_INSERT]] event when `$insert` is true, - * or an [[EVENT_AFTER_UPDATE]] event if `$insert` is false. - * When overriding this method, make sure you call the parent implementation so that - * the event is triggered. - * @param boolean $insert whether this method called while inserting a record. - * If false, it means the method is called while updating a record. - */ - public function afterSave($insert) - { - $this->trigger($insert ? self::EVENT_AFTER_INSERT : self::EVENT_AFTER_UPDATE); - } - - /** - * This method is invoked before deleting a record. - * The default implementation raises the [[EVENT_BEFORE_DELETE]] event. - * When overriding this method, make sure you call the parent implementation like the following: - * - * ~~~ - * public function beforeDelete() - * { - * if (parent::beforeDelete()) { - * // ...custom code here... - * return true; - * } else { - * return false; - * } - * } - * ~~~ - * - * @return boolean whether the record should be deleted. Defaults to true. - */ - public function beforeDelete() - { - $event = new ModelEvent; - $this->trigger(self::EVENT_BEFORE_DELETE, $event); - return $event->isValid; - } - - /** - * This method is invoked after deleting a record. - * The default implementation raises the [[EVENT_AFTER_DELETE]] event. - * You may override this method to do postprocessing after the record is deleted. - * Make sure you call the parent implementation so that the event is raised properly. - */ - public function afterDelete() - { - $this->trigger(self::EVENT_AFTER_DELETE); - } - - /** - * Repopulates this active record with the latest data. - * @return boolean whether the row still exists in the database. If true, the latest data - * will be populated to this active record. Otherwise, this record will remain unchanged. - */ - public function refresh() - { - $record = $this->find($this->getPrimaryKey(true)); - if ($record === null) { - return false; - } - foreach ($this->attributes() as $name) { - $this->_attributes[$name] = isset($record->_attributes[$name]) ? $record->_attributes[$name] : null; - } - $this->_oldAttributes = $this->_attributes; - $this->_related = []; - return true; - } - - /** * Returns a value indicating whether the given active record is the same as the current one. * The comparison is made by comparing the 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. @@ -1030,156 +340,4 @@ abstract class ActiveRecord extends Model } return $this->collectionName() === $record->collectionName() && $this->getPrimaryKey() === $record->getPrimaryKey(); } - - /** - * Returns the primary key value(s). - * @param boolean $asArray whether to return the primary key value as an array. If true, - * the return value will be an array with column names as keys and column values as values. - * Note that for composite primary keys, an array will always be returned regardless of this parameter value. - * @property mixed The primary key value. An array (column name => column value) is returned if - * the primary key is composite. A string is returned otherwise (null will be returned if - * the key value is null). - * @return mixed the primary key value. An array (column name => column value) is returned if the primary key - * is composite or `$asArray` is true. A string is returned otherwise (null will be returned if - * the key value is null). - */ - public function getPrimaryKey($asArray = false) - { - $keys = $this->primaryKey(); - if (count($keys) === 1 && !$asArray) { - return isset($this->_attributes[$keys[0]]) ? $this->_attributes[$keys[0]] : null; - } else { - $values = []; - foreach ($keys as $name) { - $values[$name] = isset($this->_attributes[$name]) ? $this->_attributes[$name] : null; - } - return $values; - } - } - - /** - * Returns the old primary key value(s). - * This refers to the primary key value that is populated into the record - * after executing a find method (e.g. find(), findAll()). - * The value remains unchanged even if the primary key attribute is manually assigned with a different value. - * @param boolean $asArray whether to return the primary key value as an array. If true, - * the return value will be an array with column name as key and column value as value. - * If this is false (default), a scalar value will be returned for non-composite primary key. - * @property mixed The old primary key value. An array (column name => column value) is - * returned if the primary key is composite. A string is returned otherwise (null will be - * returned if the key value is null). - * @return mixed the old primary key value. An array (column name => column value) is returned if the primary key - * is composite or `$asArray` is true. A string is returned otherwise (null will be returned if - * the key value is null). - */ - public function getOldPrimaryKey($asArray = false) - { - $keys = $this->primaryKey(); - if (count($keys) === 1 && !$asArray) { - return isset($this->_oldAttributes[$keys[0]]) ? $this->_oldAttributes[$keys[0]] : null; - } else { - $values = []; - foreach ($keys as $name) { - $values[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null; - } - return $values; - } - } - - /** - * Creates an active record object using a row of data. - * This method is called by [[ActiveQuery]] to populate the query results - * into Active Records. It is not meant to be used to create new records. - * @param array $row attribute values (name => value) - * @return ActiveRecord the newly created active record. - */ - public static function create($row) - { - $record = static::instantiate($row); - $columns = array_flip($record->attributes()); - foreach ($row as $name => $value) { - if (isset($columns[$name])) { - $record->_attributes[$name] = $value; - } else { - $record->$name = $value; - } - } - $record->_oldAttributes = $record->_attributes; - $record->afterFind(); - return $record; - } - - /** - * Creates an active record instance. - * This method is called by [[create()]]. - * You may override this method if the instance being created - * depends on the row data to be populated into the record. - * For example, by creating a record based on the value of a column, - * you may implement the so-called single-table inheritance mapping. - * @param array $row row data to be populated into the record. - * @return ActiveRecord the newly created active record - */ - public static function instantiate($row) - { - return new static; - } - - /** - * Returns whether there is an element at the specified offset. - * This method is required by the interface ArrayAccess. - * @param mixed $offset the offset to check on - * @return boolean whether there is an element at the specified offset. - */ - public function offsetExists($offset) - { - return $this->__isset($offset); - } - - /** - * Returns the relation object with the specified name. - * A relation is defined by a getter method which returns an [[ActiveRelation]] object. - * It can be declared in either the Active Record class itself or one of its behaviors. - * @param string $name the relation name - * @return ActiveRelation the relation object - * @throws InvalidParamException if the named relation does not exist. - */ - public function getRelation($name) - { - $getter = 'get' . $name; - try { - $relation = $this->$getter(); - if ($relation instanceof ActiveRelationInterface) { - return $relation; - } else { - throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".'); - } - } catch (UnknownMethodException $e) { - throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".', 0, $e); - } - } - - /** - * Sets the element at the specified offset. - * This method is required by the SPL interface `ArrayAccess`. - * It is implicitly called when you use something like `$model[$offset] = $item;`. - * @param integer $offset the offset to set element - * @param mixed $item the element value - * @throws \Exception on failure - */ - public function offsetSet($offset, $item) - { - // Bypass relation owner restriction to 'yii\db\ActiveRecord' at [[yii\db\ActiveRelationTrait::findWith()]]: - try { - $relation = $this->getRelation($offset); - if (is_object($relation)) { - $this->populateRelation($offset, $item); - return; - } - } catch (InvalidParamException $e) { - // shut down exception : has getter, but not relation - } catch (UnknownMethodException $e) { - throw $e->getPrevious(); - } - parent::offsetSet($offset, $item); - } } \ No newline at end of file From 1129d820edde891155213b3cbd7841981cc22cea Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sun, 1 Dec 2013 17:45:18 +0200 Subject: [PATCH 22/49] Mongo Active Relation unit test fixed. --- tests/unit/data/ar/mongo/Customer.php | 2 +- tests/unit/data/ar/mongo/CustomerOrder.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/data/ar/mongo/Customer.php b/tests/unit/data/ar/mongo/Customer.php index 83c4255..f90e5cd 100644 --- a/tests/unit/data/ar/mongo/Customer.php +++ b/tests/unit/data/ar/mongo/Customer.php @@ -27,6 +27,6 @@ class Customer extends ActiveRecord public function getOrders() { - return $this->hasMany(CustomerOrder::className(), ['customer_id' => 'id']); + 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 index 2192be3..a01e47f 100644 --- a/tests/unit/data/ar/mongo/CustomerOrder.php +++ b/tests/unit/data/ar/mongo/CustomerOrder.php @@ -22,6 +22,6 @@ class CustomerOrder extends ActiveRecord public function getCustomer() { - return $this->hasOne(Customer::className(), ['id' => 'customer_id']); + return $this->hasOne(Customer::className(), ['_id' => 'customer_id']); } } \ No newline at end of file From cddb87836062626c3d73c340e3323aa6b64aed77 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sun, 1 Dec 2013 17:46:21 +0200 Subject: [PATCH 23/49] ActiveRelationTrait::getModelKey() updated to work with non scalar keys. --- framework/yii/db/ActiveRelationTrait.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/framework/yii/db/ActiveRelationTrait.php b/framework/yii/db/ActiveRelationTrait.php index 832bb62..c477df9 100644 --- a/framework/yii/db/ActiveRelationTrait.php +++ b/framework/yii/db/ActiveRelationTrait.php @@ -203,7 +203,8 @@ trait ActiveRelationTrait return serialize($key); } else { $attribute = reset($attributes); - return $model[$attribute]; + $key = $model[$attribute]; + return is_scalar($key) ? $key : serialize($key); } } From 49a70dc31173fea57d7b181bd7e4bac05fbad695 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Sun, 1 Dec 2013 21:20:34 +0200 Subject: [PATCH 24/49] Mongo aggregation functions added as draft. --- extensions/mongo/Collection.php | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 7700233..7d3e2a5 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -191,6 +191,46 @@ class Collection extends Object } /** + * 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. + */ + public function distinct($column, $condition = []) + { + return $this->mongoCollection->distinct($column, $this->buildCondition($condition)); + } + + /** + * @param $pipeline + * @param array $pipelineOperator + * @return array + */ + public function aggregate($pipeline, $pipelineOperator = []) + { + $args = func_get_args(); + return call_user_func_array([$this->mongoCollection, 'aggregate'], $args); + } + + /** + * @param mixed $keys + * @param array $initial + * @param \MongoCode|string $reduce + * @param array $options + * @return array + */ + public function mapReduce($keys, $initial, $reduce, $options = []) + { + if (!($reduce instanceof \MongoCode)) { + $reduce = new \MongoCode($reduce); + } + if (array_key_exists('condition', $options)) { + $options['condition'] = $this->buildCondition($options['condition']); + } + return $this->mongoCollection->group($keys, $initial, $reduce, $options); + } + + /** * Checks if command execution result ended with an error. * @param mixed $result raw command execution result. * @throws Exception if an error occurred. From 0e082c170cda0115683483d88ef108569e2ae482 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 2 Dec 2013 14:01:46 +0200 Subject: [PATCH 25/49] Aggregation functions added to Mongo Query. --- extensions/mongo/Collection.php | 28 ++++-- extensions/mongo/Query.php | 121 +++++++++++++++++++++-- tests/unit/extensions/mongo/ActiveRecordTest.php | 7 +- 3 files changed, 137 insertions(+), 19 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 7d3e2a5..daa3ff1 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -202,9 +202,11 @@ class Collection extends Object } /** - * @param $pipeline - * @param array $pipelineOperator - * @return array + * Performs aggregation using Mongo Aggregation Framework. + * @param array $pipeline list of pipeline operators, or just the first operator + * @param array $pipelineOperator Additional pipeline operators + * @return array the result of the aggregation. + * @see http://docs.mongodb.org/manual/applications/aggregation/ */ public function aggregate($pipeline, $pipelineOperator = []) { @@ -213,20 +215,30 @@ class Collection extends Object } /** + * Performs aggregation using Mongo Map Reduce mechanism. * @param mixed $keys - * @param array $initial - * @param \MongoCode|string $reduce - * @param array $options - * @return array + * @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. */ public function mapReduce($keys, $initial, $reduce, $options = []) { if (!($reduce instanceof \MongoCode)) { - $reduce = new \MongoCode($reduce); + $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']); + } + } return $this->mongoCollection->group($keys, $initial, $reduce, $options); } diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index 13be87d..6259b99 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -105,8 +105,8 @@ class Query extends Component implements QueryInterface /** * Executes the query and returns all results as an array. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. + * @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) @@ -130,8 +130,8 @@ class Query extends Component implements QueryInterface /** * Executes the query and returns a single row of result. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. + * @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. */ @@ -148,8 +148,8 @@ class Query extends Component implements QueryInterface /** * Returns the number of records. * @param string $q the COUNT expression. Defaults to '*'. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. + * @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 */ public function count($q = '*', $db = null) @@ -160,12 +160,117 @@ class Query extends Component implements QueryInterface /** * Returns a value indicating whether the query result contains any row of data. - * @param Connection $db the database connection used to execute the query. - * If this parameter is not given, the `db` application component will be used. + * @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 or expression. + * 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 or expression. + * 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 or expression. + * 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 or expression. + * 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 (!empty($result['ok'])) { + return $result['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/tests/unit/extensions/mongo/ActiveRecordTest.php b/tests/unit/extensions/mongo/ActiveRecordTest.php index e96ea30..812d6e3 100644 --- a/tests/unit/extensions/mongo/ActiveRecordTest.php +++ b/tests/unit/extensions/mongo/ActiveRecordTest.php @@ -83,13 +83,14 @@ class ActiveRecordTest extends MongoTestCase $this->assertTrue($customer instanceof Customer); $this->assertEquals(4, $customer->status); - // find count, sum, average, min, max, scalar + // 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*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(10, Customer::find()->max('status')); + $this->assertEquals(range(1, 10), Customer::find()->distinct('status')); // scope $this->assertEquals(1, Customer::find()->activeOnly()->count()); From c294e033b3b3c74cc2cd4d8510283d8359ee4755 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 2 Dec 2013 16:23:20 +0200 Subject: [PATCH 26/49] Unit test for "yii\mongo\Collection::mapReduce()" added. --- extensions/mongo/Collection.php | 17 ++++++++++++++-- tests/unit/extensions/mongo/CollectionTest.php | 27 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index daa3ff1..a9e6f0a 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -216,7 +216,9 @@ class Collection extends Object /** * Performs aggregation using Mongo Map Reduce mechanism. - * @param mixed $keys + * @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. @@ -225,6 +227,7 @@ class Collection extends Object * - 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. + * @see http://docs.mongodb.org/manual/core/map-reduce/ */ public function mapReduce($keys, $initial, $reduce, $options = []) { @@ -239,7 +242,17 @@ class Collection extends Object $options['finalize'] = new \MongoCode((string)$options['finalize']); } } - return $this->mongoCollection->group($keys, $initial, $reduce, $options); + // 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); + } + if (array_key_exists('retval', $result)) { + return $result['retval']; + } else { + return []; + } } /** diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index 1e86236..f7d8090 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -145,4 +145,31 @@ class CollectionTest extends MongoTestCase list($row) = $collection->findAll(); $this->assertEquals($newData['name'], $row['name']); } + + /** + * @depends testBatchInsert + */ + public function testMapReduce() + { + $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->mapReduce($keys, $initial, $reduce); + $this->assertEquals(2, count($result)); + $this->assertNotEmpty($result[0]['address']); + $this->assertNotEmpty($result[0]['items']); + } } \ No newline at end of file From 469507d494a03c5a8a39a538258f41e9b6512674 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Mon, 2 Dec 2013 17:26:30 +0200 Subject: [PATCH 27/49] Mongo index manipulation methods added. --- extensions/mongo/Collection.php | 66 ++++++++++++++++++++++++++ extensions/mongo/Database.php | 14 ++++++ tests/unit/extensions/mongo/CollectionTest.php | 10 ++++ 3 files changed, 90 insertions(+) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index a9e6f0a..497122c 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -33,6 +33,72 @@ class Collection extends Object } /** + * 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]; + } + $token = 'Creating index at ' . $this->mongoCollection->getName() . ' on ' . implode(',', $columns); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $keys = []; + foreach ($columns as $key => $value) { + if (is_numeric($key)) { + $keys[$value] = \MongoCollection::ASCENDING; + } else { + $keys[$key] = $value; + } + } + $options = array_merge(['w' => 1], $options); + $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. + * @return array result. + */ + public function dropIndex($columns) + { + return $this->mongoCollection->deleteIndex($columns); + } + + /** + * Drops all indexes for this collection + * @return boolean whether the operation successful. + */ + public function dropAllIndexes() + { + $result = $this->mongoCollection->deleteIndexes(); + return !empty($result['ok']); + } + + /** * @param array $condition * @param array $fields * @return \MongoCursor diff --git a/extensions/mongo/Database.php b/extensions/mongo/Database.php index 4954a20..c6ecc74 100644 --- a/extensions/mongo/Database.php +++ b/extensions/mongo/Database.php @@ -61,4 +61,18 @@ class Database extends Object { $this->mongoDb->drop(); } + + /** + * 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. + */ + public function createCollection($name, $options = []) + { + return $this->mongoDb->createCollection($name, $options); + } } \ No newline at end of file diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index f7d8090..110e716 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -172,4 +172,14 @@ class CollectionTest extends MongoTestCase $this->assertNotEmpty($result[0]['address']); $this->assertNotEmpty($result[0]['items']); } + + public function testCreateIndex() + { + $collection = $this->getConnection()->getCollection('customer'); + $columns = [ + 'name', + 'status' => \MongoCollection::DESCENDING, + ]; + $this->assertTrue($collection->createIndex($columns)); + } } \ No newline at end of file From dfefd06016508ff37d2e51b6f28f023c84f3f2c4 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Mon, 2 Dec 2013 21:09:35 +0200 Subject: [PATCH 28/49] Mongo index related methods fixed. --- extensions/mongo/Collection.php | 152 +++++++++++++++++++++---- tests/unit/extensions/mongo/CollectionTest.php | 38 +++++++ tests/unit/extensions/mongo/MongoTestCase.php | 7 +- 3 files changed, 171 insertions(+), 26 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 497122c..172e4e6 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -25,11 +25,40 @@ class Collection extends Object 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(); + } + + /** * Drops this collection. + * @throws Exception on failure. + * @return boolean whether the operation successful. */ public function drop() { - $this->mongoCollection->drop(); + $token = 'Drop collection ' . $this->getFullName(); + 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); + } } /** @@ -55,18 +84,11 @@ class Collection extends Object if (!is_array($columns)) { $columns = [$columns]; } - $token = 'Creating index at ' . $this->mongoCollection->getName() . ' on ' . implode(',', $columns); + $token = 'Creating index at ' . $this->getFullName() . ' on ' . implode(',', $columns); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); - $keys = []; - foreach ($columns as $key => $value) { - if (is_numeric($key)) { - $keys[$value] = \MongoCollection::ASCENDING; - } else { - $keys[$key] = $value; - } - } + $keys = $this->normalizeIndexKeys($columns); $options = array_merge(['w' => 1], $options); $result = $this->mongoCollection->ensureIndex($keys, $options); $this->tryResultError($result); @@ -81,21 +103,73 @@ class Collection extends Object /** * Drop indexes for specified column(s). * @param string|array $columns column name or list of column names. - * @return array result. + * 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, + * ] + * ~~~ + * @throws Exception on failure. + * @return boolean whether the operation successful. */ public function dropIndex($columns) { - return $this->mongoCollection->deleteIndex($columns); + if (!is_array($columns)) { + $columns = [$columns]; + } + $token = 'Drop index at ' . $this->getFullName() . ' on ' . implode(',', $columns); + Yii::info($token, __METHOD__); + try { + $keys = $this->normalizeIndexKeys($columns); + $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); + } } /** - * Drops all indexes for this collection - * @return boolean whether the operation successful. + * 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() { - $result = $this->mongoCollection->deleteIndexes(); - return !empty($result['ok']); + $token = 'Drop ALL indexes at ' . $this->getFullName(); + Yii::info($token, __METHOD__); + try { + $result = $this->mongoCollection->deleteIndexes(); + $this->tryResultError($result); + return $result['nIndexesWas'] - 1; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } } /** @@ -132,7 +206,7 @@ class Collection extends Object */ public function insert($data, $options = []) { - $token = 'Inserting data into ' . $this->mongoCollection->getName(); + $token = 'Inserting data into ' . $this->getFullName(); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -155,7 +229,7 @@ class Collection extends Object */ public function batchInsert($rows, $options = []) { - $token = 'Inserting batch data into ' . $this->mongoCollection->getName(); + $token = 'Inserting batch data into ' . $this->getFullName(); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -179,7 +253,7 @@ class Collection extends Object */ public function update($condition, $newData, $options = []) { - $token = 'Updating data in ' . $this->mongoCollection->getName(); + $token = 'Updating data in ' . $this->getFullName(); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -214,7 +288,7 @@ class Collection extends Object */ public function save($data, $options = []) { - $token = 'Saving data into ' . $this->mongoCollection->getName(); + $token = 'Saving data into ' . $this->getFullName(); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -237,7 +311,7 @@ class Collection extends Object */ public function remove($condition = [], $options = []) { - $token = 'Removing data from ' . $this->mongoCollection->getName(); + $token = 'Removing data from ' . $this->getFullName(); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -264,7 +338,12 @@ class Collection extends Object */ public function distinct($column, $condition = []) { - return $this->mongoCollection->distinct($column, $this->buildCondition($condition)); + $token = 'Get distinct ' . $column . ' from ' . $this->getFullName(); + Yii::info($token, __METHOD__); + Yii::beginProfile($token, __METHOD__); + $result = $this->mongoCollection->distinct($column, $this->buildCondition($condition)); + Yii::endProfile($token, __METHOD__); + return $result; } /** @@ -276,8 +355,13 @@ class Collection extends Object */ public function aggregate($pipeline, $pipelineOperator = []) { + $token = 'Aggregating from ' . $this->getFullName(); + Yii::info($token, __METHOD__); + Yii::beginProfile($token, __METHOD__); $args = func_get_args(); - return call_user_func_array([$this->mongoCollection, 'aggregate'], $args); + $result = call_user_func_array([$this->mongoCollection, 'aggregate'], $args); + Yii::endProfile($token, __METHOD__); + return $result; } /** @@ -297,6 +381,10 @@ class Collection extends Object */ public function mapReduce($keys, $initial, $reduce, $options = []) { + $token = 'Map reduce from ' . $this->getFullName(); + Yii::info($token, __METHOD__); + Yii::beginProfile($token, __METHOD__); + if (!($reduce instanceof \MongoCode)) { $reduce = new \MongoCode((string)$reduce); } @@ -314,6 +402,8 @@ class Collection extends Object } else { $result = $this->mongoCollection->group($keys, $initial, $reduce, $options); } + + Yii::endProfile($token, __METHOD__); if (array_key_exists('retval', $result)) { return $result['retval']; } else { @@ -329,8 +419,20 @@ class Collection extends Object protected function tryResultError($result) { if (is_array($result)) { - if (!empty($result['err'])) { - throw new Exception($result['errmsg'], (int)$result['code']); + 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'); diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index 110e716..e71df67 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -15,6 +15,14 @@ class CollectionTest extends MongoTestCase // 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'); @@ -181,5 +189,35 @@ class CollectionTest extends MongoTestCase '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(1, $collection->dropAllIndexes()); + $indexInfo = $collection->mongoCollection->getIndexInfo(); + $this->assertEquals(1, count($indexInfo)); } } \ No newline at end of file diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index 0bdcd74..27b7b13 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -5,6 +5,7 @@ namespace yiiunit\extensions\mongo; use yii\helpers\FileHelper; use yii\mongo\Connection; use Yii; +use yii\mongo\Exception; use yiiunit\TestCase; class MongoTestCase extends TestCase @@ -95,7 +96,11 @@ class MongoTestCase extends TestCase protected function dropCollection($name) { if ($this->mongo) { - $this->mongo->getCollection($name)->drop(); + try { + $this->mongo->getCollection($name)->drop(); + } catch (Exception $e) { + // shut down exception + } } } } \ No newline at end of file From 10bdd6b858f247fc5ec8f9ba0d78fb91190ea0b1 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 3 Dec 2013 12:27:44 +0200 Subject: [PATCH 29/49] Method "execute()" added to Mongo Database. --- extensions/mongo/Database.php | 11 +++++++++++ tests/unit/extensions/mongo/DatabaseTest.php | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/extensions/mongo/Database.php b/extensions/mongo/Database.php index c6ecc74..2ff5abb 100644 --- a/extensions/mongo/Database.php +++ b/extensions/mongo/Database.php @@ -75,4 +75,15 @@ class Database extends Object { return $this->mongoDb->createCollection($name, $options); } + + /** + * Executes Mongo command. + * @param array $command command specification. + * @param array $options options in format: "name" => "value" + * @return array database response. + */ + public function execute($command, $options = []) + { + return $this->mongoDb->command($command, $options); + } } \ No newline at end of file diff --git a/tests/unit/extensions/mongo/DatabaseTest.php b/tests/unit/extensions/mongo/DatabaseTest.php index ec0bf2d..dbe05c0 100644 --- a/tests/unit/extensions/mongo/DatabaseTest.php +++ b/tests/unit/extensions/mongo/DatabaseTest.php @@ -31,4 +31,16 @@ class DatabaseTest extends MongoTestCase $collectionRefreshed = $database->getCollection('customer', true); $this->assertFalse($collection === $collectionRefreshed); } + + public function testCommand() + { + $database = $connection = $this->getConnection()->getDatabase(); + + $result = $database->execute([ + 'distinct' => 'customer', + 'key' => 'name' + ]); + $this->assertTrue(array_key_exists('ok', $result)); + $this->assertTrue(array_key_exists('values', $result)); + } } \ No newline at end of file From a3f5236ea65d72b4d63370f4afb440bd81b8ecf9 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 3 Dec 2013 14:03:23 +0200 Subject: [PATCH 30/49] Mongo Collection "group" and "mapReduce" functions fixed. --- extensions/mongo/Collection.php | 105 +++++++++++++++++++------ tests/unit/extensions/mongo/CollectionTest.php | 58 +++++++++++++- 2 files changed, 136 insertions(+), 27 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 172e4e6..ba631e9 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -365,7 +365,7 @@ class Collection extends Object } /** - * Performs aggregation using Mongo Map Reduce mechanism. + * 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. @@ -377,37 +377,92 @@ class Collection extends Object * - 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. - * @see http://docs.mongodb.org/manual/core/map-reduce/ + * @see http://docs.mongodb.org/manual/reference/command/group/ + * @throws Exception on failure. */ - public function mapReduce($keys, $initial, $reduce, $options = []) + public function group($keys, $initial, $reduce, $options = []) { - $token = 'Map reduce from ' . $this->getFullName(); + $token = 'Grouping from ' . $this->getFullName(); Yii::info($token, __METHOD__); - Yii::beginProfile($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); - 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']); + 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']); + } + } + // 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); } - // 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); - } + } - Yii::endProfile($token, __METHOD__); - if (array_key_exists('retval', $result)) { - return $result['retval']; - } else { - return []; + /** + * 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. + * @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 = []) + { + $token = 'Map reduce from ' . $this->getFullName(); + Yii::info($token, __METHOD__); + + try { + Yii::beginProfile($token, __METHOD__); + 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); + } + + $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); } } diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index e71df67..4bce0fa 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -10,6 +10,7 @@ class CollectionTest extends MongoTestCase protected function tearDown() { $this->dropCollection('customer'); + $this->dropCollection('mapReduceOut'); parent::tearDown(); } @@ -157,7 +158,7 @@ class CollectionTest extends MongoTestCase /** * @depends testBatchInsert */ - public function testMapReduce() + public function testGroup() { $collection = $this->getConnection()->getCollection('customer'); $rows = [ @@ -175,12 +176,65 @@ class CollectionTest extends MongoTestCase $keys = ['address' => 1]; $initial = ['items' => []]; $reduce = "function (obj, prev) { prev.items.push(obj.name); }"; - $result = $collection->mapReduce($keys, $initial, $reduce); + $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 = $outputCollection->findAll(); + $expectedRows = [ + [ + '_id' => 1, + 'value' => 300, + ], + [ + '_id' => 2, + 'value' => 400, + ], + ]; + $this->assertEquals($expectedRows, $rows); + } + public function testCreateIndex() { $collection = $this->getConnection()->getCollection('customer'); From e3c281f3cb247dec24f8dfd14c444b8c62b98e07 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 3 Dec 2013 14:18:25 +0200 Subject: [PATCH 31/49] Mongo Collection "aggregate" method interface refactored. --- extensions/mongo/Collection.php | 22 +++++++++++++++------- extensions/mongo/Query.php | 4 ++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index ba631e9..e92480b 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -349,19 +349,27 @@ class Collection extends Object /** * Performs aggregation using Mongo Aggregation Framework. * @param array $pipeline list of pipeline operators, or just the first operator - * @param array $pipelineOperator Additional pipeline operators + * @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 = []) { $token = 'Aggregating from ' . $this->getFullName(); Yii::info($token, __METHOD__); - Yii::beginProfile($token, __METHOD__); - $args = func_get_args(); - $result = call_user_func_array([$this->mongoCollection, 'aggregate'], $args); - Yii::endProfile($token, __METHOD__); - return $result; + try { + Yii::beginProfile($token, __METHOD__); + $args = func_get_args(); + $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); + } } /** @@ -377,8 +385,8 @@ class Collection extends Object * - 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. - * @see http://docs.mongodb.org/manual/reference/command/group/ * @throws Exception on failure. + * @see http://docs.mongodb.org/manual/reference/command/group/ */ public function group($keys, $initial, $reduce, $options = []) { diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index 6259b99..16fd8db 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -244,8 +244,8 @@ class Query extends Component implements QueryInterface ] ]; $result = $collection->aggregate($pipelines); - if (!empty($result['ok'])) { - return $result['result'][0]['total']; + if (array_key_exists(0, $result)) { + return $result[0]['total']; } else { return 0; } From 1a9d5a11ffff12866ce0d8f126689f6e21297e4f Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 3 Dec 2013 15:28:35 +0200 Subject: [PATCH 32/49] Mongo Collection "deleteAllIndexes" return result fixed. --- extensions/mongo/Collection.php | 2 +- tests/unit/extensions/mongo/CollectionTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index e92480b..64242c1 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -165,7 +165,7 @@ class Collection extends Object try { $result = $this->mongoCollection->deleteIndexes(); $this->tryResultError($result); - return $result['nIndexesWas'] - 1; + return $result['nIndexesWas']; } catch (\Exception $e) { Yii::endProfile($token, __METHOD__); throw new Exception($e->getMessage(), (int)$e->getCode(), $e); diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index 4bce0fa..bc13b2e 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -270,7 +270,7 @@ class CollectionTest extends MongoTestCase { $collection = $this->getConnection()->getCollection('customer'); $collection->createIndex('name'); - $this->assertEquals(1, $collection->dropAllIndexes()); + $this->assertEquals(2, $collection->dropAllIndexes()); $indexInfo = $collection->mongoCollection->getIndexInfo(); $this->assertEquals(1, count($indexInfo)); } From 3e744b73e95c785bdd25161ca96761bc5e3beb99 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 3 Dec 2013 16:19:45 +0200 Subject: [PATCH 33/49] Mongo Collection "findAll" method removed. Logging and profiling for Mongo Query added. --- extensions/mongo/ActiveQuery.php | 5 +- extensions/mongo/Collection.php | 24 ++------ extensions/mongo/Query.php | 77 +++++++++++++++++++------- tests/unit/extensions/mongo/CollectionTest.php | 13 +++-- tests/unit/extensions/mongo/MongoTestCase.php | 17 ++++++ 5 files changed, 90 insertions(+), 46 deletions(-) diff --git a/extensions/mongo/ActiveQuery.php b/extensions/mongo/ActiveQuery.php index 9b7e207..9031723 100644 --- a/extensions/mongo/ActiveQuery.php +++ b/extensions/mongo/ActiveQuery.php @@ -29,10 +29,7 @@ class ActiveQuery extends Query implements ActiveQueryInterface public function all($db = null) { $cursor = $this->buildCursor($db); - $rows = []; - foreach ($cursor as $row) { - $rows[] = $row; - } + $rows = $this->fetchRows($cursor); if (!empty($rows)) { $models = $this->createModels($rows); if (!empty($this->with)) { diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 64242c1..aeae8a9 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -173,9 +173,12 @@ class Collection extends Object } /** - * @param array $condition - * @param array $fields - * @return \MongoCursor + * 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 = []) { @@ -183,21 +186,6 @@ class Collection extends Object } /** - * @param array $condition - * @param array $fields - * @return array - */ - public function findAll($condition = [], $fields = []) - { - $cursor = $this->find($condition, $fields); - $result = []; - foreach ($cursor as $data) { - $result[] = $data; - } - return $result; - } - - /** * Inserts new data into collection. * @param array|object $data data to be inserted. * @param array $options list of options in format: optionName => optionValue. diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index 16fd8db..1a00b08 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -10,6 +10,7 @@ namespace yii\mongo; use yii\base\Component; use yii\db\QueryInterface; use yii\db\QueryTrait; +use yii\helpers\Json; use Yii; /** @@ -104,6 +105,48 @@ class Query extends Component implements QueryInterface } /** + * @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 + * @throws Exception + * @return array|boolean + */ + protected function fetchRows(\MongoCursor $cursor, $all = true, $indexBy = null) + { + $token = 'Querying: ' . Json::encode($cursor->info()); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $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; + } + } + Yii::endProfile($token, __METHOD__); + return $result; + } catch (\Exception $e) { + Yii::endProfile($token, __METHOD__); + throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** * 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. @@ -112,20 +155,7 @@ class Query extends Component implements QueryInterface public function all($db = null) { $cursor = $this->buildCursor($db); - $result = []; - foreach ($cursor as $row) { - if ($this->indexBy !== null) { - if (is_string($this->indexBy)) { - $key = $row[$this->indexBy]; - } else { - $key = call_user_func($this->indexBy, $row); - } - $result[$key] = $row; - } else { - $result[] = $row; - } - } - return $result; + return $this->fetchRows($cursor, true, $this->indexBy); } /** @@ -138,11 +168,7 @@ class Query extends Component implements QueryInterface public function one($db = null) { $cursor = $this->buildCursor($db); - if ($cursor->hasNext()) { - return $cursor->getNext(); - } else { - return false; - } + return $this->fetchRows($cursor, false); } /** @@ -151,11 +177,22 @@ class Query extends Component implements QueryInterface * @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); - return $cursor->count(); + $token = 'Counting: ' . 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); + } } /** diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index bc13b2e..a50177d 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -45,6 +45,7 @@ class CollectionTest extends MongoTestCase /** * @depends testInsert + * @depends testFind */ public function testFindAll() { @@ -55,7 +56,11 @@ class CollectionTest extends MongoTestCase ]; $id = $collection->insert($data); - $rows = $collection->findAll(); + $cursor = $collection->find(); + $rows = []; + foreach ($cursor as $row) { + $rows[] = $row; + } $this->assertEquals(1, count($rows)); $this->assertEquals($id, $rows[0]['_id']); } @@ -129,7 +134,7 @@ class CollectionTest extends MongoTestCase $count = $collection->remove(['_id' => $id]); $this->assertEquals(1, $count); - $rows = $collection->findAll(); + $rows = $this->findAll($collection); $this->assertEquals(0, count($rows)); } @@ -151,7 +156,7 @@ class CollectionTest extends MongoTestCase $count = $collection->update(['_id' => $id], $newData); $this->assertEquals(1, $count); - list($row) = $collection->findAll(); + list($row) = $this->findAll($collection); $this->assertEquals($newData['name'], $row['name']); } @@ -221,7 +226,7 @@ class CollectionTest extends MongoTestCase $this->assertEquals('mapReduceOut', $result); $outputCollection = $this->getConnection()->getCollection($result); - $rows = $outputCollection->findAll(); + $rows = $this->findAll($outputCollection); $expectedRows = [ [ '_id' => 1, diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index 27b7b13..e344109 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -103,4 +103,21 @@ class MongoTestCase extends TestCase } } } + + /** + * 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; + } } \ No newline at end of file From a4224df518db8ea7702eb6177839e64b401e14d1 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Tue, 3 Dec 2013 17:28:10 +0200 Subject: [PATCH 34/49] Doc comments at Mongo updated. --- extensions/mongo/Collection.php | 52 +++++++++++++++++++++++++++++++++++- extensions/mongo/Connection.php | 58 ++++++++++++++++++++++++++++++++++++++--- extensions/mongo/Database.php | 8 ------ 3 files changed, 105 insertions(+), 13 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index aeae8a9..93307b6 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -14,6 +14,40 @@ use Yii; /** * Collection represents the Mongo collection information. * + * A collection object is usually created by calling [[Database::getCollection()]] or [[Connection::getCollection()]]. + * + * Collection provides the basic interface for the Mongo queries, mostly: insert, update, delete operations. + * For example: + * + * ~~~ + * $collection = Yii::$app->mongo->getCollection('customer'); + * $collection->insert(['name' => 'John Smith', 'status' => 1]); + * ~~~ + * + * 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 = [ + * ['AND', 'name', 'John'], + * ['OR', 'status', [1, 2, 3]], + * ]; + * print_r($collection->buildCondition($condition)); + * // outputs : + * [ + * '$or' => [ + * 'name' => 'John', + * 'status' => ['$in' => [1, 2, 3]], + * ] + * ] + * ~~~ + * + * To perform "find" queries, please use [[Query]] instead. + * + * @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. + * * @author Paul Klimov * @since 2.0 */ @@ -62,7 +96,7 @@ class Collection extends Object } /** - * Creates an index on the collection and the specified fields + * 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. @@ -233,6 +267,8 @@ class Collection extends Object /** * 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. @@ -418,6 +454,20 @@ class Collection extends Object * 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 diff --git a/extensions/mongo/Connection.php b/extensions/mongo/Connection.php index 725c757..5396e27 100644 --- a/extensions/mongo/Connection.php +++ b/extensions/mongo/Connection.php @@ -12,10 +12,58 @@ use yii\base\InvalidConfigException; use Yii; /** - * Class Connection + * Connection represents a connection to a MongoDb server. + * + * Connection works together with [[Database]] and [[Collection]] to provide data access + * to the Mongo database. They are wrappers of the [[MongoDB PHP extension]](http://us1.php.net/manual/en/book.mongo.php). + * + * To establish a DB connection, set [[dsn]] and then call [[open()]] to be true. + * + * The following example shows how to create a Connection instance and establish + * the DB connection: + * + * ~~~ + * $connection = new \yii\mongo\Connection([ + * 'dsn' => $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. - * @property QueryBuilder $queryBuilder The query builder for the current Mongo connection. This property * is read-only. * * @author Paul Klimov @@ -30,8 +78,8 @@ class Connection extends Component * mongodb://[username:password@]host1[:port1][,host2[:port2:],...][/dbname] * For example: * mongodb://localhost:27017 - * mongodb://developer:somepassword@localhost:27017 - * mongodb://developer:somepassword@localhost:27017/mydatabase + * mongodb://developer:password@localhost:27017 + * mongodb://developer:password@localhost:27017/mydatabase */ public $dsn; /** @@ -48,6 +96,8 @@ class Connection extends Component 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; /** diff --git a/extensions/mongo/Database.php b/extensions/mongo/Database.php index 2ff5abb..6495eb8 100644 --- a/extensions/mongo/Database.php +++ b/extensions/mongo/Database.php @@ -55,14 +55,6 @@ class Database extends Object } /** - * Drops this database. - */ - public function drop() - { - $this->mongoDb->drop(); - } - - /** * 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 From aa3a6dbe74132534beb730274bdac9c4c344795d Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Tue, 3 Dec 2013 20:28:10 +0200 Subject: [PATCH 35/49] Doc comments at Mongo updated. --- extensions/mongo/ActiveQuery.php | 26 ++++++++++++++---- extensions/mongo/ActiveRecord.php | 58 +++++++++++++++++++-------------------- extensions/mongo/Collection.php | 22 +++++++++++---- extensions/mongo/Query.php | 36 ++++++++++++++++++------ 4 files changed, 94 insertions(+), 48 deletions(-) diff --git a/extensions/mongo/ActiveQuery.php b/extensions/mongo/ActiveQuery.php index 9031723..fc02df9 100644 --- a/extensions/mongo/ActiveQuery.php +++ b/extensions/mongo/ActiveQuery.php @@ -11,7 +11,23 @@ use yii\db\ActiveQueryInterface; use yii\db\ActiveQueryTrait; /** - * Class ActiveQuery + * ActiveQuery represents a Mongo query associated with an Active Record class. + * + * ActiveQuery instances are usually created by [[ActiveRecord::find()]]. + * + * Because ActiveQuery extends from [[Query]], one can use query methods, such as [[where()]], + * [[orderBy()]] to customize the query options. + * + * ActiveQuery also provides the following additional query options: + * + * - [[with()]]: list of relations that this query should be performed with. + * - [[asArray()]]: whether to return each record as an array. + * + * These options can be configured using methods of the same name. For example: + * + * ~~~ + * $customers = Customer::find()->with('orders')->asArray()->all(); + * ~~~ * * @author Paul Klimov * @since 2.0 @@ -22,8 +38,8 @@ class ActiveQuery extends Query implements ActiveQueryInterface /** * Executes query and returns all results as an array. - * @param Connection $db the DB connection used to create the DB command. - * If null, the DB connection returned by [[modelClass]] will be used. + * @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) @@ -43,8 +59,8 @@ class ActiveQuery extends Query implements ActiveQueryInterface /** * Executes query and returns a single row of result. - * @param Connection $db the DB connection used to create the DB command. - * If null, the DB connection returned by [[modelClass]] will be used. + * @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. diff --git a/extensions/mongo/ActiveRecord.php b/extensions/mongo/ActiveRecord.php index 9c83080..41c1d7a 100644 --- a/extensions/mongo/ActiveRecord.php +++ b/extensions/mongo/ActiveRecord.php @@ -16,7 +16,7 @@ use yii\helpers\Inflector; use yii\helpers\StringHelper; /** - * Class ActiveRecord + * ActiveRecord is the base class for classes representing Mongo documents in terms of objects. * * @author Paul Klimov * @since 2.0 @@ -24,8 +24,8 @@ use yii\helpers\StringHelper; abstract class ActiveRecord extends BaseActiveRecord { /** - * Returns the database connection used by this AR class. - * By default, the "db" application component is used as the database connection. + * 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. */ @@ -35,18 +35,18 @@ abstract class ActiveRecord extends BaseActiveRecord } /** - * Updates the whole table using the provided attribute values and conditions. + * 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 table - * @param array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. + * @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 rows updated. + * @return integer the number of documents updated. */ public static function updateAll($attributes, $condition = [], $options = []) { @@ -54,7 +54,7 @@ abstract class ActiveRecord extends BaseActiveRecord } /** - * Updates the whole table using the provided counter changes and conditions. + * Updates all documents in the collection using the provided counter changes and conditions. * For example, to increment all customers' age by 1, * * ~~~ @@ -63,10 +63,10 @@ abstract class ActiveRecord extends BaseActiveRecord * * @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 the conditions that will be put in the WHERE part of the UPDATE SQL. + * @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 rows updated. + * @return integer the number of documents updated. */ public static function updateAllCounters($counters, $condition = [], $options = []) { @@ -74,8 +74,8 @@ abstract class ActiveRecord extends BaseActiveRecord } /** - * Deletes rows in the table using the provided conditions. - * WARNING: If you do not specify any condition, this method will delete ALL rows in the table. + * 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: * @@ -83,10 +83,10 @@ abstract class ActiveRecord extends BaseActiveRecord * Customer::deleteAll('status = 3'); * ~~~ * - * @param array $condition the conditions that will be put in the WHERE part of the DELETE SQL. + * @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 rows updated. + * @return integer the number of documents deleted. */ public static function deleteAll($condition = [], $options = []) { @@ -99,7 +99,7 @@ abstract class ActiveRecord extends BaseActiveRecord /** * Creates an [[ActiveQuery]] instance. - * This method is called by [[find()]] to start a SELECT query. + * 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. @@ -118,7 +118,7 @@ abstract class ActiveRecord extends BaseActiveRecord * 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 the table name + * @return string|array the collection name */ public static function collectionName() { @@ -138,9 +138,9 @@ abstract class ActiveRecord extends BaseActiveRecord * 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 table with single primary key. + * Note that an array should be returned even for a collection with single primary key. * - * @return string[] the primary keys of the associated database table. + * @return string[] the primary keys of the associated Mongo collection. */ public static function primaryKey() { @@ -178,7 +178,7 @@ abstract class ActiveRecord extends BaseActiveRecord } /** - * Inserts a row into the associated database table using the attribute values of this record. + * Inserts a row into the associated Mongo collection using the attribute values of this record. * * This method performs the following steps in order: * @@ -187,7 +187,7 @@ abstract class ActiveRecord extends BaseActiveRecord * 2. call [[afterValidate()]] when `$runValidation` is true. * 3. call [[beforeSave()]]. If the method returns false, it will skip the * rest of the steps; - * 4. insert the record into database. If this fails, it will skip the rest of the steps; + * 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]], @@ -196,8 +196,8 @@ abstract class ActiveRecord extends BaseActiveRecord * * Only the [[dirtyAttributes|changed attribute values]] will be inserted into database. * - * If the table's primary key is auto-incremental and is null during insertion, - * it will be populated with the actual value after insertion. + * 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: * @@ -209,9 +209,9 @@ abstract class ActiveRecord extends BaseActiveRecord * ~~~ * * @param boolean $runValidation whether to perform validation before saving the record. - * If the validation fails, the record will not be inserted into the database. + * 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 from DB will be saved. + * 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. */ @@ -287,20 +287,20 @@ abstract class ActiveRecord extends BaseActiveRecord } /** - * Deletes the table row corresponding to this active record. + * 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 record from the database; + * 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 rows deleted, or false if the deletion is unsuccessful for some reason. - * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful. + * @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. @@ -331,7 +331,7 @@ abstract class ActiveRecord extends BaseActiveRecord * 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 database table. + * @return boolean whether the two active records refer to the same row in the same Mongo collection. */ public function equals($record) { diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 93307b6..5eca45a 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -24,26 +24,38 @@ use Yii; * $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 = [ - * ['AND', 'name', 'John'], - * ['OR', 'status', [1, 2, 3]], + * [ + * 'OR', + * ['AND', ['first_name' => 'John'], ['last_name' => 'Smith']], + * ['status' => [1, 2, 3]] + * ], * ]; * print_r($collection->buildCondition($condition)); * // outputs : * [ * '$or' => [ - * 'name' => 'John', - * 'status' => ['$in' => [1, 2, 3]], + * [ + * 'first_name' => 'John', + * 'last_name' => 'John', + * ], + * [ + * 'status' => ['$in' => [1, 2, 3]], + * ] * ] * ] * ~~~ * - * To perform "find" queries, please use [[Query]] instead. + * 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. diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index 1a00b08..8c46479 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -14,7 +14,22 @@ use yii\helpers\Json; use Yii; /** - * Class Query + * Query represents Mongo "find" operation. + * + * Query provides a set of methods to facilitate the specification of "find" command. + * These methods can be chained together. + * + * For example, + * + * ~~~ + * $query = new Query; + * // compose the query + * $query->select(['name', 'status']) + * ->from('customer') + * ->limit(10); + * // execute the query + * $rows = $query->all(); + * ~~~ * * @author Paul Klimov * @since 2.0 @@ -75,6 +90,7 @@ class Query extends Component implements QueryInterface } /** + * Builds the Mongo cursor for this query. * @param Connection $db the database connection used to execute the query. * @return \MongoCursor mongo cursor instance. */ @@ -105,11 +121,13 @@ class Query extends Component implements QueryInterface } /** + * 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 - * @throws Exception - * @return array|boolean + * @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(\MongoCursor $cursor, $all = true, $indexBy = null) { @@ -173,7 +191,7 @@ class Query extends Component implements QueryInterface /** * Returns the number of records. - * @param string $q the COUNT expression. Defaults to '*'. + * @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 @@ -208,7 +226,7 @@ class Query extends Component implements QueryInterface /** * Returns the sum of the specified column values. - * @param string $q the column name or expression. + * @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. @@ -221,7 +239,7 @@ class Query extends Component implements QueryInterface /** * Returns the average of the specified column values. - * @param string $q the column name or expression. + * @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. @@ -234,7 +252,7 @@ class Query extends Component implements QueryInterface /** * Returns the minimum of the specified column values. - * @param string $q the column name or expression. + * @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. @@ -247,7 +265,7 @@ class Query extends Component implements QueryInterface /** * Returns the maximum of the specified column values. - * @param string $q the column name or expression. + * @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. From 156f7c04676ce35479de1e872b7973ea5a4b50c7 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 4 Dec 2013 11:18:09 +0200 Subject: [PATCH 36/49] Unit tests for Mongo updated to check nested columns. --- tests/unit/extensions/mongo/ActiveRecordTest.php | 26 ++++++++++++++++++++++++ tests/unit/extensions/mongo/QueryRunTest.php | 12 +++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/unit/extensions/mongo/ActiveRecordTest.php b/tests/unit/extensions/mongo/ActiveRecordTest.php index 812d6e3..78e1da1 100644 --- a/tests/unit/extensions/mongo/ActiveRecordTest.php +++ b/tests/unit/extensions/mongo/ActiveRecordTest.php @@ -218,4 +218,30 @@ class ActiveRecordTest extends MongoTestCase $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/QueryRunTest.php b/tests/unit/extensions/mongo/QueryRunTest.php index 078e1c7..7fe4812 100644 --- a/tests/unit/extensions/mongo/QueryRunTest.php +++ b/tests/unit/extensions/mongo/QueryRunTest.php @@ -32,6 +32,11 @@ class QueryRunTest extends MongoTestCase $rows[] = [ 'name' => 'name' . $i, 'address' => 'address' . $i, + 'avatar' => [ + 'width' => 50 + $i, + 'height' => 100 + $i, + 'url' => 'http://some.url/' . $i, + ], ]; } $collection->batchInsert($rows); @@ -99,11 +104,18 @@ class QueryRunTest extends MongoTestCase 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() From 16d857df50fe22b7a55e10a3929c94eea05d78fd Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 4 Dec 2013 14:12:19 +0200 Subject: [PATCH 37/49] Mongo File Collection added. --- extensions/mongo/Collection.php | 18 ++++ extensions/mongo/Connection.php | 26 ++++- extensions/mongo/Database.php | 31 ++++++ extensions/mongo/file/Collection.php | 109 +++++++++++++++++++++ tests/unit/extensions/mongo/ConnectionTest.php | 18 ++++ tests/unit/extensions/mongo/DatabaseTest.php | 17 ++++ tests/unit/extensions/mongo/MongoTestCase.php | 15 +++ .../unit/extensions/mongo/file/CollectionTest.php | 87 ++++++++++++++++ 8 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 extensions/mongo/file/Collection.php create mode 100644 tests/unit/extensions/mongo/file/CollectionTest.php diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 5eca45a..1add592 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -59,6 +59,7 @@ use Yii; * * @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 @@ -87,6 +88,14 @@ class Collection extends Object } /** + * @return array last error information. + */ + public function getLastError() + { + return $this->mongoCollection->db->lastError(); + } + + /** * Drops this collection. * @throws Exception on failure. * @return boolean whether the operation successful. @@ -553,6 +562,15 @@ class Collection extends Object } /** + * 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. diff --git a/extensions/mongo/Connection.php b/extensions/mongo/Connection.php index 5396e27..8c19cb4 100644 --- a/extensions/mongo/Connection.php +++ b/extensions/mongo/Connection.php @@ -162,10 +162,10 @@ class Connection extends Component /** * Returns the Mongo collection with the given name. - * @param string|array $name collection name. If string considered as the name of the collection + * @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 table schema even if it is found in the cache. + * @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) @@ -179,6 +179,28 @@ class Connection extends Component } /** + * 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 */ diff --git a/extensions/mongo/Database.php b/extensions/mongo/Database.php index 6495eb8..01d43de 100644 --- a/extensions/mongo/Database.php +++ b/extensions/mongo/Database.php @@ -26,6 +26,10 @@ class Database extends Object * @var Collection[] list of collections. */ private $_collections = []; + /** + * @var file\Collection[] list of GridFS collections. + */ + private $_fileCollections = []; /** * Returns the Mongo collection with the given name. @@ -42,6 +46,20 @@ class Database extends Object } /** + * Returns Mongo GridFS collection with given prefix. + * @param string $prefix collection prefix. + * @param boolean $refresh whether to reload the table schema 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. @@ -55,6 +73,19 @@ class Database extends Object } /** + * 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 diff --git a/extensions/mongo/file/Collection.php b/extensions/mongo/file/Collection.php new file mode 100644 index 0000000..f3e8d33 --- /dev/null +++ b/extensions/mongo/file/Collection.php @@ -0,0 +1,109 @@ + + * @since 2.0 + */ +class Collection extends \yii\mongo\Collection +{ + /** + * @var \MongoGridFS Mongo GridFS collection instance. + */ + public $mongoCollection; + + /** + * 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; + } + + /** + * @param string $filename name of the file to store. + * @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. + */ + public function put($filename, $metadata = []) + { + return $this->mongoCollection->put($filename, $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. + */ + public function storeBytes($bytes, $metadata = [], $options = []) + { + $options = array_merge(['w' => 1], $options); + return $this->mongoCollection->storeBytes($bytes, $metadata, $options); + } + + /** + * @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. + */ + public function storeFile($filename, $metadata = [], $options = []) + { + $options = array_merge(['w' => 1], $options); + return $this->mongoCollection->storeFile($filename, $metadata, $options); + } + + /** + * @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. + */ + public function storeUploads($name, $metadata = []) + { + return $this->mongoCollection->storeUpload($name, $metadata); + } + + /** + * @param mixed $id _id of the file to find. + * @return \MongoGridFSFile|null found file, or null if file does not exist + */ + public function get($id) + { + return $this->mongoCollection->get($id); + } + + /** + * @param mixed $id _id of the file to find. + * @return boolean whether the operation was successful. + */ + public function delete($id) + { + $result = $this->mongoCollection->delete($id); + $this->tryResultError($result); + return true; + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/ConnectionTest.php b/tests/unit/extensions/mongo/ConnectionTest.php index b3252b9..04d5351 100644 --- a/tests/unit/extensions/mongo/ConnectionTest.php +++ b/tests/unit/extensions/mongo/ConnectionTest.php @@ -3,6 +3,7 @@ namespace yiiunit\extensions\mongo; use yii\mongo\Collection; +use yii\mongo\file\Collection as FileCollection; use yii\mongo\Connection; use yii\mongo\Database; @@ -98,4 +99,21 @@ class ConnectionTest extends MongoTestCase $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 index dbe05c0..e844d9d 100644 --- a/tests/unit/extensions/mongo/DatabaseTest.php +++ b/tests/unit/extensions/mongo/DatabaseTest.php @@ -3,6 +3,7 @@ namespace yiiunit\extensions\mongo; use yii\mongo\Collection; +use yii\mongo\file\Collection as FileCollection; /** * @group mongo @@ -12,6 +13,7 @@ class DatabaseTest extends MongoTestCase protected function tearDown() { $this->dropCollection('customer'); + $this->dropFileCollection('testfs'); parent::tearDown(); } @@ -32,6 +34,21 @@ class DatabaseTest extends MongoTestCase $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 testCommand() { $database = $connection = $this->getConnection()->getDatabase(); diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index e344109..a14e27b 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -105,6 +105,21 @@ class MongoTestCase extends TestCase } /** + * Drops the specified file collection. + * @param string $name file collection name. + */ + protected function dropFileCollection($name) + { + 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 diff --git a/tests/unit/extensions/mongo/file/CollectionTest.php b/tests/unit/extensions/mongo/file/CollectionTest.php new file mode 100644 index 0000000..bed7ab9 --- /dev/null +++ b/tests/unit/extensions/mongo/file/CollectionTest.php @@ -0,0 +1,87 @@ +dropFileCollection('fs'); + parent::tearDown(); + } + + // Tests : + + public function testFind() + { + $collection = $this->getConnection()->getFileCollection(); + $cursor = $collection->find(); + $this->assertTrue($cursor instanceof \MongoGridFSCursor); + } + + public function testStoreFile() + { + $collection = $this->getConnection()->getFileCollection(); + + $filename = __FILE__; + $id = $collection->storeFile($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 testStoreBytes() + { + $collection = $this->getConnection()->getFileCollection(); + + $bytes = 'Test file content'; + $id = $collection->storeBytes($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 testStoreBytes + */ + public function testGet() + { + $collection = $this->getConnection()->getFileCollection(); + + $bytes = 'Test file content'; + $id = $collection->storeBytes($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->storeBytes($bytes); + + $this->assertTrue($collection->delete($id)); + + $file = $collection->get($id); + $this->assertNull($file); + } +} \ No newline at end of file From f0e62971aacdf7d040daeb1301aa7fcb3fb28752 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Wed, 4 Dec 2013 17:02:26 +0200 Subject: [PATCH 38/49] Mongo GridFS Query added. Mongo GridFS AR added as draft. --- extensions/mongo/file/ActiveQuery.php | 91 +++++++++++++ extensions/mongo/file/ActiveRecord.php | 146 +++++++++++++++++++++ extensions/mongo/file/ActiveRelation.php | 22 ++++ extensions/mongo/file/Query.php | 32 +++++ tests/unit/extensions/mongo/MongoTestCase.php | 2 +- .../unit/extensions/mongo/file/CollectionTest.php | 3 + tests/unit/extensions/mongo/file/QueryTest.php | 53 ++++++++ 7 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 extensions/mongo/file/ActiveQuery.php create mode 100644 extensions/mongo/file/ActiveRecord.php create mode 100644 extensions/mongo/file/ActiveRelation.php create mode 100644 extensions/mongo/file/Query.php create mode 100644 tests/unit/extensions/mongo/file/QueryTest.php diff --git a/extensions/mongo/file/ActiveQuery.php b/extensions/mongo/file/ActiveQuery.php new file mode 100644 index 0000000..005e4ce --- /dev/null +++ b/extensions/mongo/file/ActiveQuery.php @@ -0,0 +1,91 @@ + + * @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 0000000..b7f6f51 --- /dev/null +++ b/extensions/mongo/file/ActiveRecord.php @@ -0,0 +1,146 @@ + + * @since 2.0 + */ +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. `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()]); + } + + /** + * 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: primary key attribute "_id" should be always present in returned array. + * @return array list of attribute names. + */ + public function attributes() + { + return ['id', 'file']; + } + + /** + * @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 CActiveRecord::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; + } + + public function getContent() + { + $file = $this->getAttribute('file'); + if (empty($file)) { + return null; + } + if ($file instanceof \MongoGridFSFile) { + return $file->getBytes(); + } + } + + public function getFileName() + { + $file = $this->getAttribute('file'); + if (empty($file)) { + return null; + } + if ($file instanceof \MongoGridFSFile) { + return $file->getFilename(); + } + } + +} \ 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 0000000..6ea0831 --- /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/Query.php b/extensions/mongo/file/Query.php new file mode 100644 index 0000000..6cf2215 --- /dev/null +++ b/extensions/mongo/file/Query.php @@ -0,0 +1,32 @@ + + * @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); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index a14e27b..eefb972 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -108,7 +108,7 @@ class MongoTestCase extends TestCase * Drops the specified file collection. * @param string $name file collection name. */ - protected function dropFileCollection($name) + protected function dropFileCollection($name = 'fs') { if ($this->mongo) { try { diff --git a/tests/unit/extensions/mongo/file/CollectionTest.php b/tests/unit/extensions/mongo/file/CollectionTest.php index bed7ab9..a0aaa3e 100644 --- a/tests/unit/extensions/mongo/file/CollectionTest.php +++ b/tests/unit/extensions/mongo/file/CollectionTest.php @@ -4,6 +4,9 @@ namespace yiiunit\extensions\mongo\file; use yiiunit\extensions\mongo\MongoTestCase; +/** + * @group mongo + */ class CollectionTest extends MongoTestCase { protected function tearDown() diff --git a/tests/unit/extensions/mongo/file/QueryTest.php b/tests/unit/extensions/mongo/file/QueryTest.php new file mode 100644 index 0000000..cbba53c --- /dev/null +++ b/tests/unit/extensions/mongo/file/QueryTest.php @@ -0,0 +1,53 @@ +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->storeBytes('content' . $i, ['filename' => 'name' . $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($row instanceof \MongoGridFSFile); + } +} \ No newline at end of file From 85a32bea4246a49df95e5d0b782922ba13b5623a Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Wed, 4 Dec 2013 21:48:43 +0200 Subject: [PATCH 39/49] Method "getChunkCollection()" added to Mongo file collection. Method file unit tests improved. --- extensions/mongo/Database.php | 4 +-- extensions/mongo/file/ActiveRecord.php | 30 +++++++++++++++++++++- extensions/mongo/file/Collection.php | 22 ++++++++++++++++ .../unit/extensions/mongo/file/CollectionTest.php | 8 ++++++ tests/unit/extensions/mongo/file/QueryTest.php | 18 ++++++++++++- 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/extensions/mongo/Database.php b/extensions/mongo/Database.php index 01d43de..fcc733f 100644 --- a/extensions/mongo/Database.php +++ b/extensions/mongo/Database.php @@ -34,7 +34,7 @@ class Database extends Object /** * Returns the Mongo collection with the given name. * @param string $name collection name - * @param boolean $refresh whether to reload the table schema even if it is found in the cache. + * @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) @@ -48,7 +48,7 @@ class Database extends Object /** * Returns Mongo GridFS collection with given prefix. * @param string $prefix collection prefix. - * @param boolean $refresh whether to reload the table schema even if it is found in the cache. + * @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) diff --git a/extensions/mongo/file/ActiveRecord.php b/extensions/mongo/file/ActiveRecord.php index b7f6f51..35f7ad1 100644 --- a/extensions/mongo/file/ActiveRecord.php +++ b/extensions/mongo/file/ActiveRecord.php @@ -16,6 +16,11 @@ namespace yii\mongo\file; class ActiveRecord extends \yii\mongo\ActiveRecord { /** + * @var \MongoGridFSFile|string + */ + public $file; + + /** * 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 @@ -49,6 +54,29 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } /** + * Creates an active record object using a row of data. + * This method is called by [[ActiveQuery]] to populate the query results + * into Active Records. It is not meant to be used to create new records. + * @param \MongoGridFSFile $row attribute values (name => value) + * @return ActiveRecord the newly created active record. + */ + public static function create($row) + { + $record = static::instantiate($row); + $columns = array_flip($record->attributes()); + foreach ($row->file as $name => $value) { + if (isset($columns[$name])) { + $record->setAttribute($name, $value); + } else { + $record->$name = $value; + } + } + $record->setOldAttributes($record->getAttributes()); + $record->afterFind(); + return $record; + } + + /** * Returns the list of all attribute names of the model. * This method could be overridden by child classes to define available attributes. * Note: primary key attribute "_id" should be always present in returned array. @@ -56,7 +84,7 @@ class ActiveRecord extends \yii\mongo\ActiveRecord */ public function attributes() { - return ['id', 'file']; + return ['id', 'filename']; } /** diff --git a/extensions/mongo/file/Collection.php b/extensions/mongo/file/Collection.php index f3e8d33..d9de3f9 100644 --- a/extensions/mongo/file/Collection.php +++ b/extensions/mongo/file/Collection.php @@ -8,10 +8,12 @@ namespace yii\mongo\file; use yii\mongo\Exception; +use Yii; /** * Collection represents the Mongo GridFS collection information. * + * @property \yii\mongo\Collection $chunkCollection file chunks Mongo collection. This property is read-only. * @method \MongoGridFSCursor find() returns a cursor for the search results. * * @author Paul Klimov @@ -23,6 +25,26 @@ 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. diff --git a/tests/unit/extensions/mongo/file/CollectionTest.php b/tests/unit/extensions/mongo/file/CollectionTest.php index a0aaa3e..acec5ed 100644 --- a/tests/unit/extensions/mongo/file/CollectionTest.php +++ b/tests/unit/extensions/mongo/file/CollectionTest.php @@ -17,6 +17,14 @@ class CollectionTest extends MongoTestCase // 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(); diff --git a/tests/unit/extensions/mongo/file/QueryTest.php b/tests/unit/extensions/mongo/file/QueryTest.php index cbba53c..b4c2f0f 100644 --- a/tests/unit/extensions/mongo/file/QueryTest.php +++ b/tests/unit/extensions/mongo/file/QueryTest.php @@ -29,7 +29,10 @@ class QueryTest extends MongoTestCase { $collection = $this->getConnection()->getFileCollection(); for ($i = 1; $i <= 10; $i++) { - $collection->storeBytes('content' . $i, ['filename' => 'name' . $i]); + $collection->storeBytes('content' . $i, [ + 'filename' => 'name' . $i, + 'file_index' => $i, + ]); } } @@ -50,4 +53,17 @@ class QueryTest extends MongoTestCase $row = $query->from('fs')->one($connection); $this->assertTrue($row 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->file['filename']); + } } \ No newline at end of file From ca608a81a4be46ccdb340316e66b520831ed9756 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 5 Dec 2013 00:15:23 +0200 Subject: [PATCH 40/49] Mongo file Active Record updated. --- extensions/mongo/ActiveRecord.php | 36 +++-- extensions/mongo/file/ActiveRecord.php | 156 +++++++++++++-------- extensions/mongo/file/Query.php | 50 +++++++ tests/unit/data/ar/mongo/file/ActiveRecord.php | 16 +++ tests/unit/data/ar/mongo/file/CustomerFile.php | 27 ++++ .../extensions/mongo/file/ActiveRecordTest.php | 119 ++++++++++++++++ tests/unit/extensions/mongo/file/QueryTest.php | 5 +- 7 files changed, 335 insertions(+), 74 deletions(-) create mode 100644 tests/unit/data/ar/mongo/file/ActiveRecord.php create mode 100644 tests/unit/data/ar/mongo/file/CustomerFile.php create mode 100644 tests/unit/extensions/mongo/file/ActiveRecordTest.php diff --git a/extensions/mongo/ActiveRecord.php b/extensions/mongo/ActiveRecord.php index 41c1d7a..b0a360e 100644 --- a/extensions/mongo/ActiveRecord.php +++ b/extensions/mongo/ActiveRecord.php @@ -250,7 +250,7 @@ abstract class ActiveRecord extends BaseActiveRecord } /** - * @see CActiveRecord::update() + * @see ActiveRecord::update() * @throws StaleObjectException */ protected function updateInternal($attributes = null) @@ -309,24 +309,34 @@ abstract class ActiveRecord extends BaseActiveRecord { $result = false; if ($this->beforeDelete()) { - // we do not check the return value of deleteAll() because it's possible - // the record is already deleted in the database and thus the method will return 0 - $condition = $this->getOldPrimaryKey(true); - $lock = $this->optimisticLock(); - if ($lock !== null) { - $condition[$lock] = $this->$lock; - } - $result = static::getCollection()->remove($condition); - if ($lock !== null && !$result) { - throw new StaleObjectException('The object being deleted is outdated.'); - } - $this->setOldAttributes(null); + $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. diff --git a/extensions/mongo/file/ActiveRecord.php b/extensions/mongo/file/ActiveRecord.php index 35f7ad1..36fe644 100644 --- a/extensions/mongo/file/ActiveRecord.php +++ b/extensions/mongo/file/ActiveRecord.php @@ -7,6 +7,10 @@ namespace yii\mongo\file; +use yii\base\InvalidParamException; +use yii\db\StaleObjectException; +use yii\web\UploadedFile; + /** * ActiveRecord is the base class for classes representing Mongo GridFS files in terms of objects. * @@ -16,11 +20,6 @@ namespace yii\mongo\file; class ActiveRecord extends \yii\mongo\ActiveRecord { /** - * @var \MongoGridFSFile|string - */ - public $file; - - /** * 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 @@ -54,29 +53,6 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } /** - * Creates an active record object using a row of data. - * This method is called by [[ActiveQuery]] to populate the query results - * into Active Records. It is not meant to be used to create new records. - * @param \MongoGridFSFile $row attribute values (name => value) - * @return ActiveRecord the newly created active record. - */ - public static function create($row) - { - $record = static::instantiate($row); - $columns = array_flip($record->attributes()); - foreach ($row->file as $name => $value) { - if (isset($columns[$name])) { - $record->setAttribute($name, $value); - } else { - $record->$name = $value; - } - } - $record->setOldAttributes($record->getAttributes()); - $record->afterFind(); - return $record; - } - - /** * Returns the list of all attribute names of the model. * This method could be overridden by child classes to define available attributes. * Note: primary key attribute "_id" should be always present in returned array. @@ -84,7 +60,16 @@ class ActiveRecord extends \yii\mongo\ActiveRecord */ public function attributes() { - return ['id', 'filename']; + return [ + '_id', + 'filename', + 'uploadDate', + 'length', + 'chunkSize', + 'md5', + 'file', + 'newFileContent' + ]; } /** @@ -103,7 +88,30 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } } $collection = static::getCollection(); - $newId = $collection->insert($values); + if (array_key_exists('newFileContent', $values)) { + $fileContent = $values['newFileContent']; + unset($values['newFileContent']); + unset($values['file']); + $newId = $collection->storeBytes($fileContent, $values); + } elseif (array_key_exists('file', $values)) { + $file = $values['file']; + if ($file instanceof UploadedFile) { + $fileName = $file->tempName; + } elseif (is_string($file)) { + if (file_exists($file)) { + $fileName = $file; + } else { + throw new InvalidParamException("File '{$file}' does not exist."); + } + } else { + throw new InvalidParamException('Unsupported type of "file" attribute.'); + } + unset($values['newFileContent']); + unset($values['file']); + $newId = $collection->storeFile($fileName, $values); + } else { + $newId = $collection->insert($values); + } $this->setAttribute('_id', $newId); foreach ($values as $name => $value) { $this->setOldAttribute($name, $value); @@ -113,7 +121,7 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } /** - * @see CActiveRecord::update() + * @see ActiveRecord::update() * @throws StaleObjectException */ protected function updateInternal($attributes = null) @@ -126,20 +134,50 @@ class ActiveRecord extends \yii\mongo\ActiveRecord $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.'); + $collection = static::getCollection(); + if (array_key_exists('newFileContent', $values)) { + $fileContent = $values['newFileContent']; + unset($values['newFileContent']); + unset($values['file']); + $values['_id'] = $this->getAttribute('_id'); + $this->deleteInternal(); + $collection->storeBytes($fileContent, $values); + $rows = 1; + } elseif (array_key_exists('file', $values)) { + $file = $values['file']; + if ($file instanceof UploadedFile) { + $fileName = $file->tempName; + } elseif (is_string($file)) { + if (file_exists($file)) { + $fileName = $file; + } else { + throw new InvalidParamException("File '{$file}' does not exist."); + } + } else { + throw new InvalidParamException('Unsupported type of "file" attribute.'); + } + unset($values['newFileContent']); + unset($values['file']); + $values['_id'] = $this->getAttribute('_id'); + $this->deleteInternal(); + $collection->storeFile($fileName, $values); + $rows = 1; + } 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) { @@ -149,26 +187,26 @@ class ActiveRecord extends \yii\mongo\ActiveRecord return $rows; } - public function getContent() + /** + * Returns the associated file content. + * @return null|string file content. + * @throws \yii\base\InvalidParamException on invalid file value. + */ + public function getFileContent() { $file = $this->getAttribute('file'); if (empty($file)) { return null; - } - if ($file instanceof \MongoGridFSFile) { + } elseif ($file instanceof \MongoGridFSFile) { 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('Unsupported type of "file" attribute.'); } } - - public function getFileName() - { - $file = $this->getAttribute('file'); - if (empty($file)) { - return null; - } - if ($file instanceof \MongoGridFSFile) { - return $file->getFilename(); - } - } - } \ No newline at end of file diff --git a/extensions/mongo/file/Query.php b/extensions/mongo/file/Query.php index 6cf2215..b22b64f 100644 --- a/extensions/mongo/file/Query.php +++ b/extensions/mongo/file/Query.php @@ -8,6 +8,8 @@ namespace yii\mongo\file; use Yii; +use yii\helpers\Json; +use yii\mongo\Exception; /** * Class Query @@ -29,4 +31,52 @@ class Query extends \yii\mongo\Query } return $db->getFileCollection($this->from); } + + /** + * 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(\MongoCursor $cursor, $all = true, $indexBy = null) + { + $token = 'Querying: ' . Json::encode($cursor->info()); + Yii::info($token, __METHOD__); + try { + Yii::beginProfile($token, __METHOD__); + $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; + } + } + Yii::endProfile($token, __METHOD__); + return $result; + } 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/tests/unit/data/ar/mongo/file/ActiveRecord.php b/tests/unit/data/ar/mongo/file/ActiveRecord.php new file mode 100644 index 0000000..70ebeb2 --- /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/extensions/mongo/file/ActiveRecordTest.php b/tests/unit/extensions/mongo/file/ActiveRecordTest.php new file mode 100644 index 0000000..b4ab890 --- /dev/null +++ b/tests/unit/extensions/mongo/file/ActiveRecordTest.php @@ -0,0 +1,119 @@ +getConnection(); + $this->setUpTestRows(); + } + + protected function tearDown() + { + $this->dropFileCollection(CustomerFile::collectionName()); + parent::tearDown(); + } + + /** + * 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->storeBytes($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); + } +} \ No newline at end of file diff --git a/tests/unit/extensions/mongo/file/QueryTest.php b/tests/unit/extensions/mongo/file/QueryTest.php index b4c2f0f..2fdf1f1 100644 --- a/tests/unit/extensions/mongo/file/QueryTest.php +++ b/tests/unit/extensions/mongo/file/QueryTest.php @@ -51,7 +51,8 @@ class QueryTest extends MongoTestCase $connection = $this->getConnection(); $query = new Query; $row = $query->from('fs')->one($connection); - $this->assertTrue($row instanceof \MongoGridFSFile); + $this->assertTrue(is_array($row)); + $this->assertTrue($row['file'] instanceof \MongoGridFSFile); } public function testDirectMatch() @@ -64,6 +65,6 @@ class QueryTest extends MongoTestCase $this->assertEquals(1, count($rows)); /** @var $file \MongoGridFSFile */ $file = $rows[0]; - $this->assertEquals('name5', $file->file['filename']); + $this->assertEquals('name5', $file['filename']); } } \ No newline at end of file From 77f10ed91b86609804c0e0632e3d9a1af570879f Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 5 Dec 2013 12:36:53 +0200 Subject: [PATCH 41/49] Mongo file Active Record saving fixed. --- extensions/mongo/file/ActiveRecord.php | 33 ++++- extensions/mongo/file/Collection.php | 23 +--- tests/unit/extensions/mongo/ActiveRecordTest.php | 3 +- .../extensions/mongo/file/ActiveRecordTest.php | 142 ++++++++++++++++++++- .../unit/extensions/mongo/file/CollectionTest.php | 14 +- tests/unit/extensions/mongo/file/QueryTest.php | 2 +- 6 files changed, 184 insertions(+), 33 deletions(-) diff --git a/extensions/mongo/file/ActiveRecord.php b/extensions/mongo/file/ActiveRecord.php index 36fe644..bbdf10d 100644 --- a/extensions/mongo/file/ActiveRecord.php +++ b/extensions/mongo/file/ActiveRecord.php @@ -92,7 +92,7 @@ class ActiveRecord extends \yii\mongo\ActiveRecord $fileContent = $values['newFileContent']; unset($values['newFileContent']); unset($values['file']); - $newId = $collection->storeBytes($fileContent, $values); + $newId = $collection->insertFileContent($fileContent, $values); } elseif (array_key_exists('file', $values)) { $file = $values['file']; if ($file instanceof UploadedFile) { @@ -108,7 +108,7 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } unset($values['newFileContent']); unset($values['file']); - $newId = $collection->storeFile($fileName, $values); + $newId = $collection->insertFile($fileName, $values); } else { $newId = $collection->insert($values); } @@ -142,8 +142,10 @@ class ActiveRecord extends \yii\mongo\ActiveRecord unset($values['file']); $values['_id'] = $this->getAttribute('_id'); $this->deleteInternal(); - $collection->storeBytes($fileContent, $values); + $collection->insertFileContent($fileContent, $values); $rows = 1; + $this->setAttribute('newFileContent', null); + $this->setAttribute('file', null); } elseif (array_key_exists('file', $values)) { $file = $values['file']; if ($file instanceof UploadedFile) { @@ -161,8 +163,10 @@ class ActiveRecord extends \yii\mongo\ActiveRecord unset($values['file']); $values['_id'] = $this->getAttribute('_id'); $this->deleteInternal(); - $collection->storeFile($fileName, $values); + $collection->insertFile($fileName, $values); $rows = 1; + $this->setAttribute('newFileContent', null); + $this->setAttribute('file', null); } else { $condition = $this->getOldPrimaryKey(true); $lock = $this->optimisticLock(); @@ -188,6 +192,17 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } /** + * 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 value. @@ -195,10 +210,18 @@ class ActiveRecord extends \yii\mongo\ActiveRecord public function getFileContent() { $file = $this->getAttribute('file'); + if (empty($file) && !$this->getIsNewRecord()) { + $file = $this->refreshFile(); + } if (empty($file)) { return null; } elseif ($file instanceof \MongoGridFSFile) { - return $file->getBytes(); + $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)) { diff --git a/extensions/mongo/file/Collection.php b/extensions/mongo/file/Collection.php index d9de3f9..b357d01 100644 --- a/extensions/mongo/file/Collection.php +++ b/extensions/mongo/file/Collection.php @@ -63,38 +63,27 @@ class Collection extends \yii\mongo\Collection /** * @param string $filename name of the file to store. * @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. - */ - public function put($filename, $metadata = []) - { - return $this->mongoCollection->put($filename, $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. */ - public function storeBytes($bytes, $metadata = [], $options = []) + public function insertFile($filename, $metadata = [], $options = []) { $options = array_merge(['w' => 1], $options); - return $this->mongoCollection->storeBytes($bytes, $metadata, $options); + return $this->mongoCollection->storeFile($filename, $metadata, $options); } /** - * @param string $filename name of the file to store. + * @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. */ - public function storeFile($filename, $metadata = [], $options = []) + public function insertFileContent($bytes, $metadata = [], $options = []) { $options = array_merge(['w' => 1], $options); - return $this->mongoCollection->storeFile($filename, $metadata, $options); + return $this->mongoCollection->storeBytes($bytes, $metadata, $options); } /** @@ -104,7 +93,7 @@ class Collection extends \yii\mongo\Collection * @return mixed the "_id" of the saved file document. This will be a generated [[\MongoId]] * unless an "_id" was explicitly specified in the metadata. */ - public function storeUploads($name, $metadata = []) + public function insertUploads($name, $metadata = []) { return $this->mongoCollection->storeUpload($name, $metadata); } diff --git a/tests/unit/extensions/mongo/ActiveRecordTest.php b/tests/unit/extensions/mongo/ActiveRecordTest.php index 78e1da1..8467ba0 100644 --- a/tests/unit/extensions/mongo/ActiveRecordTest.php +++ b/tests/unit/extensions/mongo/ActiveRecordTest.php @@ -156,8 +156,7 @@ class ActiveRecordTest extends MongoTestCase // updateAll $pk = ['_id' => $record->_id]; - //$ret = Customer::updateAll(['status' => 55], $pk); - $ret = Customer::updateAll(['$set' => ['status' => 55]], $pk); + $ret = Customer::updateAll(['status' => 55], $pk); $this->assertEquals(1, $ret); $record = Customer::find($pk); $this->assertEquals(55, $record->status); diff --git a/tests/unit/extensions/mongo/file/ActiveRecordTest.php b/tests/unit/extensions/mongo/file/ActiveRecordTest.php index b4ab890..93c2cc2 100644 --- a/tests/unit/extensions/mongo/file/ActiveRecordTest.php +++ b/tests/unit/extensions/mongo/file/ActiveRecordTest.php @@ -43,7 +43,7 @@ class ActiveRecordTest extends MongoTestCase 'status' => $i, ]; $content = 'content' . $i; - $record['_id'] = $collection->storeBytes($content, $record); + $record['_id'] = $collection->insertFileContent($content, $record); $record['content'] = $content; $rows[] = $record; } @@ -116,4 +116,144 @@ class ActiveRecordTest extends MongoTestCase $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()); + } } \ No newline at end of file diff --git a/tests/unit/extensions/mongo/file/CollectionTest.php b/tests/unit/extensions/mongo/file/CollectionTest.php index acec5ed..58a5864 100644 --- a/tests/unit/extensions/mongo/file/CollectionTest.php +++ b/tests/unit/extensions/mongo/file/CollectionTest.php @@ -32,12 +32,12 @@ class CollectionTest extends MongoTestCase $this->assertTrue($cursor instanceof \MongoGridFSCursor); } - public function testStoreFile() + public function testInsertFile() { $collection = $this->getConnection()->getFileCollection(); $filename = __FILE__; - $id = $collection->storeFile($filename); + $id = $collection->insertFile($filename); $this->assertTrue($id instanceof \MongoId); $files = $this->findAll($collection); @@ -49,12 +49,12 @@ class CollectionTest extends MongoTestCase $this->assertEquals(file_get_contents($filename), $file->getBytes()); } - public function testStoreBytes() + public function testInsertFileContent() { $collection = $this->getConnection()->getFileCollection(); $bytes = 'Test file content'; - $id = $collection->storeBytes($bytes); + $id = $collection->insertFileContent($bytes); $this->assertTrue($id instanceof \MongoId); $files = $this->findAll($collection); @@ -66,14 +66,14 @@ class CollectionTest extends MongoTestCase } /** - * @depends testStoreBytes + * @depends testInsertFileContent */ public function testGet() { $collection = $this->getConnection()->getFileCollection(); $bytes = 'Test file content'; - $id = $collection->storeBytes($bytes); + $id = $collection->insertFileContent($bytes); $file = $collection->get($id); $this->assertTrue($file instanceof \MongoGridFSFile); @@ -88,7 +88,7 @@ class CollectionTest extends MongoTestCase $collection = $this->getConnection()->getFileCollection(); $bytes = 'Test file content'; - $id = $collection->storeBytes($bytes); + $id = $collection->insertFileContent($bytes); $this->assertTrue($collection->delete($id)); diff --git a/tests/unit/extensions/mongo/file/QueryTest.php b/tests/unit/extensions/mongo/file/QueryTest.php index 2fdf1f1..2f9ec67 100644 --- a/tests/unit/extensions/mongo/file/QueryTest.php +++ b/tests/unit/extensions/mongo/file/QueryTest.php @@ -29,7 +29,7 @@ class QueryTest extends MongoTestCase { $collection = $this->getConnection()->getFileCollection(); for ($i = 1; $i <= 10; $i++) { - $collection->storeBytes('content' . $i, [ + $collection->insertFileContent('content' . $i, [ 'filename' => 'name' . $i, 'file_index' => $i, ]); From c0d5c785e888902ce6527784ed08d5332c189c39 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 5 Dec 2013 12:53:47 +0200 Subject: [PATCH 42/49] Logging and profiling for Mongo File Collection added. --- extensions/mongo/file/Collection.php | 74 +++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/extensions/mongo/file/Collection.php b/extensions/mongo/file/Collection.php index b357d01..fbe65be 100644 --- a/extensions/mongo/file/Collection.php +++ b/extensions/mongo/file/Collection.php @@ -66,11 +66,22 @@ class Collection extends \yii\mongo\Collection * @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 = []) { - $options = array_merge(['w' => 1], $options); - return $this->mongoCollection->storeFile($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); + } } /** @@ -79,11 +90,22 @@ class Collection extends \yii\mongo\Collection * @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 = []) { - $options = array_merge(['w' => 1], $options); - return $this->mongoCollection->storeBytes($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); + } } /** @@ -92,29 +114,63 @@ class Collection extends \yii\mongo\Collection * @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 = []) { - return $this->mongoCollection->storeUpload($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) { - return $this->mongoCollection->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) { - $result = $this->mongoCollection->delete($id); - $this->tryResultError($result); - return true; + $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 From 04fb75b6a765975ad885de04cc9005e7bdff42c0 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 5 Dec 2013 13:55:35 +0200 Subject: [PATCH 43/49] File retrieve methods added to Mongo File Active Record. --- extensions/mongo/Query.php | 57 +++++++++++-------- extensions/mongo/file/ActiveRecord.php | 63 ++++++++++++++++++++- extensions/mongo/file/Query.php | 59 ++++++++------------ .../extensions/mongo/file/ActiveRecordTest.php | 64 ++++++++++++++++++++++ 4 files changed, 185 insertions(+), 58 deletions(-) diff --git a/extensions/mongo/Query.php b/extensions/mongo/Query.php index 8c46479..5f313f4 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -129,33 +129,13 @@ class Query extends Component implements QueryInterface * @throws Exception on failure. * @return array|boolean result. */ - protected function fetchRows(\MongoCursor $cursor, $all = true, $indexBy = null) + protected function fetchRows($cursor, $all = true, $indexBy = null) { $token = 'Querying: ' . Json::encode($cursor->info()); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); - $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; - } - } + $result = $this->fetchRowsInternal($cursor, $all, $indexBy); Yii::endProfile($token, __METHOD__); return $result; } catch (\Exception $e) { @@ -165,6 +145,39 @@ class Query extends Component implements QueryInterface } /** + * @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. diff --git a/extensions/mongo/file/ActiveRecord.php b/extensions/mongo/file/ActiveRecord.php index bbdf10d..fd9367b 100644 --- a/extensions/mongo/file/ActiveRecord.php +++ b/extensions/mongo/file/ActiveRecord.php @@ -205,7 +205,7 @@ class ActiveRecord extends \yii\mongo\ActiveRecord /** * Returns the associated file content. * @return null|string file content. - * @throws \yii\base\InvalidParamException on invalid file value. + * @throws \yii\base\InvalidParamException on invalid file attribute value. */ public function getFileContent() { @@ -227,6 +227,67 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } 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.'); diff --git a/extensions/mongo/file/Query.php b/extensions/mongo/file/Query.php index b22b64f..4c1e5ff 100644 --- a/extensions/mongo/file/Query.php +++ b/extensions/mongo/file/Query.php @@ -33,50 +33,39 @@ class Query extends \yii\mongo\Query } /** - * Fetches rows from the given Mongo cursor. - * @param \MongoCursor $cursor Mongo cursor instance to fetch data 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 the column name or PHP callback, - * by which the query results should be indexed by. - * @throws Exception on failure. + * @param string|callable $indexBy value to index by. * @return array|boolean result. + * @see Query::fetchRows() */ - protected function fetchRows(\MongoCursor $cursor, $all = true, $indexBy = null) + protected function fetchRowsInternal($cursor, $all, $indexBy) { - $token = 'Querying: ' . Json::encode($cursor->info()); - Yii::info($token, __METHOD__); - try { - Yii::beginProfile($token, __METHOD__); - $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; + $result = []; + if ($all) { + foreach ($cursor as $file) { + $row = $file->file; + $row['file'] = $file; + if ($indexBy !== null) { + if (is_string($indexBy)) { + $key = $row[$indexBy]; } else { - $result[] = $row; + $key = call_user_func($indexBy, $row); } - } - } else { - if ($cursor->hasNext()) { - $file = $cursor->getNext(); - $result = $file->file; - $result['file'] = $file; + $result[$key] = $row; } else { - $result = false; + $result[] = $row; } } - Yii::endProfile($token, __METHOD__); - return $result; - } catch (\Exception $e) { - Yii::endProfile($token, __METHOD__); - throw new Exception($e->getMessage(), (int)$e->getCode(), $e); + } 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/tests/unit/extensions/mongo/file/ActiveRecordTest.php b/tests/unit/extensions/mongo/file/ActiveRecordTest.php index 93c2cc2..93fb552 100644 --- a/tests/unit/extensions/mongo/file/ActiveRecordTest.php +++ b/tests/unit/extensions/mongo/file/ActiveRecordTest.php @@ -2,6 +2,8 @@ namespace yiiunit\extensions\mongo\file; +use Yii; +use yii\helpers\FileHelper; use yiiunit\extensions\mongo\MongoTestCase; use yii\mongo\file\ActiveQuery; use yiiunit\data\ar\mongo\file\ActiveRecord; @@ -22,15 +24,31 @@ class ActiveRecordTest extends MongoTestCase parent::setUp(); ActiveRecord::$db = $this->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() @@ -256,4 +274,50 @@ class ActiveRecordTest extends MongoTestCase $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 From c783e9fdb093bb8da972287d5d41e6868626d672 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 5 Dec 2013 15:34:48 +0200 Subject: [PATCH 44/49] Mongo File Active Record refactored. --- extensions/mongo/file/ActiveRecord.php | 88 +++++++++++++++++----------------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/extensions/mongo/file/ActiveRecord.php b/extensions/mongo/file/ActiveRecord.php index fd9367b..1bb2f55 100644 --- a/extensions/mongo/file/ActiveRecord.php +++ b/extensions/mongo/file/ActiveRecord.php @@ -88,26 +88,18 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } } $collection = static::getCollection(); - if (array_key_exists('newFileContent', $values)) { - $fileContent = $values['newFileContent']; - unset($values['newFileContent']); - unset($values['file']); - $newId = $collection->insertFileContent($fileContent, $values); - } elseif (array_key_exists('file', $values)) { - $file = $values['file']; - if ($file instanceof UploadedFile) { - $fileName = $file->tempName; - } elseif (is_string($file)) { - if (file_exists($file)) { - $fileName = $file; - } else { - throw new InvalidParamException("File '{$file}' does not exist."); - } - } else { - throw new InvalidParamException('Unsupported type of "file" attribute.'); - } + 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); @@ -136,35 +128,24 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } $collection = static::getCollection(); - if (array_key_exists('newFileContent', $values)) { - $fileContent = $values['newFileContent']; + if (isset($values['newFileContent'])) { + $newFileContent = $values['newFileContent']; unset($values['newFileContent']); + } + if (isset($values['file'])) { + $newFile = $values['file']; unset($values['file']); - $values['_id'] = $this->getAttribute('_id'); - $this->deleteInternal(); - $collection->insertFileContent($fileContent, $values); - $rows = 1; - $this->setAttribute('newFileContent', null); - $this->setAttribute('file', null); - } elseif (array_key_exists('file', $values)) { - $file = $values['file']; - if ($file instanceof UploadedFile) { - $fileName = $file->tempName; - } elseif (is_string($file)) { - if (file_exists($file)) { - $fileName = $file; - } else { - throw new InvalidParamException("File '{$file}' does not exist."); - } + } + if (isset($newFileContent) || isset($newFile)) { + $rows = $this->deleteInternal(); + $insertValues = $values; + $insertValues['_id'] = $this->getAttribute('_id'); + if (isset($newFileContent)) { + $collection->insertFileContent($newFileContent, $insertValues); } else { - throw new InvalidParamException('Unsupported type of "file" attribute.'); + $fileName = $this->extractFileName($newFile); + $collection->insertFile($fileName, $insertValues); } - unset($values['newFileContent']); - unset($values['file']); - $values['_id'] = $this->getAttribute('_id'); - $this->deleteInternal(); - $collection->insertFile($fileName, $values); - $rows = 1; $this->setAttribute('newFileContent', null); $this->setAttribute('file', null); } else { @@ -192,6 +173,27 @@ class ActiveRecord extends \yii\mongo\ActiveRecord } /** + * 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. */ From c542d09d26339997367b6f6b705ae6b63f30be9f Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 5 Dec 2013 16:05:56 +0200 Subject: [PATCH 45/49] Doc comments at \yii\mongo\file\* updated. --- extensions/mongo/file/ActiveQuery.php | 18 +++++++++++- extensions/mongo/file/ActiveRecord.php | 50 +++++++++++++++++++++++++++++++--- extensions/mongo/file/Collection.php | 10 +++++++ extensions/mongo/file/Query.php | 6 +++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/extensions/mongo/file/ActiveQuery.php b/extensions/mongo/file/ActiveQuery.php index 005e4ce..91661d6 100644 --- a/extensions/mongo/file/ActiveQuery.php +++ b/extensions/mongo/file/ActiveQuery.php @@ -11,7 +11,23 @@ use yii\db\ActiveQueryInterface; use yii\db\ActiveQueryTrait; /** - * Class ActiveQuery + * ActiveQuery represents a Mongo query associated with an file Active Record class. + * + * ActiveQuery instances are usually created by [[ActiveRecord::find()]]. + * + * Because ActiveQuery extends from [[Query]], one can use query methods, such as [[where()]], + * [[orderBy()]] to customize the query options. + * + * ActiveQuery also provides the following additional query options: + * + * - [[with()]]: list of relations that this query should be performed with. + * - [[asArray()]]: whether to return each record as an array. + * + * These options can be configured using methods of the same name. For example: + * + * ~~~ + * $images = ImageFile::find()->with('tags')->asArray()->all(); + * ~~~ * * @author Paul Klimov * @since 2.0 diff --git a/extensions/mongo/file/ActiveRecord.php b/extensions/mongo/file/ActiveRecord.php index 1bb2f55..c7ca752 100644 --- a/extensions/mongo/file/ActiveRecord.php +++ b/extensions/mongo/file/ActiveRecord.php @@ -14,16 +14,47 @@ use yii\web\UploadedFile; /** * ActiveRecord is the base class for classes representing Mongo GridFS files in terms of objects. * + * To specify source file use the [[file]] attribute. It can be specified in one of the following ways: + * - string - full name of the file, which content should be stored in GridFS + * - \yii\web\UploadedFile - uploaded file instance, which content should be stored in GridFS + * + * For example: + * + * ~~~ + * $record = new ImageFile(); + * $record->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 */ -class ActiveRecord extends \yii\mongo\ActiveRecord +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. `CustomerQuery` specified - * written for querying `Customer` purpose.) + * 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() @@ -55,7 +86,18 @@ class ActiveRecord extends \yii\mongo\ActiveRecord /** * Returns the list of all attribute names of the model. * This method could be overridden by child classes to define available attributes. - * Note: primary key attribute "_id" should be always present in returned array. + * 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() diff --git a/extensions/mongo/file/Collection.php b/extensions/mongo/file/Collection.php index fbe65be..b3c722b 100644 --- a/extensions/mongo/file/Collection.php +++ b/extensions/mongo/file/Collection.php @@ -13,6 +13,10 @@ use Yii; /** * Collection represents the Mongo GridFS collection information. * + * A file collection object is usually created by calling [[Database::getFileCollection()]] or [[Connection::getFileCollection()]]. + * + * File collection inherits all interface from regular [[\yii\mongo\Collection]], adding methods to store files. + * * @property \yii\mongo\Collection $chunkCollection file chunks Mongo collection. This property is read-only. * @method \MongoGridFSCursor find() returns a cursor for the search results. * @@ -61,6 +65,8 @@ class Collection extends \yii\mongo\Collection } /** + * 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 @@ -85,6 +91,8 @@ class Collection extends \yii\mongo\Collection } /** + * 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 @@ -109,6 +117,8 @@ class Collection extends \yii\mongo\Collection } /** + * 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. diff --git a/extensions/mongo/file/Query.php b/extensions/mongo/file/Query.php index 4c1e5ff..c15d4f1 100644 --- a/extensions/mongo/file/Query.php +++ b/extensions/mongo/file/Query.php @@ -12,7 +12,11 @@ use yii\helpers\Json; use yii\mongo\Exception; /** - * Class Query + * Query represents Mongo "find" operation for GridFS collection. + * + * Query behaves exactly as regular [[\yii\mongo\Query]]. + * Found files will be represented as arrays of file document attributes with + * additional 'file' key, which stores [[\MongoGridFSFile]] instance. * * @author Paul Klimov * @since 2.0 From 0fea8a5080fc7a4fd222007997794211bca74b5d Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 5 Dec 2013 16:25:44 +0200 Subject: [PATCH 46/49] Mongo README and composer updated. --- extensions/mongo/README.md | 78 +++++++++++++++++++++++++++++++++++++++++- extensions/mongo/composer.json | 2 +- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/extensions/mongo/README.md b/extensions/mongo/README.md index b04e1b9..7f5ef70 100644 --- a/extensions/mongo/README.md +++ b/extensions/mongo/README.md @@ -37,4 +37,80 @@ to the require section of your composer.json. Usage & Documentation --------------------- -This extension adds [MongoDb](http://www.mongodb.org/) data storage support for the Yii2 framework. +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 index 324b497..a9dd06e 100644 --- a/extensions/mongo/composer.json +++ b/extensions/mongo/composer.json @@ -20,7 +20,7 @@ "minimum-stability": "dev", "require": { "yiisoft/yii2": "*", - "ext-mongo": "*" + "ext-mongo": ">=1.3.0" }, "autoload": { "psr-0": { "yii\\mongo\\": "" } From e2f587a3574c6b419041d8882ac1516f95a3c776 Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 5 Dec 2013 17:25:03 +0200 Subject: [PATCH 47/49] Mongo log tokens improved. --- extensions/mongo/Collection.php | 139 +++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 58 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 1add592..e211d08 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -10,6 +10,7 @@ namespace yii\mongo; use yii\base\InvalidParamException; use yii\base\Object; use Yii; +use yii\helpers\Json; /** * Collection represents the Mongo collection information. @@ -96,13 +97,28 @@ class Collection extends Object } /** + * 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 = 'Drop collection ' . $this->getFullName(); + $token = $this->composeLogToken('drop'); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -139,12 +155,12 @@ class Collection extends Object if (!is_array($columns)) { $columns = [$columns]; } - $token = 'Creating index at ' . $this->getFullName() . ' on ' . implode(',', $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__); - $keys = $this->normalizeIndexKeys($columns); - $options = array_merge(['w' => 1], $options); $result = $this->mongoCollection->ensureIndex($keys, $options); $this->tryResultError($result); Yii::endProfile($token, __METHOD__); @@ -177,10 +193,10 @@ class Collection extends Object if (!is_array($columns)) { $columns = [$columns]; } - $token = 'Drop index at ' . $this->getFullName() . ' on ' . implode(',', $columns); + $keys = $this->normalizeIndexKeys($columns); + $token = $this->composeLogToken('dropIndex', [$keys]); Yii::info($token, __METHOD__); try { - $keys = $this->normalizeIndexKeys($columns); $result = $this->mongoCollection->deleteIndex($keys); $this->tryResultError($result); return true; @@ -215,7 +231,7 @@ class Collection extends Object */ public function dropAllIndexes() { - $token = 'Drop ALL indexes at ' . $this->getFullName(); + $token = $this->composeLogToken('dropIndexes'); Yii::info($token, __METHOD__); try { $result = $this->mongoCollection->deleteIndexes(); @@ -249,7 +265,7 @@ class Collection extends Object */ public function insert($data, $options = []) { - $token = 'Inserting data into ' . $this->getFullName(); + $token = $this->composeLogToken('insert', [$data]); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -272,7 +288,7 @@ class Collection extends Object */ public function batchInsert($rows, $options = []) { - $token = 'Inserting batch data into ' . $this->getFullName(); + $token = $this->composeLogToken('batchInsert', [$rows]); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -298,18 +314,18 @@ class Collection extends Object */ public function update($condition, $newData, $options = []) { - $token = 'Updating data in ' . $this->getFullName(); + $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__); - $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]; - } - } - $condition = $this->buildCondition($condition); $result = $this->mongoCollection->update($condition, $newData, $options); $this->tryResultError($result); Yii::endProfile($token, __METHOD__); @@ -333,7 +349,7 @@ class Collection extends Object */ public function save($data, $options = []) { - $token = 'Saving data into ' . $this->getFullName(); + $token = $this->composeLogToken('save', [$data]); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -356,12 +372,13 @@ class Collection extends Object */ public function remove($condition = [], $options = []) { - $token = 'Removing data from ' . $this->getFullName(); + $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__); - $options = array_merge(['w' => 1, 'multiple' => true], $options); - $result = $this->mongoCollection->remove($this->buildCondition($condition), $options); + $result = $this->mongoCollection->remove($condition, $options); $this->tryResultError($result); Yii::endProfile($token, __METHOD__); if (is_array($result) && array_key_exists('n', $result)) { @@ -380,15 +397,22 @@ class Collection extends Object * @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 = []) { - $token = 'Get distinct ' . $column . ' from ' . $this->getFullName(); + $condition = $this->buildCondition($condition); + $token = $this->composeLogToken('distinct', [$column, $condition]); Yii::info($token, __METHOD__); - Yii::beginProfile($token, __METHOD__); - $result = $this->mongoCollection->distinct($column, $this->buildCondition($condition)); - Yii::endProfile($token, __METHOD__); - return $result; + 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); + } } /** @@ -435,22 +459,21 @@ class Collection extends Object */ public function group($keys, $initial, $reduce, $options = []) { - $token = 'Grouping from ' . $this->getFullName(); + 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__); - - 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']); - } - } // Avoid possible E_DEPRECATED for $options: if (empty($options)) { $result = $this->mongoCollection->group($keys, $initial, $reduce); @@ -502,27 +525,27 @@ class Collection extends Object */ public function mapReduce($map, $reduce, $out, $condition = []) { - $token = 'Map reduce from ' . $this->getFullName(); - Yii::info($token, __METHOD__); + 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__); - 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); - } - + $command = array_merge(['mapReduce' => $this->getName()], $command); $result = $this->mongoCollection->db->command($command); $this->tryResultError($result); Yii::endProfile($token, __METHOD__); From 99b6ae27b3f434970daaa048cd144b260a6b88ff Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 5 Dec 2013 20:29:14 +0200 Subject: [PATCH 48/49] Logging and profiling at Mongo improved. --- extensions/mongo/Collection.php | 4 +- extensions/mongo/Database.php | 66 ++++++++++++++++++++++++++-- extensions/mongo/Query.php | 4 +- tests/unit/extensions/mongo/DatabaseTest.php | 11 ++++- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index e211d08..5b6c829 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -426,11 +426,11 @@ class Collection extends Object */ public function aggregate($pipeline, $pipelineOperator = []) { - $token = 'Aggregating from ' . $this->getFullName(); + $args = func_get_args(); + $token = $this->composeLogToken('aggregate', $args); Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); - $args = func_get_args(); $result = call_user_func_array([$this->mongoCollection, 'aggregate'], $args); $this->tryResultError($result); Yii::endProfile($token, __METHOD__); diff --git a/extensions/mongo/Database.php b/extensions/mongo/Database.php index fcc733f..bb919b5 100644 --- a/extensions/mongo/Database.php +++ b/extensions/mongo/Database.php @@ -9,10 +9,13 @@ namespace yii\mongo; use yii\base\Object; use Yii; +use yii\helpers\Json; /** * Database represents the Mongo database information. * + * @property string $name name of this database. This property is read-only. + * * @author Paul Klimov * @since 2.0 */ @@ -32,6 +35,14 @@ class Database extends Object 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. @@ -93,10 +104,21 @@ class Database extends Object * @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 = []) { - return $this->mongoDb->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); + } } /** @@ -104,9 +126,47 @@ class Database extends Object * @param array $command command specification. * @param array $options options in format: "name" => "value" * @return array database response. + * @throws Exception on failure. */ - public function execute($command, $options = []) + public function executeCommand($command, $options = []) { - return $this->mongoDb->command($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/Query.php b/extensions/mongo/Query.php index 5f313f4..cce6645 100644 --- a/extensions/mongo/Query.php +++ b/extensions/mongo/Query.php @@ -131,7 +131,7 @@ class Query extends Component implements QueryInterface */ protected function fetchRows($cursor, $all = true, $indexBy = null) { - $token = 'Querying: ' . Json::encode($cursor->info()); + $token = 'find(' . Json::encode($cursor->info()) . ')'; Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); @@ -213,7 +213,7 @@ class Query extends Component implements QueryInterface public function count($q = '*', $db = null) { $cursor = $this->buildCursor($db); - $token = 'Counting: ' . Json::encode($cursor->info()); + $token = 'find.count(' . Json::encode($cursor->info()) . ')'; Yii::info($token, __METHOD__); try { Yii::beginProfile($token, __METHOD__); diff --git a/tests/unit/extensions/mongo/DatabaseTest.php b/tests/unit/extensions/mongo/DatabaseTest.php index e844d9d..6847d2e 100644 --- a/tests/unit/extensions/mongo/DatabaseTest.php +++ b/tests/unit/extensions/mongo/DatabaseTest.php @@ -49,15 +49,22 @@ class DatabaseTest extends MongoTestCase $this->assertFalse($collection === $collectionRefreshed); } - public function testCommand() + public function testExecuteCommand() { $database = $connection = $this->getConnection()->getDatabase(); - $result = $database->execute([ + $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 From 062e138c836c2a80e61d1261f4af8fd72a6e6692 Mon Sep 17 00:00:00 2001 From: Klimov Paul Date: Thu, 5 Dec 2013 21:09:33 +0200 Subject: [PATCH 49/49] Method "\yii\mongo\Collection::fullTextSearch()" added. --- extensions/mongo/Collection.php | 46 +++++++++++++++++++++++++- tests/unit/extensions/mongo/CollectionTest.php | 31 +++++++++++++++++ tests/unit/extensions/mongo/MongoTestCase.php | 11 ++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/extensions/mongo/Collection.php b/extensions/mongo/Collection.php index 5b6c829..e9c37b8 100644 --- a/extensions/mongo/Collection.php +++ b/extensions/mongo/Collection.php @@ -176,6 +176,7 @@ class Collection extends Object * @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: @@ -183,6 +184,7 @@ class Collection extends Object * [ * 'name', * 'status' => -1, + * 'description' => 'text', * ] * ~~~ * @throws Exception on failure. @@ -540,7 +542,6 @@ class Collection extends Object if (!empty($condition)) { $command['query'] = $this->buildCondition($condition); } - $token = $this->composeLogToken('mapReduce', [$map, $reduce, $out]); Yii::info($token, __METHOD__); try { @@ -557,6 +558,49 @@ class Collection extends Object } /** + * 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. diff --git a/tests/unit/extensions/mongo/CollectionTest.php b/tests/unit/extensions/mongo/CollectionTest.php index a50177d..153ffa9 100644 --- a/tests/unit/extensions/mongo/CollectionTest.php +++ b/tests/unit/extensions/mongo/CollectionTest.php @@ -279,4 +279,35 @@ class CollectionTest extends MongoTestCase $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/MongoTestCase.php b/tests/unit/extensions/mongo/MongoTestCase.php index eefb972..291debd 100644 --- a/tests/unit/extensions/mongo/MongoTestCase.php +++ b/tests/unit/extensions/mongo/MongoTestCase.php @@ -135,4 +135,15 @@ class MongoTestCase extends TestCase } 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