@xeplr/expression-handler 1.0.6 → 2.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/README.md +302 -67
- package/index.js +151 -36
- package/lib/analyze.js +71 -0
- package/lib/compile.js +323 -0
- package/lib/dates.js +86 -0
- package/lib/evaluate.js +54 -80
- package/lib/functions.js +276 -41
- package/lib/nlp.js +1 -1
- package/lib/parse.js +301 -0
- package/lib/text.js +103 -0
- package/package.json +2 -2
- package/lib/formula.js +0 -196
package/lib/analyze.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// What a formula uses, without running it — for editors (which columns does
|
|
2
|
+
// this need? is it a per-row or a per-group formula?) and for validation.
|
|
3
|
+
//
|
|
4
|
+
// analyze('if(sum(a) > 10, upper(city), "-")')
|
|
5
|
+
// → { fields: ['a', 'city'], fieldsOutsideTotals: ['city'], thisrow: [],
|
|
6
|
+
// functions: ['sum', 'upper'], aggregates: [{ name: 'sum', list: null, fields: ['a'] }],
|
|
7
|
+
// levels: 1 }
|
|
8
|
+
//
|
|
9
|
+
// `fields` are dotted names (`form.country`); a list total's own list counts
|
|
10
|
+
// as a field of the row, its condition and value read the list's items.
|
|
11
|
+
|
|
12
|
+
var functions = require('./functions');
|
|
13
|
+
|
|
14
|
+
function analyzeTree(tree) {
|
|
15
|
+
var fields = [];
|
|
16
|
+
var outside = [];
|
|
17
|
+
var thisrow = [];
|
|
18
|
+
var fns = [];
|
|
19
|
+
var aggregates = [];
|
|
20
|
+
var levels = 0;
|
|
21
|
+
|
|
22
|
+
function add(list, v) { if (list.indexOf(v) === -1) list.push(v); }
|
|
23
|
+
|
|
24
|
+
// `into` collects the fields of the innermost total being walked, or null.
|
|
25
|
+
function walk(node, level, into) {
|
|
26
|
+
if (!node || typeof node !== 'object') return;
|
|
27
|
+
if (Array.isArray(node)) { node.forEach(function (n) { walk(n, level, into); }); return; }
|
|
28
|
+
|
|
29
|
+
if (node.fn !== undefined && node.call === undefined) add(fns, functions.lookup(node.fn) ? functions.lookup(node.fn).name : String(node.fn));
|
|
30
|
+
if (node.field !== undefined || node.path !== undefined) {
|
|
31
|
+
var name = node.field !== undefined ? String(node.field) : node.path.join('.');
|
|
32
|
+
add(fields, name);
|
|
33
|
+
if (into) add(into, name); else add(outside, name);
|
|
34
|
+
}
|
|
35
|
+
if (node.thisrow !== undefined) add(thisrow, node.thisrow.join('.'));
|
|
36
|
+
|
|
37
|
+
if (node.call !== undefined) {
|
|
38
|
+
var entry = functions.lookup(node.call);
|
|
39
|
+
var fname = entry ? entry.name : String(node.call);
|
|
40
|
+
add(fns, fname);
|
|
41
|
+
var args = node.args || [];
|
|
42
|
+
if (entry && entry.aggregate) {
|
|
43
|
+
var implicit = (entry.shape.cond ? 1 : 0) + (entry.shape.value ? 1 : 0);
|
|
44
|
+
var hasList = args.length === implicit + 1;
|
|
45
|
+
var own = [];
|
|
46
|
+
var agg = { name: entry.name, list: null, fields: own };
|
|
47
|
+
levels = Math.max(levels, level + 1);
|
|
48
|
+
if (hasList) {
|
|
49
|
+
var l = args[0];
|
|
50
|
+
agg.list = l.field !== undefined ? String(l.field) : l.path ? l.path.join('.') : null;
|
|
51
|
+
walk(l, level, into);
|
|
52
|
+
}
|
|
53
|
+
(hasList ? args.slice(1) : args).forEach(function (a) { walk(a, level + 1, own); });
|
|
54
|
+
own.forEach(function (f) { add(fields, f); if (into) add(into, f); });
|
|
55
|
+
aggregates.push(agg);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
walk(args, level, into);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
['left', 'right', 'if', 'then', 'else', 'not', 'neg', 'pos', 'all', 'any', 'list'].forEach(function (k) {
|
|
63
|
+
if (node[k] !== undefined) walk(node[k], level, into);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
walk(tree, 0, null);
|
|
68
|
+
return { fields: fields, fieldsOutsideTotals: outside, thisrow: thisrow, functions: fns, aggregates: aggregates, levels: levels };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { analyzeTree: analyzeTree };
|
package/lib/compile.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// JSON tree → a plain JavaScript function, built ONCE and then run per row.
|
|
2
|
+
//
|
|
3
|
+
// Every node becomes a closure (row, top) → value, with everything that can
|
|
4
|
+
// be settled up front settled here:
|
|
5
|
+
// • names — `revenue` becomes "read row.revenue", `upper` a direct
|
|
6
|
+
// reference; running a row looks nothing up and allocates nothing
|
|
7
|
+
// • constants — any part whose inputs are all fixed (`upper("pune")`,
|
|
8
|
+
// `-5`, `2 * 3`) is worked out now and becomes that value
|
|
9
|
+
// • short-circuit — if / ? : / and / or run only what they need
|
|
10
|
+
//
|
|
11
|
+
// `row` is what names read; `top` is the row the formula is being worked out
|
|
12
|
+
// for — the same object except inside a list total, where names read the list
|
|
13
|
+
// item and $thisrow still reads `top`.
|
|
14
|
+
//
|
|
15
|
+
// Operators behave as expr-eval's did (BI's saved formulas depend on it) —
|
|
16
|
+
// `+` adds as numbers, `||` joins as text, `and` / `or` give true / false —
|
|
17
|
+
// with two deliberate changes: equality is forgiving (18 = "18"; it is the 1.x
|
|
18
|
+
// `eq`, which Workflow's conditions already use), and comparing nothing with
|
|
19
|
+
// < > is false. The 1.x word operators (eq, gt, contains, …) keep their 1.x
|
|
20
|
+
// meaning — see operators.js.
|
|
21
|
+
|
|
22
|
+
var functions = require('./functions');
|
|
23
|
+
var operators = require('./operators');
|
|
24
|
+
var dates = require('./dates');
|
|
25
|
+
var BLOCKED = require('./parse').BLOCKED;
|
|
26
|
+
|
|
27
|
+
var DEFAULTS = {
|
|
28
|
+
fiscalYearStart: 4, // April
|
|
29
|
+
today: null, // pinned date for ytd / fytd; null = now
|
|
30
|
+
missing: 'null', // a name the row doesn't have: 'null' or 'error'
|
|
31
|
+
maxListItems: 10000, // items in one list a total runs over
|
|
32
|
+
maxDistinct: 1000000, // distinct values one countifunique may hold
|
|
33
|
+
maxSteps: 10000000, // list items visited in one evaluation
|
|
34
|
+
maxLevels: 2 // totals inside totals
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function konst(v) {
|
|
38
|
+
var f = function () { return v; };
|
|
39
|
+
f.isConst = true;
|
|
40
|
+
f.value = v;
|
|
41
|
+
return f;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fail(message) { throw new Error(message); }
|
|
45
|
+
|
|
46
|
+
function compileTree(tree, options) {
|
|
47
|
+
var opts = Object.assign({}, DEFAULTS, options || {});
|
|
48
|
+
if (opts.missing !== 'null' && opts.missing !== 'error') fail('missing must be "null" or "error"');
|
|
49
|
+
var fy = Number(opts.fiscalYearStart);
|
|
50
|
+
if (!(fy >= 1 && fy <= 12 && Math.floor(fy) === fy)) fail('fiscalYearStart must be a month, 1–12');
|
|
51
|
+
|
|
52
|
+
var ctx = { now: dates.todayFor(opts), fiscalYearStart: fy };
|
|
53
|
+
// Shared by every closure of this formula: how many list items one
|
|
54
|
+
// evaluation has visited. Reset per top-level call.
|
|
55
|
+
var state = { steps: 0 };
|
|
56
|
+
|
|
57
|
+
function c(node, level) {
|
|
58
|
+
if (node === null || node === undefined || typeof node !== 'object' || Array.isArray(node)) {
|
|
59
|
+
fail('Not a formula node: ' + JSON.stringify(node));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 1.x operand with a function: { field, fn } / { value, fn }
|
|
63
|
+
if (node.fn !== undefined && node.call === undefined) {
|
|
64
|
+
var bare = Object.assign({}, node);
|
|
65
|
+
delete bare.fn;
|
|
66
|
+
return callOf(node.fn, [c(bare, level)]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Object.prototype.hasOwnProperty.call(node, 'value')) return konst(node.value);
|
|
70
|
+
if (node.field !== undefined) return getter(String(node.field).split('.'), false);
|
|
71
|
+
if (node.path !== undefined) return getter(node.path, false);
|
|
72
|
+
if (node.thisrow !== undefined) return getter(node.thisrow, true);
|
|
73
|
+
if (node.list !== undefined) return listOf(node.list.map(function (n) { return c(n, level); }));
|
|
74
|
+
if (node.if !== undefined) return ifOf(c(node.if, level), c(node.then, level), c(node.else, level));
|
|
75
|
+
if (node.all !== undefined) return allOf(node.all.map(function (n) { return c(n, level); }));
|
|
76
|
+
if (node.any !== undefined) return anyOf(node.any.map(function (n) { return c(n, level); }));
|
|
77
|
+
if (node.not !== undefined) return unary(c(node.not, level), function (a) { return !a; });
|
|
78
|
+
if (node.neg !== undefined) return unary(c(node.neg, level), function (a) { return -a; });
|
|
79
|
+
if (node.pos !== undefined) return unary(c(node.pos, level), Number);
|
|
80
|
+
if (node.call !== undefined) {
|
|
81
|
+
var entry = functions.lookup(node.call);
|
|
82
|
+
if (entry && entry.aggregate) return aggregateOf(entry, node.args || [], level);
|
|
83
|
+
return callOf(node.call, (node.args || []).map(function (n) { return c(n, level); }));
|
|
84
|
+
}
|
|
85
|
+
if (node.op !== undefined) return opOf(node, level);
|
|
86
|
+
fail('Not a formula node: ' + JSON.stringify(node));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── names ──────────────────────────────────────────────────────────────
|
|
90
|
+
function getter(segs, fromTop) {
|
|
91
|
+
if (!Array.isArray(segs) || !segs.length) fail('A path needs at least one name');
|
|
92
|
+
segs.forEach(function (s) {
|
|
93
|
+
if (typeof s !== 'string' && typeof s !== 'number') fail('A path is made of names');
|
|
94
|
+
if (BLOCKED.has(String(s))) fail('"' + s + '" can\'t be used as a column name');
|
|
95
|
+
});
|
|
96
|
+
var strict = opts.missing === 'error';
|
|
97
|
+
var label = segs.join('.');
|
|
98
|
+
|
|
99
|
+
// A row's own value only: a function found on it (a name like `toString`
|
|
100
|
+
// reaching the prototype) reads as nothing. Values are never called.
|
|
101
|
+
function read(obj, key) {
|
|
102
|
+
if (obj === null || obj === undefined || typeof obj !== 'object') return null;
|
|
103
|
+
var v = obj[key];
|
|
104
|
+
if (v === undefined) {
|
|
105
|
+
if (strict && !(key in obj)) fail('Unknown column "' + label + '"');
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return typeof v === 'function' ? null : v;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (segs.length === 1) {
|
|
112
|
+
var k = segs[0];
|
|
113
|
+
return fromTop
|
|
114
|
+
? function (row, top) { return read(top, k); }
|
|
115
|
+
: function (row) { return read(row, k); };
|
|
116
|
+
}
|
|
117
|
+
return function (row, top) {
|
|
118
|
+
var v = fromTop ? top : row;
|
|
119
|
+
for (var i = 0; i < segs.length; i++) {
|
|
120
|
+
v = read(v, segs[i]);
|
|
121
|
+
if (v === null) return null;
|
|
122
|
+
}
|
|
123
|
+
return v;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── functions ──────────────────────────────────────────────────────────
|
|
128
|
+
function callOf(name, args) {
|
|
129
|
+
var entry = functions.lookup(name);
|
|
130
|
+
if (!entry || entry.special) fail('Unknown function "' + name + '"');
|
|
131
|
+
if (entry.aggregate) fail(entry.name + '() must be written as a call, not a { fn } operand');
|
|
132
|
+
if (args.length < entry.min || args.length > entry.max) fail(entry.name + '() takes ' + entry.min + (entry.max === entry.min ? '' : '–' + entry.max) + ' arguments, not ' + args.length);
|
|
133
|
+
var fn = entry.fn;
|
|
134
|
+
if (entry.context) {
|
|
135
|
+
var inner = fn;
|
|
136
|
+
fn = function () { var a = [ctx]; for (var i = 0; i < arguments.length; i++) a.push(arguments[i]); return inner.apply(null, a); };
|
|
137
|
+
}
|
|
138
|
+
if (!entry.volatile && args.every(function (a) { return a.isConst; })) {
|
|
139
|
+
return konst(fn.apply(null, args.map(function (a) { return a.value; })));
|
|
140
|
+
}
|
|
141
|
+
// Fixed shapes for the common arities, so a call allocates no array.
|
|
142
|
+
var a0 = args[0], a1 = args[1], a2 = args[2];
|
|
143
|
+
switch (args.length) {
|
|
144
|
+
case 0: return function () { return fn(); };
|
|
145
|
+
case 1: return function (r, t) { return fn(a0(r, t)); };
|
|
146
|
+
case 2: return function (r, t) { return fn(a0(r, t), a1(r, t)); };
|
|
147
|
+
case 3: return function (r, t) { return fn(a0(r, t), a1(r, t), a2(r, t)); };
|
|
148
|
+
default: return function (r, t) {
|
|
149
|
+
var vals = new Array(args.length);
|
|
150
|
+
for (var i = 0; i < args.length; i++) vals[i] = args[i](r, t);
|
|
151
|
+
return fn.apply(null, vals);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── operators ──────────────────────────────────────────────────────────
|
|
157
|
+
var SYMBOLS = {
|
|
158
|
+
'+': function (a, b) { return Number(a) + Number(b); },
|
|
159
|
+
'-': function (a, b) { return a - b; },
|
|
160
|
+
'*': function (a, b) { return a * b; },
|
|
161
|
+
'/': function (a, b) { return a / b; },
|
|
162
|
+
'%': function (a, b) { return a % b; },
|
|
163
|
+
'^': function (a, b) { return Math.pow(a, b); },
|
|
164
|
+
'||': function (a, b) { return Array.isArray(a) && Array.isArray(b) ? a.concat(b) : '' + a + b; },
|
|
165
|
+
// Ordering as JavaScript orders (numbers as numbers, ISO dates as text),
|
|
166
|
+
// except that nothing is never less or greater than anything — as in SQL.
|
|
167
|
+
// expr-eval read `null < 100` as true, which sends a branch the wrong way.
|
|
168
|
+
'<': function (a, b) { return a !== null && b !== null && a < b; },
|
|
169
|
+
'<=': function (a, b) { return a !== null && b !== null && a <= b; },
|
|
170
|
+
'>': function (a, b) { return a !== null && b !== null && a > b; },
|
|
171
|
+
'>=': function (a, b) { return a !== null && b !== null && a >= b; },
|
|
172
|
+
// Membership compares like the 1.x `in` (18 matches "18").
|
|
173
|
+
'in': function (a, b) {
|
|
174
|
+
if (!Array.isArray(b)) return false;
|
|
175
|
+
for (var i = 0; i < b.length; i++) if (operators.looseEq(a, b[i])) return true;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
function opOf(node, level) {
|
|
181
|
+
var sym = SYMBOLS[node.op];
|
|
182
|
+
if (sym && Object.prototype.hasOwnProperty.call(SYMBOLS, node.op)) {
|
|
183
|
+
if (node.left === undefined || node.right === undefined) fail('"' + node.op + '" needs a left and a right side');
|
|
184
|
+
return binary(c(node.left, level), c(node.right, level), sym);
|
|
185
|
+
}
|
|
186
|
+
// 1.x word operators: { left, op: 'eq' | 'contains' | 'isNull' | …, right }
|
|
187
|
+
var def = Object.prototype.hasOwnProperty.call(operators.OPERATORS, node.op) ? operators.OPERATORS[node.op] : null;
|
|
188
|
+
if (!def) fail('Unknown operator: "' + node.op + '"');
|
|
189
|
+
if (node.left === undefined) fail('missing left operand');
|
|
190
|
+
var left = c(node.left, level);
|
|
191
|
+
if (def.arity === 1) return unary(left, function (a) { return !!def.fn(a); });
|
|
192
|
+
// As 1.x: a missing right side compares against nothing (validate() flags it).
|
|
193
|
+
var right = node.right === undefined || node.right === null ? konst(undefined) : c(node.right, level);
|
|
194
|
+
return binary(left, right, function (a, b) { return !!def.fn(a, b); });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function unary(a, f) {
|
|
198
|
+
if (a.isConst) return konst(f(a.value));
|
|
199
|
+
return function (r, t) { return f(a(r, t)); };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function binary(a, b, f) {
|
|
203
|
+
if (a.isConst && b.isConst) return konst(f(a.value, b.value));
|
|
204
|
+
return function (r, t) { return f(a(r, t), b(r, t)); };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function listOf(items) {
|
|
208
|
+
if (items.every(function (i) { return i.isConst; })) return konst(items.map(function (i) { return i.value; }));
|
|
209
|
+
return function (r, t) { return items.map(function (i) { return i(r, t); }); };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ifOf(cond, yes, no) {
|
|
213
|
+
if (cond.isConst) return cond.value ? yes : no;
|
|
214
|
+
return function (r, t) { return cond(r, t) ? yes(r, t) : no(r, t); };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function allOf(parts) {
|
|
218
|
+
return function (r, t) {
|
|
219
|
+
for (var i = 0; i < parts.length; i++) if (!parts[i](r, t)) return false;
|
|
220
|
+
return true;
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function anyOf(parts) {
|
|
225
|
+
return function (r, t) {
|
|
226
|
+
for (var i = 0; i < parts.length; i++) if (parts[i](r, t)) return true;
|
|
227
|
+
return false;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── totals over rows ─────────────────────────────────────────────────────
|
|
232
|
+
// Two ways to be written:
|
|
233
|
+
// sumif(status = "paid", amount) the rows of a group — the caller
|
|
234
|
+
// supplies them (options.aggregate)
|
|
235
|
+
// sumif(orders, status = "paid", amount) a list the formula names
|
|
236
|
+
// A list total is one loop over the list: no filtered copy, no list of
|
|
237
|
+
// values — a running number, a count, or a set of distinct values.
|
|
238
|
+
function aggregateOf(entry, args, level) {
|
|
239
|
+
var implicit = (entry.shape.cond ? 1 : 0) + (entry.shape.value ? 1 : 0);
|
|
240
|
+
var inner = level + 1;
|
|
241
|
+
if (inner > opts.maxLevels) fail('A total can hold at most ' + (opts.maxLevels - 1) + ' more total inside it — ' + entry.name + '() is ' + inner + ' levels deep');
|
|
242
|
+
|
|
243
|
+
if (args.length === implicit) {
|
|
244
|
+
if (typeof opts.aggregate !== 'function') {
|
|
245
|
+
fail(entry.name + '() adds up the rows of a group. Outside a report, name the list first — ' +
|
|
246
|
+
entry.name + '(orders, ' + (entry.shape.cond ? 'condition' + (entry.shape.value ? ', value' : '') : 'value') + ')');
|
|
247
|
+
}
|
|
248
|
+
return opts.aggregate(entry.name, args, { level: inner, compile: function (n) { return c(n, inner); } });
|
|
249
|
+
}
|
|
250
|
+
if (args.length !== implicit + 1) fail(entry.name + '() takes ' + implicit + ' or ' + (implicit + 1) + ' arguments, not ' + args.length);
|
|
251
|
+
|
|
252
|
+
var list = c(args[0], level);
|
|
253
|
+
var cond = entry.shape.cond ? c(args[1], inner) : null;
|
|
254
|
+
var value = entry.shape.value ? c(args[args.length - 1], inner) : null;
|
|
255
|
+
var label = describeList(args[0]);
|
|
256
|
+
var maxItems = opts.maxListItems, maxSteps = opts.maxSteps, maxDistinct = opts.maxDistinct;
|
|
257
|
+
|
|
258
|
+
function items(r, t) {
|
|
259
|
+
var arr = list(r, t);
|
|
260
|
+
if (arr === null || arr === undefined) return null;
|
|
261
|
+
if (!Array.isArray(arr)) fail(entry.name + '(): ' + label + ' is not a list');
|
|
262
|
+
if (arr.length > maxItems) fail(label + ' has ' + arr.length + ' items; a total can run over at most ' + maxItems);
|
|
263
|
+
state.steps += arr.length;
|
|
264
|
+
if (state.steps > maxSteps) fail('This formula visited more than ' + maxSteps + ' list items in one evaluation');
|
|
265
|
+
return arr;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (entry.name === 'countif') {
|
|
269
|
+
return function (r, t) {
|
|
270
|
+
var arr = items(r, t);
|
|
271
|
+
if (!arr) return 0;
|
|
272
|
+
var n = 0;
|
|
273
|
+
for (var i = 0; i < arr.length; i++) if (cond(arr[i], t)) n++;
|
|
274
|
+
return n;
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (entry.name === 'countifunique') {
|
|
278
|
+
return function (r, t) {
|
|
279
|
+
var arr = items(r, t);
|
|
280
|
+
if (!arr) return 0;
|
|
281
|
+
var seen = new Set();
|
|
282
|
+
for (var i = 0; i < arr.length; i++) {
|
|
283
|
+
if (!cond(arr[i], t)) continue;
|
|
284
|
+
var v = value(arr[i], t);
|
|
285
|
+
if (v === null || v === undefined) continue;
|
|
286
|
+
seen.add(v);
|
|
287
|
+
if (seen.size > maxDistinct) fail(entry.name + '(): more than ' + maxDistinct + ' different values');
|
|
288
|
+
}
|
|
289
|
+
return seen.size;
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// sum / sumif — values that aren't numbers add nothing.
|
|
293
|
+
return function (r, t) {
|
|
294
|
+
var arr = items(r, t);
|
|
295
|
+
if (!arr) return 0;
|
|
296
|
+
var total = 0;
|
|
297
|
+
for (var i = 0; i < arr.length; i++) {
|
|
298
|
+
if (cond && !cond(arr[i], t)) continue;
|
|
299
|
+
var v = value(arr[i], t);
|
|
300
|
+
if (v === null || v === undefined || v === '') continue;
|
|
301
|
+
var n = Number(v);
|
|
302
|
+
if (!isNaN(n)) total += n;
|
|
303
|
+
}
|
|
304
|
+
return total;
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
var run = c(tree, 0);
|
|
309
|
+
var formula = function (row) {
|
|
310
|
+
state.steps = 0;
|
|
311
|
+
return run(row, row);
|
|
312
|
+
};
|
|
313
|
+
formula.isConst = !!run.isConst;
|
|
314
|
+
return formula;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function describeList(node) {
|
|
318
|
+
if (node.field !== undefined) return String(node.field);
|
|
319
|
+
if (node.path) return node.path.join('.');
|
|
320
|
+
return 'the list';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = { compileTree: compileTree, DEFAULTS: DEFAULTS };
|
package/lib/dates.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Date coercion for the date functions — one place, rather than each function
|
|
2
|
+
// guessing. Moved from BI's report-engine (formulaFunctions.js) unchanged in
|
|
3
|
+
// behaviour: BI's saved reports depend on it.
|
|
4
|
+
//
|
|
5
|
+
// Dates arrive as strings, Date objects, or timestamps depending on the
|
|
6
|
+
// driver. A date-only string "2026-05-01" is built from its parts as LOCAL
|
|
7
|
+
// midnight — the spec parses it as UTC midnight, and every reader here
|
|
8
|
+
// (getFullYear, getMonth, …) works in local time, so west of UTC the 1st of a
|
|
9
|
+
// month would read as the last day of the one before. Built from parts, which
|
|
10
|
+
// is also what a database driver does for a DATE column, the browser and the
|
|
11
|
+
// server agree whatever the time zone.
|
|
12
|
+
|
|
13
|
+
var DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
14
|
+
|
|
15
|
+
// A streamed report repeats the same date strings row after row; parsing each
|
|
16
|
+
// once is most of the cost of a date function. Bounded so a stream of unique
|
|
17
|
+
// timestamps can't grow it without limit. Callers only READ the Dates.
|
|
18
|
+
var cache = new Map();
|
|
19
|
+
var CACHE_MAX = 512;
|
|
20
|
+
|
|
21
|
+
function parseDate(v) {
|
|
22
|
+
var parts = DATE_ONLY.exec(v.trim());
|
|
23
|
+
if (parts) return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]));
|
|
24
|
+
var d = new Date(v);
|
|
25
|
+
return isNaN(d.getTime()) ? null : d;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function asDate(v) {
|
|
29
|
+
if (v === null || v === undefined || v === '') return null;
|
|
30
|
+
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
|
31
|
+
if (typeof v === 'string') {
|
|
32
|
+
var hit = cache.get(v);
|
|
33
|
+
if (hit !== undefined) return hit;
|
|
34
|
+
var d = parseDate(v);
|
|
35
|
+
if (cache.size >= CACHE_MAX) cache.clear();
|
|
36
|
+
cache.set(v, d);
|
|
37
|
+
return d;
|
|
38
|
+
}
|
|
39
|
+
var t = new Date(v);
|
|
40
|
+
return isNaN(t.getTime()) ? null : t;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// YYYY-MM-DD in LOCAL time. Not toISOString(), which converts to UTC first —
|
|
44
|
+
// with dates parsed as local midnight that shifts the answer a day.
|
|
45
|
+
function isoLocal(d) {
|
|
46
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
50
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
51
|
+
var WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
52
|
+
var MS_PER_DAY = 86400000;
|
|
53
|
+
|
|
54
|
+
// "Today" for ytd / fytd, per run context. A pinned `today` is fixed; the live
|
|
55
|
+
// one is recomputed at most once a minute rather than per row — these run
|
|
56
|
+
// inside accumulation loops over millions of rows.
|
|
57
|
+
// Returns now() → { year, end, fyStart }.
|
|
58
|
+
function todayFor(context) {
|
|
59
|
+
var fyMonth = (context.fiscalYearStart || 4) - 1;
|
|
60
|
+
if (context.today !== undefined && context.today !== null) {
|
|
61
|
+
var d = asDate(context.today);
|
|
62
|
+
if (!d) throw new Error('context.today is not a date: ' + JSON.stringify(context.today));
|
|
63
|
+
// A pinned day covers all of it, so its own timestamps count as "so far".
|
|
64
|
+
var pinned = build(new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999), fyMonth);
|
|
65
|
+
return function () { return pinned; };
|
|
66
|
+
}
|
|
67
|
+
var live = null;
|
|
68
|
+
var at = 0;
|
|
69
|
+
return function () {
|
|
70
|
+
var t = Date.now();
|
|
71
|
+
if (live && t - at < 60000) return live;
|
|
72
|
+
at = t;
|
|
73
|
+
live = build(new Date(t), fyMonth);
|
|
74
|
+
return live;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function build(end, fyMonth) {
|
|
79
|
+
var year = end.getFullYear();
|
|
80
|
+
// The financial year containing today began this calendar year only if we
|
|
81
|
+
// are already past its start month.
|
|
82
|
+
var fyStart = new Date(end.getMonth() >= fyMonth ? year : year - 1, fyMonth, 1);
|
|
83
|
+
return { year: year, end: end, fyStart: fyStart };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { asDate: asDate, isoLocal: isoLocal, todayFor: todayFor, MONTH_NAMES: MONTH_NAMES, WEEKDAYS: WEEKDAYS, MS_PER_DAY: MS_PER_DAY };
|
package/lib/evaluate.js
CHANGED
|
@@ -1,102 +1,76 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// { left: <operand>, op: <string>, right?: <operand> }
|
|
5
|
-
//
|
|
6
|
-
// Operand shape (one of):
|
|
7
|
-
// { field: 'name' } → row.name
|
|
8
|
-
// { field: 'a.b' } → row.a.b (dot path)
|
|
9
|
-
// { field: 'name', fn: 'upper' } → upper(row.name)
|
|
10
|
-
// { value: 42 } → literal (string | number | boolean | null | array)
|
|
11
|
-
// { value: [1, 10] } → literal array (for between / in)
|
|
12
|
-
//
|
|
13
|
-
// Unary operators (isNull / isNotNull) ignore `right`.
|
|
14
|
-
//
|
|
15
|
-
// evaluate({ left:{fn:'month',field:'createdAt'}, op:'eq', right:{value:'January'} }, row)
|
|
1
|
+
// The 1.x entry points, now running on the 2.0 compiler. Workflow calls
|
|
2
|
+
// evaluate(condition, row) on every arrow, with conditions stored in the 1.x
|
|
3
|
+
// { left, op, right } shape — those keep working, and so does any 2.0 tree.
|
|
16
4
|
|
|
17
|
-
var
|
|
18
|
-
var { FUNCTIONS, applyFunction } = require('./functions');
|
|
5
|
+
var compileTree = require('./compile').compileTree;
|
|
19
6
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return path.split('.').reduce(function (o, k) {
|
|
24
|
-
return (o === null || o === undefined) ? undefined : o[k];
|
|
25
|
-
}, obj);
|
|
26
|
-
}
|
|
7
|
+
// A tree is compiled once and remembered against the object itself, so a
|
|
8
|
+
// stored condition evaluated on every run isn't rebuilt each time.
|
|
9
|
+
var compiled = new WeakMap();
|
|
27
10
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
v = operand.value;
|
|
34
|
-
} else if (operand.field !== null && operand.field !== undefined) {
|
|
35
|
-
v = getPath(row, operand.field);
|
|
36
|
-
} else {
|
|
37
|
-
throw new Error('Invalid operand: needs "field" or "value"');
|
|
38
|
-
}
|
|
39
|
-
if (operand.fn) v = applyFunction(operand.fn, v);
|
|
40
|
-
return v;
|
|
11
|
+
function compiledFor(expr) {
|
|
12
|
+
if (!expr || typeof expr !== 'object') throw new Error('expression must be an object');
|
|
13
|
+
var f = compiled.get(expr);
|
|
14
|
+
if (!f) { f = compileTree(expr); compiled.set(expr, f); }
|
|
15
|
+
return f;
|
|
41
16
|
}
|
|
42
17
|
|
|
18
|
+
/** A condition → true / false. */
|
|
43
19
|
function evaluate(expr, row) {
|
|
44
|
-
|
|
45
|
-
var opDef = OPERATORS[expr.op];
|
|
46
|
-
if (!opDef) throw new Error('Unknown operator: "' + expr.op + '"');
|
|
47
|
-
|
|
48
|
-
var left = resolveOperand(expr.left, row);
|
|
49
|
-
if (opDef.arity === 1) return !!opDef.fn(left);
|
|
50
|
-
|
|
51
|
-
var right = resolveOperand(
|
|
52
|
-
(expr.right === null || expr.right === undefined) ? { value: undefined } : expr.right,
|
|
53
|
-
row
|
|
54
|
-
);
|
|
55
|
-
return !!opDef.fn(left, right);
|
|
20
|
+
return !!compiledFor(expr)(row);
|
|
56
21
|
}
|
|
57
22
|
|
|
58
|
-
|
|
59
|
-
// Drops straight into Array.filter / a streaming .filter(...).
|
|
23
|
+
/** Compile once, filter many — drops into Array.filter or a stream. */
|
|
60
24
|
function toPredicate(expr) {
|
|
61
25
|
var v = validate(expr);
|
|
62
26
|
if (!v.valid) throw new Error('Invalid expression: ' + v.errors.join('; '));
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function validateOperand(operand, side, errors) {
|
|
67
|
-
if (operand === null || operand === undefined || typeof operand !== 'object') {
|
|
68
|
-
errors.push(side + ' operand must be an object with "field" or "value"');
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
var hasValue = Object.prototype.hasOwnProperty.call(operand, 'value');
|
|
72
|
-
if (!hasValue && (operand.field === null || operand.field === undefined)) {
|
|
73
|
-
errors.push(side + ' operand needs "field" or "value"');
|
|
74
|
-
}
|
|
75
|
-
if (operand.fn && !FUNCTIONS[operand.fn]) {
|
|
76
|
-
errors.push('unknown function: "' + operand.fn + '"');
|
|
77
|
-
}
|
|
27
|
+
var f = compiledFor(expr);
|
|
28
|
+
return function (row) { return !!f(row); };
|
|
78
29
|
}
|
|
79
30
|
|
|
80
|
-
|
|
31
|
+
/** Static check without a row → { valid, errors }. */
|
|
81
32
|
function validate(expr) {
|
|
82
33
|
var errors = [];
|
|
83
34
|
if (!expr || typeof expr !== 'object') return { valid: false, errors: ['expression must be an object'] };
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
35
|
+
// The 1.x shape checks, kept for their messages.
|
|
36
|
+
if (expr.op !== undefined && expr.left !== undefined) {
|
|
37
|
+
['left', 'right'].forEach(function (side) {
|
|
38
|
+
var o = expr[side];
|
|
39
|
+
if (o && typeof o === 'object' && !Object.prototype.hasOwnProperty.call(o, 'value') &&
|
|
40
|
+
o.field === undefined && o.path === undefined && o.thisrow === undefined && o.call === undefined &&
|
|
41
|
+
o.op === undefined && o.if === undefined && o.all === undefined && o.any === undefined &&
|
|
42
|
+
o.not === undefined && o.neg === undefined && o.pos === undefined && o.list === undefined) {
|
|
43
|
+
errors.push(side + ' operand needs "field" or "value"');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
var ops = require('./operators').OPERATORS;
|
|
47
|
+
var def = Object.prototype.hasOwnProperty.call(ops, expr.op) ? ops[expr.op] : null;
|
|
48
|
+
if (def && def.arity === 2 && (expr.right === undefined || expr.right === null)) {
|
|
93
49
|
errors.push('operator "' + expr.op + '" requires a right operand');
|
|
94
|
-
} else {
|
|
95
|
-
validateOperand(expr.right, 'right', errors);
|
|
96
50
|
}
|
|
97
51
|
}
|
|
98
|
-
|
|
52
|
+
if (!errors.length) {
|
|
53
|
+
try { compileTree(expr); } catch (err) { errors.push(err.message.charAt(0).toLowerCase() + err.message.slice(1)); }
|
|
54
|
+
}
|
|
99
55
|
return { valid: errors.length === 0, errors: errors };
|
|
100
56
|
}
|
|
101
57
|
|
|
102
|
-
|
|
58
|
+
/** 1.x: one operand's value against a row. */
|
|
59
|
+
function resolveOperand(operand, row) {
|
|
60
|
+
if (operand === null || operand === undefined) return undefined;
|
|
61
|
+
if (typeof operand !== 'object' || (!Object.prototype.hasOwnProperty.call(operand, 'value') && operand.field == null && operand.path == null)) {
|
|
62
|
+
throw new Error('Invalid operand: needs "field" or "value"');
|
|
63
|
+
}
|
|
64
|
+
return compiledFor(operand)(row);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 1.x: read a dotted path. */
|
|
68
|
+
function getPath(obj, path) {
|
|
69
|
+
if (obj === null || obj === undefined) return undefined;
|
|
70
|
+
if (path.indexOf('.') === -1) return obj[path];
|
|
71
|
+
return path.split('.').reduce(function (o, k) {
|
|
72
|
+
return (o === null || o === undefined) ? undefined : o[k];
|
|
73
|
+
}, obj);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { evaluate: evaluate, toPredicate: toPredicate, validate: validate, resolveOperand: resolveOperand, getPath: getPath };
|