@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
package/src/evaluator.ts
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ASTNode,
|
|
3
|
+
EvaluationContext,
|
|
4
|
+
EvaluationResult,
|
|
5
|
+
FunctionDefinition,
|
|
6
|
+
FormulaEngineConfig,
|
|
7
|
+
SecurityConfig,
|
|
8
|
+
} from './types';
|
|
9
|
+
import { DecimalUtils, Decimal } from './decimal-utils';
|
|
10
|
+
import {
|
|
11
|
+
UndefinedVariableError,
|
|
12
|
+
UndefinedFunctionError,
|
|
13
|
+
DivisionByZeroError,
|
|
14
|
+
InvalidOperationError,
|
|
15
|
+
PropertyAccessError,
|
|
16
|
+
IndexAccessError,
|
|
17
|
+
ArgumentCountError,
|
|
18
|
+
MaxIterationsError,
|
|
19
|
+
MaxRecursionError,
|
|
20
|
+
} from './errors';
|
|
21
|
+
import { Parser } from './parser';
|
|
22
|
+
|
|
23
|
+
export class Evaluator {
|
|
24
|
+
private parser: Parser;
|
|
25
|
+
private decimalUtils: DecimalUtils;
|
|
26
|
+
private functions: Map<string, FunctionDefinition>;
|
|
27
|
+
private strictMode: boolean;
|
|
28
|
+
private securityConfig: SecurityConfig;
|
|
29
|
+
private recursionDepth: number = 0;
|
|
30
|
+
private iterationCount: number = 0;
|
|
31
|
+
private accessedVariables: Set<string> = new Set();
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
decimalUtils: DecimalUtils,
|
|
35
|
+
functions: Map<string, FunctionDefinition>,
|
|
36
|
+
config?: FormulaEngineConfig
|
|
37
|
+
) {
|
|
38
|
+
this.parser = new Parser();
|
|
39
|
+
this.decimalUtils = decimalUtils;
|
|
40
|
+
this.functions = functions;
|
|
41
|
+
this.strictMode = config?.strictMode ?? true;
|
|
42
|
+
this.securityConfig = config?.security ?? {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Evaluate an expression string
|
|
47
|
+
*/
|
|
48
|
+
evaluate(expression: string, context: EvaluationContext): EvaluationResult {
|
|
49
|
+
const startTime = Date.now();
|
|
50
|
+
this.accessedVariables = new Set();
|
|
51
|
+
this.recursionDepth = 0;
|
|
52
|
+
this.iterationCount = 0;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const ast = this.parser.parse(expression);
|
|
56
|
+
const value = this.evaluateNode(ast, context);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
value,
|
|
60
|
+
success: true,
|
|
61
|
+
executionTimeMs: Date.now() - startTime,
|
|
62
|
+
accessedVariables: new Set(this.accessedVariables),
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return {
|
|
66
|
+
value: null,
|
|
67
|
+
success: false,
|
|
68
|
+
error: error as Error,
|
|
69
|
+
executionTimeMs: Date.now() - startTime,
|
|
70
|
+
accessedVariables: new Set(this.accessedVariables),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Evaluate an AST node
|
|
77
|
+
*/
|
|
78
|
+
evaluateNode(node: ASTNode, context: EvaluationContext): unknown {
|
|
79
|
+
this.checkRecursionLimit();
|
|
80
|
+
|
|
81
|
+
switch (node.type) {
|
|
82
|
+
case 'DecimalLiteral':
|
|
83
|
+
return this.decimalUtils.from(node.value);
|
|
84
|
+
|
|
85
|
+
case 'NumberLiteral':
|
|
86
|
+
return node.value;
|
|
87
|
+
|
|
88
|
+
case 'StringLiteral':
|
|
89
|
+
return node.value;
|
|
90
|
+
|
|
91
|
+
case 'BooleanLiteral':
|
|
92
|
+
return node.value;
|
|
93
|
+
|
|
94
|
+
case 'NullLiteral':
|
|
95
|
+
return null;
|
|
96
|
+
|
|
97
|
+
case 'ArrayLiteral':
|
|
98
|
+
return node.elements.map(el => this.evaluateNode(el, context));
|
|
99
|
+
|
|
100
|
+
case 'VariableReference':
|
|
101
|
+
return this.evaluateVariable(node.prefix, node.name, context);
|
|
102
|
+
|
|
103
|
+
case 'BinaryOperation':
|
|
104
|
+
return this.evaluateBinaryOperation(node, context);
|
|
105
|
+
|
|
106
|
+
case 'UnaryOperation':
|
|
107
|
+
return this.evaluateUnaryOperation(node, context);
|
|
108
|
+
|
|
109
|
+
case 'ConditionalExpression':
|
|
110
|
+
return this.evaluateConditional(node, context);
|
|
111
|
+
|
|
112
|
+
case 'FunctionCall':
|
|
113
|
+
return this.evaluateFunctionCall(node, context);
|
|
114
|
+
|
|
115
|
+
case 'MemberAccess':
|
|
116
|
+
return this.evaluateMemberAccess(node, context);
|
|
117
|
+
|
|
118
|
+
case 'IndexAccess':
|
|
119
|
+
return this.evaluateIndexAccess(node, context);
|
|
120
|
+
|
|
121
|
+
default:
|
|
122
|
+
throw new Error(`Unknown node type: ${(node as any).type}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private checkRecursionLimit(): void {
|
|
127
|
+
this.recursionDepth++;
|
|
128
|
+
const limit = this.securityConfig.maxRecursionDepth ?? 100;
|
|
129
|
+
if (this.recursionDepth > limit) {
|
|
130
|
+
throw new MaxRecursionError(limit);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private checkIterationLimit(): void {
|
|
135
|
+
this.iterationCount++;
|
|
136
|
+
const limit = this.securityConfig.maxIterations ?? 10000;
|
|
137
|
+
if (this.iterationCount > limit) {
|
|
138
|
+
throw new MaxIterationsError(limit);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private evaluateVariable(
|
|
143
|
+
prefix: '$' | '@',
|
|
144
|
+
name: string,
|
|
145
|
+
context: EvaluationContext
|
|
146
|
+
): unknown {
|
|
147
|
+
this.accessedVariables.add(name);
|
|
148
|
+
|
|
149
|
+
if (prefix === '$') {
|
|
150
|
+
// Local variable
|
|
151
|
+
if (name in context.variables) {
|
|
152
|
+
const value = context.variables[name];
|
|
153
|
+
return this.maybeConvertToDecimal(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check if it's a special iteration variable ($it)
|
|
157
|
+
if (name === 'it' && context.extra && '_currentItem' in context.extra) {
|
|
158
|
+
return context.extra._currentItem;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (this.strictMode) {
|
|
162
|
+
throw new UndefinedVariableError(name, '');
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
} else {
|
|
166
|
+
// Context variable (@)
|
|
167
|
+
if (context.extra && name in context.extra) {
|
|
168
|
+
return context.extra[name];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (this.strictMode) {
|
|
172
|
+
throw new UndefinedVariableError(`@${name}`, '');
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private maybeConvertToDecimal(value: unknown): unknown {
|
|
179
|
+
if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
|
|
180
|
+
return this.decimalUtils.from(value);
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private evaluateBinaryOperation(
|
|
186
|
+
node: { operator: string; left: ASTNode; right: ASTNode },
|
|
187
|
+
context: EvaluationContext
|
|
188
|
+
): unknown {
|
|
189
|
+
const { operator } = node;
|
|
190
|
+
|
|
191
|
+
// Short-circuit evaluation for logical operators
|
|
192
|
+
if (operator === '&&') {
|
|
193
|
+
const left = this.evaluateNode(node.left, context);
|
|
194
|
+
if (!this.toBool(left)) return false;
|
|
195
|
+
return this.toBool(this.evaluateNode(node.right, context));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (operator === '||') {
|
|
199
|
+
const left = this.evaluateNode(node.left, context);
|
|
200
|
+
if (this.toBool(left)) return true;
|
|
201
|
+
return this.toBool(this.evaluateNode(node.right, context));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const left = this.evaluateNode(node.left, context);
|
|
205
|
+
const right = this.evaluateNode(node.right, context);
|
|
206
|
+
|
|
207
|
+
switch (operator) {
|
|
208
|
+
// Arithmetic
|
|
209
|
+
case '+':
|
|
210
|
+
return this.add(left, right);
|
|
211
|
+
case '-':
|
|
212
|
+
return this.subtract(left, right);
|
|
213
|
+
case '*':
|
|
214
|
+
return this.multiply(left, right);
|
|
215
|
+
case '/':
|
|
216
|
+
return this.divide(left, right);
|
|
217
|
+
case '%':
|
|
218
|
+
return this.modulo(left, right);
|
|
219
|
+
case '^':
|
|
220
|
+
return this.power(left, right);
|
|
221
|
+
|
|
222
|
+
// Comparison
|
|
223
|
+
case '==':
|
|
224
|
+
return this.equals(left, right);
|
|
225
|
+
case '!=':
|
|
226
|
+
return !this.equals(left, right);
|
|
227
|
+
case '<':
|
|
228
|
+
return this.lessThan(left, right);
|
|
229
|
+
case '>':
|
|
230
|
+
return this.greaterThan(left, right);
|
|
231
|
+
case '<=':
|
|
232
|
+
return this.lessThanOrEqual(left, right);
|
|
233
|
+
case '>=':
|
|
234
|
+
return this.greaterThanOrEqual(left, right);
|
|
235
|
+
|
|
236
|
+
default:
|
|
237
|
+
throw new InvalidOperationError(operator, [this.typeOf(left), this.typeOf(right)]);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private evaluateUnaryOperation(
|
|
242
|
+
node: { operator: string; operand: ASTNode },
|
|
243
|
+
context: EvaluationContext
|
|
244
|
+
): unknown {
|
|
245
|
+
const operand = this.evaluateNode(node.operand, context);
|
|
246
|
+
|
|
247
|
+
switch (node.operator) {
|
|
248
|
+
case '-':
|
|
249
|
+
return this.negate(operand);
|
|
250
|
+
case '!':
|
|
251
|
+
return !this.toBool(operand);
|
|
252
|
+
default:
|
|
253
|
+
throw new InvalidOperationError(node.operator, [this.typeOf(operand)]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private evaluateConditional(
|
|
258
|
+
node: { condition: ASTNode; consequent: ASTNode; alternate: ASTNode },
|
|
259
|
+
context: EvaluationContext
|
|
260
|
+
): unknown {
|
|
261
|
+
const condition = this.evaluateNode(node.condition, context);
|
|
262
|
+
if (this.toBool(condition)) {
|
|
263
|
+
return this.evaluateNode(node.consequent, context);
|
|
264
|
+
}
|
|
265
|
+
return this.evaluateNode(node.alternate, context);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private evaluateFunctionCall(
|
|
269
|
+
node: { name: string; arguments: ASTNode[] },
|
|
270
|
+
context: EvaluationContext
|
|
271
|
+
): unknown {
|
|
272
|
+
const fnName = node.name.toUpperCase();
|
|
273
|
+
const fn = this.functions.get(fnName);
|
|
274
|
+
|
|
275
|
+
if (!fn) {
|
|
276
|
+
throw new UndefinedFunctionError(fnName);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Validate argument count
|
|
280
|
+
if (node.arguments.length < fn.minArgs) {
|
|
281
|
+
throw new ArgumentCountError(fnName, { min: fn.minArgs, max: fn.maxArgs }, node.arguments.length);
|
|
282
|
+
}
|
|
283
|
+
if (fn.maxArgs !== -1 && node.arguments.length > fn.maxArgs) {
|
|
284
|
+
throw new ArgumentCountError(fnName, { min: fn.minArgs, max: fn.maxArgs }, node.arguments.length);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Handle special functions that need AST nodes (for iteration)
|
|
288
|
+
if (fnName === 'SUM' && node.arguments.length === 2) {
|
|
289
|
+
return this.evaluateSumWithExpression(node.arguments, context);
|
|
290
|
+
}
|
|
291
|
+
if (fnName === 'FILTER') {
|
|
292
|
+
return this.evaluateFilter(node.arguments, context);
|
|
293
|
+
}
|
|
294
|
+
if (fnName === 'MAP') {
|
|
295
|
+
return this.evaluateMap(node.arguments, context);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Evaluate arguments
|
|
299
|
+
const args = node.arguments.map(arg => this.evaluateNode(arg, context));
|
|
300
|
+
|
|
301
|
+
// Call the function
|
|
302
|
+
return fn.implementation(args, context, this);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private evaluateSumWithExpression(args: ASTNode[], context: EvaluationContext): Decimal {
|
|
306
|
+
const array = this.evaluateNode(args[0], context);
|
|
307
|
+
if (!Array.isArray(array)) {
|
|
308
|
+
throw new Error('SUM first argument must be an array');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const expression = args[1];
|
|
312
|
+
let sum = this.decimalUtils.zero();
|
|
313
|
+
|
|
314
|
+
for (const item of array) {
|
|
315
|
+
this.checkIterationLimit();
|
|
316
|
+
const itemContext: EvaluationContext = {
|
|
317
|
+
...context,
|
|
318
|
+
extra: {
|
|
319
|
+
...context.extra,
|
|
320
|
+
_currentItem: item,
|
|
321
|
+
},
|
|
322
|
+
variables: {
|
|
323
|
+
...context.variables,
|
|
324
|
+
it: item,
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
const value = this.evaluateNode(expression, itemContext);
|
|
328
|
+
if (this.isNumeric(value)) {
|
|
329
|
+
sum = this.decimalUtils.add(sum, this.toDecimal(value));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return sum;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private evaluateFilter(args: ASTNode[], context: EvaluationContext): unknown[] {
|
|
337
|
+
const array = this.evaluateNode(args[0], context);
|
|
338
|
+
if (!Array.isArray(array)) {
|
|
339
|
+
throw new Error('FILTER first argument must be an array');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const condition = args[1];
|
|
343
|
+
const result: unknown[] = [];
|
|
344
|
+
|
|
345
|
+
for (const item of array) {
|
|
346
|
+
this.checkIterationLimit();
|
|
347
|
+
const itemContext: EvaluationContext = {
|
|
348
|
+
...context,
|
|
349
|
+
extra: {
|
|
350
|
+
...context.extra,
|
|
351
|
+
_currentItem: item,
|
|
352
|
+
},
|
|
353
|
+
variables: {
|
|
354
|
+
...context.variables,
|
|
355
|
+
it: item,
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
const keep = this.evaluateNode(condition, itemContext);
|
|
359
|
+
if (this.toBool(keep)) {
|
|
360
|
+
result.push(item);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private evaluateMap(args: ASTNode[], context: EvaluationContext): unknown[] {
|
|
368
|
+
const array = this.evaluateNode(args[0], context);
|
|
369
|
+
if (!Array.isArray(array)) {
|
|
370
|
+
throw new Error('MAP first argument must be an array');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const expression = args[1];
|
|
374
|
+
const result: unknown[] = [];
|
|
375
|
+
|
|
376
|
+
for (const item of array) {
|
|
377
|
+
this.checkIterationLimit();
|
|
378
|
+
const itemContext: EvaluationContext = {
|
|
379
|
+
...context,
|
|
380
|
+
extra: {
|
|
381
|
+
...context.extra,
|
|
382
|
+
_currentItem: item,
|
|
383
|
+
},
|
|
384
|
+
variables: {
|
|
385
|
+
...context.variables,
|
|
386
|
+
it: item,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
result.push(this.evaluateNode(expression, itemContext));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private evaluateMemberAccess(
|
|
396
|
+
node: { object: ASTNode; property: string },
|
|
397
|
+
context: EvaluationContext
|
|
398
|
+
): unknown {
|
|
399
|
+
const object = this.evaluateNode(node.object, context);
|
|
400
|
+
|
|
401
|
+
if (object === null || object === undefined) {
|
|
402
|
+
if (this.strictMode) {
|
|
403
|
+
throw new PropertyAccessError(node.property, 'null');
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (typeof object !== 'object') {
|
|
409
|
+
throw new PropertyAccessError(node.property, typeof object);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const value = (object as Record<string, unknown>)[node.property];
|
|
413
|
+
return this.maybeConvertToDecimal(value);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private evaluateIndexAccess(
|
|
417
|
+
node: { object: ASTNode; index: ASTNode },
|
|
418
|
+
context: EvaluationContext
|
|
419
|
+
): unknown {
|
|
420
|
+
const object = this.evaluateNode(node.object, context);
|
|
421
|
+
const index = this.evaluateNode(node.index, context);
|
|
422
|
+
|
|
423
|
+
if (object === null || object === undefined) {
|
|
424
|
+
if (this.strictMode) {
|
|
425
|
+
throw new IndexAccessError(index, 'null');
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (Array.isArray(object)) {
|
|
431
|
+
const idx = this.toNumber(index);
|
|
432
|
+
if (idx < 0 || idx >= object.length) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
return this.maybeConvertToDecimal(object[idx]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (typeof object === 'object') {
|
|
439
|
+
const key = String(index);
|
|
440
|
+
const value = (object as Record<string, unknown>)[key];
|
|
441
|
+
return this.maybeConvertToDecimal(value);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
throw new IndexAccessError(index, typeof object);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ============================================================================
|
|
448
|
+
// Helper methods
|
|
449
|
+
// ============================================================================
|
|
450
|
+
|
|
451
|
+
private isNumeric(value: unknown): boolean {
|
|
452
|
+
return value instanceof Decimal || typeof value === 'number';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private toDecimal(value: unknown): Decimal {
|
|
456
|
+
if (value instanceof Decimal) return value;
|
|
457
|
+
if (typeof value === 'number') return this.decimalUtils.from(value);
|
|
458
|
+
if (typeof value === 'string') return this.decimalUtils.from(value);
|
|
459
|
+
throw new InvalidOperationError('toDecimal', [this.typeOf(value)]);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private toNumber(value: unknown): number {
|
|
463
|
+
if (value instanceof Decimal) return value.toNumber();
|
|
464
|
+
if (typeof value === 'number') return value;
|
|
465
|
+
if (typeof value === 'string') return parseFloat(value);
|
|
466
|
+
throw new InvalidOperationError('toNumber', [this.typeOf(value)]);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private toBool(value: unknown): boolean {
|
|
470
|
+
if (value instanceof Decimal) return !value.isZero();
|
|
471
|
+
if (typeof value === 'boolean') return value;
|
|
472
|
+
if (value === null || value === undefined) return false;
|
|
473
|
+
if (typeof value === 'number') return value !== 0;
|
|
474
|
+
if (typeof value === 'string') return value.length > 0;
|
|
475
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private typeOf(value: unknown): string {
|
|
480
|
+
if (value === null) return 'null';
|
|
481
|
+
if (value instanceof Decimal) return 'decimal';
|
|
482
|
+
if (Array.isArray(value)) return 'array';
|
|
483
|
+
return typeof value;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private add(left: unknown, right: unknown): unknown {
|
|
487
|
+
// String concatenation
|
|
488
|
+
if (typeof left === 'string' || typeof right === 'string') {
|
|
489
|
+
const leftStr = left instanceof Decimal ? left.toString() : String(left);
|
|
490
|
+
const rightStr = right instanceof Decimal ? right.toString() : String(right);
|
|
491
|
+
return leftStr + rightStr;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Numeric addition
|
|
495
|
+
if (this.isNumeric(left) && this.isNumeric(right)) {
|
|
496
|
+
return this.decimalUtils.add(this.toDecimal(left), this.toDecimal(right));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
throw new InvalidOperationError('+', [this.typeOf(left), this.typeOf(right)]);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private subtract(left: unknown, right: unknown): Decimal {
|
|
503
|
+
if (!this.isNumeric(left) || !this.isNumeric(right)) {
|
|
504
|
+
throw new InvalidOperationError('-', [this.typeOf(left), this.typeOf(right)]);
|
|
505
|
+
}
|
|
506
|
+
return this.decimalUtils.subtract(this.toDecimal(left), this.toDecimal(right));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private multiply(left: unknown, right: unknown): Decimal {
|
|
510
|
+
if (!this.isNumeric(left) || !this.isNumeric(right)) {
|
|
511
|
+
throw new InvalidOperationError('*', [this.typeOf(left), this.typeOf(right)]);
|
|
512
|
+
}
|
|
513
|
+
return this.decimalUtils.multiply(this.toDecimal(left), this.toDecimal(right));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private divide(left: unknown, right: unknown): Decimal {
|
|
517
|
+
if (!this.isNumeric(left) || !this.isNumeric(right)) {
|
|
518
|
+
throw new InvalidOperationError('/', [this.typeOf(left), this.typeOf(right)]);
|
|
519
|
+
}
|
|
520
|
+
const divisor = this.toDecimal(right);
|
|
521
|
+
if (divisor.isZero()) {
|
|
522
|
+
throw new DivisionByZeroError();
|
|
523
|
+
}
|
|
524
|
+
return this.decimalUtils.divide(this.toDecimal(left), divisor);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private modulo(left: unknown, right: unknown): Decimal {
|
|
528
|
+
if (!this.isNumeric(left) || !this.isNumeric(right)) {
|
|
529
|
+
throw new InvalidOperationError('%', [this.typeOf(left), this.typeOf(right)]);
|
|
530
|
+
}
|
|
531
|
+
const divisor = this.toDecimal(right);
|
|
532
|
+
if (divisor.isZero()) {
|
|
533
|
+
throw new DivisionByZeroError();
|
|
534
|
+
}
|
|
535
|
+
return this.decimalUtils.modulo(this.toDecimal(left), divisor);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private power(left: unknown, right: unknown): Decimal {
|
|
539
|
+
if (!this.isNumeric(left) || !this.isNumeric(right)) {
|
|
540
|
+
throw new InvalidOperationError('^', [this.typeOf(left), this.typeOf(right)]);
|
|
541
|
+
}
|
|
542
|
+
return this.decimalUtils.power(this.toDecimal(left), this.toNumber(right));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private negate(value: unknown): Decimal {
|
|
546
|
+
if (!this.isNumeric(value)) {
|
|
547
|
+
throw new InvalidOperationError('-', [this.typeOf(value)]);
|
|
548
|
+
}
|
|
549
|
+
return this.decimalUtils.negate(this.toDecimal(value));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private equals(left: unknown, right: unknown): boolean {
|
|
553
|
+
if (left instanceof Decimal && right instanceof Decimal) {
|
|
554
|
+
return left.equals(right);
|
|
555
|
+
}
|
|
556
|
+
if (left instanceof Decimal && typeof right === 'number') {
|
|
557
|
+
return left.equals(this.decimalUtils.from(right));
|
|
558
|
+
}
|
|
559
|
+
if (typeof left === 'number' && right instanceof Decimal) {
|
|
560
|
+
return this.decimalUtils.from(left).equals(right);
|
|
561
|
+
}
|
|
562
|
+
return left === right;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private lessThan(left: unknown, right: unknown): boolean {
|
|
566
|
+
if (this.isNumeric(left) && this.isNumeric(right)) {
|
|
567
|
+
return this.decimalUtils.lessThan(this.toDecimal(left), this.toDecimal(right));
|
|
568
|
+
}
|
|
569
|
+
if (typeof left === 'string' && typeof right === 'string') {
|
|
570
|
+
return left < right;
|
|
571
|
+
}
|
|
572
|
+
throw new InvalidOperationError('<', [this.typeOf(left), this.typeOf(right)]);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private greaterThan(left: unknown, right: unknown): boolean {
|
|
576
|
+
if (this.isNumeric(left) && this.isNumeric(right)) {
|
|
577
|
+
return this.decimalUtils.greaterThan(this.toDecimal(left), this.toDecimal(right));
|
|
578
|
+
}
|
|
579
|
+
if (typeof left === 'string' && typeof right === 'string') {
|
|
580
|
+
return left > right;
|
|
581
|
+
}
|
|
582
|
+
throw new InvalidOperationError('>', [this.typeOf(left), this.typeOf(right)]);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private lessThanOrEqual(left: unknown, right: unknown): boolean {
|
|
586
|
+
if (this.isNumeric(left) && this.isNumeric(right)) {
|
|
587
|
+
return this.decimalUtils.lessThanOrEqual(this.toDecimal(left), this.toDecimal(right));
|
|
588
|
+
}
|
|
589
|
+
if (typeof left === 'string' && typeof right === 'string') {
|
|
590
|
+
return left <= right;
|
|
591
|
+
}
|
|
592
|
+
throw new InvalidOperationError('<=', [this.typeOf(left), this.typeOf(right)]);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private greaterThanOrEqual(left: unknown, right: unknown): boolean {
|
|
596
|
+
if (this.isNumeric(left) && this.isNumeric(right)) {
|
|
597
|
+
return this.decimalUtils.greaterThanOrEqual(this.toDecimal(left), this.toDecimal(right));
|
|
598
|
+
}
|
|
599
|
+
if (typeof left === 'string' && typeof right === 'string') {
|
|
600
|
+
return left >= right;
|
|
601
|
+
}
|
|
602
|
+
throw new InvalidOperationError('>=', [this.typeOf(left), this.typeOf(right)]);
|
|
603
|
+
}
|
|
604
|
+
}
|