@objectql/core 1.8.4 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/formula-engine.d.ts +95 -0
- package/dist/formula-engine.js +426 -0
- package/dist/formula-engine.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/repository.d.ts +6 -0
- package/dist/repository.js +65 -2
- package/dist/repository.js.map +1 -1
- package/package.json +2 -2
- package/src/formula-engine.ts +564 -0
- package/src/index.ts +1 -0
- package/src/repository.ts +80 -3
- package/test/formula-engine.test.ts +717 -0
- package/test/formula-integration.test.ts +278 -0
- package/test/mock-driver.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formula Engine Implementation
|
|
3
|
+
*
|
|
4
|
+
* Evaluates formula expressions defined in object metadata.
|
|
5
|
+
* Formulas are read-only calculated fields computed at query time.
|
|
6
|
+
*
|
|
7
|
+
* @see @objectql/types/formula for type definitions
|
|
8
|
+
* @see docs/spec/formula.md for complete specification
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
FormulaContext,
|
|
13
|
+
FormulaEvaluationResult,
|
|
14
|
+
FormulaEvaluationOptions,
|
|
15
|
+
FormulaError,
|
|
16
|
+
FormulaErrorType,
|
|
17
|
+
FormulaValue,
|
|
18
|
+
FormulaDataType,
|
|
19
|
+
FormulaMetadata,
|
|
20
|
+
FormulaEngineConfig,
|
|
21
|
+
FormulaCustomFunction,
|
|
22
|
+
} from '@objectql/types';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Formula Engine for evaluating JavaScript-style expressions
|
|
26
|
+
*
|
|
27
|
+
* Features:
|
|
28
|
+
* - Field references (e.g., `quantity * unit_price`)
|
|
29
|
+
* - System variables (e.g., `$today`, `$current_user.id`)
|
|
30
|
+
* - Lookup chains (e.g., `account.owner.name`)
|
|
31
|
+
* - Built-in functions (Math, String, Date methods)
|
|
32
|
+
* - Conditional logic (ternary, if/else)
|
|
33
|
+
* - Safe sandbox execution
|
|
34
|
+
*/
|
|
35
|
+
export class FormulaEngine {
|
|
36
|
+
private config: FormulaEngineConfig;
|
|
37
|
+
private customFunctions: Record<string, FormulaCustomFunction>;
|
|
38
|
+
|
|
39
|
+
constructor(config: FormulaEngineConfig = {}) {
|
|
40
|
+
this.config = {
|
|
41
|
+
enable_cache: config.enable_cache ?? false,
|
|
42
|
+
cache_ttl: config.cache_ttl ?? 300,
|
|
43
|
+
max_execution_time: config.max_execution_time ?? 0, // 0 means no timeout enforcement
|
|
44
|
+
enable_monitoring: config.enable_monitoring ?? false,
|
|
45
|
+
custom_functions: config.custom_functions ?? {},
|
|
46
|
+
sandbox: {
|
|
47
|
+
enabled: config.sandbox?.enabled ?? true,
|
|
48
|
+
allowed_globals: config.sandbox?.allowed_globals ?? ['Math', 'String', 'Number', 'Boolean', 'Date', 'Array', 'Object'],
|
|
49
|
+
blocked_operations: config.sandbox?.blocked_operations ?? ['eval', 'Function', 'require', 'import'],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
this.customFunctions = this.config.custom_functions || {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Evaluate a formula expression
|
|
57
|
+
*
|
|
58
|
+
* @param expression - The JavaScript expression to evaluate
|
|
59
|
+
* @param context - Runtime context with record data, system variables, user context
|
|
60
|
+
* @param dataType - Expected return data type
|
|
61
|
+
* @param options - Evaluation options
|
|
62
|
+
* @returns Evaluation result with value, type, success flag, and optional error
|
|
63
|
+
*/
|
|
64
|
+
evaluate(
|
|
65
|
+
expression: string,
|
|
66
|
+
context: FormulaContext,
|
|
67
|
+
dataType: FormulaDataType,
|
|
68
|
+
options: FormulaEvaluationOptions = {}
|
|
69
|
+
): FormulaEvaluationResult {
|
|
70
|
+
const startTime = Date.now();
|
|
71
|
+
const timeout = options.timeout ?? this.config.max_execution_time ?? 0;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Validate expression
|
|
75
|
+
if (!expression || expression.trim() === '') {
|
|
76
|
+
throw new FormulaError(
|
|
77
|
+
FormulaErrorType.SYNTAX_ERROR,
|
|
78
|
+
'Formula expression cannot be empty',
|
|
79
|
+
expression
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Prepare evaluation context
|
|
84
|
+
const evalContext = this.buildEvaluationContext(context);
|
|
85
|
+
|
|
86
|
+
// Execute expression with timeout
|
|
87
|
+
const value = this.executeExpression(expression, evalContext, timeout, options);
|
|
88
|
+
|
|
89
|
+
// Validate and coerce result type
|
|
90
|
+
const coercedValue = this.coerceValue(value, dataType, expression);
|
|
91
|
+
|
|
92
|
+
const executionTime = Date.now() - startTime;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
value: coercedValue,
|
|
96
|
+
type: dataType,
|
|
97
|
+
success: true,
|
|
98
|
+
execution_time: executionTime,
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const executionTime = Date.now() - startTime;
|
|
102
|
+
|
|
103
|
+
if (error instanceof FormulaError) {
|
|
104
|
+
return {
|
|
105
|
+
value: null,
|
|
106
|
+
type: dataType,
|
|
107
|
+
success: false,
|
|
108
|
+
error: error.message,
|
|
109
|
+
stack: error.stack,
|
|
110
|
+
execution_time: executionTime,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Wrap unknown errors
|
|
115
|
+
return {
|
|
116
|
+
value: null,
|
|
117
|
+
type: dataType,
|
|
118
|
+
success: false,
|
|
119
|
+
error: error instanceof Error ? error.message : String(error),
|
|
120
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
121
|
+
execution_time: executionTime,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the evaluation context from FormulaContext
|
|
128
|
+
* Creates a safe object with field values, system variables, and user context
|
|
129
|
+
*/
|
|
130
|
+
private buildEvaluationContext(context: FormulaContext): Record<string, any> {
|
|
131
|
+
const evalContext: Record<string, any> = {};
|
|
132
|
+
|
|
133
|
+
// Add all record fields to context
|
|
134
|
+
for (const [key, value] of Object.entries(context.record)) {
|
|
135
|
+
evalContext[key] = value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Add system variables with $ prefix
|
|
139
|
+
evalContext.$today = context.system.today;
|
|
140
|
+
evalContext.$now = context.system.now;
|
|
141
|
+
evalContext.$year = context.system.year;
|
|
142
|
+
evalContext.$month = context.system.month;
|
|
143
|
+
evalContext.$day = context.system.day;
|
|
144
|
+
evalContext.$hour = context.system.hour;
|
|
145
|
+
evalContext.$minute = context.system.minute;
|
|
146
|
+
evalContext.$second = context.system.second;
|
|
147
|
+
|
|
148
|
+
// Add current user context
|
|
149
|
+
evalContext.$current_user = context.current_user;
|
|
150
|
+
|
|
151
|
+
// Add record context flags
|
|
152
|
+
evalContext.$is_new = context.is_new;
|
|
153
|
+
evalContext.$record_id = context.record_id;
|
|
154
|
+
|
|
155
|
+
// Add custom functions
|
|
156
|
+
for (const [name, func] of Object.entries(this.customFunctions)) {
|
|
157
|
+
evalContext[name] = func;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return evalContext;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Execute the expression in a sandboxed environment
|
|
165
|
+
*
|
|
166
|
+
* SECURITY NOTE: Uses Function constructor for dynamic evaluation.
|
|
167
|
+
* While we check for blocked operations, this is not a complete security sandbox.
|
|
168
|
+
* For production use with untrusted formulas, consider using a proper sandboxing library
|
|
169
|
+
* like vm2 or implementing an AST-based evaluator.
|
|
170
|
+
*/
|
|
171
|
+
private executeExpression(
|
|
172
|
+
expression: string,
|
|
173
|
+
context: Record<string, any>,
|
|
174
|
+
timeout: number,
|
|
175
|
+
options: FormulaEvaluationOptions
|
|
176
|
+
): FormulaValue {
|
|
177
|
+
// Check for blocked operations
|
|
178
|
+
// NOTE: This is a basic check using string matching. It will have false positives
|
|
179
|
+
// (e.g., a field named 'evaluation' contains 'eval') and can be bypassed
|
|
180
|
+
// (e.g., using this['eval'] or globalThis['eval']).
|
|
181
|
+
// For production security with untrusted formulas, use AST parsing or vm2.
|
|
182
|
+
if (this.config.sandbox?.enabled) {
|
|
183
|
+
const blockedOps = this.config.sandbox.blocked_operations || [];
|
|
184
|
+
for (const op of blockedOps) {
|
|
185
|
+
if (expression.includes(op)) {
|
|
186
|
+
throw new FormulaError(
|
|
187
|
+
FormulaErrorType.SECURITY_VIOLATION,
|
|
188
|
+
`Blocked operation detected: ${op}`,
|
|
189
|
+
expression
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
// Create function parameters from context keys
|
|
197
|
+
const paramNames = Object.keys(context);
|
|
198
|
+
const paramValues = Object.values(context);
|
|
199
|
+
|
|
200
|
+
// Wrap expression to handle both expression-style and statement-style formulas
|
|
201
|
+
const wrappedExpression = this.wrapExpression(expression);
|
|
202
|
+
|
|
203
|
+
// Create and execute function
|
|
204
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
205
|
+
const func = new Function(...paramNames, wrappedExpression);
|
|
206
|
+
|
|
207
|
+
// Execute with timeout protection
|
|
208
|
+
const result = this.executeWithTimeout(func, paramValues, timeout);
|
|
209
|
+
|
|
210
|
+
return result as FormulaValue;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof FormulaError) {
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Parse JavaScript errors
|
|
217
|
+
const err = error as Error;
|
|
218
|
+
|
|
219
|
+
if (error instanceof ReferenceError) {
|
|
220
|
+
throw new FormulaError(
|
|
221
|
+
FormulaErrorType.FIELD_NOT_FOUND,
|
|
222
|
+
`Referenced field not found: ${err.message}`,
|
|
223
|
+
expression,
|
|
224
|
+
{ original_error: err.message }
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (error instanceof TypeError) {
|
|
229
|
+
throw new FormulaError(
|
|
230
|
+
FormulaErrorType.TYPE_ERROR,
|
|
231
|
+
`Type error in formula: ${err.message}`,
|
|
232
|
+
expression,
|
|
233
|
+
{ original_error: err.message }
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (error instanceof SyntaxError) {
|
|
238
|
+
throw new FormulaError(
|
|
239
|
+
FormulaErrorType.SYNTAX_ERROR,
|
|
240
|
+
`Syntax error in formula: ${err.message}`,
|
|
241
|
+
expression,
|
|
242
|
+
{ original_error: err.message }
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
throw new FormulaError(
|
|
247
|
+
FormulaErrorType.RUNTIME_ERROR,
|
|
248
|
+
`Runtime error: ${error instanceof Error ? err.message : String(error)}`,
|
|
249
|
+
expression,
|
|
250
|
+
{ original_error: error instanceof Error ? err.message : String(error) }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Wrap expression to handle both expression and statement styles
|
|
257
|
+
*/
|
|
258
|
+
private wrapExpression(expression: string): string {
|
|
259
|
+
const trimmed = expression.trim();
|
|
260
|
+
|
|
261
|
+
// If it contains a return statement or is a block, use as-is
|
|
262
|
+
if (trimmed.includes('return ') || (trimmed.startsWith('{') && trimmed.endsWith('}'))) {
|
|
263
|
+
return trimmed;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// If it's multi-line with if/else, wrap in a function body
|
|
267
|
+
if (trimmed.includes('\n') || trimmed.match(/if\s*\(/)) {
|
|
268
|
+
return trimmed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Otherwise, treat as expression and return it
|
|
272
|
+
return `return (${trimmed});`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Execute function with timeout protection
|
|
277
|
+
*
|
|
278
|
+
* NOTE: This synchronous implementation **cannot** pre-emptively interrupt execution.
|
|
279
|
+
* To avoid giving a false sense of safety, any positive finite timeout configuration
|
|
280
|
+
* is rejected up-front. Callers must not rely on timeout-based protection in this
|
|
281
|
+
* runtime; instead, formulas must be written to be fast and side-effect free.
|
|
282
|
+
*/
|
|
283
|
+
private executeWithTimeout(
|
|
284
|
+
func: Function,
|
|
285
|
+
args: any[],
|
|
286
|
+
timeout: number
|
|
287
|
+
): unknown {
|
|
288
|
+
// Reject any positive finite timeout to avoid misleading "protection" semantics.
|
|
289
|
+
if (Number.isFinite(timeout) && timeout > 0) {
|
|
290
|
+
throw new FormulaError(
|
|
291
|
+
FormulaErrorType.TIMEOUT,
|
|
292
|
+
'Formula timeout enforcement is not supported for synchronous execution. ' +
|
|
293
|
+
'Remove the timeout configuration or migrate to an async/isolated runtime ' +
|
|
294
|
+
'that can safely interrupt long-running formulas.',
|
|
295
|
+
'',
|
|
296
|
+
{ requestedTimeoutMs: timeout }
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// No timeout configured (or non-positive/invalid value): execute directly.
|
|
301
|
+
return func(...args);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Coerce the result value to the expected data type
|
|
306
|
+
*/
|
|
307
|
+
private coerceValue(value: unknown, dataType: FormulaDataType, expression: string): FormulaValue {
|
|
308
|
+
// Handle null/undefined
|
|
309
|
+
if (value === null || value === undefined) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
switch (dataType) {
|
|
315
|
+
case 'number':
|
|
316
|
+
case 'currency':
|
|
317
|
+
case 'percent':
|
|
318
|
+
if (typeof value === 'number') {
|
|
319
|
+
// Check for division by zero result
|
|
320
|
+
if (!isFinite(value)) {
|
|
321
|
+
throw new FormulaError(
|
|
322
|
+
FormulaErrorType.DIVISION_BY_ZERO,
|
|
323
|
+
'Formula resulted in Infinity or NaN (possible division by zero)',
|
|
324
|
+
expression
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
if (typeof value === 'string') {
|
|
330
|
+
const num = Number(value);
|
|
331
|
+
if (isNaN(num)) {
|
|
332
|
+
throw new FormulaError(
|
|
333
|
+
FormulaErrorType.TYPE_ERROR,
|
|
334
|
+
`Cannot convert "${value}" to number`,
|
|
335
|
+
expression
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return num;
|
|
339
|
+
}
|
|
340
|
+
if (typeof value === 'boolean') {
|
|
341
|
+
return value ? 1 : 0;
|
|
342
|
+
}
|
|
343
|
+
throw new FormulaError(
|
|
344
|
+
FormulaErrorType.TYPE_ERROR,
|
|
345
|
+
`Expected number, got ${typeof value}`,
|
|
346
|
+
expression
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
case 'text':
|
|
350
|
+
return String(value);
|
|
351
|
+
|
|
352
|
+
case 'boolean':
|
|
353
|
+
return Boolean(value);
|
|
354
|
+
|
|
355
|
+
case 'date':
|
|
356
|
+
case 'datetime':
|
|
357
|
+
if (value instanceof Date) {
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
if (typeof value === 'string') {
|
|
361
|
+
const date = new Date(value);
|
|
362
|
+
if (isNaN(date.getTime())) {
|
|
363
|
+
throw new FormulaError(
|
|
364
|
+
FormulaErrorType.TYPE_ERROR,
|
|
365
|
+
`Cannot convert "${value}" to date`,
|
|
366
|
+
expression
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return date;
|
|
370
|
+
}
|
|
371
|
+
if (typeof value === 'number') {
|
|
372
|
+
return new Date(value);
|
|
373
|
+
}
|
|
374
|
+
throw new FormulaError(
|
|
375
|
+
FormulaErrorType.TYPE_ERROR,
|
|
376
|
+
`Expected date, got ${typeof value}`,
|
|
377
|
+
expression
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
default:
|
|
381
|
+
return value as FormulaValue;
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error instanceof FormulaError) {
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
throw new FormulaError(
|
|
388
|
+
FormulaErrorType.TYPE_ERROR,
|
|
389
|
+
`Type coercion failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
390
|
+
expression
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Extract metadata from a formula expression
|
|
397
|
+
* Analyzes the expression to determine dependencies and complexity
|
|
398
|
+
*/
|
|
399
|
+
extractMetadata(
|
|
400
|
+
fieldName: string,
|
|
401
|
+
expression: string,
|
|
402
|
+
dataType: FormulaDataType
|
|
403
|
+
): FormulaMetadata {
|
|
404
|
+
const dependencies: string[] = [];
|
|
405
|
+
const lookupChains: string[] = [];
|
|
406
|
+
const systemVariables: string[] = [];
|
|
407
|
+
const validationErrors: string[] = [];
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
// Extract system variables (start with $)
|
|
411
|
+
const systemVarPattern = /\$([a-z_][a-z0-9_]*)/gi;
|
|
412
|
+
const systemMatches = Array.from(expression.matchAll(systemVarPattern));
|
|
413
|
+
for (const match of systemMatches) {
|
|
414
|
+
const sysVar = '$' + match[1];
|
|
415
|
+
if (!systemVariables.includes(sysVar)) {
|
|
416
|
+
systemVariables.push(sysVar);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Extract field references (but not system variables or keywords)
|
|
421
|
+
const fieldPattern = /\b([a-z_][a-z0-9_]*)\b/gi;
|
|
422
|
+
const matches = Array.from(expression.matchAll(fieldPattern));
|
|
423
|
+
|
|
424
|
+
for (const match of matches) {
|
|
425
|
+
const identifier = match[1];
|
|
426
|
+
|
|
427
|
+
// Skip JavaScript keywords and built-ins
|
|
428
|
+
if (this.isJavaScriptKeyword(identifier)) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Field references
|
|
433
|
+
if (!dependencies.includes(identifier)) {
|
|
434
|
+
dependencies.push(identifier);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Extract lookup chains (e.g., account.owner.name)
|
|
439
|
+
const lookupPattern = /\b([a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)+)\b/gi;
|
|
440
|
+
const lookupMatches = Array.from(expression.matchAll(lookupPattern));
|
|
441
|
+
|
|
442
|
+
for (const match of lookupMatches) {
|
|
443
|
+
const chain = match[1];
|
|
444
|
+
if (!lookupChains.includes(chain)) {
|
|
445
|
+
lookupChains.push(chain);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Basic validation
|
|
450
|
+
if (!expression.trim()) {
|
|
451
|
+
validationErrors.push('Expression cannot be empty');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Estimate complexity
|
|
455
|
+
const complexity = this.estimateComplexity(expression);
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
field_name: fieldName,
|
|
459
|
+
expression,
|
|
460
|
+
data_type: dataType,
|
|
461
|
+
dependencies,
|
|
462
|
+
lookup_chains: lookupChains,
|
|
463
|
+
system_variables: systemVariables,
|
|
464
|
+
is_valid: validationErrors.length === 0,
|
|
465
|
+
validation_errors: validationErrors.length > 0 ? validationErrors : undefined,
|
|
466
|
+
complexity,
|
|
467
|
+
};
|
|
468
|
+
} catch (error) {
|
|
469
|
+
return {
|
|
470
|
+
field_name: fieldName,
|
|
471
|
+
expression,
|
|
472
|
+
data_type: dataType,
|
|
473
|
+
dependencies: [],
|
|
474
|
+
lookup_chains: [],
|
|
475
|
+
system_variables: [],
|
|
476
|
+
is_valid: false,
|
|
477
|
+
validation_errors: [
|
|
478
|
+
error instanceof Error ? error.message : String(error),
|
|
479
|
+
],
|
|
480
|
+
complexity: 'simple',
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Check if identifier is a JavaScript keyword or built-in
|
|
487
|
+
*/
|
|
488
|
+
private isJavaScriptKeyword(identifier: string): boolean {
|
|
489
|
+
const keywords = new Set([
|
|
490
|
+
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
|
|
491
|
+
'return', 'function', 'var', 'let', 'const', 'true', 'false', 'null',
|
|
492
|
+
'undefined', 'this', 'new', 'typeof', 'instanceof', 'in', 'of',
|
|
493
|
+
'Math', 'String', 'Number', 'Boolean', 'Date', 'Array', 'Object',
|
|
494
|
+
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
|
|
495
|
+
]);
|
|
496
|
+
return keywords.has(identifier);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Estimate formula complexity based on heuristics
|
|
501
|
+
*/
|
|
502
|
+
private estimateComplexity(expression: string): 'simple' | 'medium' | 'complex' {
|
|
503
|
+
const lines = expression.split('\n').length;
|
|
504
|
+
const hasConditionals = /if\s*\(|switch\s*\(|\?/.test(expression);
|
|
505
|
+
const hasLoops = /for\s*\(|while\s*\(/.test(expression);
|
|
506
|
+
const hasLookups = /\.[a-z_][a-z0-9_]*/.test(expression);
|
|
507
|
+
|
|
508
|
+
if (lines > 20 || hasLoops) {
|
|
509
|
+
return 'complex';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (lines > 5 || hasConditionals || hasLookups) {
|
|
513
|
+
return 'medium';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return 'simple';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Register a custom function for use in formulas
|
|
521
|
+
*/
|
|
522
|
+
registerFunction(name: string, func: FormulaCustomFunction): void {
|
|
523
|
+
this.customFunctions[name] = func;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Validate a formula expression without executing it
|
|
528
|
+
*
|
|
529
|
+
* SECURITY NOTE: Uses Function constructor for syntax validation.
|
|
530
|
+
* This doesn't execute the code but creates a function object.
|
|
531
|
+
* For stricter validation, consider using a parser library like @babel/parser.
|
|
532
|
+
*/
|
|
533
|
+
validate(expression: string): { valid: boolean; errors: string[] } {
|
|
534
|
+
const errors: string[] = [];
|
|
535
|
+
|
|
536
|
+
if (!expression || expression.trim() === '') {
|
|
537
|
+
errors.push('Expression cannot be empty');
|
|
538
|
+
return { valid: false, errors };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Check for blocked operations
|
|
542
|
+
if (this.config.sandbox?.enabled) {
|
|
543
|
+
const blockedOps = this.config.sandbox.blocked_operations || [];
|
|
544
|
+
for (const op of blockedOps) {
|
|
545
|
+
if (expression.includes(op)) {
|
|
546
|
+
errors.push(`Blocked operation detected: ${op}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Try to parse as JavaScript (basic syntax check)
|
|
552
|
+
try {
|
|
553
|
+
const wrappedExpression = this.wrapExpression(expression);
|
|
554
|
+
new Function('', wrappedExpression);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
errors.push(`Syntax error: ${error instanceof Error ? error.message : String(error)}`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
valid: errors.length === 0,
|
|
561
|
+
errors,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
package/src/index.ts
CHANGED
package/src/repository.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult } from '@objectql/types';
|
|
1
|
+
import { ObjectQLContext, IObjectQL, ObjectConfig, Driver, UnifiedQuery, ActionContext, HookAPI, RetrievalHookContext, MutationHookContext, UpdateHookContext, ValidationContext, ValidationError, ValidationRuleResult, FormulaContext } from '@objectql/types';
|
|
2
2
|
import { Validator } from './validator';
|
|
3
|
+
import { FormulaEngine } from './formula-engine';
|
|
3
4
|
|
|
4
5
|
export class ObjectRepository {
|
|
5
6
|
private validator: Validator;
|
|
7
|
+
private formulaEngine: FormulaEngine;
|
|
6
8
|
|
|
7
9
|
constructor(
|
|
8
10
|
private objectName: string,
|
|
@@ -10,6 +12,7 @@ export class ObjectRepository {
|
|
|
10
12
|
private app: IObjectQL
|
|
11
13
|
) {
|
|
12
14
|
this.validator = new Validator();
|
|
15
|
+
this.formulaEngine = new FormulaEngine();
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
private getDriver(): Driver {
|
|
@@ -130,6 +133,74 @@ export class ObjectRepository {
|
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Evaluate formula fields for a record
|
|
138
|
+
* Adds computed formula field values to the record
|
|
139
|
+
*/
|
|
140
|
+
private evaluateFormulas(record: any): any {
|
|
141
|
+
const schema = this.getSchema();
|
|
142
|
+
const now = new Date();
|
|
143
|
+
|
|
144
|
+
// Build formula context
|
|
145
|
+
const formulaContext: FormulaContext = {
|
|
146
|
+
record,
|
|
147
|
+
system: {
|
|
148
|
+
today: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
|
|
149
|
+
now: now,
|
|
150
|
+
year: now.getFullYear(),
|
|
151
|
+
month: now.getMonth() + 1,
|
|
152
|
+
day: now.getDate(),
|
|
153
|
+
hour: now.getHours(),
|
|
154
|
+
minute: now.getMinutes(),
|
|
155
|
+
second: now.getSeconds(),
|
|
156
|
+
},
|
|
157
|
+
current_user: {
|
|
158
|
+
id: this.context.userId || '',
|
|
159
|
+
// TODO: Retrieve actual user name from user object if available
|
|
160
|
+
name: undefined,
|
|
161
|
+
email: undefined,
|
|
162
|
+
role: this.context.roles?.[0],
|
|
163
|
+
},
|
|
164
|
+
is_new: false,
|
|
165
|
+
record_id: record._id || record.id,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Evaluate each formula field
|
|
169
|
+
for (const [fieldName, fieldConfig] of Object.entries(schema.fields)) {
|
|
170
|
+
if (fieldConfig.type === 'formula' && fieldConfig.formula) {
|
|
171
|
+
const result = this.formulaEngine.evaluate(
|
|
172
|
+
fieldConfig.formula,
|
|
173
|
+
formulaContext,
|
|
174
|
+
fieldConfig.data_type || 'text',
|
|
175
|
+
{ strict: true }
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (result.success) {
|
|
179
|
+
record[fieldName] = result.value;
|
|
180
|
+
} else {
|
|
181
|
+
// In case of error, set to null and log for diagnostics
|
|
182
|
+
record[fieldName] = null;
|
|
183
|
+
// Formula evaluation should not throw here, but we need observability
|
|
184
|
+
// This logging is intentionally minimal and side-effect free
|
|
185
|
+
// eslint-disable-next-line no-console
|
|
186
|
+
console.error(
|
|
187
|
+
'[ObjectQL][FormulaEngine] Formula evaluation failed',
|
|
188
|
+
{
|
|
189
|
+
objectName: this.objectName,
|
|
190
|
+
fieldName,
|
|
191
|
+
recordId: formulaContext.record_id,
|
|
192
|
+
formula: fieldConfig.formula,
|
|
193
|
+
error: result.error,
|
|
194
|
+
stack: result.stack,
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return record;
|
|
202
|
+
}
|
|
203
|
+
|
|
133
204
|
async find(query: UnifiedQuery = {}): Promise<any[]> {
|
|
134
205
|
const hookCtx: RetrievalHookContext = {
|
|
135
206
|
...this.context,
|
|
@@ -145,7 +216,10 @@ export class ObjectRepository {
|
|
|
145
216
|
// TODO: Apply basic filters like spaceId
|
|
146
217
|
const results = await this.getDriver().find(this.objectName, hookCtx.query || {}, this.getOptions());
|
|
147
218
|
|
|
148
|
-
|
|
219
|
+
// Evaluate formulas for each result
|
|
220
|
+
const resultsWithFormulas = results.map(record => this.evaluateFormulas(record));
|
|
221
|
+
|
|
222
|
+
hookCtx.result = resultsWithFormulas;
|
|
149
223
|
await this.app.triggerHook('afterFind', this.objectName, hookCtx);
|
|
150
224
|
|
|
151
225
|
return hookCtx.result as any[];
|
|
@@ -166,7 +240,10 @@ export class ObjectRepository {
|
|
|
166
240
|
|
|
167
241
|
const result = await this.getDriver().findOne(this.objectName, idOrQuery, hookCtx.query, this.getOptions());
|
|
168
242
|
|
|
169
|
-
|
|
243
|
+
// Evaluate formulas if result exists
|
|
244
|
+
const resultWithFormulas = result ? this.evaluateFormulas(result) : result;
|
|
245
|
+
|
|
246
|
+
hookCtx.result = resultWithFormulas;
|
|
170
247
|
await this.app.triggerHook('afterFind', this.objectName, hookCtx);
|
|
171
248
|
return hookCtx.result;
|
|
172
249
|
} else {
|