/**
* Class TransactionSubscriber
+ *
+ * Implement transaction management for API calls. Two API options are accepted:
+ * - is_transactional: bool|'nest' - if true, then all work is done inside a transaction. by default, true for mutator actions (C-UD)
+ * 'nest' will force creation of a nested transaction; otherwise, the default is to re-use any existing transactions
+ * - options.force_rollback: bool - if true, all work is done in a nested transaction which will be rolled back
+ *
* @package Civi\API\Subscriber
*/
class TransactionSubscriber implements EventSubscriberInterface {
*/
private $transactions = array();
+ /**
+ * @var array (scalar $apiRequestId => bool)
+ *
+ * A list of requests which should be forcibly rolled back to
+ * their save points.
+ */
+ private $forceRollback = array();
+
/**
* Determine if an API request should be treated as transactional
*
* @return bool
*/
public function isTransactional($apiProvider, $apiRequest) {
+ if ($this->isForceRollback($apiProvider, $apiRequest)) {
+ return true;
+ }
if (isset($apiRequest['params']['is_transactional'])) {
- return \CRM_Utils_String::strtobool($apiRequest['params']['is_transactional']);
+ return \CRM_Utils_String::strtobool($apiRequest['params']['is_transactional']) || $apiRequest['params']['is_transactional'] == 'nest';
}
return strtolower($apiRequest['action']) == 'create' || strtolower($apiRequest['action']) == 'delete' || strtolower($apiRequest['action']) == 'submit';
}
+ /**
+ * Determine if caller wants us to *always* rollback.
+ *
+ * @param \Civi\API\Provider\ProviderInterface $apiProvider
+ * @param array $apiRequest
+ * @return bool
+ */
+ public function isForceRollback($apiProvider, $apiRequest) {
+ // FIXME: When APIv3 uses better parsing, the [params][options][force_rollback] check will be redundant
+ if (isset($apiRequest['params']['options']['force_rollback'])) {
+ return \CRM_Utils_String::strtobool($apiRequest['params']['options']['force_rollback']);
+ }
+ if (isset($apiRequest['options']['force_rollback'])) {
+ return \CRM_Utils_String::strtobool($apiRequest['options']['force_rollback']);
+ }
+ return FALSE;
+ }
+
+ /**
+ * Determine if caller wants a nested transaction or a re-used transaction.
+ *
+ * @param \Civi\API\Provider\ProviderInterface $apiProvider
+ * @param array $apiRequest
+ * @return bool True if a new nested transaction is required; false if active tx may be used
+ */
+ public function isNested($apiProvider, $apiRequest) {
+ if ($this->isForceRollback($apiProvider, $apiRequest)) {
+ return TRUE;
+ }
+ if (isset($apiRequest['params']['is_transactional']) && $apiRequest['params']['is_transactional'] === 'nest') {
+ return TRUE;
+ }
+ return FALSE;
+ }
+
/**
* Open a new transaction instance (if appropriate in the current policy)
*
function onApiPrepare(\Civi\API\Event\PrepareEvent $event) {
$apiRequest = $event->getApiRequest();
if ($this->isTransactional($event->getApiProvider(), $apiRequest)) {
- $this->transactions[$apiRequest['id']] = new \CRM_Core_Transaction();
+ $this->transactions[$apiRequest['id']] = new \CRM_Core_Transaction($this->isNested($event->getApiProvider(), $apiRequest));
+ }
+ if ($this->isForceRollback($event->getApiProvider(), $apiRequest)) {
+ $this->transactions[$apiRequest['id']]->rollback();
}
}
--- /dev/null
+<?php
+namespace Civi\API\Subscriber;
+
+require_once 'CiviTest/CiviUnitTestCase.php';
+
+/**
+ */
+class TransactionSubscriberTest extends \CiviUnitTestCase {
+
+ function transactionOptions() {
+ $r = array();
+ // $r[] = array(string $entity, string $action, array $params, bool $isTransactional, bool $isForceRollback, bool $isNested);
+
+ $r[] = array(3, 'Widget', 'get', array(), FALSE, FALSE, FALSE);
+ $r[] = array(3, 'Widget', 'create', array(), TRUE, FALSE, FALSE);
+ $r[] = array(3, 'Widget', 'delete', array(), TRUE, FALSE, FALSE);
+ $r[] = array(3, 'Widget', 'submit', array(), TRUE, FALSE, FALSE);
+
+ $r[] = array(3, 'Widget', 'get', array('is_transactional' => TRUE), TRUE, FALSE, FALSE);
+ $r[] = array(3, 'Widget', 'get', array('is_transactional' => FALSE), FALSE, FALSE, FALSE);
+ $r[] = array(3, 'Widget', 'get', array('is_transactional' => 'nest'), TRUE, FALSE, TRUE);
+
+ $r[] = array(3, 'Widget', 'create', array('is_transactional' => TRUE), TRUE, FALSE, FALSE);
+ $r[] = array(3, 'Widget', 'create', array('is_transactional' => FALSE), FALSE, FALSE, FALSE);
+ $r[] = array(3, 'Widget', 'create', array('is_transactional' => 'nest'), TRUE, FALSE, TRUE);
+
+ $r[] = array(3, 'Widget', 'create', array('options' => array('force_rollback' => TRUE)), TRUE, TRUE, TRUE);
+ $r[] = array(3, 'Widget', 'create', array('options' => array('force_rollback' => FALSE)), TRUE, FALSE, FALSE);
+
+ $r[] = array(3, 'Widget', 'create', array('is_transactional' => TRUE, 'options' => array('force_rollback' => TRUE)), TRUE, TRUE, TRUE);
+ $r[] = array(3, 'Widget', 'create', array('is_transactional' => TRUE, 'options' => array('force_rollback' => FALSE)), TRUE, FALSE, FALSE);
+ $r[] = array(3, 'Widget', 'create', array('is_transactional' => FALSE, 'options' => array('force_rollback' => TRUE)), TRUE, TRUE, TRUE);
+ $r[] = array(3, 'Widget', 'create', array('is_transactional' => FALSE, 'options' => array('force_rollback' => FALSE)), FALSE, FALSE, FALSE);
+
+ $r[] = array(4, 'Widget', 'get', array(), FALSE, FALSE, FALSE);
+ $r[] = array(4, 'Widget', 'create', array(), TRUE, FALSE, FALSE);
+
+ $r[] = array(4, 'Widget', 'create', array('is_transactional' => TRUE), TRUE, FALSE, FALSE);
+ $r[] = array(4, 'Widget', 'create', array('is_transactional' => FALSE), FALSE, FALSE, FALSE);
+ $r[] = array(4, 'Widget', 'create', array('is_transactional' => 'nest'), TRUE, FALSE, TRUE);
+
+ $r[] = array(4, 'Widget', 'create', array('options' => array('force_rollback' => TRUE)), TRUE, TRUE, TRUE);
+ $r[] = array(4, 'Widget', 'create', array('options' => array('force_rollback' => FALSE)), TRUE, FALSE, FALSE);
+
+ return $r;
+ }
+
+ /**
+ * Ensure that API parameters "is_transactional" and "force_rollback" are parsed correctly
+ * @dataProvider transactionOptions
+ */
+ function testTransactionOptions($version, $entity, $action, $params, $isTransactional, $isForceRollback, $isNested) {
+ $txs = new TransactionSubscriber();
+ $apiProvider = NULL;
+
+ $params['version'] = $version;
+ $apiRequest = \Civi\API\Request::create($entity, $action, $params, array());
+
+ $this->assertEquals($isTransactional, $txs->isTransactional($apiProvider, $apiRequest), 'check isTransactional');
+ $this->assertEquals($isForceRollback, $txs->isForceRollback($apiProvider, $apiRequest), 'check isForceRollback');
+ $this->assertEquals($isNested, $txs->isNested($apiProvider, $apiRequest), 'check isNested');
+ }
+
+ function testForceRollback() {
+ $result = $this->callAPISuccess('contact', 'create', array(
+ 'contact_type' => 'Individual',
+ 'first_name' => 'Me',
+ 'last_name' => 'Myself',
+ 'options' => array(
+ 'force_rollback' => TRUE
+ ),
+ ));
+ $this->assertTrue(is_numeric($result['id']));
+ $this->assertDBQuery(0, 'SELECT count(*) FROM civicrm_contact WHERE id = %1', array(
+ 1 => array($result['id'], 'Integer'),
+ ));
+ }
+}