APIv4 - Add export action to managed entities
authorColeman Watts <coleman@civicrm.org>
Tue, 9 Nov 2021 13:57:28 +0000 (08:57 -0500)
committerColeman Watts <coleman@civicrm.org>
Sat, 27 Nov 2021 01:26:47 +0000 (20:26 -0500)
This action generates an exportable array suitable for use in a .mgd.php file.

CRM/Core/DAO.php
Civi/Api4/Generic/ExportAction.php [new file with mode: 0644]
Civi/Api4/Generic/Traits/ManagedEntity.php
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchExportTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Entity/ManagedEntityTest.php

index 0104f8614cd461c642f7814b4e714ae8520fe820..84bf0251e9ffa827a7afcdb65680f30e0a5ba9f2 100644 (file)
@@ -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 (file)
index 0000000..b7de9e8
--- /dev/null
@@ -0,0 +1,192 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+use Civi\Api4\Utils\CoreUtil;
+
+/**
+ * Export $ENTITY to civicrm_managed format.
+ *
+ * This action generates an exportable array suitable for use in a .mgd.php file.
+ * The array will include any other entities that reference the $ENTITY.
+ *
+ * @method $this setId(int $id)
+ * @method int getId()
+ * @method $this setCleanup(string $cleanup)
+ * @method string getCleanup()
+ * @method $this setUpdate(string $update)
+ * @method string getUpdate()
+ */
+class ExportAction extends AbstractAction {
+
+  /**
+   * Id of $ENTITY to export
+   * @var int
+   * @required
+   */
+  protected $id;
+
+  /**
+   * Specify rule for auto-updating managed entity
+   * @var string
+   * @options never,always,unmodified
+   */
+  protected $update = 'unmodified';
+
+  /**
+   * Specify rule for auto-deleting managed entity
+   * @var string
+   * @options never,always,unused
+   */
+  protected $cleanup = 'unused';
+
+  /**
+   * Used to prevent circular references
+   * @var array
+   */
+  private $exportedEntities = [];
+
+  /**
+   * @param \Civi\Api4\Generic\Result $result
+   */
+  public function _run(Result $result) {
+    $this->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 [];
+    }
+  }
+
+}
index da2e25899c4e4a41f181b1c2bc02c89e56a918b0..df7fc0edf6be5393c4cc952cf189ebee6f4201b8 100644 (file)
@@ -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 (file)
index 0000000..9a90a09
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+namespace api\v4\SearchDisplay;
+
+use Civi\Api4\SavedSearch;
+use Civi\Api4\SearchDisplay;
+use Civi\Test\HeadlessInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class SearchExportTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, TransactionalInterface {
+
+  public function setUpHeadless() {
+    // Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
+    // See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
+    return \Civi\Test::headless()
+      ->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']);
+  }
+
+}
index 70403a1accb905b1ab5277e6c365647e40e86a66..96683664b50c0339d775835da55fb5b98fd4ead4 100644 (file)
@@ -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