@xeplr/expression-handler 2.1.0 → 2.3.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 CHANGED
@@ -29,7 +29,9 @@ f({ country: 'India', score: 600, referred: true }); // → 'India payroll'
29
29
  | `xf.toText(tree)` | the tree back as text, for an editor showing a stored formula |
30
30
  | `xf.check(text, context?)` | `{ ok, tree, error, position }` — never throws; for editors |
31
31
  | `xf.analyze(textOrTree)` | `{ fields, fieldsOutsideTotals, thisrow, functions, aggregates, levels }` |
32
- | `xf.functions()` | the function list, for pickers and help |
32
+ | `xf.functions()` | the function list, for pickers and help; each entry carries `params` |
33
+ | `xf.usage(name, { rows }?)` | how to call it: `{ signature, params: [{ name, about, example }], description, example, min, max }`; `rows: false` for a total that names its list first. For signature help, a reference, an error |
34
+ | `xf.arityMessage(name, given, { rows }?)` | the words for a wrong argument count, built from `usage` |
33
35
  | `xf.implementations(context?)` | `{ name: fn }` ready to call, for a caller that wants the functions without the language |
34
36
  | `xf.registerFunction(entry)` | adds one to the list |
35
37
  | `xf.evaluate(condition, row)` | 1.x: a condition → `true` / `false` |
@@ -132,6 +134,23 @@ Each function is one entry: its code **and** what the UI shows.
132
134
  validation, and Workflow's condition picker are all built from it.
133
135
  `xf.registerFunction(entry)` adds one.
134
136
 
137
+ **Every argument is documented** — its name, what it is, and an example
138
+ (`PARAMS` in `lib/functions.js`; a test fails for a function without them).
139
+ `xf.usage(name)` gives them to an editor's signature help, and a call with the
140
+ wrong number of arguments says exactly what it needs:
141
+
142
+ ```
143
+ sumif(condition, value) needs 2 arguments, not 1. condition: Which rows to add:
144
+ a test that is true or false for each row (e.g. status = "Paid"). value: What to
145
+ add up for those rows — a column or a calculation (e.g. amount).
146
+ Example: sumif(status = "Refunded", amount)
147
+ ```
148
+
149
+ The words follow the caller: over a report group's rows, a total is
150
+ `sumif(condition, value)`; where it names its list (Workflow, a `compile`
151
+ without `aggregate`), `sumif(list, condition, value)`. Why: "takes 2 to 3
152
+ arguments" named neither the arguments nor which form applied.
153
+
135
154
  | Category | Functions |
136
155
  |---|---|
137
156
  | Math | `round` `abs` `ceil` `floor` `sqrt` `min` `max` `pow` `trunc` `exp` `ln` `log` `log10` `log2` `cbrt` |
