APIv4 - Restructure function params, add labels
authorColeman Watts <coleman@civicrm.org>
Fri, 24 Sep 2021 13:11:45 +0000 (09:11 -0400)
committerColeman Watts <coleman@civicrm.org>
Sun, 26 Sep 2021 15:02:59 +0000 (11:02 -0400)
16 files changed:
Civi/Api4/Query/SqlEquation.php
Civi/Api4/Query/SqlExpression.php
Civi/Api4/Query/SqlFunction.php
Civi/Api4/Query/SqlFunctionCOALESCE.php
Civi/Api4/Query/SqlFunctionCONCAT.php
Civi/Api4/Query/SqlFunctionCONCAT_WS.php
Civi/Api4/Query/SqlFunctionGREATEST.php
Civi/Api4/Query/SqlFunctionGROUP_CONCAT.php
Civi/Api4/Query/SqlFunctionIF.php
Civi/Api4/Query/SqlFunctionLEAST.php
Civi/Api4/Query/SqlFunctionNULLIF.php
Civi/Api4/Query/SqlFunctionREPLACE.php
Civi/Api4/Query/SqlFunctionROUND.php
ext/search_kit/ang/crmSearchAdmin/crmSearchFunction.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchFunction.html
tests/phpunit/api/v4/Action/SqlFunctionTest.php

