vectrojs 0.0.1 → 0.1.1

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +828 -4
  3. package/bin/vectro.mjs +82 -0
  4. package/dist/vectro.wasm +0 -0
  5. package/mcp/server.mjs +288 -0
  6. package/package.json +84 -6
  7. package/src/arrow.mjs +905 -0
  8. package/src/bitpack.mjs +135 -0
  9. package/src/browser.mjs +129 -0
  10. package/src/bytes.mjs +60 -0
  11. package/src/csv-kernel.mjs +205 -0
  12. package/src/csv.mjs +270 -0
  13. package/src/dataset.mjs +1763 -0
  14. package/src/dates.mjs +139 -0
  15. package/src/emitter.mjs +109 -0
  16. package/src/engine.mjs +881 -0
  17. package/src/files-browser.mjs +104 -0
  18. package/src/files-node.mjs +59 -0
  19. package/src/frames.mjs +191 -0
  20. package/src/fuse.mjs +512 -0
  21. package/src/interp.mjs +305 -0
  22. package/src/join.mjs +534 -0
  23. package/src/kernels.mjs +312 -0
  24. package/src/ndjson.mjs +136 -0
  25. package/src/node.mjs +120 -0
  26. package/src/pack.mjs +114 -0
  27. package/src/parallel-sort.mjs +99 -0
  28. package/src/partition-kernels.mjs +388 -0
  29. package/src/partition.mjs +395 -0
  30. package/src/plan.mjs +551 -0
  31. package/src/portable.mjs +21 -0
  32. package/src/rank-kernels.mjs +81 -0
  33. package/src/rows.mjs +91 -0
  34. package/src/serve/catalog.mjs +148 -0
  35. package/src/serve/flight.mjs +270 -0
  36. package/src/serve/formats.mjs +87 -0
  37. package/src/serve/http.mjs +182 -0
  38. package/src/serve/index.mjs +9 -0
  39. package/src/serve/odata.mjs +291 -0
  40. package/src/serve/pg.mjs +588 -0
  41. package/src/serve/pgcatalog.mjs +220 -0
  42. package/src/serve/pgtypes.mjs +199 -0
  43. package/src/sketch.mjs +75 -0
  44. package/src/sort-kernels.mjs +117 -0
  45. package/src/sort.mjs +102 -0
  46. package/src/split-worker-core.mjs +252 -0
  47. package/src/split-worker-node.mjs +8 -0
  48. package/src/split-worker.mjs +7 -0
  49. package/src/split.mjs +741 -0
  50. package/src/sql.mjs +1558 -0
  51. package/src/sqltext.mjs +132 -0
  52. package/src/static-kernels.mjs +164 -0
  53. package/src/stream-worker.mjs +553 -0
  54. package/src/table-kernels.mjs +323 -0
  55. package/src/table.mjs +2247 -0
  56. package/src/textmap.mjs +159 -0
  57. package/src/vectors.mjs +138 -0
  58. package/src/wasm.mjs +254 -0
  59. package/src/window.mjs +160 -0
  60. package/src/worker-browser.mjs +21 -0
  61. package/src/worker-loop.mjs +106 -0
  62. package/src/worker.mjs +22 -0
  63. package/src/zones.mjs +75 -0
