@the-trybe/formula-engine 1.0.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/.claude/settings.local.json +6 -0
- package/PRD_FORMULA_ENGINE.md +1863 -0
- package/README.md +382 -0
- package/dist/decimal-utils.d.ts +180 -0
- package/dist/decimal-utils.js +355 -0
- package/dist/dependency-extractor.d.ts +20 -0
- package/dist/dependency-extractor.js +103 -0
- package/dist/dependency-graph.d.ts +60 -0
- package/dist/dependency-graph.js +252 -0
- package/dist/errors.d.ts +161 -0
- package/dist/errors.js +260 -0
- package/dist/evaluator.d.ts +51 -0
- package/dist/evaluator.js +494 -0
- package/dist/formula-engine.d.ts +79 -0
- package/dist/formula-engine.js +355 -0
- package/dist/functions.d.ts +3 -0
- package/dist/functions.js +720 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +61 -0
- package/dist/lexer.d.ts +25 -0
- package/dist/lexer.js +357 -0
- package/dist/parser.d.ts +32 -0
- package/dist/parser.js +372 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.js +62 -0
- package/jest.config.js +23 -0
- package/package.json +35 -0
- package/src/decimal-utils.ts +408 -0
- package/src/dependency-extractor.ts +117 -0
- package/src/dependency-graph.test.ts +238 -0
- package/src/dependency-graph.ts +288 -0
- package/src/errors.ts +296 -0
- package/src/evaluator.ts +604 -0
- package/src/formula-engine.test.ts +660 -0
- package/src/formula-engine.ts +430 -0
- package/src/functions.ts +770 -0
- package/src/index.ts +103 -0
- package/src/lexer.test.ts +288 -0
- package/src/lexer.ts +394 -0
- package/src/parser.test.ts +349 -0
- package/src/parser.ts +449 -0
- package/src/types.ts +347 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FormulaEngineConfig,
|
|
3
|
+
FormulaDefinition,
|
|
4
|
+
EvaluationContext,
|
|
5
|
+
EvaluationResult,
|
|
6
|
+
EvaluationResultSet,
|
|
7
|
+
ValidationResult,
|
|
8
|
+
FunctionDefinition,
|
|
9
|
+
ASTNode,
|
|
10
|
+
CacheStats,
|
|
11
|
+
DependencyGraph as IDependencyGraph,
|
|
12
|
+
} from './types';
|
|
13
|
+
import { Parser } from './parser';
|
|
14
|
+
import { Evaluator } from './evaluator';
|
|
15
|
+
import { DependencyExtractor } from './dependency-extractor';
|
|
16
|
+
import { DependencyGraph, DependencyGraphBuilder } from './dependency-graph';
|
|
17
|
+
import { DecimalUtils, Decimal } from './decimal-utils';
|
|
18
|
+
import { createBuiltInFunctions } from './functions';
|
|
19
|
+
import {
|
|
20
|
+
FormulaEngineError,
|
|
21
|
+
GeneralFormulaError,
|
|
22
|
+
DuplicateFormulaError,
|
|
23
|
+
MaxExpressionLengthError,
|
|
24
|
+
} from './errors';
|
|
25
|
+
|
|
26
|
+
export class FormulaEngine {
|
|
27
|
+
private config: FormulaEngineConfig;
|
|
28
|
+
private parser: Parser;
|
|
29
|
+
private evaluator: Evaluator;
|
|
30
|
+
private dependencyExtractor: DependencyExtractor;
|
|
31
|
+
private graphBuilder: DependencyGraphBuilder;
|
|
32
|
+
private decimalUtils: DecimalUtils;
|
|
33
|
+
private functions: Map<string, FunctionDefinition>;
|
|
34
|
+
|
|
35
|
+
// Caches
|
|
36
|
+
private astCache: Map<string, ASTNode> = new Map();
|
|
37
|
+
private dependencyCache: Map<string, Set<string>> = new Map();
|
|
38
|
+
private cacheHits: number = 0;
|
|
39
|
+
private cacheMisses: number = 0;
|
|
40
|
+
|
|
41
|
+
constructor(config?: FormulaEngineConfig) {
|
|
42
|
+
this.config = {
|
|
43
|
+
enableCache: true,
|
|
44
|
+
maxCacheSize: 1000,
|
|
45
|
+
strictMode: true,
|
|
46
|
+
...config,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
this.decimalUtils = new DecimalUtils(this.config.decimal);
|
|
50
|
+
this.functions = createBuiltInFunctions(this.decimalUtils);
|
|
51
|
+
this.parser = new Parser();
|
|
52
|
+
this.dependencyExtractor = new DependencyExtractor();
|
|
53
|
+
this.graphBuilder = new DependencyGraphBuilder();
|
|
54
|
+
this.evaluator = new Evaluator(this.decimalUtils, this.functions, this.config);
|
|
55
|
+
|
|
56
|
+
// Register any custom functions from config
|
|
57
|
+
if (this.config.functions) {
|
|
58
|
+
for (const fn of this.config.functions) {
|
|
59
|
+
this.registerFunction(fn);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse an expression into an AST
|
|
66
|
+
*/
|
|
67
|
+
parse(expression: string): ASTNode {
|
|
68
|
+
this.checkExpressionLength(expression);
|
|
69
|
+
|
|
70
|
+
if (this.config.enableCache) {
|
|
71
|
+
const cached = this.astCache.get(expression);
|
|
72
|
+
if (cached) {
|
|
73
|
+
this.cacheHits++;
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
this.cacheMisses++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ast = this.parser.parse(expression);
|
|
80
|
+
|
|
81
|
+
if (this.config.enableCache) {
|
|
82
|
+
this.maybeEvictCache();
|
|
83
|
+
this.astCache.set(expression, ast);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return ast;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Extract variable dependencies from an expression
|
|
91
|
+
*/
|
|
92
|
+
extractDependencies(expression: string): Set<string> {
|
|
93
|
+
if (this.config.enableCache) {
|
|
94
|
+
const cached = this.dependencyCache.get(expression);
|
|
95
|
+
if (cached) {
|
|
96
|
+
return new Set(cached);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const deps = this.dependencyExtractor.extract(expression);
|
|
101
|
+
|
|
102
|
+
if (this.config.enableCache) {
|
|
103
|
+
this.dependencyCache.set(expression, new Set(deps));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return deps;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build a dependency graph from formula definitions
|
|
111
|
+
*/
|
|
112
|
+
buildDependencyGraph(formulas: FormulaDefinition[]): IDependencyGraph {
|
|
113
|
+
return this.graphBuilder.build(formulas);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get the evaluation order for formulas
|
|
118
|
+
*/
|
|
119
|
+
getEvaluationOrder(formulas: FormulaDefinition[]): string[] {
|
|
120
|
+
return this.graphBuilder.getEvaluationOrder(formulas);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate formulas without evaluating
|
|
125
|
+
*/
|
|
126
|
+
validate(formulas: FormulaDefinition[]): ValidationResult {
|
|
127
|
+
const errors: FormulaEngineError[] = [];
|
|
128
|
+
const warnings: string[] = [];
|
|
129
|
+
|
|
130
|
+
// Check for duplicate IDs
|
|
131
|
+
const ids = new Set<string>();
|
|
132
|
+
for (const formula of formulas) {
|
|
133
|
+
if (ids.has(formula.id)) {
|
|
134
|
+
errors.push(new DuplicateFormulaError(formula.id));
|
|
135
|
+
}
|
|
136
|
+
ids.add(formula.id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Try to parse all expressions
|
|
140
|
+
for (const formula of formulas) {
|
|
141
|
+
try {
|
|
142
|
+
this.parse(formula.expression);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof FormulaEngineError) {
|
|
145
|
+
errors.push(error);
|
|
146
|
+
} else {
|
|
147
|
+
errors.push(new GeneralFormulaError(String(error)));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Build dependency graph and check for cycles
|
|
153
|
+
let dependencyGraph: IDependencyGraph = new DependencyGraph();
|
|
154
|
+
let evaluationOrder: string[] = [];
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
dependencyGraph = this.buildDependencyGraph(formulas);
|
|
158
|
+
evaluationOrder = dependencyGraph.topologicalSort();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof FormulaEngineError) {
|
|
161
|
+
errors.push(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
valid: errors.length === 0,
|
|
167
|
+
errors,
|
|
168
|
+
warnings,
|
|
169
|
+
dependencyGraph,
|
|
170
|
+
evaluationOrder,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Evaluate a single expression
|
|
176
|
+
*/
|
|
177
|
+
evaluate(expression: string, context: EvaluationContext): EvaluationResult {
|
|
178
|
+
this.checkExpressionLength(expression);
|
|
179
|
+
const normalizedContext = this.normalizeContext(context);
|
|
180
|
+
return this.evaluator.evaluate(expression, normalizedContext);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Evaluate all formulas in dependency order
|
|
185
|
+
*/
|
|
186
|
+
evaluateAll(
|
|
187
|
+
formulas: FormulaDefinition[],
|
|
188
|
+
context: EvaluationContext
|
|
189
|
+
): EvaluationResultSet {
|
|
190
|
+
const startTime = Date.now();
|
|
191
|
+
const results = new Map<string, EvaluationResult>();
|
|
192
|
+
const errors: Error[] = [];
|
|
193
|
+
|
|
194
|
+
// Get evaluation order
|
|
195
|
+
let evaluationOrder: string[];
|
|
196
|
+
try {
|
|
197
|
+
evaluationOrder = this.getEvaluationOrder(formulas);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return {
|
|
200
|
+
results,
|
|
201
|
+
success: false,
|
|
202
|
+
errors: [error as Error],
|
|
203
|
+
totalExecutionTimeMs: Date.now() - startTime,
|
|
204
|
+
evaluationOrder: [],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Create a map of formulas by ID
|
|
209
|
+
const formulaMap = new Map<string, FormulaDefinition>();
|
|
210
|
+
for (const formula of formulas) {
|
|
211
|
+
formulaMap.set(formula.id, formula);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Evaluate in order, merging results into context
|
|
215
|
+
const workingContext: EvaluationContext = this.normalizeContext(context);
|
|
216
|
+
|
|
217
|
+
for (const formulaId of evaluationOrder) {
|
|
218
|
+
const formula = formulaMap.get(formulaId);
|
|
219
|
+
if (!formula) continue;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const result = this.evaluator.evaluate(formula.expression, workingContext);
|
|
223
|
+
|
|
224
|
+
// Apply rounding if configured
|
|
225
|
+
let value = result.value;
|
|
226
|
+
if (formula.rounding && this.isDecimal(value)) {
|
|
227
|
+
value = this.applyRounding(value as Decimal, formula.rounding);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Handle errors based on formula config
|
|
231
|
+
if (!result.success && formula.onError) {
|
|
232
|
+
value = this.handleError(formula, result.error);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
results.set(formulaId, {
|
|
236
|
+
...result,
|
|
237
|
+
value,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Merge result into context for subsequent formulas
|
|
241
|
+
workingContext.variables[formulaId] = value;
|
|
242
|
+
|
|
243
|
+
if (!result.success) {
|
|
244
|
+
errors.push(result.error!);
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const evalResult: EvaluationResult = {
|
|
248
|
+
value: null,
|
|
249
|
+
success: false,
|
|
250
|
+
error: error as Error,
|
|
251
|
+
executionTimeMs: 0,
|
|
252
|
+
accessedVariables: new Set(),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Handle error based on formula config
|
|
256
|
+
if (formula.onError) {
|
|
257
|
+
evalResult.value = this.handleError(formula, error as Error);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
results.set(formulaId, evalResult);
|
|
261
|
+
errors.push(error as Error);
|
|
262
|
+
|
|
263
|
+
// Still add to context (as null or default value) so dependent formulas can proceed
|
|
264
|
+
workingContext.variables[formulaId] = evalResult.value;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
results,
|
|
270
|
+
success: errors.length === 0,
|
|
271
|
+
errors,
|
|
272
|
+
totalExecutionTimeMs: Date.now() - startTime,
|
|
273
|
+
evaluationOrder,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Register a custom function
|
|
279
|
+
*/
|
|
280
|
+
registerFunction(definition: FunctionDefinition): void {
|
|
281
|
+
const name = definition.name.toUpperCase();
|
|
282
|
+
this.functions.set(name, definition);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Register multiple custom functions
|
|
287
|
+
*/
|
|
288
|
+
registerFunctions(definitions: FunctionDefinition[]): void {
|
|
289
|
+
for (const definition of definitions) {
|
|
290
|
+
this.registerFunction(definition);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get registered function names
|
|
296
|
+
*/
|
|
297
|
+
getRegisteredFunctions(): string[] {
|
|
298
|
+
return Array.from(this.functions.keys());
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Clear the AST cache
|
|
303
|
+
*/
|
|
304
|
+
clearCache(): void {
|
|
305
|
+
this.astCache.clear();
|
|
306
|
+
this.dependencyCache.clear();
|
|
307
|
+
this.cacheHits = 0;
|
|
308
|
+
this.cacheMisses = 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get cache statistics
|
|
313
|
+
*/
|
|
314
|
+
getCacheStats(): CacheStats {
|
|
315
|
+
const total = this.cacheHits + this.cacheMisses;
|
|
316
|
+
return {
|
|
317
|
+
size: this.astCache.size,
|
|
318
|
+
hits: this.cacheHits,
|
|
319
|
+
misses: this.cacheMisses,
|
|
320
|
+
hitRate: total > 0 ? this.cacheHits / total : 0,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get the decimal utilities instance
|
|
326
|
+
*/
|
|
327
|
+
getDecimalUtils(): DecimalUtils {
|
|
328
|
+
return this.decimalUtils;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Create a Decimal from a value
|
|
333
|
+
*/
|
|
334
|
+
createDecimal(value: string | number): Decimal {
|
|
335
|
+
return this.decimalUtils.from(value);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ============================================================================
|
|
339
|
+
// Private methods
|
|
340
|
+
// ============================================================================
|
|
341
|
+
|
|
342
|
+
private checkExpressionLength(expression: string): void {
|
|
343
|
+
const maxLength = this.config.security?.maxExpressionLength ?? 10000;
|
|
344
|
+
if (expression.length > maxLength) {
|
|
345
|
+
throw new MaxExpressionLengthError(expression.length, maxLength);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private normalizeContext(context: EvaluationContext): EvaluationContext {
|
|
350
|
+
// Deep copy and convert numeric values to Decimal if autoConvertFloats is enabled
|
|
351
|
+
const autoConvert = this.config.decimal?.autoConvertFloats ?? true;
|
|
352
|
+
|
|
353
|
+
if (!autoConvert) {
|
|
354
|
+
return { ...context };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const normalizedVariables: Record<string, unknown> = {};
|
|
358
|
+
for (const [key, value] of Object.entries(context.variables)) {
|
|
359
|
+
normalizedVariables[key] = this.convertValue(value);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
...context,
|
|
364
|
+
variables: normalizedVariables,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private convertValue(value: unknown): unknown {
|
|
369
|
+
if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
|
|
370
|
+
return this.decimalUtils.from(value);
|
|
371
|
+
}
|
|
372
|
+
if (Array.isArray(value)) {
|
|
373
|
+
return value.map(v => this.convertValue(v));
|
|
374
|
+
}
|
|
375
|
+
if (value !== null && typeof value === 'object') {
|
|
376
|
+
const converted: Record<string, unknown> = {};
|
|
377
|
+
for (const [k, v] of Object.entries(value)) {
|
|
378
|
+
converted[k] = this.convertValue(v);
|
|
379
|
+
}
|
|
380
|
+
return converted;
|
|
381
|
+
}
|
|
382
|
+
return value;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private isDecimal(value: unknown): boolean {
|
|
386
|
+
return value instanceof Decimal;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private applyRounding(
|
|
390
|
+
value: Decimal,
|
|
391
|
+
config: { mode: string; precision: number }
|
|
392
|
+
): Decimal {
|
|
393
|
+
if (config.mode === 'NONE') {
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
return this.decimalUtils.round(value, config.precision, config.mode as any);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private handleError(formula: FormulaDefinition, _error?: Error): unknown {
|
|
400
|
+
const behavior = formula.onError;
|
|
401
|
+
if (!behavior) return null;
|
|
402
|
+
|
|
403
|
+
switch (behavior.type) {
|
|
404
|
+
case 'NULL':
|
|
405
|
+
return null;
|
|
406
|
+
case 'ZERO':
|
|
407
|
+
return this.decimalUtils.zero();
|
|
408
|
+
case 'DEFAULT':
|
|
409
|
+
return behavior.defaultValue ?? formula.defaultValue ?? null;
|
|
410
|
+
case 'SKIP':
|
|
411
|
+
return undefined;
|
|
412
|
+
case 'THROW':
|
|
413
|
+
default:
|
|
414
|
+
throw _error;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private maybeEvictCache(): void {
|
|
419
|
+
const maxSize = this.config.maxCacheSize ?? 1000;
|
|
420
|
+
if (this.astCache.size >= maxSize) {
|
|
421
|
+
// Simple FIFO eviction - remove first 10% of entries
|
|
422
|
+
const toRemove = Math.ceil(maxSize * 0.1);
|
|
423
|
+
const keys = Array.from(this.astCache.keys()).slice(0, toRemove);
|
|
424
|
+
for (const key of keys) {
|
|
425
|
+
this.astCache.delete(key);
|
|
426
|
+
this.dependencyCache.delete(key);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|