From 70284e3e764bf3a66850f98a7e201c87523780d2 Mon Sep 17 00:00:00 2001 From: "Matthew Wire (MJW)" Date: Mon, 27 Jul 2026 17:34:29 +0100 Subject: [PATCH] Api4 - Support portable export/import for dynamic foreign keys Dynamic FKs (e.g. an `entity_id` column paired with an `entity_table` discriminator, as used by PriceSetEntity, EntityTag, Note, etc.) had no way to export/import portably: ExportAction only checked the static `fk_entity` field, so such columns always fell back to a raw, non-portable database id, and DAOActionTrait::resolveFKValues() had no way to resolve a `.name`-suffixed dynamic FK back to an id on write. ExportAction now resolves the concrete target entity for each dynamic FK from the record's own discriminator column value, and uses a `field.name` lookup instead of the raw id when that target entity has a `name` field (falling back to the raw id otherwise, unchanged). resolveFKValues() gains the matching write-side resolution, using the sibling discriminator column already present in the record to pick the correct target DAO. This is a generic, entity-agnostic framework change with no behavior change for entities whose dynamic FK target has no `name` field. --- Civi/Api4/Generic/ExportAction.php | 24 ++++ Civi/Api4/Generic/Traits/DAOActionTrait.php | 20 ++- .../api/v4/Action/ExportActionTest.php | 120 ++++++++++++++++++ 3 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/api/v4/Action/ExportActionTest.php diff --git a/Civi/Api4/Generic/ExportAction.php b/Civi/Api4/Generic/ExportAction.php index e9efb0026889..612e654216cc 100644 --- a/Civi/Api4/Generic/ExportAction.php +++ b/Civi/Api4/Generic/ExportAction.php @@ -117,6 +117,30 @@ private function exportRecord(string $entityType, int $entityId, Result $result, } // The get api always returns ID, but it should not be included in an export unset($record['id']); + // Dynamic FKs (e.g. `entity_id` paired with `entity_table`) can't use the implicit + // `.name` join syntax above: a single sql join can't target a different table per + // row, so the query engine has no way to resolve it as part of the main select. + // Once we know the concrete target entity for *this* record (via its own + // discriminator column value), resolve the name with a small individual lookup. + foreach ($allFields as $field) { + $controlField = $field['input_attrs']['control_field'] ?? NULL; + if (!$controlField || empty($field['dfk_entities']) || empty($record[$field['name']])) { + continue; + } + $fkApiEntity = $field['dfk_entities'][$record[$controlField] ?? NULL] ?? NULL; + if (!$fkApiEntity || !array_key_exists('name', $this->getFieldsForExport($fkApiEntity))) { + continue; + } + $fkName = civicrm_api4($fkApiEntity, 'get', [ + 'checkPermissions' => $this->checkPermissions, + 'select' => ['name'], + 'where' => [['id', '=', $record[$field['name']]]], + ])->first()['name'] ?? NULL; + if ($fkName !== NULL) { + $record[$field['name'] . '.name'] = $fkName; + $pseudofields[$field['name'] . '.name'] = $field['name']; + } + } $name = ($parentName ?? '') . $entityType . '_' . ($record['name'] ?? count($this->exportedEntities[$entityType])); // Ensure safe characters, max length. // This is used for the value of `civicrm_managed.name` which has a maxlength of 255, but is also used diff --git a/Civi/Api4/Generic/Traits/DAOActionTrait.php b/Civi/Api4/Generic/Traits/DAOActionTrait.php index 64bfb7959afb..f62508df516b 100644 --- a/Civi/Api4/Generic/Traits/DAOActionTrait.php +++ b/Civi/Api4/Generic/Traits/DAOActionTrait.php @@ -254,12 +254,26 @@ protected function resolveFKValues(array &$record): void { } [$fieldName, $fkField] = explode('.', $key); $field = $this->entityFields()[$fieldName] ?? NULL; - if (!$field || $field['type'] !== 'Field' || empty($field['fk_entity'])) { + if (!$field || $field['type'] !== 'Field') { continue; } - $fkDao = CoreUtil::getBAOFromApiName($field['fk_entity']); + $fkApiEntity = $field['fk_entity'] ?? NULL; + // Dynamic FK (e.g. `entity_id` paired with `entity_table`): the target entity + // isn't fixed, so resolve it from the sibling discriminator column's value, + // which must already be present (as a plain value) in this same record. + if (!$fkApiEntity && !empty($field['dfk_entities'])) { + $controlField = $field['input_attrs']['control_field'] ?? NULL; + if (empty($record[$controlField])) { + continue; + } + $fkApiEntity = CoreUtil::getApiNameFromTableName($record[$controlField]); + } + if (!$fkApiEntity) { + continue; + } + $fkDao = CoreUtil::getBAOFromApiName($fkApiEntity); if (!$fkDao) { - throw new \CRM_Core_Exception('Failed to load ' . $field['fk_entity']); + throw new \CRM_Core_Exception('Failed to load ' . $fkApiEntity); } // Constrain search to the domain of the current entity $domainConstraint = NULL; diff --git a/tests/phpunit/api/v4/Action/ExportActionTest.php b/tests/phpunit/api/v4/Action/ExportActionTest.php new file mode 100644 index 000000000000..faed83306819 --- /dev/null +++ b/tests/phpunit/api/v4/Action/ExportActionTest.php @@ -0,0 +1,120 @@ +addValue('title', 'Test Export Page') + ->addValue('name', 'test_export_page') + ->execute()->single(); + // Event has no `name` field, so there's nothing portable to join on: this must not + // error, and must not regress to some other broken/incorrect representation. + $event = Event::create(FALSE) + ->addValue('title', 'Test Export Event') + ->addValue('event_type_id', 1) + ->addValue('start_date', 'now') + ->execute()->single(); + + $priceSet = PriceSet::create(FALSE) + ->addValue('name', 'test_export_pset') + ->addValue('title', 'Test Export PriceSet') + ->addValue('extends:name', ['CiviEvent']) + ->addValue('financial_type_id:name', 'Donation') + ->execute()->single(); + + [$pageLink, $eventLink] = PriceSetEntity::save(FALSE) + ->setRecords([ + ['entity_table' => 'civicrm_contribution_page', 'entity_id' => $page['id']], + ['entity_table' => 'civicrm_event', 'entity_id' => $event['id']], + ]) + ->setDefaults(['price_set_id' => $priceSet['id']]) + ->execute(); + + $pageExport = (new ExportAction('PriceSetEntity', 'export')) + ->setCheckPermissions(FALSE) + ->setId($pageLink['id']) + ->execute()->single(); + + $this->assertEquals('test_export_page', $pageExport['params']['values']['entity_id.name']); + $this->assertArrayNotHasKey('entity_id', $pageExport['params']['values']); + $this->assertEquals('civicrm_contribution_page', $pageExport['params']['values']['entity_table']); + + $eventExport = (new ExportAction('PriceSetEntity', 'export')) + ->setCheckPermissions(FALSE) + ->setId($eventLink['id']) + ->execute()->single(); + + $this->assertEquals($event['id'], $eventExport['params']['values']['entity_id']); + $this->assertArrayNotHasKey('entity_id.name', $eventExport['params']['values']); + } + + /** + * The write-side counterpart: `entity_id.name` should resolve back to the correct + * local id for whichever concrete entity `entity_table` points at, even though the + * field has no single fixed fk_entity. + * + * @throws \CRM_Core_Exception + */ + public function testDynamicForeignKeyCreateResolvesNameToId(): void { + $page = ContributionPage::create(FALSE) + ->addValue('title', 'Test Import Page') + ->addValue('name', 'test_import_page') + ->execute()->single(); + + $priceSet = PriceSet::create(FALSE) + ->addValue('name', 'test_import_pset') + ->addValue('title', 'Test Import PriceSet') + ->addValue('extends:name', ['CiviEvent']) + ->addValue('financial_type_id:name', 'Donation') + ->execute()->single(); + + $priceSetEntity = PriceSetEntity::create(FALSE) + ->addValue('price_set_id', $priceSet['id']) + ->addValue('entity_table', 'civicrm_contribution_page') + ->addValue('entity_id.name', 'test_import_page') + ->execute()->single(); + + $this->assertEquals($page['id'], $priceSetEntity['entity_id']); + } + +}