Skip to content

Commit ec90463

Browse files
committed
Fix incorrect transactional() handling when DB auto-rolled back the 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
1 parent 976f3f3 commit ec90463

7 files changed

+695
-12
lines changed

src/Connection.php

+42-10
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@
99
use Doctrine\DBAL\Cache\QueryCacheProfile;
1010
use Doctrine\DBAL\Driver\API\ExceptionConverter;
1111
use Doctrine\DBAL\Driver\Connection as DriverConnection;
12+
use Doctrine\DBAL\Driver\Exception as TheDriverException;
1213
use Doctrine\DBAL\Driver\ServerInfoAwareConnection;
1314
use Doctrine\DBAL\Driver\Statement as DriverStatement;
1415
use Doctrine\DBAL\Event\TransactionBeginEventArgs;
1516
use Doctrine\DBAL\Event\TransactionCommitEventArgs;
1617
use Doctrine\DBAL\Event\TransactionRollBackEventArgs;
1718
use Doctrine\DBAL\Exception\ConnectionLost;
19+
use Doctrine\DBAL\Exception\DeadlockException;
1820
use Doctrine\DBAL\Exception\DriverException;
21+
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
1922
use Doctrine\DBAL\Exception\InvalidArgumentException;
23+
use Doctrine\DBAL\Exception\TransactionRolledBack;
24+
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
2025
use Doctrine\DBAL\Platforms\AbstractPlatform;
2126
use Doctrine\DBAL\Query\Expression\ExpressionBuilder;
2227
use Doctrine\DBAL\Query\QueryBuilder;
@@ -1283,16 +1288,36 @@ public function transactional(Closure $func)
12831288

12841289
try {
12851290
$res = $func($this);
1286-
$this->commit();
12871291

12881292
$successful = true;
1289-
1290-
return $res;
12911293
} finally {
12921294
if (! $successful) {
12931295
$this->rollBack();
12941296
}
12951297
}
1298+
1299+
$shouldRollback = true;
1300+
try {
1301+
$this->commit();
1302+
1303+
$shouldRollback = false;
1304+
} catch (TheDriverException $t) {
1305+
$convertedException = $this->handleDriverException($t, null);
1306+
$shouldRollback = ! (
1307+
$convertedException instanceof TransactionRolledBack
1308+
|| $convertedException instanceof UniqueConstraintViolationException
1309+
|| $convertedException instanceof ForeignKeyConstraintViolationException
1310+
|| $convertedException instanceof DeadlockException
1311+
);
1312+
1313+
throw $t;
1314+
} finally {
1315+
if ($shouldRollback) {
1316+
$this->rollBack();
1317+
}
1318+
}
1319+
1320+
return $res;
12961321
}
12971322