index 7585ab0d08db5d8ff77e5f640bf7ba089dfc36ce..f29fb343d8ee50fdd35735254beeb13b86344c20 100644 (file)
@@ -53,7 +53,7 @@ class SqlEquation extends SqlExpression {
     $permitted = ['SqlField', 'SqlString', 'SqlNumber', 'SqlNull'];
     $operators = array_merge(self::$arithmeticOperators, self::$comparisonOperators);
     while (strlen($arg)) {
-      $this->args = array_merge($this->args, $this->captureExpressions($arg, $permitted, FALSE));
+      $this->args = array_merge($this->args, $this->captureExpressions($arg, $permitted, 1));
       $op = $this->captureKeyword($operators, $arg);
       if ($op) {
         $this->args[] = $op;
index c05228131582700c390d23be47931fafe9ddcbdc..4cbe46386a6b407640e76f3e3f30c76832b0325c 100644 (file)
@@ -188,7 +188,8 @@ abstract class SqlExpression {
    */
   protected function captureKeyword($keywords, &$arg) {
     foreach ($keywords as $key) {
-      if (strpos($arg, $key . ' ') === 0) {
+      // Match keyword followed by a space or eol
+      if (strpos($arg, $key . ' ') === 0 || rtrim($arg) === $key) {
         $arg = ltrim(substr($arg, strlen($key)));
         return $key;
       }
@@ -201,11 +202,11 @@ abstract class SqlExpression {
    *
    * @param string $arg
    * @param array $mustBe
-   * @param bool $multi
+   * @param int $max
    * @return SqlExpression[]
    * @throws \API_Exception
    */
-  protected function captureExpressions(string &$arg, array $mustBe, bool $multi) {
+  protected function captureExpressions(string &$arg, array $mustBe, int $max) {
     $captured = [];
     $arg = ltrim($arg);
     while ($arg) {
@@ -215,7 +216,7 @@ abstract class SqlExpression {
       $this->fields = array_merge($this->fields, $expr->getFields());
       $captured[] = $expr;
       // Keep going if we have a comma indicating another expression follows
-      if ($multi && substr($arg, 0, 1) === ',') {
+      if (count($captured) < $max && substr($arg, 0, 1) === ',') {
         $arg = ltrim(substr($arg, 1));
       }
       else {
index b1d1c94093fe6a92649d7dc7902f7b773aeef0a3..c5bc9b46f1e17bceff813687273fc1195040a9d1 100644 (file)
@@ -43,19 +43,23 @@ abstract class SqlFunction extends SqlExpression {
     $arg = trim(substr($this->expr, strpos($this->expr, '(') + 1, -1));
     foreach ($this->getParams() as $idx => $param) {
       $prefix = NULL;
-      if ($param['name']) {
-        $prefix = $this->captureKeyword([$param['name']], $arg);
+      $name = $param['name'] ?: ($idx + 1);
+      // If this isn't the first param it needs to start with something;
+      // either the name (e.g. "ORDER BY") if it has one, or a comma separating it from the previous param.
+      $start = $param['name'] ?: ($idx ? ',' : NULL);
+      if ($start) {
+        $prefix = $this->captureKeyword([$start], $arg);
         // Supply api_default
         if (!$prefix && isset($param['api_default'])) {
           $this->args[$idx] = [
-            'prefix' => [$param['name']],
+            'prefix' => [$start],
             'expr' => array_map([parent::class, 'convert'], $param['api_default']['expr']),
             'suffix' => [],
           ];
           continue;
         }
         if (!$prefix && !$param['optional']) {
-          throw new \API_Exception("Missing {$param['name']} for SQL function " . static::getName());
+          throw new \API_Exception("Missing param $name for SQL function " . static::getName());
         }
       }
       elseif ($param['flag_before']) {
@@ -67,18 +71,21 @@ abstract class SqlFunction extends SqlExpression {
         'suffix' => [],
       ];
       if ($param['max_expr'] && (!$param['name'] || $param['name'] === $prefix)) {
-        $exprs = $this->captureExpressions($arg, $param['must_be'], TRUE);
+        $exprs = $this->captureExpressions($arg, $param['must_be'], $param['max_expr']);
         if (
-          (count($exprs) < $param['min_expr'] || count($exprs) > $param['max_expr']) &&
+          count($exprs) < $param['min_expr'] &&
           !(!$exprs && $param['optional'])
         ) {
-          throw new \API_Exception('Incorrect number of arguments for SQL function ' . static::getName());
+          throw new \API_Exception("Too few arguments to param $name for SQL function " . static::getName());
         }
         $this->args[$idx]['expr'] = $exprs;
 
         $this->args[$idx]['suffix'] = (array) $this->captureKeyword(array_keys($param['flag_after']), $arg);
       }
     }
+    if (trim($arg)) {
+      throw new \API_Exception("Too many arguments given for SQL function " . static::getName());
+    }
   }
 
   /**
@@ -158,6 +165,7 @@ abstract class SqlFunction extends SqlExpression {
       // Merge in defaults to ensure each param has these properties
       $params[] = $param + [
         'name' => NULL,
+        'label' => ts('Select'),
         'min_expr' => 1,
         'max_expr' => 1,
         'flag_before' => [],
index 5264e04ef1432beeb220bed94daf4db6860eab86..1616b05280d4b58fddd903277759b2f022a68445 100644 (file)
@@ -25,10 +25,7 @@ class SqlFunctionCOALESCE extends SqlFunction {
       [
         'max_expr' => 99,
         'optional' => FALSE,
-        'ui_defaults' => [
-          ['type' => 'SqlField', 'placeholder' => ts('If')],
-          ['type' => 'SqlField', 'placeholder' => ts('Else')],
-        ],
+        'label' => ts('Value?'),
       ],
     ];
   }
index 3659fa2d137ca625aa3eec13cc020c8448c17a64..fff67d74f9f23ae431c77fb3c92f8f8a8b8c6c73 100644 (file)
@@ -26,9 +26,7 @@ class SqlFunctionCONCAT extends SqlFunction {
         'max_expr' => 99,
         'optional' => FALSE,
         'must_be' => ['SqlField', 'SqlString'],
-        'ui_defaults' => [
-          ['placeholder' => ts('Plus')],
-        ],
+        'label' => ts('And'),
       ],
     ];
   }
index d381baf701a2a43ee55b0c50a65f2c0a927f3e6d..92c7a2e28940582b1383b16c616215e0263b07dc 100644 (file)
@@ -22,14 +22,16 @@ class SqlFunctionCONCAT_WS extends SqlFunction {
 
   protected static function params(): array {
     return [
+      [
+        'optional' => FALSE,
+        'must_be' => ['SqlString'],
+        'label' => ts('Separator'),
+      ],
       [
         'max_expr' => 99,
         'optional' => FALSE,
         'must_be' => ['SqlField', 'SqlString'],
-        'ui_defaults' => [
-          ['type' => 'SqlString', 'placeholder' => ts('Separator')],
-          ['type' => 'SqlField', 'placeholder' => ts('Plus')],
-        ],
+        'label' => ts('Plus'),
       ],
     ];
   }
index a76e35b14fa0b0ed2062a3d8f3a8309ff43d9880..5cc4211c5dad20398c54eb8d7c557a57c53adb0f 100644 (file)
@@ -26,10 +26,7 @@ class SqlFunctionGREATEST extends SqlFunction {
         'max_expr' => 99,
         'min_expr' => 2,
         'optional' => FALSE,
-        'ui_defaults' => [
-          ['type' => 'SqlField', 'placeholder' => ts('If')],
-          ['type' => 'SqlField', 'placeholder' => ts('Else')],
-        ],
+        'label' => ts('Else'),
       ],
     ];
   }
index 1b67dfb9083549dce0c97dc249a7050bae9f3417..3c90f1a3236d969ccf892c03db7cc3d5bb93aad6 100644 (file)
@@ -30,6 +30,7 @@ class SqlFunctionGROUP_CONCAT extends SqlFunction {
       ],
       [
         'name' => 'ORDER BY',
+        'label' => ts('Order by'),
         'max_expr' => 1,
         'flag_after' => ['ASC' => ts('Ascending'), 'DESC' => ts('Descending')],
         'must_be' => ['SqlField'],
index e9bda2705a0aad9d39ac75c0cf7f6e62545d6735..810cfe2d70f0253894da47341d1500159ab43e7f 100644 (file)
@@ -23,15 +23,19 @@ class SqlFunctionIF extends SqlFunction {
   protected static function params(): array {
     return [
       [
-        'min_expr' => 3,
-        'max_expr' => 3,
         'optional' => FALSE,
-        'must_be' => ['SqlEquation', 'SqlField', 'SqlFunction', 'SqlString', 'SqlNumber', 'SqlNull'],
-        'ui_defaults' => [
-          ['type' => 'SqlField', 'placeholder' => ts('If')],
-          ['type' => 'SqlField', 'placeholder' => ts('Then')],
-          ['type' => 'SqlField', 'placeholder' => ts('Else')],
-        ],
+        'must_be' => ['SqlEquation', 'SqlField'],
+        'label' => ts('If'),
+      ],
+      [
+        'optional' => FALSE,
+        'must_be' => ['SqlField', 'SqlString', 'SqlNumber', 'SqlNull'],
+        'label' => ts('Then'),
+      ],
+      [
+        'optional' => FALSE,
+        'must_be' => ['SqlField', 'SqlString', 'SqlNumber', 'SqlNull'],
+        'label' => ts('Else'),
       ],
     ];
   }
index a79ee4a8a29b19945d92f4f8cd50fbc7fe90a820..c45893421dcefaf86702e73d3d87662114dde250 100644 (file)
@@ -26,10 +26,7 @@ class SqlFunctionLEAST extends SqlFunction {
         'max_expr' => 99,
         'min_expr' => 2,
         'optional' => FALSE,
-        'ui_defaults' => [
-          ['type' => 'SqlField', 'placeholder' => ts('If')],
-          ['type' => 'SqlField', 'placeholder' => ts('Else')],
-        ],
+        'label' => ts('Else'),
       ],
     ];
   }
index bda6a8e2e828237e81be9f20fb0c6d63e6357f8e..07e4f2df2e132a5992a8aa1ec367f6d9e619d4a9 100644 (file)
@@ -26,10 +26,7 @@ class SqlFunctionNULLIF extends SqlFunction {
         'min_expr' => 2,
         'max_expr' => 2,
         'optional' => FALSE,
-        'ui_defaults' => [
-          ['type' => 'SqlField', 'placeholder' => ts('Preferred')],
-          ['type' => 'SqlField', 'placeholder' => ts('Alternate')],
-        ],
+        'label' => ts('Compare with'),
       ],
     ];
   }
index f5a1cfbbea09c00b86b8f0cb541e54f83188b85a..961d151e2369228e7de3c65b65e8beada497e9b1 100644 (file)
@@ -21,15 +21,19 @@ class SqlFunctionREPLACE extends SqlFunction {
   protected static function params(): array {
     return [
       [
-        'min_expr' => 3,
-        'max_expr' => 3,
+        'optional' => FALSE,
+        'must_be' => ['SqlField', 'SqlString'],
+        'label' => ts('Source'),
+      ],
+      [
+        'optional' => FALSE,
+        'must_be' => ['SqlString', 'SqlField'],
+        'label' => ts('Find'),
+      ],
+      [
         'optional' => FALSE,
         'must_be' => ['SqlString', 'SqlField'],
-        'ui_defaults' => [
-          ['type' => 'SqlField', 'placeholder' => ts('Source')],
-          ['type' => 'SqlString', 'placeholder' => ts('Find')],
-          ['type' => 'SqlString', 'placeholder' => ts('Replace')],
-        ],
+        'label' => ts('Replace'),
       ],
     ];
   }
index 6e09945ecd232994ded88cdff99e782ac687034b..5db43b962caed088b5598a780a89ff208289b393 100644 (file)
@@ -22,13 +22,13 @@ class SqlFunctionROUND extends SqlFunction {
     return [
       [
         'optional' => FALSE,
-        'min_expr' => 1,
-        'max_expr' => 2,
-        'must_be' => ['SqlNumber', 'SqlField'],
-        'ui_defaults' => [
-          ['type' => 'SqlField', 'placeholder' => ts('Number')],
-          ['type' => 'SqlNumber', 'placeholder' => ts('Decimals')],
-        ],
+        'must_be' => ['SqlField', 'SqlNumber'],
+        'label' => ts('Number'),
+      ],
+      [
+        'optional' => TRUE,
+        'must_be' => ['SqlNumber'],
+        'label' => ts('Decimal places'),
       ],
     ];
   }
index 25e7528269d8990db942ab21844170bf86514b56..090ae17359ce2dc4e1b9170aa8163730c706bb72 100644 (file)
@@ -13,8 +13,6 @@
       var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
         ctrl = this;
 
-      var defaultUiDefaults = {type: 'SqlField', placeholder: ts('Select')};
-
       var allTypes = {
         aggregate: ts('Aggregate'),
         comparison: ts('Comparison'),
@@ -39,7 +37,6 @@
       };
 
       this.addArg = function(exprType) {
-        exprType = exprType || ctrl.getUiDefault(ctrl.args.length).type;
         ctrl.args.push({
           type: ctrl.exprTypes[exprType].type,
           value: exprType === 'SqlNumber' ? 0 : ''
           ctrl.modifier = null;
         }
         // Push args to reach the minimum
-        while (ctrl.args.length < ctrl.fn.params[0].min_expr) {
-          ctrl.addArg();
-        }
+        _.each(ctrl.fn.params, function(param, index) {
+          while (
+            (ctrl.args.length - index < param.min_expr) &&
+            // TODO: Handle named params like "ORDER BY"
+            !param.name &&
+            (!param.optional || param.must_be.length === 1)
+          ) {
+            ctrl.addArg(param.must_be[0]);
+          }
+        });
       }
 
-      this.getUiDefault = function(index) {
-        if (ctrl.fn.params[0].ui_defaults) {
-          return ctrl.fn.params[0].ui_defaults[index] || _.last(ctrl.fn.params[0].ui_defaults);
+      this.getParam = function(index) {
+        return ctrl.fn.params[index] || _.last(ctrl.fn.params);
+      };
+
+      this.canAddArg = function() {
+        if (!ctrl.fn) {
+          return false;
+        }
+        var param = ctrl.getParam(ctrl.args.length),
+          index = ctrl.fn.params.indexOf(param);
+        // TODO: Handle named params like "ORDER BY"
+        if (param.name) {
+          return false;
         }
-        defaultUiDefaults.type = ctrl.fn.params[0].must_be[0];
-        return defaultUiDefaults;
+        return ctrl.args.length - index < param.max_expr;
       };
 
       // On-demand options for dropdown function selector
index 67c9b6c980c2e5e216bc2519fa7e7755757f60db..89bba89a0a7a20a17a82edd90e32b93c721241de 100644 (file)
@@ -7,17 +7,17 @@
   </label>
   <div class="form-group" ng-repeat="arg in $ctrl.args" ng-if="arg !== $ctrl.fieldArg">
     <span ng-switch="arg.type">
-      <input ng-switch-when="number" class="form-control" type="number" ng-model="arg.value" placeholder="{{ $ctrl.getUiDefault($index).placeholder }}" ng-change="$ctrl.changeArg($index)" ng-model-options="{updateOn: 'blur'}">
-      <input ng-switch-when="string" class="form-control" ng-model="arg.value" placeholder="{{ $ctrl.getUiDefault($index).placeholder }}" ng-change="$ctrl.changeArg($index)" ng-trim="false" ng-model-options="{updateOn: 'blur'}">
-      <input ng-switch-default class="form-control" ng-model="arg.value" crm-ui-select="{data: $ctrl.getFields, placeholder: $ctrl.getUiDefault($index).placeholder}" ng-change="$ctrl.changeArg($index)">
+      <input ng-switch-when="number" class="form-control" type="number" ng-model="arg.value" placeholder="{{ $ctrl.getParam($index).label }}" ng-change="$ctrl.changeArg($index)" ng-model-options="{updateOn: 'blur'}">
+      <input ng-switch-when="string" class="form-control" ng-model="arg.value" placeholder="{{ $ctrl.getParam($index).label }}" ng-change="$ctrl.changeArg($index)" ng-trim="false" ng-model-options="{updateOn: 'blur'}">
+      <input ng-switch-default class="form-control" ng-model="arg.value" crm-ui-select="{data: $ctrl.getFields, placeholder: $ctrl.getParam($index).label}" ng-change="$ctrl.changeArg($index)">
     </span>
   </div>
-  <div class="btn-group" ng-if="$ctrl.args.length < $ctrl.fn.params[0].max_expr">
+  <div class="btn-group" ng-if="$ctrl.canAddArg()">
     <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
       <i class="crm-i fa-plus"></i> <span class="caret"></span>
     </button>
     <ul class="dropdown-menu">
-      <li ng-repeat="(name, type) in $ctrl.exprTypes" ng-show="$ctrl.fn.params[0].must_be.indexOf(name) >= 0">
+      <li ng-repeat="(name, type) in $ctrl.exprTypes" ng-show="$ctrl.getParam($ctrl.args.length).must_be.indexOf(name) >= 0">
         <a href ng-click="$ctrl.addArg(name)">{{ type.label }}</a>
       </li>
     </ul>
index 9101fad3e42a33a120acf5b1e9e3a2ca2f1612f9..59300c3692059b637dbca0d5d6ae720fbfaaf778 100644 (file)
@@ -193,7 +193,7 @@ class SqlFunctionTest extends UnitTestCase {
 
   public function testStringFunctions() {
     $sampleData = [
-      ['first_name' => 'abc', 'middle_name' => 'q', 'last_name' => 'tester1', 'source' => '123'],
+      ['first_name' => 'abc', 'middle_name' => 'Q', 'last_name' => 'tester1', 'source' => '123'],
     ];
     $cid = Contact::save(FALSE)
       ->setRecords($sampleData)
@@ -202,9 +202,15 @@ class SqlFunctionTest extends UnitTestCase {
     $result = Contact::get(FALSE)
       ->addWhere('id', '=', $cid)
       ->addSelect('CONCAT_WS("|", first_name, middle_name, last_name) AS concat_ws')
+      ->addSelect('REPLACE(first_name, "c", "cdef") AS new_first')
+      ->addSelect('UPPER(first_name)')
+      ->addSelect('LOWER(middle_name)')
       ->execute()->first();
 
-    $this->assertEquals('abc|q|tester1', $result['concat_ws']);
+    $this->assertEquals('abc|Q|tester1', $result['concat_ws']);
+    $this->assertEquals('abcdef', $result['new_first']);
+    $this->assertEquals('ABC', $result['UPPER:first_name']);
+    $this->assertEquals('q', $result['LOWER:middle_name']);
   }
 
   public function testIncorrectNumberOfArguments() {
@@ -215,7 +221,7 @@ class SqlFunctionTest extends UnitTestCase {
       $this->fail('Api should have thrown exception');
     }
     catch (\API_Exception $e) {
-      $this->assertEquals('Incorrect number of arguments for SQL function IF', $e->getMessage());
+      $this->assertEquals('Missing param 2 for SQL function IF', $e->getMessage());
     }
 
     try {
@@ -225,7 +231,17 @@ class SqlFunctionTest extends UnitTestCase {
       $this->fail('Api should have thrown exception');
     }
     catch (\API_Exception $e) {
-      $this->assertEquals('Incorrect number of arguments for SQL function NULLIF', $e->getMessage());
+      $this->assertEquals('Too many arguments given for SQL function NULLIF', $e->getMessage());
+    }
+
+    try {
+      Activity::get(FALSE)
+        ->addSelect('CONCAT_WS(",", ) AS whoops')
+        ->execute();
+      $this->fail('Api should have thrown exception');
+    }
+    catch (\API_Exception $e) {
+      $this->assertEquals('Too few arguments to param 2 for SQL function CONCAT_WS', $e->getMessage());
     }
   }