@openmrs/esm-expression-evaluator
Version:
Utilities for evaluating user-defined expressions
633 lines (632 loc) • 26.2 kB
JavaScript
/** @category Utility */ import jsep from "jsep";
import jsepArrow from "@jsep-plugin/arrow";
import jsepNew from "@jsep-plugin/new";
import jsepNumbers from "@jsep-plugin/numbers";
import jsepRegex from "@jsep-plugin/regex";
import jsepTernary from "@jsep-plugin/ternary";
import jsepTemplate from "@jsep-plugin/template";
import { globals, globalsAsync } from "./globals.js";
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 };
/**
* `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, variables = {}) {
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, variables = {}) {
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, variables = {}) {
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, variables = {}) {
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, variables = {}) {
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, variables = {}) {
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(expression, variables = {}, typePredicate) {
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 expression === 'string' && expression.trim().length === 0) {
throw {
type: 'Empty expression',
message: 'Expression cannot be an empty 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(expression, variables = {}, typePredicate) {
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 expression === 'string' && expression.trim().length === 0) {
throw {
type: 'Empty expression',
message: 'Expression cannot be an empty 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) {
return jsep(expression);
}
// Pre-defined type guards
function defaultTypePredicate(result) {
return typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean' || typeof result === 'undefined' || result === null || result instanceof Date;
}
function booleanTypePredicate(result) {
return typeof result === 'boolean';
}
function numberTypePredicate(result) {
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, context) {
switch(expression.type){
case 'UnaryExpression':
return visitUnaryExpression(expression, context);
case 'BinaryExpression':
return visitBinaryExpression(expression, context);
case 'ConditionalExpression':
return visitConditionalExpression(expression, context);
case 'CallExpression':
return visitCallExpression(expression, context);
case 'ArrowFunctionExpression':
return visitArrowFunctionExpression(expression, context);
case 'MemberExpression':
return visitMemberExpression(expression, context);
case 'ArrayExpression':
return visitArrayExpression(expression, context);
case 'SequenceExpression':
return visitSequenceExpression(expression, context);
case 'NewExpression':
return visitNewExpression(expression, context);
case 'Literal':
return visitLiteral(expression, context);
case 'Identifier':
return visitIdentifier(expression, context);
case 'TemplateLiteral':
return visitTemplateLiteral(expression, context);
case 'TemplateElement':
return visitTemplateElement(expression, context);
default:
throw `Expression evaluator does not support expression of type '${expression.type}'`;
}
}
function visitExpressionName(expression, context) {
switch(expression.type){
case 'Literal':
return expression.value;
case 'Identifier':
return expression.name;
case 'MemberExpression':
return visitExpressionName(expression.property, context);
default:
throw `VisitExpressionName does not support expression of type '${expression.type}'`;
}
}
function visitUnaryExpression(expression, context) {
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, context) {
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, context) {
const test = visitExpression(expression.test, context);
return test ? visitExpression(expression.consequent, context) : visitExpression(expression.alternate, context);
}
function visitCallExpression(expression, context) {
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, context) {
const params = expression.params?.map((p)=>{
switch(p.type){
case 'Identifier':
return p.name;
default:
throw `Cannot handle parameter of type ${p.type}`;
}
}) ?? [];
return (function(...rest) {
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, 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, context) {
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;
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, context) {
return expression.elements?.map(handleNullableExpression(context));
}
function visitSequenceExpression(expression, context) {
const result = expression.expressions.map(handleNullableExpression(context));
return result[result.length - 1];
}
function visitNewExpression(expression, context) {
if (expression.callee && expression.callee.type === 'Identifier') {
let args = expression.arguments?.map(handleNullableExpression(context));
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, context) {
const expressions = expression.expressions?.map(handleNullableExpression(context)) ?? [];
const quasis = 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, context) {
return {
value: expression.cooked,
tail: expression.tail
};
}
function visitIdentifier(expression, context) {
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, context) {
validatePropertyName(expression.value);
return expression.value;
}
function createSynchronousContext(variables) {
return createContextInternal(variables, globals);
}
function createAsynchronousContext(variables) {
return createContextInternal(variables, globalsAsync);
}
function createContextInternal(variables, globals_) {
const context = {
thisObj: undefined,
variables: {
...variables
},
globals: {
...globals_
},
addVariables (vars) {
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) {
return function handleNullableExpressionInner(expression) {
if (expression === null) {
return null;
}
return visitExpression(expression, context);
};
}
function validatePropertyName(name) {
if (name === '__proto__' || name === 'prototype' || name === 'constructor') {
throw {
type: 'Illegal property access',
message: `Cannot access the ${name} property of objects`
};
}
}
function isValidVariableType(val) {
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) {
if (!expression) {
return null;
}
if (expression.type === 'MemberExpression') {
return expression.property?.name;
}
// identifier expression
return expression.name;
}