diff --git a/includes/database/database.inc b/includes/database/database.inc index 73dbfe78fc9..7f3617b5add 100644 --- a/includes/database/database.inc +++ b/includes/database/database.inc @@ -200,16 +200,9 @@ abstract class DatabaseConnection extends PDO { * nested calls to transactions and collapse them into a single * transaction. * - * @var int + * @var array */ - protected $transactionLayers = 0; - - /** - * Whether or not the active transaction (if any) will be rolled back. - * - * @var boolean - */ - protected $willRollback; + protected $transactionLayers = array(); /** * Array of argument arrays for logging post-rollback. @@ -870,29 +863,42 @@ abstract class DatabaseConnection extends PDO { * TRUE if we're currently in a transaction, FALSE otherwise. */ public function inTransaction() { - return ($this->transactionLayers > 0); + return ($this->transactionDepth() > 0); + } + + /** + * Determines current transaction depth. + */ + public function transactionDepth() { + return count($this->transactionLayers); } /** * Returns a new DatabaseTransaction object on this connection. * + * @param $name + * Optional name of the savepoint. + * * @see DatabaseTransaction */ - public function startTransaction() { + public function startTransaction($name = '') { if (empty($this->transactionClass)) { $this->transactionClass = 'DatabaseTransaction_' . $this->driver(); if (!class_exists($this->transactionClass)) { $this->transactionClass = 'DatabaseTransaction'; } } - return new $this->transactionClass($this); + return new $this->transactionClass($this, $name); } /** - * Schedules the current transaction for rollback. + * Rolls back the transaction entirely or to a named savepoint. * * This method throws an exception if no transaction is active. * + * @param $savepoint_name + * The name of the savepoint. The default, 'drupal_transaction', will roll + * the entire transaction back. * @param $type * The category to which the rollback message belongs. * @param $message @@ -912,9 +918,14 @@ abstract class DatabaseConnection extends PDO { * @see DatabaseTransaction::rollback() * @see watchdog() */ - public function rollback($type = NULL, $message = NULL, $variables = array(), $severity = NULL, $link = NULL) { - if ($this->transactionLayers == 0) { - throw new NoActiveTransactionException(); + public function rollback($savepoint_name = 'drupal_transaction', $type = NULL, $message = NULL, $variables = array(), $severity = NULL, $link = NULL) { + if (!$this->inTransaction()) { + throw new DatabaseTransactionNoActiveException(); + } + // A previous rollback to an earlier savepoint may mean that the savepoint + // in question has already been rolled back. + if (!in_array($savepoint_name, $this->transactionLayers)) { + return; } // Set the severity to the configured default if not specified. @@ -940,25 +951,49 @@ abstract class DatabaseConnection extends PDO { ); } - $this->willRollback = TRUE; + // We need to find the point we're rolling back to, all other savepoints + // before are no longer needed. + while ($savepoint = array_pop($this->transactionLayers)) { + if ($savepoint == $savepoint_name) { + // If it is the last the transaction in the stack, then it is not a + // savepoint, it is the transaction itself so we will need to roll back + // the transaction rather than a savepoint. + if (empty($this->transactionLayers)) { + break; + } + $this->query('ROLLBACK TO SAVEPOINT ' . $savepoint); + return; + } + } + if ($this->supportsTransactions()) { + parent::rollBack(); + } + $this->logRollback(); } /** - * Determines if this transaction will roll back. - * - * Use this function to skip further operations if the current transaction - * is already scheduled to roll back. Throws an exception if no transaction - * is active. - * - * @return - * TRUE if the transaction will roll back, FALSE otherwise. + * Logs messages from rollback(). */ - public function willRollback() { - if ($this->transactionLayers == 0) { - throw new NoActiveTransactionException(); + protected function logRollback() { + $logging = Database::getLoggingCallback(); + // If there is no callback defined. We can't do anything. + if (!is_array($logging)) { + return; } - return $this->willRollback; + $logging_callback = $logging['callback']; + + // Log the failed rollback. + $logging_callback('database', 'Explicit rollback failed: not supported on active connection.', array(), $logging['error_severity']); + + // Play back the logged errors to the specified logging callback post- + // rollback. + foreach ($this->rollbackLogs as $log_item) { + $logging_callback($log_item['type'], $log_item['message'], $log_item['variables'], $log_item['severity'], $log_item['link']); + } + + // Reset the error logs. + $this->rollbackLogs = array(); } /** @@ -968,72 +1003,57 @@ abstract class DatabaseConnection extends PDO { * * @see DatabaseTransaction */ - public function pushTransaction() { - ++$this->transactionLayers; - - if ($this->transactionLayers == 1) { - if ($this->supportsTransactions()) { - parent::beginTransaction(); - } + public function pushTransaction($name) { + if (!$this->supportsTransactions()) { + return; } + if (isset($this->transactionLayers[$name])) { + throw new DatabaseTransactionNameNonUniqueException($name . " is already in use."); + } + // If we're already in a transaction then we want to create a savepoint + // rather than try to create another transaction. + if ($this->inTransaction()) { + $this->query('SAVEPOINT ' . $name); + } + else { + parent::beginTransaction(); + } + $this->transactionLayers[$name] = $name; } /** * Decreases the depth of transaction nesting. * - * This function first attempts to decrease the number of layers of - * transaction nesting by one. If there was no active transaction, the - * function throws an exception. If this was the last transaction layer, the - * function either rolls back or commits the transaction, depending on whether - * the transaction was marked for rollback or not. + * If we pop off the last transaction layer, then we either commit or roll + * back the transaction as necessary. If no transaction is active, we return + * because the transaction may have manually been rolled back. + * + * @param $name + * The name of the savepoint * * @see DatabaseTransaction */ - public function popTransaction() { - if ($this->transactionLayers == 0) { - throw new NoActiveTransactionException(); + public function popTransaction($name) { + if (!$this->supportsTransactions()) { + return; + } + if (!$this->inTransaction()) { + throw new DatabaseTransactionNoActiveException(); } - --$this->transactionLayers; + // Commit everything since SAVEPOINT $name. + while($savepoint = array_pop($this->transactionLayers)) { + if ($savepoint != $name) continue; - if ($this->transactionLayers == 0) { - if ($this->willRollback) { - // Reset the rollback status so that the next transaction starts clean. - $this->willRollback = FALSE; - - // Reset the error log. - $rollback_logs = $this->rollbackLogs; - $this->rollbackLogs = array(); - - $logging = Database::getLoggingCallback(); - $logging_callback = NULL; - if (is_array($logging)) { - $logging_callback = $logging['callback']; - } - - if ($this->supportsTransactions()) { - parent::rollBack(); - } - else { - if (isset($logging_callback)) { - // Log the failed rollback. - call_user_func($logging_callback, 'database', 'Explicit rollback failed: not supported on active connection.', array(), $logging['error_severity']); - } - - // It would be nice to throw an exception here if logging failed, - // but throwing exceptions in destructors is not supported. - } - - if (isset($logging_callback)) { - // Play back the logged errors to the specified logging callback post- - // rollback. - foreach ($rollback_logs as $log_item) { - call_user_func($logging_callback, $log_item['type'], $log_item['message'], $log_item['variables'], $log_item['severity'], $log_item['link']); - } + // If there are no more layers left then we should commit. + if (empty($this->transactionLayers)) { + if (!parent::commit()) { + throw new DatabaseTransactionCommitFailedException(); } } - elseif ($this->supportsTransactions()) { - parent::commit(); + else { + $this->query('RELEASE SAVEPOINT ' . $name); + break; } } } @@ -1166,7 +1186,7 @@ abstract class DatabaseConnection extends PDO { * @see DatabaseTransaction */ public function commit() { - throw new ExplicitTransactionsNotSupportedException(); + throw new DatabaseTransactionExplicitCommitNotAllowedException(); } /** @@ -1611,7 +1631,17 @@ abstract class Database { /** * Exception for when popTransaction() is called with no active transaction. */ -class NoActiveTransactionException extends Exception { } +class DatabaseTransactionNoActiveException extends Exception { } + +/** + * Exception thrown when a savepoint or transaction name occurs twice. + */ +class DatabaseTransactionNameNonUniqueException extends Exception { } + +/** + * Exception thrown when a commit() function fails. + */ +class DatabaseTransactionCommitFailedException extends Exception { } /** * Exception to deny attempts to explicitly manage transactions. @@ -1619,7 +1649,7 @@ class NoActiveTransactionException extends Exception { } * This exception will be thrown when the PDO connection commit() is called. * Code should never call this method directly. */ -class ExplicitTransactionsNotSupportedException extends Exception { } +class DatabaseTransactionExplicitCommitNotAllowedException extends Exception { } /** * Exception thrown for merge queries that do not make semantic sense. @@ -1670,13 +1700,51 @@ class DatabaseTransaction { */ protected $connection; - public function __construct(DatabaseConnection &$connection) { + /** + * A boolean value to indicate whether this transaction has been rolled back. + * + * @var Boolean + */ + protected $rolledBack = FALSE; + + /** + * The name of the transaction. + * + * This is used to label the transaction savepoint. It will be overridden to + * 'drupal_transaction' if there is no transaction depth. + */ + protected $name; + + public function __construct(DatabaseConnection &$connection, $name = NULL) { $this->connection = &$connection; - $this->connection->pushTransaction(); + // If there is no transaction depth, then no transaction has started. Name + // the transaction 'drupal_transaction'. + if (!$depth = $connection->transactionDepth()) { + $this->name = 'drupal_transaction'; + } + // Within transactions, savepoints are used. Each savepoint requires a + // name. So if no name is present we need to create one. + elseif (!$name) { + $this->name = 'savepoint_' . $depth; + } + else { + $this->name = $name; + } + $this->connection->pushTransaction($this->name); } public function __destruct() { - $this->connection->popTransaction(); + // If we rolled back then the transaction would have already been popped. + if ($this->connection->inTransaction() && !$this->rolledBack) { + $this->connection->popTransaction($this->name); + } + } + + /** + * Retrieves the name of the transaction or savepoint. + */ + public function name() { + return $this->name; } /** @@ -1704,22 +1772,15 @@ class DatabaseTransaction { * @see watchdog() */ public function rollback($type = NULL, $message = NULL, $variables = array(), $severity = NULL, $link = NULL) { + $this->rolledBack = TRUE; if (!isset($severity)) { $logging = Database::getLoggingCallback(); if (is_array($logging)) { $severity = $logging['default_severity']; } } - $this->connection->rollback($type, $message, $variables, $severity, $link); + $this->connection->rollback($this->name, $type, $message, $variables, $severity, $link); } - - /** - * Determines if this transaction will roll back. - */ - public function willRollback() { - return $this->connection->willRollback(); - } - } /** @@ -2310,22 +2371,20 @@ function db_select($table, $alias = NULL, array $options = array()) { /** * Returns a new transaction object for the active database. * - * @param $required - * TRUE if the calling code will not function properly without transaction - * support. If set to TRUE and the active database does not support - * transactions, a TransactionsNotSupportedException exception will be thrown. - * @param $options - * An array of options to control how the transaction operates. Only the - * target key has any meaning in this case. + * @param string $name + * Optional name of the transaction. + * @param array $options + * An array of options to control how the transaction operates: + * - target: The database target name. * * @return DatabaseTransaction * A new DatabaseTransaction object for this connection. */ -function db_transaction($required = FALSE, Array $options = array()) { +function db_transaction($name = NULL, array $options = array()) { if (empty($options['target'])) { $options['target'] = 'default'; } - return Database::getConnection($options['target'])->startTransaction($required); + return Database::getConnection($options['target'])->startTransaction($name); } /** @@ -2413,7 +2472,7 @@ function db_driver() { * Closes the active database connection. * * @param $options - * An array of options to control which connection is closed. Only the target + * An array of options to control which connection is closed. Only the target * key has any meaning in this case. */ function db_close(array $options = array()) { diff --git a/modules/simpletest/tests/database_test.test b/modules/simpletest/tests/database_test.test index 3dc48b727dd..c09d9a312d5 100644 --- a/modules/simpletest/tests/database_test.test +++ b/modules/simpletest/tests/database_test.test @@ -2908,6 +2908,7 @@ class DatabaseTransactionTestCase extends DatabaseTestCase { */ protected function transactionOuterLayer($suffix, $rollback = FALSE) { $connection = Database::getConnection(); + $depth = $connection->transactionDepth(); $txn = db_transaction(); // Insert a single row into the testing table. @@ -2925,6 +2926,13 @@ class DatabaseTransactionTestCase extends DatabaseTestCase { $this->transactionInnerLayer($suffix, $rollback); $this->assertTrue($connection->inTransaction(), t('In transaction after calling nested transaction.')); + + if ($rollback) { + // Roll back the transaction, if requested. + // This rollback should propagate to the last savepoint. + $txn->rollback(); + $this->assertTrue(($connection->transactionDepth() == $depth), t('Transaction has rolled back to the last savepoint after calling rollback().')); + } } /** @@ -2939,12 +2947,18 @@ class DatabaseTransactionTestCase extends DatabaseTestCase { protected function transactionInnerLayer($suffix, $rollback = FALSE) { $connection = Database::getConnection(); + $this->assertTrue($connection->inTransaction(), t('In transaction in nested transaction.')); + + $depth = $connection->transactionDepth(); // Start a transaction. If we're being called from ->transactionOuterLayer, // then we're already in a transaction. Normally, that would make starting // a transaction here dangerous, but the database API handles this problem // for us by tracking the nesting and avoiding the danger. $txn = db_transaction(); + $depth2 = $connection->transactionDepth(); + $this->assertTrue($depth < $depth2, t('Transaction depth is has increased with new transaction.')); + // Insert a single row into the testing table. db_insert('test') ->fields(array( @@ -2957,9 +2971,9 @@ class DatabaseTransactionTestCase extends DatabaseTestCase { if ($rollback) { // Roll back the transaction, if requested. - // This rollback should propagate to the the outer transaction, if present. + // This rollback should propagate to the last savepoint. $txn->rollback(); - $this->assertTrue($txn->willRollback(), t('Transaction is scheduled to roll back after calling rollback().')); + $this->assertTrue(($connection->transactionDepth() == $depth), t('Transaction has rolled back to the last savepoint after calling rollback().')); } }