APIv4 - Add UNIQUE flag to GROUP_CONCAT
authorcolemanw <coleman@civicrm.org>
Sat, 19 Aug 2023 20:07:38 +0000 (16:07 -0400)
committercolemanw <coleman@civicrm.org>
Tue, 5 Sep 2023 02:32:02 +0000 (22:32 -0400)
Unlike DISTINCT which dedupes by the value of a field, UNIQUE will dedupe by the id of the record

15 files changed:
Civi/Api4/Query/Api4EntitySetQuery.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Query/SqlBool.php
Civi/Api4/Query/SqlEquation.php
Civi/Api4/Query/SqlExpression.php
Civi/Api4/Query/SqlField.php
Civi/Api4/Query/SqlFunction.php
Civi/Api4/Query/SqlFunctionDAYSTOANNIV.php
Civi/Api4/Query/SqlFunctionGROUP_CONCAT.php
Civi/Api4/Query/SqlNull.php
Civi/Api4/Query/SqlNumber.php
Civi/Api4/Query/SqlString.php
Civi/Api4/Query/SqlWild.php
Civi/Api4/Utils/FormattingUtil.php
tests/phpunit/api/v4/Action/SqlFunctionTest.php

index 5e553f2217c94d514acea5e99fe6983edea1f074..3be5f7c2ce9b558cfc3e7ef1d935cd16078edad0 100644 (file)
@@ -119,7 +119,7 @@ class Api4EntitySetQuery extends Api4Query {
       $expr = SqlExpression::convert($item, TRUE);
       $alias = $expr->getAlias();
       $this->selectAliases[$alias] = $expr->getExpr();
-      $this->query->select($expr->render($this) . " AS `$alias`");
+      $this->query->select($expr->render($this, TRUE));
     }
   }
 
index addd276056878168d2a2ca8320f8ba4ec5c76d1f..95de27e36e24e5150867388eaed961bf0471ae81 100644 (file)
@@ -198,7 +198,7 @@ class Api4SelectQuery extends Api4Query {
           throw new \CRM_Core_Exception('Cannot use existing field name as alias');
         }
         $this->selectAliases[$alias] = $expr->getExpr();
-        $this->query->select($expr->render($this) . " AS `$alias`");
+        $this->query->select($expr->render($this, TRUE));
       }
     }
   }
