Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,48 @@ parser.evaluate('$a + $b', {}); // 15
- `{ value: any }` - Return a value directly
- `undefined` - Use default behavior (throws error for unknown variables)

### Per-Expression Variable Resolver

`parser.resolve` is shared by every expression a parser produces. When you need different resolution logic for different evaluations — e.g. per-row, per-request, or per-tenant lookups — pass a resolver directly to `Expression.evaluate()` (or `parser.evaluate()`) instead of mutating `parser.resolve`. This lets a single parsed expression be reused across many calls without the cost of re-parsing or the hazards of shared mutable state.

```js
const parser = new Parser();
const expr = parser.parse('$user.name + " is " + $user.age');

// Same compiled expression, two different data sources, no parser mutation.
expr.evaluate({}, (name) =>
name === '$user' ? { value: { name: 'Alice', age: 30 } } : undefined
); // 'Alice is 30'

expr.evaluate({}, (name) =>
name === '$user' ? { value: { name: 'Bob', age: 25 } } : undefined
); // 'Bob is 25'
```

The per-call resolver uses the same return shape as `parser.resolve` — `{ alias }`, `{ value }`, or `undefined` — and the resolution order during evaluation is:

1. The `variables` object passed to `evaluate()`
2. The per-call `resolver` (if provided)
3. `parser.resolve` (the parser-level callback)
4. Otherwise, a `VariableError` is thrown

Because the per-call resolver falls through to `parser.resolve` on `undefined`, the two can be layered: a parser-level resolver can provide defaults or shared lookups, while per-call resolvers handle request-specific data.

```js
const parser = new Parser();

// Parser-level: shared constants that never change
parser.resolve = (name) =>
name === '$pi' ? { value: Math.PI } : undefined;

// Per-call: request-specific data
parser.parse('$pi * $radius ^ 2').evaluate({}, (name) =>
name === '$radius' ? { value: 5 } : undefined
); // 78.539...
```

Both `Parser.evaluate(expr, variables, resolver)` and `Expression.evaluate(variables, resolver)` accept the resolver, and it propagates through nested constructs such as short-circuit `and`/`or`, the ternary `?:` operator, user-defined functions, and arrow functions.

## Type Conversion (as Operator)

The `as` operator provides type conversion capabilities. **Disabled by default.**
Expand Down
2 changes: 1 addition & 1 deletion docs/enhancements.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ This TypeScript port adds the following features over the original library:
### Developer Integration Features

- **Promise support** - Custom functions can return promises (async evaluation)
- **Custom variable resolution** - `parser.resolve` callback for dynamic variable lookup
- **Custom variable resolution** - `parser.resolve` callback for dynamic variable lookup, plus a per-call `resolver` argument on `Expression.evaluate()` / `Parser.evaluate()` so a single parsed expression can be evaluated against different data sources without mutating parser state
- **`as` operator** - Type conversion with customizable implementation

For detailed documentation, see:
Expand Down
17 changes: 16 additions & 1 deletion docs/expression.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

`Parser.parse(str)` returns an `Expression` object. `Expression`s are similar to JavaScript functions, i.e. they can be "called" with variables bound to passed-in values. In fact, they can even be converted into JavaScript functions.

## evaluate(variables?: object)
## evaluate(variables?: object, resolver?: VariableResolver)

Evaluate the expression, with variables bound to the values in `{variables}`. Each variable in the expression is bound to the corresponding member of the `variables` object. If there are unbound variables, `evaluate` will throw an exception.

Expand All @@ -15,6 +15,21 @@ const expr = Parser.parse("2 ^ x");
console.log(expr.evaluate({ x: 3 })); // 8
```

The optional `resolver` argument is a per-call variable resolver. It has the same shape as `parser.resolve` — `(name) => { alias } | { value } | undefined` — but applies only to the current `evaluate()` call, so a single parsed `Expression` can be evaluated multiple times against different data sources without mutating parser state. The per-call `resolver` is consulted before `parser.resolve`; the `variables` object still takes precedence over both.

```js
const parser = new Parser();
const expr = parser.parse('$user.name');

const resolveAlice = (name) => name === '$user' ? { value: { name: 'Alice' } } : undefined;
const resolveBob = (name) => name === '$user' ? { value: { name: 'Bob' } } : undefined;

