@openmrs/esm-expression-evaluator
Version:
Utilities for evaluating user-defined expressions
736 lines (668 loc) • 27.2 kB
text/typescript
/** @category Utility */
import jsep, { type Expression, type MemberExpression } from 'jsep';
import jsepArrow, { type ArrowExpression } from '@jsep-plugin/arrow';
import jsepNew, { type NewExpression } from '@jsep-plugin/new';
import jsepNumbers from '@jsep-plugin/numbers';
import jsepRegex from '@jsep-plugin/regex';
import jsepTernary from '@jsep-plugin/ternary';
import jsepTemplate, { type TemplateElement, type TemplateLiteral } from '@jsep-plugin/template';
import { globals, globalsAsync } from './globals';
jsep.plugins.register(jsepArrow);
jsep.plugins.register(jsepNew);
jsep.plugins.register(jsepNumbers);
jsep.plugins.register(jsepRegex);
jsep.plugins.register(jsepTernary);
jsep.plugins.register(jsepTemplate);
// see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_precedence
// for the order defined here
// 7 is jsep's internal for "relational operators"
jsep.addBinaryOp('in', 7);
jsep.addBinaryOp('??', 1);
export { jsep };
/** An object containing the variable to use when evaluating this expression */
export type VariablesMap = {
[key: string]: string | number | boolean | Function | RegExp | object | null | VariablesMap | Array<VariablesMap>;
};
/** The valid return types for `evaluate()` and `evaluateAsync()` */
export type DefaultEvaluateReturnType = string | number | boolean | Date | null | undefined;
/**
* `evaluate()` implements a relatively safe version of `eval()` that is limited to evaluating synchronous
* Javascript expressions. This allows us to safely add features that depend on user-supplied code without
* polluting the global namespace or needing to support `eval()` and the like.
*
* By default it supports any expression that evalutes to a string, number, boolean, Date, null, or undefined.
* Other values will result in an error.
*
* @example
* ```ts
* // shouldDisplayOptionalData will be false
* const shouldDisplayOptionalData = evaluate('!isEmpty(array)', {
* array: [],
* isEmpty(arr: unknown) {
* return Array.isArray(arr) && arr.length === 0;
* }
* })
* ```
*
* Since this only implements the expression lanaguage part of Javascript, there is no support for assigning
* values, creating functions, or creating objects, so the following will throw an error:
*
* @example
* ```ts
* evaluate('var a = 1; a');
* ```
*
* In addition to string expressions, `evaluate()` can use an existing `jsep.Expression`, such as that returned
* from the `compile()` function. The goal here is to support cases where the same expression will be evaluated
* multiple times, possibly with different variables, e.g.,
*
* @example
* ```ts
* const expr = compile('isEmpty(array)');
*
* // then we use it like
* evaluate(expr, {
* array: [],
* isEmpty(arr: unknown) {
* return Array.isArray(arr) && arr.length === 0;
* }
* ));
*
* evaluate(expr, {
* array: ['value'],
* isEmpty(arr: unknown) {
* return Array.isArray(arr) && arr.length === 0;
* }
* ));
* ```
*
* This saves the overhead of parsing the expression everytime and simply allows us to evaluate it.
*
* The `variables` parameter should be used to supply any variables or functions that should be in-scope for
* the evaluation. A very limited number of global objects, like NaN and Infinity are always available, but
* any non-global values will need to be passed as a variable. Note that expressions do not have any access to
* the variables in the scope in which they were defined unless they are supplied here.
*
* @param expression The expression to evaluate, either as a string or pre-parsed expression
* @param variables Optional object which contains any variables, functions, etc. that will be available to
* the expression.
* @returns The result of evaluating the expression
*/
export function evaluate(expression: string | jsep.Expression, variables: VariablesMap = {}) {
return evaluateAsType(expression, variables, defaultTypePredicate);
}
/**
* `evaluateAsync()` implements a relatively safe version of `eval()` that can evaluate Javascript expressions
* that use Promises. This allows us to safely add features that depend on user-supplied code without
* polluting the global namespace or needing to support `eval()` and the like.
*
* By default it supports any expression that evalutes to a string, number, boolean, Date, null, or undefined.
* Other values will result in an error.
*
* @example
* ```ts
* // shouldDisplayOptionalData will be false
* const shouldDisplayOptionalData = await evaluateAsync('Promise.resolve(!isEmpty(array))', {
* array: [],
* isEmpty(arr: unknown) {
* return Array.isArray(arr) && arr.length === 0;
* }
* })
* ```
*
* Since this only implements the expression lanaguage part of Javascript, there is no support for assigning
* values, creating functions, or creating objects, so the following will throw an error:
*
* @example
* ```ts
* evaluateAsync('var a = 1; a');
* ```
*
* In addition to string expressions, `evaluate()` can use an existing `jsep.Expression`, such as that returned
* from the `compile()` function. The goal here is to support cases where the same expression will be evaluated
* multiple times, possibly with different variables, e.g.,
*
* @example
* ```ts
* const expr = compile('Promise.resolve(isEmpty(array))');
*
* // then we use it like
* evaluateAsync(expr, {
* array: [],
* isEmpty(arr: unknown) {
* return Array.isArray(arr) && arr.length === 0;
* }
* ));
*
* evaluateAsync(expr, {
* array: ['value'],
* isEmpty(arr: unknown) {
* return Array.isArray(arr) && arr.length === 0;
* }
* ));
* ```
*
* This saves the overhead of parsing the expression everytime and simply allows us to evaluate it.
*
* The `variables` parameter should be used to supply any variables or functions that should be in-scope for
* the evaluation. A very limited number of global objects, like NaN and Infinity are always available, but
* any non-global values will need to be passed as a variable. Note that expressions do not have any access to
* the variables in the scope in which they were defined unless they are supplied here.
*
* **Note:** `evaluateAsync()` currently only supports Promise-based asynchronous flows and does not support
* the `await` keyword.
*
* @param expression The expression to evaluate, either as a string or pre-parsed expression
* @param variables Optional object which contains any variables, functions, etc. that will be available to
* the expression.
* @returns The result of evaluating the expression
*/
export async function evaluateAsync(expression: string | jsep.Expression, variables: VariablesMap = {}) {
return evaluateAsTypeAsync(expression, variables, defaultTypePredicate);
}
/**
* `evaluateAsBoolean()` is a variant of {@link evaluate()} which only supports boolean results. Useful
* if valid expression must return boolean values.
*
* @param expression The expression to evaluate, either as a string or pre-parsed expression
* @param variables Optional object which contains any variables, functions, etc. that will be available to
* the expression.
* @returns The result of evaluating the expression
*/
export function evaluateAsBoolean(expression: string | jsep.Expression, variables: VariablesMap = {}) {
return evaluateAsType(expression, variables, booleanTypePredicate);
}
/**
* `evaluateAsBooleanAsync()` is a variant of {@link evaluateAsync()} which only supports boolean results. Useful
* if valid expression must return boolean values.
*
* @param expression The expression to evaluate, either as a string or pre-parsed expression
* @param variables Optional object which contains any variables, functions, etc. that will be available to
* the expression.
* @returns The result of evaluating the expression
*/
export function evaluateAsBooleanAsync(expression: string | jsep.Expression, variables: VariablesMap = {}) {
return evaluateAsTypeAsync(expression, variables, booleanTypePredicate);
}
/**
* `evaluateAsNumber()` is a variant of {@link evaluate()} which only supports number results. Useful
* if valid expression must return numeric values.
*
* @param expression The expression to evaluate, either as a string or pre-parsed expression
* @param variables Optional object which contains any variables, functions, etc. that will be available to
* the expression.
* @returns The result of evaluating the expression
*/
export function evaluateAsNumber(expression: string | jsep.Expression, variables: VariablesMap = {}) {
return evaluateAsType(expression, variables, numberTypePredicate);
}
/**
* `evaluateAsNumberAsync()` is a variant of {@link evaluateAsync()} which only supports number results. Useful
* if valid expression must return numeric values.
*
* @param expression The expression to evaluate, either as a string or pre-parsed expression
* @param variables Optional object which contains any variables, functions, etc. that will be available to
* the expression.
* @returns The result of evaluating the expression
*/
export function evaluateAsNumberAsync(expression: string | jsep.Expression, variables: VariablesMap = {}) {
return evaluateAsTypeAsync(expression, variables, numberTypePredicate);
}
/**
* `evaluateAsType()` is a type-safe version of {@link evaluate()} which returns a result if the result
* passes a custom type predicate. The main use-case for this is to narrow the return types allowed based on
* context, e.g., if the expected result should be a number or boolean, you can supply a custom type-guard
* to ensure that only number or boolean results are returned.
*
* @param expression The expression to evaluate, either as a string or pre-parsed expression
* @param variables Optional object which contains any variables, functions, etc. that will be available to
* the expression.
* @param typePredicate A [type predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
* which asserts that the result value matches one of the expected results.
* @returns The result of evaluating the expression
*/
export function evaluateAsType<T>(
expression: string | jsep.Expression,
variables: VariablesMap = {},
typePredicate: (result: unknown) => result is T,
): T {
if (typeof expression !== 'string' && (typeof expression !== 'object' || !expression || !('type' in expression))) {
throw `Unknown expression type ${expression}. Expressions must either be a string or pre-compiled string.`;
}
if (typeof variables === 'undefined' || variables === null) {
variables = {};
}
const context = createSynchronousContext(variables);
const result = visitExpression(typeof expression === 'string' ? jsep(expression) : expression, context);
if (typePredicate(result)) {
return result;
} else {
throw {
type: 'Invalid result',
message:
typeof expression === 'string'
? `The expression ${expression} did not produce a valid result`
: 'The expression did not produce a valid result',
};
}
}
/**
* `evaluateAsTypeAsync()` is a type-safe version of {@link evaluateAsync()} which returns a result if the result
* passes a custom type predicate. The main use-case for this is to narrow the return types allowed based on
* context, e.g., if the expected result should be a number or boolean, you can supply a custom type-guard
* to ensure that only number or boolean results are returned.
*
* @param expression The expression to evaluate, either as a string or pre-parsed expression
* @param variables Optional object which contains any variables, functions, etc. that will be available to
* the expression.
* @param typePredicate A [type predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
* which asserts that the result value matches one of the expected results.
* @returns The result of evaluating the expression
*/
export async function evaluateAsTypeAsync<T>(
expression: string | jsep.Expression,
variables: VariablesMap = {},
typePredicate: (result: unknown) => result is T,
): Promise<T> {
if (typeof expression !== 'string' && (typeof expression !== 'object' || !expression || !('type' in expression))) {
return Promise.reject(
`Unknown expression type ${expression}. Expressions must either be a string or pre-compiled string.`,
);
}
if (typeof variables === 'undefined' || variables === null) {
variables = {};
}
const context = createAsynchronousContext(variables);
return Promise.resolve(visitExpression(typeof expression === 'string' ? jsep(expression) : expression, context)).then(
(result) => {
if (typePredicate(result)) {
return result;
} else {
throw {
type: 'Invalid result',
message:
typeof expression === 'string'
? `The expression ${expression} did not produce a valid result`
: 'The expression did not produce a valid result',
};
}
},
);
}
/**
* `compile()` is a companion function for use with {@link evaluate()} and the various `evaluateAs*()` functions.
* It processes an expression string into the resulting AST that is executed by those functions. This is useful if
* you have an expression that will need to be evaluated mulitple times, potentially with different values, as the
* lexing and parsing steps can be skipped by using the AST object returned from this.
*
* The returned AST is intended to be opaque to client applications, but, of course, it is possible to manipulate
* the AST before passing it back to {@link evaluate()}, if desired. This might be useful if, for example, certain
* values are known to be constant.
*
* @param expression The expression to be parsed
* @returns An executable AST representation of the expression
*/
export function compile(expression: string): jsep.Expression {
return jsep(expression);
}
// Pre-defined type guards
function defaultTypePredicate(result: unknown): result is DefaultEvaluateReturnType {
return (
typeof result === 'string' ||
typeof result === 'number' ||
typeof result === 'boolean' ||
typeof result === 'undefined' ||
result === null ||
result instanceof Date
);
}
function booleanTypePredicate(result: unknown): result is Boolean {
return typeof result === 'boolean';
}
function numberTypePredicate(result: unknown): result is number {
return typeof result === 'number';
}
// Implementation
// This is the core of the implementation; it takes an expression, the variables and the current object
// each expression is dispatched to an appropriate handler.
function visitExpression(expression: jsep.Expression, context: EvaluationContext) {
switch (expression.type) {
case 'UnaryExpression':
return visitUnaryExpression(expression as jsep.UnaryExpression, context);
case 'BinaryExpression':
return visitBinaryExpression(expression as jsep.BinaryExpression, context);
case 'ConditionalExpression':
return visitConditionalExpression(expression as jsep.ConditionalExpression, context);
case 'CallExpression':
return visitCallExpression(expression as jsep.CallExpression, context);
case 'ArrowFunctionExpression':
return visitArrowFunctionExpression(expression as ArrowExpression, context);
case 'MemberExpression':
return visitMemberExpression(expression as jsep.MemberExpression, context);
case 'ArrayExpression':
return visitArrayExpression(expression as jsep.ArrayExpression, context);
case 'SequenceExpression':
return visitSequenceExpression(expression as jsep.SequenceExpression, context);
case 'NewExpression':
return visitNewExpression(expression as NewExpression, context);
case 'Literal':
return visitLiteral(expression as jsep.Literal, context);
case 'Identifier':
return visitIdentifier(expression as jsep.Identifier, context);
case 'TemplateLiteral':
return visitTemplateLiteral(expression as TemplateLiteral, context);
case 'TemplateElement':
return visitTemplateElement(expression as TemplateElement, context);
default:
throw `Expression evaluator does not support expression of type '${expression.type}'`;
}
}
function visitExpressionName(expression: jsep.Expression, context: EvaluationContext) {
switch (expression.type) {
case 'Literal':
return (expression as jsep.Literal).value as string;
case 'Identifier':
return (expression as jsep.Identifier).name;
case 'MemberExpression':
return visitExpressionName((expression as jsep.MemberExpression).property, context);
default:
throw `VisitExpressionName does not support expression of type '${expression.type}'`;
}
}
function visitUnaryExpression(expression: jsep.UnaryExpression, context: EvaluationContext) {
const value = visitExpression(expression.argument, context);
switch (expression.operator) {
case '+':
return +value;
case '-':
return -value;
case '~':
return ~value;
case '!':
return !value;
default:
throw `Expression evaluator does not support operator '${expression.operator}''`;
}
}
function visitBinaryExpression(expression: jsep.BinaryExpression, context: EvaluationContext) {
let left = visitExpression(expression.left, context);
let right = visitExpression(expression.right, context);
switch (expression.operator) {
case '+':
return left + right;
case '-':
return left - right;
case '*':
return left * right;
case '/':
return left / right;
case '%':
return left % right;
case '**':
return left ** right;
case '==':
return left == right;
case '===':
return left === right;
case '!=':
return left != right;
case '!==':
return left !== right;
case '>':
return left > right;
case '>=':
return left >= right;
case '<':
return left < right;
case '<=':
return left <= right;
case 'in':
return left in right;
case '&&':
return left && right;
case '||':
return left || right;
case '??':
return left ?? right;
default:
throw `Expression evaluator does not support operator '${expression.operator}' operator`;
}
}
function visitConditionalExpression(expression: jsep.ConditionalExpression, context: EvaluationContext) {
const test = visitExpression(expression.test, context);
return test ? visitExpression(expression.consequent, context) : visitExpression(expression.alternate, context);
}
function visitCallExpression(expression: jsep.CallExpression, context: EvaluationContext) {
let args = expression.arguments?.map(handleNullableExpression(context));
let callee = visitExpression(expression.callee, context);
if (!callee) {
throw `No function named ${getCallTargetName(expression.callee)} is defined in this context`;
} else if (!(typeof callee === 'function')) {
throw `${getCallTargetName(expression.callee)} is not a function`;
}
return callee(...args);
}
function visitArrowFunctionExpression(expression: ArrowExpression, context: EvaluationContext) {
const params =
expression.params?.map((p) => {
switch (p.type) {
case 'Identifier':
return (p as jsep.Identifier).name;
default:
throw `Cannot handle parameter of type ${p.type}`;
}
}) ?? [];
return function (...rest: unknown[]) {
if (rest.length < params.length) {
throw `Required argument(s) ${params.slice(rest.length, -1).join(', ')} were not provided`;
}
const vars = Object.fromEntries(
params.reduce((acc: Array<[string, VariablesMap['a']]>, p, idx) => {
const val = rest[idx];
if (isValidVariableType(val)) {
acc.push([p, val]);
}
return acc;
}, []),
);
return visitExpression(expression.body, context.addVariables(vars));
}.bind(context.thisObj ?? null);
}
function visitMemberExpression(expression: jsep.MemberExpression, context: EvaluationContext) {
let obj = visitExpression(expression.object, context);
if (obj === undefined) {
switch (expression.object.type) {
case 'Identifier': {
let objectName = visitExpressionName(expression.object, context);
throw ReferenceError(`ReferenceError: ${objectName} is not defined`);
}
case 'MemberExpression': {
let propertyName = visitExpressionName(expression.property, context);
throw TypeError(`TypeError: cannot read properties of undefined (reading '${propertyName}')`);
}
default:
throw `VisitMemberExpression does not support operator '${expression.object.type}' type`;
}
}
let newObj = obj;
if (typeof obj === 'string') {
newObj = String.prototype;
} else if (typeof obj === 'number') {
newObj = Number.prototype;
} else if (typeof obj === 'function') {
// no-op
} else if (typeof obj !== 'object') {
throw `VisitMemberExpression does not support member access on type ${typeof obj}`;
}
context.thisObj = newObj;
let result: unknown;
switch (expression.property.type) {
case 'Identifier':
case 'MemberExpression':
result = visitExpression(expression.property, context);
break;
default: {
const property = visitExpression(expression.property, context);
if (typeof property === 'undefined') {
throw { type: 'Illegal property access', message: 'No property was supplied to the property access' };
}
validatePropertyName(property);
result = obj[property];
}
}
if (typeof result === 'function') {
return result.bind(obj);
}
return result;
}
function visitArrayExpression(expression: jsep.ArrayExpression, context: EvaluationContext) {
return expression.elements?.map(handleNullableExpression(context));
}
function visitSequenceExpression(expression: jsep.SequenceExpression, context: EvaluationContext) {
const result = expression.expressions.map(handleNullableExpression(context));
return result[result.length - 1];
}
function visitNewExpression(expression: NewExpression, context: EvaluationContext) {
if (expression.callee && expression.callee.type === 'Identifier') {
let args = expression.arguments?.map(handleNullableExpression(context)) as Array<any>;
switch (expression.callee.name) {
case 'Date': {
/** @ts-ignore because we can use the spread operator here */
return new Date(...args);
}
case 'RegExp':
/** @ts-ignore because we can use the spread operator here */
return new RegExp(...args);
default:
throw `Cannot instantiate object of type ${expression.callee.name}`;
}
} else {
if (!expression.callee) {
throw `Could not handle "new" without a specified class`;
} else {
throw 'new must be called with either Date or RegExp';
}
}
}
function visitTemplateLiteral(expression: TemplateLiteral, context: EvaluationContext) {
const expressions: Array<unknown> = expression.expressions?.map(handleNullableExpression(context)) ?? [];
const quasis: Array<{ tail: boolean; value: unknown }> =
expression.quasis?.map(handleNullableExpression(context)) ?? [];
return (
quasis
.filter((q) => !q.tail)
.map((q) => q.value)
.join('') +
expressions.join('') +
quasis
.filter((q) => q.tail)
.map((q) => q.value)
.join('')
);
}
function visitTemplateElement(expression: TemplateElement, context: EvaluationContext) {
return { value: expression.cooked, tail: expression.tail };
}
function visitIdentifier(expression: jsep.Identifier, context: EvaluationContext) {
validatePropertyName(expression.name);
// we support both `object` and `function` in the same way as technically property access on functions
// is possible; the use-case here is to support JS's "static" functions like `Number.isInteger()`, which
// is technically reading a property on a function
const thisObj = context.thisObj;
if (thisObj && (typeof thisObj === 'object' || typeof thisObj === 'function') && expression.name in thisObj) {
const result = thisObj[expression.name];
validatePropertyName(result);
return result;
} else if (context.variables && expression.name in context.variables) {
const result = context.variables[expression.name];
validatePropertyName(result);
return result;
} else if (expression.name in context.globals) {
return context.globals[expression.name];
} else {
return undefined;
}
}
function visitLiteral(expression: jsep.Literal, context: EvaluationContext) {
validatePropertyName(expression.value);
return expression.value;
}
// Internal helpers and utilities
interface EvaluationContext {
thisObj: object | undefined;
variables: VariablesMap;
globals: typeof globals | typeof globalsAsync;
addVariables(vars: VariablesMap): EvaluationContext;
}
function createSynchronousContext(variables: VariablesMap): EvaluationContext {
return createContextInternal(variables, globals);
}
function createAsynchronousContext(variables: VariablesMap): EvaluationContext {
return createContextInternal(variables, globalsAsync);
}
function createContextInternal(variables: VariablesMap, globals_: typeof globals | typeof globalsAsync) {
const context = {
thisObj: undefined,
variables: { ...variables },
globals: { ...globals_ },
addVariables(vars: VariablesMap) {
this.variables = { ...this.variables, ...vars };
return this;
},
};
context.addVariables.bind(context);
return context;
}
// helper useful for handling arrays of expressions, since `null` expressions should not be
// dispatched to `visitExpression()`
function handleNullableExpression(context: EvaluationContext) {
return function handleNullableExpressionInner(expression: jsep.Expression | null) {
if (expression === null) {
return null;
}
return visitExpression(expression, context);
};
}
function validatePropertyName(name: unknown) {
if (name === '__proto__' || name === 'prototype' || name === 'constructor') {
throw { type: 'Illegal property access', message: `Cannot access the ${name} property of objects` };
}
}
function isValidVariableType(val: unknown): val is VariablesMap['a'] {
if (
typeof val === 'string' ||
typeof val === 'number' ||
typeof val === 'boolean' ||
typeof val === 'function' ||
val === null ||
val instanceof RegExp
) {
return true;
}
if (typeof val === 'object') {
for (const key of Object.keys(val)) {
if (!isValidVariableType(val[key])) {
return false;
}
}
return true;
}
if (Array.isArray(val)) {
for (const item of val) {
if (!isValidVariableType(item)) {
return false;
}
}
}
return false;
}
function getCallTargetName(expression: Expression) {
if (!expression) {
return null;
}
if (expression.type === 'MemberExpression') {
return (expression.property as MemberExpression)?.name;
}
// identifier expression
return expression.name;
}