Index: includes/database/database.inc
===================================================================
RCS file: /cvs/drupal/drupal/includes/database/database.inc,v
retrieving revision 1.102
diff -u -p -r1.102 database.inc
--- includes/database/database.inc	9 Mar 2010 11:39:07 -0000	1.102
+++ includes/database/database.inc	12 Mar 2010 18:14:11 -0000
@@ -205,16 +205,9 @@ abstract class DatabaseConnection extend
    * nested calls to transactions and collapse them into a single
    * transaction.
    *
-   * @var int
-   */
-  protected $transactionLayers = 0;
-
-  /**
-   * Whether or not the active transaction (if any) will be rolled back.
-   *
-   * @var boolean
+   * @var array
    */
-  protected $willRollback;
+  protected $transactionLayers = array();
 
   /**
    * Array of argument arrays for logging post-rollback.
@@ -885,29 +878,42 @@ abstract class DatabaseConnection extend
    *   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
@@ -927,9 +933,14 @@ abstract class DatabaseConnection extend
    * @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.
@@ -955,25 +966,49 @@ abstract class DatabaseConnection extend
       );
     }
 
-    $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;
+    }
+
+    $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']);
     }
 
-    return $this->willRollback;
+    // Reset the error logs.
+    $this->rollbackLogs = array();
   }
 
   /**
@@ -983,72 +1018,57 @@ abstract class DatabaseConnection extend
    *
    * @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();
-    }
-
-    --$this->transactionLayers;
-
-    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.
-            $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) {
-            $logging_callback($log_item['type'], $log_item['message'], $log_item['variables'], $log_item['severity'], $log_item['link']);
-          }
+  public function popTransaction($name) {
+    if (!$this->supportsTransactions()) {
+      return;
+    }
+    if (!$this->inTransaction()) {
+      throw new DatabaseTransactionNoActiveException();
+    }
+
+    // Commit everything since SAVEPOINT $name.
+    while($savepoint = array_pop($this->transactionLayers)) {
+      if ($savepoint != $name) continue;
+
+      // 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;
       }
     }
   }
@@ -1181,7 +1201,7 @@ abstract class DatabaseConnection extend
    * @see DatabaseTransaction
    */
   public function commit() {
-    throw new ExplicitTransactionsNotSupportedException();
+    throw new DatabaseTransactionExplicitCommitNotAllowedException();
   }
 
   /**
@@ -1626,7 +1646,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.
@@ -1634,7 +1664,7 @@ class NoActiveTransactionException exten
  * 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.
@@ -1685,13 +1715,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;
   }
 
   /**
@@ -1719,22 +1787,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();
-  }
-
 }
 
 /**
@@ -2326,22 +2387,20 @@ function db_select($table, $alias = NULL
 /**
  * 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);
 }
 
 /**
@@ -2429,7 +2488,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()) {
Index: modules/simpletest/tests/database_test.test
===================================================================
RCS file: /cvs/drupal/drupal/modules/simpletest/tests/database_test.test,v
retrieving revision 1.83
diff -u -p -r1.83 database_test.test
--- modules/simpletest/tests/database_test.test	7 Mar 2010 08:03:45 -0000	1.83
+++ modules/simpletest/tests/database_test.test	12 Mar 2010 18:14:11 -0000
@@ -2908,6 +2908,7 @@ class DatabaseTransactionTestCase extend
    */
   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 extend
     $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 extend
   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 extend
 
     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().'));
     }
   }
 
