--- /dev/null
+<?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 [];
+ }
+ }
+
+}
--- /dev/null
+<?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']);
+ }
+
+}