* @param string $expression
* @param bool $parseAlias
* @param array $mustBe
- * @param array $cantBe
* @return SqlExpression
* @throws \API_Exception
*/
- public static function convert(string $expression, $parseAlias = FALSE, $mustBe = [], $cantBe = ['SqlWild']) {
+ public static function convert(string $expression, $parseAlias = FALSE, $mustBe = []) {
$as = $parseAlias ? strrpos($expression, ' AS ') : FALSE;
$expr = $as ? substr($expression, 0, $as) : $expression;
$alias = $as ? \CRM_Utils_String::munge(substr($expression, $as + 4), '_', 256) : NULL;
throw new \API_Exception('Unable to parse sql expression: ' . $expression);
}
$sqlExpression = new $className($expr, $alias);
- foreach ($cantBe as $cant) {
- if (is_a($sqlExpression, __NAMESPACE__ . '\\' . $cant)) {
- throw new \API_Exception('Illegal sql expression.');
- }
- }
if ($mustBe) {
foreach ($mustBe as $must) {
if (is_a($sqlExpression, __NAMESPACE__ . '\\' . $must)) {
'suffix' => [],
];
if ($param['max_expr'] && (!$param['name'] || $param['name'] === $prefix)) {
- $exprs = $this->captureExpressions($arg, $param['must_be'], $param['cant_be']);
+ $exprs = $this->captureExpressions($arg, $param['must_be']);
if (count($exprs) < $param['min_expr'] || count($exprs) > $param['max_expr']) {
throw new \API_Exception('Incorrect number of arguments for SQL function ' . static::getName());
}
*
* @param string $arg
* @param array $mustBe
- * @param array $cantBe
* @return array
* @throws \API_Exception
*/
- private function captureExpressions(&$arg, $mustBe, $cantBe) {
+ private function captureExpressions(&$arg, $mustBe) {
$captured = [];
$arg = ltrim($arg);
while ($arg) {
$item = $this->captureExpression($arg);
$arg = ltrim(substr($arg, strlen($item)));
- $expr = SqlExpression::convert($item, FALSE, $mustBe, $cantBe);
+ $expr = SqlExpression::convert($item, FALSE, $mustBe);
$this->fields = array_merge($this->fields, $expr->getFields());
$captured[] = $expr;
// Keep going if we have a comma indicating another expression follows
'flag_before' => [],
'flag_after' => [],
'optional' => FALSE,
- 'must_be' => [],
- 'cant_be' => ['SqlWild'],
+ 'must_be' => ['SqlField', 'SqlFunction', 'SqlString', 'SqlNumber', 'SqlNull'],
'api_default' => NULL,
];
}
'flag_before' => ['DISTINCT' => ts('Distinct')],
'max_expr' => 1,
'must_be' => ['SqlField', 'SqlWild'],
- 'cant_be' => [],
],
];
}
return [
[
'max_expr' => 99,
+ 'min_expr' => 2,
'optional' => FALSE,
],
];
return [
[
'max_expr' => 99,
+ 'min_expr' => 2,
'optional' => FALSE,
],
];
crm-ui-select="{placeholder: ts('Group By'), data: fieldsForGroupBy, dropdownCss: {width: '300px'}}"
on-crm-ui-select="$ctrl.addParam('groupBy', selection)" >
</div>
- <fieldset id="crm-search-build-functions">
- <legend ng-click="controls.showFunctions = !controls.showFunctions">
- <i class="crm-i fa-caret-{{ !controls.showFunctions ? 'right' : 'down' }}"></i>
- {{:: ts('Field Transformations') }}
- </legend>
- <div ng-if="!!controls.showFunctions">
- <fieldset ng-repeat="col in $ctrl.savedSearch.api_params.select" ng-if="!$ctrl.isPseudoField(col)">
- <crm-search-function expr="$ctrl.savedSearch.api_params.select[$index]"></crm-search-function>
- </fieldset>
- </div>
- </fieldset>
</fieldset>
</div>
<div class="crm-search-criteria-column">
</fieldset>
</div>
</div>
+<fieldset id="crm-search-build-functions">
+ <legend ng-click="controls.showFunctions = !controls.showFunctions">
+ <i class="crm-i fa-caret-{{ !controls.showFunctions ? 'right' : 'down' }}"></i>
+ {{:: ts('Field Transformations') }}
+ </legend>
+ <div ng-if="!!controls.showFunctions">
+ <!-- Must use track by $index with an array of primitives, and manually refresh this loop when indexes change -->
+ <fieldset ng-repeat="col in $ctrl.savedSearch.api_params.select track by $index" ng-if="!$ctrl.isPseudoField(col)">
+ <crm-search-function expr="$ctrl.savedSearch.api_params.select[$index]"></crm-search-function>
+ </fieldset>
+ </div>
+</fieldset>
// Deletes an item from an array param
this.clearParam = function(name, idx) {
+ if (name === 'select') {
+ // Function selectors use `ng-repeat` with `track by $index` so must be refreshed when splicing the array
+ ctrl.hideFuncitons();
+ }
ctrl.savedSearch.api_params[name].splice(idx, 1);
};
+ this.hideFuncitons = function() {
+ $scope.controls.showFunctions = false;
+ };
+
function onChangeSelect(newSelect, oldSelect) {
// When removing a column from SELECT, also remove from ORDER BY & HAVING
_.each(_.difference(oldSelect, newSelect), function(col) {
string: ts('Text')
};
- $scope.$watch('$ctrl.expr', function(expr) {
- var fieldInfo = searchMeta.parseExpr(expr);
- ctrl.path = fieldInfo.path + fieldInfo.suffix;
- ctrl.field = [fieldInfo.field];
- ctrl.fn = !fieldInfo.fn ? '' : fieldInfo.fn.name;
- ctrl.modifier = fieldInfo.modifier || null;
+ this.exprTypes = {
+ SqlField: {label: ts('Field'), type: 'field'},
+ SqlString: {label: ts('Text'), type: 'string'},
+ SqlNumber: {label: ts('Number'), type: 'number'},
+ };
+
+ this.$onInit = function() {
+ var info = searchMeta.parseExpr(ctrl.expr);
+ ctrl.args = info.args;
+ ctrl.fn = info.fn;
+ ctrl.fnName = !info.fn ? '' : info.fn.name;
initFunction();
- });
+ };
+
+ this.addArg = function(exprType) {
+ exprType = exprType || ctrl.fn.params[0].must_be[0];
+ ctrl.args.push({
+ type: ctrl.exprTypes[exprType].type,
+ value: exprType === 'SqlNumber' ? 0 : ''
+ });
+ };
function initFunction() {
- ctrl.fnInfo = _.find(CRM.crmSearchAdmin.functions, {name: ctrl.fn});
- if (ctrl.fnInfo && ctrl.fnInfo.params[0] && !_.isEmpty(ctrl.fnInfo.params[0].flag_before)) {
- ctrl.modifierName = _.keys(ctrl.fnInfo.params[0].flag_before)[0];
- ctrl.modifierLabel = ctrl.fnInfo.params[0].flag_before[ctrl.modifierName];
+ if (!ctrl.fn) {
+ return;
+ }
+ if (ctrl.fn && ctrl.fn.params[0] && !_.isEmpty(ctrl.fn.params[0].flag_before)) {
+ ctrl.modifierName = _.keys(ctrl.fn.params[0].flag_before)[0];
+ ctrl.modifierLabel = ctrl.fn.params[0].flag_before[ctrl.modifierName];
}
else {
ctrl.modifierName = null;
ctrl.modifier = null;
}
+ while (ctrl.args.length < ctrl.fn.params[0].min_expr) {
+ ctrl.addArg();
+ }
}
// On-demand options for dropdown function selector
this.getFunctions = function() {
var allowedTypes = [], functions = [];
- if (ctrl.expr && ctrl.field) {
+ if (ctrl.expr && ctrl.args[0] && ctrl.args[0].field) {
if (ctrl.crmSearchAdmin.canAggregate(ctrl.expr)) {
allowedTypes.push('aggregate');
} else {
allowedTypes.push('comparison', 'string');
- if (_.includes(['Integer', 'Float', 'Date', 'Timestamp'], ctrl.field[0].data_type)) {
+ if (_.includes(['Integer', 'Float', 'Date', 'Timestamp'], ctrl.args[0].field.data_type)) {
allowedTypes.push('math');
}
- if (_.includes(['Date', 'Timestamp'], ctrl.field[0].data_type)) {
+ if (_.includes(['Date', 'Timestamp'], ctrl.args[0].field.data_type)) {
allowedTypes.push('date');
}
}
var allowedFunctions = _.filter(CRM.crmSearchAdmin.functions, function(fn) {
return fn.category === type &&
fn.params.length &&
- // For now, only support functions that take a single field
- fn.params[0].min_expr === 1 &&
- fn.params[0].max_expr === 1 &&
- !_.includes(fn.params[0].cant_be, 'SqlField') &&
- (!fn.params[0].must_be.length || _.includes(fn.params[0].must_be, 'SqlField'));
+ fn.params[0].min_expr > 0 &&
+ _.includes(fn.params[0].must_be, 'SqlField');
});
functions.push({
text: allTypes[type],
return {results: functions};
};
+ this.getFields = function() {
+ return {
+ results: ctrl.crmSearchAdmin.getAllFields(':label', ['Field', 'Custom', 'Extra'])
+ };
+ };
+
this.selectFunction = function() {
+ ctrl.fn = _.find(CRM.crmSearchAdmin.functions, {name: ctrl.fnName});
+ ctrl.args.length = 1;
+ initFunction();
ctrl.writeExpr();
};
this.toggleModifier = function() {
- ctrl.modifier = ctrl.modifier ? null : ctrl. modifierName;
+ ctrl.modifier = ctrl.modifier ? null : ctrl.modifierName;
+ ctrl.writeExpr();
+ };
+
+ this.changeArg = function(index) {
+ var val = ctrl.args[index].value;
+ // Delete empty value
+ if (!val && ctrl.args.length > ctrl.fn.params[0].min_expr) {
+ ctrl.args.splice(index, 1);
+ }
ctrl.writeExpr();
};
// Make a sql-friendly alias for this expression
function makeAlias() {
- return (ctrl.fn + '_' + ctrl.path).replace(/[.:]/g, '_');
+ var args = _.pluck(_.filter(_.filter(ctrl.args, 'value'), {type: 'field'}), 'value');
+ return (ctrl.fnName + '_' + args.join('_')).replace(/[.:]/g, '_');
}
this.writeExpr = function() {
- ctrl.expr = ctrl.fn ? (ctrl.fn + '(' + (ctrl.modifier ? ctrl.modifier + ' ' : '') + ctrl.path + ') AS ' + makeAlias()) : ctrl.path;
+ if (ctrl.fnName) {
+ var args = _.transform(ctrl.args, function(args, arg) {
+ if (arg.value) {
+ args.push(arg.type === 'string' ? JSON.stringify(arg.value) : arg.value);
+ }
+ });
+ ctrl.expr = ctrl.fnName + '(' + (ctrl.modifier ? ctrl.modifier + ' ' : '') + args.join(', ') + ') AS ' + makeAlias();
+ } else {
+ ctrl.expr = ctrl.args[0].value;
+ }
};
}
});
<div class="form-inline">
- <label>{{ $ctrl.field[0].label }}:</label>
- <input class="form-control" style="width: 15em;" ng-model="$ctrl.fn" crm-ui-select="{data: $ctrl.getFunctions, placeholder: ts('Select')}" ng-change="$ctrl.selectFunction()">
+ <input class="form-control" style="width: 15em;" ng-model="$ctrl.fnName" crm-ui-select="{data: $ctrl.getFunctions, placeholder: ts('Function')}" ng-change="$ctrl.selectFunction()">
+ <label>{{ $ctrl.args[0].field.label }}</label>
<label ng-if="$ctrl.modifierName">
<input type="checkbox" ng-checked="!!$ctrl.modifier" ng-click="$ctrl.toggleModifier()">
{{ $ctrl.modifierLabel }}
</label>
+ <div class="form-group" ng-repeat="arg in $ctrl.args" ng-if="$index">
+ <span ng-switch="arg.type">
+ <input ng-switch-when="number" class="form-control" type="number" ng-model="arg.value" ng-change="$ctrl.changeArg($index)" ng-model-options="{updateOn: 'blur'}">
+ <input ng-switch-when="string" class="form-control" ng-model="arg.value" ng-change="$ctrl.changeArg($index)" ng-model-options="{updateOn: 'blur'}">
+ <input ng-switch-default class="form-control" ng-model="arg.value" crm-ui-select="{data: $ctrl.getFields, placeholder: ts('Field')}" ng-change="$ctrl.changeArg($index)">
+ </span>
+ </div>
+ <div class="btn-group" ng-if="$ctrl.args.length < $ctrl.fn.params[0].max_expr">
+ <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">
+ <a href ng-click="$ctrl.addArg(name)">{{ type.label }}</a>
+ </li>
+ </ul>
+ </div>
</div>
if (!ui.item.sortable.dropindex && ctrl.crmSearchAdmin.groupExists) {
ui.item.sortable.cancel();
}
+ // Function selectors use `ng-repeat` with `track by $index` so must be refreshed when rearranging the array
+ ctrl.crmSearchAdmin.hideFuncitons();
}
};