Skip to content

Commit 5d509dc

Browse files
theodorejbclaude
andcommitted
Add transaction() method and support nested transactions
transaction() runs a function in a transaction, which is committed if the function returns (returning its value), or rolled back if it throws an exception (which is rethrown). Transactions can now be nested: if a transaction has already been started (including directly with PDO), begin() creates a savepoint instead of failing, and the matching commit() or rollback() releases or rolls back to it. So a nested transaction() call which throws only rolls back its own changes, and the outer transaction can continue. This matters especially with PostgreSQL, which otherwise rejects further queries in a transaction after an error. Like Doctrine and Laravel, the nesting level is tracked by the PeachySql instance, so only one instance should be used for each connection. If an error rolled back the entire transaction (e.g. a deadlock with SQL Server or MySQL), the savepoint no longer exists, so the nested commit() or rollback() throws a TransactionRolledBackException (like Laravel's DeadlockException), which transaction() wraps around the original exception. This is detected by the savepoint statement failing rather than by specific error codes, so with PostgreSQL, where a deadlock only aborts the transaction and the savepoint can still be rolled back to, the outer transaction can continue. SqlException now accepts an optional previous exception. The savepoint statements are executed directly, since MySQL doesn't support them in prepared statements. A failed rollback in transaction() doesn't replace the original exception. Batched inserts, updates, and deletes now use transaction(), replacing the private runInTransaction() method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 86d42ac commit 5d509dc

6 files changed

Lines changed: 323 additions & 40 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@
77
are correct even when other connections insert into the same table at the same time, when inserting
88
explicit identity values, and with any identity increment. With SQL Server, this requires 2016+.
99
- `updateRows()` and `deleteFrom()` split queries with more bound parameters than allowed into multiple
10-
queries (by splitting the largest list of values to match), which are run in a transaction unless
11-
one has already been started.
10+
queries (by splitting the largest list of values to match), which are run in a transaction (or a
11+
savepoint, if a transaction has already been started).
12+
- `transaction()` method, which runs a function in a transaction that's committed if the function returns,
13+
or rolled back if it throws an exception. If a transaction has already been started, a savepoint is used,
14+
so only the function's changes are rolled back. If an error rolled back the entire transaction instead
15+
(e.g. a deadlock with SQL Server or MySQL), a `TransactionRolledBackException` is thrown.
16+
- Optional `$previous` parameter for the `SqlException` constructor.
17+
- Nested transactions: calling `begin()` when a transaction has already been started creates a savepoint
18+
(rather than failing), and the matching `commit()` or `rollback()` releases or rolls back to it.
19+
Only one `PeachySql` instance should be used for each connection.
1220

1321
### Fixed
1422
- The maximum number of bound parameters for SQL Server is now 2,097 rather than 2,099, since the call
@@ -26,8 +34,8 @@
2634
- With SQL Server, `insertRow()` and `insertRows()` no longer return IDs for tables and views with an
2735
`INSTEAD OF INSERT` trigger, as before v7.0. Since v7.0 these were computed from `@@IDENTITY`,
2836
which isn't reliable with concurrent inserts.
29-
- When `insertRows()` requires multiple queries, they're now run in a transaction unless one has
30-
already been started, so that if one of them fails, none of the rows are inserted.
37+
- When `insertRows()` requires multiple queries, they're now run in a transaction (or a savepoint,
38+
if a transaction has already been started), so that if one of them fails, none of the rows are inserted.
3139

3240

3341
## [7.0.2] - 2026-08-06

‎README.md‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,7 @@ $ids = $result->ids; // e.g. [64, 66, 68]
230230
> or thousands of rows at a time. To avoid these limits, the `insertRows()` method automatically
231231
> splits row sets that exceed the limits into chunks to efficiently insert any number of rows
232232
> (`queryCount` contains the number of required queries). When multiple queries are required, they
233-
> are run in a transaction unless one has already been started, so that if one of them fails, none
234-
> of the rows are inserted.
233+
> are run with `transaction()`, so that if one of them fails, none of the rows are inserted.
235234
236235
#### updateRows and deleteFrom
237236

@@ -253,7 +252,7 @@ $userTable->deleteFrom('Users', ['user_id' => [1, 2, 3]]);
253252