index 36aeb3ee1ff28f13c3241ecdd69054c5226df1e0..3577a31eb1b4710f018a86e95b2fad92e81dddd2 100644 (file)
@@ -21,8 +21,8 @@ class SqlBool extends SqlExpression {
   protected function initialize() {
   }
 
-  public function render(Api4Query $query): string {
-    return $this->expr === 'TRUE' ? '1' : '0';
+  public function render(Api4Query $query, bool $includeAlias = FALSE): string {
+    return ($this->expr === 'TRUE' ? '1' : '0') . ($includeAlias ? " AS `{$this->getAlias()}`" : '');
   }
 
   public static function getTitle(): string {
index ac0fb8cf2271f6348f7350285edf11ff6f3f7fd4..04f08cbe1568fa1bec7b11ae521e633c65635d7e 100644 (file)
@@ -77,9 +77,10 @@ class SqlEquation extends SqlExpression {
    * Render the expression for insertion into the sql query
    *
    * @param \Civi\Api4\Query\Api4Query $query
+   * @param bool $includeAlias
    * @return string
    */
-  public function render(Api4Query $query): string {
+  public function render(Api4Query $query, bool $includeAlias = FALSE): string {
     $output = [];
     foreach ($this->args as $i => $arg) {
       // Just an operator
@@ -98,7 +99,7 @@ class SqlEquation extends SqlExpression {
         $output[] = $arg->render($query);
       }
     }
-    return '(' . implode(' ', $output) . ')';
+    return '(' . implode(' ', $output) . ')' . ($includeAlias ? " AS `{$this->getAlias()}`" : '');
   }
 
   /**
index 9416a9aa3a2f5219401645e80a4f76c600353985..9555152db6fa5ee48f880fea9ab9fe2378634e04 100644 (file)
@@ -145,9 +145,12 @@ abstract class SqlExpression {
    * Renders expression to a sql string, replacing field names with column names.
    *
    * @param \Civi\Api4\Query\Api4Query $query
+   * @param bool $includeAlias
    * @return string
    */
-  abstract public function render(Api4Query $query): string;
+  public function render(Api4Query $query, bool $includeAlias = FALSE): string {
+    return $this->expr . ($includeAlias ? " AS `{$this->getAlias()}`" : '');
+  }
 
   /**
    * @return string
index 862f67aea087850f3b321469d834958fe68e3b8e..0f2fb26aba200f99008f86089020f1c6ffef15e5 100644 (file)
@@ -25,13 +25,13 @@ class SqlField extends SqlExpression {
     $this->fields[] = $this->expr;
   }
 
-  public function render(Api4Query $query): string {
+  public function render(Api4Query $query, bool $includeAlias = FALSE): string {
     $field = $query->getField($this->expr, TRUE);
+    $rendered = $field['sql_name'];
     if (!empty($field['sql_renderer'])) {
-      $renderer = $field['sql_renderer'];
-      return $renderer($field, $query);
+      $rendered = $field['sql_renderer']($field, $query);
     }
-    return $field['sql_name'];
+    return $rendered . ($includeAlias ? " AS `{$this->getAlias()}`" : '');
   }
 
   public static function getTitle(): string {
index e2b8cb9118e0125c153086b3866905dab1c61f1b..44a0b097d595118e0e3c2c766f73eee286a2c557 100644 (file)
@@ -141,9 +141,10 @@ abstract class SqlFunction extends SqlExpression {
    * Render the expression for insertion into the sql query
    *
    * @param \Civi\Api4\Query\Api4Query $query
+   * @param bool $includeAlias
    * @return string
    */
-  public function render(Api4Query $query): string {
+  public function render(Api4Query $query, bool $includeAlias = FALSE): string {
     $output = '';
     foreach ($this->args as $arg) {
       $rendered = $this->renderArg($arg, $query);
@@ -151,7 +152,7 @@ abstract class SqlFunction extends SqlExpression {
         $output .= (strlen($output) ? ' ' : '') . $rendered;
       }
     }
-    return $this->renderExpression($output);
+    return $this->renderExpression($output) . ($includeAlias ? " AS `{$this->getAlias()}`" : '');
   }
 
   /**
@@ -160,7 +161,7 @@ abstract class SqlFunction extends SqlExpression {
    * @param string $output
    * @return string
    */
-  protected function renderExpression($output): string {
+  protected function renderExpression(string $output): string {
     return $this->getName() . '(' . $output . ')';
   }
 
index b47a4a1b31aa5d005b2c082e683745ff4c4990c5..14f85b2ccc61ecd6a2134669104afbd1cb33f751 100644 (file)
@@ -46,7 +46,7 @@ class SqlFunctionDAYSTOANNIV extends SqlFunction {
   /**
    * @inheritDoc
    */
-  protected function renderExpression($output): string {
+  protected function renderExpression(string $output): string {
     return "DATEDIFF(
       IF(
           DATE(CONCAT(YEAR(CURDATE()), '-', MONTH({$output}), '-', DAY({$output}))) < CURDATE(),
index dfb255a273d102d8715ff2ce9433c90c99ce85d1..7af9a2f64d78caec5fb393c5b3bd44d85c1e116c 100644 (file)
@@ -11,6 +11,8 @@
 
 namespace Civi\Api4\Query;
 
+use Civi\Api4\Utils\CoreUtil;
+
 /**
  * Sql function
  */
@@ -23,7 +25,7 @@ class SqlFunctionGROUP_CONCAT extends SqlFunction {
   protected static function params(): array {
     return [
       [
-        'flag_before' => ['' => NULL, 'DISTINCT' => ts('Distinct')],
+        'flag_before' => ['' => NULL, 'DISTINCT' => ts('Distinct Value'), 'UNIQUE' => ts('Unique Record')],
         'max_expr' => 1,
         'must_be' => ['SqlField', 'SqlFunction', 'SqlEquation'],
         'optional' => FALSE,
@@ -68,6 +70,17 @@ class SqlFunctionGROUP_CONCAT extends SqlFunction {
           $exprArgs[0]['expr'][0]->formatOutputValue($dataType, $values[$key], $index);
         }
       }
+      // Perform deduping by unique id
+      if ($this->args[0]['prefix'] === ['UNIQUE'] && isset($values["_$key"])) {
+        $ids = \CRM_Utils_Array::explodePadded($values["_$key"]);
+        unset($values["_$key"]);
+        foreach ($ids as $index => $id) {
+          if (in_array($id, array_slice($ids, 0, $index))) {
+            unset($values[$key][$index]);
+          }
+        }
+        $values[$key] = array_values($values[$key]);
+      }
     }
     // If using custom separator, preserve raw string
     else {
@@ -89,4 +102,40 @@ class SqlFunctionGROUP_CONCAT extends SqlFunction {
     return ts('All values in the grouping.');
   }
 
+  public function render(Api4Query $query, bool $includeAlias = FALSE): string {
+    $result = '';
+    // Handle pseudo-prefix `UNIQUE` which is like `DISTINCT` but based on the record id rather than the field value
+    if ($this->args[0]['prefix'] === ['UNIQUE']) {
+      $this->args[0]['prefix'] = [];
+      $expr = $this->args[0]['expr'][0];
+      $field = $query->getField($expr->getFields()[0]);
+      if ($field) {
+        $idField = CoreUtil::getIdFieldName($field['entity']);
+        $idFieldKey = substr($expr->getFields()[0], 0, 0 - strlen($field['name'])) . $idField;
+        // Keep the ordering consistent
+        if (empty($this->args[1]['prefix'])) {
+          $this->args[1] = [
+            'prefix' => ['ORDER BY'],
+            'expr' => [SqlExpression::convert($idFieldKey)],
+            'suffix' => [],
+          ];
+        }
+        // Already a unique field, so DISTINCT will work fine
+        if ($field['name'] === $idField) {
+          $this->args[0]['prefix'] = ['DISTINCT'];
+        }
+        // Add a unique field on which to dedupe in postprocessing (@see self::formatOutputValue)
+        elseif ($includeAlias) {
+          $orderByKey = $this->args[1]['expr'][0]->getFields()[0];
+          $extraSelectAlias = '_' . $this->getAlias();
+          $extraSelect = SqlExpression::convert("GROUP_CONCAT($idFieldKey ORDER BY $orderByKey) AS $extraSelectAlias", TRUE);
+          $query->selectAliases[$extraSelectAlias] = $extraSelect->getExpr();
+          $result .= $extraSelect->render($query, TRUE) . ',';
+        }
+      }
+    }
+    $result .= parent::render($query, $includeAlias);
+    return $result;
+  }
+
 }
index 312f01fa4b558c9e1f3acdf95518bb61a303f307..935ff6cdf226d11dd440f956cae41e13c6a6e22a 100644 (file)
@@ -19,10 +19,6 @@ class SqlNull extends SqlExpression {
   protected function initialize() {
   }
 
-  public function render(Api4Query $query): string {
-    return 'NULL';
-  }
-
   public static function getTitle(): string {
     return ts('Null');
   }
index 82c784a845d24ed812d02dda72f6b47ea532b564..d8e301f53c296b4d1fa0b9660547faa377196e27 100644 (file)
@@ -22,10 +22,6 @@ class SqlNumber extends SqlExpression {
     \CRM_Utils_Type::validate($this->expr, 'Float');
   }
 
-  public function render(Api4Query $query): string {
-    return $this->expr;
-  }
-
   public static function getTitle(): string {
     return ts('Number');
   }
index 1efcaf15bb3663d7d9ca74cb89486000d4321eb5..cd6db545e5249762cd90a43f3dfec0945e4ce082 100644 (file)
@@ -27,8 +27,8 @@ class SqlString extends SqlExpression {
     $this->expr = str_replace(['\\\\', "\\$quot", $backslash], [$backslash, $quot, '\\\\'], $str);
   }
 
-  public function render(Api4Query $query): string {
-    return '"' . \CRM_Core_DAO::escapeString($this->expr) . '"';
+  public function render(Api4Query $query, bool $includeAlias = FALSE): string {
+    return '"' . \CRM_Core_DAO::escapeString($this->expr) . '"' . ($includeAlias ? " AS `{$this->getAlias()}`" : '');
   }
 
   /**
index 972c47f1d8d6543b0fb0d377ac4fdfcb9ce447d9..f59bcf12082fb2c3f36db354c80f11c62b0d8fca 100644 (file)
@@ -19,10 +19,6 @@ class SqlWild extends SqlExpression {
   protected function initialize() {
   }
 
-  public function render(Api4Query $query): string {
-    return '*';
-  }
-
   public static function getTitle(): string {
     return ts('Wild');
   }
index 252b71e841322bac4c60a74d6a35d94c74257842..4a63e4db4192c1bea7603d9a9a853c02685b1b6b 100644 (file)
@@ -232,6 +232,10 @@ class FormattingUtil {
       }
     }
     foreach ($result as $key => $value) {
+      // Skip null values or values that have already been unset by `formatOutputValue` functions
+      if (!isset($result[$key])) {
+        continue;
+      }
       $fieldExpr = SqlExpression::convert($selectAliases[$key] ?? $key);
       $fieldName = \CRM_Utils_Array::first($fieldExpr->getFields() ?? '');
       $baseName = $fieldName ? \CRM_Utils_Array::first(explode(':', $fieldName)) : NULL;
index 799371b0b525658c6a50018470dead124787c98a..232709eb0e7d9aff99c109b787fc7ddc5c0c439f 100644 (file)
@@ -106,6 +106,39 @@ class SqlFunctionTest extends Api4TestBase implements TransactionalInterface {
     $this->assertEquals(['January', 'February', 'March', 'April'], $agg['months']);
   }
 
+  public function testGroupConcatUnique(): void {
+    $cid1 = $this->createTestRecord('Contact')['id'];
+    $cid2 = $this->createTestRecord('Contact')['id'];
+
+    $this->saveTestRecords('Address', [
+      'records' => [
+        ['contact_id' => $cid1, 'city' => 'A', 'location_type_id' => 1],
+        ['contact_id' => $cid1, 'city' => 'A', 'location_type_id' => 2],
+        ['contact_id' => $cid1, 'city' => 'B', 'location_type_id' => 3],
+      ],
+    ]);
+    $this->saveTestRecords('Email', [
+      'records' => [
+        ['contact_id' => $cid1, 'email' => 'test1@example.org', 'location_type_id' => 1],
+        ['contact_id' => $cid1, 'email' => 'test2@example.org', 'location_type_id' => 2],
+      ],
+    ]);
+
+    $result = Contact::get(FALSE)
+      ->addSelect('GROUP_CONCAT(UNIQUE address.id) AS address_id')
+      ->addSelect('GROUP_CONCAT(UNIQUE address.city) AS address_city')
+      ->addSelect('GROUP_CONCAT(UNIQUE email.email) AS email')
+      ->addGroupBy('id')
+      ->addJoin('Address AS address', 'LEFT', ['id', '=', 'address.contact_id'])
+      ->addJoin('Email AS email', 'LEFT', ['id', '=', 'email.contact_id'])
+      ->addOrderBy('id')
+      ->addWhere('id', 'IN', [$cid1, $cid2])
+      ->execute();
+
+    $this->assertEquals(['A', 'A', 'B'], $result[0]['address_city']);
+    $this->assertEquals(['test1@example.org', 'test2@example.org'], $result[0]['email']);
+  }
+
   public function testGroupHaving(): void {
     $cid = Contact::create(FALSE)->addValue('first_name', 'donor')->execute()->first()['id'];
     Contribution::save(FALSE)