expr.evaluate({}, resolveAlice); // 'Alice'
expr.evaluate({}, resolveBob); // 'Bob'
```

See [Per-Expression Variable Resolver](advanced-features.md#per-expression-variable-resolver) for details.

## substitute(variable: string, expression: Expression | string | number)

Create a new `Expression` with the specified variable replaced with another expression. This is similar to function composition. If `expression` is a string or number, it will be parsed into an `Expression`.
Expand Down
16 changes: 13 additions & 3 deletions docs/parser.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,22 @@ const expr = parser.parse('x * 2 + y');

Returns an [Expression](expression.md) object with methods like `evaluate()`, `simplify()`, `variables()`, etc.

### evaluate(expression: string, variables?: object)
### evaluate(expression: string, variables?: object, resolver?: VariableResolver)

Parse and immediately evaluate an expression.

```js
parser.evaluate('x + y', { x: 2, y: 3 }); // 5
```

The optional `resolver` callback is a per-call [custom variable resolver](advanced-features.md#custom-variable-name-resolution). It is tried before `parser.resolve` when a variable is not found in `variables`.

```js
parser.evaluate('$a + $b', {}, (name) =>
name.startsWith('$') ? { value: lookup(name.substring(1)) } : undefined
); // per-call resolver; parser.resolve is not mutated
```

## Static Methods

### Parser.parse(expression: string)
Expand All @@ -103,9 +111,9 @@ Static equivalent of `new Parser().parse(expression)`.
const expr = Parser.parse('x + 1');
```

### Parser.evaluate(expression: string, variables?: object)
### Parser.evaluate(expression: string, variables?: object, resolver?: VariableResolver)

Parse and immediately evaluate an expression. Equivalent to `Parser.parse(expr).evaluate(vars)`.
Parse and immediately evaluate an expression. Equivalent to `Parser.parse(expr).evaluate(vars, resolver)`.

