@objectql/core 1.8.3 → 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 +25 -0
- package/dist/ai-agent.d.ts +1 -1
- package/dist/ai-agent.js +8 -39
- package/dist/ai-agent.js.map +1 -1
- 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/ai-agent.ts +9 -37
- package/src/formula-engine.ts +564 -0
- package/src/index.ts +1 -0
- package/src/repository.ts +80 -3
- package/test/app.test.ts +587 -0
- package/test/formula-engine.test.ts +717 -0
- package/test/formula-integration.test.ts +278 -0
- package/test/mock-driver.ts +4 -0
- package/test/object.test.ts +183 -0
- package/test/util.test.ts +470 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectql/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Universal runtime engine for ObjectQL - AI-native metadata-driven ORM with validation, repository pattern, and driver orchestration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"objectql",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"openai": "^4.28.0",
|
|
22
22
|
"js-yaml": "^4.1.0",
|
|
23
|
-
"@objectql/types": "1.
|
|
23
|
+
"@objectql/types": "1.9.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"typescript": "^5.3.0",
|
package/src/ai-agent.ts
CHANGED
|
@@ -51,7 +51,7 @@ export interface GenerateAppResult {
|
|
|
51
51
|
files: Array<{
|
|
52
52
|
filename: string;
|
|
53
53
|
content: string;
|
|
54
|
-
type: 'object' | 'validation' | '
|
|
54
|
+
type: 'object' | 'validation' | 'action' | 'hook' | 'permission' | 'workflow' | 'data' | 'application' | 'typescript' | 'test' | 'other';
|
|
55
55
|
}>;
|
|
56
56
|
/** Any errors encountered */
|
|
57
57
|
errors?: string[];
|
|
@@ -464,8 +464,7 @@ export class ObjectQLAgent {
|
|
|
464
464
|
const fileTypes = new Set(currentFiles.map(f => f.type));
|
|
465
465
|
|
|
466
466
|
const allTypes = [
|
|
467
|
-
'object', 'validation', '
|
|
468
|
-
'menu', 'action', 'hook', 'permission', 'workflow', 'report', 'data'
|
|
467
|
+
'object', 'validation', 'action', 'hook', 'permission', 'workflow', 'data'
|
|
469
468
|
];
|
|
470
469
|
|
|
471
470
|
const missingTypes = allTypes.filter(t => !fileTypes.has(t as any));
|
|
@@ -478,17 +477,9 @@ export class ObjectQLAgent {
|
|
|
478
477
|
suggestions.push('Add permissions to control access');
|
|
479
478
|
}
|
|
480
479
|
|
|
481
|
-
if (!fileTypes.has('menu')) {
|
|
482
|
-
suggestions.push('Create a menu for navigation');
|
|
483
|
-
}
|
|
484
|
-
|
|
485
480
|
if (!fileTypes.has('workflow') && fileTypes.has('object')) {
|
|
486
481
|
suggestions.push('Add workflows for approval processes');
|
|
487
482
|
}
|
|
488
|
-
|
|
489
|
-
if (!fileTypes.has('report') && fileTypes.has('object')) {
|
|
490
|
-
suggestions.push('Generate reports for analytics');
|
|
491
|
-
}
|
|
492
483
|
|
|
493
484
|
return suggestions;
|
|
494
485
|
}
|
|
@@ -512,14 +503,7 @@ Follow ObjectQL metadata standards for ALL metadata types:
|
|
|
512
503
|
- beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete
|
|
513
504
|
- Workflows (*.workflow.yml): approval processes, automation
|
|
514
505
|
|
|
515
|
-
**3.
|
|
516
|
-
- Pages (*.page.yml): composable UI pages with layouts
|
|
517
|
-
- Views (*.view.yml): list views, kanban, calendar displays
|
|
518
|
-
- Forms (*.form.yml): data entry forms with field layouts
|
|
519
|
-
- Reports (*.report.yml): tabular, summary, matrix reports
|
|
520
|
-
- Menus (*.menu.yml): navigation structure
|
|
521
|
-
|
|
522
|
-
**4. Security Layer:**
|
|
506
|
+
**3. Security Layer:**
|
|
523
507
|
- Permissions (*.permission.yml): access control rules
|
|
524
508
|
- Application (*.application.yml): app-level configuration
|
|
525
509
|
|
|
@@ -696,7 +680,6 @@ Include:
|
|
|
696
680
|
- 2-3 core objects with essential fields
|
|
697
681
|
- Basic relationships between objects
|
|
698
682
|
- Simple validation rules
|
|
699
|
-
- At least one form and view per object
|
|
700
683
|
- At least one action with TypeScript implementation
|
|
701
684
|
- At least one hook with TypeScript implementation
|
|
702
685
|
|
|
@@ -708,21 +691,15 @@ Output: Provide each file separately with clear filename headers (e.g., "# filen
|
|
|
708
691
|
Include ALL necessary metadata types WITH implementations:
|
|
709
692
|
1. **Objects**: All entities with comprehensive fields
|
|
710
693
|
2. **Validations**: Business rules and constraints
|
|
711
|
-
3. **
|
|
712
|
-
4. **
|
|
713
|
-
5. **
|
|
714
|
-
6. **
|
|
715
|
-
7. **
|
|
716
|
-
8. **
|
|
717
|
-
9. **Permissions**: Basic access control
|
|
718
|
-
10. **Data**: Sample seed data (optional)
|
|
719
|
-
11. **Workflows**: Approval processes if applicable
|
|
720
|
-
12. **Reports**: Key reports for analytics
|
|
721
|
-
13. **Tests**: Generate test files (.test.ts) for actions and hooks to validate business logic
|
|
694
|
+
3. **Actions WITH TypeScript implementations**: Common operations (approve, export, etc.) - Generate BOTH .yml metadata AND .action.ts implementation files
|
|
695
|
+
4. **Hooks WITH TypeScript implementations**: Lifecycle triggers - Generate .hook.ts implementation files
|
|
696
|
+
5. **Permissions**: Basic access control
|
|
697
|
+
6. **Data**: Sample seed data (optional)
|
|
698
|
+
7. **Workflows**: Approval processes if applicable
|
|
699
|
+
8. **Tests**: Generate test files (.test.ts) for actions and hooks to validate business logic
|
|
722
700
|
|
|
723
701
|
Consider:
|
|
724
702
|
- Security and permissions from the start
|
|
725
|
-
- User experience in form/view design
|
|
726
703
|
- Business processes and workflows
|
|
727
704
|
- Data integrity and validation
|
|
728
705
|
- Complete TypeScript implementations for all actions and hooks
|
|
@@ -863,15 +840,10 @@ Provide feedback in the specified format.`;
|
|
|
863
840
|
private inferFileType(filename: string): GenerateAppResult['files'][0]['type'] {
|
|
864
841
|
if (filename.includes('.object.yml')) return 'object';
|
|
865
842
|
if (filename.includes('.validation.yml')) return 'validation';
|
|
866
|
-
if (filename.includes('.form.yml')) return 'form';
|
|
867
|
-
if (filename.includes('.view.yml')) return 'view';
|
|
868
|
-
if (filename.includes('.page.yml')) return 'page';
|
|
869
|
-
if (filename.includes('.menu.yml')) return 'menu';
|
|
870
843
|
if (filename.includes('.action.yml')) return 'action';
|
|
871
844
|
if (filename.includes('.hook.yml')) return 'hook';
|
|
872
845
|
if (filename.includes('.permission.yml')) return 'permission';
|
|
873
846
|
if (filename.includes('.workflow.yml')) return 'workflow';
|
|
874
|
-
if (filename.includes('.report.yml')) return 'report';
|
|
875
847
|
if (filename.includes('.data.yml')) return 'data';
|
|
876
848
|
if (filename.includes('.application.yml') || filename.includes('.app.yml')) return 'application';
|
|
877
849
|
if (filename.includes('.action.ts') || filename.includes('.hook.ts')) return 'typescript';
|
|
@@ -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