@syncfusion/ej2-react-form-renderer 0.1.1-beta → 0.1.2-beta
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/README.md +3 -10
- package/package.json +2 -2
- package/src/CheckboxGroup.js +2 -1
- package/src/ConditionalRuleEngine.d.ts +43 -1
- package/src/ConditionalRuleEngine.js +77 -0
- package/src/DataGridComponent.js +8 -5
- package/src/FormRenderer.d.ts +1 -7
- package/src/FormRenderer.js +236 -178
- package/src/MinMaxRuleEngine.d.ts +95 -0
- package/src/MinMaxRuleEngine.js +361 -0
- package/src/expressions/DependencyTracker.d.ts +30 -0
- package/src/expressions/DependencyTracker.js +128 -0
- package/src/expressions/ExpressionEngine.d.ts +81 -0
- package/src/expressions/ExpressionEngine.js +503 -0
- package/src/expressions/ExpressionParser.d.ts +39 -0
- package/src/expressions/ExpressionParser.js +270 -0
- package/src/json-converter.d.ts +22 -31
- package/src/json-converter.js +141 -261
- package/src/parser.js +4 -1
- package/src/renderFormField.d.ts +2 -1
- package/src/renderFormField.js +37 -14
- package/src/types/FormSchema.d.ts +7 -1
- package/src/utils.js +262 -0
- package/styles/form-renderer.base.css +10 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { ExpressionParser } from './ExpressionParser';
|
|
2
|
+
import { DependencyTracker } from './DependencyTracker';
|
|
3
|
+
import { Internationalization } from '@syncfusion/ej2-base';
|
|
4
|
+
/**
|
|
5
|
+
* ExpressionEngine - Core evaluation engine
|
|
6
|
+
*
|
|
7
|
+
* Safely evaluates user-defined expressions against form state
|
|
8
|
+
* Supports field references, math operations, functions, and conditionals
|
|
9
|
+
*
|
|
10
|
+
* @spec Expression_Engine.md
|
|
11
|
+
*/
|
|
12
|
+
export class ExpressionEngine {
|
|
13
|
+
parser;
|
|
14
|
+
dependencyTracker;
|
|
15
|
+
evaluationCache = new Map();
|
|
16
|
+
compiledFunctions = new Map();
|
|
17
|
+
constructor() {
|
|
18
|
+
this.parser = new ExpressionParser();
|
|
19
|
+
this.dependencyTracker = new DependencyTracker();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Evaluate expression against form values
|
|
23
|
+
*/
|
|
24
|
+
evaluate(expression, formValues, options = {}) {
|
|
25
|
+
try {
|
|
26
|
+
if (!expression || expression.trim() === '') {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const { timeout = 5000, maxDepth = 10, locale = 'en-US', throwOnError = false } = options;
|
|
30
|
+
// Check cache
|
|
31
|
+
const cacheKey = this.getCacheKey(expression, formValues);
|
|
32
|
+
if (this.evaluationCache.has(cacheKey)) {
|
|
33
|
+
return this.evaluationCache.get(cacheKey);
|
|
34
|
+
}
|
|
35
|
+
// Get or compile function
|
|
36
|
+
let fn = this.compiledFunctions.get(expression);
|
|
37
|
+
if (!fn) {
|
|
38
|
+
fn = this.parser.compile(expression, {
|
|
39
|
+
timeout,
|
|
40
|
+
maxDepth,
|
|
41
|
+
locale
|
|
42
|
+
});
|
|
43
|
+
this.compiledFunctions.set(expression, fn);
|
|
44
|
+
}
|
|
45
|
+
// Execute with context
|
|
46
|
+
const context = this.buildContext(formValues);
|
|
47
|
+
const startTime = Date.now();
|
|
48
|
+
const result = fn(context);
|
|
49
|
+
// Validate timeout
|
|
50
|
+
if (Date.now() - startTime > timeout) {
|
|
51
|
+
const error = new Error(`Expression evaluation exceeded timeout of ${timeout}ms`);
|
|
52
|
+
if (throwOnError)
|
|
53
|
+
throw error;
|
|
54
|
+
console.warn(error);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// Cache result
|
|
58
|
+
this.evaluationCache.set(cacheKey, result);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
console.error('❌ Expression evaluation error:', message, { expression, formValues });
|
|
64
|
+
if (options.throwOnError) {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract field names referenced in expression
|
|
72
|
+
*/
|
|
73
|
+
extractDependencies(expression) {
|
|
74
|
+
if (!expression)
|
|
75
|
+
return [];
|
|
76
|
+
return this.dependencyTracker.extractFieldReferences(expression);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build dependency graph for all expressions
|
|
80
|
+
*/
|
|
81
|
+
buildDependencyGraph(components) {
|
|
82
|
+
const graph = {};
|
|
83
|
+
components.forEach(comp => {
|
|
84
|
+
if (comp.expressionValue) {
|
|
85
|
+
const deps = this.extractDependencies(comp.expressionValue);
|
|
86
|
+
graph[comp.name] = deps;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return graph;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Detect circular dependencies
|
|
93
|
+
*/
|
|
94
|
+
detectCircularDependencies(graph) {
|
|
95
|
+
const visited = new Set();
|
|
96
|
+
const recursionStack = new Set();
|
|
97
|
+
const cycles = [];
|
|
98
|
+
const visit = (node, path) => {
|
|
99
|
+
visited.add(node);
|
|
100
|
+
recursionStack.add(node);
|
|
101
|
+
path.push(node);
|
|
102
|
+
const deps = graph[node] || [];
|
|
103
|
+
for (const dep of deps) {
|
|
104
|
+
if (!visited.has(dep)) {
|
|
105
|
+
visit(dep, [...path]);
|
|
106
|
+
}
|
|
107
|
+
else if (recursionStack.has(dep)) {
|
|
108
|
+
const cycleStart = path.indexOf(dep);
|
|
109
|
+
cycles.push([...path.slice(cycleStart), dep]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
recursionStack.delete(node);
|
|
113
|
+
};
|
|
114
|
+
for (const node of Object.keys(graph)) {
|
|
115
|
+
if (!visited.has(node)) {
|
|
116
|
+
visit(node, []);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return cycles;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get topological order for evaluation
|
|
123
|
+
*/
|
|
124
|
+
getTopologicalOrder(graph) {
|
|
125
|
+
const visited = new Set();
|
|
126
|
+
const order = [];
|
|
127
|
+
const visit = (node) => {
|
|
128
|
+
if (visited.has(node))
|
|
129
|
+
return;
|
|
130
|
+
visited.add(node);
|
|
131
|
+
const deps = graph[node] || [];
|
|
132
|
+
for (const dep of deps) {
|
|
133
|
+
visit(dep);
|
|
134
|
+
}
|
|
135
|
+
order.push(node);
|
|
136
|
+
};
|
|
137
|
+
for (const node of Object.keys(graph)) {
|
|
138
|
+
visit(node);
|
|
139
|
+
}
|
|
140
|
+
return order;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Clear internal caches
|
|
144
|
+
*/
|
|
145
|
+
clearCache() {
|
|
146
|
+
this.evaluationCache.clear();
|
|
147
|
+
this.compiledFunctions.clear();
|
|
148
|
+
}
|
|
149
|
+
// Private helper methods
|
|
150
|
+
getCacheKey(expression, formValues) {
|
|
151
|
+
// Simple cache key (in production, use better hashing)
|
|
152
|
+
return `${expression}:${JSON.stringify(formValues)}`;
|
|
153
|
+
}
|
|
154
|
+
buildContext(formValues) {
|
|
155
|
+
let intl = new Internationalization();
|
|
156
|
+
let dFormatter = intl.getDateFormat({
|
|
157
|
+
format: 'MM/dd/yyyy',
|
|
158
|
+
type: 'date'
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
// Field values
|
|
162
|
+
...formValues,
|
|
163
|
+
// Constants
|
|
164
|
+
PI: Math.PI,
|
|
165
|
+
TRUE: true,
|
|
166
|
+
FALSE: false,
|
|
167
|
+
NULL: null,
|
|
168
|
+
UNDEFINED: undefined,
|
|
169
|
+
Math: Math,
|
|
170
|
+
// Aggregate Functions
|
|
171
|
+
SUM: (values) => Array.isArray(values) ? values.reduce((a, b) => Number(a) + Number(b), 0) : 0,
|
|
172
|
+
AVG: (values) => {
|
|
173
|
+
if (!Array.isArray(values) || values.length === 0)
|
|
174
|
+
return 0;
|
|
175
|
+
const sum = values.reduce((a, b) => Number(a) + Number(b), 0);
|
|
176
|
+
return sum / values.length;
|
|
177
|
+
},
|
|
178
|
+
MIN: (values) => Array.isArray(values) && values.length > 0 ? Math.min(...values.map(Number)) : 0,
|
|
179
|
+
MAX: (values) => Array.isArray(values) && values.length > 0 ? Math.max(...values.map(Number)) : 0,
|
|
180
|
+
COUNT: (values) => Array.isArray(values) ? values.length : 0,
|
|
181
|
+
// Math Functions
|
|
182
|
+
ROUND: (value, decimals = 0) => Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals),
|
|
183
|
+
FLOOR: Math.floor,
|
|
184
|
+
CEIL: Math.ceil,
|
|
185
|
+
ABS: Math.abs,
|
|
186
|
+
SQRT: Math.sqrt,
|
|
187
|
+
// String Functions
|
|
188
|
+
UPPER: (text) => String(text).toUpperCase(),
|
|
189
|
+
LOWER: (text) => String(text).toLowerCase(),
|
|
190
|
+
CONCAT: (...strings) => strings.map(String).join(''),
|
|
191
|
+
LENGTH: (text) => String(text).length,
|
|
192
|
+
TRIM: (text) => String(text).trim(),
|
|
193
|
+
SUBSTRING: (text, start, length) => length !== undefined
|
|
194
|
+
? String(text).substr(start, length)
|
|
195
|
+
: String(text).substr(start),
|
|
196
|
+
// Date Functions
|
|
197
|
+
TODAY: () => {
|
|
198
|
+
const value = dFormatter(new Date());
|
|
199
|
+
return value;
|
|
200
|
+
},
|
|
201
|
+
AGE: (birthDate) => {
|
|
202
|
+
const birth = typeof birthDate === 'string' ? new Date(birthDate) : birthDate;
|
|
203
|
+
const today = new Date();
|
|
204
|
+
let age = today.getFullYear() - birth.getFullYear();
|
|
205
|
+
const monthDiff = today.getMonth() - birth.getMonth();
|
|
206
|
+
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
|
207
|
+
age--;
|
|
208
|
+
}
|
|
209
|
+
return age;
|
|
210
|
+
},
|
|
211
|
+
DATEFORMAT: (date, format) => {
|
|
212
|
+
const d = typeof date === 'string' ? new Date(date) : date;
|
|
213
|
+
let formatter = intl.getDateFormat({
|
|
214
|
+
format: format,
|
|
215
|
+
type: 'date'
|
|
216
|
+
});
|
|
217
|
+
// Simple date format implementation
|
|
218
|
+
return formatter(d);
|
|
219
|
+
},
|
|
220
|
+
DATEDIFF: (date1, date2) => {
|
|
221
|
+
const d1 = typeof date1 === 'string' ? new Date(date1) : date1;
|
|
222
|
+
const d2 = typeof date2 === 'string' ? new Date(date2) : date2;
|
|
223
|
+
return Math.floor((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
|
|
224
|
+
},
|
|
225
|
+
// Add days to a date (for date arithmetic like {date} + 5)
|
|
226
|
+
ADDDAYS: (date, days) => {
|
|
227
|
+
const d = typeof date === 'string' ? new Date(date) : new Date(date);
|
|
228
|
+
d.setDate(d.getDate() + Number(days));
|
|
229
|
+
return dFormatter(d);
|
|
230
|
+
},
|
|
231
|
+
// Add days to a date (for date arithmetic like {date} + 5)
|
|
232
|
+
SUBDAYS: (date, days) => {
|
|
233
|
+
const d = typeof date === 'string' ? new Date(date) : new Date(date);
|
|
234
|
+
d.setDate(d.getDate() - Number(days));
|
|
235
|
+
return dFormatter(d);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* ExpressionValidator - Validates expressions before runtime
|
|
242
|
+
*
|
|
243
|
+
* Checks syntax, validates field references, and detects circular dependencies
|
|
244
|
+
*
|
|
245
|
+
* @spec Expression_Engine.md
|
|
246
|
+
*/
|
|
247
|
+
export class ExpressionValidator {
|
|
248
|
+
supportedFunctions = [
|
|
249
|
+
'SUM', 'AVG', 'MIN', 'MAX', 'COUNT',
|
|
250
|
+
'ROUND', 'FLOOR', 'CEIL', 'ABS', 'SQRT',
|
|
251
|
+
'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'TRIM', 'SUBSTRING',
|
|
252
|
+
'TODAY', 'AGE', 'DATEFORMAT', 'DATEDIFF', 'ADDDAYS', 'SUBDAYS'
|
|
253
|
+
];
|
|
254
|
+
keywords = ['PI', 'TRUE', 'FALSE', 'NULL', 'UNDEFINED', 'and', 'or', 'in'];
|
|
255
|
+
/**
|
|
256
|
+
* Validate expression against available fields
|
|
257
|
+
*/
|
|
258
|
+
validate(expression, availableFields) {
|
|
259
|
+
const errors = [];
|
|
260
|
+
if (!expression || expression.trim() === '') {
|
|
261
|
+
return errors; // Empty expression is valid
|
|
262
|
+
}
|
|
263
|
+
// Check syntax
|
|
264
|
+
const syntaxErrors = this.validateSyntax(expression);
|
|
265
|
+
errors.push(...syntaxErrors);
|
|
266
|
+
// Check field references
|
|
267
|
+
const fieldErrors = this.validateFieldReferences(expression, availableFields);
|
|
268
|
+
errors.push(...fieldErrors);
|
|
269
|
+
// Check function calls
|
|
270
|
+
const funcErrors = this.validateFunctionCalls(expression);
|
|
271
|
+
errors.push(...funcErrors);
|
|
272
|
+
// Check field operations based on types
|
|
273
|
+
const opErrors = this.validateFieldOperations(expression, availableFields);
|
|
274
|
+
errors.push(...opErrors);
|
|
275
|
+
return errors;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Validate field operations - check for invalid operators on string/date types
|
|
279
|
+
*/
|
|
280
|
+
validateFieldOperations(expression, availableFields) {
|
|
281
|
+
const errors = [];
|
|
282
|
+
// Skip if not field objects with type info
|
|
283
|
+
if (!Array.isArray(availableFields) || availableFields.length === 0) {
|
|
284
|
+
return errors;
|
|
285
|
+
}
|
|
286
|
+
const firstField = availableFields[0];
|
|
287
|
+
if (typeof firstField === 'string') {
|
|
288
|
+
return errors; // No type information available
|
|
289
|
+
}
|
|
290
|
+
// Build field map from objects
|
|
291
|
+
const fieldMap = new Map();
|
|
292
|
+
availableFields.forEach(field => {
|
|
293
|
+
if (field && field.name) {
|
|
294
|
+
fieldMap.set(field.name, field);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
if (fieldMap.size === 0)
|
|
298
|
+
return errors;
|
|
299
|
+
// Track reported operator positions to avoid duplicates
|
|
300
|
+
const reportedOperators = new Set();
|
|
301
|
+
// Check each field reference for invalid operations
|
|
302
|
+
const fieldRefPattern = /(\{[^}]+\})/g;
|
|
303
|
+
let match;
|
|
304
|
+
// Invalid operators for string fields (allow only + for concatenation)
|
|
305
|
+
const stringInvalidOps = ['-', '*', '/', '%', '^'];
|
|
306
|
+
// Operators invalid for date fields (allow + for date arithmetic with numbers)
|
|
307
|
+
const dateInvalidOps = ['-', '*', '/', '%', '^', '+'];
|
|
308
|
+
while ((match = fieldRefPattern.exec(expression)) !== null) {
|
|
309
|
+
const fieldRef = match[0];
|
|
310
|
+
const fieldName = fieldRef.slice(1, -1); // Remove { }
|
|
311
|
+
const field = fieldMap.get(fieldName);
|
|
312
|
+
if (!field)
|
|
313
|
+
continue;
|
|
314
|
+
const fieldIndex = match.index;
|
|
315
|
+
const fieldEndIndex = fieldIndex + fieldRef.length;
|
|
316
|
+
// Get context around field reference (including spaces)
|
|
317
|
+
const textBefore = expression.substring(Math.max(0, fieldIndex - 10), fieldIndex);
|
|
318
|
+
const textAfter = expression.substring(fieldEndIndex, Math.min(expression.length, fieldEndIndex + 10));
|
|
319
|
+
// Extract the operator if present (skip spaces)
|
|
320
|
+
const operatorBefore = textBefore.trimEnd().slice(-1);
|
|
321
|
+
const operatorAfter = textAfter.trimStart().charAt(0);
|
|
322
|
+
// Calculate actual position of operators in the original expression
|
|
323
|
+
const operatorBeforePos = fieldIndex - (textBefore.length - textBefore.trimEnd().length) - 1;
|
|
324
|
+
const operatorAfterPos = fieldEndIndex + (textAfter.length - textAfter.trimStart().length);
|
|
325
|
+
// For STRING fields (without format)
|
|
326
|
+
if (field.type === 'string' && !field.format) {
|
|
327
|
+
if (operatorBefore && stringInvalidOps.includes(operatorBefore)) {
|
|
328
|
+
const key = `${operatorBeforePos}:${operatorBefore}`;
|
|
329
|
+
if (!reportedOperators.has(key)) {
|
|
330
|
+
errors.push({
|
|
331
|
+
type: 'InvalidOperation',
|
|
332
|
+
message: `Cannot perform '${operatorBefore}' operation on string field '{${fieldName}}'`
|
|
333
|
+
});
|
|
334
|
+
reportedOperators.add(key);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (operatorAfter && stringInvalidOps.includes(operatorAfter)) {
|
|
338
|
+
const key = `${operatorAfterPos}:${operatorAfter}`;
|
|
339
|
+
if (!reportedOperators.has(key)) {
|
|
340
|
+
errors.push({
|
|
341
|
+
type: 'InvalidOperation',
|
|
342
|
+
message: `Cannot perform '${operatorAfter}' operation on string field '{${fieldName}}'`
|
|
343
|
+
});
|
|
344
|
+
reportedOperators.add(key);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// For DATE fields - allow + with numbers, reject other operators
|
|
349
|
+
if (field.format === 'date' || field.format === 'date-time' || field.format === 'time') {
|
|
350
|
+
if (operatorBefore && dateInvalidOps.includes(operatorBefore)) {
|
|
351
|
+
const key = `${operatorBeforePos}:${operatorBefore}`;
|
|
352
|
+
if (!reportedOperators.has(key)) {
|
|
353
|
+
errors.push({
|
|
354
|
+
type: 'InvalidOperation',
|
|
355
|
+
message: `Cannot perform '${operatorBefore}' operation on date field '{${fieldName}}'`
|
|
356
|
+
});
|
|
357
|
+
reportedOperators.add(key);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (operatorAfter && dateInvalidOps.includes(operatorAfter)) {
|
|
361
|
+
const key = `${operatorAfterPos}:${operatorAfter}`;
|
|
362
|
+
if (!reportedOperators.has(key)) {
|
|
363
|
+
errors.push({
|
|
364
|
+
type: 'InvalidOperation',
|
|
365
|
+
message: `Cannot perform '${operatorAfter}' operation on date field '{${fieldName}}'`
|
|
366
|
+
});
|
|
367
|
+
reportedOperators.add(key);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Allow + only when combined with numbers or number fields
|
|
371
|
+
if (operatorAfter === '+') {
|
|
372
|
+
const operandAfter = textAfter.trimStart().substring(1).trim();
|
|
373
|
+
const isNumberField = /^\{[^}]+\}/.test(operandAfter) && fieldMap.get(operandAfter.slice(1, -1))?.type === 'number';
|
|
374
|
+
const isNumberLiteral = /^\d+/.test(operandAfter);
|
|
375
|
+
if (!isNumberField && !isNumberLiteral) {
|
|
376
|
+
const key = `${operatorAfterPos}:${operatorAfter}`;
|
|
377
|
+
if (!reportedOperators.has(key)) {
|
|
378
|
+
errors.push({
|
|
379
|
+
type: 'InvalidOperation',
|
|
380
|
+
message: `Date field '{${fieldName}}' can only be used with +number or +{numberField}, not '${operatorAfter}${operandAfter.substring(0, 10)}'`
|
|
381
|
+
});
|
|
382
|
+
reportedOperators.add(key);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (operatorBefore === '+') {
|
|
387
|
+
const operandBefore = textBefore.trimEnd().substring(0, textBefore.trimEnd().length - 1).trim();
|
|
388
|
+
const isNumberField = /\{[^}]+\}$/.test(operandBefore) && fieldMap.get(operandBefore.slice(operandBefore.lastIndexOf('{') + 1, -1))?.type === 'number';
|
|
389
|
+
const isNumberLiteral = /\d+$/.test(operandBefore);
|
|
390
|
+
if (!isNumberField && !isNumberLiteral) {
|
|
391
|
+
const key = `${operatorBeforePos}:${operatorBefore}`;
|
|
392
|
+
if (!reportedOperators.has(key)) {
|
|
393
|
+
errors.push({
|
|
394
|
+
type: 'InvalidOperation',
|
|
395
|
+
message: `Date field '{${fieldName}}' can only be used with +number or +{numberField}`
|
|
396
|
+
});
|
|
397
|
+
reportedOperators.add(key);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return errors;
|
|
404
|
+
}
|
|
405
|
+
validateSyntax(expression) {
|
|
406
|
+
const errors = [];
|
|
407
|
+
// Check balanced braces
|
|
408
|
+
const openBraces = (expression.match(/\{/g) || []).length;
|
|
409
|
+
const closeBraces = (expression.match(/\}/g) || []).length;
|
|
410
|
+
if (openBraces !== closeBraces) {
|
|
411
|
+
errors.push({
|
|
412
|
+
type: 'SyntaxError',
|
|
413
|
+
message: 'Unbalanced braces in expression'
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// Check balanced parentheses
|
|
417
|
+
const openParens = (expression.match(/\(/g) || []).length;
|
|
418
|
+
const closeParens = (expression.match(/\)/g) || []).length;
|
|
419
|
+
if (openParens !== closeParens) {
|
|
420
|
+
errors.push({
|
|
421
|
+
type: 'SyntaxError',
|
|
422
|
+
message: 'Unbalanced parentheses in expression'
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Check balanced $ brackets
|
|
426
|
+
const openDollar = (expression.match(/\$\{/g) || []).length;
|
|
427
|
+
const closeDollar = (expression.match(/\}/g) || []).length;
|
|
428
|
+
if (openDollar > closeDollar) {
|
|
429
|
+
errors.push({
|
|
430
|
+
type: 'SyntaxError',
|
|
431
|
+
message: 'Unbalanced ${ } in expression'
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return errors;
|
|
435
|
+
}
|
|
436
|
+
validateFieldReferences(expression, availableFields) {
|
|
437
|
+
const errors = [];
|
|
438
|
+
// Skip field validation if availableFields is not properly populated
|
|
439
|
+
// This allows expressions to work at runtime even if we can't validate field names at editor time
|
|
440
|
+
if (!availableFields || availableFields.length === 0) {
|
|
441
|
+
return errors;
|
|
442
|
+
}
|
|
443
|
+
const fieldRefPattern = /\{([^{}]+)\}/g;
|
|
444
|
+
let match;
|
|
445
|
+
// Handle both string[] and AvailableField[] formats
|
|
446
|
+
let availableFieldsSet;
|
|
447
|
+
if (typeof availableFields[0] === 'string') {
|
|
448
|
+
availableFieldsSet = new Set(availableFields);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
availableFieldsSet = new Set(availableFields.map(f => f.name));
|
|
452
|
+
}
|
|
453
|
+
while ((match = fieldRefPattern.exec(expression)) !== null) {
|
|
454
|
+
const ref = match[1].trim();
|
|
455
|
+
// Skip if it's a function call
|
|
456
|
+
if (this.isFunctionCall(ref))
|
|
457
|
+
continue;
|
|
458
|
+
// Skip if it's a math expression (contains operators or numbers)
|
|
459
|
+
// This distinguishes between {fieldName} and ${5 + 3}
|
|
460
|
+
if (this.isMathExpression(ref))
|
|
461
|
+
continue;
|
|
462
|
+
// Extract field name from nested paths (e.g., "customer.name" -> "customer")
|
|
463
|
+
const fieldName = ref.split(/[.\[\]]/)[0].trim();
|
|
464
|
+
// Check if field exists
|
|
465
|
+
if (fieldName && !this.keywords.includes(fieldName.toUpperCase())) {
|
|
466
|
+
if (!availableFieldsSet.has(fieldName)) {
|
|
467
|
+
errors.push({
|
|
468
|
+
type: 'UndefinedField',
|
|
469
|
+
message: `Field '${fieldName}' is not defined in the form`,
|
|
470
|
+
field: fieldName
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return errors;
|
|
476
|
+
}
|
|
477
|
+
validateFunctionCalls(expression) {
|
|
478
|
+
const errors = [];
|
|
479
|
+
const funcPattern = /(\w+)\s*\(/g;
|
|
480
|
+
let match;
|
|
481
|
+
while ((match = funcPattern.exec(expression)) !== null) {
|
|
482
|
+
const funcName = match[1];
|
|
483
|
+
// Only validate uppercase functions (user-defined lowercase functions might be allowed)
|
|
484
|
+
if (funcName === funcName.toUpperCase() && !this.supportedFunctions.includes(funcName)) {
|
|
485
|
+
errors.push({
|
|
486
|
+
type: 'InvalidFunction',
|
|
487
|
+
message: `Unknown function: '${funcName}'. Supported functions: ${this.supportedFunctions.join(', ')}`
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return errors;
|
|
492
|
+
}
|
|
493
|
+
isFunctionCall(ref) {
|
|
494
|
+
return /^\w+\s*\(/.test(ref);
|
|
495
|
+
}
|
|
496
|
+
isMathExpression(ref) {
|
|
497
|
+
// Check if the expression contains math operators, numbers, or other non-field-name patterns
|
|
498
|
+
// Math expressions like: "5 + 3", "qty * 2", "age > 18", "sum([1,2,3])"
|
|
499
|
+
// should be skipped, while field references like "fieldName" or "customer.name" should be validated
|
|
500
|
+
const mathOperatorPattern = /[\+\-\*\/\%\=\<\>\!\&\|\^\~\s\d]/;
|
|
501
|
+
return mathOperatorPattern.test(ref);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field type information for validation
|
|
3
|
+
*/
|
|
4
|
+
export interface AvailableField {
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
format?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* ExpressionParser - Parse and compile expressions
|
|
11
|
+
*
|
|
12
|
+
* Converts string expressions into safe, compiled functions
|
|
13
|
+
* Supports field references, operators, functions, and conditionals
|
|
14
|
+
*
|
|
15
|
+
* @spec Expression_Engine.md
|
|
16
|
+
*/
|
|
17
|
+
export declare class ExpressionParser {
|
|
18
|
+
private supportedFunctions;
|
|
19
|
+
private availableFields;
|
|
20
|
+
/**
|
|
21
|
+
* Initialize parser with available fields for type validation
|
|
22
|
+
*/
|
|
23
|
+
setAvailableFields(fields: AvailableField[]): void;
|
|
24
|
+
private validateFieldOperations;
|
|
25
|
+
/**
|
|
26
|
+
* Validate expression syntax
|
|
27
|
+
*/
|
|
28
|
+
validate(expression: string): {
|
|
29
|
+
valid: boolean;
|
|
30
|
+
errors: string[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Compile expression to executable function
|
|
34
|
+
*/
|
|
35
|
+
compile(expression: string, options?: any): Function;
|
|
36
|
+
private compileMatrixExpressions;
|
|
37
|
+
private compileBareFunctionCalls;
|
|
38
|
+
private compileFieldReferences;
|
|
39
|
+
}
|