Skip to content

Commit 3d51448

Browse files
committed
Use DB tx manager in ManagesTransactions trait
`DatabaseTransactionsManager` was introduced in Laravel to keep track of things like pending transactions and fire transaction events, so that e.g. dispatches could hook into transaction state. Our trait was not using it yet, resulting in missing `afterCommit` behavior (see PHPLIB-373)
1 parent c483b99 commit 3d51448

File tree

2 files changed

+318
-3
lines changed

2 files changed

+318
-3
lines changed

src/Concerns/ManagesTransactions.php

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
use MongoDB\Client;
99
use MongoDB\Driver\Exception\RuntimeException;
1010
use MongoDB\Driver\Session;
11+
use MongoDB\Laravel\Connection;
1112
use Throwable;
1213

14+
use function max;
1315
use function MongoDB\with_transaction;
16+
use function property_exists;
1417

1518
/**
1619
* @internal
@@ -55,32 +58,93 @@ private function getSessionOrThrow(): Session
5558
*/
5659
public function beginTransaction(array $options = []): void
5760
{
61+
$this->runCallbacksBeforeTransaction();
62+
5863
$this->getSessionOrCreate()->startTransaction($options);
64+
65+
$this->handleInitialTransactionState();
66+
}
67+
68+
private function handleInitialTransactionState(): void
69+
{
5970
$this->transactions = 1;
71+
72+
$this->transactionsManager?->begin(
73+
$this->getName(),
74+
$this->transactions,
75+
);
76+
77+
$this->fireConnectionEvent('beganTransaction');
6078
}
6179

6280
/**
6381
* Commit transaction in this session.
6482
*/
6583
public function commit(): void
6684
{
85+
$this->fireConnectionEvent('committing');
6786
$this->getSessionOrThrow()->commitTransaction();
68-
$this->transactions = 0;
87+
88+
$this->handleCommitState();
89+
}
90+
91+
private function handleCommitState(): void
92+
{
93+
[$levelBeingCommitted, $this->transactions] = [
94+
$this->transactions,
95+
max(0, $this->transactions - 1),
96+
];
97+
98+
$this->transactionsManager?->commit(
99+
$this->getName(),
100+
$levelBeingCommitted,
101+
$this->transactions,
102+
);
103+
104+
$this->fireConnectionEvent('committed');
69105
}
70106

71107
/**
72108
* Abort transaction in this session.
73109
*/
74110
public function rollBack($toLevel = null): void
75111
{
76-
$this->getSessionOrThrow()->abortTransaction();
112+
$session = $this->getSessionOrThrow();
113+
if ($session->isInTransaction()) {
114+
$session->abortTransaction();
115+
}
116+
117+
$this->handleRollbackState();
118+
}
119+
120+
private function handleRollbackState(): void
121+
{
77122
$this->transactions = 0;
123+
124+
$this->transactionsManager?->rollback(
125+
$this->getName(),
126+
$this->transactions,
127+
);
128+
129+
$this->fireConnectionEvent('rollingBack');
130+
}
131+
132+
private function runCallbacksBeforeTransaction(): void
133+
{
134+
// ToDo: remove conditional once we stop supporting Laravel 10.x
135+
if (property_exists(Connection::class, 'beforeStartingTransaction')) {
136+
foreach ($this->beforeStartingTransaction as $beforeTransactionCallback) {
137+
$beforeTransactionCallback($this);
138+
}
139+
}
78140
}
79141

