Skip to content

Commit

Permalink
Fix incorrect transactional() handling when DB auto-rolled back the…
Browse files Browse the repository at this point in the history
… transaction

Let's get rid of There's no active transaction exception which occurs e.g. when using deferred constraints so the violation is checked at the end of the transaction and not during it.

- Do not rollback only on exceptions where we know the transaction is no longer active
- Introduce TransactionRolledBack exception
- Transform Oracle's "transaction rolled back" exception and use the underlying one that DBAL supports
  • Loading branch information
simPod committed Oct 31, 2024
1 parent 976f3f3 commit 7bf7c9f
Show file tree
Hide file tree
Showing 7 changed files with 711 additions and 12 deletions.
52 changes: 42 additions & 10 deletions src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@
use Doctrine\DBAL\Cache\QueryCacheProfile;
use Doctrine\DBAL\Driver\API\ExceptionConverter;
use Doctrine\DBAL\Driver\Connection as DriverConnection;
use Doctrine\DBAL\Driver\Exception as TheDriverException;
use Doctrine\DBAL\Driver\ServerInfoAwareConnection;
use Doctrine\DBAL\Driver\Statement as DriverStatement;
use Doctrine\DBAL\Event\TransactionBeginEventArgs;
use Doctrine\DBAL\Event\TransactionCommitEventArgs;
use Doctrine\DBAL\Event\TransactionRollBackEventArgs;
use Doctrine\DBAL\Exception\ConnectionLost;
use Doctrine\DBAL\Exception\DeadlockException;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Doctrine\DBAL\Exception\InvalidArgumentException;
use Doctrine\DBAL\Exception\TransactionRolledBack;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Query\Expression\ExpressionBuilder;
use Doctrine\DBAL\Query\QueryBuilder;
Expand Down Expand Up @@ -1283,16 +1288,36 @@ public function transactional(Closure $func)

try {
$res = $func($this);
$this->commit();

$successful = true;

return $res;
} finally {
if (! $successful) {
$this->rollBack();
}
}

$shouldRollback = true;
try {
$this->commit();

$shouldRollback = false;
} catch (TheDriverException $t) {
$convertedException = $this->handleDriverException($t, null);
$shouldRollback = ! (
$convertedException instanceof TransactionRolledBack
|| $convertedException instanceof UniqueConstraintViolationException
|| $convertedException instanceof ForeignKeyConstraintViolationException
|| $convertedException instanceof DeadlockException
);

Check warning on line 1311 in src/Connection.php

View check run for this annotation

Codecov / codecov/patch

src/Connection.php#L1304-L1311

Added lines #L1304 - L1311 were not covered by tests

throw $t;

Check warning on line 1313 in src/Connection.php

View check run for this annotation

Codecov / codecov/patch

src/Connection.php#L1313

Added line #L1313 was not covered by tests
} finally {
if ($shouldRollback) {
$this->rollBack();

Check warning on line 1316 in src/Connection.php

View check run for this annotation

Codecov / codecov/patch

src/Connection.php#L1316

Added line #L1316 was not covered by tests
}
}

return $res;
}

