Skip to content

Commit df5a9a5

Browse files
Copilothotlong
andcommitted
Add expression caching and schema validation helpers
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 054112f commit df5a9a5

5 files changed

Lines changed: 405 additions & 11 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* @object-ui/core - Expression Cache
11+
*
12+
* Caches compiled expressions to avoid re-parsing on every render.
13+
* Provides significant performance improvement for frequently evaluated expressions.
14+
*
15+
* @module evaluator
16+
* @packageDocumentation
17+
*/
18+
19+
/**
20+
* A compiled expression function that can be executed with context values
21+
*/
22+
export type CompiledExpression = (...args: any[]) => any;
23+
24+
/**
25+
* Expression compilation metadata
26+
*/
27+
export interface ExpressionMetadata {
28+
/**
29+
* The compiled function
30+
*/
31+
fn: CompiledExpression;
32+
33+
/**
34+
* Variable names used in the expression
35+
*/
36+
varNames: string[];
37+
38+
/**
39+
* Original expression string
40+
*/
41+
expression: string;
42+
43+
/**
44+
* Timestamp when the expression was compiled
45+
*/
46+
compiledAt: number;
47+
48+
/**
49+
* Number of times this expression has been used
50+
*/
51+
hitCount: number;
52+
}
53+
54+
/**
55+
* Cache for compiled expressions
56+
*
57+
* @example
58+
* ```ts
59+
* const cache = new ExpressionCache();
60+
* const compiled = cache.compile('data.amount > 1000', ['data']);
61+
* const result = compiled({ amount: 1500 }); // true
62+
* ```
63+
*/
64+
export class ExpressionCache {
65+
private cache = new Map<string, ExpressionMetadata>();
66+
private maxSize: number;
67+
68+
/**
69+
* Create a new expression cache
70+
*
71+
* @param maxSize Maximum number of expressions to cache (default: 1000)
72+
*/
73+
constructor(maxSize = 1000) {
74+
this.maxSize = maxSize;
75+
}
76+
77+
/**
78+
* Compile an expression or retrieve from cache
79+
*
80+
* @param expr The expression to compile
81+
* @param varNames Variable names available in the context
82+
* @returns Compiled expression metadata
83+
*/
84+
compile(expr: string, varNames: string[]): ExpressionMetadata {
85+
// Create a cache key that includes variable names to ensure correct scoping
86+
const cacheKey = `${expr}::${varNames.join(',')}`;
87+
88+
if (this.cache.has(cacheKey)) {
89+
const metadata = this.cache.get(cacheKey)!;
90+
metadata.hitCount++;
91+
return metadata;
92+
}
93+
94+
// Evict least recently used if cache is full
95+
if (this.cache.size >= this.maxSize) {
96+
this.evictLRU();
97+
}
98+
99+
// Compile the expression
100+
const fn = this.compileExpression(expr, varNames);
101+
102+
const metadata: ExpressionMetadata = {
103+
fn,
104+
varNames: [...varNames],
105+
expression: expr,
106+
compiledAt: Date.now(),
107+
hitCount: 1,
108+
};
109+
110+
this.cache.set(cacheKey, metadata);
111+
return metadata;
112+
}
113+
114+
/**
115+
* Compile an expression into a function
116+
*/
117+
private compileExpression(expression: string, varNames: string[]): CompiledExpression {
118+
// SECURITY NOTE: Using Function constructor for expression evaluation.
119+
// This is a controlled use case with:
120+
// 1. Sanitization check (isDangerous) performed by caller
121+
// 2. Strict mode enabled ("use strict")
122+
// 3. Limited scope (only varNames variables available)
123+
// 4. No access to global objects (process, window, etc.)
124+
return new Function(...varNames, `"use strict"; return (${expression});`) as CompiledExpression;
125+
}
126+
127+
/**
128+
* Evict the least recently used expression from cache
129+
*/
130+
private evictLRU(): void {
131+
let oldestKey: string | null = null;
132+
let oldestTime = Infinity;
133+
let lowestHits = Infinity;
134+
135+
// Find the entry with lowest hit count, or oldest if tied
136+
for (const [key, metadata] of this.cache.entries()) {
137+
if (metadata.hitCount < lowestHits ||
138+
(metadata.hitCount === lowestHits && metadata.compiledAt < oldestTime)) {
139+
oldestKey = key;
140+
oldestTime = metadata.compiledAt;
141+
lowestHits = metadata.hitCount;
142+
}
143+
}
144+
145+
if (oldestKey) {
146+
this.cache.delete(oldestKey);
147+
}
148+
}
149+
150+
/**
151+
* Check if an expression is cached
152+
*/
153+
has(expr: string, varNames: string[]): boolean {
154+
const cacheKey = `${expr}::${varNames.join(',')}`;
155+
return this.cache.has(cacheKey);
156+
}
157+
158+
/**
159+
* Clear the cache
160+
*/
161+
clear(): void {
162+
this.cache.clear();
163+
}
164+
165+
/**
166+
* Get cache statistics
167+
*/
168+
getStats(): {
169+
size: number;
170+
maxSize: number;
171+
totalHits: number;
172+
entries: Array<{ expression: string; hitCount: number }>;
173+
} {
174+
let totalHits = 0;
175+
const entries: Array<{ expression: string; hitCount: number }> = [];
176+
177+
for (const metadata of this.cache.values()) {
178+
totalHits += metadata.hitCount;
179+
entries.push({
180+
expression: metadata.expression,
181+
hitCount: metadata.hitCount,
182+
});
183+
}
184+
185+
return {
186+
size: this.cache.size,
187+
maxSize: this.maxSize,
188+
totalHits,
189+
entries: entries.sort((a, b) => b.hitCount - a.hitCount),
190+
};
191+
}
192+
}

