Skip to content

Commit 32b5bbe

Browse files
Sander-ToonenSander Toonen
andauthored
Add expression level resolver callback (#42)
Co-authored-by: Sander Toonen <s.toonen@pro-fa.com>
1 parent d43231f commit 32b5bbe

10 files changed

Lines changed: 266 additions & 56 deletions

File tree

docs/advanced-features.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,48 @@ parser.evaluate('$a + $b', {}); // 15
7373
- `{ value: any }` - Return a value directly
7474
- `undefined` - Use default behavior (throws error for unknown variables)
7575

76+
### Per-Expression Variable Resolver
77+
78+
`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.
79+
80+
```js
81+
const parser = new Parser();
82+
const expr = parser.parse('$user.name + " is " + $user.age');
83+
84+
// Same compiled expression, two different data sources, no parser mutation.
85+
expr.evaluate({}, (name) =>
86+
name === '$user' ? { value: { name: 'Alice', age: 30 } } : undefined
87+
); // 'Alice is 30'
88+
89+
expr.evaluate({}, (name) =>
90+
name === '$user' ? { value: { name: 'Bob', age: 25 } } : undefined
91+
); // 'Bob is 25'
92+
```
93+
94+
The per-call resolver uses the same return shape as `parser.resolve``{ alias }`, `{ value }`, or `undefined` — and the resolution order during evaluation is:
95+
96+
1. The `variables` object passed to `evaluate()`
97+
2. The per-call `resolver` (if provided)
98+
3. `parser.resolve` (the parser-level callback)
99+
4. Otherwise, a `VariableError` is thrown
100+
101+
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.
102+
103+
```js
104+
const parser = new Parser();
105+
106+
// Parser-level: shared constants that never change
107+
parser.resolve = (name) =>
108+
name === '$pi' ? { value: Math.PI } : undefined;
109+
110+
// Per-call: request-specific data
111+
parser.parse('$pi * $radius ^ 2').evaluate({}, (name) =>
112+
name === '$radius' ? { value: 5 } : undefined
113+
); // 78.539...
114+
```
115+
116+
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.
117+
76118
## Type Conversion (as Operator)
77119

78120
The `as` operator provides type conversion capabilities. **Disabled by default.**

docs/enhancements.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ This TypeScript port adds the following features over the original library:
2222
### Developer Integration Features
2323

2424
- **Promise support** - Custom functions can return promises (async evaluation)
25-
- **Custom variable resolution** - `parser.resolve` callback for dynamic variable lookup
25+
- **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
2626
- **`as` operator** - Type conversion with customizable implementation
2727

2828
For detailed documentation, see:

docs/expression.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
`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.
66

7-
## evaluate(variables?: object)
7+
## evaluate(variables?: object, resolver?: VariableResolver)
88

99
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.
1010

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

18+
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.
19+
20+
```js
21+
const parser = new Parser();
22+
const expr = parser.parse('$user.name');
23+
24+
const resolveAlice = (name) => name === '$user' ? { value: { name: 'Alice' } } : undefined;
25+
const resolveBob = (name) => name === '$user' ? { value: { name: 'Bob' } } : undefined;
26+
27+
expr.evaluate({}, resolveAlice); // 'Alice'
28+
expr.evaluate({}, resolveBob); // 'Bob'
29+
```
30+
31+
See [Per-Expression Variable Resolver](advanced-features.md#per-expression-variable-resolver) for details.
32+
1833
## substitute(variable: string, expression: Expression | string | number)
1934

2035
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`.

docs/parser.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,22 @@ const expr = parser.parse('x * 2 + y');
8585
8686
Returns an [Expression](expression.md) object with methods like `evaluate()`, `simplify()`, `variables()`, etc.
8787
88-
### evaluate(expression: string, variables?: object)
88+
### evaluate(expression: string, variables?: object, resolver?: VariableResolver)
8989
9090
Parse and immediately evaluate an expression.
9191
9292
```js
9393
parser.evaluate('x + y', { x: 2, y: 3 }); // 5
9494
```
9595
96+
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`.
97+
98+
```js
99+
parser.evaluate('$a + $b', {}, (name) =>
100+
name.startsWith('$') ? { value: lookup(name.substring(1)) } : undefined
101+
); // per-call resolver; parser.resolve is not mutated
102+
```
103+
96104
## Static Methods
97105
98106
### Parser.parse(expression: string)
@@ -103,9 +111,9 @@ Static equivalent of `new Parser().parse(expression)`.
103111
const expr = Parser.parse('x + 1');
104112
```
105113
106-
### Parser.evaluate(expression: string, variables?: object)
114+
### Parser.evaluate(expression: string, variables?: object, resolver?: VariableResolver)
107115
108-
Parse and immediately evaluate an expression. Equivalent to `Parser.parse(expr).evaluate(vars)`.
116+
Parse and immediately evaluate an expression. Equivalent to `Parser.parse(expr).evaluate(vars, resolver)`.
109117
110118
```js
111119
Parser.evaluate('6 * x', { x: 7 }); // 42
@@ -202,6 +210,8 @@ The `resolve` callback should return:
202210
- `{ value: any }` - to return a value directly
203211
- `undefined` - to use default behavior (throws error for unknown variables)
204212
213+
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).
214+
205215
## Advanced Configuration
206216
207217
### Type Conversion (as operator)

index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type {
2424
VariableAlias,
2525
VariableValue,
2626
VariableResolveResult,
27+
VariableResolver,
2728
OperatorFunction
2829
} from './src/types/index.js';
2930

src/core/evaluate.ts

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
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';
99
import type { Instruction } from '../parsing/instruction.js';
1010
import type { Expression } from './expression.js';
11-
import type { Value, Values, VariableResolveResult } from '../types/values.js';
11+
import type { Value, Values, VariableResolveResult, VariableResolver } from '../types/values.js';
1212
import { VariableError } from '../types/errors.js';
1313
import { ExpressionValidator } from '../validation/expression-validator.js';
1414

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

5556
const nstack: EvaluationStack = [];
56-
return runEvaluateLoop(tokens as Instruction[], expr, values, nstack);
57+
return runEvaluateLoop(tokens as Instruction[], expr, values, nstack, 0, resolver);
5758
}
5859

