Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SearchKit - Respect currency when formatting monetary fields #24524

Merged
merged 3 commits into from
Sep 16, 2022
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
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
122 changes: 96 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,9 @@ protected function formatViewValue($key, $rawValue) {
break;

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

case 'Date':
Expand Down Expand Up @@ -877,21 +880,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 +909,99 @@ 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;
}
}
}
// 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
if ($entityDao) {
foreach ($entityDao::getSupportedFields() as $fieldName => $field) {
if (($field['FKClassName'] ?? NULL) === 'CRM_Contribute_DAO_Contribution') {
return $prefix . $fieldName . '.currency';
}
}
}
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,65 @@ 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', $result[2]['columns'][0]['val']);

// Now do a search for the contribution line-items
$params['savedSearch'] = [
'api_entity' => 'LineItem',
'api_params' => [
'version' => 4,
'select' => ['line_total'],
'where' => [['contribution_id', 'IN', $contributions->column('id')]],
],
];

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

// An automatic join should have been added to fetch the contribution currency
$this->assertEquals('GBP', $result[0]['data']['contribution_id.currency']);
$this->assertEquals('£100.00', $result[0]['columns'][0]['val']);

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

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

}
2 changes: 1 addition & 1 deletion release-notes/5.18.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ Released October 2, 2019
([dev/core#1188](https://lab.civicrm.org/dev/core/issues/1188):
[15043](https://github.com/civicrm/civicrm-core/pull/15043))**

Ensures that the Psalm Autoloader can find `CiviCRM_API3_Exception`.
Ensures that the Psalm Autoloader can find `CRM_Core_Exception`.

- **File attachment uploads - pptx issue
([dev/core#1190](https://lab.civicrm.org/dev/core/issues/1190):
Expand Down