SearchKit - Refactor SearchAdmin.parseExpr to handle multiple function arguments
authorColeman Watts <coleman@civicrm.org>
Thu, 16 Sep 2021 21:59:31 +0000 (17:59 -0400)
committerColeman Watts <coleman@civicrm.org>
Sat, 18 Sep 2021 18:13:20 +0000 (14:13 -0400)
Civi/Api4/Query/SqlFunction.php
ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php
ext/search_kit/ang/crmSearchAdmin.module.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js
ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js

index 43adbff9f0bbe93457f06f9411ab04fa161b2536..915962aa821a49a45eaece69437de6694ade84bb 100644 (file)
@@ -147,7 +147,6 @@ abstract class SqlFunction extends SqlExpression {
    * @return string
    */
   private function captureExpression($arg) {
-    $chars = str_split($arg);
     $isEscaped = $quote = NULL;
     $item = '';
     $quotes = ['"', "'"];
@@ -155,7 +154,7 @@ abstract class SqlFunction extends SqlExpression {
       ')' => '(',
     ];
     $enclosures = array_fill_keys($brackets, 0);
-    foreach ($chars as $index => $char) {
+    foreach (str_split($arg) as $char) {
       if (!$isEscaped && in_array($char, $quotes, TRUE)) {
         // Open quotes - we'll ignore everything inside
         if (!$quote) {
index 289206b08fc118b098a0fe68f0ba82b06976fa6d..2724c2a3497fb218f485c3c84268b60a36c1d8f8 100644 (file)
@@ -273,9 +273,12 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
     foreach ($apiParams['select'] ?? [] as $select) {
       if (strstr($select, ' AS ')) {
         $expr = SqlExpression::convert($select, TRUE);
-        $field = $expr->getFields() ? $selectQuery->getField($expr->getFields()[0]) : NULL;
-        $joinName = explode('.', $expr->getFields()[0] ?? '')[0];
-        $label = $expr::getTitle() . ': ' . (isset($joinMap[$joinName]) ? $joinMap[$joinName] . ' ' : '') . $field['title'];
+        $label = $expr::getTitle();
+        foreach ($expr->getFields() as $num => $fieldName) {
+          $field = $selectQuery->getField($fieldName);
+          $joinName = explode('.', $fieldName)[0];
+          $label .= ($num ? ', ' : ': ') . (isset($joinMap[$joinName]) ? $joinMap[$joinName] . ' ' : '') . $field['title'];
+        }
         $calcFields[] = [
           '#tag' => 'af-field',
           'name' => $expr->getAlias(),
index a3688afa9bf4ecbfb7dea172d66fff90eac11f7b..10ebb5ac435be9a9e1039454b9c057a1a9b7e209 100644 (file)
           return {field: field, join: join};
         }
       }
+      function parseFnArgs(info, expr) {
+        var fnName = expr.split('(')[0],
+          argString = expr.substr(fnName.length + 1, expr.length - fnName.length - 2);
+        info.fn = _.find(CRM.crmSearchAdmin.functions, {name: fnName});
+
+        function getKeyword(whitelist) {
+          var keyword;
+          _.each(whitelist, function(flag) {
+            if (argString.indexOf(flag) === 0) {
+              keyword = flag;
+              argString = _.trim(argString.substr(flag.length));
+              return false;
+            }
+          });
+          return keyword;
+        }
+
+        function getExpr() {
+          var expr;
+          if (argString.indexOf('"') === 0) {
+            // Match double-quoted string
+            expr = argString.match(/"([^"\\]|\\.)*"/)[0];
+          } else if (argString.indexOf("'") === 0) {
+            // Match single-quoted string
+            expr = argString.match(/'([^'\\]|\\.)*'/)[0];
+          } else {
+            // Match anything else
+            expr = argString.match(/[^ ,]+/)[0];
+          }
+          if (expr) {
+            argString = _.trim(argString.substr(expr.length));
+            return parseArg(expr);
+          }
+        }
+
+        _.each(info.fn.params, function(param, index) {
+          var exprCount = 0,
+            expr, flagBefore;
+          argString = _.trim(argString);
+          if (!argString.length || (param.name && !getKeyword(param.name))) {
+            return false;
+          }
+          flagBefore = getKeyword(_.keys(param.flag_before || {}));
+          if (param.max_expr) {
+            while (++exprCount <= param.max_expr && argString.length) {
+              expr = getExpr();
+              if (expr) {
+                expr.param = param.name || index;
+                expr.flag_before = flagBefore;
+                info.args.push(expr);
+              }
+              // Only continue if an expression was found and followed by a comma
+              if (!expr || !getKeyword([','])) {
+                break;
+              }
+            }
+            if (expr && !_.isEmpty(expr.flag_after)) {
+              _.last(info.args).flag_after = getKeyword(_.keys(param.flag_after));
+            }
+          }
+        });
+      }
+      // @param {String} arg
+      function parseArg(arg) {
+        arg = _.trim(arg);
+        if (arg && !isNaN(arg)) {
+          return {
+            type: 'number',
+            value: +arg
+          };
+        } else if (_.includes(['"', "'"], arg.substr(0, 1))) {
+          return {
+            type: 'string',
+            value: arg.substr(1, arg.length - 2)
+          };
+        } else if (arg) {
+          var fieldAndJoin = getFieldAndJoin(arg, searchEntity);
+          if (fieldAndJoin) {
+            var split = arg.split(':'),
+              prefixPos = split[0].lastIndexOf(fieldAndJoin.field.name);
+            return {
+              type: 'field',
+              value: arg,
+              path: split[0],
+              field: fieldAndJoin.field,
+              join: fieldAndJoin.join,
+              prefix: prefixPos > 0 ? split[0].substring(0, prefixPos) : '',
+              suffix: !split[1] ? '' : ':' + split[1]
+            };
+          }
+        }
+      }
       function parseExpr(expr) {
         if (!expr) {
           return;
         }
         var splitAs = expr.split(' AS '),
-          info = {fn: null, modifier: '', field: {}, alias: _.last(splitAs)},
-          fieldName = splitAs[0],
-          bracketPos = splitAs[0].indexOf('(');
-        if (bracketPos >= 0) {
-          var parsed = splitAs[0].substr(bracketPos).match(/[ ]?([A-Z]+[ ]+)?([\w.:]+)/);
-          fieldName = parsed[2];
-          info.fn = _.find(CRM.crmSearchAdmin.functions, {name: expr.substring(0, bracketPos)});
-          info.modifier = _.trim(parsed[1]);
-        }
-        var fieldAndJoin = getFieldAndJoin(fieldName, searchEntity);
-        if (fieldAndJoin) {
-          var split = fieldName.split(':'),
-            prefixPos = split[0].lastIndexOf(fieldAndJoin.field.name);
-          info.path = split[0];
-          info.prefix = prefixPos > 0 ? info.path.substring(0, prefixPos) : '';
-          info.suffix = !split[1] ? '' : ':' + split[1];
-          info.field = fieldAndJoin.field;
-          info.join = fieldAndJoin.join;
+          info = {fn: null, args: [], alias: _.last(splitAs)},
+          bracketPos = expr.indexOf('(');
+        if (bracketPos > 0) {
+          parseFnArgs(info, splitAs[0]);
+        } else {
+          var arg = parseArg(splitAs[0]);
+          if (arg) {
+            arg.param = 0;
+            info.args.push(arg);
+          }
         }
         return info;
       }
       function getDefaultLabel(col) {
         var info = parseExpr(col),
-          label = info.field.label;
+          label = '';
         if (info.fn) {
-          label = '(' + info.fn.title + ') ' + label;
-        }
-        if (info.join) {
-          label = info.join.label + ': ' + label;
+          label = '(' + info.fn.title + ')';
         }
+        _.each(info.args, function(arg) {
+          if (arg.join) {
+            label += (label ? ' ' : '') + arg.join.label + ':';
+          }
+          if (arg.field) {
+            label += (label ? ' ' : '') + arg.field.label;
+          } else {
+            label += (label ? ' ' : '') + arg.value;
+          }
+        });
         return label;
       }
       function fieldToColumn(fieldExpr, defaults) {
         var info = parseExpr(fieldExpr),
+          field = _.findWhere(info.args, {type: 'field'}) || {},
           values = _.merge({
             type: 'field',
             key: info.alias,
-            dataType: (info.fn && info.fn.dataType) || (info.field && info.field.data_type)
+            dataType: (info.fn && info.fn.dataType) || field.data_type
           }, defaults);
         if (defaults.label === true) {
           values.label = getDefaultLabel(fieldExpr);
         }
         if (defaults.sortable) {
-          values.sortable = (info.field.type === 'Field');
+          values.sortable = field.type === 'Field';
         }
         return values;
       }
         },
         // Returns name of explicit or implicit join, for links
         getJoinEntity: function(info) {
-          if (info.field.fk_entity || info.field.name !== info.field.fieldName) {
-            return info.prefix + (info.field.fk_entity ? info.field.name : info.field.name.substr(0, info.field.name.lastIndexOf('.')));
-          } else if (info.prefix) {
-            return info.prefix.replace('.', '');
+          var arg = _.findWhere(info.args, {type: 'field'}) || {},
+            field = arg.field || {};
+          if (field.fk_entity || field.name !== field.fieldName) {
+            return arg.prefix + (field.fk_entity ? field.name : field.name.substr(0, field.name.lastIndexOf('.')));
+          } else if (arg.prefix) {
+            return arg.prefix.replace('.', '');
           }
           return '';
         },
index f06792b4dd88098c96470f4ebf7d70001ef09d50..ffc1ad78ec6b95d6ecb701bc5989f9fd555f6245 100644 (file)
       function reconcileAggregateColumns() {
         _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
           var info = searchMeta.parseExpr(col),
-            fieldExpr = info.path + info.suffix;
+            fieldExpr = (_.findWhere(info.args, {type: 'field'}) || {}).value;
           if (ctrl.canAggregate(col)) {
             // Ensure all non-grouped columns are aggregated if using GROUP BY
             if (!info.fn || info.fn.category !== 'aggregate') {
         if (!ctrl.savedSearch.api_params.groupBy.length) {
           return false;
         }
-        var info = searchMeta.parseExpr(col);
+        var arg = _.findWhere(searchMeta.parseExpr(col).args, {type: 'field'}) || {};
+        // If the column is not a database field, no
+        if (!arg.field || !arg.field.entity || arg.field.type !== 'Field') {
+          return false;
+        }
         // If the column is used for a groupBy, no
-        if (ctrl.savedSearch.api_params.groupBy.indexOf(info.path) > -1) {
+        if (ctrl.savedSearch.api_params.groupBy.indexOf(arg.path) > -1) {
           return false;
         }
         // If the entity this column belongs to is being grouped by primary key, then also no
-        var idField = searchMeta.getEntity(info.field.entity).primary_key[0];
-        return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + idField) < 0;
+        var idField = searchMeta.getEntity(arg.field.entity).primary_key[0];
+        return ctrl.savedSearch.api_params.groupBy.indexOf(arg.prefix + idField) < 0;
       };
 
       $scope.fieldsForGroupBy = function() {
           var item = {
             id: info.alias,
             text: ctrl.getFieldLabel(name),
-            description: info.field && info.field.description
+            description: info.fn ? info.fn.description : info.args[0].field && info.args[0].field.description
           };
           if (disabledIf(item.id)) {
             item.disabled = true;
         // Links to implicit joins
         _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
           if (!_.includes(fieldName, ' AS ')) {
-            var info = searchMeta.parseExpr(fieldName);
+            var info = searchMeta.parseExpr(fieldName).args[0];
             if (info.field && !info.suffix && !info.fn && info.field.type === 'Field' && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
               var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
-                idField = searchMeta.parseExpr(idFieldName).field;
+                idField = searchMeta.parseExpr(idFieldName).args[0].field;
               if (!ctrl.canAggregate(idFieldName)) {
                 var joinEntity = searchMeta.getEntity(idField.fk_entity),
                   label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
index 6ea6b50e8ffa174980fe4af2744e38e52378b93f..96e3947021ef48f17a56b6a8da50d59392618bb4 100644 (file)
         }
 
         var info = searchMeta.parseExpr(col.key),
+          field = info.args[0] && info.args[0].field,
           value = col.key.split(':')[0];
+        if (!field || info.fn) {
+          delete col.editable;
+          return;
+        }
         // If field is an implicit join, use the original fk field
-        if (info.field.name !== info.field.fieldName) {
+        if (field.name !== field.fieldName) {
           value = value.substr(0, value.lastIndexOf('.'));
           info = searchMeta.parseExpr(value);
+          field = info.args[0].field;
         }
         col.editable = {
-          entity: info.field.baseEntity,
-          options: !!info.field.options,
-          serialize: !!info.field.serialize,
-          fk_entity: info.field.fk_entity,
+          entity: field.baseEntity,
+          options: !!field.options,
+          serialize: !!field.serialize,
+          fk_entity: field.fk_entity,
           id: info.prefix + 'id',
-          name: info.field.name,
+          name: field.name,
           value: value
         };
       };
       this.isEditable = function(col) {
         var expr = ctrl.getExprFromSelect(col.key),
           info = searchMeta.parseExpr(expr);
-        return !col.image && !col.rewrite && !col.link && !info.fn && info.field && !info.field.readonly;
+        return !col.image && !col.rewrite && !col.link && !info.fn && info.args[0] && info.args[0].field && !info.args[0].field.readonly;
       };
 
       // Aggregate functions (COUNT, AVG, MAX) cannot display as links, except for GROUP_CONCAT
index c2b21354ebf1732a595b9263c442743c8271ad3c..25b3e0a0a265a4d70fb7f7b07e39beafc2ff962e 100644 (file)
 
       this.getField = function(expr) {
         if (!meta[expr]) {
-          meta[expr] = searchMeta.parseExpr(expr);
+          meta[expr] = searchMeta.parseExpr(expr).args[0];
         }
         return meta[expr].field;
       };
 
       this.getOptionKey = function(expr) {
         if (!meta[expr]) {
-          meta[expr] = searchMeta.parseExpr(expr);
+          meta[expr] = _.findWhere(searchMeta.parseExpr(expr).args, {type: 'field'});
         }
         return meta[expr].suffix ? meta[expr].suffix.slice(1) : 'id';
       };
index 025f1e60632fe767d264cca2668c7ea6de158a36..36c751fc2f65e0a800663948bb46133d5ead4e88 100644 (file)
@@ -18,8 +18,9 @@
       // Output user-facing name/label fields as a link, if possible
       function getViewLink(fieldExpr, links) {
         var info = searchMeta.parseExpr(fieldExpr),
-          entity = searchMeta.getEntity(info.field.entity);
-        if (entity && info.field.fieldName === entity.label_field) {
+          firstField = _.findWhere(info.args, {type: 'field'}),
+          entity = firstField && searchMeta.getEntity(firstField.field.entity);
+        if (entity && firstField.field.fieldName === entity.label_field) {
           var joinEntity = searchMeta.getJoinEntity(info);
           return _.find(links, {join: joinEntity, action: 'view'});
         }