diff --git a/lib/Cake/Model/Datasource/Database/Sqlite.php b/lib/Cake/Model/Datasource/Database/Sqlite.php index 7dd81f8a4..bfda2c8dd 100644 --- a/lib/Cake/Model/Datasource/Database/Sqlite.php +++ b/lib/Cake/Model/Datasource/Database/Sqlite.php @@ -591,4 +591,15 @@ class Sqlite extends DboSource { return $this->useNestedTransactions && version_compare($this->getVersion(), '3.6.8', '>='); } +/** + * Returns a locking hint for the given mode. + * + * Sqlite Datasource doesn't support row-level locking. + * + * @param mixed $mode Lock mode + * @return string|null Null + */ + public function getLockingHint($mode) { + return null; + } } diff --git a/lib/Cake/Model/Datasource/Database/Sqlserver.php b/lib/Cake/Model/Datasource/Database/Sqlserver.php index cb66267b5..90590e6ad 100644 --- a/lib/Cake/Model/Datasource/Database/Sqlserver.php +++ b/lib/Cake/Model/Datasource/Database/Sqlserver.php @@ -526,6 +526,9 @@ class Sqlserver extends DboSource { extract($data); $fields = trim($fields); + $having = !empty($having) ? " $having" : ''; + $lock = !empty($lock) ? " $lock" : ''; + if (strpos($limit, 'TOP') !== false && strpos($fields, 'DISTINCT ') === 0) { $limit = 'DISTINCT ' . trim($limit); $fields = substr($fields, 9); @@ -547,7 +550,7 @@ class Sqlserver extends DboSource { $rowCounter = static::ROW_COUNTER; $sql = "SELECT {$limit} * FROM ( SELECT {$fields}, ROW_NUMBER() OVER ({$order}) AS {$rowCounter} - FROM {$table} {$alias} {$joins} {$conditions} {$group} + FROM {$table} {$alias}{$lock} {$joins} {$conditions} {$group}{$having} ) AS _cake_paging_ WHERE _cake_paging_.{$rowCounter} > {$offset} ORDER BY _cake_paging_.{$rowCounter} @@ -555,9 +558,9 @@ class Sqlserver extends DboSource { return trim($sql); } if (strpos($limit, 'FETCH') !== false) { - return trim("SELECT {$fields} FROM {$table} {$alias} {$joins} {$conditions} {$group} {$order} {$limit}"); + return trim("SELECT {$fields} FROM {$table} {$alias}{$lock} {$joins} {$conditions} {$group}{$having} {$order} {$limit}"); } - return trim("SELECT {$limit} {$fields} FROM {$table} {$alias} {$joins} {$conditions} {$group} {$order}"); + return trim("SELECT {$limit} {$fields} FROM {$table} {$alias}{$lock} {$joins} {$conditions} {$group}{$having} {$order}"); case "schema": extract($data); @@ -814,4 +817,18 @@ class Sqlserver extends DboSource { return $this->config['schema']; } +/** + * Returns a locking hint for the given mode. + * + * Currently, this method only returns WITH (UPDLOCK) when the mode is set to true. + * + * @param mixed $mode Lock mode + * @return string|null WITH (UPDLOCK) clause or null + */ + public function getLockingHint($mode) { + if ($mode !== true) { + return null; + } + return ' WITH (UPDLOCK)'; + } } diff --git a/lib/Cake/Model/Datasource/DboSource.php b/lib/Cake/Model/Datasource/DboSource.php index 089fe68dc..f69144746 100644 --- a/lib/Cake/Model/Datasource/DboSource.php +++ b/lib/Cake/Model/Datasource/DboSource.php @@ -213,7 +213,9 @@ class DboSource extends DataSource { 'limit' => null, 'joins' => array(), 'group' => null, - 'offset' => null + 'offset' => null, + 'having' => null, + 'lock' => null, ); /** @@ -1732,7 +1734,9 @@ class DboSource extends DataSource { 'joins' => $queryData['joins'], 'conditions' => $queryData['conditions'], 'order' => $queryData['order'], - 'group' => $queryData['group'] + 'group' => $queryData['group'], + 'having' => $queryData['having'], + 'lock' => $queryData['lock'], ), $Model ); @@ -2011,7 +2015,9 @@ class DboSource extends DataSource { 'order' => $this->order($query['order'], 'ASC', $Model), 'limit' => $this->limit($query['limit'], $query['offset']), 'joins' => implode(' ', $query['joins']), - 'group' => $this->group($query['group'], $Model) + 'group' => $this->group($query['group'], $Model), + 'having' => $this->having($query['having'], true, $Model), + 'lock' => $this->getLockingHint($query['lock']), )); } @@ -2041,7 +2047,9 @@ class DboSource extends DataSource { switch (strtolower($type)) { case 'select': - return trim("SELECT {$fields} FROM {$table} {$alias} {$joins} {$conditions} {$group} {$order} {$limit}"); + $having = !empty($having) ? " $having" : ''; + $lock = !empty($lock) ? " $lock" : ''; + return trim("SELECT {$fields} FROM {$table} {$alias} {$joins} {$conditions} {$group}{$having} {$order} {$limit}{$lock}"); case 'create': return "INSERT INTO {$table} ({$fields}) VALUES ({$values})"; case 'update': @@ -2549,6 +2557,8 @@ class DboSource extends DataSource { static $base = null; if ($base === null) { $base = array_fill_keys(array('conditions', 'fields', 'joins', 'order', 'limit', 'offset', 'group'), array()); + $base['having'] = null; + $base['lock'] = null; $base['callbacks'] = null; } return (array)$data + $base; @@ -3125,6 +3135,36 @@ class DboSource extends DataSource { return ' GROUP BY ' . $this->_quoteFields($fields); } +/** + * Create a HAVING SQL clause. + * + * @param mixed $fields Array or string of conditions + * @param bool $quoteValues If true, values should be quoted + * @param Model $Model A reference to the Model instance making the query + * @return string|null HAVING clause or null + */ + public function having($fields, $quoteValues = true, Model $Model = null) { + if (!$fields) { + return null; + } + return ' HAVING ' . $this->conditions($fields, $quoteValues, false, $Model); + } + +/** + * Returns a locking hint for the given mode. + * + * Currently, this method only returns FOR UPDATE when the mode is set to true. + * + * @param mixed $mode Lock mode + * @return string|null FOR UPDATE clause or null + */ + public function getLockingHint($mode) { + if ($mode !== true) { + return null; + } + return ' FOR UPDATE'; + } + /** * Disconnects database, kills the connection and says the connection is closed. * diff --git a/lib/Cake/Test/Case/Model/Datasource/Database/MysqlTest.php b/lib/Cake/Test/Case/Model/Datasource/Database/MysqlTest.php index 9577bad34..8e7dc7b3c 100644 --- a/lib/Cake/Test/Case/Model/Datasource/Database/MysqlTest.php +++ b/lib/Cake/Test/Case/Model/Datasource/Database/MysqlTest.php @@ -1277,6 +1277,8 @@ SQL; 'limit' => array(), 'offset' => array(), 'group' => array(), + 'having' => null, + 'lock' => null, 'callbacks' => null ); $queryData['joins'][0]['table'] = $this->Dbo->fullTableName($queryData['joins'][0]['table']); diff --git a/lib/Cake/Test/Case/Model/Datasource/Database/SqliteTest.php b/lib/Cake/Test/Case/Model/Datasource/Database/SqliteTest.php index 46950760f..8f831a671 100644 --- a/lib/Cake/Test/Case/Model/Datasource/Database/SqliteTest.php +++ b/lib/Cake/Test/Case/Model/Datasource/Database/SqliteTest.php @@ -626,4 +626,25 @@ SQL; $this->assertEquals($expected, $result); } +/** + * Test Sqlite Datasource doesn't support locking hint + * + * @return void + */ + public function testBuildStatementWithoutLockingHint() { + $model = new TestModel(); + $sql = $this->Dbo->buildStatement( + array( + 'fields' => array('id'), + 'table' => 'users', + 'alias' => 'User', + 'order' => array('id'), + 'limit' => 1, + 'lock' => true, + ), + $model + ); + $expected = 'SELECT id FROM users AS "User" WHERE 1 = 1 ORDER BY "id" ASC LIMIT 1'; + $this->assertEquals($expected, $sql); + } } diff --git a/lib/Cake/Test/Case/Model/Datasource/Database/SqlserverTest.php b/lib/Cake/Test/Case/Model/Datasource/Database/SqlserverTest.php index 6c03287a7..81fa8e7b2 100644 --- a/lib/Cake/Test/Case/Model/Datasource/Database/SqlserverTest.php +++ b/lib/Cake/Test/Case/Model/Datasource/Database/SqlserverTest.php @@ -707,4 +707,139 @@ SQL; $this->assertEquals(2, $result['value']); } +/** + * Test build statement with having option + * + * @return void + */ + public function testBuildStatementWithHaving() { + $db = $this->getMock('SqlserverTestDb', array('getVersion'), array($this->Dbo->config)); + + $db->expects($this->any()) + ->method('getVersion') + ->will($this->returnValue('11.00.0000')); + + $query = array( + 'fields' => array('user_id', 'COUNT(*) AS count'), + 'table' => 'articles', + 'alias' => 'Article', + 'group' => 'user_id', + 'order' => array('COUNT(*)' => 'DESC'), + 'limit' => 5, + 'having' => array('COUNT(*) >' => 10), + ); + + $sql = $db->buildStatement($query, $this->model); + $expected = 'SELECT TOP 5 user_id, COUNT(*) AS count FROM articles AS [Article] WHERE 1 = 1 GROUP BY user_id HAVING COUNT(*) > 10 ORDER BY COUNT(*) DESC'; + $this->assertEquals($expected, $sql); + + $sql = $db->buildStatement(array('offset' => 15) + $query, $this->model); + $expected = 'SELECT user_id, COUNT(*) AS count FROM articles AS [Article] WHERE 1 = 1 GROUP BY user_id HAVING COUNT(*) > 10 ORDER BY COUNT(*) DESC OFFSET 15 ROWS FETCH FIRST 5 ROWS ONLY'; + $this->assertEquals($expected, $sql); + } + +/** + * Test build statement with lock option + * + * @return void + */ + public function testBuildStatementWithLockingHint() { + $db = $this->getMock('SqlserverTestDb', array('getVersion'), array($this->Dbo->config)); + + $db->expects($this->any()) + ->method('getVersion') + ->will($this->returnValue('11.00.0000')); + + $query = array( + 'fields' => array('id'), + 'table' => 'users', + 'alias' => 'User', + 'order' => array('id'), + 'limit' => 1, + 'lock' => true, + ); + + $sql = $db->buildStatement($query, $this->model); + $expected = 'SELECT TOP 1 id FROM users AS [User] WITH (UPDLOCK) WHERE 1 = 1 ORDER BY [id] ASC'; + $this->assertEquals($expected, $sql); + + $sql = $db->buildStatement(array('offset' => 15) + $query, $this->model); + $expected = 'SELECT id FROM users AS [User] WITH (UPDLOCK) WHERE 1 = 1 ORDER BY [id] ASC OFFSET 15 ROWS FETCH FIRST 1 ROWS ONLY'; + $this->assertEquals($expected, $sql); + } + +/** + * Test build statement with having option for legacy version + * + * @return void + */ + public function testBuildStatementWithHavingForLegacyVersion() { + $db = $this->getMock('SqlserverTestDb', array('getVersion'), array($this->Dbo->config)); + + $db->expects($this->any()) + ->method('getVersion') + ->will($this->returnValue('10.00.0000')); + + $query = array( + 'fields' => array('user_id', 'COUNT(*) AS count'), + 'table' => 'articles', + 'alias' => 'Article', + 'group' => 'user_id', + 'order' => array('COUNT(*)' => 'DESC'), + 'limit' => 5, + 'having' => array('COUNT(*) >' => 10), + ); + + $sql = $db->buildStatement($query, $this->model); + $expected = 'SELECT TOP 5 user_id, COUNT(*) AS count FROM articles AS [Article] WHERE 1 = 1 GROUP BY user_id HAVING COUNT(*) > 10 ORDER BY COUNT(*) DESC'; + $this->assertEquals($expected, $sql); + + $sql = $db->buildStatement(array('offset' => 15) + $query, $this->model); + $expected = << 10 +) AS _cake_paging_ +WHERE _cake_paging_._cake_page_rownum_ > 15 +ORDER BY _cake_paging_._cake_page_rownum_ +SQL; + $this->assertEquals($expected, preg_replace('/^\s+|\s+$/m', '', $sql)); + } + +/** + * Test build statement with lock option for legacy version + * + * @return void + */ + public function testBuildStatementWithLockingHintForLegacyVersion() { + $db = $this->getMock('SqlserverTestDb', array('getVersion'), array($this->Dbo->config)); + + $db->expects($this->any()) + ->method('getVersion') + ->will($this->returnValue('10.00.0000')); + + $query = array( + 'fields' => array('id'), + 'table' => 'users', + 'alias' => 'User', + 'order' => array('id'), + 'limit' => 1, + 'lock' => true, + ); + + $sql = $db->buildStatement($query, $this->model); + $expected = 'SELECT TOP 1 id FROM users AS [User] WITH (UPDLOCK) WHERE 1 = 1 ORDER BY [id] ASC'; + $this->assertEquals($expected, $sql); + + $sql = $db->buildStatement(array('offset' => 15) + $query, $this->model); + $expected = << 15 +ORDER BY _cake_paging_._cake_page_rownum_ +SQL; + $this->assertEquals($expected, preg_replace('/^\s+|\s+$/m', '', $sql)); + } } diff --git a/lib/Cake/Test/Case/Model/Datasource/DboSourceTest.php b/lib/Cake/Test/Case/Model/Datasource/DboSourceTest.php index 0d2fa2c8a..75e519136 100644 --- a/lib/Cake/Test/Case/Model/Datasource/DboSourceTest.php +++ b/lib/Cake/Test/Case/Model/Datasource/DboSourceTest.php @@ -1294,6 +1294,33 @@ class DboSourceTest extends CakeTestCase { $this->assertEquals(' GROUP BY created', $result); } +/** + * Test having method + * + * @return void + */ + public function testHaving() { + $this->loadFixtures('User'); + + $result = $this->testDb->having(array('COUNT(*) >' => 0)); + $this->assertEquals(' HAVING COUNT(*) > 0', $result); + + $User = ClassRegistry::init('User'); + $result = $this->testDb->having('COUNT(User.id) > 0', true, $User); + $this->assertEquals(' HAVING COUNT(`User`.`id`) > 0', $result); + } + +/** + * Test getLockingHint method + * + * @return void + */ + public function testGetLockingHint() { + $this->assertEquals(' FOR UPDATE', $this->testDb->getLockingHint(true)); + $this->assertNull($this->testDb->getLockingHint(false)); + $this->assertNull($this->testDb->getLockingHint(null)); + } + /** * Test getting the last error. * @@ -1427,19 +1454,20 @@ class DboSourceTest extends CakeTestCase { */ public function testBuildStatementDefaults() { $conn = $this->getMock('MockPDO', array('quote')); - $conn->expects($this->at(0)) + $conn->expects($this->any()) ->method('quote') - ->will($this->returnValue('foo bar')); + ->will($this->returnArgument(0)); $db = new DboTestSource(); $db->setConnection($conn); + $subQuery = $db->buildStatement( array( 'fields' => array('DISTINCT(AssetsTag.asset_id)'), - 'table' => "assets_tags", - 'alias' => "AssetsTag", - 'conditions' => array("Tag.name" => 'foo bar'), + 'table' => 'assets_tags', + 'alias' => 'AssetsTag', + 'conditions' => array('Tag.name' => 'foo bar'), 'limit' => null, - 'group' => "AssetsTag.asset_id" + 'group' => 'AssetsTag.asset_id' ), $this->Model ); @@ -1447,6 +1475,63 @@ class DboSourceTest extends CakeTestCase { $this->assertEquals($expected, $subQuery); } +/** + * Test build statement with having option + * + * @return void + */ + public function testBuildStatementWithHaving() { + $conn = $this->getMock('MockPDO', array('quote')); + $conn->expects($this->any()) + ->method('quote') + ->will($this->returnArgument(0)); + $db = new DboTestSource(); + $db->setConnection($conn); + + $sql = $db->buildStatement( + array( + 'fields' => array('user_id', 'COUNT(*) AS count'), + 'table' => 'articles', + 'alias' => 'Article', + 'group' => 'user_id', + 'order' => array('COUNT(*)' => 'DESC'), + 'limit' => 5, + 'having' => array('COUNT(*) >' => 10), + ), + $this->Model + ); + $expected = 'SELECT user_id, COUNT(*) AS count FROM articles AS Article WHERE 1 = 1 GROUP BY user_id HAVING COUNT(*) > 10 ORDER BY COUNT(*) DESC LIMIT 5'; + $this->assertEquals($expected, $sql); + } + +/** + * Test build statement with lock option + * + * @return void + */ + public function testBuildStatementWithLockingHint() { + $conn = $this->getMock('MockPDO', array('quote')); + $conn->expects($this->any()) + ->method('quote') + ->will($this->returnArgument(0)); + $db = new DboTestSource(); + $db->setConnection($conn); + + $sql = $db->buildStatement( + array( + 'fields' => array('id'), + 'table' => 'users', + 'alias' => 'User', + 'order' => array('id'), + 'limit' => 1, + 'lock' => true, + ), + $this->Model + ); + $expected = 'SELECT id FROM users AS User WHERE 1 = 1 ORDER BY id ASC LIMIT 1 FOR UPDATE'; + $this->assertEquals($expected, $sql); + } + /** * data provider for testBuildJoinStatement * @@ -2024,4 +2109,29 @@ class DboSourceTest extends CakeTestCase { $result = $this->db->length("enum('One Value','ANOTHER ... VALUE ...')"); $this->assertEquals(21, $result); } + +/** + * Test find with locking hint + */ + public function testFindWithLockingHint() { + $db = $this->getMock('DboTestSource', array('connect', '_execute', 'execute', 'describ')); + + $Test = $this->getMock('Test', array('getDataSource')); + $Test->expects($this->any()) + ->method('getDataSource') + ->will($this->returnValue($db)); + + $expected = 'SELECT Test.id FROM tests AS Test WHERE id = 1 ORDER BY Test.id ASC LIMIT 1 FOR UPDATE'; + + $db->expects($this->once()) + ->method('execute') + ->with($expected); + + $Test->find('first', array( + 'recursive' => -1, + 'fields' => array('id'), + 'conditions' => array('id' => 1), + 'lock' => true, + )); + } } diff --git a/lib/Cake/Test/Case/Model/ModelReadTest.php b/lib/Cake/Test/Case/Model/ModelReadTest.php index 7723a8cfc..817f8b69c 100644 --- a/lib/Cake/Test/Case/Model/ModelReadTest.php +++ b/lib/Cake/Test/Case/Model/ModelReadTest.php @@ -352,6 +352,33 @@ class ModelReadTest extends BaseModelTest { $this->assertEquals($expected, $result); } +/** + * Test find method with having clause + * + * @return void + */ + public function testHaving() { + $this->loadFixtures('Comment'); + + $Comment = ClassRegistry::init('Comment'); + $comments = $Comment->find('all', array( + 'fields' => array('user_id', 'COUNT(*) AS count'), + 'group' => array('user_id'), + 'having' => array('COUNT(*) >' => 1), + 'order' => array('COUNT(*)' => 'DESC'), + 'recursive' => -1, + )); + + $results = Hash::combine($comments, '{n}.Comment.user_id', '{n}.0.count'); + + $expected = array( + 1 => 3, + 2 => 2, + ); + + $this->assertEquals($expected, $results); + } + /** * testOldQuery method *