12981323
/**
@@ -1424,12 +1449,21 @@ public function commit()
14241449

14251450
$connection = $this->getWrappedConnection();
14261451

1427-
if ($this->transactionNestingLevel === 1) {
1428-
$result = $this->doCommit($connection);
1429-
} elseif ($this->nestTransactionsWithSavepoints) {
1430-
$this->releaseSavepoint($this->_getNestedTransactionSavePointName());
1452+
try {
1453+
if ($this->transactionNestingLevel === 1) {
1454+
$result = $this->doCommit($connection);
1455+
} elseif ($this->nestTransactionsWithSavepoints) {
1456+
$this->releaseSavepoint($this->_getNestedTransactionSavePointName());
1457+
}
1458+
} finally {
1459+
$this->updateTransactionStateAfterCommit();
14311460
}
14321461

1462+
return $result;
1463+
}
1464+
1465+
private function updateTransactionStateAfterCommit(): void
1466+
{
14331467
--$this->transactionNestingLevel;
14341468

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

14481482
if ($this->autoCommit !== false || $this->transactionNestingLevel !== 0) {
1449-
return $result;
1483+
return;
14501484
}
14511485

14521486
$this->beginTransaction();
1453-
1454-
return $result;
14551487
}
14561488

14571489
/**

src/Driver/API/OCI/ExceptionConverter.php

+33-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use Doctrine\DBAL\Driver\API\ExceptionConverter as ExceptionConverterInterface;
88
use Doctrine\DBAL\Driver\Exception;
9+
use Doctrine\DBAL\Driver\OCI8\Exception\Error;
10+
use Doctrine\DBAL\Driver\PDO\PDOException;
911
use Doctrine\DBAL\Exception\ConnectionException;
1012
use Doctrine\DBAL\Exception\DatabaseDoesNotExist;
1113
use Doctrine\DBAL\Exception\DatabaseObjectNotFoundException;
@@ -17,16 +19,30 @@
1719
use Doctrine\DBAL\Exception\SyntaxErrorException;
1820
use Doctrine\DBAL\Exception\TableExistsException;
1921
use Doctrine\DBAL\Exception\TableNotFoundException;
22+
use Doctrine\DBAL\Exception\TransactionRolledBack;
2023
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
2124
use Doctrine\DBAL\Query;
2225

26+
use function explode;
27+
use function str_replace;
28+
2329
/** @internal */
2430
final class ExceptionConverter implements ExceptionConverterInterface
2531
{
2632
/** @link http://www.dba-oracle.com/t_error_code_list.htm */
2733
public function convert(Exception $exception, ?Query $query): DriverException
2834
{
29-
switch ($exception->getCode()) {
35+
/** @psalm-var int|'HY000' $code */
36+
$code = $exception->getCode();
37+
/** @psalm-suppress NoInterfaceProperties */
38+
if ($code === 'HY000' && isset($exception->errorInfo[1], $exception->errorInfo[2])) {
39+
$errorInfo = $exception->errorInfo;
40+
$exception = new PDOException($errorInfo[2], $errorInfo[1]);
41+
$exception->errorInfo = $errorInfo;
42+
$code = $exception->getCode();
43+
}
44+
45+
switch ($code) {
3046
case 1:
3147
case 2299:
3248
case 38911:
@@ -58,6 +74,22 @@ public function convert(Exception $exception, ?Query $query): DriverException
5874
case 1918:
5975
return new DatabaseDoesNotExist($exception, $query);
6076

77+
case 2091:
78+
//ORA-02091: transaction rolled back
79+
//ORA-00001: unique constraint (DOCTRINE.GH3423_UNIQUE) violated
80+
[, $causeError] = explode("\n", $exception->getMessage(), 2);
81+
82+
[$causeCode] = explode(': ', $causeError, 2);
83+
$code = (int) str_replace('ORA-', '', $causeCode);
84+
85+
if ($exception instanceof PDOException) {
86+
$why = $this->convert(new PDOException($causeError, $code, $exception), $query);
87+
} else {
88+
$why = $this->convert(new Error($causeError, null, $code, $exception), $query);
89+
}
90+
91+
return new TransactionRolledBack($why, $query);
92+
6193
case 2289:
6294
case 2443:
6395
case 4080:

src/Driver/OCI8/Connection.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ public function beginTransaction(): bool
142142

143143
public function commit(): bool
144144
{
145-
if (! oci_commit($this->connection)) {
145+
if (! @oci_commit($this->connection)) {
146146
throw Error::new($this->connection);
147147
}
148148

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Doctrine\DBAL\Exception;
4+
5+
/** @psalm-immutable */
6+
class TransactionRolledBack extends DriverException
7+
{
8+
}

tests/ConnectionTest.php

+22
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,28 @@ public function rollBack(): void
10951095
self::assertSame('Original exception', $e->getPrevious()->getMessage());
10961096
}
10971097
}
1098+
1099+
/**
1100+
* We are not sure if this can happen in real life scenario
1101+
*/
1102+
public function testItFailsDuringCommitBeforeTouchingDb(): void
1103+
{
1104+
$connection = new class (['memory' => true], new Driver\SQLite3\Driver()) extends Connection {
1105+
public function commit(): void
1106+
{
1107+
throw new \Exception('Fail before touching the db');
1108+
}
1109+
1110+
public function rollBack(): void
1111+
{
1112+
throw new \Exception('Rollback got triggered');
1113+
}
1114+
};
1115+
1116+
$this->expectExceptionMessage('Rollback got triggered');
1117+
$connection->transactional(static function (): void {
1118+
});
1119+
}
10981120
}
10991121

11001122
interface ConnectDispatchEventListener

0 commit comments

Comments
 (0)