form0-core 0.1.8 → 0.1.10
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/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { runExpression } from './evaluator.js';
|
|
2
|
-
import { flattenFields } from '../utilities/field-helpers.js';
|
|
3
2
|
import { ContextResolver } from './context-resolver.js';
|
|
4
3
|
import { WarningSystem } from './warning-system.js';
|
|
4
|
+
import {
|
|
5
|
+
buildCalculationDependencyPlan,
|
|
6
|
+
buildCalculationExecutionContext,
|
|
7
|
+
extractStaticFieldReferences,
|
|
8
|
+
} from '../utilities/calculation-dependencies.js';
|
|
9
|
+
import { areStructuredValuesEqual } from '../utilities/value-equality.js';
|
|
5
10
|
|
|
6
11
|
export function evaluateCalculatedFields(
|
|
7
12
|
schema,
|
|
@@ -10,71 +15,86 @@ export function evaluateCalculatedFields(
|
|
|
10
15
|
securityConfig,
|
|
11
16
|
contextResolver = null,
|
|
12
17
|
warningSystem = null,
|
|
13
|
-
runtimeDiagnostics = null
|
|
18
|
+
runtimeDiagnostics = null,
|
|
19
|
+
calculationPlan = null
|
|
14
20
|
) {
|
|
15
|
-
const fields = flattenFields(schema.elements);
|
|
16
|
-
|
|
17
|
-
// Initialize context resolver and warning system if not provided (backward compatibility)
|
|
18
21
|
const resolver = contextResolver || new ContextResolver(schema);
|
|
19
22
|
const warnings = warningSystem || new WarningSystem();
|
|
23
|
+
const plan = calculationPlan || buildCalculationDependencyPlan(schema, resolver);
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
25
|
+
emitDependencyPlanWarnings(plan, warnings, runtimeDiagnostics);
|
|
26
|
+
|
|
27
|
+
if (plan.totalCalculatedFieldCount === 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const blockedFieldNames = blockCyclicCalculatedFields(plan, values);
|
|
32
|
+
const evaluableFieldNames = plan.orderedFieldNames.filter(
|
|
33
|
+
(fieldName) => !blockedFieldNames.has(fieldName)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (evaluableFieldNames.length === 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (plan.hasDynamicDependencies) {
|
|
41
|
+
evaluateFieldSequenceUntilStable(
|
|
42
|
+
evaluableFieldNames,
|
|
43
|
+
plan,
|
|
44
|
+
schema,
|
|
45
|
+
values,
|
|
46
|
+
helpers,
|
|
47
|
+
securityConfig,
|
|
48
|
+
resolver,
|
|
49
|
+
warnings,
|
|
50
|
+
runtimeDiagnostics
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
plan.orderedComponentIds.forEach((componentId) => {
|
|
56
|
+
const component = plan.componentsById.get(componentId);
|
|
57
|
+
if (!component || component.fieldNames.length === 0) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (component.isCyclic) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fieldName = component.fieldNames[0];
|
|
66
|
+
const node = plan.nodesByDataName.get(fieldName);
|
|
67
|
+
if (node?.field) {
|
|
68
|
+
evaluateCalculatedField(
|
|
69
|
+
node.field,
|
|
70
|
+
schema,
|
|
71
|
+
values,
|
|
72
|
+
helpers,
|
|
73
|
+
securityConfig,
|
|
74
|
+
resolver,
|
|
75
|
+
warnings,
|
|
76
|
+
runtimeDiagnostics
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function blockCyclicCalculatedFields(plan, values) {
|
|
83
|
+
const blockedFieldNames = new Set();
|
|
84
|
+
|
|
85
|
+
plan.cyclicComponentIds.forEach((componentId) => {
|
|
86
|
+
const component = plan.componentsById.get(componentId);
|
|
87
|
+
if (!component?.isCyclic) {
|
|
88
|
+
return;
|
|
76
89
|
}
|
|
90
|
+
|
|
91
|
+
component.fieldNames.forEach((fieldName) => {
|
|
92
|
+
blockedFieldNames.add(fieldName);
|
|
93
|
+
values[fieldName] = null;
|
|
94
|
+
});
|
|
77
95
|
});
|
|
96
|
+
|
|
97
|
+
return blockedFieldNames;
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
function buildScopedContext(
|
|
@@ -87,43 +107,32 @@ function buildScopedContext(
|
|
|
87
107
|
) {
|
|
88
108
|
const ctx = { ...helpers };
|
|
89
109
|
|
|
90
|
-
|
|
91
|
-
const referencedFields = extractFieldReferences(expressionCode);
|
|
92
|
-
|
|
93
|
-
// Track problematic fields that are actually accessed
|
|
110
|
+
const referencedFields = extractStaticFieldReferences(expressionCode);
|
|
94
111
|
const restrictedAccessedFields = [];
|
|
95
112
|
const notFoundAccessedFields = [];
|
|
96
113
|
|
|
97
|
-
// Add scoped field access
|
|
98
114
|
for (const [fieldName, value] of Object.entries(values)) {
|
|
99
115
|
const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
|
|
100
116
|
|
|
101
117
|
if (accessLevel === 'accessible') {
|
|
102
118
|
ctx[`$${fieldName}`] = value;
|
|
103
119
|
} else if (accessLevel === 'restricted') {
|
|
104
|
-
// Only add restricted fields to context if they're actually referenced
|
|
105
120
|
if (referencedFields.has(fieldName)) {
|
|
106
121
|
ctx[`$${fieldName}`] = undefined;
|
|
107
122
|
restrictedAccessedFields.push(fieldName);
|
|
108
123
|
}
|
|
109
|
-
// If not referenced, don't add to context at all
|
|
110
124
|
}
|
|
111
|
-
// 'not_found' fields are not added to context at all
|
|
112
125
|
}
|
|
113
126
|
|
|
114
|
-
// Check for not_found fields that are actually referenced
|
|
115
127
|
for (const fieldName of referencedFields) {
|
|
116
|
-
// Check if this field was processed above (exists in values)
|
|
117
128
|
if (!(fieldName in values)) {
|
|
118
129
|
const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
|
|
119
130
|
if (accessLevel === 'not_found') {
|
|
120
131
|
notFoundAccessedFields.push(fieldName);
|
|
121
|
-
// Don't add to context - it will be undefined when accessed
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
135
|
|
|
126
|
-
// Emit warnings for restricted fields that are actually accessed
|
|
127
136
|
restrictedAccessedFields.forEach((fieldName) => {
|
|
128
137
|
const warning = contextResolver.generateAccessWarning(
|
|
129
138
|
executionContext,
|
|
@@ -133,7 +142,6 @@ function buildScopedContext(
|
|
|
133
142
|
warningSystem.emitWarning(warning);
|
|
134
143
|
});
|
|
135
144
|
|
|
136
|
-
// Emit warnings for not_found fields that are actually accessed
|
|
137
145
|
notFoundAccessedFields.forEach((fieldName) => {
|
|
138
146
|
const warning = contextResolver.generateAccessWarning(executionContext, fieldName, 'not_found');
|
|
139
147
|
warningSystem.emitWarning(warning);
|
|
@@ -142,93 +150,226 @@ function buildScopedContext(
|
|
|
142
150
|
return ctx;
|
|
143
151
|
}
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
153
|
+
function pushRuntimeDiagnostic(
|
|
154
|
+
runtimeDiagnostics,
|
|
155
|
+
fieldName,
|
|
156
|
+
message,
|
|
157
|
+
severity = 'error',
|
|
158
|
+
dedupeKey = null
|
|
159
|
+
) {
|
|
160
|
+
if (!Array.isArray(runtimeDiagnostics)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
dedupeKey &&
|
|
166
|
+
runtimeDiagnostics.some(
|
|
167
|
+
(diagnostic) =>
|
|
168
|
+
diagnostic?.fieldName === fieldName &&
|
|
169
|
+
diagnostic?.message === message &&
|
|
170
|
+
diagnostic?.dedupeKey === dedupeKey
|
|
171
|
+
)
|
|
172
|
+
) {
|
|
173
|
+
return;
|
|
165
174
|
}
|
|
166
175
|
|
|
167
|
-
|
|
176
|
+
runtimeDiagnostics.push({
|
|
177
|
+
fieldName,
|
|
178
|
+
message,
|
|
179
|
+
severity,
|
|
180
|
+
dedupeKey,
|
|
181
|
+
});
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
} else if (char === '"' && !inSingleQuote && !inBacktick) {
|
|
201
|
-
inDoubleQuote = !inDoubleQuote;
|
|
202
|
-
} else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
|
|
203
|
-
inBacktick = !inBacktick;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Only count parentheses when not inside strings
|
|
207
|
-
if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
|
|
208
|
-
if (char === '(') {
|
|
209
|
-
parenCount++;
|
|
210
|
-
} else if (char === ')') {
|
|
211
|
-
parenCount--;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
i++;
|
|
216
|
-
}
|
|
217
|
-
} else {
|
|
218
|
-
// Not an EVAL call, add character to result
|
|
219
|
-
result += code[i];
|
|
220
|
-
i++;
|
|
184
|
+
function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics) {
|
|
185
|
+
plan.dynamicFieldNames.forEach((fieldName) => {
|
|
186
|
+
const message = `CalculatedField '${fieldName}' uses dynamic field access. Runtime evaluation falls back to bounded stabilization.`;
|
|
187
|
+
warningSystem.emitWarning({
|
|
188
|
+
type: 'calculation_dependency',
|
|
189
|
+
reason: 'dynamic_dependencies',
|
|
190
|
+
message,
|
|
191
|
+
suggestion: 'Prefer direct $field references when possible to enable dependency ordering.',
|
|
192
|
+
executionContext: {
|
|
193
|
+
type: 'calculation',
|
|
194
|
+
fieldName,
|
|
195
|
+
},
|
|
196
|
+
fieldContext: {
|
|
197
|
+
fieldName,
|
|
198
|
+
dynamic: true,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
pushRuntimeDiagnostic(
|
|
202
|
+
runtimeDiagnostics,
|
|
203
|
+
fieldName,
|
|
204
|
+
message,
|
|
205
|
+
'warning',
|
|
206
|
+
`dynamic:${fieldName}`
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
plan.cyclicComponentIds.forEach((componentId) => {
|
|
211
|
+
const component = plan.componentsById.get(componentId);
|
|
212
|
+
if (!component?.isCyclic) {
|
|
213
|
+
return;
|
|
221
214
|
}
|
|
215
|
+
|
|
216
|
+
const fieldNames = component.fieldNames;
|
|
217
|
+
const message = `CalculatedField dependency cycle detected: ${fieldNames.join(' -> ')}. Runtime evaluation is disabled for the involved fields until the cycle is removed.`;
|
|
218
|
+
warningSystem.emitWarning({
|
|
219
|
+
type: 'calculation_dependency',
|
|
220
|
+
reason: 'cyclic_dependencies',
|
|
221
|
+
severity: 'error',
|
|
222
|
+
message,
|
|
223
|
+
suggestion:
|
|
224
|
+
'Remove the cycle or break it with a source field so calculated values can be evaluated deterministically.',
|
|
225
|
+
executionContext: {
|
|
226
|
+
type: 'calculation',
|
|
227
|
+
fieldName: fieldNames[0] || null,
|
|
228
|
+
},
|
|
229
|
+
fieldContext: {
|
|
230
|
+
fieldNames: [...fieldNames],
|
|
231
|
+
cyclic: true,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
fieldNames.forEach((fieldName) => {
|
|
236
|
+
pushRuntimeDiagnostic(
|
|
237
|
+
runtimeDiagnostics,
|
|
238
|
+
fieldName,
|
|
239
|
+
message,
|
|
240
|
+
'error',
|
|
241
|
+
`cycle:${componentId}`
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function evaluateCalculatedField(
|
|
248
|
+
field,
|
|
249
|
+
schema,
|
|
250
|
+
values,
|
|
251
|
+
helpers,
|
|
252
|
+
securityConfig,
|
|
253
|
+
resolver,
|
|
254
|
+
warnings,
|
|
255
|
+
runtimeDiagnostics
|
|
256
|
+
) {
|
|
257
|
+
if (!field?.data_name || !field.calculate) {
|
|
258
|
+
return { changed: false, hasError: false };
|
|
222
259
|
}
|
|
223
260
|
|
|
224
|
-
|
|
261
|
+
try {
|
|
262
|
+
const executionContext = buildCalculationExecutionContext(schema, field.data_name, resolver);
|
|
263
|
+
const context = buildScopedContext(
|
|
264
|
+
values,
|
|
265
|
+
helpers,
|
|
266
|
+
executionContext,
|
|
267
|
+
resolver,
|
|
268
|
+
warnings,
|
|
269
|
+
field.calculate
|
|
270
|
+
);
|
|
271
|
+
const previousValue = values[field.data_name];
|
|
272
|
+
const nextValue = runExpression(field.calculate, context, securityConfig, false, schema, {
|
|
273
|
+
suppressConsoleWarning: Array.isArray(runtimeDiagnostics),
|
|
274
|
+
onError: (error) => {
|
|
275
|
+
pushRuntimeDiagnostic(
|
|
276
|
+
runtimeDiagnostics,
|
|
277
|
+
field.data_name,
|
|
278
|
+
error instanceof Error && error.message
|
|
279
|
+
? error.message
|
|
280
|
+
: 'Unknown calculation runtime error.'
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
values[field.data_name] = nextValue;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
changed: !areStructuredValuesEqual(previousValue, nextValue),
|
|
288
|
+
hasError: false,
|
|
289
|
+
};
|
|
290
|
+
} catch (error) {
|
|
291
|
+
pushRuntimeDiagnostic(
|
|
292
|
+
runtimeDiagnostics,
|
|
293
|
+
field.data_name,
|
|
294
|
+
error instanceof Error && error.message ? error.message : 'Unknown calculation runtime error.'
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
if (!Array.isArray(runtimeDiagnostics)) {
|
|
298
|
+
console.warn(`Calculation failed for ${field.data_name}:`, error?.message);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { changed: false, hasError: true };
|
|
302
|
+
}
|
|
225
303
|
}
|
|
226
304
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
305
|
+
function evaluateFieldSequenceUntilStable(
|
|
306
|
+
fieldNames,
|
|
307
|
+
plan,
|
|
308
|
+
schema,
|
|
309
|
+
values,
|
|
310
|
+
helpers,
|
|
311
|
+
securityConfig,
|
|
312
|
+
resolver,
|
|
313
|
+
warnings,
|
|
314
|
+
runtimeDiagnostics
|
|
315
|
+
) {
|
|
316
|
+
const maxPasses = Math.max(2, fieldNames.length);
|
|
317
|
+
let stillChangingFieldNames = [];
|
|
318
|
+
|
|
319
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
320
|
+
stillChangingFieldNames = [];
|
|
321
|
+
|
|
322
|
+
fieldNames.forEach((fieldName) => {
|
|
323
|
+
const node = plan.nodesByDataName.get(fieldName);
|
|
324
|
+
if (!node?.field) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const result = evaluateCalculatedField(
|
|
329
|
+
node.field,
|
|
330
|
+
schema,
|
|
331
|
+
values,
|
|
332
|
+
helpers,
|
|
333
|
+
securityConfig,
|
|
334
|
+
resolver,
|
|
335
|
+
warnings,
|
|
336
|
+
runtimeDiagnostics
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
if (result.changed) {
|
|
340
|
+
stillChangingFieldNames.push(fieldName);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (stillChangingFieldNames.length === 0) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
232
347
|
}
|
|
233
|
-
|
|
348
|
+
|
|
349
|
+
const message = `Calculated fields did not stabilize within ${maxPasses} evaluation passes: ${stillChangingFieldNames.join(', ')}.`;
|
|
350
|
+
warnings.emitWarning({
|
|
351
|
+
type: 'calculation_dependency',
|
|
352
|
+
reason: 'non_converging_runtime',
|
|
353
|
+
message,
|
|
354
|
+
suggestion:
|
|
355
|
+
'Review the involved CalculatedField expressions for dependency cycles or dynamic references that never settle.',
|
|
356
|
+
executionContext: {
|
|
357
|
+
type: 'calculation',
|
|
358
|
+
fieldName: stillChangingFieldNames[0] || null,
|
|
359
|
+
},
|
|
360
|
+
fieldContext: {
|
|
361
|
+
fieldNames: [...stillChangingFieldNames],
|
|
362
|
+
maxPasses,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
stillChangingFieldNames.forEach((fieldName) => {
|
|
367
|
+
pushRuntimeDiagnostic(
|
|
368
|
+
runtimeDiagnostics,
|
|
369
|
+
fieldName,
|
|
370
|
+
message,
|
|
371
|
+
'warning',
|
|
372
|
+
`non-converging:${fieldNames.join(',')}`
|
|
373
|
+
);
|
|
374
|
+
});
|
|
234
375
|
}
|
|
@@ -12,6 +12,7 @@ import { FIELD_SPECS } from '../schema/field-specs.js';
|
|
|
12
12
|
import { ContextResolver } from './context-resolver.js';
|
|
13
13
|
import { WarningSystem } from './warning-system.js';
|
|
14
14
|
import { expandBuildingPlanSchema } from '../schema/building-plan-expander.js';
|
|
15
|
+
import { buildCalculationDependencyPlan } from '../utilities/calculation-dependencies.js';
|
|
15
16
|
|
|
16
17
|
export function createFormEngine({
|
|
17
18
|
schema,
|
|
@@ -94,6 +95,7 @@ export function createFormEngine({
|
|
|
94
95
|
|
|
95
96
|
// Initialize context resolution system
|
|
96
97
|
const contextResolver = new ContextResolver(form);
|
|
98
|
+
const calculationPlan = buildCalculationDependencyPlan(form, contextResolver);
|
|
97
99
|
const sharedWarningSystem = warningSystem || new WarningSystem();
|
|
98
100
|
|
|
99
101
|
// Initialize event system with context resolution
|
|
@@ -128,7 +130,8 @@ export function createFormEngine({
|
|
|
128
130
|
security,
|
|
129
131
|
contextResolver,
|
|
130
132
|
sharedWarningSystem,
|
|
131
|
-
runtimeDiagnostics
|
|
133
|
+
runtimeDiagnostics,
|
|
134
|
+
calculationPlan
|
|
132
135
|
);
|
|
133
136
|
evaluateRequirement(form, values, required);
|
|
134
137
|
evaluateVisibility(form, values, visible);
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { ContextResolver } from '../engine/context-resolver.js';
|
|
2
|
+
import { flattenFields } from './field-helpers.js';
|
|
3
|
+
|
|
4
|
+
function sortFieldNames(fieldNames, orderByFieldName) {
|
|
5
|
+
return [...fieldNames].sort(
|
|
6
|
+
(left, right) => orderByFieldName.get(left) - orderByFieldName.get(right)
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function buildStronglyConnectedComponents(nodesByDataName, orderByFieldName) {
|
|
11
|
+
const indices = new Map();
|
|
12
|
+
const lowLinks = new Map();
|
|
13
|
+
const stack = [];
|
|
14
|
+
const onStack = new Set();
|
|
15
|
+
const components = [];
|
|
16
|
+
let currentIndex = 0;
|
|
17
|
+
|
|
18
|
+
function strongConnect(fieldName) {
|
|
19
|
+
indices.set(fieldName, currentIndex);
|
|
20
|
+
lowLinks.set(fieldName, currentIndex);
|
|
21
|
+
currentIndex += 1;
|
|
22
|
+
stack.push(fieldName);
|
|
23
|
+
onStack.add(fieldName);
|
|
24
|
+
|
|
25
|
+
const node = nodesByDataName.get(fieldName);
|
|
26
|
+
if (node) {
|
|
27
|
+
for (const dependency of node.dependencies) {
|
|
28
|
+
if (!indices.has(dependency)) {
|
|
29
|
+
strongConnect(dependency);
|
|
30
|
+
lowLinks.set(fieldName, Math.min(lowLinks.get(fieldName), lowLinks.get(dependency)));
|
|
31
|
+
} else if (onStack.has(dependency)) {
|
|
32
|
+
lowLinks.set(fieldName, Math.min(lowLinks.get(fieldName), indices.get(dependency)));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (lowLinks.get(fieldName) !== indices.get(fieldName)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const componentFieldNames = [];
|
|
42
|
+
while (stack.length > 0) {
|
|
43
|
+
const stackFieldName = stack.pop();
|
|
44
|
+
onStack.delete(stackFieldName);
|
|
45
|
+
componentFieldNames.push(stackFieldName);
|
|
46
|
+
if (stackFieldName === fieldName) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
components.push(sortFieldNames(componentFieldNames, orderByFieldName));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sortedFieldNames = sortFieldNames(nodesByDataName.keys(), orderByFieldName);
|
|
55
|
+
sortedFieldNames.forEach((fieldName) => {
|
|
56
|
+
if (!indices.has(fieldName)) {
|
|
57
|
+
strongConnect(fieldName);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return components;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function topologicallyOrderComponents(componentIds, componentEdges) {
|
|
65
|
+
const inDegree = new Map(componentIds.map((componentId) => [componentId, 0]));
|
|
66
|
+
|
|
67
|
+
componentEdges.forEach((targets) => {
|
|
68
|
+
targets.forEach((targetId) => {
|
|
69
|
+
inDegree.set(targetId, (inDegree.get(targetId) || 0) + 1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const queue = componentIds.filter((componentId) => (inDegree.get(componentId) || 0) === 0);
|
|
74
|
+
const ordered = [];
|
|
75
|
+
|
|
76
|
+
while (queue.length > 0) {
|
|
77
|
+
const componentId = queue.shift();
|
|
78
|
+
ordered.push(componentId);
|
|
79
|
+
|
|
80
|
+
for (const targetId of componentEdges.get(componentId) || []) {
|
|
81
|
+
const nextDegree = (inDegree.get(targetId) || 0) - 1;
|
|
82
|
+
inDegree.set(targetId, nextDegree);
|
|
83
|
+
if (nextDegree === 0) {
|
|
84
|
+
queue.push(targetId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (ordered.length === componentIds.length) {
|
|
90
|
+
return ordered;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [...componentIds];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function normalizeCalculationFormSchema(schema) {
|
|
97
|
+
if (schema && Array.isArray(schema.elements)) {
|
|
98
|
+
return schema;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (schema && schema.form && Array.isArray(schema.form.elements)) {
|
|
102
|
+
return schema.form;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw new Error('Expected a form schema or a root schema containing form.elements');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isEscapedCharacter(source, index) {
|
|
109
|
+
let backslashCount = 0;
|
|
110
|
+
let cursor = index - 1;
|
|
111
|
+
|
|
112
|
+
while (cursor >= 0 && source[cursor] === '\\') {
|
|
113
|
+
backslashCount += 1;
|
|
114
|
+
cursor -= 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return backslashCount % 2 === 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function skipWhitespace(source, state) {
|
|
121
|
+
while (state.index < source.length && /\s/.test(source[state.index])) {
|
|
122
|
+
state.index += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseStaticStringLiteral(source, state) {
|
|
127
|
+
const quote = source[state.index];
|
|
128
|
+
if (quote !== "'" && quote !== '"' && quote !== '`') {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
state.index += 1;
|
|
133
|
+
let value = '';
|
|
134
|
+
|
|
135
|
+
while (state.index < source.length) {
|
|
136
|
+
const char = source[state.index];
|
|
137
|
+
|
|
138
|
+
if (char === quote && !isEscapedCharacter(source, state.index)) {
|
|
139
|
+
state.index += 1;
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (quote === '`' && char === '$' && source[state.index + 1] === '{') {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (char === '\\' && state.index + 1 < source.length) {
|
|
148
|
+
const escapedChar = source[state.index + 1];
|
|
149
|
+
const escapeMap = {
|
|
150
|
+
n: '\n',
|
|
151
|
+
r: '\r',
|
|
152
|
+
t: '\t',
|
|
153
|
+
"'": "'",
|
|
154
|
+
'"': '"',
|
|
155
|
+
'`': '`',
|
|
156
|
+
'\\': '\\',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
value += escapeMap[escapedChar] ?? escapedChar;
|
|
160
|
+
state.index += 2;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
value += char;
|
|
165
|
+
state.index += 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseStaticStringTerm(source, state) {
|
|
172
|
+
skipWhitespace(source, state);
|
|
173
|
+
|
|
174
|
+
if (source[state.index] === '(') {
|
|
175
|
+
state.index += 1;
|
|
176
|
+
const nestedValue = parseStaticStringConcatenation(source, state);
|
|
177
|
+
if (nestedValue === null) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
skipWhitespace(source, state);
|
|
182
|
+
if (source[state.index] !== ')') {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
state.index += 1;
|
|
187
|
+
return nestedValue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return parseStaticStringLiteral(source, state);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseStaticStringConcatenation(source, state) {
|
|
194
|
+
const firstValue = parseStaticStringTerm(source, state);
|
|
195
|
+
if (firstValue === null) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let result = firstValue;
|
|
200
|
+
|
|
201
|
+
while (true) {
|
|
202
|
+
skipWhitespace(source, state);
|
|
203
|
+
if (source[state.index] !== '+') {
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
state.index += 1;
|
|
208
|
+
const nextValue = parseStaticStringTerm(source, state);
|
|
209
|
+
if (nextValue === null) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
result += nextValue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resolveStaticStringExpression(source) {
|
|
218
|
+
if (typeof source !== 'string') {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const state = { index: 0 };
|
|
223
|
+
skipWhitespace(source, state);
|
|
224
|
+
|
|
225
|
+
if (state.index >= source.length) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const result = parseStaticStringConcatenation(source, state);
|
|
230
|
+
if (result === null) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
skipWhitespace(source, state);
|
|
235
|
+
return state.index === source.length ? result : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function extractEvalCallArguments(code) {
|
|
239
|
+
const source = typeof code === 'string' ? code : '';
|
|
240
|
+
const argumentsList = [];
|
|
241
|
+
let i = 0;
|
|
242
|
+
|
|
243
|
+
while (i < source.length) {
|
|
244
|
+
const evalMatch = source.substring(i).match(/^EVAL\s*\(/);
|
|
245
|
+
if (!evalMatch) {
|
|
246
|
+
i += 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
i += evalMatch[0].length;
|
|
251
|
+
const startIndex = i;
|
|
252
|
+
|
|
253
|
+
let parenCount = 1;
|
|
254
|
+
let inSingleQuote = false;
|
|
255
|
+
let inDoubleQuote = false;
|
|
256
|
+
let inBacktick = false;
|
|
257
|
+
|
|
258
|
+
while (i < source.length && parenCount > 0) {
|
|
259
|
+
const char = source[i];
|
|
260
|
+
|
|
261
|
+
if (char === "'" && !inDoubleQuote && !inBacktick && !isEscapedCharacter(source, i)) {
|
|
262
|
+
inSingleQuote = !inSingleQuote;
|
|
263
|
+
} else if (
|
|
264
|
+
char === '"' &&
|
|
265
|
+
!inSingleQuote &&
|
|
266
|
+
!inBacktick &&
|
|
267
|
+
!isEscapedCharacter(source, i)
|
|
268
|
+
) {
|
|
269
|
+
inDoubleQuote = !inDoubleQuote;
|
|
270
|
+
} else if (
|
|
271
|
+
char === '`' &&
|
|
272
|
+
!inSingleQuote &&
|
|
273
|
+
!inDoubleQuote &&
|
|
274
|
+
!isEscapedCharacter(source, i)
|
|
275
|
+
) {
|
|
276
|
+
inBacktick = !inBacktick;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
|
|
280
|
+
if (char === '(') {
|
|
281
|
+
parenCount += 1;
|
|
282
|
+
} else if (char === ')') {
|
|
283
|
+
parenCount -= 1;
|
|
284
|
+
if (parenCount === 0) {
|
|
285
|
+
argumentsList.push(source.slice(startIndex, i));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
i += 1;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return argumentsList;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function resolveStaticEvalFieldReference(expression) {
|
|
298
|
+
let currentExpression = typeof expression === 'string' ? expression : null;
|
|
299
|
+
|
|
300
|
+
for (let depth = 0; depth < 2 && currentExpression; depth += 1) {
|
|
301
|
+
const resolvedString = resolveStaticStringExpression(currentExpression);
|
|
302
|
+
if (resolvedString === null) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const fieldReferenceMatch = resolvedString.match(/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
307
|
+
if (fieldReferenceMatch) {
|
|
308
|
+
return fieldReferenceMatch[1];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
currentExpression = resolvedString;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function extractStaticEvalFieldReferences(code) {
|
|
318
|
+
const fieldReferences = new Set();
|
|
319
|
+
|
|
320
|
+
extractEvalCallArguments(code).forEach((argumentSource) => {
|
|
321
|
+
const fieldName = resolveStaticEvalFieldReference(argumentSource);
|
|
322
|
+
if (fieldName) {
|
|
323
|
+
fieldReferences.add(fieldName);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return fieldReferences;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function hasDynamicCalculationDependencies(code) {
|
|
331
|
+
const evalArguments = extractEvalCallArguments(code);
|
|
332
|
+
if (evalArguments.length === 0) {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return evalArguments.some((argumentSource) => !resolveStaticEvalFieldReference(argumentSource));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function removeEvalContents(code) {
|
|
340
|
+
const source = typeof code === 'string' ? code : '';
|
|
341
|
+
let result = '';
|
|
342
|
+
let i = 0;
|
|
343
|
+
|
|
344
|
+
while (i < source.length) {
|
|
345
|
+
const evalMatch = source.substring(i).match(/^EVAL\s*\(/);
|
|
346
|
+
if (evalMatch) {
|
|
347
|
+
result += 'EVAL()';
|
|
348
|
+
i += evalMatch[0].length;
|
|
349
|
+
|
|
350
|
+
let parenCount = 1;
|
|
351
|
+
let inSingleQuote = false;
|
|
352
|
+
let inDoubleQuote = false;
|
|
353
|
+
let inBacktick = false;
|
|
354
|
+
|
|
355
|
+
while (i < source.length && parenCount > 0) {
|
|
356
|
+
const char = source[i];
|
|
357
|
+
|
|
358
|
+
if (char === "'" && !inDoubleQuote && !inBacktick) {
|
|
359
|
+
inSingleQuote = !inSingleQuote;
|
|
360
|
+
} else if (char === '"' && !inSingleQuote && !inBacktick) {
|
|
361
|
+
inDoubleQuote = !inDoubleQuote;
|
|
362
|
+
} else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
|
|
363
|
+
inBacktick = !inBacktick;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
|
|
367
|
+
if (char === '(') {
|
|
368
|
+
parenCount += 1;
|
|
369
|
+
} else if (char === ')') {
|
|
370
|
+
parenCount -= 1;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
i += 1;
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
result += source[i];
|
|
378
|
+
i += 1;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function extractStaticFieldReferenceMatches(code) {
|
|
386
|
+
const cleanedCode = removeEvalContents(code);
|
|
387
|
+
const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
388
|
+
const matches = [];
|
|
389
|
+
let match;
|
|
390
|
+
|
|
391
|
+
while ((match = fieldRegex.exec(cleanedCode)) !== null) {
|
|
392
|
+
matches.push({
|
|
393
|
+
fieldName: match[1],
|
|
394
|
+
reference: `$${match[1]}`,
|
|
395
|
+
index: match.index,
|
|
396
|
+
length: match[0].length,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return matches;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function extractStaticFieldReferences(code) {
|
|
404
|
+
const fieldReferences = new Set(
|
|
405
|
+
extractStaticFieldReferenceMatches(code).map((match) => match.fieldName)
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
extractStaticEvalFieldReferences(code).forEach((fieldName) => {
|
|
409
|
+
fieldReferences.add(fieldName);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
return fieldReferences;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function buildCalculationExecutionContext(form, fieldDataName, resolver) {
|
|
416
|
+
return {
|
|
417
|
+
type: 'calculation',
|
|
418
|
+
fieldName: fieldDataName,
|
|
419
|
+
parentPath: resolver.getFieldInfo(fieldDataName)?.parentPath || [],
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function buildCalculationDependencyPlan(schema, contextResolver = null) {
|
|
424
|
+
const form = normalizeCalculationFormSchema(schema);
|
|
425
|
+
const resolver = contextResolver || new ContextResolver(form);
|
|
426
|
+
const fields = flattenFields(form.elements).filter((field) => field && typeof field === 'object');
|
|
427
|
+
const fieldsByDataName = new Map(
|
|
428
|
+
fields
|
|
429
|
+
.filter((field) => typeof field.data_name === 'string' && field.data_name.length > 0)
|
|
430
|
+
.map((field) => [field.data_name, field])
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const orderByFieldName = new Map();
|
|
434
|
+
fields.forEach((field, index) => {
|
|
435
|
+
if (typeof field?.data_name === 'string') {
|
|
436
|
+
orderByFieldName.set(field.data_name, index);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const nodesByDataName = new Map();
|
|
441
|
+
fields.forEach((field) => {
|
|
442
|
+
if (field.type !== 'CalculatedField' || typeof field.data_name !== 'string') {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const fieldName = field.data_name;
|
|
447
|
+
const executionContext = buildCalculationExecutionContext(form, fieldName, resolver);
|
|
448
|
+
const directReferences = extractStaticFieldReferences(field.calculate || '');
|
|
449
|
+
const dependencies = new Set();
|
|
450
|
+
|
|
451
|
+
directReferences.forEach((referenceFieldName) => {
|
|
452
|
+
const accessInfo = resolver.resolveFieldAccessInfo(executionContext, referenceFieldName);
|
|
453
|
+
if (accessInfo.level !== 'accessible') {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const dependencyField = fieldsByDataName.get(referenceFieldName);
|
|
458
|
+
if (dependencyField?.type === 'CalculatedField') {
|
|
459
|
+
dependencies.add(referenceFieldName);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
nodesByDataName.set(fieldName, {
|
|
464
|
+
field,
|
|
465
|
+
fieldName,
|
|
466
|
+
dependencies,
|
|
467
|
+
directReferences,
|
|
468
|
+
hasDynamicDependencies: hasDynamicCalculationDependencies(field.calculate || ''),
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const componentFieldGroups = buildStronglyConnectedComponents(nodesByDataName, orderByFieldName);
|
|
473
|
+
const componentIdByFieldName = new Map();
|
|
474
|
+
const componentsById = new Map();
|
|
475
|
+
|
|
476
|
+
componentFieldGroups.forEach((fieldNames, componentIndex) => {
|
|
477
|
+
const componentId = `component_${componentIndex}`;
|
|
478
|
+
fieldNames.forEach((fieldName) => {
|
|
479
|
+
componentIdByFieldName.set(fieldName, componentId);
|
|
480
|
+
});
|
|
481
|
+
componentsById.set(componentId, {
|
|
482
|
+
id: componentId,
|
|
483
|
+
fieldNames,
|
|
484
|
+
hasDynamicDependencies: false,
|
|
485
|
+
isCyclic: false,
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
nodesByDataName.forEach((node) => {
|
|
490
|
+
node.componentId = componentIdByFieldName.get(node.fieldName);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
componentsById.forEach((component) => {
|
|
494
|
+
component.hasDynamicDependencies = component.fieldNames.some(
|
|
495
|
+
(fieldName) => nodesByDataName.get(fieldName)?.hasDynamicDependencies
|
|
496
|
+
);
|
|
497
|
+
component.isCyclic =
|
|
498
|
+
component.fieldNames.length > 1 ||
|
|
499
|
+
component.fieldNames.some((fieldName) =>
|
|
500
|
+
nodesByDataName.get(fieldName)?.dependencies.has(fieldName)
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const componentEdges = new Map(
|
|
505
|
+
Array.from(componentsById.keys(), (componentId) => [componentId, new Set()])
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
nodesByDataName.forEach((node) => {
|
|
509
|
+
const targetComponentId = node.componentId;
|
|
510
|
+
node.dependencies.forEach((dependencyFieldName) => {
|
|
511
|
+
const dependencyComponentId = componentIdByFieldName.get(dependencyFieldName);
|
|
512
|
+
if (!dependencyComponentId || dependencyComponentId === targetComponentId) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
componentEdges.get(dependencyComponentId).add(targetComponentId);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const orderedComponentIds = topologicallyOrderComponents(
|
|
520
|
+
Array.from(componentsById.keys()),
|
|
521
|
+
componentEdges
|
|
522
|
+
);
|
|
523
|
+
const orderedFieldNames = orderedComponentIds.flatMap(
|
|
524
|
+
(componentId) => componentsById.get(componentId)?.fieldNames || []
|
|
525
|
+
);
|
|
526
|
+
const dynamicFieldNames = orderedFieldNames.filter(
|
|
527
|
+
(fieldName) => nodesByDataName.get(fieldName)?.hasDynamicDependencies
|
|
528
|
+
);
|
|
529
|
+
const cyclicComponentIds = orderedComponentIds.filter(
|
|
530
|
+
(componentId) => componentsById.get(componentId)?.isCyclic
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
form,
|
|
535
|
+
fieldsByDataName,
|
|
536
|
+
nodesByDataName,
|
|
537
|
+
componentsById,
|
|
538
|
+
componentIdByFieldName,
|
|
539
|
+
orderedComponentIds,
|
|
540
|
+
orderedFieldNames,
|
|
541
|
+
hasDynamicDependencies: dynamicFieldNames.length > 0,
|
|
542
|
+
dynamicFieldNames,
|
|
543
|
+
cyclicComponentIds,
|
|
544
|
+
totalCalculatedFieldCount: orderedFieldNames.length,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
@@ -8,6 +8,13 @@ import { createFormEngine } from '../engine/form-engine.js';
|
|
|
8
8
|
import { WarningSystem } from '../engine/warning-system.js';
|
|
9
9
|
import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
|
|
10
10
|
import { validateExpression } from '../security/validation.js';
|
|
11
|
+
import {
|
|
12
|
+
buildCalculationDependencyPlan,
|
|
13
|
+
buildCalculationExecutionContext,
|
|
14
|
+
extractStaticFieldReferenceMatches,
|
|
15
|
+
hasDynamicCalculationDependencies,
|
|
16
|
+
normalizeCalculationFormSchema,
|
|
17
|
+
} from './calculation-dependencies.js';
|
|
11
18
|
import {
|
|
12
19
|
isMultilineCalculationExpression,
|
|
13
20
|
normalizeInlineCalculationExpression,
|
|
@@ -53,67 +60,10 @@ const CALCULATION_BUILTIN_METADATA_BY_NAME = new Map(
|
|
|
53
60
|
CALCULATION_BUILTIN_CATALOG.map((definition) => [definition.name, definition])
|
|
54
61
|
);
|
|
55
62
|
|
|
56
|
-
function normalizeFormSchema(schema) {
|
|
57
|
-
if (schema && Array.isArray(schema.elements)) {
|
|
58
|
-
return schema;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (schema && schema.form && Array.isArray(schema.form.elements)) {
|
|
62
|
-
return schema.form;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
throw new Error('Expected a form schema or a root schema containing form.elements');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
63
|
function isBuiltinCallMatchAllowed(functionName) {
|
|
69
64
|
return COMMON_GLOBAL_FUNCTIONS.has(functionName);
|
|
70
65
|
}
|
|
71
66
|
|
|
72
|
-
function removeEvalContents(code) {
|
|
73
|
-
let result = '';
|
|
74
|
-
let i = 0;
|
|
75
|
-
|
|
76
|
-
while (i < code.length) {
|
|
77
|
-
const evalMatch = code.substring(i).match(/^EVAL\s*\(/);
|
|
78
|
-
if (evalMatch) {
|
|
79
|
-
result += 'EVAL()';
|
|
80
|
-
i += evalMatch[0].length;
|
|
81
|
-
|
|
82
|
-
let parenCount = 1;
|
|
83
|
-
let inSingleQuote = false;
|
|
84
|
-
let inDoubleQuote = false;
|
|
85
|
-
let inBacktick = false;
|
|
86
|
-
|
|
87
|
-
while (i < code.length && parenCount > 0) {
|
|
88
|
-
const char = code[i];
|
|
89
|
-
|
|
90
|
-
if (char === "'" && !inDoubleQuote && !inBacktick) {
|
|
91
|
-
inSingleQuote = !inSingleQuote;
|
|
92
|
-
} else if (char === '"' && !inSingleQuote && !inBacktick) {
|
|
93
|
-
inDoubleQuote = !inDoubleQuote;
|
|
94
|
-
} else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
|
|
95
|
-
inBacktick = !inBacktick;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
|
|
99
|
-
if (char === '(') {
|
|
100
|
-
parenCount++;
|
|
101
|
-
} else if (char === ')') {
|
|
102
|
-
parenCount--;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
i++;
|
|
107
|
-
}
|
|
108
|
-
} else {
|
|
109
|
-
result += code[i];
|
|
110
|
-
i++;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return result;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
67
|
function extractFunctionCalls(code) {
|
|
118
68
|
const functionCallPattern = /\b([A-Z_][A-Z0-9_]*)\s*\(/g;
|
|
119
69
|
const matches = [];
|
|
@@ -130,32 +80,6 @@ function extractFunctionCalls(code) {
|
|
|
130
80
|
return matches;
|
|
131
81
|
}
|
|
132
82
|
|
|
133
|
-
function extractFieldReferenceMatches(code) {
|
|
134
|
-
const cleanedCode = removeEvalContents(code);
|
|
135
|
-
const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
136
|
-
const matches = [];
|
|
137
|
-
let match;
|
|
138
|
-
|
|
139
|
-
while ((match = fieldRegex.exec(cleanedCode)) !== null) {
|
|
140
|
-
matches.push({
|
|
141
|
-
fieldName: match[1],
|
|
142
|
-
reference: `$${match[1]}`,
|
|
143
|
-
index: match.index,
|
|
144
|
-
length: match[0].length,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return matches;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function buildExecutionContext(form, fieldDataName, resolver) {
|
|
152
|
-
return {
|
|
153
|
-
type: 'calculation',
|
|
154
|
-
fieldName: fieldDataName,
|
|
155
|
-
parentPath: resolver.getFieldInfo(fieldDataName)?.parentPath || [],
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
83
|
function cloneCatalogDefinition(definition) {
|
|
160
84
|
return {
|
|
161
85
|
...definition,
|
|
@@ -254,7 +178,7 @@ function cloneSimulationSchema(schema) {
|
|
|
254
178
|
}
|
|
255
179
|
|
|
256
180
|
return {
|
|
257
|
-
form: deepClone(
|
|
181
|
+
form: deepClone(normalizeCalculationFormSchema(schema)),
|
|
258
182
|
};
|
|
259
183
|
}
|
|
260
184
|
|
|
@@ -383,9 +307,9 @@ export function getCalculationReferenceCatalog({
|
|
|
383
307
|
fieldDataName,
|
|
384
308
|
includeRestricted = false,
|
|
385
309
|
}) {
|
|
386
|
-
const form =
|
|
310
|
+
const form = normalizeCalculationFormSchema(schema);
|
|
387
311
|
const resolver = new ContextResolver(form);
|
|
388
|
-
const executionContext =
|
|
312
|
+
const executionContext = buildCalculationExecutionContext(form, fieldDataName, resolver);
|
|
389
313
|
|
|
390
314
|
return flattenFields(form.elements)
|
|
391
315
|
.filter((field) => field && typeof field === 'object')
|
|
@@ -431,9 +355,14 @@ export function analyzeCalculationExpression({
|
|
|
431
355
|
const setResultCalls = [];
|
|
432
356
|
|
|
433
357
|
const normalizedExpression = typeof expression === 'string' ? expression : '';
|
|
434
|
-
const
|
|
358
|
+
const baseForm = normalizeCalculationFormSchema(schema);
|
|
359
|
+
const form = deepClone(baseForm);
|
|
360
|
+
const targetField = findFieldByDataName(form.elements, fieldDataName);
|
|
361
|
+
if (targetField && targetField.type === 'CalculatedField') {
|
|
362
|
+
targetField.calculate = normalizedExpression;
|
|
363
|
+
}
|
|
435
364
|
const resolver = new ContextResolver(form);
|
|
436
|
-
const executionContext =
|
|
365
|
+
const executionContext = buildCalculationExecutionContext(form, fieldDataName, resolver);
|
|
437
366
|
|
|
438
367
|
if (normalizedExpression.trim().length === 0) {
|
|
439
368
|
addIssue(
|
|
@@ -590,7 +519,7 @@ export function analyzeCalculationExpression({
|
|
|
590
519
|
);
|
|
591
520
|
}
|
|
592
521
|
|
|
593
|
-
for (const reference of
|
|
522
|
+
for (const reference of extractStaticFieldReferenceMatches(normalizedExpression)) {
|
|
594
523
|
if (!referencedFieldNames.has(reference.fieldName)) {
|
|
595
524
|
referencedFieldNames.add(reference.fieldName);
|
|
596
525
|
referencedFields.push(reference.fieldName);
|
|
@@ -633,6 +562,39 @@ export function analyzeCalculationExpression({
|
|
|
633
562
|
}
|
|
634
563
|
}
|
|
635
564
|
|
|
565
|
+
if (hasDynamicCalculationDependencies(normalizedExpression)) {
|
|
566
|
+
addIssue(
|
|
567
|
+
issues,
|
|
568
|
+
issueKeys,
|
|
569
|
+
createIssue({
|
|
570
|
+
code: 'dynamic_calculation_dependency',
|
|
571
|
+
severity: 'warning',
|
|
572
|
+
message:
|
|
573
|
+
'This expression uses dynamic field access through EVAL(). Runtime evaluation will fall back to bounded stabilization.',
|
|
574
|
+
})
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (targetField?.type === 'CalculatedField') {
|
|
579
|
+
const dependencyPlan = buildCalculationDependencyPlan(form, resolver);
|
|
580
|
+
const targetNode = dependencyPlan.nodesByDataName.get(fieldDataName);
|
|
581
|
+
const targetComponent = targetNode?.componentId
|
|
582
|
+
? dependencyPlan.componentsById.get(targetNode.componentId)
|
|
583
|
+
: null;
|
|
584
|
+
|
|
585
|
+
if (targetComponent?.isCyclic) {
|
|
586
|
+
addIssue(
|
|
587
|
+
issues,
|
|
588
|
+
issueKeys,
|
|
589
|
+
createIssue({
|
|
590
|
+
code: 'calculated_field_cycle',
|
|
591
|
+
severity: 'error',
|
|
592
|
+
message: `CalculatedField dependency cycle detected: ${targetComponent.fieldNames.join(' -> ')}.`,
|
|
593
|
+
})
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
636
598
|
return {
|
|
637
599
|
valid: !issues.some((issue) => issue.severity === 'error'),
|
|
638
600
|
issues,
|
|
@@ -673,23 +635,20 @@ export function createCalculationPreviewSession({
|
|
|
673
635
|
security = DEFAULT_SECURITY_CONFIG,
|
|
674
636
|
}) {
|
|
675
637
|
const simulationSchema = cloneSimulationSchema(schema);
|
|
676
|
-
const form =
|
|
638
|
+
const form = normalizeCalculationFormSchema(simulationSchema);
|
|
677
639
|
const targetField = findFieldByDataName(form.elements, fieldDataName);
|
|
678
640
|
|
|
679
641
|
if (!targetField) {
|
|
680
642
|
return {
|
|
681
643
|
run: () =>
|
|
682
|
-
createSimulationErrorResult(
|
|
683
|
-
`Field '${fieldDataName}' does not exist in the form schema.`
|
|
684
|
-
),
|
|
644
|
+
createSimulationErrorResult(`Field '${fieldDataName}' does not exist in the form schema.`),
|
|
685
645
|
dispose: () => {},
|
|
686
646
|
};
|
|
687
647
|
}
|
|
688
648
|
|
|
689
649
|
if (targetField.type !== 'CalculatedField') {
|
|
690
650
|
return {
|
|
691
|
-
run: () =>
|
|
692
|
-
createSimulationErrorResult(`Field '${fieldDataName}' is not a CalculatedField.`),
|
|
651
|
+
run: () => createSimulationErrorResult(`Field '${fieldDataName}' is not a CalculatedField.`),
|
|
693
652
|
dispose: () => {},
|
|
694
653
|
};
|
|
695
654
|
}
|
|
@@ -739,9 +698,13 @@ export function createCalculationPreviewSession({
|
|
|
739
698
|
|
|
740
699
|
const simulatedResult = engineState.values[fieldDataName];
|
|
741
700
|
const runtimeError =
|
|
742
|
-
runtimeDiagnostics.find(
|
|
701
|
+
runtimeDiagnostics.find(
|
|
702
|
+
(diagnostic) =>
|
|
703
|
+
diagnostic.fieldName === fieldDataName &&
|
|
704
|
+
(diagnostic.severity || 'error') !== 'warning'
|
|
705
|
+
)?.message ||
|
|
706
|
+
runtimeDiagnostics.find((diagnostic) => (diagnostic.severity || 'error') !== 'warning')
|
|
743
707
|
?.message ||
|
|
744
|
-
runtimeDiagnostics[0]?.message ||
|
|
745
708
|
null;
|
|
746
709
|
|
|
747
710
|
return {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
if (!value || typeof value !== 'object') {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return Object.prototype.toString.call(value) === '[object Object]';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function areStructuredValuesEqual(left, right) {
|
|
10
|
+
if (Object.is(left, right)) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (left instanceof Date && right instanceof Date) {
|
|
15
|
+
return left.getTime() === right.getTime();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
19
|
+
if (left.length !== right.length) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
24
|
+
if (!areStructuredValuesEqual(left[index], right[index])) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (isPlainObject(left) && isPlainObject(right)) {
|
|
33
|
+
const leftKeys = Object.keys(left);
|
|
34
|
+
const rightKeys = Object.keys(right);
|
|
35
|
+
|
|
36
|
+
if (leftKeys.length !== rightKeys.length) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const key of leftKeys) {
|
|
41
|
+
if (!Object.prototype.hasOwnProperty.call(right, key)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!areStructuredValuesEqual(left[key], right[key])) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return false;
|
|
54
|
+
}
|