UNPKG

@pawel-up/jexl

Version:

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

706 lines (683 loc) 24.9 kB
import Expression, { type Context } from './Expression.js' import { type BinaryOpFunction, type FunctionFunction, getGrammar, type UnaryOpFunction, type Grammar, type TransformFunction, GrammarElement, BinaryElement, UnaryElement, } from './grammar.js' /** * Jexl is the JavaScript Expression Language, capable of parsing and evaluating * basic to complex expression strings, combined with advanced xpath-like drill * down into native JavaScript objects. * * This is the main entry point for the Jexl library. It provides methods for: * - Parsing and evaluating expressions with context data * - Adding custom operators, functions, and transforms * - Compiling expressions for repeated evaluation * - Creating template literal expressions * * @example * ```typescript * import { Jexl } from '@pawel-up/jexl' * * const jexl = new Jexl() * * // Basic evaluation * const result = await jexl.eval('user.name | upper', { * user: { name: 'John' } * }) * console.log(result) // 'JOHN' * * // Add custom transform * jexl.addTransform('double', (val: number) => val * 2) * await jexl.eval('5 | double') // 10 * * // Template literal syntax * const name = 'World' * const expr = jexl.expr`"Hello, ${name}!"` * await expr.eval() // 'Hello, World!' * * // Compile for reuse * const compiled = jexl.compile('items[type == "urgent"] | length') * await compiled.eval({ items: [...] }) * ``` * * ## Core Features * * - **Type-safe**: Written in TypeScript with comprehensive type definitions * - **Extensible**: Add custom operators, functions, and transforms * - **Performance**: Compile expressions once, evaluate many times * - **Context-aware**: Access nested object properties and array elements * - **Async-friendly**: All evaluations return promises for consistent API * * ## Expression Syntax * * Jexl supports a rich expression syntax including: * - Property access: `user.profile.name` * - Array filtering: `items[category == "books"]` * - Transforms: `title | upper | truncate(50)` * - Functions: `max(scores) + average(grades)` * - Ternary operators: `age >= 18 ? "adult" : "minor"` * - Boolean logic: `active && verified || admin` */ export class Jexl { /** * The grammar used by this Jexl instance. */ grammar: Grammar constructor() { // Allow expr to be called outside of the jexl context this.expr = this.expr.bind(this) this.grammar = getGrammar() } /** * Adds a custom binary operator to Jexl at the specified precedence level. * Binary operators work between two operands (left and right values). * * The precedence determines the order of operations - higher precedence operators * are evaluated first. For reference, multiplication (*) has higher precedence * than addition (+), so `2 + 3 * 4` evaluates as `2 + (3 * 4) = 14`. * * ## Precedence Guidelines * - **Arithmetic**: `*` (60), `/` (60), `%` (60), `+` (40), `-` (40) * - **Comparison**: `<` (30), `>` (30), `<=` (30), `>=` (30), `==` (20), `!=` (20) * - **Logical**: `&&` (10), `||` (5) * * @param operator The operator string (e.g., '**', '%%', '<>') * @param precedence The operator's precedence (higher = evaluated first) * @param fn Function to calculate the result, receives (left, right) operands * @param manualEval If true, operands are wrapped with eval() for conditional evaluation * * @example * ```typescript * // Add exponentiation operator * jexl.addBinaryOp('**', 70, (left: number, right: number) => Math.pow(left, right)) * await jexl.eval('2 ** 3') // 8 * * // Add string concatenation with custom operator * jexl.addBinaryOp('~', 45, (left: string, right: string) => left + right) * await jexl.eval('"Hello" ~ " World"') // "Hello World" * * // Manual evaluation for short-circuiting (like && operator) * jexl.addBinaryOp('??', 8, async (left, right) => { * const leftVal = await left.eval() * return leftVal != null ? leftVal : await right.eval() * }, true) * await jexl.eval('null ?? "default"') // "default" * ``` */ addBinaryOp(operator: string, precedence: number, fn: BinaryOpFunction, manualEval?: boolean): void { const element: BinaryElement = { type: 'binaryOp', precedence: precedence, } if (manualEval) { element.evalOnDemand = fn } else { element.eval = fn } this._addGrammarElement(operator, element) } /** * Adds or replaces a custom function that can be called within Jexl expressions. * Functions can accept multiple arguments and perform complex operations. * * Unlike transforms (which operate on piped values), functions are called explicitly * with parentheses and can be used anywhere in an expression. * * @param name The function name as it will appear in expressions * @param fn The JavaScript function to execute, receives all expression arguments * * @example * ```typescript * // Math functions * jexl.addFunction('max', (...args: number[]) => Math.max(...args)) * jexl.addFunction('min', (...args: number[]) => Math.min(...args)) * await jexl.eval('max(1, 5, 3)') // 5 * * // String utilities * jexl.addFunction('concat', (...strings: string[]) => strings.join('')) * await jexl.eval('concat("Hello", " ", "World")') // "Hello World" * * // Array operations * jexl.addFunction('sum', (arr: number[]) => arr.reduce((a, b) => a + b, 0)) * await jexl.eval('sum([1, 2, 3, 4])') // 10 * * // Complex logic with context access * jexl.addFunction('formatName', (first: string, last: string) => { * return `${last}, ${first}` * }) * await jexl.eval('formatName(user.firstName, user.lastName)', { * user: { firstName: 'John', lastName: 'Doe' } * }) // "Doe, John" * ``` */ addFunction(name: string, fn: FunctionFunction) { this.grammar.functions[name] = fn } /** * Convenience method for adding multiple functions at once. * Equivalent to calling `addFunction()` for each key-value pair in the map. * * @param map Object mapping function names to their implementations * * @example * ```typescript * jexl.addFunctions({ * // Math utilities * square: (n: number) => n * n, * cube: (n: number) => n * n * n, * * // String utilities * reverse: (str: string) => str.split('').reverse().join(''), * titleCase: (str: string) => str.charAt(0).toUpperCase() + str.slice(1), * * // Array utilities * first: (arr: unknown[]) => arr[0], * last: (arr: unknown[]) => arr[arr.length - 1], * length: (arr: unknown[]) => arr.length * }) * * // Usage examples * await jexl.eval('square(5)') // 25 * await jexl.eval('titleCase("hello world")') // "Hello world" * await jexl.eval('first([1, 2, 3])') // 1 * ``` */ addFunctions(map: Record<string, FunctionFunction>) { for (const key in map) { if (Object.prototype.hasOwnProperty.call(map, key)) { const fn = map[key] if (fn) { this.grammar.functions[key] = fn } } } } /** * Adds a custom unary operator to Jexl. Unary operators work on a single operand * and are currently only supported as prefix operators (before the value). * * All unary operators have infinite weight, meaning they are evaluated before * any binary operators in the expression. * * @param operator The operator string (e.g., '!', '~', '++') * @param fn Function to calculate the result, receives the operand value * * @example * ```typescript * // Add bitwise NOT operator * jexl.addUnaryOp('~', (val: number) => ~val) * await jexl.eval('~5') // -6 * * // Add absolute value operator * jexl.addUnaryOp('abs', (val: number) => Math.abs(val)) * await jexl.eval('abs(-42)') // 42 * * // Add string length operator * jexl.addUnaryOp('#', (val: string) => val.length) * await jexl.eval('#"hello"') // 5 * * // Works with expressions * await jexl.eval('~(3 + 2)') // ~5 = -6 * await jexl.eval('#user.name', { user: { name: 'John' } }) // 4 * ``` */ addUnaryOp(operator: string, fn: UnaryOpFunction) { const element: UnaryElement = { type: 'unaryOp', weight: Infinity, eval: fn, } this._addGrammarElement(operator, element) } /** * Adds or replaces a transform function in this Jexl instance. * Transforms are applied using the pipe operator (|) and operate on the * value to their left, optionally accepting additional arguments. * * Transforms are ideal for data processing pipelines where you want to * chain multiple operations together. * * @param name The transform name as it will appear after the pipe operator * @param fn Function that receives the piped value as first argument, plus any additional args * * @example * ```typescript * // Simple value transformation * jexl.addTransform('double', (val: number) => val * 2) * await jexl.eval('5 | double') // 10 * * // Transform with arguments * jexl.addTransform('multiply', (val: number, factor: number) => val * factor) * await jexl.eval('5 | multiply(3)') // 15 * * // String transformations * jexl.addTransform('truncate', (str: string, length: number) => * str.length > length ? str.slice(0, length) + '...' : str * ) * await jexl.eval('"Hello World" | truncate(5)') // "Hello..." * * // Array transformations * jexl.addTransform('sum', (arr: number[]) => arr.reduce((a, b) => a + b, 0)) * await jexl.eval('[1, 2, 3, 4] | sum') // 10 * * // Chaining transforms * jexl.addTransform('sort', (arr: number[]) => [...arr].sort((a, b) => a - b)) * await jexl.eval('[3, 1, 4, 2] | sort | sum') // 10 * ``` */ addTransform(name: string, fn: TransformFunction) { this.grammar.transforms[name] = fn } /** * Convenience method for adding multiple transforms at once. * Equivalent to calling `addTransform()` for each key-value pair in the map. * * @param map Object mapping transform names to their implementations * * @example * ```typescript * jexl.addTransforms({ * // String transforms * upper: (str: string) => str.toUpperCase(), * lower: (str: string) => str.toLowerCase(), * trim: (str: string) => str.trim(), * * // Number transforms * abs: (num: number) => Math.abs(num), * round: (num: number, places = 0) => Number(num.toFixed(places)), * * // Array transforms * reverse: (arr: unknown[]) => [...arr].reverse(), * unique: (arr: unknown[]) => [...new Set(arr)] * }) * * // Usage examples * await jexl.eval('" Hello World " | trim | upper') // "HELLO WORLD" * await jexl.eval('3.14159 | round(2)') // 3.14 * await jexl.eval('[1, 2, 2, 3] | unique') // [1, 2, 3] * ``` */ addTransforms(map: Record<string, TransformFunction>) { for (const key in map) { if (Object.prototype.hasOwnProperty.call(map, key)) { const fn = map[key] if (fn) { this.grammar.transforms[key] = fn } } } } /** * Creates an Expression object from the given Jexl expression string and * immediately compiles it into an Abstract Syntax Tree (AST). * * Compilation parses the expression once, creating an optimized representation * that can be evaluated multiple times with different contexts without * re-parsing the original string. * * @param expression The Jexl expression string to compile * @returns A compiled Expression object ready for evaluation * * @example * ```typescript * // Compile an expression for reuse * const compiled = jexl.compile('user.orders | length') * * // Evaluate with different contexts * await compiled.eval({ user: { orders: [1, 2, 3] } }) // 3 * await compiled.eval({ user: { orders: [1, 2] } }) // 2 * await compiled.eval({ user: { orders: [] } }) // 0 * * // Complex expressions benefit most from compilation * const complexExpr = jexl.compile( * 'items[price > 100] | map("name") | sort | join(", ")' * ) * * // Use with different datasets * const result1 = await complexExpr.eval({ items: expensiveItems }) * const result2 = await complexExpr.eval({ items: luxuryItems }) * ``` */ compile<R>(expression: string): Expression<R> { const exprObj = this.createExpression<R>(expression) return exprObj.compile() } /** * Creates an Expression object from a Jexl expression string without compiling it. * The expression will be compiled automatically when first evaluated. * * Use this method when you want to create an expression object but defer * compilation until evaluation time, or when you need to access the raw * expression string before compilation. * * @param expression The Jexl expression string to wrap * @returns An Expression object (not yet compiled) * * @example * ```typescript * // Create expression without immediate compilation * const expr = jexl.createExpression('user.name | upper') * * // Compilation happens automatically on first eval * await expr.eval({ user: { name: 'john' } }) // 'JOHN' * * // Or compile explicitly when ready * expr.compile() * await expr.eval({ user: { name: 'jane' } }) // 'JANE' * * // Useful for conditional compilation * const expressions = [ * jexl.createExpression('simple'), * jexl.createExpression('complex | operation') * ] * // Compile only the ones you need * expressions[1].compile() * ``` */ createExpression<R = unknown>(expression: string): Expression<R> { return new Expression<R>(this.grammar, expression) } /** * Retrieves a previously registered expression function by name. * Useful for introspection, testing, or calling functions programmatically. * * @param name The name of the expression function * @returns The function implementation * @throws {Error} if the function is not found * * @example * ```typescript * // Register a function * jexl.addFunction('max', (...args: number[]) => Math.max(...args)) * * // Retrieve and use it directly * const maxFn = jexl.getFunction('max') * maxFn(1, 5, 3) // 5 * * // Check if function exists before using * try { * const myFn = jexl.getFunction('customFunction') * // Function exists, safe to use * } catch (error) { * // Function doesn't exist, handle gracefully * console.log('Function not found') * } * ``` */ getFunction(name: string): FunctionFunction { const fn = this.grammar.functions[name] if (!fn) { throw new Error(`Function '${name}' is not defined`) } return fn } /** * Retrieves a previously registered transform function by name. * Useful for introspection, testing, or calling transforms programmatically. * * @param name The name of the transform function * @returns The transform function implementation * @throws {Error} if the transform is not found * * @example * ```typescript * // Register a transform * jexl.addTransform('double', (val: number) => val * 2) * * // Retrieve and use it directly * const doubleFn = jexl.getTransform('double') * doubleFn(5) // 10 * * // Use for programmatic transformation * const upperFn = jexl.getTransform('upper') * const names = ['john', 'jane', 'bob'] * const upperNames = names.map(upperFn) // ['JOHN', 'JANE', 'BOB'] * * // Conditional transform application * try { * const customTransform = jexl.getTransform('customTransform') * result = customTransform(value) * } catch (error) { * result = value // fallback if transform doesn't exist * } * ``` */ getTransform(name: string): TransformFunction { const fn = this.grammar.transforms[name] if (!fn) { throw new Error(`Transform '${name}' is not defined`) } return fn } /** * Evaluates a Jexl expression string with optional context data. * This is a convenience method that creates and evaluates an expression in one call. * * For repeated evaluations of the same expression, consider using `compile()` * for better performance as it avoids re-parsing the expression string. * * @param expression The Jexl expression string to evaluate * @param context Variables and values accessible within the expression * @returns Promise resolving to the evaluation result * * @example * ```typescript * // Simple evaluation * await jexl.eval('2 + 3') // 5 * * // With context data * await jexl.eval('user.name', { * user: { name: 'Alice', age: 30 } * }) // 'Alice' * * // Complex expressions with transforms * await jexl.eval('users | map("name") | join(", ")', { * users: [ * { name: 'Alice', age: 30 }, * { name: 'Bob', age: 25 } * ] * }) // 'Alice, Bob' * * // Conditional logic * await jexl.eval('age >= 18 ? "adult" : "minor"', { age: 20 }) // 'adult' * * // Array filtering and processing * await jexl.eval('products[price < 100] | length', { * products: [ * { name: 'Book', price: 15 }, * { name: 'Phone', price: 300 }, * { name: 'Pen', price: 2 } * ] * }) // 2 * ``` */ eval<R = unknown>(expression: string, context: Context = {}): Promise<R> { const exprObj = this.createExpression<R>(expression) return exprObj.eval(context) } /** * Evaluates a Jexl expression and coerces the result to a string. * @param expression The Jexl expression string to evaluate. * @param context Optional context object. * @returns A promise that resolves to the string result. */ evalAsString(expression: string, context: Context = {}): Promise<string> { const exprObj = this.createExpression<string>(expression) return exprObj.evalAsString(context) } /** * Evaluates a Jexl expression and coerces the result to a number. * `null` and `undefined` results are coerced to `NaN`. * @param expression The Jexl expression string to evaluate. * @param context Optional context object. * @returns A promise that resolves to the number result. */ evalAsNumber(expression: string, context: Context = {}): Promise<number> { const exprObj = this.createExpression<number>(expression) return exprObj.evalAsNumber(context) } /** * Evaluates a Jexl expression and coerces the result to a boolean. * Uses standard JavaScript truthiness rules. * @param expression The Jexl expression string to evaluate. * @param context Optional context object. * @returns A promise that resolves to the boolean result. */ evalAsBoolean(expression: string, context: Context = {}): Promise<boolean> { const exprObj = this.createExpression<boolean>(expression) return exprObj.evalAsBoolean(context) } /** * Evaluates a Jexl 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. * @template T The expected type of elements in the array. * @param expression The Jexl expression string to evaluate. * @param context Optional context object. * @returns A promise that resolves with the result as an array. */ evalAsArray<T = unknown>(expression: string, context: Context = {}): Promise<T[]> { // The expression can return a single item of type T, or an array of T. // We set the expression's expected type R to T | T[] to cover both cases. const exprObj = this.createExpression<T>(expression) return exprObj.evalAsArray(context) as Promise<T[]> } /** * Evaluates a Jexl expression and validates it against a list of allowed values. * @template T The type of the enum values. * @param expression The Jexl expression string to evaluate. * @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`. */ evalAsEnum<T = string | number | boolean>( expression: string, context: Context = {}, allowedValues: readonly T[] ): Promise<T | undefined> { const exprObj = this.createExpression<T>(expression) return exprObj.evalAsEnum(context, allowedValues) } /** * Evaluates a Jexl 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 expression The Jexl expression string to evaluate. * @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. */ evalWithDefault<T = unknown>(expression: string, context: Context = {}, defaultValue: T): Promise<T> { const exprObj = this.createExpression<T>(expression) return exprObj.evalWithDefault(context, defaultValue) } /** * Template literal function for creating Jexl expressions with embedded JavaScript values. * Allows you to interpolate variables directly into expression strings using template syntax. * * The interpolated values are converted to strings and embedded directly into the expression, * so be careful with user input to avoid injection issues. * * @param strings The template string parts * @param args The interpolated values * @returns An Expression object ready for evaluation * * @example * ```typescript * // Basic interpolation * const threshold = 100 * const expr = jexl.expr`price > ${threshold}` * await expr.eval({ price: 150 }) // true * * // Multiple interpolations * const field = 'name' * const value = 'John' * const filterExpr = jexl.expr`users[${field} == "${value}"]` * await filterExpr.eval({ * users: [ * { name: 'John', age: 30 }, * { name: 'Jane', age: 25 } * ] * }) // [{ name: 'John', age: 30 }] * * // Dynamic property access * const property = 'email' * const userExpr = jexl.expr`user.${property}` * await userExpr.eval({ * user: { name: 'Alice', email: 'alice@example.com' } * }) // 'alice@example.com' * * // Building expressions programmatically * const operations = ['upper', 'trim'] * const transformChain = operations.join(' | ') * const dynamicExpr = jexl.expr`input | ${transformChain}` * await dynamicExpr.eval({ input: ' hello world ' }) // 'HELLO WORLD' * ``` */ expr(strings: string[] | TemplateStringsArray, ...args: unknown[]): Expression { const exprStr = strings.reduce((acc, str, idx) => { const arg = idx < args.length ? args[idx] : '' acc += str + arg return acc }, '') return this.createExpression(exprStr) } /** * Removes a binary or unary operator from the Jexl grammar. * This permanently removes the operator from this Jexl instance, making it * unavailable for use in future expressions. * * @param operator The operator string to remove (e.g., '+', '&&', '!') * * @example * ```typescript * // Remove the modulo operator * jexl.removeOp('%') * * // This will now throw an error * try { * await jexl.eval('10 % 3') * } catch (error) { * console.log('Modulo operator not available') * } * * // Remove multiple operators * jexl.removeOp('+') * jexl.removeOp('-') * jexl.removeOp('!') * * // Create a restricted expression environment * const restrictedJexl = new Jexl() * restrictedJexl.removeOp('*') // No multiplication * restrictedJexl.removeOp('/') // No division * // Only allow safe operations * ``` */ removeOp(operator: string): void { if ( Object.prototype.hasOwnProperty.call(this.grammar.elements, operator) && (this.grammar.elements[operator].type === 'binaryOp' || this.grammar.elements[operator].type === 'unaryOp') ) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.grammar.elements[operator] } } /** * Adds an element to the grammar map used by this Jexl instance. * @param str The key string to be added * @param obj A map of configuration options for this grammar element * @private */ private _addGrammarElement(str: string, obj: GrammarElement): void { this.grammar.elements[str] = obj } }