diff --git a/core/lib/Drupal/Core/Cache/CacheTagsChecksumTrait.php b/core/lib/Drupal/Core/Cache/CacheTagsChecksumTrait.php index f75882c5e31..2a14f8a5407 100644 --- a/core/lib/Drupal/Core/Cache/CacheTagsChecksumTrait.php +++ b/core/lib/Drupal/Core/Cache/CacheTagsChecksumTrait.php @@ -68,7 +68,14 @@ trait CacheTagsChecksumTrait { $in_transaction = $this->getDatabaseConnection()->inTransaction(); if ($in_transaction) { if (empty($this->delayedTags)) { - $this->getDatabaseConnection()->addRootTransactionEndCallback([$this, 'rootTransactionEndCallback']); + // @todo in drupal:11.0.0, remove the conditional and only call the + // TransactionManager(). + if ($this->getDatabaseConnection()->transactionManager()) { + $this->getDatabaseConnection()->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionEndCallback']); + } + else { + $this->getDatabaseConnection()->addRootTransactionEndCallback([$this, 'rootTransactionEndCallback']); + } } $this->delayedTags = Cache::mergeTags($this->delayedTags, $tags); } diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php index aecaed728ff..67dc4a1a1a4 100644 --- a/core/lib/Drupal/Core/Database/Connection.php +++ b/core/lib/Drupal/Core/Database/Connection.php @@ -13,6 +13,7 @@ use Drupal\Core\Database\Query\Select; use Drupal\Core\Database\Query\Truncate; use Drupal\Core\Database\Query\Update; use Drupal\Core\Database\Query\Upsert; +use Drupal\Core\Database\Transaction\TransactionManagerInterface; use Drupal\Core\Pager\PagerManagerInterface; /** @@ -62,6 +63,11 @@ abstract class Connection { * transaction. * * @var array + * + * @deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. The + * transaction stack is now managed by TransactionManager. + * + * @see https://www.drupal.org/node/3381002 */ protected $transactionLayers = []; @@ -204,6 +210,11 @@ abstract class Connection { * Post-root (non-nested) transaction commit callbacks. * * @var callable[] + * + * @deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. The + * transaction end callbacks are now managed by TransactionManager. + * + * @see https://www.drupal.org/node/3381002 */ protected $rootTransactionEndCallbacks = []; @@ -226,6 +237,11 @@ abstract class Connection { */ private array $enabledEvents = []; + /** + * The transaction manager. + */ + protected TransactionManagerInterface|FALSE $transactionManager; + /** * Constructs a Connection object. * @@ -276,6 +292,20 @@ abstract class Connection { $this->connection = NULL; } + /** + * Returns the client-level database connection object. + * + * This method should normally be used only within database driver code. Not + * doing so constitutes a risk of introducing code that is not database + * independent. + * + * @return object + * The client-level database connection, for example \PDO. + */ + public function getClientConnection(): object { + return $this->connection; + } + /** * Returns the default query options for any given query. * @@ -1337,6 +1367,41 @@ abstract class Connection { return addcslashes($string, '\%_'); } + /** + * Returns the transaction manager. + * + * @return \Drupal\Core\Database\Transaction\TransactionManagerInterface|false + * The transaction manager, or FALSE if not available. + */ + public function transactionManager(): TransactionManagerInterface|FALSE { + if (!isset($this->transactionManager)) { + try { + $this->transactionManager = $this->driverTransactionManager(); + } + catch (\LogicException $e) { + $this->transactionManager = FALSE; + } + } + return $this->transactionManager; + } + + /** + * Returns a new instance of the driver's transaction manager. + * + * Database drivers must implement their own class extending from + * \Drupal\Core\Database\Transaction\TransactionManagerBase, and instantiate + * it here. + * + * @return \Drupal\Core\Database\Transaction\TransactionManagerInterface + * The transaction manager. + * + * @throws \LogicException + * If the transaction manager is undefined or unavailable. + */ + protected function driverTransactionManager(): TransactionManagerInterface { + throw new \LogicException('The database driver has no TransactionManager implementation'); + } + /** * Determines if there is an active transaction open. * @@ -1344,7 +1409,13 @@ abstract class Connection { * TRUE if we're currently in a transaction, FALSE otherwise. */ public function inTransaction() { + if ($this->transactionManager()) { + return $this->transactionManager()->inTransaction(); + } + // Start of BC layer. + // @phpstan-ignore-next-line return ($this->transactionDepth() > 0); + // End of BC layer. } /** @@ -1352,8 +1423,17 @@ abstract class Connection { * * @return int * The current transaction depth. + * + * @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 */ public function transactionDepth() { + @trigger_error(__METHOD__ . '() 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', E_USER_DEPRECATED); + if ($this->transactionManager()) { + return $this->transactionManager()->stackDepth(); + } return count($this->transactionLayers); } @@ -1368,9 +1448,12 @@ abstract class Connection { * * @see \Drupal\Core\Database\Transaction * - * @todo in drupal:11.0.0, return a new Transaction instance directly. + * @todo in drupal:11.0.0, push to the TransactionManager directly. */ public function startTransaction($name = '') { + if ($this->transactionManager()) { + return $this->transactionManager()->push($name); + } $class = $this->getDriverClass('Transaction'); return new $class($this, $name); } @@ -1388,8 +1471,18 @@ abstract class Connection { * @throws \Drupal\Core\Database\TransactionNoActiveException * * @see \Drupal\Core\Database\Transaction::rollBack() + * + * @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 */ public function rollBack($savepoint_name = 'drupal_transaction') { + @trigger_error(__METHOD__ . '() 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', E_USER_DEPRECATED); + if ($this->transactionManager()) { + $this->transactionManager()->rollback($savepoint_name); + return; + } if (!$this->inTransaction()) { throw new TransactionNoActiveException(); } @@ -1447,8 +1540,14 @@ abstract class Connection { * @throws \Drupal\Core\Database\TransactionNameNonUniqueException * * @see \Drupal\Core\Database\Transaction + * + * @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 */ public function pushTransaction($name) { + @trigger_error(__METHOD__ . '() 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', E_USER_DEPRECATED); if (isset($this->transactionLayers[$name])) { throw new TransactionNameNonUniqueException($name . " is already in use."); } @@ -1477,8 +1576,14 @@ abstract class Connection { * @throws \Drupal\Core\Database\TransactionCommitFailedException * * @see \Drupal\Core\Database\Transaction + * + * @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 */ public function popTransaction($name) { + @trigger_error(__METHOD__ . '() 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', E_USER_DEPRECATED); // The transaction has already been committed earlier. There is nothing we // need to do. If this transaction was part of an earlier out-of-order // rollback, an exception would already have been thrown by @@ -1512,8 +1617,18 @@ abstract class Connection { * The callback to invoke. * * @see \Drupal\Core\Database\Connection::doCommit() + * + * @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 */ public function addRootTransactionEndCallback(callable $callback) { + @trigger_error(__METHOD__ . '() 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', E_USER_DEPRECATED); + if ($this->transactionManager()) { + $this->transactionManager()->addPostTransactionCallback($callback); + return; + } if (!$this->transactionLayers) { throw new \LogicException('Root transaction end callbacks can only be added when there is an active transaction.'); } @@ -1524,8 +1639,14 @@ abstract class Connection { * Commit all the transaction layers that can commit. * * @internal + * + * @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 */ protected function popCommittableTransactions() { + @trigger_error(__METHOD__ . '() 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', E_USER_DEPRECATED); // Commit all the committable layers. foreach (array_reverse($this->transactionLayers) as $name => $active) { // Stop once we found an active transaction. @@ -1548,8 +1669,14 @@ abstract class Connection { * Do the actual commit, invoke post-commit callbacks. * * @internal + * + * @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 */ protected function doCommit() { + @trigger_error(__METHOD__ . '() 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', E_USER_DEPRECATED); $success = $this->connection->commit(); if (!empty($this->rootTransactionEndCallbacks)) { $callbacks = $this->rootTransactionEndCallbacks; @@ -1687,8 +1814,14 @@ abstract class Connection { * @throws \Drupal\Core\Database\TransactionExplicitCommitNotAllowedException * * @see \Drupal\Core\Database\Transaction + * + * @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 */ public function commit() { + @trigger_error(__METHOD__ . '() 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', E_USER_DEPRECATED); throw new TransactionExplicitCommitNotAllowedException(); } diff --git a/core/lib/Drupal/Core/Database/Transaction.php b/core/lib/Drupal/Core/Database/Transaction.php index 76d5fc8f5fa..47069baafba 100644 --- a/core/lib/Drupal/Core/Database/Transaction.php +++ b/core/lib/Drupal/Core/Database/Transaction.php @@ -48,9 +48,16 @@ class Transaction { protected $name; public function __construct(Connection $connection, $name = NULL) { + if ($connection->transactionManager()) { + $this->connection = $connection; + $this->name = $name; + return; + } + // Start of BC layer. $this->connection = $connection; // If there is no transaction depth, then no transaction has started. Name // the transaction 'drupal_transaction'. + // @phpstan-ignore-next-line if (!$depth = $connection->transactionDepth()) { $this->name = 'drupal_transaction'; } @@ -62,14 +69,23 @@ class Transaction { else { $this->name = $name; } + // @phpstan-ignore-next-line $this->connection->pushTransaction($this->name); + // End of BC layer. } public function __destruct() { + if ($this->connection->transactionManager()) { + $this->connection->transactionManager()->unpile($this->name); + return; + } + // Start of BC layer. // If we rolled back then the transaction would have already been popped. if (!$this->rolledBack) { + // @phpstan-ignore-next-line $this->connection->popTransaction($this->name); } + // End of BC layer. } /** @@ -90,8 +106,15 @@ class Transaction { * @see \Drupal\Core\Database\Connection::rollBack() */ public function rollBack() { + if ($this->connection->transactionManager()) { + $this->connection->transactionManager()->rollback($this->name); + return; + } + // Start of BC layer. $this->rolledBack = TRUE; + // @phpstan-ignore-next-line $this->connection->rollBack($this->name); + // End of BC layer. } } diff --git a/core/lib/Drupal/Core/Database/Transaction/ClientConnectionTransactionState.php b/core/lib/Drupal/Core/Database/Transaction/ClientConnectionTransactionState.php new file mode 100644 index 00000000000..34712f2df4a --- /dev/null +++ b/core/lib/Drupal/Core/Database/Transaction/ClientConnectionTransactionState.php @@ -0,0 +1,29 @@ + + */ + private array $stack = []; + + /** + * A list of Drupal transactions rolled back but not yet unpiled. + * + * @var array + */ + private array $rollbacks = []; + + /** + * A list of post-transaction callbacks. + * + * @var callable[] + */ + private array $postTransactionCallbacks = []; + + /** + * The state of the underlying client connection transaction. + * + * Note that this is a proxy of the actual state on the database server, + * best determined through calls to methods in this class. The actual + * state on the database server could be different. + */ + private ClientConnectionTransactionState $connectionTransactionState; + + /** + * Constructor. + * + * @param \Drupal\Core\Database\Connection $connection + * The database connection. + */ + public function __construct( + protected readonly Connection $connection, + ) { + } + + /** + * Returns the current depth of the transaction stack. + * + * @return int + * The current depth of the transaction stack. + * + * @todo consider making this function protected. + * + * @internal + */ + public function stackDepth(): int { + return count($this->stack()); + } + + /** + * Returns the content of the transaction stack. + * + * Drivers should not override this method unless they also override the + * $stack property. + * + * phpcs:ignore Drupal.Commenting.FunctionComment.InvalidReturn + * @return array + * The elements of the transaction stack. + */ + protected function stack(): array { + return $this->stack; + } + + /** + * Resets the transaction stack. + * + * Drivers should not override this method unless they also override the + * $stack property. + */ + protected function resetStack(): void { + $this->stack = []; + } + + /** + * Adds an item to the transaction stack. + * + * Drivers should not override this method unless they also override the + * $stack property. + * + * @param string $name + * The name of the transaction. + * @param \Drupal\Core\Database\Transaction\StackItemType $type + * The stack item type. + */ + protected function addStackItem(string $name, StackItemType $type): void { + $this->stack[$name] = $type; + } + + /** + * Removes an item from the transaction stack. + * + * Drivers should not override this method unless they also override the + * $stack property. + * + * @param string $name + * The name of the transaction. + */ + protected function removeStackItem(string $name): void { + unset($this->stack[$name]); + } + + /** + * {@inheritdoc} + */ + public function inTransaction(): bool { + return (bool) $this->stackDepth() && $this->getConnectionTransactionState() === ClientConnectionTransactionState::Active; + } + + /** + * {@inheritdoc} + */ + public function push(string $name = ''): Transaction { + if (!$this->inTransaction()) { + // If there is no transaction active, name the transaction + // 'drupal_transaction'. + $name = 'drupal_transaction'; + } + elseif (!$name) { + // Within transactions, savepoints are used. Each savepoint requires a + // name. So if no name is present we need to create one. + $name = 'savepoint_' . $this->stackDepth(); + } + + if ($this->has($name)) { + throw new TransactionNameNonUniqueException($name . " is already in use."); + } + + // Do the client-level processing. + if ($this->stackDepth() === 0) { + $this->beginClientTransaction(); + $type = StackItemType::Root; + $this->setConnectionTransactionState(ClientConnectionTransactionState::Active); + } + else { + // If we're already in a Drupal transaction then we want to create a + // database savepoint, rather than try to begin another database + // transaction. + $this->addClientSavepoint($name); + $type = StackItemType::Savepoint; + } + + // Push the transaction on the stack, increasing its depth. + $this->addStackItem($name, $type); + + return new Transaction($this->connection, $name); + } + + /** + * {@inheritdoc} + */ + public function unpile(string $name): void { + // If an already rolled back Drupal transaction, do nothing on the client + // connection, just cleanup the list of transactions rolled back. + if (isset($this->rollbacks[$name])) { + unset($this->rollbacks[$name]); + return; + } + + if ($name !== 'drupal_transaction' && !$this->has($name)) { + throw new TransactionOutOfOrderException(); + } + + // Release the client transaction savepoint in case the Drupal transaction + // is not a root one. + if ( + $this->has($name) + && $this->stack()[$name] === StackItemType::Savepoint + && $this->getConnectionTransactionState() === ClientConnectionTransactionState::Active + ) { + $this->releaseClientSavepoint($name); + } + + // Remove the transaction from the stack. + $this->removeStackItem($name); + + // If this was the last Drupal transaction open, we can commit the client + // transaction. + if ( + $this->stackDepth() === 0 + && $this->getConnectionTransactionState() === ClientConnectionTransactionState::Active + ) { + $this->processRootCommit(); + } + } + + /** + * {@inheritdoc} + */ + public function rollback(string $name): void { + if (!$this->inTransaction()) { + throw new TransactionNoActiveException(); + } + + // Do the client-level processing. + match ($this->stack()[$name]) { + StackItemType::Root => $this->processRootRollback(), + StackItemType::Savepoint => $this->rollbackClientSavepoint($name), + }; + + // Rolled back item should match the last one in stack. + if ($name !== array_key_last($this->stack())) { + throw new TransactionOutOfOrderException(); + } + + $this->rollbacks[$name] = TRUE; + $this->removeStackItem($name); + + // If this was the last Drupal transaction open, we can commit the client + // transaction. + if ($this->stackDepth() === 0 && $this->getConnectionTransactionState() === ClientConnectionTransactionState::Active) { + $this->processRootCommit(); + } + } + + /** + * {@inheritdoc} + */ + public function addPostTransactionCallback(callable $callback): void { + if (!$this->inTransaction()) { + throw new \LogicException('Root transaction end callbacks can only be added when there is an active transaction.'); + } + $this->postTransactionCallbacks[] = $callback; + } + + /** + * {@inheritdoc} + */ + public function has(string $name): bool { + return isset($this->stack()[$name]); + } + + /** + * Sets the state of the client connection transaction. + * + * Note that this is a proxy of the actual state on the database server, + * best determined through calls to methods in this class. The actual + * state on the database server could be different. + * + * Drivers should not override this method unless they also override the + * $connectionTransactionState property. + * + * @param \Drupal\Core\Database\Transaction\ClientConnectionTransactionState $state + * The state of the client connection. + */ + protected function setConnectionTransactionState(ClientConnectionTransactionState $state): void { + $this->connectionTransactionState = $state; + } + + /** + * Gets the state of the client connection transaction. + * + * Note that this is a proxy of the actual state on the database server, + * best determined through calls to methods in this class. The actual + * state on the database server could be different. + * + * Drivers should not override this method unless they also override the + * $connectionTransactionState property. + * + * @return \Drupal\Core\Database\Transaction\ClientConnectionTransactionState + * The state of the client connection. + */ + protected function getConnectionTransactionState(): ClientConnectionTransactionState { + return $this->connectionTransactionState; + } + + /** + * Processes the root transaction rollback. + */ + protected function processRootRollback(): void { + $this->processPostTransactionCallbacks(); + $this->rollbackClientTransaction(); + } + + /** + * Processes the root transaction commit. + * + * @throws \Drupal\Core\Database\TransactionCommitFailedException + * If the commit of the root transaction failed. + */ + protected function processRootCommit(): void { + $clientCommit = $this->commitClientTransaction(); + $this->processPostTransactionCallbacks(); + if (!$clientCommit) { + throw new TransactionCommitFailedException(); + } + } + + /** + * Processes the post-transaction callbacks. + */ + protected function processPostTransactionCallbacks(): void { + if (!empty($this->postTransactionCallbacks)) { + $callbacks = $this->postTransactionCallbacks; + $this->postTransactionCallbacks = []; + foreach ($callbacks as $callback) { + call_user_func($callback, $this->getConnectionTransactionState() === ClientConnectionTransactionState::Committed || $this->getConnectionTransactionState() === ClientConnectionTransactionState::Voided); + } + } + } + + /** + * Begins a transaction on the client connection. + * + * @return bool + * Returns TRUE on success or FALSE on failure. + */ + abstract protected function beginClientTransaction(): bool; + + /** + * Adds a savepoint on the client transaction. + * + * This is a generic implementation. Drivers should override this method + * to use a method specific for their client connection. + * + * @param string $name + * The name of the savepoint. + * + * @return bool + * Returns TRUE on success or FALSE on failure. + */ + protected function addClientSavepoint(string $name): bool { + $this->connection->query('SAVEPOINT ' . $name); + return TRUE; + } + + /** + * Rolls back to a savepoint on the client transaction. + * + * This is a generic implementation. Drivers should override this method + * to use a method specific for their client connection. + * + * @param string $name + * The name of the savepoint. + * + * @return bool + * Returns TRUE on success or FALSE on failure. + */ + protected function rollbackClientSavepoint(string $name): bool { + $this->connection->query('ROLLBACK TO SAVEPOINT ' . $name); + return TRUE; + } + + /** + * Releases a savepoint on the client transaction. + * + * This is a generic implementation. Drivers should override this method + * to use a method specific for their client connection. + * + * @param string $name + * The name of the savepoint. + * + * @return bool + * Returns TRUE on success or FALSE on failure. + */ + protected function releaseClientSavepoint(string $name): bool { + $this->connection->query('RELEASE SAVEPOINT ' . $name); + return TRUE; + } + + /** + * Rolls back a client transaction. + * + * @return bool + * Returns TRUE on success or FALSE on failure. + */ + abstract protected function rollbackClientTransaction(): bool; + + /** + * Commits a client transaction. + * + * @return bool + * Returns TRUE on success or FALSE on failure. + */ + abstract protected function commitClientTransaction(): bool; + +} diff --git a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php new file mode 100644 index 00000000000..86d5de4ac5a --- /dev/null +++ b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php @@ -0,0 +1,112 @@ +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)) { - $this->doCommit(); - } - else { - // Attempt to release this savepoint in the standard way. - try { - $this->query('RELEASE SAVEPOINT ' . $name); - } - catch (DatabaseExceptionWrapper $e) { - // However, in MySQL (InnoDB), savepoints are automatically committed - // when tables are altered or created (DDL transactions are not - // supported). This can cause exceptions due to trying to release - // savepoints which no longer exist. - // - // To avoid exceptions when no actual error has occurred, we silently - // succeed for MySQL error code 1305 ("SAVEPOINT does not exist"). - if ($e->getPrevious()->errorInfo[1] == '1305') { - // If one SAVEPOINT was released automatically, then all were. - // Therefore, clean the transaction stack. - $this->transactionLayers = []; - // We also have to explain to PDO that the transaction stack has - // been cleaned-up. - $this->doCommit(); - } - else { - throw $e; - } - } - } - } - } - - /** - * {@inheritdoc} - */ - public function rollBack($savepoint_name = 'drupal_transaction') { - // MySQL will automatically commit transactions when tables are altered or - // created (DDL transactions are not supported). Prevent triggering an - // exception to ensure that the error that has caused the rollback is - // properly reported. - if (!$this->connection->inTransaction()) { - // On PHP 7 $this->connection->inTransaction() will return TRUE and - // $this->connection->rollback() does not throw an exception; the - // following code is unreachable. - - // If \Drupal\Core\Database\Connection::rollBack() would throw an - // exception then continue to throw an exception. - if (!$this->inTransaction()) { - throw new TransactionNoActiveException(); - } - // A previous rollback to an earlier savepoint may mean that the savepoint - // in question has already been accidentally committed. - if (!isset($this->transactionLayers[$savepoint_name])) { - throw new TransactionNoActiveException(); - } - - trigger_error('Rollback attempted when there is no active transaction. This can cause data integrity issues.', E_USER_WARNING); - return; - } - return parent::rollBack($savepoint_name); - } - - /** - * {@inheritdoc} - */ - protected function doCommit() { - // MySQL will automatically commit transactions when tables are altered or - // created (DDL transactions are not supported). Prevent triggering an - // exception in this case as all statements have been committed. - if ($this->connection->inTransaction()) { - // On PHP 7 $this->connection->inTransaction() will return TRUE and - // $this->connection->commit() does not throw an exception. - $success = parent::doCommit(); - } - else { - // Process the post-root (non-nested) transaction commit callbacks. The - // following code is copied from - // \Drupal\Core\Database\Connection::doCommit() - $success = TRUE; - if (!empty($this->rootTransactionEndCallbacks)) { - $callbacks = $this->rootTransactionEndCallbacks; - $this->rootTransactionEndCallbacks = []; - foreach ($callbacks as $callback) { - call_user_func($callback, $success); - } - } - } - return $success; - } - /** * {@inheritdoc} */ @@ -586,11 +481,18 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn return new Condition($conjunction); } + /** + * {@inheritdoc} + */ + protected function driverTransactionManager(): TransactionManagerInterface { + return new TransactionManager($this); + } + /** * {@inheritdoc} */ public function startTransaction($name = '') { - return new Transaction($this, $name); + return $this->transactionManager()->push($name); } } diff --git a/core/modules/mysql/src/Driver/Database/mysql/TransactionManager.php b/core/modules/mysql/src/Driver/Database/mysql/TransactionManager.php new file mode 100644 index 00000000000..3b7ce03f5f7 --- /dev/null +++ b/core/modules/mysql/src/Driver/Database/mysql/TransactionManager.php @@ -0,0 +1,99 @@ +connection->getClientConnection()->beginTransaction(); + } + + /** + * {@inheritdoc} + */ + protected function processRootCommit(): void { + if (!$this->connection->getClientConnection()->inTransaction()) { + $this->setConnectionTransactionState(ClientConnectionTransactionState::Voided); + $this->processPostTransactionCallbacks(); + return; + } + parent::processRootCommit(); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientSavepoint(string $name): bool { + if (!$this->connection->getClientConnection()->inTransaction()) { + $this->resetStack(); + $this->setConnectionTransactionState(ClientConnectionTransactionState::Voided); + $this->processPostTransactionCallbacks(); + return TRUE; + } + return parent::rollbackClientSavepoint($name); + } + + /** + * {@inheritdoc} + */ + protected function releaseClientSavepoint(string $name): bool { + if (!$this->connection->getClientConnection()->inTransaction()) { + $this->resetStack(); + $this->setConnectionTransactionState(ClientConnectionTransactionState::Voided); + $this->processPostTransactionCallbacks(); + return TRUE; + } + return parent::releaseClientSavepoint($name); + } + + /** + * {@inheritdoc} + */ + protected function commitClientTransaction(): bool { + if (!$this->connection->getClientConnection()->inTransaction()) { + $this->setConnectionTransactionState(ClientConnectionTransactionState::Voided); + $this->processPostTransactionCallbacks(); + return TRUE; + } + $clientCommit = $this->connection->getClientConnection()->commit(); + $this->setConnectionTransactionState($clientCommit ? + ClientConnectionTransactionState::Committed : + ClientConnectionTransactionState::CommitFailed + ); + return $clientCommit; + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientTransaction(): bool { + if (!$this->connection->getClientConnection()->inTransaction()) { + $this->setConnectionTransactionState(ClientConnectionTransactionState::Voided); + $this->processPostTransactionCallbacks(); + trigger_error('Rollback attempted when there is no active transaction. This can cause data integrity issues.', E_USER_WARNING); + } + $clientRollback = $this->connection->getClientConnection()->rollBack(); + $this->setConnectionTransactionState($clientRollback ? + ClientConnectionTransactionState::RolledBack : + ClientConnectionTransactionState::RollbackFailed + ); + return $clientRollback; + } + +} diff --git a/core/modules/pgsql/src/Driver/Database/pgsql/Connection.php b/core/modules/pgsql/src/Driver/Database/pgsql/Connection.php index 6bda0f31477..8a78723465c 100644 --- a/core/modules/pgsql/src/Driver/Database/pgsql/Connection.php +++ b/core/modules/pgsql/src/Driver/Database/pgsql/Connection.php @@ -11,7 +11,7 @@ use Drupal\Core\Database\Query\Condition; use Drupal\Core\Database\StatementInterface; use Drupal\Core\Database\StatementWrapperIterator; use Drupal\Core\Database\SupportsTemporaryTablesInterface; -use Drupal\Core\Database\Transaction; +use Drupal\Core\Database\Transaction\TransactionManagerInterface; // cSpell:ignore ilike nextval @@ -72,6 +72,21 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn */ protected $identifierQuotes = ['"', '"']; + /** + * An array of transaction savepoints. + * + * The main use for this array is to store information about transaction + * savepoints opened to to mimic MySql's InnoDB functionality, which provides + * an inherent savepoint before any query in a transaction. + * + * @see ::addSavepoint() + * @see ::releaseSavepoint() + * @see ::rollbackSavepoint() + * + * @var array + */ + protected array $savepoints = []; + /** * Constructs a connection object. */ @@ -198,7 +213,7 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn // - A 'mimic_implicit_commit' does not exist already. // - The query is not a savepoint query. $wrap_with_savepoint = $this->inTransaction() && - !isset($this->transactionLayers['mimic_implicit_commit']) && + !$this->transactionManager()->has('mimic_implicit_commit') && !(is_string($query) && ( stripos($query, 'ROLLBACK TO SAVEPOINT ') === 0 || stripos($query, 'RELEASE SAVEPOINT ') === 0 || @@ -380,12 +395,10 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn * @param $savepoint_name * A string representing the savepoint name. By default, * "mimic_implicit_commit" is used. - * - * @see Drupal\Core\Database\Connection::pushTransaction() */ public function addSavepoint($savepoint_name = 'mimic_implicit_commit') { if ($this->inTransaction()) { - $this->pushTransaction($savepoint_name); + $this->savepoints[$savepoint_name] = $this->startTransaction($savepoint_name); } } @@ -395,12 +408,10 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn * @param $savepoint_name * A string representing the savepoint name. By default, * "mimic_implicit_commit" is used. - * - * @see Drupal\Core\Database\Connection::popTransaction() */ public function releaseSavepoint($savepoint_name = 'mimic_implicit_commit') { - if (isset($this->transactionLayers[$savepoint_name])) { - $this->popTransaction($savepoint_name); + if ($this->inTransaction() && $this->transactionManager()->has($savepoint_name)) { + unset($this->savepoints[$savepoint_name]); } } @@ -412,8 +423,9 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn * "mimic_implicit_commit" is used. */ public function rollbackSavepoint($savepoint_name = 'mimic_implicit_commit') { - if (isset($this->transactionLayers[$savepoint_name])) { - $this->rollBack($savepoint_name); + if ($this->inTransaction() && $this->transactionManager()->has($savepoint_name)) { + $this->savepoints[$savepoint_name]->rollBack(); + unset($this->savepoints[$savepoint_name]); } } @@ -502,11 +514,18 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn return new Condition($conjunction); } + /** + * {@inheritdoc} + */ + protected function driverTransactionManager(): TransactionManagerInterface { + return new TransactionManager($this); + } + /** * {@inheritdoc} */ public function startTransaction($name = '') { - return new Transaction($this, $name); + return $this->transactionManager()->push($name); } } diff --git a/core/modules/pgsql/src/Driver/Database/pgsql/TransactionManager.php b/core/modules/pgsql/src/Driver/Database/pgsql/TransactionManager.php new file mode 100644 index 00000000000..4374ac993c3 --- /dev/null +++ b/core/modules/pgsql/src/Driver/Database/pgsql/TransactionManager.php @@ -0,0 +1,46 @@ +connection->getClientConnection()->beginTransaction(); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientTransaction(): bool { + $clientRollback = $this->connection->getClientConnection()->rollBack(); + $this->setConnectionTransactionState($clientRollback ? + ClientConnectionTransactionState::RolledBack : + ClientConnectionTransactionState::RollbackFailed + ); + return $clientRollback; + } + + /** + * {@inheritdoc} + */ + protected function commitClientTransaction(): bool { + $clientCommit = $this->connection->getClientConnection()->commit(); + $this->setConnectionTransactionState($clientCommit ? + ClientConnectionTransactionState::Committed : + ClientConnectionTransactionState::CommitFailed + ); + return $clientCommit; + } + +} diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php index 0c14efb20f7..31622c35a84 100644 --- a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php @@ -9,7 +9,7 @@ use Drupal\Core\Database\ExceptionHandler; use Drupal\Core\Database\Query\Condition; use Drupal\Core\Database\StatementInterface; use Drupal\Core\Database\SupportsTemporaryTablesInterface; -use Drupal\Core\Database\Transaction; +use Drupal\Core\Database\Transaction\TransactionManagerInterface; /** * SQLite implementation of \Drupal\Core\Database\Connection. @@ -30,6 +30,11 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn * Whether or not the active transaction (if any) will be rolled back. * * @var bool + * + * @deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. It is + * unused. + * + * @see https://www.drupal.org/node/3381002 */ protected $willRollback; @@ -584,11 +589,18 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn return new Condition($conjunction); } + /** + * {@inheritdoc} + */ + protected function driverTransactionManager(): TransactionManagerInterface { + return new TransactionManager($this); + } + /** * {@inheritdoc} */ public function startTransaction($name = '') { - return new Transaction($this, $name); + return $this->transactionManager()->push($name); } } diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/TransactionManager.php b/core/modules/sqlite/src/Driver/Database/sqlite/TransactionManager.php new file mode 100644 index 00000000000..7b422767d63 --- /dev/null +++ b/core/modules/sqlite/src/Driver/Database/sqlite/TransactionManager.php @@ -0,0 +1,46 @@ +connection->getClientConnection()->beginTransaction(); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientTransaction(): bool { + $clientRollback = $this->connection->getClientConnection()->rollBack(); + $this->setConnectionTransactionState($clientRollback ? + ClientConnectionTransactionState::RolledBack : + ClientConnectionTransactionState::RollbackFailed + ); + return $clientRollback; + } + + /** + * {@inheritdoc} + */ + protected function commitClientTransaction(): bool { + $clientCommit = $this->connection->getClientConnection()->commit(); + $this->setConnectionTransactionState($clientCommit ? + ClientConnectionTransactionState::Committed : + ClientConnectionTransactionState::CommitFailed + ); + return $clientCommit; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Cache/EndOfTransactionQueriesTest.php b/core/tests/Drupal/KernelTests/Core/Cache/EndOfTransactionQueriesTest.php index 8d8b1a1400f..40d804893f4 100644 --- a/core/tests/Drupal/KernelTests/Core/Cache/EndOfTransactionQueriesTest.php +++ b/core/tests/Drupal/KernelTests/Core/Cache/EndOfTransactionQueriesTest.php @@ -68,6 +68,14 @@ class EndOfTransactionQueriesTest extends KernelTestBase { $executed_statements = []; foreach (Database::getLog('testEntitySave') as $log) { + // Exclude transaction related statements from the log. + if ( + str_starts_with($log['query'], 'ROLLBACK TO SAVEPOINT ') || + str_starts_with($log['query'], 'RELEASE SAVEPOINT ') || + str_starts_with($log['query'], 'SAVEPOINT ') + ) { + continue; + } $executed_statements[] = $log['query']; } $last_statement_index = max(array_keys($executed_statements)); diff --git a/core/tests/Drupal/KernelTests/Core/Database/DeleteTruncateTest.php b/core/tests/Drupal/KernelTests/Core/Database/DeleteTruncateTest.php index 689d40ac9a6..ca28d42c324 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DeleteTruncateTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DeleteTruncateTest.php @@ -130,7 +130,7 @@ class DeleteTruncateTest extends DatabaseTestBase { // Roll back the transaction, and check that we are back to status before // insert and truncate. - $this->connection->rollBack(); + $transaction->rollBack(); $this->assertFalse($this->connection->inTransaction()); $num_records_after = $this->connection->select('test')->countQuery()->execute()->fetchField(); $this->assertEquals($num_records_before, $num_records_after); diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php index ebbf60826ad..18c2efcb0ca 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php @@ -2,9 +2,10 @@ namespace Drupal\KernelTests\Core\Database; -use Drupal\Component\Render\FormattableMarkup; -use Drupal\Core\Database\TransactionOutOfOrderException; +use Drupal\Core\Database\TransactionExplicitCommitNotAllowedException; +use Drupal\Core\Database\TransactionNameNonUniqueException; use Drupal\Core\Database\TransactionNoActiveException; +use Drupal\Core\Database\TransactionOutOfOrderException; use PHPUnit\Framework\Error\Warning; /** @@ -34,6 +35,11 @@ use PHPUnit\Framework\Error\Warning; */ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { + /** + * Keeps track of the post-transaction callback action executed. + */ + protected ?string $postTransactionCallbackAction = NULL; + /** * Encapsulates a transaction's "inner layer" with an "outer layer". * @@ -57,7 +63,7 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { * Whether to execute a DDL statement during the inner transaction. */ protected function transactionOuterLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) { - $depth = $this->connection->transactionDepth(); + $depth = $this->connection->transactionManager()->stackDepth(); $txn = $this->connection->startTransaction(); // Insert a single row into the testing table. @@ -80,7 +86,7 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { // 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().'); + $this->assertSame($depth, $this->connection->transactionManager()->stackDepth(), 'Transaction has rolled back to the last savepoint after calling rollBack().'); } } @@ -98,14 +104,14 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { * Whether to execute a DDL statement during the transaction. */ protected function transactionInnerLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) { - $depth = $this->connection->transactionDepth(); + $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->transactionDepth(); + $depth2 = $this->connection->transactionManager()->stackDepth(); $this->assertGreaterThan($depth, $depth2, 'Transaction depth has increased with new transaction.'); // Insert a single row into the testing table. @@ -138,7 +144,7 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { // 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().'); + $this->assertSame($depth, $this->connection->transactionManager()->stackDepth(), 'Transaction has rolled back to the last savepoint after calling rollBack().'); } } @@ -267,7 +273,7 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { try { // Rollback the outer transaction. $transaction->rollBack(); - // @see \Drupal\mysql\Driver\Database\mysql\Connection::rollBack() + // @see \Drupal\mysql\Driver\Database\mysql\TransactionManager::rollbackClientTransaction() $this->fail('Rolling back a transaction containing DDL should produce a warning.'); } catch (Warning $warning) { @@ -313,6 +319,7 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { protected function cleanUp() { $this->connection->truncate('test') ->execute(); + $this->postTransactionCallbackAction = NULL; } /** @@ -326,11 +333,8 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { * @internal */ public function assertRowPresent(string $name, string $message = NULL): void { - if (!isset($message)) { - $message = new FormattableMarkup('Row %name is present.', ['%name' => $name]); - } $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE [name] = :name', [':name' => $name])->fetchField(); - $this->assertTrue($present, $message); + $this->assertTrue($present, $message ?? "Row '{$name}' should be present, but it actually does not exist."); } /** @@ -344,11 +348,8 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { * @internal */ public function assertRowAbsent(string $name, string $message = NULL): void { - if (!isset($message)) { - $message = new FormattableMarkup('Row %name is absent.', ['%name' => $name]); - } $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE [name] = :name', [':name' => $name])->fetchField(); - $this->assertFalse($present, $message); + $this->assertFalse($present, $message ?? "Row '{$name}' should be absent, but it actually exists."); } /** @@ -575,4 +576,108 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase { $this->assertEquals('24', $saved_age); } + /** + * Tests for transaction names. + */ + public function testTransactionName(): void { + $transaction = $this->connection->startTransaction(); + $this->assertSame('drupal_transaction', $transaction->name()); + + $savepoint1 = $this->connection->startTransaction(); + $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 { + $this->cleanUp(); + $transaction = $this->connection->startTransaction(); + $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 testRootTransactionEndCallbackCalledOnRollback(): void { + $this->cleanUp(); + $transaction = $this->connection->startTransaction(); + $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']); + $this->insertRow('row'); + $this->assertNull($this->postTransactionCallbackAction); + $transaction->rollBack(); + $this->assertSame('rtcRollback', $this->postTransactionCallbackAction); + unset($transaction); + $this->assertRowAbsent('row'); + // The row insert should be missing since the client rollback occurs after + // the processing of the callbacks. + $this->assertRowAbsent('rtcRollback'); + } + + /** + * A post-transaction callback for testing purposes. + */ + public function rootTransactionCallback(bool $success): void { + $this->postTransactionCallbackAction = $success ? 'rtcCommit' : 'rtcRollback'; + $this->insertRow($this->postTransactionCallbackAction); + } + + /** + * 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'); + } + }