From e4b4e33a647a1e7eb6490d36dda401168d170047 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 30 Aug 2013 18:39:51 -0700 Subject: [PATCH] CRM-13312 - CRM_Utils_API_MatchOption - Improvements: * Extend support to the "replace" action * Accept 'match' as a string *or* array * Tweak comments ---------------------------------------- * CRM-13312: Implement API support for options.match http://issues.civicrm.org/jira/browse/CRM-13312 --- CRM/Utils/API/MatchOption.php | 89 +++++++++++++++---- api/v3/utils.php | 12 ++- .../phpunit/CRM/Utils/API/MatchOptionTest.php | 85 +++++++++++++++++- 3 files changed, 161 insertions(+), 25 deletions(-) diff --git a/CRM/Utils/API/MatchOption.php b/CRM/Utils/API/MatchOption.php index 494e2d1705..d09f3fe6f1 100644 --- a/CRM/Utils/API/MatchOption.php +++ b/CRM/Utils/API/MatchOption.php @@ -28,7 +28,8 @@ /** * Implement the "match" and "match-mandatory" options. If the submitted record doesn't have an ID * but a "match" key is specified, then we will automatically search for pre-existing record and - * update it. + * fill-in the missing ID. The "match" or "match-mandatory" can specified as a string (the name of the key + * to match on) or array (the names of several keys to match on). * * Note that "match" and "match-mandatory" behave the same in the case where one matching record * exists (ie they update the record). They also behave the same if there are multiple matching @@ -74,34 +75,86 @@ class CRM_Utils_API_MatchOption implements API_Wrapper { * {@inheritDoc} */ public function fromApiInput($apiRequest) { - if ($apiRequest['action'] === 'create' && empty($apiRequest['params']['id']) && isset($apiRequest['params'], $apiRequest['params']['options'])) { - $keys = NULL; + // Parse options.match or options.match-mandatory + $keys = NULL; // array of fields to match against + if (isset($apiRequest['params'], $apiRequest['params']['options'])) { if (isset($apiRequest['params']['options']['match-mandatory'])) { $isMandatory = TRUE; $keys = $apiRequest['params']['options']['match-mandatory']; } - elseif ($apiRequest['params']['options']['match']) { + elseif (isset($apiRequest['params']['options']['match'])) { $isMandatory = FALSE; $keys = $apiRequest['params']['options']['match']; } + if (is_string($keys)) { + $keys = array($keys); + } + } - if (!empty($keys)) { - $getParams = $this->createGetParams($apiRequest, $keys); - $getResult = civicrm_api3($apiRequest['entity'], 'get', $getParams); - if ($getResult['count'] == 0) { - if ($isMandatory) throw new API_Exception("Failed to match existing record"); - // OK, don't care - } elseif ($getResult['count'] == 1) { - $item = array_shift($getResult['values']); - $apiRequest['params']['id'] = $item['id']; - } else { - throw new API_Exception("Ambiguous match criteria"); - } + // If one of the options was specified, then try to match records. + // Matching logic differs for 'create' and 'replace' actions. + if ($keys !== NULL) { + switch($apiRequest['action']) { + case 'create': + if (empty($apiRequest['params']['id'])) { + $apiRequest['params'] = $this->match($apiRequest['entity'], $apiRequest['params'], $keys, $isMandatory); + } + break; + case 'replace': + // In addition to matching on the listed keys, also match on the set-definition keys. + // For example, if the $apiRequest is to "replace the set of civicrm_emails for contact_id=123 while + // matching emails on location_type_id", then we would need to search for pre-existing emails using + // both 'contact_id' and 'location_type_id' + $baseParams = _civicrm_api3_generic_replace_base_params($apiRequest['params']); + $keys = array_unique(array_merge( + array_keys($baseParams), + $keys + )); + + // attempt to match each replacement item + foreach($apiRequest['params']['values'] as $offset => $createParams) { + $createParams = array_merge($baseParams, $createParams); + $createParams = $this->match($apiRequest['entity'], $createParams, $keys, $isMandatory); + $apiRequest['params']['values'][$offset] = $createParams; + } + break; + default: + // be forgiveful of sloppily api calls } } + return $apiRequest; } + /** + * Attempt to match a contact. This filters/updates the $createParams if there is a match. + * + * @param string $entity + * @param array $createParams + * @param array $keys + * @param bool $isMandatory + * @return array revised $createParams, including 'id' if known + * @throws API_Exception + */ + public function match($entity, $createParams, $keys, $isMandatory) { + $getParams = $this->createGetParams($createParams, $keys); + $getResult = civicrm_api3($entity, 'get', $getParams); + if ($getResult['count'] == 0) { + if ($isMandatory) { + throw new API_Exception("Failed to match existing record"); + } + return $createParams; // OK, don't care + } + elseif ($getResult['count'] == 1) { + $item = array_shift($getResult['values']); + $createParams['id'] = $item['id']; + return $createParams; + } + else { + throw new API_Exception("Ambiguous match criteria"); + } + } + /** * {@inheritDoc} */ @@ -116,10 +169,10 @@ class CRM_Utils_API_MatchOption implements API_Wrapper { * @param array $keys list of keys to match against * @return array APIv3 $params */ - function createGetParams($apiRequest, $keys) { + function createGetParams($origParams, $keys) { $params = array('version' => 3); foreach ($keys as $key) { - $params[$key] = CRM_Utils_Array::value($key, $apiRequest['params'], ''); + $params[$key] = CRM_Utils_Array::value($key, $origParams, ''); } return $params; } diff --git a/api/v3/utils.php b/api/v3/utils.php index 149a7fb34b..ca168f90c5 100644 --- a/api/v3/utils.php +++ b/api/v3/utils.php @@ -1220,9 +1220,7 @@ function _civicrm_api3_generic_replace($entity, $params) { } // Extract the keys -- somewhat scary, don't think too hard about it - $baseParams = $params; - unset($baseParams['values']); - unset($baseParams['sequential']); + $baseParams = _civicrm_api3_generic_replace_base_params($params); // Lookup pre-existing records $preexisting = civicrm_api($entity, 'get', $baseParams, $params); @@ -1275,6 +1273,14 @@ function _civicrm_api3_generic_replace($entity, $params) { } } +function _civicrm_api3_generic_replace_base_params($params) { + $baseParams = $params; + unset($baseParams['values']); + unset($baseParams['sequential']); + unset($baseParams['options']); + return $baseParams; +} + /** * returns fields allowable by api * @param $entity string Entity to query diff --git a/tests/phpunit/CRM/Utils/API/MatchOptionTest.php b/tests/phpunit/CRM/Utils/API/MatchOptionTest.php index 60fcae9d7b..ea087029e1 100644 --- a/tests/phpunit/CRM/Utils/API/MatchOptionTest.php +++ b/tests/phpunit/CRM/Utils/API/MatchOptionTest.php @@ -17,7 +17,7 @@ class CRM_Utils_API_MatchOptionTest extends CiviUnitTestCase { /** * If there's no pre-existing record, then insert a new one. */ - function testMatch_none() { + function testCreateMatch_none() { $result = $this->callAPISuccess('contact', 'create', array( 'options' => array( 'match' => array('first_name', 'last_name'), @@ -35,7 +35,7 @@ class CRM_Utils_API_MatchOptionTest extends CiviUnitTestCase { /** * If there's no pre-existing record, then throw an error. */ - function testMatchMandatory_none() { + function testCreateMatchMandatory_none() { $this->callAPIFailure('contact', 'create', array( 'options' => array( 'match-mandatory' => array('first_name', 'last_name'), @@ -61,7 +61,7 @@ class CRM_Utils_API_MatchOptionTest extends CiviUnitTestCase { * @dataProvider apiOptionNames * @param string $apiOptionName e.g. "match" or "match-mandatory" */ - function testMatch_one($apiOptionName) { + function testCreateMatch_one($apiOptionName) { // create basic record $result1 = $this->callAPISuccess('contact', 'create', array( 'contact_type' => 'Individual', @@ -99,7 +99,7 @@ class CRM_Utils_API_MatchOptionTest extends CiviUnitTestCase { * @dataProvider apiOptionNames * @param string $apiOptionName e.g. "match" or "match-mandatory" */ - function testMatch_many($apiOptionName) { + function testCreateMatch_many($apiOptionName) { // create the first Lebowski $result1 = $this->callAPISuccess('contact', 'create', array( 'contact_type' => 'Individual', @@ -133,4 +133,81 @@ class CRM_Utils_API_MatchOptionTest extends CiviUnitTestCase { ), 'Ambiguous match criteria'); } + /** + * When replacing one set with another set, match items within + * the set using a key. + */ + function testReplaceMatch() { + // Create contact with two emails (j1,j2) + $createResult = $this->callAPISuccess('contact', 'create', array( + 'contact_type' => 'Individual', + 'first_name' => 'Jeffrey', + 'last_name' => 'Lebowski', + 'api.Email.replace' => array( + 'options' => array('match' => 'location_type_id'), + 'values' => array( + array('location_type_id' => 1, 'email' => 'j1-a@example.com', 'signature_text' => 'The Dude abides.'), + array('location_type_id' => 2, 'email' => 'j2@example.com', 'signature_text' => 'You know, a lotta ins, a lotta outs, a lotta what-have-yous.'), + ), + ), + )); + $this->assertEquals(1, $createResult['count']); + foreach ($createResult['values'] as $value) { + $this->assertAPISuccess($value['api.Email.replace']); + $this->assertEquals(2, $value['api.Email.replace']['count']); + foreach ($value['api.Email.replace']['values'] as $v2) { + $this->assertEquals($createResult['id'], $v2['contact_id']); + } + $createEmailValues = array_values($value['api.Email.replace']['values']); + } + + // Update contact's emails -- specifically, modify j1, delete j2, add j3 + $updateResult = $this->callAPISuccess('contact', 'create', array( + 'id' => $createResult['id'], + 'nick_name' => 'The Dude', + 'api.Email.replace' => array( + 'options' => array('match' => 'location_type_id'), + 'values' => array( + array('location_type_id' => 1, 'email' => 'j1-b@example.com'), + array('location_type_id' => 3, 'email' => 'j3@example.com'), + ), + ), + )); + $this->assertEquals(1, $updateResult['count']); + foreach ($updateResult['values'] as $value) { + $this->assertAPISuccess($value['api.Email.replace']); + $this->assertEquals(2, $value['api.Email.replace']['count']); + foreach ($value['api.Email.replace']['values'] as $v2) { + $this->assertEquals($createResult['id'], $v2['contact_id']); + } + $updateEmailValues = array_values($value['api.Email.replace']['values']); + } + + // Re-read from DB + $getResult = $this->callAPISuccess('Email', 'get', array( + 'contact_id' => $createResult['id'], + )); + $this->assertEquals(2, $getResult['count']); + $getValues = array_values($getResult['values']); + + // The first email (j1@example.com) is updated (same ID#) because it matched on contact_id+location_type_id. + $this->assertTrue(is_numeric($createEmailValues[0]['id'])); + $this->assertTrue(is_numeric($updateEmailValues[0]['id'])); + $this->assertTrue(is_numeric($getValues[0]['id'])); + $this->assertEquals($createEmailValues[0]['id'], $updateEmailValues[0]['id']); + $this->assertEquals($createEmailValues[0]['id'], $getValues[0]['id']); + $this->assertEquals('j1-b@example.com', $getValues[0]['email']); + $this->assertEquals('The Dude abides.', $getValues[0]['signature_text']); // preserved from original creation; proves that we updated existing record + + // The second email (j2@example.com) is deleted because contact_id+location_type_id doesn't appear in new list. + // The third email (j3@example.com) is inserted (new ID#) because it doesn't match an existing contact_id+location_type_id. + $this->assertTrue(is_numeric($createEmailValues[1]['id'])); + $this->assertTrue(is_numeric($updateEmailValues[1]['id'])); + $this->assertTrue(is_numeric($getValues[1]['id'])); + $this->assertNotEquals($createEmailValues[1]['id'], $updateEmailValues[1]['id']); + $this->assertEquals($updateEmailValues[1]['id'], $getValues[1]['id']); + $this->assertEquals('j3@example.com', $getValues[1]['email']); + $this->assertTrue(empty($getValues[1]['signature_text'])); + } + } -- 2.25.1