CRM-15578 - APIv3 - options.force_rollback
authorTim Otten <totten@civicrm.org>
Mon, 10 Nov 2014 22:54:48 +0000 (14:54 -0800)
committerTim Otten <totten@civicrm.org>
Thu, 13 Nov 2014 23:45:50 +0000 (15:45 -0800)
Civi/API/Subscriber/TransactionSubscriber.php
tests/phpunit/Civi/API/Subscriber/TransactionSubscriberTest.php [new file with mode: 0644]

index 2dd33da51452434235d39d5c7bc230cf3c1fd198..ca16b7d7bd7243acb9320180124f5a7a72b1444a 100644 (file)
@@ -31,6 +31,12 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
  * 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 {
@@ -50,6 +56,14 @@ 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
    *
@@ -58,12 +72,50 @@ class TransactionSubscriber implements EventSubscriberInterface {
    * @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)
    *
@@ -72,7 +124,10 @@ class TransactionSubscriber implements EventSubscriberInterface {
   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();
     }
   }
 
diff --git a/tests/phpunit/Civi/API/Subscriber/TransactionSubscriberTest.php b/tests/phpunit/Civi/API/Subscriber/TransactionSubscriberTest.php
new file mode 100644 (file)
index 0000000..e56b05a
--- /dev/null
@@ -0,0 +1,78 @@
+<?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'),
+    ));
+  }
+}