Browse Source

Merge branch 'master' into 2.6

Conflicts:
	lib/Cake/Model/Model.php
mark_story 11 years ago
parent
commit
9c3089796f

+ 9 - 9
README.md

@@ -1,13 +1,16 @@
-CakePHP
-=======
+# CakePHP
+
+[![Bake Status](https://secure.travis-ci.org/cakephp/cakephp.png?branch=master)](http://travis-ci.org/cakephp/cakephp)
+[![Latest Stable Version](https://poser.pugx.org/cakephp/cakephp/v/stable.svg)](https://packagist.org/packages/cakephp/cakephp)
+[![License](https://poser.pugx.org/cakephp/cakephp/license.svg)](https://packagist.org/packages/cakephp/cakephp)
 
 [![CakePHP](http://cakephp.org/img/cake-logo.png)](http://www.cakephp.org)
 
 CakePHP is a rapid development framework for PHP which uses commonly known design patterns like Active Record, Association Data Mapping, Front Controller and MVC.
 Our primary goal is to provide a structured framework that enables PHP users at all levels to rapidly develop robust web applications, without any loss to flexibility.
 
-Some Handy Links
-----------------
+
+## Some Handy Links
 
 [CakePHP](http://www.cakephp.org) - The rapid development PHP framework
 
@@ -27,8 +30,8 @@ Some Handy Links
 
 [Cake Software Foundation](http://cakefoundation.org) - Promoting development related to CakePHP
 
-Get Support!
-------------
+
+## Get Support!
 
 [#cakephp](http://webchat.freenode.net/?channels=#cakephp) on irc.freenode.net - Come chat with us, we have cake
 
@@ -38,6 +41,3 @@ Get Support!
 
 [Roadmaps](https://github.com/cakephp/cakephp/wiki#roadmaps) - Want to contribute? Get involved!
 
-[![Bake Status](https://secure.travis-ci.org/cakephp/cakephp.png?branch=master)](http://travis-ci.org/cakephp/cakephp)
-
-![Cake Power](https://raw.github.com/cakephp/cakephp/master/lib/Cake/Console/Templates/skel/webroot/img/cake.power.gif)

+ 1 - 1
lib/Cake/Console/Command/Task/ExtractTask.php

@@ -411,7 +411,7 @@ class ExtractTask extends AppShell {
 			}
 
 			list($type, $string, $line) = $countToken;
-			if (($type == T_STRING) && ($string == $functionName) && ($firstParenthesis === '(')) {
+			if (($type == T_STRING) && ($string === $functionName) && ($firstParenthesis === '(')) {
 				$position = $count;
 				$depth = 0;
 

+ 1 - 1
lib/Cake/Controller/Component/CookieComponent.php

@@ -284,7 +284,7 @@ class CookieComponent extends Component {
 			return null;
 		}
 
-		if (!empty($names[1])) {
+		if (!empty($names[1]) && is_array($this->_values[$this->name][$key])) {
 			return Hash::get($this->_values[$this->name][$key], $names[1]);
 		}
 		return $this->_values[$this->name][$key];

+ 3 - 0
lib/Cake/Log/CakeLog.php

@@ -70,6 +70,7 @@ App::uses('LogEngineCollection', 'Log');
  * on scopes
  *
  * @package       Cake.Log
+ * @link http://book.cakephp.org/2.0/en/core-libraries/logging.html#logging
  */
 class CakeLog {
 
@@ -183,6 +184,7 @@ class CakeLog {
  * @param array $config Array of configuration information for the logger
  * @return bool success of configuration.
  * @throws CakeLogException
+ * @link http://book.cakephp.org/2.0/en/core-libraries/logging.html#creating-and-configuring-log-streams
  */
 	public static function config($key, $config) {
 		if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/', $key)) {
@@ -398,6 +400,7 @@ class CakeLog {
  * @param string|array $scope The scope(s) a log message is being created in.
  *    See CakeLog::config() for more information on logging scopes.
  * @return bool Success
+ * @link http://book.cakephp.org/2.0/en/core-libraries/logging.html#writing-to-logs
  */
 	public static function write($type, $message, $scope = array()) {
 		if (empty(self::$_Collection)) {

+ 1 - 1
lib/Cake/Model/Datasource/CakeSession.php

@@ -348,7 +348,7 @@ class CakeSession {
 		$config = self::read('Config');
 		$validAgent = (
 			Configure::read('Session.checkAgent') === false ||
-			self::$_userAgent == $config['userAgent']
+			isset($config['userAgent']) && self::$_userAgent === $config['userAgent']
 		);
 		return ($validAgent && self::$time <= $config['time']);
 	}

+ 5 - 3
lib/Cake/Model/Datasource/DboSource.php

@@ -1316,6 +1316,7 @@ class DboSource extends DataSource {
 		foreach ($resultSet as &$row) {
 			if ($type === 'hasOne' || $type === 'belongsTo' || $type === 'hasMany') {
 				$assocResultSet = array();
+				$prefetched = false;
 
 				if (
 					($type === 'hasOne' || $type === 'belongsTo') &&
@@ -1326,6 +1327,7 @@ class DboSource extends DataSource {
 					if (!empty($joinedData)) {
 						$assocResultSet[0] = array($LinkModel->alias => $row[$LinkModel->alias]);
 					}
+					$prefetched = true;
 				} else {
 					$query = $this->insertQueryData($queryTemplate, $row, $association, $Model, $stack);
 					if ($query !== false) {
@@ -1376,7 +1378,7 @@ class DboSource extends DataSource {
 					$this->_mergeAssociation($row, $assocResultSet, $association, $type, $selfJoin);
 				}
 
-				if ($type !== 'hasAndBelongsToMany' && isset($row[$association])) {
+				if ($type !== 'hasAndBelongsToMany' && isset($row[$association]) && !$prefetched) {
 					$row[$association] = $LinkModel->afterFind($row[$association], false);
 				}
 
@@ -1863,7 +1865,7 @@ class DboSource extends DataSource {
 			'type' => null,
 			'alias' => null,
 			'table' => 'join_table',
-			'conditions' => array()
+			'conditions' => '',
 		), $join);
 
 		if (!empty($data['alias'])) {
@@ -1917,7 +1919,7 @@ class DboSource extends DataSource {
  * @return string
  */
 	public function renderJoinStatement($data) {
-		if (strtoupper($data['type']) === 'CROSS') {
+		if (strtoupper($data['type']) === 'CROSS' || empty($data['conditions'])) {
 			return "{$data['type']} JOIN {$data['table']} {$data['alias']}";
 		}
 		return trim("{$data['type']} JOIN {$data['table']} {$data['alias']} ON ({$data['conditions']})");

+ 148 - 126
lib/Cake/Model/Model.php

@@ -2264,6 +2264,7 @@ class Model extends Object implements CakeEventListener {
  * @return mixed If atomic: True on success, or false on failure.
  *    Otherwise: array similar to the $data array passed, but values are set to true/false
  *    depending on whether each record saved successfully.
+ * @throws PDOException
  * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-savemany-array-data-null-array-options-array
  */
 	public function saveMany($data = null, $options = array()) {
@@ -2291,50 +2292,60 @@ class Model extends Object implements CakeEventListener {
 			$options['validate'] = false;
 		}
 
+		$transactionBegun = false;
 		if ($options['atomic']) {
 			$db = $this->getDataSource();
 			$transactionBegun = $db->begin();
 		}
 
-		$return = array();
-		foreach ($data as $key => $record) {
-			$validates = $this->create(null) !== null;
-			$saved = false;
-			if ($validates) {
-				if ($options['deep']) {
-					$saved = $this->saveAssociated($record, array('atomic' => false) + $options);
-				} else {
-					$saved = $this->save($record, array('atomic' => false) + $options);
+		try {
+			$return = array();
+			foreach ($data as $key => $record) {
+				$validates = $this->create(null) !== null;
+				$saved = false;
+				if ($validates) {
+					if ($options['deep']) {
+						$saved = $this->saveAssociated($record, array('atomic' => false) + $options);
+					} else {
+						$saved = $this->save($record, array('atomic' => false) + $options);
+					}
+				}
+
+				$validates = ($validates && ($saved === true || (is_array($saved) && !in_array(false, $saved, true))));
+				if (!$validates) {
+					$validationErrors[$key] = $this->validationErrors;
 				}
-			}
 
-			$validates = ($validates && ($saved === true || (is_array($saved) && !in_array(false, $saved, true))));
-			if (!$validates) {
-				$validationErrors[$key] = $this->validationErrors;
+				if (!$options['atomic']) {
+					$return[$key] = $validates;
+				} elseif (!$validates) {
+					break;
+				}
 			}
 
+			$this->validationErrors = $validationErrors;
+
 			if (!$options['atomic']) {
-				$return[$key] = $validates;
-			} elseif (!$validates) {
-				break;
+				return $return;
 			}
-		}
 
-		$this->validationErrors = $validationErrors;
-
-		if (!$options['atomic']) {
-			return $return;
-		}
+			if ($validates) {
+				if ($transactionBegun) {
+					return $db->commit() !== false;
+				}
+				return true;
+			}
 
-		if ($validates) {
 			if ($transactionBegun) {
-				return $db->commit() !== false;
+				$db->rollback();
 			}
-			return true;
+			return false;
+		} catch (Exception $e) {
+			if ($transactionBegun) {
+				$db->rollback();
+			}
+			throw $e;
 		}
-
-		$db->rollback();
-		return false;
 	}
 
 /**
@@ -2386,6 +2397,7 @@ class Model extends Object implements CakeEventListener {
  * @return mixed If atomic: True on success, or false on failure.
  *    Otherwise: array similar to the $data array passed, but values are set to true/false
  *    depending on whether each record saved successfully.
+ * @throws PDOException
  * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-saveassociated-array-data-null-array-options-array
  */
 	public function saveAssociated($data = null, $options = array()) {
@@ -2414,136 +2426,146 @@ class Model extends Object implements CakeEventListener {
 			$options['validate'] = false;
 		}
 
+		$transactionBegun = false;
 		if ($options['atomic']) {
 			$db = $this->getDataSource();
 			$transactionBegun = $db->begin();
 		}
 
-		$associations = $this->getAssociated();
-		$return = array();
-		$validates = true;
-		foreach ($data as $association => $values) {
-			$isEmpty = empty($values) || (isset($values[$association]) && empty($values[$association]));
-			if ($isEmpty || !isset($associations[$association]) || $associations[$association] !== 'belongsTo') {
-				continue;
-			}
+		try {
+			$associations = $this->getAssociated();
+			$return = array();
+			$validates = true;
+			foreach ($data as $association => $values) {
+				$isEmpty = empty($values) || (isset($values[$association]) && empty($values[$association]));
+				if ($isEmpty || !isset($associations[$association]) || $associations[$association] !== 'belongsTo') {
+					continue;
+				}
 
-			$Model = $this->{$association};
+				$Model = $this->{$association};
 
-			$validates = $Model->create(null) !== null;
-			$saved = false;
-			if ($validates) {
-				if ($options['deep']) {
-					$saved = $Model->saveAssociated($values, array('atomic' => false) + $options);
-				} else {
-					$saved = $Model->save($values, array('atomic' => false) + $options);
+				$validates = $Model->create(null) !== null;
+				$saved = false;
+				if ($validates) {
+					if ($options['deep']) {
+						$saved = $Model->saveAssociated($values, array('atomic' => false) + $options);
+					} else {
+						$saved = $Model->save($values, array('atomic' => false) + $options);
+					}
+					$validates = ($saved === true || (is_array($saved) && !in_array(false, $saved, true)));
 				}
-				$validates = ($saved === true || (is_array($saved) && !in_array(false, $saved, true)));
-			}
 
-			if ($validates) {
-				$key = $this->belongsTo[$association]['foreignKey'];
-				if (isset($data[$this->alias])) {
-					$data[$this->alias][$key] = $Model->id;
+				if ($validates) {
+					$key = $this->belongsTo[$association]['foreignKey'];
+					if (isset($data[$this->alias])) {
+						$data[$this->alias][$key] = $Model->id;
+					} else {
+						$data = array_merge(array($key => $Model->id), $data, array($key => $Model->id));
+					}
+					$options = $this->_addToWhiteList($key, $options);
 				} else {
-					$data = array_merge(array($key => $Model->id), $data, array($key => $Model->id));
+					$validationErrors[$association] = $Model->validationErrors;
 				}
-				$options = $this->_addToWhiteList($key, $options);
-			} else {
-				$validationErrors[$association] = $Model->validationErrors;
-			}
-
-			$return[$association] = $validates;
-		}
 
-		if ($validates && !($this->create(null) !== null && $this->save($data, array('atomic' => false) + $options))) {
-			$validationErrors[$this->alias] = $this->validationErrors;
-			$validates = false;
-		}
-		$return[$this->alias] = $validates;
-
-		foreach ($data as $association => $values) {
-			if (!$validates) {
-				break;
+				$return[$association] = $validates;
 			}
 
-			$isEmpty = empty($values) || (isset($values[$association]) && empty($values[$association]));
-			if ($isEmpty || !isset($associations[$association])) {
-				continue;
+			if ($validates && !($this->create(null) !== null && $this->save($data, array('atomic' => false) + $options))) {
+				$validationErrors[$this->alias] = $this->validationErrors;
+				$validates = false;
 			}
+			$return[$this->alias] = $validates;
 
-			$Model = $this->{$association};
+			foreach ($data as $association => $values) {
+				if (!$validates) {
+					break;
+				}
 
-			$type = $associations[$association];
-			$key = $this->{$type}[$association]['foreignKey'];
-			switch ($type) {
-				case 'hasOne':
-					if (isset($values[$association])) {
-						$values[$association][$key] = $this->id;
-					} else {
-						$values = array_merge(array($key => $this->id), $values, array($key => $this->id));
-					}
+				$isEmpty = empty($values) || (isset($values[$association]) && empty($values[$association]));
+				if ($isEmpty || !isset($associations[$association])) {
+					continue;
+				}
 
-					$validates = $Model->create(null) !== null;
-					$saved = false;
+				$Model = $this->{$association};
 
-					if ($validates) {
-						$options = $Model->_addToWhiteList($key, $options);
-						if ($options['deep']) {
-							$saved = $Model->saveAssociated($values, array('atomic' => false) + $options);
+				$type = $associations[$association];
+				$key = $this->{$type}[$association]['foreignKey'];
+				switch ($type) {
+					case 'hasOne':
+						if (isset($values[$association])) {
+							$values[$association][$key] = $this->id;
 						} else {
-							$saved = $Model->save($values, array('atomic' => false) + $options);
+							$values = array_merge(array($key => $this->id), $values, array($key => $this->id));
 						}
-					}
 
-					$validates = ($validates && ($saved === true || (is_array($saved) && !in_array(false, $saved, true))));
-					if (!$validates) {
-						$validationErrors[$association] = $Model->validationErrors;
-					}
+						$validates = $Model->create(null) !== null;
+						$saved = false;
 
-					$return[$association] = $validates;
-					break;
-				case 'hasMany':
-					foreach ($values as $i => $value) {
-						if (isset($values[$i][$association])) {
-							$values[$i][$association][$key] = $this->id;
-						} else {
-							$values[$i] = array_merge(array($key => $this->id), $value, array($key => $this->id));
+						if ($validates) {
+							$options = $Model->_addToWhiteList($key, $options);
+							if ($options['deep']) {
+								$saved = $Model->saveAssociated($values, array('atomic' => false) + $options);
+							} else {
+								$saved = $Model->save($values, $options);
+							}
 						}
-					}
 
-					$options = $Model->_addToWhiteList($key, $options);
-					$_return = $Model->saveMany($values, array('atomic' => false) + $options);
-					if (in_array(false, $_return, true)) {
-						$validationErrors[$association] = $Model->validationErrors;
-						$validates = false;
-					}
+						$validates = ($validates && ($saved === true || (is_array($saved) && !in_array(false, $saved, true))));
+						if (!$validates) {
+							$validationErrors[$association] = $Model->validationErrors;
+						}
 
-					$return[$association] = $_return;
-					break;
+						$return[$association] = $validates;
+						break;
+					case 'hasMany':
+						foreach ($values as $i => $value) {
+							if (isset($values[$i][$association])) {
+								$values[$i][$association][$key] = $this->id;
+							} else {
+								$values[$i] = array_merge(array($key => $this->id), $value, array($key => $this->id));
+							}
+						}
+
+						$options = $Model->_addToWhiteList($key, $options);
+						$_return = $Model->saveMany($values, array('atomic' => false) + $options);
+						if (in_array(false, $_return, true)) {
+							$validationErrors[$association] = $Model->validationErrors;
+							$validates = false;
+						}
+
+						$return[$association] = $_return;
+						break;
+				}
 			}
-		}
-		$this->validationErrors = $validationErrors;
+			$this->validationErrors = $validationErrors;
 
-		if (isset($validationErrors[$this->alias])) {
-			$this->validationErrors = $validationErrors[$this->alias];
-			unset($validationErrors[$this->alias]);
-			$this->validationErrors = array_merge($this->validationErrors, $validationErrors);
-		}
+			if (isset($validationErrors[$this->alias])) {
+				$this->validationErrors = $validationErrors[$this->alias];
+				unset($validationErrors[$this->alias]);
+				$this->validationErrors = array_merge($this->validationErrors, $validationErrors);
+			}
 
-		if (!$options['atomic']) {
-			return $return;
-		}
-		if ($validates) {
-			if ($transactionBegun) {
-				return $db->commit() !== false;
+			if (!$options['atomic']) {
+				return $return;
 			}
+			if ($validates) {
+				if ($transactionBegun) {
+					return $db->commit() !== false;
+				}
 
-			return true;
-		}
+				return true;
+			}
 
-		$db->rollback();
-		return false;
+			if ($transactionBegun) {
+				$db->rollback();
+			}
+			return false;
+		} catch (Exception $e) {
+			if ($transactionBegun) {
+				$db->rollback();
+			}
+			throw $e;
+		}
 	}
 
 /**

+ 1 - 0
lib/Cake/Network/CakeResponse.php

@@ -601,6 +601,7 @@ class CakeResponse {
 			return isset($headers['Location']) ? $headers['Location'] : null;
 		}
 		$this->header('Location', $url);
+		return null;
 	}
 
 /**

+ 52 - 0
lib/Cake/Test/Case/Model/Datasource/DboSourceTest.php

@@ -1201,6 +1201,11 @@ class DboSourceTest extends CakeTestCase {
 				'type' => 'LEFT',
 				'alias' => 'PostsTag',
 				'table' => 'posts_tags',
+			), 'LEFT JOIN cakephp.posts_tags AS PostsTag'),
+			array(array(
+				'type' => 'LEFT',
+				'alias' => 'PostsTag',
+				'table' => 'posts_tags',
 				'conditions' => array('PostsTag.post_id = Post.id')
 			), 'LEFT JOIN cakephp.posts_tags AS PostsTag ON (PostsTag.post_id = Post.id)'),
 			array(array(
@@ -1456,4 +1461,51 @@ class DboSourceTest extends CakeTestCase {
 		$result = $db->defaultConditions($Article, null);
 		$this->assertFalse($result);
 	}
+
+/**
+ * Test that count how many times is afterFind called
+ *
+ * @return void
+ */
+	public function testCountAfterFindCalls() {
+		$this->loadFixtures('Article', 'User', 'Comment', 'Attachment', 'Tag', 'ArticlesTag');
+
+		// Use alias to make testing "primary = true" easy
+		$Primary = $this->getMock('Comment', array('afterFind'), array(array('alias' => 'Primary')), '', true);
+		$Primary->expects($this->any())->method('afterFind')->will($this->returnArgument(0));
+
+		$Article = $this->getMock('Article', array('afterFind'), array(), '', true);
+		$User = $this->getMock('User', array('afterFind'), array(), '', true);
+		$Comment = $this->getMock('Comment', array('afterFind'), array(), '', true);
+		$Tag = $this->getMock('Tag', array('afterFind'), array(), '', true);
+		$Attachment = $this->getMock('Attachment', array('afterFind'), array(), '', true);
+
+		$Primary->Article = $Article;
+		$Primary->Article->User = $User;
+		$Primary->Article->Tag = $Tag;
+		$Primary->Article->Comment = $Comment;
+		$Primary->Attachment = $Attachment;
+		$Primary->Attachment->Comment = $Comment;
+		$Primary->User = $User;
+
+		// primary = true
+		$Primary->expects($this->once())
+			->method('afterFind')->with($this->anything(), $this->isTrue())->will($this->returnArgument(0));
+
+		// primary = false
+		$Article->expects($this->once()) // Primary belongs to 1 Article
+			->method('afterFind')->with($this->anything(), $this->isFalse())->will($this->returnArgument(0));
+		$User->expects($this->exactly(2)) // Article belongs to 1 User and Primary belongs to 1 User
+			->method('afterFind')->with($this->anything(), $this->isFalse())->will($this->returnArgument(0));
+		$Tag->expects($this->exactly(2)) // Article has 2 Tags
+			->method('afterFind')->with($this->anything(), $this->isFalse())->will($this->returnArgument(0));
+		$Comment->expects($this->exactly(3)) // Article has 2 Comments and Attachment belongs to 1 Comment
+			->method('afterFind')->with($this->anything(), $this->isFalse())->will($this->returnArgument(0));
+		$Attachment->expects($this->once()) // Primary has 1 Attachment
+			->method('afterFind')->with($this->anything(), $this->isFalse())->will($this->returnArgument(0));
+
+		$result = $Primary->find('first', array('conditions' => array('Primary.id' => 5), 'recursive' => 2));
+		$this->assertCount(2, $result['Article']['Tag']);
+		$this->assertCount(2, $result['Article']['Comment']);
+	}
 }

+ 397 - 44
lib/Cake/Test/Case/Model/ModelWriteTest.php

@@ -36,6 +36,8 @@ class TestAuthor extends Author {
 
 	protected $_dataSourceObject;
 
+	public $dataForAfterSave;
+
 /**
  * Helper method to set a datasource object
  *
@@ -74,6 +76,8 @@ class TestPost extends Post {
 
 	protected $_dataSourceObject;
 
+	public $dataForAfterSave;
+
 /**
  * Helper method to set a datasource object
  *
@@ -4378,23 +4382,55 @@ class ModelWriteTest extends BaseModelTest {
 	public function testSaveAllManyRowsTransactionNoRollback() {
 		$this->loadFixtures('Post');
 
-		$db = $this->getMock('DboSource', array('begin', 'connect', 'rollback', 'describe'));
+		$Post = new TestPost();
+		$Post->validate = array(
+			'title' => array('rule' => array('notEmpty'))
+		);
 
-		$db->expects($this->once())
-			->method('describe')
-			->will($this->returnValue(array()));
+		// If validation error occurs, rollback() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
 		$db->expects($this->once())->method('rollback');
 
-		$Post = new TestPost();
 		$Post->setDataSourceObject($db);
 
-		$Post->validate = array(
-			'title' => array('rule' => array('notEmpty'))
+		$data = array(
+			array('author_id' => 1, 'title' => 'New Fourth Post'),
+			array('author_id' => 1, 'title' => '')
 		);
+		$Post->saveAll($data, array('atomic' => true, 'validate' => true));
+
+		// If exception thrown, rollback() should be called too.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
+		$db->expects($this->once())->method('rollback');
+
+		$Post->setDataSourceObject($db);
 
 		$data = array(
 			array('author_id' => 1, 'title' => 'New Fourth Post'),
-			array('author_id' => 1, 'title' => '')
+			array('author_id' => 1, 'title' => 'New Fifth Post', 'body' => $db->expression('PDO_EXCEPTION()'))
+		);
+
+		try {
+			$Post->saveAll($data, array('atomic' => true, 'validate' => true));
+			$this->fail('No exception thrown');
+		} catch (PDOException $e) {
+		}
+
+		// Otherwise, commit() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->once())->method('commit');
+		$db->expects($this->never())->method('rollback');
+
+		$Post->setDataSourceObject($db);
+
+		$data = array(
+			array('author_id' => 1, 'title' => 'New Fourth Post'),
+			array('author_id' => 1, 'title' => 'New Fifth Post')
 		);
 		$Post->saveAll($data, array('atomic' => true, 'validate' => true));
 	}
@@ -4405,28 +4441,71 @@ class ModelWriteTest extends BaseModelTest {
  * @return void
  */
 	public function testSaveAllAssociatedTransactionNoRollback() {
-		$testDb = ConnectionManager::getDataSource('test');
+		$this->loadFixtures('Post', 'Author');
 
-		$db = $this->getMock('DboSource', array('connect', 'rollback', 'describe', 'create', 'update', 'begin'));
-		$db->columns = $testDb->columns;
+		$Post = new TestPost();
+		$Post->Author->validate = array(
+			'user' => array('rule' => array('notEmpty'))
+		);
 
+		// If validation error occurs, rollback() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
 		$db->expects($this->once())->method('rollback');
-		$db->expects($this->any())->method('describe')
-			->will($this->returnValue(array(
-				'id' => array('type' => 'integer', 'length' => 11),
-				'title' => array('type' => 'string'),
-				'body' => array('type' => 'text'),
-				'published' => array('type' => 'string')
-			)));
 
-		$Post = new TestPost();
 		$Post->setDataSourceObject($db);
 		$Post->Author->setDataSourceObject($db);
 
-		$Post->Author->validate = array(
-			'user' => array('rule' => array('notEmpty'))
+		$data = array(
+			'Post' => array(
+				'title' => 'New post',
+				'body' => 'Content',
+				'published' => 'Y'
+			),
+			'Author' => array(
+				'user' => '',
+				'password' => "sekret"
+			)
+		);
+		$Post->saveAll($data, array('validate' => true));
+
+		// If exception thrown, rollback() should be called too.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
+		$db->expects($this->once())->method('rollback');
+
+		$Post->setDataSourceObject($db);
+		$Post->Author->setDataSourceObject($db);
+
+		$data = array(
+			'Post' => array(
+				'title' => 'New post',
+				'body' => $db->expression('PDO_EXCEPTION()'),
+				'published' => 'Y'
+			),
+			'Author' => array(
+				'user' => 'New user',
+				'password' => "sekret"
+			)
 		);
 
+		try {
+			$Post->saveAll($data, array('validate' => true));
+			$this->fail('No exception thrown');
+		} catch (PDOException $e) {
+		}
+
+		// Otherwise, commit() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->once())->method('commit');
+		$db->expects($this->never())->method('rollback');
+
+		$Post->setDataSourceObject($db);
+		$Post->Author->setDataSourceObject($db);
+
 		$data = array(
 			'Post' => array(
 				'title' => 'New post',
@@ -4434,7 +4513,7 @@ class ModelWriteTest extends BaseModelTest {
 				'published' => 'Y'
 			),
 			'Author' => array(
-				'user' => '',
+				'user' => 'New user',
 				'password' => "sekret"
 			)
 		);
@@ -5811,23 +5890,55 @@ class ModelWriteTest extends BaseModelTest {
 	public function testSaveManyTransactionNoRollback() {
 		$this->loadFixtures('Post');
 
-		$db = $this->getMock('DboSource', array('begin', 'connect', 'rollback', 'describe'));
+		$Post = new TestPost();
+		$Post->validate = array(
+			'title' => array('rule' => array('notEmpty'))
+		);
 
-		$db->expects($this->once())
-			->method('describe')
-			->will($this->returnValue(array()));
+		// If validation error occurs, rollback() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
 		$db->expects($this->once())->method('rollback');
 
-		$Post = new TestPost();
 		$Post->setDataSourceObject($db);
 
-		$Post->validate = array(
-			'title' => array('rule' => array('notEmpty'))
+		$data = array(
+			array('author_id' => 1, 'title' => 'New Fourth Post'),
+			array('author_id' => 1, 'title' => '')
+		);
+		$Post->saveMany($data, array('validate' => true));
+
+		// If exception thrown, rollback() should be called too.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
+		$db->expects($this->once())->method('rollback');
+
+		$Post->setDataSourceObject($db);
+
+		$data = array(
+			array('author_id' => 1, 'title' => 'New Fourth Post'),
+			array('author_id' => 1, 'title' => 'New Fifth Post', 'body' => $db->expression('PDO_EXCEPTION()'))
 		);
 
+		try {
+			$Post->saveMany($data, array('validate' => true));
+			$this->fail('No exception thrown');
+		} catch (PDOException $e) {
+		}
+
+		// Otherwise, commit() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->once())->method('commit');
+		$db->expects($this->never())->method('rollback');
+
+		$Post->setDataSourceObject($db);
+
 		$data = array(
 			array('author_id' => 1, 'title' => 'New Fourth Post'),
-			array('author_id' => 1, 'title' => '')
+			array('author_id' => 1, 'title' => 'New Fifth Post')
 		);
 		$Post->saveMany($data, array('validate' => true));
 	}
@@ -5838,28 +5949,71 @@ class ModelWriteTest extends BaseModelTest {
  * @return void
  */
 	public function testSaveAssociatedTransactionNoRollback() {
-		$testDb = ConnectionManager::getDataSource('test');
+		$this->loadFixtures('Post', 'Author');
 
-		$db = $this->getMock('DboSource', array('connect', 'rollback', 'describe', 'create', 'begin'));
-		$db->columns = $testDb->columns;
+		$Post = new TestPost();
+		$Post->Author->validate = array(
+			'user' => array('rule' => array('notEmpty'))
+		);
 
+		// If validation error occurs, rollback() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
 		$db->expects($this->once())->method('rollback');
-		$db->expects($this->any())->method('describe')
-			->will($this->returnValue(array(
-				'id' => array('type' => 'integer', 'length' => 11),
-				'title' => array('type' => 'string'),
-				'body' => array('type' => 'text'),
-				'published' => array('type' => 'string')
-			)));
 
-		$Post = new TestPost();
 		$Post->setDataSourceObject($db);
 		$Post->Author->setDataSourceObject($db);
 
-		$Post->Author->validate = array(
-			'user' => array('rule' => array('notEmpty'))
+		$data = array(
+			'Post' => array(
+				'title' => 'New post',
+				'body' => 'Content',
+				'published' => 'Y'
+			),
+			'Author' => array(
+				'user' => '',
+				'password' => "sekret"
+			)
+		);
+		$Post->saveAssociated($data, array('validate' => true, 'atomic' => true));
+
+		// If exception thrown, commit() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
+		$db->expects($this->once())->method('rollback');
+
+		$Post->setDataSourceObject($db);
+		$Post->Author->setDataSourceObject($db);
+
+		$data = array(
+			'Post' => array(
+				'title' => 'New post',
+				'body' => $db->expression('PDO_EXCEPTION()'),
+				'published' => 'Y'
+			),
+			'Author' => array(
+				'user' => 'New user',
+				'password' => "sekret"
+			)
 		);
 
+		try {
+			$Post->saveAssociated($data, array('validate' => true, 'atomic' => true));
+			$this->fail('No exception thrown');
+		} catch (PDOException $e) {
+		}
+
+		// Otherwise, commit() should be called.
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->once())->method('begin')->will($this->returnValue(true));
+		$db->expects($this->once())->method('commit');
+		$db->expects($this->never())->method('rollback');
+
+		$Post->setDataSourceObject($db);
+		$Post->Author->setDataSourceObject($db);
+
 		$data = array(
 			'Post' => array(
 				'title' => 'New post',
@@ -5867,7 +6021,7 @@ class ModelWriteTest extends BaseModelTest {
 				'published' => 'Y'
 			),
 			'Author' => array(
-				'user' => '',
+				'user' => 'New user',
 				'password' => "sekret"
 			)
 		);
@@ -7569,4 +7723,203 @@ class ModelWriteTest extends BaseModelTest {
 		$Model = $event->subject;
 		$Model->getDataSource()->delete($Model, array($Model->alias . '.' . $Model->primaryKey => $Model->id));
 	}
+
+/**
+ * Creates a convenient mock DboSource
+ *
+ * We cannot call several methods via mock DboSource, such as DboSource::value()
+ * because mock DboSource has no $_connection.
+ * This method helps us to avoid this problem.
+ *
+ * @param array $methods Configurable method names.
+ * @return DboSource
+ */
+	protected function _getMockDboSource($methods = array()) {
+		$testDb = ConnectionManager::getDataSource('test');
+
+		$passthrough = array_diff(array('value', 'begin', 'rollback', 'commit', 'describe', 'lastInsertId', 'execute'), $methods);
+
+		$methods = array_merge($methods, $passthrough);
+		if (!in_array('connect', $methods)) {
+			$methods[] = 'connect'; // This will be called by DboSource::__construct().
+		}
+
+		$db = $this->getMock('DboSource', $methods);
+		$db->columns = $testDb->columns;
+		$db->startQuote = $testDb->startQuote;
+		$db->endQuote = $testDb->endQuote;
+
+		foreach ($passthrough as $method) {
+			$db->expects($this->any())
+				->method($method)
+				->will($this->returnCallback(array($testDb, $method)));
+		}
+
+		return $db;
+	}
+
+/**
+ * Test that transactions behave correctly on nested saveMany calls.
+ *
+ * @return void
+ */
+	public function testTransactionOnNestedSaveMany() {
+		$this->loadFixtures('Post');
+		$Post = new TestPost();
+		$Post->getEventManager()->attach(array($this, 'nestedSaveMany'), 'Model.afterSave');
+
+		// begin -> [ begin -> commit ] -> commit
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->exactly(2))->method('begin')->will($this->returnValue(true));
+		$db->expects($this->exactly(2))->method('commit');
+		$db->expects($this->never())->method('rollback');
+		$Post->setDataSourceObject($db);
+
+		$data = array(
+			array('author_id' => 1, 'title' => 'Outer Post'),
+		);
+		$Post->dataForAfterSave = array(
+			array('author_id' => 1, 'title' => 'Inner Post'),
+		);
+		$this->assertTrue($Post->saveMany($data));
+
+		// begin -> [  begin(false) ] -> commit
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->at(0))->method('begin')->will($this->returnValue(true));
+		$db->expects($this->at(1))->method('begin')->will($this->returnValue(false));
+		$db->expects($this->once())->method('commit');
+		$db->expects($this->never())->method('rollback');
+		$Post->setDataSourceObject($db);
+
+		$data = array(
+			array('author_id' => 1, 'title' => 'Outer Post'),
+		);
+		$Post->dataForAfterSave = array(
+			array('author_id' => 1, 'title' => 'Inner Post'),
+		);
+		$this->assertTrue($Post->saveMany($data));
+
+		// begin -> [ begin -> rollback ] -> rollback
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->exactly(2))->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
+		$db->expects($this->exactly(2))->method('rollback');
+		$Post->setDataSourceObject($db);
+		$data = array(
+			array('author_id' => 1, 'title' => 'Outer Post'),
+		);
+		$Post->dataForAfterSave = array(
+			array('author_id' => 1, 'title' => 'Inner Post', 'body' => $db->expression('PDO_EXCEPTION()')),
+		);
+
+		try {
+			$Post->saveMany($data);
+			$this->fail('No exception thrown');
+		} catch(Exception $e) {
+		}
+	}
+
+/**
+ * Test that transaction behaves correctly on nested saveAssociated calls.
+ *
+ * @return void
+ */
+	public function testTransactionOnNestedSaveAssociated() {
+		$this->loadFixtures('Author', 'Post');
+
+		$Author = new TestAuthor();
+		$Author->getEventManager()->attach(array($this, 'nestedSaveAssociated'), 'Model.afterSave');
+
+		// begin -> [ begin -> commit ] -> commit
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->exactly(2))->method('begin')->will($this->returnValue(true));
+		$db->expects($this->exactly(2))->method('commit');
+		$db->expects($this->never())->method('rollback');
+		$Author->setDataSourceObject($db);
+		$Author->Post->setDataSourceObject($db);
+
+		$data = array(
+			'Author' => array('user' => 'outer'),
+			'Post' => array(
+				array('title' => 'Outer Post'),
+			)
+		);
+		$Author->dataForAfterSave = array(
+			'Author' => array('user' => 'inner'),
+			'Post' => array(
+				array('title' => 'Inner Post'),
+			)
+		);
+		$this->assertTrue($Author->saveAssociated($data));
+
+		// begin -> [  begin(false) ] -> commit
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->at(0))->method('begin')->will($this->returnValue(true));
+		$db->expects($this->at(1))->method('begin')->will($this->returnValue(false));
+		$db->expects($this->once())->method('commit');
+		$db->expects($this->never())->method('rollback');
+		$Author->setDataSourceObject($db);
+		$Author->Post->setDataSourceObject($db);
+		$data = array(
+			'Author' => array('user' => 'outer'),
+			'Post' => array(
+				array('title' => 'Outer Post'),
+			)
+		);
+		$Author->dataForAfterSave = array(
+			'Author' => array('user' => 'inner'),
+			'Post' => array(
+				array('title' => 'Inner Post'),
+			)
+		);
+		$this->assertTrue($Author->saveAssociated($data));
+
+		// begin -> [ begin -> rollback ] -> rollback
+		$db = $this->_getMockDboSource(array('begin', 'commit', 'rollback'));
+		$db->expects($this->exactly(2))->method('begin')->will($this->returnValue(true));
+		$db->expects($this->never())->method('commit');
+		$db->expects($this->exactly(2))->method('rollback');
+		$Author->setDataSourceObject($db);
+		$Author->Post->setDataSourceObject($db);
+		$data = array(
+			'Author' => array('user' => 'outer'),
+			'Post' => array(
+				array('title' => 'Outer Post'),
+			)
+		);
+		$Author->dataForAfterSave = array(
+			'Author' => array('user' => 'inner', 'password' => $db->expression('PDO_EXCEPTION()')),
+			'Post' => array(
+				array('title' => 'Inner Post'),
+			)
+		);
+
+		try {
+			$Author->saveAssociated($data);
+			$this->fail('No exception thrown');
+		} catch(Exception $e) {
+		}
+	}
+
+/**
+ * A callback for testing nested saveMany.
+ *
+ * @param CakeEvent $event containing the Model
+ * @return void
+ */
+	public function nestedSaveMany($event) {
+		$Model = $event->subject;
+		$Model->saveMany($Model->dataForAfterSave, array('callbacks' => false));
+	}
+
+/**
+ * A callback for testing nested saveAssociated.
+ *
+ * @param CakeEvent $event containing the Model
+ * @return void
+ */
+	public function nestedSaveAssociated($event) {
+		$Model = $event->subject;
+		$Model->saveAssociated($Model->dataForAfterSave, array('callbacks' => false));
+	}
 }

+ 1 - 1
lib/Cake/Test/Case/Network/Email/CakeEmailTest.php

@@ -2096,7 +2096,7 @@ class CakeEmailTest extends CakeTestCase {
 		$result['html'] = false;
 		$length = count($message);
 		for ($i = 0; $i < $length; ++$i) {
-			if ($message[$i] == $boundary) {
+			if ($message[$i] === $boundary) {
 				$flag = false;
 				$type = '';
 				while (!preg_match('/^$/', $message[$i])) {

+ 2 - 2
lib/Cake/Test/Case/Utility/DebuggerTest.php

@@ -540,8 +540,8 @@ TEXT;
 		Debugger::dump($var, 1);
 		$result = ob_get_clean();
 
-		$open = php_sapi_name() == 'cli' ? "\n" : '<pre>';
-		$close = php_sapi_name() == 'cli' ? "\n" : '</pre>';
+		$open = php_sapi_name() === 'cli' ? "\n" : '<pre>';
+		$close = php_sapi_name() === 'cli' ? "\n" : '</pre>';
 		$expected = <<<TEXT
 {$open}array(
 	'People' => array(

+ 5 - 5
lib/Cake/Utility/String.php

@@ -132,21 +132,21 @@ class String {
 			}
 			if ($tmpOffset !== -1) {
 				$buffer .= substr($data, $offset, ($tmpOffset - $offset));
-				if (!$depth && $data{$tmpOffset} == $separator) {
+				if (!$depth && $data{$tmpOffset} === $separator) {
 					$results[] = $buffer;
 					$buffer = '';
 				} else {
 					$buffer .= $data{$tmpOffset};
 				}
-				if ($leftBound != $rightBound) {
-					if ($data{$tmpOffset} == $leftBound) {
+				if ($leftBound !== $rightBound) {
+					if ($data{$tmpOffset} === $leftBound) {
 						$depth++;
 					}
-					if ($data{$tmpOffset} == $rightBound) {
+					if ($data{$tmpOffset} === $rightBound) {
 						$depth--;
 					}
 				} else {
-					if ($data{$tmpOffset} == $leftBound) {
+					if ($data{$tmpOffset} === $leftBound) {
 						if (!$open) {
 							$depth++;
 							$open = true;

+ 6 - 1
lib/Cake/View/Helper/CacheHelper.php

@@ -109,6 +109,7 @@ class CacheHelper extends AppHelper {
  * @param string $out output to cache
  * @return string view output
  * @link http://book.cakephp.org/2.0/en/core-libraries/helpers/cache.html
+ * @throws Exception If debug mode is enabled and writing to cache file fails.
  */
 	public function cache($file, $out) {
 		$cacheTime = 0;
@@ -120,7 +121,7 @@ class CacheHelper extends AppHelper {
 			$index = null;
 
 			foreach ($keys as $action) {
-				if ($action == $this->request->params['action']) {
+				if ($action === $this->request->params['action']) {
 					$index = $action;
 					break;
 				}
@@ -153,6 +154,10 @@ class CacheHelper extends AppHelper {
 			try {
 				$this->_writeFile($cached, $cacheTime, $useCallbacks);
 			} catch (Exception $e) {
+				if (Configure::read('debug')) {
+					throw $e;
+				}
+
 				$message = __d(
 					'cake_dev',
 					'Unable to write view cache file: "%s" for "%s"',

+ 1 - 1
lib/Cake/View/Helper/TimeHelper.php

@@ -364,7 +364,7 @@ class TimeHelper extends AppHelper {
 	}
 
 /**
- * Formats date for RSS feeds
+ * Formats a date into a phrase expressing the relative time.
  *
  * ## Addition options
  *