@xeplr/expression-handler 1.0.6 → 2.1.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 +303 -67
- package/index.js +151 -36
- package/lib/analyze.js +71 -0
- package/lib/compile.js +329 -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/operators.js +23 -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/parse.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Formula text → JSON tree. Our own reader: no eval, no dependencies.
|
|
2
|
+
//
|
|
3
|
+
// The grammar is BI's formula language (expr-eval's), with the agreed changes:
|
|
4
|
+
// `=` is equals, `[any name]` is a column, lists are `in (a, b)`. Precedence,
|
|
5
|
+
// lowest first — identical to expr-eval, so old formulas mean what they meant:
|
|
6
|
+
//
|
|
7
|
+
// ? : → or → and → = == != <> < <= > >= in → + - ||
|
|
8
|
+
// → * / % → unary - + not → ^ (right) → calls, paths
|
|
9
|
+
//
|
|
10
|
+
// The tree (all plain JSON — it is what gets stored):
|
|
11
|
+
// { value: 42 } literal
|
|
12
|
+
// { field: 'revenue' } a column
|
|
13
|
+
// { path: ['form', 'first name'] } a path, or a name only brackets can write
|
|
14
|
+
// { thisrow: ['city'] } $thisrow.city
|
|
15
|
+
// { call: 'upper', args: [ … ] } a function
|
|
16
|
+
// { op: '+', left, right } + - * / % ^ || < <= > >= in
|
|
17
|
+
// { op: 'eq', left, right } = == (and 'neq' for != <>) — the 1.x shape
|
|
18
|
+
// { neg: x } { pos: x } { not: x } unary
|
|
19
|
+
// { all: [ … ] } { any: [ … ] } and / or
|
|
20
|
+
// { if, then, else } if() and ? :
|
|
21
|
+
// { list: [ … ] } the right side of `in (…)`
|
|
22
|
+
|
|
23
|
+
var functions = require('./functions');
|
|
24
|
+
|
|
25
|
+
var KEYWORDS = new Set(['and', 'or', 'not', 'in', 'true', 'false', 'null']);
|
|
26
|
+
// A Set, not an object literal: `{ __proto__: 1 }` would set the prototype.
|
|
27
|
+
var BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
|
|
28
|
+
var ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '\\': '\\', '/': '/', '"': '"', "'": "'" };
|
|
29
|
+
|
|
30
|
+
function fail(message, at) {
|
|
31
|
+
var err = new Error(message + (at === undefined ? '' : ' at character ' + (at + 1)));
|
|
32
|
+
err.position = at;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── tokens ────────────────────────────────────────────────────────────────
|
|
37
|
+
// { t: 'num'|'str'|'name'|'bracket'|'punct'|'end', v, at }
|
|
38
|
+
function tokenize(src) {
|
|
39
|
+
var out = [];
|
|
40
|
+
var i = 0, n = src.length;
|
|
41
|
+
while (i < n) {
|
|
42
|
+
var c = src[i];
|
|
43
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
|
|
44
|
+
var at = i;
|
|
45
|
+
|
|
46
|
+
if (c === '"' || c === "'") {
|
|
47
|
+
// A backslash escapes (as expr-eval); a doubled quote escapes (as 1.x).
|
|
48
|
+
var q = c, s = '';
|
|
49
|
+
i++;
|
|
50
|
+
for (;;) {
|
|
51
|
+
if (i >= n) fail('Text is never closed — missing ' + q, at);
|
|
52
|
+
var ch = src[i];
|
|
53
|
+
if (ch === '\\') {
|
|
54
|
+
var e = src[i + 1];
|
|
55
|
+
if (e === 'u' && /^[0-9a-fA-F]{4}$/.test(src.substr(i + 2, 4))) { s += String.fromCharCode(parseInt(src.substr(i + 2, 4), 16)); i += 6; continue; }
|
|
56
|
+
if (ESCAPES[e] === undefined) fail('Unknown escape \\' + (e || ''), i);
|
|
57
|
+
s += ESCAPES[e]; i += 2; continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === q) {
|
|
60
|
+
if (src[i + 1] === q) { s += q; i += 2; continue; }
|
|
61
|
+
i++; break;
|
|
62
|
+
}
|
|
63
|
+
s += ch; i++;
|
|
64
|
+
}
|
|
65
|
+
out.push({ t: 'str', v: s, at: at });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (c === '[') {
|
|
70
|
+
// Everything up to the closing ] is the name; ]] is a literal ].
|
|
71
|
+
var name = '';
|
|
72
|
+
i++;
|
|
73
|
+
for (;;) {
|
|
74
|
+
if (i >= n) fail('Column name is never closed — missing ]', at);
|
|
75
|
+
if (src[i] === ']') {
|
|
76
|
+
if (src[i + 1] === ']') { name += ']'; i += 2; continue; }
|
|
77
|
+
i++; break;
|
|
78
|
+
}
|
|
79
|
+
name += src[i++];
|
|
80
|
+
}
|
|
81
|
+
out.push({ t: 'bracket', v: name, at: at });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if ((c >= '0' && c <= '9') || (c === '.' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
|
|
86
|
+
var m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(src.slice(i));
|
|
87
|
+
out.push({ t: 'num', v: parseFloat(m[0]), at: at });
|
|
88
|
+
i += m[0].length;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Any letter, as expr-eval allows — `café`, `größe`.
|
|
93
|
+
if (/[\p{L}_$]/u.test(c)) {
|
|
94
|
+
var id = /^[\p{L}_$][\p{L}\p{N}_$]*/u.exec(src.slice(i))[0];
|
|
95
|
+
out.push({ t: 'name', v: id, at: at });
|
|
96
|
+
i += id.length;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
var two = src.substr(i, 2);
|
|
101
|
+
if (two === '==' || two === '!=' || two === '<>' || two === '<=' || two === '>=' || two === '||') {
|
|
102
|
+
out.push({ t: 'punct', v: two, at: at }); i += 2; continue;
|
|
103
|
+
}
|
|
104
|
+
if ('()+-*/%^,.?:=<>'.indexOf(c) !== -1) { out.push({ t: 'punct', v: c, at: at }); i++; continue; }
|
|
105
|
+
if (c === '!') fail('Use "not" rather than "!"', at);
|
|
106
|
+
fail('Unexpected character "' + c + '"', at);
|
|
107
|
+
}
|
|
108
|
+
out.push({ t: 'end', at: n });
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── grammar ───────────────────────────────────────────────────────────────
|
|
113
|
+
function parse(src) {
|
|
114
|
+
if (typeof src !== 'string') throw new Error('parse: formula must be text');
|
|
115
|
+
if (!src.trim()) fail('The formula is empty');
|
|
116
|
+
var toks = tokenize(src);
|
|
117
|
+
var pos = 0;
|
|
118
|
+
|
|
119
|
+
function peek() { return toks[pos]; }
|
|
120
|
+
function next() { return toks[pos++]; }
|
|
121
|
+
function isPunct(v) { var t = toks[pos]; return t.t === 'punct' && t.v === v; }
|
|
122
|
+
function isWord(w) { var t = toks[pos]; return t.t === 'name' && t.v.toLowerCase() === w; }
|
|
123
|
+
function describe(t) {
|
|
124
|
+
if (t.t === 'end') return 'the end of the formula';
|
|
125
|
+
if (t.t === 'str') return 'text "' + t.v + '"';
|
|
126
|
+
if (t.t === 'bracket') return '[' + t.v + ']';
|
|
127
|
+
return '"' + t.v + '"';
|
|
128
|
+
}
|
|
129
|
+
function expect(v) {
|
|
130
|
+
if (!isPunct(v)) fail('Expected "' + v + '" but found ' + describe(peek()), peek().at);
|
|
131
|
+
return next();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function conditional() {
|
|
135
|
+
var cond = or();
|
|
136
|
+
if (!isPunct('?')) return cond;
|
|
137
|
+
next();
|
|
138
|
+
var yes = conditional();
|
|
139
|
+
expect(':');
|
|
140
|
+
return { if: cond, then: yes, else: conditional() };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function or() {
|
|
144
|
+
var first = and();
|
|
145
|
+
if (!isWord('or')) return first;
|
|
146
|
+
var parts = [first];
|
|
147
|
+
while (isWord('or')) { next(); parts.push(and()); }
|
|
148
|
+
return { any: parts };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function and() {
|
|
152
|
+
var first = comparison();
|
|
153
|
+
if (!isWord('and')) return first;
|
|
154
|
+
var parts = [first];
|
|
155
|
+
while (isWord('and')) { next(); parts.push(comparison()); }
|
|
156
|
+
return { all: parts };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Equality is the 1.x `eq` / `neq` whichever way it's spelled — forgiving
|
|
160
|
+
// (18 = "18"), and the shape Workflow's stored conditions already use.
|
|
161
|
+
var COMPARE = { '=': 'eq', '==': 'eq', '!=': 'neq', '<>': 'neq', '<': '<', '<=': '<=', '>': '>', '>=': '>=' };
|
|
162
|
+
|
|
163
|
+
function comparison() {
|
|
164
|
+
var left = addSub();
|
|
165
|
+
for (;;) {
|
|
166
|
+
var t = peek();
|
|
167
|
+
if (t.t === 'punct' && COMPARE[t.v]) { next(); left = { op: COMPARE[t.v], left: left, right: addSub() }; continue; }
|
|
168
|
+
if (isWord('in')) { next(); left = { op: 'in', left: left, right: inList() }; continue; }
|
|
169
|
+
return left;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// `in (a, b, c)` is a list; anything else is an expression holding one.
|
|
174
|
+
function inList() {
|
|
175
|
+
var t = peek();
|
|
176
|
+
if (t.t === 'bracket') fail('Lists are written in parentheses — in (' + t.v + ')', t.at);
|
|
177
|
+
if (!isPunct('(')) return addSub();
|
|
178
|
+
next();
|
|
179
|
+
var items = [];
|
|
180
|
+
if (!isPunct(')')) {
|
|
181
|
+
items.push(conditional());
|
|
182
|
+
while (isPunct(',')) { next(); items.push(conditional()); }
|
|
183
|
+
}
|
|
184
|
+
expect(')');
|
|
185
|
+
return { list: items };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function addSub() {
|
|
189
|
+
var left = term();
|
|
190
|
+
for (;;) {
|
|
191
|
+
var t = peek();
|
|
192
|
+
if (t.t === 'punct' && (t.v === '+' || t.v === '-' || t.v === '||')) { next(); left = { op: t.v, left: left, right: term() }; continue; }
|
|
193
|
+
return left;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function term() {
|
|
198
|
+
var left = factor();
|
|
199
|
+
for (;;) {
|
|
200
|
+
var t = peek();
|
|
201
|
+
if (t.t === 'punct' && (t.v === '*' || t.v === '/' || t.v === '%')) { next(); left = { op: t.v, left: left, right: factor() }; continue; }
|
|
202
|
+
return left;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function factor() {
|
|
207
|
+
if (isPunct('-')) { next(); return { neg: factor() }; }
|
|
208
|
+
if (isPunct('+')) { next(); return { pos: factor() }; }
|
|
209
|
+
if (isWord('not')) { next(); return { not: factor() }; }
|
|
210
|
+
return exponential();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function exponential() {
|
|
214
|
+
var base = atom();
|
|
215
|
+
if (!isPunct('^')) return base;
|
|
216
|
+
next();
|
|
217
|
+
return { op: '^', left: base, right: factor() };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function atom() {
|
|
221
|
+
var t = next();
|
|
222
|
+
if (t.t === 'num') return { value: t.v };
|
|
223
|
+
if (t.t === 'str') return { value: t.v };
|
|
224
|
+
if (t.t === 'punct' && t.v === '(') { var inner = conditional(); expect(')'); return inner; }
|
|
225
|
+
if (t.t === 'bracket') return pathFrom([segment(t.v, t.at)]);
|
|
226
|
+
if (t.t === 'name') {
|
|
227
|
+
var low = t.v.toLowerCase();
|
|
228
|
+
if (isPunct('(')) return call(t);
|
|
229
|
+
if (low === 'true') return { value: true };
|
|
230
|
+
if (low === 'false') return { value: false };
|
|
231
|
+
if (low === 'null') return { value: null };
|
|
232
|
+
if (KEYWORDS.has(low)) fail('"' + t.v + '" is a keyword here — write [' + t.v + '] for a column of that name', t.at);
|
|
233
|
+
if (low === '$thisrow') {
|
|
234
|
+
var rest = pathSegments([]);
|
|
235
|
+
if (!rest.length) fail('$thisrow needs a column — $thisrow.city', t.at);
|
|
236
|
+
return { thisrow: rest };
|
|
237
|
+
}
|
|
238
|
+
return pathFrom([segment(t.v, t.at)]);
|
|
239
|
+
}
|
|
240
|
+
fail('Expected a value but found ' + describe(t), t.at);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function segment(name, at) {
|
|
244
|
+
if (BLOCKED.has(name)) fail('"' + name + '" can\'t be used as a column name', at);
|
|
245
|
+
return name;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Continues a path: .name .[any name] [any name] ["name"] [0]
|
|
249
|
+
function pathSegments(segs) {
|
|
250
|
+
for (;;) {
|
|
251
|
+
var t = peek();
|
|
252
|
+
if (t.t === 'punct' && t.v === '.') {
|
|
253
|
+
next();
|
|
254
|
+
var s = next();
|
|
255
|
+
if (s.t === 'name' || s.t === 'bracket') { segs.push(segment(s.v, s.at)); continue; }
|
|
256
|
+
fail('Expected a name after "." but found ' + describe(s), s.at);
|
|
257
|
+
}
|
|
258
|
+
if (t.t === 'bracket') {
|
|
259
|
+
next();
|
|
260
|
+
var q = /^\s*(["'])([\s\S]*)\1\s*$/.exec(t.v);
|
|
261
|
+
segs.push(segment(q ? q[2] : t.v, t.at));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
return segs;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function pathFrom(segs) {
|
|
269
|
+
var start = pos;
|
|
270
|
+
segs = pathSegments(segs);
|
|
271
|
+
if (isPunct('(')) fail('Only a function name can be followed by "("', peek().at);
|
|
272
|
+
// A lone plain name keeps the 1.x { field } shape; anything else is a path,
|
|
273
|
+
// so a bracketed name containing a dot is never split into two.
|
|
274
|
+
if (segs.length === 1 && segs[0].indexOf('.') === -1 && pos === start) return { field: segs[0] };
|
|
275
|
+
return { path: segs };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function call(nameTok) {
|
|
279
|
+
var entry = functions.lookup(nameTok.v);
|
|
280
|
+
if (!entry || (entry.special && entry.name !== 'if')) fail('Unknown function "' + nameTok.v + '"', nameTok.at);
|
|
281
|
+
expect('(');
|
|
282
|
+
var args = [];
|
|
283
|
+
if (!isPunct(')')) {
|
|
284
|
+
args.push(conditional());
|
|
285
|
+
while (isPunct(',')) { next(); args.push(conditional()); }
|
|
286
|
+
}
|
|
287
|
+
expect(')');
|
|
288
|
+
if (args.length < entry.min || args.length > entry.max) {
|
|
289
|
+
var want = entry.min === entry.max ? String(entry.min) : entry.max === Infinity ? 'at least ' + entry.min : entry.min + ' to ' + entry.max;
|
|
290
|
+
fail(entry.name + '() takes ' + want + ' argument' + (want === '1' ? '' : 's') + ', not ' + args.length, nameTok.at);
|
|
291
|
+
}
|
|
292
|
+
if (entry.name === 'if') return { if: args[0], then: args[1], else: args[2] };
|
|
293
|
+
return { call: entry.name, args: args };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
var tree = conditional();
|
|
297
|
+
if (peek().t !== 'end') fail('Unexpected ' + describe(peek()), peek().at);
|
|
298
|
+
return tree;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = { parse: parse, tokenize: tokenize, BLOCKED: BLOCKED };
|
package/lib/text.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// JSON tree → formula text. The other direction from parse.js, and the reason
|
|
2
|
+
// it lives here: an editor that shows a stored condition back to someone has
|
|
3
|
+
// to write the same language the engine reads. Written anywhere else it would
|
|
4
|
+
// drift, and the drift would only show when a condition that displays fine
|
|
5
|
+
// fails to compile.
|
|
6
|
+
//
|
|
7
|
+
// toText(xf.parse('a=1 and upper(b)="X"')) // → 'a = 1 and upper(b) = "X"'
|
|
8
|
+
// toText(parse(t)) parses again to the same tree (round-trip).
|
|
9
|
+
//
|
|
10
|
+
// Brackets go round any name that isn't a plain one, and parentheses only
|
|
11
|
+
// where precedence needs them.
|
|
12
|
+
|
|
13
|
+
var PLAIN = /^[\p{L}_$][\p{L}\p{N}_$]*$/u;
|
|
14
|
+
var KEYWORD = /^(and|or|not|in|true|false|null)$/i;
|
|
15
|
+
|
|
16
|
+
// Mirrors parse.js. A child binds looser than its parent → parenthesise.
|
|
17
|
+
var PREC = { any: 2, all: 3, compare: 4, add: 5, mul: 6, unary: 7, pow: 8, atom: 9 };
|
|
18
|
+
var OP_PREC = {
|
|
19
|
+
eq: PREC.compare, neq: PREC.compare, '<': PREC.compare, '<=': PREC.compare,
|
|
20
|
+
'>': PREC.compare, '>=': PREC.compare, in: PREC.compare,
|
|
21
|
+
'+': PREC.add, '-': PREC.add, '||': PREC.add,
|
|
22
|
+
'*': PREC.mul, '/': PREC.mul, '%': PREC.mul,
|
|
23
|
+
'^': PREC.pow
|
|
24
|
+
};
|
|
25
|
+
// The 1.x word operators have no infix spelling; they are written as text
|
|
26
|
+
// (`between(x, 1, 10)`) where one exists, and otherwise left to the caller.
|
|
27
|
+
var WORD_OP_TEXT = { eq: '=', equals: '=', neq: '!=', notEquals: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=' };
|
|
28
|
+
|
|
29
|
+
function name(n) {
|
|
30
|
+
var s = String(n);
|
|
31
|
+
return PLAIN.test(s) && !KEYWORD.test(s) ? s : '[' + s.split(']').join(']]') + ']';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function literal(v) {
|
|
35
|
+
if (v === null) return 'null';
|
|
36
|
+
if (typeof v === 'string') return '"' + v.split('"').join('""') + '"';
|
|
37
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
38
|
+
if (Array.isArray(v)) return '(' + v.map(literal).join(', ') + ')';
|
|
39
|
+
if (v === undefined) return 'null';
|
|
40
|
+
if (v instanceof Date) return '"' + v.toISOString() + '"';
|
|
41
|
+
return '"' + String(v).split('"').join('""') + '"';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function precOf(node) {
|
|
45
|
+
if (!node || typeof node !== 'object') return PREC.atom;
|
|
46
|
+
if (node.any !== undefined) return PREC.any;
|
|
47
|
+
if (node.all !== undefined) return PREC.all;
|
|
48
|
+
if (node.not !== undefined || node.neg !== undefined || node.pos !== undefined) return PREC.unary;
|
|
49
|
+
if (node.op !== undefined) return OP_PREC[node.op] || PREC.compare;
|
|
50
|
+
return PREC.atom;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function wrap(node, min) {
|
|
54
|
+
var text = toText(node);
|
|
55
|
+
return precOf(node) < min ? '(' + text + ')' : text;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toText(node) {
|
|
59
|
+
if (node === null || node === undefined) return '';
|
|
60
|
+
if (typeof node !== 'object') return literal(node);
|
|
61
|
+
|
|
62
|
+
if (node.fn !== undefined && node.call === undefined) { // 1.x operand
|
|
63
|
+
var inner = Object.assign({}, node);
|
|
64
|
+
delete inner.fn;
|
|
65
|
+
return String(node.fn) + '(' + toText(inner) + ')';
|
|
66
|
+
}
|
|
67
|
+
if (Object.prototype.hasOwnProperty.call(node, 'value')) return literal(node.value);
|
|
68
|
+
if (node.field !== undefined) return String(node.field).split('.').map(name).join('.');
|
|
69
|
+
if (node.path !== undefined) return node.path.map(name).join('.');
|
|
70
|
+
if (node.thisrow !== undefined) return '$thisrow.' + node.thisrow.map(name).join('.');
|
|
71
|
+
if (node.list !== undefined) return '(' + node.list.map(toText).join(', ') + ')';
|
|
72
|
+
if (node.call !== undefined) return String(node.call) + '(' + (node.args || []).map(toText).join(', ') + ')';
|
|
73
|
+
if (node.if !== undefined) return 'if(' + toText(node.if) + ', ' + toText(node.then) + ', ' + toText(node.else) + ')';
|
|
74
|
+
if (node.all !== undefined) return node.all.map(function (n) { return wrap(n, PREC.all); }).join(' and ');
|
|
75
|
+
if (node.any !== undefined) return node.any.map(function (n) { return wrap(n, PREC.any); }).join(' or ');
|
|
76
|
+
if (node.not !== undefined) return 'not ' + wrap(node.not, PREC.unary);
|
|
77
|
+
if (node.neg !== undefined) return '-' + wrap(node.neg, PREC.unary);
|
|
78
|
+
if (node.pos !== undefined) return '+' + wrap(node.pos, PREC.unary);
|
|
79
|
+
|
|
80
|
+
if (node.op !== undefined) {
|
|
81
|
+
var sym = WORD_OP_TEXT[node.op] || (OP_PREC[node.op] ? node.op : null);
|
|
82
|
+
var here = precOf(node);
|
|
83
|
+
if (sym) {
|
|
84
|
+
var left = wrap(node.left, here);
|
|
85
|
+
if (node.right === undefined || node.right === null) return left + ' ' + sym;
|
|
86
|
+
// The right side of a left-associative operator needs parentheses at
|
|
87
|
+
// equal precedence: a - (b - c) is not a - b - c.
|
|
88
|
+
var right = wrap(node.right, node.op === '^' ? here : here + 1);
|
|
89
|
+
return left + ' ' + sym + ' ' + right;
|
|
90
|
+
}
|
|
91
|
+
// Word operators with no infix spelling: contains, between, isNull, in…
|
|
92
|
+
var args = [toText(node.left)];
|
|
93
|
+
if (node.right !== undefined && node.right !== null) {
|
|
94
|
+
var r = node.right;
|
|
95
|
+
if (node.op === 'between' && r && Array.isArray(r.value)) args = args.concat(r.value.map(literal));
|
|
96
|
+
else args.push(toText(r));
|
|
97
|
+
}
|
|
98
|
+
return String(node.op) + '(' + args.join(', ') + ')';
|
|
99
|
+
}
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { toText: toText };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/expression-handler",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "The one formula engine for xeplr: its own parser (no eval, no dependencies), one function list, compiled-once formulas, totals over lists, AND/OR conditions. Runs in Node and the browser.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
7
7
|
"index.js",
|
package/lib/formula.js
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
// Formula wrapper — parse an Excel-like formula string into the structured
|
|
2
|
-
// JSON the evaluator understands, and evaluate it against a row.
|
|
3
|
-
//
|
|
4
|
-
// parseFormula('if(upper(someColumn)=anotherColumn, "Yes", "No")')
|
|
5
|
-
// →
|
|
6
|
-
// { if: { left: { fn:'upper', field:'someColumn' }, op:'eq',
|
|
7
|
-
// right: { field:'anotherColumn' } },
|
|
8
|
-
// then: { value: 'Yes' },
|
|
9
|
-
// else: { value: 'No' } }
|
|
10
|
-
//
|
|
11
|
-
// Rules (level 1 — no arithmetic, no AND/OR, single function nesting):
|
|
12
|
-
// • "double quoted" (or 'single quoted') → string literal { value }
|
|
13
|
-
// • bare word → field reference { field }
|
|
14
|
-
// • number / null / true / false → literal { value }
|
|
15
|
-
// • name(...) → function call (value fn like
|
|
16
|
-
// upper/month, boolean fn like contains/between/isnull, or IF)
|
|
17
|
-
// • comparisons: = == != <> > >= < <=
|
|
18
|
-
//
|
|
19
|
-
// An IF node evaluates to its then/else VALUE; a bare comparison evaluates to
|
|
20
|
-
// a boolean. So compileFormula() yields a value-producer or a predicate alike.
|
|
21
|
-
|
|
22
|
-
var { evaluate, resolveOperand } = require('./evaluate');
|
|
23
|
-
var { FUNCTIONS } = require('./functions');
|
|
24
|
-
|
|
25
|
-
// Boolean functions → operator names (arity fixed). Keys are lowercased.
|
|
26
|
-
var BOOL_FUNCS = {
|
|
27
|
-
contains: { op: 'contains', arity: 2 },
|
|
28
|
-
notcontains: { op: 'notContains', arity: 2 },
|
|
29
|
-
doesnotcontain: { op: 'notContains', arity: 2 },
|
|
30
|
-
startswith: { op: 'startsWith', arity: 2 },
|
|
31
|
-
endswith: { op: 'endsWith', arity: 2 },
|
|
32
|
-
isnull: { op: 'isNull', arity: 1 },
|
|
33
|
-
isnotnull: { op: 'isNotNull', arity: 1 },
|
|
34
|
-
between: { op: 'between', arity: 3 }
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
var OP_SYMBOLS = { '=': 'eq', '==': 'eq', '!=': 'neq', '<>': 'neq', '>': 'gt', '>=': 'gte', '<': 'lt', '<=': 'lte' };
|
|
38
|
-
|
|
39
|
-
// ── tokenizer ────────────────────────────────────────────────────────────
|
|
40
|
-
function tokenize(input) {
|
|
41
|
-
var tokens = [];
|
|
42
|
-
var i = 0, n = input.length;
|
|
43
|
-
while (i < n) {
|
|
44
|
-
var c = input[i];
|
|
45
|
-
if (/\s/.test(c)) { i++; continue; }
|
|
46
|
-
|
|
47
|
-
if (c === '"' || c === "'") { // string literal (doubled quote = escape)
|
|
48
|
-
var q = c; i++; var s = '';
|
|
49
|
-
while (i < n) {
|
|
50
|
-
if (input[i] === q) {
|
|
51
|
-
if (input[i + 1] === q) { s += q; i += 2; continue; }
|
|
52
|
-
i++; break;
|
|
53
|
-
}
|
|
54
|
-
s += input[i++];
|
|
55
|
-
}
|
|
56
|
-
tokens.push({ t: 'string', v: s });
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (c === '(') { tokens.push({ t: 'lparen' }); i++; continue; }
|
|
60
|
-
if (c === ')') { tokens.push({ t: 'rparen' }); i++; continue; }
|
|
61
|
-
if (c === ',') { tokens.push({ t: 'comma' }); i++; continue; }
|
|
62
|
-
|
|
63
|
-
var two = input.substr(i, 2);
|
|
64
|
-
if (two === '>=' || two === '<=' || two === '<>' || two === '!=' || two === '==') { tokens.push({ t: 'op', v: two }); i += 2; continue; }
|
|
65
|
-
if (c === '=' || c === '>' || c === '<') { tokens.push({ t: 'op', v: c }); i++; continue; }
|
|
66
|
-
|
|
67
|
-
if (/[0-9]/.test(c) || (c === '-' && /[0-9]/.test(input[i + 1] || ''))) {
|
|
68
|
-
var num = c; i++;
|
|
69
|
-
while (i < n && /[0-9.]/.test(input[i])) num += input[i++];
|
|
70
|
-
tokens.push({ t: 'number', v: parseFloat(num) });
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
if (/[A-Za-z_]/.test(c)) {
|
|
74
|
-
var id = c; i++;
|
|
75
|
-
while (i < n && /[A-Za-z0-9_.]/.test(input[i])) id += input[i++];
|
|
76
|
-
tokens.push({ t: 'ident', v: id });
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
throw new Error('Unexpected character "' + c + '" at position ' + i);
|
|
80
|
-
}
|
|
81
|
-
return tokens;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Resolve a function name to the registry's canonical (case-insensitive) key.
|
|
85
|
-
function canonicalFn(low) {
|
|
86
|
-
if (FUNCTIONS[low]) return low;
|
|
87
|
-
var keys = Object.keys(FUNCTIONS);
|
|
88
|
-
for (var i = 0; i < keys.length; i++) if (keys[i].toLowerCase() === low) return keys[i];
|
|
89
|
-
return low; // unknown — evaluate() will throw a clear error at run time
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function literalOf(node, ctx) {
|
|
93
|
-
if (node && Object.prototype.hasOwnProperty.call(node, 'value')) return node.value;
|
|
94
|
-
throw new Error(ctx + ' must be a literal value');
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── parser (recursive descent) ─────────────────────────────────────────────
|
|
98
|
-
function parseFormula(input) {
|
|
99
|
-
if (typeof input !== 'string') throw new Error('parseFormula: input must be a string');
|
|
100
|
-
var toks = tokenize(input);
|
|
101
|
-
var pos = 0;
|
|
102
|
-
function peek() { return toks[pos]; }
|
|
103
|
-
function expect(t) {
|
|
104
|
-
var tk = toks[pos];
|
|
105
|
-
if (!tk || tk.t !== t) throw new Error('Expected ' + t + ' but got ' + (tk ? (tk.t + (tk.v != null ? ' "' + tk.v + '"' : '')) : 'end of formula'));
|
|
106
|
-
return toks[pos++];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function parseExpression() {
|
|
110
|
-
var left = parsePrimary();
|
|
111
|
-
var tk = peek();
|
|
112
|
-
if (tk && tk.t === 'op') {
|
|
113
|
-
pos++;
|
|
114
|
-
var right = parsePrimary();
|
|
115
|
-
return { left: left, op: OP_SYMBOLS[tk.v], right: right };
|
|
116
|
-
}
|
|
117
|
-
return left;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function parsePrimary() {
|
|
121
|
-
var tk = peek();
|
|
122
|
-
if (!tk) throw new Error('Unexpected end of formula');
|
|
123
|
-
|
|
124
|
-
if (tk.t === 'string') { pos++; return { value: tk.v }; }
|
|
125
|
-
if (tk.t === 'number') { pos++; return { value: tk.v }; }
|
|
126
|
-
if (tk.t === 'lparen') { pos++; var e = parseExpression(); expect('rparen'); return e; }
|
|
127
|
-
|
|
128
|
-
if (tk.t === 'ident') {
|
|
129
|
-
var name = tk.v;
|
|
130
|
-
var low = name.toLowerCase();
|
|
131
|
-
var isCall = toks[pos + 1] && toks[pos + 1].t === 'lparen';
|
|
132
|
-
if (isCall) {
|
|
133
|
-
pos += 2; // consume ident + '('
|
|
134
|
-
if (low === 'if') return parseIf();
|
|
135
|
-
if (BOOL_FUNCS[low]) return parseBoolFunc(low);
|
|
136
|
-
return parseValueFunc(low);
|
|
137
|
-
}
|
|
138
|
-
pos++;
|
|
139
|
-
if (low === 'null') return { value: null };
|
|
140
|
-
if (low === 'true') return { value: true };
|
|
141
|
-
if (low === 'false') return { value: false };
|
|
142
|
-
return { field: name }; // field refs keep their case
|
|
143
|
-
}
|
|
144
|
-
throw new Error('Unexpected token: ' + tk.t);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function parseIf() { // 'if' + '(' already consumed
|
|
148
|
-
var cond = parseExpression();
|
|
149
|
-
expect('comma');
|
|
150
|
-
var thenNode = parseExpression();
|
|
151
|
-
expect('comma');
|
|
152
|
-
var elseNode = parseExpression();
|
|
153
|
-
expect('rparen');
|
|
154
|
-
return { if: cond, then: thenNode, else: elseNode };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function parseValueFunc(low) { // e.g. upper(x), month(d)
|
|
158
|
-
var arg = parseExpression();
|
|
159
|
-
expect('rparen');
|
|
160
|
-
if (arg && (arg.fn || arg.if || arg.op)) throw new Error('level-1: function "' + low + '" argument must be a field or literal');
|
|
161
|
-
return Object.assign({ fn: canonicalFn(low) }, arg);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function parseBoolFunc(low) { // e.g. contains(a,b), isnull(a), between(a,lo,hi)
|
|
165
|
-
var def = BOOL_FUNCS[low];
|
|
166
|
-
var args = [parseExpression()];
|
|
167
|
-
while (peek() && peek().t === 'comma') { pos++; args.push(parseExpression()); }
|
|
168
|
-
expect('rparen');
|
|
169
|
-
if (args.length !== def.arity) throw new Error(low + '() expects ' + def.arity + ' argument(s), got ' + args.length);
|
|
170
|
-
if (def.op === 'between') return { left: args[0], op: 'between', right: { value: [literalOf(args[1], 'between low bound'), literalOf(args[2], 'between high bound')] } };
|
|
171
|
-
if (def.arity === 1) return { left: args[0], op: def.op };
|
|
172
|
-
return { left: args[0], op: def.op, right: args[1] };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
var result = parseExpression();
|
|
176
|
-
if (pos < toks.length) throw new Error('Unexpected trailing tokens in formula (near token ' + (pos + 1) + ')');
|
|
177
|
-
return result;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ── evaluation ─────────────────────────────────────────────────────────────
|
|
181
|
-
// Returns a VALUE (IF → then/else value), or a boolean (bare comparison).
|
|
182
|
-
function evaluateFormula(node, row) {
|
|
183
|
-
if (node === null || node === undefined) return null;
|
|
184
|
-
if (Object.prototype.hasOwnProperty.call(node, 'if')) {
|
|
185
|
-
return evaluate(node.if, row) ? evaluateFormula(node.then, row) : evaluateFormula(node.else, row);
|
|
186
|
-
}
|
|
187
|
-
if (node.op) return evaluate(node, row); // comparison → boolean
|
|
188
|
-
return resolveOperand(node, row); // operand → value
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function compileFormula(input) {
|
|
192
|
-
var node = parseFormula(input);
|
|
193
|
-
return function (row) { return evaluateFormula(node, row); };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
module.exports = { parseFormula, evaluateFormula, compileFormula };
|