APIv4 - Add support for HAVING clause
authorColeman Watts <coleman@civicrm.org>
Wed, 8 Apr 2020 00:43:12 +0000 (20:43 -0400)
committerColeman Watts <coleman@civicrm.org>
Wed, 8 Apr 2020 11:59:05 +0000 (07:59 -0400)
Civi/Api4/Generic/DAOGetAction.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Query/SqlExpression.php
Civi/Api4/Query/SqlField.php
Civi/Api4/Query/SqlFunction.php
Civi/Api4/Query/SqlNumber.php
Civi/Api4/Query/SqlString.php
tests/phpunit/api/v4/Action/SqlFunctionTest.php
tests/phpunit/api/v4/Query/SqlExpressionParserTest.php

index ae29e3992a3f116bbddb20844e3e1d23cdb6fe4f..ab7d85b966d3ab1802b2b0dd2219a95f54b34854 100644 (file)
@@ -29,6 +29,9 @@ use Civi\Api4\Query\Api4SelectQuery;
  * Use the `select` param to determine which fields are returned, defaults to `[*]`.
  *
  * Perform joins on other related entities using a dot notation.
+ *
+ * @method $this setHaving(array $clauses)
+ * @method array getHaving()
  */
 class DAOGetAction extends AbstractGetAction {
   use Traits\DAOActionTrait;
@@ -51,6 +54,15 @@ class DAOGetAction extends AbstractGetAction {
    */
   protected $groupBy = [];
 
+  /**
+   * Clause for filtering results after grouping and filters are applied.
+   *
+   * Each expression should correspond to an item from the SELECT array.
+   *
+   * @var array
+   */
+  protected $having = [];
+
   public function _run(Result $result) {
     $this->setDefaultWhereClause();
     $this->expandSelectClauseWildcards();
@@ -95,4 +107,19 @@ class DAOGetAction extends AbstractGetAction {
     return $this;
   }
 
+  /**
+   * @param string $expr
+   * @param string $op
+   * @param mixed $value
+   * @return $this
+   * @throws \API_Exception
+   */
+  public function addHaving(string $expr, string $op, $value = NULL) {
+    if (!in_array($op, \CRM_Core_DAO::acceptedSQLOperators())) {
+      throw new \API_Exception('Unsupported operator');
+    }
+    $this->having[] = [$expr, $op, $value];
+    return $this;
+  }
+
 }
index 09e95f147ec9260647010b77099147f498843675..34c27ee671f2e54da0b2a3543451569a35e823e9 100644 (file)
@@ -64,6 +64,11 @@ class Api4SelectQuery extends SelectQuery {
    */
   public $groupBy = [];
 
+  /**
+   * @var array
+   */
+  public $having = [];
+
   /**
    * @param \Civi\Api4\Generic\DAOGetAction $apiGet
    */
@@ -76,6 +81,7 @@ class Api4SelectQuery extends SelectQuery {
     $this->orderBy = $apiGet->getOrderBy();
     $this->limit = $apiGet->getLimit();
     $this->offset = $apiGet->getOffset();
+    $this->having = $apiGet->getHaving();
     if ($apiGet->getDebug()) {
       $this->debugOutput =& $apiGet->_debugOutput;
     }
@@ -106,6 +112,7 @@ class Api4SelectQuery extends SelectQuery {
     $this->buildOrderBy();
     $this->buildLimit();
     $this->buildGroupBy();
+    $this->buildHavingClause();
     return $this->query->toSQL();
   }
 
@@ -129,7 +136,7 @@ class Api4SelectQuery extends SelectQuery {
         break;
       }
       $results[$id] = [];
-      foreach ($this->selectAliases as $alias) {
+      foreach ($this->selectAliases as $alias => $expr) {
         $returnName = $alias;
         $alias = str_replace('.', '_', $alias);
         $results[$id][$returnName] = property_exists($query, $alias) ? $query->$alias : NULL;
@@ -186,7 +193,8 @@ class Api4SelectQuery extends SelectQuery {
         }
       }
       if ($valid) {
-        $alias = $this->selectAliases[] = $expr->getAlias();
+        $alias = $expr->getAlias();
+        $this->selectAliases[$alias] = $expr->getExpr();
         $this->query->select($expr->render($this->apiFieldSpec) . " AS `$alias`");
       }
     }
@@ -197,8 +205,18 @@ class Api4SelectQuery extends SelectQuery {
    */
   protected function buildWhereClause() {
     foreach ($this->where as $clause) {
-      $sql_clause = $this->treeWalkWhereClause($clause);
-      $this->query->where($sql_clause);
+      $this->query->where($this->treeWalkClauses($clause, 'WHERE'));
+    }
+  }
+
+  /**
+   * Build HAVING clause.
+   *
+   * Every expression referenced must also be in the SELECT clause.
+   */
+  protected function buildHavingClause() {
+    foreach ($this->having as $clause) {
+      $this->query->having($this->treeWalkClauses($clause, 'HAVING'));
     }
   }
 
@@ -244,25 +262,26 @@ class Api4SelectQuery extends SelectQuery {
    * Recursively validate and transform a branch or leaf clause array to SQL.
    *
    * @param array $clause
+   * @param string $type
+   *   WHERE|HAVING
    * @return string SQL where clause
    *
-   * @uses validateClauseAndComposeSql() to generate the SQL etc.
-   * @todo if an 'and' is nested within and 'and' (or or-in-or) then should
-   * flatten that to be a single list of clauses.
+   * @throws \API_Exception
+   * @uses composeClause() to generate the SQL etc.
    */
-  protected function treeWalkWhereClause($clause) {
+  protected function treeWalkClauses($clause, $type) {
     switch ($clause[0]) {
       case 'OR':
       case 'AND':
         // handle branches
         if (count($clause[1]) === 1) {
           // a single set so AND|OR is immaterial
-          return $this->treeWalkWhereClause($clause[1][0]);
+          return $this->treeWalkClauses($clause[1][0], $type);
         }
         else {
           $sql_subclauses = [];
           foreach ($clause[1] as $subclause) {
-            $sql_subclauses[] = $this->treeWalkWhereClause($subclause);
+            $sql_subclauses[] = $this->treeWalkClauses($subclause, $type);
           }
           return '(' . implode("\n" . $clause[0], $sql_subclauses) . ')';
         }
@@ -272,30 +291,48 @@ class Api4SelectQuery extends SelectQuery {
         if (!is_string($clause[1][0])) {
           $clause[1] = ['AND', $clause[1]];
         }
-        return 'NOT (' . $this->treeWalkWhereClause($clause[1]) . ')';
+        return 'NOT (' . $this->treeWalkClauses($clause[1], $type) . ')';
 
       default:
-        return $this->validateClauseAndComposeSql($clause);
+        return $this->composeClause($clause, $type);
     }
   }
 
   /**
    * Validate and transform a leaf clause array to SQL.
    * @param array $clause [$fieldName, $operator, $criteria]
+   * @param string $type
+   *   WHERE|HAVING
    * @return string SQL
    * @throws \API_Exception
    * @throws \Exception
    */
-  protected function validateClauseAndComposeSql($clause) {
+  protected function composeClause(array $clause, string $type) {
     // Pad array for unary operators
-    list($fieldName, $operator, $value) = array_pad($clause, 3, NULL);
-    $field = $this->getField($fieldName, TRUE);
+    list($expr, $operator, $value) = array_pad($clause, 3, NULL);
 
-    FormattingUtil::formatInputValue($value, $field, $this->getEntity());
+    // For WHERE clause, expr must be the name of a field.
+    if ($type === 'WHERE') {
+      $field = $this->getField($expr, TRUE);
+      FormattingUtil::formatInputValue($value, $field, $this->getEntity());
+      $fieldAlias = $field['sql_name'];
+    }
+    // For HAVING, expr must be an item in the SELECT clause
+    else {
+      if (isset($this->selectAliases[$expr])) {
+        $fieldAlias = $expr;
+      }
+      elseif (in_array($expr, $this->selectAliases)) {
+        $fieldAlias = array_search($expr, $this->selectAliases);
+      }
+      else {
+        throw new \API_Exception("Invalid expression in $type clause: '$expr'. Must use a value from SELECT clause.");
+      }
+    }
 
-    $sql_clause = \CRM_Core_DAO::createSQLFilter($field['sql_name'], [$operator => $value]);
+    $sql_clause = \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
     if ($sql_clause === NULL) {
-      throw new \API_Exception("Invalid value in where clause for field '$fieldName'");
+      throw new \API_Exception("Invalid value in $type clause for '$expr'");
     }
     return $sql_clause;
   }
index 198ce1c51cf68a2ba2b586467f1affc649a16310..e21f3880f4fb72d601ef070a24d7936b1b391dff 100644 (file)
@@ -26,23 +26,24 @@ abstract class SqlExpression {
   protected $fields = [];
 
   /**
+   * The SELECT alias (if null it will be calculated by getAlias)
    * @var string|null
    */
   protected $alias;
 
   /**
-   * The argument string.
+   * The raw expression, minus the alias.
    * @var string
    */
-  protected $arg = '';
+  protected $expr = '';
 
   /**
    * SqlFunction constructor.
-   * @param string $arg
+   * @param string $expr
    * @param string|null $alias
    */
-  public function __construct(string $arg, $alias = NULL) {
-    $this->arg = $arg;
+  public function __construct(string $expr, $alias = NULL) {
+    $this->expr = $expr;
     $this->alias = $alias;
     $this->initialize();
   }
@@ -68,14 +69,13 @@ abstract class SqlExpression {
     $bracketPos = strpos($expr, '(');
     $firstChar = substr($expr, 0, 1);
     $lastChar = substr($expr, -1);
-    // Function
+    // If there are brackets but not the first character, we have a function
     if ($bracketPos && $lastChar === ')') {
       $fnName = substr($expr, 0, $bracketPos);
       if ($fnName !== strtoupper($fnName)) {
         throw new \API_Exception('Sql function must be uppercase.');
       }
       $className = 'SqlFunction' . $fnName;
-      $expr = substr($expr, $bracketPos + 1, -1);
     }
     // String expression
     elseif ($firstChar === $lastChar && in_array($firstChar, ['"', "'"], TRUE)) {
@@ -132,13 +132,20 @@ abstract class SqlExpression {
    */
   abstract public function render(array $fieldList): string;
 
+  /**
+   * @return string
+   */
+  public function getExpr(): string {
+    return $this->expr;
+  }
+
   /**
    * Returns the alias to use for SELECT AS.
    *
    * @return string
    */
   public function getAlias(): string {
-    return $this->alias ?? $this->fields[0] ?? \CRM_Utils_String::munge($this->arg);
+    return $this->alias ?? $this->fields[0] ?? \CRM_Utils_String::munge($this->expr);
   }
 
 }
index 488e3b052c9292a723c01b7f291e680d1f83bcda..712bd7b8cc69bd6da8138f8f8a1b0e34264e4256 100644 (file)
@@ -17,14 +17,14 @@ namespace Civi\Api4\Query;
 class SqlField extends SqlExpression {
 
   protected function initialize() {
-    $this->fields[] = $this->arg;
+    $this->fields[] = $this->expr;
   }
 
   public function render(array $fieldList): string {
-    if (empty($fieldList[$this->arg])) {
-      throw new \API_Exception("Invalid field '{$this->arg}'");
+    if (empty($fieldList[$this->expr])) {
+      throw new \API_Exception("Invalid field '{$this->expr}'");
     }
-    return $fieldList[$this->arg]['sql_name'];
+    return $fieldList[$this->expr]['sql_name'];
   }
 
 }
index df93b6660364fe3f60c56ec2c8de4efa7485bb17..e10bbcb23b0637f7a92aac75ef0050c9751b377b 100644 (file)
@@ -26,7 +26,7 @@ abstract class SqlFunction extends SqlExpression {
    * Parse the argument string into an array of function arguments
    */
   protected function initialize() {
-    $arg = $this->arg;
+    $arg = trim(substr($this->expr, strpos($this->expr, '(') + 1, -1));
     foreach ($this->getParams() as $param) {
       $prefix = $this->captureKeyword($param['prefix'], $arg);
       if ($param['expr'] && isset($prefix) || in_array('', $param['prefix']) || !$param['optional']) {
index e8ee2550b3c8960efe471aedff5ab4218f8970d8..064121bfa95a23de0cbde73bae480c3edde67634 100644 (file)
@@ -17,11 +17,11 @@ namespace Civi\Api4\Query;
 class SqlNumber extends SqlExpression {
 
   protected function initialize() {
-    \CRM_Utils_Type::validate($this->arg, 'Float');
+    \CRM_Utils_Type::validate($this->expr, 'Float');
   }
 
   public function render(array $fieldList): string {
-    return $this->arg;
+    return $this->expr;
   }
 
 }
index 12c85ab3937d37320d8db7dfaaecfe5667ecf195..8ea9c0013773e7851e4497e69b9c7f25c4bcceb9 100644 (file)
@@ -18,15 +18,15 @@ class SqlString extends SqlExpression {
 
   protected function initialize() {
     // Remove surrounding quotes
-    $str = substr($this->arg, 1, -1);
+    $str = substr($this->expr, 1, -1);
     // Unescape the outer quote character inside the string to prevent double-escaping in render()
-    $quot = substr($this->arg, 0, 1);
+    $quot = substr($this->expr, 0, 1);
     $backslash = chr(0) . 'backslash' . chr(0);
-    $this->arg = str_replace(['\\\\', "\\$quot", $backslash], [$backslash, $quot, '\\\\'], $str);
+    $this->expr = str_replace(['\\\\', "\\$quot", $backslash], [$backslash, $quot, '\\\\'], $str);
   }
 
   public function render(array $fieldList): string {
-    return '"' . \CRM_Core_DAO::escapeString($this->arg) . '"';
+    return '"' . \CRM_Core_DAO::escapeString($this->expr) . '"';
   }
 
 }
index 9b33c594c3951f0c72bb9a8d734fb4c865fd9812..ab9b09dd8b65211582e3c9998c8f72720bcf4ba4 100644 (file)
@@ -59,4 +59,42 @@ class SqlFunctionTest extends UnitTestCase {
     $this->assertEquals(4, $agg['count']);
   }
 
+  public function testGroupHaving() {
+    $cid = Contact::create()->setCheckPermissions(FALSE)->addValue('first_name', 'donor')->execute()->first()['id'];
+    Contribution::save()
+      ->setCheckPermissions(FALSE)
+      ->setDefaults(['contact_id' => $cid, 'financial_type_id' => 1])
+      ->setRecords([
+        ['total_amount' => 100, 'receive_date' => '2020-02-02'],
+        ['total_amount' => 200, 'receive_date' => '2020-02-02'],
+        ['total_amount' => 300, 'receive_date' => '2020-03-03'],
+        ['total_amount' => 400, 'receive_date' => '2020-04-04'],
+      ])
+      ->execute();
+    $result = Contribution::get()
+      ->setCheckPermissions(FALSE)
+      ->addGroupBy('contact_id')
+      ->addGroupBy('receive_date')
+      ->addSelect('contact_id')
+      ->addSelect('receive_date')
+      ->addSelect('AVG(total_amount) AS average')
+      ->addSelect('SUM(total_amount)')
+      ->addSelect('MAX(total_amount)')
+      ->addSelect('MIN(total_amount)')
+      ->addSelect('COUNT(*) AS count')
+      ->addOrderBy('receive_date')
+      ->addHaving('contact_id', '=', $cid)
+      ->addHaving('receive_date', '<', '2020-04-01')
+      ->execute();
+    $this->assertCount(2, $result);
+    $this->assertEquals(150, $result[0]['average']);
+    $this->assertEquals(300, $result[1]['average']);
+    $this->assertEquals(300, $result[0]['SUM:total_amount']);
+    $this->assertEquals(300, $result[1]['SUM:total_amount']);
+    $this->assertEquals(200, $result[0]['MAX:total_amount']);
+    $this->assertEquals(100, $result[0]['MIN:total_amount']);
+    $this->assertEquals(2, $result[0]['count']);
+    $this->assertEquals(1, $result[1]['count']);
+  }
+
 }
index cf43dbd6c06b4c86b70789278f49f36136c9a83a..4be10cefec91bb3a17223c6623d854065fbba7cf 100644 (file)
@@ -49,7 +49,7 @@ class SqlExpressionParserTest extends UnitTestCase {
     $this->assertNotEmpty($params[0]['prefix']);
     $this->assertEmpty($params[0]['suffix']);
 
-    $sqlFn = new $className('total');
+    $sqlFn = new $className($fnName . '(total)');
     $this->assertEquals($fnName, $sqlFn->getName());
     $this->assertEquals(['total'], $sqlFn->getFields());
     $this->assertCount(1, $this->getArgs($sqlFn));