@adaptabletools/adaptable
Version:
Powerful data-agnostic HTML5 AG Grid extension which provides advanced, cutting-edge functionality to meet all DataGrid requirements
692 lines (691 loc) • 28 kB
JavaScript
import { ExpressionEvaluationError } from '../../parser/src/ExpressionEvaluationError';
import { parseISO } from 'date-fns';
import { startOfDay } from 'date-fns';
import { startOfWeek } from 'date-fns';
import { startOfMonth } from 'date-fns';
import { startOfYear } from 'date-fns';
import { addDays } from 'date-fns';
import { addWeeks } from 'date-fns';
import { addMonths } from 'date-fns';
import { addYears } from 'date-fns';
import { differenceInDays } from 'date-fns';
import { differenceInWeeks } from 'date-fns';
import { differenceInMonths } from 'date-fns';
import { differenceInYears } from 'date-fns';
import { evaluateExpressionNode, getNumericValue, isTextSearchCaseInsensitive, } from './expressionFunctionUtils';
import { normalizeDateParams } from './dateUtils';
import StringExtensions from '../Extensions/StringExtensions';
import { getTypedKeys } from '../Extensions/TypeExtensions';
import { evaluate } from '../../parser/src';
import { evaluateNode } from '../../parser/src/evaluator';
// useful for unary operators which expect a list of arguments
// we extract the provided array elements into a list
const flattenArguments = (values) => {
if (!Array.isArray(values)) {
return values;
}
return values.flat(Infinity);
};
const sanitizeArguments = (values, allowNaN) => {
return values.filter((value) => value != undefined && value != null && value !== '' && (allowNaN || !isNaN(value)));
};
const sanitizeNumericResult = (value) => {
if (isNaN(value)) {
return '';
}
return value;
};
export const scalarExpressionFunctions = {
VAR: {
handler(args, context) {
const [functionName, ...optionalArgs] = args;
if (StringExtensions.IsNullOrEmpty(functionName)) {
throw new ExpressionEvaluationError('VAR', 'should have a name');
}
return context.evaluateCustomQueryVariable(functionName, optionalArgs);
},
description: 'Returns the variable evaluation',
signatures: ['VAR(varName)', 'VAR(varName, arg1, arg2)'],
examples: ['VAR(CURRENT_USER)', 'VAR(IS_VALID_VALUE, IS_BLANK([col1]), [col2] < [col3])'],
category: 'special',
returnType: 'any',
},
COL: {
handler(args, context) {
const columnId = args[0];
const column = context.adaptableApi?.columnApi.getColumnWithColumnId(columnId);
if (!column) {
throw new ExpressionEvaluationError('COL', `Column name "${columnId}" is not found`);
}
if (!column.queryable) {
throw new ExpressionEvaluationError('COL', `Column name "${columnId}" is not queryable`);
}
return context.adaptableApi?.gridApi.getNormalisedValueFromRowNode(context.node, columnId);
},
description: 'Returns the value of a Column',
signatures: ['[colName]', 'COL(name: string)'],
examples: ['[col1]', 'COL("col1")'],
category: 'special',
returnType: 'any',
},
FIELD: {
handler(args, context) {
const fieldName = args[0];
if (StringExtensions.IsNullOrEmpty(fieldName)) {
throw new ExpressionEvaluationError('FIELD', 'requires a field name');
}
return context.adaptableApi?.internalApi.getValueUsingField(context.node?.data, fieldName);
},
description: 'Returns the value of a row field (not necessarily mapped to a column). If the field is nested, use dot notation (e.g. "nested.fieldName")',
signatures: ['FIELD(fieldName:string)'],
examples: ['FIELD("fieldName")', 'FIELD("nested.fieldName")'],
category: 'special',
returnType: 'any',
},
QUERY: {
handler(args, context) {
const namedQueryName = args[0];
if (StringExtensions.IsNullOrEmpty(namedQueryName)) {
return false;
}
const namedQuery = context.adaptableApi?.namedQueryApi.getNamedQueryByName(namedQueryName);
if (!namedQuery) {
throw new ExpressionEvaluationError('QUERY', `Named Query with name ${namedQueryName} not found!`);
}
// add query to call stack
if (!context.namedQueryCallStack) {
context.namedQueryCallStack = [];
}
context.namedQueryCallStack.push(namedQueryName);
//check if this Named Query is not already evaluated, in which case this would lead to an infinite evaluation cycle
const firstIndex = context.namedQueryCallStack.indexOf(namedQueryName);
const lastIndex = context.namedQueryCallStack.lastIndexOf(namedQueryName);
if (firstIndex !== lastIndex) {
const cycle = context.namedQueryCallStack.slice(firstIndex, lastIndex + 1);
throw new ExpressionEvaluationError(`${namedQueryName}`, ` contains a circular reference: ${cycle.join(' -> ')}`);
}
const queryEvaluationResult = evaluate(namedQuery.BooleanExpression, context);
// remove query name from callstack
context.namedQueryCallStack?.pop();
return queryEvaluationResult;
},
category: 'special',
description: 'Returns the evaluation result of the Named Query with the given name',
signatures: ['QUERY("anyNamedQuery")'],
examples: ['QUERY("anyNamedQuery")'],
returnType: 'boolean',
},
TO_ARRAY: {
handler(args) {
if (!args?.length) {
return [];
}
return [...args];
},
description: 'Creates an array containing all given parameters (which can be scalar values or expressions)',
signatures: ['TO_ARRAY(value1, expression1, value2)'],
examples: [`TO_ARRAY([col1], AVG([col2],[col3]), QUERY("maxValue"))`],
category: 'special',
returnType: 'any',
},
COALESCE: {
handler(args) {
return flattenArguments(args).find((arg) => !(arg === null || arg === undefined));
},
description: 'Returns the first argument which is not null',
signatures: ['COALESCE(value, value, ...value)'],
examples: ['COALESCE([col1], [col2], [col3], 0)'],
category: 'special',
returnType: 'string',
},
NULL: {
handler: () => {
return null;
},
description: 'Returns the NULL literal which represents the intentional absence of any object value',
category: 'special',
returnType: 'null',
signatures: ['NULL'],
examples: ['[status] = "accepted" ? 100 : NULL'],
},
IS_BLANK: {
handler(args) {
return args[0] === undefined || args[0] === null || args[0] === '';
},
category: 'special',
description: 'Returns true if input value is undefined, null, or an empty string',
signatures: ['IS_BLANK(input: any)'],
examples: ['IS_BLANK([col1])'],
returnType: 'boolean',
},
IS_NOT_BLANK: {
handler(args) {
return args[0] !== undefined && args[0] !== null && String(args[0]).trim() !== '';
},
category: 'special',
description: 'Returns true if input value is not empty',
signatures: ['IS_NOT_BLANK(input: any)'],
examples: ['IS_NOT_BLANK([col1])'],
returnType: 'boolean',
},
ABS: {
handler(args) {
return sanitizeNumericResult(Math.abs(args[0]));
},
isHiddenFromMenu: true,
description: 'Returns the absolute value of the given number',
signatures: ['ABS(a: number)'],
examples: ['ABS([columnName])'],
category: 'maths',
returnType: 'number',
},
CEILING: {
handler(args) {
return sanitizeNumericResult(Math.ceil(args[0]));
},
isHiddenFromMenu: true,
description: 'Returns smallest integer greater than or equal to the given number',
signatures: ['CEILING(a: number)'],
examples: ['CEILING([columnName])'],
category: 'maths',
returnType: 'number',
},
FLOOR: {
handler(args) {
return sanitizeNumericResult(Math.floor(args[0]));
},
isHiddenFromMenu: true,
description: 'Returns largest integer less than or equal to the given number',
signatures: ['FLOOR(a: number)'],
examples: ['FLOOR([columnName])'],
category: 'maths',
returnType: 'number',
},
ROUND: {
handler(args) {
return sanitizeNumericResult(Math.round(args[0]));
},
isHiddenFromMenu: true,
description: 'Returns value of the given number rounded to the nearest integer',
signatures: ['ROUND(a: number)'],
examples: ['ROUND([columnName])'],
category: 'maths',
returnType: 'number',
},
MIN: {
handler(args) {
return Math.min(...sanitizeArguments(flattenArguments(args)));
},
description: 'Returns the smallest of given numbers',
signatures: ['MIN(number, number, ...number)'],
examples: ['MIN([col1], 5)'],
category: 'maths',
returnType: 'number',
},
MAX: {
handler(args) {
return Math.max(...sanitizeArguments(flattenArguments(args)));
},
description: 'Returns the highest of given numbers',
signatures: ['MAX(number, number, ...number)'],
examples: ['MAX([col1], 5)'],
category: 'maths',
returnType: 'number',
},
AVG: {
handler(args) {
const sanitizedArguments = sanitizeArguments(flattenArguments(args));
if (args.length && !sanitizedArguments.length) {
// expression is syntactically valid, but operates with incompatible values
return;
}
return sanitizedArguments.reduce((a, b) => a + b) / sanitizedArguments.length;
},
description: 'Returns the average of inputted numbers',
signatures: ['AVG(number, number, ...number)'],
examples: ['AVG([col1], 5)'],
category: 'maths',
returnType: 'number',
},
ADD: {
handler(args) {
const sanitizedArguments = sanitizeArguments(args, true);
if (args.length && !sanitizedArguments.length) {
// expression is syntactically valid, but operates with incompatible values
return;
}
return sanitizedArguments.reduce((a, b) => a + b);
},
isHiddenFromMenu: true,
description: 'Returns the sum of 2 numbers',
signatures: ['number + number', 'ADD(a: number, b: number)'],
examples: ['[col1] + 5', 'ADD([col1], 5)'],
category: 'maths',
returnType: 'number',
},
SUB: {
handler(args) {
const sanitizedArguments = sanitizeArguments(args);
if (args.length && !sanitizedArguments.length) {
// expression is syntactically valid, but operates with incompatible values
return;
}
return sanitizedArguments.reduce((a, b) => a - b);
},
isHiddenFromMenu: true,
description: 'Returns the difference of 2 numbers',
signatures: ['number - number', 'SUB(a: number, b: number)'],
examples: ['[col1] - 5', 'SUB([col1], 5)'],
category: 'maths',
returnType: 'number',
},
MUL: {
handler(args) {
return sanitizeNumericResult(args.reduce((a, b) => a * b));
},
isHiddenFromMenu: true,
description: 'Returns the product of 2 numbers',
signatures: ['number * number', 'mul(a: number, b: number)'],
examples: ['[col1] * 5', 'mul([col1], 5)'],
category: 'maths',
returnType: 'number',
},
DIV: {
handler(args) {
return sanitizeNumericResult(args.reduce((a, b) => a / b));
},
isHiddenFromMenu: true,
description: 'Returns the division of 2 numbers',
signatures: ['number / number', 'DIV(a: number, b: number)'],
examples: ['[col1] / 5', 'DIV([col1], 5)'],
category: 'maths',
returnType: 'number',
},
MOD: {
handler(args) {
return sanitizeNumericResult(args[0] % args[1]);
},
isHiddenFromMenu: true,
description: 'Returns the modulo of 2 numbers',
signatures: ['number % number', 'MOD(a: number, b: number)'],
examples: ['[col1] % 5', 'MOD([col1], 5)'],
category: 'maths',
returnType: 'number',
},
IF: {
handler(args) {
return args[0] ? args[1] : args[2];
},
isHiddenFromMenu: true,
description: 'Evaluates a condition expression and returns the result of one of the two expressions, depending on whether the condition expression evaluates to true or false',
signatures: ['condition_expr ? true_statement : false_statement'],
examples: ['[col1] > [col2] ? "BIG" : "SMALL"'],
category: 'conditional',
returnType: 'any',
},
CASE: {
handler([expressionValueNode, whenThenListNodes, defaultValueNode], context) {
const caseExpressionValue = expressionValueNode != undefined
? // CASE matches the first WHEN statement with the provided expression value
evaluateExpressionNode(expressionValueNode, context)
: // otherwise it returns the first WHEN statement evaluated to TRUE
true;
const matchingWhen = whenThenListNodes.find((whenThenStatement) => {
const whenValue = evaluateExpressionNode(whenThenStatement.WHEN, context);
return whenValue === caseExpressionValue;
});
if (!matchingWhen) {
if (defaultValueNode) {
return evaluateExpressionNode(defaultValueNode, context);
}
return;
}
const matchingThen = evaluateExpressionNode(matchingWhen.THEN, context);
return matchingThen;
},
isHiddenFromMenu: true,
hasEagerEvaluation: true,
description: `The syntax of CASE supports 2 variants:
1. CASE takes an expression and compares its value with each WHEN clause until one of them is equal, whereupon the corresponding THEN clause is executed.
2. Each expression in the WHEN clause is evaluated until one of them is true, whereupon the corresponding THEN clause is executed.
If no WHEN clause is satisfied, the ELSE clause is executed if one exists, otherwise NULL is returned`,
signatures: [
`CASE <expression> [WHEN <expression> THEN <expression>] [ELSE <expression>] END`,
`CASE [WHEN <expression> THEN <expression>] [ELSE <expression>] END`,
],
examples: [
`CASE [day] WHEN 'Saturday' THEN 'weekend' WHEN 'Sunday' THEN 'weekend' ELSE 'workday' END`,
`CASE WHEN [price] < 10 THEN 'low price' WHEN [price] < 50 THEN 'medium price' ELSE 'high price' END`,
],
category: 'conditional',
returnType: 'any',
},
POW: {
handler(args) {
return sanitizeNumericResult(Math.pow(args[0], args[1]));
},
isHiddenFromMenu: true,
description: 'Returns the pow of 2 numbers',
signatures: ['number ^ number', 'POW(a: number, b: number)'],
examples: ['[col1] ^ 5', 'POW([col1], 5)'],
category: 'maths',
returnType: 'number',
},
DATE: {
handler(args) {
return parseISO(args[0]);
},
description: 'Returns a new date by parsing the given string in ISO 8601 format',
signatures: [
'DATE(input: string)',
'DATE("YYYYMMDD")',
'DATE("YYYY-MM-DD")',
'DATE("YYYY-MM-DD HH:SS")',
],
examples: ['DATE("20210101")', 'DATE("2021-01-01")', 'DATE("2021-01-01 10:10")'],
category: 'dates',
returnType: 'date',
},
NOW: {
handler() {
return new Date();
},
description: 'Returns the current date',
signatures: ['NOW()'],
examples: ['[col1] > NOW()'],
category: 'dates',
returnType: 'date',
},
CURRENT_DAY: {
handler() {
return startOfDay(new Date());
},
description: 'Returns the current day',
signatures: ['CURRENT_DAY()'],
examples: ['[col1] > CURRENT_DAY()'],
category: 'dates',
returnType: 'date',
},
DAY: {
handler(args) {
return startOfDay(args[0]);
},
description: 'Returns the day from a date',
signatures: ['DAY(input: date)'],
examples: ['DAY([col1]) = DAY(NOW())'],
category: 'dates',
returnType: 'date',
},
WEEK: {
handler(args) {
return startOfWeek(args[0]);
},
description: 'Returns the week from a date',
signatures: ['WEEK(input: date)'],
examples: ['WEEK([col1]) = WEEK(NOW())'],
category: 'dates',
returnType: 'number',
},
MONTH: {
handler(args) {
return startOfMonth(args[0]);
},
description: 'Returns the month from a date',
signatures: ['MONTH(input: date)'],
examples: ['MONTH([col1]) = MONTH(NOW())'],
category: 'dates',
returnType: 'number',
},
YEAR: {
handler(args) {
return startOfYear(args[0]);
},
description: 'Returns the year from a date',
signatures: ['YEAR(input: date)'],
examples: ['YEAR([col1]) = YEAR(NOW())'],
category: 'dates',
returnType: 'date',
},
ADD_DAYS: {
handler(args) {
return addDays(args[0], args[1]);
},
description: 'Returns a date based on input data and days to add',
signatures: ['ADD_DAYS(input: date, days: number)'],
examples: ['ADD_DAYS(CURRENT_DAY(), 5)', 'ADD_DAYS([col1], 5)'],
category: 'dates',
returnType: 'date',
},
ADD_WEEKS: {
handler(args) {
return addWeeks(args[0], args[1]);
},
description: 'Returns a date based on input data and weeks to add',
signatures: ['ADD_WEEKS(input: date, weeks: number)'],
examples: ['ADD_WEEKS(CURRENT_DAY(), 5)', 'ADD_WEEKS([col1], 5)'],
category: 'dates',
returnType: 'date',
},
ADD_MONTHS: {
handler(args) {
return addMonths(args[0], args[1]);
},
description: 'Returns a date based on input data and months to add',
signatures: ['ADD_MONTHS(input: date, months: number)'],
examples: ['ADD_MONTHS(CURRENT_DAY(), 5)', 'ADD_MONTHS([col1], 5)'],
category: 'dates',
returnType: 'date',
},
ADD_YEARS: {
handler(args) {
return addYears(args[0], args[1]);
},
description: 'Returns a date based on input data and years to add',
signatures: ['ADD_YEARS(input: date, years: number)'],
examples: ['ADD_YEARS(CURRENT_DAY(), 5)', 'ADD_YEARS([col1], 5)'],
category: 'dates',
returnType: 'date',
},
DIFF_DAYS: {
handler(args) {
const [first, second] = normalizeDateParams(args);
const result = differenceInDays(first, second);
return sanitizeNumericResult(result);
},
description: 'Returns the difference in days between 2 dates',
signatures: ['DIFF_DAYS(a: date, b: date)'],
examples: ['DIFF_DAYS([col1], CURRENT_DAY())'],
category: 'dates',
returnType: 'number',
},
DIFF_WEEKS: {
handler(args) {
const [first, second] = normalizeDateParams(args);
const result = differenceInWeeks(first, second);
return sanitizeNumericResult(result);
},
description: 'Returns the difference in weeks between 2 dates',
signatures: ['DIFF_WEEKS(a: date, b: date)'],
examples: ['DIFF_WEEKS([col1], CURRENT_DAY())'],
category: 'dates',
returnType: 'number',
},
DIFF_MONTHS: {
handler(args) {
const [first, second] = normalizeDateParams(args);
const result = differenceInMonths(first, second);
return sanitizeNumericResult(result);
},
description: 'Returns the difference in months between 2 dates',
signatures: ['DIFF_MONTHS(a: date, b: date)'],
examples: ['DIFF_MONTHS([col1], CURRENT_DAY())'],
category: 'dates',
returnType: 'number',
},
DIFF_YEARS: {
handler(args) {
const [first, second] = normalizeDateParams(args);
const result = differenceInYears(first, second);
return sanitizeNumericResult(result);
},
description: 'Returns the difference in years between 2 dates',
signatures: ['DIFF_YEARS(a: date, b: date)'],
examples: ['DIFF_YEARS([col1], CURRENT_DAY())'],
category: 'dates',
returnType: 'number',
},
LEN: {
handler(args) {
return args[0] == null || args[0] == undefined ? 0 : String(args[0]).length;
},
description: 'Returns the length of a string',
signatures: ['LEN(a: string)'],
examples: ['LEN([col1])'],
category: 'strings',
returnType: 'number',
},
SUB_STRING: {
handler(args) {
return String(args[0]).substring(args[1], args[2]);
},
description: 'Extracts characters from string, between 2 indices, returning new sub string',
signatures: ['SUB_STRING(a: string, b: number, c:number)'],
examples: ['SUB_STRING([col1], 1, 5)'],
category: 'strings',
returnType: 'string',
},
UPPER: {
handler(args) {
return String(args[0]).toUpperCase();
},
description: 'Converts a string to Upper Case',
signatures: ['UPPER(a: string)'],
examples: ['UPPER([col1])'],
category: 'strings',
returnType: 'string',
},
LOWER: {
handler(args) {
return String(args[0]).toLowerCase();
},
description: 'Converts a string to Lower Case',
signatures: ['LOWER(a: string)'],
examples: ['LOWER([col1])'],
category: 'strings',
returnType: 'string',
},
REPLACE: {
handler(args, context) {
if (isTextSearchCaseInsensitive(context)) {
return String(args[0]).replace(new RegExp(args[1], 'ig'), args[2]);
}
else {
return String(args[0]).replace(args[1], args[2]);
}
},
description: 'Searches string for specified value returning new string with replaced values',
signatures: ['REPLACE(a: string, b: string, c:string)'],
examples: ['REPLACE([col1], "GBP", "EUR")'],
category: 'strings',
returnType: 'string',
},
CONCAT: {
handler(args) {
return args.join(' ');
},
description: 'Concatenates multiple strings',
signatures: ['CONCAT(value1, value2, value3)'],
examples: ['CONCAT([col1], [col2], [col3]'],
category: 'strings',
returnType: 'string',
},
PERCENT_CHANGE: {
handler(args, context) {
if (!context.dataChangedEvent) {
throw new ExpressionEvaluationError('PERCENT_CHANGE', 'is only valid in changed cell contexts');
}
const currentValue = getNumericValue(args[0], true);
if (Number.isNaN(currentValue)) {
throw new ExpressionEvaluationError('PERCENT_CHANGE', 'First argument should be numeric');
}
const previousValue = context.dataChangedEvent.oldValue;
const increaseDecrease = args[1];
let result;
if (!increaseDecrease) {
result = sanitizeNumericResult(Math.abs(((currentValue - previousValue) / previousValue) * 100));
}
else if (increaseDecrease === 'INCREASE') {
result =
currentValue - previousValue > 0
? sanitizeNumericResult(((currentValue - previousValue) / previousValue) * 100)
: undefined;
}
else if (increaseDecrease === 'DECREASE') {
result =
currentValue - previousValue < 0
? sanitizeNumericResult(((previousValue - currentValue) / previousValue) * 100)
: undefined;
}
else {
throw new ExpressionEvaluationError('PERCENT_CHANGE', 'Optional second argument must be "INCREASE" or "DECREASE"');
}
return result;
},
description: "Returns the percentage difference between a cell's current value and its previous value.",
signatures: [
'PERCENT_CHANGE( [colName], <INCREASE|DECREASE> )',
'PERCENT_CHANGE( COL(name: string), <INCREASE|DECREASE>)',
],
examples: [
'PERCENT_CHANGE([col1])',
'PERCENT_CHANGE([col1], "INCREASE")',
'PERCENT_CHANGE([col1], "DECREASE")',
],
category: 'changes',
returnType: 'number',
},
ABSOLUTE_CHANGE: {
handler(args, context) {
if (!context.dataChangedEvent) {
throw new ExpressionEvaluationError('ABSOLUTE_CHANGE', 'is only valid in changed cell contexts');
}
const currentValue = getNumericValue(args[0], true);
if (Number.isNaN(currentValue)) {
throw new ExpressionEvaluationError('ABSOLUTE_CHANGE', 'First argument should be numeric');
}
const previousValue = context.dataChangedEvent.oldValue;
return currentValue - previousValue;
},
description: "Returns the absolute difference between a cell's current value and its previous value.",
signatures: ['ABSOLUTE_CHANGE( [colName] )', 'ABSOLUTE_CHANGE( COL(name: string) )'],
examples: ['ABSOLUTE_CHANGE([col1])'],
category: 'changes',
returnType: 'number',
},
ANY_CHANGE: {
handler(args, context) {
if (!context.dataChangedEvent) {
throw new ExpressionEvaluationError('ANY_CHANGE', 'is only valid in changed cell contexts');
}
const columnArg = args[0];
if (!columnArg) {
// if no column is provided, we check if any value has changed
return context.dataChangedEvent.oldValue !== context.dataChangedEvent.newValue;
}
// only COL argument is supported
if (columnArg.type !== 'COL') {
throw new ExpressionEvaluationError('ANY_CHANGE', 'accepts only a column reference as an argument');
}
const currentColumnValue = evaluateNode(columnArg, context);
const previousValue = context.dataChangedEvent.oldValue;
return currentColumnValue !== previousValue;
},
description: "Returns true if a cell's current value is different from its previous value, otherwise false. If no column is provided, it checks if any value has changed.",
signatures: ['ANY_CHANGE( [colName] )', 'ANY_CHANGE()'],
examples: ['ANY_CHANGE([col1])', 'ANY_CHANGE()'],
category: 'changes',
returnType: 'boolean',
hasEagerEvaluation: true,
},
};
export const scalarExpressionFunctionNames = getTypedKeys(scalarExpressionFunctions);