From db11224f49b015c1bfc031a0ad87ba7ed1bf499e Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Mon, 18 Oct 2021 20:09:52 -0400 Subject: [PATCH] APIv4 - Add 'match' param to save action This works similarly to the 'match' param in v3 `create`, it checks for records matching the given fields, and will update instead of create if an existing record is found. --- Civi/Api4/Generic/AbstractAction.php | 9 +++- Civi/Api4/Generic/AbstractSaveAction.php | 60 +++++++++++++++++++++ Civi/Api4/Generic/BasicSaveAction.php | 1 + Civi/Api4/Generic/DAOSaveAction.php | 1 + ang/api4Explorer/Explorer.html | 5 +- tests/phpunit/api/v4/Action/SaveTest.php | 68 ++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/api/v4/Action/SaveTest.php diff --git a/Civi/Api4/Generic/AbstractAction.php b/Civi/Api4/Generic/AbstractAction.php index fcc6001ab7..08daf23954 100644 --- a/Civi/Api4/Generic/AbstractAction.php +++ b/Civi/Api4/Generic/AbstractAction.php @@ -281,8 +281,13 @@ abstract class AbstractAction implements \ArrayAccess { foreach ($this->reflect()->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) { $name = $property->getName(); if ($name != 'version' && $name[0] != '_') { - $this->_paramInfo[$name] = ReflectionUtils::getCodeDocs($property, 'Property', $vars); - $this->_paramInfo[$name]['default'] = $defaults[$name]; + $docs = ReflectionUtils::getCodeDocs($property, 'Property', $vars); + $docs['default'] = $defaults[$name]; + if (!empty($docs['optionsCallback'])) { + $docs['options'] = $this->{$docs['optionsCallback']}(); + unset($docs['optionsCallback']); + } + $this->_paramInfo[$name] = $docs; } } } diff --git a/Civi/Api4/Generic/AbstractSaveAction.php b/Civi/Api4/Generic/AbstractSaveAction.php index 3892e4f1b2..50aa311c46 100644 --- a/Civi/Api4/Generic/AbstractSaveAction.php +++ b/Civi/Api4/Generic/AbstractSaveAction.php @@ -32,6 +32,8 @@ use Civi\Api4\Utils\CoreUtil; * @method array getDefaults() * @method $this setReload(bool $reload) Specify whether complete objects will be returned after saving. * @method bool getReload() + * @method $this setMatch(array $match) Specify fields to match for update. + * @method bool getMatch() * * @package Civi\Api4\Generic */ @@ -69,6 +71,20 @@ abstract class AbstractSaveAction extends AbstractAction { */ protected $reload = FALSE; + /** + * Specify fields to match for update. + * + * Normally each record is either created or updated based on the presence of an `id`. + * Specifying `$match` fields will also perform an update if an existing $ENTITY matches all specified fields. + * + * Note: the fields named in this param should be without any options suffix (e.g. `my_field` not `my_field:name`). + * Any options suffixes in the $records will be resolved by the api prior to matching. + * + * @var array + * @optionsCallback getMatchFields + */ + protected $match = []; + /** * @throws \API_Exception * @throws \Civi\API\Exception\UnauthorizedException @@ -115,8 +131,37 @@ abstract class AbstractSaveAction extends AbstractAction { } } + /** + * Find existing record based on $this->match param + * + * @param $record + */ + protected function matchExisting(&$record) { + $primaryKey = CoreUtil::getIdFieldName($this->getEntityName()); + if (empty($record[$primaryKey]) && !empty($this->match)) { + $where = []; + foreach ($record as $key => $val) { + if (isset($val) && in_array($key, $this->match, TRUE)) { + $where[] = [$key, '=', $val]; + } + } + if (count($where) === count($this->match)) { + $existing = civicrm_api4($this->getEntityName(), 'get', [ + 'select' => [$primaryKey], + 'where' => $where, + 'checkPermissions' => $this->checkPermissions, + 'limit' => 2, + ]); + if ($existing->count() === 1) { + $record[$primaryKey] = $existing->first()[$primaryKey]; + } + } + } + } + /** * @return string + * @deprecated */ protected function getIdField() { return CoreUtil::getInfoItem($this->getEntityName(), 'primary_key')[0]; @@ -143,4 +188,19 @@ abstract class AbstractSaveAction extends AbstractAction { return $this; } + /** + * Options callback for $this->match + * @return array + */ + protected function getMatchFields() { + return (array) civicrm_api4($this->getEntityName(), 'getFields', [ + 'checkPermissions' => FALSE, + 'action' => 'get', + 'where' => [ + ['type', 'IN', ['Field', 'Custom']], + ['name', 'NOT IN', CoreUtil::getInfoItem($this->getEntityName(), 'primary_key')], + ], + ], ['name']); + } + } diff --git a/Civi/Api4/Generic/BasicSaveAction.php b/Civi/Api4/Generic/BasicSaveAction.php index b886b50e3e..a312554d2f 100644 --- a/Civi/Api4/Generic/BasicSaveAction.php +++ b/Civi/Api4/Generic/BasicSaveAction.php @@ -56,6 +56,7 @@ class BasicSaveAction extends AbstractSaveAction { foreach ($this->records as &$record) { $record += $this->defaults; $this->formatWriteValues($record); + $this->matchExisting($record); } $this->validateValues(); foreach ($this->records as $item) { diff --git a/Civi/Api4/Generic/DAOSaveAction.php b/Civi/Api4/Generic/DAOSaveAction.php index 14eee58945..7d6d6b709f 100644 --- a/Civi/Api4/Generic/DAOSaveAction.php +++ b/Civi/Api4/Generic/DAOSaveAction.php @@ -25,6 +25,7 @@ class DAOSaveAction extends AbstractSaveAction { foreach ($this->records as &$record) { $record += $this->defaults; $this->formatWriteValues($record); + $this->matchExisting($record); if (empty($record['id'])) { $this->fillDefaults($record); } diff --git a/ang/api4Explorer/Explorer.html b/ang/api4Explorer/Explorer.html index 3d80055942..43308c15bb 100644 --- a/ang/api4Explorer/Explorer.html +++ b/ang/api4Explorer/Explorer.html @@ -98,8 +98,11 @@
- +
diff --git a/tests/phpunit/api/v4/Action/SaveTest.php b/tests/phpunit/api/v4/Action/SaveTest.php new file mode 100644 index 0000000000..d58b4f4c74 --- /dev/null +++ b/tests/phpunit/api/v4/Action/SaveTest.php @@ -0,0 +1,68 @@ + 'One', 'last_name' => 'Test', 'external_identifier' => 'abc'], + ['first_name' => 'Two', 'last_name' => 'Test', 'external_identifier' => 'def'], + ]; + + $contacts = Contact::save(FALSE) + ->setRecords($records) + ->execute(); + + $records[0]['last_name'] = $records[1]['last_name'] = 'Changed'; + $records[0]['external_identifier'] = 'ghi'; + + $modified = Contact::save(FALSE) + ->setRecords($records) + ->setMatch(['first_name', 'external_identifier']) + ->execute(); + + $this->assertGreaterThan($contacts[0]['id'], $modified[0]['id']); + $this->assertEquals($contacts[1]['id'], $modified[1]['id']); + + $ids = [$contacts[0]['id'], $modified[0]['id'], $contacts[1]['id']]; + $get = Contact::get(FALSE) + ->setSelect(['id', 'first_name', 'last_name', 'external_identifier']) + ->addWhere('id', 'IN', $ids) + ->addOrderBy('id') + ->execute(); + $expected = [ + // Original insert + ['id' => $contacts[0]['id'], 'first_name' => 'One', 'last_name' => 'Test', 'external_identifier' => 'abc'], + // Match+update + ['id' => $contacts[1]['id'], 'first_name' => 'Two', 'last_name' => 'Changed', 'external_identifier' => 'def'], + // Subsequent insert + ['id' => $modified[0]['id'], 'first_name' => 'One', 'last_name' => 'Changed', 'external_identifier' => 'ghi'], + ]; + $this->assertEquals($expected, (array) $get); + } + +} -- 2.25.1