diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 1dd4475290d6..0d7b9a2e944d 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -3,6 +3,7 @@ namespace Illuminate\Database\Concerns; use Closure; +use RuntimeException; use Throwable; trait ManagesTransactions @@ -42,6 +43,10 @@ public function transaction(Closure $callback, $attempts = 1) try { if ($this->transactions == 1) { $this->getPdo()->commit(); + + if ($this->transactionsManager) { + $this->transactionsManager->commit($this->getName()); + } } $this->transactions = max(0, $this->transactions - 1); @@ -78,6 +83,12 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma $this->transactions > 1) { $this->transactions--; + if ($this->transactionsManager) { + $this->transactionsManager->rollback( + $this->getName(), $this->transactions + ); + } + throw $e; } @@ -107,6 +118,12 @@ public function beginTransaction() $this->transactions++; + if ($this->transactionsManager) { + $this->transactionsManager->begin( + $this->getName(), $this->transactions + ); + } + $this->fireConnectionEvent('beganTransaction'); } @@ -176,6 +193,10 @@ public function commit() { if ($this->transactions == 1) { $this->getPdo()->commit(); + + if ($this->transactionsManager) { + $this->transactionsManager->commit($this->getName()); + } } $this->transactions = max(0, $this->transactions - 1); @@ -241,6 +262,12 @@ public function rollBack($toLevel = null) $this->transactions = $toLevel; + if ($this->transactionsManager) { + $this->transactionsManager->rollback( + $this->getName(), $this->transactions + ); + } + $this->fireConnectionEvent('rollingBack'); } @@ -275,6 +302,12 @@ protected function handleRollBackException(Throwable $e) { if ($this->causedByLostConnection($e)) { $this->transactions = 0; + + if ($this->transactionsManager) { + $this->transactionsManager->rollback( + $this->getName(), $this->transactions + ); + } } throw $e; @@ -289,4 +322,18 @@ public function transactionLevel() { return $this->transactions; } + + /** + * Execute the callback after a transaction commits. + * + * @return void + */ + public function afterCommit($callback) + { + if ($this->transactionsManager) { + return $this->transactionsManager->addCallback($callback); + } + + throw new RuntimeException('Transactions Manager has not been set.'); + } } diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index 98f12639713e..ea8c41e908e8 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -112,6 +112,13 @@ class Connection implements ConnectionInterface */ protected $transactions = 0; + /** + * The transaction manager instance. + * + * @var \Illuminate\Database\DatabaseTransactionsManager + */ + protected $transactionsManager; + /** * Indicates if changes have been made to the database. * @@ -1151,6 +1158,29 @@ public function unsetEventDispatcher() $this->events = null; } + /** + * Set the transaction manager instance on the connection. + * + * @param \Illuminate\Database\DatabaseTransactionsManager $manager + * @return $this + */ + public function setTransactionManager($manager) + { + $this->transactionsManager = $manager; + + return $this; + } + + /** + * Unset the event transaction manager for this connection. + * + * @return void + */ + public function unsetTransactionManager() + { + $this->transactionsManager = null; + } + /** * Determine if the connection is in a "dry run". * diff --git a/src/Illuminate/Database/DatabaseManager.php b/src/Illuminate/Database/DatabaseManager.php index d558d1665fc8..7f8fdf921fb3 100755 --- a/src/Illuminate/Database/DatabaseManager.php +++ b/src/Illuminate/Database/DatabaseManager.php @@ -174,6 +174,10 @@ protected function configure(Connection $connection, $type) $connection->setEventDispatcher($this->app['events']); } + if ($this->app->bound('db.transactions')) { + $connection->setTransactionManager($this->app['db.transactions']); + } + // Here we'll set a reconnector callback. This reconnector can be any callable // so we will set a Closure to reconnect from this manager with the name of // the connection, which will allow us to reconnect from the connections. diff --git a/src/Illuminate/Database/DatabaseServiceProvider.php b/src/Illuminate/Database/DatabaseServiceProvider.php index f64f8f2683d5..d8f87227ae07 100755 --- a/src/Illuminate/Database/DatabaseServiceProvider.php +++ b/src/Illuminate/Database/DatabaseServiceProvider.php @@ -71,6 +71,10 @@ protected function registerConnectionServices() $this->app->bind('db.connection', function ($app) { return $app['db']->connection(); }); + + $this->app->singleton('db.transactions', function ($app) { + return new DatabaseTransactionsManager(); + }); } /** diff --git a/src/Illuminate/Database/DatabaseTransactionRecord.php b/src/Illuminate/Database/DatabaseTransactionRecord.php new file mode 100755 index 000000000000..b4556d8fc305 --- /dev/null +++ b/src/Illuminate/Database/DatabaseTransactionRecord.php @@ -0,0 +1,73 @@ +connection = $connection; + $this->level = $level; + } + + /** + * Register a callback to be executed after committing. + * + * @param callable $callback + * @return void + */ + public function addCallback($callback) + { + $this->callbacks[] = $callback; + } + + /** + * Execute all of the callbacks. + * + * @return void + */ + public function executeCallbacks() + { + foreach ($this->callbacks as $callback) { + call_user_func($callback); + } + } + + /** + * Get all of the callbacks. + * + * @return array + */ + public function getCallbacks() + { + return $this->callbacks; + } +} diff --git a/src/Illuminate/Database/DatabaseTransactionsManager.php b/src/Illuminate/Database/DatabaseTransactionsManager.php new file mode 100755 index 000000000000..95462a331c8f --- /dev/null +++ b/src/Illuminate/Database/DatabaseTransactionsManager.php @@ -0,0 +1,96 @@ +transactions = collect(); + } + + /** + * Start a new database transaction. + * + * @param string $connection + * @param int $level + * @return void + */ + public function begin($connection, $level) + { + $this->transactions->push( + new DatabaseTransactionRecord($connection, $level) + ); + } + + /** + * Rollback the active database transaction. + * + * @param string $connection + * @param int $level + * @return void + */ + public function rollback($connection, $level) + { + $this->transactions = $this->transactions->reject(function ($transaction) use ($connection, $level) { + return $transaction->connection == $connection && + $transaction->level > $level; + })->values(); + } + + /** + * Commit the active database transaction. + * + * @param string $connection + * @return void + */ + public function commit($connection) + { + $this->transactions = $this->transactions->reject(function ($transaction) use ($connection) { + if ($transaction->connection == $connection) { + $transaction->executeCallbacks(); + + return true; + } + + return false; + })->values(); + } + + /** + * Register a transaction callback. + * + * @param callable $callback + * @return void. + */ + public function addCallback($callback) + { + if ($current = $this->transactions->last()) { + return $current->addCallback($callback); + } + + call_user_func($callback); + } + + /** + * Get all the transactions. + * + * @return \Illuminate\Support\Collection + */ + public function getTransactions() + { + return $this->transactions; + } +} diff --git a/tests/Database/DatabaseTransactionsManagerTest.php b/tests/Database/DatabaseTransactionsManagerTest.php new file mode 100755 index 000000000000..172a48e5a4a1 --- /dev/null +++ b/tests/Database/DatabaseTransactionsManagerTest.php @@ -0,0 +1,166 @@ +begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $this->assertCount(3, $manager->getTransactions()); + $this->assertEquals('default', $manager->getTransactions()[0]->connection); + $this->assertEquals(1, $manager->getTransactions()[0]->level); + $this->assertEquals('default', $manager->getTransactions()[1]->connection); + $this->assertEquals(2, $manager->getTransactions()[1]->level); + $this->assertEquals('admin', $manager->getTransactions()[2]->connection); + $this->assertEquals(1, $manager->getTransactions()[2]->level); + } + + public function testRollingBackTransactions() + { + $manager = (new DatabaseTransactionsManager()); + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->rollback('default', 1); + + $this->assertCount(2, $manager->getTransactions()); + + $this->assertEquals('default', $manager->getTransactions()[0]->connection); + $this->assertEquals(1, $manager->getTransactions()[0]->level); + + $this->assertEquals('admin', $manager->getTransactions()[1]->connection); + $this->assertEquals(1, $manager->getTransactions()[1]->level); + } + + public function testRollingBackTransactionsAllTheWay() + { + $manager = (new DatabaseTransactionsManager()); + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->rollback('default', 0); + + $this->assertCount(1, $manager->getTransactions()); + + $this->assertEquals('admin', $manager->getTransactions()[0]->connection); + $this->assertEquals(1, $manager->getTransactions()[0]->level); + } + + public function testCommittingTransactions() + { + $manager = (new DatabaseTransactionsManager()); + + $manager->begin('default', 1); + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->commit('default'); + + $this->assertCount(1, $manager->getTransactions()); + + $this->assertEquals('admin', $manager->getTransactions()[0]->connection); + $this->assertEquals(1, $manager->getTransactions()[0]->level); + } + + public function testCallbacksAreAddedToTheCurrentTransaction() + { + $callbacks = []; + + $manager = (new DatabaseTransactionsManager()); + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + }); + + $manager->begin('default', 2); + + $manager->begin('admin', 1); + + $manager->addCallback(function () use (&$callbacks) { + }); + + $this->assertCount(1, $manager->getTransactions()[0]->getCallbacks()); + $this->assertCount(0, $manager->getTransactions()[1]->getCallbacks()); + $this->assertCount(1, $manager->getTransactions()[2]->getCallbacks()); + } + + public function testCommittingTransactionsExecutesCallbacks() + { + $callbacks = []; + + $manager = (new DatabaseTransactionsManager()); + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $manager->begin('default', 2); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 2]; + }); + + $manager->begin('admin', 1); + + $manager->commit('default'); + + $this->assertCount(2, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + $this->assertEquals(['default', 2], $callbacks[1]); + } + + public function testCommittingExecutesOnlyCallbacksOfTheConnection() + { + $callbacks = []; + + $manager = (new DatabaseTransactionsManager()); + + $manager->begin('default', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $manager->begin('default', 2); + $manager->begin('admin', 1); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['admin', 1]; + }); + + $manager->commit('default'); + + $this->assertCount(1, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + } + + public function testCallbackIsExecutedIfNoTransactions() + { + $callbacks = []; + + $manager = (new DatabaseTransactionsManager()); + + $manager->addCallback(function () use (&$callbacks) { + $callbacks[] = ['default', 1]; + }); + + $this->assertCount(1, $callbacks); + $this->assertEquals(['default', 1], $callbacks[0]); + } +} diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php new file mode 100644 index 000000000000..2385d6d26313 --- /dev/null +++ b/tests/Database/DatabaseTransactionsTest.php @@ -0,0 +1,254 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'second_connection'); + + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + foreach (['default', 'second_connection'] as $connection) { + $this->schema($connection)->create('users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('value')->nullable(); + }); + } + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + foreach (['default', 'second_connection'] as $connection) { + $this->schema($connection)->drop('users'); + } + + m::close(); + } + + public function testTransactionIsRecordedAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager()); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('commit')->once()->with('default'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + } + + public function testTransactionIsRecordedAndCommittedUsingTheSeparateMethods() + { + $transactionManager = m::mock(new DatabaseTransactionsManager()); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('commit')->once()->with('default'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->beginTransaction(); + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + $this->connection()->commit(); + } + + public function testNestedTransactionIsRecordedAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager()); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('default', 2); + $transactionManager->shouldReceive('commit')->once()->with('default'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + }); + } + + public function testNestedTransactionIsRecordeForDifferentConnectionsdAndCommitted() + { + $transactionManager = m::mock(new DatabaseTransactionsManager()); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('second_connection', 1); + $transactionManager->shouldReceive('begin')->once()->with('second_connection', 2); + $transactionManager->shouldReceive('commit')->once()->with('default'); + $transactionManager->shouldReceive('commit')->once()->with('second_connection'); + + $this->connection()->setTransactionManager($transactionManager); + $this->connection('second_connection')->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection('second_connection')->transaction(function () { + $this->connection('second_connection')->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection('second_connection')->transaction(function () { + $this->connection('second_connection')->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + }); + }); + }); + } + + public function testTransactionIsRolledBack() + { + $transactionManager = m::mock(new DatabaseTransactionsManager()); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + try { + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + throw new \Exception; + }); + } catch (\Throwable $e) { + } + } + + public function testTransactionIsRolledBackUsingSeparateMethods() + { + $transactionManager = m::mock(new DatabaseTransactionsManager()); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + $this->connection()->beginTransaction(); + + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->rollBack(); + } + + public function testNestedTransactionsAreRolledBack() + { + $transactionManager = m::mock(new DatabaseTransactionsManager()); + $transactionManager->shouldReceive('begin')->once()->with('default', 1); + $transactionManager->shouldReceive('begin')->once()->with('default', 2); + $transactionManager->shouldReceive('rollback')->once()->with('default', 1); + $transactionManager->shouldReceive('rollback')->once()->with('default', 0); + $transactionManager->shouldNotReceive('commit'); + + $this->connection()->setTransactionManager($transactionManager); + + $this->connection()->table('users')->insert([ + 'name' => 'zain', 'value' => 1, + ]); + + try { + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + $this->connection()->transaction(function () { + $this->connection()->table('users')->where(['name' => 'zain'])->update([ + 'value' => 2, + ]); + + throw new \Exception; + }); + }); + } catch (\Throwable $e) { + } + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } + + public function connection($name = 'default') + { + return DB::connection($name); + } +}