Skip to content

Commit

Permalink
SearchKit - Respect currency when formatting monetary fields
Browse files Browse the repository at this point in the history
Fixes dev/core#3428
  • Loading branch information
colemanw committed Sep 15, 2022
1 parent 11b893c commit e2bb96a
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 32 deletions.
9 changes: 5 additions & 4 deletions Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,14 @@ protected function getSelectClause() {
'expr' => $expr,
'dataType' => $expr->getDataType(),
];
foreach ($expr->getFields() as $fieldName) {
$fieldMeta = $this->getField($fieldName);
foreach ($expr->getFields() as $fieldAlias) {
$fieldMeta = $this->getField($fieldAlias);
if ($fieldMeta) {
$item['fields'][] = $fieldMeta;
$item['fields'][$fieldAlias] = $fieldMeta;
}
}
if (!isset($item['dataType']) && $item['fields']) {
$item['dataType'] = $item['fields'][0]['data_type'];
$item['dataType'] = \CRM_Utils_Array::first($item['fields'])['data_type'];
}
$this->_selectClause[$expr->getAlias()] = $item;
}
Expand All @@ -152,6 +152,7 @@ protected function getSelectClause() {
* @return array{fields: array, expr: SqlExpression, dataType: string}|NULL
*/
protected function getSelectExpression($key) {
$key = explode(' AS ', $key)[1] ?? $key;
return $this->getSelectClause()[$key] ?? NULL;
}

Expand Down
10 changes: 10 additions & 0 deletions Civi/Api4/Query/SqlExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@ public function getType(): string {
return substr($className, strrpos($className, '\\') + 1);
}

/**
* Checks the name of this sql expression class.
*
* @param $type
* @return bool
*/
public function isType($type): bool {
return $this->getType() === $type;
}

/**
* @return string
*/
Expand Down
108 changes: 82 additions & 26 deletions ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ private function getValue($key, $data, $rowIndex) {
default:
if (!empty($data[$key])) {
$item = $this->getSelectExpression($key);
if ($item['expr'] instanceof SqlField && $item['fields'][0]['fk_entity'] === 'File') {
if ($item['expr'] instanceof SqlField && $item['fields'][$key]['fk_entity'] === 'File') {
return $this->generateFileUrl($data[$key]);
}
}
Expand Down Expand Up @@ -214,7 +214,7 @@ private function formatColumn($column, $data) {
$out['val'] = $this->rewrite($column, $data);
}
else {
$out['val'] = $this->formatViewValue($column['key'], $rawValue);
$out['val'] = $this->formatViewValue($column['key'], $rawValue, $data);
}
if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $this->hasValue($out['val']))) {
$out['label'] = $this->replaceTokens($column['label'], $data, 'view');
Expand Down Expand Up @@ -735,7 +735,7 @@ private function replaceTokens($tokenExpr, $data, $format, $index = 0) {
foreach ($this->getTokens($tokenExpr) as $token) {
$val = $data[$token] ?? NULL;
if (isset($val) && $format === 'view') {
$val = $this->formatViewValue($token, $val);
$val = $this->formatViewValue($token, $val, $data);
}
$replacement = is_array($val) ? $val[$index] ?? '' : $val;
// A missing token value in a url invalidates it
Expand All @@ -752,12 +752,13 @@ private function replaceTokens($tokenExpr, $data, $format, $index = 0) {
* Format raw field value according to data type
* @param string $key
* @param mixed $rawValue
* @param array $data
* @return array|string
*/
protected function formatViewValue($key, $rawValue) {
protected function formatViewValue($key, $rawValue, $data) {
if (is_array($rawValue)) {
return array_map(function($val) use ($key) {
return $this->formatViewValue($key, $val);
return array_map(function($val) use ($key, $data) {
return $this->formatViewValue($key, $val, $data);
}, $rawValue);
}

Expand All @@ -773,7 +774,8 @@ protected function formatViewValue($key, $rawValue) {
break;

case 'Money':
$formatted = \CRM_Utils_Money::format($rawValue);
$currencyField = $this->getCurrencyField($key);
$formatted = \CRM_Utils_Money::format($rawValue, $data[$currencyField] ?? NULL);
break;

case 'Date':
Expand Down Expand Up @@ -877,21 +879,20 @@ protected function augmentSelectClause(&$apiParams): void {
$existing = array_map(function($item) {
return explode(' AS ', $item)[1] ?? $item;
}, $apiParams['select']);
$additions = [];
// Add primary key field if actions are enabled
// (only needed for non-dao entities, as Api4SelectQuery will auto-add the id)
if (!in_array('DAOEntity', CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'type')) &&
(!empty($this->display['settings']['actions']) || !empty($this->display['settings']['draggable']))
) {
$additions = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'primary_key');
$this->addSelectExpression(CoreUtil::getIdFieldName($this->savedSearch['api_entity']));
}
// Add draggable column (typically "weight")
if (!empty($this->display['settings']['draggable'])) {
$additions[] = $this->display['settings']['draggable'];
$this->addSelectExpression($this->display['settings']['draggable']);
}
// Add style conditions for the display
foreach ($this->getCssRulesSelect($this->display['settings']['cssRules'] ?? []) as $addition) {
$additions[] = $addition;
$this->addSelectExpression($addition);
}
$possibleTokens = '';
foreach ($this->display['settings']['columns'] as $column) {
Expand All @@ -907,31 +908,86 @@ protected function augmentSelectClause(&$apiParams): void {
$possibleTokens .= $this->getLinkPath($link) ?? '';
}

// Select id & value for in-place editing
// Select id, value & grouping for in-place editing
if (!empty($column['editable'])) {
$editable = $this->getEditableInfo($column['key']);
if ($editable) {
$additions = array_merge($additions, $editable['grouping_fields'], [$editable['value_path'], $editable['id_path']]);
foreach (array_merge($editable['grouping_fields'], [$editable['value_path'], $editable['id_path']]) as $addition) {
$this->addSelectExpression($addition);
}
}
}
// Add style & icon conditions for the column
$additions = array_merge($additions,
$this->getCssRulesSelect($column['cssRules'] ?? []),
$this->getIconsSelect($column['icons'] ?? [])
);
foreach ($this->getCssRulesSelect($column['cssRules'] ?? []) as $addition) {
$this->addSelectExpression($addition);
}
foreach ($this->getIconsSelect($column['icons'] ?? []) as $addition) {
$this->addSelectExpression($addition);
}
}
// Add fields referenced via token
$tokens = $this->getTokens($possibleTokens);
// Only add fields not already in SELECT clause
$additions = array_diff(array_merge($additions, $tokens), $existing);
// Tokens for aggregated columns start with 'GROUP_CONCAT_'
foreach ($additions as $index => $alias) {
if (strpos($alias, 'GROUP_CONCAT_') === 0) {
$additions[$index] = 'GROUP_CONCAT(' . $this->getJoinFromAlias(explode('_', $alias, 3)[2]) . ') AS ' . $alias;
foreach ($this->getTokens($possibleTokens) as $addition) {
$this->addSelectExpression($addition);
}

// When selecting monetary fields, also select currency
foreach ($apiParams['select'] as $select) {
$currencyFieldName = $this->getCurrencyField($select);
if ($currencyFieldName) {
$this->addSelectExpression($currencyFieldName);
}
}
}

/**
* Given a field that contains money, find the corresponding currency field
*
* @param string $select
* @return string|null
*/
private function getCurrencyField(string $select):?string {
$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['expr']->isType('SqlField') && $clause['dataType'] === 'Money' && $clause['fields'])) {
return NULL;
}
$moneyFieldAlias = array_keys($clause['fields'])[0];
$moneyField = $clause['fields'][$moneyFieldAlias];
// Custom fields do their own thing wrt currency
if ($moneyField['type'] === 'Custom') {
return NULL;
}
$prefix = substr($moneyFieldAlias, 0, strrpos($moneyFieldAlias, $moneyField['name']));
// If the entity has a field named 'currency', just assume that's it.
if ($this->getField($prefix . 'currency')) {
return $prefix . 'currency';
}
// Some currency fields go by other names like `fee_currency`. We find them by checking the pseudoconstant.
$entityDao = CoreUtil::getInfoItem($moneyField['entity'], 'dao');
if ($entityDao) {
foreach ($entityDao::getSupportedFields() as $fieldName => $field) {
if (($field['pseudoconstant']['table'] ?? NULL) === 'civicrm_currency') {
return $prefix . $fieldName;
}
}
}
return NULL;
}

/**
* @param string $expr
*/
protected function addSelectExpression(string $expr):void {
if (!$this->getSelectExpression($expr)) {
// Tokens for aggregated columns start with 'GROUP_CONCAT_'
if (strpos($expr, 'GROUP_CONCAT_') === 0) {
$expr = 'GROUP_CONCAT(' . $this->getJoinFromAlias(explode('_', $expr, 3)[2]) . ') AS ' . $expr;
}
$this->_apiParams['select'][] = $expr;
// Force-reset cache so it gets rebuilt with the new select param
$this->_selectClause = NULL;
}
$this->_selectClause = NULL;
$apiParams['select'] = array_unique(array_merge($apiParams['select'], $additions));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<?php
namespace api\v4\SearchDisplay;

// Not sure why this is needed but without it Jenkins crashed
require_once __DIR__ . '/../../../../../../../tests/phpunit/api/v4/Api4TestBase.php';

use api\v4\Api4TestBase;
use Civi\API\Exception\UnauthorizedException;
use Civi\Api4\Activity;
use Civi\Api4\Contact;
Expand All @@ -10,13 +14,12 @@
use Civi\Api4\SavedSearch;
use Civi\Api4\SearchDisplay;
use Civi\Api4\UFMatch;
use Civi\Test\HeadlessInterface;
use Civi\Test\TransactionalInterface;

/**
* @group headless
*/
class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, TransactionalInterface {
class SearchRunTest extends Api4TestBase implements TransactionalInterface {
use \Civi\Test\ACLPermissionTrait;

public function setUpHeadless() {
Expand Down Expand Up @@ -1357,4 +1360,42 @@ public function testEditableContactFields() {
$this->assertEquals($expectedFirstNameEdit, $result[3]['columns'][2]['edit']);
}

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

$params = [
'checkPermissions' => FALSE,
'return' => 'page:1',
'savedSearch' => [
'api_entity' => 'Contribution',
'api_params' => [
'version' => 4,
'select' => ['total_amount'],
'where' => [['id', 'IN', $contributions->column('id')]],
],
],
'display' => NULL,
'sort' => [['id', 'ASC']],
];

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

// Currency should have been fetched automatically and used to format the value
$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('$ 200.00', $result[1]['columns'][0]['val']);

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

}

0 comments on commit e2bb96a

Please sign in to comment.