From d902c2f214e88ad53cdc2985f533c85aa98db39b Mon Sep 17 00:00:00 2001 From: Dries Buytaert <dries@buytaert.net> Date: Mon, 1 Aug 2011 20:04:11 -0400 Subject: [PATCH] - Patch #1185780 by Damien Tournoud: make transactions more flexible and useful. --- includes/database/database.inc | 43 +++++- includes/database/mysql/database.inc | 22 +-- modules/simpletest/tests/database_test.test | 159 +++++++++++++++++++- 3 files changed, 202 insertions(+), 22 deletions(-) diff --git a/includes/database/database.inc b/includes/database/database.inc index e08f9074f396..610861429d1b 100644 --- a/includes/database/database.inc +++ b/includes/database/database.inc @@ -1022,7 +1022,9 @@ public function rollback($savepoint_name = 'drupal_transaction') { } // We need to find the point we're rolling back to, all other savepoints - // before are no longer needed. + // before are no longer needed. If we rolled back other active savepoints, + // we need to throw an exception. + $rolled_back_other_active_savepoints = FALSE; 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 @@ -1032,10 +1034,20 @@ public function rollback($savepoint_name = 'drupal_transaction') { break; } $this->query('ROLLBACK TO SAVEPOINT ' . $savepoint); + $this->popCommittableTransactions(); + if ($rolled_back_other_active_savepoints) { + throw new DatabaseTransactionOutOfOrderException(); + } return; } + else { + $rolled_back_other_active_savepoints = TRUE; + } } parent::rollBack(); + if ($rolled_back_other_active_savepoints) { + throw new DatabaseTransactionOutOfOrderException(); + } } /** @@ -1084,15 +1096,28 @@ public function popTransaction($name) { if (!$this->supportsTransactions()) { return; } - if (!$this->inTransaction()) { + if (!isset($this->transactionLayers[$name])) { throw new DatabaseTransactionNoActiveException(); } - // Commit everything since SAVEPOINT $name. - while($savepoint = array_pop($this->transactionLayers)) { - if ($savepoint != $name) continue; + // Mark this layer as committable. + $this->transactionLayers[$name] = FALSE; + $this->popCommittableTransactions(); + } + + /** + * Internal function: commit all the transaction layers that can commit. + */ + protected function popCommittableTransactions() { + // Commit all the committable layers. + foreach (array_reverse($this->transactionLayers) as $name => $active) { + // Stop once we found an active transaction. + if ($active) { + break; + } // If there are no more layers left then we should commit. + unset($this->transactionLayers[$name]); if (empty($this->transactionLayers)) { if (!parent::commit()) { throw new DatabaseTransactionCommitFailedException(); @@ -1100,7 +1125,6 @@ public function popTransaction($name) { } else { $this->query('RELEASE SAVEPOINT ' . $name); - break; } } } @@ -1744,6 +1768,11 @@ class DatabaseTransactionCommitFailedException extends Exception { } */ class DatabaseTransactionExplicitCommitNotAllowedException extends Exception { } +/** + * Exception thrown when a rollback() resulted in other active transactions being rolled-back. + */ +class DatabaseTransactionOutOfOrderException extends Exception { } + /** * Exception thrown for merge queries that do not make semantic sense. * @@ -1839,7 +1868,7 @@ public function __construct(DatabaseConnection &$connection, $name = NULL) { public function __destruct() { // If we rolled back then the transaction would have already been popped. - if ($this->connection->inTransaction() && !$this->rolledBack) { + if (!$this->rolledBack) { $this->connection->popTransaction($this->name); } } diff --git a/includes/database/mysql/database.inc b/includes/database/mysql/database.inc index 157cbfa567eb..0d9158789bba 100644 --- a/includes/database/mysql/database.inc +++ b/includes/database/mysql/database.inc @@ -137,21 +137,16 @@ public function nextIdDelete() { /** * Overridden to work around issues to MySQL not supporting transactional DDL. */ - 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; + protected function popCommittableTransactions() { + // Commit all the committable layers. + foreach (array_reverse($this->transactionLayers) as $name => $active) { + // Stop once we found an active transaction. + if ($active) { + break; } // If there are no more layers left then we should commit. + unset($this->transactionLayers[$name]); if (empty($this->transactionLayers)) { if (!PDO::commit()) { throw new DatabaseTransactionCommitFailedException(); @@ -173,13 +168,12 @@ public function popTransaction($name) { if ($e->errorInfo[1] == '1305') { // If one SAVEPOINT was released automatically, then all were. // Therefore, we keep just the topmost transaction. - $this->transactionLayers = array('drupal_transaction'); + $this->transactionLayers = array('drupal_transaction' => 'drupal_transaction'); } else { throw $e; } } - break; } } } diff --git a/modules/simpletest/tests/database_test.test b/modules/simpletest/tests/database_test.test index 143640d60b39..e27693c97539 100644 --- a/modules/simpletest/tests/database_test.test +++ b/modules/simpletest/tests/database_test.test @@ -3433,7 +3433,7 @@ class DatabaseTransactionTestCase extends DatabaseTestCase { $this->assertIdentical($count, '0', t('Table was successfully created inside a transaction.')); } catch (Exception $e) { - $this->fail($e->getMessage()); + $this->fail((string) $e); } // If we rollback the transaction, an exception might be thrown. @@ -3451,6 +3451,163 @@ class DatabaseTransactionTestCase extends DatabaseTestCase { $this->assertTrue(true, t('Exception thrown on rollback after a DDL statement was executed.')); } } + + /** + * Insert a single row into the testing table. + */ + protected function insertRow($name) { + db_insert('test') + ->fields(array( + 'name' => $name, + )) + ->execute(); + } + + /** + * Start over for a new test. + */ + protected function cleanUp() { + db_truncate('test') + ->execute(); + } + + /** + * Assert that a given row is present in the test table. + * + * @param $name + * The name of the row. + * @param $message + * The message to log for the assertion. + */ + function assertRowPresent($name, $message = NULL) { + if (!isset($message)) { + $message = t('Row %name is present.', array('%name' => $name)); + } + $present = (boolean) db_query('SELECT 1 FROM {test} WHERE name = :name', array(':name' => $name))->fetchField(); + return $this->assertTrue($present, $message); + } + + /** + * Assert that a given row is absent from the test table. + * + * @param $name + * The name of the row. + * @param $message + * The message to log for the assertion. + */ + function assertRowAbsent($name, $message = NULL) { + if (!isset($message)) { + $message = t('Row %name is absent.', array('%name' => $name)); + } + $present = (boolean) db_query('SELECT 1 FROM {test} WHERE name = :name', array(':name' => $name))->fetchField(); + return $this->assertFalse($present, $message); + } + + /** + * Test transaction stacking and commit / rollback. + */ + function testTransactionStacking() { + // This test won't work right if transactions are supported. + if (Database::getConnection()->supportsTransactions()) { + return; + } + + $database = Database::getConnection(); + + // Standard case: pop the inner transaction before the outer transaction. + $transaction = db_transaction(); + $this->insertRow('outer'); + $transaction2 = db_transaction(); + $this->insertRow('inner'); + // Pop the inner transaction. + unset($transaction2); + $this->assertTrue($database->inTransaction(), t('Still in a transaction after popping the inner transaction')); + // Pop the outer transaction. + unset($transaction); + $this->assertFalse($database->inTransaction(), t('Transaction closed after popping the outer transaction')); + $this->assertRowPresent('outer'); + $this->assertRowPresent('inner'); + + // Pop the transaction in a different order they have been pushed. + $this->cleanUp(); + $transaction = db_transaction(); + $this->insertRow('outer'); + $transaction2 = db_transaction(); + $this->insertRow('inner'); + // Pop the outer transaction, nothing should happen. + unset($transaction); + $this->insertRow('inner-after-outer-commit'); + $this->assertTrue($database->inTransaction(), t('Still in a transaction after popping the outer transaction')); + // Pop the inner transaction, the whole transaction should commit. + unset($transaction2); + $this->assertFalse($database->inTransaction(), t('Transaction closed after popping the inner transaction')); + $this->assertRowPresent('outer'); + $this->assertRowPresent('inner'); + $this->assertRowPresent('inner-after-outer-commit'); + + // Rollback the inner transaction. + $this->cleanUp(); + $transaction = db_transaction(); + $this->insertRow('outer'); + $transaction2 = db_transaction(); + $this->insertRow('inner'); + // Now rollback the inner transaction. + $transaction2->rollback(); + unset($transaction2); + $this->assertTrue($database->inTransaction(), t('Still in a transaction after popping the outer transaction')); + // Pop the outer transaction, it should commit. + $this->insertRow('outer-after-inner-rollback'); + unset($transaction); + $this->assertFalse($database->inTransaction(), t('Transaction closed after popping the inner transaction')); + $this->assertRowPresent('outer'); + $this->assertRowAbsent('inner'); + $this->assertRowPresent('outer-after-inner-rollback'); + + // Rollback the inner transaction after committing the outer one. + $this->cleanUp(); + $transaction = db_transaction(); + $this->insertRow('outer'); + $transaction2 = db_transaction(); + $this->insertRow('inner'); + // Pop the outer transaction, nothing should happen. + unset($transaction); + $this->assertTrue($database->inTransaction(), t('Still in a transaction after popping the outer transaction')); + // Now rollback the inner transaction, it should rollback. + $transaction2->rollback(); + unset($transaction2); + $this->assertFalse($database->inTransaction(), t('Transaction closed after popping the inner transaction')); + $this->assertRowPresent('outer'); + $this->assertRowAbsent('inner'); + + // Rollback the outer transaction while the inner transaction is active. + // In that case, an exception will be triggered because we cannot + // ensure that the final result will have any meaning. + $this->cleanUp(); + $transaction = db_transaction(); + $this->insertRow('outer'); + $transaction2 = db_transaction(); + $this->insertRow('inner'); + // Rollback the outer transaction. + try { + $transaction->rollback(); + unset($transaction); + $this->fail(t('Rolling back the outer transaction while the inner transaction is active resulted in an exception.')); + } + catch (Exception $e) { + $this->pass(t('Rolling back the outer transaction while the inner transaction is active resulted in an exception.')); + } + $this->assertFalse($database->inTransaction(), t('No more in a transaction after rolling back the outer transaction')); + // Try to commit the inner transaction. + try { + unset($transaction2); + $this->fail(t('Trying to commit the inner transaction resulted in an exception.')); + } + catch (Exception $e) { + $this->pass(t('Trying to commit the inner transaction resulted in an exception.')); + } + $this->assertRowAbsent('outer'); + $this->assertRowAbsent('inner'); + } } -- GitLab