diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php index 26d09e1897af..addd27605687 100644 --- a/Civi/Api4/Query/Api4SelectQuery.php +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -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 = []; @@ -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. * diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php index 9631a1df1dd7..2230e149bf09 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php @@ -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 @@ -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 @@ -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; } /** diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php index c55317d581bf..d894de1d91c7 100644 --- a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php @@ -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]], ], ]); @@ -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')]], ], @@ -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'], ], ]); @@ -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() {