@@ -171,7 +190,7 @@ const f = xf.compile('sumif(fytd(order_date), revenue)', {
171
190
  | `fiscalYearStart` | `4` (April) | `fytd` and any financial-year function |
172
191
  | `today` | now | `ytd`, `fytd` — pin it for tests and scheduled runs |
173
192
  | `missing` | `'null'` | `'error'` makes a name the row lacks an error |
174
- | `ignoreCase` | `false` | `true` makes `=`, `!=`, `in` and the text operators (`contains`, `startsWith`, `endsWith`, `notIn`, …) ignore case. Numbers and null compare exactly as before. BI's report formulas set it, so a formula and the SQL it is translated into agree whatever the database's collation; Workflow's conditions leave it off |
193
+ | `ignoreCase` | `false` | `true` makes `=`, `!=`, `in`, the text operators (`contains`, `startsWith`, `endsWith`, `notIn`, …) and the functions `contains()`, `startsWith()`, `endsWith()` ignore case. Numbers and null compare exactly as before. BI's report formulas set it, so a formula and the SQL it is translated into agree whatever the database's collation; Workflow's conditions leave it off |
175
194
  | `maxListItems` · `maxDistinct` · `maxSteps` · `maxLevels` | see [Limits](#limits) | |
176
195
  | `aggregate(name, args, { compile, level })` | none | supplies the rows for the group form of a total (BI) |
177
196
 
package/index.js CHANGED
@@ -53,6 +53,12 @@ function contextKey(options) {
53
53
  // key to build: this is the path a caller passing text per row takes.
54
54
  var plainCache = new Map();
55
55
 
56
+ // A total's words in an error follow the caller: over a report's rows when it
57
+ // supplies them (options.aggregate), else naming a list first.
58
+ function parseOptions(options) {
59
+ return { rows: Boolean(options && typeof options.aggregate === 'function') };
60
+ }
61
+
56
62
  function compile(formula, options) {
57
63
  if (typeof formula === 'string' && options === undefined) {
58
64
  var plain = plainCache.get(formula);
@@ -64,11 +70,11 @@ function compile(formula, options) {
64
70
  }
65
71
  var key = contextKey(options);
66
72
  if (typeof formula === 'string') {
67
- if (key === null) return compileTree(parse(formula), options);
73
+ if (key === null) return compileTree(parse(formula, parseOptions(options)), options);
68
74
  var k = key + '\u0000' + formula;
69
75
  var hit = textCache.get(k);
70
76
  if (hit) return hit;
71
- var f = compileTree(parse(formula), options);
77
+ var f = compileTree(parse(formula, parseOptions(options)), options);
72
78
  if (textCache.size >= CACHE_MAX) textCache.clear();
73
79
  textCache.set(k, f);
74
80
  return f;
@@ -149,6 +155,10 @@ module.exports = {
149
155
  },
150
156
 
151
157
  functions: fns.listFunctions,
158
+ // How to call one: its signature and every argument named, explained and
159
+ // exemplified — for signature help, a reference list, an error.
160
+ usage: fns.usage,
161
+ arityMessage: fns.arityMessage,
152
162
  implementations: implementations,
153
163
  CATEGORIES: fns.CATEGORIES,
154
164
  registerFunction: registerFunction,
package/lib/compile.js CHANGED
@@ -50,7 +50,7 @@ function compileTree(tree, options) {
50
50
  var fy = Number(opts.fiscalYearStart);
51
51
  if (!(fy >= 1 && fy <= 12 && Math.floor(fy) === fy)) fail('fiscalYearStart must be a month, 1–12');
52
52
 
53
- var ctx = { now: dates.todayFor(opts), fiscalYearStart: fy };
53
+ var ctx = { now: dates.todayFor(opts), fiscalYearStart: fy, ignoreCase: Boolean(opts.ignoreCase) };
54
54
  // Equality for `=`, `in` and the word operators: exact, or folded when the
55
55
  // caller asked (opts.ignoreCase) — see operators.IGNORE_CASE.
56
56
  var eq = opts.ignoreCase ? operators.looseEqIgnoreCase : operators.looseEq;
@@ -133,7 +133,7 @@ function compileTree(tree, options) {
133
133
  var entry = functions.lookup(name);
134
134
  if (!entry || entry.special) fail('Unknown function "' + name + '"');
135
135
  if (entry.aggregate) fail(entry.name + '() must be written as a call, not a { fn } operand');
136
- if (args.length < entry.min || args.length > entry.max) fail(entry.name + '() takes ' + entry.min + (entry.max === entry.min ? '' : '–' + entry.max) + ' arguments, not ' + args.length);
136
+ if (args.length < entry.min || args.length > entry.max) fail(functions.arityMessage(entry.name, args.length));
137
137
  var fn = entry.fn;
138
138
  if (entry.context) {
139
139
  var inner = fn;
@@ -253,7 +253,8 @@ function compileTree(tree, options) {
253
253
  }
254
254
  return opts.aggregate(entry.name, args, { level: inner, compile: function (n) { return c(n, inner); } });
255
255
  }
256
- if (args.length !== implicit + 1) fail(entry.name + '() takes ' + implicit + ' or ' + (implicit + 1) + ' arguments, not ' + args.length);
256
+ // The words for the form this caller writes: a report's rows, or a list.
257
+ if (args.length !== implicit + 1) fail(functions.arityMessage(entry.name, args.length, { rows: typeof opts.aggregate === 'function' }));
257
258
 
258
259
  var list = c(args[0], level);
259
260
  var cond = entry.shape.cond ? c(args[1], inner) : null;
package/lib/functions.js CHANGED
@@ -28,6 +28,9 @@ var asDate = dates.asDate;
28
28
 
29
29
  var CATEGORIES = ['Math', 'Text', 'Date', 'Logic', 'Aggregate'];
30
30
 
31
+ // Text for a comparison: lower-cased when the formula ignores case.
32
+ function fold(c, v) { var s = str(v); return c && c.ignoreCase ? s.toLowerCase() : s; }
33
+
31
34
  function str(v) { return v === null || v === undefined ? '' : String(v); }
32
35
  function num(v) { var n = Number(v); return isNaN(n) ? null : n; }
33
36
  function isEmpty(v) { return v === null || v === undefined || v === ''; }
@@ -121,15 +124,17 @@ var LIST = [
121
124
  { name: 'replace', category: 'Text', args: 'text, find, replace with', min: 3, max: 3,
122
125
  description: 'Swap one piece of text for another', example: 'replace(phone, "-", "")',
123
126
  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,
127
+ // context: the compile option ignoreCase folds both sides, as it does for
128
+ // = and in — one rule for every text comparison in a formula.
129
+ { name: 'contains', category: 'Text', args: 'text, find', min: 2, max: 2, context: true,
125
130
  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,
131
+ fn: function (c, v, sub) { return fold(c, v).indexOf(fold(c, sub)) !== -1; } },
132
+ { name: 'startsWith', category: 'Text', args: 'text, find', min: 2, max: 2, context: true,
128
133
  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,
134
+ fn: function (c, v, sub) { return fold(c, v).startsWith(fold(c, sub)); } },
135
+ { name: 'endsWith', category: 'Text', args: 'text, find', min: 2, max: 2, context: true,
131
136
  description: 'True when the text ends with it', example: 'endsWith(file, ".pdf")',
132
- fn: function (v, sub) { return str(v).endsWith(str(sub)); } },
137
+ fn: function (c, v, sub) { return fold(c, v).endsWith(fold(c, sub)); } },
133
138
  { name: 'length', category: 'Text', args: 'text', min: 1, max: 1,
134
139
  description: 'How many characters', example: 'length(name)',
135
140
  fn: function (v) { return Array.isArray(v) ? v.length : str(v).length; } },
@@ -239,16 +244,118 @@ var LIST = [
239
244
  { name: 'sum', category: 'Aggregate', args: 'value', min: 1, max: 2, aggregate: true, shape: { cond: false, value: true },
240
245
  description: 'Total across the rows in this group', example: 'sum(amount)' },
241
246
  { 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)' },
247
+ description: 'Total across the rows that match', example: 'sumif(status = "Refunded", amount)' },
243
248
  { name: 'countif', category: 'Aggregate', args: 'condition', min: 1, max: 2, aggregate: true, shape: { cond: true, value: false },
244
249
  description: 'How many rows match', example: 'countif(qty > 10)' },
245
250
  { 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)' },
251
+ description: 'How many different values among the rows that match', example: 'countifunique(status = "Paid", customer_id)' },
247
252
  // Not a function — a reference, usable inside any aggregate's condition.
248
253
  { 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)' }
254
+ description: 'This row\'s own value, so a total can cover every row sharing it', example: 'sumif(month = $thisrow.month, revenue)' }
250
255
  ];
251
256
 
257
+ // ── What each argument IS ───────────────────────────────────────────────
258
+ // [name, what it is, an example]. Read by usage() — the signature help in an
259
+ // editor, the reference list and every "wrong number of arguments" message
260
+ // come from here, so they cannot disagree. Optional ones say so; `…` repeats.
261
+ var PARAMS = {
262
+ round: [['value', 'The number to round', 'amount'], ['places', 'Optional. Decimal places to keep; 0 when left out', '2']],
263
+ abs: [['value', 'A number', 'variance']],
264
+ ceil: [['value', 'A number', 'days']],
265
+ floor: [['value', 'A number', 'days']],
266
+ sqrt: [['value', 'A number, 0 or more', 'area']],
267
+ min: [['value …', 'Two or more values to compare, on the same row', 'price, cap']],
268
+ max: [['value …', 'Two or more values to compare, on the same row', 'price, floor_price']],
269
+ trunc: [['value', 'A number', 'hours']],
270
+ exp: [['value', 'The power to raise e to', 'rate']],
271
+ ln: [['value', 'A number above 0', 'growth']],
272
+ log: [['value', 'A number above 0', 'growth']],
273
+ log10: [['value', 'A number above 0', 'size']],
274
+ log2: [['value', 'A number above 0', 'size']],
275
+ cbrt: [['value', 'A number', 'volume']],
276
+ pow: [['value', 'The number to raise', 'rate'], ['exponent', 'The power to raise it to', 'years']],
277
+ upper: [['text', 'The text to change', 'city']],
278
+ lower: [['text', 'The text to change', 'email']],
279
+ trim: [['text', 'The text to clean', 'name']],
280
+ concat: [['value …', 'The pieces to join, in order — columns or "text in quotes"', 'first, " ", last']],
281
+ substring: [['text', 'The text to take from', 'code'], ['start', 'Where to start; the first character is 1', '1'], ['count', 'Optional. How many characters; to the end when left out', '3']],
282
+ left: [['text', 'The text to take from', 'deptCode'], ['count', 'How many characters from the start', '3']],
283
+ right: [['text', 'The text to take from', 'invoice'], ['count', 'How many characters from the end', '4']],
284
+ replace: [['text', 'The text to change', 'phone'], ['find', 'What to look for, in quotes', '"-"'], ['replace with', 'What to put instead, in quotes ("" removes it)', '""']],
285
+ contains: [['text', 'The text to look in', 'notes'], ['find', 'What to look for, in quotes; capitals ignored', '"urgent"']],
286
+ startsWith: [['text', 'The text to look at', 'sku'], ['find', 'What it should begin with, in quotes; capitals ignored', '"IN"']],
287
+ endsWith: [['text', 'The text to look at', 'file'], ['find', 'What it should end with, in quotes; capitals ignored', '".pdf"']],
288
+ length: [['text', 'The text to count', 'name']],
289
+ year: [['date', 'A date column', 'order_date']],
290
+ month: [['date', 'A date column', 'order_date']],
291
+ day: [['date', 'A date column', 'order_date']],
292
+ quarter: [['date', 'A date column', 'order_date']],
293
+ weekday: [['date', 'A date column', 'order_date']],
294
+ datediff: [['from', 'The earlier date', 'ordered'], ['to', 'The later date; days from "from" to this', 'shipped']],
295
+ dateadd: [['date', 'The date to move', 'order_date'], ['days', 'How many days to add; negative goes back', '30']],
296
+ monthname: [['date', 'A date column, or a month number 1–12', 'order_date']],
297
+ monthkey: [['date', 'A date column', 'order_date']],
298
+ quarterkey: [['date', 'A date column', 'order_date']],
299
+ daykey: [['date', 'A date column', 'order_date']],
300
+ ytd: [['date', 'The date column that decides the year', 'order_date']],
301
+ fytd: [['date', 'The date column that decides the financial year', 'order_date']],
302
+ if: [['condition', 'A test that is true or false', 'qty > 10'], ['then', 'The result when it is true', '"bulk"'], ['otherwise', 'The result when it is false', '"single"']],
303
+ coalesce: [['value …', 'Values to try in order; the first that is not empty wins', 'nickname, name']],
304
+ ifnull: [['value', 'The value to use', 'discount'], ['fallback', 'What to use when it is empty', '0']],
305
+ isnull: [['value', 'The value to test', 'closed_date']],
306
+ between: [['value', 'The value to test', 'score'], ['low', 'The lowest allowed, included', '600'], ['high', 'The highest allowed, included', '750']],
307
+ sum: [['value', 'What to add up for each row of the group — a column or a calculation', 'amount']],
308
+ sumif: [['condition', 'Which rows to add: a test that is true or false for each row', 'status = "Paid"'], ['value', 'What to add up for those rows — a column or a calculation', 'amount']],
309
+ countif: [['condition', 'Which rows to count: a test that is true or false for each row', 'qty > 10']],
310
+ countifunique: [['condition', 'Which rows to look at: a test that is true or false for each row', 'status = "Paid"'], ['value', 'What to count the different values of', 'customer_id']]
311
+ };
312
+ // Written first when a total names its own list (outside a report).
313
+ var LIST_PARAM = ['list', 'The list to go through — outside a report a total names it first', 'orders'];
314
+
315
+ function paramsOf(entry) {
316
+ return (PARAMS[entry.name] || []).map(function (p) { return { name: p[0], about: p[1], example: p[2] }; });
317
+ }
318
+
319
+ /**
320
+ * How to call a function, with every argument named and explained.
321
+ *
322
+ * @param name the function's name
323
+ * @param options { rows: true } — a total over the rows of a report group (the
324
+ * default for a total); { rows: false } — a total that names
325
+ * its list first, sumif(orders, condition, value)
326
+ * @returns {{ name, signature, params: [{ name, about, example }], description,
327
+ * example, min, max }} or null for a name that is no function
328
+ */
329
+ function usage(name, options) {
330
+ var entry = lookup(name);
331
+ if (!entry) return null;
332
+ var params = paramsOf(entry);
333
+ var listed = entry.aggregate && options && options.rows === false;
334
+ if (listed) params = [{ name: LIST_PARAM[0], about: LIST_PARAM[1], example: LIST_PARAM[2] }].concat(params);
335
+ var count = entry.aggregate ? params.length : null;
336
+ return {
337
+ name: entry.name,
338
+ signature: entry.name + '(' + params.map(function (p) { return p.name; }).join(', ') + ')',
339
+ params: params,
340
+ description: entry.description || '',
341
+ example: listed ? entry.name + '(' + params.map(function (p) { return p.example; }).join(', ') + ')' : (entry.example || ''),
342
+ min: count === null ? entry.min : count,
343
+ max: count === null ? entry.max : count
344
+ };
345
+ }
346
+
347
+ /**
348
+ * The words for a call with the wrong number of arguments: the signature,
349
+ * how many it needs, how many it got, and each argument with an example.
350
+ */
351
+ function arityMessage(name, given, options) {
352
+ var u = usage(name, options);
353
+ var need = u.min === u.max ? String(u.min) : u.max === Infinity ? u.min + ' or more' : u.max === u.min + 1 ? u.min + ' or ' + u.max : u.min + ' to ' + u.max;
354
+ return u.signature + ' needs ' + need + ' argument' + (need === '1' ? '' : 's') + ', not ' + given + '. ' +
355
+ u.params.map(function (p) { return p.name + ': ' + p.about + ' (e.g. ' + p.example + ')'; }).join('. ') +
356
+ (u.example ? '. Example: ' + u.example : '');
357
+ }
358
+
252
359
  // Lookup by lower-cased name. A null-prototype map, so no formula can reach
253
360
  // Object.prototype through a function name.
254
361
  var FUNCTIONS = Object.create(null);
@@ -279,7 +386,9 @@ function registerFunction(entryOrName, fn) {
279
386
 
280
387
  /** The list the UI renders: every visible entry, in reference order. */
281
388
  function listFunctions() {
282
- return LIST.filter(function (f) { return !f.hidden; });
389
+ return LIST.filter(function (f) { return !f.hidden; }).map(function (f) {
390
+ return f.special && f.name !== 'if' ? f : Object.assign({}, f, { params: f.params || paramsOf(f) });
391
+ });
283
392
  }
284
393
 
285
- module.exports = { FUNCTIONS: FUNCTIONS, CATEGORIES: CATEGORIES, lookup: lookup, registerFunction: registerFunction, listFunctions: listFunctions, str: str, num: num, isEmpty: isEmpty };
394
+ module.exports = { FUNCTIONS: FUNCTIONS, CATEGORIES: CATEGORIES, lookup: lookup, usage: usage, arityMessage: arityMessage, registerFunction: registerFunction, listFunctions: listFunctions, str: str, num: num, isEmpty: isEmpty };
package/lib/parse.js CHANGED
@@ -110,8 +110,12 @@ function tokenize(src) {
110
110
  }
111
111
 
112
112
  // ── grammar ───────────────────────────────────────────────────────────────
113
- function parse(src) {
113
+ // options.rows: false when a total names its list first (Workflow);
114
+ // otherwise a total is over a report group's rows — sumif(condition, value).
115
+ // Only the WORDS of an error depend on it.
116
+ function parse(src, options) {
114
117
  if (typeof src !== 'string') throw new Error('parse: formula must be text');
118
+ var rows = !(options && options.rows === false);
115
119
  if (!src.trim()) fail('The formula is empty');
116
120
  var toks = tokenize(src);
117
121
  var pos = 0;
@@ -285,10 +289,7 @@ function parse(src) {
285
289
  while (isPunct(',')) { next(); args.push(conditional()); }
286
290
  }
287
291
  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 (args.length < entry.min || args.length > entry.max) fail(functions.arityMessage(entry.name, args.length, { rows: rows }), nameTok.at);
292
293
  if (entry.name === 'if') return { if: args[0], then: args[1], else: args[2] };
293
294
  return { call: entry.name, args: args };
294
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/expression-handler",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
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": [