packages/core/src/evaluator/ExpressionEvaluator.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*/
1818

1919
import { ExpressionContext } from './ExpressionContext';
20+
import { ExpressionCache } from './ExpressionCache';
2021

2122
/**
2223
* Options for expression evaluation
@@ -45,13 +46,17 @@ export interface EvaluationOptions {
4546
*/
4647
export class ExpressionEvaluator {
4748
private context: ExpressionContext;
49+
private cache: ExpressionCache;
4850

49-
constructor(context?: ExpressionContext | Record<string, any>) {
51+
constructor(context?: ExpressionContext | Record<string, any>, cache?: ExpressionCache) {
5052
if (context instanceof ExpressionContext) {
5153
this.context = context;
5254
} else {
5355
this.context = new ExpressionContext(context || {});
5456
}
57+
58+
// Use provided cache or create a new one
59+
this.cache = cache || new ExpressionCache();
5560
}
5661

5762
/**
@@ -138,17 +143,11 @@ export class ExpressionEvaluator {
138143
const varNames = Object.keys(contextObj);
139144
const varValues = Object.values(contextObj);
140145

141-
// SECURITY NOTE: Using Function constructor for expression evaluation.
142-
// This is a controlled use case with:
143-
// 1. Sanitization check (isDangerous) blocks dangerous patterns
144-
// 2. Strict mode enabled ("use strict")
145-
// 3. Limited scope (only contextObj variables available)
146-
// 4. No access to global objects (process, window, etc.)
147-
// For production use, consider: expr-eval, safe-eval, or a custom parser
148-
const fn = new Function(...varNames, `"use strict"; return (${expression});`);
146+
// Use cached compilation
147+
const compiled = this.cache.compile(expression, varNames);
149148

150149
// Execute with context values
151-
return fn(...varValues);
150+
return compiled.fn(...varValues);
152151
} catch (error) {
153152
throw new Error(`Failed to evaluate expression "${expression}": ${(error as Error).message}`);
154153
}
@@ -220,7 +219,22 @@ export class ExpressionEvaluator {
220219
* Create a new evaluator with additional context data
221220
*/
222221
withContext(data: Record<string, any>): ExpressionEvaluator {
223-
return new ExpressionEvaluator(this.context.createChild(data));
222+
// Share the cache with the new evaluator for maximum efficiency
223+
return new ExpressionEvaluator(this.context.createChild(data), this.cache);
224+
}
225+
226+
/**
227+
* Get cache statistics (useful for debugging and optimization)
228+
*/
229+
getCacheStats() {
230+
return this.cache.getStats();
231+
}
232+
233+
/**
234+
* Clear the expression cache
235+
*/
236+
clearCache(): void {
237+
this.cache.clear();
224238
}
225239
}
226240

0 commit comments

Comments
 (0)