TransactionTest.php
Same filename in other branches
- 8.9.x core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php
- 10 core/modules/sqlite/tests/src/Kernel/sqlite/TransactionTest.php
- 10 core/modules/mysql/tests/src/Kernel/mysql/TransactionTest.php
- 10 core/modules/pgsql/tests/src/Kernel/pgsql/TransactionTest.php
- 11.x core/modules/sqlite/tests/src/Kernel/sqlite/TransactionTest.php
- 11.x core/modules/mysql/tests/src/Kernel/mysql/TransactionTest.php
- 11.x core/modules/pgsql/tests/src/Kernel/pgsql/TransactionTest.php
Namespace
Drupal\KernelTests\Core\DatabaseFile
-
core/
tests/ Drupal/ KernelTests/ Core/ Database/ TransactionTest.php
View source
<?php
namespace Drupal\KernelTests\Core\Database;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Database\TransactionOutOfOrderException;
use Drupal\Core\Database\TransactionNoActiveException;
use PHPUnit\Framework\Error\Warning;
/**
* Tests the transaction abstraction system.
*
* We test nesting by having two transaction layers, an outer and inner. The
* outer layer encapsulates the inner layer. Our transaction nesting abstraction
* should allow the outer layer function to call any function it wants,
* especially the inner layer that starts its own transaction, and be
* confident that, when the function it calls returns, its own transaction
* is still "alive."
*
* Call structure:
* transactionOuterLayer()
* Start transaction
* transactionInnerLayer()
* Start transaction (does nothing in database)
* [Maybe decide to roll back]
* Do more stuff
* Should still be in transaction A
*
* @group Database
*/
class TransactionTest extends DatabaseTestBase {
/**
* Encapsulates a transaction's "inner layer" with an "outer layer".
*
* This "outer layer" transaction starts and then encapsulates the "inner
* layer" transaction. This nesting is used to evaluate whether the database
* transaction API properly supports nesting. By "properly supports," we mean
* the outer transaction continues to exist regardless of what functions are
* called and whether those functions start their own transactions.
*
* In contrast, a typical database would commit the outer transaction, start
* a new transaction for the inner layer, commit the inner layer transaction,
* and then be confused when the outer layer transaction tries to commit its
* transaction (which was already committed when the inner transaction
* started).
*
* @param $suffix
* Suffix to add to field values to differentiate tests.
* @param $rollback
* Whether or not to try rolling back the transaction when we're done.
* @param $ddl_statement
* Whether to execute a DDL statement during the inner transaction.
*/
protected function transactionOuterLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
$depth = $this->connection
->transactionDepth();
$txn = $this->connection
->startTransaction();
// Insert a single row into the testing table.
$this->connection
->insert('test')
->fields([
'name' => 'David' . $suffix,
'age' => '24',
])
->execute();
$this->assertTrue($this->connection
->inTransaction(), 'In transaction before calling nested transaction.');
// We're already in a transaction, but we call ->transactionInnerLayer
// to nest another transaction inside the current one.
$this->transactionInnerLayer($suffix, $rollback, $ddl_statement);
$this->assertTrue($this->connection
->inTransaction(), '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->assertSame($depth, $this->connection
->transactionDepth(), 'Transaction has rolled back to the last savepoint after calling rollBack().');
}
}
/**
* Creates an "inner layer" transaction.
*
* This "inner layer" transaction is either used alone or nested inside of the
* "outer layer" transaction.
*
* @param $suffix
* Suffix to add to field values to differentiate tests.
* @param $rollback
* Whether or not to try rolling back the transaction when we're done.
* @param $ddl_statement
* Whether to execute a DDL statement during the transaction.
*/
protected function transactionInnerLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
$depth = $this->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 = $this->connection
->startTransaction();
$depth2 = $this->connection
->transactionDepth();
$this->assertGreaterThan($depth, $depth2, 'Transaction depth has increased with new transaction.');
// Insert a single row into the testing table.
$this->connection
->insert('test')
->fields([
'name' => 'Daniel' . $suffix,
'age' => '19',
])
->execute();
$this->assertTrue($this->connection
->inTransaction(), 'In transaction inside nested transaction.');
if ($ddl_statement) {
$table = [
'fields' => [
'id' => [
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
],
],
'primary key' => [
'id',
],
];
$this->connection
->schema()
->createTable('database_test_1', $table);
$this->assertTrue($this->connection
->inTransaction(), 'In transaction inside nested transaction.');
}
if ($rollback) {
// Roll back the transaction, if requested.
// This rollback should propagate to the last savepoint.
$txn->rollBack();
$this->assertSame($depth, $this->connection
->transactionDepth(), 'Transaction has rolled back to the last savepoint after calling rollBack().');
}
}
/**
* Tests transaction rollback on a database that supports transactions.
*
* If the active connection does not support transactions, this test does
* nothing.
*/
public function testTransactionRollBackSupported() {
try {
// Create two nested transactions. Roll back from the inner one.
$this->transactionOuterLayer('B', TRUE);
// Neither of the rows we inserted in the two transaction layers
// should be present in the tables post-rollback.
$saved_age = $this->connection
->query('SELECT [age] FROM {test} WHERE [name] = :name', [
':name' => 'DavidB',
])
->fetchField();
$this->assertNotSame('24', $saved_age, 'Cannot retrieve DavidB row after commit.');
$saved_age = $this->connection
->query('SELECT [age] FROM {test} WHERE [name] = :name', [
':name' => 'DanielB',
])
->fetchField();
$this->assertNotSame('19', $saved_age, 'Cannot retrieve DanielB row after commit.');
} catch (\Exception $e) {
$this->fail($e->getMessage());
}
}
/**
* Tests a committed transaction.
*
* The behavior of this test should be identical for connections that support
* transactions and those that do not.
*/
public function testCommittedTransaction() {
try {
// Create two nested transactions. The changes should be committed.
$this->transactionOuterLayer('A');
// Because we committed, both of the inserted rows should be present.
$saved_age = $this->connection
->query('SELECT [age] FROM {test} WHERE [name] = :name', [
':name' => 'DavidA',
])
->fetchField();
$this->assertSame('24', $saved_age, 'Can retrieve DavidA row after commit.');
$saved_age = $this->connection
->query('SELECT [age] FROM {test} WHERE [name] = :name', [
':name' => 'DanielA',
])
->fetchField();
$this->assertSame('19', $saved_age, 'Can retrieve DanielA row after commit.');
} catch (\Exception $e) {
$this->fail($e->getMessage());
}
}
/**
* Tests the compatibility of transactions with DDL statements.
*/
public function testTransactionWithDdlStatement() {
// First, test that a commit works normally, even with DDL statements.
$transaction = $this->connection
->startTransaction();
$this->insertRow('row');
$this->executeDDLStatement();
unset($transaction);
$this->assertRowPresent('row');
// Even in different order.
$this->cleanUp();
$transaction = $this->connection
->startTransaction();
$this->executeDDLStatement();
$this->insertRow('row');
unset($transaction);
$this->assertRowPresent('row');
// Even with stacking.
$this->cleanUp();
$transaction = $this->connection
->startTransaction();
$transaction2 = $this->connection
->startTransaction();
$this->executeDDLStatement();
unset($transaction2);
$transaction3 = $this->connection
->startTransaction();
$this->insertRow('row');
unset($transaction3);
unset($transaction);
$this->assertRowPresent('row');
// A transaction after a DDL statement should still work the same.
$this->cleanUp();
$transaction = $this->connection
->startTransaction();
$transaction2 = $this->connection
->startTransaction();
$this->executeDDLStatement();
unset($transaction2);
$transaction3 = $this->connection
->startTransaction();
$this->insertRow('row');
$transaction3->rollBack();
unset($transaction3);
unset($transaction);
$this->assertRowAbsent('row');
// The behavior of a rollback depends on the type of database server.
if ($this->connection
->supportsTransactionalDDL()) {
// For database servers that support transactional DDL, a rollback
// of a transaction including DDL statements should be possible.
$this->cleanUp();
$transaction = $this->connection
->startTransaction();
$this->insertRow('row');
$this->executeDDLStatement();
$transaction->rollBack();
unset($transaction);
$this->assertRowAbsent('row');
// Including with stacking.
$this->cleanUp();
$transaction = $this->connection
->startTransaction();
$transaction2 = $this->connection
->startTransaction();
$this->executeDDLStatement();
unset($transaction2);
$transaction3 = $this->connection
->startTransaction();
$this->insertRow('row');
unset($transaction3);
$transaction->rollBack();
unset($transaction);
$this->assertRowAbsent('row');
}
else {
// For database servers that do not support transactional DDL,
// the DDL statement should commit the transaction stack.
$this->cleanUp();
$transaction = $this->connection
->startTransaction();
$this->insertRow('row');
$this->executeDDLStatement();
try {
// Rollback the outer transaction.
$transaction->rollBack();
// @see \Drupal\mysql\Driver\Database\mysql\Connection::rollBack()
if (PHP_VERSION_ID >= 80000) {
$this->fail('Rolling back a transaction containing DDL should produce a warning.');
}
} catch (Warning $warning) {
$this->assertSame('Rollback attempted when there is no active transaction. This can cause data integrity issues.', $warning->getMessage());
}
unset($transaction);
$this->assertRowPresent('row');
}
}
/**
* Inserts a single row into the testing table.
*/
protected function insertRow($name) {
$this->connection
->insert('test')
->fields([
'name' => $name,
])
->execute();
}
/**
* Executes a DDL statement.
*/
protected function executeDDLStatement() {
static $count = 0;
$table = [
'fields' => [
'id' => [
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
],
],
'primary key' => [
'id',
],
];
$this->connection
->schema()
->createTable('database_test_' . ++$count, $table);
}
/**
* Starts over for a new test.
*/
protected function cleanUp() {
$this->connection
->truncate('test')
->execute();
}
/**
* Asserts that a given row is present in the test table.
*
* @param string $name
* The name of the row.
* @param string $message
* The message to log for the assertion.
*
* @internal
*/
public function assertRowPresent(string $name, string $message = NULL) : void {
if (!isset($message)) {
$message = new FormattableMarkup('Row %name is present.', [
'%name' => $name,
]);
}
$present = (bool) $this->connection
->query('SELECT 1 FROM {test} WHERE [name] = :name', [
':name' => $name,
])
->fetchField();
$this->assertTrue($present, $message);
}
/**
* Asserts that a given row is absent from the test table.
*
* @param string $name
* The name of the row.
* @param string $message
* The message to log for the assertion.
*
* @internal
*/
public function assertRowAbsent(string $name, string $message = NULL) : void {
if (!isset($message)) {
$message = new FormattableMarkup('Row %name is absent.', [
'%name' => $name,
]);
}
$present = (bool) $this->connection
->query('SELECT 1 FROM {test} WHERE [name] = :name', [
':name' => $name,
])
->fetchField();
$this->assertFalse($present, $message);
}
/**
* Tests transaction stacking, commit, and rollback.
*/
public function testTransactionStacking() {
// Standard case: pop the inner transaction before the outer transaction.
$transaction = $this->connection
->startTransaction();
$this->insertRow('outer');
$transaction2 = $this->connection
->startTransaction();
$this->insertRow('inner');
// Pop the inner transaction.
unset($transaction2);
$this->assertTrue($this->connection
->inTransaction(), 'Still in a transaction after popping the inner transaction');
// Pop the outer transaction.
unset($transaction);
$this->assertFalse($this->connection
->inTransaction(), '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 = $this->connection
->startTransaction();
$this->insertRow('outer');
$transaction2 = $this->connection
->startTransaction();
$this->insertRow('inner');
// Pop the outer transaction, nothing should happen.
unset($transaction);
$this->insertRow('inner-after-outer-commit');
$this->assertTrue($this->connection
->inTransaction(), 'Still in a transaction after popping the outer transaction');
// Pop the inner transaction, the whole transaction should commit.
unset($transaction2);
$this->assertFalse($this->connection
->inTransaction(), '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 = $this->connection
->startTransaction();
$this->insertRow('outer');
$transaction2 = $this->connection
->startTransaction();
$this->insertRow('inner');
// Now rollback the inner transaction.
$transaction2->rollBack();
unset($transaction2);
$this->assertTrue($this->connection
->inTransaction(), '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($this->connection
->inTransaction(), '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 = $this->connection
->startTransaction();
$this->insertRow('outer');
$transaction2 = $this->connection
->startTransaction();
$this->insertRow('inner');
// Pop the outer transaction, nothing should happen.
unset($transaction);
$this->assertTrue($this->connection
->inTransaction(), 'Still in a transaction after popping the outer transaction');
// Now rollback the inner transaction, it should rollback.
$transaction2->rollBack();
unset($transaction2);
$this->assertFalse($this->connection
->inTransaction(), '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 = $this->connection
->startTransaction();
$this->insertRow('outer');
$transaction2 = $this->connection
->startTransaction();
$this->insertRow('inner');
$transaction3 = $this->connection
->startTransaction();
$this->insertRow('inner2');
// Rollback the outer transaction.
try {
$transaction->rollBack();
unset($transaction);
$this->fail('Rolling back the outer transaction while the inner transaction is active resulted in an exception.');
} catch (TransactionOutOfOrderException $e) {
// Expected exception; just continue testing.
}
$this->assertFalse($this->connection
->inTransaction(), 'No more in a transaction after rolling back the outer transaction');
// Try to commit one inner transaction.
unset($transaction3);
// Try to rollback one inner transaction.
try {
$transaction->rollBack();
unset($transaction2);
$this->fail('Trying to commit an inner transaction resulted in an exception.');
} catch (TransactionNoActiveException $e) {
// Expected exception; just continue testing.
}
$this->assertRowAbsent('outer');
$this->assertRowAbsent('inner');
$this->assertRowAbsent('inner2');
}
/**
* Tests that transactions can continue to be used if a query fails.
*/
public function testQueryFailureInTransaction() {
$transaction = $this->connection
->startTransaction('test_transaction');
$this->connection
->schema()
->dropTable('test');
// Test a failed query using the query() method.
try {
$this->connection
->query('SELECT [age] FROM {test} WHERE [name] = :name', [
':name' => 'David',
])
->fetchField();
$this->fail('Using the query method should have failed.');
} catch (\Exception $e) {
// Just continue testing.
}
// Test a failed select query.
try {
$this->connection
->select('test')
->fields('test', [
'name',
])
->execute();
$this->fail('Select query should have failed.');
} catch (\Exception $e) {
// Just continue testing.
}
// Test a failed insert query.
try {
$this->connection
->insert('test')
->fields([
'name' => 'David',
'age' => '24',
])
->execute();
$this->fail('Insert query should have failed.');
} catch (\Exception $e) {
// Just continue testing.
}
// Test a failed update query.
try {
$this->connection
->update('test')
->fields([
'name' => 'Tiffany',
])
->condition('id', 1)
->execute();
$this->fail('Update query should have failed.');
} catch (\Exception $e) {
// Just continue testing.
}
// Test a failed delete query.
try {
$this->connection
->delete('test')
->condition('id', 1)
->execute();
$this->fail('Delete query should have failed.');
} catch (\Exception $e) {
// Just continue testing.
}
// Test a failed merge query.
try {
$this->connection
->merge('test')
->key('job', 'Presenter')
->fields([
'age' => '31',
'name' => 'Tiffany',
])
->execute();
$this->fail('Merge query should have failed.');
} catch (\Exception $e) {
// Just continue testing.
}
// Test a failed upsert query.
try {
$this->connection
->upsert('test')
->key('job')
->fields([
'job',
'age',
'name',
])
->values([
'job' => 'Presenter',
'age' => 31,
'name' => 'Tiffany',
])
->execute();
$this->fail('Upsert query should have failed.');
} catch (\Exception $e) {
// Just continue testing.
}
// Create the missing schema and insert a row.
$this->installSchema('database_test', [
'test',
]);
$this->connection
->insert('test')
->fields([
'name' => 'David',
'age' => '24',
])
->execute();
// Commit the transaction.
unset($transaction);
$saved_age = $this->connection
->query('SELECT [age] FROM {test} WHERE [name] = :name', [
':name' => 'David',
])
->fetchField();
$this->assertEquals('24', $saved_age);
}
}
Classes
Title | Deprecated | Summary |
---|---|---|
TransactionTest | Tests the transaction abstraction system. |
Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.