Skip to content

Commit e5e9193

Browse files
alcaeusklinsonlevon80999jmikola
authored
Transaction support (#2465)
* Add support for transactions Co-authored-by: klinson <[email protected]> Co-authored-by: levon80999 <[email protected]> * Start single-member replica set in CI Co-authored-by: levon80999 <[email protected]> * Add connection options for faster failures in tests The faster connection and server selection timeouts ensure we don't spend too much time waiting for the inevitable as we're expecting fast connections on CI systems Co-authored-by: levon80999 <[email protected]> * Apply readme code review suggestions * Simplify replica set creation in CI * Apply feedback from code review * Update naming of database env variable in tests * Use default argument for server selection (which defaults to primary) * Revert "Simplify replica set creation in CI" This partially reverts commit 203160e. The simplified call unfortunately breaks tests. * Pass connection instance to transactional closure This is consistent with the behaviour of the original ManagesTransactions concern. * Correctly re-throw exception when callback attempts have been exceeded. * Limit transaction lifetime to 5 seconds This ensures that hung transactions don't block any subsequent operations for an unnecessary period of time. * Add build step to print MongoDB server status * Update src/Concerns/ManagesTransactions.php Co-authored-by: Jeremy Mikola <[email protected]> Co-authored-by: klinson <[email protected]> Co-authored-by: levon80999 <[email protected]> Co-authored-by: Jeremy Mikola <[email protected]>
1 parent 0606fc0 commit e5e9193

File tree

9 files changed

+672
-18
lines changed

9 files changed

+672
-18
lines changed

.github/workflows/build-ci.yml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,6 @@ jobs:
4444
- '8.0'
4545
- '8.1'
4646
services:
47-
mongo:
48-
image: mongo:${{ matrix.mongodb }}
49-
ports:
50-
- 27017:27017
5147
mysql:
5248
image: mysql:5.7
5349
ports:
@@ -59,6 +55,16 @@ jobs:
5955

6056
steps:
6157
- uses: actions/checkout@v2
58+
- name: Create MongoDB Replica Set
59+
run: |
60+
docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5
61+
until docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do
62+
sleep 1
63+
done
64+
sudo docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})"
65+
- name: Show MongoDB server status
66+
run: |
67+
docker exec --tty mongodb mongo 127.0.0.1:27017 --eval "db.runCommand({ serverStatus: 1 })"
6268
- name: "Installing php"
6369
uses: shivammathur/setup-php@v2
6470
with:
@@ -88,7 +94,7 @@ jobs:
8894
run: |
8995
./vendor/bin/phpunit --coverage-clover coverage.xml
9096
env:
91-
MONGODB_URI: 'mongodb://127.0.0.1/'
97+
MONGODB_URI: 'mongodb://127.0.0.1/?replicaSet=rs'
9298
MYSQL_HOST: 0.0.0.0
9399
MYSQL_PORT: 3307
94100
- uses: codecov/codecov-action@v1

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ This package adds functionalities to the Eloquent model and Query builder for Mo
3737
- [Query Builder](#query-builder)
3838
- [Basic Usage](#basic-usage-2)
3939
- [Available operations](#available-operations)
40+
- [Transactions](#transactions)
4041
- [Schema](#schema)
4142
- [Basic Usage](#basic-usage-3)
4243
- [Geospatial indexes](#geospatial-indexes)
@@ -968,6 +969,52 @@ If you are familiar with [Eloquent Queries](http://laravel.com/docs/queries), th
968969
### Available operations
969970
To see the available operations, check the [Eloquent](#eloquent) section.
970971

972+
Transactions
973+
------------
974+
Transactions require MongoDB version ^4.0 as well as deployment of replica set or sharded clusters. You can find more information [in the MongoDB docs](https://docs.mongodb.com/manual/core/transactions/)
975+
976+
### Basic Usage
977+
978+
```php
979+
DB::transaction(function () {
980+
User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => '[email protected]']);
981+
DB::collection('users')->where('name', 'john')->update(['age' => 20]);
982+
DB::collection('users')->where('name', 'john')->delete();
983+
});
984+
```
985+
986+
```php
987+
// begin a transaction
988+
DB::beginTransaction();
989+
User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => '[email protected]']);
990+
DB::collection('users')->where('name', 'john')->update(['age' => 20]);
991+
DB::collection('users')->where('name', 'john')->delete();
992+
993+
// commit changes
994+
DB::commit();
995+
```
996+
997+
To abort a transaction, call the `rollBack` method at any point during the transaction:
998+
```php
999+
DB::beginTransaction();
1000+
User::create(['name' => 'john', 'age' => 19, 'title' => 'admin', 'email' => '[email protected]']);
1001+
1002+
// Abort the transaction, discarding any data created as part of it
1003+
DB::rollBack();
1004+
```
1005+
1006+
**NOTE:** Transactions in MongoDB cannot be nested. DB::beginTransaction() function will start new transactions in a new created or existing session and will raise the RuntimeException when transactions already exist. See more in MongoDB official docs [Transactions and Sessions](https://www.mongodb.com/docs/manual/core/transactions/#transactions-and-sessions)
1007+
```php
1008+
DB::beginTransaction();
1009+
User::create(['name' => 'john', 'age' => 20, 'title' => 'admin']);
1010+
1011+
// This call to start a nested transaction will raise a RuntimeException
1012+
DB::beginTransaction();
1013+
DB::collection('users')->where('name', 'john')->update(['age' => 20]);
1014+
DB::commit();
1015+
DB::rollBack();
1016+
```
1017+
9711018
Schema
9721019
------
9731020
The database driver also has (limited) schema builder support. You can easily manipulate collections and set indexes.

phpunit.xml.dist

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
<file>tests/QueryBuilderTest.php</file>
2020
<file>tests/QueryTest.php</file>
2121
</testsuite>
22+
<testsuite name="transaction">
23+
<file>tests/TransactionTest.php</file>
24+
</testsuite>
2225
<testsuite name="model">
2326
<file>tests/ModelTest.php</file>
2427
<file>tests/RelationsTest.php</file>
@@ -36,7 +39,7 @@
3639
</testsuites>
3740
<php>
3841
<env name="MONGODB_URI" value="mongodb://127.0.0.1/" />
39-
<env name="MONGO_DATABASE" value="unittest"/>
42+
<env name="MONGODB_DATABASE" value="unittest"/>
4043
<env name="MYSQL_HOST" value="mysql"/>
4144
<env name="MYSQL_PORT" value="3306"/>
4245
<env name="MYSQL_DATABASE" value="unittest"/>

src/Concerns/ManagesTransactions.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
namespace Jenssegers\Mongodb\Concerns;
4+
5+
use Closure;
6+
use MongoDB\Client;
7+
use MongoDB\Driver\Exception\RuntimeException;
8+
use MongoDB\Driver\Session;
9+
use function MongoDB\with_transaction;
10+
use Throwable;
11+
12+
/**
13+
* @see https://docs.mongodb.com/manual/core/transactions/
14+
*/
15+
trait ManagesTransactions
16+
{
17+
protected ?Session $session = null;
18+
19+
protected $transactions = 0;
20+
21+
/**
22+
* @return Client
23+
*/
24+
abstract public function getMongoClient();
25+
26+
public function getSession(): ?Session
27+
{
28+
return $this->session;
29+
}
30+
31+
private function getSessionOrCreate(): Session
32+
{
33+
if ($this->session === null) {
34+
$this->session = $this->getMongoClient()->startSession();
35+
}
36+
37+
return $this->session;
38+
}
39+
40+
private function getSessionOrThrow(): Session
41+
{
42+
$session = $this->getSession();
43+
44+
if ($session === null) {
45+
throw new RuntimeException('There is no active session.');
46+
}
47+
48+
return $session;
49+
}
50+
51+
/**
52+
* Starts a transaction on the active session. An active session will be created if none exists.
53+
*/
54+
public function beginTransaction(array $options = []): void
55+
{
56+
$this->getSessionOrCreate()->startTransaction($options);
57+
$this->transactions = 1;
58+
}
59+
60+
/**
61+
* Commit transaction in this session.
62+
*/
63+
public function commit(): void
64+
{
65+
$this->getSessionOrThrow()->commitTransaction();
66+
$this->transactions = 0;
67+
}
68+
69+
/**
70+
* Abort transaction in this session.
71+
*/
72+
public function rollBack($toLevel = null): void
73+
{
74+
$this->getSessionOrThrow()->abortTransaction();
75+
$this->transactions = 0;
76+
}
77+
78+
/**
79+
* Static transaction function realize the with_transaction functionality provided by MongoDB.
80+
*
81+
* @param int $attempts
82+
*/
83+
public function transaction(Closure $callback, $attempts = 1, array $options = []): mixed
84+
{
85+
$attemptsLeft = $attempts;
86+
$callbackResult = null;
87+
$throwable = null;
88+
89+
$callbackFunction = function (Session $session) use ($callback, &$attemptsLeft, &$callbackResult, &$throwable) {
90+
$attemptsLeft--;
91+
92+
if ($attemptsLeft < 0) {
93+
$session->abortTransaction();
94+
95+
return;
96+
}
97+
98+
// Catch, store, and re-throw any exception thrown during execution
99+
// of the callable. The last exception is re-thrown if the transaction
100+
// was aborted because the number of callback attempts has been exceeded.
101+
try {
102+
$callbackResult = $callback($this);
103+
} catch (Throwable $throwable) {
104+
throw $throwable;
105+
}
106+
};
107+
108+
with_transaction($this->getSessionOrCreate(), $callbackFunction, $options);
109+
110+
if ($attemptsLeft < 0 && $throwable) {
111+
throw $throwable;
112+
}
113+
114+
return $callbackResult;
115+
}
116+
}

src/Connection.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
use Illuminate\Database\Connection as BaseConnection;
66
use Illuminate\Support\Arr;
77
use InvalidArgumentException;
8+
use Jenssegers\Mongodb\Concerns\ManagesTransactions;
89
use MongoDB\Client;
910
use MongoDB\Database;
1011

1112
class Connection extends BaseConnection
1213
{
14+
use ManagesTransactions;
15+
1316
/**
1417
* The MongoDB database handler.
1518
*

src/Query/Builder.php

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,8 @@ public function getFresh($columns = [], $returnLazy = false)
346346
$options = array_merge($options, $this->options);
347347
}
348348

349+
$options = $this->inheritConnectionOptions($options);
350+
349351
// Execute aggregation
350352
$results = iterator_to_array($this->collection->aggregate($pipeline, $options));
351353

@@ -356,12 +358,10 @@ public function getFresh($columns = [], $returnLazy = false)
356358
// Return distinct results directly
357359
$column = isset($this->columns[0]) ? $this->columns[0] : '_id';
358360

361+
$options = $this->inheritConnectionOptions();
362+
359363
// Execute distinct
360-
if ($wheres) {
361-
$result = $this->collection->distinct($column, $wheres);
362-
} else {
363-
$result = $this->collection->distinct($column);
364-
}
364+
$result = $this->collection->distinct($column, $wheres ?: [], $options);
365365

366366
return new Collection($result);
367367
} // Normal query
@@ -407,6 +407,8 @@ public function getFresh($columns = [], $returnLazy = false)
407407
$options = array_merge($options, $this->options);
408408
}
409409

410+
$options = $this->inheritConnectionOptions($options);
411+
410412
// Execute query and get MongoCursor
411413
$cursor = $this->collection->find($wheres, $options);
412414

@@ -581,8 +583,9 @@ public function insert(array $values)
581583
$values = [$values];
582584
}
583585

584-
// Batch insert
585-
$result = $this->collection->insertMany($values);
586+
$options = $this->inheritConnectionOptions();
587+
588+
$result = $this->collection->insertMany($values, $options);
586589

587590
return 1 == (int) $result->isAcknowledged();
588591
}
@@ -592,7 +595,9 @@ public function insert(array $values)
592595
*/
593596
public function insertGetId(array $values, $sequence = null)
594597
{
595-
$result = $this->collection->insertOne($values);
598+
$options = $this->inheritConnectionOptions();
599+
600+
$result = $this->collection->insertOne($values, $options);
596601

597602
if (1 == (int) $result->isAcknowledged()) {
598603
if ($sequence === null) {
@@ -614,6 +619,8 @@ public function update(array $values, array $options = [])
614619
$values = ['$set' => $values];
615620
}
616621

622+
$options = $this->inheritConnectionOptions($options);
623+
617624
return $this->performUpdate($values, $options);
618625
}
619626

@@ -635,6 +642,8 @@ public function increment($column, $amount = 1, array $extra = [], array $option
635642
$query->orWhereNotNull($column);
636643
});
637644

645+
$options = $this->inheritConnectionOptions($options);
646+
638647
return $this->performUpdate($query, $options);
639648
}
640649

@@ -696,7 +705,10 @@ public function delete($id = null)
696705
}
697706

698707
$wheres = $this->compileWheres();
699-
$result = $this->collection->DeleteMany($wheres);
708+
$options = $this->inheritConnectionOptions();
709+
710+
$result = $this->collection->deleteMany($wheres, $options);
711+
700712
if (1 == (int) $result->isAcknowledged()) {
701713
return $result->getDeletedCount();
702714
}
@@ -721,7 +733,8 @@ public function from($collection, $as = null)
721733
*/
722734
public function truncate(): bool
723735
{
724-
$result = $this->collection->deleteMany([]);
736+
$options = $this->inheritConnectionOptions();
737+
$result = $this->collection->deleteMany([], $options);
725738

726739
return 1 === (int) $result->isAcknowledged();
727740
}
@@ -855,6 +868,8 @@ protected function performUpdate($query, array $options = [])
855868
$options['multiple'] = true;
856869
}
857870

871+
$options = $this->inheritConnectionOptions($options);
872+
858873
$wheres = $this->compileWheres();
859874
$result = $this->collection->UpdateMany($wheres, $query, $options);
860875
if (1 == (int) $result->isAcknowledged()) {
@@ -1249,6 +1264,18 @@ public function options(array $options)
12491264
return $this;
12501265
}
12511266

1267+
/**
1268+
* Apply the connection's session to options if it's not already specified.
1269+
*/
1270+
private function inheritConnectionOptions(array $options = []): array
1271+
{
1272+
if (! isset($options['session']) && ($session = $this->connection->getSession())) {
1273+
$options['session'] = $session;
1274+
}
1275+
1276+
return $options;
1277+
}
1278+
12521279
/**
12531280
* @inheritdoc
12541281
*/

0 commit comments

Comments
 (0)