From 5bb8417a79fcde9d7ceb12bab23021b54ee11e1b Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Nov 2014 23:07:57 -0800 Subject: [PATCH] CRM-15578 - CRM_Core_Transaction - Add support for nested transactions; add tests. --- CRM/Core/Transaction.php | 236 +++++----- Civi/Core/Transaction/Frame.php | 190 +++++++++ Civi/Core/Transaction/Manager.php | 175 ++++++++ tests/phpunit/CRM/Core/TransactionTest.php | 449 ++++++++++++++------ tests/phpunit/CiviTest/CiviUnitTestCase.php | 9 +- 5 files changed, 819 insertions(+), 240 deletions(-) create mode 100644 Civi/Core/Transaction/Frame.php create mode 100644 Civi/Core/Transaction/Manager.php diff --git a/CRM/Core/Transaction.php b/CRM/Core/Transaction.php index 17f7e9a88d..4d2f85e9d0 100644 --- a/CRM/Core/Transaction.php +++ b/CRM/Core/Transaction.php @@ -32,14 +32,55 @@ * @copyright David Strauss (c) 2007 * $Id$ * - * This file has its origins in Donald Lobo's conversation with David - * Strauss over IRC and the CRM_Core_DAO::transaction() function. + * (Note: This has been considerably rewritten; the interface is preserved + * for backward compatibility.) * - * David went on and abstracted this into a class which can be used in PHP 5 - * (since destructors are called automagically at the end of the script). - * Lobo modified the code and used CiviCRM coding standards. David's - * PressFlow Transaction module is available at - * http://drupal.org/project/pressflow_transaction + * Transaction management in Civi is divided among three classes: + * - CRM_Core_Transaction: API. This binds to __construct() + __destruct() + * and notifies the transaction manager when it's OK to begin/end a transaction. + * - Civi\Core\Transaction\Manager: Tracks pending transaction-frames + * - Civi\Core\Transaction\Frame: A nestable transaction (e.g. based on BEGIN/COMMIT/ROLLBACK + * or SAVEPOINT/ROLLBACK TO SAVEPOINT). + * + * Examples: + * + * @code + * // Some business logic using the helper functions + * function my_business_logic() { + * CRM_Core_Transaction::create()->run(function($tx) { + * ...do work... + * if ($error) throw new Exception(); + * }); + * } + * + * // Some business logic which returns an error-value + * // and explicitly manages the transaction. + * function my_business_logic() { + * $tx = new CRM_Core_Transaction(); + * ...do work... + * if ($error) { + * $tx->rollback(); + * return error_value; + * } + * } + * + * // Some business logic which uses exceptions + * // and explicitly manages the transaction. + * function my_business_logic() { + * $tx = new CRM_Core_Transaction(); + * try { + * ...do work... + * } catch (Exception $ex) { + * $tx->rollback()->commit(); + * throw $ex; + * } + * } + * + * @endcode + * + * Note: As of 4.6, the transaction manager supports both reference-counting and nested + * transactions (SAVEPOINTs). In the past, it only supported reference-counting. The two cases + * may exhibit different systemic effects with respect to unhandled exceptions. */ class CRM_Core_Transaction { @@ -52,103 +93,99 @@ class CRM_Core_Transaction { CONST PHASE_POST_ROLLBACK = 8; /** - * Keep track of the number of opens and close - * - * @var int + * Whether commit() has been called on this instance + * of CRM_Core_Transaction */ - private static $_count = 0; + private $_pseudoCommitted = FALSE; /** - * Keep track if we need to commit or rollback + * Ensure that an SQL transaction is started * - * @var boolean - */ - private static $_doCommit = TRUE; - - /** - * hold a dao singleton for query operations + * This is a thin wrapper around __construct() which allows more fluent coding. * - * @var object + * @param bool $nest Determines what to do if there's currently an active transaction: + * - If true, then make a new nested transaction ("SAVEPOINT") + * - If false, then attach to the existing transaction + * @return \CRM_Core_Transaction */ - private static $_dao = NULL; - - /** - * Array of callbacks to invoke when the transaction commits or rolls back. - * Array keys are phase constants. - * Array values are arrays of callbacks. - */ - private static $_callbacks = NULL; - - /** - * Whether commit() has been called on this instance - * of CRM_Core_Transaction - */ - private $_pseudoCommitted = FALSE; + public static function create($nest = FALSE) { + return new self($nest); + } /** + * Ensure that an SQL transaction is started * + * @param bool $nest Determines what to do if there's currently an active transaction: + * - If true, then make a new nested transaction ("SAVEPOINT") + * - If false, then attach to the existing transaction */ - function __construct() { - if (!self::$_dao) { - self::$_dao = new CRM_Core_DAO(); - } - - if (self::$_count == 0) { - self::$_dao->query('BEGIN'); - self::$_callbacks = array( - self::PHASE_PRE_COMMIT => array(), - self::PHASE_POST_COMMIT => array(), - self::PHASE_PRE_ROLLBACK => array(), - self::PHASE_POST_ROLLBACK => array(), - ); - } - - self::$_count++; + function __construct($nest = FALSE) { + \Civi\Core\Transaction\Manager::singleton()->inc($nest); } function __destruct() { $this->commit(); } + /** + * Immediately commit or rollback + * + * (Note: Prior to 4.6, return void) + * + * @return \CRM_Core_Exception this + */ function commit() { - if (self::$_count > 0 && !$this->_pseudoCommitted) { + if (!$this->_pseudoCommitted) { $this->_pseudoCommitted = TRUE; - self::$_count--; - - if (self::$_count == 0) { - - // It's possible that, say, a POST_COMMIT callback creates another - // transaction. That transaction will need its own list of callbacks. - $oldCallbacks = self::$_callbacks; - self::$_callbacks = NULL; - - if (self::$_doCommit) { - self::invokeCallbacks(self::PHASE_PRE_COMMIT, $oldCallbacks); - self::$_dao->query('COMMIT'); - self::invokeCallbacks(self::PHASE_POST_COMMIT, $oldCallbacks); - } - else { - self::invokeCallbacks(self::PHASE_PRE_ROLLBACK, $oldCallbacks); - self::$_dao->query('ROLLBACK'); - self::invokeCallbacks(self::PHASE_POST_ROLLBACK, $oldCallbacks); - } - // this transaction is complete, so reset doCommit flag - self::$_doCommit = TRUE; - } + \Civi\Core\Transaction\Manager::singleton()->dec(); } + return $this; } /** * @param $flag */ static public function rollbackIfFalse($flag) { - if ($flag === FALSE) { - self::$_doCommit = FALSE; + $frame = \Civi\Core\Transaction\Manager::singleton()->getFrame(); + if ($flag === FALSE && $frame !== NULL) { + $frame->setRollbackOnly(); } } + /** + * Mark the transaction for rollback. + * + * (Note: Prior to 4.6, return void) + * @return \CRM_Core_Exception this + */ public function rollback() { - self::$_doCommit = FALSE; + $frame = \Civi\Core\Transaction\Manager::singleton()->getFrame(); + if ($frame !== NULL) { + $frame->setRollbackOnly(); + } + return $this; + } + + /** + * Execute a function ($callable) within the scope of a transaction. If + * $callable encounters an unhandled exception, then rollback the transaction. + * + * After calling run(), the CRM_Core_Transaction object is "used up"; do not + * use it again. + * + * @param type $callable Should exception one paramter (CRM_Core_Transaction $tx) + * @return \CRM_Core_Exception this + * @throws Exception + */ + public function run($callable) { + try { + $callable($this); + } catch (Exception $ex) { + $this->rollback()->commit(); + throw $ex; + } + $this->commit(); + return $this; } /** @@ -163,14 +200,8 @@ class CRM_Core_Transaction { * a call to exit(). */ static public function forceRollbackIfEnabled() { - if (self::$_count > 0) { - $oldCallbacks = self::$_callbacks; - self::$_callbacks = NULL; - self::invokeCallbacks(self::PHASE_PRE_ROLLBACK, $oldCallbacks); - self::$_dao->query('ROLLBACK'); - self::invokeCallbacks(self::PHASE_POST_ROLLBACK, $oldCallbacks); - self::$_count = 0; - self::$_doCommit = TRUE; + if (\Civi\Core\Transaction\Manager::singleton()->getFrame() !== NULL) { + \Civi\Core\Transaction\Manager::singleton()->forceRollback(); } } @@ -178,19 +209,28 @@ class CRM_Core_Transaction { * @return bool */ static public function willCommit() { - return self::$_doCommit; + $frame = \Civi\Core\Transaction\Manager::singleton()->getFrame(); + return ($frame === NULL) ? TRUE : !$frame->isRollbackOnly(); } /** * Determine whether there is a pending transaction */ static public function isActive() { - return (self::$_count > 0); + $frame = \Civi\Core\Transaction\Manager::singleton()->getFrame(); + return ($frame !== NULL); } /** * Add a transaction callback * + * Note: It's conceivable to add callbacks to the main/overall transaction + * (aka $manager->getBaseFrame()) or to the innermost nested transaction + * (aka $manager->getFrame()). addCallback() has been used in the past to + * work-around deadlocks. This may or may not be necessary now -- but it + * seems more consistent (for b/c purposes) to attach callbacks to the + * main/overall transaction. + * * Pre-condition: isActive() * * @param $phase A constant; one of: self::PHASE_{PRE,POST}_{COMMIT,ROLLBACK} @@ -199,29 +239,7 @@ class CRM_Core_Transaction { * See php manual call_user_func_array for details. */ static public function addCallback($phase, $callback, $params = null, $id = NULL) { - if ($id) { - self::$_callbacks[$phase][$id] = array( - 'callback' => $callback, - 'parameters' => (is_array($params) ? $params : array($params)) - ); - } else { - self::$_callbacks[$phase][] = array( - 'callback' => $callback, - 'parameters' => (is_array($params) ? $params : array($params)) - ); - } - } - - /** - * @param $phase - * @param $callbacks - */ - static protected function invokeCallbacks($phase, $callbacks) { - if (is_array($callbacks[$phase])) { - foreach ($callbacks[$phase] as $cb) { - call_user_func_array($cb['callback'], $cb['parameters']); - } - } + $frame = \Civi\Core\Transaction\Manager::singleton()->getBaseFrame(); + $frame->addCallback($phase, $callback, $params, $id); } } - diff --git a/Civi/Core/Transaction/Frame.php b/Civi/Core/Transaction/Frame.php new file mode 100644 index 0000000000..a7493f68da --- /dev/null +++ b/Civi/Core/Transaction/Frame.php @@ -0,0 +1,190 @@ +dao = $dao; + $this->beginStmt = $beginStmt; + $this->commitStmt = $commitStmt; + $this->rollbackStmt = $rollbackStmt; + + $this->callbacks = array( + \CRM_Core_Transaction::PHASE_PRE_COMMIT => array(), + \CRM_Core_Transaction::PHASE_POST_COMMIT => array(), + \CRM_Core_Transaction::PHASE_PRE_ROLLBACK => array(), + \CRM_Core_Transaction::PHASE_POST_ROLLBACK => array(), + ); + } + + public function inc() { + $this->refCount++; + } + + public function dec() { + $this->refCount--; + } + + public function isEmpty() { + return ($this->refCount == 0); + } + + public function isRollbackOnly() { + return !$this->doCommit; + } + + public function setRollbackOnly() { + $this->doCommit = false; + } + + public function begin() { + assert('$this->state === self::F_NEW'); + $this->state = self::F_ACTIVE; + if ($this->beginStmt) { + $this->dao->query($this->beginStmt); + } + } + + /** + * @param type $newState + * @void + */ + public function finish($newState = self::F_DONE) { + if ($this->state == self::F_FORCED) { + return; + } + assert('$this->state === self::F_ACTIVE'); + $this->state = $newState; + + if ($this->doCommit) { + $this->invokeCallbacks(\CRM_Core_Transaction::PHASE_PRE_COMMIT); + if ($this->commitStmt) { + $this->dao->query($this->commitStmt); + } + $this->invokeCallbacks(\CRM_Core_Transaction::PHASE_POST_COMMIT); + } + else { + $this->invokeCallbacks(\CRM_Core_Transaction::PHASE_PRE_ROLLBACK); + if ($this->rollbackStmt) { + $this->dao->query($this->rollbackStmt); + } + $this->invokeCallbacks(\CRM_Core_Transaction::PHASE_POST_ROLLBACK); + } + } + + public function forceRollback() { + $this->setRollbackOnly(); + $this->finish(self::F_FORCED); + } + + /** + * Add a transaction callback + * + * Pre-condition: isActive() + * + * @param int $phase A constant; one of: self::PHASE_{PRE,POST}_{COMMIT,ROLLBACK} + * @param mixed $callback A PHP callback + * @param array|NULL $params Optional values to pass to callback. + * See php manual call_user_func_array for details. + */ + public function addCallback($phase, $callback, $params = null, $id = NULL) { + if ($id) { + $this->callbacks[$phase][$id] = array( + 'callback' => $callback, + 'parameters' => (is_array($params) ? $params : array($params)) + ); + } + else { + $this->callbacks[$phase][] = array( + 'callback' => $callback, + 'parameters' => (is_array($params) ? $params : array($params)) + ); + } + } + + /** + * @param int $phase + */ + public function invokeCallbacks($phase) { + if (is_array($this->callbacks[$phase])) { + foreach ($this->callbacks[$phase] as $cb) { + call_user_func_array($cb['callback'], $cb['parameters']); + } + } + } + +} diff --git a/Civi/Core/Transaction/Manager.php b/Civi/Core/Transaction/Manager.php new file mode 100644 index 0000000000..06dbf8af56 --- /dev/null +++ b/Civi/Core/Transaction/Manager.php @@ -0,0 +1,175 @@ + stack of SQL transactions/savepoints + */ + private $frames = array(); + + /** + * + * @var int + */ + private $savePointCount = 0; + + /** + * @param bool $fresh + * @return Manager + */ + public static function singleton($fresh = FALSE) { + if (NULL === self::$singleton || $fresh) { + self::$singleton = new Manager(new \CRM_Core_DAO()); + } + return self::$singleton; + } + + /** + * @param CRM_Core_DAO $dao handle for the DB connection that will execute transaction statements + * (all we really care about is the query() function) + */ + function __construct($dao) { + $this->dao = $dao; + } + + /** + * Increment the transaction count / add a new transaction level + * + * @param bool $nest Determines what to do if there's currently an active transaction: + * - If true, then make a new nested transaction ("SAVEPOINT") + * - If false, then attach to the existing transaction + */ + public function inc($nest = FALSE) { + if (!isset($this->frames[0])) { + $frame = $this->createBaseFrame(); + array_unshift($this->frames, $frame); + $frame->inc(); + $frame->begin(); + } + elseif ($nest) { + $frame = $this->createSavePoint(); + array_unshift($this->frames, $frame); + $frame->inc(); + $frame->begin(); + } + else { + $this->frames[0]->inc(); + } + } + + /** + * Decrement the transaction count / close out a transaction level + * + * @throws \CRM_Core_Exception + */ + public function dec() { + if (!isset($this->frames[0]) || $this->frames[0]->isEmpty()) { + throw new \CRM_Core_Exception('Transaction integrity error: Expected to find active frame'); + } + + $this->frames[0]->dec(); + + if ($this->frames[0]->isEmpty()) { + // Callbacks may cause additional work (such as new transactions), + // and it would be confusing if the old frame was still active. + // De-register it before calling finish(). + $oldFrame = array_shift($this->frames); + $oldFrame->finish(); + } + } + + /** + * Force an immediate rollback, regardless of how many + * transaction or frame objects exist. + * + * This is only appropriate when it is _certain_ that the + * callstack will not wind-down normally -- e.g. before + * a call to exit(). + */ + public function forceRollback() { + // we take the long-way-round (rolling back each frame) so that the + // internal state of each frame is consistent with its outcome + + $oldFrames = $this->frames; + $this->frames = array(); + foreach ($oldFrames as $oldFrame) { + $oldFrame->forceRollback(); + } + } + + /** + * Get the (innermost) SQL transaction. + * + * @return \Civi\Core\Transaction\Frame + */ + public function getFrame() { + return isset($this->frames[0]) ? $this->frames[0] : NULL; + } + + /** + * Get the (outermost) SQL transaction (i.e. the one + * demarcated by BEGIN/COMMIT/ROLLBACK) + * + * @return \Civi\Core\Transaction\Frame + */ + public function getBaseFrame() { + if (empty($this->frames)) return NULL; + return $this->frames[count($this->frames)-1]; + } + + /** + * @return \Civi\Core\Transaction\Frame + */ + protected function createBaseFrame() { + return new Frame($this->dao, 'BEGIN', 'COMMIT', 'ROLLBACK'); + ; + } + + /** + * @return \Civi\Core\Transaction\Frame + */ + protected function createSavePoint() { + $spId = $this->savePointCount++; + return new Frame($this->dao, "SAVEPOINT civi_{$spId}", NULL, "ROLLBACK TO SAVEPOINT civi_{$spId}"); + } + +} diff --git a/tests/phpunit/CRM/Core/TransactionTest.php b/tests/phpunit/CRM/Core/TransactionTest.php index 701824a1aa..7ac5d1379a 100644 --- a/tests/phpunit/CRM/Core/TransactionTest.php +++ b/tests/phpunit/CRM/Core/TransactionTest.php @@ -8,190 +8,383 @@ require_once 'CiviTest/Custom.php'; */ class CRM_Core_TransactionTest extends CiviUnitTestCase { + /** + * @var array + */ + private $callbackLog; + + /** + * @var array (int $idx => int $contactId) list of contact IDs that have been created (in order of creation) + * + * Note that ID this is all IDs created by the test-case -- even if the creation was subsequently rolled back + */ + private $cids; + function setUp() { parent::setUp(); $this->quickCleanup(array('civicrm_contact', 'civicrm_activity')); + $this->callbackLog = array(); + $this->cids = array(); } - function testDefaultCommit_RawInsert_1xOuter() { - $cid = NULL; - $test = $this; - - $transactionalFunction_outermost = function () use (&$cid, $test) { - $tx = new CRM_Core_Transaction(); - $r = CRM_Core_DAO::executeQuery("INSERT INTO civicrm_contact(first_name,last_name) VALUES ('ff', 'll')"); - $cid = mysql_insert_id(); - $test->assertContactsExist(array($cid), TRUE); - - // End of outermost $tx; COMMIT will execute ASAP - }; - - $transactionalFunction_outermost(); - $test->assertContactsExist(array($cid), TRUE); + function dataCreateStyle() { + return array( + array('sql-insert'), + array('bao-create'), + ); } - function testDefaultCommit_BaoCreate_1xOuter() { - $cid = NULL; - $test = $this; - - $transactionalFunction_outermost = function () use (&$cid, $test) { - $tx = new CRM_Core_Transaction(); - $params = array( - 'contact_type' => 'Individual', - 'first_name' => 'FF', - 'last_name' => 'LL', - ); - $r = CRM_Contact_BAO_Contact::create($params); - $cid = $r->id; - $test->assertContactsExist(array($cid), TRUE); - - // End of outermost $tx; COMMIT will execute ASAP - }; - - $transactionalFunction_outermost(); - $test->assertContactsExist(array($cid), TRUE); + function dataCreateAndCommitStyles() { + return array( + array('sql-insert', 'implicit-commit'), + array('sql-insert', 'explicit-commit'), + array('bao-create', 'implicit-commit'), + array('bao-create', 'explicit-commit'), + ); } - function testRollback_RawInsert_1xOuter() { - $cid = NULL; - $test = $this; - - $transactionalFunction_outermost = function() use (&$cid, $test) { - $tx = new CRM_Core_Transaction(); - - $r = CRM_Core_DAO::executeQuery("INSERT INTO civicrm_contact(first_name,last_name) VALUES ('ff', 'll')"); - $cid = mysql_insert_id(); - - $test->assertContactsExist(array($cid), TRUE); + /** + * @param string $createStyle 'sql-insert'|'bao-create' + * @param string $commitStyle 'implicit-commit'|'explicit-commit' + * @dataProvider dataCreateAndCommitStyles + */ + function testBasicCommit($createStyle, $commitStyle) { + $this->createContactWithTransaction('reuse-tx', $createStyle, $commitStyle); + $this->assertCount(1, $this->cids); + $this->assertContactsExistByOffset(array(0 => TRUE)); + } - $tx->rollback(); // Mark ROLLBACK, but don't execute yet + /** + * @dataProvider dataCreateStyle + */ + function testBasicRollback($createStyle) { + $this->createContactWithTransaction('reuse-tx', $createStyle, 'rollback'); + $this->assertCount(1, $this->cids); + $this->assertContactsExistByOffset(array(0 => FALSE)); + } - // End of outermost $tx; ROLLBACK will execute ASAP - }; + /** + * Test in which an outer function makes multiple calls to inner functions + * but then rolls back the entire set. + * + * @param string $createStyle 'sql-insert'|'bao-create' + * @param string $commitStyle 'implicit-commit'|'explicit-commit' + * @dataProvider dataCreateAndCommitStyles + */ + function testBatchRollback($createStyle, $commitStyle) { + $this->runBatch( + 'reuse-tx', + array( + array('reuse-tx', $createStyle, $commitStyle), // cid 0 + array('reuse-tx', $createStyle, $commitStyle), // cid 1 + ), + array(0 => TRUE, 1 => TRUE), + 'rollback' + ); + $this->assertCount(2, $this->cids); + $this->assertContactsExistByOffset(array(0 => FALSE, 1 => FALSE)); + } - $transactionalFunction_outermost(); - $test->assertContactsExist(array($cid), FALSE); + /** + * Test in which runBatch makes multiple calls to + * createContactWithTransaction using a mix of rollback/commit. + * createContactWithTransaction use nesting (savepoints), so the + * batch is able to commit. + * + * @param string $createStyle 'sql-insert'|'bao-create' + * @param string $commitStyle 'implicit-commit'|'explicit-commit' + * @dataProvider dataCreateAndCommitStyles + */ + function testMixedBatchCommit_nesting($createStyle, $commitStyle) { + $this->runBatch( + 'reuse-tx', + array( + array('nest-tx', $createStyle, $commitStyle), // cid 0 + array('nest-tx', $createStyle, 'rollback'), // cid 1 + array('nest-tx', $createStyle, $commitStyle), // cid 2 + ), + array(0 => TRUE, 1 => FALSE, 2 => TRUE), + $commitStyle + ); + $this->assertCount(3, $this->cids); + $this->assertContactsExistByOffset(array(0 => TRUE, 1 => FALSE, 2 => TRUE)); } /** - * Test in which an outer function ($transactionalFunction_outermost) makes multiple calls - * to inner functions ($transactionalFunction_inner) but then rollsback the entire set. + * Test in which runBatch makes multiple calls to + * createContactWithTransaction using a mix of rollback/commit. + * createContactWithTransaction reuses the main transaction, + * so the overall batch must rollback. + * + * @param string $createStyle 'sql-insert'|'bao-create' + * @param string $commitStyle 'implicit-commit'|'explicit-commit' + * @dataProvider dataCreateAndCommitStyles */ - function testRollback_RawInsert_2xInner() { - $cids = array(); - $test = $this; + function testMixedBatchCommit_reuse($createStyle, $commitStyle) { + $this->runBatch( + 'reuse-tx', + array( + array('reuse-tx', $createStyle, $commitStyle), // cid 0 + array('reuse-tx', $createStyle, 'rollback'), // cid 1 + array('reuse-tx', $createStyle, $commitStyle), // cid 2 + ), + array(0 => TRUE, 1 => TRUE, 2 => TRUE), + $commitStyle + ); + $this->assertCount(3, $this->cids); + $this->assertContactsExistByOffset(array(0 => FALSE, 1 => FALSE, 2 => FALSE)); + } - $transactionalFunction_inner = function() use (&$cids, $test) { - $tx = new CRM_Core_Transaction(); + /** + * Test in which runBatch makes multiple calls to + * createContactWithTransaction using a mix of rollback/commit. + * The overall batch is rolled back. + * + * @param string $createStyle 'sql-insert'|'bao-create' + * @param string $commitStyle 'implicit-commit'|'explicit-commit' + * @dataProvider dataCreateAndCommitStyles + */ + function testMixedBatchRollback_nesting($createStyle, $commitStyle) { + $this->assertFalse(CRM_Core_Transaction::isActive()); + $this->runBatch( + 'reuse-tx', + array( + array('nest-tx', $createStyle, $commitStyle), // cid 0 + array('nest-tx', $createStyle, 'rollback'), // cid 1 + array('nest-tx', $createStyle, $commitStyle), // cid 2 + ), + array(0 => TRUE, 1 => FALSE, 2 => TRUE), + 'rollback' + ); + $this->assertFalse(CRM_Core_Transaction::isActive()); + $this->assertCount(3, $this->cids); + $this->assertContactsExistByOffset(array(0 => FALSE, 1 => FALSE, 2 => FALSE)); + } - $r = CRM_Core_DAO::executeQuery("INSERT INTO civicrm_contact(first_name,last_name) VALUES ('ff', 'll')"); - $cid = mysql_insert_id(); - $cids[] = $cid; + public function testIsActive() { + $this->assertEquals(FALSE, CRM_Core_Transaction::isActive()); + $this->assertEquals(TRUE, CRM_Core_Transaction::willCommit()); + $tx = new CRM_Core_Transaction(); + $this->assertEquals(TRUE, CRM_Core_Transaction::isActive()); + $this->assertEquals(TRUE, CRM_Core_Transaction::willCommit()); + $tx = NULL; + $this->assertEquals(FALSE, CRM_Core_Transaction::isActive()); + $this->assertEquals(TRUE, CRM_Core_Transaction::willCommit()); + } - $test->assertContactsExist($cids, TRUE); + public function testIsActive_rollback() { + $this->assertEquals(FALSE, CRM_Core_Transaction::isActive()); + $this->assertEquals(TRUE, CRM_Core_Transaction::willCommit()); - // End of inner $tx; neither COMMIT nor ROLLBACK b/c another $tx remains - }; + $tx = new CRM_Core_Transaction(); + $this->assertEquals(TRUE, CRM_Core_Transaction::isActive()); + $this->assertEquals(TRUE, CRM_Core_Transaction::willCommit()); - $transactionalFunction_outermost = function() use (&$cids, $test, $transactionalFunction_inner) { - $tx = new CRM_Core_Transaction(); + $tx->rollback(); + $this->assertEquals(TRUE, CRM_Core_Transaction::isActive()); + $this->assertEquals(FALSE, CRM_Core_Transaction::willCommit()); - $transactionalFunction_inner(); - $transactionalFunction_inner(); + $tx = NULL; + $this->assertEquals(FALSE, CRM_Core_Transaction::isActive()); + $this->assertEquals(TRUE, CRM_Core_Transaction::willCommit()); + } - $tx->rollback(); // Mark ROLLBACK, but don't execute yet + public function testCallback_commit() { + $tx = new CRM_Core_Transaction(); - $test->assertContactsExist($cids, TRUE); // not yet rolled back + CRM_Core_Transaction::addCallback(CRM_Core_Transaction::PHASE_PRE_COMMIT, array($this, '_preCommit'), array('qwe','rty')); + CRM_Core_Transaction::addCallback(CRM_Core_Transaction::PHASE_POST_COMMIT, array($this, '_postCommit'), array('uio','p[]')); + CRM_Core_Transaction::addCallback(CRM_Core_Transaction::PHASE_PRE_ROLLBACK, array($this, '_preRollback'), array('asd','fgh')); + CRM_Core_Transaction::addCallback(CRM_Core_Transaction::PHASE_POST_ROLLBACK, array($this, '_postRollback'), array('jkl',';')); - // End of outermost $tx; ROLLBACK will execute ASAP - }; + CRM_Core_DAO::executeQuery('UPDATE civicrm_contact SET id = 100 WHERE id = 100'); - $transactionalFunction_outermost(); - $test->assertContactsExist($cids, FALSE); + $this->assertEquals(array(), $this->callbackLog); + $tx = NULL; + $this->assertEquals(array('_preCommit', 'qwe', 'rty'), $this->callbackLog[0]); + $this->assertEquals(array('_postCommit', 'uio', 'p[]'), $this->callbackLog[1]); } - function testRollback_BaoCreate_1xOuter() { - $cid = NULL; - $test = $this; + public function testCallback_rollback() { + $tx = new CRM_Core_Transaction(); - $transactionalFunction_outermost = function() use (&$cid, $test) { - $tx = new CRM_Core_Transaction(); + CRM_Core_Transaction::addCallback(CRM_Core_Transaction::PHASE_PRE_COMMIT, array($this, '_preCommit'), array('ewq','ytr')); + CRM_Core_Transaction::addCallback(CRM_Core_Transaction::PHASE_POST_COMMIT, array($this, '_postCommit'), array('oiu','][p')); + CRM_Core_Transaction::addCallback(CRM_Core_Transaction::PHASE_PRE_ROLLBACK, array($this, '_preRollback'), array('dsa','hgf')); + CRM_Core_Transaction::addCallback(CRM_Core_Transaction::PHASE_POST_ROLLBACK, array($this, '_postRollback'), array('lkj',';')); - $params = array( - 'contact_type' => 'Individual', - 'first_name' => 'F', - 'last_name' => 'L', - ); - $r = CRM_Contact_BAO_Contact::create($params); - $cid = $r->id; + CRM_Core_DAO::executeQuery('UPDATE civicrm_contact SET id = 100 WHERE id = 100'); + $tx->rollback(); - $test->assertContactsExist(array($cid), TRUE); + $this->assertEquals(array(), $this->callbackLog); + $tx = NULL; + $this->assertEquals(array('_preRollback', 'dsa', 'hgf'), $this->callbackLog[0]); + $this->assertEquals(array('_postRollback', 'lkj', ';'), $this->callbackLog[1]); + } - $tx->rollback(); // Mark ROLLBACK, but don't execute yet + /** + * + * @param string $createStyle 'sql-insert'|'bao-create' + * @param string $commitStyle 'implicit-commit'|'explicit-commit' + * @dataProvider dataCreateAndCommitStyles + */ + public function testRun_ok($createStyle, $commitStyle) { + $test = $this; + CRM_Core_Transaction::create(TRUE)->run(function($tx) use (&$test, $createStyle, $commitStyle) { + $test->createContactWithTransaction('nest-tx', $createStyle, $commitStyle); + $test->assertContactsExistByOffset(array(0 => TRUE)); + }); + $this->assertContactsExistByOffset(array(0 => TRUE)); + } - // End of outermost $tx; ROLLBACK will execute ASAP - }; + /** + * + * @param string $createStyle 'sql-insert'|'bao-create' + * @param string $commitStyle 'implicit-commit'|'explicit-commit' + * @dataProvider dataCreateAndCommitStyles + */ + public function testRun_exception($createStyle, $commitStyle) { + $tx = new CRM_Core_Transaction(); + $test = $this; + $e = NULL; // Exception + try { + CRM_Core_Transaction::create(TRUE)->run(function($tx) use (&$test, $createStyle, $commitStyle) { + $test->createContactWithTransaction('nest-tx', $createStyle, $commitStyle); + $test->assertContactsExistByOffset(array(0 => TRUE)); + throw new Exception("Ruh-roh"); + }); + } catch (Exception $ex) { + $e = $ex; + } + $this->assertTrue($e instanceof Exception); + $this->assertEquals('Ruh-roh', $e->getMessage()); + $this->assertContactsExistByOffset(array(0 => FALSE)); + } - $transactionalFunction_outermost(); + /** + * @param $cids + * @param bool $exist + */ + public function assertContactsExist($cids, $exist = TRUE) { + foreach ($cids as $cid) { + $this->assertTrue(is_numeric($cid)); + $this->assertDBQuery($exist ? 1 : 0, 'SELECT count(*) FROM civicrm_contact WHERE id = %1', array( + 1 => array($cid, 'Integer'), + )); + } + } - // No outstanding $tx -- ROLLBACK should be done - $test->assertContactsExist(array($cid), FALSE); + /** + * + * @param array $existsByOffset array(int $cidOffset => bool $expectExists) + * @param int $generalOffset + */ + public function assertContactsExistByOffset($existsByOffset, $generalOffset = 0) { + foreach ($existsByOffset as $offset => $expectExists) { + $this->assertTrue(isset($this->cids[$generalOffset + $offset]), "Find cid at offset($generalOffset + $offset)"); + $cid = $this->cids[$generalOffset + $offset]; + $this->assertTrue(is_numeric($cid)); + $this->assertDBQuery($expectExists ? 1 : 0, 'SELECT count(*) FROM civicrm_contact WHERE id = %1', array( + 1 => array($cid, 'Integer'), + ), "Check contact at offset($generalOffset + $offset)"); + } } /** - * Test in which an outer function ($transactionalFunction_outermost) makes multiple calls - * to inner functions ($transactionalFunction_inner) but then rollsback the entire set. + * Use SQL to INSERT a contact and assert success. Perform + * work within a transaction. + * + * @param string $nesting 'reuse-tx'|'nest-tx' how to construct transaction + * @param string $insert 'sql-insert'|'bao-create' how to add the example record + * @param string $outcome 'rollback'|'implicit-commit'|'explicit-commit' how to finish transaction + * @return int cid */ - function testRollback_BaoCreate_2xInner() { - $cids = array(); - $test = $this; + public function createContactWithTransaction($nesting, $insert, $outcome) { + if ($nesting != 'reuse-tx' && $nesting != 'nest-tx') { + throw new RuntimeException('Bad test data: reuse=' . $nesting); + } + if ($insert != 'sql-insert' && $insert != 'bao-create') { + throw new RuntimeException('Bad test data: insert=' . $insert); + } + if ($outcome != 'rollback' && $outcome != 'implicit-commit' && $outcome != 'explicit-commit') { + throw new RuntimeException('Bad test data: outcome=' . $outcome); + } - $transactionalFunction_inner = function() use (&$cids, $test) { - $tx = new CRM_Core_Transaction(); + $tx = new CRM_Core_Transaction($nesting === 'nest-tx'); + if ($insert == 'sql-insert') { + $r = CRM_Core_DAO::executeQuery("INSERT INTO civicrm_contact(first_name,last_name) VALUES ('ff', 'll')"); + $cid = mysql_insert_id(); + } elseif ($insert == 'bao-create') { $params = array( 'contact_type' => 'Individual', - 'first_name' => 'F', - 'last_name' => 'L', + 'first_name' => 'FF', + 'last_name' => 'LL', ); $r = CRM_Contact_BAO_Contact::create($params); $cid = $r->id; - $cids[] = $cid; + } - $test->assertContactsExist($cids, TRUE); + $this->cids[] = $cid; - // end of inner $tx; neither COMMIT nor ROLLBACK b/c it's inner - }; + $this->assertContactsExist(array($cid), TRUE); - $transactionalFunction_outermost = function() use (&$cids, $test, $transactionalFunction_inner) { - $tx = new CRM_Core_Transaction(); + if ($outcome == 'rollback') { + $tx->rollback(); + } elseif ($outcome == 'explicit-commit') { + $tx->commit(); + } // else: implicit-commit - $transactionalFunction_inner(); - $transactionalFunction_inner(); + return $cid; + } - $tx->rollback(); // Mark ROLLBACK, but don't execute yet + /** + * Perform a series of operations within smaller transactions + * + * @param string $nesting 'reuse-tx'|'nest-tx' how to construct transaction + * @param array $callbacks see createContactWithTransaction + * @param array $existsByOffset see assertContactsMix + * @param string $outcome 'rollback'|'implicit-commit'|'explicit-commit' how to finish transaction + * @return int cid + */ + function runBatch($nesting, $callbacks, $existsByOffset, $outcome) { + if ($nesting != 'reuse-tx' && $nesting != 'nest-tx') { + throw new RuntimeException('Bad test data: nesting=' . $nesting); + } + if ($outcome != 'rollback' && $outcome != 'implicit-commit' && $outcome != 'explicit-commit') { + throw new RuntimeException('Bad test data: outcome=' . $nesting); + } - $test->assertContactsExist($cids, TRUE); // not yet rolled back + $tx = new CRM_Core_Transaction($nesting === 'reuse-tx'); - // End of outermost $tx; ROLLBACK will execute ASAP - }; + $generalOffset = count($this->cids); + foreach ($callbacks as $callback) { + list ($cbNesting, $cbInsert, $cbOutcome) = $callback; + $this->createContactWithTransaction($cbNesting, $cbInsert, $cbOutcome); + } - $transactionalFunction_outermost(); + $this->assertContactsExistByOffset($existsByOffset, $generalOffset); - // No outstanding $tx -- ROLLBACK should be done - $test->assertContactsExist($cids, FALSE); + if ($outcome == 'rollback') { + $tx->rollback(); + } elseif ($outcome == 'explicit-commit') { + $tx->commit(); + } // else: implicit-commit } - /** - * @param $cids - * @param bool $exist - */ - public function assertContactsExist($cids, $exist = TRUE) { - foreach ($cids as $cid) { - $this->assertTrue(is_numeric($cid)); - $this->assertDBQuery($exist ? 1 : 0, 'SELECT count(*) FROM civicrm_contact WHERE id = %1', array( - 1 => array($cid, 'Integer'), - )); - } + function _preCommit($arg1, $arg2) { + $this->callbackLog[] = array('_preCommit', $arg1, $arg2); + } + + function _postCommit($arg1, $arg2) { + $this->callbackLog[] = array('_postCommit', $arg1, $arg2); + } + + function _preRollback($arg1, $arg2) { + $this->callbackLog[] = array('_preRollback', $arg1, $arg2); + } + + function _postRollback($arg1, $arg2) { + $this->callbackLog[] = array('_postRollback', $arg1, $arg2); } } diff --git a/tests/phpunit/CiviTest/CiviUnitTestCase.php b/tests/phpunit/CiviTest/CiviUnitTestCase.php index 269a66b14f..749301f540 100644 --- a/tests/phpunit/CiviTest/CiviUnitTestCase.php +++ b/tests/phpunit/CiviTest/CiviUnitTestCase.php @@ -446,6 +446,8 @@ class CiviUnitTestCase extends PHPUnit_Extensions_Database_TestCase { $this->quickCleanup($tablesToTruncate); $this->cleanTempDirs(); $this->unsetExtensionSystem(); + CRM_Core_Transaction::forceRollbackIfEnabled(); + \Civi\Core\Transaction\Manager::singleton(TRUE); } /** @@ -623,11 +625,12 @@ class CiviUnitTestCase extends PHPUnit_Extensions_Database_TestCase { * Example: $this->assertSql(2, 'select count(*) from foo where foo.bar like "%1"', * array(1 => array("Whiz", "String"))); */ - function assertDBQuery($expected, $query, $params = array()) { + function assertDBQuery($expected, $query, $params = array(), $message = '') { + if ($message) $message .= ': '; $actual = CRM_Core_DAO::singleValueQuery($query, $params); $this->assertEquals($expected, $actual, - sprintf('expected=[%s] actual=[%s] query=[%s]', - $expected, $actual, CRM_Core_DAO::composeQuery($query, $params, FALSE) + sprintf('%sexpected=[%s] actual=[%s] query=[%s]', + $message, $expected, $actual, CRM_Core_DAO::composeQuery($query, $params, FALSE) ) ); } -- 2.25.1