@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.
Files changed (36) hide show
  1. package/dist/engine/conditional-format.d.ts +48 -0
  2. package/dist/engine/conditional-format.d.ts.map +1 -0
  3. package/dist/engine/conditional-format.js +167 -0
  4. package/dist/engine/conditional-format.js.map +1 -0
  5. package/dist/engine/cross-tab.d.ts +68 -0
  6. package/dist/engine/cross-tab.d.ts.map +1 -0
  7. package/dist/engine/cross-tab.js +549 -0
  8. package/dist/engine/cross-tab.js.map +1 -0
  9. package/dist/engine/expression.d.ts +46 -2
  10. package/dist/engine/expression.d.ts.map +1 -1
  11. package/dist/engine/expression.js +1415 -90
  12. package/dist/engine/expression.js.map +1 -1
  13. package/dist/engine/multi-pass-engine.d.ts +74 -0
  14. package/dist/engine/multi-pass-engine.d.ts.map +1 -0
  15. package/dist/engine/multi-pass-engine.js +1082 -0
  16. package/dist/engine/multi-pass-engine.js.map +1 -0
  17. package/dist/engine/running-totals.d.ts +74 -0
  18. package/dist/engine/running-totals.d.ts.map +1 -0
  19. package/dist/engine/running-totals.js +247 -0
  20. package/dist/engine/running-totals.js.map +1 -0
  21. package/dist/engine/subreport.d.ts +59 -0
  22. package/dist/engine/subreport.d.ts.map +1 -0
  23. package/dist/engine/subreport.js +295 -0
  24. package/dist/engine/subreport.js.map +1 -0
  25. package/dist/index.d.ts +11 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/schema/report-schema.d.ts +346 -346
  30. package/dist/serialization/json.d.ts +88 -88
  31. package/dist/templates/page-sizes.d.ts.map +1 -1
  32. package/dist/templates/page-sizes.js +95 -3
  33. package/dist/templates/page-sizes.js.map +1 -1
  34. package/dist/types.d.ts +38 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +1 -1
@@ -1,112 +1,1437 @@
1
1
  // @zentto/report-core — Expression engine
2
- // Evaluates formulas and expressions in report fields
2
+ // Complete formula language with recursive descent parser
3
+ // Inspired by Crystal Reports formula syntax
3
4
  import { computeAggregate } from './data-binding.js';
4
- /** Built-in functions available in expressions */
5
- const FUNCTIONS = {
6
- // Aggregates
7
- SUM: (...args) => args.reduce((a, b) => a + b, 0),
8
- AVG: (...args) => {
9
- const nums = args;
10
- return nums.reduce((a, b) => a + b, 0) / nums.length;
11
- },
12
- COUNT: (...args) => args.length,
13
- MIN: (...args) => Math.min(...args),
14
- MAX: (...args) => Math.max(...args),
15
- // Math
16
- ROUND: (val, decimals) => Math.round(val * 10 ** decimals) / 10 ** decimals,
17
- ABS: (val) => Math.abs(val),
18
- CEIL: (val) => Math.ceil(val),
19
- FLOOR: (val) => Math.floor(val),
20
- // String
21
- UPPER: (val) => String(val).toUpperCase(),
22
- LOWER: (val) => String(val).toLowerCase(),
23
- TRIM: (val) => String(val).trim(),
24
- LEFT: (val, n) => String(val).slice(0, n),
25
- RIGHT: (val, n) => String(val).slice(-n),
26
- LEN: (val) => String(val).length,
27
- CONCAT: (...args) => args.map(String).join(''),
28
- // Logic
29
- IF: (cond, trueVal, falseVal) => cond ? trueVal : falseVal,
30
- IIF: (cond, trueVal, falseVal) => cond ? trueVal : falseVal,
31
- // Date
32
- NOW: () => new Date().toISOString(),
33
- TODAY: () => new Date().toISOString().split('T')[0],
34
- YEAR: (val) => new Date(val).getFullYear(),
35
- MONTH: (val) => new Date(val).getMonth() + 1,
36
- DAY: (val) => new Date(val).getDate(),
37
- // Format
38
- FORMAT: (val, fmt) => {
39
- if (typeof val === 'number' && typeof fmt === 'string') {
40
- if (fmt.startsWith('$')) {
41
- return '$' + val.toFixed(2);
42
- }
43
- const decimals = (fmt.split('.')[1] || '').length;
44
- return val.toFixed(decimals);
45
- }
46
- return String(val);
47
- },
48
- // Null handling
49
- ISNULL: (val, fallback) => val ?? fallback,
50
- COALESCE: (...args) => args.find(a => a != null) ?? null,
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
- // Check for aggregate function over dataset: SUM_GROUP(dsId, field)
69
- const aggGroupMatch = formula.match(/^(SUM|AVG|COUNT|MIN|MAX)_GROUP\((\w+),\s*(\w+)\)$/);
70
- if (aggGroupMatch) {
71
- const [, fn, _dsId, field] = aggGroupMatch;
72
- const rows = ctx.groupRows || ctx.allRows || [];
73
- return computeAggregate(rows, field, fn.toLowerCase());
74
- }
75
- // Check for aggregate function over all: SUM_ALL(field)
76
- const aggAllMatch = formula.match(/^(SUM|AVG|COUNT|MIN|MAX)_ALL\((\w+)\)$/);
77
- if (aggAllMatch) {
78
- const [, fn, field] = aggAllMatch;
79
- const rows = ctx.allRows || [];
80
- return computeAggregate(rows, field, fn.toLowerCase());
81
- }
82
- // Replace {fieldName} with actual values
83
- let resolved = formula.replace(/\{(\w+)(?:\.(\w+))?\}/g, (_m, ref1, ref2) => {
84
- if (ref2) {
85
- // {dataSource.field}
86
- const ds = ctx.dataSets[ref1];
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 fn = new Function('__fn', `"use strict"; return (${resolved});`);
106
- return fn(FUNCTIONS);
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 `#ERR: ${expr}`;
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