@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/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": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.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 };
|