my-test-app-builder-2024/src/common/utils/expressionEvaluator.ts

156 lines
4.9 KiB
TypeScript

/**
* Safe JavaScript expression evaluator with state context
*/
export interface EvaluationContext {
state: any;
}
/**
* Create a deep proxy that returns safe defaults for undefined properties
*
* This prevents "undefined" from appearing in expressions when properties don't exist.
* Instead of `undefined + "1"` becoming `"undefined1"`, it becomes `"" + "1"` = `"1"`.
*
* @param state - The state object to wrap
* @returns A proxied state object that returns "" for undefined properties
*
* @example
* const safeState = createSafeStateProxy({ page: { user: { name: "Jon" } }, global: {} });
* safeState.page.user.mobileNumber // Returns "" instead of undefined
* safeState.page.user.name // Returns "Jon" (existing values unchanged)
*/
const createSafeStateProxy = (state: any): any => {
// Handle null/undefined at the root
if (state === null || state === undefined) {
return "";
}
// Don't proxy primitives or special objects
if (typeof state !== 'object' || state instanceof Date || state instanceof RegExp) {
return state;
}
// Handle arrays specially
if (Array.isArray(state)) {
return new Proxy(state, {
get(target, prop) {
// Array methods and properties
if (prop === 'length' || typeof target[prop as any] === 'function') {
return target[prop as any];
}
const value = target[prop as any];
// If index exists, return proxied value
if (value !== undefined) {
if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
return createSafeStateProxy(value);
}
return value;
}
// Out of bounds index - return empty string
return "";
}
});
}
// Proxy objects
return new Proxy(state, {
get(target, prop) {
const value = target[prop];
// If property exists, return it (wrapped in proxy if it's an object)
if (value !== undefined) {
// Recursively wrap objects/arrays in proxy
if (typeof value === 'object' && value !== null && !(value instanceof Date) && !(value instanceof RegExp)) {
return createSafeStateProxy(value);
}
return value;
}
// Property is undefined - return empty string as safe default
// This prevents "undefined" from appearing in string concatenations
return "";
}
});
};
/**
* Evaluate a JavaScript expression with state context
*
* @param expression - JavaScript expression as string (e.g., "page.user.age + 1")
* @param context - Evaluation context with state object
* @returns Evaluated result
* @throws Error if evaluation fails
*
* @example
* evaluateExpression("page.user.name", { state: { page: { user: { name: "Jon" } }, global: {} } })
* // Returns: "Jon"
*
* evaluateExpression("global.counter + 1", { state: { page: {}, global: { counter: 25 } } })
* // Returns: 26
*
* evaluateExpression("state.page.user.name.toUpperCase()", { state: { page: { user: { name: "Jon" } }, global: {} } })
* // Returns: "JON"
*/
export const evaluateExpression = (
expression: string,
context: EvaluationContext
): any => {
// Validate input
if (!expression || typeof expression !== 'string') {
throw new Error('Expression must be a non-empty string');
}
const trimmedExpression = expression.trim();
if (!trimmedExpression) {
throw new Error('Expression cannot be empty');
}
try {
// Wrap state and aliases in safe proxies that return defaults for undefined properties
// This prevents "undefined" from appearing in expressions
// Example: state.page.user.mobileNumber returns "" instead of undefined
const safeState = createSafeStateProxy(context.state);
const safePage = createSafeStateProxy(context?.state?.page ?? {});
const safeGlobal = createSafeStateProxy(context?.state?.global ?? {});
// Create a function with scoped aliases in scope
// This is safe because:
// 1. This is a builder tool (not end-user facing)
// 2. Expressions are written by trusted builder users
// 3. Expressions run in isolated preview context
const func = new Function('state', 'page', 'global', `return (${trimmedExpression});`);
return func(safeState, safePage, safeGlobal);
} catch (error: any) {
throw new Error(`Expression evaluation failed: ${error?.message || String(error)}`);
}
};
/**
* Validate if an expression is syntactically correct (without executing it)
*
* @param expression - JavaScript expression to validate
* @returns true if valid, false otherwise
*/
export const validateExpression = (expression: string): boolean => {
if (!expression || typeof expression !== 'string') {
return false;
}
const trimmedExpression = expression.trim();
if (!trimmedExpression) {
return false;
}
try {
// Try to create the function without executing it
new Function('state', 'page', 'global', `return (${trimmedExpression});`);
return true;
} catch {
return false;
}
};