vectrojs 0.1.1 → 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 +36 -0
- package/README.md +19 -9
- 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 +17 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
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
|
+
|
|
3
39
|
## 0.1.1
|
|
4
40
|
|
|
5
41
|
A performance release. No API changes.
|
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
|
|
|
@@ -408,8 +417,8 @@ there (1.4x at a billion rows, 1.5x at 100M).
|
|
|
408
417
|
## SQL over HTTP: Excel, Power BI, Google Sheets, pandas
|
|
409
418
|
|
|
410
419
|
```sh
|
|
411
|
-
npx vectro serve --root ~/data # every CSV, TSV, JSON-lines, Arrow / Feather and .vectro file is a table
|
|
412
|
-
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
|
|
413
422
|
```
|
|
414
423
|
|
|
415
424
|
| Endpoint | Gives |
|
|
@@ -441,7 +450,7 @@ On 10M orders: a group by region answers in 9 ms over HTTP; 1M rows export in 0.
|
|
|
441
450
|
## The PostgreSQL protocol: Power BI, Tableau, Metabase, Grafana, psql
|
|
442
451
|
|
|
443
452
|
```sh
|
|
444
|
-
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
|
|
445
454
|
psql -h 127.0.0.1 -p 5433 -U me vectro
|
|
446
455
|
```
|
|
447
456
|
|
|
@@ -496,7 +505,7 @@ ODBC, and every driver. Queries arrive in each tool's own SQL and run on Vectro'
|
|
|
496
505
|
## Arrow Flight SQL: pandas, Polars, DuckDB
|
|
497
506
|
|
|
498
507
|
```sh
|
|
499
|
-
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)
|
|
500
509
|
```
|
|
501
510
|
|
|
502
511
|
```python
|
|
@@ -729,6 +738,7 @@ writeFileSync('eu.feather', sales.filter("region == 'EU'").toArrow({ file: true
|
|
|
729
738
|
// split tables: await engine.fromArrowAsync(bytes), await table.toArrowAsync()
|
|
730
739
|
|
|
731
740
|
// streams: any Arrow IPC stream over TCP or WebSocket, e.g. from pyarrow
|
|
741
|
+
const split = new SplitEngine(); // import { SplitEngine } from 'vectrojs'
|
|
732
742
|
const orders = await split.streamAsync({ format: 'arrow', columns: { ts: 'number', region: 'category' } });
|
|
733
743
|
// ...or Vectro's Emitter speaking Arrow: Emitter.listen({ columns, format: 'arrow' })
|
|
734
744
|
```
|
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';
|
|
@@ -107,17 +107,18 @@ export class Table {
|
|
|
107
107
|
for (const name of Object.keys(rows[0])) {
|
|
108
108
|
checkName(name);
|
|
109
109
|
const data = engine.alloc(type, n);
|
|
110
|
-
|
|
110
|
+
const first = firstPresent(rows.map((r) => r[name]));
|
|
111
|
+
if (typeof first === 'string') {
|
|
111
112
|
const codes = new Map(), dict = [];
|
|
112
113
|
for (let i = 0; i < n; i++) {
|
|
113
|
-
const s = rows[i][name];
|
|
114
|
+
const s = rows[i][name] ?? ''; // (null / undefined: missing text)
|
|
114
115
|
let c = codes.get(s);
|
|
115
116
|
if (c === undefined) { c = dict.length; dict.push(s); codes.set(s, c); }
|
|
116
117
|
data[i] = c;
|
|
117
118
|
}
|
|
118
119
|
cols.set(name, { data, dict, codes });
|
|
119
120
|
} else {
|
|
120
|
-
const date =
|
|
121
|
+
const date = first instanceof Date;
|
|
121
122
|
if (date && type === Float32Array) throw new Error(f32Dates(name));
|
|
122
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)
|
|
123
124
|
cols.set(name, { data, dict: null, codes: null, ...(date ? { date: true } : {}) });
|
|
@@ -314,7 +315,14 @@ export class Table {
|
|
|
314
315
|
// Lazy filter: returns a view of the same columns with a condition attached.
|
|
315
316
|
filter(expr, params = {}) {
|
|
316
317
|
const merged = this.#mergeParams(params);
|
|
317
|
-
|
|
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
|
+
}
|
|
318
326
|
const pred = this.pred ? `(${this.pred}) && (${src})` : src;
|
|
319
327
|
return new Table(this.engine, this.cols, this.n, { pred, params: merged, zones: this.zones });
|
|
320
328
|
}
|
|
@@ -1891,7 +1899,9 @@ function codedText(engine, type, { codes: src, labels }) {
|
|
|
1891
1899
|
}
|
|
1892
1900
|
|
|
1893
1901
|
const isDates = (values) => Array.isArray(values) && values.find((v) => v != null) instanceof Date;
|
|
1894
|
-
|
|
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';
|
|
1895
1905
|
|
|
1896
1906
|
function copyNumbers(engine, type, values) {
|
|
1897
1907
|
const data = engine.alloc(type, values.length);
|
|
@@ -1904,7 +1914,7 @@ function encodeText(engine, type, values) {
|
|
|
1904
1914
|
const data = engine.alloc(type, values.length);
|
|
1905
1915
|
const codes = new Map(), dict = [];
|
|
1906
1916
|
for (let i = 0; i < values.length; i++) {
|
|
1907
|
-
const s = values[i];
|
|
1917
|
+
const s = values[i] ?? ''; // (null / undefined: missing text, as in JSON)
|
|
1908
1918
|
let c = codes.get(s);
|
|
1909
1919
|
if (c === undefined) { c = dict.length; dict.push(s); codes.set(s, c); }
|
|
1910
1920
|
data[i] = c;
|