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 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.1. Node 20+ and modern browsers. MIT license.
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.readCSV('orders.csv');
38
- sql('SELECT region, COUNT(*) AS n, SUM(price) AS revenue FROM orders GROUP BY region ORDER BY revenue DESC', { orders }).toRows();
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 'http://127.0.0.1:8787/sql?q=SELECT region, COUNT(*) AS n FROM orders GROUP BY 1&format=csv'
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.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.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 result = yield* runChunksG(plan, path, sink);
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 + 15) & ~15;
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
- function attach(rel, outerKeys, relKeys, cols) {
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
- let rq = rel.query;
879
- const all = [...new Set([...relKeys, ...cols])];
880
- all.forEach((c) => { rq = rq.derive(fresh(c), c); });
881
- rq = rq.select(...all.map(fresh));
882
- let lefts = outerKeys.map((t) => hide(t));
883
- let rights = relKeys.map(fresh);
884
- if (!lefts.length) { const k = `__x${n.hidden++}`; q = q.derive(k, '0'); rq = rq.derive(k, '0'); lefts = [k]; rights = [k]; }
885
- 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)
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
- function subqueryValue(e, ctx) {
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
- const [m] = attach({ ...dist, names: [rel.names[0], '__m1'] }, [translate(e.x, ctx)], [rel.names[0]], ['__m1']);
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
- const [m] = attach(rel, [translate(e.x, ctx), ...pairs.map((pr) => translate(pr.outer, ctx))], ['__v', ...keyItems.map((it) => it.alias)], ['__m']);
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': case 'insub': return bin('&&', { t: 'un', op: '!', x: T(x) }, present(x.x, ctx));
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
- if (e.op === 'AND') return bin('&&', T(e.l), T(e.r));
1150
- if (e.op === 'OR') return bin('||', T(e.l), T(e.r));
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': if (a.length < 2) fail(`${e.fn}() needs two values or more`); return a.reduce((x, y) => call(e.fn === 'LEAST' ? 'min' : 'max', x, y));
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 aggOf = (e) => {
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 outs = withCols(items.map((it) => ({ ...it, ast: textConst(translatePost(it.e), post) })));
1484
- const havingAst = ast.having ? translatePost(ast.having) : null;
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) => ({ ...it, ast: textConst(it.e.k === 'col' ? colOf(it.e) : translate(it.e, pre), pre) })));
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
- function sortKey(o) {
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
- if (o.nullsFirst) { const h = `__nf${n.hidden++}`; q = q.derive(h, `${k} == ${k} ? 1 : 0`); sortKeys.push(h); }
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
- if (typeof rows[0][name] === 'string') {
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 = rows[0][name] instanceof 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
- const { src } = this.#bind(expr, merged);
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
- const isText = (values) => !ArrayBuffer.isView(values) && values.length > 0 && typeof values[0] === 'string';
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;