DriverSpecificTransactionTestBase.php

Same filename in other branches
  1. 11.x core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php

Namespace

Drupal\KernelTests\Core\Database

File

core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php

View source
<?php

declare (strict_types=1);
namespace Drupal\KernelTests\Core\Database;

use Drupal\Core\Database\Database;
use Drupal\Core\Database\Transaction;
use Drupal\Core\Database\Transaction\ClientConnectionTransactionState;
use Drupal\Core\Database\Transaction\StackItem;
use Drupal\Core\Database\Transaction\StackItemType;
use Drupal\Core\Database\Transaction\TransactionManagerBase;
use Drupal\Core\Database\TransactionExplicitCommitNotAllowedException;
use Drupal\Core\Database\TransactionNameNonUniqueException;
use Drupal\Core\Database\TransactionOutOfOrderException;

/**
 * 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
 *
 * These method can be overridden by non-core database driver if their
 * transaction behavior is different from core. For example, both oci8 (Oracle)
 * and mysqli (MySql) clients do not have a solution to check if a transaction
 * is active, and mysqli does not fail when rolling back and no transaction
 * active.
 */
class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase {
    
    /**
     * Keeps track of the post-transaction callback action executed.
     */
    protected ?string $postTransactionCallbackAction = NULL;
    
    /**
     * Create a root Drupal transaction.
     */
    protected function createRootTransaction(string $name = '', bool $insertRow = TRUE) : Transaction {
        $this->assertFalse($this->connection
            ->inTransaction());
        $this->assertSame(0, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Start root transaction. Corresponds to 'BEGIN TRANSACTION' on the
        // database.
        $transaction = $this->connection
            ->startTransaction($name);
        $this->assertTrue($this->connection
            ->inTransaction());
        $this->assertSame(1, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Insert a single row into the testing table.
        if ($insertRow) {
            $this->insertRow('David');
            $this->assertRowPresent('David');
        }
        return $transaction;
    }
    
    /**
     * Create a Drupal savepoint transaction after root.
     */
    protected function createFirstSavepointTransaction(string $name = '', bool $insertRow = TRUE) : Transaction {
        $this->assertTrue($this->connection
            ->inTransaction());
        $this->assertSame(1, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_1'
        // on the database. The name can be changed by the $name argument.
        $savepoint = $this->connection
            ->startTransaction($name);
        $this->assertTrue($this->connection
            ->inTransaction());
        $this->assertSame(2, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Insert a single row into the testing table.
        if ($insertRow) {
            $this->insertRow('Roger');
            $this->assertRowPresent('Roger');
        }
        return $savepoint;
    }
    
    /**
     * 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
            ->transactionManager()
            ->stackDepth();
        $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
                ->transactionManager()
                ->stackDepth(), '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
            ->transactionManager()
            ->stackDepth();
        // 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
            ->transactionManager()
            ->stackDepth();
        $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
                ->transactionManager()
                ->stackDepth(), 'Transaction has rolled back to the last savepoint after calling rollBack().');
        }
    }
    
    /**
     * Tests root transaction rollback.
     */
    public function testRollbackRoot() : void {
        $transaction = $this->createRootTransaction();
        // Rollback. Since we are at the root, the transaction is closed.
        // Corresponds to 'ROLLBACK' on the database.
        $transaction->rollBack();
        $this->assertRowAbsent('David');
        $this->assertFalse($this->connection
            ->inTransaction());
        $this->assertSame(0, $this->connection
            ->transactionManager()
            ->stackDepth());
    }
    
    /**
     * Tests root transaction rollback after savepoint rollback.
     */
    public function testRollbackRootAfterSavepointRollback() : void {
        $transaction = $this->createRootTransaction();
        $savepoint = $this->createFirstSavepointTransaction();
        // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK
        // TO savepoint_1' plus 'RELEASE savepoint_1' on the database.
        $savepoint->rollBack();
        $this->assertRowPresent('David');
        $this->assertRowAbsent('Roger');
        $this->assertTrue($this->connection
            ->inTransaction());
        $this->assertSame(1, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Try to rollback root. No savepoint is active, this should succeed.
        $transaction->rollBack();
        $this->assertRowAbsent('David');
        $this->assertRowAbsent('Roger');
        $this->assertFalse($this->connection
            ->inTransaction());
        $this->assertSame(0, $this->connection
            ->transactionManager()
            ->stackDepth());
    }
    
    /**
     * Tests root transaction rollback failure when savepoint is open.
     */
    public function testRollbackRootWithActiveSavepoint() : void {
        $transaction = $this->createRootTransaction();
        $savepoint = $this->createFirstSavepointTransaction();
        // Try to rollback root. Since a savepoint is active, this should fail.
        $this->expectException(TransactionOutOfOrderException::class);
        $this->expectExceptionMessageMatches("/^Error attempting rollback of .*\\\\drupal_transaction\\. Active stack: .*\\\\drupal_transaction > .*\\\\savepoint_1/");
        $transaction->rollBack();
    }
    
    /**
     * Tests savepoint transaction rollback.
     */
    public function testRollbackSavepoint() : void {
        $transaction = $this->createRootTransaction();
        $savepoint = $this->createFirstSavepointTransaction();
        // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK
        // TO savepoint_1' plus 'RELEASE savepoint_1' on the database.
        $savepoint->rollBack();
        $this->assertRowPresent('David');
        $this->assertRowAbsent('Roger');
        $this->assertTrue($this->connection
            ->inTransaction());
        $this->assertSame(1, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Insert a row.
        $this->insertRow('Syd');
        // Commit root. Corresponds to 'COMMIT' on the database.
        unset($transaction);
        $this->assertRowPresent('David');
        $this->assertRowAbsent('Roger');
        $this->assertRowPresent('Syd');
        $this->assertFalse($this->connection
            ->inTransaction());
        $this->assertSame(0, $this->connection
            ->transactionManager()
            ->stackDepth());
    }
    
    /**
     * Tests savepoint transaction duplicated rollback.
     */
    public function testRollbackTwiceSameSavepoint() : void {
        $transaction = $this->createRootTransaction();
        $savepoint = $this->createFirstSavepointTransaction();
        // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK
        // TO savepoint_1' plus 'RELEASE savepoint_1' on the database.
        $savepoint->rollBack();
        $this->assertRowPresent('David');
        $this->assertRowAbsent('Roger');
        $this->assertTrue($this->connection
            ->inTransaction());
        $this->assertSame(1, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Insert a row.
        $this->insertRow('Syd');
        // Rollback savepoint again. Should fail since it was released already.
        try {
            $savepoint->rollBack();
            $this->fail('Expected TransactionOutOfOrderException was not thrown');
        } catch (\Exception $e) {
            $this->assertInstanceOf(TransactionOutOfOrderException::class, $e);
            $this->assertMatchesRegularExpression("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage());
        }
        $this->assertRowPresent('David');
        $this->assertRowAbsent('Roger');
        $this->assertRowPresent('Syd');
        $this->assertTrue($this->connection
            ->inTransaction());
        $this->assertSame(1, $this->connection
            ->transactionManager()
            ->stackDepth());
    }
    
    /**
     * Tests savepoint transaction rollback failure when later savepoints exist.
     */
    public function testRollbackSavepointWithLaterSavepoint() : void {
        $transaction = $this->createRootTransaction();
        $savepoint1 = $this->createFirstSavepointTransaction();
        // Starts another savepoint transaction. Corresponds to 'SAVEPOINT
        // savepoint_2' on the database.
        $savepoint2 = $this->connection
            ->startTransaction();
        $this->assertTrue($this->connection
            ->inTransaction());
        $this->assertSame(3, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Insert a row.
        $this->insertRow('Syd');
        $this->assertRowPresent('David');
        $this->assertRowPresent('Roger');
        $this->assertRowPresent('Syd');
        // Try to rollback to savepoint 1. Out of order.
        $this->expectException(TransactionOutOfOrderException::class);
        $this->expectExceptionMessageMatches("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction > .*\\\\savepoint_1 > .*\\\\savepoint_2/");
        $savepoint1->rollBack();
    }
    
    /**
     * 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() : void {
        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() : void {
        // First, test that a commit works normally, even with DDL statements.
        $transaction = $this->createRootTransaction('', FALSE);
        $this->insertRow('row');
        $this->executeDDLStatement();
        unset($transaction);
        $this->assertRowPresent('row');
        // Even in different order.
        $this->cleanUp();
        $transaction = $this->createRootTransaction('', FALSE);
        $this->executeDDLStatement();
        $this->insertRow('row');
        unset($transaction);
        $this->assertRowPresent('row');
        // Even with stacking.
        $this->cleanUp();
        $transaction = $this->createRootTransaction('', FALSE);
        $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
        $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->createRootTransaction('', FALSE);
        $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
        $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->createRootTransaction('', FALSE);
            $this->insertRow('row');
            $this->executeDDLStatement();
            $transaction->rollBack();
            unset($transaction);
            $this->assertRowAbsent('row');
            // Including with stacking.
            $this->cleanUp();
            $transaction = $this->createRootTransaction('', FALSE);
            $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
            $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->createRootTransaction('', FALSE);
            $this->insertRow('row');
            $this->executeDDLStatement();
            // Try to rollback the outer transaction. It should fail and void
            // the transaction stack.
            $transaction->rollBack();
            $manager = $this->connection
                ->transactionManager();
            $reflectedTransactionState = new \ReflectionMethod($manager, 'getConnectionTransactionState');
            $this->assertSame(ClientConnectionTransactionState::Voided, $reflectedTransactionState->invoke($manager));
            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();
        $this->postTransactionCallbackAction = NULL;
    }
    
    /**
     * 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 {
        $present = (bool) $this->connection
            ->query('SELECT 1 FROM {test} WHERE [name] = :name', [
            ':name' => $name,
        ])
            ->fetchField();
        $this->assertTrue($present, $message ?? "Row '{$name}' should be present, but it actually does not exist.");
    }
    
    /**
     * 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 {
        $present = (bool) $this->connection
            ->query('SELECT 1 FROM {test} WHERE [name] = :name', [
            ':name' => $name,
        ])
            ->fetchField();
        $this->assertFalse($present, $message ?? "Row '{$name}' should be absent, but it actually exists.");
    }
    
    /**
     * Tests transaction stacking, commit, and rollback.
     */
    public function testTransactionStacking() : void {
        // Standard case: pop the inner transaction before the outer transaction.
        $transaction = $this->createRootTransaction('', FALSE);
        $this->insertRow('outer');
        $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
        $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');
        // Rollback the inner transaction.
        $this->cleanUp();
        $transaction = $this->createRootTransaction('', FALSE);
        $this->insertRow('outer');
        $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
        $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');
    }
    
    /**
     * Tests that transactions can continue to be used if a query fails.
     */
    public function testQueryFailureInTransaction() : void {
        $transaction = $this->createRootTransaction('test_transaction', FALSE);
        $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);
    }
    
    /**
     * Tests releasing a savepoint before last is safe.
     */
    public function testReleaseIntermediateSavepoint() : void {
        $transaction = $this->createRootTransaction();
        $savepoint1 = $this->createFirstSavepointTransaction('', FALSE);
        // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_2'
        // on the database.
        $savepoint2 = $this->connection
            ->startTransaction();
        $this->assertSame(3, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_3'
        // on the database.
        $savepoint3 = $this->connection
            ->startTransaction();
        $this->assertSame(4, $this->connection
            ->transactionManager()
            ->stackDepth());
        // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_4'
        // on the database.
        $savepoint4 = $this->connection
            ->startTransaction();
        $this->assertSame(5, $this->connection
            ->transactionManager()
            ->stackDepth());
        $this->insertRow('row');
        // Commit a savepoint transaction. Corresponds to 'RELEASE SAVEPOINT
        // savepoint_2' on the database.
        unset($savepoint2);
        // Since we have committed an intermediate savepoint Transaction object,
        // the savepoints created later have been dropped by the database already.
        $this->assertSame(2, $this->connection
            ->transactionManager()
            ->stackDepth());
        $this->assertRowPresent('row');
        // Commit the remaining Transaction objects. The client transaction is
        // eventually committed.
        unset($savepoint1);
        unset($transaction);
        $this->assertFalse($this->connection
            ->inTransaction());
        $this->assertRowPresent('row');
    }
    
    /**
     * Tests committing a transaction while savepoints are active.
     */
    public function testCommitWithActiveSavepoint() : void {
        $transaction = $this->createRootTransaction();
        $savepoint1 = $this->createFirstSavepointTransaction('', FALSE);
        // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_2'
        // on the database.
        $savepoint2 = $this->connection
            ->startTransaction();
        $this->assertSame(3, $this->connection
            ->transactionManager()
            ->stackDepth());
        $this->insertRow('row');
        // Commit the root transaction. Corresponds to 'COMMIT' on the database.
        unset($transaction);
        // Since we have committed the outer (root) Transaction object, the inner
        // (savepoint) ones have been dropped by the database already, and we are
        // no longer in an active transaction state.
        $this->assertSame(0, $this->connection
            ->transactionManager()
            ->stackDepth());
        $this->assertFalse($this->connection
            ->inTransaction());
        $this->assertRowPresent('row');
        // Unpile the inner (savepoint) Transaction object, it should be a no-op
        // anyway given it was dropped by the database already, and removed from
        // our transaction stack.
        unset($savepoint2);
        $this->assertSame(0, $this->connection
            ->transactionManager()
            ->stackDepth());
        $this->assertFalse($this->connection
            ->inTransaction());
        $this->assertRowPresent('row');
    }
    
    /**
     * Tests for transaction names.
     */
    public function testTransactionName() : void {
        $transaction = $this->createRootTransaction('', FALSE);
        $this->assertSame('drupal_transaction', $transaction->name());
        $savepoint1 = $this->createFirstSavepointTransaction('', FALSE);
        $this->assertSame('savepoint_1', $savepoint1->name());
        $this->expectException(TransactionNameNonUniqueException::class);
        $this->expectExceptionMessage("savepoint_1 is already in use.");
        $savepointFailure = $this->connection
            ->startTransaction('savepoint_1');
    }
    
    /**
     * Tests that adding a post-transaction callback fails with no transaction.
     */
    public function testRootTransactionEndCallbackAddedWithoutTransaction() : void {
        $this->expectException(\LogicException::class);
        $this->connection
            ->transactionManager()
            ->addPostTransactionCallback([
            $this,
            'rootTransactionCallback',
        ]);
    }
    
    /**
     * Tests post-transaction callback executes after transaction commit.
     */
    public function testRootTransactionEndCallbackCalledOnCommit() : void {
        $transaction = $this->createRootTransaction('', FALSE);
        $this->connection
            ->transactionManager()
            ->addPostTransactionCallback([
            $this,
            'rootTransactionCallback',
        ]);
        $this->insertRow('row');
        $this->assertNull($this->postTransactionCallbackAction);
        unset($transaction);
        $this->assertSame('rtcCommit', $this->postTransactionCallbackAction);
        $this->assertRowPresent('row');
        $this->assertRowPresent('rtcCommit');
    }
    
    /**
     * Tests post-transaction callback executes after transaction rollback.
     */
    public function testRootTransactionEndCallbackCalledAfterRollbackAndDestruction() : void {
        $transaction = $this->createRootTransaction('', FALSE);
        $this->connection
            ->transactionManager()
            ->addPostTransactionCallback([
            $this,
            'rootTransactionCallback',
        ]);
        $this->insertRow('row');
        $this->assertNull($this->postTransactionCallbackAction);
        // Callbacks are processed only when destructing the transaction.
        // Executing a rollback is not sufficient by itself.
        $transaction->rollBack();
        $this->assertNull($this->postTransactionCallbackAction);
        $this->assertRowAbsent('rtcCommit');
        $this->assertRowAbsent('rtcRollback');
        $this->assertRowAbsent('row');
        // Destruct the transaction.
        unset($transaction);
        // The post-transaction callback should now have inserted a 'rtcRollback'
        // row.
        $this->assertSame('rtcRollback', $this->postTransactionCallbackAction);
        $this->assertRowAbsent('rtcCommit');
        $this->assertRowPresent('rtcRollback');
        $this->assertRowAbsent('row');
    }
    
    /**
     * Tests post-transaction callback executes after a DDL statement.
     */
    public function testRootTransactionEndCallbackCalledAfterDdlAndDestruction() : void {
        $transaction = $this->createRootTransaction('', FALSE);
        $this->connection
            ->transactionManager()
            ->addPostTransactionCallback([
            $this,
            'rootTransactionCallback',
        ]);
        $this->insertRow('row');
        $this->assertNull($this->postTransactionCallbackAction);
        // Callbacks are processed only when destructing the transaction.
        // Executing a DDL statement is not sufficient itself.
        // We cannot use truncate here, since it has protective code to fall back
        // to a transactional delete when in transaction. We drop an unrelated
        // table instead.
        $this->connection
            ->schema()
            ->dropTable('test_people');
        $this->assertNull($this->postTransactionCallbackAction);
        $this->assertRowAbsent('rtcCommit');
        $this->assertRowAbsent('rtcRollback');
        $this->assertRowPresent('row');
        // Destruct the transaction.
        unset($transaction);
        // The post-transaction callback should now have inserted a 'rtcCommit'
        // row.
        $this->assertSame('rtcCommit', $this->postTransactionCallbackAction);
        $this->assertRowPresent('rtcCommit');
        $this->assertRowAbsent('rtcRollback');
        $this->assertRowPresent('row');
    }
    
    /**
     * A post-transaction callback for testing purposes.
     */
    public function rootTransactionCallback(bool $success) : void {
        $this->postTransactionCallbackAction = $success ? 'rtcCommit' : 'rtcRollback';
        $this->insertRow($this->postTransactionCallbackAction);
    }
    
    /**
     * Tests TransactionManager failure.
     */
    public function testTransactionManagerFailureOnPendingStackItems() : void {
        $connectionInfo = Database::getConnectionInfo();
        Database::addConnectionInfo('default', 'test_fail', $connectionInfo['default']);
        $testConnection = Database::getConnection('test_fail');
        // Add a fake item to the stack.
        $manager = $testConnection->transactionManager();
        $reflectionMethod = new \ReflectionMethod($manager, 'addStackItem');
        $reflectionMethod->invoke($manager, 'bar', new StackItem('qux', StackItemType::Root));
        // Ensure transaction state can be determined during object destruction.
        // This is necessary for the test to pass when xdebug.mode has the 'develop'
        // option enabled.
        $reflectionProperty = new \ReflectionProperty(TransactionManagerBase::class, 'connectionTransactionState');
        $reflectionProperty->setValue($manager, ClientConnectionTransactionState::Active);
        // Ensure that __destruct() results in an assertion error. Note that this
        // will normally be called by PHP during the object's destruction but Drupal
        // will commit all transactions when a database is closed thereby making
        // this impossible to test unless it is called directly.
        try {
            $manager->__destruct();
            $this->fail("Expected AssertionError error not thrown");
        } catch (\AssertionError $e) {
            $this->assertStringStartsWith('Transaction $stack was not empty. Active stack: bar\\qux', $e->getMessage());
        }
        // Clean up.
        $reflectionProperty = new \ReflectionProperty(TransactionManagerBase::class, 'stack');
        $reflectionProperty->setValue($manager, []);
        unset($testConnection);
        Database::closeConnection('test_fail');
    }
    
    /**
     * Tests deprecation of Connection methods.
     *
     * @group legacy
     */
    public function testConnectionDeprecations() : void {
        $this->cleanUp();
        $transaction = $this->connection
            ->startTransaction();
        $this->expectDeprecation('Drupal\\Core\\Database\\Connection::transactionDepth() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Do not access the transaction stack depth, it is an implementation detail. See https://www.drupal.org/node/3381002');
        $this->assertSame(1, $this->connection
            ->transactionDepth());
        $this->insertRow('row');
        $this->expectDeprecation('Drupal\\Core\\Database\\Connection::rollBack() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Do not rollback the connection, roll back the Transaction objects instead. See https://www.drupal.org/node/3381002');
        $this->connection
            ->rollback();
        $transaction = NULL;
        $this->assertRowAbsent('row');
        $this->cleanUp();
        $transaction = $this->connection
            ->startTransaction();
        $this->expectDeprecation('Drupal\\Core\\Database\\Connection::addRootTransactionEndCallback() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Use TransactionManagerInterface::addPostTransactionCallback() instead. See https://www.drupal.org/node/3381002');
        $this->connection
            ->addRootTransactionEndCallback([
            $this,
            'rootTransactionCallback',
        ]);
        $this->insertRow('row');
        $this->expectDeprecation('Drupal\\Core\\Database\\Connection::commit() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Do not commit the connection, void the Transaction objects instead. See https://www.drupal.org/node/3381002');
        try {
            $this->connection
                ->commit();
        } catch (TransactionExplicitCommitNotAllowedException $e) {
            // Do nothing.
        }
        $transaction = NULL;
        $this->assertRowPresent('row');
        $this->cleanUp();
        $this->expectDeprecation('Drupal\\Core\\Database\\Connection::pushTransaction() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Use TransactionManagerInterface methods instead. See https://www.drupal.org/node/3381002');
        $this->connection
            ->pushTransaction('foo');
        $this->expectDeprecation('Drupal\\Core\\Database\\Connection::popTransaction() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Use TransactionManagerInterface methods instead. See https://www.drupal.org/node/3381002');
        $this->expectDeprecation('Drupal\\Core\\Database\\Connection::popCommittableTransactions() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Use TransactionManagerInterface methods instead. See https://www.drupal.org/node/3381002');
        $this->expectDeprecation('Drupal\\Core\\Database\\Connection::doCommit() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Use TransactionManagerInterface methods instead. See https://www.drupal.org/node/3381002');
        $this->connection
            ->popTransaction('foo');
        // Ensure there are no outstanding transactions left. This is necessary for
        // the test to pass when xdebug.mode has the 'develop' option enabled.
        $this->connection
            ->commitAll();
    }

}

Classes

Title Deprecated Summary
DriverSpecificTransactionTestBase Tests the transaction abstraction system.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.