@zentto/report-core 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine/conditional-format.d.ts +48 -0
- package/dist/engine/conditional-format.d.ts.map +1 -0
- package/dist/engine/conditional-format.js +167 -0
- package/dist/engine/conditional-format.js.map +1 -0
- package/dist/engine/cross-tab.d.ts +68 -0
- package/dist/engine/cross-tab.d.ts.map +1 -0
- package/dist/engine/cross-tab.js +549 -0
- package/dist/engine/cross-tab.js.map +1 -0
- package/dist/engine/expression.d.ts +46 -2
- package/dist/engine/expression.d.ts.map +1 -1
- package/dist/engine/expression.js +1415 -90
- package/dist/engine/expression.js.map +1 -1
- package/dist/engine/multi-pass-engine.d.ts +74 -0
- package/dist/engine/multi-pass-engine.d.ts.map +1 -0
- package/dist/engine/multi-pass-engine.js +1082 -0
- package/dist/engine/multi-pass-engine.js.map +1 -0
- package/dist/engine/running-totals.d.ts +74 -0
- package/dist/engine/running-totals.d.ts.map +1 -0
- package/dist/engine/running-totals.js +247 -0
- package/dist/engine/running-totals.js.map +1 -0
- package/dist/engine/subreport.d.ts +59 -0
- package/dist/engine/subreport.d.ts.map +1 -0
- package/dist/engine/subreport.js +295 -0
- package/dist/engine/subreport.js.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/schema/report-schema.d.ts +346 -346
- package/dist/serialization/json.d.ts +88 -88
- package/dist/templates/page-sizes.d.ts.map +1 -1
- package/dist/templates/page-sizes.js +95 -3
- package/dist/templates/page-sizes.js.map +1 -1
- package/dist/types.d.ts +38 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,112 +1,1437 @@
|
|
|
1
1
|
// @zentto/report-core — Expression engine
|
|
2
|
-
//
|
|
2
|
+
// Complete formula language with recursive descent parser
|
|
3
|
+
// Inspired by Crystal Reports formula syntax
|
|
3
4
|
import { computeAggregate } from './data-binding.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
}
|
|
5
|
+
// ─── Token Types ───────────────────────────────────────────────────
|
|
6
|
+
var TokenType;
|
|
7
|
+
(function (TokenType) {
|
|
8
|
+
TokenType[TokenType["Number"] = 0] = "Number";
|
|
9
|
+
TokenType[TokenType["String"] = 1] = "String";
|
|
10
|
+
TokenType[TokenType["Boolean"] = 2] = "Boolean";
|
|
11
|
+
TokenType[TokenType["Null"] = 3] = "Null";
|
|
12
|
+
TokenType[TokenType["Identifier"] = 4] = "Identifier";
|
|
13
|
+
TokenType[TokenType["FieldRef"] = 5] = "FieldRef";
|
|
14
|
+
TokenType[TokenType["LParen"] = 6] = "LParen";
|
|
15
|
+
TokenType[TokenType["RParen"] = 7] = "RParen";
|
|
16
|
+
TokenType[TokenType["Comma"] = 8] = "Comma";
|
|
17
|
+
TokenType[TokenType["Plus"] = 9] = "Plus";
|
|
18
|
+
TokenType[TokenType["Minus"] = 10] = "Minus";
|
|
19
|
+
TokenType[TokenType["Star"] = 11] = "Star";
|
|
20
|
+
TokenType[TokenType["Slash"] = 12] = "Slash";
|
|
21
|
+
TokenType[TokenType["Percent"] = 13] = "Percent";
|
|
22
|
+
TokenType[TokenType["Caret"] = 14] = "Caret";
|
|
23
|
+
TokenType[TokenType["Ampersand"] = 15] = "Ampersand";
|
|
24
|
+
TokenType[TokenType["Eq"] = 16] = "Eq";
|
|
25
|
+
TokenType[TokenType["Neq"] = 17] = "Neq";
|
|
26
|
+
TokenType[TokenType["Lt"] = 18] = "Lt";
|
|
27
|
+
TokenType[TokenType["Gt"] = 19] = "Gt";
|
|
28
|
+
TokenType[TokenType["Lte"] = 20] = "Lte";
|
|
29
|
+
TokenType[TokenType["Gte"] = 21] = "Gte";
|
|
30
|
+
TokenType[TokenType["And"] = 22] = "And";
|
|
31
|
+
TokenType[TokenType["Or"] = 23] = "Or";
|
|
32
|
+
TokenType[TokenType["Not"] = 24] = "Not";
|
|
33
|
+
TokenType[TokenType["EOF"] = 25] = "EOF";
|
|
34
|
+
})(TokenType || (TokenType = {}));
|
|
35
|
+
// ─── Lexer ─────────────────────────────────────────────────────────
|
|
36
|
+
class Lexer {
|
|
37
|
+
input;
|
|
38
|
+
pos = 0;
|
|
39
|
+
tokens = [];
|
|
40
|
+
constructor(input) {
|
|
41
|
+
this.input = input;
|
|
42
|
+
this.tokenize();
|
|
43
|
+
}
|
|
44
|
+
tokenize() {
|
|
45
|
+
const src = this.input;
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (i < src.length) {
|
|
48
|
+
// Skip whitespace
|
|
49
|
+
if (/\s/.test(src[i])) {
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// Field reference: {field} or {ds.field}
|
|
54
|
+
if (src[i] === '{') {
|
|
55
|
+
const start = i;
|
|
56
|
+
i++; // skip {
|
|
57
|
+
let ref = '';
|
|
58
|
+
while (i < src.length && src[i] !== '}') {
|
|
59
|
+
ref += src[i];
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
if (i < src.length)
|
|
63
|
+
i++; // skip }
|
|
64
|
+
this.tokens.push({ type: TokenType.FieldRef, value: ref, pos: start });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// String literal (single or double quotes)
|
|
68
|
+
if (src[i] === '"' || src[i] === "'") {
|
|
69
|
+
const quote = src[i];
|
|
70
|
+
const start = i;
|
|
71
|
+
i++;
|
|
72
|
+
let str = '';
|
|
73
|
+
while (i < src.length && src[i] !== quote) {
|
|
74
|
+
if (src[i] === '\\' && i + 1 < src.length) {
|
|
75
|
+
i++;
|
|
76
|
+
if (src[i] === 'n')
|
|
77
|
+
str += '\n';
|
|
78
|
+
else if (src[i] === 't')
|
|
79
|
+
str += '\t';
|
|
80
|
+
else if (src[i] === '\\')
|
|
81
|
+
str += '\\';
|
|
82
|
+
else if (src[i] === quote)
|
|
83
|
+
str += quote;
|
|
84
|
+
else
|
|
85
|
+
str += src[i];
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
str += src[i];
|
|
89
|
+
}
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
if (i < src.length)
|
|
93
|
+
i++; // skip closing quote
|
|
94
|
+
this.tokens.push({ type: TokenType.String, value: str, pos: start });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Number literal (integer or decimal)
|
|
98
|
+
if (/\d/.test(src[i]) || (src[i] === '.' && i + 1 < src.length && /\d/.test(src[i + 1]))) {
|
|
99
|
+
const start = i;
|
|
100
|
+
let num = '';
|
|
101
|
+
while (i < src.length && /[\d.]/.test(src[i])) {
|
|
102
|
+
num += src[i];
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
this.tokens.push({ type: TokenType.Number, value: num, pos: start });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Two-character operators
|
|
109
|
+
if (i + 1 < src.length) {
|
|
110
|
+
const two = src[i] + src[i + 1];
|
|
111
|
+
if (two === '==') {
|
|
112
|
+
this.tokens.push({ type: TokenType.Eq, value: '==', pos: i });
|
|
113
|
+
i += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (two === '!=') {
|
|
117
|
+
this.tokens.push({ type: TokenType.Neq, value: '!=', pos: i });
|
|
118
|
+
i += 2;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (two === '<=') {
|
|
122
|
+
this.tokens.push({ type: TokenType.Lte, value: '<=', pos: i });
|
|
123
|
+
i += 2;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (two === '>=') {
|
|
127
|
+
this.tokens.push({ type: TokenType.Gte, value: '>=', pos: i });
|
|
128
|
+
i += 2;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (two === '<>') {
|
|
132
|
+
this.tokens.push({ type: TokenType.Neq, value: '<>', pos: i });
|
|
133
|
+
i += 2;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Single-character operators
|
|
138
|
+
const ch = src[i];
|
|
139
|
+
if (ch === '(') {
|
|
140
|
+
this.tokens.push({ type: TokenType.LParen, value: '(', pos: i });
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === ')') {
|
|
145
|
+
this.tokens.push({ type: TokenType.RParen, value: ')', pos: i });
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (ch === ',') {
|
|
150
|
+
this.tokens.push({ type: TokenType.Comma, value: ',', pos: i });
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === '+') {
|
|
155
|
+
this.tokens.push({ type: TokenType.Plus, value: '+', pos: i });
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (ch === '-') {
|
|
160
|
+
this.tokens.push({ type: TokenType.Minus, value: '-', pos: i });
|
|
161
|
+
i++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ch === '*') {
|
|
165
|
+
this.tokens.push({ type: TokenType.Star, value: '*', pos: i });
|
|
166
|
+
i++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (ch === '/') {
|
|
170
|
+
this.tokens.push({ type: TokenType.Slash, value: '/', pos: i });
|
|
171
|
+
i++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (ch === '%') {
|
|
175
|
+
this.tokens.push({ type: TokenType.Percent, value: '%', pos: i });
|
|
176
|
+
i++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === '^') {
|
|
180
|
+
this.tokens.push({ type: TokenType.Caret, value: '^', pos: i });
|
|
181
|
+
i++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (ch === '&') {
|
|
185
|
+
this.tokens.push({ type: TokenType.Ampersand, value: '&', pos: i });
|
|
186
|
+
i++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (ch === '<') {
|
|
190
|
+
this.tokens.push({ type: TokenType.Lt, value: '<', pos: i });
|
|
191
|
+
i++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (ch === '>') {
|
|
195
|
+
this.tokens.push({ type: TokenType.Gt, value: '>', pos: i });
|
|
196
|
+
i++;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (ch === '=') {
|
|
200
|
+
this.tokens.push({ type: TokenType.Eq, value: '=', pos: i });
|
|
201
|
+
i++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (ch === '!') {
|
|
205
|
+
this.tokens.push({ type: TokenType.Not, value: '!', pos: i });
|
|
206
|
+
i++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// Identifiers and keywords
|
|
210
|
+
if (/[a-zA-Z_]/.test(ch)) {
|
|
211
|
+
const start = i;
|
|
212
|
+
let id = '';
|
|
213
|
+
while (i < src.length && /[a-zA-Z0-9_]/.test(src[i])) {
|
|
214
|
+
id += src[i];
|
|
215
|
+
i++;
|
|
216
|
+
}
|
|
217
|
+
const upper = id.toUpperCase();
|
|
218
|
+
if (upper === 'TRUE') {
|
|
219
|
+
this.tokens.push({ type: TokenType.Boolean, value: 'true', pos: start });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (upper === 'FALSE') {
|
|
223
|
+
this.tokens.push({ type: TokenType.Boolean, value: 'false', pos: start });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (upper === 'NULL' || upper === 'NIL') {
|
|
227
|
+
this.tokens.push({ type: TokenType.Null, value: 'null', pos: start });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (upper === 'AND') {
|
|
231
|
+
this.tokens.push({ type: TokenType.And, value: 'AND', pos: start });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (upper === 'OR') {
|
|
235
|
+
this.tokens.push({ type: TokenType.Or, value: 'OR', pos: start });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (upper === 'NOT') {
|
|
239
|
+
this.tokens.push({ type: TokenType.Not, value: 'NOT', pos: start });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
this.tokens.push({ type: TokenType.Identifier, value: id, pos: start });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Unknown character — skip
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
this.tokens.push({ type: TokenType.EOF, value: '', pos: i });
|
|
249
|
+
}
|
|
250
|
+
getTokens() { return this.tokens; }
|
|
251
|
+
}
|
|
252
|
+
// ─── Parser (Recursive Descent) ───────────────────────────────────
|
|
253
|
+
class Parser {
|
|
254
|
+
pos = 0;
|
|
255
|
+
tokens;
|
|
256
|
+
constructor(tokens) {
|
|
257
|
+
this.tokens = tokens;
|
|
258
|
+
}
|
|
259
|
+
parse() {
|
|
260
|
+
const result = this.parseOr();
|
|
261
|
+
if (this.peek().type !== TokenType.EOF) {
|
|
262
|
+
throw new ExprError(`Token inesperado: "${this.peek().value}" en posicion ${this.peek().pos}`);
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
peek() {
|
|
267
|
+
return this.tokens[this.pos] || { type: TokenType.EOF, value: '', pos: -1 };
|
|
268
|
+
}
|
|
269
|
+
advance() {
|
|
270
|
+
const t = this.tokens[this.pos];
|
|
271
|
+
this.pos++;
|
|
272
|
+
return t;
|
|
273
|
+
}
|
|
274
|
+
expect(type) {
|
|
275
|
+
const t = this.peek();
|
|
276
|
+
if (t.type !== type) {
|
|
277
|
+
throw new ExprError(`Se esperaba ${TokenType[type]}, se encontro "${t.value}" en posicion ${t.pos}`);
|
|
278
|
+
}
|
|
279
|
+
return this.advance();
|
|
280
|
+
}
|
|
281
|
+
// OR
|
|
282
|
+
parseOr() {
|
|
283
|
+
let left = this.parseAnd();
|
|
284
|
+
while (this.peek().type === TokenType.Or) {
|
|
285
|
+
this.advance();
|
|
286
|
+
const right = this.parseAnd();
|
|
287
|
+
left = { kind: 'binary', op: 'OR', left, right };
|
|
288
|
+
}
|
|
289
|
+
return left;
|
|
290
|
+
}
|
|
291
|
+
// AND
|
|
292
|
+
parseAnd() {
|
|
293
|
+
let left = this.parseNot();
|
|
294
|
+
while (this.peek().type === TokenType.And) {
|
|
295
|
+
this.advance();
|
|
296
|
+
const right = this.parseNot();
|
|
297
|
+
left = { kind: 'binary', op: 'AND', left, right };
|
|
298
|
+
}
|
|
299
|
+
return left;
|
|
300
|
+
}
|
|
301
|
+
// NOT
|
|
302
|
+
parseNot() {
|
|
303
|
+
if (this.peek().type === TokenType.Not) {
|
|
304
|
+
this.advance();
|
|
305
|
+
const operand = this.parseNot();
|
|
306
|
+
return { kind: 'unary', op: 'NOT', operand };
|
|
307
|
+
}
|
|
308
|
+
return this.parseComparison();
|
|
309
|
+
}
|
|
310
|
+
// Comparison: ==, !=, <, >, <=, >=
|
|
311
|
+
parseComparison() {
|
|
312
|
+
let left = this.parseConcatenation();
|
|
313
|
+
const compOps = [TokenType.Eq, TokenType.Neq, TokenType.Lt, TokenType.Gt, TokenType.Lte, TokenType.Gte];
|
|
314
|
+
while (compOps.includes(this.peek().type)) {
|
|
315
|
+
const op = this.advance().value;
|
|
316
|
+
const right = this.parseConcatenation();
|
|
317
|
+
left = { kind: 'binary', op, left, right };
|
|
318
|
+
}
|
|
319
|
+
return left;
|
|
320
|
+
}
|
|
321
|
+
// String concatenation &
|
|
322
|
+
parseConcatenation() {
|
|
323
|
+
let left = this.parseAddSub();
|
|
324
|
+
while (this.peek().type === TokenType.Ampersand) {
|
|
325
|
+
this.advance();
|
|
326
|
+
const right = this.parseAddSub();
|
|
327
|
+
left = { kind: 'binary', op: '&', left, right };
|
|
328
|
+
}
|
|
329
|
+
return left;
|
|
330
|
+
}
|
|
331
|
+
// Addition / Subtraction
|
|
332
|
+
parseAddSub() {
|
|
333
|
+
let left = this.parseMulDiv();
|
|
334
|
+
while (this.peek().type === TokenType.Plus || this.peek().type === TokenType.Minus) {
|
|
335
|
+
const op = this.advance().value;
|
|
336
|
+
const right = this.parseMulDiv();
|
|
337
|
+
left = { kind: 'binary', op, left, right };
|
|
338
|
+
}
|
|
339
|
+
return left;
|
|
340
|
+
}
|
|
341
|
+
// Multiplication / Division / Modulo
|
|
342
|
+
parseMulDiv() {
|
|
343
|
+
let left = this.parsePower();
|
|
344
|
+
while (this.peek().type === TokenType.Star || this.peek().type === TokenType.Slash || this.peek().type === TokenType.Percent) {
|
|
345
|
+
const op = this.advance().value;
|
|
346
|
+
const right = this.parsePower();
|
|
347
|
+
left = { kind: 'binary', op, left, right };
|
|
348
|
+
}
|
|
349
|
+
return left;
|
|
350
|
+
}
|
|
351
|
+
// Power ^
|
|
352
|
+
parsePower() {
|
|
353
|
+
let left = this.parseUnary();
|
|
354
|
+
while (this.peek().type === TokenType.Caret) {
|
|
355
|
+
this.advance();
|
|
356
|
+
const right = this.parseUnary(); // right-associative would use parsePower here
|
|
357
|
+
left = { kind: 'binary', op: '^', left, right };
|
|
358
|
+
}
|
|
359
|
+
return left;
|
|
360
|
+
}
|
|
361
|
+
// Unary + / -
|
|
362
|
+
parseUnary() {
|
|
363
|
+
if (this.peek().type === TokenType.Minus) {
|
|
364
|
+
this.advance();
|
|
365
|
+
const operand = this.parseUnary();
|
|
366
|
+
return { kind: 'unary', op: '-', operand };
|
|
367
|
+
}
|
|
368
|
+
if (this.peek().type === TokenType.Plus) {
|
|
369
|
+
this.advance();
|
|
370
|
+
return this.parseUnary();
|
|
371
|
+
}
|
|
372
|
+
return this.parsePrimary();
|
|
373
|
+
}
|
|
374
|
+
// Primary: literals, field refs, function calls, identifiers, parenthesized expressions
|
|
375
|
+
parsePrimary() {
|
|
376
|
+
const t = this.peek();
|
|
377
|
+
// Number
|
|
378
|
+
if (t.type === TokenType.Number) {
|
|
379
|
+
this.advance();
|
|
380
|
+
return { kind: 'number', value: parseFloat(t.value) };
|
|
381
|
+
}
|
|
382
|
+
// String
|
|
383
|
+
if (t.type === TokenType.String) {
|
|
384
|
+
this.advance();
|
|
385
|
+
return { kind: 'string', value: t.value };
|
|
386
|
+
}
|
|
387
|
+
// Boolean
|
|
388
|
+
if (t.type === TokenType.Boolean) {
|
|
389
|
+
this.advance();
|
|
390
|
+
return { kind: 'boolean', value: t.value === 'true' };
|
|
391
|
+
}
|
|
392
|
+
// Null
|
|
393
|
+
if (t.type === TokenType.Null) {
|
|
394
|
+
this.advance();
|
|
395
|
+
return { kind: 'null' };
|
|
396
|
+
}
|
|
397
|
+
// Field reference
|
|
398
|
+
if (t.type === TokenType.FieldRef) {
|
|
399
|
+
this.advance();
|
|
400
|
+
return { kind: 'fieldRef', ref: t.value };
|
|
401
|
+
}
|
|
402
|
+
// Identifier — could be function call or bare identifier
|
|
403
|
+
if (t.type === TokenType.Identifier) {
|
|
404
|
+
this.advance();
|
|
405
|
+
if (this.peek().type === TokenType.LParen) {
|
|
406
|
+
// Function call
|
|
407
|
+
this.advance(); // skip (
|
|
408
|
+
const args = [];
|
|
409
|
+
if (this.peek().type !== TokenType.RParen) {
|
|
410
|
+
args.push(this.parseOr());
|
|
411
|
+
while (this.peek().type === TokenType.Comma) {
|
|
412
|
+
this.advance();
|
|
413
|
+
args.push(this.parseOr());
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
this.expect(TokenType.RParen);
|
|
417
|
+
return { kind: 'call', name: t.value.toUpperCase(), args };
|
|
418
|
+
}
|
|
419
|
+
return { kind: 'identifier', name: t.value };
|
|
420
|
+
}
|
|
421
|
+
// Parenthesized expression
|
|
422
|
+
if (t.type === TokenType.LParen) {
|
|
423
|
+
this.advance();
|
|
424
|
+
const expr = this.parseOr();
|
|
425
|
+
this.expect(TokenType.RParen);
|
|
426
|
+
return expr;
|
|
427
|
+
}
|
|
428
|
+
throw new ExprError(`Token inesperado: "${t.value}" (${TokenType[t.type]}) en posicion ${t.pos}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// ─── Expression Error ──────────────────────────────────────────────
|
|
432
|
+
class ExprError extends Error {
|
|
433
|
+
constructor(message) {
|
|
434
|
+
super(message);
|
|
435
|
+
this.name = 'ExprError';
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// ─── Report-level Variable Store (persists across report execution) ─
|
|
439
|
+
const reportVariables = new Map();
|
|
440
|
+
/** Reset all report variables (call before a new render) */
|
|
441
|
+
export function resetReportVariables() {
|
|
442
|
+
reportVariables.clear();
|
|
443
|
+
}
|
|
444
|
+
// ─── Number to Words ───────────────────────────────────────────────
|
|
445
|
+
const ONES_EN = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
|
|
446
|
+
'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
|
|
447
|
+
const TENS_EN = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
|
|
448
|
+
const SCALES_EN = ['', 'thousand', 'million', 'billion', 'trillion'];
|
|
449
|
+
const ONES_ES = ['', 'uno', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve',
|
|
450
|
+
'diez', 'once', 'doce', 'trece', 'catorce', 'quince', 'dieciseis', 'diecisiete', 'dieciocho', 'diecinueve'];
|
|
451
|
+
const TENS_ES = ['', '', 'veinte', 'treinta', 'cuarenta', 'cincuenta', 'sesenta', 'setenta', 'ochenta', 'noventa'];
|
|
452
|
+
const HUNDREDS_ES = ['', 'ciento', 'doscientos', 'trescientos', 'cuatrocientos', 'quinientos',
|
|
453
|
+
'seiscientos', 'setecientos', 'ochocientos', 'novecientos'];
|
|
454
|
+
const SCALES_ES = ['', 'mil', 'millon', 'billon', 'trillon'];
|
|
455
|
+
function numberToWordsEN(n) {
|
|
456
|
+
if (n === 0)
|
|
457
|
+
return 'zero';
|
|
458
|
+
if (n < 0)
|
|
459
|
+
return 'negative ' + numberToWordsEN(-n);
|
|
460
|
+
const intPart = Math.floor(n);
|
|
461
|
+
const decPart = Math.round((n - intPart) * 100);
|
|
462
|
+
let result = intToWordsEN(intPart);
|
|
463
|
+
if (decPart > 0) {
|
|
464
|
+
result += ' and ' + intToWordsEN(decPart) + '/100';
|
|
465
|
+
}
|
|
466
|
+
return result;
|
|
467
|
+
}
|
|
468
|
+
function intToWordsEN(n) {
|
|
469
|
+
if (n === 0)
|
|
470
|
+
return 'zero';
|
|
471
|
+
if (n < 0)
|
|
472
|
+
return 'negative ' + intToWordsEN(-n);
|
|
473
|
+
let result = '';
|
|
474
|
+
let scaleIdx = 0;
|
|
475
|
+
while (n > 0) {
|
|
476
|
+
const chunk = n % 1000;
|
|
477
|
+
if (chunk !== 0) {
|
|
478
|
+
const chunkStr = chunkToWordsEN(chunk);
|
|
479
|
+
const scale = SCALES_EN[scaleIdx];
|
|
480
|
+
result = chunkStr + (scale ? ' ' + scale : '') + (result ? ' ' + result : '');
|
|
481
|
+
}
|
|
482
|
+
n = Math.floor(n / 1000);
|
|
483
|
+
scaleIdx++;
|
|
484
|
+
}
|
|
485
|
+
return result.trim();
|
|
486
|
+
}
|
|
487
|
+
function chunkToWordsEN(n) {
|
|
488
|
+
if (n === 0)
|
|
489
|
+
return '';
|
|
490
|
+
if (n < 20)
|
|
491
|
+
return ONES_EN[n];
|
|
492
|
+
if (n < 100) {
|
|
493
|
+
return TENS_EN[Math.floor(n / 10)] + (n % 10 ? '-' + ONES_EN[n % 10] : '');
|
|
494
|
+
}
|
|
495
|
+
const hundreds = ONES_EN[Math.floor(n / 100)] + ' hundred';
|
|
496
|
+
const remainder = n % 100;
|
|
497
|
+
return remainder ? hundreds + ' ' + chunkToWordsEN(remainder) : hundreds;
|
|
498
|
+
}
|
|
499
|
+
function numberToWordsES(n) {
|
|
500
|
+
if (n === 0)
|
|
501
|
+
return 'cero';
|
|
502
|
+
if (n < 0)
|
|
503
|
+
return 'menos ' + numberToWordsES(-n);
|
|
504
|
+
const intPart = Math.floor(n);
|
|
505
|
+
const decPart = Math.round((n - intPart) * 100);
|
|
506
|
+
let result = intToWordsES(intPart);
|
|
507
|
+
if (decPart > 0) {
|
|
508
|
+
result += ' con ' + intToWordsES(decPart) + '/100';
|
|
509
|
+
}
|
|
510
|
+
return result;
|
|
511
|
+
}
|
|
512
|
+
function intToWordsES(n) {
|
|
513
|
+
if (n === 0)
|
|
514
|
+
return 'cero';
|
|
515
|
+
if (n < 0)
|
|
516
|
+
return 'menos ' + intToWordsES(-n);
|
|
517
|
+
if (n === 100)
|
|
518
|
+
return 'cien';
|
|
519
|
+
let result = '';
|
|
520
|
+
let scaleIdx = 0;
|
|
521
|
+
while (n > 0) {
|
|
522
|
+
const chunk = n % 1000;
|
|
523
|
+
if (chunk !== 0) {
|
|
524
|
+
let chunkStr;
|
|
525
|
+
if (scaleIdx === 1 && chunk === 1) {
|
|
526
|
+
// "mil" not "uno mil"
|
|
527
|
+
chunkStr = '';
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
chunkStr = chunkToWordsES(chunk);
|
|
531
|
+
}
|
|
532
|
+
const scale = SCALES_ES[scaleIdx];
|
|
533
|
+
let scalePart = scale;
|
|
534
|
+
// "millon" -> "millones" for plural
|
|
535
|
+
if (scaleIdx >= 2 && chunk > 1) {
|
|
536
|
+
scalePart = scale + 'es';
|
|
537
|
+
}
|
|
538
|
+
const part = (chunkStr ? chunkStr + ' ' : '') + scalePart;
|
|
539
|
+
result = part.trim() + (result ? ' ' + result : '');
|
|
540
|
+
}
|
|
541
|
+
n = Math.floor(n / 1000);
|
|
542
|
+
scaleIdx++;
|
|
543
|
+
}
|
|
544
|
+
return result.trim();
|
|
545
|
+
}
|
|
546
|
+
function chunkToWordsES(n) {
|
|
547
|
+
if (n === 0)
|
|
548
|
+
return '';
|
|
549
|
+
if (n === 100)
|
|
550
|
+
return 'cien';
|
|
551
|
+
if (n < 20)
|
|
552
|
+
return ONES_ES[n];
|
|
553
|
+
if (n < 30) {
|
|
554
|
+
const remainder = n % 10;
|
|
555
|
+
return remainder === 0 ? 'veinte' : 'veinti' + ONES_ES[remainder];
|
|
556
|
+
}
|
|
557
|
+
if (n < 100) {
|
|
558
|
+
const tens = TENS_ES[Math.floor(n / 10)];
|
|
559
|
+
const remainder = n % 10;
|
|
560
|
+
return remainder ? tens + ' y ' + ONES_ES[remainder] : tens;
|
|
561
|
+
}
|
|
562
|
+
const hundreds = HUNDREDS_ES[Math.floor(n / 100)];
|
|
563
|
+
const remainder = n % 100;
|
|
564
|
+
return remainder ? hundreds + ' ' + chunkToWordsES(remainder) : hundreds;
|
|
565
|
+
}
|
|
566
|
+
// ─── Format Helpers ────────────────────────────────────────────────
|
|
567
|
+
function formatNumberPattern(value, pattern) {
|
|
568
|
+
// Patterns: #,##0.00 $#,##0.00 0.00% etc.
|
|
569
|
+
const isNeg = value < 0;
|
|
570
|
+
let absVal = Math.abs(value);
|
|
571
|
+
let prefix = '';
|
|
572
|
+
let suffix = '';
|
|
573
|
+
let workPattern = pattern;
|
|
574
|
+
// Extract leading currency/symbol
|
|
575
|
+
const prefixMatch = workPattern.match(/^([^#0,.]+)/);
|
|
576
|
+
if (prefixMatch) {
|
|
577
|
+
prefix = prefixMatch[1];
|
|
578
|
+
workPattern = workPattern.slice(prefix.length);
|
|
579
|
+
}
|
|
580
|
+
// Extract trailing symbol
|
|
581
|
+
const suffixMatch = workPattern.match(/([^#0,.]+)$/);
|
|
582
|
+
if (suffixMatch) {
|
|
583
|
+
suffix = suffixMatch[1];
|
|
584
|
+
workPattern = workPattern.slice(0, workPattern.length - suffix.length);
|
|
585
|
+
}
|
|
586
|
+
// Determine decimals from pattern
|
|
587
|
+
const dotPos = workPattern.indexOf('.');
|
|
588
|
+
let decimals = 0;
|
|
589
|
+
if (dotPos >= 0) {
|
|
590
|
+
decimals = workPattern.length - dotPos - 1;
|
|
591
|
+
}
|
|
592
|
+
// Handle percentage
|
|
593
|
+
if (suffix === '%') {
|
|
594
|
+
absVal = absVal * 100;
|
|
595
|
+
}
|
|
596
|
+
// Check for thousand separator
|
|
597
|
+
const useThousands = workPattern.includes(',');
|
|
598
|
+
let formatted;
|
|
599
|
+
if (useThousands) {
|
|
600
|
+
const parts = absVal.toFixed(decimals).split('.');
|
|
601
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
602
|
+
formatted = parts.join('.');
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
formatted = absVal.toFixed(decimals);
|
|
606
|
+
}
|
|
607
|
+
return (isNeg ? '-' : '') + prefix + formatted + suffix;
|
|
608
|
+
}
|
|
609
|
+
function formatDatePattern(date, pattern) {
|
|
610
|
+
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
|
611
|
+
const yyyy = String(date.getFullYear());
|
|
612
|
+
const yy = yyyy.slice(-2);
|
|
613
|
+
const M = date.getMonth() + 1;
|
|
614
|
+
const d = date.getDate();
|
|
615
|
+
const H = date.getHours();
|
|
616
|
+
const h = H % 12 || 12;
|
|
617
|
+
const m = date.getMinutes();
|
|
618
|
+
const s = date.getSeconds();
|
|
619
|
+
const ampm = H < 12 ? 'AM' : 'PM';
|
|
620
|
+
const dayNamesEN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
621
|
+
const dayNamesShortEN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
622
|
+
const monthNamesEN = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
623
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
624
|
+
const monthNamesShortEN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
625
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
626
|
+
// Replace from longest to shortest to avoid partial matches
|
|
627
|
+
let result = pattern;
|
|
628
|
+
result = result.replace(/yyyy/g, yyyy);
|
|
629
|
+
result = result.replace(/yy/g, yy);
|
|
630
|
+
result = result.replace(/MMMM/g, monthNamesEN[M - 1]);
|
|
631
|
+
result = result.replace(/MMM/g, monthNamesShortEN[M - 1]);
|
|
632
|
+
result = result.replace(/MM/g, pad(M));
|
|
633
|
+
result = result.replace(/(?<![\w])M(?![\w])/g, String(M));
|
|
634
|
+
result = result.replace(/dddd/g, dayNamesEN[date.getDay()]);
|
|
635
|
+
result = result.replace(/ddd/g, dayNamesShortEN[date.getDay()]);
|
|
636
|
+
result = result.replace(/dd/g, pad(d));
|
|
637
|
+
result = result.replace(/(?<![a-zA-Z])d(?![a-zA-Z])/g, String(d));
|
|
638
|
+
result = result.replace(/HH/g, pad(H));
|
|
639
|
+
result = result.replace(/(?<![a-zA-Z])H(?![a-zA-Z])/g, String(H));
|
|
640
|
+
result = result.replace(/hh/g, pad(h));
|
|
641
|
+
result = result.replace(/mm/g, pad(m));
|
|
642
|
+
result = result.replace(/ss/g, pad(s));
|
|
643
|
+
result = result.replace(/tt/g, ampm);
|
|
644
|
+
result = result.replace(/TT/g, ampm);
|
|
645
|
+
return result;
|
|
646
|
+
}
|
|
647
|
+
const MONTH_NAMES_EN = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
648
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
649
|
+
const MONTH_NAMES_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
|
650
|
+
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
|
651
|
+
const DAY_NAMES_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
652
|
+
const DAY_NAMES_ES = ['Domingo', 'Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'];
|
|
653
|
+
// ─── Safe date parsing ─────────────────────────────────────────────
|
|
654
|
+
function toDate(val) {
|
|
655
|
+
if (val instanceof Date)
|
|
656
|
+
return val;
|
|
657
|
+
if (typeof val === 'string') {
|
|
658
|
+
const d = new Date(val);
|
|
659
|
+
return isNaN(d.getTime()) ? null : d;
|
|
660
|
+
}
|
|
661
|
+
if (typeof val === 'number')
|
|
662
|
+
return new Date(val);
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
function toNumber(val) {
|
|
666
|
+
if (typeof val === 'number')
|
|
667
|
+
return val;
|
|
668
|
+
if (typeof val === 'string') {
|
|
669
|
+
const n = parseFloat(val);
|
|
670
|
+
return isNaN(n) ? 0 : n;
|
|
671
|
+
}
|
|
672
|
+
if (typeof val === 'boolean')
|
|
673
|
+
return val ? 1 : 0;
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
function toBool(val) {
|
|
677
|
+
if (typeof val === 'boolean')
|
|
678
|
+
return val;
|
|
679
|
+
if (typeof val === 'number')
|
|
680
|
+
return val !== 0;
|
|
681
|
+
if (typeof val === 'string') {
|
|
682
|
+
const lower = val.toLowerCase();
|
|
683
|
+
return lower === 'true' || lower === 'yes' || lower === 'si' || lower === '1';
|
|
684
|
+
}
|
|
685
|
+
return val != null;
|
|
686
|
+
}
|
|
687
|
+
function toString(val) {
|
|
688
|
+
if (val == null)
|
|
689
|
+
return '';
|
|
690
|
+
if (val instanceof Date)
|
|
691
|
+
return val.toISOString();
|
|
692
|
+
return String(val);
|
|
693
|
+
}
|
|
694
|
+
const FUNCTIONS = {};
|
|
695
|
+
function registerFn(name, fn) {
|
|
696
|
+
FUNCTIONS[name.toUpperCase()] = fn;
|
|
697
|
+
}
|
|
698
|
+
// ─── String Functions (26) ─────────────────────────────────────────
|
|
699
|
+
registerFn('LEFT', (args) => toString(args[0]).slice(0, toNumber(args[1])));
|
|
700
|
+
registerFn('RIGHT', (args) => {
|
|
701
|
+
const s = toString(args[0]);
|
|
702
|
+
const n = toNumber(args[1]);
|
|
703
|
+
return s.slice(Math.max(0, s.length - n));
|
|
704
|
+
});
|
|
705
|
+
registerFn('MID', (args) => {
|
|
706
|
+
const s = toString(args[0]);
|
|
707
|
+
const start = toNumber(args[1]) - 1; // 1-based
|
|
708
|
+
const len = args.length > 2 ? toNumber(args[2]) : s.length - start;
|
|
709
|
+
return s.slice(Math.max(0, start), start + len);
|
|
710
|
+
});
|
|
711
|
+
registerFn('LEN', (args) => toString(args[0]).length);
|
|
712
|
+
registerFn('TRIM', (args) => toString(args[0]).trim());
|
|
713
|
+
registerFn('LTRIM', (args) => toString(args[0]).replace(/^\s+/, ''));
|
|
714
|
+
registerFn('RTRIM', (args) => toString(args[0]).replace(/\s+$/, ''));
|
|
715
|
+
registerFn('UPPER', (args) => toString(args[0]).toUpperCase());
|
|
716
|
+
registerFn('LOWER', (args) => toString(args[0]).toLowerCase());
|
|
717
|
+
registerFn('PROPERCASE', (args) => toString(args[0]).toLowerCase().replace(/(?:^|\s)\S/g, c => c.toUpperCase()));
|
|
718
|
+
registerFn('REPLACE', (args) => {
|
|
719
|
+
const s = toString(args[0]);
|
|
720
|
+
const search = toString(args[1]);
|
|
721
|
+
const replacement = toString(args[2]);
|
|
722
|
+
// Replace all occurrences
|
|
723
|
+
return s.split(search).join(replacement);
|
|
724
|
+
});
|
|
725
|
+
registerFn('INSTR', (args) => {
|
|
726
|
+
const s = toString(args[0]);
|
|
727
|
+
const search = toString(args[1]);
|
|
728
|
+
const start = args.length > 2 ? toNumber(args[2]) - 1 : 0;
|
|
729
|
+
const idx = s.indexOf(search, Math.max(0, start));
|
|
730
|
+
return idx >= 0 ? idx + 1 : 0; // 1-based, 0 = not found
|
|
731
|
+
});
|
|
732
|
+
registerFn('SPLIT', (args) => {
|
|
733
|
+
const s = toString(args[0]);
|
|
734
|
+
const delim = toString(args[1]);
|
|
735
|
+
const parts = s.split(delim);
|
|
736
|
+
if (args.length > 2) {
|
|
737
|
+
const idx = toNumber(args[2]) - 1;
|
|
738
|
+
return idx >= 0 && idx < parts.length ? parts[idx] : '';
|
|
739
|
+
}
|
|
740
|
+
return parts.join(', ');
|
|
741
|
+
});
|
|
742
|
+
registerFn('JOIN', (args) => {
|
|
743
|
+
// JOIN(delimiter, val1, val2, ...) — joins all values with delimiter
|
|
744
|
+
const delim = toString(args[0]);
|
|
745
|
+
return args.slice(1).map(toString).join(delim);
|
|
746
|
+
});
|
|
747
|
+
registerFn('CHR', (args) => String.fromCharCode(toNumber(args[0])));
|
|
748
|
+
registerFn('ASC', (args) => {
|
|
749
|
+
const s = toString(args[0]);
|
|
750
|
+
return s.length > 0 ? s.charCodeAt(0) : 0;
|
|
751
|
+
});
|
|
752
|
+
registerFn('SPACE', (args) => ' '.repeat(Math.max(0, toNumber(args[0]))));
|
|
753
|
+
registerFn('REPLICATESTRING', (args) => toString(args[0]).repeat(Math.max(0, toNumber(args[1]))));
|
|
754
|
+
registerFn('STRREVERSE', (args) => toString(args[0]).split('').reverse().join(''));
|
|
755
|
+
registerFn('CONTAINS', (args) => toString(args[0]).toLowerCase().includes(toString(args[1]).toLowerCase()));
|
|
756
|
+
registerFn('STARTSWITH', (args) => toString(args[0]).toLowerCase().startsWith(toString(args[1]).toLowerCase()));
|
|
757
|
+
registerFn('ENDSWITH', (args) => toString(args[0]).toLowerCase().endsWith(toString(args[1]).toLowerCase()));
|
|
758
|
+
registerFn('PADLEFT', (args) => toString(args[0]).padStart(toNumber(args[1]), args.length > 2 ? toString(args[2]) : ' '));
|
|
759
|
+
registerFn('PADRIGHT', (args) => toString(args[0]).padEnd(toNumber(args[1]), args.length > 2 ? toString(args[2]) : ' '));
|
|
760
|
+
registerFn('TOTEXT', (args) => {
|
|
761
|
+
if (args.length > 1) {
|
|
762
|
+
const val = args[0];
|
|
763
|
+
const fmt = toString(args[1]);
|
|
764
|
+
if (typeof val === 'number')
|
|
765
|
+
return formatNumberPattern(val, fmt);
|
|
766
|
+
const d = toDate(val);
|
|
767
|
+
if (d)
|
|
768
|
+
return formatDatePattern(d, fmt);
|
|
769
|
+
}
|
|
770
|
+
return toString(args[0]);
|
|
771
|
+
});
|
|
772
|
+
registerFn('TOWORDS', (args) => {
|
|
773
|
+
const n = toNumber(args[0]);
|
|
774
|
+
const lang = args.length > 1 ? toString(args[1]).toLowerCase() : 'es';
|
|
775
|
+
return lang === 'en' ? numberToWordsEN(n) : numberToWordsES(n);
|
|
776
|
+
});
|
|
777
|
+
registerFn('CONCAT', (args) => args.map(toString).join(''));
|
|
778
|
+
// ─── Math Functions (16) ───────────────────────────────────────────
|
|
779
|
+
registerFn('ABS', (args) => Math.abs(toNumber(args[0])));
|
|
780
|
+
registerFn('ROUND', (args) => {
|
|
781
|
+
const val = toNumber(args[0]);
|
|
782
|
+
const dec = args.length > 1 ? toNumber(args[1]) : 0;
|
|
783
|
+
const factor = Math.pow(10, dec);
|
|
784
|
+
return Math.round(val * factor) / factor;
|
|
785
|
+
});
|
|
786
|
+
registerFn('TRUNCATE', (args) => {
|
|
787
|
+
const val = toNumber(args[0]);
|
|
788
|
+
const dec = args.length > 1 ? toNumber(args[1]) : 0;
|
|
789
|
+
const factor = Math.pow(10, dec);
|
|
790
|
+
return Math.trunc(val * factor) / factor;
|
|
791
|
+
});
|
|
792
|
+
registerFn('FLOOR', (args) => Math.floor(toNumber(args[0])));
|
|
793
|
+
registerFn('CEILING', (args) => Math.ceil(toNumber(args[0])));
|
|
794
|
+
registerFn('CEIL', (args) => Math.ceil(toNumber(args[0])));
|
|
795
|
+
registerFn('REMAINDER', (args) => toNumber(args[0]) % toNumber(args[1]));
|
|
796
|
+
registerFn('MOD', (args) => toNumber(args[0]) % toNumber(args[1]));
|
|
797
|
+
registerFn('SGN', (args) => Math.sign(toNumber(args[0])));
|
|
798
|
+
registerFn('SQRT', (args) => Math.sqrt(toNumber(args[0])));
|
|
799
|
+
registerFn('EXP', (args) => Math.exp(toNumber(args[0])));
|
|
800
|
+
registerFn('LOG', (args) => {
|
|
801
|
+
if (args.length > 1)
|
|
802
|
+
return Math.log(toNumber(args[0])) / Math.log(toNumber(args[1]));
|
|
803
|
+
return Math.log(toNumber(args[0]));
|
|
804
|
+
});
|
|
805
|
+
registerFn('LOG10', (args) => Math.log10(toNumber(args[0])));
|
|
806
|
+
registerFn('PI', () => Math.PI);
|
|
807
|
+
registerFn('POWER', (args) => Math.pow(toNumber(args[0]), toNumber(args[1])));
|
|
808
|
+
registerFn('RANDOM', () => Math.random());
|
|
809
|
+
registerFn('MIN2', (args) => Math.min(toNumber(args[0]), toNumber(args[1])));
|
|
810
|
+
registerFn('MAX2', (args) => Math.max(toNumber(args[0]), toNumber(args[1])));
|
|
811
|
+
// ─── Date Functions (16) ───────────────────────────────────────────
|
|
812
|
+
registerFn('NOW', () => new Date());
|
|
813
|
+
registerFn('TODAY', () => {
|
|
814
|
+
const d = new Date();
|
|
815
|
+
d.setHours(0, 0, 0, 0);
|
|
816
|
+
return d;
|
|
817
|
+
});
|
|
818
|
+
registerFn('YEAR', (args) => { const d = toDate(args[0]); return d ? d.getFullYear() : 0; });
|
|
819
|
+
registerFn('MONTH', (args) => { const d = toDate(args[0]); return d ? d.getMonth() + 1 : 0; });
|
|
820
|
+
registerFn('DAY', (args) => { const d = toDate(args[0]); return d ? d.getDate() : 0; });
|
|
821
|
+
registerFn('HOUR', (args) => { const d = toDate(args[0]); return d ? d.getHours() : 0; });
|
|
822
|
+
registerFn('MINUTE', (args) => { const d = toDate(args[0]); return d ? d.getMinutes() : 0; });
|
|
823
|
+
registerFn('SECOND', (args) => { const d = toDate(args[0]); return d ? d.getSeconds() : 0; });
|
|
824
|
+
registerFn('DATEADD', (args) => {
|
|
825
|
+
const interval = toString(args[0]).toLowerCase();
|
|
826
|
+
const amount = toNumber(args[1]);
|
|
827
|
+
const d = toDate(args[2]);
|
|
828
|
+
if (!d)
|
|
829
|
+
return null;
|
|
830
|
+
const result = new Date(d.getTime());
|
|
831
|
+
switch (interval) {
|
|
832
|
+
case 'y':
|
|
833
|
+
case 'year':
|
|
834
|
+
case 'years':
|
|
835
|
+
result.setFullYear(result.getFullYear() + amount);
|
|
836
|
+
break;
|
|
837
|
+
case 'm':
|
|
838
|
+
case 'month':
|
|
839
|
+
case 'months':
|
|
840
|
+
result.setMonth(result.getMonth() + amount);
|
|
841
|
+
break;
|
|
842
|
+
case 'd':
|
|
843
|
+
case 'day':
|
|
844
|
+
case 'days':
|
|
845
|
+
result.setDate(result.getDate() + amount);
|
|
846
|
+
break;
|
|
847
|
+
case 'h':
|
|
848
|
+
case 'hour':
|
|
849
|
+
case 'hours':
|
|
850
|
+
result.setHours(result.getHours() + amount);
|
|
851
|
+
break;
|
|
852
|
+
case 'mi':
|
|
853
|
+
case 'minute':
|
|
854
|
+
case 'minutes':
|
|
855
|
+
result.setMinutes(result.getMinutes() + amount);
|
|
856
|
+
break;
|
|
857
|
+
case 's':
|
|
858
|
+
case 'second':
|
|
859
|
+
case 'seconds':
|
|
860
|
+
result.setSeconds(result.getSeconds() + amount);
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
return result;
|
|
864
|
+
});
|
|
865
|
+
registerFn('DATEDIFF', (args) => {
|
|
866
|
+
const interval = toString(args[0]).toLowerCase();
|
|
867
|
+
const d1 = toDate(args[1]);
|
|
868
|
+
const d2 = toDate(args[2]);
|
|
869
|
+
if (!d1 || !d2)
|
|
870
|
+
return 0;
|
|
871
|
+
const ms = d2.getTime() - d1.getTime();
|
|
872
|
+
switch (interval) {
|
|
873
|
+
case 'y':
|
|
874
|
+
case 'year':
|
|
875
|
+
case 'years': return d2.getFullYear() - d1.getFullYear();
|
|
876
|
+
case 'm':
|
|
877
|
+
case 'month':
|
|
878
|
+
case 'months':
|
|
879
|
+
return (d2.getFullYear() - d1.getFullYear()) * 12 + (d2.getMonth() - d1.getMonth());
|
|
880
|
+
case 'd':
|
|
881
|
+
case 'day':
|
|
882
|
+
case 'days': return Math.floor(ms / 86400000);
|
|
883
|
+
case 'h':
|
|
884
|
+
case 'hour':
|
|
885
|
+
case 'hours': return Math.floor(ms / 3600000);
|
|
886
|
+
case 'mi':
|
|
887
|
+
case 'minute':
|
|
888
|
+
case 'minutes': return Math.floor(ms / 60000);
|
|
889
|
+
case 's':
|
|
890
|
+
case 'second':
|
|
891
|
+
case 'seconds': return Math.floor(ms / 1000);
|
|
892
|
+
default: return Math.floor(ms / 86400000);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
registerFn('DAYOFWEEK', (args) => {
|
|
896
|
+
const d = toDate(args[0]);
|
|
897
|
+
return d ? d.getDay() + 1 : 0; // 1=Sunday .. 7=Saturday
|
|
898
|
+
});
|
|
899
|
+
registerFn('MONTHNAME', (args) => {
|
|
900
|
+
const m = toNumber(args[0]);
|
|
901
|
+
const lang = args.length > 1 ? toString(args[1]).toLowerCase() : 'en';
|
|
902
|
+
if (m < 1 || m > 12)
|
|
903
|
+
return '';
|
|
904
|
+
return lang === 'es' ? MONTH_NAMES_ES[m - 1] : MONTH_NAMES_EN[m - 1];
|
|
905
|
+
});
|
|
906
|
+
registerFn('DAYNAME', (args) => {
|
|
907
|
+
const d = args.length === 1 ? toDate(args[0]) : null;
|
|
908
|
+
const dayNum = d ? d.getDay() : toNumber(args[0]) - 1;
|
|
909
|
+
const lang = args.length > 1 && !toDate(args[0]) ? toString(args[1]).toLowerCase() : 'en';
|
|
910
|
+
if (dayNum < 0 || dayNum > 6)
|
|
911
|
+
return '';
|
|
912
|
+
return lang === 'es' ? DAY_NAMES_ES[dayNum] : DAY_NAMES_EN[dayNum];
|
|
913
|
+
});
|
|
914
|
+
registerFn('ISDATE', (args) => toDate(args[0]) !== null);
|
|
915
|
+
registerFn('FORMATDATE', (args) => {
|
|
916
|
+
const d = toDate(args[0]);
|
|
917
|
+
if (!d)
|
|
918
|
+
return '';
|
|
919
|
+
const pattern = args.length > 1 ? toString(args[1]) : 'yyyy-MM-dd';
|
|
920
|
+
return formatDatePattern(d, pattern);
|
|
921
|
+
});
|
|
922
|
+
registerFn('DATESERIAL', (args) => {
|
|
923
|
+
const y = toNumber(args[0]);
|
|
924
|
+
const m = toNumber(args[1]) - 1;
|
|
925
|
+
const d = toNumber(args[2]);
|
|
926
|
+
return new Date(y, m, d);
|
|
927
|
+
});
|
|
928
|
+
// ─── Type Conversion Functions (8) ─────────────────────────────────
|
|
929
|
+
registerFn('TONUMBER', (args) => toNumber(args[0]));
|
|
930
|
+
registerFn('TOSTRING', (args) => toString(args[0]));
|
|
931
|
+
registerFn('TODATE', (args) => toDate(args[0]));
|
|
932
|
+
registerFn('TOBOOLEAN', (args) => toBool(args[0]));
|
|
933
|
+
registerFn('ISNUMBER', (args) => typeof args[0] === 'number' || (typeof args[0] === 'string' && !isNaN(parseFloat(args[0]))));
|
|
934
|
+
registerFn('ISNULL', (args) => {
|
|
935
|
+
// ISNULL(val) -> boolean, ISNULL(val, fallback) -> coalesce
|
|
936
|
+
if (args.length === 1)
|
|
937
|
+
return args[0] == null;
|
|
938
|
+
return args[0] ?? args[1];
|
|
939
|
+
});
|
|
940
|
+
registerFn('COALESCE', (args) => args.find(a => a != null) ?? null);
|
|
941
|
+
// ─── Aggregate Functions (12) ──────────────────────────────────────
|
|
942
|
+
function getAggRows(ctx) {
|
|
943
|
+
return ctx.binding.groupRows || ctx.binding.allRows || [];
|
|
944
|
+
}
|
|
945
|
+
function extractNumericValues(rows, field) {
|
|
946
|
+
return rows.map(r => r[field]).filter((v) => typeof v === 'number');
|
|
947
|
+
}
|
|
948
|
+
registerFn('SUM', (args, ctx) => {
|
|
949
|
+
// SUM({field}) — aggregate over rows; SUM(a, b, c) — sum of args
|
|
950
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
951
|
+
const rows = getAggRows(ctx);
|
|
952
|
+
return extractNumericValues(rows, args[0]).reduce((a, b) => a + b, 0);
|
|
953
|
+
}
|
|
954
|
+
return args.map(toNumber).reduce((a, b) => a + b, 0);
|
|
955
|
+
});
|
|
956
|
+
registerFn('AVG', (args, ctx) => {
|
|
957
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
958
|
+
const vals = extractNumericValues(getAggRows(ctx), args[0]);
|
|
959
|
+
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
|
960
|
+
}
|
|
961
|
+
const nums = args.map(toNumber);
|
|
962
|
+
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
|
|
963
|
+
});
|
|
964
|
+
registerFn('COUNT', (args, ctx) => {
|
|
965
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
966
|
+
const rows = getAggRows(ctx);
|
|
967
|
+
return rows.filter(r => r[args[0]] != null).length;
|
|
968
|
+
}
|
|
969
|
+
return args.filter(a => a != null).length;
|
|
970
|
+
});
|
|
971
|
+
registerFn('DISTINCTCOUNT', (args, ctx) => {
|
|
972
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
973
|
+
const rows = getAggRows(ctx);
|
|
974
|
+
const values = rows.map(r => r[args[0]]).filter(v => v != null);
|
|
975
|
+
return new Set(values.map(String)).size;
|
|
976
|
+
}
|
|
977
|
+
return new Set(args.filter(a => a != null).map(String)).size;
|
|
978
|
+
});
|
|
979
|
+
registerFn('MIN', (args, ctx) => {
|
|
980
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
981
|
+
const vals = extractNumericValues(getAggRows(ctx), args[0]);
|
|
982
|
+
return vals.length ? Math.min(...vals) : 0;
|
|
983
|
+
}
|
|
984
|
+
const nums = args.map(toNumber);
|
|
985
|
+
return nums.length ? Math.min(...nums) : 0;
|
|
986
|
+
});
|
|
987
|
+
registerFn('MAX', (args, ctx) => {
|
|
988
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
989
|
+
const vals = extractNumericValues(getAggRows(ctx), args[0]);
|
|
990
|
+
return vals.length ? Math.max(...vals) : 0;
|
|
991
|
+
}
|
|
992
|
+
const nums = args.map(toNumber);
|
|
993
|
+
return nums.length ? Math.max(...nums) : 0;
|
|
994
|
+
});
|
|
995
|
+
registerFn('MEDIAN', (args, ctx) => {
|
|
996
|
+
let vals;
|
|
997
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
998
|
+
vals = extractNumericValues(getAggRows(ctx), args[0]);
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
vals = args.map(toNumber);
|
|
1002
|
+
}
|
|
1003
|
+
if (vals.length === 0)
|
|
1004
|
+
return 0;
|
|
1005
|
+
const sorted = [...vals].sort((a, b) => a - b);
|
|
1006
|
+
const mid = Math.floor(sorted.length / 2);
|
|
1007
|
+
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
1008
|
+
});
|
|
1009
|
+
registerFn('STDDEV', (args, ctx) => {
|
|
1010
|
+
let vals;
|
|
1011
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
1012
|
+
vals = extractNumericValues(getAggRows(ctx), args[0]);
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
vals = args.map(toNumber);
|
|
1016
|
+
}
|
|
1017
|
+
if (vals.length < 2)
|
|
1018
|
+
return 0;
|
|
1019
|
+
const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
1020
|
+
const variance = vals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (vals.length - 1);
|
|
1021
|
+
return Math.sqrt(variance);
|
|
1022
|
+
});
|
|
1023
|
+
registerFn('VARIANCE', (args, ctx) => {
|
|
1024
|
+
let vals;
|
|
1025
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
1026
|
+
vals = extractNumericValues(getAggRows(ctx), args[0]);
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
vals = args.map(toNumber);
|
|
1030
|
+
}
|
|
1031
|
+
if (vals.length < 2)
|
|
1032
|
+
return 0;
|
|
1033
|
+
const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
1034
|
+
return vals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (vals.length - 1);
|
|
1035
|
+
});
|
|
1036
|
+
registerFn('PERCENTOFSUM', (args, ctx) => {
|
|
1037
|
+
const fieldVal = toNumber(args[0]);
|
|
1038
|
+
let total;
|
|
1039
|
+
if (args.length > 1 && typeof args[1] === 'string') {
|
|
1040
|
+
total = extractNumericValues(getAggRows(ctx), args[1]).reduce((a, b) => a + b, 0);
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
// Use same field from context
|
|
1044
|
+
const rows = getAggRows(ctx);
|
|
1045
|
+
total = rows.map(r => toNumber(r[Object.keys(r)[0]])).reduce((a, b) => a + b, 0);
|
|
1046
|
+
}
|
|
1047
|
+
return total !== 0 ? (fieldVal / total) * 100 : 0;
|
|
1048
|
+
});
|
|
1049
|
+
registerFn('NTHLARGEST', (args, ctx) => {
|
|
1050
|
+
const n = toNumber(args[1]);
|
|
1051
|
+
let vals;
|
|
1052
|
+
if (typeof args[0] === 'string') {
|
|
1053
|
+
vals = extractNumericValues(getAggRows(ctx), args[0]);
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
vals = args.slice(1).map(toNumber);
|
|
1057
|
+
}
|
|
1058
|
+
const sorted = [...vals].sort((a, b) => b - a);
|
|
1059
|
+
return n >= 1 && n <= sorted.length ? sorted[n - 1] : 0;
|
|
1060
|
+
});
|
|
1061
|
+
registerFn('NTHSMALLEST', (args, ctx) => {
|
|
1062
|
+
const n = toNumber(args[1]);
|
|
1063
|
+
let vals;
|
|
1064
|
+
if (typeof args[0] === 'string') {
|
|
1065
|
+
vals = extractNumericValues(getAggRows(ctx), args[0]);
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
vals = args.slice(1).map(toNumber);
|
|
1069
|
+
}
|
|
1070
|
+
const sorted = [...vals].sort((a, b) => a - b);
|
|
1071
|
+
return n >= 1 && n <= sorted.length ? sorted[n - 1] : 0;
|
|
1072
|
+
});
|
|
1073
|
+
// Aggregate over group/all shortcuts: SUM_GROUP(dsId, field), SUM_ALL(field)
|
|
1074
|
+
registerFn('SUM_GROUP', (args, ctx) => {
|
|
1075
|
+
const field = toString(args.length > 1 ? args[1] : args[0]);
|
|
1076
|
+
const rows = ctx.binding.groupRows || ctx.binding.allRows || [];
|
|
1077
|
+
return computeAggregate(rows, field, 'sum');
|
|
1078
|
+
});
|
|
1079
|
+
registerFn('AVG_GROUP', (args, ctx) => {
|
|
1080
|
+
const field = toString(args.length > 1 ? args[1] : args[0]);
|
|
1081
|
+
const rows = ctx.binding.groupRows || ctx.binding.allRows || [];
|
|
1082
|
+
return computeAggregate(rows, field, 'avg');
|
|
1083
|
+
});
|
|
1084
|
+
registerFn('COUNT_GROUP', (args, ctx) => {
|
|
1085
|
+
const rows = ctx.binding.groupRows || ctx.binding.allRows || [];
|
|
1086
|
+
return rows.length;
|
|
1087
|
+
});
|
|
1088
|
+
registerFn('SUM_ALL', (args, ctx) => {
|
|
1089
|
+
const field = toString(args[0]);
|
|
1090
|
+
const rows = ctx.binding.allRows || [];
|
|
1091
|
+
return computeAggregate(rows, field, 'sum');
|
|
1092
|
+
});
|
|
1093
|
+
registerFn('AVG_ALL', (args, ctx) => {
|
|
1094
|
+
const field = toString(args[0]);
|
|
1095
|
+
const rows = ctx.binding.allRows || [];
|
|
1096
|
+
return computeAggregate(rows, field, 'avg');
|
|
1097
|
+
});
|
|
1098
|
+
registerFn('COUNT_ALL', (_args, ctx) => {
|
|
1099
|
+
const rows = ctx.binding.allRows || [];
|
|
1100
|
+
return rows.length;
|
|
1101
|
+
});
|
|
1102
|
+
// ─── Print State Functions (9) ─────────────────────────────────────
|
|
1103
|
+
registerFn('PREVIOUS', (args, ctx) => {
|
|
1104
|
+
const field = toString(args[0]);
|
|
1105
|
+
return ctx.printState.previousRow ? ctx.printState.previousRow[field] ?? null : null;
|
|
1106
|
+
});
|
|
1107
|
+
registerFn('NEXT', (args, ctx) => {
|
|
1108
|
+
const field = toString(args[0]);
|
|
1109
|
+
return ctx.printState.nextRow ? ctx.printState.nextRow[field] ?? null : null;
|
|
1110
|
+
});
|
|
1111
|
+
registerFn('RECORDNUMBER', (_args, ctx) => ctx.printState.recordNumber);
|
|
1112
|
+
registerFn('GROUPNUMBER', (_args, ctx) => ctx.printState.groupNumber);
|
|
1113
|
+
registerFn('PAGENUMBER', (_args, ctx) => ctx.printState.pageNumber);
|
|
1114
|
+
registerFn('TOTALPAGECOUNT', (_args, ctx) => ctx.printState.totalPageCount);
|
|
1115
|
+
registerFn('ONFIRSTRECORD', (_args, ctx) => ctx.printState.isFirstRecord);
|
|
1116
|
+
registerFn('ONLASTRECORD', (_args, ctx) => ctx.printState.isLastRecord);
|
|
1117
|
+
registerFn('INREPEATEDGROUPHEADER', (_args, ctx) => ctx.printState.inRepeatedGroupHeader);
|
|
1118
|
+
// ─── Control Structures (4) ───────────────────────────────────────
|
|
1119
|
+
registerFn('IF', (args) => toBool(args[0]) ? args[1] : (args.length > 2 ? args[2] : null));
|
|
1120
|
+
registerFn('IIF', (args) => toBool(args[0]) ? args[1] : (args.length > 2 ? args[2] : null));
|
|
1121
|
+
registerFn('SWITCH', (args) => {
|
|
1122
|
+
// SWITCH(val, case1, result1, case2, result2, ..., default?)
|
|
1123
|
+
const val = args[0];
|
|
1124
|
+
for (let i = 1; i + 1 < args.length; i += 2) {
|
|
1125
|
+
// Use loose equality for comparison
|
|
1126
|
+
if (val == args[i] || String(val) === String(args[i])) {
|
|
1127
|
+
return args[i + 1];
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
// Odd number of remaining args means last is default
|
|
1131
|
+
return args.length % 2 === 0 ? args[args.length - 1] : null;
|
|
1132
|
+
});
|
|
1133
|
+
registerFn('CHOOSE', (args) => {
|
|
1134
|
+
const index = toNumber(args[0]);
|
|
1135
|
+
if (index >= 1 && index < args.length) {
|
|
1136
|
+
return args[index];
|
|
1137
|
+
}
|
|
1138
|
+
return null;
|
|
1139
|
+
});
|
|
1140
|
+
// ─── Variable Support (2) ──────────────────────────────────────────
|
|
1141
|
+
registerFn('SETVAR', (args) => {
|
|
1142
|
+
const name = toString(args[0]);
|
|
1143
|
+
const value = args.length > 1 ? args[1] : null;
|
|
1144
|
+
reportVariables.set(name, value);
|
|
1145
|
+
return value;
|
|
1146
|
+
});
|
|
1147
|
+
registerFn('GETVAR', (args) => {
|
|
1148
|
+
const name = toString(args[0]);
|
|
1149
|
+
return reportVariables.get(name) ?? (args.length > 1 ? args[1] : null);
|
|
1150
|
+
});
|
|
1151
|
+
// ─── Format Functions (4) ──────────────────────────────────────────
|
|
1152
|
+
registerFn('FORMAT', (args) => {
|
|
1153
|
+
const val = args[0];
|
|
1154
|
+
const fmt = toString(args[1]);
|
|
1155
|
+
if (typeof val === 'number')
|
|
1156
|
+
return formatNumberPattern(val, fmt);
|
|
1157
|
+
const d = toDate(val);
|
|
1158
|
+
if (d)
|
|
1159
|
+
return formatDatePattern(d, fmt);
|
|
1160
|
+
return toString(val);
|
|
1161
|
+
});
|
|
1162
|
+
registerFn('FORMATCURRENCY', (args) => {
|
|
1163
|
+
const val = toNumber(args[0]);
|
|
1164
|
+
const symbol = args.length > 1 ? toString(args[1]) : '$';
|
|
1165
|
+
const decimals = args.length > 2 ? toNumber(args[2]) : 2;
|
|
1166
|
+
const isNeg = val < 0;
|
|
1167
|
+
const absVal = Math.abs(val);
|
|
1168
|
+
const parts = absVal.toFixed(decimals).split('.');
|
|
1169
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
1170
|
+
return (isNeg ? '-' : '') + symbol + parts.join('.');
|
|
1171
|
+
});
|
|
1172
|
+
registerFn('FORMATPERCENT', (args) => {
|
|
1173
|
+
const val = toNumber(args[0]);
|
|
1174
|
+
const decimals = args.length > 1 ? toNumber(args[1]) : 1;
|
|
1175
|
+
return (val * 100).toFixed(decimals) + '%';
|
|
1176
|
+
});
|
|
1177
|
+
// FORMATDATE is already registered above in date functions
|
|
1178
|
+
// ─── AST Evaluator ─────────────────────────────────────────────────
|
|
1179
|
+
function evaluateNode(node, ctx) {
|
|
1180
|
+
switch (node.kind) {
|
|
1181
|
+
case 'number': return node.value;
|
|
1182
|
+
case 'string': return node.value;
|
|
1183
|
+
case 'boolean': return node.value;
|
|
1184
|
+
case 'null': return null;
|
|
1185
|
+
case 'fieldRef': {
|
|
1186
|
+
const ref = node.ref;
|
|
1187
|
+
const dotIdx = ref.indexOf('.');
|
|
1188
|
+
if (dotIdx >= 0) {
|
|
1189
|
+
// {dataSource.field}
|
|
1190
|
+
const dsId = ref.slice(0, dotIdx);
|
|
1191
|
+
const field = ref.slice(dotIdx + 1);
|
|
1192
|
+
const ds = ctx.binding.dataSets[dsId];
|
|
1193
|
+
if (ds && !Array.isArray(ds))
|
|
1194
|
+
return ds[field] ?? null;
|
|
1195
|
+
if (ctx.binding.currentRow && field in ctx.binding.currentRow)
|
|
1196
|
+
return ctx.binding.currentRow[field];
|
|
1197
|
+
}
|
|
1198
|
+
// {field} — look in current row first, then parameters
|
|
1199
|
+
if (ctx.binding.currentRow && ref in ctx.binding.currentRow) {
|
|
1200
|
+
return ctx.binding.currentRow[ref];
|
|
1201
|
+
}
|
|
1202
|
+
if (ctx.binding.parameters && ref in ctx.binding.parameters) {
|
|
1203
|
+
return ctx.binding.parameters[ref];
|
|
1204
|
+
}
|
|
1205
|
+
// Check all datasets for single-value (object) datasets
|
|
1206
|
+
for (const [, ds] of Object.entries(ctx.binding.dataSets)) {
|
|
1207
|
+
if (ds && !Array.isArray(ds) && ref in ds) {
|
|
1208
|
+
return ds[ref];
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
case 'identifier': {
|
|
1214
|
+
// Bare identifier — treat as field name (same resolution as fieldRef)
|
|
1215
|
+
const name = node.name;
|
|
1216
|
+
if (ctx.binding.currentRow && name in ctx.binding.currentRow) {
|
|
1217
|
+
return ctx.binding.currentRow[name];
|
|
1218
|
+
}
|
|
1219
|
+
if (ctx.binding.parameters && name in ctx.binding.parameters) {
|
|
1220
|
+
return ctx.binding.parameters[name];
|
|
1221
|
+
}
|
|
1222
|
+
// Could be a string literal used as function argument
|
|
1223
|
+
return name;
|
|
1224
|
+
}
|
|
1225
|
+
case 'unary': {
|
|
1226
|
+
const operand = evaluateNode(node.operand, ctx);
|
|
1227
|
+
switch (node.op) {
|
|
1228
|
+
case '-': return -toNumber(operand);
|
|
1229
|
+
case 'NOT': return !toBool(operand);
|
|
1230
|
+
default: return operand;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
case 'binary': {
|
|
1234
|
+
const left = evaluateNode(node.left, ctx);
|
|
1235
|
+
const right = evaluateNode(node.right, ctx);
|
|
1236
|
+
switch (node.op) {
|
|
1237
|
+
// Arithmetic
|
|
1238
|
+
case '+': {
|
|
1239
|
+
// If both are numbers, add. Otherwise concatenate strings.
|
|
1240
|
+
if (typeof left === 'number' && typeof right === 'number')
|
|
1241
|
+
return left + right;
|
|
1242
|
+
if (typeof left === 'number' || typeof right === 'number') {
|
|
1243
|
+
const ln = Number(left);
|
|
1244
|
+
const rn = Number(right);
|
|
1245
|
+
if (!isNaN(ln) && !isNaN(rn))
|
|
1246
|
+
return ln + rn;
|
|
1247
|
+
}
|
|
1248
|
+
return toString(left) + toString(right);
|
|
1249
|
+
}
|
|
1250
|
+
case '-': return toNumber(left) - toNumber(right);
|
|
1251
|
+
case '*': return toNumber(left) * toNumber(right);
|
|
1252
|
+
case '/': {
|
|
1253
|
+
const divisor = toNumber(right);
|
|
1254
|
+
if (divisor === 0)
|
|
1255
|
+
return 0; // Avoid Infinity — return 0 like Crystal Reports
|
|
1256
|
+
return toNumber(left) / divisor;
|
|
1257
|
+
}
|
|
1258
|
+
case '%': return toNumber(left) % toNumber(right);
|
|
1259
|
+
case '^': return Math.pow(toNumber(left), toNumber(right));
|
|
1260
|
+
// String concatenation
|
|
1261
|
+
case '&': return toString(left) + toString(right);
|
|
1262
|
+
// Comparison
|
|
1263
|
+
case '==':
|
|
1264
|
+
case '=': {
|
|
1265
|
+
if (left == null && right == null)
|
|
1266
|
+
return true;
|
|
1267
|
+
if (left == null || right == null)
|
|
1268
|
+
return false;
|
|
1269
|
+
if (typeof left === 'number' && typeof right === 'number')
|
|
1270
|
+
return left === right;
|
|
1271
|
+
return String(left) === String(right);
|
|
1272
|
+
}
|
|
1273
|
+
case '!=':
|
|
1274
|
+
case '<>': {
|
|
1275
|
+
if (left == null && right == null)
|
|
1276
|
+
return false;
|
|
1277
|
+
if (left == null || right == null)
|
|
1278
|
+
return true;
|
|
1279
|
+
if (typeof left === 'number' && typeof right === 'number')
|
|
1280
|
+
return left !== right;
|
|
1281
|
+
return String(left) !== String(right);
|
|
1282
|
+
}
|
|
1283
|
+
case '<': return toNumber(left) < toNumber(right);
|
|
1284
|
+
case '>': return toNumber(left) > toNumber(right);
|
|
1285
|
+
case '<=': return toNumber(left) <= toNumber(right);
|
|
1286
|
+
case '>=': return toNumber(left) >= toNumber(right);
|
|
1287
|
+
// Logical
|
|
1288
|
+
case 'AND': return toBool(left) && toBool(right);
|
|
1289
|
+
case 'OR': return toBool(left) || toBool(right);
|
|
1290
|
+
default: return null;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
case 'call': {
|
|
1294
|
+
const fn = FUNCTIONS[node.name];
|
|
1295
|
+
if (!fn) {
|
|
1296
|
+
throw new ExprError(`Funcion desconocida: ${node.name}`);
|
|
1297
|
+
}
|
|
1298
|
+
const evaluatedArgs = node.args.map(arg => evaluateNode(arg, ctx));
|
|
1299
|
+
return fn(evaluatedArgs, ctx);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
// ─── Default Print State ───────────────────────────────────────────
|
|
1304
|
+
function defaultPrintState(ctx) {
|
|
1305
|
+
return {
|
|
1306
|
+
recordNumber: (ctx.currentIndex ?? 0) + 1,
|
|
1307
|
+
groupNumber: 1,
|
|
1308
|
+
pageNumber: ctx.pageNumber ?? 1,
|
|
1309
|
+
totalPageCount: ctx.totalPages ?? 1,
|
|
1310
|
+
isFirstRecord: ctx.currentIndex === 0,
|
|
1311
|
+
isLastRecord: ctx.allRows ? ctx.currentIndex === ctx.allRows.length - 1 : false,
|
|
1312
|
+
inRepeatedGroupHeader: false,
|
|
1313
|
+
previousRow: ctx.allRows && ctx.currentIndex != null && ctx.currentIndex > 0
|
|
1314
|
+
? ctx.allRows[ctx.currentIndex - 1]
|
|
1315
|
+
: undefined,
|
|
1316
|
+
nextRow: ctx.allRows && ctx.currentIndex != null && ctx.currentIndex < (ctx.allRows.length - 1)
|
|
1317
|
+
? ctx.allRows[ctx.currentIndex + 1]
|
|
1318
|
+
: undefined,
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
// ─── Public API ────────────────────────────────────────────────────
|
|
52
1322
|
/**
|
|
53
1323
|
* Evaluate an expression string with context.
|
|
54
1324
|
*
|
|
55
1325
|
* Supports:
|
|
56
1326
|
* - Field references: {fieldName} or {dataSource.field}
|
|
57
1327
|
* - Function calls: =SUM({qty}), =IF({status}=="paid","PAID","PENDING")
|
|
58
|
-
* - Arithmetic: =qty*price
|
|
1328
|
+
* - Arithmetic: =qty*price, ={qty}*{price}
|
|
1329
|
+
* - String concatenation: ={firstName} & " " & {lastName}
|
|
1330
|
+
* - Comparison: ={total} > 1000
|
|
1331
|
+
* - Logical: ={active} AND {verified}
|
|
1332
|
+
* - Nested calls: =IF({total} > 1000, FORMATCURRENCY({total}, "$", 2), "N/A")
|
|
59
1333
|
* - Aggregate over group: =SUM_GROUP(detail, total)
|
|
1334
|
+
* - Variables: =SETVAR("runningTotal", GETVAR("runningTotal") + {amount})
|
|
60
1335
|
*/
|
|
61
|
-
export function evaluateExpression(expr, ctx) {
|
|
1336
|
+
export function evaluateExpression(expr, ctx, printState) {
|
|
62
1337
|
if (!expr)
|
|
63
1338
|
return '';
|
|
64
1339
|
// Non-expression strings are returned as-is
|
|
65
1340
|
if (!expr.startsWith('='))
|
|
66
1341
|
return expr;
|
|
67
1342
|
const formula = expr.slice(1).trim();
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (ds && !Array.isArray(ds))
|
|
88
|
-
return JSON.stringify(ds[ref2] ?? null);
|
|
89
|
-
if (ctx.currentRow && ref1 in ctx.currentRow)
|
|
90
|
-
return JSON.stringify(ctx.currentRow[ref1]);
|
|
91
|
-
}
|
|
92
|
-
// {field} — look in current row
|
|
93
|
-
if (ctx.currentRow && ref1 in ctx.currentRow) {
|
|
94
|
-
const val = ctx.currentRow[ref1];
|
|
95
|
-
return typeof val === 'string' ? JSON.stringify(val) : String(val ?? 'null');
|
|
96
|
-
}
|
|
97
|
-
return 'null';
|
|
98
|
-
});
|
|
99
|
-
// Replace function calls with JS equivalents
|
|
100
|
-
for (const [name, fn] of Object.entries(FUNCTIONS)) {
|
|
101
|
-
const regex = new RegExp(`\\b${name}\\(`, 'g');
|
|
102
|
-
resolved = resolved.replace(regex, `__fn.${name}(`);
|
|
1343
|
+
if (!formula)
|
|
1344
|
+
return '';
|
|
1345
|
+
try {
|
|
1346
|
+
const lexer = new Lexer(formula);
|
|
1347
|
+
const parser = new Parser(lexer.getTokens());
|
|
1348
|
+
const ast = parser.parse();
|
|
1349
|
+
const evalCtx = {
|
|
1350
|
+
binding: ctx,
|
|
1351
|
+
variables: reportVariables,
|
|
1352
|
+
printState: {
|
|
1353
|
+
...defaultPrintState(ctx),
|
|
1354
|
+
...printState,
|
|
1355
|
+
},
|
|
1356
|
+
};
|
|
1357
|
+
return evaluateNode(ast, evalCtx);
|
|
1358
|
+
}
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1361
|
+
return `#ERR: ${message}`;
|
|
103
1362
|
}
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Evaluate an expression and return the result as a string.
|
|
1366
|
+
* Convenience wrapper for display purposes.
|
|
1367
|
+
*/
|
|
1368
|
+
export function evaluateExpressionAsString(expr, ctx, printState) {
|
|
1369
|
+
const result = evaluateExpression(expr, ctx, printState);
|
|
1370
|
+
if (result == null)
|
|
1371
|
+
return '';
|
|
1372
|
+
if (result instanceof Date)
|
|
1373
|
+
return result.toISOString();
|
|
1374
|
+
return String(result);
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Validate an expression without evaluating it.
|
|
1378
|
+
* Returns null if valid, or an error message if invalid.
|
|
1379
|
+
*/
|
|
1380
|
+
export function validateExpression(expr) {
|
|
1381
|
+
if (!expr || !expr.startsWith('='))
|
|
1382
|
+
return null;
|
|
1383
|
+
const formula = expr.slice(1).trim();
|
|
1384
|
+
if (!formula)
|
|
1385
|
+
return null;
|
|
104
1386
|
try {
|
|
105
|
-
const
|
|
106
|
-
|
|
1387
|
+
const lexer = new Lexer(formula);
|
|
1388
|
+
const parser = new Parser(lexer.getTokens());
|
|
1389
|
+
parser.parse();
|
|
1390
|
+
return null;
|
|
107
1391
|
}
|
|
108
|
-
catch {
|
|
109
|
-
return
|
|
1392
|
+
catch (err) {
|
|
1393
|
+
return err instanceof Error ? err.message : String(err);
|
|
110
1394
|
}
|
|
111
1395
|
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Get a list of all field references used in an expression.
|
|
1398
|
+
* Useful for dependency analysis and validation in the designer.
|
|
1399
|
+
*/
|
|
1400
|
+
export function getExpressionFields(expr) {
|
|
1401
|
+
if (!expr || !expr.startsWith('='))
|
|
1402
|
+
return [];
|
|
1403
|
+
const fields = [];
|
|
1404
|
+
const regex = /\{([^}]+)\}/g;
|
|
1405
|
+
let match;
|
|
1406
|
+
while ((match = regex.exec(expr)) !== null) {
|
|
1407
|
+
fields.push(match[1]);
|
|
1408
|
+
}
|
|
1409
|
+
return fields;
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Get a list of all functions used in an expression.
|
|
1413
|
+
* Useful for validation in the designer.
|
|
1414
|
+
*/
|
|
1415
|
+
export function getExpressionFunctions(expr) {
|
|
1416
|
+
if (!expr || !expr.startsWith('='))
|
|
1417
|
+
return [];
|
|
1418
|
+
const formula = expr.slice(1).trim();
|
|
1419
|
+
const fns = [];
|
|
1420
|
+
const regex = /\b([A-Za-z_]\w*)\s*\(/g;
|
|
1421
|
+
let match;
|
|
1422
|
+
while ((match = regex.exec(formula)) !== null) {
|
|
1423
|
+
const name = match[1].toUpperCase();
|
|
1424
|
+
if (FUNCTIONS[name] && !fns.includes(name)) {
|
|
1425
|
+
fns.push(name);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return fns;
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Get the full list of available function names.
|
|
1432
|
+
* Useful for autocomplete in the designer.
|
|
1433
|
+
*/
|
|
1434
|
+
export function getAvailableFunctions() {
|
|
1435
|
+
return Object.keys(FUNCTIONS).sort();
|
|
1436
|
+
}
|
|
112
1437
|
//# sourceMappingURL=expression.js.map
|