Skip to content

Commit

Permalink
Merge pull request #27202 from colemanw/searchKitCurrency
Browse files Browse the repository at this point in the history
SearchKit - Improve handling of money currency
  • Loading branch information
eileenmcnaughton authored Sep 3, 2023
2 parents 0c78d56 + f745712 commit 3678a37
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 34 deletions.
32 changes: 28 additions & 4 deletions Civi/Api4/Query/Api4SelectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class Api4SelectQuery extends Api4Query {
public $forceSelectId = TRUE;

/**
* @var array
* @var array{entity: string, alias: string, table: string, on: array, bridge: string|NULL}[]
*/
private $explicitJoins = [];

Expand Down Expand Up @@ -897,19 +897,43 @@ public function getEntity() {

/**
* @param string $alias
* @return array{entity: string, alias: string, table: string, bridge: string|NULL}|NULL
* @return array{entity: string, alias: string, table: string, on: array, bridge: string|NULL}|NULL
*/
public function getExplicitJoin($alias) {
return $this->explicitJoins[$alias] ?? NULL;
}

/**
* @return array{entity: string, alias: string, table: string, bridge: string|NULL}[]
* @return array{entity: string, alias: string, table: string, on: array, bridge: string|NULL}[]
*/
public function getExplicitJoins() {
public function getExplicitJoins(): array {
return $this->explicitJoins;
}

/**
* If a join is based on another join, return the name of the other.
*
* @param string $joinAlias
* @return string|null
*/
public function getJoinParent(string $joinAlias): ?string {
$join = $this->getExplicitJoin($joinAlias);
foreach ($join['on'] ?? [] as $clause) {
$prefix = $join['alias'] . '.';
if (
count($clause) === 3 && $clause[1] === '=' &&
(str_starts_with($clause[0], $prefix) || str_starts_with($clause[2], $prefix))
) {
$otherField = str_starts_with($clause[0], $prefix) ? $clause[2] : $clause[0];
[$otherJoin] = explode('.', $otherField);
if (str_contains($otherField, '.') && $this->getExplicitJoin($otherJoin)) {
return $otherJoin;
}
}
}
return NULL;
}

/**
* Returns rendered expression or alias if it is already aliased in the SELECT clause.
*
Expand Down
96 changes: 71 additions & 25 deletions ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {

private $editableInfo = [];

private $currencyFields = [];

/**
* Override execute method to change the result object type
* @return \Civi\Api4\Result\SearchDisplayRunResult
Expand Down Expand Up @@ -1122,7 +1124,8 @@ protected function augmentSelectClause(&$apiParams): void {
foreach ($apiParams['select'] as $select) {
// When selecting monetary fields, also select currency
$currencyFieldName = $this->getCurrencyField($select);
if ($currencyFieldName) {
// Only select currency field if it doesn't break ONLY_FULL_GROUP_BY
if ($currencyFieldName && !$this->canAggregate($currencyFieldName)) {
$this->addSelectExpression($currencyFieldName);
}
// Add field dependencies needed to resolve pseudoconstants
Expand All @@ -1142,60 +1145,103 @@ protected function augmentSelectClause(&$apiParams): void {
}

/**
* Given a field that contains money, find the corresponding currency field
* Return the corresponding currency field if a select expression is monetary
*
* @param string $select
* @return string|null
*/
private function getCurrencyField(string $select):?string {
private function getCurrencyField(string $select): ?string {
// This function is called one or more times per row so cache the results
if (array_key_exists($select, $this->currencyFields)) {
return $this->currencyFields[$select];
}
$this->currencyFields[$select] = NULL;

$clause = $this->getSelectExpression($select);
// Only deal with fields of type money.
// TODO: In theory it might be possible to support aggregated columns but be careful about FULL_GROUP_BY errors
if (!($clause && $clause['dataType'] === 'Money' && $clause['fields'])) {
if (!$clause || !$clause['fields'] || $clause['dataType'] !== 'Money') {
return NULL;
}

$moneyFieldAlias = array_keys($clause['fields'])[0];
$moneyField = $clause['fields'][$moneyFieldAlias];
$prefix = substr($moneyFieldAlias, 0, strrpos($moneyFieldAlias, $moneyField['name']));

// Custom fields do their own thing wrt currency
if ($moneyField['type'] === 'Custom') {
return NULL;
}

$prefix = substr($moneyFieldAlias, 0, strrpos($moneyFieldAlias, $moneyField['name']));
// First look for a currency field on the same entity as the money field
$ownCurrencyField = $this->findCurrencyField($moneyField['entity']);
if ($ownCurrencyField) {
return $this->currencyFields[$select] = $prefix . $ownCurrencyField;
}

// If using aggregation, this will only work if grouping by currency
if ($clause['expr']->isType('SqlFunction')) {
$groupingByCurrency = array_intersect([$prefix . 'currency', 'currency'], $this->savedSearch['api_params']['groupBy'] ?? []);
return \CRM_Utils_Array::first($groupingByCurrency);
// Next look at the previously-joined entity
if ($prefix && $this->getQuery()) {
$parentJoin = $this->getQuery()->getJoinParent(rtrim($prefix, '.'));
$parentCurrencyField = $parentJoin ? $this->findCurrencyField($this->getQuery()->getExplicitJoin($parentJoin)['entity']) : NULL;
if ($parentCurrencyField) {
return $this->currencyFields[$select] = $parentJoin . '.' . $parentCurrencyField;
}
}

// Fall back on the base entity
$baseCurrencyField = $this->findCurrencyField($this->savedSearch['api_entity']);
if ($baseCurrencyField) {
return $this->currencyFields[$select] = $baseCurrencyField;
}

// If the entity has a field named 'currency', just assume that's it.
if ($this->getField($prefix . 'currency')) {
return $prefix . 'currency';
// Finally, try adding an implicit join
// e.g. the LineItem entity can use `contribution_id.currency`
foreach ($this->findFKFields($moneyField['entity']) as $fieldName => $fkEntity) {
$joinCurrencyField = $this->findCurrencyField($fkEntity);
if ($joinCurrencyField) {
return $this->currencyFields[$select] = $prefix . $fieldName . '.' . $joinCurrencyField;
}
}
// Some currency fields go by other names like `fee_currency`. We find them by checking the pseudoconstant.
$entityDao = CoreUtil::getInfoItem($moneyField['entity'], 'dao');
return NULL;
}

/**
* Find currency field for an entity.
*
* @param string $entityName
* @return string|null
*/
private function findCurrencyField(string $entityName): ?string {
$entityDao = CoreUtil::getInfoItem($entityName, 'dao');
if ($entityDao) {
// Check for a pseudoconstant that points to civicrm_currency.
foreach ($entityDao::getSupportedFields() as $fieldName => $field) {
if (($field['pseudoconstant']['table'] ?? NULL) === 'civicrm_currency') {
return $prefix . $fieldName;
return $fieldName;
}
}
}
// If the base entity has a field named 'currency', fall back on that.
if ($this->getField('currency')) {
return 'currency';
}
// Finally, if there's a FK field to civicrm_contribution, we can use an implicit join
// E.G. the LineItem entity has no `currency` field of its own & uses that of the contribution record
return NULL;
}

/**
* Return all fields for this entity with a foreign key
*
* @param string $entityName
* @return string[]
*/
private function findFKFields(string $entityName): array {
$entityDao = CoreUtil::getInfoItem($entityName, 'dao');
$fkFields = [];
if ($entityDao) {
// Check for a pseudoconstant that points to civicrm_currency.
foreach ($entityDao::getSupportedFields() as $fieldName => $field) {
if (($field['FKClassName'] ?? NULL) === 'CRM_Contribute_DAO_Contribution') {
return $prefix . $fieldName . '.currency';
$fkEntity = !empty($field['FKClassName']) ? CoreUtil::getApiNameFromBAO($field['FKClassName']) : NULL;
if ($fkEntity) {
$fkFields[$fieldName] = $fkEntity;
}
}
}
return NULL;
return $fkFields;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1530,11 +1530,12 @@ public function testEditableContactFields() {
}

public function testContributionCurrency():void {
$cid = $this->saveTestRecords('Contact', ['records' => 3])->column('id');
$contributions = $this->saveTestRecords('Contribution', [
'records' => [
['total_amount' => 100, 'currency' => 'GBP'],
['total_amount' => 200, 'currency' => 'USD'],
['total_amount' => 500, 'currency' => 'JPY'],
['total_amount' => 100, 'currency' => 'GBP', 'contact_id' => $cid[0]],
['total_amount' => 200, 'currency' => 'USD', 'contact_id' => $cid[1]],
['total_amount' => 500, 'currency' => 'JPY', 'contact_id' => $cid[2]],
],
]);

Expand All @@ -1545,6 +1546,7 @@ public function testContributionCurrency():void {
'api_entity' => 'Contribution',
'api_params' => [
'version' => 4,
// Include `id` column so the `sort` works
'select' => ['total_amount', 'id'],
'where' => [['id', 'IN', $contributions->column('id')]],
],
Expand Down Expand Up @@ -1588,15 +1590,41 @@ public function testContributionCurrency():void {

$this->assertEquals('JPY', $result[0]['data']['contribution_id.currency']);
$this->assertEquals('¥500', $result[0]['columns'][0]['val']);

// Now try it via joins
$params['savedSearch'] = [
'api_entity' => 'Contact',
'api_params' => [
'version' => 4,
'select' => ['line_item.line_total', 'id'],
'where' => [['contribution.id', 'IN', $contributions->column('id')]],
'join' => [
['Contribution AS contribution', 'INNER', ['id', '=', 'contribution.contact_id']],
['LineItem AS line_item', 'INNER', ['contribution.id', '=', 'line_item.contribution_id']],
],
],
];
$result = civicrm_api4('SearchDisplay', 'run', $params);
$this->assertCount(3, $result);

// The parent join should have been used rather than adding an unnecessary implicit join
$this->assertEquals('GBP', $result[2]['data']['contribution.currency']);
$this->assertEquals('£100.00', $result[2]['columns'][0]['val']);

$this->assertEquals('USD', $result[1]['data']['contribution.currency']);
$this->assertEquals('$200.00', $result[1]['columns'][0]['val']);

$this->assertEquals('JPY', $result[0]['data']['contribution.currency']);
$this->assertEquals('¥500', $result[0]['columns'][0]['val']);
}

public function testContributionAggregateCurrency():void {
$contributions = $this->saveTestRecords('Contribution', [
'records' => [
['total_amount' => 100, 'currency' => 'GBP'],
['total_amount' => 200, 'currency' => 'USD'],
['total_amount' => 150, 'currency' => 'USD'],
['total_amount' => 500, 'currency' => 'JPY'],
['total_amount' => 200, 'currency' => 'USD'],
['total_amount' => 250, 'currency' => 'USD'],
],
]);

Expand Down Expand Up @@ -1631,6 +1659,41 @@ public function testContributionAggregateCurrency():void {
$this->assertEquals('USD', $result[2]['data']['currency']);
$this->assertEquals('$400.00', $result[2]['columns'][0]['val']);
$this->assertEquals(2, $result[2]['columns'][1]['val']);

$params = [
'checkPermissions' => FALSE,
'return' => 'page:1',
'savedSearch' => [
'api_entity' => 'Contribution',
'api_params' => [
'version' => 4,
'select' => ['SUM(line_item.line_total) AS total', 'id'],
'where' => [['id', 'IN', $contributions->column('id')]],
'groupBy' => ['id'],
'join' => [
['LineItem AS line_item', 'INNER', ['id', '=', 'line_item.contribution_id']],
],
],
],
'display' => NULL,
'sort' => [['id', 'ASC']],
];

$result = civicrm_api4('SearchDisplay', 'run', $params);
$this->assertCount(4, $result);

// Currency should have been used to format the aggregated values
$this->assertEquals('GBP', $result[0]['data']['currency']);
$this->assertEquals('£100.00', $result[0]['columns'][0]['val']);

$this->assertEquals('USD', $result[1]['data']['currency']);
$this->assertEquals('$150.00', $result[1]['columns'][0]['val']);

$this->assertEquals('JPY', $result[2]['data']['currency']);
$this->assertEquals('¥500', $result[2]['columns'][0]['val']);

$this->assertEquals('USD', $result[3]['data']['currency']);
$this->assertEquals('$250.00', $result[3]['columns'][0]['val']);
}

public function testSelectEquations() {
Expand Down

0 comments on commit 3678a37

Please sign in to comment.