@xeplr/expression-handler 1.0.6 → 2.1.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 +303 -67
- package/index.js +151 -36
- package/lib/analyze.js +71 -0
- package/lib/compile.js +329 -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/operators.js +23 -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/README.md
CHANGED
|
@@ -1,103 +1,339 @@
|
|
|
1
1
|
# @xeplr/expression-handler
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
combinators (by design — level 1 only). Zero dependencies.
|
|
3
|
+
> **Status: 2.0 built, not released.** The code in this repo is 2.0; npm still
|
|
4
|
+
> has 1.x, which does less — see [What changes from 1.x](#what-changes-from-1x).
|
|
5
|
+
> BI and Workflow move over next ([Moving callers over](#moving-callers-over)).
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
**The one formula engine for xeplr.** Every formula that is calculated in
|
|
8
|
+
JavaScript — BI report formulas, BI text templates, Workflow conditions — is
|
|
9
|
+
read, checked and run by this package, with one list of functions. Functions
|
|
10
|
+
that a **database** runs (query-engine ranking, SQL formulas, warehouse cube
|
|
11
|
+
aggregates) are not formulas in this sense and stay where they are.
|
|
12
|
+
|
|
13
|
+
Zero dependencies. No `eval`, no `new Function`. Runs the same in Node and the
|
|
14
|
+
browser.
|
|
9
15
|
|
|
10
16
|
```js
|
|
11
17
|
const xf = require('@xeplr/expression-handler');
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
{ left: { fn: 'upper', field: 'name' }, op: 'eq', right: { fn: 'upper', field: 'nickName' } },
|
|
16
|
-
{ name: 'ada', nickName: 'ADA' }
|
|
17
|
-
); // → true
|
|
18
|
-
|
|
19
|
-
// month(createdAt) = 'January'
|
|
20
|
-
xf.evaluate(
|
|
21
|
-
{ left: { fn: 'month', field: 'createdAt' }, op: 'eq', right: { value: 'January' } },
|
|
22
|
-
{ createdAt: '2026-01-15T00:00:00Z' }
|
|
23
|
-
); // → true
|
|
24
|
-
|
|
25
|
-
// Compile once, filter many (drops into a streaming .filter too)
|
|
26
|
-
const isAdult = xf.toPredicate({ left: { field: 'age' }, op: 'gte', right: { value: 18 } });
|
|
27
|
-
rows.filter(isAdult);
|
|
19
|
+
const f = xf.compile('if(country = "India" and (score >= 750 or referred), "India payroll", "Offer")');
|
|
20
|
+
f({ country: 'India', score: 600, referred: true }); // → 'India payroll'
|
|
28
21
|
```
|
|
29
22
|
|
|
30
|
-
##
|
|
23
|
+
## API
|
|
31
24
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
| Call | Gives |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `xf.compile(textOrTree, context?)` | `(row) → value`. Cached by text and context. |
|
|
28
|
+
| `xf.parse(text)` | the JSON tree (what gets stored); throws with the position |
|
|
29
|
+
| `xf.toText(tree)` | the tree back as text, for an editor showing a stored formula |
|
|
30
|
+
| `xf.check(text, context?)` | `{ ok, tree, error, position }` — never throws; for editors |
|
|
31
|
+
| `xf.analyze(textOrTree)` | `{ fields, fieldsOutsideTotals, thisrow, functions, aggregates, levels }` |
|
|
32
|
+
| `xf.functions()` | the function list, for pickers and help |
|
|
33
|
+
| `xf.implementations(context?)` | `{ name: fn }` ready to call, for a caller that wants the functions without the language |
|
|
34
|
+
| `xf.registerFunction(entry)` | adds one to the list |
|
|
35
|
+
| `xf.evaluate(condition, row)` | 1.x: a condition → `true` / `false` |
|
|
36
|
+
| `xf.toPredicate` · `xf.validate` · `xf.formula.*` · `xf.registerOperator` | 1.x, kept |
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
## Who uses it
|
|
37
39
|
|
|
38
|
-
|
|
|
40
|
+
| Caller | What for |
|
|
39
41
|
|---|---|
|
|
40
|
-
|
|
|
41
|
-
| `{
|
|
42
|
-
| `
|
|
43
|
-
|
|
|
42
|
+
| BI report-engine | per-row formulas, conditional totals (`sumif`…), the formula reference / autocomplete |
|
|
43
|
+
| BI text templates | `{{ expr }}` in board text |
|
|
44
|
+
| Workflow (`@xeplr/workflow`) | the condition on each arrow (`xf.evaluate(condition, row)`) |
|
|
45
|
+
| Workflow designer (`@xeplr/ui-workflow`) | formula text → stored JSON (`xf.parse`) |
|
|
46
|
+
|
|
47
|
+
## The language
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
Written the way BI formulas are written today (expr-eval's syntax), so every
|
|
50
|
+
saved BI formula keeps working, with the changes listed below.
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
| | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Numbers, text | `42` `3.5` `1e3` `"text"` `'text'` (a doubled quote or a backslash escapes) |
|
|
55
|
+
| Constants | `true` `false` `null` |
|
|
56
|
+
| Columns / fields | `revenue`, `café`, dotted paths `form.country` · any name in brackets: `[order month]` |
|
|
57
|
+
| Maths | `+ - * / % ^` |
|
|
58
|
+
| Text join | `a \|\| b` |
|
|
59
|
+
| Compare | `=` `==` `!=` `<>` `<` `<=` `>` `>=` · `x in (1, 2, 3)` |
|
|
60
|
+
| Logic | `and` `or` `not` · `cond ? a : b` · `if(cond, a, b)` |
|
|
61
|
+
| Function call | `upper(city)`, `round(amount, 2)` — names are case-insensitive |
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
**Precedence**, lowest first — identical to expr-eval so old formulas mean what
|
|
64
|
+
they meant: `? :` → `or` → `and` → comparisons and `in` → `+ - ||` →
|
|
65
|
+
`* / %` → unary `- + not` → `^` (right-associative) → calls and paths.
|
|
66
|
+
One consequence to know: `not a = b` reads as `(not a) = b`. Write `not (a = b)`.
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
**Rules that differ from expr-eval:**
|
|
69
|
+
- `=` means **equals**. expr-eval treated it as assignment, which BI had to
|
|
70
|
+
turn into an error. There is no assignment at all here.
|
|
71
|
+
- **Equality is forgiving, however it's spelled** — `=` `==` `!=` `<>` are the
|
|
72
|
+
1.x `eq` / `neq`: `18 = "18"` is true, `null = null` is true. That is what
|
|
73
|
+
Workflow's stored conditions already mean, and in BI it fixes the silent
|
|
74
|
+
`false` when a database returns a number as text (Postgres `numeric`).
|
|
75
|
+
expr-eval's `==` was strict.
|
|
76
|
+
- **Nothing is never less or greater** — `null < 100` is false, as in SQL.
|
|
77
|
+
expr-eval said true, which sends a Workflow branch the wrong way.
|
|
78
|
+
- `||` stays **text join**, so old formulas keep working. OR is the word `or`.
|
|
79
|
+
- **A name followed by `(` is always a function; a bare name is always a
|
|
80
|
+
column.** A column called `month` and the function `month()` no longer clash
|
|
81
|
+
(BI's `formulaScope` workaround goes away).
|
|
82
|
+
- `if()`, `? :`, `and`, `or` **only evaluate what they need**. expr-eval
|
|
83
|
+
evaluated both branches of every `if`.
|
|
55
84
|
|
|
56
|
-
|
|
85
|
+
**Brackets name a column exactly.** `[order month]`, `[2024 sales]`, `[and]`,
|
|
86
|
+
`[form.country]` (one key that contains a dot) — whatever is inside is the
|
|
87
|
+
column's name, never a keyword, number or path. `]` inside a name is written
|
|
88
|
+
`]]`. Paths combine them: `form.[first name]`. Bare names still work for plain
|
|
89
|
+
ones (`revenue`); the column picker inserts brackets whenever a name needs
|
|
90
|
+
them — a space or symbol, a leading digit, a dot, or a keyword. BI's
|
|
91
|
+
`$thisrow["col"]` keeps working alongside `$thisrow.[col]`.
|
|
57
92
|
|
|
58
|
-
|
|
59
|
-
|
|
93
|
+
**Lists are written in parentheses** — `status in ("paid", "refunded")` — because
|
|
94
|
+
brackets now mean a column. (expr-eval wrote lists as `[ … ]`; no BI formula
|
|
95
|
+
in the code or tests uses them, and the side-by-side test checks saved ones.)
|
|
60
96
|
|
|
61
|
-
|
|
97
|
+
**A name the row doesn't have** reads as `null`. With `{ missing: 'error' }`
|
|
98
|
+
in the context it is an error instead — `Unknown column "revnue"` — which is
|
|
99
|
+
what a report editor wants (expr-eval raised it too).
|
|
100
|
+
|
|
101
|
+
Errors say where: `Expected ")" at character 18`.
|
|
102
|
+
|
|
103
|
+
## Stored form
|
|
104
|
+
|
|
105
|
+
A formula is text for people and a **JSON tree** for storage and running.
|
|
106
|
+
`xf.parse(text)` gives the tree; `xf.compile(textOrTree)` gives a function.
|
|
107
|
+
The 1.x structured shape is a valid tree, so existing Workflow conditions load
|
|
108
|
+
unchanged:
|
|
62
109
|
|
|
63
110
|
```js
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
{ left: { field: 'age' }, op: 'gte', right: { value: 18 } } // 1.x — still valid
|
|
112
|
+
{ op: 'eq', left: { field: 'country' }, right: { value: 'India' } } // what `country = "India"` parses to
|
|
113
|
+
|
|
114
|
+
{ all: [ <condition>, <condition>, { any: [ <condition>, … ] } ] } // AND / OR groups (new)
|
|
115
|
+
{ not: <condition> }
|
|
66
116
|
```
|
|
67
117
|
|
|
68
|
-
|
|
118
|
+
The Workflow designer's AND / OR boxes write `all` / `any` groups directly; a
|
|
119
|
+
condition can also be a formula.
|
|
120
|
+
|
|
121
|
+
## Functions — the one list
|
|
69
122
|
|
|
70
|
-
|
|
123
|
+
Each function is one entry: its code **and** what the UI shows.
|
|
71
124
|
|
|
72
125
|
```js
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
126
|
+
{ name: 'round', category: 'Math', args: 'value, places',
|
|
127
|
+
description: 'Round to a number of decimal places', example: 'round(amount, 2)',
|
|
128
|
+
fn: (v, places) => … }
|
|
129
|
+
```
|
|
77
130
|
|
|
78
|
-
|
|
79
|
-
|
|
131
|
+
`xf.functions()` returns the list; BI's reference panel, autocomplete and
|
|
132
|
+
validation, and Workflow's condition picker are all built from it.
|
|
133
|
+
`xf.registerFunction(entry)` adds one.
|
|
134
|
+
|
|
135
|
+
| Category | Functions |
|
|
136
|
+
|---|---|
|
|
137
|
+
| Math | `round` `abs` `ceil` `floor` `sqrt` `min` `max` `pow` `trunc` `exp` `ln` `log` `log10` `log2` `cbrt` |
|
|
138
|
+
| Text | `upper` `lower` `trim` `concat` `substring` `left` `right` `replace` `contains` `startsWith` `endsWith` `length` |
|
|
139
|
+
| Date | `year` `month` `day` `quarter` `weekday` `datediff` `dateadd` `monthname` `monthkey` `quarterkey` `daykey` `ytd` `fytd` |
|
|
140
|
+
| Logic | `if` `coalesce` `ifnull` `isnull` `between` |
|
|
141
|
+
| Aggregate | `sum` `sumif` `countif` `countifunique` — see [Array formulas](#array-formulas) |
|
|
142
|
+
|
|
143
|
+
`trunc` … `cbrt` were expr-eval built-ins BI never listed; they're listed now
|
|
144
|
+
so saved formulas using them keep working. `log` is the **natural** log there,
|
|
145
|
+
kept that way.
|
|
146
|
+
|
|
147
|
+
**Behaviour — BI's rules, adopted everywhere (agreed):**
|
|
148
|
+
- **Every function is total:** nonsense in → `null` out, never an exception.
|
|
149
|
+
- **Text of nothing is empty:** `upper(null)` → `''`, never `"null"`.
|
|
150
|
+
- **Empty counts as missing:** `coalesce('', x)` → `x`; `0` is not missing.
|
|
151
|
+
- **Dates:** a date-only string `2026-05-01` is that calendar day in any time
|
|
152
|
+
zone (built from its parts, as a database driver does). `month()` is
|
|
153
|
+
`1`–`12`; `monthname()` is `'May'`. The financial year comes from the
|
|
154
|
+
[run context](#run-context) — April when none is given.
|
|
155
|
+
- **Spreadsheet counting:** `substring(x, 1, 3)` counts from 1.
|
|
156
|
+
|
|
157
|
+
## Run context
|
|
158
|
+
|
|
159
|
+
Some answers depend on **who** the formula runs for, which the engine can't
|
|
160
|
+
know. The caller passes them in:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
const f = xf.compile('sumif(fytd(order_date), revenue)', {
|
|
164
|
+
fiscalYearStart: 7, // July — the workspace's / company's setting
|
|
165
|
+
today: '2026-09-19' // optional; defaults to the current date
|
|
166
|
+
});
|
|
80
167
|
```
|
|
81
168
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`
|
|
86
|
-
`
|
|
87
|
-
|
|
169
|
+
| Key | Default | Used by |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `fiscalYearStart` | `4` (April) | `fytd` and any financial-year function |
|
|
172
|
+
| `today` | now | `ytd`, `fytd` — pin it for tests and scheduled runs |
|
|
173
|
+
| `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 |
|
|
175
|
+
| `maxListItems` · `maxDistinct` · `maxSteps` · `maxLevels` | see [Limits](#limits) | |
|
|
176
|
+
| `aggregate(name, args, { compile, level })` | none | supplies the rows for the group form of a total (BI) |
|
|
88
177
|
|
|
89
|
-
|
|
178
|
+
**The financial year is a tenant setting.** It belongs to the workspace or
|
|
179
|
+
company, whichever level the app has, and the **app** stores and resolves it
|
|
180
|
+
(BI: the workspace setting; a generated app: its tenancy level). This package
|
|
181
|
+
only receives the value. The context is part of the compile cache key, so two
|
|
182
|
+
tenants with different years never share a compiled formula.
|
|
90
183
|
|
|
91
|
-
##
|
|
184
|
+
## Array formulas
|
|
92
185
|
|
|
93
|
-
|
|
94
|
-
(`xf.nlp.implemented === false`; `xf.nlp.parse()` throws). Placeholder surface for
|
|
95
|
-
a future LLM/grammar backend.
|
|
186
|
+
A formula that adds up **many rows** into one value.
|
|
96
187
|
|
|
97
|
-
|
|
188
|
+
**Where the rows come from:**
|
|
189
|
+
- **BI** — the rows of the current group, implicitly:
|
|
190
|
+
`sumif(status = "paid", amount)`. The engine can't see a report's groups, so
|
|
191
|
+
the caller supplies them through the `aggregate` hook in the context; without
|
|
192
|
+
it this form is an error that shows the list form.
|
|
193
|
+
- **Workflow and anything else** — a named list, as the first argument:
|
|
194
|
+
`sumif(orders, month(order_date) = 8, revenue)`. The engine runs these itself,
|
|
195
|
+
as one loop over the list. Inside the condition and the value, names read the
|
|
196
|
+
list's item.
|
|
197
|
+
|
|
198
|
+
**`$thisrow`** refers to the row the formula is being worked out for, from
|
|
199
|
+
inside a condition that runs over other rows:
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
if(sumif(city = $thisrow.city and month(order_date) = 8, revenue) > 10000, "High August city", "Normal")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
— "revenue for **my** city in August; over 10K?".
|
|
206
|
+
|
|
207
|
+
**Levels.** A total inside another total is one level deeper ("the highest of
|
|
208
|
+
each city's August total" is 2). The `if` and `> 10000` around a total don't
|
|
209
|
+
count. **Maximum: 2 levels** (agreed).
|
|
210
|
+
|
|
211
|
+
### Memory rules
|
|
212
|
+
|
|
213
|
+
1. **Streaming, never collecting.** Rows arrive one at a time; each total is a
|
|
214
|
+
running number (sum, count, min, max, avg) that the row is added to and
|
|
215
|
+
then forgotten. Memory depends on the number of **groups**, not rows.
|
|
216
|
+
2. **One loop per total, no temporary lists.** `sumif`, and any
|
|
217
|
+
filter-then-total chain, compiles into a single loop.
|
|
218
|
+
3. **`$thisrow` totals run on the result rows** (grouped, small). If one is
|
|
219
|
+
ever needed on raw rows, the query runs twice rather than holding the rows.
|
|
220
|
+
4. **Only distinct values grow with the data** — `countifunique` and
|
|
221
|
+
`$thisrow` partitions keep one entry per distinct key, under a hard cap
|
|
222
|
+
(below) that ends with a clear error, not an out-of-memory crash.
|
|
223
|
+
5. **Totals are mergeable** (two part-totals add into one), which keeps a later
|
|
224
|
+
split across processes possible without redesign.
|
|
225
|
+
|
|
226
|
+
## Speed
|
|
227
|
+
|
|
228
|
+
A formula is **compiled once** into plain JavaScript closures and then run for
|
|
229
|
+
every row:
|
|
230
|
+
|
|
231
|
+
1. **Names resolved at compile time.** `revenue` becomes "read `row.revenue`",
|
|
232
|
+
`upper` a direct reference — no lookup object per row, so a row allocates
|
|
233
|
+
nothing.
|
|
234
|
+
2. **Constant parts computed once** — `upper("pune")` is folded at compile time.
|
|
235
|
+
3. **Short-circuit** — `if`, `and`, `or` run only what they need.
|
|
236
|
+
4. **Compiled once per run.** Callers compile when a run starts; `xf.compile`
|
|
237
|
+
also caches by formula text for callers that pass text per row. A test
|
|
238
|
+
asserts a report run compiles each formula exactly once.
|
|
239
|
+
5. **Date strings parsed once** — the last few parsed strings are remembered,
|
|
240
|
+
since a streamed report repeats the same dates row after row.
|
|
241
|
+
|
|
242
|
+
Single-threaded by decision: one report uses one core. If that stops being
|
|
243
|
+
enough, the answer is a move to a compiled language (e.g. Rust), not worker
|
|
244
|
+
threads.
|
|
245
|
+
|
|
246
|
+
### Measured
|
|
247
|
+
|
|
248
|
+
1M streamed rows, 4 row formulas (`revenue - cost`, `upper(trim(city))`, a
|
|
249
|
+
nested `if`, `monthname(order_date)`), median of 3 runs, 2026-09-20. Compare
|
|
250
|
+
within a column — the two columns ran under different load.
|
|
251
|
+
|
|
252
|
+
| Path | Apple M4 Pro | Docker ½ CPU / 256 MB |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| BI before 2.0 (`enrichRow` — expr-eval, re-parses per row) | 15.5 s | 35.7 s, peak 186 MB |
|
|
255
|
+
| expr-eval, parsed once | 2.2 s | 5.5 s, peak 84 MB |
|
|
256
|
+
| **2.0, compiled once** | **0.38 s** | **1.19 s, peak 62 MB** |
|
|
257
|
+
| 2.0, formula text passed per row (cache) | 0.45 s | 1.29 s, peak 64 MB |
|
|
258
|
+
| hand-written JavaScript (the ceiling) | 0.29 s | 0.89 s, peak 62 MB |
|
|
259
|
+
| August `sumif` per city — BI before 2.0 | 0.95 s | 2.3 s |
|
|
260
|
+
| **August `sumif` per city — 2.0** | **0.16 s** | **0.50 s** |
|
|
261
|
+
| August `sumif` per city — hand-written | 0.15 s | 0.44 s |
|
|
262
|
+
|
|
263
|
+
About **40× faster** than BI's path before 2.0, and within 1.3× of
|
|
264
|
+
hand-written JavaScript. `bench/run.sh` reproduces it (the expr-eval rows need
|
|
265
|
+
BI checked out beside this repo).
|
|
266
|
+
|
|
267
|
+
## Limits
|
|
268
|
+
|
|
269
|
+
| Limit | Value | When hit |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| Nesting of totals (`maxLevels`) | 2 levels | error at compile time |
|
|
272
|
+
| Distinct values (`maxDistinct`) | 1,000,000 per total | clear error, run stops |
|
|
273
|
+
| Items in one list (`maxListItems`) | 10,000 | clear error |
|
|
274
|
+
| List items visited per evaluation (`maxSteps`) | 10,000,000 | clear error — a formula can't run away |
|
|
275
|
+
|
|
276
|
+
Each is a context key, so a caller can lower (or raise) it.
|
|
277
|
+
|
|
278
|
+
## Security
|
|
279
|
+
|
|
280
|
+
- The only callable things are the functions in the list. A value in the row
|
|
281
|
+
can never be called, and a function found on a row (inherited `toString`)
|
|
282
|
+
reads as nothing.
|
|
283
|
+
- Paths through `__proto__`, `constructor` or `prototype` are refused — in
|
|
284
|
+
formula text and in stored trees.
|
|
285
|
+
- No `eval` / `new Function`, no dependencies. (This removes BI's `expr-eval`,
|
|
286
|
+
which has two unpatched high-severity advisories.)
|
|
287
|
+
|
|
288
|
+
## What changes from 1.x
|
|
289
|
+
|
|
290
|
+
2.0 is a major version. For callers of 1.x (Workflow):
|
|
291
|
+
|
|
292
|
+
| | 1.x | 2.0 |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| `month(d)` | `'January'` | `1` (`monthname` gives `'January'`) |
|
|
295
|
+
| Dates | UTC | date-only strings are calendar days; see Functions |
|
|
296
|
+
| `upper(null)` | `null` | `''` |
|
|
297
|
+
| AND / OR | none | `and` / `or` / `not`, `all` / `any` groups |
|
|
298
|
+
| Formula mode | one comparison, `if`, one function per value | the full language |
|
|
299
|
+
| Functions | 10 | the list above |
|
|
300
|
+
| `monthNum` | exists | still accepted, not listed — use `month` |
|
|
301
|
+
| `formula.parse` output | `{ left: { fn, field } … }` | the 2.0 tree (`{ call, args }`) |
|
|
302
|
+
| `functions()` | names | the full entries |
|
|
303
|
+
| `<` `>` in formula text | `gt`, as numbers | JavaScript order (ISO dates work); nothing is never less |
|
|
304
|
+
|
|
305
|
+
`evaluate(expr, row)`, `toPredicate`, `validate` and the `{ left, op, right }`
|
|
306
|
+
shape keep working.
|
|
307
|
+
|
|
308
|
+
## Moving callers over
|
|
309
|
+
|
|
310
|
+
1. **This package — done.** Engine, 63 tests (`npm test`), `bench/`, and
|
|
311
|
+
`bench/side-by-side.mjs`: every formula in BI's source and tests, plus the
|
|
312
|
+
saved ones in `report_dataset_formulas`, through expr-eval and 2.0 against
|
|
313
|
+
five kinds of row. On 2026-09-20: 133 formulas (22 totals, left for BI's own
|
|
314
|
+
suites), 555 checks, **516 identical**; all 39 differences have a named
|
|
315
|
+
cause — 32 are expr-eval bugs (a column named like a function; a function
|
|
316
|
+
used as a value), 7 are the agreed changes (`=`, forgiving equality,
|
|
317
|
+
`length(null)`). None unexplained; the script exits 1 if one appears.
|
|
318
|
+
2. **BI:** report-engine and text templates switch; `expr-eval` is removed.
|
|
319
|
+
BI's own formula suites must pass unchanged.
|
|
320
|
+
3. **Workflow:** arrow conditions accept formulas and `all` / `any` groups.
|
|
321
|
+
|
|
322
|
+
## Decisions (2026-09-19)
|
|
323
|
+
|
|
324
|
+
- Grow this package rather than add a new one.
|
|
325
|
+
- Our own parser, not a library — the syntax above fits none of them, and the
|
|
326
|
+
point is to own the security.
|
|
327
|
+
- `=` means equals · `||` joins text · BI's function behaviour wins · the
|
|
328
|
+
engine lists aggregates, BI keeps the adding-up.
|
|
329
|
+
- At most 2 levels of totals · single-threaded.
|
|
330
|
+
- `[column name]` brackets for any column; lists as `in (a, b)`.
|
|
331
|
+
- The financial year is configurable per workspace / company, passed in the run context.
|
|
332
|
+
- (2026-09-20, while building) Equality is forgiving however it's spelled;
|
|
333
|
+
nothing is never less or greater. Chosen so Workflow's stored conditions
|
|
334
|
+
keep their meaning — to be confirmed.
|
|
98
335
|
|
|
99
|
-
|
|
100
|
-
`formula.{parse,compile,evaluate}` · `nlp` (stub) ·
|
|
101
|
-
`registerFunction` · `registerOperator` · `operators()` · `functions()`
|
|
336
|
+
## Open
|
|
102
337
|
|
|
103
|
-
|
|
338
|
+
- Where each app stores the financial-year setting (BI workspace settings; the
|
|
339
|
+
CLI's tenancy levels) — decided when those callers move over.
|
package/index.js
CHANGED
|
@@ -1,50 +1,165 @@
|
|
|
1
|
-
// @xeplr/expression-handler —
|
|
1
|
+
// @xeplr/expression-handler — the one formula engine for xeplr.
|
|
2
2
|
//
|
|
3
3
|
// const xf = require('@xeplr/expression-handler');
|
|
4
4
|
//
|
|
5
|
-
// xf.
|
|
6
|
-
//
|
|
7
|
-
// { name: 'ada', nickName: 'ADA' }
|
|
8
|
-
// ); // → true
|
|
5
|
+
// const f = xf.compile('if(country = "India" and score >= 750, "Fast track", "Review")');
|
|
6
|
+
// f({ country: 'India', score: 800 }); // → 'Fast track'
|
|
9
7
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// xf.parse('revenue - cost') // → JSON tree, the form that is stored
|
|
9
|
+
// xf.toText(tree) // → back to text, for an editor
|
|
10
|
+
// xf.analyze('sum(a) / sum(b)') // → the fields, functions and totals it uses
|
|
11
|
+
// xf.functions() // → the function list, for pickers and help
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
// xf.
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
// // Run context — per tenant / per run:
|
|
14
|
+
// xf.compile('sumif(orders, fytd(date), amount)', { fiscalYearStart: 7 });
|
|
15
|
+
//
|
|
16
|
+
// // 1.x conditions still work:
|
|
17
|
+
// xf.evaluate({ left: { field: 'age' }, op: 'gte', right: { value: 18 } }, row);
|
|
18
|
+
//
|
|
19
|
+
// See README.md for the language, the limits and what changed from 1.x.
|
|
20
|
+
|
|
21
|
+
var parse = require('./lib/parse').parse;
|
|
22
|
+
var compileTree = require('./lib/compile').compileTree;
|
|
23
|
+
var DEFAULTS = require('./lib/compile').DEFAULTS;
|
|
24
|
+
var analyzeTree = require('./lib/analyze').analyzeTree;
|
|
25
|
+
var toText = require('./lib/text').toText;
|
|
26
|
+
var fns = require('./lib/functions');
|
|
27
|
+
var ops = require('./lib/operators');
|
|
28
|
+
var ev = require('./lib/evaluate');
|
|
21
29
|
var nlp = require('./lib/nlp');
|
|
22
30
|
|
|
31
|
+
// ── compile, with a cache ────────────────────────────────────────────────
|
|
32
|
+
// Callers that pass formula TEXT per row (rather than compiling once up
|
|
33
|
+
// front) still parse each formula once: text → function, keyed by the text
|
|
34
|
+
// and the run context, so two tenants with different financial years never
|
|
35
|
+
// share a compiled formula. Bounded; cleared if a function is registered.
|
|
36
|
+
var CACHE_MAX = 1000;
|
|
37
|
+
var textCache = new Map();
|
|
38
|
+
var treeCache = new WeakMap();
|
|
39
|
+
|
|
40
|
+
function contextKey(options) {
|
|
41
|
+
if (!options) return '';
|
|
42
|
+
var keys = Object.keys(options).sort();
|
|
43
|
+
var parts = [];
|
|
44
|
+
for (var i = 0; i < keys.length; i++) {
|
|
45
|
+
var v = options[keys[i]];
|
|
46
|
+
if (typeof v === 'function') return null; // a hook — can't be keyed
|
|
47
|
+
parts.push(keys[i] + '=' + JSON.stringify(v));
|
|
48
|
+
}
|
|
49
|
+
return parts.join('&');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The common call — text, no context — looks the text up directly, with no
|
|
53
|
+
// key to build: this is the path a caller passing text per row takes.
|
|
54
|
+
var plainCache = new Map();
|
|
55
|
+
|
|
56
|
+
function compile(formula, options) {
|
|
57
|
+
if (typeof formula === 'string' && options === undefined) {
|
|
58
|
+
var plain = plainCache.get(formula);
|
|
59
|
+
if (plain) return plain;
|
|
60
|
+
plain = compileTree(parse(formula));
|
|
61
|
+
if (plainCache.size >= CACHE_MAX) plainCache.clear();
|
|
62
|
+
plainCache.set(formula, plain);
|
|
63
|
+
return plain;
|
|
64
|
+
}
|
|
65
|
+
var key = contextKey(options);
|
|
66
|
+
if (typeof formula === 'string') {
|
|
67
|
+
if (key === null) return compileTree(parse(formula), options);
|
|
68
|
+
var k = key + '\u0000' + formula;
|
|
69
|
+
var hit = textCache.get(k);
|
|
70
|
+
if (hit) return hit;
|
|
71
|
+
var f = compileTree(parse(formula), options);
|
|
72
|
+
if (textCache.size >= CACHE_MAX) textCache.clear();
|
|
73
|
+
textCache.set(k, f);
|
|
74
|
+
return f;
|
|
75
|
+
}
|
|
76
|
+
if (!formula || typeof formula !== 'object') throw new Error('compile: needs formula text or a tree');
|
|
77
|
+
if (key === null) return compileTree(formula, options);
|
|
78
|
+
var byKey = treeCache.get(formula);
|
|
79
|
+
if (!byKey) { byKey = new Map(); treeCache.set(formula, byKey); }
|
|
80
|
+
var got = byKey.get(key);
|
|
81
|
+
if (!got) { got = compileTree(formula, options); byKey.set(key, got); }
|
|
82
|
+
return got;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* name → a ready-to-call implementation, with the run context already bound.
|
|
87
|
+
* For callers that want the FUNCTIONS without the language — BI's dashboard
|
|
88
|
+
* text templates have their own tiny `{{ }}` reader and only need to call
|
|
89
|
+
* `monthname(x)`. Aggregates are absent: they span rows, which a caller
|
|
90
|
+
* outside a formula has none of.
|
|
91
|
+
*/
|
|
92
|
+
function implementations(options) {
|
|
93
|
+
var ctx = { now: require('./lib/dates').todayFor(options || {}), fiscalYearStart: (options && options.fiscalYearStart) || 4 };
|
|
94
|
+
var out = {};
|
|
95
|
+
fns.listFunctions().forEach(function (f) {
|
|
96
|
+
if (!f.fn || f.aggregate) return;
|
|
97
|
+
out[f.name] = f.context
|
|
98
|
+
? function () { var a = [ctx]; for (var i = 0; i < arguments.length; i++) a.push(arguments[i]); return f.fn.apply(null, a); }
|
|
99
|
+
: f.fn;
|
|
100
|
+
});
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Text or tree → what it uses. */
|
|
105
|
+
function analyze(formula) {
|
|
106
|
+
return analyzeTree(typeof formula === 'string' ? parse(formula) : formula);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Text → { ok, tree, error, position } without throwing — for editors that
|
|
111
|
+
* check as the user types.
|
|
112
|
+
*/
|
|
113
|
+
function check(text, options) {
|
|
114
|
+
try {
|
|
115
|
+
var tree = parse(text);
|
|
116
|
+
compileTree(tree, options);
|
|
117
|
+
return { ok: true, tree: tree, error: null, position: null };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { ok: false, tree: null, error: err.message, position: err.position === undefined ? null : err.position };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function registerFunction(entryOrName, fn) {
|
|
124
|
+
fns.registerFunction(entryOrName, fn);
|
|
125
|
+
plainCache.clear();
|
|
126
|
+
textCache.clear();
|
|
127
|
+
treeCache = new WeakMap();
|
|
128
|
+
}
|
|
129
|
+
|
|
23
130
|
module.exports = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
131
|
+
parse: parse,
|
|
132
|
+
toText: toText,
|
|
133
|
+
compile: compile,
|
|
134
|
+
analyze: analyze,
|
|
135
|
+
check: check,
|
|
136
|
+
|
|
137
|
+
// 1.x — a condition → true / false; still what Workflow calls per arrow.
|
|
138
|
+
evaluate: ev.evaluate,
|
|
139
|
+
toPredicate: ev.toPredicate,
|
|
140
|
+
validate: ev.validate,
|
|
141
|
+
resolveOperand: ev.resolveOperand,
|
|
142
|
+
getPath: ev.getPath,
|
|
143
|
+
|
|
144
|
+
// 1.x formula namespace, kept as aliases.
|
|
33
145
|
formula: {
|
|
34
|
-
parse:
|
|
35
|
-
|
|
36
|
-
|
|
146
|
+
parse: parse,
|
|
147
|
+
compile: function (text, options) { return compile(text, options); },
|
|
148
|
+
evaluate: function (tree, row, options) { return compile(tree, options)(row); }
|
|
37
149
|
},
|
|
38
150
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
registerOperator: registerOperator,
|
|
151
|
+
functions: fns.listFunctions,
|
|
152
|
+
implementations: implementations,
|
|
153
|
+
CATEGORIES: fns.CATEGORIES,
|
|
43
154
|
registerFunction: registerFunction,
|
|
44
155
|
|
|
45
|
-
//
|
|
46
|
-
OPERATORS: OPERATORS,
|
|
47
|
-
|
|
48
|
-
operators: function () { return Object.keys(OPERATORS); },
|
|
49
|
-
|
|
156
|
+
// 1.x word operators for { left, op, right } conditions.
|
|
157
|
+
OPERATORS: ops.OPERATORS,
|
|
158
|
+
registerOperator: ops.registerOperator,
|
|
159
|
+
operators: function () { return Object.keys(ops.OPERATORS); },
|
|
160
|
+
|
|
161
|
+
DEFAULTS: DEFAULTS,
|
|
162
|
+
|
|
163
|
+
// Natural-language wrapper — STUB (not implemented yet).
|
|
164
|
+
nlp: nlp
|
|
50
165
|
};
|