254253
If a query would have more bound parameters than the database allows (e.g. when deleting thousands
255254
of rows by ID), the largest list of values to match is split into chunks, and the chunks are updated
256-
or deleted in separate queries. These are run in a transaction unless one has already been started.
255+
or deleted in separate queries. These are run with `transaction()` (see below), so they succeed or fail together.
257256
A list can only be split if it's for an `eq` condition (since the other list operators have to match
258257
all the values in one query), and isn't for a column being updated (since an updated row could then
259258
match a later chunk). If no list can be split, an exception is thrown.
@@ -264,6 +263,35 @@ Call the `begin()` method to start a transaction. `prepare()`, `execute()`, `que
264263
and any of the shorthand methods can then be called as needed, before committing
265264
or rolling back the transaction with `commit()` or `rollback()`.
266265

266+
Alternatively, pass a function to the `transaction()` method. The transaction is committed if the
267+
function returns (and its return value is returned), or rolled back if it throws an exception
268+
(which is then rethrown):
269+
270+
```php
271+
$userId = $db->transaction(function (PeachySql $db) use ($userData, $roles) {
272+
$userId = $db->insertRow('Users', $userData)->id;
273+
$db->insertRows('UserRoles', array_map(fn($role) => ['user_id' => $userId, 'role' => $role], $roles));
274+
return $userId;
275+
});
276+
```
277+
278+
Transactions can be nested. If a transaction has already been started, `begin()` (and therefore
279+
`transaction()`) creates a savepoint instead, and the matching `commit()` or `rollback()` only
280+
applies to the changes made since then. So if a nested function passed to `transaction()` throws
281+
an exception, only the changes it made are rolled back, and the outer transaction can continue.
282+
283+
However, some errors roll back the entire transaction (e.g. a deadlock with SQL Server or MySQL).
284+
In this case the nested `commit()` or `rollback()` throws a `TransactionRolledBackException`, since
285+
the outer transaction's changes were also rolled back. When thrown by `transaction()`, the original
286+
exception is available from `getPrevious()`.
287+
288+
> [!NOTE]
289+
> Nested transactions are tracked by the `PeachySql` instance, so use a single instance for each
290+
> connection. A transaction can be started directly with PDO (e.g. to wrap a test so its changes
291+
> are rolled back afterwards), and `begin()` will then create savepoints within it. However, any
292+
> nested transactions should be ended with `commit()` or `rollback()` before the outer transaction
293+
> is committed or rolled back with PDO.
294+
267295
### Binary columns
268296

269297
In order to insert/update raw binary data (e.g. to a binary, blob, or bytea column),

‎src/PeachySql.php‎

Lines changed: 124 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@
1313
*/
1414
class PeachySql
1515
{
16+
private const SAVEPOINT_PREFIX = 'peachy_savepoint_';
17+
1618
public Options $options;
1719
private PDO $conn;
1820
private bool $usedPrepare = true;
1921

22+
/** The number of nested transactions (savepoints) in the current transaction */
23+
private int $nestingLevel = 0;
24+
2025
public function __construct(PDO $connection, ?Options $options = null)
2126
{
2227
$this->conn = $connection;
@@ -31,41 +36,155 @@ public function __construct(PDO $connection, ?Options $options = null)
3136
}
3237

3338
/**
34-
* Begins a transaction
39+
* Begins a transaction. If a transaction has already been started, a savepoint is created instead,
40+
* so that the matching commit() or rollback() only applies to the changes made after this call.
3541
* @throws SqlException if an error occurs
3642
*/
3743
public function begin(): void
3844
{
45+
if ($this->conn->inTransaction()) {
46+
$savepoint = self::SAVEPOINT_PREFIX . ($this->nestingLevel + 1);
47+
$sql = $this->options->driver === 'sqlsrv' ? "SAVE TRANSACTION {$savepoint}" : "SAVEPOINT {$savepoint}";
48+
$this->execTransactionStatement($sql);
49+
$this->nestingLevel++;
50+
return;
51+
}
52+
53+
// any savepoints from a transaction that wasn't ended with commit() or rollback() no longer exist
54+
$this->nestingLevel = 0;
55+
3956
if (!$this->conn->beginTransaction()) {
4057
/** @phpstan-ignore argument.type */
4158
throw $this->getError('Failed to begin transaction', $this->conn->errorInfo());
4259
}
4360
}
4461

4562
/**
46-
* Commits a transaction begun with begin()
63+
* Commits a transaction begun with begin(), or releases its savepoint if it was nested in another transaction.
64+
* @throws TransactionRolledBackException if the savepoint no longer exists, since an error rolled back
65+
* the entire transaction (e.g. a deadlock with SQL Server or MySQL)
4766
* @throws SqlException if an error occurs
4867
*/
4968
public function commit(): void
5069
{
70+
if ($this->nestingLevel > 0) {
71+
$savepoint = self::SAVEPOINT_PREFIX . $this->nestingLevel--;
72+
73+
// SQL Server doesn't support releasing a savepoint
74+
if ($this->options->driver !== 'sqlsrv') {
75+
$this->endSavepoint("RELEASE SAVEPOINT {$savepoint}");
76+
}
77+
78+
return;
79+
}
80+
5181
if (!$this->conn->commit()) {
5282
/** @phpstan-ignore argument.type */
5383
throw $this->getError('Failed to commit transaction', $this->conn->errorInfo());
5484
}
5585
}
5686

5787
/**
58-
* Rolls back a transaction begun with begin()
88+
* Rolls back a transaction begun with begin(), or only the changes made since then if it was nested
89+
* in another transaction.
90+
* @throws TransactionRolledBackException if the savepoint no longer exists, since an error rolled back
91+
* the entire transaction (e.g. a deadlock with SQL Server or MySQL)
5992
* @throws SqlException if an error occurs
6093
*/
6194
public function rollback(): void
6295
{
96+
if ($this->nestingLevel > 0) {
97+
$savepoint = self::SAVEPOINT_PREFIX . $this->nestingLevel--;
98+
$sqlsrv = $this->options->driver === 'sqlsrv';
99+
$this->endSavepoint($sqlsrv ? "ROLLBACK TRANSACTION {$savepoint}" : "ROLLBACK TO SAVEPOINT {$savepoint}");
100+
return;
101+
}
102+
63103
if (!$this->conn->rollback()) {
64104
/** @phpstan-ignore argument.type */
65105
throw $this->getError('Failed to roll back transaction', $this->conn->errorInfo());
66106
}
67107
}
68108

109+
/**
110+
* Runs the function in a transaction, which is committed if the function returns, or rolled back
111+
* if it throws an exception (which is then rethrown). If a transaction has already been started,
112+
* a savepoint is used instead, so only the changes made by the function are rolled back.
113+
* @template T
114+
* @param Closure(self): T $fn
115+
* @throws TransactionRolledBackException if the function is nested in another transaction, and throws an error
116+
* which rolled back the entire transaction (e.g. due to a deadlock)
117+
* @throws SqlException if an error occurs when starting, committing, or rolling back the transaction
118+
* @return T The value returned by the function
119+
*/
120+
public function transaction(Closure $fn): mixed
121+
{
122+
$nested = $this->conn->inTransaction();
123+
$this->begin();
124+
125+
try {
126+
$result = $fn($this);
127+
} catch (\Throwable $e) {
128+
try {
129+
$this->rollback();
130+
} catch (TransactionRolledBackException) {
131+
// the outer transaction can't continue as if its earlier changes weren't also rolled back
132+
throw $e instanceof TransactionRolledBackException ? $e : new TransactionRolledBackException($e);
133+
} catch (\Throwable) {
134+
// the original exception is more useful (e.g. if the connection was lost)
135+
}
136+
137+
throw $e;
138+
}
139+
140+
try {
141+
$this->commit();
142+
} catch (\Throwable $e) {
143+
if (!$nested && $this->conn->inTransaction()) {
144+
try {
145+
$this->rollback();
146+
} catch (\Throwable) {
147+
// the original exception is more useful
148+
}
149+
}
150+
151+
throw $e;
152+
}
153+
154+
return $result;
155+
}
156+
157+
/**
158+
* Releases or rolls back to a savepoint.
159+
* @throws TransactionRolledBackException if this fails, since the savepoint no longer exists
160+
*/
161+
private function endSavepoint(string $sql): void
162+
{
163+
try {
164+
$this->execTransactionStatement($sql);
165+
} catch (SqlException $e) {
166+
throw new TransactionRolledBackException($e);
167+
}
168+
}
169+
170+
/**
171+
* Savepoint statements are executed directly, since MySQL doesn't support them in prepared statements.
172+
* @throws SqlException if an error occurs
173+
*/
174+
private function execTransactionStatement(string $sql): void
175+
{
176+
try {
177+
$success = $this->conn->exec($sql) !== false;
178+
} catch (\PDOException $e) {
179+
$success = false;
180+
}
181+
182+
if (!$success) {
183+
/** @phpstan-ignore argument.type */
184+
throw $this->getError("Failed to execute {$sql}", $this->conn->errorInfo());
185+
}
186+
}
187+
69188
/**
70189
* Takes a binary string and returns a value that can be bound to an insert/update statement
71190
* @return array{0: string|null, 1: int, 2: int, 3: mixed}
@@ -256,7 +375,7 @@ public function insertRows(string $table, array $colVals, int $identityIncrement
256375
return new BulkInsertResult($ids, $affected, count($batches));
257376
};
258377

259-
return count($batches) > 1 ? $this->runInTransaction($insertBatches) : $insertBatches();
378+
return count($batches) > 1 ? $this->transaction($insertBatches) : $insertBatches();
260379
}
261380

262381
/**
@@ -302,7 +421,7 @@ private function runInBatches(array $where, Closure $buildQuery, array $setColum
302421

303422
$batches = Query::batchWhere($where, $paramCount, $maxParams, $setColumns);
304423

305-
return $this->runInTransaction(function () use ($batches, $buildQuery): int {
424+
return $this->transaction(function () use ($batches, $buildQuery): int {
306425
$affected = 0;
307426

308427
foreach ($batches as $batchWhere) {
@@ -327,29 +446,4 @@ private function runAffectingRows(SqlParams $sqlParams): int
327446

328447
return $this->query($sqlParams->sql, $sqlParams->params)->getAffected();
329448
}
330-
331-
/**
332-
* Runs the function in a transaction (unless one has already been started), so that if it runs multiple
333-
* queries, a failure doesn't leave the changes from some of them committed.
334-
* @template T
335-
* @param Closure(): T $fn
336-
* @return T
337-
*/
338-
private function runInTransaction(Closure $fn): mixed
339-
{
340-
if ($this->conn->inTransaction()) {
341-
return $fn();
342-
}
343-
344-
$this->begin();
345-
346-
try {
347-
$result = $fn();
348-
$this->commit();
349-
return $result;
350-
} catch (\Throwable $e) {
351-
$this->rollback();
352-
throw $e;
353-
}
354-
}
355449
}

‎src/SqlException.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ class SqlException extends \RuntimeException
1212
*/
1313
private readonly string $sqlState;
1414

15-
public function __construct(string $message, int $code, string $details, string $sqlState)
15+
public function __construct(string $message, int $code, string $details, string $sqlState, ?\Throwable $previous = null)
1616
{
1717
if ($details !== '') {
1818
$message .= ": $details";
1919
}
2020

21-
parent::__construct($message, $code);
21+
parent::__construct($message, $code, $previous);
2222
$this->sqlState = $sqlState;
2323
}
2424

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace DevTheorem\PeachySQL;
4+
5+
/**
6+
* Thrown by a nested call to transaction() when an error (e.g. a deadlock) rolled back the entire
7+
* transaction, so the changes made earlier in the outer transaction were also rolled back.
8+
* The original exception is available from getPrevious().
9+
*/
10+
class TransactionRolledBackException extends SqlException
11+
{
12+
public function __construct(\Throwable $previous)
13+
{
14+
$sqlState = $previous instanceof SqlException ? $previous->getSqlState() : '';
15+
parent::__construct('The transaction was rolled back', (int) $previous->getCode(), $previous->getMessage(), $sqlState, $previous);
16+
}
17+
}

0 commit comments

Comments
 (0)