@xeplr/expression-handler 1.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,103 +1,338 @@
1
1
  # @xeplr/expression-handler
2
2
 
3
- Level-1 structured expression evaluator. JSON expression in, boolean out — apply
4
- field **functions** (`upper`, `month`, …) and **operators** (`eq`, `contains`,
5
- `gt`, `between`, `isNull`, …) to a data row. No parser, no nesting, no `AND`/`OR`
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
- ## Usage
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
- // upper(name) = upper(nickName)
14
- xf.evaluate(
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
- ## Expression shape
23
+ ## API
31
24
 
32
- ```
33
- { left: <operand>, op: <string>, right?: <operand> }
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
- **Operand** — one of:
38
+ ## Who uses it
37
39
 
38
- | Form | Meaning |
40
+ | Caller | What for |
39
41
  |---|---|
40
- | `{ field: 'name' }` | `row.name` |
41
- | `{ field: 'a.b' }` | `row.a.b` (dot path) |
42
- | `{ field: 'name', fn: 'upper' }` | `upper(row.name)` |
43
- | `{ value: 42 }` | literal (string / number / boolean / null / array) |
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
- `right` is omitted for unary operators (`isNull`, `isNotNull`).
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
- ## Operators
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
- `eq` `neq` `isNull` `isNotNull` · `gt` `gte` `lt` `lte` `between` (`[lo,hi]`, inclusive) ·
50
- `contains` `notContains` `startsWith` `endsWith` · `in` `notIn`
51
- Aliases: `equals`, `notEquals`, `doesNotContain`.
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
- `eq`/`neq` against `{ value: null }` also work as null checks. Null-ish values make
54
- numeric/string comparisons return `false` (never throw).
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
- ## Functions
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
- String: `upper` `lower` `trim` `length` · Date (UTC): `month` (`'January'`) `monthNum`
59
- `year` `day` `quarter` `weekday` (`'Monday'`).
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
- ## Extending
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
- xf.registerFunction('reverse', v => String(v).split('').reverse().join(''));
65
- xf.registerOperator('regex', (a, b) => new RegExp(b).test(String(a)));
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
- ## Formula wrapper (Excel-like)
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
- Write formulas as strings; they compile to the same structured JSON.
123
+ Each function is one entry: its code **and** what the UI shows.
71
124
 
72
125
  ```js
73
- xf.formula.parse('if(upper(someColumn)=anotherColumn, "Yes", "No")');
74
- // → { if: { left:{fn:'upper',field:'someColumn'}, op:'eq', right:{field:'anotherColumn'} },
75
- // then: { value:'Yes' },
76
- // else: { value:'No' } }
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
- const f = xf.formula.compile('if(score>=90,"A",if(score>=80,"B","C"))'); // nested ifs
79
- f({ score: 85 }); // → 'B'
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
- Rules: `"double"`/`'single'` quoted → string literal (doubled quote escapes); bare
83
- word → field ref; `number` / `null` / `true` / `false` → literals; `name(...)` →
84
- function (value fn `upper`/`month`…, boolean fn `contains`/`between`/`isnull`…, or
85
- `if`); comparisons `= == != <> > >= < <=`. Function names are case-insensitive;
86
- `if(...)` nests. An IF evaluates to its then/else value; a bare comparison
87
- (`upper(name)="ADA"`) compiles to a boolean predicate.
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
+ | `maxListItems` · `maxDistinct` · `maxSteps` · `maxLevels` | see [Limits](#limits) | |
175
+ | `aggregate(name, args, { compile, level })` | none | supplies the rows for the group form of a total (BI) |
88
176
 
89
- `xf.formula.parse(str)` · `xf.formula.compile(str) → (row)=>value` · `xf.formula.evaluate(node, row)`
177
+ **The financial year is a tenant setting.** It belongs to the workspace or
178
+ company, whichever level the app has, and the **app** stores and resolves it
179
+ (BI: the workspace setting; a generated app: its tenancy level). This package
180
+ only receives the value. The context is part of the compile cache key, so two
181
+ tenants with different years never share a compiled formula.
90
182
 
91
- ## NLP wrapper (stub)
183
+ ## Array formulas
92
184
 
93
- `xf.nlp` — natural language → expression JSON. **Not implemented yet**
94
- (`xf.nlp.implemented === false`; `xf.nlp.parse()` throws). Placeholder surface for
95
- a future LLM/grammar backend.
185
+ A formula that adds up **many rows** into one value.
96
186
 
97
- ## API
187
+ **Where the rows come from:**
188
+ - **BI** — the rows of the current group, implicitly:
189
+ `sumif(status = "paid", amount)`. The engine can't see a report's groups, so
190
+ the caller supplies them through the `aggregate` hook in the context; without
191
+ it this form is an error that shows the list form.
192
+ - **Workflow and anything else** — a named list, as the first argument:
193
+ `sumif(orders, month(order_date) = 8, revenue)`. The engine runs these itself,
194
+ as one loop over the list. Inside the condition and the value, names read the
195
+ list's item.
196
+
197
+ **`$thisrow`** refers to the row the formula is being worked out for, from
198
+ inside a condition that runs over other rows:
199
+
200
+ ```
201
+ if(sumif(city = $thisrow.city and month(order_date) = 8, revenue) > 10000, "High August city", "Normal")
202
+ ```
203
+
204
+ — "revenue for **my** city in August; over 10K?".
205
+
206
+ **Levels.** A total inside another total is one level deeper ("the highest of
207
+ each city's August total" is 2). The `if` and `> 10000` around a total don't
208
+ count. **Maximum: 2 levels** (agreed).
209
+
210
+ ### Memory rules
211
+
212
+ 1. **Streaming, never collecting.** Rows arrive one at a time; each total is a
213
+ running number (sum, count, min, max, avg) that the row is added to and
214
+ then forgotten. Memory depends on the number of **groups**, not rows.
215
+ 2. **One loop per total, no temporary lists.** `sumif`, and any
216
+ filter-then-total chain, compiles into a single loop.
217
+ 3. **`$thisrow` totals run on the result rows** (grouped, small). If one is
218
+ ever needed on raw rows, the query runs twice rather than holding the rows.
219
+ 4. **Only distinct values grow with the data** — `countifunique` and
220
+ `$thisrow` partitions keep one entry per distinct key, under a hard cap
221
+ (below) that ends with a clear error, not an out-of-memory crash.
222
+ 5. **Totals are mergeable** (two part-totals add into one), which keeps a later
223
+ split across processes possible without redesign.
224
+
225
+ ## Speed
226
+
227
+ A formula is **compiled once** into plain JavaScript closures and then run for
228
+ every row:
229
+
230
+ 1. **Names resolved at compile time.** `revenue` becomes "read `row.revenue`",
231
+ `upper` a direct reference — no lookup object per row, so a row allocates
232
+ nothing.
233
+ 2. **Constant parts computed once** — `upper("pune")` is folded at compile time.
234
+ 3. **Short-circuit** — `if`, `and`, `or` run only what they need.
235
+ 4. **Compiled once per run.** Callers compile when a run starts; `xf.compile`
236
+ also caches by formula text for callers that pass text per row. A test
237
+ asserts a report run compiles each formula exactly once.
238
+ 5. **Date strings parsed once** — the last few parsed strings are remembered,
239
+ since a streamed report repeats the same dates row after row.
240
+
241
+ Single-threaded by decision: one report uses one core. If that stops being
242
+ enough, the answer is a move to a compiled language (e.g. Rust), not worker
243
+ threads.
244
+
245
+ ### Measured
246
+
247
+ 1M streamed rows, 4 row formulas (`revenue - cost`, `upper(trim(city))`, a
248
+ nested `if`, `monthname(order_date)`), median of 3 runs, 2026-09-20. Compare
249
+ within a column — the two columns ran under different load.
250
+
251
+ | Path | Apple M4 Pro | Docker ½ CPU / 256 MB |
252
+ |---|---|---|
253
+ | BI before 2.0 (`enrichRow` — expr-eval, re-parses per row) | 15.5 s | 35.7 s, peak 186 MB |
254
+ | expr-eval, parsed once | 2.2 s | 5.5 s, peak 84 MB |
255
+ | **2.0, compiled once** | **0.38 s** | **1.19 s, peak 62 MB** |
256
+ | 2.0, formula text passed per row (cache) | 0.45 s | 1.29 s, peak 64 MB |
257
+ | hand-written JavaScript (the ceiling) | 0.29 s | 0.89 s, peak 62 MB |
258
+ | August `sumif` per city — BI before 2.0 | 0.95 s | 2.3 s |
259
+ | **August `sumif` per city — 2.0** | **0.16 s** | **0.50 s** |
260
+ | August `sumif` per city — hand-written | 0.15 s | 0.44 s |
261
+
262
+ About **40× faster** than BI's path before 2.0, and within 1.3× of
263
+ hand-written JavaScript. `bench/run.sh` reproduces it (the expr-eval rows need
264
+ BI checked out beside this repo).
265
+
266
+ ## Limits
267
+
268
+ | Limit | Value | When hit |
269
+ |---|---|---|
270
+ | Nesting of totals (`maxLevels`) | 2 levels | error at compile time |
271
+ | Distinct values (`maxDistinct`) | 1,000,000 per total | clear error, run stops |
272
+ | Items in one list (`maxListItems`) | 10,000 | clear error |
273
+ | List items visited per evaluation (`maxSteps`) | 10,000,000 | clear error — a formula can't run away |
274
+
275
+ Each is a context key, so a caller can lower (or raise) it.
276
+
277
+ ## Security
278
+
279
+ - The only callable things are the functions in the list. A value in the row
280
+ can never be called, and a function found on a row (inherited `toString`)
281
+ reads as nothing.
282
+ - Paths through `__proto__`, `constructor` or `prototype` are refused — in
283
+ formula text and in stored trees.
284
+ - No `eval` / `new Function`, no dependencies. (This removes BI's `expr-eval`,
285
+ which has two unpatched high-severity advisories.)
286
+
287
+ ## What changes from 1.x
288
+
289
+ 2.0 is a major version. For callers of 1.x (Workflow):
290
+
291
+ | | 1.x | 2.0 |
292
+ |---|---|---|
293
+ | `month(d)` | `'January'` | `1` (`monthname` gives `'January'`) |
294
+ | Dates | UTC | date-only strings are calendar days; see Functions |
295
+ | `upper(null)` | `null` | `''` |
296
+ | AND / OR | none | `and` / `or` / `not`, `all` / `any` groups |
297
+ | Formula mode | one comparison, `if`, one function per value | the full language |
298
+ | Functions | 10 | the list above |
299
+ | `monthNum` | exists | still accepted, not listed — use `month` |
300
+ | `formula.parse` output | `{ left: { fn, field } … }` | the 2.0 tree (`{ call, args }`) |
301
+ | `functions()` | names | the full entries |
302
+ | `<` `>` in formula text | `gt`, as numbers | JavaScript order (ISO dates work); nothing is never less |
303
+
304
+ `evaluate(expr, row)`, `toPredicate`, `validate` and the `{ left, op, right }`
305
+ shape keep working.
306
+
307
+ ## Moving callers over
308
+
309
+ 1. **This package — done.** Engine, 63 tests (`npm test`), `bench/`, and
310
+ `bench/side-by-side.mjs`: every formula in BI's source and tests, plus the
311
+ saved ones in `report_dataset_formulas`, through expr-eval and 2.0 against
312
+ five kinds of row. On 2026-09-20: 133 formulas (22 totals, left for BI's own
313
+ suites), 555 checks, **516 identical**; all 39 differences have a named
314
+ cause — 32 are expr-eval bugs (a column named like a function; a function
315
+ used as a value), 7 are the agreed changes (`=`, forgiving equality,
316
+ `length(null)`). None unexplained; the script exits 1 if one appears.
317
+ 2. **BI:** report-engine and text templates switch; `expr-eval` is removed.
318
+ BI's own formula suites must pass unchanged.
319
+ 3. **Workflow:** arrow conditions accept formulas and `all` / `any` groups.
320
+
321
+ ## Decisions (2026-09-19)
322
+
323
+ - Grow this package rather than add a new one.
324
+ - Our own parser, not a library — the syntax above fits none of them, and the
325
+ point is to own the security.
326
+ - `=` means equals · `||` joins text · BI's function behaviour wins · the
327
+ engine lists aggregates, BI keeps the adding-up.
328
+ - At most 2 levels of totals · single-threaded.
329
+ - `[column name]` brackets for any column; lists as `in (a, b)`.
330
+ - The financial year is configurable per workspace / company, passed in the run context.
331
+ - (2026-09-20, while building) Equality is forgiving however it's spelled;
332
+ nothing is never less or greater. Chosen so Workflow's stored conditions
333
+ keep their meaning — to be confirmed.
98
334
 
99
- `evaluate(expr, row)` · `toPredicate(expr)` · `validate(expr) → { valid, errors }` ·
100
- `formula.{parse,compile,evaluate}` · `nlp` (stub) ·
101
- `registerFunction` · `registerOperator` · `operators()` · `functions()`
335
+ ## Open
102
336
 
103
- Test: `npm test` (node --test, no DB). 29 tests.
337
+ - Where each app stores the financial-year setting (BI workspace settings; the
338
+ CLI's tenancy levels) — decided when those callers move over.
package/index.js CHANGED
@@ -1,50 +1,165 @@
1
- // @xeplr/expression-handler — level-1 structured expression evaluator.
1
+ // @xeplr/expression-handler — the one formula engine for xeplr.
2
2
  //
3
3
  // const xf = require('@xeplr/expression-handler');
4
4
  //
5
- // xf.evaluate(
6
- // { left: { fn: 'upper', field: 'name' }, op: 'eq', right: { fn: 'upper', field: 'nickName' } },
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
- // const isAdult = xf.toPredicate({ left: { field: 'age' }, op: 'gte', right: { value: 18 } });
11
- // rows.filter(isAdult);
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
- // Extend with your own functions / operators:
14
- // xf.registerFunction('reverse', v => String(v).split('').reverse().join(''));
15
- // xf.registerOperator('regex', (a, b) => new RegExp(b).test(a));
16
-
17
- var { evaluate, toPredicate, validate, resolveOperand, getPath } = require('./lib/evaluate');
18
- var { OPERATORS, registerOperator } = require('./lib/operators');
19
- var { FUNCTIONS, registerFunction } = require('./lib/functions');
20
- var { parseFormula, evaluateFormula, compileFormula } = require('./lib/formula');
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
- evaluate: evaluate,
25
- toPredicate: toPredicate,
26
- validate: validate,
27
- resolveOperand: resolveOperand,
28
- getPath: getPath,
29
-
30
- // Excel-like formula wrapper → structured JSON (+ evaluate it).
31
- // xf.formula.parse('if(upper(a)=b,"Yes","No")') → JSON
32
- // xf.formula.compile(str)(row) → value
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: parseFormula,
35
- evaluate: evaluateFormula,
36
- compile: compileFormula
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
- // Natural-language wrapper — STUB (not implemented yet).
40
- nlp: nlp,
41
-
42
- registerOperator: registerOperator,
151
+ functions: fns.listFunctions,
152
+ implementations: implementations,
153
+ CATEGORIES: fns.CATEGORIES,
43
154
  registerFunction: registerFunction,
44
155
 
45
- // Introspection — the registered operator/function names.
46
- OPERATORS: OPERATORS,
47
- FUNCTIONS: FUNCTIONS,
48
- operators: function () { return Object.keys(OPERATORS); },
49
- functions: function () { return Object.keys(FUNCTIONS); }
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
  };