vectrojs 0.1.0 → 0.1.2
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/CHANGELOG.md +54 -0
- package/README.md +79 -62
- package/mcp/server.mjs +1 -1
- package/package.json +1 -1
- package/src/dataset.mjs +6 -1
- package/src/engine.mjs +3 -1
- package/src/fuse.mjs +12 -3
- package/src/join.mjs +1 -1
- package/src/sort.mjs +2 -1
- package/src/sql.mjs +82 -28
- package/src/table.mjs +88 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
A correctness release, from comparing about 1,700 new SQL queries with DuckDB
|
|
6
|
+
on the same data. No API changes. Every case below now gives DuckDB's answer,
|
|
7
|
+
or a clear error.
|
|
8
|
+
|
|
9
|
+
**Engine**
|
|
10
|
+
- A query too big for the 4GB memory (a join with billions of output rows)
|
|
11
|
+
now fails with `out of memory (4GB limit)`. Before, the size wrapped around
|
|
12
|
+
in the allocator, and later queries on the same engine could return wrong
|
|
13
|
+
numbers.
|
|
14
|
+
- Missing text (`''`) sorts last, up or down, as missing numbers do, in
|
|
15
|
+
`sort`, `top`, window ordering and SQL `ORDER BY`.
|
|
16
|
+
- `null` and `undefined` in a text column load as missing text, also when
|
|
17
|
+
the column starts with one (`engine.table`, `tableFromRows`).
|
|
18
|
+
- A missing text key never matches in a join, as the docs say.
|
|
19
|
+
- A filter with no column in it (`'1'`, `'1 == 0'`) keeps every row or none,
|
|
20
|
+
instead of failing.
|
|
21
|
+
- Comparing a text column with `<` or `>` and then grouping gives a clear
|
|
22
|
+
error instead of overflowing the stack. On datasets, the unknown-column
|
|
23
|
+
error lists every column.
|
|
24
|
+
|
|
25
|
+
**SQL**
|
|
26
|
+
- Conditions follow SQL's three values everywhere: a condition on a missing
|
|
27
|
+
value is NULL when selected, `IS TRUE` / `IS FALSE` treat it as neither,
|
|
28
|
+
comparing two conditions is unknown if either is, and a `NULL` literal in
|
|
29
|
+
`AND` / `OR` is unknown rather than true.
|
|
30
|
+
- `WHERE TRUE`, `WHERE 1 = 1`, `WHERE NULL` and other constant conditions work.
|
|
31
|
+
- `ORDER BY text NULLS FIRST` works.
|
|
32
|
+
- `x NOT IN (SELECT ...)` keeps every row when the subquery is empty and no
|
|
33
|
+
row when it contains a NULL.
|
|
34
|
+
- `LEAST` and `GREATEST` skip missing values (NULL only if all are).
|
|
35
|
+
- The same aggregate twice (`SUM(x) AS a, SUM(x) AS b`) gives NULL both times
|
|
36
|
+
over no values.
|
|
37
|
+
- Subqueries in the SELECT list or HAVING of a GROUP BY query work.
|
|
38
|
+
|
|
39
|
+
## 0.1.1
|
|
40
|
+
|
|
41
|
+
A performance release. No API changes.
|
|
42
|
+
|
|
43
|
+
**Faster group-by**
|
|
44
|
+
- SQL `SUM` no longer computes a hidden count of non-missing values when the
|
|
45
|
+
column has none missing, and sum, mean, min and max skip the per-row
|
|
46
|
+
missing-value check on such columns.
|
|
47
|
+
- On many threads, a group-by remembers its key range and merges the
|
|
48
|
+
threads' partial results with a WebAssembly kernel instead of a JavaScript
|
|
49
|
+
loop. This also removes the 31-thread limit for queries with many groups.
|
|
50
|
+
- Measured on 2M orders (`SELECT product_id, sum(amount) ... GROUP BY 1`,
|
|
51
|
+
`compare/bench-2m-groupby.mjs`, numbers in `compare/results.json`): with
|
|
52
|
+
1,000 products, 3.0 ms on 1 thread (DuckDB 2.0: 6.8 ms) and 2.3 ms on 8
|
|
53
|
+
threads (DuckDB 2.0: 2.4 ms). Before this release Vectro was 1.9x slower
|
|
54
|
+
than DuckDB on 8 threads in this case; it is now level or faster at every
|
|
55
|
+
size from 100 to 100,000 products.
|
|
56
|
+
|
|
3
57
|
## 0.1.0 (first release)
|
|
4
58
|
|
|
5
59
|
The first public release. Everything below is new.
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ and compiles each query, filter included, into one SIMD WebAssembly loop, then
|
|
|
5
5
|
runs it on every CPU core. It runs in Node, in browsers (any page) and on
|
|
6
6
|
Cloudflare Workers, with no native addon, compiler or server.
|
|
7
7
|
|
|
8
|
-
> Version 0.1.
|
|
8
|
+
> Version 0.1.2. Node 20+ and modern browsers. MIT license.
|
|
9
9
|
|
|
10
10
|
**Website and docs: [vectrojs.dev](https://vectrojs.dev)**. [Quick start](https://vectrojs.dev/docs/quick-start) · [API reference](https://vectrojs.dev/api) · [Race it in your browser](https://vectrojs.dev/live) · [Benchmarks](https://vectrojs.dev/benchmarks)
|
|
11
11
|
|
|
@@ -30,18 +30,27 @@ orders.groupBy('region').agg({ revenue: 'sum(price * qty)' }).toRows();
|
|
|
30
30
|
npm install vectrojs
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
Save this as `try.mjs` (or set `"type": "module"` in package.json) and run `node try.mjs`:
|
|
34
|
+
|
|
33
35
|
```js
|
|
34
36
|
import { Engine, sql } from 'vectrojs';
|
|
35
37
|
|
|
36
|
-
const engine = new Engine();
|
|
37
|
-
const orders = engine.
|
|
38
|
-
|
|
38
|
+
const engine = new Engine(); // uses every core
|
|
39
|
+
const orders = engine.table({
|
|
40
|
+
region: ['EU', 'US', 'EU', 'APAC', 'US'],
|
|
41
|
+
price: [120, 80, 300, 45, 99],
|
|
42
|
+
});
|
|
43
|
+
console.log(sql('SELECT region, COUNT(*) AS n, SUM(price) AS revenue FROM orders GROUP BY region ORDER BY revenue DESC', { orders }).toRows());
|
|
44
|
+
// [ { region: 'EU', n: 2, revenue: 420 }, { region: 'US', n: 2, revenue: 179 }, { region: 'APAC', n: 1, revenue: 45 } ]
|
|
45
|
+
await engine.close();
|
|
39
46
|
```
|
|
40
47
|
|
|
48
|
+
For your own data, `engine.readCSV('orders.csv')` reads a CSV file on every core.
|
|
49
|
+
|
|
41
50
|
Serve a folder of CSV / Arrow / `.vectro` files to BI tools, with no code:
|
|
42
51
|
|
|
43
52
|
```sh
|
|
44
|
-
npx vectro serve --root ./data --pg 5433 # HTTP on 8787, PostgreSQL on 5433
|
|
53
|
+
npx -y -p vectrojs vectro serve --root ./data --pg 5433 # HTTP on 8787, PostgreSQL on 5433
|
|
45
54
|
psql -h 127.0.0.1 -p 5433 -U vectro vectro # or Power BI, Tableau, Metabase, Grafana
|
|
46
55
|
```
|
|
47
56
|
|
|
@@ -82,10 +91,10 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
|
|
|
82
91
|
|
|
83
92
|
| Keys | Vectro | DuckDB |
|
|
84
93
|
|---|---|---|
|
|
85
|
-
| region + day (2,920 pairs) |
|
|
86
|
-
| price (100K amounts) |
|
|
87
|
-
| account (1M big IDs) |
|
|
88
|
-
| account + region (5.7M pairs) |
|
|
94
|
+
| region + day (2,920 pairs) | 6 ms | 39 ms |
|
|
95
|
+
| price (100K amounts) | 27 ms | 49 ms |
|
|
96
|
+
| account (1M big IDs) | 36 ms | 88 ms |
|
|
97
|
+
| account + region (5.7M pairs) | 50 ms | 139 ms |
|
|
89
98
|
|
|
90
99
|
Vectro's groups also come back sorted by key; DuckDB's come back unsorted.
|
|
91
100
|
- **Missing values** are NaN for numbers and `''` for text. `sum`, `mean`,
|
|
@@ -124,9 +133,9 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
|
|
|
124
133
|
by the hash table first, or, with many of them, sorted by key range on
|
|
125
134
|
every thread (as a group-by with many groups) and counted in runs; values
|
|
126
135
|
a whole step apart (IDs like 1e9 + 7919k) get the byte map too. On 10M rows
|
|
127
|
-
vs DuckDB (`compare/bench-distinct.mjs`): 365 days
|
|
128
|
-
day
|
|
129
|
-
step: 24 ms vs 46); accounts per region (5.7M pairs)
|
|
136
|
+
vs DuckDB (`compare/bench-distinct.mjs`): 365 days 5 ms vs 5; regions per
|
|
137
|
+
day 9 ms vs 36; 1M accounts 11 ms vs 51 (1M random IDs up to 2^40, no common
|
|
138
|
+
step, from an earlier run: 24 ms vs 46); accounts per region (5.7M pairs) 35 ms vs 103.
|
|
130
139
|
- **Compressed columns:** `table.compress()` (or `engine.table(cols, { compress:
|
|
131
140
|
true })`, `parseCSV(text, { compress: true })`) stores each number and
|
|
132
141
|
category column in 1, 2 or 4 bytes per row when every value comes back
|
|
@@ -198,11 +207,11 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
|
|
|
198
207
|
dates, and comparing text with `<` / `>`. Each function runs once per
|
|
199
208
|
distinct value, not per row (a category column has few labels), and the
|
|
200
209
|
answer is a category column again, so it groups, sorts and joins like any
|
|
201
|
-
other: `GROUP BY UPPER(region)` on 10M rows
|
|
202
|
-
'-' || qty` as a key 85 ms (
|
|
203
|
-
are rounded to the month first), ILIKE on a column
|
|
210
|
+
other: `GROUP BY UPPER(region)` on 10M rows 31 ms (DuckDB 47), `region ||
|
|
211
|
+
'-' || qty` as a key 85 ms (106), `STRFTIME(ts, '%Y-%m')` 51 ms (70; times
|
|
212
|
+
are rounded to the month first), ILIKE on a column 30 ms (54). A CASE whose
|
|
204
213
|
results are all quoted labels is worked out as label codes in the fused
|
|
205
|
-
pass (CASE text + group
|
|
214
|
+
pass (CASE text + group 6.2 ms vs 25). Missing text
|
|
206
215
|
is `''`, as everywhere in Vectro, and positions count characters from 1 as
|
|
207
216
|
in DuckDB.
|
|
208
217
|
|
|
@@ -210,7 +219,7 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
|
|
|
210
219
|
rows (FULL: plus the left rows without a match) followed by the right rows
|
|
211
220
|
whose keys no left row has. The join marks which right rows it matched as
|
|
212
221
|
it probes, so the rows without a match come from the same pass (RIGHT JOIN
|
|
213
|
-
+ group
|
|
222
|
+
+ group 30 vs 64 ms; FULL JOIN + count 19 vs 29 ms).
|
|
214
223
|
|
|
215
224
|
`compare/check-sql.mjs` runs 40 such queries (CTEs, set operations,
|
|
216
225
|
correlated subqueries, windows, percentiles, text functions on awkward
|
|
@@ -219,15 +228,15 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
|
|
|
219
228
|
correlation on anything but equalities, MIN / MAX of text, and STRING_AGG.
|
|
220
229
|
The MCP server has
|
|
221
230
|
a `sql` tool. The same SQL text vs DuckDB on 10M orders + 100K customers
|
|
222
|
-
(`compare/bench-sql.mjs`, answers checked,
|
|
223
|
-
20 queries: filtered totals 3
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
correlated subquery 16.
|
|
228
|
-
|
|
229
|
-
vs
|
|
230
|
-
|
|
231
|
+
(`compare/bench-sql.mjs`, answers checked, best of 5): Vectro is faster on all
|
|
232
|
+
20 queries (1.3–6.0x): filtered totals 4.3 vs 10.1 ms, group by region 7.4 vs
|
|
233
|
+
36.8 ms, join + filter + group 19.8 vs 32.8 ms, top 10 customers 13.1 vs
|
|
234
|
+
50.3 ms, distinct customers per month 34.2 vs 82.5 ms, top 20 rows 9.1 vs
|
|
235
|
+
25.3 ms, CASE inside SUM 6.6 vs 39.6 ms, IN (subquery) 23.4 vs 29.7 ms,
|
|
236
|
+
correlated subquery 16.8 vs 61.4 ms, top 3 per region with RANK 38 vs
|
|
237
|
+
139 ms, ROW_NUMBER over all rows 129 vs 221 ms, running total by day 9.8
|
|
238
|
+
vs 17.3 ms, MEDIAN per region 39.1 vs 83.9 ms, FULL JOIN + count 19.3 vs
|
|
239
|
+
29.3 ms. A filter like `rn <= 3` on ROW_NUMBER or RANK keeps
|
|
231
240
|
only the rows that can rank that high before sorting (picked on every
|
|
232
241
|
thread); percentiles of a few partitions count values in buckets on every
|
|
233
242
|
thread and select only within the wanted buckets. Window functions and
|
|
@@ -257,10 +266,10 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
|
|
|
257
266
|
|
|
258
267
|
| Join | Vectro | DuckDB |
|
|
259
268
|
|---|---|---|
|
|
260
|
-
| × 100K customers |
|
|
261
|
-
| × 1M accounts |
|
|
262
|
-
| left join, half the keys match |
|
|
263
|
-
| two keys: region + day (2,920 rows) |
|
|
269
|
+
| × 100K customers | 77 ms | 210 ms |
|
|
270
|
+
| × 1M accounts | 151 ms | 251 ms |
|
|
271
|
+
| left join, half the keys match | 82 ms | 206 ms |
|
|
272
|
+
| two keys: region + day (2,920 rows) | 61 ms | 189 ms |
|
|
264
273
|
- **Text functions** on category columns: `startsWith(col, 'x')`,
|
|
265
274
|
`endsWith`, `contains`, `in(col, 'a', 'b')` and `like(col, 'EU-%')`. They run
|
|
266
275
|
once per distinct label (not per row) and become a few range checks on the
|
|
@@ -282,8 +291,8 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
|
|
|
282
291
|
of 50,000+ rows run on every core: a parallel radix sort (11-bit digits,
|
|
283
292
|
digits that never change are skipped, whole numbers sort as offsets), then
|
|
284
293
|
every column is gathered in the new order on every core. Sorting 10M rows
|
|
285
|
-
by `-price` takes ~
|
|
286
|
-
sort: ~4.
|
|
294
|
+
by `-price` takes ~117 ms (one thread: ~350 ms; a JavaScript comparator
|
|
295
|
+
sort: ~4.1 s).
|
|
287
296
|
- **Async API:** every query has an `…Async` version that runs on the worker
|
|
288
297
|
threads while your server or page keeps working.
|
|
289
298
|
- **Expressions** are a small, safe language: numbers, columns, `+ - * /`,
|
|
@@ -383,31 +392,33 @@ formulas exact in both, answers compared; best of 3):
|
|
|
383
392
|
|
|
384
393
|
| Query | 100M rows: Vectro | DuckDB | 1 billion rows: Vectro | DuckDB |
|
|
385
394
|
|---|---|---|---|---|
|
|
386
|
-
| scan everything (count, revenue) |
|
|
387
|
-
| filter: region and quantity |
|
|
388
|
-
| the last day (1 chunk read) | 3 ms | 1 ms | 3 ms |
|
|
389
|
-
| group by region |
|
|
390
|
-
| top 10 of 1M customers |
|
|
391
|
-
| top 10 rows by price |
|
|
392
|
-
| join 1M customers, group by tier |
|
|
393
|
-
| distinct customers per region |
|
|
395
|
+
| scan everything (count, revenue) | 70 ms | 73 ms | 478 ms | 662 ms |
|
|
396
|
+
| filter: region and quantity | 100 ms | 87 ms | 691 ms | 729 ms |
|
|
397
|
+
| the last day (1 chunk read) | 3 ms | 1 ms | 3 ms | 4 ms |
|
|
398
|
+
| group by region | 142 ms | 149 ms | 1.05 s | 1.25 s |
|
|
399
|
+
| top 10 of 1M customers | 529 ms | 637 ms | 4.39 s | 4.50 s |
|
|
400
|
+
| top 10 rows by price | 15 ms | 100 ms | 11 ms | 843 ms |
|
|
401
|
+
| join 1M customers, group by tier | 126 ms | 85 ms | 880 ms | 627 ms |
|
|
402
|
+
| distinct customers per region | 268 ms | 650 ms | 2.43 s | 6.84 s |
|
|
394
403
|
| file on disk | 0.5 GB | 0.5 GB | 5.1 GB | 5.4 GB |
|
|
395
|
-
| peak memory (RSS) |
|
|
404
|
+
| peak memory (RSS) | 344 MB | 2.9 GB | **0.37 GB** | 6.9 GB |
|
|
396
405
|
|
|
397
406
|
A billion rows would take 37 GB as plain columns in memory (this laptop has
|
|
398
|
-
16 GB); Vectro's engine never used more than 163 MB for them.
|
|
399
|
-
or faster on 7 of the 8 queries
|
|
407
|
+
16 GB); Vectro's engine never used more than 163 MB for them. At a billion
|
|
408
|
+
rows Vectro is level or faster on 7 of the 8 queries (only the join is
|
|
409
|
+
slower, 1.4x); at 100M rows it is slower on three: the filter (1.1x), the
|
|
410
|
+
last day (3 vs 1 ms) and the join (1.5x). Top rows read only the blocks of rows whose
|
|
400
411
|
stored min / max can still beat the rows kept so far (here 1 chunk of 239),
|
|
401
412
|
so they take the same few milliseconds at any size. A join whose right side
|
|
402
413
|
has unique whole-number keys and a GROUP BY on its columns looks each left
|
|
403
414
|
key's group up in one array instead of joining rows; DuckDB is still faster
|
|
404
|
-
there (1.
|
|
415
|
+
there (1.4x at a billion rows, 1.5x at 100M).
|
|
405
416
|
|
|
406
417
|
## SQL over HTTP: Excel, Power BI, Google Sheets, pandas
|
|
407
418
|
|
|
408
419
|
```sh
|
|
409
|
-
npx vectro serve --root ~/data # every CSV, TSV, JSON-lines, Arrow / Feather and .vectro file is a table
|
|
410
|
-
curl
|
|
420
|
+
npx -y -p vectrojs vectro serve --root ~/data # every CSV, TSV, JSON-lines, Arrow / Feather and .vectro file is a table
|
|
421
|
+
curl -G http://127.0.0.1:8787/sql --data-urlencode 'q=SELECT region, COUNT(*) AS n FROM orders GROUP BY 1' -d format=csv
|
|
411
422
|
```
|
|
412
423
|
|
|
413
424
|
| Endpoint | Gives |
|
|
@@ -439,7 +450,7 @@ On 10M orders: a group by region answers in 9 ms over HTTP; 1M rows export in 0.
|
|
|
439
450
|
## The PostgreSQL protocol: Power BI, Tableau, Metabase, Grafana, psql
|
|
440
451
|
|
|
441
452
|
```sh
|
|
442
|
-
npx vectro serve --root ~/data --pg 5433 --user me --password secret
|
|
453
|
+
npx -y -p vectrojs vectro serve --root ~/data --pg 5433 --user me --password secret
|
|
443
454
|
psql -h 127.0.0.1 -p 5433 -U me vectro
|
|
444
455
|
```
|
|
445
456
|
|
|
@@ -494,7 +505,7 @@ ODBC, and every driver. Queries arrive in each tool's own SQL and run on Vectro'
|
|
|
494
505
|
## Arrow Flight SQL: pandas, Polars, DuckDB
|
|
495
506
|
|
|
496
507
|
```sh
|
|
497
|
-
npx vectro serve --root ~/data --flight 31337 # grpc://localhost:31337 (--user / --password, --tls-cert / --tls-key)
|
|
508
|
+
npx -y -p vectrojs vectro serve --root ~/data --flight 31337 # grpc://localhost:31337 (--user / --password, --tls-cert / --tls-key)
|
|
498
509
|
```
|
|
499
510
|
|
|
500
511
|
```python
|
|
@@ -589,9 +600,9 @@ no index to build or tune, results identical to brute force.
|
|
|
589
600
|
|
|
590
601
|
| Embeddings | Vectro | Plain JS loop (Float32Array) |
|
|
591
602
|
|---|---|---|
|
|
592
|
-
| 100,000 (154 MB) |
|
|
593
|
-
| 200,000 (307 MB) | 13.
|
|
594
|
-
| 500,000 (768 MB) | 22.8 ms | 179.3 ms (7.9×) |
|
|
603
|
+
| 100,000 (154 MB) | 9.3 ms | 36.6 ms (3.9×) |
|
|
604
|
+
| 200,000 (307 MB) | 13.6 ms | 72.6 ms (5.3×) |
|
|
605
|
+
| 500,000 (768 MB), an earlier run | 22.8 ms | 179.3 ms (7.9×) |
|
|
595
606
|
|
|
596
607
|
With a `SplitEngine`, every worker searches its own rows in one round trip
|
|
597
608
|
and the page keeps the best k. In Chrome (`examples/check/vectors.html?n=200000`),
|
|
@@ -727,6 +738,7 @@ writeFileSync('eu.feather', sales.filter("region == 'EU'").toArrow({ file: true
|
|
|
727
738
|
// split tables: await engine.fromArrowAsync(bytes), await table.toArrowAsync()
|
|
728
739
|
|
|
729
740
|
// streams: any Arrow IPC stream over TCP or WebSocket, e.g. from pyarrow
|
|
741
|
+
const split = new SplitEngine(); // import { SplitEngine } from 'vectrojs'
|
|
730
742
|
const orders = await split.streamAsync({ format: 'arrow', columns: { ts: 'number', region: 'category' } });
|
|
731
743
|
// ...or Vectro's Emitter speaking Arrow: Emitter.listen({ columns, format: 'arrow' })
|
|
732
744
|
```
|
|
@@ -763,25 +775,30 @@ iPhone) and Cloudflare Workers.
|
|
|
763
775
|
|
|
764
776
|
| Query | Vectro | DuckDB 2.0 | SQLite* | Arquero |
|
|
765
777
|
|---|---|---|---|---|
|
|
766
|
-
| Filtered revenue | 3.
|
|
767
|
-
| Dashboard, 4 aggregates |
|
|
768
|
-
| Group by region (8 groups) |
|
|
769
|
-
| Group by customer (100K groups) |
|
|
770
|
-
| Filter into a new table |
|
|
771
|
-
| New column | 3.
|
|
778
|
+
| Filtered revenue | 3.2 ms | 10.1 ms | 772 ms | 74 ms |
|
|
779
|
+
| Dashboard, 4 aggregates | 4.1 ms | 23.9 ms | 359 ms | 171 ms |
|
|
780
|
+
| Group by region (8 groups) | 6.5 ms | 39.7 ms | 2.59 s | 1.00 s |
|
|
781
|
+
| Group by customer (100K groups) | 8.1 ms | 13.2 ms | 3.90 s | 772 ms |
|
|
782
|
+
| Filter into a new table | 6.4 ms | 55.7 ms | 540 ms | 124 ms |
|
|
783
|
+
| New column | 3.7 ms | 32.3 ms | 1.50 s | 125 ms |
|
|
772
784
|
|
|
773
785
|
Best of 5, each system using all 8 cores (Arquero is single-threaded), the
|
|
774
|
-
same answers from every system.
|
|
775
|
-
(
|
|
786
|
+
same answers from every system. Vectro is 1.6–8.7x faster than DuckDB here
|
|
787
|
+
(3–9x on five of the six, 1.6x with 100K groups). \*SQLite from a separate
|
|
788
|
+
run (`npm run bench:sqlite`, best of 3).
|
|
776
789
|
|
|
777
790
|
Run them yourself: `cd compare && npm install && npm run bench`. Timings vary
|
|
778
791
|
with the machine's state (battery, background load), so compare systems in the
|
|
779
|
-
same run.
|
|
792
|
+
same run. The numbers in this README were measured on 1 November 2026
|
|
793
|
+
(commit b2a3033). These runs shared the laptop with other jobs (load average 3–8 for
|
|
794
|
+
the in-memory runs, up to 15 for the billion-row run), and ratios move by up
|
|
795
|
+
to about ±30% between runs. More comparisons in `compare/`: SQL (`bench-sql.mjs`: Vectro faster
|
|
780
796
|
on all 20 queries), joins (`bench-join.mjs`: faster on all 4), group-by
|
|
781
|
-
(`bench-groupby.mjs`: faster on all 4, 2.
|
|
797
|
+
(`bench-groupby.mjs`: faster on all 4, 2.4–2.8x with millions of groups),
|
|
782
798
|
distinct counts (`bench-distinct.mjs`: level or faster on all 4). On files
|
|
783
799
|
on disk (`bench-dataset.mjs`) DuckDB is still faster at joins with a GROUP
|
|
784
|
-
BY on the joined table (1.
|
|
800
|
+
BY on the joined table (1.4–1.5x), and at 100M rows also at the filter (1.1x)
|
|
801
|
+
and the last-day lookup (3 vs 1 ms).
|
|
785
802
|
|
|
786
803
|
## Layout
|
|
787
804
|
|
package/mcp/server.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { createInterface } from 'node:readline';
|
|
|
14
14
|
import { Engine, sql } from '../src/node.mjs';
|
|
15
15
|
import { LoadError, safePath as safePathIn, loadFile, columnsOf, kindOf } from '../src/serve/catalog.mjs';
|
|
16
16
|
|
|
17
|
-
const VERSION = '0.1.
|
|
17
|
+
const VERSION = '0.1.2';
|
|
18
18
|
const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
19
19
|
const MAX_ROWS = 500, DEFAULT_ROWS = 50;
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vectrojs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Multi-core analytics engine for JavaScript: columnar tables, SQL and fused SIMD WebAssembly kernels for Node, browsers and edge runtimes, with no native code. Serves tables to Power BI, Tableau, Metabase, Grafana, pandas and Polars.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/dataset.mjs
CHANGED
|
@@ -834,7 +834,12 @@ export function* runPlanG(plan, sink = null) {
|
|
|
834
834
|
for (let n = plan; n.op !== 'scan' && n.op !== 'union'; ) { n = n.op === 'join' ? n.left : n.input; path.push(n); }
|
|
835
835
|
const scan = path[path.length - 1];
|
|
836
836
|
if (!scan.table?.isDataset) result = yield { run: plan };
|
|
837
|
-
else
|
|
837
|
+
else {
|
|
838
|
+
try { result = yield* runChunksG(plan, path, sink); } catch (err) { // (chunks are read with only the columns used: name them all)
|
|
839
|
+
if (/^vectro: unknown column /.test(err?.message)) err.message = err.message.replace(/\(columns: [^)]*\)$/, `(columns: ${scan.table.columnNames.join(', ')})`);
|
|
840
|
+
throw err;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
838
843
|
if (sink && result) { // (the result itself was built in memory)
|
|
839
844
|
if (result.cols) sink.append(result);
|
|
840
845
|
else sink.append(Object.fromEntries(Object.entries(result).map(([k, v]) => [k, [v]])));
|
package/src/engine.mjs
CHANGED
|
@@ -32,7 +32,7 @@ const DEFAULT_MIN_PARALLEL_WASM = {
|
|
|
32
32
|
const PAGE = 65536;
|
|
33
33
|
const MAX_PAGES = 65536; // 4GB, the most a 32-bit WASM address can reach
|
|
34
34
|
const HEAP_START = 64; // keep address 0 unused
|
|
35
|
-
const align16 = (n) => (n
|
|
35
|
+
const align16 = (n) => Math.ceil(n / 16) * 16; // (not bitwise: sizes may pass 2^31 bytes, and must not wrap)
|
|
36
36
|
|
|
37
37
|
// Can this runtime compile WebAssembly from bytes? (Cloudflare Workers and
|
|
38
38
|
// Vercel Edge can't: they only run modules bundled ahead of time.)
|
|
@@ -141,6 +141,8 @@ export class Engine {
|
|
|
141
141
|
// WASM SIMD kernels; other shared arrays fall back to JS kernels.
|
|
142
142
|
alloc(Type, n) {
|
|
143
143
|
const size = align16(Math.max(16, n * Type.BYTES_PER_ELEMENT));
|
|
144
|
+
// too big for the 4GB memory (a huge join output, say): fail before touching the free list
|
|
145
|
+
if (!(size <= MAX_PAGES * PAGE - 64)) throw new RangeError('vectro: out of memory (4GB limit)');
|
|
144
146
|
let ptr = -1;
|
|
145
147
|
for (let i = 0; i < this.freeList.length; i++) {
|
|
146
148
|
const blk = this.freeList[i];
|
package/src/fuse.mjs
CHANGED
|
@@ -125,6 +125,12 @@ export function walk(n, f) {
|
|
|
125
125
|
else if (n.t === 'cond') { walk(n.c, f); walk(n.x, f); walk(n.y, f); }
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// text left in an expression a kernel computes (a text column was compared with < or >, or
|
|
129
|
+
// isn't one): a clear error, not a loop between the value and condition emitters
|
|
130
|
+
function noText(n) {
|
|
131
|
+
if (n.t === 'str') throw new SyntaxError(`vectro: text like ${JSON.stringify(n.v)} can only be compared with == or != to a text column (to compare dates, load the column as dates: { dates: ['name'] })`);
|
|
132
|
+
}
|
|
133
|
+
|
|
128
134
|
function checkElementwise(n, src) {
|
|
129
135
|
walk(n, (m) => {
|
|
130
136
|
if (m.t === 'str') throw new SyntaxError(`vectro: text like ${JSON.stringify(m.v)} can only be compared (== / !=) with a text column, in "${src}"`);
|
|
@@ -162,7 +168,10 @@ export function analyze(spec) {
|
|
|
162
168
|
|
|
163
169
|
// Constant parts computed now: 2 * 3 -> 6, floor(2.5) -> 2, (1 ? x : y) -> x.
|
|
164
170
|
// Same results as the kernels (IEEE doubles; f32 kernels round literals).
|
|
165
|
-
const FOLD_BIN = { '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, '/': (a, b) => a / b
|
|
171
|
+
const FOLD_BIN = { '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, '/': (a, b) => a / b,
|
|
172
|
+
// as the kernels compute them: 1 / 0, a comparison with NaN false (but != true), NaN counting as true in && and ||
|
|
173
|
+
'==': (a, b) => +(a === b), '!=': (a, b) => +(a !== b), '<': (a, b) => +(a < b), '<=': (a, b) => +(a <= b), '>': (a, b) => +(a > b), '>=': (a, b) => +(a >= b),
|
|
174
|
+
'&&': (a, b) => +(a !== 0 && b !== 0), '||': (a, b) => +(a !== 0 || b !== 0) };
|
|
166
175
|
const FOLD_FN = { abs: Math.abs, sqrt: Math.sqrt, floor: Math.floor, ceil: Math.ceil, min: Math.min, max: Math.max };
|
|
167
176
|
export function fold(n) {
|
|
168
177
|
const isNum = (x) => x.t === 'num';
|
|
@@ -255,8 +264,8 @@ export class Emitter {
|
|
|
255
264
|
this.memo.set(m, l);
|
|
256
265
|
}
|
|
257
266
|
|
|
258
|
-
vec(n, k) { return this.#reuse(n, k, V128, () => this.#vec(n, k)); }
|
|
259
|
-
sca(n) { return this.#reuse(n, 'sca', this.ty.val, () => this.#sca(n)); }
|
|
267
|
+
vec(n, k) { noText(n); return this.#reuse(n, k, V128, () => this.#vec(n, k)); }
|
|
268
|
+
sca(n) { noText(n); return this.#reuse(n, 'sca', this.ty.val, () => this.#sca(n)); }
|
|
260
269
|
|
|
261
270
|
#vec(n, k) {
|
|
262
271
|
const { f, env } = this, V = VEC[this.t];
|
package/src/join.mjs
CHANGED
|
@@ -415,7 +415,7 @@ function* buildRight(engine, t, L, R, lk, rk, how) {
|
|
|
415
415
|
for (let k = 0; k < nk; k++) {
|
|
416
416
|
const a = L.cols.get(lk[k]), b = R.cols.get(rk[k]);
|
|
417
417
|
if (!b.dict) { build.rightKeys.push(b.data); continue; }
|
|
418
|
-
const lut = b.dict.map((label) => a.codes?.get(label) ?? NaN); // a label the left table lacks matches nothing
|
|
418
|
+
const lut = b.dict.map((label) => (label === '' ? NaN : a.codes?.get(label) ?? NaN)); // missing text, or a label the left table lacks, matches nothing
|
|
419
419
|
const mapped = alloc(L.type, nR);
|
|
420
420
|
const src = engine.live(b.data), dst = engine.live(mapped);
|
|
421
421
|
for (let i = 0; i < nR; i++) dst[i] = lut[src[i]];
|
package/src/sort.mjs
CHANGED
|
@@ -22,10 +22,11 @@ export function parseKeys(by, cols) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// Ranks for a category column's codes: code -> position of its label A..Z.
|
|
25
|
+
// Missing text ('') ranks NaN, so it comes last either way, as a missing number does.
|
|
25
26
|
export function labelRanks(dict, desc) {
|
|
26
27
|
const order = dict.map((_, i) => i).sort((a, b) => (dict[a] < dict[b] ? -1 : dict[a] > dict[b] ? 1 : 0));
|
|
27
28
|
const rank = new Float64Array(dict.length);
|
|
28
|
-
order.forEach((code, r) => { rank[code] = desc ? -r : r; });
|
|
29
|
+
order.forEach((code, r) => { rank[code] = dict[code] === '' || dict[code] == null ? NaN : desc ? -r : r; });
|
|
29
30
|
return rank;
|
|
30
31
|
}
|
|
31
32
|
|
package/src/sql.mjs
CHANGED
|
@@ -872,20 +872,39 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
872
872
|
}
|
|
873
873
|
// join rel (its columns `cols`) on outer key ASTs = rel key columns; the
|
|
874
874
|
// rel's columns get fresh names; returns them
|
|
875
|
-
|
|
875
|
+
// a subquery's columns looked up on the outer rows by key; in a grouped SELECT (ctx.step deferred),
|
|
876
|
+
// done after grouping, so the columns are there for the grouped rows
|
|
877
|
+
function attach(rel, outerKeys, relKeys, cols, ctx) {
|
|
876
878
|
const tag = n.hidden++;
|
|
877
879
|
const fresh = (c) => `__s${tag}_${c}`;
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
880
|
+
const run = () => {
|
|
881
|
+
let rq = rel.query;
|
|
882
|
+
const all = [...new Set([...relKeys, ...cols])];
|
|
883
|
+
all.forEach((c) => { rq = rq.derive(fresh(c), c); });
|
|
884
|
+
rq = rq.select(...all.map(fresh));
|
|
885
|
+
let lefts = outerKeys.map((t) => hide(t));
|
|
886
|
+
let rights = relKeys.map(fresh);
|
|
887
|
+
if (!lefts.length) { const k = `__x${n.hidden++}`; q = q.derive(k, '0'); rq = rq.derive(k, '0'); lefts = [k]; rights = [k]; }
|
|
888
|
+
q = q.join(rq, { on: { left: lefts, right: rights }, how: 'left', view: true }); // (its keys are unique: right columns looked up, left rows not copied)
|
|
889
|
+
};
|
|
890
|
+
if (ctx?.step) ctx.step({ name: fresh(cols[0]), inputs: [], run }); else run();
|
|
886
891
|
return cols.map(fresh);
|
|
887
892
|
}
|
|
888
|
-
|
|
893
|
+
// x NOT IN (subquery), as SQL has it: true where the subquery has no rows (x missing or not);
|
|
894
|
+
// else true only where x is present, not among the values, and none of them is missing
|
|
895
|
+
function notInValue(e, ctx, rel, vcol, mcol, relKeys, outerKeys) { // (rel: one row per value and key, mcol 1 on each)
|
|
896
|
+
const missing = rel.kinds?.get(vcol) === 'text' ? `${vcol} == ''` : `${vcol} != ${vcol}`;
|
|
897
|
+
let sq = rel.query;
|
|
898
|
+
let keys = relKeys;
|
|
899
|
+
if (!keys.length) { sq = sq.derive('__g', '0'); keys = ['__g']; }
|
|
900
|
+
sq = sq.groupBy(keys).agg({ __n: 'count()', __nn: `count(${missing})` });
|
|
901
|
+
const [cn, cnn] = attach({ ...rel, query: sq, names: [...keys, '__n', '__nn'] }, outerKeys, relKeys, ['__n', '__nn'], ctx);
|
|
902
|
+
const [m] = attach(rel, [translate(e.x, ctx), ...outerKeys], [vcol, ...relKeys], [mcol], ctx);
|
|
903
|
+
const isIn = bin('==', v(m), num(1));
|
|
904
|
+
const none = bin('==', call('coalesce', v(cn), num(0)), num(0));
|
|
905
|
+
return bin('||', none, bin('&&', bin('&&', present(e.x, ctx), { t: 'un', op: '!', x: isIn }), bin('==', call('coalesce', v(cnn), num(0)), num(0))));
|
|
906
|
+
}
|
|
907
|
+
function subqueryValue(e, ctx, negate = false) {
|
|
889
908
|
if (e.k === 'exists') {
|
|
890
909
|
const { inner, pairs } = correlate(e.q);
|
|
891
910
|
const body = inner.body;
|
|
@@ -893,7 +912,7 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
893
912
|
const sel = { ...body, distinct: true, items: [...keyItems, { e: { k: 'num', v: 1 }, alias: '__m' }], groupBy: [] };
|
|
894
913
|
if (body.groupBy?.length || body.having) fail('EXISTS (SELECT ... GROUP BY ...) is not supported');
|
|
895
914
|
const rel = compileQuery({ ...inner, body: sel, orderBy: [], limit: pairs.length ? null : 1, offset: 0 }, env, outerHere);
|
|
896
|
-
const [m] = attach(rel, pairs.map((pr) => translate(pr.outer, ctx)), keyItems.map((it) => it.alias), ['__m']);
|
|
915
|
+
const [m] = attach(rel, pairs.map((pr) => translate(pr.outer, ctx)), keyItems.map((it) => it.alias), ['__m'], ctx);
|
|
897
916
|
return bin('==', v(m), num(1));
|
|
898
917
|
}
|
|
899
918
|
if (e.k === 'insub') {
|
|
@@ -904,7 +923,8 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
904
923
|
const rel = compileQuery(inner, env, outerHere);
|
|
905
924
|
if (rel.names.length !== 1) fail('IN (SELECT ...) needs exactly one column');
|
|
906
925
|
const dist = { ...rel, query: rel.query.groupBy([rel.names[0]]).agg({ __m: 'count()' }).derive('__m1', '1') };
|
|
907
|
-
|
|
926
|
+
if (negate) return notInValue(e, ctx, { ...dist, names: [rel.names[0], '__m', '__m1'] }, rel.names[0], '__m1', [], []);
|
|
927
|
+
const [m] = attach({ ...dist, names: [rel.names[0], '__m1'] }, [translate(e.x, ctx)], [rel.names[0]], ['__m1'], ctx);
|
|
908
928
|
return bin('==', v(m), num(1));
|
|
909
929
|
}
|
|
910
930
|
fail('IN (SELECT ...) needs exactly one column');
|
|
@@ -913,7 +933,8 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
913
933
|
const sel = { ...body, distinct: !body.groupBy?.length, items: [{ e: body.items[0].e, alias: '__v' }, ...keyItems, ...(body.groupBy?.length ? [] : [{ e: { k: 'num', v: 1 }, alias: '__m' }])] };
|
|
914
934
|
let rel = compileQuery({ ...inner, body: sel, orderBy: [], limit: null, offset: 0 }, env, outerHere);
|
|
915
935
|
if (body.groupBy?.length) { const keys = ['__v', ...keyItems.map((it) => it.alias)]; rel = { ...rel, query: rel.query.groupBy(keys).agg({ __c: 'count()' }).derive('__m', '1'), names: [...keys, '__m'] }; }
|
|
916
|
-
|
|
936
|
+
if (negate) return notInValue(e, ctx, rel, '__v', '__m', keyItems.map((it) => it.alias), pairs.map((pr) => translate(pr.outer, ctx)));
|
|
937
|
+
const [m] = attach(rel, [translate(e.x, ctx), ...pairs.map((pr) => translate(pr.outer, ctx))], ['__v', ...keyItems.map((it) => it.alias)], ['__m'], ctx);
|
|
917
938
|
return bin('==', v(m), num(1));
|
|
918
939
|
}
|
|
919
940
|
// (SELECT one value)
|
|
@@ -923,14 +944,14 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
923
944
|
if (!pairs.length) {
|
|
924
945
|
const rel = compileQuery(inner, env, outerHere);
|
|
925
946
|
if (rel.names.length !== 1) fail('a subquery used as a value needs exactly one column');
|
|
926
|
-
const [c] = attach(rel, [], [], [rel.names[0]]);
|
|
947
|
+
const [c] = attach(rel, [], [], [rel.names[0]], ctx);
|
|
927
948
|
return v(c);
|
|
928
949
|
}
|
|
929
950
|
if (!(body.items.length === 1 && hasAgg(body.items[0].e)) || body.groupBy?.length) fail('a correlated subquery used as a value must compute one aggregate, e.g. (SELECT MAX(x) FROM t WHERE t.k = o.k)');
|
|
930
951
|
const keyItems = pairs.map((pr, i) => ({ e: pr.inner, alias: `__k${i}` }));
|
|
931
952
|
const sel = { ...body, items: [...keyItems, { e: body.items[0].e, alias: '__v' }], groupBy: keyItems.map((it) => it.e) };
|
|
932
953
|
const rel = compileQuery({ ...inner, body: sel, orderBy: [], limit: null, offset: 0 }, env, outerHere);
|
|
933
|
-
const [c] = attach(rel, pairs.map((pr) => translate(pr.outer, ctx)), keyItems.map((it) => it.alias), ['__v']);
|
|
954
|
+
const [c] = attach(rel, pairs.map((pr) => translate(pr.outer, ctx)), keyItems.map((it) => it.alias), ['__v'], ctx);
|
|
934
955
|
const it = body.items[0].e;
|
|
935
956
|
return it.k === 'call' && it.fn === 'COUNT' ? call('coalesce', v(c), num(0)) : v(c); // (no rows: COUNT is 0)
|
|
936
957
|
}
|
|
@@ -1099,9 +1120,19 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1099
1120
|
// true where a value is there (SQL's NULL is NaN for numbers, '' for text); null: always there (a literal)
|
|
1100
1121
|
function present(e, ctx) {
|
|
1101
1122
|
if (e.k === 'num' || e.k === 'str' || e.k === 'date' || e.k === 'interval') return num(1);
|
|
1123
|
+
if (isCond(e)) return bin('||', translate(e, ctx), negated(e, ctx)); // (a condition: known where it's true or false)
|
|
1102
1124
|
const x = translate(e, ctx);
|
|
1103
1125
|
return kindOf(e, ctx) === 'text' ? bin('!=', x, { t: 'str', v: '' }) : bin('==', x, x);
|
|
1104
1126
|
}
|
|
1127
|
+
// a true / false / unknown condition (comparisons, AND, OR, NOT, IS, IN, LIKE...): translate()
|
|
1128
|
+
// gives where it's true, negated() where it's false; elsewhere it's unknown
|
|
1129
|
+
function isCond(e) {
|
|
1130
|
+
if (e.k === 'num') return !!e.bool;
|
|
1131
|
+
if (e.k === 'bin') return ['AND', 'OR', 'IS', '=', '<>', '!=', '<', '<=', '>', '>=', '~', '~*', '!~', '!~*'].includes(e.op);
|
|
1132
|
+
return ['not', 'isnull', 'like', 'in', 'between', 'exists', 'insub'].includes(e.k);
|
|
1133
|
+
}
|
|
1134
|
+
// a condition as a value: 1 true, 0 false, NaN unknown (SQL's NULL boolean)
|
|
1135
|
+
function condValue(e, ctx, t) { return cond(t, num(1), cond(negated(e, ctx), num(0), num(NaN))); }
|
|
1105
1136
|
// NOT x with SQL's three values: where x is unknown (a missing value), so is NOT x, and the
|
|
1106
1137
|
// row is dropped. Comparisons become their opposites (false on missing values already).
|
|
1107
1138
|
const FLIP = { '=': '<>', '<>': '=', '!=': '=', '<': '>=', '<=': '>', '>': '<=', '>=': '<' };
|
|
@@ -1118,7 +1149,8 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1118
1149
|
case 'in':
|
|
1119
1150
|
if (kindOf(x.x, ctx) === 'text') return bin('&&', { t: 'un', op: '!', x: T(x) }, present(x.x, ctx));
|
|
1120
1151
|
return x.list.map((i) => T({ k: 'bin', op: '<>', l: x.x, r: i })).reduce((a, b) => bin('&&', a, b));
|
|
1121
|
-
case 'like':
|
|
1152
|
+
case 'like': return bin('&&', { t: 'un', op: '!', x: T(x) }, present(x.x, ctx));
|
|
1153
|
+
case 'insub': return subqueryValue(x, ctx, true);
|
|
1122
1154
|
case 'exists': case 'isnull': return { t: 'un', op: '!', x: T(x) };
|
|
1123
1155
|
default: { // a true / false value (1 / 0): NOT of a missing one stays missing
|
|
1124
1156
|
const v = T(x);
|
|
@@ -1146,9 +1178,16 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1146
1178
|
case 'exists': case 'insub': case 'subq': return subqueryValue(e, ctx);
|
|
1147
1179
|
case 'window': return ctx.window(e);
|
|
1148
1180
|
case 'bin': {
|
|
1149
|
-
|
|
1150
|
-
if (e.op === '
|
|
1181
|
+
const B = (x) => (x.k === 'null' ? num(0) : T(x)); // (a NULL condition is unknown: never true)
|
|
1182
|
+
if (e.op === 'AND') return bin('&&', B(e.l), B(e.r));
|
|
1183
|
+
if (e.op === 'OR') return bin('||', B(e.l), B(e.r));
|
|
1151
1184
|
const op = { '=': '==', '<>': '!=' }[e.op] ?? e.op;
|
|
1185
|
+
if (op === 'IS' && e.r.k === 'num' && e.r.bool) { // x IS TRUE / IS FALSE: never unknown (unknown is neither)
|
|
1186
|
+
const x = e.l;
|
|
1187
|
+
if (isCond(x)) return e.r.v ? T(x) : negated(x, ctx);
|
|
1188
|
+
if (x.k === 'null') return num(0);
|
|
1189
|
+
return e.r.v ? bin('&&', bin('!=', T(x), num(0)), present(x, ctx)) : bin('==', T(x), num(0));
|
|
1190
|
+
}
|
|
1152
1191
|
if (op === 'IS') { // a IS b: equal, or both missing
|
|
1153
1192
|
const a = T(e.l), b = T(e.r);
|
|
1154
1193
|
return bin('||', bin('==', a, b), bin('&&', call('isMissing', a), call('isMissing', b)));
|
|
@@ -1159,6 +1198,8 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1159
1198
|
return op.startsWith('!') ? bin('==', m, num(0)) : bin('==', m, num(1));
|
|
1160
1199
|
}
|
|
1161
1200
|
if (['==', '!=', '<', '<=', '>', '>='].includes(op)) {
|
|
1201
|
+
if (isCond(e.l) || isCond(e.r)) // (conditions compared: unknown if either one is)
|
|
1202
|
+
return bin('&&', bin(op, T(e.l), T(e.r)), bin('&&', present(e.l, ctx), present(e.r, ctx)));
|
|
1162
1203
|
const lk = kindOf(e.l, ctx), rk = kindOf(e.r, ctx);
|
|
1163
1204
|
if (lk === 'text' || rk === 'text') {
|
|
1164
1205
|
if ((lk === 'date' && e.r.k === 'str') || (rk === 'date' && e.l.k === 'str')) // a date written as text
|
|
@@ -1265,7 +1306,11 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1265
1306
|
const s = bin('*', a[0], num(f));
|
|
1266
1307
|
return bin('/', cond(bin('>=', s, num(0)), call('floor', bin('+', s, num(0.5))), call('ceil', bin('-', s, num(0.5)))), num(f));
|
|
1267
1308
|
}
|
|
1268
|
-
case 'LEAST': case 'GREATEST':
|
|
1309
|
+
case 'LEAST': case 'GREATEST': { // missing values skipped, as in PostgreSQL and DuckDB (NULL only if all are)
|
|
1310
|
+
if (a.length < 2) fail(`${e.fn}() needs two values or more`);
|
|
1311
|
+
const f = e.fn === 'LEAST' ? 'min' : 'max';
|
|
1312
|
+
return a.reduce((x, y) => call(f, call('coalesce', x, y), call('coalesce', y, x)));
|
|
1313
|
+
}
|
|
1269
1314
|
case 'COALESCE': case 'IFNULL': case 'NVL': if (a.length < 2) fail(`${e.fn}() needs two values or more`); return call('coalesce', ...a);
|
|
1270
1315
|
case 'NULLIF': need(2); return cond(bin('==', a[0], a[1]), num(NaN), a[0]);
|
|
1271
1316
|
case 'DATE_TRUNC': {
|
|
@@ -1303,7 +1348,7 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1303
1348
|
if (ast.where) {
|
|
1304
1349
|
if (hasAgg(ast.where)) fail('aggregates (SUM, COUNT...) can\'t be used in WHERE: use HAVING');
|
|
1305
1350
|
if (hasWindow(ast.where)) fail('window functions can\'t be used in WHERE (filter on them in an outer query)');
|
|
1306
|
-
const t = translate(ast.where, plain);
|
|
1351
|
+
const t = ast.where.k === 'null' ? num(0) : translate(ast.where, plain); // (WHERE NULL: unknown, no row)
|
|
1307
1352
|
q = q.filter(src(t));
|
|
1308
1353
|
}
|
|
1309
1354
|
|
|
@@ -1440,7 +1485,9 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1440
1485
|
// aggregates, each once
|
|
1441
1486
|
const aggs = new Map(); // sqlKey -> name
|
|
1442
1487
|
const spec = {}, extraKeys = [];
|
|
1443
|
-
const
|
|
1488
|
+
const aggDone = new Map(); // (an aggregate used twice: the same expression, SUM's NULL check included)
|
|
1489
|
+
const aggOf = (e) => { const k = key(e); if (!aggDone.has(k)) aggDone.set(k, aggOnce(e)); return aggDone.get(k); };
|
|
1490
|
+
const aggOnce = (e) => {
|
|
1444
1491
|
const k = key(e);
|
|
1445
1492
|
if (aggs.has(k)) return v(aggs.get(k));
|
|
1446
1493
|
const name = `__a${aggs.size}_${n.hidden++}`;
|
|
@@ -1480,8 +1527,9 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1480
1527
|
return translate(e, post);
|
|
1481
1528
|
};
|
|
1482
1529
|
planWindows(translatePost, (x) => kindOf(x, post));
|
|
1483
|
-
const
|
|
1484
|
-
const
|
|
1530
|
+
const tri = (e, t) => { if (!isCond(e) || e.k === 'num' || t.t === 'var') return t; try { return condValue(e, post, t); } catch { return t; } }; // (built from GROUP BY expressions: as it is)
|
|
1531
|
+
const outs = withCols(items.map((it) => { const t = translatePost(it.e); return { ...it, ast: textConst(tri(it.e, t), post) }; }));
|
|
1532
|
+
const havingAst = ast.having ? (ast.having.k === 'null' ? num(0) : translatePost(ast.having)) : null;
|
|
1485
1533
|
const orderAsts = orderBy.map((o) => ({ ...o, ast: orderTarget(o.e, outs, translatePost) }));
|
|
1486
1534
|
q = q.groupBy([...keys.map((k) => k.name), ...extraKeys]).agg(Object.keys(spec).length ? spec : { __a_rows: 'count()' });
|
|
1487
1535
|
const late = new Set(windowVars.values()), after = []; // (those using window values: after the windows)
|
|
@@ -1490,16 +1538,16 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1490
1538
|
applyWindows();
|
|
1491
1539
|
for (const st of after) st.run();
|
|
1492
1540
|
for (const o of outs) if (o.ast.t !== 'var') q = q.derive(o.col, src(o.ast));
|
|
1493
|
-
for (const o of orderAsts) sortKey(o);
|
|
1541
|
+
for (const o of orderAsts) sortKey(o, orderKind(o, outs, post));
|
|
1494
1542
|
return finish(outs, (o) => (o.e.k === 'col' ? post.kindOfCol(o.e) ?? kinds.get(resolveSafe(o.e)) : kindOf(o.e, post)), post);
|
|
1495
1543
|
}
|
|
1496
1544
|
const pre = windowCtx(plain);
|
|
1497
1545
|
planWindows((e) => (e.k === 'col' ? colOf(e) : translate(e, pre)), (x) => kindOf(x, pre));
|
|
1498
1546
|
applyWindows();
|
|
1499
|
-
const outs = withCols(items.map((it) =>
|
|
1547
|
+
const outs = withCols(items.map((it) => { const t = it.e.k === 'col' ? colOf(it.e) : translate(it.e, pre); return { ...it, ast: textConst(isCond(it.e) && it.e.k !== 'num' ? condValue(it.e, pre, t) : t, pre) }; }));
|
|
1500
1548
|
const orderAsts = orderBy.map((o) => ({ ...o, ast: orderTarget(o.e, outs, (e) => (e.k === 'col' ? colOf(e) : translate(e, pre))) }));
|
|
1501
1549
|
for (const o of outs) if (o.ast.t !== 'var') q = q.derive(o.col, src(o.ast));
|
|
1502
|
-
for (const o of orderAsts) sortKey(o);
|
|
1550
|
+
for (const o of orderAsts) sortKey(o, orderKind(o, outs, pre));
|
|
1503
1551
|
if (ast.distinct) q = q.groupBy([...new Set(outs.map((o) => o.col))]).agg({ __distinct_rows: 'count()' });
|
|
1504
1552
|
return finish(outs, (o) => (o.e.k === 'col' ? (sessionRef(o.e)?.kind ?? kinds.get(o.e.resolved ? o.e.name : resolve(o.e))) : kindOf(o.e, pre)), pre);
|
|
1505
1553
|
|
|
@@ -1516,11 +1564,17 @@ function compileSelect(ast, env, outer, tail) {
|
|
|
1516
1564
|
const t = tr(e), same = outs.find((o) => toSourceAst(o.ast) === toSourceAst(t));
|
|
1517
1565
|
return same ? v(same.col) : t;
|
|
1518
1566
|
}
|
|
1519
|
-
|
|
1567
|
+
// the kind (text or not) of what an ORDER BY item sorts: a select item by number or name, or the expression
|
|
1568
|
+
function orderKind(o, outs, ctx) {
|
|
1569
|
+
const e = o.e.k === 'num' ? outs[o.e.v - 1]?.e : (o.e.k === 'col' && !o.e.table && outs.find((x) => x.name === o.e.name)?.e) || o.e;
|
|
1570
|
+
try { return kindOf(e, ctx); } catch { return null; }
|
|
1571
|
+
}
|
|
1572
|
+
function sortKey(o, kind) {
|
|
1520
1573
|
let k;
|
|
1521
1574
|
if (o.ast.t === 'var') k = o.ast.name;
|
|
1522
1575
|
else { k = `__o${n.hidden++}`; q = q.derive(k, src(o.ast)); }
|
|
1523
|
-
|
|
1576
|
+
// missing sorts last on its own (NaN, and '' for text); NULLS FIRST sorts present values after missing ones first
|
|
1577
|
+
if (o.nullsFirst) { const h = `__nf${n.hidden++}`; q = q.derive(h, kind === 'text' ? `${k} == '' ? 0 : 1` : `${k} == ${k} ? 1 : 0`); sortKeys.push(h); }
|
|
1524
1578
|
sortKeys.push(o.desc ? `-${k}` : k);
|
|
1525
1579
|
}
|
|
1526
1580
|
function finish(outs, kindFn, ctx) {
|
package/src/table.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { parseKeys, labelRanks, sortIndex } from './sort.mjs';
|
|
9
9
|
import { ZONE_ROWS, computeZones, zoneRange } from './zones.mjs';
|
|
10
|
-
import { parse, toSource, walk, analyze, isMask } from './fuse.mjs';
|
|
10
|
+
import { parse, toSource, walk, analyze, isMask, fold } from './fuse.mjs';
|
|
11
11
|
import { generateGroupBy, generateCompact, generateHashGroups, generateMarkPairs, generateStridedKeys } from './table-kernels.mjs';
|
|
12
12
|
import { decodeString } from './csv-kernel.mjs';
|
|
13
13
|
import { maxUnits } from './kernels.mjs';
|
|
@@ -31,6 +31,10 @@ const IDENTITY = { sum: 0, count: 0, mean: 0, min: Infinity, max: -Infinity };
|
|
|
31
31
|
const MAX_GROUP_SLOTS = 2 ** 28; // threads * aggregations * groups (2GB of float64)
|
|
32
32
|
const PARALLEL_SORT_ROWS = 50_000; // smaller tables sort on the calling thread
|
|
33
33
|
const TIME_STEPS = [86_400_000, 3_600_000, 60_000, 1_000]; // a day, an hour, a minute, a second (ms): see #directGroups
|
|
34
|
+
// Column facts worked out once and remembered with the column's array
|
|
35
|
+
// (arrays are never written once in a table):
|
|
36
|
+
const MISSING = new WeakMap(); // { n: rows, missing: how many missing values }
|
|
37
|
+
const KEY_RANGE = new WeakMap(); // { n: rows, range: [min, max, how many aren't whole] } (see #directGroups)
|
|
34
38
|
|
|
35
39
|
export class Table {
|
|
36
40
|
// cols: Map name -> { data, dict, codes, strings }
|
|
@@ -103,17 +107,18 @@ export class Table {
|
|
|
103
107
|
for (const name of Object.keys(rows[0])) {
|
|
104
108
|
checkName(name);
|
|
105
109
|
const data = engine.alloc(type, n);
|
|
106
|
-
|
|
110
|
+
const first = firstPresent(rows.map((r) => r[name]));
|
|
111
|
+
if (typeof first === 'string') {
|
|
107
112
|
const codes = new Map(), dict = [];
|
|
108
113
|
for (let i = 0; i < n; i++) {
|
|
109
|
-
const s = rows[i][name];
|
|
114
|
+
const s = rows[i][name] ?? ''; // (null / undefined: missing text)
|
|
110
115
|
let c = codes.get(s);
|
|
111
116
|
if (c === undefined) { c = dict.length; dict.push(s); codes.set(s, c); }
|
|
112
117
|
data[i] = c;
|
|
113
118
|
}
|
|
114
119
|
cols.set(name, { data, dict, codes });
|
|
115
120
|
} else {
|
|
116
|
-
const date =
|
|
121
|
+
const date = first instanceof Date;
|
|
117
122
|
if (date && type === Float32Array) throw new Error(f32Dates(name));
|
|
118
123
|
for (let i = 0; i < n; i++) { const v = rows[i][name]; data[i] = v === null || v === undefined ? NaN : date ? toMillis(v) : v; } // (null: missing)
|
|
119
124
|
cols.set(name, { data, dict: null, codes: null, ...(date ? { date: true } : {}) });
|
|
@@ -310,7 +315,14 @@ export class Table {
|
|
|
310
315
|
// Lazy filter: returns a view of the same columns with a condition attached.
|
|
311
316
|
filter(expr, params = {}) {
|
|
312
317
|
const merged = this.#mergeParams(params);
|
|
313
|
-
|
|
318
|
+
let { ast, src } = this.#bind(expr, merged);
|
|
319
|
+
walk(ast, (m) => { if (m.t === 'str') throw new SyntaxError(`vectro: text like ${JSON.stringify(m.v)} can only be compared with == or != to a text column, in "${expr}" (to compare dates, load the column as dates: { dates: ['name'] })`); });
|
|
320
|
+
const k = fold(ast);
|
|
321
|
+
if (k.t === 'num') { // no column in it (1 == 1, 0): every row, or none (a kernel needs a column to read)
|
|
322
|
+
if (k.v !== 0) return new Table(this.engine, this.cols, this.n, { pred: this.pred, params: merged, zones: this.zones });
|
|
323
|
+
const c = [...this.cols.keys()].find((x) => !this.cols.get(x).strings);
|
|
324
|
+
src = `${c} != ${c} && ${c} == ${c}`;
|
|
325
|
+
}
|
|
314
326
|
const pred = this.pred ? `(${this.pred}) && (${src})` : src;
|
|
315
327
|
return new Table(this.engine, this.cols, this.n, { pred, params: merged, zones: this.zones });
|
|
316
328
|
}
|
|
@@ -578,6 +590,19 @@ export class Table {
|
|
|
578
590
|
return Math.floor((engine._directSlots ?? DIRECT_SLOTS) / (engine.T * totals));
|
|
579
591
|
}
|
|
580
592
|
|
|
593
|
+
// Is src a number column of this table with no missing values? Counted
|
|
594
|
+
// once per column (one pass on every thread) and remembered with its array.
|
|
595
|
+
*#noMissingG(src) {
|
|
596
|
+
const c = src && IDENT.test(src) ? this.cols.get(src) : null;
|
|
597
|
+
if (!c || c.dict || c.strings) return false;
|
|
598
|
+
let known = MISSING.get(c.data);
|
|
599
|
+
if (known?.n !== this.n) {
|
|
600
|
+
const k = c.enc ? { data: c.data, enc: c.enc, n: this.n, type: this.type === Float32Array ? 'f32' : 'f64' } : c.data;
|
|
601
|
+
MISSING.set(c.data, (known = { n: this.n, missing: (yield* this.engine.evalG(['count(k != k)'], { k }))[0] }));
|
|
602
|
+
}
|
|
603
|
+
return known.missing === 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
581
606
|
// Keys small enough to be group numbers as they are: a text column's codes
|
|
582
607
|
// or whole numbers 0..2^24, several combined as k1 * size2 + k2..., while
|
|
583
608
|
// the accumulators stay small (or no larger than the rows). Else null.
|
|
@@ -590,7 +615,9 @@ export class Table {
|
|
|
590
615
|
if (!this.n) { sizes.push(0); offsets.push(0); steps.push(1); continue; }
|
|
591
616
|
// whole numbers in a range under 2^24 (from their minimum: 202301..202412, IDs from 1e9, negatives)
|
|
592
617
|
const k = c.enc ? { data: c.data, enc: c.enc, n: this.n, type: this.type === Float32Array ? 'f32' : 'f64' } : c.data;
|
|
593
|
-
|
|
618
|
+
let known = KEY_RANGE.get(c.data);
|
|
619
|
+
if (known?.n !== this.n) KEY_RANGE.set(c.data, (known = { n: this.n, range: yield* engine.evalG(['min(k)', 'max(k)', 'count(k != floor(k))'], { k }) }));
|
|
620
|
+
const [lo, hi, frac] = known.range;
|
|
594
621
|
if (frac > 0 || !Number.isFinite(lo) || !Number.isFinite(hi)) return null;
|
|
595
622
|
// or times a whole number of days (hours, minutes, seconds) apart: numbered by that unit
|
|
596
623
|
let step = 1;
|
|
@@ -604,9 +631,16 @@ export class Table {
|
|
|
604
631
|
offsets.push(lo);
|
|
605
632
|
steps.push(step);
|
|
606
633
|
}
|
|
634
|
+
const fits = (K) => !(K > 2 ** 24 || engine.T * J * K * (engine.jit && K <= 1024 ? 4 : 1) > (engine._maxGroupSlots ?? MAX_GROUP_SLOTS) || (K > 65_536 && K > 8 * this.n));
|
|
635
|
+
// One key from a small positive number (IDs from 1, years from 1990):
|
|
636
|
+
// numbered from 0 with a few groups left empty, so the key column itself
|
|
637
|
+
// is the group number and needs no copy.
|
|
638
|
+
if (keys.length === 1 && steps[0] === 1 && offsets[0] > 0 && offsets[0] <= Math.max(1024, sizes[0]) && offsets[0] + sizes[0] <= maxK && fits(offsets[0] + sizes[0])) {
|
|
639
|
+
sizes[0] += offsets[0];
|
|
640
|
+
offsets[0] = 0;
|
|
641
|
+
}
|
|
607
642
|
const K = sizes.reduce((a, b) => a * b, 1);
|
|
608
|
-
|
|
609
|
-
if (K > 2 ** 24 || engine.T * J * K * C > (engine._maxGroupSlots ?? MAX_GROUP_SLOTS) || (K > 65_536 && K > 8 * this.n)) return null;
|
|
643
|
+
if (!fits(K)) return null;
|
|
610
644
|
if (K > maxK) { if (info) info.K = K; return null; }
|
|
611
645
|
const below = sizes.map((_, k) => sizes.slice(k + 1).reduce((a, b) => a * b, 1));
|
|
612
646
|
const keyColumn = (k, keep) => directKeys(keep, below[k], sizes[k], steps[k], offsets[k]); // key k of each kept group (codes for text)
|
|
@@ -747,12 +781,20 @@ export class Table {
|
|
|
747
781
|
*#runGroupsG(keys, groups, allItems, merged) {
|
|
748
782
|
const { engine } = this;
|
|
749
783
|
const { K } = groups;
|
|
750
|
-
|
|
784
|
+
// Over a column with no missing values, count(x) is the row count (always
|
|
785
|
+
// counted) and sum / mean / min / max need no check for missing values:
|
|
786
|
+
// SQL's SUM(x) is sum(x) and count(x), so this takes a whole accumulator off.
|
|
787
|
+
const full = new Set();
|
|
788
|
+
for (const it of allItems) if (!DISTINCT.has(it.fn) && (yield* this.#noMissingG(it.raw))) full.add(it);
|
|
789
|
+
const rowCount = (it) => it.fn === 'count' && (!it.arg || full.has(it));
|
|
790
|
+
const items = allItems.filter((it) => !DISTINCT.has(it.fn) && !rowCount(it));
|
|
751
791
|
// means need the count of values present: one more kernel item each
|
|
752
|
-
const kernelItems = [...items
|
|
792
|
+
const kernelItems = [...items.map((it) => (full.has(it) ? { fn: it.fn, arg: it.raw } : it)),
|
|
793
|
+
...items.filter((it) => it.present && !full.has(it)).map((it) => ({ fn: 'count', arg: it.present, of: it }))]; // means, mins, maxes
|
|
753
794
|
const totals = yield* this.#groupTotalsG(groups, kernelItems, merged);
|
|
754
795
|
if (!totals) return null; // (lookup groups the kernels can't take)
|
|
755
|
-
const
|
|
796
|
+
const totalOf = (it) => (rowCount(it) ? totals[0] : totals[1 + items.indexOf(it)]);
|
|
797
|
+
const presentOf = (it) => (full.has(it) ? totals[0] : totals[1 + kernelItems.findIndex((k) => k.of === it)]);
|
|
756
798
|
// Keep groups that have rows, sorted by key (codes for text keys, so
|
|
757
799
|
// dictionary order; NaN last), and build whole columns.
|
|
758
800
|
let keep = keptGroups(totals[0], K);
|
|
@@ -768,7 +810,7 @@ export class Table {
|
|
|
768
810
|
}
|
|
769
811
|
// each item's value for every group number
|
|
770
812
|
const perGroup = allItems.map((it) => {
|
|
771
|
-
const
|
|
813
|
+
const v = DISTINCT.has(it.fn) ? it.perGroup : totalOf(it);
|
|
772
814
|
return it.fn === 'mean' ? ratioOf(v, presentOf(it)) : it.fn === 'min' || it.fn === 'max' ? presentOnly(v, presentOf(it)) : v;
|
|
773
815
|
});
|
|
774
816
|
if (engine.jit && m >= PARALLEL_SORT_ROWS) return yield* this.#groupTableG(keys, groups, allItems, perGroup, keep);
|
|
@@ -963,29 +1005,34 @@ export class Table {
|
|
|
963
1005
|
interpretGroup(engine, t, analysis.items, predAst, cols, merged, groups.data, acc, K, this.n);
|
|
964
1006
|
}
|
|
965
1007
|
|
|
966
|
-
// Combine threads (and copies) into one value per group and aggregation
|
|
1008
|
+
// Combine threads (and copies) into one value per group and aggregation:
|
|
1009
|
+
// the thread blocks by fused passes added into thread 0's, 15 blocks a
|
|
1010
|
+
// pass (kernels, so fast from the first query; small ones run on this
|
|
1011
|
+
// thread), then the C copies of each group by a plain loop.
|
|
967
1012
|
totals = [];
|
|
968
1013
|
for (const [j, it] of analysis.items.entries()) {
|
|
969
|
-
|
|
970
|
-
if (
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const
|
|
975
|
-
|
|
1014
|
+
let from = T;
|
|
1015
|
+
if (T > 1 && engine.jit) {
|
|
1016
|
+
for (let s0 = 1; s0 < T; s0 += 15) {
|
|
1017
|
+
const vars = { t0: acc.subarray(...slots(0, j)) };
|
|
1018
|
+
for (let s = s0; s < Math.min(T, s0 + 15); s++) vars[`t${s}`] = acc.subarray(...slots(s, j));
|
|
1019
|
+
const vnames = Object.keys(vars);
|
|
1020
|
+
const expr = it.reduce === 'min' || it.reduce === 'max'
|
|
1021
|
+
? vnames.slice(1).reduce((e, nm) => `${it.reduce}(${e}, ${nm})`, vnames[0])
|
|
1022
|
+
: vnames.join(' + ');
|
|
1023
|
+
yield* engine.evalG(expr, vars, vars.t0);
|
|
976
1024
|
}
|
|
977
|
-
|
|
978
|
-
|
|
1025
|
+
from = 1;
|
|
1026
|
+
}
|
|
1027
|
+
const a = engine.live(acc);
|
|
1028
|
+
if (C === 1 && from === 1) { const [lo, hi] = slots(0, j); totals.push(a.slice(lo, hi)); continue; }
|
|
1029
|
+
const op = it.reduce === 'min' ? Math.min : it.reduce === 'max' ? Math.max : (x, y) => x + y;
|
|
1030
|
+
const out = new Float64Array(K).fill(IDENTITY[it.reduce]);
|
|
1031
|
+
for (let s = 0; s < from; s++) {
|
|
1032
|
+
const [lo] = slots(s, j);
|
|
1033
|
+
for (let g = 0; g < K; g++) for (let c = 0; c < C; c++) out[g] = op(out[g], a[lo + g * C + c]);
|
|
979
1034
|
}
|
|
980
|
-
|
|
981
|
-
const vars = {};
|
|
982
|
-
for (let s = 0; s < T; s++) vars[`t${s}`] = acc.subarray(...slots(s, j));
|
|
983
|
-
const vnames = Object.keys(vars);
|
|
984
|
-
const expr = it.reduce === 'min' || it.reduce === 'max'
|
|
985
|
-
? vnames.slice(1).reduce((e, nm) => `${it.reduce}(${e}, ${nm})`, vnames[0])
|
|
986
|
-
: vnames.join(' + ');
|
|
987
|
-
yield* engine.evalG(expr, vars, vars.t0);
|
|
988
|
-
totals.push(Float64Array.from(engine.live(vars.t0)));
|
|
1035
|
+
totals.push(out);
|
|
989
1036
|
}
|
|
990
1037
|
engine.free(acc);
|
|
991
1038
|
}
|
|
@@ -1821,12 +1868,14 @@ function checkName(name) {
|
|
|
1821
1868
|
// (mean divides by the values present), min and max skip them, count(x)
|
|
1822
1869
|
// counts the values present (count(condition) still counts where it holds).
|
|
1823
1870
|
// arg is the bound source; returns the aggregation item.
|
|
1871
|
+
// (raw: the argument as given; where it is a column with no missing values,
|
|
1872
|
+
// the group-by uses it as it is and takes counts from the row count)
|
|
1824
1873
|
function missingRules(fn, arg) {
|
|
1825
1874
|
const present = `(${arg}) == (${arg})`;
|
|
1826
|
-
if (fn === 'count') return
|
|
1827
|
-
if (fn === 'min') return { fn, arg: `(${present} ? (${arg}) : Infinity)`, present }; // (present: NaN when nothing is)
|
|
1828
|
-
if (fn === 'max') return { fn, arg: `(${present} ? (${arg}) : -Infinity)`, present };
|
|
1829
|
-
return fn === 'mean' ? { fn, arg: `(${present} ? (${arg}) : 0)`, present } : { fn, arg: `(${present} ? (${arg}) : 0)
|
|
1875
|
+
if (fn === 'count') return isMask(parse(arg)) ? { fn, arg } : { fn, arg: present, raw: arg };
|
|
1876
|
+
if (fn === 'min') return { fn, arg: `(${present} ? (${arg}) : Infinity)`, present, raw: arg }; // (present: NaN when nothing is)
|
|
1877
|
+
if (fn === 'max') return { fn, arg: `(${present} ? (${arg}) : -Infinity)`, present, raw: arg };
|
|
1878
|
+
return fn === 'mean' ? { fn, arg: `(${present} ? (${arg}) : 0)`, present, raw: arg } : { fn, arg: `(${present} ? (${arg}) : 0)`, raw: arg }; // sum, mean
|
|
1830
1879
|
}
|
|
1831
1880
|
|
|
1832
1881
|
// a category column given as codes into a labels array: { codes, labels }
|
|
@@ -1850,7 +1899,9 @@ function codedText(engine, type, { codes: src, labels }) {
|
|
|
1850
1899
|
}
|
|
1851
1900
|
|
|
1852
1901
|
const isDates = (values) => Array.isArray(values) && values.find((v) => v != null) instanceof Date;
|
|
1853
|
-
|
|
1902
|
+
// the first value present decides (a column may start with null / undefined: missing)
|
|
1903
|
+
const firstPresent = (values) => { for (const v of values) if (v !== null && v !== undefined) return v; return undefined; };
|
|
1904
|
+
const isText = (values) => !ArrayBuffer.isView(values) && values.length > 0 && typeof firstPresent(values) === 'string';
|
|
1854
1905
|
|
|
1855
1906
|
function copyNumbers(engine, type, values) {
|
|
1856
1907
|
const data = engine.alloc(type, values.length);
|
|
@@ -1863,7 +1914,7 @@ function encodeText(engine, type, values) {
|
|
|
1863
1914
|
const data = engine.alloc(type, values.length);
|
|
1864
1915
|
const codes = new Map(), dict = [];
|
|
1865
1916
|
for (let i = 0; i < values.length; i++) {
|
|
1866
|
-
const s = values[i];
|
|
1917
|
+
const s = values[i] ?? ''; // (null / undefined: missing text, as in JSON)
|
|
1867
1918
|
let c = codes.get(s);
|
|
1868
1919
|
if (c === undefined) { c = dict.length; dict.push(s); codes.set(s, c); }
|
|
1869
1920
|
data[i] = c;
|