diff --git a/lib/Pheasant/Database/Mysqli/Connection.php b/lib/Pheasant/Database/Mysqli/Connection.php index 5635d3a..b78d0b4 100755 --- a/lib/Pheasant/Database/Mysqli/Connection.php +++ b/lib/Pheasant/Database/Mysqli/Connection.php @@ -5,6 +5,7 @@ use Pheasant\Database\Dsn; use Pheasant\Database\FilterChain; use Pheasant\Database\MysqlPlatform; +use Pheasant\Database\Mysqli\SavePointStack; /** * A connection to a MySql database @@ -19,6 +20,8 @@ class Connection $_sequencePool, $_strict, $_selectedDatabase, + $_savePointStack, + $_events, $_debug=false ; @@ -43,7 +46,45 @@ public function __construct(Dsn $dsn) if(!empty($this->_dsn->database)) $this->_selectedDatabase = $this->_dsn->database; + $this->_events = new \Pheasant\Events(); $this->_debug = getenv('PHEASANT_DEBUG'); + + // Setup a transaction stack + $this->_savePointStack = new SavePointStack(); + + // Keep a copy of ourselves around + $self = $this; + + // The beforeTransaction event is where we will BEGIN or SAVEPOINT + $this->_events->register('beforeTransaction', function() use($self) { + // if `descend` returns null, there is nothing on the stack + // so we should BEGIN a transaction instead of a numbered SAVEPOINT. + $savepoint = $self->savePointStack()->descend(); + $self->execute($savepoint === null ? "BEGIN" : "SAVEPOINT {$savepoint}"); + }); + + // The afterTransaction replaces commitTransaction, and is where + // we will COMMIT or RELEASE + $this->_events->register('afterTransaction', function() use($self) { + // if `pop` returns null, then the stack is now empty + // so we should COMMIT instead of RELEASE. + $savepoint = $self->savePointStack()->pop(); + + // If the savepoint is null, then we are committing the + // transaction, and should fire the appropriate events. + $callback_name = $savepoint === null ? 'Commit' : 'SavePoint'; + $self->events()->wrap($callback_name, $self, function($self) use($savepoint) { + $self->execute($savepoint === null ? "COMMIT" : "RELEASE SAVEPOINT {$savepoint}"); + }); + }); + + // The rollbackTransaction event is fired when we need to ROLLBACK + $this->_events->register('rollback', function() use($self) { + // if `pop` returns null, then the stack is now empty + // so we should ROLLBACK instead of ROLLBACK_TO. + $savepoint = $self->savePointStack()->pop(); + $self->execute($savepoint === null ? "ROLLBACK" : "ROLLBACK TO {$savepoint}"); + }); } /** @@ -248,4 +289,22 @@ public function selectedDatabase() { return $this->_selectedDatabase; } + + /** + * Returns the Event object + * @return Event + */ + public function events() + { + return $this->_events; + } + + /** + * Returns the transaction stack + * @return SavePointStack + */ + public function savePointStack() + { + return $this->_savePointStack; + } } diff --git a/lib/Pheasant/Database/Mysqli/SavePointStack.php b/lib/Pheasant/Database/Mysqli/SavePointStack.php new file mode 100644 index 0000000..5da01a6 --- /dev/null +++ b/lib/Pheasant/Database/Mysqli/SavePointStack.php @@ -0,0 +1,45 @@ +_savePointStack); + } + + /** + * Decend deeper into the transaction stack and return a unique + * transaction savepoint name + * @return string + */ + public function descend() + { + $this->_savePointStack[] = current($this->_savePointStack) === false + ? null + : 'savepoint_'.$this->depth(); + + return end($this->_savePointStack); + } + + /** + * Pop off the last savepoint + * @return string + */ + public function pop() + { + return array_pop($this->_savePointStack); + } +} diff --git a/lib/Pheasant/Database/Mysqli/Transaction.php b/lib/Pheasant/Database/Mysqli/Transaction.php index 8033ec3..ed67f02 100755 --- a/lib/Pheasant/Database/Mysqli/Transaction.php +++ b/lib/Pheasant/Database/Mysqli/Transaction.php @@ -20,7 +20,7 @@ class Transaction public function __construct($connection=null) { $this->_connection = $connection ?: \Pheasant::instance()->connection(); - $this->_events = new \Pheasant\Events(); + $this->_events = new \Pheasant\Events(array(), $this->_connection->events()); } public function execute() @@ -28,13 +28,11 @@ public function execute() $this->results = array(); try { - $this->_connection->execute('BEGIN'); - $this->_events->trigger('startTransaction', $this->_connection); - $this->_connection->execute('COMMIT'); - $this->_events->trigger('commitTransaction', $this->_connection); + $this->_events->wrap('Transaction', $this, function($self) { + $self->events()->trigger('transaction', $self->connection()); + }); } catch (\Exception $e) { - $this->_connection->execute('ROLLBACK'); - $this->_events->trigger('rollbackTransaction', $this->_connection); + $this->_events->trigger('rollback', $this->_connection); throw $e; } @@ -51,7 +49,7 @@ public function callback($callback) $args = array_slice(func_get_args(),1); // use an event handler to dispatch to the callback - $this->_events->register('startTransaction', function($event, $connection) use ($t, $callback, $args) { + $this->_events->register('transaction', function($event, $connection) use ($t, $callback, $args) { $t->results []= call_user_func_array($callback, $args); }); @@ -67,6 +65,15 @@ public function events() return $this->_events; } + /** + * Get the connection object + * @return Connection + */ + public function connection() + { + return $this->_connection; + } + /** * Links another Events object such that events in it are corked until either commit/rollback and then uncorked * @chainable @@ -74,16 +81,19 @@ public function events() public function deferEvents($events) { $this->_events - ->register('startTransaction', function() use ($events) { + ->registerOne('beforeTransaction', function() use ($events) { $events->cork(); }) - ->register('commitTransaction', function() use ($events) { - $events->uncork(); - }) - ->register('rollbackTransaction', function() use ($events) { + ->registerOne('rollback', function() use ($events) { $events->discard()->uncork(); }) ; + + $this->connection()->events()->registerOne('afterCommit', function() use ($events) { + $events->uncork(); + }); + + return $this; } /** * Creates a transaction and optionally execute a transaction diff --git a/lib/Pheasant/Events.php b/lib/Pheasant/Events.php index 63fb608..d731dc8 100755 --- a/lib/Pheasant/Events.php +++ b/lib/Pheasant/Events.php @@ -10,6 +10,7 @@ class Events { private $_handlers = array(), + $_oneHandlers = array(), $_queue = array(), $_corked = false, $_upstream @@ -77,9 +78,18 @@ private function _callbacksFor($event) { $events = isset($this->_handlers[$event]) ? $this->_handlers[$event] : array(); + if(isset($this->_oneHandlers[$event])) + $events = array_merge($events, $this->_oneHandlers[$event]); + + if(isset($this->_oneHandlers['*'])) + $events = array_merge($events, $this->_oneHandlers['*']); + if(isset($this->_handlers['*'])) $events = array_merge($events, $this->_handlers['*']); + // Clear the events that should only be run once + $this->_oneHandlers[$event] = array(); + return $events; } @@ -94,6 +104,19 @@ public function register($event, $callback) return $this; } + /** + * Registers a handler for an event that is immediately removed + * after it is executed. + * @chainable + */ + public function registerOne($event, $callback) + { + $this->_oneHandlers[$event][] = $callback; + + return $this; + } + + /** * Unregisters an event handler based on event, or all * @chainable diff --git a/tests/Pheasant/Tests/EventsTest.php b/tests/Pheasant/Tests/EventsTest.php index d4ff547..8a6e155 100755 --- a/tests/Pheasant/Tests/EventsTest.php +++ b/tests/Pheasant/Tests/EventsTest.php @@ -197,4 +197,19 @@ public function testSaveInAfterCreateDoesntLoop() $do->test = "blargh"; $do->save(); } + + public function testRegisterOneEventBinding() + { + $fired = array(); + + $events = new Events(); + $events->registerOne('fireOnceEvent', function() use(&$fired) { + $fired[] = 1; + }); + + $events->trigger('fireOnceEvent', new \stdClass()); + $events->trigger('fireOnceEvent', new \stdClass()); + + $this->assertCount(1, $fired); + } } diff --git a/tests/Pheasant/Tests/TransactionTest.php b/tests/Pheasant/Tests/TransactionTest.php index 0ba6697..0fea4a3 100755 --- a/tests/Pheasant/Tests/TransactionTest.php +++ b/tests/Pheasant/Tests/TransactionTest.php @@ -2,14 +2,30 @@ namespace Pheasant\Tests\Transaction; use \Pheasant\Database\Mysqli\Transaction; +use \Pheasant\Tests\Examples\Animal; class TransactionTest extends \Pheasant\Tests\MysqlTestCase { + public function setUp() + { + parent::setUp(); + + $migrator = new \Pheasant\Migrate\Migrator(); + $migrator + ->create('animal', Animal::schema()) + ; + + $this->queries = array(); + $test = $this; + $this->connection()->filterChain()->onQuery(function($sql) use($test) { + $test->queries []= $sql; + return $sql; + }); + } + public function testBasicSuccessfulTransaction() { - $connection = \Mockery::mock('\Pheasant\Database\Mysqli\Connection'); - $connection->shouldReceive('execute')->with('BEGIN')->once(); - $connection->shouldReceive('execute')->with('COMMIT')->once(); + $connection = $this->connection(); $transaction = new Transaction($connection); $transaction->callback(function(){ @@ -17,16 +33,18 @@ public function testBasicSuccessfulTransaction() }); $transaction->execute(); + + $this->assertEquals(count($this->queries), 2); + $this->assertEquals($this->queries[0], 'BEGIN'); + $this->assertEquals($this->queries[1], 'COMMIT'); + $this->assertEquals(count($transaction->results), 1); $this->assertEquals($transaction->results[0], 'blargh'); } public function testExceptionsCauseRollback() { - $connection = \Mockery::mock('\Pheasant\Database\Mysqli\Connection'); - $connection->shouldReceive('execute')->with('BEGIN')->once(); - $connection->shouldReceive('execute')->with('ROLLBACK')->once(); - + $connection = $this->connection(); $transaction = new Transaction($connection); $transaction->callback(function(){ throw new \Exception('Eeeek!'); @@ -34,15 +52,16 @@ public function testExceptionsCauseRollback() $this->setExpectedException('\Exception'); $transaction->execute(); + + $this->assertEquals(count($this->queries), 2); + $this->assertEquals($this->queries[0], 'BEGIN'); + $this->assertEquals($this->queries[1], 'ROLLBACK'); } public function testCallbacksWithConnectionCalls() { - $sql = "SELECT * FROM table"; - $connection = \Mockery::mock('\Pheasant\Database\Mysqli\Connection'); - $connection->shouldReceive('execute')->with('BEGIN')->once(); - $connection->shouldReceive('execute')->with($sql)->once(); - $connection->shouldReceive('execute')->with('COMMIT')->once(); + $sql = "SELECT * FROM animal"; + $connection = $this->connection(); $transaction = new Transaction($connection); $transaction->callback(function() use ($connection, $sql) { @@ -50,13 +69,16 @@ public function testCallbacksWithConnectionCalls() }); $transaction->execute(); + + $this->assertEquals(count($this->queries), 3); + $this->assertEquals($this->queries[0], 'BEGIN'); + $this->assertEquals($this->queries[1], $sql); + $this->assertEquals($this->queries[2], 'COMMIT'); } public function testCallbacksWithParams() { - $connection = \Mockery::mock('\Pheasant\Database\Mysqli\Connection'); - $connection->shouldReceive('execute')->with('BEGIN')->once(); - $connection->shouldReceive('execute')->with('COMMIT')->once(); + $connection = $this->connection(); $transaction = new Transaction($connection); $transaction->callback(function($param) { @@ -64,15 +86,18 @@ public function testCallbacksWithParams() }, 'blargh'); $transaction->execute(); + $this->assertEquals(count($transaction->results), 1); $this->assertEquals($transaction->results[0], 'blargh'); + + $this->assertEquals(count($this->queries), 2); + $this->assertEquals($this->queries[0], 'BEGIN'); + $this->assertEquals($this->queries[1], 'COMMIT'); } public function testDeferEventsFireOnCommit() { - $connection = \Mockery::mock('\Pheasant\Database\Mysqli\Connection'); - $connection->shouldReceive('execute')->with('BEGIN')->once(); - $connection->shouldReceive('execute')->with('COMMIT')->once(); + $connection = $this->connection(); $events = \Mockery::mock(); $events->shouldReceive('cork')->once(); @@ -85,13 +110,15 @@ public function testDeferEventsFireOnCommit() }); $transaction->execute(); + + $this->assertEquals(count($this->queries), 2); + $this->assertEquals($this->queries[0], 'BEGIN'); + $this->assertEquals($this->queries[1], 'COMMIT'); } public function testDeferEventsFireOnRollback() { - $connection = \Mockery::mock('\Pheasant\Database\Mysqli\Connection'); - $connection->shouldReceive('execute')->with('BEGIN')->once(); - $connection->shouldReceive('execute')->with('ROLLBACK')->once(); + $connection = $this->connection(); $events = \Mockery::mock(); $events->shouldReceive('cork')->once()->andReturn($events); @@ -104,7 +131,52 @@ public function testDeferEventsFireOnRollback() throw new \Exception("Llamas :( :)"); }); - $this->setExpectedException('\Exception'); - $transaction->execute(); + try { + $transaction->execute(); + } catch(\Exception $e) { + $exception = $e; + } + + $this->assertInstanceOf('\Exception', $exception); + $this->assertEquals(count($this->queries), 2); + $this->assertEquals($this->queries[0], 'BEGIN'); + $this->assertEquals($this->queries[1], 'ROLLBACK'); + } + + public function testNestedDeferEventsFireOnRollback() + { + $connection = $this->connection(); + + $events = \Mockery::mock(); + $events->shouldReceive('cork')->once()->andReturn($events); + $events->shouldReceive('discard')->once()->andReturn($events); + $events->shouldReceive('uncork')->once()->andReturn($events); + + $transaction = new Transaction($connection); + $transaction->deferEvents($events); + $transaction->callback(function() use($connection){ + $t = new Transaction($connection); + $t->callback(function() use($connection){ + $t = new Transaction($connection); + $t->callback(function() use($connection){ + throw new \Exception("Llamas :( :)"); + })->execute(); + })->execute(); + }); + + try { + $transaction->execute(); + } catch(\Exception $e) { + $exception = $e; + } + + $this->assertInstanceOf('\Exception', $exception); + $this->assertEquals(count($this->queries), 6); + $this->assertEquals($this->queries[0], 'BEGIN'); + $this->assertEquals($this->queries[1], 'SAVEPOINT savepoint_1'); + $this->assertEquals($this->queries[2], 'SAVEPOINT savepoint_2'); + $this->assertEquals($this->queries[3], 'ROLLBACK TO savepoint_2'); + $this->assertEquals($this->queries[4], 'ROLLBACK TO savepoint_1'); + $this->assertEquals($this->queries[5], 'ROLLBACK'); } }