From 4d8c26291d06c934395c4a5a8a842afa691139c8 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Tue, 9 Nov 2021 08:57:28 -0500 Subject: [PATCH] APIv4 - Add export action to managed entities This action generates an exportable array suitable for use in a .mgd.php file. --- CRM/Core/DAO.php | 3 +- Civi/Api4/Generic/ExportAction.php | 192 ++++++++++++++++++ Civi/Api4/Generic/Traits/ManagedEntity.php | 10 + .../api/v4/SearchDisplay/SearchExportTest.php | 121 +++++++++++ .../api/v4/Entity/ManagedEntityTest.php | 11 + 5 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 Civi/Api4/Generic/ExportAction.php create mode 100644 ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchExportTest.php diff --git a/CRM/Core/DAO.php b/CRM/Core/DAO.php index 0104f8614c..84bf0251e9 100644 --- a/CRM/Core/DAO.php +++ b/CRM/Core/DAO.php @@ -2523,8 +2523,7 @@ SELECT contact_id /** * Find all records which refer to this entity. * - * @return array - * Array of objects referencing this + * @return CRM_Core_DAO[] */ public function findReferences() { $links = self::getReferencesToTable(static::getTableName()); diff --git a/Civi/Api4/Generic/ExportAction.php b/Civi/Api4/Generic/ExportAction.php new file mode 100644 index 0000000000..b7de9e8d77 --- /dev/null +++ b/Civi/Api4/Generic/ExportAction.php @@ -0,0 +1,192 @@ +exportRecord($this->getEntityName(), $this->id, $result); + } + + /** + * @param string $entityType + * @param int $entityId + * @param \Civi\Api4\Generic\Result $result + * @param string $parentName + */ + private function exportRecord(string $entityType, int $entityId, Result $result, $parentName = NULL) { + if (isset($this->exportedEntities[$entityType][$entityId])) { + throw new \API_Exception("Circular reference detected: attempted to export $entityType id $entityId multiple times."); + } + $this->exportedEntities[$entityType][$entityId] = TRUE; + $select = $pseudofields = []; + $allFields = $this->getFieldsForExport($entityType, TRUE); + foreach ($allFields as $field) { + // Use implicit join syntax but only if the fk entity has a `name` field + if (!empty($field['fk_entity']) && array_key_exists('name', $this->getFieldsForExport($field['fk_entity']))) { + $select[] = $field['name'] . '.name'; + $pseudofields[$field['name'] . '.name'] = $field['name']; + } + // Use pseudoconstant syntax if appropriate + elseif ($this->shouldUsePseudoconstant($field)) { + $select[] = $field['name'] . ':name'; + $pseudofields[$field['name'] . ':name'] = $field['name']; + } + elseif (empty($field['fk_entity'])) { + $select[] = $field['name']; + } + } + $record = civicrm_api4($entityType, 'get', [ + 'checkPermissions' => $this->checkPermissions, + 'select' => $select, + 'where' => [['id', '=', $entityId]], + ])->first(); + if (!$record) { + return; + } + // The get api always returns ID, but it should not be included in an export + unset($record['id']); + // Null fields should not use joins/pseudoconstants + foreach ($pseudofields as $alias => $fieldName) { + if (is_null($record[$alias])) { + unset($record[$alias]); + $record[$fieldName] = NULL; + } + } + // Special handing of current_domain + foreach ($allFields as $fieldName => $field) { + if (($field['fk_entity'] ?? NULL) === 'Domain') { + $alias = $fieldName . '.name'; + if (isset($record[$alias]) && $record[$alias] === \CRM_Core_BAO_Domain::getDomain()->name) { + unset($record[$alias]); + $record[$fieldName] = 'current_domain'; + } + } + } + $name = ($parentName ?? '') . $entityType . '_' . ($record['name'] ?? count($this->exportedEntities[$entityType])); + $result[] = [ + 'name' => $name, + 'entity' => $entityType, + 'cleanup' => $this->cleanup, + 'update' => $this->update, + 'params' => [ + 'version' => 4, + 'values' => $record, + ], + ]; + // Export entities that reference this one + $daoName = CoreUtil::getInfoItem($entityType, 'dao'); + /** @var \CRM_Core_DAO $dao */ + $dao = new $daoName(); + $dao->id = $entityId; + foreach ($dao->findReferences() as $reference) { + $refEntity = $reference::fields()['id']['entity'] ?? ''; + $refApiType = CoreUtil::getInfoItem($refEntity, 'type') ?? []; + // Reference must be a ManagedEntity + if (in_array('ManagedEntity', $refApiType, TRUE)) { + $this->exportRecord($refEntity, $reference->id, $result, $name . '_'); + } + } + } + + /** + * If a field has a pseudoconstant list, determine whether it would be better + * to use pseudoconstant (field:name) syntax. + * + * Generally speaking, options with numeric keys are the ones we need to worry about + * because auto-increment keys can vary when migrating an entity to a different database. + * + * But options with string keys tend to be stable, + * and it's better not to use the pseudoconstant syntax with these fields because + * the option list may not be populated at the time of managed entity reconciliation. + * + * @param array $field + * @return bool + */ + private function shouldUsePseudoconstant(array $field) { + if (empty($field['options'])) { + return FALSE; + } + $numericKeys = array_filter(array_keys($field['options']), 'is_numeric'); + return count($numericKeys) === count($field['options']); + } + + /** + * @param $entityType + * @param bool $loadOptions + * @return array + */ + private function getFieldsForExport($entityType, $loadOptions = FALSE): array { + try { + return (array) civicrm_api4($entityType, 'getFields', [ + 'action' => 'create', + 'where' => [ + ['type', 'IN', ['Field', 'Custom']], + ['readonly', '!=', TRUE], + ], + 'loadOptions' => $loadOptions, + 'checkPermissions' => $this->checkPermissions, + ])->indexBy('name'); + } + catch (NotImplementedException $e) { + return []; + } + } + +} diff --git a/Civi/Api4/Generic/Traits/ManagedEntity.php b/Civi/Api4/Generic/Traits/ManagedEntity.php index da2e25899c..df7fc0edf6 100644 --- a/Civi/Api4/Generic/Traits/ManagedEntity.php +++ b/Civi/Api4/Generic/Traits/ManagedEntity.php @@ -12,6 +12,7 @@ namespace Civi\Api4\Generic\Traits; use Civi\Api4\Generic\BasicBatchAction; +use Civi\Api4\Generic\ExportAction; /** * A managed entity includes extra fields and methods to revert from an overridden local to base state. @@ -36,4 +37,13 @@ trait ManagedEntity { }))->setCheckPermissions($checkPermissions); } + /** + * @param bool $checkPermissions + * @return \Civi\Api4\Generic\ExportAction + */ + public static function export($checkPermissions = TRUE) { + return (new ExportAction(static::getEntityName(), __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + } diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchExportTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchExportTest.php new file mode 100644 index 0000000000..9a90a0910c --- /dev/null +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchExportTest.php @@ -0,0 +1,121 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * Test using the Export action on a SavedSearch. + */ + public function testExportSearch() { + $search = SavedSearch::create(FALSE) + ->setValues([ + 'name' => 'TestSearchToExport', + 'label' => 'TestSearchToExport', + 'api_entity' => 'Contact', + 'api_params' => [ + 'version' => 4, + 'select' => ['id'], + ], + ]) + ->execute()->first(); + + SearchDisplay::create(FALSE) + ->setValues([ + 'name' => 'TestDisplayToExport', + 'label' => 'TestDisplayToExport', + 'saved_search_id.name' => 'TestSearchToExport', + 'type' => 'table', + 'settings' => [ + 'columns' => [ + [ + 'key' => 'id', + 'label' => 'Contact ID', + 'dataType' => 'Integer', + 'type' => 'field', + ], + ], + ], + 'acl_bypass' => FALSE, + ]) + ->execute(); + + $export = SavedSearch::export(FALSE) + ->setId($search['id']) + ->execute() + ->indexBy('name'); + + $this->assertCount(2, $export); + // Default update policy should be 'unmodified' + $this->assertEquals('unmodified', $export->first()['update']); + $this->assertEquals('unmodified', $export->itemAt(1)['update']); + // Default cleanup policy should be 'unused' + $this->assertEquals('unused', $export->first()['cleanup']); + $this->assertEquals('unused', $export->itemAt(1)['cleanup']); + // The savedSearch should be first before its reference entities + $this->assertEquals('SavedSearch', $export->first()['entity']); + // Ensure api version is set to 4 + $this->assertEquals(4, $export['SavedSearch_TestSearchToExport']['params']['version']); + $this->assertEquals('Contact', $export['SavedSearch_TestSearchToExport']['params']['values']['api_entity']); + // Ensure FK is set correctly + $this->assertArrayNotHasKey('saved_search_id', $export['SavedSearch_TestSearchToExport_SearchDisplay_TestDisplayToExport']['params']['values']); + $this->assertEquals('TestSearchToExport', $export['SavedSearch_TestSearchToExport_SearchDisplay_TestDisplayToExport']['params']['values']['saved_search_id.name']); + // Ensure value is used instead of pseudoconstant + $this->assertEquals('table', $export['SavedSearch_TestSearchToExport_SearchDisplay_TestDisplayToExport']['params']['values']['type']); + $this->assertArrayNotHasKey('type:name', $export['SavedSearch_TestSearchToExport_SearchDisplay_TestDisplayToExport']['params']['values']); + // Readonly fields should not be included + $this->assertArrayNotHasKey('created_date', $export['SavedSearch_TestSearchToExport_SearchDisplay_TestDisplayToExport']['params']['values']); + $this->assertArrayNotHasKey('modified_date', $export['SavedSearch_TestSearchToExport_SearchDisplay_TestDisplayToExport']['params']['values']); + + // Add a second display + SearchDisplay::create(FALSE) + ->setValues([ + 'name' => 'SecondDisplayToExport', + 'label' => 'TestDisplayToExport', + 'saved_search_id.name' => 'TestSearchToExport', + 'type' => 'table', + 'settings' => [ + 'columns' => [ + [ + 'key' => 'id', + 'label' => 'Contact ID', + 'dataType' => 'Integer', + 'type' => 'field', + ], + ], + ], + 'acl_bypass' => FALSE, + ]) + ->execute(); + + $export = SavedSearch::export(FALSE) + ->setId($search['id']) + ->setCleanup('always') + ->setUpdate('never') + ->execute() + ->indexBy('name'); + + $this->assertCount(3, $export); + $this->assertEquals('always', $export->first()['cleanup']); + $this->assertEquals('never', $export->first()['update']); + $this->assertEquals('always', $export->last()['cleanup']); + $this->assertEquals('never', $export->last()['update']); + $this->assertEquals('TestSearchToExport', $export['SavedSearch_TestSearchToExport_SearchDisplay_SecondDisplayToExport']['params']['values']['saved_search_id.name']); + } + +} diff --git a/tests/phpunit/api/v4/Entity/ManagedEntityTest.php b/tests/phpunit/api/v4/Entity/ManagedEntityTest.php index 70403a1acc..96683664b5 100644 --- a/tests/phpunit/api/v4/Entity/ManagedEntityTest.php +++ b/tests/phpunit/api/v4/Entity/ManagedEntityTest.php @@ -352,6 +352,17 @@ class ManagedEntityTest extends UnitTestCase implements TransactionalInterface, $this->assertCount(0, OptionGroup::get(FALSE)->addWhere('name', '=', 'testManagedOptionGroup')->execute()); } + public function testExportOptionGroupWithDomain() { + $result = OptionGroup::get(FALSE) + ->addWhere('name', '=', 'from_email_address') + ->addChain('export', OptionGroup::export()->setId('$id')) + ->execute()->first(); + $this->assertEquals('from_email_address', $result['export'][1]['params']['values']['option_group_id.name']); + $this->assertEquals('current_domain', $result['export'][1]['params']['values']['domain_id']); + $this->assertNull($result['export'][1]['params']['values']['visibility_id']); + $this->assertStringStartsWith('OptionGroup_from_email_address_OptionValue_', $result['export'][1]['name']); + } + /** * @dataProvider sampleEntityTypes * @param string $entityName -- 2.25.1