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']); + } + +}