Skip to content

Commit 478b405

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 478b405

File tree

3 files changed

+343
-3
lines changed

3 files changed

+343
-3
lines changed

src/Concerns/ManagesTransactions.php

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use MongoDB\Driver\Session;
1111
use Throwable;
1212

13+
use function max;
1314
use function MongoDB\with_transaction;
1415

1516
/**
@@ -55,32 +56,90 @@ private function getSessionOrThrow(): Session
5556
*/
5657
public function beginTransaction(array $options = []): void
5758
{
59+
$this->runCallbacksBeforeTransaction();
60+
5861
$this->getSessionOrCreate()->startTransaction($options);
62+
63+
$this->handleInitialTransactionState();
64+
}
65+
66+
private function handleInitialTransactionState(): void
67+
{
5968
$this->transactions = 1;
69+
70+
$this->transactionsManager?->begin(
71+
$this->getName(),
72+
$this->transactions,
73+
);
74+
75+
$this->fireConnectionEvent('beganTransaction');
6076
}
6177

6278
/**
6379
* Commit transaction in this session.
6480
*/
6581
public function commit(): void
6682
{
83+
$this->fireConnectionEvent('committing');
6784
$this->getSessionOrThrow()->commitTransaction();
68-
$this->transactions = 0;
85+
86+
$this->handleCommitState();
87+
}
88+
89+
private function handleCommitState(): void
90+
{
91+
[$levelBeingCommitted, $this->transactions] = [
92+
$this->transactions,
93+
max(0, $this->transactions - 1),
94+
];
95+
96+
$this->transactionsManager?->commit(
97+
$this->getName(),
98+
$levelBeingCommitted,
99+
$this->transactions,
100+
);
101+
102+
$this->fireConnectionEvent('committed');
69103
}
70104

71105
/**
72106
* Abort transaction in this session.
73107
*/
74108
public function rollBack($toLevel = null): void
75109
{
76-
$this->getSessionOrThrow()->abortTransaction();
110+
$session = $this->getSessionOrThrow();
111+
if ($session->isInTransaction()) {
112+
$session->abortTransaction();
113+
}
114+
115+
$this->handleRollbackState();
116+
}
117+
118+
private function handleRollbackState(): void
119+
{
77120
$this->transactions = 0;
121+
122+
$this->transactionsManager?->rollback(
123+
$this->getName(),
124+
$this->transactions,
125+
);
126+
127+
$this->fireConnectionEvent('rollingBack');
128+
}
129+
130+
private function runCallbacksBeforeTransaction(): void
131+
{
132+
foreach ($this->beforeStartingTransaction as $beforeTransactionCallback) {
133+
$beforeTransactionCallback($this);
134+
}
78135
}
79136

80137
/**
81138
* Static transaction function realize the with_transaction functionality provided by MongoDB.
82139
*
83-
* @param int $attempts
140+
* @param int $attempts
141+
*
142+
* @throws Throwable
84143
*/
85144
public function transaction(Closure $callback, $attempts = 1, array $options = []): mixed
86145
{
@@ -93,15 +152,20 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [
93152

94153
if ($attemptsLeft < 0) {
95154
$session->abortTransaction();
155+
$this->handleRollbackState();
96156

97157
return;
98158
}
99159

160+
$this->runCallbacksBeforeTransaction();
161+
$this->handleInitialTransactionState();
162+
100163
// Catch, store, and re-throw any exception thrown during execution
101164
// of the callable. The last exception is re-thrown if the transaction
102165
// was aborted because the number of callback attempts has been exceeded.
103166
try {
104167
$callbackResult = $callback($this);
168+
$this->fireConnectionEvent('committing');
105169
} catch (Throwable $throwable) {
106170
throw $throwable;
107171
}
@@ -110,9 +174,12 @@ public function transaction(Closure $callback, $attempts = 1, array $options = [
110174
with_transaction($this->getSessionOrCreate(), $callbackFunction, $options);
111175

112176
if ($attemptsLeft < 0 && $throwable) {
177+
$this->handleRollbackState();
113178
throw $throwable;
114179
}
115180

181+
$this->handleCommitState();
182+
116183
return $callbackResult;
117184
}
118185
}

tests/Ticket/GH3328Test.php

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

0 commit comments

Comments
 (0)