80142
/**
81143
* Static transaction function realize the with_transaction functionality provided by MongoDB.
82144
*
83-
* @param int $attempts
145+
* @param int $attempts
146+
*
147+
* @throws Throwable
84148
*/
85149
public function transaction(Closure $callback, $attempts = 1, array $options = []): mixed
86150
{
@@ -93,15 +157,20 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [
93157

94158
if ($attemptsLeft < 0) {
95159
$session->abortTransaction();
160+
$this->handleRollbackState();
96161

97162
return;
98163
}
99164

165+
$this->runCallbacksBeforeTransaction();
166+
$this->handleInitialTransactionState();
167+
100168
// Catch, store, and re-throw any exception thrown during execution
101169
// of the callable. The last exception is re-thrown if the transaction
102170
// was aborted because the number of callback attempts has been exceeded.
103171
try {
104172
$callbackResult = $callback($this);
173+
$this->fireConnectionEvent('committing');
105174
} catch (Throwable $throwable) {
106175
throw $throwable;
107176
}
@@ -110,9 +179,12 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [
110179
with_transaction($this->getSessionOrCreate(), $callbackFunction, $options);
111180

112181
if ($attemptsLeft < 0 && $throwable) {
182+
$this->handleRollbackState();
113183
throw $throwable;
114184
}
115185

186+
$this->handleCommitState();
187+
116188
return $callbackResult;
117189
}
118190
}

tests/Ticket/GH3328Test.php

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
<?php
2+
3+
namespace MongoDB\Laravel\Tests\Ticket;
4+
5+
use Closure;
6+
use Exception;
7+
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
8+
use Illuminate\Database\Connection;
9+
use Illuminate\Database\Events\TransactionBeginning;
10+
use Illuminate\Database\Events\TransactionCommitted;
11+
use Illuminate\Database\Events\TransactionCommitting;
12+
use Illuminate\Database\Events\TransactionRolledBack;
13+
use Illuminate\Foundation\Events\Dispatchable;
14+
use Illuminate\Support\Facades\DB;
15+
use Illuminate\Support\Facades\Event;
16+
use MongoDB\Driver\Exception\RuntimeException;
17+
use MongoDB\Laravel\Tests\TestCase;
18+
use Throwable;
19+
20+
use function event;
21+
use function interface_exists;
22+
23+
/**
24+
* @see https://github.com/mongodb/laravel-mongodb/issues/3328
25+
* @see https://jira.mongodb.org/browse/PHPORM-373
26+
*/
27+
class GH3328Test extends TestCase
28+
{
29+
public function testAfterCommitOnSuccessfulTransaction(): void
30+
{
31+
$callback = static function (): void {
32+
event(new RegularEvent());
33+
event(new AfterCommitEvent());
34+
};
35+
36+
$assert = function (): void {
37+
Event::assertDispatchedTimes(BeforeTransactionEvent::class);
38+
Event::assertDispatchedTimes(RegularEvent::class);
39+
Event::assertDispatchedTimes(AfterCommitEvent::class);
40+
41+
Event::assertDispatched(TransactionBeginning::class);
42+
Event::assertDispatched(TransactionCommitting::class);
43+
Event::assertDispatched(TransactionCommitted::class);
44+
};
45+
46+
$this->assertTransactionCallbackResult($callback, $assert);
47+
}
48+
49+
public function testAfterCommitOnFailedTransaction(): void
50+
{
51+
$callback = static function (): void {
52+
event(new RegularEvent());
53+
event(new AfterCommitEvent());
54+
55+
// Transaction failed; after commit event should not be dispatched
56+
throw new Fake();
57+
};
58+
59+
$assert = function (): void {
60+
Event::assertDispatchedTimes(BeforeTransactionEvent::class, 3);
61+
Event::assertDispatchedTimes(RegularEvent::class, 3);
62+
63+
Event::assertDispatchedTimes(TransactionBeginning::class, 3);
64+
Event::assertDispatched(TransactionRolledBack::class);
65+
Event::assertNotDispatched(TransactionCommitting::class);
66+
Event::assertNotDispatched(TransactionCommitted::class);
67+
};
68+
69+
$this->assertCallbackResultForConnection(
70+
DB::connection('mongodb'),
71+
$callback,
72+
$assert,
73+
3,
74+
);
75+
76+
if (! interface_exists('\Illuminate\Contracts\Database\ConcurrencyErrorDetector')) {
77+
// Earlier versions of Laravel use a trait instead of DI to detect concurrency errors
78+
// That would increase the scope of this comparison dramatically and is probably not worth it.
79+
return;
80+
}
81+
82+
// phpcs:ignore
83+
$this->app->bind(\Illuminate\Contracts\Database\ConcurrencyErrorDetector::class, FakeConcurrencyErrorDetector::class);
84+
85+
$this->assertCallbackResultForConnection(
86+
DB::connection('sqlite'),
87+
$callback,
88+
$assert,
89+
3,
90+
);
91+
}
92+
93+
public function testAfterCommitOnSuccessfulManualTransaction(): void
94+
{
95+
$callback = function (): void {
96+
event(new RegularEvent());
97+
event(new AfterCommitEvent());
98+
};
99+
100+
$assert = function (): void {
101+
Event::assertDispatchedTimes(BeforeTransactionEvent::class);
102+
Event::assertDispatchedTimes(RegularEvent::class);
103+
Event::assertDispatchedTimes(AfterCommitEvent::class);
104+
105+
Event::assertDispatched(TransactionBeginning::class);
106+
Event::assertNotDispatched(TransactionRolledBack::class);
107+
Event::assertDispatched(TransactionCommitting::class);
108+
Event::assertDispatched(TransactionCommitted::class);
109+
};
110+
111+
$this->assertTransactionResult($callback, $assert);
112+
}
113+
114+
public function testAfterCommitOnFailedManualTransaction(): void
115+
{
116+
$callback = function (): void {
117+
event(new RegularEvent());
118+
event(new AfterCommitEvent());
119+
120+
throw new Fake();
121+
};
122+
123+
$assert = function (): void {
124+
Event::assertDispatchedTimes(BeforeTransactionEvent::class);
125+
Event::assertDispatchedTimes(RegularEvent::class);
126+
Event::assertNotDispatched(AfterCommitEvent::class);
127+
128+
Event::assertDispatched(TransactionBeginning::class);
129+
Event::assertDispatched(TransactionRolledBack::class);
130+
Event::assertNotDispatched(TransactionCommitting::class);
131+
Event::assertNotDispatched(TransactionCommitted::class);
132+
};
133+
134+
$this->assertTransactionResult($callback, $assert);
135+
}
136+
137+
private function assertTransactionCallbackResult(Closure $callback, Closure $assert, ?int $attempts = 1): void
138+
{
139+
$this->assertCallbackResultForConnection(
140+
DB::connection('sqlite'),
141+
$callback,
142+
$assert,
143+
$attempts,
144+
);
145+
146+
$this->assertCallbackResultForConnection(
147+
DB::connection('mongodb'),
148+
$callback,
149+
$assert,
150+
$attempts,
151+
);
152+
}
153+
154+
/**
155+
* Ensure equal transaction behavior between SQLite (handled by Laravel) and MongoDB
156+
*/
157+
private function assertCallbackResultForConnection(Connection $connection, Closure $callback, Closure $assertions, int $attempts): void
158+
{
159+
$fake = Event::fake();
160+
$connection->setEventDispatcher($fake);
161+
$connection->beforeStartingTransaction(function () {
162+
event(new BeforeTransactionEvent());
163+
});
164+
165+
try {
166+
$connection->transaction($callback, $attempts);
167+
} catch (Exception) {
168+
}
169+
170+
$assertions();
171+
}
172+
173+
private function assertTransactionResult(Closure $callback, Closure $assert): void
174+
{
175+
$this->assertManualResultForConnection(
176+
DB::connection('sqlite'),
177+
$callback,
178+
$assert,
179+
);
180+
181+
$this->assertManualResultForConnection(
182+
DB::connection('mongodb'),
183+
$callback,
184+
$assert,
185+
);
186+
}
187+
188+
/**
189+
* Ensure equal transaction behavior between SQLite (handled by Laravel) and MongoDB
190+
*/
191+
private function assertManualResultForConnection(Connection $connection, Closure $callback, Closure $assert): void
192+
{
193+
$fake = Event::fake();
194+
$connection->setEventDispatcher($fake);
195+
196+
$connection->beforeStartingTransaction(function () {
197+
event(new BeforeTransactionEvent());
198+
});
199+
200+
$connection->beginTransaction();
201+
202+
try {
203+
$callback();
204+
$connection->commit();
205+
} catch (Exception) {
206+
$connection->rollBack();
207+
}
208+
209+
$assert();
210+
}
211+
}
212+
213+
class AfterCommitEvent implements ShouldDispatchAfterCommit
214+
{
215+
use Dispatchable;
216+
}
217+
218+
class BeforeTransactionEvent
219+
{
220+
use Dispatchable;
221+
}
222+
class RegularEvent
223+
{
224+
use Dispatchable;
225+
}
226+
class Fake extends RuntimeException
227+
{
228+
public function __construct()
229+
{
230+
$this->errorLabels = ['TransientTransactionError'];
231+
}
232+
}
233+
234+
if (interface_exists('\Illuminate\Contracts\Database\ConcurrencyErrorDetector')) {
235+
// phpcs:ignore
236+
class FakeConcurrencyErrorDetector implements \Illuminate\Contracts\Database\ConcurrencyErrorDetector
237+
{
238+
public function causedByConcurrencyError(Throwable $e): bool
239+
{
240+
return true;
241+
}
242+
}
243+
}

0 commit comments

Comments
 (0)