Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Civi/Api4/Generic/ExportAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions Civi/Api4/Generic/Traits/DAOActionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
120 changes: 120 additions & 0 deletions tests/phpunit/api/v4/Action/ExportActionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?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 |
+--------------------------------------------------------------------+
*/

/**
*
* @package CRM
* @copyright CiviCRM LLC https://civicrm.org/licensing
*/

namespace api\v4\Action;

use api\v4\Api4TestBase;
use Civi\Api4\ContributionPage;
use Civi\Api4\Event;
use Civi\Api4\Generic\ExportAction;
use Civi\Api4\PriceSet;
use Civi\Api4\PriceSetEntity;
use Civi\Test\TransactionalInterface;

/**
* PriceSetEntity.entity_id is a dynamic FK (paired with entity_table). Neither PriceSet
* nor PriceSetEntity implement the ManagedEntity trait yet, so `export()` is not
* registered as a callable action for them via civicrm_api4(). Since ExportAction is a
* generic, entity-agnostic action, it can still be tested directly against these (and any
* other) real entities by constructing it explicitly, bypassing the trait/action-registry
* requirement that only gates the civicrm_api4() dispatch, not the action class itself.
*
* @group headless
*/
class ExportActionTest extends Api4TestBase implements TransactionalInterface {

/**
* @throws \CRM_Core_Exception
*/
public function testDynamicForeignKeyExport(): void {
// ContributionPage has a `name` field, so its dynamic FK should be portable.
$page = ContributionPage::create(FALSE)
->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']);
}

}