diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 4728f2a8c..e2ff26b47 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -28,9 +28,19 @@ jobs: MYSQL_DATABASE: 'unittest' MYSQL_ROOT_PASSWORD: name: PHP v${{ matrix.php }} with Mongo v${{ matrix.mongodb }} - steps: - uses: actions/checkout@v1 + - name: Creating MongoDB replica + if: matrix.mongodb == '4.0' || matrix.mongodb == '4.2' + run: | + docker run --name mongodb_repl -e MONGO_INITDB_DATABASE=unittest --publish 27018:27018 --detach mongo:${{ matrix.mongodb }} mongod --port 27018 --replSet rs + until docker exec --tty mongodb_repl mongo 127.0.0.1:27018 --eval "db.serverStatus()"; do + sleep 1 + done + sudo docker exec --tty mongodb_repl mongo 127.0.0.1:27018 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27018\" }]})" + env: + MONGO_HOST: 0.0.0.0 + MONGO_REPL_HOST: 0.0.0.0 - name: Show PHP version run: php${{ matrix.php }} -v && composer -V - name: Show Docker version @@ -53,7 +63,9 @@ jobs: run: | ./vendor/bin/phpunit --coverage-clover coverage.xml env: + MONGO_VERSION: ${{ matrix.mongodb }}) MONGO_HOST: 0.0.0.0 + MONGO_REPL_HOST: 0.0.0.0 MYSQL_HOST: 0.0.0.0 MYSQL_PORT: 3307 - name: Send coveralls diff --git a/docker-compose.yml b/docker-compose.yml index c6f20163e..c5ca864b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,12 @@ services: - .:/code working_dir: /code depends_on: - - mongodb - - mysql + - mongodb + - mongodb_repl + - mongodb_repl_2 + - mongodb_repl_3 + - mongo_repl_init + - mysql mysql: container_name: mysql @@ -30,3 +34,62 @@ services: - 27017:27017 logging: driver: none + + mongodb_repl: + container_name: mongodb_repl + image: mongo:4.2.5 + restart: always + ports: + - "27018:27018" + entrypoint: [ "/usr/bin/mongod", "--quiet", "--bind_ip_all", "--port", "27018", "--replSet", "rs" ] + depends_on: + - mongodb_repl_2 + - mongodb_repl_3 + links: + - mongodb_repl_2:mongodb_repl_2 + - mongodb_repl_3:mongodb_repl_3 + logging: + driver: none + + mongodb_repl_2: + container_name: mongodb_repl_2 + image: mongo:4.2.5 + restart: always + ports: + - "27019:27018" + entrypoint: [ "/usr/bin/mongod", "--quiet", "--bind_ip_all", "--port", "27018", "--replSet", "rs" ] + depends_on: + - mongodb_repl_3 + logging: + driver: none + + mongodb_repl_3: + container_name: mongodb_repl_3 + image: mongo:4.2.5 + restart: always + ports: + - "27020:27018" + entrypoint: [ "/usr/bin/mongod", "--quiet", "--bind_ip_all", "--port", "27018", "--replSet", "rs" ] + logging: + driver: none + + mongo_repl_init: + image: mongo:4.2.5 + depends_on: + - mongodb_repl + - mongodb_repl_2 + - mongodb_repl_3 + links: + - mongodb_repl:mongodb_repl + - mongodb_repl_2:mongodb_repl_2 + - mongodb_repl_3:mongodb_repl_3 + environment: + - MONGO1=mongodb_repl + - MONGO2=mongodb_repl_2 + - MONGO3=mongodb_repl_3 + - RS=rs + volumes: + - ./:/scripts + entrypoint: [ "sh", "-c", "/scripts/mongo-repl-init.sh" ] + logging: + driver: none diff --git a/mongo-repl-init.sh b/mongo-repl-init.sh new file mode 100644 index 000000000..9c184d51a --- /dev/null +++ b/mongo-repl-init.sh @@ -0,0 +1,39 @@ +#!/bin/bash +mongodb1=`getent hosts ${MONGO1} | awk '{ print $1 }'` + +port=${PORT:-27018} + +echo "Waiting for startup.." +until mongo --host ${mongodb1}:${port} --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 2)' &>/dev/null; do + printf '.' + sleep 1 +done + +echo "Started.." + +echo setup.sh time now: `date +"%T" ` +mongo --host ${mongodb1}:${port} < tests/RelationsTest.php + + tests/TransactionTest.php + tests/ValidationTest.php @@ -47,6 +50,8 @@ + + diff --git a/src/Jenssegers/Mongodb/Connection.php b/src/Jenssegers/Mongodb/Connection.php index b5ba23762..ecdd26edd 100644 --- a/src/Jenssegers/Mongodb/Connection.php +++ b/src/Jenssegers/Mongodb/Connection.php @@ -9,6 +9,11 @@ class Connection extends BaseConnection { + /** + * The MongoDB session handler. + */ + protected $session; + /** * The MongoDB database handler. * @var \MongoDB\Database @@ -267,6 +272,40 @@ protected function getDefaultSchemaGrammar() return new Schema\Grammar(); } + public function beginTransaction(array $options = []) + { + if (!$this->getSession()) { + $this->session = $this->getMongoClient()->startSession(); + $this->session->startTransaction($options); + } + } + + public function commit() + { + if ($this->getSession()) { + $this->session->commitTransaction(); + $this->clearSession(); + } + } + + public function rollBack($toLevel = null) + { + if ($this->getSession()) { + $this->session->abortTransaction(); + $this->clearSession(); + } + } + + protected function clearSession() + { + $this->session = null; + } + + public function getSession() + { + return $this->session; + } + /** * Dynamically pass methods to the connection. * @param string $method diff --git a/src/Jenssegers/Mongodb/Query/Builder.php b/src/Jenssegers/Mongodb/Query/Builder.php index 7c5c973ba..1451155b7 100644 --- a/src/Jenssegers/Mongodb/Query/Builder.php +++ b/src/Jenssegers/Mongodb/Query/Builder.php @@ -562,8 +562,20 @@ public function insert(array $values) $values = [$values]; } + $options = $this->session(); + + // Check if table exists for multi-document transaction, otherwise create table. + if (!empty($options)) { + $schemaBuilder = $this->connection->getSchemaBuilder(); + $tableName = $this->collection->getCollectionName(); + + if (!$schemaBuilder->hasTable($tableName)) { + $schemaBuilder->create($tableName); + } + } + // Batch insert - $result = $this->collection->insertMany($values); + $result = $this->collection->insertMany($values, $options); return (1 == (int) $result->isAcknowledged()); } @@ -573,7 +585,9 @@ public function insert(array $values) */ public function insertGetId(array $values, $sequence = null) { - $result = $this->collection->insertOne($values); + $options = $this->session(); + + $result = $this->collection->insertOne($values, $options); if (1 == (int) $result->isAcknowledged()) { if ($sequence === null) { @@ -595,7 +609,7 @@ public function update(array $values, array $options = []) $values = ['$set' => $values]; } - return $this->performUpdate($values, $options); + return $this->performUpdate($values, array_merge($options, $this->session())); } /** @@ -616,7 +630,7 @@ public function increment($column, $amount = 1, array $extra = [], array $option $query->orWhereNotNull($column); }); - return $this->performUpdate($query, $options); + return $this->performUpdate($query, array_merge($options, $this->session())); } /** @@ -624,7 +638,7 @@ public function increment($column, $amount = 1, array $extra = [], array $option */ public function decrement($column, $amount = 1, array $extra = [], array $options = []) { - return $this->increment($column, -1 * $amount, $extra, $options); + return $this->increment($column, -1 * $amount, $extra, array_merge($options, $this->session())); } /** @@ -674,8 +688,9 @@ public function delete($id = null) $this->where('_id', '=', $id); } + $options = $this->session(); $wheres = $this->compileWheres(); - $result = $this->collection->DeleteMany($wheres); + $result = $this->collection->DeleteMany($wheres, $options); if (1 == (int) $result->isAcknowledged()) { return $result->getDeletedCount(); } @@ -704,7 +719,7 @@ public function truncate() 'typeMap' => ['root' => 'object', 'document' => 'object'], ]; - $result = $this->collection->drop($options); + $result = $this->collection->drop(array_merge($options, $this->session())); return (1 == (int) $result->ok); } @@ -833,7 +848,7 @@ protected function performUpdate($query, array $options = []) } $wheres = $this->compileWheres(); - $result = $this->collection->UpdateMany($wheres, $query, $options); + $result = $this->collection->UpdateMany($wheres, $query, array_merge($options, $this->session())); if (1 == (int) $result->isAcknowledged()) { return $result->getModifiedCount() ? $result->getModifiedCount() : $result->getUpsertedCount(); } @@ -1153,6 +1168,15 @@ public function options(array $options) return $this; } + protected function session(array $options = []) + { + if ($session = $this->connection->getSession()) { + $options['session'] = $this->connection->getSession(); + } + + return $options; + } + /** * @inheritdoc */ diff --git a/tests/TestCase.php b/tests/TestCase.php index 4c01d5755..0417665c3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -51,6 +51,7 @@ protected function getEnvironmentSetUp($app) $app['config']->set('database.default', 'mongodb'); $app['config']->set('database.connections.mysql', $config['connections']['mysql']); $app['config']->set('database.connections.mongodb', $config['connections']['mongodb']); + $app['config']->set('database.connections.mongodb_repl', $config['connections']['mongodb_repl']); $app['config']->set('database.connections.mongodb2', $config['connections']['mongodb']); $app['config']->set('database.connections.dsn_mongodb', $config['connections']['dsn_mongodb']); $app['config']->set('database.connections.dsn_mongodb_db', $config['connections']['dsn_mongodb_db']); diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php new file mode 100644 index 000000000..514d68f5a --- /dev/null +++ b/tests/TransactionTest.php @@ -0,0 +1,158 @@ +markTestSkipped('MongoDB with version below 4 is not supported for transactions'); + } + + $config = require 'config/database.php'; + + $app['config']->set('database.connections.'.$this->connection, $config['connections'][$this->connection]); + $app['config']->set('database.default', $this->connection); + } + + public function tearDown(): void + { + parent::setUp(); + + User::on($this->connection)->truncate(); + DB::collection('users')->truncate(); + Schema::drop('users'); + } + + public function testCommitTransaction(): void + { + /** + * Insert Commit + */ + try { + DB::beginTransaction(); + + User::on($this->connection)->insert([ + 'name' => 'John Doe' + ]); + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + + $this->assertTrue(false); + } + + $this->assertTrue(User::on($this->connection)->where('name', 'John Doe')->exists()); + + /** + * Update Commit + */ + try { + DB::beginTransaction(); + + User::on($this->connection)->where('name', 'John Doe')->update([ + 'name' => 'Jane Doe' + ]); + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + + $this->assertTrue(false); + } + + $this->assertTrue(User::on($this->connection)->where('name', 'Jane Doe')->exists()); + + /** + * Delete Commit + */ + try { + DB::beginTransaction(); + + User::on($this->connection)->where('name', 'Jane Doe')->delete(); + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + + $this->assertTrue(false); + } + + $this->assertFalse(User::on($this->connection)->where('name', 'Jane Doe')->exists()); + } + + public function testRollbackTransaction(): void + { + try { + DB::beginTransaction(); + + User::on($this->connection)->insert([ + 'name' => 'John Doe' + ]); + + DB::rollBack(); + } catch (Exception $e) { + DB::rollBack(); + + $this->assertTrue(false); + } + + $this->assertFalse(User::on($this->connection)->where('name', 'John Doe')->exists()); + + try { + DB::beginTransaction(); + + User::on($this->connection)->insert([ + 'name' => 'John Doe' + ]); + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + + $this->assertTrue(false); + } + + $this->assertTrue(User::on($this->connection)->where('name', 'John Doe')->exists()); + + try { + DB::beginTransaction(); + + User::on($this->connection)->where('name', 'John Doe')->update([ + 'name' => 'Jane Doe' + ]); + + DB::rollBack(); + } catch (Exception $e) { + DB::rollBack(); + + $this->assertTrue(false); + } + + $this->assertTrue(User::on($this->connection)->where('name', 'John Doe')->exists()); + + try { + DB::beginTransaction(); + + User::on($this->connection)->where('name', 'John Doe')->delete(); + + DB::rollBack(); + } catch (Exception $e) { + DB::rollBack(); + + $this->assertTrue(false); + } + + $this->assertTrue(User::on($this->connection)->where('name', 'John Doe')->exists()); + } +} diff --git a/tests/config/database.php b/tests/config/database.php index 556b71d33..94ba6916a 100644 --- a/tests/config/database.php +++ b/tests/config/database.php @@ -1,8 +1,10 @@ env('MONGO_DATABASE', 'unittest'), ], + 'mongodb_repl' => [ + 'name' => 'mongodb_repl', + 'driver' => 'mongodb', + 'host' => $mongoReplHost, + 'port' => $mongoReplPort, + 'database' => env('MONGO_DATABASE', 'unittest'), + 'options' => [ + 'replicaSet' => 'rs', + 'serverSelectionTryOnce' => false, + ], + ], + 'dsn_mongodb' => [ 'driver' => 'mongodb', 'dsn' => "mongodb://$mongoHost:$mongoPort",