```js
Parser.evaluate('6 * x', { x: 7 }); // 42
Expand Down Expand Up @@ -202,6 +210,8 @@ The `resolve` callback should return:
- `{ value: any }` - to return a value directly
- `undefined` - to use default behavior (throws error for unknown variables)

For cases where different evaluations of the same parsed expression need different resolution logic, prefer passing a resolver directly to `Expression.evaluate(values, resolver)` or `parser.evaluate(expr, values, resolver)` instead of mutating `parser.resolve`. See [Per-Expression Variable Resolver](advanced-features.md#per-expression-variable-resolver).

## Advanced Configuration

### Type Conversion (as operator)
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type {
VariableAlias,
VariableValue,
VariableResolveResult,
VariableResolver,
OperatorFunction
} from './src/types/index.js';

Expand Down
102 changes: 62 additions & 40 deletions src/core/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { ISCALAR, IOP1, IOP2, IOP3, IVAR, IVARNAME, IFUNCALL, IFUNDEF, IARROW, IEXPR, IEXPREVAL, IMEMBER, IENDSTATEMENT, IARRAY, IUNDEFINED, ICASEMATCH, IWHENMATCH, ICASEELSE, ICASECOND, IWHENCOND, IOBJECT, IPROPERTY, IOBJECTEND } from '../parsing/instruction.js';
import type { Instruction } from '../parsing/instruction.js';
import type { Expression } from './expression.js';
import type { Value, Values, VariableResolveResult } from '../types/values.js';
import type { Value, Values, VariableResolveResult, VariableResolver } from '../types/values.js';
import { VariableError } from '../types/errors.js';
import { ExpressionValidator } from '../validation/expression-validator.js';

Expand Down Expand Up @@ -44,16 +44,17 @@
* of objects returned by the {@link Token} function.
* @param expr The instance of the {@link Expression} class that invoked the evaluator.
* @param values Input values provided to the expression.
* @param resolver Optional per-call variable resolver. Tried before the parser-level resolver.
* @returns The return value is the expression result value or a promise that when resolved will contain
* the expression result value. A promise is only returned if a caller defined function returns a promise.
*/
export default function evaluate(tokens: Instruction | Instruction[], expr: Expression, values: EvaluationValues): Value | Promise<Value> {
export default function evaluate(tokens: Instruction | Instruction[], expr: Expression, values: EvaluationValues, resolver?: VariableResolver): Value | Promise<Value> {
if (isExpressionEvaluator(tokens)) {
return resolveExpression(tokens, values);
}

const nstack: EvaluationStack = [];
return runEvaluateLoop(tokens as Instruction[], expr, values, nstack);
return runEvaluateLoop(tokens as Instruction[], expr, values, nstack, 0, resolver);
}

/**
Expand All @@ -80,11 +81,11 @@
* @returns The return value is the expression result value or a promise that when resolved will contain
* the expression result value. A promise is only returned if a caller defined function returns a promise.
*/
function runEvaluateLoop(tokens: Instruction[], expr: Expression, values: EvaluationValues, nstack: EvaluationStack, startAt: number = 0): Value | Promise<Value> {
function runEvaluateLoop(tokens: Instruction[], expr: Expression, values: EvaluationValues, nstack: EvaluationStack, startAt: number = 0, resolver?: VariableResolver): Value | Promise<Value> {
const numTokens = tokens.length;
for (let i = startAt; i < numTokens; i++) {
const item = tokens[i];
evaluateExpressionToken(expr, values, item, nstack);
evaluateExpressionToken(expr, values, item, nstack, resolver);
const last = nstack[nstack.length - 1];
if (isPromise(last)) {
// The only way a promise can get added to the stack is if a custom function was invoked that
Expand All @@ -96,7 +97,7 @@
nstack.push(resolvedValue);
// ...with the stack updated with the resolved value from the promise we can call ourselves to
// continue evaluating the expression.
return runEvaluateLoop(tokens, expr, values, nstack, i + 1);
return runEvaluateLoop(tokens, expr, values, nstack, i + 1, resolver);
});
}
}
Expand Down Expand Up @@ -126,7 +127,7 @@
* the {@link Token} function.
* @param nstack The stack to use for expression evaluation.
*/
function evaluateExpressionToken(expr: Expression, values: EvaluationValues, token: Instruction, nstack: EvaluationStack): void {
function evaluateExpressionToken(expr: Expression, values: EvaluationValues, token: Instruction, nstack: EvaluationStack, resolver?: VariableResolver): void {
let leftOperand: any, rightOperand: any, conditionValue: any;
let operatorFunction: Function, functionArgs: any[], argumentCount: number;

Expand All @@ -139,12 +140,12 @@

// Handle special short-circuit logical operators
if (token.value === 'and') {
nstack.push(leftOperand ? !!evaluate(rightOperand, expr, values) : false);
nstack.push(leftOperand ? !!evaluate(rightOperand, expr, values, resolver) : false);
} else if (token.value === 'or') {
nstack.push(leftOperand ? true : !!evaluate(rightOperand, expr, values));
nstack.push(leftOperand ? true : !!evaluate(rightOperand, expr, values, resolver));
} else if (token.value === '=') {
operatorFunction = expr.binaryOps[token.value];
nstack.push(operatorFunction(leftOperand, evaluate(rightOperand, expr, values), values));
nstack.push(operatorFunction(leftOperand, evaluate(rightOperand, expr, values, resolver), values));
} else {
operatorFunction = expr.binaryOps[token.value];
nstack.push(operatorFunction(resolveExpression(leftOperand, values), resolveExpression(rightOperand, values)));
Expand All @@ -155,7 +156,7 @@
conditionValue = nstack.pop();

if (token.value === '?') {
nstack.push(evaluate(conditionValue ? trueValue : falseValue, expr, values));
nstack.push(evaluate(conditionValue ? trueValue : falseValue, expr, values, resolver));
} else {
operatorFunction = expr.ternaryOps[token.value];
nstack.push(operatorFunction(
Expand All @@ -182,31 +183,21 @@
valueResolved = true;
} else {
// We don't recognize the IVAR token. Before throwing an error for an undefined variable we
// give the parser a shot at resolving the IVAR for us. By default this callback will return
// undefined and fail to resolve, but the creator of the parser can replace the resolve callback
// with their own implementation to resolve variables. That can return values that look like:
// { alias: "xxx" } - use xxx as the IVAR token instead of what was typed.
// { value: <something> } use <something> as the value for the variable.
const resolvedVariable: VariableResolveResult | undefined = expr.parser.resolve(variableName);
if (typeof resolvedVariable === 'object' && resolvedVariable && 'alias' in resolvedVariable && typeof resolvedVariable.alias === 'string') {
// The parser's resolver function returned { alias: "xxx" }, we want to use
// resolved.alias in place of token.value.
if (resolvedVariable.alias in values) {
const aliasValue = values[resolvedVariable.alias];
// Security: Validate that functions from context are allowed
ExpressionValidator.validateAllowedFunction(aliasValue, expr.functions, expr.toString());
nstack.push(aliasValue);
valueResolved = true;
}
} else if (typeof resolvedVariable === 'object' && resolvedVariable && 'value' in resolvedVariable) {
// The parser's resolver function returned { value: <something> }, use <something>
// as the value of the token.
const resolvedValue = resolvedVariable.value;
// Security: Validate that functions from context are allowed
ExpressionValidator.validateAllowedFunction(resolvedValue, expr.functions, expr.toString());
nstack.push(resolvedValue);
valueResolved = true;
// give custom resolvers a shot at resolving the IVAR for us. Per-call resolvers (supplied to
// Expression.evaluate) take precedence over the parser-level resolver. A resolver result can
// look like:
// { alias: "xxx" } - use xxx as the IVAR token instead of what was typed.
// { value: <something> } - use <something> as the value for the variable.
// Returning undefined means "I don't know this variable" and passes the attempt on to the
// next resolver in the chain.
let resolvedVariable: VariableResolveResult | undefined;
if (resolver) {
resolvedVariable = resolver(variableName);
}
if (resolvedVariable === undefined) {
resolvedVariable = expr.parser.resolve(variableName);
}
valueResolved = applyResolvedVariable(resolvedVariable, values, expr, nstack);
}
if (!valueResolved) {
throw new VariableError(
Expand Down Expand Up @@ -247,7 +238,7 @@
for (let i = 0, len = functionParams.length; i < len; i++) {
localScope[functionParams[i]] = functionArguments[i];
}
return evaluate(expressionToEvaluate, expr, localScope);
return evaluate(expressionToEvaluate, expr, localScope, resolver);
};
// Set function name for debugging
Object.defineProperty(userDefinedFunction, 'name', {
Expand Down Expand Up @@ -277,7 +268,7 @@
for (let i = 0, len = functionParams.length; i < len; i++) {
localScope[functionParams[i]] = functionArguments[i];
}
return evaluate(expressionToEvaluate, expr, localScope);
return evaluate(expressionToEvaluate, expr, localScope, resolver);
};
// Set function name for debugging (anonymous arrow function)
Object.defineProperty(arrowFunction, 'name', {
Expand All @@ -290,7 +281,7 @@
return arrowFunction;
})());
} else if (type === IEXPR) {
nstack.push(createExpressionEvaluator(token, expr));
nstack.push(createExpressionEvaluator(token, expr, resolver));
} else if (type === IEXPREVAL) {
nstack.push(token);
} else if (type === IMEMBER) {
Expand Down Expand Up @@ -389,18 +380,49 @@
}
}

function createExpressionEvaluator(token: Instruction, expr: Expression): ExpressionEvaluator {
function createExpressionEvaluator(token: Instruction, expr: Expression, resolver?: VariableResolver): ExpressionEvaluator {
if (isExpressionEvaluator(token)) {
return token;
}

Check warning

Code scanning / CodeQL

Prototype-polluting assignment Medium

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from
library input
.
return {
type: IEXPREVAL,
value: function (scope: EvaluationValues): Value | Promise<Value> {
return evaluate(token.value as Instruction[], expr, scope);
return evaluate(token.value as Instruction[], expr, scope, resolver);
}
};
}

/**
* Dispatches a {@link VariableResolveResult} onto the evaluation stack.
* Handles `{ alias }` and `{ value }` shapes, runs function allow-listing, and reports
* whether the variable was resolved so the caller can decide whether to throw.
*/
function applyResolvedVariable(
resolvedVariable: VariableResolveResult | undefined,
values: EvaluationValues,
expr: Expression,
nstack: EvaluationStack
): boolean {
if (typeof resolvedVariable === 'object' && resolvedVariable && 'alias' in resolvedVariable && typeof resolvedVariable.alias === 'string') {
// Resolver returned { alias: "xxx" } - look xxx up in the values map.
if (resolvedVariable.alias in values) {
const aliasValue = values[resolvedVariable.alias];
ExpressionValidator.validateAllowedFunction(aliasValue, expr.functions, expr.toString());
nstack.push(aliasValue);
return true;
}
return false;
}
if (typeof resolvedVariable === 'object' && resolvedVariable && 'value' in resolvedVariable) {
// Resolver returned { value: <something> } - use <something> directly.
const resolvedValue = resolvedVariable.value;
ExpressionValidator.validateAllowedFunction(resolvedValue, expr.functions, expr.toString());
nstack.push(resolvedValue);
return true;
}
return false;
}

function isExpressionEvaluator(n: any): n is ExpressionEvaluator {
return n && n.type === IEXPREVAL;
}
Expand Down
Loading
Loading