@xeplr/expression-handler 1.0.5 → 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/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.5",
4
- "description": "Level-1 structured expression evaluator — apply field functions (upper/month/…) and operators (eq/contains/gt/between/isNull/…) to a data row. No parser; JSON expressions in, boolean out.",
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 };