/**
Expand Down Expand Up @@ -1424,12 +1449,21 @@ public function commit()

$connection = $this->getWrappedConnection();

if ($this->transactionNestingLevel === 1) {
$result = $this->doCommit($connection);
} elseif ($this->nestTransactionsWithSavepoints) {
$this->releaseSavepoint($this->_getNestedTransactionSavePointName());
try {
if ($this->transactionNestingLevel === 1) {
$result = $this->doCommit($connection);
} elseif ($this->nestTransactionsWithSavepoints) {
$this->releaseSavepoint($this->_getNestedTransactionSavePointName());
}
} finally {
$this->updateTransactionStateAfterCommit();
}

return $result;
}

private function updateTransactionStateAfterCommit(): void
{
--$this->transactionNestingLevel;

$eventManager = $this->getEventManager();
Expand All @@ -1446,12 +1480,10 @@ public function commit()
}

if ($this->autoCommit !== false || $this->transactionNestingLevel !== 0) {
return $result;
return;
}

$this->beginTransaction();

return $result;
}

/**
Expand Down
34 changes: 33 additions & 1 deletion src/Driver/API/OCI/ExceptionConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Doctrine\DBAL\Driver\API\ExceptionConverter as ExceptionConverterInterface;
use Doctrine\DBAL\Driver\Exception;
use Doctrine\DBAL\Driver\OCI8\Exception\Error;
use Doctrine\DBAL\Driver\PDO\PDOException;
use Doctrine\DBAL\Exception\ConnectionException;
use Doctrine\DBAL\Exception\DatabaseDoesNotExist;
use Doctrine\DBAL\Exception\DatabaseObjectNotFoundException;
Expand All @@ -17,16 +19,30 @@
use Doctrine\DBAL\Exception\SyntaxErrorException;
use Doctrine\DBAL\Exception\TableExistsException;
use Doctrine\DBAL\Exception\TableNotFoundException;
use Doctrine\DBAL\Exception\TransactionRolledBack;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\DBAL\Query;

use function explode;
use function str_replace;

/** @internal */
final class ExceptionConverter implements ExceptionConverterInterface
{
/** @link http://www.dba-oracle.com/t_error_code_list.htm */
public function convert(Exception $exception, ?Query $query): DriverException
{
switch ($exception->getCode()) {
/** @psalm-var int|'HY000' $code */
$code = $exception->getCode();

Check warning on line 36 in src/Driver/API/OCI/ExceptionConverter.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/API/OCI/ExceptionConverter.php#L36

Added line #L36 was not covered by tests
/** @psalm-suppress NoInterfaceProperties */
if ($code === 'HY000' && isset($exception->errorInfo[1], $exception->errorInfo[2])) {
$errorInfo = $exception->errorInfo;
$exception = new PDOException($errorInfo[2], $errorInfo[1]);
$exception->errorInfo = $errorInfo;
$code = $exception->getCode();

Check warning on line 42 in src/Driver/API/OCI/ExceptionConverter.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/API/OCI/ExceptionConverter.php#L38-L42

Added lines #L38 - L42 were not covered by tests
}

switch ($code) {
case 1:
case 2299:
case 38911:
Expand Down Expand Up @@ -58,6 +74,22 @@ public function convert(Exception $exception, ?Query $query): DriverException
case 1918:
return new DatabaseDoesNotExist($exception, $query);

case 2091:

Check warning on line 77 in src/Driver/API/OCI/ExceptionConverter.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/API/OCI/ExceptionConverter.php#L77

Added line #L77 was not covered by tests
//ORA-02091: transaction rolled back
//ORA-00001: unique constraint (DOCTRINE.GH3423_UNIQUE) violated
[, $causeError] = explode("\n", $exception->getMessage(), 2);

Check warning on line 80 in src/Driver/API/OCI/ExceptionConverter.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/API/OCI/ExceptionConverter.php#L80

Added line #L80 was not covered by tests

[$causeCode] = explode(': ', $causeError, 2);
$code = (int) str_replace('ORA-', '', $causeCode);

Check warning on line 83 in src/Driver/API/OCI/ExceptionConverter.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/API/OCI/ExceptionConverter.php#L82-L83

Added lines #L82 - L83 were not covered by tests

if ($exception instanceof PDOException) {
$why = $this->convert(new PDOException($causeError, $code, $exception), $query);

Check warning on line 86 in src/Driver/API/OCI/ExceptionConverter.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/API/OCI/ExceptionConverter.php#L85-L86

Added lines #L85 - L86 were not covered by tests
} else {
$why = $this->convert(new Error($causeError, null, $code), $query);

Check warning on line 88 in src/Driver/API/OCI/ExceptionConverter.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/API/OCI/ExceptionConverter.php#L88

Added line #L88 was not covered by tests
}

return new TransactionRolledBack($why, $exception, $query);

Check warning on line 91 in src/Driver/API/OCI/ExceptionConverter.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/API/OCI/ExceptionConverter.php#L91

Added line #L91 was not covered by tests

case 2289:
case 2443:
case 4080:
Expand Down
2 changes: 1 addition & 1 deletion src/Driver/OCI8/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public function beginTransaction(): bool

public function commit(): bool
{
if (! oci_commit($this->connection)) {
if (! @oci_commit($this->connection)) {

Check warning on line 145 in src/Driver/OCI8/Connection.php

View check run for this annotation

Codecov / codecov/patch

src/Driver/OCI8/Connection.php#L145

Added line #L145 was not covered by tests
throw Error::new($this->connection);
}

Expand Down
24 changes: 24 additions & 0 deletions src/Exception/TransactionRolledBack.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Doctrine\DBAL\Exception;

use Doctrine\DBAL\Driver\Exception as TheDriverException;
use Doctrine\DBAL\Query;

/** @psalm-immutable */
class TransactionRolledBack extends DriverException
{
private DriverException $why;

public function __construct(DriverException $why, TheDriverException $driverException, ?Query $query)

Check warning on line 13 in src/Exception/TransactionRolledBack.php

View check run for this annotation

Codecov / codecov/patch

src/Exception/TransactionRolledBack.php#L13

Added line #L13 was not covered by tests
{
parent::__construct($driverException, $query);

Check warning on line 15 in src/Exception/TransactionRolledBack.php

View check run for this annotation

Codecov / codecov/patch

src/Exception/TransactionRolledBack.php#L15

Added line #L15 was not covered by tests

$this->why = $why;

Check warning on line 17 in src/Exception/TransactionRolledBack.php

View check run for this annotation

Codecov / codecov/patch

src/Exception/TransactionRolledBack.php#L17

Added line #L17 was not covered by tests
}

public function why(): DriverException

Check warning on line 20 in src/Exception/TransactionRolledBack.php

View check run for this annotation

Codecov / codecov/patch

src/Exception/TransactionRolledBack.php#L20

Added line #L20 was not covered by tests
{
return $this->why;

Check warning on line 22 in src/Exception/TransactionRolledBack.php

View check run for this annotation

Codecov / codecov/patch

src/Exception/TransactionRolledBack.php#L22

Added line #L22 was not covered by tests
}
}
22 changes: 22 additions & 0 deletions tests/ConnectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,28 @@ public function rollBack(): void
self::assertSame('Original exception', $e->getPrevious()->getMessage());
}
}

/**
* We are not sure if this can happen in real life scenario
*/
public function testItFailsDuringCommitBeforeTouchingDb(): void
{
$connection = new class (['memory' => true], new Driver\SQLite3\Driver()) extends Connection {
public function commit(): void
{
throw new \Exception('Fail before touching the db');
}

public function rollBack(): void
{
throw new \Exception('Rollback got triggered');
}
};

$this->expectExceptionMessage('Rollback got triggered');
$connection->transactional(static function (): void {
});
}
}

interface ConnectDispatchEventListener
Expand Down
Loading

0 comments on commit 7bf7c9f

Please sign in to comment.