This moves handing of pseudoconstant suffix replacement from the UI level to the API level,
which allows APIv4 to return GROUP_CONCAT results as an array (by default, if no separator specified).
It also improves post-query formatting in general, with finer-grained formating callbacks for sql functions.
}
$results[] = $result;
}
- FormattingUtil::formatOutputValues($results, $this->apiFieldSpec, $this->getEntity());
+ FormattingUtil::formatOutputValues($results, $this->apiFieldSpec, $this->getEntity(), 'get', $this->selectAliases);
return $results;
}
*/
public $expr = '';
+ /**
+ * Whether or not pseudoconstant suffixes should be evaluated during output.
+ *
+ * @var bool
+ * @see \Civi\Api4\Utils\FormattingUtil::formatOutputValues
+ */
+ public $supportsExpansion = FALSE;
+
/**
* SqlFunction constructor.
* @param string $expr
*/
class SqlField extends SqlExpression {
+ public $supportsExpansion = TRUE;
+
protected function initialize() {
if ($this->alias && $this->alias !== $this->expr) {
throw new \API_Exception("Aliasing field names is not allowed, only expressions can have an alias.");
*/
abstract class SqlFunction extends SqlExpression {
+ /**
+ * @var array
+ */
protected static $params = [];
+ /**
+ * @var array[]
+ */
protected $args = [];
/**
*/
protected function initialize() {
$arg = trim(substr($this->expr, strpos($this->expr, '(') + 1, -1));
- foreach ($this->getParams() as $param) {
+ foreach ($this->getParams() as $idx => $param) {
$prefix = $this->captureKeyword($param['prefix'], $arg);
+ $this->args[$idx] = [
+ 'prefix' => $prefix,
+ 'expr' => [],
+ 'suffix' => NULL,
+ ];
if ($param['expr'] && isset($prefix) || in_array('', $param['prefix']) || !$param['optional']) {
- $this->captureExpressions($arg, $param['expr'], $param['must_be'], $param['cant_be']);
- $this->captureKeyword($param['suffix'], $arg);
+ $this->args[$idx]['expr'] = $this->captureExpressions($arg, $param['expr'], $param['must_be'], $param['cant_be']);
+ $this->args[$idx]['suffix'] = $this->captureKeyword($param['suffix'], $arg);
}
}
}
/**
- * Shift a keyword off the beginning of the argument string and into the argument array.
+ * Shift a keyword off the beginning of the argument string and return it.
*
* @param array $keywords
* Whitelist of keywords
private function captureKeyword($keywords, &$arg) {
foreach (array_filter($keywords) as $key) {
if (strpos($arg, $key . ' ') === 0) {
- $this->args[] = $key;
$arg = ltrim(substr($arg, strlen($key)));
return $key;
}
}
/**
- * Shifts 0 or more expressions off the argument string and into the argument array
+ * Shifts 0 or more expressions off the argument string and returns them
*
* @param string $arg
* @param int $limit
* @param array $mustBe
* @param array $cantBe
+ * @return array
* @throws \API_Exception
*/
private function captureExpressions(&$arg, $limit, $mustBe, $cantBe) {
- $captured = 0;
+ $captured = [];
$arg = ltrim($arg);
while ($arg) {
$item = $this->captureExpression($arg);
$arg = ltrim(substr($arg, strlen($item)));
$expr = SqlExpression::convert($item, FALSE, $mustBe, $cantBe);
$this->fields = array_merge($this->fields, $expr->getFields());
- if ($captured) {
- $this->args[] = ',';
- }
- $this->args[] = $expr;
+ $captured[] = $expr;
$captured++;
// Keep going if we have a comma indicating another expression follows
- if ($captured < $limit && substr($arg, 0, 1) === ',') {
+ if (count($captured) < $limit && substr($arg, 0, 1) === ',') {
$arg = ltrim(substr($arg, 1));
}
else {
- return;
+ break;
}
}
+ return $captured;
}
/**
return $item;
}
+ /**
+ * Render the expression for insertion into the sql query
+ *
+ * @param array $fieldList
+ * @return string
+ */
public function render(array $fieldList): string {
- $output = $this->getName() . '(';
+ $output = '';
+ $params = $this->getParams();
foreach ($this->args as $index => $arg) {
- if ($index && $arg !== ',') {
- $output .= ' ';
- }
- if (is_object($arg)) {
- $output .= $arg->render($fieldList);
+ $rendered = $this->renderArg($arg, $params[$index], $fieldList);
+ if (strlen($rendered)) {
+ $output .= (strlen($output) ? ' ' : '') . $rendered;
}
- else {
- $output .= $arg;
+ }
+ return $this->getName() . '(' . $output . ')';
+ }
+
+ /**
+ * @param array $arg
+ * @param array $param
+ * @param array $fieldList
+ * @return string
+ */
+ private function renderArg($arg, $param, $fieldList): string {
+ // Supply api_default
+ if (!isset($arg['prefix']) && !isset($arg['suffix']) && empty($arg['expr']) && !empty($param['api_default'])) {
+ $arg = [
+ 'prefix' => $param['api_default']['prefix'] ?? reset($param['prefix']),
+ 'expr' => array_map([parent::class, 'convert'], $param['api_default']['expr'] ?? []),
+ 'suffix' => $param['api_default']['suffix'] ?? reset($param['suffix']),
+ ];
+ }
+ $rendered = $arg['prefix'] ?? '';
+ foreach ($arg['expr'] ?? [] as $idx => $expr) {
+ if (strlen($rendered) || $idx) {
+ $rendered .= $idx ? ', ' : ' ';
}
+ $rendered .= $expr->render($fieldList);
+ }
+ if (isset($arg['suffix'])) {
+ $rendered .= (strlen($rendered) ? ' ' : '') . $arg['suffix'];
}
- return $output . ')';
+ return $rendered;
}
/**
'optional' => FALSE,
'must_be' => [],
'cant_be' => ['SqlWild'],
+ 'api_default' => NULL,
];
}
return $params;
}
+ /**
+ * Get the arguments passed to this sql function instance.
+ * @return array[]
+ */
+ public function getArgs(): array {
+ return $this->args;
+ }
+
/**
* @return string
*/
],
];
+ /**
+ * Reformat result as array if using default separator
+ *
+ * @see \Civi\Api4\Utils\FormattingUtil::formatOutputValues
+ * @param string $value
+ * @return string|array
+ */
+ public function formatOutputValue($value) {
+ return (int) $value;
+ }
+
/**
* @return string
*/
*/
class SqlFunctionGREATEST extends SqlFunction {
+ public $supportsExpansion = TRUE;
+
protected static $category = self::CATEGORY_COMPARISON;
protected static $params = [
*/
class SqlFunctionGROUP_CONCAT extends SqlFunction {
+ public $supportsExpansion = TRUE;
+
protected static $category = self::CATEGORY_AGGREGATE;
protected static $params = [
'expr' => 1,
'must_be' => ['SqlString'],
'optional' => TRUE,
+ // @see self::formatOutput()
+ 'api_default' => [
+ 'expr' => ['"' . \CRM_Core_DAO::VALUE_SEPARATOR . '"'],
+ ],
],
];
+ /**
+ * Reformat result as array if using default separator
+ *
+ * @see \Civi\Api4\Utils\FormattingUtil::formatOutputValues
+ * @param string $value
+ * @return string|array
+ */
+ public function formatOutputValue($value) {
+ $exprArgs = $this->getArgs();
+ if (!$exprArgs[2]['prefix']) {
+ $value = explode(\CRM_Core_DAO::VALUE_SEPARATOR, $value);
+ }
+ return $value;
+ }
+
/**
* @return string
*/
*/
class SqlFunctionLEAST extends SqlFunction {
+ public $supportsExpansion = TRUE;
+
protected static $category = self::CATEGORY_COMPARISON;
protected static $params = [
*/
class SqlFunctionMAX extends SqlFunction {
+ public $supportsExpansion = TRUE;
+
protected static $category = self::CATEGORY_AGGREGATE;
protected static $params = [
*/
class SqlFunctionMIN extends SqlFunction {
+ public $supportsExpansion = TRUE;
+
protected static $category = self::CATEGORY_AGGREGATE;
protected static $params = [
*/
class SqlFunctionNULLIF extends SqlFunction {
+ public $supportsExpansion = TRUE;
+
protected static $category = self::CATEGORY_COMPARISON;
protected static $params = [
namespace Civi\Api4\Utils;
+use Civi\Api4\Query\SqlExpression;
+
require_once 'api/v3/utils.php';
class FormattingUtil {
* @param array $fields
* @param string $entity
* @param string $action
+ * @param array $selectAliases
* @throws \API_Exception
* @throws \CRM_Core_Exception
*/
- public static function formatOutputValues(&$results, $fields, $entity, $action = 'get') {
+ public static function formatOutputValues(&$results, $fields, $entity, $action = 'get', $selectAliases = []) {
$fieldOptions = [];
foreach ($results as &$result) {
$contactTypePaths = [];
- foreach ($result as $fieldExpr => $value) {
- $field = $fields[$fieldExpr] ?? NULL;
- $dataType = $field['data_type'] ?? ($fieldExpr == 'id' ? 'Integer' : NULL);
- if ($field) {
- // Evaluate pseudoconstant suffixes
- $suffix = strrpos($fieldExpr, ':');
- if ($suffix) {
- $fieldOptions[$fieldExpr] = $fieldOptions[$fieldExpr] ?? self::getPseudoconstantList($field, substr($fieldExpr, $suffix + 1), $result, $action);
- $dataType = NULL;
- }
- if (!empty($field['serialize'])) {
- if (is_string($value)) {
- $value = \CRM_Core_DAO::unSerializeField($value, $field['serialize']);
- }
- }
- if (isset($fieldOptions[$fieldExpr])) {
- $value = self::replacePseudoconstant($fieldOptions[$fieldExpr], $value);
+ foreach ($result as $key => $value) {
+ $fieldExpr = SqlExpression::convert($selectAliases[$key] ?? $key);
+ $fieldName = \CRM_Utils_Array::first($fieldExpr->getFields());
+ $field = $fieldName && isset($fields[$fieldName]) ? $fields[$fieldName] : NULL;
+ $dataType = $field['data_type'] ?? ($fieldName == 'id' ? 'Integer' : NULL);
+ // If Sql Function e.g. GROUP_CONCAT or COUNT wants to do its own formatting, apply and skip dataType conversion
+ if (method_exists($fieldExpr, 'formatOutputValue') && is_string($value)) {
+ $result[$key] = $value = $fieldExpr->formatOutputValue($value);
+ $dataType = NULL;
+ }
+ if (!$field) {
+ continue;
+ }
+ // Evaluate pseudoconstant suffixes
+ $suffix = strrpos($fieldName, ':');
+ if ($suffix) {
+ $fieldOptions[$fieldName] = $fieldOptions[$fieldName] ?? self::getPseudoconstantList($field, substr($fieldName, $suffix + 1), $result, $action);
+ $dataType = NULL;
+ }
+ if ($fieldExpr->supportsExpansion) {
+ if (!empty($field['serialize']) && is_string($value)) {
+ $value = \CRM_Core_DAO::unSerializeField($value, $field['serialize']);
}
- // Keep track of contact types for self::contactFieldsToRemove
- if ($value && isset($field['entity']) && $field['entity'] === 'Contact' && $field['name'] === 'contact_type') {
- $prefix = strrpos($fieldExpr, '.');
- $contactTypePaths[$prefix ? substr($fieldExpr, 0, $prefix + 1) : ''] = $value;
+ if (isset($fieldOptions[$fieldName])) {
+ $value = self::replacePseudoconstant($fieldOptions[$fieldName], $value);
}
}
- $result[$fieldExpr] = self::convertDataType($value, $dataType);
+ // Keep track of contact types for self::contactFieldsToRemove
+ if ($value && isset($field['entity']) && $field['entity'] === 'Contact' && $field['name'] === 'contact_type') {
+ $prefix = strrpos($fieldName, '.');
+ $contactTypePaths[$prefix ? substr($fieldName, 0, $prefix + 1) : ''] = $value;
+ }
+ $result[$key] = self::convertDataType($value, $dataType);
}
// Remove inapplicable contact fields
foreach ($contactTypePaths as $prefix => $contactType) {
var info = searchMeta.parseExpr(col),
key = info.fn ? (info.fn.name + ':' + info.path + info.suffix) : col,
value = row[key];
- // Handle grouped results
- if (info.fn && info.fn.name === 'GROUP_CONCAT' && value) {
- return formatGroupConcatValues(info, value);
- }
- else if (info.fn && info.fn.name === 'COUNT') {
+ if (info.fn && info.fn.name === 'COUNT') {
return value;
}
return formatFieldValue(info.field, value);
function formatFieldValue(field, value) {
var type = field.data_type;
+ if (_.isArray(value)) {
+ return _.map(value, function(val) {
+ return formatFieldValue(field, val);
+ }).join(', ');
+ }
if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return CRM.utils.formatDate(value, null, type === 'Timestamp');
}
else if (type === 'Boolean' && typeof value === 'boolean') {
return value ? ts('Yes') : ts('No');
}
- else if (type === 'Money') {
+ else if (type === 'Money' && typeof value === 'number') {
return CRM.formatMoney(value);
}
- if (_.isArray(value)) {
- return value.join(', ');
- }
return value;
}
- function formatGroupConcatValues(info, values) {
- return _.transform(values.split(','), function(result, val) {
- if (info.field.options && !info.suffix) {
- result.push(_.result(getOption(info.field, val), 'label'));
- } else {
- result.push(formatFieldValue(info.field, val));
- }
- }).join(', ');
- }
-
function getOption(field, value) {
return _.find(field.options, function(option) {
// Type coersion is intentional
this.$onInit = function() {
var fieldInfo = searchMeta.parseExpr(ctrl.expr);
- ctrl.path = fieldInfo.path;
+ ctrl.path = fieldInfo.path + fieldInfo.suffix;
ctrl.field = fieldInfo.field;
ctrl.fn = !fieldInfo.fn ? '' : fieldInfo.fn.name;
};
public function testGroupAggregates() {
$cid = Contact::create(FALSE)->addValue('first_name', 'bill')->execute()->first()['id'];
Contribution::save(FALSE)
- ->setDefaults(['contact_id' => $cid, 'financial_type_id' => 1])
+ ->setDefaults(['contact_id' => $cid, 'financial_type_id:name' => 'Donation'])
->setRecords([
['total_amount' => 100, 'receive_date' => '2020-01-01'],
['total_amount' => 200, 'receive_date' => '2020-01-01'],
- ['total_amount' => 300, 'receive_date' => '2020-01-01'],
- ['total_amount' => 400, 'receive_date' => '2020-01-01'],
+ ['total_amount' => 300, 'receive_date' => '2020-01-01', 'financial_type_id:name' => 'Member Dues'],
+ ['total_amount' => 400, 'receive_date' => '2020-01-01', 'financial_type_id:name' => 'Event Fee'],
])
->execute();
+
+ // Test AVG, SUM, MAX, MIN, COUNT
$agg = Contribution::get(FALSE)
->addGroupBy('contact_id')
->addWhere('contact_id', '=', $cid)
->addSelect('COUNT(*) AS count')
->execute()
->first();
- $this->assertEquals(250, $agg['average']);
- $this->assertEquals(1000, $agg['SUM:total_amount']);
- $this->assertEquals(400, $agg['MAX:total_amount']);
- $this->assertEquals(100, $agg['MIN:total_amount']);
- $this->assertEquals(4, $agg['count']);
+ $this->assertTrue(250.0 === $agg['average']);
+ $this->assertTrue(1000.0 === $agg['SUM:total_amount']);
+ $this->assertTrue(400.0 === $agg['MAX:total_amount']);
+ $this->assertTrue(100.0 === $agg['MIN:total_amount']);
+ $this->assertTrue(4 === $agg['count']);
+
+ // Test GROUP_CONCAT
+ $agg = Contribution::get(FALSE)
+ ->addGroupBy('contact_id')
+ ->addWhere('contact_id', '=', $cid)
+ ->addSelect('GROUP_CONCAT(financial_type_id:name)')
+ ->addSelect('COUNT(*) AS count')
+ ->execute()
+ ->first();
+
+ $this->assertTrue(4 === $agg['count']);
+ $this->assertContains('Donation', $agg['GROUP_CONCAT:financial_type_id:name']);
}
public function testGroupHaving() {
$sqlFn = new $className($fnName . '(total)');
$this->assertEquals($fnName, $sqlFn->getName());
$this->assertEquals(['total'], $sqlFn->getFields());
- $this->assertCount(1, $this->getArgs($sqlFn));
+ $args = $sqlFn->getArgs();
+ $this->assertCount(1, $args);
+ $this->assertNull($args[0]['prefix']);
+ $this->assertNull($args[0]['suffix']);
+ $this->assertTrue(is_a($args[0]['expr'][0], 'Civi\Api4\Query\SqlField'));
$sqlFn = SqlExpression::convert($fnName . '(DISTINCT stuff)');
$this->assertEquals($fnName, $sqlFn->getName());
$this->assertEquals("Civi\Api4\Query\SqlFunction$fnName", get_class($sqlFn));
$this->assertEquals($params, $sqlFn->getParams());
$this->assertEquals(['stuff'], $sqlFn->getFields());
- $this->assertCount(2, $this->getArgs($sqlFn));
+ $args = $sqlFn->getArgs();
+ $this->assertCount(1, $args);
+ $this->assertEquals('DISTINCT', $args[0]['prefix']);
+ $this->assertNull($args[0]['suffix']);
+ $this->assertTrue(is_a($args[0]['expr'][0], 'Civi\Api4\Query\SqlField'));
try {
$sqlFn = SqlExpression::convert($fnName . '(*)');
if ($fnName === 'COUNT') {
- $this->assertTrue(is_a($this->getArgs($sqlFn)[0], 'Civi\Api4\Query\SqlWild'));
+ $args = $sqlFn->getArgs();
+ $this->assertCount(1, $args);
+ $this->assertNull($args[0]['prefix']);
+ $this->assertNull($args[0]['suffix']);
+ $this->assertTrue(is_a($args[0]['expr'][0], 'Civi\Api4\Query\SqlWild'));
}
else {
$this->fail('SqlWild should only be allowed in COUNT.');
}
}
- /**
- * @param \Civi\Api4\Query\SqlFunction $fn
- * @return array
- * @throws \ReflectionException
- */
- private function getArgs($fn) {
- $ref = new \ReflectionClass($fn);
- $args = $ref->getProperty('args');
- $args->setAccessible(TRUE);
- return $args->getValue($fn);
- }
-
}