diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 11624b6c1e6..067f5f25923 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -18,6 +18,7 @@ use Cake\Database\Exception\MissingConnectionException; use Cake\Database\Exception\MissingDriverException; use Cake\Database\Exception\MissingExtensionException; +use Cake\Database\Exception\NestedTransactionRollbackException; use Cake\Database\Log\LoggedQuery; use Cake\Database\Log\LoggingStatement; use Cake\Database\Log\QueryLogger; @@ -91,6 +92,14 @@ class Connection implements ConnectionInterface */ protected $_schemaCollection = null; + /** + * NestedTransactionRollbackException object instance, will be stored if + * the rollback method is called in some nested transaction. + * + * @var \Cake\Database\Exception\NestedTransactionRollbackException|null + */ + protected $_nestedTransactionRollbackException = null; + /** * Constructor. * @@ -441,6 +450,7 @@ public function begin() $this->_driver->beginTransaction(); $this->_transactionLevel = 0; $this->_transactionStarted = true; + $this->_nestedTransactionRollbackException = null; return; } @@ -463,7 +473,12 @@ public function commit() } if ($this->_transactionLevel === 0) { + if ($this->wasNestedTransactionRolledback()) { + throw $this->_nestedTransactionRollbackException; + } + $this->_transactionStarted = false; + $this->_nestedTransactionRollbackException = null; if ($this->_logQueries) { $this->log('COMMIT'); } @@ -482,18 +497,24 @@ public function commit() /** * Rollback current transaction. * + * @param bool|null $toBeginning Whether or not the transaction should be rolled back to the + * beginning of it. Defaults to false if using savepoints, or true if not. * @return bool */ - public function rollback() + public function rollback($toBeginning = null) { if (!$this->_transactionStarted) { return false; } $useSavePoint = $this->isSavePointsEnabled(); - if ($this->_transactionLevel === 0 || !$useSavePoint) { + if ($toBeginning === null) { + $toBeginning = !$useSavePoint; + } + if ($this->_transactionLevel === 0 || $toBeginning) { $this->_transactionLevel = 0; $this->_transactionStarted = false; + $this->_nestedTransactionRollbackException = null; if ($this->_logQueries) { $this->log('ROLLBACK'); } @@ -502,8 +523,11 @@ public function rollback() return true; } + $savePoint = $this->_transactionLevel--; if ($useSavePoint) { - $this->rollbackSavepoint($this->_transactionLevel--); + $this->rollbackSavepoint($savePoint); + } elseif ($this->_nestedTransactionRollbackException === null) { + $this->_nestedTransactionRollbackException = new NestedTransactionRollbackException(); } return true; @@ -653,21 +677,36 @@ public function transactional(callable $callback) try { $result = $callback($this); } catch (\Exception $e) { - $this->rollback(); + $this->rollback(false); throw $e; } if ($result === false) { - $this->rollback(); + $this->rollback(false); return false; } - $this->commit(); + try { + $this->commit(); + } catch (NestedTransactionRollbackException $e) { + $this->rollback(false); + throw $e; + } return $result; } + /** + * Returns whether some nested transaction has been already rolled back. + * + * @return bool + */ + public function wasNestedTransactionRolledback() + { + return $this->_nestedTransactionRollbackException instanceof NestedTransactionRollbackException; + } + /** * {@inheritDoc} * diff --git a/src/Database/Exception/NestedTransactionRollbackException.php b/src/Database/Exception/NestedTransactionRollbackException.php new file mode 100644 index 00000000000..a91c89fe63e --- /dev/null +++ b/src/Database/Exception/NestedTransactionRollbackException.php @@ -0,0 +1,36 @@ +schemaCollection($schema); $this->assertSame($schema, $connection->schemaCollection()); } + + /** + * Tests that allowed nesting of commit/rollback operations doesn't + * throw any exceptions. + * + * @return void + */ + public function testNestedTransactionRollbackExceptionNotThrown() + { + $this->connection->transactional(function () { + $this->connection->transactional(function () { + return true; + }); + + return true; + }); + $this->assertFalse($this->connection->inTransaction()); + + $this->connection->transactional(function () { + $this->connection->transactional(function () { + return true; + }); + + return false; + }); + $this->assertFalse($this->connection->inTransaction()); + + $this->connection->transactional(function () { + $this->connection->transactional(function () { + return false; + }); + + return false; + }); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests that not allowed nesting of commit/rollback operations throws + * a NestedTransactionRollbackException. + * + * @return void + * @expectedException \Cake\Database\Exception\NestedTransactionRollbackException + * @expectedExceptionMessage Cannot commit transaction - rollback() has been already called in the nested transaction + */ + public function testNestedTransactionRollbackExceptionThrown() + { + // ROLLBACK -> COMMIT + $this->connection->transactional(function () { + $this->connection->transactional(function () { + return false; + }); + + return true; + }); + } + + /** + * Tests mor detail about that not allowed nesting of rollback/commit + * operations throws a NestedTransactionRollbackException by trace. + * + * @return void + */ + public function testNestedTransactionRollbackExceptionByTrace() + { + $rollbackSourceLine = -1; + $nestedTransactionStates = []; + + $e = null; + try { + $this->connection->transactional(function () use (&$rollbackSourceLine, &$nestedTransactionStates) { + $nestedTransactionStates[] = $this->connection->wasNestedTransactionRolledback(); + + $this->connection->transactional(function () { + return true; + }); + + $this->connection->transactional(function () use (&$rollbackSourceLine, &$nestedTransactionStates) { + $nestedTransactionStates[] = $this->connection->wasNestedTransactionRolledback(); + + $this->connection->transactional(function () { + return false; + }); + $rollbackSourceLine = __LINE__ - 1; + + $nestedTransactionStates[] = $this->connection->wasNestedTransactionRolledback(); + + return true; + }); + + $this->connection->transactional(function () { + return false; + }); + + $nestedTransactionStates[] = $this->connection->wasNestedTransactionRolledback(); + + return true; + }); + } catch (NestedTransactionRollbackException $e) { + } + + $nestedTransactionStates[] = $this->connection->wasNestedTransactionRolledback(); + + $this->assertInstanceOf(NestedTransactionRollbackException::class, $e); + + $this->assertSame([false, false, true, true, false], $nestedTransactionStates); + $this->assertFalse($this->connection->inTransaction()); + + $trace = $e->getTrace(); + $this->assertEquals(__FILE__, $trace[1]['file']); + $this->assertEquals($rollbackSourceLine, $trace[1]['line']); + } }