Skip to content
This repository has been archived by the owner on May 26, 2023. It is now read-only.

Add SAVEPOINT (Fixes #56) #111

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions lib/Pheasant/Database/Mysqli/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Pheasant\Database\Dsn;
use Pheasant\Database\FilterChain;
use Pheasant\Database\MysqlPlatform;
use Pheasant\Database\Mysqli\TransactionStack;

/**
* A connection to a MySql database
Expand All @@ -19,6 +20,8 @@ class Connection
$_sequencePool,
$_strict,
$_selectedDatabase,
$_transactionStack,
$_events,
$_debug=false
;

Expand All @@ -43,7 +46,40 @@ 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->_transactionStack = new TransactionStack();

// Keep a copy of ourselves around
$self = $this;

// The beforeStartTransaction event is where we will BEGIN or SAVEPOINT
$this->_events->register('beforeStartTransaction', 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->transactionStack()->descend();
$self->execute($savepoint === null ? "BEGIN" : "SAVEPOINT {$savepoint}");
});

// The afterStartTransaction replaces commitTransaction, and is where
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a better name for this than afterStartTransaction? Isn't it fired just before the transaction completes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I used that even name for the sake of being backwards compatible if someone had bound to it. But I realize that before/afterStartTransaction events were never fired.

Thoughts on changing the name to just transaction. Which would give us beforeTransaction and afterTransaction?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds pretty good to me. Thoughts @lox?

// we will COMMIT or RELEASE
$this->_events->register('afterStartTransaction', function() use($self) {
// if `pop` returns null, then the stack is now empty
// so we should COMMIT instead of RELEASE.
$savepoint = $self->transactionStack()->pop();
$self->execute($savepoint === null ? "COMMIT" : "RELEASE SAVEPOINT {$savepoint}");
});

// The rollbackTransaction event is fired when we need to ROLLBACK
$this->_events->register('rollbackTransaction', function() use($self) {
// if `pop` returns null, then the stack is now empty
// so we should ROLLBACK instead of ROLLBACK_TO.
$savepoint = $self->transactionStack()->pop();
$self->execute($savepoint === null ? "ROLLBACK" : "ROLLBACK TO {$savepoint}");
});
}

/**
Expand Down Expand Up @@ -248,4 +284,22 @@ public function selectedDatabase()
{
return $this->_selectedDatabase;
}

/**
* Returns the Event object
* @return Event
*/
public function events()
{
return $this->_events;
}

/**
* Returns the transaction stack
* @return TransactionStack
*/
public function transactionStack()
{
return $this->_transactionStack;
}
}
24 changes: 16 additions & 8 deletions lib/Pheasant/Database/Mysqli/Transaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,19 @@ 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()
{
$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('StartTransaction', $this, function($self) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the capitalisation of this event name correct?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assumed that wrap did some first-letter-capitalization (so that trigger and wrap could use the same event name), but it appears that it doesn't. The convention seems to be that, when wrapping an event, the name is capitalized.

$this->events()->wrap(array('Delete'), $this, function($obj) use ($mapper) {

and
$event = $this->isSaved() ? 'Update' : 'Create';

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right, because before/after gets automatically prefixed to the event name? No worries.

$self->events()->trigger('startTransaction', $self->connection());
$self->events()->trigger('commitTransaction', $self->connection());
});
} catch (\Exception $e) {
$this->_connection->execute('ROLLBACK');
$this->_events->trigger('rollbackTransaction', $this->_connection);
throw $e;
}
Expand Down Expand Up @@ -67,17 +66,26 @@ 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
*/
public function deferEvents($events)
{
$this->_events
->register('startTransaction', function() use ($events) {
->register('beforeStartTransaction', function() use ($events) {
$events->cork();
})
->register('commitTransaction', function() use ($events) {
->register('afterStartTransaction', function() use ($events) {
$events->uncork();
})
->register('rollbackTransaction', function() use ($events) {
Expand Down
45 changes: 45 additions & 0 deletions lib/Pheasant/Database/Mysqli/TransactionStack.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace Pheasant\Database\Mysqli;

/**
* A Transaction Stack that keeps track of open savepoints
*/
class TransactionStack
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually a SavePointStack, or something like that? The current name implies that we push/pop Transaction objects.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, would be better as SavePointStack

{
private
$_transactionStack = array()
;

/**
* Get the depth of the stack
* @return integer
*/
public function depth()
{
return count($this->_transactionStack);
}

/**
* Decend deeper into the transaction stack and return a unique
* transaction savepoint name
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does MYSQL complain if you re-use savepoint names?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you reuse savepoint names, the old savepoint is replaced with the new one. I haven't tested, but I believe that would prevent rolling back transactions arbitrary levels > 1 and continuing with a parent transaction.

Relevant docs:
http://dev.mysql.com/doc/refman/5.0/en/savepoint.html

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, I meant say if you end up using the TransactionStack object a second time in a request, is it a problem that you reuse savepoint_1, etc.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect it's not.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would only have unintended consequences if you could simultaneously create two open transactions on the same connection. Though, I can't think of a way that would happen. Reuse after the close of another transaction is okay. And reuse across connections is okay as well.

* @return string
*/
public function descend()
{
$this->_transactionStack[] = current($this->_transactionStack) === false
? null
: 'savepoint_'.$this->depth();

return end($this->_transactionStack);
}

/**
* Pop off the last savepoint
* @return string
*/
public function pop()
{
return array_pop($this->_transactionStack);
}
}
118 changes: 95 additions & 23 deletions tests/Pheasant/Tests/TransactionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,102 @@

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(){
return 'blargh';
});

$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!');
});

$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) {
$connection->execute($sql);
});

$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) {
return $param;
}, '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();
Expand All @@ -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);
Expand All @@ -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');
}
}