diff --git a/core/DB/DB.class.php b/core/DB/DB.class.php index 3477408563..2bbf12992c 100644 --- a/core/DB/DB.class.php +++ b/core/DB/DB.class.php @@ -35,6 +35,10 @@ abstract class DB * flag to indicate whether we're in transaction **/ private $transaction = false; + /** + * @var list of all started savepoints + */ + private $savepointList = array(); private $queue = array(); private $toQueue = false; @@ -140,6 +144,7 @@ public function commit() $this->queryRaw("commit;\n"); $this->transaction = false; + $this->savepointList = array(); return $this; } @@ -155,6 +160,7 @@ public function rollback() $this->queryRaw("rollback;\n"); $this->transaction = false; + $this->savepointList = array(); return $this; } @@ -222,6 +228,69 @@ public function isQueueActive() } //@} + /** + * @param string $savepointName + * @return DB + */ + public function savepointBegin($savepointName) + { + $this->assertSavePointName($savepointName); + if (!$this->inTransaction()) + throw new DatabaseException('To use savepoint begin transaction first'); + + $query = 'savepoint '.$savepointName; + if ($this->toQueue) + $this->queue[] = $query; + else + $this->queryRaw("{$query};\n"); + + return $this->addSavepoint($savepointName); + } + + /** + * @param string $savepointName + * @return DB + */ + public function savepointRelease($savepointName) + { + $this->assertSavePointName($savepointName); + if (!$this->inTransaction()) + throw new DatabaseException('To release savepoint need first begin transaction'); + + if (!$this->checkSavepointExist($savepointName)) + throw new DatabaseException("savepoint with name '{$savepointName}' nor registered"); + + $query = 'release savepoint '.$savepointName; + if ($this->toQueue) + $this->queue[] = $query; + else + $this->queryRaw("{$query};\n"); + + return $this->dropSavepoint($savepointName); + } + + /** + * @param string $savepointName + * @return DB + */ + public function savepointRollback($savepointName) + { + $this->assertSavePointName($savepointName); + if (!$this->inTransaction()) + throw new DatabaseException('To rollback savepoint need first begin transaction'); + + if (!$this->checkSavepointExist($savepointName)) + throw new DatabaseException("savepoint with name '{$savepointName}' nor registered"); + + $query = 'rollback to savepoint '.$savepointName; + if ($this->toQueue) + $this->queue[] = $query; + else + $this->queryRaw("{$query};\n"); + + return $this->dropSavepoint($savepointName); + } + /** * base queries **/ @@ -331,5 +400,41 @@ public function setEncoding($encoding) return $this; } + + /** + * @param string $savepointName + * @return DB + */ + private function addSavepoint($savepointName) + { + if ($this->checkSavepointExist($savepointName)) + throw new DatabaseException("savepoint with name '{$savepointName}' already marked"); + + $this->savepointList[$savepointName] = true; + return $this; + } + + /** + * @param string $savepointName + * @return DB + */ + private function dropSavepoint($savepointName) + { + if (!$this->checkSavepointExist($savepointName)) + throw new DatabaseException("savepoint with name '{$savepointName}' nor registered"); + + unset($this->savepointList[$savepointName]); + return $this; + } + + private function checkSavepointExist($savepointName) + { + return isset($this->savepointList[$savepointName]); + } + + private function assertSavePointName($savepointName) + { + Assert::isEqual(1, preg_match('~^[A-Za-z][A-Za-z0-9]*$~iu', $savepointName)); + } } ?> \ No newline at end of file diff --git a/core/DB/Transaction/InnerTransaction.class.php b/core/DB/Transaction/InnerTransaction.class.php new file mode 100644 index 0000000000..9b38811a6f --- /dev/null +++ b/core/DB/Transaction/InnerTransaction.class.php @@ -0,0 +1,113 @@ +db = $database; + } elseif ($database instanceof GenericDAO) { + $this->db = DBPool::getByDao($database); + } else { + throw new WrongStateException( + '$database must be instance of DB or GenericDAO' + ); + } + + $this->beginTransaction($level, $mode); + } + + public function commit() + { + $this->assertFinished(); + $this->finished = true; + if (!$this->savepointName) { + $this->db->commit(); + } else { + $this->db->savepointRelease($this->savepointName); + } + } + + public function rollback() + { + $this->assertFinished(); + $this->finished = true; + if (!$this->savepointName) { + $this->db->rollback(); + } else { + $this->db->savepointRollback($this->savepointName); + } + } + + private function beginTransaction( + IsolationLevel $level = null, + AccessMode $mode = null + ) + { + $this->assertFinished(); + if (!$this->db->inTransaction()) { + $this->db->begin($level, $mode); + } else { + $this->savepointName = $this->createSavepointName(); + $this->db->savepointBegin($this->savepointName); + } + } + + private function assertFinished() + { + if ($this->finished) + throw new WrongStateException('This Transaction already finished'); + } + + private static function createSavepointName() + { + static $i = 1; + return 'innerSavepoint'.($i++); + } + } +?> \ No newline at end of file diff --git a/core/DB/Transaction/InnerTransactionWrapper.class.php b/core/DB/Transaction/InnerTransactionWrapper.class.php new file mode 100644 index 0000000000..c9574d1ba8 --- /dev/null +++ b/core/DB/Transaction/InnerTransactionWrapper.class.php @@ -0,0 +1,134 @@ +db = $db; + return $this; + } + + /** + * @param StorableDAO $dao + * @return InnerTransactionWrapper + */ + public function setDao(StorableDAO $dao) + { + $this->dao = $dao; + return $this; + } + + /** + * @param collable $function + * @return InnerTransactionWrapper + */ + public function setFunction($function) + { + Assert::isTrue(is_callable($function, false), '$function must be callable'); + $this->function = $function; + return $this; + } + + /** + * @param collable $function + * @return InnerTransactionWrapper + */ + public function setExceptionFunction($function) + { + Assert::isTrue(is_callable($function, false), '$function must be callable'); + $this->exceptionFunction = $function; + return $this; + } + + /** + * @param IsolationLevel $level + * @return InnerTransactionWrapper + */ + public function setLevel(IsolationLevel $level) + { + $this->level = $level; + return $this; + } + + /** + * @param AccessMode $mode + * @return InnerTransactionWrapper + */ + public function setMode(AccessMode $mode) + { + $this->mode = $mode; + return $this; + } + + public function run() + { + Assert::isTrue(!is_null($this->dao) || !is_null($this->db), 'set first dao or db'); + Assert::isNotNull($this->function, 'set first function'); + + $transaction = InnerTransaction::begin( + $this->dao ?: $this->db, + $this->level, + $this->mode + ); + + try { + $result = call_user_func_array($this->function, func_get_args()); + $transaction->commit(); + return $result; + } catch (Exception $e) { + $transaction->rollback(); + if ($this->exceptionFunction) { + $args = func_get_args(); + array_unshift($args, $e); + return call_user_func_array($this->exceptionFunction, $args); + } + throw $e; + } + } + } +?> \ No newline at end of file diff --git a/test/config.inc.php.tpl b/test/config.inc.php.tpl index 66c7b1f7c7..847ed05472 100644 --- a/test/config.inc.php.tpl +++ b/test/config.inc.php.tpl @@ -37,7 +37,7 @@ 'host' => '127.0.0.1', 'base' => 'onphp' ), - 'SQLite' => array( + 'SQLitePDO' => array( 'user' => 'onphp', 'pass' => 'onphp', 'host' => '127.0.0.1', diff --git a/test/core/InnerTransactionTest.class.php b/test/core/InnerTransactionTest.class.php new file mode 100644 index 0000000000..2b6edebcef --- /dev/null +++ b/test/core/InnerTransactionTest.class.php @@ -0,0 +1,194 @@ +spawnDb(array( + 'begin' => 1, + 'commit' => 1, + )); + + //execute + $transaction = InnerTransaction::begin($db); + $transaction->commit(); + + //test Exception on second commit + try { + $transaction->commit(); + $this->fail('expecting exception on second transaction commit'); + } catch (WrongStateException $e) { + /* all ok */ + } + } + + public function testRollbackExt() + { + //setup + $db = $this->spawnDb(array( + 'begin' => 1, + 'rollback' => 1, + )); + + //execute + $transaction = InnerTransaction::begin($db); + $transaction->rollback(); + + //test Exception on second commit + try { + $transaction->rollback(); + $this->fail('expecting exception on second transaction commit'); + } catch (WrongStateException $e) { + /* all ok */ + } + } + + public function testCommitInt() + { + //setup + $db = $this->spawnDb(array( + 'savepointBegin' => 1, + 'savepointRelease' => 1, + 'inTransaction' => true, + )); + + //execute + $transaction = InnerTransaction::begin($db); + $transaction->commit(); + + //test Exception on second commit + try { + $transaction->rollback(); + $this->fail('expecting exception on second transaction commit'); + } catch (WrongStateException $e) { + /* all ok */ + } + } + + public function testRollbackInt() + { + //setup + $db = $this->spawnDb(array( + 'savepointBegin' => 1, + 'savepointRollback' => 1, + 'inTransaction' => true, + )); + + //execute + $transaction = InnerTransaction::begin($db); + $transaction->rollback(); + + //test Exception on second commit + try { + $transaction->commit(); + $this->fail('expecting exception on second transaction commit'); + } catch (WrongStateException $e) { + /* all ok */ + } + } + + public function testWrapCommit() + { + $db = $this->spawnDb(array( + 'begin' => 1, + 'commit' => 1, + )); + + $foo = 'foo'; + $bar = 'bar'; + $innerFunction = function($foo) use ($bar) { + return $foo . $bar; + }; + + $wrapper = InnerTransactionWrapper::create()-> + setDB($db)-> + setFunction($innerFunction); + + $this->assertEquals($foo . $bar, $wrapper->run($foo)); + } + + public function testWrapRollbackByException() + { + $db = $this->spawnDb(array( + 'begin' => 1, + 'rollback' => 1, + )); + + $foo = 'foo'; + $bar = 'bar'; + + $catchExceptionFunc = function ($e, $foo, $bar) { + return $e->getMessage().' '.$foo.$bar; + }; + + $wrapper = InnerTransactionWrapper::create()-> + setDB($db)-> + setFunction(array($this, 'wrapExceptionFunction'))-> + setExceptionFunction($catchExceptionFunc); + + $this->assertEquals('some unimplemented feature foobar', $wrapper->run($foo, $bar)); + } + + public function testWrapRollbackByOtherException() + { + $db = $this->spawnDb(array( + 'begin' => 1, + 'rollback' => 1, + )); + + $exception = new DatabaseException('Some database exception'); + + $function = function () use ($exception) {throw $exception;}; + + $wrapper = InnerTransactionWrapper::create()-> + setDB($db)-> + setFunction($function); + + try { + $wrapper->run(); + } catch (Exception $e) { + $this->assertEquals($exception, $e); + } + } + + public function wrapExceptionFunction($foo, $bar) + { + throw new UnimplementedFeatureException('some unimplemented feature'); + } + + /** + * @param array $options + * @return DB + */ + private function spawnDb($options = array()) + { + $options += array( + 'begin' => 0, + 'commit' => 0, + 'rollback' => 0, + 'savepointBegin' => 0, + 'savepointRelease' => 0, + 'savepointRollback' => 0, + 'inTransaction' => false, + ); + + $mock = $this->getMock('DB'); + $countMethods = array( + 'begin', 'commit', 'rollback', + 'savepointBegin', 'savepointRelease', 'savepointRollback' + ); + foreach ($countMethods as $method) + $mock-> + expects($this->exactly($options[$method]))-> + method($method)-> + will($this->returnSelf()); + + $mock-> + expects($this->any())-> + method('inTransaction')-> + will($this->returnValue($options['inTransaction'])); + + return $mock; + } + } +?> \ No newline at end of file diff --git a/test/misc/DAOTest.class.php b/test/misc/DAOTest.class.php index e7ca277076..e0e2baaf21 100644 --- a/test/misc/DAOTest.class.php +++ b/test/misc/DAOTest.class.php @@ -91,6 +91,42 @@ public function testBoolean() $this->drop(); } + public function testInnerTransaction() + { + $this->create(); + + foreach (DBTestPool::me()->getPool() as $connector => $db) { + DBPool::me()->setDefault($db); + $this->fill(); + + $moscow = TestCity::dao()->getByLogic(Expression::eq('name', 'Moscow')); + $piter = TestCity::dao()->getByLogic(Expression::eq('name', 'Saint-Peterburg')); + + $cityNewer = function(TestCity $city) { + $city->dao()->merge($city->setName('New '.$city->getName())); + }; + + $citiesNewer = function($moscow, $piter) use ($cityNewer, $db) { + $cityNewer($moscow); + + InnerTransactionWrapper::create()-> + setDB($db)-> + setFunction($cityNewer)-> + run($piter); + }; + + InnerTransactionWrapper::create()-> + setDao($moscow->dao())-> + setFunction($citiesNewer)-> + run($moscow, $piter); + + $this->assertNotNull(TestCity::dao()->getByLogic(Expression::eq('name', 'New Moscow'))); + $this->assertNotNull(TestCity::dao()->getByLogic(Expression::eq('name', 'New Saint-Peterburg'))); + } + + $this->drop(); + } + public function testCriteria() { $this->create();