@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/functions.js
CHANGED
|
@@ -1,50 +1,285 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// The ONE function list — every function a formula can call, with the words
|
|
2
|
+
// the UI shows for it. BI's reference panel, autocomplete and validation and
|
|
3
|
+
// Workflow's condition picker are all built from this; adding a function is
|
|
4
|
+
// adding one entry here.
|
|
3
5
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
// Moved from BI's report-engine/formulaFunctions.js; its implementations and
|
|
7
|
+
// their behaviour are unchanged (BI's saved reports depend on them). The 1.x
|
|
8
|
+
// functions merged in: weekday, between. 1.x's monthNum is now month.
|
|
9
|
+
//
|
|
10
|
+
// EVERY FUNCTION IS TOTAL — nonsense in, null out, never an exception. The
|
|
11
|
+
// engine only evaluates the branch an if() takes, but a function can still see
|
|
12
|
+
// a missing value or a column holding text where a number was expected, and a
|
|
13
|
+
// report must show an empty cell there, not fail.
|
|
14
|
+
//
|
|
15
|
+
// Entry shape:
|
|
16
|
+
// name, category, args, description, example what the UI shows
|
|
17
|
+
// fn the implementation
|
|
18
|
+
// min / max how many arguments it takes (max: Infinity = any number)
|
|
19
|
+
// context fn receives the run context first: fn(ctx, …args)
|
|
20
|
+
// volatile its answer depends on more than its arguments (the date), so
|
|
21
|
+
// it is never folded to a constant at compile time
|
|
22
|
+
// aggregate spans rows — compiled by the engine, see compile.js
|
|
23
|
+
// special syntax rather than a function (if, $thisrow) — listed so the
|
|
24
|
+
// reference shows it, compiled by the engine
|
|
25
|
+
|
|
26
|
+
var dates = require('./dates');
|
|
27
|
+
var asDate = dates.asDate;
|
|
28
|
+
|
|
29
|
+
var CATEGORIES = ['Math', 'Text', 'Date', 'Logic', 'Aggregate'];
|
|
30
|
+
|
|
31
|
+
function str(v) { return v === null || v === undefined ? '' : String(v); }
|
|
32
|
+
function num(v) { var n = Number(v); return isNaN(n) ? null : n; }
|
|
33
|
+
function isEmpty(v) { return v === null || v === undefined || v === ''; }
|
|
34
|
+
function math(f) { return function (v) { var n = num(v); return n === null ? null : f(n); }; }
|
|
35
|
+
function minMax(pick) {
|
|
36
|
+
return function () {
|
|
37
|
+
var vals = arguments.length === 1 && Array.isArray(arguments[0]) ? arguments[0] : Array.prototype.slice.call(arguments);
|
|
38
|
+
if (!vals.length) return null;
|
|
39
|
+
var out = null;
|
|
40
|
+
for (var i = 0; i < vals.length; i++) {
|
|
41
|
+
var n = num(vals[i]);
|
|
42
|
+
if (n === null) return null;
|
|
43
|
+
out = out === null ? n : pick(out, n);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var LIST = [
|
|
50
|
+
// ── Math ──
|
|
51
|
+
{ name: 'round', category: 'Math', args: 'value, places', min: 1, max: 2,
|
|
52
|
+
description: 'Round to a number of decimal places', example: 'round(amount, 2)',
|
|
53
|
+
// Nothing rounds to 0, not null — Number(null) is 0, the coercion the rest
|
|
54
|
+
// of the engine uses (`revenue - missing` is revenue). Text that isn't a
|
|
55
|
+
// number is null, not NaN.
|
|
56
|
+
fn: function (v, places) {
|
|
57
|
+
var n = num(v);
|
|
58
|
+
if (n === null) return null;
|
|
59
|
+
var p = Math.pow(10, Number(places) || 0);
|
|
60
|
+
return Math.round(n * p) / p;
|
|
61
|
+
} },
|
|
62
|
+
{ name: 'abs', category: 'Math', args: 'value', min: 1, max: 1,
|
|
63
|
+
description: 'Distance from zero, ignoring sign', example: 'abs(variance)', fn: math(Math.abs) },
|
|
64
|
+
{ name: 'ceil', category: 'Math', args: 'value', min: 1, max: 1,
|
|
65
|
+
description: 'Round up to a whole number', example: 'ceil(days)', fn: math(Math.ceil) },
|
|
66
|
+
{ name: 'floor', category: 'Math', args: 'value', min: 1, max: 1,
|
|
67
|
+
description: 'Round down to a whole number', example: 'floor(days)', fn: math(Math.floor) },
|
|
68
|
+
{ name: 'sqrt', category: 'Math', args: 'value', min: 1, max: 1,
|
|
69
|
+
description: 'Square root', example: 'sqrt(area)', fn: math(Math.sqrt) },
|
|
70
|
+
{ name: 'min', category: 'Math', args: 'a, b, …', min: 1, max: Infinity,
|
|
71
|
+
description: 'Smallest of the values given — not across rows', example: 'min(price, cap)',
|
|
72
|
+
fn: minMax(function (a, b) { return b < a ? b : a; }) },
|
|
73
|
+
{ name: 'max', category: 'Math', args: 'a, b, …', min: 1, max: Infinity,
|
|
74
|
+
description: 'Largest of the values given — not across rows', example: 'max(price, floor_price)',
|
|
75
|
+
fn: minMax(function (a, b) { return b > a ? b : a; }) },
|
|
76
|
+
// expr-eval shipped these unlisted, so saved formulas may use them.
|
|
77
|
+
// log is the NATURAL log there (not Excel's base 10), kept for that reason.
|
|
78
|
+
{ name: 'trunc', category: 'Math', args: 'value', min: 1, max: 1,
|
|
79
|
+
description: 'Drop the decimals, toward zero', example: 'trunc(hours)', fn: math(Math.trunc) },
|
|
80
|
+
{ name: 'exp', category: 'Math', args: 'value', min: 1, max: 1,
|
|
81
|
+
description: 'e raised to the value', example: 'exp(rate)', fn: math(Math.exp) },
|
|
82
|
+
{ name: 'ln', category: 'Math', args: 'value', min: 1, max: 1,
|
|
83
|
+
description: 'Natural logarithm', example: 'ln(growth)', fn: math(Math.log) },
|
|
84
|
+
{ name: 'log', category: 'Math', args: 'value', min: 1, max: 1,
|
|
85
|
+
description: 'Natural logarithm — the same as ln, not base 10', example: 'log(growth)', fn: math(Math.log) },
|
|
86
|
+
{ name: 'log10', category: 'Math', args: 'value', min: 1, max: 1,
|
|
87
|
+
description: 'Base-10 logarithm', example: 'log10(size)', fn: math(Math.log10) },
|
|
88
|
+
{ name: 'log2', category: 'Math', args: 'value', min: 1, max: 1,
|
|
89
|
+
description: 'Base-2 logarithm', example: 'log2(size)', fn: math(Math.log2) },
|
|
90
|
+
{ name: 'cbrt', category: 'Math', args: 'value', min: 1, max: 1,
|
|
91
|
+
description: 'Cube root', example: 'cbrt(volume)', fn: math(Math.cbrt) },
|
|
92
|
+
{ name: 'pow', category: 'Math', args: 'value, exponent', min: 2, max: 2,
|
|
93
|
+
description: 'Raise to a power', example: 'pow(rate, years)',
|
|
94
|
+
fn: function (v, e) { var a = num(v), b = num(e); return a === null || b === null ? null : Math.pow(a, b); } },
|
|
95
|
+
|
|
96
|
+
// ── Text ──
|
|
97
|
+
// Text of nothing is empty, never the word "null" — that would show in a report.
|
|
98
|
+
{ name: 'upper', category: 'Text', args: 'text', min: 1, max: 1,
|
|
99
|
+
description: 'Convert to upper case', example: 'upper(city)', fn: function (v) { return str(v).toUpperCase(); } },
|
|
100
|
+
{ name: 'lower', category: 'Text', args: 'text', min: 1, max: 1,
|
|
101
|
+
description: 'Convert to lower case', example: 'lower(email)', fn: function (v) { return str(v).toLowerCase(); } },
|
|
102
|
+
{ name: 'trim', category: 'Text', args: 'text', min: 1, max: 1,
|
|
103
|
+
description: 'Remove leading and trailing spaces', example: 'trim(name)', fn: function (v) { return str(v).trim(); } },
|
|
104
|
+
{ name: 'concat', category: 'Text', args: 'a, b, …', min: 0, max: Infinity,
|
|
105
|
+
description: 'Join values into one piece of text', example: 'concat(first, " ", last)',
|
|
106
|
+
fn: function () { var s = ''; for (var i = 0; i < arguments.length; i++) s += str(arguments[i]); return s; } },
|
|
107
|
+
// 1-based, like every spreadsheet — a report author is not a programmer.
|
|
108
|
+
{ name: 'substring', category: 'Text', args: 'text, start, count', min: 2, max: 3,
|
|
109
|
+
description: 'Part of a value, counting from 1', example: 'substring(code, 1, 3)',
|
|
110
|
+
fn: function (v, start, count) {
|
|
111
|
+
var s = str(v);
|
|
112
|
+
var from = Math.max(0, (Number(start) || 1) - 1);
|
|
113
|
+
return count === undefined ? s.slice(from) : s.slice(from, from + (Number(count) || 0));
|
|
114
|
+
} },
|
|
115
|
+
{ name: 'left', category: 'Text', args: 'text, count', min: 2, max: 2,
|
|
116
|
+
description: 'First few characters', example: 'left(deptCode, 3)',
|
|
117
|
+
fn: function (v, count) { return str(v).slice(0, Number(count) || 0); } },
|
|
118
|
+
{ name: 'right', category: 'Text', args: 'text, count', min: 2, max: 2,
|
|
119
|
+
description: 'Last few characters', example: 'right(invoice, 4)',
|
|
120
|
+
fn: function (v, count) { var n = Number(count) || 0; return n <= 0 ? '' : str(v).slice(-n); } },
|
|
121
|
+
{ name: 'replace', category: 'Text', args: 'text, find, replace with', min: 3, max: 3,
|
|
122
|
+
description: 'Swap one piece of text for another', example: 'replace(phone, "-", "")',
|
|
123
|
+
fn: function (v, find, into) { return str(v).split(str(find)).join(str(into)); } },
|
|
124
|
+
{ name: 'contains', category: 'Text', args: 'text, find', min: 2, max: 2,
|
|
125
|
+
description: 'True when the text contains it', example: 'contains(notes, "urgent")',
|
|
126
|
+
fn: function (v, sub) { return str(v).indexOf(str(sub)) !== -1; } },
|
|
127
|
+
{ name: 'startsWith', category: 'Text', args: 'text, find', min: 2, max: 2,
|
|
128
|
+
description: 'True when the text begins with it', example: 'startsWith(sku, "IN")',
|
|
129
|
+
fn: function (v, sub) { return str(v).startsWith(str(sub)); } },
|
|
130
|
+
{ name: 'endsWith', category: 'Text', args: 'text, find', min: 2, max: 2,
|
|
131
|
+
description: 'True when the text ends with it', example: 'endsWith(file, ".pdf")',
|
|
132
|
+
fn: function (v, sub) { return str(v).endsWith(str(sub)); } },
|
|
133
|
+
{ name: 'length', category: 'Text', args: 'text', min: 1, max: 1,
|
|
134
|
+
description: 'How many characters', example: 'length(name)',
|
|
135
|
+
fn: function (v) { return Array.isArray(v) ? v.length : str(v).length; } },
|
|
136
|
+
|
|
137
|
+
// ── Date ── (local time; a date-only string is that calendar day — see dates.js)
|
|
138
|
+
{ name: 'year', category: 'Date', args: 'date', min: 1, max: 1,
|
|
139
|
+
description: 'The year, as a number', example: 'year(order_date)',
|
|
140
|
+
fn: function (v) { var d = asDate(v); return d ? d.getFullYear() : null; } },
|
|
141
|
+
{ name: 'month', category: 'Date', args: 'date', min: 1, max: 1,
|
|
142
|
+
description: 'The month, 1–12', example: 'month(order_date)',
|
|
143
|
+
fn: function (v) { var d = asDate(v); return d ? d.getMonth() + 1 : null; } },
|
|
144
|
+
{ name: 'day', category: 'Date', args: 'date', min: 1, max: 1,
|
|
145
|
+
description: 'Day of the month', example: 'day(order_date)',
|
|
146
|
+
fn: function (v) { var d = asDate(v); return d ? d.getDate() : null; } },
|
|
147
|
+
{ name: 'quarter', category: 'Date', args: 'date', min: 1, max: 1,
|
|
148
|
+
description: 'The quarter, 1–4', example: 'quarter(order_date)',
|
|
149
|
+
fn: function (v) { var d = asDate(v); return d ? Math.floor(d.getMonth() / 3) + 1 : null; } },
|
|
150
|
+
{ name: 'weekday', category: 'Date', args: 'date', min: 1, max: 1,
|
|
151
|
+
description: 'The day of the week by name', example: 'weekday(order_date)',
|
|
152
|
+
fn: function (v) { var d = asDate(v); return d ? dates.WEEKDAYS[d.getDay()] : null; } },
|
|
153
|
+
{ name: 'datediff', category: 'Date', args: 'from, to', min: 2, max: 2,
|
|
154
|
+
description: 'Whole days between two dates', example: 'datediff(ordered, shipped)',
|
|
155
|
+
// Later minus earlier.
|
|
156
|
+
fn: function (a, b) {
|
|
157
|
+
var from = asDate(a), to = asDate(b);
|
|
158
|
+
if (!from || !to) return null;
|
|
159
|
+
return Math.round((to.getTime() - from.getTime()) / dates.MS_PER_DAY);
|
|
160
|
+
} },
|
|
161
|
+
{ name: 'dateadd', category: 'Date', args: 'date, days', min: 2, max: 2,
|
|
162
|
+
description: 'Move a date by a number of days', example: 'dateadd(order_date, 30)',
|
|
163
|
+
// Built from calendar parts, not by adding milliseconds, so a DST boundary
|
|
164
|
+
// can't turn "+1 day" into 23 or 25 hours and land on the wrong date.
|
|
165
|
+
fn: function (v, days) {
|
|
166
|
+
var d = asDate(v);
|
|
167
|
+
if (!d) return null;
|
|
168
|
+
return dates.isoLocal(new Date(d.getFullYear(), d.getMonth(), d.getDate() + (Number(days) || 0)));
|
|
169
|
+
} },
|
|
170
|
+
{ name: 'monthname', category: 'Date', args: 'date or month number', min: 1, max: 1,
|
|
171
|
+
description: 'The month by name. Reads well, but sorts alphabetically — group by monthkey instead', example: 'monthname(order_date)',
|
|
172
|
+
// A number is always a month, never an epoch timestamp — monthname(13)
|
|
173
|
+
// must not read as 13ms after 1970 and answer "January".
|
|
174
|
+
fn: function (v) {
|
|
175
|
+
if (typeof v === 'number') return v >= 1 && v <= 12 ? dates.MONTH_NAMES[v - 1] : null;
|
|
176
|
+
var d = asDate(v);
|
|
177
|
+
return d ? dates.MONTH_NAMES[d.getMonth()] : null;
|
|
178
|
+
} },
|
|
179
|
+
// Grouping KEYS: they sort correctly as text ("2024-06" before "2024-10"),
|
|
180
|
+
// which is what makes them safe to pivot or group on, unlike monthname.
|
|
181
|
+
{ name: 'monthkey', category: 'Date', args: 'date', min: 1, max: 1,
|
|
182
|
+
description: 'The month as 2024-06 — sorts chronologically, so group or pivot by this', example: 'monthkey(order_date)',
|
|
183
|
+
fn: function (v) { var d = asDate(v); return d ? d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') : null; } },
|
|
184
|
+
{ name: 'quarterkey', category: 'Date', args: 'date', min: 1, max: 1,
|
|
185
|
+
description: 'The quarter as 2024-Q2 — sorts chronologically', example: 'quarterkey(order_date)',
|
|
186
|
+
fn: function (v) { var d = asDate(v); return d ? d.getFullYear() + '-Q' + (Math.floor(d.getMonth() / 3) + 1) : null; } },
|
|
187
|
+
{ name: 'daykey', category: 'Date', args: 'date', min: 1, max: 1,
|
|
188
|
+
description: 'The day as 2024-06-17 — sorts chronologically', example: 'daykey(order_date)',
|
|
189
|
+
fn: function (v) { var d = asDate(v); return d ? dates.isoLocal(d) : null; } },
|
|
190
|
+
// Predicates, not date maths — they exist to go inside sumif / countif. Both
|
|
191
|
+
// ends matter: a date later this year is NOT year-to-date. False, not null,
|
|
192
|
+
// on nothing — the honest answer, and it composes with `not`.
|
|
193
|
+
{ name: 'ytd', category: 'Date', args: 'date', min: 1, max: 1, context: true, volatile: true,
|
|
194
|
+
description: 'True when the date is in this calendar year, up to today. Pair it with sumif', example: 'ytd(order_date)',
|
|
195
|
+
fn: function (ctx, v) {
|
|
196
|
+
var d = asDate(v);
|
|
197
|
+
if (!d) return false;
|
|
198
|
+
var now = ctx.now();
|
|
199
|
+
return d.getFullYear() === now.year && d.getTime() <= now.end.getTime();
|
|
200
|
+
} },
|
|
201
|
+
{ name: 'fytd', category: 'Date', args: 'date', min: 1, max: 1, context: true, volatile: true,
|
|
202
|
+
description: 'True when the date is in this financial year, up to today. Pair it with sumif', example: 'fytd(order_date)',
|
|
203
|
+
fn: function (ctx, v) {
|
|
204
|
+
var d = asDate(v);
|
|
205
|
+
if (!d) return false;
|
|
206
|
+
var now = ctx.now();
|
|
207
|
+
return d.getTime() >= now.fyStart.getTime() && d.getTime() <= now.end.getTime();
|
|
208
|
+
} },
|
|
209
|
+
|
|
210
|
+
// ── Logic ──
|
|
211
|
+
{ name: 'if', category: 'Logic', args: 'condition, then, otherwise', min: 3, max: 3, special: true,
|
|
212
|
+
description: 'Choose between two values. Nests freely', example: 'if(qty > 10, "bulk", "single")' },
|
|
213
|
+
// Empty text counts as missing: a database NULL and an empty cell mean the
|
|
214
|
+
// same thing to someone reading a report. Zero does not.
|
|
215
|
+
{ name: 'coalesce', category: 'Logic', args: 'a, b, …', min: 1, max: Infinity,
|
|
216
|
+
description: 'First value that is not empty', example: 'coalesce(nickname, name)',
|
|
217
|
+
fn: function () { for (var i = 0; i < arguments.length; i++) if (!isEmpty(arguments[i])) return arguments[i]; return null; } },
|
|
218
|
+
{ name: 'ifnull', category: 'Logic', args: 'value, fallback', min: 2, max: 2,
|
|
219
|
+
description: 'Use a fallback when the value is empty', example: 'ifnull(discount, 0)',
|
|
220
|
+
fn: function (v, fallback) { return isEmpty(v) ? fallback : v; } },
|
|
221
|
+
{ name: 'isnull', category: 'Logic', args: 'value', min: 1, max: 1,
|
|
222
|
+
description: 'True when the value is empty', example: 'isnull(closed_date)',
|
|
223
|
+
fn: function (v) { return isEmpty(v); } },
|
|
224
|
+
{ name: 'between', category: 'Logic', args: 'value, low, high', min: 3, max: 3,
|
|
225
|
+
description: 'True when the value is from low to high, both included', example: 'between(score, 600, 750)',
|
|
226
|
+
// Numbers compare as numbers; anything else as text, which orders ISO
|
|
227
|
+
// dates ("2026-01-31") correctly. Nothing is never between.
|
|
228
|
+
fn: function (v, lo, hi) {
|
|
229
|
+
if (isEmpty(v) || isEmpty(lo) || isEmpty(hi)) return false;
|
|
230
|
+
var x = num(v), a = num(lo), b = num(hi);
|
|
231
|
+
if (x !== null && a !== null && b !== null) return x >= a && x <= b;
|
|
232
|
+
var s = String(v);
|
|
233
|
+
return s >= String(lo) && s <= String(hi);
|
|
234
|
+
} },
|
|
235
|
+
|
|
236
|
+
// ── Aggregate ── spans ROWS; compiled by the engine (compile.js).
|
|
237
|
+
// In BI the rows are the current group: sumif(status = "paid", amount).
|
|
238
|
+
// Anywhere else the list comes first: sumif(orders, status = "paid", amount).
|
|
239
|
+
{ name: 'sum', category: 'Aggregate', args: 'value', min: 1, max: 2, aggregate: true, shape: { cond: false, value: true },
|
|
240
|
+
description: 'Total across the rows in this group', example: 'sum(amount)' },
|
|
241
|
+
{ name: 'sumif', category: 'Aggregate', args: 'condition, value', min: 2, max: 3, aggregate: true, shape: { cond: true, value: true },
|
|
242
|
+
description: 'Total across the rows that match', example: 'sumif(status == "refunded", amount)' },
|
|
243
|
+
{ name: 'countif', category: 'Aggregate', args: 'condition', min: 1, max: 2, aggregate: true, shape: { cond: true, value: false },
|
|
244
|
+
description: 'How many rows match', example: 'countif(qty > 10)' },
|
|
245
|
+
{ name: 'countifunique', category: 'Aggregate', args: 'condition, value', min: 2, max: 3, aggregate: true, shape: { cond: true, value: true },
|
|
246
|
+
description: 'How many different values among the rows that match', example: 'countifunique(status == "paid", customer_id)' },
|
|
247
|
+
// Not a function — a reference, usable inside any aggregate's condition.
|
|
248
|
+
{ name: '$thisrow.column', category: 'Aggregate', args: '', special: true,
|
|
249
|
+
description: 'This row\'s own value, so a total can cover every row sharing it', example: 'sumif(month == $thisrow.month, revenue)' }
|
|
12
250
|
];
|
|
13
|
-
var WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
14
251
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
252
|
+
// Lookup by lower-cased name. A null-prototype map, so no formula can reach
|
|
253
|
+
// Object.prototype through a function name.
|
|
254
|
+
var FUNCTIONS = Object.create(null);
|
|
255
|
+
LIST.forEach(function (f) { if (!f.special || f.name === 'if') FUNCTIONS[f.name.toLowerCase()] = f; });
|
|
256
|
+
// 1.x name, kept so stored 1.x conditions still run. Not listed.
|
|
257
|
+
FUNCTIONS.monthnum = Object.assign({}, FUNCTIONS.month, { name: 'monthNum', hidden: true });
|
|
258
|
+
|
|
259
|
+
function lookup(name) {
|
|
260
|
+
return FUNCTIONS[String(name).toLowerCase()] || null;
|
|
19
261
|
}
|
|
20
262
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
var
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
weekday: function (v) { var d = toDate(v); return d ? WEEKDAYS[d.getUTCDay()] : null; } // 'Monday'
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
function registerFunction(name, fn) {
|
|
40
|
-
if (typeof fn !== 'function') throw new Error('registerFunction: fn must be a function');
|
|
41
|
-
FUNCTIONS[name] = fn;
|
|
263
|
+
// registerFunction(entry) or, as in 1.x, registerFunction(name, fn).
|
|
264
|
+
function registerFunction(entryOrName, fn) {
|
|
265
|
+
var entry = typeof entryOrName === 'string'
|
|
266
|
+
? { name: entryOrName, fn: fn, category: 'Custom', args: '', description: '', example: '' }
|
|
267
|
+
: Object.assign({}, entryOrName);
|
|
268
|
+
if (!entry.name || typeof entry.fn !== 'function') throw new Error('registerFunction: needs a name and a function');
|
|
269
|
+
if (entry.min === undefined) entry.min = 0;
|
|
270
|
+
if (entry.max === undefined) entry.max = Infinity;
|
|
271
|
+
var key = entry.name.toLowerCase();
|
|
272
|
+
if (FUNCTIONS[key] && (FUNCTIONS[key].special || FUNCTIONS[key].aggregate)) {
|
|
273
|
+
throw new Error('registerFunction: "' + entry.name + '" is built into the language');
|
|
274
|
+
}
|
|
275
|
+
var at = LIST.findIndex(function (f) { return f.name.toLowerCase() === key; });
|
|
276
|
+
if (at === -1) LIST.push(entry); else LIST[at] = entry;
|
|
277
|
+
FUNCTIONS[key] = entry;
|
|
42
278
|
}
|
|
43
279
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return fn(value);
|
|
280
|
+
/** The list the UI renders: every visible entry, in reference order. */
|
|
281
|
+
function listFunctions() {
|
|
282
|
+
return LIST.filter(function (f) { return !f.hidden; });
|
|
48
283
|
}
|
|
49
284
|
|
|
50
|
-
module.exports = { FUNCTIONS, registerFunction,
|
|
285
|
+
module.exports = { FUNCTIONS: FUNCTIONS, CATEGORIES: CATEGORIES, lookup: lookup, registerFunction: registerFunction, listFunctions: listFunctions, str: str, num: num, isEmpty: isEmpty };
|
package/lib/nlp.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// and callers can feature-detect. Wire an LLM/grammar backend here later.
|
|
8
8
|
|
|
9
9
|
function parse(_text, _options) {
|
|
10
|
-
throw new Error('nlp wrapper: not implemented yet (stub). Use
|
|
10
|
+
throw new Error('nlp wrapper: not implemented yet (stub). Use xf.parse / xf.compile or the structured JSON API.');
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
module.exports = {
|
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 };
|