UNPKG

@pawel-up/jexl

Version:

Javascript Expression Language: Powerful context-based expression parser and evaluator

254 lines (239 loc) 8.97 kB
import Evaluator from './evaluator/Evaluator.js' import { Grammar, ASTNode } from './grammar.js' import Lexer from './Lexer.js' import Parser from './parser/Parser.js' /** * Context for expression evaluation. * Maps variable names to their values that will be accessible within the expression. * * @example * ```typescript * const context: Context = { * user: { name: 'John', age: 30 }, * items: [1, 2, 3], * config: { debug: true } * } * ``` */ export type Context = Record<string, unknown> /** * Expression represents a compiled Jexl expression that can be evaluated multiple times * with different contexts. This class handles the compilation of expression strings into * Abstract Syntax Trees (AST) and their subsequent evaluation. * * Expressions are created through the `Jexl.compile()` or `Jexl.createExpression()` methods * and provide better performance for repeated evaluations since the parsing overhead * is incurred only once during compilation. * * @example * ```typescript * import { Jexl } from '@pawel-up/jexl' * * const jexl = new Jexl() * * // Create and compile an expression * const expr = jexl.compile('user.profile.name | upper') * * // Evaluate with different contexts * await expr.eval({ user: { profile: { name: 'alice' } } }) // 'ALICE' * await expr.eval({ user: { profile: { name: 'bob' } } }) // 'BOB' * * // Complex expressions with filters and functions * const orderExpr = jexl.compile('orders[status == "pending"] | length') * await orderExpr.eval({ * orders: [ * { id: 1, status: 'pending' }, * { id: 2, status: 'completed' }, * { id: 3, status: 'pending' } * ] * }) // 2 * ``` * * ## Compilation Process * * 1. **Lexical Analysis**: Expression string is tokenized into meaningful parts * 2. **Parsing**: Tokens are organized into an Abstract Syntax Tree (AST) * 3. **Optimization**: AST is prepared for efficient evaluation * * ## Evaluation Process * * 1. **Context Binding**: Variables in the context are made available to the expression * 2. **AST Traversal**: The syntax tree is walked and operations are executed * 3. **Result Computation**: Final result is computed and returned as a Promise * * ## Performance Benefits * * - **Reusability**: Compile once, evaluate many times * - **Efficiency**: No repeated parsing overhead * - **Type Safety**: Compile-time validation of expression syntax * * @template R The type of the result returned by the expression evaluation */ export default class Expression<R = unknown> { /** The grammar configuration used for parsing and evaluation */ _grammar: Grammar /** The original expression string provided during construction */ _exprStr: string /** The compiled Abstract Syntax Tree, null until compilation */ _ast: ASTNode | null /** * Creates a new Expression instance with the given grammar and expression string. * Note: The expression is not compiled until `compile()` is called explicitly * or implicitly during the first evaluation. * * @param grammar The grammar configuration containing operators, functions, and transforms * @param exprStr The Jexl expression string to be compiled and evaluated * * @example * ```typescript * const expr = new Expression(grammar, 'user.name | upper') * ``` */ constructor(grammar: Grammar, exprStr: string) { this._grammar = grammar this._exprStr = exprStr this._ast = null } /** * Forces a compilation of the expression string that this Expression object * was constructed with. This function can be called multiple times; useful * if the language elements of the associated Jexl instance change. * @returns this Expression instance, for convenience */ compile(): this { const lexer = new Lexer(this._grammar) const parser = new Parser(this._grammar) parser.addTokens(lexer.tokenize(this._exprStr)) // Convert Token to ASTNode - they have compatible structures this._ast = parser.complete() as ASTNode | null return this } /** * Asynchronously evaluates the expression within an optional context. * @param context A mapping of variables to values, which will be * made accessible to the Jexl expression when evaluating it * @returns resolves with the result of the evaluation. */ eval(context: Context = {}): Promise<R> { return this._eval(context) } /** * Asynchronously evaluates the expression and coerces the result to a string. * @param context A mapping of variables to values. * @returns A promise that resolves with the result of the evaluation as a string. */ async evalAsString(context: Context = {}): Promise<string> { const result = await this.eval(context) if (result === null) { return 'null' } if (result === undefined) { return 'undefined' } return String(result) } /** * Asynchronously evaluates the expression and coerces the result to a number. * `null` and `undefined` are coerced to `NaN`, as you suggested. * @param context A mapping of variables to values. * @returns A promise that resolves with the result of the evaluation as a number. */ async evalAsNumber(context: Context = {}): Promise<number> { const result = await this.eval(context) if (result === null || result === undefined) { return NaN } return Number(result) } /** * Asynchronously evaluates the expression and coerces the result to a boolean. * Uses standard JavaScript truthiness. * @param context A mapping of variables to values. * @returns A promise that resolves with the result of the evaluation as a boolean. */ async evalAsBoolean(context: Context = {}): Promise<boolean> { const result = await this.eval(context) return !!result } /** * Asynchronously evaluates the expression and ensures the result is an array. * - If the result is an array, it's returned as is. * - If the result is `null` or `undefined`, an empty array `[]` is returned. * - Otherwise, the result is wrapped in an array. * * The element type of the returned array is inferred from the Expression's * generic type `R`. If `R` is `T[]`, elements are of type `T`. If `R` is `T`, * elements are also of type `T`. * * @param context A mapping of variables to values. * @returns A promise that resolves with the result as an array. */ async evalAsArray(context: Context = {}): Promise<(R extends (infer E)[] ? E : R)[]> { const result = await this.eval(context) if (result === null || result === undefined) { return [] } if (Array.isArray(result)) { return result } return [result as R extends (infer E)[] ? E : R] } /** * Asynchronously evaluates the expression and validates it against a list of allowed values. * * @param context A mapping of variables to values. * @param allowedValues An array of allowed values for the result. * @returns A promise that resolves with the result if it's in the `allowedValues` list, otherwise `undefined`. */ async evalAsEnum(context: Context = {}, allowedValues: readonly R[]): Promise<R | undefined> { const result = await this.eval(context) if (allowedValues.includes(result as R)) { return result as R } return undefined } /** * Asynchronously evaluates the expression, returning a default value if the result is `null` or `undefined`. * This is useful for providing defaults for optional paths without relying on the `||` operator, * which would also override falsy values like `0`, `false`, or `""`. * @template T The expected type of the result. * @param context A mapping of variables to values. * @param defaultValue The value to return if the expression result is `null` or `undefined`. * @returns A promise that resolves with the expression's result or the default value. */ async evalWithDefault(context: Context = {}, defaultValue: R): Promise<R> { const result = await this.eval(context) if (result === null || result === undefined) { return defaultValue } return result } /** * Internal evaluation method that handles the actual evaluation logic. * @param context The evaluation context * @returns Promise that resolves to the evaluation result */ async _eval(context: Context): Promise<R> { const ast = this._getAst() if (!ast) { throw new Error('No AST available for evaluation. Expression may not be compiled.') } const evaluator = new Evaluator( this._grammar, context, context // Use the same context as relative context ) return evaluator.eval(ast) as Promise<R> } /** * Gets the compiled AST, compiling if necessary. * @returns The compiled AST * @throws {Error} if compilation fails */ _getAst(): ASTNode | null { if (!this._ast) { this.compile() } return this._ast } }