@@ -0,0 +1,182 @@
1
+ // An HTTP SQL server over Vectro tables: anything that reads a URL (Excel,
2
+ // Power BI's Web connector, Google Sheets, pandas, curl) can query them.
3
+ //
4
+ // GET|POST /sql?q=SELECT ...&format=json|csv|ndjson|arrow[&limit=n]
5
+ // (or the SQL as the POST body, or JSON { "sql": "..." })
6
+ // GET /tables names, rows, columns
7
+ // GET /tables/<name> one table's columns
8
+ // GET /tables/<name>/rows?format=csv[&limit=n]
9
+ // POST /reload read the data folder again
10
+ //
11
+ // import { serve } from 'vectrojs/serve';
12
+ // const s = await serve({ tables: { orders }, port: 8787 }); // or { root: '~/data' }
13
+ //
14
+ // Read only (SQL here only reads). Listens on 127.0.0.1 unless host is
15
+ // given; with a token, requests need "Authorization: Bearer <token>" (or
16
+ // ?token=, for tools that can't send headers).
17
+ import { createServer } from 'node:http';
18
+ import { relative } from 'node:path';
19
+ import { once } from 'node:events';
20
+ import { timingSafeEqual } from 'node:crypto';
21
+ import { sql } from '../node.mjs';
22
+ import { openTables, LoadError } from './catalog.mjs';
23
+ import { odata, ODataError } from './odata.mjs';
24
+ import { FORMATS, columnsOf, encode } from './formats.mjs';
25
+
26
+ const MAX_BODY = 1 << 20;
27
+
28
+ class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }
29
+
30
+ // catalog: tables opened with openTables (catalog.mjs), to share them with
31
+ // the PostgreSQL server; else tables / root / engine open them here.
32
+ export async function serve({ tables = {}, root = null, engine = null, catalog = null, port = 8787, host = '127.0.0.1', token = null, cors = false, odataPage = 50_000, log = () => {} } = {}) {
33
+ const shared = !!catalog;
34
+ catalog ??= await openTables({ tables, root, engine, log });
35
+ const entries = catalog.entries, rootPath = catalog.root;
36
+ const reload = () => catalog.reload().catch((err) => { throw new HttpError(400, err.message); });
37
+
38
+ const allowed = (req, url) => {
39
+ if (!token) return true;
40
+ const auth = req.headers.authorization ?? '';
41
+ const basic = /^Basic\s+(.+)$/i.exec(auth); // (Basic: any user name, the token as the password: Power BI's and Excel's OData credentials)
42
+ const given = basic ? Buffer.from(basic[1], 'base64').toString().replace(/^[^:]*:/, '') : auth.replace(/^Bearer\s+/i, '') || url.searchParams.get('token') || '';
43
+ const a = Buffer.from(given), b = Buffer.from(token);
44
+ return a.length === b.length && timingSafeEqual(a, b);
45
+ };
46
+ const named = () => catalog.named();
47
+ const describe = (name, { table, source }) => ({ name, rows: table.length, columns: columnsOf(table), ...(table.isDataset ? { on_disk: true } : {}), ...(source === 'given' ? {} : { file: relative(rootPath, source) }) });
48
+
49
+ async function body(req) {
50
+ const parts = [];
51
+ let size = 0;
52
+ for await (const chunk of req) { size += chunk.length; if (size > MAX_BODY) throw new HttpError(413, 'the request body is larger than 1 MB'); parts.push(chunk); }
53
+ return Buffer.concat(parts).toString('utf8');
54
+ }
55
+
56
+ async function sqlOf(req, url) {
57
+ let text = url.searchParams.get('q') ?? url.searchParams.get('sql');
58
+ if (text === null && req.method === 'POST') {
59
+ const b = await body(req);
60
+ if (/json/.test(req.headers['content-type'] ?? '')) {
61
+ let o;
62
+ try { o = JSON.parse(b); } catch { throw new HttpError(400, 'the body is not valid JSON'); }
63
+ text = o?.sql ?? o?.q ?? null;
64
+ if (o?.format && !url.searchParams.has('format')) url.searchParams.set('format', o.format);
65
+ if (o?.limit !== undefined && !url.searchParams.has('limit')) url.searchParams.set('limit', String(o.limit));
66
+ } else text = b;
67
+ }
68
+ if (!text || !String(text).trim()) throw new HttpError(400, 'no SQL given: use /sql?q=SELECT ... or POST the SQL');
69
+ return String(text);
70
+ }
71
+
72
+ const formatOf = (url) => {
73
+ const f = (url.searchParams.get('format') ?? 'json').toLowerCase();
74
+ if (!FORMATS[f]) throw new HttpError(400, `unknown format "${f}" (json, csv, ndjson or arrow)`);
75
+ return f;
76
+ };
77
+ const limitOf = (url) => {
78
+ const l = url.searchParams.get('limit');
79
+ if (l === null || l === '') return null;
80
+ const n = Number(l);
81
+ if (!(n >= 0) || !Number.isInteger(n)) throw new HttpError(400, 'limit must be a whole number');
82
+ return n;
83
+ };
84
+
85
+ // run a query (Query or Table) and stream its rows in the format asked
86
+ async function answer(res, query, format, extra, filename) {
87
+ const t0 = performance.now();
88
+ const t = query.collectAsync ? await query.collectAsync() : query.isDataset ? await query.lazy().collectAsync() : query;
89
+ try {
90
+ const columns = columnsOf(t), data = await t.toColumnsAsync(), n = t.length;
91
+ res.writeHead(200, { 'content-type': FORMATS[format], ...(format === 'csv' ? { 'content-disposition': `inline; filename="${filename}.csv"` } : {}) });
92
+ for (const chunk of encode(format, columns, data, n, { ...extra, row_count: n, ms: Math.round((performance.now() - t0) * 100) / 100 })) {
93
+ if (!res.write(chunk)) await once(res, 'drain');
94
+ }
95
+ res.end();
96
+ } finally { if (t !== query) t.dispose(); }
97
+ }
98
+
99
+ const json = (res, status, o) => { res.writeHead(status, { 'content-type': FORMATS.json }); res.end(JSON.stringify(o)); };
100
+
101
+ async function route(req, res) {
102
+ const url = new URL(req.url, 'http://localhost');
103
+ if (cors) {
104
+ res.setHeader('access-control-allow-origin', '*');
105
+ res.setHeader('access-control-allow-headers', 'authorization, content-type');
106
+ if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
107
+ }
108
+ if (!allowed(req, url)) { res.setHeader('WWW-Authenticate', 'Basic realm="vectro"'); throw new HttpError(401, 'this server needs a token: send "Authorization: Bearer <token>", Basic with the token as the password, or add ?token='); }
109
+ const path = url.pathname.replace(/\/+$/, '') || '/';
110
+ const parts = path.split('/').filter(Boolean).map(decodeURIComponent);
111
+ if (path === '/odata' || path.startsWith('/odata/')) {
112
+ if (req.method !== 'GET') throw new HttpError(405, 'OData here is read only: use GET');
113
+ const params = new URLSearchParams(url.search);
114
+ params.delete('token');
115
+ const host = req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost';
116
+ let r;
117
+ try { r = await odata({ path, params, named: named(), base: `http://${host}/odata`, headers: req.headers, pageSize: odataPage }); } catch (err) {
118
+ if (err instanceof ODataError || /^vectro/.test(err.message)) { res.writeHead(err.status ?? 400, { 'content-type': 'application/json', 'OData-Version': '4.0' }); return res.end(JSON.stringify({ error: { code: String(err.status ?? 400), message: err.message.replace(/^vectro( SQL)?:\s*/, '') } })); }
119
+ throw err;
120
+ }
121
+ log(`odata ${decodeURIComponent(url.pathname + url.search).slice(0, 200)}`);
122
+ res.writeHead(r.status, r.headers);
123
+ for (const chunk of r.body) if (!res.write(chunk)) await once(res, 'drain');
124
+ return res.end();
125
+ }
126
+ if (path === '/sql') {
127
+ if (req.method !== 'GET' && req.method !== 'POST') throw new HttpError(405, 'use GET or POST');
128
+ const text = await sqlOf(req, url), format = formatOf(url), limit = limitOf(url);
129
+ let q;
130
+ try { q = sql(text, named()); } catch (err) { throw new HttpError(400, err.message); }
131
+ if (limit !== null) q = q.limit(limit);
132
+ log(`sql ${text.replace(/\s+/g, ' ').slice(0, 200)}`);
133
+ return answer(res, q, format, {}, 'result');
134
+ }
135
+ if (parts[0] === 'tables' && req.method === 'GET') {
136
+ if (parts.length === 1) return json(res, 200, { tables: [...entries].map(([n, e]) => describe(n, e)) });
137
+ const e = entries.get(parts[1]);
138
+ if (!e) throw new HttpError(404, `no table "${parts[1]}" (tables: ${[...entries.keys()].join(', ') || 'none'})`);
139
+ if (parts.length === 2) return json(res, 200, describe(parts[1], e));
140
+ if (parts.length === 3 && parts[2] === 'rows') {
141
+ const format = formatOf(url), limit = limitOf(url);
142
+ const q = limit !== null ? e.table.lazy().limit(limit) : e.table;
143
+ return answer(res, q, format, {}, parts[1]);
144
+ }
145
+ }
146
+ if (path === '/reload' && req.method === 'POST') return json(res, 200, await reload());
147
+ if (path === '/' && req.method === 'GET') {
148
+ return json(res, 200, {
149
+ server: 'vectro',
150
+ endpoints: { sql: '/sql?q=SELECT ...&format=json|csv|ndjson|arrow', tables: '/tables', table: '/tables/<name>', rows: '/tables/<name>/rows?format=csv', odata: '/odata (an OData v4 feed: Power BI, Excel, Tableau)' },
151
+ tables: [...entries.keys()],
152
+ });
153
+ }
154
+ throw new HttpError(404, `no such endpoint: ${req.method} ${path} (see /)`);
155
+ }
156
+
157
+ const server = createServer((req, res) => {
158
+ route(req, res).catch((err) => {
159
+ const status = err.status ?? (err instanceof LoadError || /^vectro/.test(err.message) ? 400 : 500);
160
+ if (status === 500) log(`error: ${err.stack}`);
161
+ if (res.headersSent) { res.destroy(err); return; } // (mid-stream: the client sees a cut answer)
162
+ json(res, status, { error: err.message });
163
+ });
164
+ });
165
+ server.listen(port, host);
166
+ await once(server, 'listening');
167
+ const actual = server.address().port;
168
+ return {
169
+ url: `http://${host.includes(':') ? `[${host}]` : host}:${actual}`,
170
+ port: actual,
171
+ server,
172
+ engine: catalog.engine,
173
+ tables: entries,
174
+ catalog,
175
+ reload,
176
+ async close() {
177
+ server.closeAllConnections?.();
178
+ await new Promise((r) => server.close(r));
179
+ if (!shared) await catalog.close();
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,9 @@
1
+ // vectrojs/serve: Vectro tables served to other programs.
2
+ // serve() SQL over HTTP (JSON, CSV, Arrow): Excel, Power BI's Web connector, pandas
3
+ // servePg() the PostgreSQL protocol: Power BI, Tableau, Metabase, Grafana, psql
4
+ // serveFlight() Arrow Flight SQL: pandas, Polars, DuckDB through ADBC, as Arrow
5
+ // openTables() tables from code or a data folder, to share between them
6
+ export { serve } from './http.mjs';
7
+ export { servePg } from './pg.mjs';
8
+ export { openTables } from './catalog.mjs';
9
+ export { serveFlight } from './flight.mjs';
@@ -0,0 +1,291 @@
1
+ // OData v4 over Vectro tables (part of the HTTP server): Power BI's and
2
+ // Excel's "OData feed", Tableau's OData connector and OData client libraries
3
+ // read tables with no driver, with filters, sorting and paging done by Vectro.
4
+ //
5
+ // GET /odata the service document (the tables)
6
+ // GET /odata/$metadata their columns and types (CSDL XML)
7
+ // GET /odata/<table>?$filter=region eq 'Europe' and qty gt 2&$select=...&$orderby=ts desc&$top=100&$skip=0&$count=true
8
+ // GET /odata/<table>?$apply=filter(qty gt 1)/groupby((region),aggregate(qty with sum as total,$count as n))
9
+ // GET /odata/<table>/$count
10
+ //
11
+ // Every request becomes one Vectro SQL query. Answers come a page at a time
12
+ // (50,000 rows, or Prefer: odata.maxpagesize=n) with @odata.nextLink to the
13
+ // next page. A table's key is its first whole-number column ending in "id"
14
+ // (or its first whole-number column); requests by key aren't served.
15
+ import { sql, columnType, displayName } from '../sql.mjs';
16
+
17
+ export const PAGE = 50_000;
18
+ const NS = 'Vectro';
19
+ const EDM = { int: 'Edm.Int64', float: 'Edm.Double', bool: 'Edm.Boolean', text: 'Edm.String', date: 'Edm.DateTimeOffset', day: 'Edm.Date' };
20
+
21
+ export class ODataError extends Error { constructor(status, message) { super(message); this.status = status; } }
22
+ const bad = (m) => { throw new ODataError(400, m); };
23
+ const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
+ const qid = (name) => `"${String(name).replaceAll('"', '""')}"`;
25
+ const sqlText = (s) => `'${String(s).replaceAll("'", "''")}'`;
26
+ const odataName = (n) => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(n) ? n : null); // (OData names: letters, digits, _)
27
+
28
+ function keyOf(table) {
29
+ const ints = table.columnNames.filter((c) => columnType(table, c) === 'int');
30
+ return ints.find((c) => /id$/i.test(c)) ?? ints[0] ?? null;
31
+ }
32
+
33
+ export function metadataXml(named) {
34
+ const types = [], sets = [];
35
+ for (const [name, t] of Object.entries(named)) {
36
+ if (name.includes('.') || !odataName(name)) continue;
37
+ const key = keyOf(t);
38
+ const props = t.columnNames.filter(odataName).map((c) => {
39
+ const type = EDM[columnType(t, c)] ?? 'Edm.Double';
40
+ return `<Property Name="${xml(c)}" Type="${type}"${c === key ? ' Nullable="false"' : ''}${type === 'Edm.DateTimeOffset' ? ' Precision="3"' : ''}/>`;
41
+ });
42
+ types.push(`<EntityType Name="${xml(name)}">${key ? `<Key><PropertyRef Name="${xml(key)}"/></Key>` : ''}${props.join('')}</EntityType>`);
43
+ sets.push(`<EntitySet Name="${xml(name)}" EntityType="${NS}.${xml(name)}"/>`);
44
+ }
45
+ return `<?xml version="1.0" encoding="utf-8"?><edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx"><edmx:DataServices><Schema Namespace="${NS}" xmlns="http://docs.oasis-open.org/odata/ns/edm">${types.join('')}<EntityContainer Name="Container">${sets.join('')}</EntityContainer></Schema></edmx:DataServices></edmx:Edmx>`;
46
+ }
47
+
48
+ // ---- $filter expressions -> SQL ------------------------------------------------------------
49
+ function tokens(src) {
50
+ const out = [];
51
+ let i = 0;
52
+ while (i < src.length) {
53
+ const c = src[i];
54
+ if (/\s/.test(c)) { i++; continue; }
55
+ if (c === "'") { let v = ''; i++; for (;;) { if (i >= src.length) bad('a text in quotes is not closed'); if (src[i] === "'") { if (src[i + 1] === "'") { v += "'"; i += 2; continue; } i++; break; } v += src[i++]; } out.push({ t: 'str', v }); continue; }
56
+ const dt = /^\d{4}-\d\d-\d\d(T\d\d:\d\d(:\d\d(\.\d+)?)?(Z|[+-]\d\d:\d\d)?)?/.exec(src.slice(i));
57
+ if (dt) { out.push({ t: dt[1] ? 'datetime' : 'date', v: dt[0] }); i += dt[0].length; continue; }
58
+ const num = /^-?(\d+\.?\d*(e[+-]?\d+)?|\.\d+)[mMdDlLfF]?(?![\w])/.exec(src.slice(i));
59
+ if (num && (c !== '-' || !out.length || ['op', 'kw'].includes(out.at(-1).t) || out.at(-1).v === '(' || out.at(-1).v === ',')) { out.push({ t: 'num', v: num[0].replace(/[mMdDlLfF]$/, '') }); i += num[0].length; continue; }
60
+ const id = /^[A-Za-z_$][\w$]*/.exec(src.slice(i));
61
+ if (id) { out.push({ t: 'id', v: id[0] }); i += id[0].length; continue; }
62
+ if ('(),/'.includes(c)) { out.push({ t: 'op', v: c }); i++; continue; }
63
+ bad(`unexpected "${c}" in the expression`);
64
+ }
65
+ return out;
66
+ }
67
+ const CMP = { eq: '=', ne: '<>', gt: '>', ge: '>=', lt: '<', le: '<=' };
68
+ const ARITH = { add: '+', sub: '-', mul: '*', div: '/', divby: '/', mod: '%' };
69
+ // columns: names allowed (the table's, or the current $apply step's)
70
+ export function filterSql(src, columns) {
71
+ const toks = tokens(src);
72
+ let p = 0;
73
+ const peek = () => toks[p], isId = (v) => peek()?.t === 'id' && peek().v === v;
74
+ const need = (v) => { if (peek()?.v !== v) bad(`expected "${v}"${peek() ? ` near "${peek().v}"` : ' at the end'}`); p++; };
75
+ const or = () => { let l = and(); while (isId('or')) { p++; l = `(${l} OR ${and()})`; } return l; };
76
+ const and = () => { let l = not(); while (isId('and')) { p++; l = `(${l} AND ${not()})`; } return l; };
77
+ const not = () => { if (isId('not')) { p++; return `(NOT ${not()})`; } return cmp(); };
78
+ const cmp = () => {
79
+ const l = add();
80
+ const op = peek()?.t === 'id' ? peek().v : null;
81
+ if (op && CMP[op]) {
82
+ p++;
83
+ const r = add();
84
+ if (r === 'NULL') return op === 'eq' ? `(${l} IS NULL)` : op === 'ne' ? `(${l} IS NOT NULL)` : bad('null can only be compared with eq or ne');
85
+ return `(${l} ${CMP[op]} ${r})`;
86
+ }
87
+ if (op === 'in') { p++; need('('); const list = [add()]; while (peek()?.v === ',') { p++; list.push(add()); } need(')'); return `(${l} IN (${list.join(', ')}))`; }
88
+ return l;
89
+ };
90
+ const add = () => { let l = mul(); while (peek()?.t === 'id' && (peek().v === 'add' || peek().v === 'sub')) { const o = ARITH[toks[p++].v]; l = `(${l} ${o} ${mul()})`; } return l; };
91
+ const mul = () => { let l = unary(); while (peek()?.t === 'id' && ['mul', 'div', 'divby', 'mod'].includes(peek().v)) { const o = ARITH[toks[p++].v]; l = `(${l} ${o} ${unary()})`; } return l; };
92
+ const unary = () => (peek()?.t === 'id' && peek().v === '-' ? (p++, `(-${unary()})`) : primary());
93
+ const primary = () => {
94
+ const t = peek();
95
+ if (!t) bad('the expression ends too early');
96
+ p++;
97
+ if (t.t === 'str') return sqlText(t.v);
98
+ if (t.t === 'num') return `(${Number(t.v)})`;
99
+ if (t.t === 'date') return `DATE ${sqlText(t.v)}`;
100
+ if (t.t === 'datetime') { const ms = Date.parse(t.v); if (!Number.isFinite(ms)) bad(`bad date and time ${t.v}`); return `TIMESTAMP ${sqlText(new Date(ms).toISOString().replace('T', ' ').replace('Z', ''))}`; }
101
+ if (t.v === '(') { const e = or(); need(')'); return e; }
102
+ if (t.t !== 'id') bad(`unexpected "${t.v}"`);
103
+ if (t.v === 'true') return 'TRUE';
104
+ if (t.v === 'false') return 'FALSE';
105
+ if (t.v === 'null') return 'NULL';
106
+ if (peek()?.v === '(') { // a function
107
+ p++;
108
+ const args = [];
109
+ if (peek()?.v !== ')') { args.push(or()); while (peek()?.v === ',') { p++; args.push(or()); } }
110
+ need(')');
111
+ return fn(t.v, args);
112
+ }
113
+ if (peek()?.v === '/') bad('paths (a/b) are not supported');
114
+ if (!columns.includes(t.v)) bad(`no column "${t.v}" (columns: ${columns.join(', ')})`);
115
+ return qid(t.v);
116
+ };
117
+ const fn = (name, a) => {
118
+ const n = (k) => { if (a.length !== k) bad(`${name}() takes ${k} value(s)`); };
119
+ switch (name) {
120
+ case 'contains': n(2); return `(STRPOS(${a[0]}, ${a[1]}) > 0)`;
121
+ case 'startswith': n(2); return `STARTS_WITH(${a[0]}, ${a[1]})`;
122
+ case 'endswith': n(2); return `ENDS_WITH(${a[0]}, ${a[1]})`;
123
+ case 'tolower': n(1); return `LOWER(${a[0]})`;
124
+ case 'toupper': n(1); return `UPPER(${a[0]})`;
125
+ case 'trim': n(1); return `TRIM(${a[0]})`;
126
+ case 'length': n(1); return `LENGTH(${a[0]})`;
127
+ case 'concat': n(2); return `(${a[0]} || ${a[1]})`;
128
+ case 'indexof': n(2); return `(STRPOS(${a[0]}, ${a[1]}) - 1)`;
129
+ case 'substring': if (a.length < 2 || a.length > 3) bad('substring() takes 2 or 3 values'); return `SUBSTRING(${a[0]}, ${a[1]} + 1${a[2] ? `, ${a[2]}` : ''})`;
130
+ case 'year': case 'month': case 'day': case 'hour': case 'minute': case 'second': n(1); return `EXTRACT(${name.toUpperCase()} FROM ${a[0]})`;
131
+ case 'date': n(1); return `CAST(${a[0]} AS DATE)`;
132
+ case 'round': n(1); return `ROUND(${a[0]})`;
133
+ case 'floor': n(1); return `FLOOR(${a[0]})`;
134
+ case 'ceiling': n(1); return `CEIL(${a[0]})`;
135
+ case 'now': n(0); return 'NOW()';
136
+ default: bad(`the function ${name}() is not supported`);
137
+ }
138
+ };
139
+ const out = or();
140
+ if (p < toks.length) bad(`unexpected "${toks[p].v}"`);
141
+ return out;
142
+ }
143
+
144
+ // ---- $apply -> nested SQL --------------------------------------------------------------------
145
+ // Returns { sql, columns } for the rows the transformations leave.
146
+ function splitTop(s, sep) { // split at sep outside parentheses and quotes
147
+ const out = [];
148
+ let depth = 0, q = false, cur = '';
149
+ for (let i = 0; i < s.length; i++) {
150
+ const c = s[i];
151
+ if (c === "'") q = !q;
152
+ if (!q && c === '(') depth++;
153
+ if (!q && c === ')') depth--;
154
+ if (!q && depth === 0 && s.startsWith(sep, i)) { out.push(cur); cur = ''; i += sep.length - 1; continue; }
155
+ cur += c;
156
+ }
157
+ out.push(cur);
158
+ return out.map((x) => x.trim()).filter(Boolean);
159
+ }
160
+ const inner = (s, name) => { const m = new RegExp(`^${name}\\s*\\(([\\s\\S]*)\\)$`).exec(s.trim()); return m ? m[1] : null; };
161
+ const METHODS = { sum: 'SUM', min: 'MIN', max: 'MAX', average: 'AVG', countdistinct: 'COUNT' };
162
+ function aggregates(spec, columns) {
163
+ return splitTop(spec, ',').map((a) => {
164
+ let m = /^\$count\s+as\s+([A-Za-z_]\w*)$/.exec(a);
165
+ if (m) return { sql: `COUNT(*) AS ${qid(m[1])}`, name: m[1] };
166
+ m = /^([\s\S]+?)\s+with\s+(\w+)\s+as\s+([A-Za-z_]\w*)$/.exec(a);
167
+ if (!m) bad(`aggregate(${a}): write "column with sum as name" or "$count as name"`);
168
+ const method = METHODS[m[2]] ?? bad(`aggregate method ${m[2]} is not supported (sum, min, max, average, countdistinct)`);
169
+ const e = filterSql(m[1], columns);
170
+ return { sql: `${method}(${m[2] === 'countdistinct' ? 'DISTINCT ' : ''}${e}) AS ${qid(m[3])}`, name: m[3] };
171
+ });
172
+ }
173
+ export function applySql(apply, table, columns) {
174
+ let from = qid(table), cols = columns, k = 0;
175
+ for (const step of splitTop(apply, '/')) {
176
+ const alias = `_a${k++}`;
177
+ let body;
178
+ if ((body = inner(step, 'filter')) !== null) { from = `(SELECT * FROM ${from} ${alias === '_a0' ? '' : ''}WHERE ${filterSql(body, cols)})`; from = wrap(from, alias); continue; }
179
+ if ((body = inner(step, 'aggregate')) !== null) { const ag = aggregates(body, cols); from = wrap(`(SELECT ${ag.map((x) => x.sql).join(', ')} FROM ${from})`, alias); cols = ag.map((x) => x.name); continue; }
180
+ if ((body = inner(step, 'groupby')) !== null) {
181
+ const parts = splitTop(body, ',');
182
+ const keys = inner(`g${parts[0]}`, 'g');
183
+ if (keys === null) bad('groupby((columns), aggregate(...))');
184
+ const names = splitTop(keys, ',');
185
+ for (const nm of names) if (!cols.includes(nm)) bad(`no column "${nm}"`);
186
+ const ag = parts[1] ? aggregates(inner(parts[1], 'aggregate') ?? bad('groupby(..., aggregate(...))'), cols) : [];
187
+ const select = [...names.map(qid), ...ag.map((x) => x.sql)].join(', ');
188
+ from = wrap(`(SELECT ${select} FROM ${from} GROUP BY ${names.map(qid).join(', ')})`, alias);
189
+ cols = [...names, ...ag.map((x) => x.name)];
190
+ continue;
191
+ }
192
+ bad(`$apply: ${step.split('(')[0]} is not supported (filter, groupby, aggregate)`);
193
+ }
194
+ return { from, columns: cols };
195
+ }
196
+ const wrap = (sub, alias) => `${sub} ${alias}`;
197
+
198
+ // ---- a request -----------------------------------------------------------------------------------
199
+ // The SQL for a read of a table: { sql, countSql, columns, top, skip }
200
+ export function querySql(table, tableColumns, params, pageSize) {
201
+ let from = qid(table), columns = tableColumns;
202
+ const get = (k) => params.get(k) ?? params.get(k.slice(1)); // ($filter or filter)
203
+ if (get('$apply')) ({ from, columns } = applySql(get('$apply'), table, tableColumns));
204
+ const where = get('$filter') ? ` WHERE ${filterSql(get('$filter'), columns)}` : '';
205
+ let select = columns;
206
+ if (get('$select') && get('$select').trim() !== '*') {
207
+ select = get('$select').split(',').map((s) => s.trim()).filter(Boolean);
208
+ for (const c of select) if (!columns.includes(c)) bad(`no column "${c}" to select`);
209
+ }
210
+ const order = get('$orderby') ? ` ORDER BY ${get('$orderby').split(',').map((s) => { const [c, dir] = s.trim().split(/\s+/); if (!columns.includes(c)) bad(`no column "${c}" to order by`); if (dir && !/^(asc|desc)$/i.test(dir)) bad(`order "${dir}": asc or desc`); return `${qid(c)}${dir?.toLowerCase() === 'desc' ? ' DESC' : ''}`; }).join(', ')}` : '';
211
+ const num = (k) => { const v = get(k); if (v === null || v === undefined) return null; if (!/^\d+$/.test(v)) bad(`${k} must be a whole number`); return Number(v); };
212
+ const top = num('$top'), skip = num('$skip') ?? 0, token = params.get('$skiptoken');
213
+ const start = skip + (token && /^\d+$/.test(token) ? Number(token) : 0);
214
+ const want = top === null ? pageSize : Math.min(pageSize, Math.max(0, top - (start - skip)));
215
+ const base = `FROM ${from}${where}`;
216
+ return {
217
+ sql: `SELECT ${select.map(qid).join(', ')} ${base}${order} LIMIT ${want + 1} OFFSET ${start}`, // (one more: is there a next page?)
218
+ countSql: `SELECT COUNT(*) AS n ${base}`,
219
+ columns: select, want, start, top, skip, count: /^true$/i.test(get('$count') ?? ''),
220
+ };
221
+ }
222
+
223
+ // OData JSON for one value
224
+ function cell(type, v) {
225
+ if (v === null || v === undefined || (typeof v === 'number' && v !== v)) return 'null';
226
+ switch (type) {
227
+ case 'text': return v === '' ? 'null' : JSON.stringify(v);
228
+ case 'int': return String(Math.round(v));
229
+ case 'bool': return v ? 'true' : 'false';
230
+ case 'date': return `"${new Date(v).toISOString()}"`;
231
+ case 'day': return `"${new Date(v).toISOString().slice(0, 10)}"`;
232
+ default: return v === Infinity ? '"INF"' : v === -Infinity ? '"-INF"' : String(v);
233
+ }
234
+ }
235
+
236
+ // Answer GET /odata... ; write(res, status, headers, body chunks)
237
+ export async function odata({ path, params, named, base, headers = {}, pageSize = PAGE }) {
238
+ const H = { 'OData-Version': '4.0' };
239
+ const json = (o, status = 200) => ({ status, headers: { ...H, 'content-type': 'application/json;odata.metadata=minimal' }, body: [JSON.stringify(o)] });
240
+ const parts = path.split('/').filter(Boolean).map(decodeURIComponent); // ['odata', ...]
241
+ const tables = Object.fromEntries(Object.entries(named).filter(([n]) => !n.includes('.') && odataName(n)));
242
+ if (parts.length === 1) return json({ '@odata.context': `${base}/$metadata`, value: Object.keys(tables).map((n) => ({ name: n, kind: 'EntitySet', url: n })) });
243
+ if (parts[1] === '$metadata') return { status: 200, headers: { ...H, 'content-type': 'application/xml' }, body: [metadataXml(tables)] };
244
+ const name = parts[1].replace(/\(.*\)$/, ''), t = tables[name];
245
+ if (!t) throw new ODataError(404, `no table "${name}" (tables: ${Object.keys(tables).join(', ') || 'none'})`);
246
+ if (parts[1] !== name) throw new ODataError(501, 'reading one row by its key is not supported; use $filter');
247
+ const columns = t.columnNames.filter(odataName);
248
+ if (parts[2] === '$count') {
249
+ const where = params.get('$filter') ? ` WHERE ${filterSql(params.get('$filter'), columns)}` : '';
250
+ const n = (await sql(`SELECT COUNT(*) AS n FROM ${qid(name)}${where}`, tables).toRowsAsync())[0].n;
251
+ return { status: 200, headers: { ...H, 'content-type': 'text/plain' }, body: [String(n)] };
252
+ }
253
+ if (parts.length > 2) throw new ODataError(501, `/${parts.slice(2).join('/')} is not supported`);
254
+ const pref = /odata\.maxpagesize=(\d+)/i.exec(headers.prefer ?? '');
255
+ const page = pref ? Math.max(1, Math.min(Number(pref[1]), 1_000_000)) : pageSize;
256
+ const q = querySql(name, columns, params, page);
257
+ let query;
258
+ try { query = sql(q.sql, tables, { types: true }); } catch (err) { bad(err.message.replace(/^vectro( SQL)?:\s*/, '')); }
259
+ const types = query.columns.map((c) => c.type);
260
+ const table = await query.collectAsync();
261
+ const out = { status: 200, headers: { ...H, 'content-type': 'application/json;odata.metadata=minimal;odata.streaming=true', ...(pref ? { 'Preference-Applied': `odata.maxpagesize=${page}` } : {}) }, body: null };
262
+ try {
263
+ const n = Math.min(table.length, q.want), more = table.length > q.want && (q.top === null || q.start - q.skip + n < q.top);
264
+ const data = await table.toColumnsAsync();
265
+ const readers = query.columns.map((c) => { const d = data[c.name]; return d?.labels ? (i) => d.labels[d.codes[i]] : (i) => d[i]; });
266
+ const keys = q.columns.map((c) => `${JSON.stringify(c)}:`);
267
+ const select = params.get('$select') || params.get('$apply') ? `(${q.columns.join(',')})` : '';
268
+ let head = `{"@odata.context":${JSON.stringify(`${base}/$metadata#${name}${select}`)}`;
269
+ if (q.count) head += `,"@odata.count":${(await sql(q.countSql, tables).toRowsAsync())[0].n}`;
270
+ if (more) { // the next page: the same request from the next row
271
+ const next = new URLSearchParams(params);
272
+ next.set('$skiptoken', String(q.start - q.skip + n));
273
+ head += `,"@odata.nextLink":${JSON.stringify(`${base}/${encodeURIComponent(name)}?${next.toString().replace(/\+/g, '%20')}`)}`;
274
+ }
275
+ const body = [`${head},"value":[`];
276
+ for (let a = 0; a < n; a += 16_384) {
277
+ let s = '';
278
+ for (let i = a; i < Math.min(n, a + 16_384); i++) {
279
+ s += `${i ? ',' : ''}{`;
280
+ for (let k = 0; k < keys.length; k++) s += `${k ? ',' : ''}${keys[k]}${cell(types[k], readers[k](i))}`;
281
+ s += '}';
282
+ }
283
+ body.push(s);
284
+ }
285
+ body.push(']}');
286
+ out.body = body;
287
+ } finally { table.dispose(); }
288
+ return out;
289
+ }
290
+
291
+ export { displayName };