Skip to content

Commit 06426c8

Browse files
committed
feat add database savepoint support
1 parent 7185379 commit 06426c8

20 files changed

+2063
-52
lines changed
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Concerns;
4+
5+
use Illuminate\Database\Events\SavepointCreated;
6+
use Illuminate\Database\Events\SavepointReleased;
7+
use Illuminate\Database\Events\SavepointRolledBack;
8+
use Illuminate\Database\Events\TransactionBeginning;
9+
use Illuminate\Database\Events\TransactionCommitted;
10+
use Illuminate\Database\Events\TransactionRolledBack;
11+
use InvalidArgumentException;
12+
use LogicException;
13+
use RuntimeException;
14+
use Throwable;
15+
16+
trait ManagesSavepoints
17+
{
18+
/**
19+
* Status of savepoint management initialization.
20+
*/
21+
protected bool $savepointManagementInitialized = false;
22+
23+
/**
24+
* An array of savepoints indexed by transaction level.
25+
*
26+
* @var array<int, array<string>>
27+
*/
28+
protected array $savepoints = [];
29+
30+
/**
31+
* Determine if the connection supports savepoints.
32+
*/
33+
public function supportsSavepoints(): bool
34+
{
35+
return $this->queryGrammar?->supportsSavepoints() ?? false;
36+
}
37+
38+
/**
39+
* Determine if the connection supports releasing savepoints.
40+
*/
41+
public function supportsSavepointRelease(): bool
42+
{
43+
return $this->queryGrammar?->supportsSavepointRelease() ?? false;
44+
}
45+
46+
/**
47+
* Create a savepoint within the current transaction. Optionally provide a callback
48+
* to be executed following creation of the savepoint. If the callback fails, the transaction
49+
* will be rolled back to the savepoint. The savepoint will be released after the callback
50+
* has been executed.
51+
*
52+
* @throws Throwable
53+
*/
54+
public function savepoint(string $name, ?callable $callback = null): mixed
55+
{
56+
if (! $this->supportsSavepoints()) {
57+
$this->savepointsUnsupportedError();
58+
}
59+
60+
if (! $this->transactionLevel()) {
61+
$this->savepointOutsideTransactionError();
62+
}
63+
64+
if ($this->hasSavepoint($name)) {
65+
$this->duplicateSavepointError($name);
66+
}
67+
68+
if ($this->getPdo()->exec($this->queryGrammar->compileSavepoint($this->encodeSavepointName($name))) === false) {
69+
$this->savepointActionFailedError('create', $name);
70+
}
71+
72+
$this->savepoints[$this->transactionLevel()][] = $name;
73+
74+
$this->event(new SavepointCreated($this, $name));
75+
76+
if (! is_null($callback)) {
77+
try {
78+
return $callback();
79+
} catch (Throwable $e) {
80+
if ($this->hasSavepoint($name)) {
81+
$this->rollbackToSavepoint($name);
82+
}
83+
84+
throw $e;
85+
} finally {
86+
if ($this->supportsSavepointRelease() && $this->hasSavepoint($name)) {
87+
$this->releaseSavepoint($name);
88+
}
89+
}
90+
}
91+
92+
return true;
93+
}
94+
95+
/**
96+
* Rollback to a named savepoint within the current transaction.
97+
*
98+
* @throws Throwable
99+
*/
100+
public function rollbackToSavepoint(string $name): void
101+
{
102+
if (! $this->supportsSavepoints()) {
103+
$this->savepointsUnsupportedError();
104+
}
105+
106+
if (! $this->hasSavepoint($name)) {
107+
$this->unknownSavepointError($name);
108+
}
109+
110+
if (($position = array_search($name, $this->savepoints[$level = $this->transactionLevel()] ?? [], true)) !== false) {
111+
$released = array_splice($this->savepoints[$level], $position + 1);
112+
}
113+
114+
if ($this->getPdo()->exec($this->queryGrammar->compileRollbackToSavepoint($this->encodeSavepointName($name))) === false) {
115+
$this->savepointActionFailedError('rollback to', $name);
116+
}
117+
118+
$this->event(new SavepointRolledBack($this, $name, $released ?? []));
119+
}
120+
121+
/**
122+
* Release a savepoint from the current transaction.
123+
*
124+
* @throws Throwable
125+
*/
126+
public function releaseSavepoint(string $name, ?int $level = null): void
127+
{
128+
if (! $this->supportsSavepoints()) {
129+
$this->savepointsUnsupportedError();
130+
}
131+
132+
if (! $this->supportsSavepointRelease()) {
133+
$this->savepointReleaseUnsupportedError();
134+
}
135+
136+
if (! $this->hasSavepoint($name)) {
137+
$this->unknownSavepointError($name);
138+
}
139+
140+
if ($this->getPdo()->exec($this->queryGrammar->compileReleaseSavepoint($this->encodeSavepointName($name))) === false) {
141+
$this->savepointActionFailedError('release', $name);
142+
}
143+
144+
$this->savepoints[$level ??= $this->transactionLevel()] = array_values(array_diff($this->savepoints[$level], [$name]));
145+
146+
$this->event(new SavepointReleased($this, $name));
147+
}
148+
149+
/**
150+
* Purge all savepoints from the current transaction.
151+
*
152+
* @throws Throwable
153+
*/
154+
public function purgeSavepoints(?int $level = null): void
155+
{
156+
if (! $this->supportsSavepoints()) {
157+
$this->savepointsUnsupportedError();
158+
}
159+
160+
if (! $this->supportsSavepointRelease()) {
161+
$this->savepointPurgeUnsupportedError();
162+
}
163+
164+
foreach ($this->savepoints[$level ?? $this->transactionLevel()] ?? [] as $name) {
165+
$this->releaseSavepoint($name, $level);
166+
}
167+
}
168+
169+
/**
170+
* Determine if the connection has a savepoint within the current transaction.
171+
*/
172+
public function hasSavepoint(string $name): bool
173+
{
174+
return in_array($name, $this->savepoints[$this->transactionLevel()] ?? [], true);
175+
}
176+
177+
/**
178+
* Get the names of all savepoints within the current transaction.
179+
*/
180+
public function getSavepoints(): array
181+
{
182+
return $this->savepoints[$this->transactionLevel()] ?? [];
183+
}
184+
185+
/**
186+
* Get the name of the current savepoint.
187+
*/
188+
public function getCurrentSavepoint(): ?string
189+
{
190+
return isset($this->savepoints[$level = $this->transactionLevel()]) && ! empty($this->savepoints[$level])
191+
? end($this->savepoints[$level])
192+
: null;
193+
}
194+
195+
/**
196+
* Initialize savepoint management for the connection; sets up event
197+
* listeners to manage savepoints during transaction events.
198+
*/
199+
protected function initializeSavepointManagement(bool $force = false): void
200+
{
201+
if (($this->savepointManagementInitialized && ! $force) || ! $this->supportsSavepoints()) {
202+
return;
203+
}
204+
205+
$this->savepointManagementInitialized = true;
206+
207+
$this->savepoints = [];
208+
209+
$this->events?->listen(function (TransactionBeginning $event) {
210+
$this->syncTransactionBeginning();
211+
});
212+
213+
$this->events?->listen(function (TransactionCommitted $event) {
214+
$this->syncTransactionCommitted();
215+
});
216+
217+
$this->events?->listen(function (TransactionRolledBack $event) {
218+
$this->syncTransactionRolledBack();
219+
});
220+
}
221+
222+
/**
223+
* Update savepoint management to reflect the transaction beginning event.
224+
*/
225+
protected function syncTransactionBeginning(): void
226+
{
227+
$this->savepoints[$this->transactionLevel()] = [];
228+
}
229+
230+
/**
231+
* Update savepoint management to reflect the transaction committed event.
232+
*
233+
* @throws Throwable
234+
*/
235+
protected function syncTransactionCommitted(): void
236+
{
237+
$this->syncSavepoints();
238+
}
239+
240+
/**
241+
* Update savepoint management to reflect the transaction rolled back event.
242+
*
243+
* @throws Throwable
244+
*/
245+
protected function syncTransactionRolledBack(): void
246+
{
247+
$this->syncSavepoints();
248+
}
249+
250+
/**
251+
* Sync savepoints after a transaction commit or rollback.
252+
*
253+
* @throws Throwable
254+
*/
255+
protected function syncSavepoints(): void
256+
{
257+
foreach (array_keys($this->savepoints) as $level) {
258+
if ($level > $this->transactionLevel()) {
259+
if ($this->supportsSavepointRelease()) {
260+
$this->purgeSavepoints($level);
261+
}
262+
263+
unset($this->savepoints[$level]);
264+
}
265+
}
266+
267+
if (! $this->transactionLevel()) {
268+
$this->savepoints = [];
269+
}
270+
}
271+
272+
/**
273+
* Encode a savepoint name to ensure it's safe for SQL compilation.
274+
*/
275+
protected function encodeSavepointName(string $name): string
276+
{
277+
return bin2hex($name);
278+
}
279+
280+
/**
281+
* Throw an error indicating that savepoints are unsupported.
282+
*
283+
* @throws RuntimeException
284+
*/
285+
protected function savepointsUnsupportedError(): void
286+
{
287+
throw new RuntimeException('This database connection does not support creating savepoints.');
288+
}
289+
290+
/**
291+
* Throw an error indicating that releasing savepoints is unsupported.
292+
*
293+
* @throws RuntimeException
294+
*/
295+
protected function savepointReleaseUnsupportedError(): void
296+
{
297+
throw new RuntimeException('This database connection does not support releasing savepoints.');
298+
}
299+
300+
/**
301+
* Throw an error indicating that purging savepoints is unsupported.
302+
*
303+
* @throws RuntimeException
304+
*/
305+
protected function savepointPurgeUnsupportedError(): void
306+
{
307+
throw new RuntimeException('This database connection does not support purging savepoints.');
308+
}
309+
310+
/**
311+
* Throw an error indicating that a savepoint already exists with the given name.
312+
*
313+
* @throws InvalidArgumentException
314+
*/
315+
protected function duplicateSavepointError(string $name): void
316+
{
317+
throw new InvalidArgumentException(
318+
"Savepoint '{$name}' already exists at position "
319+
.array_search($name, $this->savepoints[$this->transactionLevel()] ?? [], true)
320+
." in transaction level {$this->transactionLevel()}. "
321+
."Use a different name or call rollbackToSavepoint('{$name}') first. "
322+
."Current savepoints: ['".implode("', '", $this->savepoints[$this->transactionLevel()] ?? [])."']."
323+
);
324+
}
325+
326+
/**
327+
* Throw an error indicating that the specified savepoint does not exist.
328+
*
329+
* @throws InvalidArgumentException
330+
*/
331+
protected function unknownSavepointError(string $name): void
332+
{
333+
throw new InvalidArgumentException(
334+
"Savepoint '{$name}' does not exist in transaction level {$this->transactionLevel()}."
335+
.(empty($this->savepoints[$this->transactionLevel()] ?? [])
336+
? ' No savepoints exist at this transaction level.'
337+
: " Available savepoints: ['".implode("', '", $this->savepoints[$this->transactionLevel()])."'].")
338+
);
339+
}
340+
341+
/**
342+
* Throw an error indicating that a savepoint cannot be created outside a transaction.
343+
*
344+
* @throws LogicException
345+
*/
346+
protected function savepointOutsideTransactionError(): void
347+
{
348+
throw new LogicException(
349+
'Cannot create savepoint outside of transaction. Current transaction level: 0. '
350+
.'Call beginTransaction() first or use the transaction() helper method.'
351+
);
352+
}
353+
354+
/**
355+
* Throw an error indicating that an error occurred while executing a savepoint action.
356+
*
357+
* @throws RuntimeException
358+
*/
359+
protected function savepointActionFailedError(string $action = 'execute', string $name = ''): void
360+
{
361+
throw new RuntimeException(
362+
"Failed to {$action} savepoint".($name ? " '{$name}'" : '')
363+
.'. Check database permissions and transaction state. '
364+
."Current transaction level: {$this->transactionLevel()}."
365+
);
366+
}
367+
}

0 commit comments

Comments
 (0)