From c676d98192087399339d8933a8ae373435ef2ddb Mon Sep 17 00:00:00 2001 From: Yitz Willroth Date: Fri, 11 Jul 2025 13:04:54 -0400 Subject: [PATCH] feat add database savepoint support --- .../Database/Concerns/ManagesSavepoints.php | 367 +++++ .../Database/Concerns/ManagesTransactions.php | 40 +- src/Illuminate/Database/Connection.php | 30 + .../Database/ConnectionInterface.php | 52 +- .../Database/Events/SavepointCreated.php | 15 + .../Database/Events/SavepointReleased.php | 15 + .../Database/Events/SavepointRolledBack.php | 16 + .../Database/Query/Grammars/Grammar.php | 36 +- .../Query/Grammars/MariaDbGrammar.php | 40 + .../Database/Query/Grammars/MySqlGrammar.php | 40 + .../Query/Grammars/PostgresGrammar.php | 40 + .../Database/Query/Grammars/SQLiteGrammar.php | 40 + .../Query/Grammars/SqlServerGrammar.php | 41 +- .../Database/SqlServerConnection.php | 20 + .../Testing/DatabaseTransactions.php | 2 +- .../Foundation/Testing/RefreshDatabase.php | 2 +- src/Illuminate/Support/Facades/DB.php | 8 + tests/Database/DatabaseConnectionTest.php | 8 +- tests/Database/DatabaseSavepointsTest.php | 1300 +++++++++++++++++ tests/Database/DatabaseTransactionsTest.php | 4 +- 20 files changed, 2064 insertions(+), 52 deletions(-) create mode 100644 src/Illuminate/Database/Concerns/ManagesSavepoints.php create mode 100644 src/Illuminate/Database/Events/SavepointCreated.php create mode 100644 src/Illuminate/Database/Events/SavepointReleased.php create mode 100644 src/Illuminate/Database/Events/SavepointRolledBack.php create mode 100644 tests/Database/DatabaseSavepointsTest.php diff --git a/src/Illuminate/Database/Concerns/ManagesSavepoints.php b/src/Illuminate/Database/Concerns/ManagesSavepoints.php new file mode 100644 index 000000000000..e53421fd4889 --- /dev/null +++ b/src/Illuminate/Database/Concerns/ManagesSavepoints.php @@ -0,0 +1,367 @@ +> + */ + protected array $savepoints = []; + + /** + * Determine if the connection supports savepoints. + */ + public function supportsSavepoints(): bool + { + return $this->queryGrammar?->supportsSavepoints() ?? false; + } + + /** + * Determine if the connection supports releasing savepoints. + */ + public function supportsSavepointRelease(): bool + { + return $this->queryGrammar?->supportsSavepointRelease() ?? false; + } + + /** + * Create a savepoint within the current transaction. Optionally provide a callback + * to be executed following creation of the savepoint. If the callback fails, the transaction + * will be rolled back to the savepoint. The savepoint will be released after the callback + * has been executed. + * + * @throws Throwable + */ + public function savepoint(string $name, ?callable $callback = null): mixed + { + if (! $this->supportsSavepoints()) { + $this->savepointsUnsupportedError(); + } + + if (! $this->transactionLevel()) { + $this->savepointOutsideTransactionError(); + } + + if ($this->hasSavepoint($name)) { + $this->duplicateSavepointError($name); + } + + if ($this->getPdo()->exec($this->queryGrammar->compileSavepoint($this->encodeSavepointName($name))) === false) { + $this->savepointActionFailedError('create', $name); + } + + $this->savepoints[$this->transactionLevel()][] = $name; + + $this->event(new SavepointCreated($this, $name)); + + if (! is_null($callback)) { + try { + return $callback(); + } catch (Throwable $e) { + if ($this->hasSavepoint($name)) { + $this->rollbackToSavepoint($name); + } + + throw $e; + } finally { + if ($this->supportsSavepointRelease() && $this->hasSavepoint($name)) { + $this->releaseSavepoint($name); + } + } + } + + return true; + } + + /** + * Rollback to a named savepoint within the current transaction. + * + * @throws Throwable + */ + public function rollbackToSavepoint(string $name): void + { + if (! $this->supportsSavepoints()) { + $this->savepointsUnsupportedError(); + } + + if (! $this->hasSavepoint($name)) { + $this->unknownSavepointError($name); + } + + if (($position = array_search($name, $this->savepoints[$level = $this->transactionLevel()] ?? [], true)) !== false) { + $released = array_splice($this->savepoints[$level], $position + 1); + } + + if ($this->getPdo()->exec($this->queryGrammar->compileRollbackToSavepoint($this->encodeSavepointName($name))) === false) { + $this->savepointActionFailedError('rollback to', $name); + } + + $this->event(new SavepointRolledBack($this, $name, $released ?? [])); + } + + /** + * Release a savepoint from the current transaction. + * + * @throws Throwable + */ + public function releaseSavepoint(string $name, ?int $level = null): void + { + if (! $this->supportsSavepoints()) { + $this->savepointsUnsupportedError(); + } + + if (! $this->supportsSavepointRelease()) { + $this->savepointReleaseUnsupportedError(); + } + + if (! $this->hasSavepoint($name)) { + $this->unknownSavepointError($name); + } + + if ($this->getPdo()->exec($this->queryGrammar->compileReleaseSavepoint($this->encodeSavepointName($name))) === false) { + $this->savepointActionFailedError('release', $name); + } + + $this->savepoints[$level ??= $this->transactionLevel()] = array_values(array_diff($this->savepoints[$level], [$name])); + + $this->event(new SavepointReleased($this, $name)); + } + + /** + * Purge all savepoints from the current transaction. + * + * @throws Throwable + */ + public function purgeSavepoints(?int $level = null): void + { + if (! $this->supportsSavepoints()) { + $this->savepointsUnsupportedError(); + } + + if (! $this->supportsSavepointRelease()) { + $this->savepointPurgeUnsupportedError(); + } + + foreach ($this->savepoints[$level ?? $this->transactionLevel()] ?? [] as $name) { + $this->releaseSavepoint($name, $level); + } + } + + /** + * Determine if the connection has a savepoint within the current transaction. + */ + public function hasSavepoint(string $name): bool + { + return in_array($name, $this->savepoints[$this->transactionLevel()] ?? [], true); + } + + /** + * Get the names of all savepoints within the current transaction. + */ + public function getSavepoints(): array + { + return $this->savepoints[$this->transactionLevel()] ?? []; + } + + /** + * Get the name of the current savepoint. + */ + public function getCurrentSavepoint(): ?string + { + return isset($this->savepoints[$level = $this->transactionLevel()]) && ! empty($this->savepoints[$level]) + ? end($this->savepoints[$level]) + : null; + } + + /** + * Initialize savepoint management for the connection; sets up event + * listeners to manage savepoints during transaction events. + */ + protected function initializeSavepointManagement(bool $force = false): void + { + if (($this->savepointManagementInitialized && ! $force) || ! $this->supportsSavepoints()) { + return; + } + + $this->savepointManagementInitialized = true; + + $this->savepoints = []; + + $this->events?->listen(function (TransactionBeginning $event) { + $this->syncTransactionBeginning(); + }); + + $this->events?->listen(function (TransactionCommitted $event) { + $this->syncTransactionCommitted(); + }); + + $this->events?->listen(function (TransactionRolledBack $event) { + $this->syncTransactionRolledBack(); + }); + } + + /** + * Update savepoint management to reflect the transaction beginning event. + */ + protected function syncTransactionBeginning(): void + { + $this->savepoints[$this->transactionLevel()] = []; + } + + /** + * Update savepoint management to reflect the transaction committed event. + * + * @throws Throwable + */ + protected function syncTransactionCommitted(): void + { + $this->syncSavepoints(); + } + + /** + * Update savepoint management to reflect the transaction rolled back event. + * + * @throws Throwable + */ + protected function syncTransactionRolledBack(): void + { + $this->syncSavepoints(); + } + + /** + * Sync savepoints after a transaction commit or rollback. + * + * @throws Throwable + */ + protected function syncSavepoints(): void + { + foreach (array_keys($this->savepoints) as $level) { + if ($level > $this->transactionLevel()) { + if ($this->supportsSavepointRelease()) { + $this->purgeSavepoints($level); + } + + unset($this->savepoints[$level]); + } + } + + if (! $this->transactionLevel()) { + $this->savepoints = []; + } + } + + /** + * Encode a savepoint name to ensure it's safe for SQL compilation. + */ + protected function encodeSavepointName(string $name): string + { + return bin2hex($name); + } + + /** + * Throw an error indicating that savepoints are unsupported. + * + * @throws RuntimeException + */ + protected function savepointsUnsupportedError(): void + { + throw new RuntimeException('This database connection does not support creating savepoints.'); + } + + /** + * Throw an error indicating that releasing savepoints is unsupported. + * + * @throws RuntimeException + */ + protected function savepointReleaseUnsupportedError(): void + { + throw new RuntimeException('This database connection does not support releasing savepoints.'); + } + + /** + * Throw an error indicating that purging savepoints is unsupported. + * + * @throws RuntimeException + */ + protected function savepointPurgeUnsupportedError(): void + { + throw new RuntimeException('This database connection does not support purging savepoints.'); + } + + /** + * Throw an error indicating that a savepoint already exists with the given name. + * + * @throws InvalidArgumentException + */ + protected function duplicateSavepointError(string $name): void + { + throw new InvalidArgumentException( + "Savepoint '{$name}' already exists at position " + .array_search($name, $this->savepoints[$this->transactionLevel()] ?? [], true) + ." in transaction level {$this->transactionLevel()}. " + ."Use a different name or call rollbackToSavepoint('{$name}') first. " + ."Current savepoints: ['".implode("', '", $this->savepoints[$this->transactionLevel()] ?? [])."']." + ); + } + + /** + * Throw an error indicating that the specified savepoint does not exist. + * + * @throws InvalidArgumentException + */ + protected function unknownSavepointError(string $name): void + { + throw new InvalidArgumentException( + "Savepoint '{$name}' does not exist in transaction level {$this->transactionLevel()}." + .(empty($this->savepoints[$this->transactionLevel()] ?? []) + ? ' No savepoints exist at this transaction level.' + : " Available savepoints: ['".implode("', '", $this->savepoints[$this->transactionLevel()])."'].") + ); + } + + /** + * Throw an error indicating that a savepoint cannot be created outside a transaction. + * + * @throws LogicException + */ + protected function savepointOutsideTransactionError(): void + { + throw new LogicException( + 'Cannot create savepoint outside of transaction. Current transaction level: 0. ' + .'Call beginTransaction() first or use the transaction() helper method.' + ); + } + + /** + * Throw an error indicating that an error occurred while executing a savepoint action. + * + * @throws RuntimeException + */ + protected function savepointActionFailedError(string $action = 'execute', string $name = ''): void + { + throw new RuntimeException( + "Failed to {$action} savepoint".($name ? " '{$name}'" : '') + .'. Check database permissions and transaction state. ' + ."Current transaction level: {$this->transactionLevel()}." + ); + } +} diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 23bc60434e49..751a893c758c 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -101,7 +101,7 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma // If there was an exception we will rollback this transaction and then we // can check if we have exceeded the maximum attempt count for this and // if we haven't we will return and try this query again in our loop. - $this->rollBack(); + $this->rollbackTransaction(); if ($this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) { @@ -157,20 +157,6 @@ protected function createTransaction() } } - /** - * Create a save point within the database. - * - * @return void - * - * @throws \Throwable - */ - protected function createSavepoint() - { - $this->getPdo()->exec( - $this->queryGrammar->compileSavepoint('trans'.($this->transactions + 1)) - ); - } - /** * Handle an exception from a transaction beginning. * @@ -197,7 +183,7 @@ protected function handleBeginTransactionException(Throwable $e) * * @throws \Throwable */ - public function commit() + public function commitTransaction() { if ($this->transactionLevel() == 1) { $this->fireConnectionEvent('committing'); @@ -216,6 +202,22 @@ public function commit() $this->fireConnectionEvent('committed'); } + /** + * Create a save point within the database. + * + * @return void + * + * @throws \Throwable + */ + protected function createSavepoint() + { + // we do not use ManagesSavepoint::savepoint() here because this is an internally created savepoint + // used as part of nested transaction emulation and therefore not stored in the savepoints array + $this->getPdo()->exec( + $this->queryGrammar->compileSavepoint('trans'.($this->transactions + 1)) + ); + } + /** * Handle an exception encountered when committing a transaction. * @@ -249,7 +251,7 @@ protected function handleCommitTransactionException(Throwable $e, $currentAttemp * * @throws \Throwable */ - public function rollBack($toLevel = null) + public function rollbackTransaction($toLevel = null) { // We allow developers to rollback to a certain transaction level. We will verify // that this given transaction level is valid before attempting to rollback to @@ -298,7 +300,9 @@ protected function performRollBack($toLevel) } } elseif ($this->queryGrammar->supportsSavepoints()) { $this->getPdo()->exec( - $this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1)) + // we do not use ManagesSavepoints::rollbackToSavepoint() here because this is an internally created + // savepoint used as part of nested transaction emulation and therefore not stored in the savepoints array + $this->queryGrammar->compileRollbackToSavepoint('trans'.($toLevel + 1)) ); } } diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index f0474f0fd7de..1faf4a336b4a 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -30,6 +30,7 @@ class Connection implements ConnectionInterface use DetectsConcurrencyErrors, DetectsLostConnections, Concerns\ManagesTransactions, + Concerns\ManagesSavepoints, InteractsWithTime, Macroable; @@ -228,6 +229,8 @@ public function __construct($pdo, $database = '', $tablePrefix = '', array $conf $this->useDefaultQueryGrammar(); $this->useDefaultPostProcessor(); + + $this->initializeSavepointManagement(); } /** @@ -1458,6 +1461,8 @@ public function setEventDispatcher(Dispatcher $events) { $this->events = $events; + $this->initializeSavepointManagement(); + return $this; } @@ -1680,4 +1685,29 @@ public static function getResolver($driver) { return static::$resolvers[$driver] ?? null; } + + /** + * Commit the active database transaction. + * + * @return void + * + * @deprecated Use commitTransaction() instead + */ + public function commit() + { + return $this->commitTransaction(); + } + + /** + * Rollback the active database transaction. + * + * @param int|null $toLevel + * @return void + * + * @deprecated Use rollbackTransaction() instead + */ + public function rollBack($toLevel = null) + { + return $this->rollbackTransaction($toLevel); + } } diff --git a/src/Illuminate/Database/ConnectionInterface.php b/src/Illuminate/Database/ConnectionInterface.php index 22f866b43763..66acd8911f7f 100755 --- a/src/Illuminate/Database/ConnectionInterface.php +++ b/src/Illuminate/Database/ConnectionInterface.php @@ -151,14 +151,14 @@ public function beginTransaction(); * * @return void */ - public function commit(); + public function commitTransaction(); /** * Rollback the active database transaction. * * @return void */ - public function rollBack(); + public function rollbackTransaction(); /** * Get the number of active transactions. @@ -167,6 +167,54 @@ public function rollBack(); */ public function transactionLevel(); + /** + * Create a savepoint within the current transaction. Optionally provide a callback + * to be executed following creation of the savepoint. If the callback fails, the transaction + * will be rolled back to the savepoint. The savepoint will be released after the callback + * has been executed. + */ + public function savepoint(string $name, ?callable $callback = null): mixed; + + /** + * Release a savepoint in the database. + */ + public function releaseSavepoint(string $name, ?int $level = null): void; + + /** + * Release all savepoints in the database. + */ + public function purgeSavepoints(?int $level = null): void; + + /** + * Rollback to a savepoint in the database. + */ + public function rollbackToSavepoint(string $name): void; + + /** + * Determine if a savepoint exists in the database. + */ + public function hasSavepoint(string $name): bool; + + /** + * Get the names of all savepoints in the database. + */ + public function getSavepoints(): array; + + /** + * Get the current savepoint name. + */ + public function getCurrentSavepoint(): ?string; + + /** + * Determine if the connection supports releasing savepoints. + */ + public function supportsSavepoints(): bool; + + /** + * Determine if the connection releases savepoints. + */ + public function supportsSavepointRelease(): bool; + /** * Execute the given callback in "dry run" mode. * diff --git a/src/Illuminate/Database/Events/SavepointCreated.php b/src/Illuminate/Database/Events/SavepointCreated.php new file mode 100644 index 000000000000..34be09d04683 --- /dev/null +++ b/src/Illuminate/Database/Events/SavepointCreated.php @@ -0,0 +1,15 @@ +wrapValue($name); } /** * Compile the SQL statement to execute a savepoint rollback. - * - * @param string $name - * @return string */ - public function compileSavepointRollBack($name) + public function compileRollbackToSavepoint(string $name): string + { + return 'ROLLBACK TO SAVEPOINT '.$this->wrapValue($name); + } + + /** + * Compile the SQL statement to execute a savepoint release. + */ + public function compileReleaseSavepoint(string $name): string { - return 'ROLLBACK TO SAVEPOINT '.$name; + return 'RELEASE SAVEPOINT '.$this->wrapValue($name); } /** diff --git a/src/Illuminate/Database/Query/Grammars/MariaDbGrammar.php b/src/Illuminate/Database/Query/Grammars/MariaDbGrammar.php index da51125b9774..bcdaf8eca2e3 100755 --- a/src/Illuminate/Database/Query/Grammars/MariaDbGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/MariaDbGrammar.php @@ -53,4 +53,44 @@ public function useLegacyGroupLimit(Builder $query) { return false; } + + /** + * Determine if the connection supports savepoints. + */ + public function supportsSavepoints(): bool + { + return true; + } + + /** + * Determine if the connection supports releasing savepoints. + */ + public function supportsSavepointRelease(): bool + { + return true; + } + + /** + * Compile the SQL statement to define a savepoint. + */ + public function compileSavepoint(string $name): string + { + return 'SAVEPOINT '.$this->wrapValue($name); + } + + /** + * Compile the SQL statement to execute a savepoint rollback. + */ + public function compileRollbackToSavepoint(string $name): string + { + return 'ROLLBACK TO SAVEPOINT '.$this->wrapValue($name); + } + + /** + * Compile the SQL statement to execute a savepoint release. + */ + public function compileReleaseSavepoint(string $name): string + { + return 'RELEASE SAVEPOINT '.$this->wrapValue($name); + } } diff --git a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php index 6c4c2c09e212..8436622a2be6 100755 --- a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php @@ -496,6 +496,46 @@ public function compileThreadCount() return 'select variable_value as `Value` from performance_schema.session_status where variable_name = \'threads_connected\''; } + /** + * Determine if the connection supports savepoints. + */ + public function supportsSavepoints(): bool + { + return true; + } + + /** + * Determine if the connection supports releasing savepoints. + */ + public function supportsSavepointRelease(): bool + { + return true; + } + + /** + * Compile the SQL statement to define a savepoint. + */ + public function compileSavepoint(string $name): string + { + return 'SAVEPOINT '.$this->wrapValue($name); + } + + /** + * Compile the SQL statement to execute a savepoint rollback. + */ + public function compileRollbackToSavepoint(string $name): string + { + return 'ROLLBACK TO SAVEPOINT '.$this->wrapValue($name); + } + + /** + * Compile the SQL statement to execute a savepoint release. + */ + public function compileReleaseSavepoint(string $name): string + { + return 'RELEASE SAVEPOINT '.$this->wrapValue($name); + } + /** * Wrap a single string in keyword identifiers. * diff --git a/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php b/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php index 9207fe54565f..a36793fd88f5 100755 --- a/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php @@ -843,4 +843,44 @@ public static function cascadeOnTrucate(bool $value = true) { self::cascadeOnTruncate($value); } + + /** + * Determine if the connection supports savepoints. + */ + public function supportsSavepoints(): bool + { + return true; + } + + /** + * Determine if the connection supports releasing savepoints. + */ + public function supportsSavepointRelease(): bool + { + return true; + } + + /** + * Compile the SQL statement to define a savepoint. + */ + public function compileSavepoint(string $name): string + { + return 'SAVEPOINT '.$this->wrapValue($name); + } + + /** + * Compile the SQL statement to execute a savepoint rollback. + */ + public function compileRollbackToSavepoint(string $name): string + { + return 'ROLLBACK TO SAVEPOINT '.$this->wrapValue($name); + } + + /** + * Compile the SQL statement to execute a savepoint release. + */ + public function compileReleaseSavepoint(string $name): string + { + return 'RELEASE SAVEPOINT '.$this->wrapValue($name); + } } diff --git a/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php b/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php index 9fb8d8a31589..49d433b1bd2c 100755 --- a/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php @@ -449,6 +449,46 @@ public function compileTruncate(Builder $query) ]; } + /** + * Determine if the connection supports savepoints. + */ + public function supportsSavepoints(): bool + { + return true; + } + + /** + * Determine if the connection supports releasing savepoints. + */ + public function supportsSavepointRelease(): bool + { + return true; + } + + /** + * Compile the SQL statement to define a savepoint. + */ + public function compileSavepoint(string $name): string + { + return 'SAVEPOINT '.$this->wrapValue($name); + } + + /** + * Compile a rollback to savepoint statement into SQL. + */ + public function compileRollbackToSavepoint(string $name): string + { + return 'ROLLBACK TO '.$this->wrapValue($name); + } + + /** + * Compile a savepoint release statement into SQL. + */ + public function compileReleaseSavepoint(string $name): string + { + return 'RELEASE SAVEPOINT '.$this->wrapValue($name); + } + /** * Wrap the given JSON selector. * diff --git a/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php b/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php index c5e91c50e1bf..4ad53ba1ec72 100755 --- a/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php @@ -7,6 +7,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use RuntimeException; class SqlServerGrammar extends Grammar { @@ -476,26 +477,46 @@ public function compileJoinLateral(JoinLateralClause $join, string $expression): return trim("{$type} apply {$expression}"); } + /** + * Determine if the connection supports savepoints. + */ + public function supportsSavepoints(): bool + { + return true; + } + + /** + * Determine if the connection supports releasing savepoints. + */ + public function supportsSavepointRelease(): bool + { + return false; + } + /** * Compile the SQL statement to define a savepoint. - * - * @param string $name - * @return string */ - public function compileSavepoint($name) + public function compileSavepoint(string $name): string { - return 'SAVE TRANSACTION '.$name; + return 'SAVE TRANSACTION '.$this->wrapValue($name); } /** * Compile the SQL statement to execute a savepoint rollback. - * - * @param string $name - * @return string */ - public function compileSavepointRollBack($name) + public function compileRollbackToSavepoint(string $name): string + { + return 'ROLLBACK TRANSACTION '.$this->wrapValue($name); + } + + /** + * Compile the SQL statement to execute a savepoint release. + */ + public function compileReleaseSavepoint(string $name): string { - return 'ROLLBACK TRANSACTION '.$name; + throw new RuntimeException( + 'SQL Server does not support releasing savepoints.' + ); } /** diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 1e6fe52bfe16..f1518632da1d 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -133,6 +133,26 @@ public function getSchemaState(?Filesystem $files = null, ?callable $processFact throw new RuntimeException('Schema dumping is not supported when using SQL Server.'); } + /** + * Release a savepoint. + * + * @throws Throwable + */ + public function releaseSavepoint(string $name, ?int $level = null): void + { + $this->savepointReleaseUnsupportedError(); + } + + /** + * Purge all savepoints. + * + * @throws Throwable + */ + public function purgeSavepoints(?int $level = null): void + { + $this->savepointReleaseUnsupportedError(); + } + /** * Get the default post processor instance. * diff --git a/src/Illuminate/Foundation/Testing/DatabaseTransactions.php b/src/Illuminate/Foundation/Testing/DatabaseTransactions.php index 0eaa2f079457..3d1c880c58e9 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseTransactions.php +++ b/src/Illuminate/Foundation/Testing/DatabaseTransactions.php @@ -33,7 +33,7 @@ public function beginDatabaseTransaction() $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); - $connection->rollBack(); + $connection->rollbackTransaction(); $connection->setEventDispatcher($dispatcher); $connection->disconnect(); } diff --git a/src/Illuminate/Foundation/Testing/RefreshDatabase.php b/src/Illuminate/Foundation/Testing/RefreshDatabase.php index f039c510f8c2..0079af90e904 100644 --- a/src/Illuminate/Foundation/Testing/RefreshDatabase.php +++ b/src/Illuminate/Foundation/Testing/RefreshDatabase.php @@ -141,7 +141,7 @@ public function beginDatabaseTransaction() RefreshDatabaseState::$migrated = false; } - $connection->rollBack(); + $connection->rollbackTransaction(); $connection->setEventDispatcher($dispatcher); $connection->disconnect(); } diff --git a/src/Illuminate/Support/Facades/DB.php b/src/Illuminate/Support/Facades/DB.php index 90990046ed69..5394b608cb7d 100644 --- a/src/Illuminate/Support/Facades/DB.php +++ b/src/Illuminate/Support/Facades/DB.php @@ -114,6 +114,14 @@ * @method static void rollBack(int|null $toLevel = null) * @method static int transactionLevel() * @method static void afterCommit(callable $callback) + * @method static mixed savepoint(string $name, callable|null $callback = null) + * @method static void rollbackToSavepoint(string $name) + * @method static void releaseSavepoint(string $name, int|null $level = null) + * @method static void purgeSavepoints(int|null $level = null) + * @method static array getSavepoints() + * @method static string|null getCurrentSavepoint() + * @method static bool supportsSavepoints() + * @method static bool supportsSavepointRelease() * * @see \Illuminate\Database\DatabaseManager */ diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 164da72f6a58..1fbd5b41e59a 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -274,7 +274,7 @@ public function testCommittedFiresEventsIfSet() $connection->expects($this->any())->method('getName')->willReturn('name'); $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); $events->shouldReceive('dispatch')->once()->with(m::type(TransactionCommitted::class)); - $connection->commit(); + $connection->commitTransaction(); } public function testCommittingFiresEventsIfSet() @@ -286,7 +286,7 @@ public function testCommittingFiresEventsIfSet() $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); $events->shouldReceive('dispatch')->once()->with(m::type(TransactionCommitting::class)); $events->shouldReceive('dispatch')->once()->with(m::type(TransactionCommitted::class)); - $connection->commit(); + $connection->commitTransaction(); } public function testRollBackedFiresEventsIfSet() @@ -297,7 +297,7 @@ public function testRollBackedFiresEventsIfSet() $connection->beginTransaction(); $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); $events->shouldReceive('dispatch')->once()->with(m::type(TransactionRolledBack::class)); - $connection->rollBack(); + $connection->rollbackTransaction(); } public function testRedundantRollBackFiresNoEvent() @@ -307,7 +307,7 @@ public function testRedundantRollBackFiresNoEvent() $connection->expects($this->any())->method('getName')->willReturn('name'); $connection->setEventDispatcher($events = m::mock(Dispatcher::class)); $events->shouldNotReceive('dispatch'); - $connection->rollBack(); + $connection->rollbackTransaction(); } public function testTransactionMethodRunsSuccessfully() diff --git a/tests/Database/DatabaseSavepointsTest.php b/tests/Database/DatabaseSavepointsTest.php new file mode 100644 index 000000000000..72dcbd335172 --- /dev/null +++ b/tests/Database/DatabaseSavepointsTest.php @@ -0,0 +1,1300 @@ +connection(); + + $connection->beginTransaction(); + + $connection->savepoint('savepoint'); + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint').'"', $connection->getPdo()->executed); + $this->assertTrue($connection->hasSavepoint('savepoint')); + } + + /** + * @throws Throwable + */ + public function test_savepoint_creation_with_callback(): void + { + $connection = $this->connection( + ['transactionLevel'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + } + ); + + $result = $connection->savepoint('savepoint', static fn (): string => 'callback_invoked'); + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint').'"', $connection->getPdo()->executed); + $this->assertEquals('callback_invoked', $result); + } + + /** + * @throws Throwable + */ + public function test_savepoint_callback_success_releases_savepoint(): void + { + $connection = $this->connection( + ['transactionLevel', 'supportsSavepointRelease'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + $connection->method('supportsSavepointRelease')->willReturn(true); + } + ); + + $result = $connection->savepoint('savepoint', static fn (): string => 'callback_invoked'); + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint').'"', $connection->getPdo()->executed); + $this->assertContains('RELEASE SAVEPOINT "'.bin2hex('savepoint').'"', $connection->getPdo()->executed); + $this->assertEmpty($connection->savepoints(1) ?? []); + $this->assertEquals('callback_invoked', $result); + } + + /** + * @throws Throwable + */ + public function test_savepoint_callback_failure_throws_exception(): void + { + $connection = $this->connection( + ['transactionLevel'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + } + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('callback_failed'); + + $connection->savepoint( + 'savepoint', + static function (): never { + throw new RuntimeException('callback_failed'); + } + ); + } + + /** + * @throws Throwable + */ + public function test_savepoint_callback_failure_performs_rollback(): void + { + $connection = $this->connection( + ['transactionLevel'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + } + ); + + try { + $connection->savepoint('savepoint', static function (): never { + throw new RuntimeException('callback_failed'); + }); + } catch (Exception) { + // expected + } + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint').'"', $connection->getPdo()->executed); + $this->assertContains('ROLLBACK TO SAVEPOINT "'.bin2hex('savepoint').'"', $connection->getPdo()->executed); + } + + /** + * @throws Throwable + */ + public function test_rollback_to_savepoint(): void + { + $connection = $this->connection( + ['transactionLevel'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + } + ); + + $connection->savepoint('savepoint1'); + $connection->savepoint('savepoint2'); + $connection->rollbackToSavepoint('savepoint1'); + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint1').'"', $connection->getPdo()->executed); + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint2').'"', $connection->getPdo()->executed); + $this->assertContains('ROLLBACK TO SAVEPOINT "'.bin2hex('savepoint1').'"', $connection->getPdo()->executed); + $this->assertEquals(['savepoint1'], $connection->savepoints(1) ?? []); + } + + /** + * @throws Throwable + */ + public function test_release_savepoint(): void + { + $connection = $this->connection( + ['transactionLevel', 'supportsSavepointRelease'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + $connection->method('supportsSavepointRelease')->willReturn(true); + } + ); + + $connection->savepoint('savepoint1'); + $connection->savepoint('savepoint2'); + $connection->releaseSavepoint('savepoint1'); + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint1').'"', $connection->getPdo()->executed); + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint2').'"', $connection->getPdo()->executed); + $this->assertContains('RELEASE SAVEPOINT "'.bin2hex('savepoint1').'"', $connection->getPdo()->executed); + $this->assertEquals(['savepoint2'], $connection->savepoints(1) ?? []); + } + + /** + * @throws Throwable + */ + public function test_purge_savepoints(): void + { + $connection = $this->connection( + ['transactionLevel', 'supportsSavepointRelease'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + $connection->method('supportsSavepointRelease')->willReturn(true); + } + ); + + $connection->savepoint('savepoint1'); + $connection->savepoint('savepoint2'); + $connection->savepoint('savepoint3'); + $connection->purgeSavepoints(); + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint1').'"', $connection->getPdo()->executed); + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint2').'"', $connection->getPdo()->executed); + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint3').'"', $connection->getPdo()->executed); + $this->assertContains('RELEASE SAVEPOINT "'.bin2hex('savepoint1').'"', $connection->getPdo()->executed); + $this->assertContains('RELEASE SAVEPOINT "'.bin2hex('savepoint2').'"', $connection->getPdo()->executed); + $this->assertContains('RELEASE SAVEPOINT "'.bin2hex('savepoint3').'"', $connection->getPdo()->executed); + $this->assertEmpty($connection->savepoints(1) ?? []); + } + + /** + * @throws Throwable + */ + public function test_savepoint_creation_throws_exception_when_savepoints_unsupported(): void + { + $connection = $this->connection( + ['supportsSavepoints'], + static function ($connection): void { + $connection->method('supportsSavepoints')->willReturn(false); + }); + + $connection->beginTransaction(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This database connection does not support creating savepoints.'); + + $connection->savepoint('savepoint'); + } + + /** + * @throws Throwable + */ + public function test_savepoint_creation_throws_exception_when_outside_transaction(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot create savepoint outside of transaction.'); + + $this->connection()->savepoint('savepoint'); + } + + /** + * @throws Throwable + */ + public function test_savepoint_creation_throws_exception_when_duplicate_name(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + $connection->savepoint('duplicate'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Savepoint 'duplicate' already exists at position 0 in transaction level 1."); + + $connection->savepoint('duplicate'); + } + + /** + * @throws Throwable + */ + public function test_rollback_to_savepoint_throws_exception_when_unknown_savepoint(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Savepoint 'nonexistent' does not exist in transaction level 1."); + + $connection->rollbackToSavepoint('nonexistent'); + } + + /** + * @throws Throwable + */ + public function test_release_savepoint_throws_exception_when_release_unsupported(): void + { + $connection = $this->connection( + ['supportsSavepointRelease'], + static function ($connection): void { + $connection->method('supportsSavepointRelease')->willReturn(false); + }); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This database connection does not support releasing savepoints.'); + + $connection->releaseSavepoint('savepoint'); + } + + /** + * @throws Throwable + */ + public function test_release_savepoint_throws_exception_when_unknown_savepoint(): void + { + $connection = $this->connection( + ['supportsSavepointRelease'], + static function ($connection): void { + $connection->method('supportsSavepointRelease')->willReturn(true); + }); + + $connection->beginTransaction(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Savepoint 'nonexistent' does not exist in transaction level 1."); + + $connection->releaseSavepoint('nonexistent'); + } + + /** + * @throws Throwable + */ + public function test_purge_savepoints_throws_exception_when_purge_unsupported(): void + { + $connection = $this->connection( + ['supportsSavepointRelease'], + static function ($connection): void { + $connection->method('supportsSavepointRelease')->willReturn(false); + }); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This database connection does not support purging savepoints.'); + + $connection->purgeSavepoints(); + } + + /** + * @throws Throwable + */ + public function test_savepoint_creation_handles_pdo_failure(): void + { + $connection = $this->connection([], null, $this->pdo(TestPdo::FAILURE)); + + $connection->beginTransaction(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to create savepoint'); + + $connection->savepoint('savepoint'); + } + + /** + * @throws Throwable + */ + public function test_rollback_to_savepoint_handles_pdo_failure(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + + $connection = $this->connection( + [], + null, + tap( + $this->pdo(TestPdo::FAILURE), + static function ($pdo) use ($connection): void { + $pdo->executed = $connection->getPdo()->executed; + } + ) + ); + + $connection->beginTransaction(); + $connection->savepoints([1 => ['savepoint']]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to rollback to savepoint'); + + $connection->rollbackToSavepoint('savepoint'); + } + + /** + * @throws Throwable + */ + public function test_release_savepoint_handles_pdo_failure(): void + { + $connection = $this->connection( + ['supportsSavepointRelease'], + static function ($connection): void { + $connection->method('supportsSavepointRelease')->willReturn(true); + }); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + + $connection = $this->connection( + ['supportsSavepointRelease'], + static function ($connection): void { + $connection->method('supportsSavepointRelease')->willReturn(true); + }, + tap( + $this->pdo(TestPdo::FAILURE), + static function ($pdo) use ($connection): void { + $pdo->executed = $connection->getPdo()->executed; + } + ) + ); + + $connection->beginTransaction(); + $connection->savepoints([1 => ['savepoint']]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to release savepoint'); + + $connection->releaseSavepoint('savepoint'); + } + + /** + * @throws Throwable + */ + public function test_savepoint_created_event_fired(): void + { + $dispatcher = $this->dispatcher(); + + $connection = $this->connection( + ['getName'], + static function ($connection) use ($dispatcher): void { + $connection->method('getName')->willReturn('test_connection'); + $connection->setEventDispatcher($dispatcher); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint1'); + + $this->assertCount(1, $events = $dispatcher->fired()); + $this->assertInstanceOf(SavepointCreated::class, $event = Arr::first($events)); + $this->assertEquals('savepoint1', $event->savepoint); + $this->assertSame($connection, $event->connection); + $this->assertEquals('test_connection', $event->connectionName); + } + + /** + * @throws Throwable + */ + public function test_savepoint_released_event_fired(): void + { + $dispatcher = $this->dispatcher(); + + $connection = $this->connection( + ['getName', 'supportsSavepointRelease'], + static function ($connection) use ($dispatcher): void { + $connection->method('getName')->willReturn('test_connection'); + $connection->method('supportsSavepointRelease')->willReturn(true); + $connection->setEventDispatcher($dispatcher); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + $connection->releaseSavepoint('savepoint'); + + $this->assertCount(2, $events = $dispatcher->fired()); + $this->assertInstanceOf(SavepointReleased::class, $event = Arr::last($events)); + $this->assertEquals('savepoint', $event->savepoint); + $this->assertSame($connection, $event->connection); + $this->assertEquals('test_connection', $event->connectionName); + } + + /** + * @throws Throwable + */ + public function test_savepoint_rolled_back_event_fired(): void + { + $dispatcher = $this->dispatcher(); + + $connection = $this->connection( + ['getName'], + static function ($connection) use ($dispatcher): void { + $connection->method('getName')->willReturn('test_connection'); + $connection->setEventDispatcher($dispatcher); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint1'); + $connection->savepoint('savepoint2'); + $connection->savepoint('savepoint3'); + $connection->rollbackToSavepoint('savepoint1'); + + $this->assertCount(4, $events = $dispatcher->fired()); + $this->assertInstanceOf(SavepointRolledBack::class, $event = Arr::last($events)); + $this->assertEquals('savepoint1', $event->savepoint); + $this->assertEquals(['savepoint2', 'savepoint3'], array_values($event->releasedSavepoints)); + $this->assertSame($connection, $event->connection); + $this->assertEquals('test_connection', $event->connectionName); + } + + /** + * @throws Throwable + */ + public function test_savepoint_callback_success_fires_created_and_released_events(): void + { + $dispatcher = $this->dispatcher(); + + $connection = $this->connection( + ['getName', 'transactionLevel', 'supportsSavepointRelease'], + static function ($connection) use ($dispatcher): void { + $connection->method('getName')->willReturn('test_connection'); + $connection->method('transactionLevel')->willReturn(1); + $connection->method('supportsSavepointRelease')->willReturn(true); + $connection->setEventDispatcher($dispatcher); + } + ); + + $connection->savepoint('callback_test', static fn (): string => 'success'); + + $this->assertCount(2, $events = $dispatcher->fired()); + $this->assertInstanceOf(SavepointCreated::class, $creation = Arr::first($events)); + $this->assertEquals('callback_test', $creation->savepoint); + $this->assertInstanceOf(SavepointReleased::class, $release = Arr::last($events)); + $this->assertEquals('callback_test', $release->savepoint); + } + + /** + * @throws Throwable + */ + public function test_savepoint_callback_failure_fires_created_and_rolled_back_events(): void + { + $dispatcher = $this->dispatcher(); + + $connection = $this->connection( + ['getName', 'transactionLevel'], + static function ($connection) use ($dispatcher): void { + $connection->method('getName')->willReturn('test_connection'); + $connection->method('transactionLevel')->willReturn(1); + $connection->setEventDispatcher($dispatcher); + } + ); + + try { + $connection->savepoint( + 'savepoint', + static function (): never { + throw new RuntimeException('callback_failed'); + } + ); + } catch (Exception) { + // expected + } + + $events = array_values( + array_filter( + $dispatcher->fired(), + static fn ($event): bool => $event instanceof SavepointCreated || $event instanceof SavepointRolledBack + ) + ); + + $this->assertCount(2, $events); + $this->assertInstanceOf(SavepointCreated::class, $first = Arr::first($events)); + $this->assertEquals('savepoint', $first->savepoint); + $this->assertInstanceOf(SavepointRolledBack::class, $last = Arr::last($events)); + $this->assertEquals('savepoint', $last->savepoint); + $this->assertEquals([], $last->releasedSavepoints); + } + + /** + * @throws Throwable + */ + public function test_has_savepoint(): void + { + $connection = $this->connection( + ['transactionLevel', 'supportsSavepointRelease'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + $connection->method('supportsSavepointRelease')->willReturn(true); + }); + + $this->assertFalse($connection->hasSavepoint('nonexistent')); + + $connection->savepoint('savepoint1'); + + $this->assertTrue($connection->hasSavepoint('savepoint1')); + $this->assertFalse($connection->hasSavepoint('savepoint2')); + + $connection->savepoint('savepoint2'); + + $this->assertTrue($connection->hasSavepoint('savepoint1')); + $this->assertTrue($connection->hasSavepoint('savepoint2')); + + $connection->releaseSavepoint('savepoint1'); + + $this->assertFalse($connection->hasSavepoint('savepoint1')); + $this->assertTrue($connection->hasSavepoint('savepoint2')); + } + + /** + * @throws Throwable + */ + public function test_get_savepoints(): void + { + $connection = $this->connection( + ['transactionLevel', 'supportsSavepointRelease'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + $connection->method('supportsSavepointRelease')->willReturn(true); + }); + + $this->assertEquals([], $connection->getSavepoints()); + + $connection->savepoint('savepoint1'); + + $this->assertEquals(['savepoint1'], $connection->getSavepoints()); + + $connection->savepoint('savepoint2'); + + $this->assertEquals(['savepoint1', 'savepoint2'], $connection->getSavepoints()); + + $connection->releaseSavepoint('savepoint1'); + + $this->assertEquals(['savepoint2'], $connection->getSavepoints()); + + $connection->savepoint('savepoint3'); + + $this->assertEquals(['savepoint2', 'savepoint3'], $connection->getSavepoints()); + } + + /** + * @throws Throwable + */ + public function test_get_current_savepoint(): void + { + $connection = $this->connection( + ['transactionLevel', 'supportsSavepointRelease'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + $connection->method('supportsSavepointRelease')->willReturn(true); + } + ); + + $this->assertNull($connection->getCurrentSavepoint()); + + $connection->savepoint('savepoint1'); + + $this->assertEquals('savepoint1', $connection->getCurrentSavepoint()); + + $connection->savepoint('savepoint2'); + + $this->assertEquals('savepoint2', $connection->getCurrentSavepoint()); + + $connection->releaseSavepoint('savepoint2'); + + $this->assertEquals('savepoint1', $connection->getCurrentSavepoint()); + + $connection->releaseSavepoint('savepoint1'); + + $this->assertNull($connection->getCurrentSavepoint()); + } + + /** + * @throws Throwable + */ + public function test_savepoint_stack_management(): void + { + $connection = $this->connection( + ['transactionLevel'], + static function ($connection): void { + $connection->method('transactionLevel')->willReturn(1); + } + ); + + $connection->savepoint('level1savepoint'); + $connection->savepoint('level2savepoint'); + $connection->savepoint('level3savepoint'); + + $this->assertEquals( + ['level1savepoint', 'level2savepoint', 'level3savepoint'], + $connection->savepoints(1) + ); + $this->assertEquals(['level1savepoint', 'level2savepoint', 'level3savepoint'], $connection->getSavepoints()); + $this->assertEquals('level3savepoint', $connection->getCurrentSavepoint()); + } + + /** + * @throws Throwable + */ + public function test_nested_transaction_savepoints(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + $connection->savepoint('level1savepoint1'); + $connection->savepoint('level1savepoint2'); + + $this->assertEquals(['level1savepoint1', 'level1savepoint2'], $connection->savepoints(1)); + + $connection->beginTransaction(); + $connection->savepoint('level2savepoint1'); + + $this->assertEquals(['level1savepoint1', 'level1savepoint2'], $connection->savepoints(1)); + $this->assertEquals(['level2savepoint1'], $connection->savepoints(2)); + $this->assertEquals(['level2savepoint1'], $connection->getSavepoints()); + $this->assertEquals('level2savepoint1', $connection->getCurrentSavepoint()); + } + + /** + * @throws Throwable + */ + public function test_savepoint_cleanup_on_commit(): void + { + $connection = $this->connection( + ['supportsSavepointRelease'], + static function ($connection): void { + $connection->method('supportsSavepointRelease')->willReturn(false); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint1'); + $connection->savepoint('savepoint2'); + + $this->assertEquals(['savepoint1', 'savepoint2'], $connection->savepoints(1)); + + $connection->commitTransaction(); + $connection->syncSavepoints(); + + $this->assertEmpty($connection->savepoints()); + } + + /** + * @throws Throwable + */ + public function test_savepoint_cleanup_on_rollback(): void + { + $connection = $this->connection( + ['supportsSavepointRelease'], + static function ($connection): void { + $connection->method('supportsSavepointRelease')->willReturn(false); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint1'); + $connection->savepoint('savepoint2'); + + $this->assertEquals(['savepoint1', 'savepoint2'], $connection->savepoints(1)); + + $connection->rollbackTransaction(); + $connection->syncSavepoints(); + + $this->assertEmpty($connection->savepoints()); + } + + /** + * @throws Throwable + */ + public function test_savepoint_sync_events(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + + $connection->savepoint('savepoint1'); + $connection->savepoint('savepoint2'); + + $this->assertEquals(['savepoint1', 'savepoint2'], $connection->savepoints(1)); + + $connection->beginTransaction(); + $connection->syncTransactionBeginning(); + + $this->assertEquals(['savepoint1', 'savepoint2'], $connection->savepoints(1)); + $this->assertEquals([], $connection->savepoints(2)); + + $connection->beginTransaction(); + $connection->syncTransactionCommitted(); + + $this->assertEquals(['savepoint1', 'savepoint2'], $connection->savepoints(1)); + $this->assertEmpty($connection->savepoints(2)); + } + + /** + * @throws Throwable + */ + public function test_user_savepoints_isolated_from_internal_trans_savepoints(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + $connection->savepoint('user_savepoint'); + + $this->assertEquals(['user_savepoint'], $connection->getSavepoints()); + + $connection->beginTransaction(); + + $this->assertEmpty($connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('user_savepoint')); + $this->assertContains('SAVEPOINT "trans2"', $connection->getPdo()->executed); + + $connection->savepoint('trans1'); + + $this->assertEquals(['trans1'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('trans1')); + $this->assertFalse($connection->hasSavepoint('user_savepoint')); + + $connection->commitTransaction(); + + $this->assertEquals(['user_savepoint'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('user_savepoint')); + $this->assertFalse($connection->hasSavepoint('trans1')); + } + + /** + * @throws Throwable + */ + public function test_internal_savepoints_not_visible_to_user_methods(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + $connection->savepoint('level1savepoint'); + $connection->beginTransaction(); + $connection->beginTransaction(); + + $this->assertContains('SAVEPOINT "trans2"', $connection->getPdo()->executed); + $this->assertContains('SAVEPOINT "trans3"', $connection->getPdo()->executed); + + $this->assertFalse($connection->hasSavepoint('trans2')); + $this->assertFalse($connection->hasSavepoint('trans3')); + $this->assertNotContains('trans2', $connection->getSavepoints()); + $this->assertNotContains('trans3', $connection->getSavepoints()); + + $connection->savepoint('level3savepoint'); + + $this->assertEquals(['level3savepoint'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('level3savepoint')); + + $connection->rollbackTransaction(); + $connection->rollbackTransaction(); + + $this->assertEquals(['level1savepoint'], $connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('level3savepoint')); + } + + /** + * @throws Throwable + */ + public function test_user_savepoints_work_with_nested_transactions(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + $connection->savepoint('level1savepoint'); + + $this->assertEquals(['level1savepoint'], $connection->getSavepoints()); + + $connection->beginTransaction(); + + $this->assertEmpty($connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('level1savepoint')); + $this->assertContains('SAVEPOINT "trans2"', $connection->getPdo()->executed); + $this->assertContains('SAVEPOINT "'.bin2hex('level1savepoint').'"', $connection->getPdo()->executed); + + $connection->savepoint('level2savepoint'); + + $this->assertEquals(['level2savepoint'], $connection->getSavepoints()); + + $connection->rollbackTransaction(); + + $this->assertEquals(['level1savepoint'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('level1savepoint')); + $this->assertFalse($connection->hasSavepoint('level2savepoint')); + + $connection->rollbackToSavepoint('level1savepoint'); + + $this->assertTrue($connection->hasSavepoint('level1savepoint')); + } + + /** + * @throws Throwable + */ + public function test_complex_isolation_with_mixed_savepoints_and_transactions(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + $connection->savepoint('level1savepoint'); + $connection->savepoint('trans1'); + $connection->savepoint('trans2'); + $connection->beginTransaction(); + + $this->assertContains('SAVEPOINT "trans2"', $connection->getPdo()->executed); + $this->assertEmpty($connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('level1savepoint')); + $this->assertFalse($connection->hasSavepoint('trans1')); + $this->assertFalse($connection->hasSavepoint('trans2')); + + $connection->savepoint('level2savepoint'); + $connection->savepoint('trans3'); + $connection->beginTransaction(); + + $this->assertContains('SAVEPOINT "trans3"', $connection->getPdo()->executed); + $this->assertEmpty($connection->getSavepoints()); + + $connection->savepoint('level3savepoint'); + + $this->assertEquals(['level3savepoint'], $connection->getSavepoints()); + + $connection->rollbackTransaction(); + + $this->assertEquals(['level2savepoint', 'trans3'], $connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('level3savepoint')); + + $connection->rollbackToSavepoint('level2savepoint'); + + $this->assertTrue($connection->hasSavepoint('level2savepoint')); + $this->assertFalse($connection->hasSavepoint('trans3')); + + $connection->commitTransaction(); + + $this->assertEquals(['level1savepoint', 'trans1', 'trans2'], $connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('level2savepoint')); + $this->assertTrue($connection->hasSavepoint('trans1')); + $this->assertTrue($connection->hasSavepoint('trans2')); + } + + /** + * @throws Throwable + */ + public function test_same_savepoint_name_across_transaction_levels(): void + { + $connection = $this->connection(); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + + $this->assertEquals(['savepoint'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('savepoint')); + + $connection->beginTransaction(); + + $this->assertEmpty($connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('savepoint')); + + $connection->savepoint('savepoint'); + + $this->assertEquals(['savepoint'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('savepoint')); + + $connection->beginTransaction(); + + $this->assertEmpty($connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('savepoint')); + + $connection->savepoint('savepoint'); + + $this->assertEquals(['savepoint'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('savepoint')); + + $connection->rollbackTransaction(); + + $this->assertEquals(['savepoint'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('savepoint')); + + $connection->rollbackTransaction(); + + $this->assertEquals(['savepoint'], $connection->getSavepoints()); + $this->assertTrue($connection->hasSavepoint('savepoint')); + + $connection->rollbackTransaction(); + + $this->assertEmpty($connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('savepoint')); + } + + /** + * @throws Throwable + */ + public function test_savepoints_with_transaction_helper(): void + { + $dispatcher = $this->dispatcher(); + + $connection = $this->connection( + ['getName'], + static function ($connection) use ($dispatcher): void { + $connection->method('getName')->willReturn('test_connection'); + $connection->setEventDispatcher($dispatcher); + } + ); + + $result = $connection->transaction( + function ($connection) { + $connection->savepoint('nested_savepoint'); + + $this->assertTrue($connection->hasSavepoint('nested_savepoint')); + $this->assertEquals(['nested_savepoint'], $connection->getSavepoints()); + + return $connection->savepoint('inner_work', static fn (): string => 'transaction_success'); + } + ); + + $events = array_filter( + $dispatcher->fired(), + static function ($event) { + return str_contains(get_class($event), 'Savepoint'); + } + ); + + $this->assertEquals('transaction_success', $result); + $this->assertEmpty($connection->getSavepoints()); + $this->assertGreaterThan(0, count($events)); + } + + /** + * @throws Throwable + */ + public function test_savepoint_cleanup_after_transaction_rollback(): void + { + $connection = $this->connection(); + + try { + $connection->transaction( + function ($connection) { + $connection->savepoint('cleaned1'); + $connection->savepoint('cleaned2'); + + $this->assertTrue($connection->hasSavepoint('cleaned1')); + $this->assertTrue($connection->hasSavepoint('cleaned2')); + $this->assertEquals(['cleaned1', 'cleaned2'], $connection->getSavepoints()); + + throw new RuntimeException('Force rollback'); + } + ); + } catch (RuntimeException) { + // expected + } + + $this->assertEmpty($connection->getSavepoints()); + $this->assertFalse($connection->hasSavepoint('cleaned1')); + $this->assertFalse($connection->hasSavepoint('cleaned2')); + $this->assertEquals(0, $connection->transactionLevel()); + } + + /** + * @throws Throwable + */ + public function test_mysql_grammar_savepoint_sql_generation(): void + { + $connection = $this->connection( + [], + static function ($connection): void { + $connection->setQueryGrammar(new MySqlGrammar($connection)); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + + $this->assertContains('SAVEPOINT `'.bin2hex('savepoint').'`', $connection->getPdo()->executed); + } + + /** + * @throws Throwable + */ + public function test_postgres_grammar_savepoint_sql_generation(): void + { + $connection = $this->connection( + [], + static function ($connection): void { + $connection->setQueryGrammar(new PostgresGrammar($connection)); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + $connection->rollbackToSavepoint('savepoint'); + + $executed = $connection->getPdo()->executed; + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint').'"', $executed); + $this->assertContains('ROLLBACK TO SAVEPOINT "'.bin2hex('savepoint').'"', $executed); + } + + /** + * @throws Throwable + */ + public function test_sqlite_grammar_savepoint_sql_generation(): void + { + $connection = $this->connection( + [], + static function ($connection): void { + $connection->setQueryGrammar(new SQLiteGrammar($connection)); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + $connection->rollbackToSavepoint('savepoint'); + + $executed = $connection->getPdo()->executed; + + $this->assertContains('SAVEPOINT "'.bin2hex('savepoint').'"', $executed); + $this->assertContains('ROLLBACK TO "'.bin2hex('savepoint').'"', $executed); + } + + /** + * @throws Throwable + */ + public function test_sqlserver_grammar_savepoint_sql_generation(): void + { + $connection = $this->connection( + [], + static function ($connection): void { + $connection->setQueryGrammar(new SqlServerGrammar($connection)); + } + ); + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + $connection->rollbackToSavepoint('savepoint'); + + $executed = $connection->getPdo()->executed; + + $this->assertContains('SAVE TRANSACTION ['.bin2hex('savepoint').']', $executed); + $this->assertContains('ROLLBACK TRANSACTION ['.bin2hex('savepoint').']', $executed); + } + + /** + * @throws Throwable + */ + public function test_sqlserver_grammar_savepoint_release_throws_exception(): void + { + $connection = $this->connection( + ['supportsSavepointRelease'], + static function ($connection): void { + $connection->method('supportsSavepointRelease')->willReturn(false); + $connection->setQueryGrammar(new SqlServerGrammar($connection)); + } + ); + + $connection->beginTransaction(); + $connection->savepoint('savepoint'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This database connection does not support releasing savepoints.'); + + $connection->releaseSavepoint('savepoint'); + } + + /** + * @throws Throwable + */ + public function test_grammar_savepoint_support_flags(): void + { + $connection = $this->connection(); + + $grammars = [ + 'mysql' => new MySqlGrammar($connection), + 'postgres' => new PostgresGrammar($connection), + 'sqlite' => new SQLiteGrammar($connection), + 'sqlserver' => new SqlServerGrammar($connection), + ]; + + foreach ($grammars as $type => $grammar) { + $this->assertTrue($grammar->supportsSavepoints(), "{$type} should support savepoints"); + + $type === 'sqlserver' + ? $this->assertFalse($grammar->supportsSavepointRelease(), "{$type} should not support savepoint release") + : $this->assertTrue($grammar->supportsSavepointRelease(), "{$type} should support savepoint release"); + } + } + + protected function connection(array $methods = [], ?callable $callback = null, ?TestPdo $pdo = null): TestConnection + { + return tap( + $this->getMockBuilder(TestConnection::class) + ->onlyMethods($methods) + ->setConstructorArgs([$pdo ?? $this->pdo()]) + ->getMock(), + static function ($mock) use ($callback): void { + if ($callback) { + $callback($mock); + } + } + ); + } + + protected function pdo($mode = TestPdo::SUCCESS): TestPdo + { + return new TestPdo($mode); + } + + protected function dispatcher(): TestDispatcher + { + return new TestDispatcher; + } +} + +class TestDispatcher extends Dispatcher +{ + public array $events = []; + + public function dispatch($event, $payload = [], $halt = false): void + { + $this->events[] = $event; + } + + public function fired(): array + { + return $this->events; + } +} + +class TestPdo extends PDO +{ + public const SUCCESS = 'success'; + + public const FAILURE = 'failure'; + + public array $executed = []; + + private string $mode; + + public function __construct($mode = self::SUCCESS) + { + $this->mode = $mode; + } + + public function exec($statement): int|false + { + $this->executed[] = $statement; + + return $this->mode === self::FAILURE ? false : 0; + } + + public function beginTransaction(): true + { + return true; + } + + public function commit(): true + { + return true; + } + + public function rollBack(): true + { + return true; + } +} + +class TestConnection extends Connection +{ + public function __construct(?TestPdo $pdo = null) + { + parent::__construct($pdo ?? new TestPdo); + + $this->useDefaultQueryGrammar(); + } + + public function getPdo(): PDO|TestPdo + { + return $this->pdo; + } + + public function savepoints(array|int|null $savepointsOrLevel = null): array + { + return match (true) { + is_array($savepointsOrLevel) => $this->savepoints = $savepointsOrLevel, + is_null($savepointsOrLevel) => $this->savepoints, + default => $this->savepoints[$savepointsOrLevel] ?? [], + }; + } + + public function syncSavepoints(): void + { + parent::syncSavepoints(); + } + + public function syncTransactionBeginning(): void + { + parent::syncTransactionBeginning(); + } + + public function syncTransactionCommitted(): void + { + parent::syncTransactionCommitted(); + } + + public function beginTransaction() + { + return ++$this->transactions === 1 + ? $this->pdo->beginTransaction() + : $this->pdo->exec($this->queryGrammar->compileSavepoint('trans'.$this->transactions)); + } + + public function commitTransaction(): void + { + if ($this->transactions === 1) { + $this->pdo->commit(); + } + + $this->transactions = max(0, $this->transactions - 1); + } + + public function rollbackTransaction($toLevel = null): void + { + $toLevel ??= $this->transactions - 1; + + $toLevel === 0 + ? $this->pdo->rollBack() + : $this->pdo->exec($this->queryGrammar->compileRollbackToSavepoint('trans'.($toLevel + 1))); + + $this->transactions = $toLevel; + } + + public function transactionLevel(): int + { + return $this->transactions; + } +} diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index 3affe52a8a00..ae635e9f32ca 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -95,7 +95,7 @@ public function testTransactionIsRecordedAndCommittedUsingTheSeparateMethods() $this->connection()->table('users')->where(['name' => 'zain'])->update([ 'value' => 2, ]); - $this->connection()->commit(); + $this->connection()->commitTransaction(); } public function testNestedTransactionIsRecordedAndCommitted() @@ -205,7 +205,7 @@ public function testTransactionIsRolledBackUsingSeparateMethods() 'value' => 2, ]); - $this->connection()->rollBack(); + $this->connection()->rollbackTransaction(); } public function testNestedTransactionsAreRolledBack()