@xeplr/expression-handler 2.0.0 → 2.2.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 +1 -0
- package/lib/compile.js +10 -4
- package/lib/functions.js +11 -6
- package/lib/operators.js +23 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -171,6 +171,7 @@ const f = xf.compile('sumif(fytd(order_date), revenue)', {
|
|
|
171
171
|
| `fiscalYearStart` | `4` (April) | `fytd` and any financial-year function |
|
|
172
172
|
| `today` | now | `ytd`, `fytd` — pin it for tests and scheduled runs |
|
|
173
173
|
| `missing` | `'null'` | `'error'` makes a name the row lacks an error |
|
|
174
|
+
| `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 |
|
|
174
175
|
| `maxListItems` · `maxDistinct` · `maxSteps` · `maxLevels` | see [Limits](#limits) | |
|
|
175
176
|
| `aggregate(name, args, { compile, level })` | none | supplies the rows for the group form of a total (BI) |
|
|
176
177
|
|
package/lib/compile.js
CHANGED
|
@@ -31,7 +31,8 @@ var DEFAULTS = {
|
|
|
31
31
|
maxListItems: 10000, // items in one list a total runs over
|
|
32
32
|
maxDistinct: 1000000, // distinct values one countifunique may hold
|
|
33
33
|
maxSteps: 10000000, // list items visited in one evaluation
|
|
34
|
-
maxLevels: 2
|
|
34
|
+
maxLevels: 2, // totals inside totals
|
|
35
|
+
ignoreCase: false // true: =, !=, in and the text operators ignore case
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
function konst(v) {
|
|
@@ -49,7 +50,10 @@ function compileTree(tree, options) {
|
|
|
49
50
|
var fy = Number(opts.fiscalYearStart);
|
|
50
51
|
if (!(fy >= 1 && fy <= 12 && Math.floor(fy) === fy)) fail('fiscalYearStart must be a month, 1–12');
|
|
51
52
|
|
|
52
|
-
var ctx = { now: dates.todayFor(opts), fiscalYearStart: fy };
|
|
53
|
+
var ctx = { now: dates.todayFor(opts), fiscalYearStart: fy, ignoreCase: Boolean(opts.ignoreCase) };
|
|
54
|
+
// Equality for `=`, `in` and the word operators: exact, or folded when the
|
|
55
|
+
// caller asked (opts.ignoreCase) — see operators.IGNORE_CASE.
|
|
56
|
+
var eq = opts.ignoreCase ? operators.looseEqIgnoreCase : operators.looseEq;
|
|
53
57
|
// Shared by every closure of this formula: how many list items one
|
|
54
58
|
// evaluation has visited. Reset per top-level call.
|
|
55
59
|
var state = { steps: 0 };
|
|
@@ -172,7 +176,7 @@ function compileTree(tree, options) {
|
|
|
172
176
|
// Membership compares like the 1.x `in` (18 matches "18").
|
|
173
177
|
'in': function (a, b) {
|
|
174
178
|
if (!Array.isArray(b)) return false;
|
|
175
|
-
for (var i = 0; i < b.length; i++) if (
|
|
179
|
+
for (var i = 0; i < b.length; i++) if (eq(a, b[i])) return true;
|
|
176
180
|
return false;
|
|
177
181
|
}
|
|
178
182
|
};
|
|
@@ -184,7 +188,9 @@ function compileTree(tree, options) {
|
|
|
184
188
|
return binary(c(node.left, level), c(node.right, level), sym);
|
|
185
189
|
}
|
|
186
190
|
// 1.x word operators: { left, op: 'eq' | 'contains' | 'isNull' | …, right }
|
|
187
|
-
var def = Object.prototype.hasOwnProperty.call(operators.
|
|
191
|
+
var def = opts.ignoreCase && Object.prototype.hasOwnProperty.call(operators.IGNORE_CASE, node.op)
|
|
192
|
+
? operators.IGNORE_CASE[node.op]
|
|
193
|
+
: Object.prototype.hasOwnProperty.call(operators.OPERATORS, node.op) ? operators.OPERATORS[node.op] : null;
|
|
188
194
|
if (!def) fail('Unknown operator: "' + node.op + '"');
|
|
189
195
|
if (node.left === undefined) fail('missing left operand');
|
|
190
196
|
var left = c(node.left, level);
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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; } },
|
package/lib/operators.js
CHANGED
|
@@ -59,6 +59,28 @@ OPERATORS.equals = OPERATORS.eq;
|
|
|
59
59
|
OPERATORS.notEquals = OPERATORS.neq;
|
|
60
60
|
OPERATORS.doesNotContain = OPERATORS.notContains;
|
|
61
61
|
|
|
62
|
+
// THE SAME OPERATORS, IGNORING CASE — chosen per formula (compile option
|
|
63
|
+
// `ignoreCase`), never by changing the ones above: Workflow's conditions and
|
|
64
|
+
// every 1.x caller keep exact matching. Only text is folded; numbers and
|
|
65
|
+
// null compare exactly as before.
|
|
66
|
+
function fold(v) { return typeof v === 'string' ? v.toLowerCase() : v; }
|
|
67
|
+
function looseEqIgnoreCase(a, b) { return looseEq(fold(a), fold(b)); }
|
|
68
|
+
function lower(v) { return str(v).toLowerCase(); }
|
|
69
|
+
|
|
70
|
+
var IGNORE_CASE = {
|
|
71
|
+
eq: { arity: 2, fn: function (a, b) { return looseEqIgnoreCase(a, b); } },
|
|
72
|
+
neq: { arity: 2, fn: function (a, b) { return !looseEqIgnoreCase(a, b); } },
|
|
73
|
+
contains: { arity: 2, fn: function (a, b) { return lower(a).indexOf(lower(b)) !== -1; } },
|
|
74
|
+
notContains: { arity: 2, fn: function (a, b) { return lower(a).indexOf(lower(b)) === -1; } },
|
|
75
|
+
startsWith: { arity: 2, fn: function (a, b) { return lower(a).indexOf(lower(b)) === 0; } },
|
|
76
|
+
endsWith: { arity: 2, fn: function (a, b) { var s = lower(a), t = lower(b); return t === '' || s.slice(-t.length) === t; } },
|
|
77
|
+
in: { arity: 2, fn: function (a, b) { return Array.isArray(b) && b.some(function (x) { return looseEqIgnoreCase(a, x); }); } },
|
|
78
|
+
notIn: { arity: 2, fn: function (a, b) { return !(Array.isArray(b) && b.some(function (x) { return looseEqIgnoreCase(a, x); })); } }
|
|
79
|
+
};
|
|
80
|
+
IGNORE_CASE.equals = IGNORE_CASE.eq;
|
|
81
|
+
IGNORE_CASE.notEquals = IGNORE_CASE.neq;
|
|
82
|
+
IGNORE_CASE.doesNotContain = IGNORE_CASE.notContains;
|
|
83
|
+
|
|
62
84
|
function registerOperator(name, def) {
|
|
63
85
|
if (typeof def === 'function') def = { arity: 2, fn: def };
|
|
64
86
|
if (!def || typeof def.fn !== 'function') throw new Error('registerOperator: needs a function or { arity, fn }');
|
|
@@ -66,4 +88,4 @@ function registerOperator(name, def) {
|
|
|
66
88
|
OPERATORS[name] = def;
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
module.exports = { OPERATORS, registerOperator, looseEq, num, str, isNil };
|
|
91
|
+
module.exports = { OPERATORS, IGNORE_CASE, registerOperator, looseEq, looseEqIgnoreCase, num, str, isNil };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/expression-handler",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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": [
|