5960
/**
@@ -80,11 +81,11 @@ function isPromise(obj: any): obj is Promise<any> {
8081
* @returns The return value is the expression result value or a promise that when resolved will contain
8182
* the expression result value. A promise is only returned if a caller defined function returns a promise.
8283
*/
83-
function runEvaluateLoop(tokens: Instruction[], expr: Expression, values: EvaluationValues, nstack: EvaluationStack, startAt: number = 0): Value | Promise<Value> {
84+
function runEvaluateLoop(tokens: Instruction[], expr: Expression, values: EvaluationValues, nstack: EvaluationStack, startAt: number = 0, resolver?: VariableResolver): Value | Promise<Value> {
8485
const numTokens = tokens.length;
8586
for (let i = startAt; i < numTokens; i++) {
8687
const item = tokens[i];
87-
evaluateExpressionToken(expr, values, item, nstack);
88+
evaluateExpressionToken(expr, values, item, nstack, resolver);
8889
const last = nstack[nstack.length - 1];
8990
if (isPromise(last)) {
9091
// The only way a promise can get added to the stack is if a custom function was invoked that
@@ -96,7 +97,7 @@ function runEvaluateLoop(tokens: Instruction[], expr: Expression, values: Evalua
9697
nstack.push(resolvedValue);
9798
// ...with the stack updated with the resolved value from the promise we can call ourselves to
9899
// continue evaluating the expression.
99-
return runEvaluateLoop(tokens, expr, values, nstack, i + 1);
100+
return runEvaluateLoop(tokens, expr, values, nstack, i + 1, resolver);
100101
});
101102
}
102103
}
@@ -126,7 +127,7 @@ function resolveFinalValue(nstack: EvaluationStack, values: EvaluationValues): V
126127
* the {@link Token} function.
127128
* @param nstack The stack to use for expression evaluation.
128129
*/
129-
function evaluateExpressionToken(expr: Expression, values: EvaluationValues, token: Instruction, nstack: EvaluationStack): void {
130+
function evaluateExpressionToken(expr: Expression, values: EvaluationValues, token: Instruction, nstack: EvaluationStack, resolver?: VariableResolver): void {
130131
let leftOperand: any, rightOperand: any, conditionValue: any;
131132
let operatorFunction: Function, functionArgs: any[], argumentCount: number;
132133

@@ -139,12 +140,12 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
139140

140141
// Handle special short-circuit logical operators
141142
if (token.value === 'and') {
142-
nstack.push(leftOperand ? !!evaluate(rightOperand, expr, values) : false);
143+
nstack.push(leftOperand ? !!evaluate(rightOperand, expr, values, resolver) : false);
143144
} else if (token.value === 'or') {
144-
nstack.push(leftOperand ? true : !!evaluate(rightOperand, expr, values));
145+
nstack.push(leftOperand ? true : !!evaluate(rightOperand, expr, values, resolver));
145146
} else if (token.value === '=') {
146147
operatorFunction = expr.binaryOps[token.value];
147-
nstack.push(operatorFunction(leftOperand, evaluate(rightOperand, expr, values), values));
148+
nstack.push(operatorFunction(leftOperand, evaluate(rightOperand, expr, values, resolver), values));
148149
} else {
149150
operatorFunction = expr.binaryOps[token.value];
150151
nstack.push(operatorFunction(resolveExpression(leftOperand, values), resolveExpression(rightOperand, values)));
@@ -155,7 +156,7 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
155156
conditionValue = nstack.pop();
156157

157158
if (token.value === '?') {
158-
nstack.push(evaluate(conditionValue ? trueValue : falseValue, expr, values));
159+
nstack.push(evaluate(conditionValue ? trueValue : falseValue, expr, values, resolver));
159160
} else {
160161
operatorFunction = expr.ternaryOps[token.value];
161162
nstack.push(operatorFunction(
@@ -182,31 +183,21 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
182183
valueResolved = true;
183184
} else {
184185
// We don't recognize the IVAR token. Before throwing an error for an undefined variable we
185-
// give the parser a shot at resolving the IVAR for us. By default this callback will return
186-
// undefined and fail to resolve, but the creator of the parser can replace the resolve callback
187-
// with their own implementation to resolve variables. That can return values that look like:
188-
// { alias: "xxx" } - use xxx as the IVAR token instead of what was typed.
189-
// { value: <something> } use <something> as the value for the variable.
190-
const resolvedVariable: VariableResolveResult | undefined = expr.parser.resolve(variableName);
191-
if (typeof resolvedVariable === 'object' && resolvedVariable && 'alias' in resolvedVariable && typeof resolvedVariable.alias === 'string') {
192-
// The parser's resolver function returned { alias: "xxx" }, we want to use
193-
// resolved.alias in place of token.value.
194-
if (resolvedVariable.alias in values) {
195-
const aliasValue = values[resolvedVariable.alias];
196-
// Security: Validate that functions from context are allowed
197-
ExpressionValidator.validateAllowedFunction(aliasValue, expr.functions, expr.toString());
198-
nstack.push(aliasValue);
199-
valueResolved = true;
200-
}
201-
} else if (typeof resolvedVariable === 'object' && resolvedVariable && 'value' in resolvedVariable) {
202-
// The parser's resolver function returned { value: <something> }, use <something>
203-
// as the value of the token.
204-
const resolvedValue = resolvedVariable.value;
205-
// Security: Validate that functions from context are allowed
206-
ExpressionValidator.validateAllowedFunction(resolvedValue, expr.functions, expr.toString());
207-
nstack.push(resolvedValue);
208-
valueResolved = true;
186+
// give custom resolvers a shot at resolving the IVAR for us. Per-call resolvers (supplied to
187+
// Expression.evaluate) take precedence over the parser-level resolver. A resolver result can
188+
// look like:
189+
// { alias: "xxx" } - use xxx as the IVAR token instead of what was typed.
190+
// { value: <something> } - use <something> as the value for the variable.
191+
// Returning undefined means "I don't know this variable" and passes the attempt on to the
192+
// next resolver in the chain.
193+
let resolvedVariable: VariableResolveResult | undefined;
194+
if (resolver) {
195+
resolvedVariable = resolver(variableName);
196+
}
197+
if (resolvedVariable === undefined) {
198+
resolvedVariable = expr.parser.resolve(variableName);
209199
}
200+
valueResolved = applyResolvedVariable(resolvedVariable, values, expr, nstack);
210201
}
211202
if (!valueResolved) {
212203
throw new VariableError(
@@ -247,7 +238,7 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
247238
for (let i = 0, len = functionParams.length; i < len; i++) {
248239
localScope[functionParams[i]] = functionArguments[i];
249240
}
250-
return evaluate(expressionToEvaluate, expr, localScope);
241+
return evaluate(expressionToEvaluate, expr, localScope, resolver);
251242
};
252243
// Set function name for debugging
253244
Object.defineProperty(userDefinedFunction, 'name', {
@@ -277,7 +268,7 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
277268
for (let i = 0, len = functionParams.length; i < len; i++) {
278269
localScope[functionParams[i]] = functionArguments[i];
279270
}
280-
return evaluate(expressionToEvaluate, expr, localScope);
271+
return evaluate(expressionToEvaluate, expr, localScope, resolver);
281272
};
282273
// Set function name for debugging (anonymous arrow function)
283274
Object.defineProperty(arrowFunction, 'name', {
@@ -290,7 +281,7 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
290281
return arrowFunction;
291282
})());
292283
} else if (type === IEXPR) {
293-
nstack.push(createExpressionEvaluator(token, expr));
284+
nstack.push(createExpressionEvaluator(token, expr, resolver));
294285
} else if (type === IEXPREVAL) {
295286
nstack.push(token);
296287
} else if (type === IMEMBER) {
@@ -389,18 +380,49 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
389380
}
390381
}
391382

392-
function createExpressionEvaluator(token: Instruction, expr: Expression): ExpressionEvaluator {
383+
function createExpressionEvaluator(token: Instruction, expr: Expression, resolver?: VariableResolver): ExpressionEvaluator {
393384
if (isExpressionEvaluator(token)) {
394385
return token;
395386
}
396387
return {
397388
type: IEXPREVAL,
398389
value: function (scope: EvaluationValues): Value | Promise<Value> {
399-
return evaluate(token.value as Instruction[], expr, scope);
390+
return evaluate(token.value as Instruction[], expr, scope, resolver);
400391
}
401392
};
402393
}
403394

395+
/**
396+
* Dispatches a {@link VariableResolveResult} onto the evaluation stack.
397+
* Handles `{ alias }` and `{ value }` shapes, runs function allow-listing, and reports
398+
* whether the variable was resolved so the caller can decide whether to throw.
399+
*/
400+
function applyResolvedVariable(
401+
resolvedVariable: VariableResolveResult | undefined,
402+
values: EvaluationValues,
403+
expr: Expression,
404+
nstack: EvaluationStack
405+
): boolean {
406+
if (typeof resolvedVariable === 'object' && resolvedVariable && 'alias' in resolvedVariable && typeof resolvedVariable.alias === 'string') {
407+
// Resolver returned { alias: "xxx" } - look xxx up in the values map.
408+
if (resolvedVariable.alias in values) {
409+
const aliasValue = values[resolvedVariable.alias];
410+
ExpressionValidator.validateAllowedFunction(aliasValue, expr.functions, expr.toString());
411+
nstack.push(aliasValue);
412+
return true;
413+
}
414+
return false;
415+
}
416+
if (typeof resolvedVariable === 'object' && resolvedVariable && 'value' in resolvedVariable) {
417+
// Resolver returned { value: <something> } - use <something> directly.
418+
const resolvedValue = resolvedVariable.value;
419+
ExpressionValidator.validateAllowedFunction(resolvedValue, expr.functions, expr.toString());
420+
nstack.push(resolvedValue);
421+
return true;
422+
}
423+
return false;
424+
}
425+
404426
function isExpressionEvaluator(n: any): n is ExpressionEvaluator {
405427
return n && n.type === IEXPREVAL;
406428
}

0 commit comments

Comments
 (0)