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.
- package/CHANGELOG.md +89 -0
- package/README.md +828 -4
- package/bin/vectro.mjs +82 -0
- package/dist/vectro.wasm +0 -0
- package/mcp/server.mjs +288 -0
- package/package.json +84 -6
- package/src/arrow.mjs +905 -0
- package/src/bitpack.mjs +135 -0
- package/src/browser.mjs +129 -0
- package/src/bytes.mjs +60 -0
- package/src/csv-kernel.mjs +205 -0
- package/src/csv.mjs +270 -0
- package/src/dataset.mjs +1763 -0
- package/src/dates.mjs +139 -0
- package/src/emitter.mjs +109 -0
- package/src/engine.mjs +881 -0
- package/src/files-browser.mjs +104 -0
- package/src/files-node.mjs +59 -0
- package/src/frames.mjs +191 -0
- package/src/fuse.mjs +512 -0
- package/src/interp.mjs +305 -0
- package/src/join.mjs +534 -0
- package/src/kernels.mjs +312 -0
- package/src/ndjson.mjs +136 -0
- package/src/node.mjs +120 -0
- package/src/pack.mjs +114 -0
- package/src/parallel-sort.mjs +99 -0
- package/src/partition-kernels.mjs +388 -0
- package/src/partition.mjs +395 -0
- package/src/plan.mjs +551 -0
- package/src/portable.mjs +21 -0
- package/src/rank-kernels.mjs +81 -0
- package/src/rows.mjs +91 -0
- package/src/serve/catalog.mjs +148 -0
- package/src/serve/flight.mjs +270 -0
- package/src/serve/formats.mjs +87 -0
- package/src/serve/http.mjs +182 -0
- package/src/serve/index.mjs +9 -0
- package/src/serve/odata.mjs +291 -0
- package/src/serve/pg.mjs +588 -0
- package/src/serve/pgcatalog.mjs +220 -0
- package/src/serve/pgtypes.mjs +199 -0
- package/src/sketch.mjs +75 -0
- package/src/sort-kernels.mjs +117 -0
- package/src/sort.mjs +102 -0
- package/src/split-worker-core.mjs +252 -0
- package/src/split-worker-node.mjs +8 -0
- package/src/split-worker.mjs +7 -0
- package/src/split.mjs +741 -0
- package/src/sql.mjs +1558 -0
- package/src/sqltext.mjs +132 -0
- package/src/static-kernels.mjs +164 -0
- package/src/stream-worker.mjs +553 -0
- package/src/table-kernels.mjs +323 -0
- package/src/table.mjs +2247 -0
- package/src/textmap.mjs +159 -0
- package/src/vectors.mjs +138 -0
- package/src/wasm.mjs +254 -0
- package/src/window.mjs +160 -0
- package/src/worker-browser.mjs +21 -0
- package/src/worker-loop.mjs +106 -0
- package/src/worker.mjs +22 -0
- package/src/zones.mjs +75 -0
package/src/sql.mjs
ADDED
|
@@ -0,0 +1,1558 @@
|
|
|
1
|
+
// SQL on Vectro tables and datasets: sql(text, { orders, customers }) returns
|
|
2
|
+
// a lazy query (see plan.mjs): call .toRows(), .collect(), .count() or
|
|
3
|
+
// .explain() (or their Async versions).
|
|
4
|
+
//
|
|
5
|
+
// [WITH name [(cols)] AS (query), ...]
|
|
6
|
+
// SELECT [DISTINCT] items [FROM t [AS a] | (query) [AS] a
|
|
7
|
+
// [[INNER | LEFT | RIGHT | FULL [OUTER] | CROSS] JOIN u [AS b] | (query) b [ON a.x = b.y [AND ...] | USING (x, ...)]]...]
|
|
8
|
+
// [WHERE cond] [GROUP BY exprs] [HAVING cond]
|
|
9
|
+
// [UNION [ALL] | INTERSECT | EXCEPT SELECT ...]...
|
|
10
|
+
// [ORDER BY expr [ASC | DESC] [NULLS FIRST | LAST], ...] [LIMIT n | FETCH FIRST n ROWS ONLY] [OFFSET m]
|
|
11
|
+
//
|
|
12
|
+
// Expressions: numbers, 'text', DATE '2024-01-01', TIMESTAMP '...', INTERVAL
|
|
13
|
+
// '7 days', NULL, TRUE / FALSE; + - * / %; = <> != < <= > >=; AND OR NOT;
|
|
14
|
+
// BETWEEN, IN (...), IN (SELECT ...), EXISTS (SELECT ...), (SELECT one value),
|
|
15
|
+
// LIKE, IS [NOT] NULL, CASE WHEN ... THEN ... ELSE ... END;
|
|
16
|
+
// ABS SQRT FLOOR CEIL ROUND LEAST GREATEST COALESCE IFNULL NULLIF CAST
|
|
17
|
+
// YEAR MONTH DAY HOUR MINUTE SECOND EXTRACT(part FROM x) DATE_TRUNC('month', x);
|
|
18
|
+
// aggregates COUNT(*) COUNT(x) COUNT(DISTINCT x) SUM AVG MIN MAX
|
|
19
|
+
// APPROX_COUNT_DISTINCT MEDIAN PERCENTILE_CONT(p) / PERCENTILE_DISC(p) WITHIN
|
|
20
|
+
// GROUP (ORDER BY x) QUANTILE_CONT(x, p), anywhere in SELECT / HAVING / ORDER BY;
|
|
21
|
+
// window functions f(...) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS | RANGE
|
|
22
|
+
// BETWEEN ... AND ...]): ROW_NUMBER RANK DENSE_RANK PERCENT_RANK CUME_DIST NTILE
|
|
23
|
+
// LAG LEAD FIRST_VALUE LAST_VALUE NTH_VALUE and SUM AVG MIN MAX COUNT;
|
|
24
|
+
// text functions (see sqltext.mjs: UPPER, SUBSTRING, ||, REGEXP_REPLACE,
|
|
25
|
+
// STRFTIME...), ILIKE, text CASE, CAST(x AS VARCHAR), run once per distinct
|
|
26
|
+
// value (Table._textmap).
|
|
27
|
+
//
|
|
28
|
+
// Subqueries become joins, so the whole query stays one plan: IN / EXISTS
|
|
29
|
+
// join the subquery's distinct keys (correlated ones on their equalities:
|
|
30
|
+
// WHERE b.k = a.k), a single-value subquery joins its one row (correlated
|
|
31
|
+
// ones: grouped by the correlation keys).
|
|
32
|
+
|
|
33
|
+
import { Query, namesOf, findDataset } from './plan.mjs';
|
|
34
|
+
import { quoteText } from './fuse.mjs';
|
|
35
|
+
import { TEXT_FUNCTIONS, toText, isNull, textToNumber, textToDate } from './sqltext.mjs';
|
|
36
|
+
|
|
37
|
+
const KEYWORDS = new Set(['SELECT', 'DISTINCT', 'FROM', 'AS', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'OUTER', 'ON', 'USING', 'WHERE', 'GROUP', 'BY', 'HAVING', 'ORDER', 'ASC', 'DESC', 'LIMIT', 'OFFSET', 'AND', 'OR', 'NOT', 'BETWEEN', 'IN', 'LIKE', 'IS', 'NULL', 'TRUE', 'FALSE', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'NULLS', 'FIRST', 'LAST', 'CAST', 'DATE', 'TIMESTAMP', 'INTERVAL', 'EXTRACT', 'ALL', 'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT', 'WITH']);
|
|
38
|
+
|
|
39
|
+
class SqlError extends SyntaxError {}
|
|
40
|
+
const fail = (msg) => { throw new SqlError(`vectro SQL: ${msg}`); };
|
|
41
|
+
|
|
42
|
+
// ---- tokens ---------------------------------------------------------------------------
|
|
43
|
+
// fold: unquoted names in lower case, as PostgreSQL reads them
|
|
44
|
+
function tokenize(src, fold = false) {
|
|
45
|
+
const toks = [];
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (i < src.length) {
|
|
48
|
+
const c = src[i];
|
|
49
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
50
|
+
if ((c === 'E' || c === 'e') && src[i + 1] === "'") { // E'text' with backslash escapes
|
|
51
|
+
let v = '';
|
|
52
|
+
i += 2;
|
|
53
|
+
const ESC = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' };
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (i >= src.length) fail('a text in quotes is not closed');
|
|
56
|
+
const ch = src[i];
|
|
57
|
+
if (ch === "'") { if (src[i + 1] === "'") { v += "'"; i += 2; continue; } i++; break; }
|
|
58
|
+
if (ch === '\\' && i + 1 < src.length) {
|
|
59
|
+
const nx = src[i + 1];
|
|
60
|
+
if (ESC[nx]) { v += ESC[nx]; i += 2; continue; }
|
|
61
|
+
const hex = nx === 'x' ? /^[0-9a-fA-F]{1,2}/.exec(src.slice(i + 2)) : nx === 'u' ? /^[0-9a-fA-F]{4}/.exec(src.slice(i + 2)) : nx === 'U' ? /^[0-9a-fA-F]{8}/.exec(src.slice(i + 2)) : null;
|
|
62
|
+
if (hex) { v += String.fromCodePoint(parseInt(hex[0], 16)); i += 2 + hex[0].length; continue; }
|
|
63
|
+
v += nx; i += 2; continue;
|
|
64
|
+
}
|
|
65
|
+
v += ch; i++;
|
|
66
|
+
}
|
|
67
|
+
toks.push({ t: 'str', v });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (src.startsWith('--', i)) { while (i < src.length && src[i] !== '\n') i++; continue; }
|
|
71
|
+
if (src.startsWith('/*', i)) { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; continue; }
|
|
72
|
+
if (c === "'") { // 'text' ('' is a quote)
|
|
73
|
+
let v = '';
|
|
74
|
+
i++;
|
|
75
|
+
for (;;) {
|
|
76
|
+
if (i >= src.length) fail('a text in quotes is not closed');
|
|
77
|
+
if (src[i] === "'") { if (src[i + 1] === "'") { v += "'"; i += 2; continue; } i++; break; }
|
|
78
|
+
v += src[i++];
|
|
79
|
+
}
|
|
80
|
+
toks.push({ t: 'str', v });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (c === '"' || c === '`') { // "quoted name"
|
|
84
|
+
const e = src.indexOf(c, i + 1);
|
|
85
|
+
if (e < 0) fail('a quoted name is not closed');
|
|
86
|
+
toks.push({ t: 'id', v: src.slice(i + 1, e), quoted: true });
|
|
87
|
+
i = e + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const num = /^(\d+\.?\d*(e[+-]?\d+)?|\.\d+(e[+-]?\d+)?)/i.exec(src.slice(i));
|
|
91
|
+
if (num) { toks.push({ t: 'num', v: Number(num[0]) }); i += num[0].length; continue; }
|
|
92
|
+
const id = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));
|
|
93
|
+
if (id) {
|
|
94
|
+
const up = id[0].toUpperCase();
|
|
95
|
+
const raw = fold ? id[0].toLowerCase() : id[0];
|
|
96
|
+
toks.push(KEYWORDS.has(up) ? { t: 'kw', v: up, raw } : { t: 'id', v: raw });
|
|
97
|
+
i += id[0].length;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const op = ['!~*', '<>', '!=', '<=', '>=', '||', '::', '!~', '~*'].find((o) => src.startsWith(o, i)) ?? c;
|
|
101
|
+
if (op.length === 1 && !'+-*/%=<>(),.;~'.includes(op)) fail(`unexpected "${c}"`);
|
|
102
|
+
toks.push({ t: 'op', v: op });
|
|
103
|
+
i += op.length;
|
|
104
|
+
}
|
|
105
|
+
return toks;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- parser ---------------------------------------------------------------------------------
|
|
109
|
+
// Words that are keywords only where they're expected (columns may be named so).
|
|
110
|
+
const SOFT = new Set(['OVER', 'PARTITION', 'ROWS', 'RANGE', 'UNBOUNDED', 'PRECEDING', 'FOLLOWING', 'CURRENT', 'ROW', 'WITHIN', 'FETCH', 'NEXT', 'ONLY', 'FILTER']);
|
|
111
|
+
|
|
112
|
+
function parseStatement(src, fold = false) {
|
|
113
|
+
const toks = tokenize(src, fold);
|
|
114
|
+
let p = 0;
|
|
115
|
+
const peek = (o = 0) => toks[p + o];
|
|
116
|
+
const isKw = (v, o = 0) => peek(o)?.t === 'kw' && peek(o).v === v;
|
|
117
|
+
const isWord = (v, o = 0) => isKw(v, o) || (peek(o)?.t === 'id' && !peek(o).quoted && peek(o).v.toUpperCase() === v);
|
|
118
|
+
const isOp = (v, o = 0) => peek(o)?.t === 'op' && peek(o).v === v;
|
|
119
|
+
const eatKw = (v) => { if (isKw(v)) { p++; return true; } return false; };
|
|
120
|
+
const eatWord = (v) => { if (isWord(v)) { p++; return true; } return false; };
|
|
121
|
+
const eatOp = (v) => { if (isOp(v)) { p++; return true; } return false; };
|
|
122
|
+
const needKw = (v) => { if (!eatKw(v)) fail(`expected ${v} ${near()}`); };
|
|
123
|
+
const needWord = (v) => { if (!eatWord(v)) fail(`expected ${v} ${near()}`); };
|
|
124
|
+
const needOp = (v) => { if (!eatOp(v)) fail(`expected "${v}" ${near()}`); };
|
|
125
|
+
const near = () => (peek() ? `near "${peek().v}"` : 'at the end');
|
|
126
|
+
const ident = () => { const t = peek(); if (t?.t !== 'id') fail(`expected a name ${near()}`); p++; return t.v; };
|
|
127
|
+
// after AS any word is a name (AS first, AS end)
|
|
128
|
+
const aliasName = () => { const t = peek(); if (t?.t === 'kw') { p++; return t.raw; } return ident(); };
|
|
129
|
+
const startsQuery = (o = 0) => isKw('SELECT', o) || isKw('WITH', o);
|
|
130
|
+
const aliasNext = () => peek()?.t === 'id' && !(SOFT.has(peek().v.toUpperCase()) && !peek().quoted && ['OVER', 'WITHIN', 'FETCH', 'FILTER'].includes(peek().v.toUpperCase()));
|
|
131
|
+
|
|
132
|
+
function expr() { return or(); }
|
|
133
|
+
function or() { let l = and(); while (eatKw('OR')) l = { k: 'bin', op: 'OR', l, r: and() }; return l; }
|
|
134
|
+
function and() { let l = not(); while (eatKw('AND')) l = { k: 'bin', op: 'AND', l, r: not() }; return l; }
|
|
135
|
+
function not() { if (eatKw('NOT')) return { k: 'not', x: not() }; return cmp(); }
|
|
136
|
+
function cmp() {
|
|
137
|
+
if (isKw('EXISTS')) { p++; needOp('('); const q = query(); needOp(')'); return { k: 'exists', q }; }
|
|
138
|
+
const l = add();
|
|
139
|
+
const negate = (x) => ({ k: 'not', x });
|
|
140
|
+
if (isKw('IS')) {
|
|
141
|
+
p++;
|
|
142
|
+
const neg = eatKw('NOT');
|
|
143
|
+
let e;
|
|
144
|
+
if (eatKw('TRUE')) e = { k: 'bin', op: 'IS', l, r: { k: 'num', v: 1, bool: true } };
|
|
145
|
+
else if (eatKw('FALSE')) e = { k: 'bin', op: 'IS', l, r: { k: 'num', v: 0, bool: true } };
|
|
146
|
+
else if (eatKw('DISTINCT')) { needKw('FROM'); e = negate({ k: 'bin', op: 'IS', l, r: add() }); } // (IS DISTINCT FROM: not the same, missing counting as a value)
|
|
147
|
+
else { needKw('NULL'); e = { k: 'isnull', x: l }; }
|
|
148
|
+
return neg ? negate(e) : e;
|
|
149
|
+
}
|
|
150
|
+
const neg = isKw('NOT') && (isKw('BETWEEN', 1) || isKw('IN', 1) || isKw('LIKE', 1) || isWord('ILIKE', 1)) ? (p++, true) : false;
|
|
151
|
+
if (eatKw('BETWEEN')) { const lo = add(); needKw('AND'); const hi = add(); const e = { k: 'between', x: l, lo, hi }; return neg ? negate(e) : e; }
|
|
152
|
+
if (eatKw('IN')) {
|
|
153
|
+
needOp('(');
|
|
154
|
+
if (startsQuery()) { const q = query(); needOp(')'); const e = { k: 'insub', x: l, q }; return neg ? negate(e) : e; }
|
|
155
|
+
const list = [expr()];
|
|
156
|
+
while (eatOp(',')) list.push(expr());
|
|
157
|
+
needOp(')');
|
|
158
|
+
const e = { k: 'in', x: l, list };
|
|
159
|
+
return neg ? negate(e) : e;
|
|
160
|
+
}
|
|
161
|
+
if (eatKw('LIKE')) { const e = { k: 'like', x: l, pattern: add() }; return neg ? negate(e) : e; }
|
|
162
|
+
if (eatWord('ILIKE')) { const e = { k: 'like', x: l, pattern: add(), ci: true }; return neg ? negate(e) : e; }
|
|
163
|
+
const op = ['=', '<>', '!=', '<', '<=', '>', '>=', '~', '~*', '!~', '!~*'].find((o) => isOp(o));
|
|
164
|
+
if (op) { p++; return { k: 'bin', op, l, r: add() }; }
|
|
165
|
+
return l;
|
|
166
|
+
}
|
|
167
|
+
function add() {
|
|
168
|
+
let l = mul();
|
|
169
|
+
for (;;) {
|
|
170
|
+
if (eatOp('+')) l = { k: 'bin', op: '+', l, r: mul() };
|
|
171
|
+
else if (eatOp('-')) l = { k: 'bin', op: '-', l, r: mul() };
|
|
172
|
+
else if (eatOp('||')) l = { k: 'call', fn: '||', args: l.k === 'call' && l.fn === '||' ? [...l.args, mul()] : [l, mul()] };
|
|
173
|
+
else return l;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function mul() {
|
|
177
|
+
let l = unary();
|
|
178
|
+
for (;;) {
|
|
179
|
+
const op = ['*', '/', '%'].find((o) => isOp(o));
|
|
180
|
+
if (!op) return l;
|
|
181
|
+
p++;
|
|
182
|
+
l = { k: 'bin', op, l, r: unary() };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function unary() {
|
|
186
|
+
if (eatOp('-')) return { k: 'neg', x: unary() };
|
|
187
|
+
if (eatOp('+')) return unary();
|
|
188
|
+
let e = primary();
|
|
189
|
+
while (eatOp('::')) e = { k: 'cast', x: e, type: typeName() };
|
|
190
|
+
return e;
|
|
191
|
+
}
|
|
192
|
+
function typeName() {
|
|
193
|
+
let t = peek();
|
|
194
|
+
if (!t || (t.t !== 'id' && t.t !== 'kw')) fail(`expected a type ${near()}`);
|
|
195
|
+
p++;
|
|
196
|
+
while (isOp('.') && (peek(1)?.t === 'id' || peek(1)?.t === 'kw')) { p++; t = peek(); p++; } // pg_catalog.text
|
|
197
|
+
let name = String(t.v).toUpperCase();
|
|
198
|
+
if (name === 'DOUBLE' && eatWord('PRECISION')) name = 'DOUBLE PRECISION';
|
|
199
|
+
if ((name === 'CHARACTER' || name === 'CHAR') && eatWord('VARYING')) name = 'VARCHAR';
|
|
200
|
+
if (eatOp('(')) { while (!eatOp(')')) p++; } // DECIMAL(10, 2), VARCHAR(20), TIMESTAMP(3)
|
|
201
|
+
if (name === 'TIMESTAMP' || name === 'TIME') {
|
|
202
|
+
if (isKw('WITH') && isWord('TIME', 1)) { p++; needWord('TIME'); needWord('ZONE'); name += 'TZ'; }
|
|
203
|
+
else if (eatWord('WITHOUT')) { needWord('TIME'); needWord('ZONE'); }
|
|
204
|
+
}
|
|
205
|
+
if (isOp('[')) fail('arrays are not supported');
|
|
206
|
+
return name;
|
|
207
|
+
}
|
|
208
|
+
function orderItems() {
|
|
209
|
+
const out = [];
|
|
210
|
+
do {
|
|
211
|
+
const e = expr();
|
|
212
|
+
let desc = false, nullsFirst = false;
|
|
213
|
+
if (eatKw('DESC')) desc = true; else eatKw('ASC');
|
|
214
|
+
if (eatKw('NULLS')) { if (eatKw('FIRST')) nullsFirst = true; else needKw('LAST'); }
|
|
215
|
+
out.push({ e, desc, nullsFirst });
|
|
216
|
+
} while (eatOp(','));
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
function frameBound() {
|
|
220
|
+
if (eatWord('UNBOUNDED')) { if (eatWord('PRECEDING')) return { kind: 'unbounded' }; needWord('FOLLOWING'); return { kind: 'unbounded' }; }
|
|
221
|
+
if (eatWord('CURRENT')) { needWord('ROW'); return { kind: 'current' }; }
|
|
222
|
+
const n = add();
|
|
223
|
+
if (eatWord('PRECEDING')) return { kind: 'preceding', x: n };
|
|
224
|
+
needWord('FOLLOWING');
|
|
225
|
+
return { kind: 'following', x: n };
|
|
226
|
+
}
|
|
227
|
+
function windowSpec() {
|
|
228
|
+
needOp('(');
|
|
229
|
+
const partition = [];
|
|
230
|
+
if (eatWord('PARTITION')) { needKw('BY'); do partition.push(expr()); while (eatOp(',')); }
|
|
231
|
+
const order = eatKw('ORDER') ? (needKw('BY'), orderItems()) : [];
|
|
232
|
+
let frame = null;
|
|
233
|
+
if (isWord('ROWS') || isWord('RANGE')) {
|
|
234
|
+
const unit = peek().v.toUpperCase() === 'ROWS' ? 'rows' : 'range';
|
|
235
|
+
p++;
|
|
236
|
+
if (eatKw('BETWEEN')) { const start = frameBound(); needKw('AND'); frame = { unit, start, end: frameBound() }; }
|
|
237
|
+
else frame = { unit, start: frameBound(), end: { kind: 'current' } };
|
|
238
|
+
}
|
|
239
|
+
needOp(')');
|
|
240
|
+
return { partition, order, frame };
|
|
241
|
+
}
|
|
242
|
+
function primary() {
|
|
243
|
+
const t = peek();
|
|
244
|
+
if (!t) fail('the query ends too early');
|
|
245
|
+
if (t.t === 'num') { p++; return { k: 'num', v: t.v }; }
|
|
246
|
+
if (t.t === 'str') { p++; return { k: 'str', v: t.v }; }
|
|
247
|
+
if (eatOp('(')) {
|
|
248
|
+
if (startsQuery()) { const q = query(); needOp(')'); return { k: 'subq', q }; }
|
|
249
|
+
const e = expr();
|
|
250
|
+
needOp(')');
|
|
251
|
+
return e;
|
|
252
|
+
}
|
|
253
|
+
if (t.t === 'kw') {
|
|
254
|
+
if (eatKw('NULL')) return { k: 'null' };
|
|
255
|
+
if (eatKw('TRUE')) return { k: 'num', v: 1, bool: true };
|
|
256
|
+
if (eatKw('FALSE')) return { k: 'num', v: 0, bool: true };
|
|
257
|
+
if (isKw('DATE') || isKw('TIMESTAMP')) {
|
|
258
|
+
if (peek(1)?.t === 'str') { p++; return { k: 'date', v: toks[p++].v }; }
|
|
259
|
+
}
|
|
260
|
+
if (eatKw('INTERVAL')) { const s = peek(); if (s?.t !== 'str') fail("INTERVAL needs text, e.g. INTERVAL '7 days'"); p++; return { k: 'interval', v: s.v, unit: peek()?.t === 'id' && !SOFT.has(peek().v.toUpperCase()) ? ident() : null }; }
|
|
261
|
+
if (eatKw('CASE')) {
|
|
262
|
+
const base = isKw('WHEN') ? null : expr();
|
|
263
|
+
const whens = [];
|
|
264
|
+
while (eatKw('WHEN')) { const c = expr(); needKw('THEN'); whens.push({ c, v: expr() }); }
|
|
265
|
+
if (!whens.length) fail('CASE needs WHEN ... THEN ...');
|
|
266
|
+
const otherwise = eatKw('ELSE') ? expr() : { k: 'null' };
|
|
267
|
+
needKw('END');
|
|
268
|
+
return { k: 'case', base, whens, otherwise };
|
|
269
|
+
}
|
|
270
|
+
if (eatKw('CAST')) { needOp('('); const x = expr(); needKw('AS'); const type = typeName(); needOp(')'); return { k: 'cast', x, type }; }
|
|
271
|
+
if (eatKw('EXTRACT')) {
|
|
272
|
+
needOp('(');
|
|
273
|
+
const part = peek(); p++;
|
|
274
|
+
needKw('FROM');
|
|
275
|
+
const x = expr();
|
|
276
|
+
needOp(')');
|
|
277
|
+
return { k: 'call', fn: String(part.v).toUpperCase(), args: [x] };
|
|
278
|
+
}
|
|
279
|
+
if ((isKw('LEFT') || isKw('RIGHT')) && isOp('(', 1)) { const fn = peek().v; p++; return call(fn); } // LEFT(text, n)
|
|
280
|
+
fail(`unexpected ${t.v} ${near()}`);
|
|
281
|
+
}
|
|
282
|
+
if (t.t === 'id') {
|
|
283
|
+
p++;
|
|
284
|
+
if (isOp('(')) return call(t.v.toUpperCase());
|
|
285
|
+
if (eatOp('.')) {
|
|
286
|
+
if (eatOp('*')) return { k: 'star', table: t.v };
|
|
287
|
+
const second = ident();
|
|
288
|
+
if (isOp('(')) return call(second.toUpperCase()); // (schema.function(...): the function)
|
|
289
|
+
if (eatOp('.')) { if (eatOp('*')) return { k: 'star', table: second }; return { k: 'col', table: second, name: ident() }; } // (schema.table.column)
|
|
290
|
+
return { k: 'col', table: t.v, name: second };
|
|
291
|
+
}
|
|
292
|
+
return { k: 'col', name: t.v };
|
|
293
|
+
}
|
|
294
|
+
if (eatOp('*')) return { k: 'star' };
|
|
295
|
+
fail(`unexpected "${t.v}"`);
|
|
296
|
+
}
|
|
297
|
+
function call(fn) { // after the name: (args) [WITHIN GROUP (ORDER BY x)] [OVER (...)]
|
|
298
|
+
needOp('(');
|
|
299
|
+
let e;
|
|
300
|
+
// standard SQL's own forms: TRIM([BOTH | LEADING | TRAILING] [chars] FROM s), SUBSTRING(s FROM a [FOR n]), POSITION(sub IN s)
|
|
301
|
+
if (fn === 'TRIM' && (isWord('BOTH') || isWord('LEADING') || isWord('TRAILING') || isKw('FROM') || !isOp(')'))) {
|
|
302
|
+
const mode = eatWord('LEADING') ? 'LTRIM' : eatWord('TRAILING') ? 'RTRIM' : (eatWord('BOTH'), 'TRIM');
|
|
303
|
+
if (eatKw('FROM')) { const x = expr(); needOp(')'); return { k: 'call', fn: mode, args: [x] }; }
|
|
304
|
+
const first = expr();
|
|
305
|
+
if (eatKw('FROM')) { const x = expr(); needOp(')'); return { k: 'call', fn: mode, args: [x, first] }; }
|
|
306
|
+
const args = [first];
|
|
307
|
+
while (eatOp(',')) args.push(expr());
|
|
308
|
+
needOp(')');
|
|
309
|
+
return { k: 'call', fn: mode, args };
|
|
310
|
+
}
|
|
311
|
+
if ((fn === 'SUBSTRING' || fn === 'SUBSTR') && !isOp(')')) {
|
|
312
|
+
const x = expr();
|
|
313
|
+
if (eatKw('FROM')) { const args = [x, expr()]; if (eatWord('FOR')) args.push(expr()); needOp(')'); return { k: 'call', fn: 'SUBSTRING', args }; }
|
|
314
|
+
if (eatWord('FOR')) { const args = [x, { k: 'num', v: 1 }, expr()]; needOp(')'); return { k: 'call', fn: 'SUBSTRING', args }; }
|
|
315
|
+
const args = [x];
|
|
316
|
+
while (eatOp(',')) args.push(expr());
|
|
317
|
+
needOp(')');
|
|
318
|
+
return { k: 'call', fn: 'SUBSTRING', args };
|
|
319
|
+
}
|
|
320
|
+
if (fn === 'POSITION' && !isOp(')')) {
|
|
321
|
+
const sub = add();
|
|
322
|
+
if (eatKw('IN')) { const x = expr(); needOp(')'); return { k: 'call', fn: 'STRPOS', args: [x, sub] }; }
|
|
323
|
+
const args = [sub];
|
|
324
|
+
while (eatOp(',')) args.push(expr());
|
|
325
|
+
needOp(')');
|
|
326
|
+
return { k: 'call', fn: 'POSITION', args };
|
|
327
|
+
}
|
|
328
|
+
if (eatOp('*')) { needOp(')'); e = { k: 'call', fn, args: [], star: true }; }
|
|
329
|
+
else {
|
|
330
|
+
const distinct = eatKw('DISTINCT');
|
|
331
|
+
eatKw('ALL');
|
|
332
|
+
const args = [];
|
|
333
|
+
if (!isOp(')')) { args.push(expr()); while (eatOp(',')) args.push(expr()); }
|
|
334
|
+
needOp(')');
|
|
335
|
+
e = { k: 'call', fn, args, distinct };
|
|
336
|
+
}
|
|
337
|
+
if (eatWord('WITHIN')) { needKw('GROUP'); needOp('('); needKw('ORDER'); needKw('BY'); const [o] = orderItems(); needOp(')'); e.within = o; }
|
|
338
|
+
if (eatWord('OVER')) return { k: 'window', call: e, ...windowSpec() };
|
|
339
|
+
return e;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function fromItem() {
|
|
343
|
+
if (eatOp('(')) {
|
|
344
|
+
if (!startsQuery()) fail(`expected a subquery (SELECT ...) ${near()}`);
|
|
345
|
+
const q = query();
|
|
346
|
+
needOp(')');
|
|
347
|
+
eatKw('AS');
|
|
348
|
+
if (peek()?.t !== 'id') fail('a subquery in FROM needs a name: (SELECT ...) AS name');
|
|
349
|
+
return { q, alias: ident() };
|
|
350
|
+
}
|
|
351
|
+
let name = ident(), schema = null;
|
|
352
|
+
while (eatOp('.')) { schema = name; name = ident(); } // schema.table (db.schema.table: the last two)
|
|
353
|
+
let alias = name;
|
|
354
|
+
if (eatKw('AS')) alias = ident(); else if (aliasNext()) alias = ident();
|
|
355
|
+
return { name, schema, alias };
|
|
356
|
+
}
|
|
357
|
+
function selectCore() {
|
|
358
|
+
needKw('SELECT');
|
|
359
|
+
const distinct = eatKw('DISTINCT');
|
|
360
|
+
eatKw('ALL');
|
|
361
|
+
const items = [];
|
|
362
|
+
do {
|
|
363
|
+
const e = expr();
|
|
364
|
+
let alias = null;
|
|
365
|
+
if (eatKw('AS')) alias = aliasName();
|
|
366
|
+
else if (aliasNext()) alias = ident();
|
|
367
|
+
items.push({ e, alias });
|
|
368
|
+
} while (eatOp(','));
|
|
369
|
+
let from = null;
|
|
370
|
+
const joins = [];
|
|
371
|
+
if (eatKw('FROM')) {
|
|
372
|
+
from = fromItem();
|
|
373
|
+
for (;;) {
|
|
374
|
+
let how = null;
|
|
375
|
+
if (eatKw('JOIN')) how = 'inner';
|
|
376
|
+
else if (isKw('INNER')) { p++; needKw('JOIN'); how = 'inner'; }
|
|
377
|
+
else if (isKw('LEFT')) { p++; eatKw('OUTER'); needKw('JOIN'); how = 'left'; }
|
|
378
|
+
else if (isKw('RIGHT') || isKw('FULL')) { const w = peek().v; p++; eatKw('OUTER'); needKw('JOIN'); how = w.toLowerCase(); }
|
|
379
|
+
else if (isKw('CROSS')) { p++; needKw('JOIN'); how = 'cross'; }
|
|
380
|
+
else if (isOp(',')) fail('use JOIN ... ON instead of commas between tables');
|
|
381
|
+
if (!how) break;
|
|
382
|
+
const ref = fromItem();
|
|
383
|
+
if (how === 'cross') { joins.push({ ...ref, how }); continue; }
|
|
384
|
+
if (eatKw('USING')) { needOp('('); const cols = [ident()]; while (eatOp(',')) cols.push(ident()); needOp(')'); joins.push({ ...ref, how, using: cols }); }
|
|
385
|
+
else { needKw('ON'); joins.push({ ...ref, how, on: expr() }); }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const where = eatKw('WHERE') ? expr() : null;
|
|
389
|
+
const groupBy = [];
|
|
390
|
+
if (eatKw('GROUP')) { needKw('BY'); do groupBy.push(expr()); while (eatOp(',')); }
|
|
391
|
+
const having = eatKw('HAVING') ? expr() : null;
|
|
392
|
+
return { k: 'select', distinct, items, from, joins, where, groupBy, having };
|
|
393
|
+
}
|
|
394
|
+
function setTerm() {
|
|
395
|
+
if (isOp('(') && startsQuery(1)) { p++; const q = query(); needOp(')'); return q; }
|
|
396
|
+
return selectCore();
|
|
397
|
+
}
|
|
398
|
+
// query: WITH, set operations, then ORDER BY / LIMIT for the whole
|
|
399
|
+
function query() {
|
|
400
|
+
const ctes = [];
|
|
401
|
+
if (eatKw('WITH')) {
|
|
402
|
+
do {
|
|
403
|
+
const name = ident();
|
|
404
|
+
let cols = null;
|
|
405
|
+
if (eatOp('(')) { cols = [ident()]; while (eatOp(',')) cols.push(ident()); needOp(')'); }
|
|
406
|
+
needKw('AS');
|
|
407
|
+
needOp('(');
|
|
408
|
+
const q = query();
|
|
409
|
+
needOp(')');
|
|
410
|
+
ctes.push({ name, cols, q });
|
|
411
|
+
} while (eatOp(','));
|
|
412
|
+
}
|
|
413
|
+
let body = setTerm();
|
|
414
|
+
for (;;) {
|
|
415
|
+
let op = null;
|
|
416
|
+
if (eatKw('UNION')) op = eatKw('ALL') ? 'union all' : (eatKw('DISTINCT'), 'union');
|
|
417
|
+
else if (eatKw('INTERSECT')) { if (eatKw('ALL')) fail('INTERSECT ALL is not supported (INTERSECT is)'); eatKw('DISTINCT'); op = 'intersect'; }
|
|
418
|
+
else if (eatKw('EXCEPT')) { if (eatKw('ALL')) fail('EXCEPT ALL is not supported (EXCEPT is)'); eatKw('DISTINCT'); op = 'except'; }
|
|
419
|
+
if (!op) break;
|
|
420
|
+
body = { k: 'setop', op, left: body, right: setTerm() };
|
|
421
|
+
}
|
|
422
|
+
const orderBy = eatKw('ORDER') ? (needKw('BY'), orderItems()) : [];
|
|
423
|
+
let limit = null, offset = 0;
|
|
424
|
+
if (eatKw('LIMIT')) {
|
|
425
|
+
if (eatKw('ALL')) limit = null;
|
|
426
|
+
else { const t = peek(); if (t?.t !== 'num') fail('LIMIT needs a number'); p++; limit = t.v; }
|
|
427
|
+
}
|
|
428
|
+
if (eatKw('OFFSET')) { const t = peek(); if (t?.t !== 'num') fail('OFFSET needs a number'); p++; offset = t.v; eatWord('ROWS') || eatWord('ROW'); }
|
|
429
|
+
if (eatWord('FETCH')) {
|
|
430
|
+
if (!eatKw('FIRST')) needWord('NEXT');
|
|
431
|
+
const t = peek(); if (t?.t !== 'num') fail('FETCH FIRST needs a number'); p++; limit = t.v;
|
|
432
|
+
if (!eatWord('ROWS')) needWord('ROW');
|
|
433
|
+
needWord('ONLY');
|
|
434
|
+
}
|
|
435
|
+
return { k: 'query', ctes, body, orderBy, limit, offset };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const q = query();
|
|
439
|
+
eatOp(';');
|
|
440
|
+
if (p < toks.length) fail(`unexpected "${peek().v}" (one statement at a time)`);
|
|
441
|
+
return q;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---- translation ------------------------------------------------------------------------------
|
|
445
|
+
const AGG = { COUNT: 'count', SUM: 'sum', AVG: 'mean', MEAN: 'mean', MIN: 'min', MAX: 'max', APPROX_COUNT_DISTINCT: 'approxDistinct' };
|
|
446
|
+
const PERCENTILE = new Set(['MEDIAN', 'PERCENTILE_CONT', 'PERCENTILE_DISC', 'QUANTILE_CONT', 'QUANTILE_DISC', 'QUANTILE']);
|
|
447
|
+
const WINDOW_FNS = { ROW_NUMBER: 'row_number', RANK: 'rank', DENSE_RANK: 'dense_rank', PERCENT_RANK: 'percent_rank', CUME_DIST: 'cume_dist', NTILE: 'ntile', LAG: 'lag', LEAD: 'lead', FIRST_VALUE: 'first_value', LAST_VALUE: 'last_value', NTH_VALUE: 'nth_value', SUM: 'sum', AVG: 'avg', MEAN: 'avg', MIN: 'min', MAX: 'max', COUNT: 'count' };
|
|
448
|
+
const KEEPS_KIND = new Set(['lag', 'lead', 'first_value', 'last_value', 'nth_value', 'min', 'max']);
|
|
449
|
+
const DATE_PART = { YEAR: 'year', MONTH: 'month', DAY: 'day', HOUR: 'hour', MINUTE: 'minute', SECOND: 'second', ISODOW: 'weekday', DAYOFMONTH: 'day' };
|
|
450
|
+
const TRUNC = { year: 'yearStart', month: 'monthStart', week: 'weekStart', day: 'dayStart', hour: 'hourStart' };
|
|
451
|
+
const INTERVAL_MS = { millisecond: 1, second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000, week: 604_800_000 };
|
|
452
|
+
|
|
453
|
+
const num = (v) => ({ t: 'num', v });
|
|
454
|
+
const v = (name) => ({ t: 'var', name });
|
|
455
|
+
const bin = (op, l, r) => ({ t: 'bin', op, l, r });
|
|
456
|
+
const call = (fn, ...args) => ({ t: 'call', fn, args });
|
|
457
|
+
const cond = (c, x, y) => ({ t: 'cond', c, x, y });
|
|
458
|
+
const TEXT_TYPES = new Set(['VARCHAR', 'TEXT', 'STRING', 'CHAR', 'BPCHAR', 'NVARCHAR', 'NCHAR', 'CHARACTER', 'NAME', 'CITEXT', 'UUID', 'REGCLASS', 'REGTYPE', 'REGPROC', 'REGNAMESPACE', 'REGROLE']);
|
|
459
|
+
const INT_TYPES = new Set(['INT', 'INTEGER', 'BIGINT', 'SMALLINT', 'TINYINT', 'HUGEINT', 'INT2', 'INT4', 'INT8', 'OID', 'XID', 'CID']);
|
|
460
|
+
const FLOAT_TYPES = new Set(['DOUBLE', 'DOUBLE PRECISION', 'FLOAT', 'FLOAT4', 'FLOAT8', 'REAL', 'DECIMAL', 'NUMERIC']);
|
|
461
|
+
const BOOL_TYPES = new Set(['BOOL', 'BOOLEAN']);
|
|
462
|
+
const DATE_TYPES = new Set(['DATE', 'TIMESTAMP', 'TIMESTAMPTZ', 'DATETIME']);
|
|
463
|
+
// session values a bare name reads (current_user), as PostgreSQL has them
|
|
464
|
+
const SESSION_NAMES = new Set(['current_user', 'session_user', 'user', 'current_role', 'current_catalog', 'current_schema']);
|
|
465
|
+
function astVars(t, out = []) { if (t.t === 'var') out.push(t.name); for (const x of [t.x, t.l, t.r, t.c, t.y, ...(t.args ?? [])]) if (x && typeof x === 'object') astVars(x, out); return out; }
|
|
466
|
+
// Column names that aren't plain names ("sum:price", "Order Date") live under
|
|
467
|
+
// a plain one: __q_ then letters and digits as they are, others as _<hex>_.
|
|
468
|
+
const PLAIN = /^[A-Za-z_]\w*$/;
|
|
469
|
+
export const encodeName = (name) => (PLAIN.test(name) ? name : `__q_${[...name].map((ch) => (/[A-Za-z0-9]/.test(ch) ? ch : `_${ch.codePointAt(0).toString(16)}_`)).join('')}`);
|
|
470
|
+
export const displayName = (name) => (name.startsWith('__q_') ? name.slice(4).replace(/_([0-9a-f]+)_/g, (m, h) => String.fromCodePoint(parseInt(h, 16))) : name);
|
|
471
|
+
const constNum = (t) => (t.t === 'num' ? t.v : t.t === 'un' && t.op === '-' && t.x.t === 'num' ? -t.x.v : null);
|
|
472
|
+
const isAgg = (e) => e.k === 'call' && (AGG[e.fn] || PERCENTILE.has(e.fn));
|
|
473
|
+
const key = (e) => JSON.stringify(e);
|
|
474
|
+
// does e contain a node matching test (not looking inside subqueries or windows' own calls)?
|
|
475
|
+
function has(e, test, intoWindows = false) {
|
|
476
|
+
let found = false;
|
|
477
|
+
const visit = (x) => {
|
|
478
|
+
if (found || !x || typeof x !== 'object') return;
|
|
479
|
+
if (test(x)) { found = true; return; }
|
|
480
|
+
if (x.k === 'subq' || x.k === 'exists' || x.k === 'query') return;
|
|
481
|
+
if (x.k === 'insub') { visit(x.x); return; }
|
|
482
|
+
if (x.k === 'window' && !intoWindows) { for (const y of [...x.partition, ...x.order.map((o) => o.e), ...x.call.args]) visit(y); return; }
|
|
483
|
+
for (const val of Object.values(x)) if (Array.isArray(val)) val.forEach(visit); else if (val && typeof val === 'object') visit(val);
|
|
484
|
+
};
|
|
485
|
+
visit(e);
|
|
486
|
+
return found;
|
|
487
|
+
}
|
|
488
|
+
const hasAgg = (e) => has(e, isAgg);
|
|
489
|
+
const hasWindow = (e) => has(e, (x) => x.k === 'window');
|
|
490
|
+
const colRefs = (e) => { const out = []; has(e, (x) => { if (x.k === 'col') out.push(x); return false; }); return out; };
|
|
491
|
+
const conjuncts = (e) => (!e ? [] : e.k === 'bin' && e.op === 'AND' ? [...conjuncts(e.l), ...conjuncts(e.r)] : [e]);
|
|
492
|
+
const andAll = (list) => (list.length ? list.reduce((a, b) => ({ k: 'bin', op: 'AND', l: a, r: b })) : null);
|
|
493
|
+
|
|
494
|
+
// sql(text, tables[, opts]) -> Query
|
|
495
|
+
// opts.types: the Query gets .columns = [{ name, type: 'int' | 'float' | 'bool' | 'text' | 'date' | 'day' }]
|
|
496
|
+
// (day: dates without a time), worked out from the tables' columns, not from the answer
|
|
497
|
+
// opts.foldCase: unquoted names in lower case (PostgreSQL's way)
|
|
498
|
+
// opts.session: values of functions such as version(), current_user ({ version: '...' })
|
|
499
|
+
// opts.database: a schema name that means the tables themselves (as public does)
|
|
500
|
+
// opts.functions: more functions, { name: { out: 'text' | 'number', f(...values) } }
|
|
501
|
+
// (run once per distinct set of values; text missing as null)
|
|
502
|
+
// Tables may also be named "schema.table" (pg_catalog.pg_class): they are
|
|
503
|
+
// found by that name; an unqualified name also finds pg_catalog's.
|
|
504
|
+
export function sql(text, tables = {}, opts = {}) {
|
|
505
|
+
const ast = parseStatement(String(text), !!opts.foldCase);
|
|
506
|
+
const engine = Object.values(tables).find((t) => t?.engine)?.engine ?? null;
|
|
507
|
+
const env = { tables, ctes: new Map(), engine, n: { hidden: 0 }, wantTypes: !!opts.types, pgNames: !!opts.foldCase, functions: opts.functions ?? null, session: opts.session ?? null, database: opts.database ?? null };
|
|
508
|
+
const rel = compileQuery(ast, env, null);
|
|
509
|
+
if (opts.types) rel.query.columns = rel.names.map((name) => ({ name, type: rel.typeOf?.(name) ?? 'float' }));
|
|
510
|
+
return rel.query;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// A table by its (maybe schema-qualified) name, case aside; null if none.
|
|
514
|
+
function lookupTable(env, { name, schema }) {
|
|
515
|
+
const { tables } = env, keys = Object.keys(tables);
|
|
516
|
+
const get = (k) => tables[k] ?? tables[keys.find((x) => x.toLowerCase() === k.toLowerCase())];
|
|
517
|
+
const sc = schema?.toLowerCase();
|
|
518
|
+
if (sc && sc !== 'public' && sc !== env.database?.toLowerCase()) return get(`${schema}.${name}`) ?? null;
|
|
519
|
+
return get(name) ?? (sc ? null : get(`pg_catalog.${name}`)) ?? null;
|
|
520
|
+
}
|
|
521
|
+
const userTables = (tables) => Object.keys(tables).filter((k) => !k.includes('.'));
|
|
522
|
+
|
|
523
|
+
// Column types for answers (see sql's opts.types), cached per table and column:
|
|
524
|
+
// whole numbers -> int, others -> float; dates all at midnight -> day.
|
|
525
|
+
const typeCache = new WeakMap();
|
|
526
|
+
const merge = (ts) => {
|
|
527
|
+
const u = [...new Set(ts.filter(Boolean))];
|
|
528
|
+
if (u.length <= 1) return u[0] ?? 'text';
|
|
529
|
+
if (u.includes('text')) return 'text';
|
|
530
|
+
if (u.every((t) => t === 'int' || t === 'bool')) return 'int';
|
|
531
|
+
if (u.every((t) => t === 'day' || t === 'date')) return 'date';
|
|
532
|
+
return 'float';
|
|
533
|
+
};
|
|
534
|
+
function columnType(t, name) {
|
|
535
|
+
let m = typeCache.get(t);
|
|
536
|
+
if (!m) typeCache.set(t, (m = new Map()));
|
|
537
|
+
if (m.has(name)) return m.get(name);
|
|
538
|
+
const c = t.cols?.get(name);
|
|
539
|
+
let type = 'float';
|
|
540
|
+
if (!c) type = 'float';
|
|
541
|
+
else if (c.dict || c.strings) type = 'text';
|
|
542
|
+
else if (t.isDataset) { // from the chunks' encodings: whole-number codes
|
|
543
|
+
const enc = (t.meta?.chunks ?? []).map((ch) => ch.cols?.[name]);
|
|
544
|
+
if (c.date) type = 'date';
|
|
545
|
+
else type = enc.length && enc.every((z) => z && (z.missing === z.rows || (z.enc && !z.enc.div && Number.isInteger(z.enc.mul ?? 1) && Number.isInteger(z.enc.base ?? 0)))) ? 'int' : 'float';
|
|
546
|
+
} else if (c.date) type = t.length && t.agg({ n: `count(${name} == ${name} && ${name} != dayStart(${name}))` }).n === 0 ? 'day' : 'date';
|
|
547
|
+
else {
|
|
548
|
+
const a = t.agg({ frac: `count(${name} == ${name} && ${name} != floor(${name}))`, hi: `max(abs(${name}))` });
|
|
549
|
+
type = a.frac === 0 && !(a.hi >= 2 ** 53) ? 'int' : 'float';
|
|
550
|
+
}
|
|
551
|
+
m.set(name, type);
|
|
552
|
+
return type;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// A relation: { query, names, kinds (name -> 'text' | 'date' | 'number') }.
|
|
556
|
+
const kindOfTable = (t, col) => { const c = t.cols?.get(col); return c?.dict || c?.strings ? 'text' : c?.date ? 'date' : 'number'; };
|
|
557
|
+
function tableRelation(t) {
|
|
558
|
+
if (typeof t?.lazy !== 'function') { // (a split table, or something that isn't a table)
|
|
559
|
+
const what = t?.constructor?.name === 'SplitTable' || t?.constructor?.name === 'StreamTable' ? `a ${t.constructor.name}: SQL runs on Table and Dataset, not on split tables (use its own methods, filter / groupBy / aggAsync, or copy its rows into a Table: splitEngine.local.table(await t.toColumnsAsync()))` : 'not a Vectro Table or Dataset';
|
|
560
|
+
throw new TypeError(`vectro: sql() got ${what}`);
|
|
561
|
+
}
|
|
562
|
+
return { query: t.lazy(), names: t.columnNames, kinds: new Map(t.columnNames.map((n) => [n, kindOfTable(t, n)])), typeOf: (n) => columnType(t, n) };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function compileQuery(q, env, outer) {
|
|
566
|
+
let scopeEnv = env;
|
|
567
|
+
if (q.ctes.length) { // WITH: later ones and the body see earlier ones
|
|
568
|
+
scopeEnv = { ...env, ctes: new Map(env.ctes) };
|
|
569
|
+
for (const c of q.ctes) {
|
|
570
|
+
let rel = compileQuery(c.q, scopeEnv, outer);
|
|
571
|
+
if (c.cols) {
|
|
572
|
+
if (c.cols.length !== rel.names.length) fail(`WITH ${c.name}(${c.cols.join(', ')}) names ${c.cols.length} columns, its query has ${rel.names.length}`);
|
|
573
|
+
rel = renameRelation(rel, c.cols);
|
|
574
|
+
}
|
|
575
|
+
scopeEnv.ctes.set(c.name.toLowerCase(), rel);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (q.body.k === 'select') return compileSelect(q.body, scopeEnv, outer, q);
|
|
579
|
+
// set operations: then ORDER BY / LIMIT on the result's columns
|
|
580
|
+
let rel = compileSet(q.body, scopeEnv, outer);
|
|
581
|
+
let out = rel.query;
|
|
582
|
+
if (q.orderBy.length) {
|
|
583
|
+
const by = [];
|
|
584
|
+
for (const o of q.orderBy) {
|
|
585
|
+
let name = null;
|
|
586
|
+
if (o.e.k === 'num') name = rel.names[o.e.v - 1] ?? fail(`ORDER BY ${o.e.v}: there is no column ${o.e.v}`);
|
|
587
|
+
else if (o.e.k === 'col' && !o.e.table) name = rel.names.find((n) => n.toLowerCase() === o.e.name.toLowerCase() || n === encodeName(o.e.name)) ?? fail(`ORDER BY ${o.e.name}: a UNION is ordered by its columns`);
|
|
588
|
+
else fail('a UNION / INTERSECT / EXCEPT is ordered by its column names or numbers');
|
|
589
|
+
if (o.nullsFirst) { const h = `__nf${env.n.hidden++}`; out = out.derive(h, `${name} == ${name} ? 1 : 0`); by.push(h); }
|
|
590
|
+
by.push(o.desc ? `-${name}` : name);
|
|
591
|
+
}
|
|
592
|
+
out = out.sort(by);
|
|
593
|
+
}
|
|
594
|
+
if (q.limit !== null) out = out.limit(q.limit, q.offset);
|
|
595
|
+
else if (q.offset) fail('OFFSET needs LIMIT');
|
|
596
|
+
return { ...rel, query: out.select(...rel.names) };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function renameRelation(rel, names) {
|
|
600
|
+
let q = rel.query;
|
|
601
|
+
const kinds = new Map();
|
|
602
|
+
const tmp = rel.names.map((n, k) => `__r${k}_${n}`);
|
|
603
|
+
rel.names.forEach((n, k) => { q = q.derive(tmp[k], n); });
|
|
604
|
+
rel.names.forEach((n, k) => { kinds.set(names[k], rel.kinds.get(n)); });
|
|
605
|
+
// (derive copies values; text and date columns keep their kind through a plain copy)
|
|
606
|
+
let out = q.select(...tmp);
|
|
607
|
+
tmp.forEach((t, k) => { out = out.derive(names[k], t); });
|
|
608
|
+
return { query: out.select(...names), names, kinds, typeOf: (nm) => rel.typeOf?.(rel.names[names.indexOf(nm)]) ?? 'float' };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function compileSet(body, env, outer) {
|
|
612
|
+
if (body.k === 'query') return compileQuery(body, env, outer);
|
|
613
|
+
if (body.k === 'select') return compileSelect(body, env, outer, { orderBy: [], limit: null, offset: 0 });
|
|
614
|
+
let L = compileSet(body.left, env, outer);
|
|
615
|
+
const R = compileSet(body.right, env, outer);
|
|
616
|
+
if (L.names.length !== R.names.length) fail(`${body.op.toUpperCase()} needs the same number of columns on each side (${L.names.length} and ${R.names.length})`);
|
|
617
|
+
// NULL columns on one side become the other side's kind (text: '')
|
|
618
|
+
const asText = (rel, k) => { const tmp = `__nt${env.n.hidden++}`; const names = rel.names.map((nm, j) => (j === k ? tmp : nm)); return { ...rel, query: rel.query.textmap(tmp, { fn: () => '', label: 'null' }).select(...names), names }; };
|
|
619
|
+
let Lr = L, Rr = R;
|
|
620
|
+
L.names.forEach((n, k) => {
|
|
621
|
+
const a = L.kinds.get(n), b = R.kinds.get(R.names[k]);
|
|
622
|
+
if (a === 'null' && b === 'text') Lr = asText(Lr, k);
|
|
623
|
+
else if (b === 'null' && a === 'text') Rr = asText(Rr, k);
|
|
624
|
+
else if (a !== 'null' && b !== 'null' && (a === 'text') !== (b === 'text')) fail(`${body.op.toUpperCase()} column ${k + 1} is ${a} on one side and ${b} on the other`);
|
|
625
|
+
});
|
|
626
|
+
const names = L.names;
|
|
627
|
+
const kindsOut = new Map(L.names.map((n, k) => [n, L.kinds.get(n) === 'null' ? R.kinds.get(R.names[k]) : L.kinds.get(n)]));
|
|
628
|
+
const right = Rr.query; // (columns by position; named as the left's)
|
|
629
|
+
L = Lr.names === L.names ? L : { ...L, query: Lr.query.derive(L.names[Lr.names.findIndex((x, j) => x !== L.names[j])], Lr.names[Lr.names.findIndex((x, j) => x !== L.names[j])]).select(...L.names) };
|
|
630
|
+
const typeOf = (nm) => { const k = names.indexOf(nm); return merge([L.typeOf?.(nm) ?? 'float', R.typeOf?.(R.names[k]) ?? 'float']); };
|
|
631
|
+
if (body.op === 'union all') return { query: Query.union([L.query, right]), names, kinds: kindsOut, typeOf };
|
|
632
|
+
if (body.op === 'union') return { query: Query.union([L.query, right]).groupBy(names).agg({ __u: 'count()' }).select(...names), names, kinds: kindsOut, typeOf };
|
|
633
|
+
// INTERSECT / EXCEPT: both sides marked, grouped by every column
|
|
634
|
+
const mark = (rel, m) => rel.query.derive('__src', String(m)).select(...rel.names, '__src');
|
|
635
|
+
const grouped = Query.union([mark(L, 1), mark(R, 2)]).groupBy(names).agg({ __mn: 'min(__src)', __mx: 'max(__src)' });
|
|
636
|
+
const kept = body.op === 'intersect' ? grouped.filter('__mn == 1 && __mx == 2') : grouped.filter('__mx == 1');
|
|
637
|
+
return { query: kept.select(...names), names, kinds: kindsOut, typeOf };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// FROM item -> relation
|
|
641
|
+
function relationOf(item, env, outer) {
|
|
642
|
+
if (item.q) return compileQuery(item.q, env, outer);
|
|
643
|
+
const cte = item.schema ? null : env.ctes.get(item.name.toLowerCase());
|
|
644
|
+
if (cte) return cte;
|
|
645
|
+
const t = lookupTable(env, item);
|
|
646
|
+
if (!t) fail(`no table "${item.schema ? `${item.schema}.` : ''}${item.name}" (tables: ${userTables(env.tables).join(', ') || 'none given'})`);
|
|
647
|
+
return tableRelation(t);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// The names a SELECT's FROM can see, without compiling it: [{ alias, names }]
|
|
651
|
+
function scopeNamesOf(sel, env) {
|
|
652
|
+
if (!sel.from) return [];
|
|
653
|
+
const one = (item) => {
|
|
654
|
+
if (item.q) {
|
|
655
|
+
const body = item.q.body;
|
|
656
|
+
if (body.k === 'select' && body.items.every((it) => it.alias || it.e.k === 'col')) return { alias: item.alias, names: body.items.map((it) => it.alias ?? it.e.name) };
|
|
657
|
+
return { alias: item.alias, names: compileQuery(item.q, env, null).names };
|
|
658
|
+
}
|
|
659
|
+
const cte = item.schema ? null : env.ctes.get(item.name.toLowerCase());
|
|
660
|
+
const t = cte ?? lookupTable(env, item);
|
|
661
|
+
return { alias: item.alias, names: cte ? cte.names : t?.columnNames ?? [] };
|
|
662
|
+
};
|
|
663
|
+
return [one(sel.from), ...sel.joins.map(one)];
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function compileSelect(ast, env, outer, tail) {
|
|
667
|
+
const n = env.n;
|
|
668
|
+
// FROM and JOINs: the query's columns, and how each relation's columns are named in it
|
|
669
|
+
let first;
|
|
670
|
+
if (ast.from) first = relationOf(ast.from, env, outer);
|
|
671
|
+
else { // SELECT without FROM: one row
|
|
672
|
+
if (!env.engine) fail('SELECT without FROM needs at least one table given (for its engine)');
|
|
673
|
+
first = tableRelation(env.engine.table({ __one: [0] }));
|
|
674
|
+
}
|
|
675
|
+
let q = first.query;
|
|
676
|
+
const scope = [{ alias: ast.from?.alias ?? '', rel: first, names: new Map(first.names.map((x) => [x, x])) }];
|
|
677
|
+
const outNames = [...first.names];
|
|
678
|
+
const kinds = new Map(first.names.map((x) => [x, first.kinds.get(x)]));
|
|
679
|
+
const types = new Map(first.names.map((x) => [x, () => first.typeOf?.(x) ?? 'float'])); // (for opts.types: worked out when asked)
|
|
680
|
+
|
|
681
|
+
// a column reference -> its output name (or, if outer resolves it, an outer marker)
|
|
682
|
+
const find = (ref) => {
|
|
683
|
+
if (!PLAIN.test(ref.name)) { // a quoted name with spaces or signs: as an output kept it, or as CSV loading wrote it
|
|
684
|
+
const enc = { ...ref, name: encodeName(ref.name) }, und = { ...ref, name: ref.name.replace(/[^A-Za-z0-9_]/g, '_') };
|
|
685
|
+
return find1(enc) ?? find1(und) ?? find1(ref);
|
|
686
|
+
}
|
|
687
|
+
return find1(ref);
|
|
688
|
+
};
|
|
689
|
+
const find1 = (ref) => {
|
|
690
|
+
if (!ref.table && usingMap.has(ref.name.toLowerCase())) return usingMap.get(ref.name.toLowerCase());
|
|
691
|
+
if (ref.table) {
|
|
692
|
+
const s = scope.find((x) => x.alias.toLowerCase() === ref.table.toLowerCase());
|
|
693
|
+
if (!s) return null;
|
|
694
|
+
const out = s.names.get(ref.name) ?? [...s.names].find(([x]) => x.toLowerCase() === ref.name.toLowerCase())?.[1];
|
|
695
|
+
if (!out) fail(`table "${ref.table}" has no column "${ref.name}"`);
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
const hits = scope.filter((s) => s.names.has(ref.name));
|
|
699
|
+
if (!hits.length) {
|
|
700
|
+
const ci = [...new Set(scope.flatMap((s) => [...s.names].filter(([x]) => x.toLowerCase() === ref.name.toLowerCase()).map(([, o]) => o)))];
|
|
701
|
+
if (ci.length === 1) return ci[0];
|
|
702
|
+
if (ci.length > 1) fail(`column "${ref.name}" is ambiguous`);
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
const outs = new Set(hits.map((s) => s.names.get(ref.name)));
|
|
706
|
+
if (outs.size > 1) fail(`column "${ref.name}" is ambiguous: write ${hits.map((s) => `${s.alias}.${ref.name}`).join(' or ')}`);
|
|
707
|
+
return [...outs][0];
|
|
708
|
+
};
|
|
709
|
+
const resolve = (ref) => {
|
|
710
|
+
const out = find(ref);
|
|
711
|
+
if (out !== null) return out;
|
|
712
|
+
if (ref.table && !scope.some((s) => s.alias.toLowerCase() === ref.table.toLowerCase())) {
|
|
713
|
+
if (outer?.canSee(ref)) fail(`"${ref.table}.${ref.name}" belongs to the outer query: a subquery can use outer columns only in WHERE equalities (WHERE inner.x = outer.y)`);
|
|
714
|
+
fail(`no table or alias "${ref.table}"`);
|
|
715
|
+
}
|
|
716
|
+
if (outer?.canSee(ref)) fail(`"${ref.name}" belongs to the outer query: a subquery can use outer columns only in WHERE equalities (WHERE inner.x = outer.y)`);
|
|
717
|
+
fail(`no column "${ref.name}"`);
|
|
718
|
+
};
|
|
719
|
+
const canSee = (ref) => { try { return find(ref) !== null; } catch { return true; } };
|
|
720
|
+
|
|
721
|
+
// hidden columns: a name for an expression's AST (a column as it is)
|
|
722
|
+
const hide = (t, prefix = '__h') => {
|
|
723
|
+
if (t.t === 'var') return t.name;
|
|
724
|
+
const name = `${prefix}${n.hidden++}`;
|
|
725
|
+
q = q.derive(name, src(t));
|
|
726
|
+
return name;
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// USING (k) columns: an unqualified k reads the left's (LEFT), the right's (RIGHT) or either (FULL)
|
|
730
|
+
const usingMap = new Map();
|
|
731
|
+
// (the joins run after the translator below is set up: function declarations)
|
|
732
|
+
function addJoin(j) {
|
|
733
|
+
let right = relationOf(j, env, outer);
|
|
734
|
+
if (j.on && (j.how === 'left' || j.how === 'right' || j.how === 'full')) { // ON conditions on the right table alone: it is filtered first
|
|
735
|
+
const own = (c) => !has(c, (x) => x.k === 'subq' || x.k === 'exists' || x.k === 'insub') && colRefs(c).length > 0 && colRefs(c).every((ref) => (ref.table ? ref.table.toLowerCase() === j.alias.toLowerCase() : right.names.includes(ref.name) && !scope.some((s) => s.names.has(ref.name))));
|
|
736
|
+
const isKey = (c) => c.k === 'bin' && c.op === '=' && c.l.k === 'col' && c.r.k === 'col' && !(own(c));
|
|
737
|
+
const mine = conjuncts(j.on).filter((c) => !isKey(c) && own(c));
|
|
738
|
+
if (mine.length && j.how === 'left') {
|
|
739
|
+
const inner = { k: 'query', ctes: [], body: { k: 'select', distinct: false, items: [{ e: { k: 'star' }, alias: null }], from: { ...j, alias: j.alias }, joins: [], where: andAll(mine), groupBy: [], having: null }, orderBy: [], limit: null, offset: 0 };
|
|
740
|
+
j = { ...j, q: inner, on: andAll(conjuncts(j.on).filter((c) => !mine.includes(c))) };
|
|
741
|
+
right = relationOf(j, env, outer);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
const rs = { alias: j.alias, rel: right, names: new Map() };
|
|
745
|
+
const rightHas = (name) => right.names.includes(name);
|
|
746
|
+
const outerJoin = j.how === 'left' || j.how === 'right' || j.how === 'full';
|
|
747
|
+
let on;
|
|
748
|
+
const extra = []; // ON conditions that aren't key equalities (inner joins only)
|
|
749
|
+
let rq = right.query;
|
|
750
|
+
if (j.how === 'cross') { // every pair: both sides on one constant key
|
|
751
|
+
const k = `__x${n.hidden++}`;
|
|
752
|
+
q = q.derive(k, '0');
|
|
753
|
+
rq = rq.derive(k, '0');
|
|
754
|
+
on = { left: [k], right: [k] };
|
|
755
|
+
} else if (j.using) {
|
|
756
|
+
for (const c of j.using) if (!rightHas(c)) fail(`table "${j.name ?? j.alias}" has no column "${c}" (USING)`);
|
|
757
|
+
on = { left: j.using.map((c) => resolve({ name: c })), right: j.using };
|
|
758
|
+
} else {
|
|
759
|
+
const lefts = [], rights = [];
|
|
760
|
+
const conds = [];
|
|
761
|
+
for (const c of conjuncts(j.on)) {
|
|
762
|
+
const side = (ref) => (ref.k !== 'col' ? null : (ref.table ? ref.table.toLowerCase() === j.alias.toLowerCase() : rightHas(ref.name) && !scope.some((s) => s.names.has(ref.name))) ? 'right' : 'left');
|
|
763
|
+
if (c.k === 'bin' && c.op === '=' && side(c.l) && side(c.r) && side(c.l) !== side(c.r)) {
|
|
764
|
+
const [l, r] = side(c.l) === 'left' ? [c.l, c.r] : [c.r, c.l];
|
|
765
|
+
if (!rightHas(r.name)) fail(`table "${j.name ?? j.alias}" has no column "${r.name}"`);
|
|
766
|
+
lefts.push(resolve(l)); rights.push(r.name);
|
|
767
|
+
} else conds.push(c);
|
|
768
|
+
}
|
|
769
|
+
if (!lefts.length) fail(`JOIN ${j.name ?? j.alias} needs ON with an equality between the two sides, e.g. ON a.id = b.id`);
|
|
770
|
+
if (conds.length && outerJoin) fail(`${j.how.toUpperCase()} JOIN ... ON takes key equalities only (move other conditions to WHERE)`);
|
|
771
|
+
on = { left: lefts, right: rights };
|
|
772
|
+
extra.push(...conds);
|
|
773
|
+
}
|
|
774
|
+
// outer joins: a right key named like its left key gets a name of its own,
|
|
775
|
+
// so it reads as missing where no right row matched (an inner join keeps one)
|
|
776
|
+
const original = new Map(); // right column in rq -> its name in the right relation
|
|
777
|
+
let rightCols = right.names;
|
|
778
|
+
if (outerJoin) {
|
|
779
|
+
const renamed = new Map();
|
|
780
|
+
on = { left: on.left, right: on.right.map((r, k) => {
|
|
781
|
+
if (on.left[k] !== r) return r;
|
|
782
|
+
if (!renamed.has(r)) { const h = `__k${n.hidden++}`; rq = rq.derive(h, r); renamed.set(r, h); original.set(h, r); }
|
|
783
|
+
return renamed.get(r);
|
|
784
|
+
}) };
|
|
785
|
+
if (renamed.size) { rightCols = [...right.names.filter((x) => !renamed.has(x)), ...renamed.values()]; rq = rq.select(...rightCols); }
|
|
786
|
+
}
|
|
787
|
+
// output names, as the join makes them: right key columns named like the left key go away
|
|
788
|
+
const sameKey = new Set(on.right.filter((x, k) => on.left[k] === x));
|
|
789
|
+
const leftNames = [...outNames], rightOut = [];
|
|
790
|
+
for (const x of rightCols) {
|
|
791
|
+
if (sameKey.has(x)) { rs.names.set(x, x); continue; }
|
|
792
|
+
let out = x;
|
|
793
|
+
while (outNames.includes(out)) out += '_right';
|
|
794
|
+
outNames.push(out);
|
|
795
|
+
rightOut.push(out);
|
|
796
|
+
const orig = original.get(x) ?? x;
|
|
797
|
+
rs.names.set(orig, out);
|
|
798
|
+
kinds.set(out, right.kinds.get(orig));
|
|
799
|
+
types.set(out, () => right.typeOf?.(orig) ?? 'float');
|
|
800
|
+
}
|
|
801
|
+
const keys = on.left.every((x, k) => x === on.right[k]) ? on.left : on;
|
|
802
|
+
if (j.how === 'right' || j.how === 'full') q = outerJoinG(rq, on, j.how, leftNames, rightCols, rightOut);
|
|
803
|
+
else q = q.join(rq, { on: keys, how: j.how === 'cross' ? 'inner' : j.how });
|
|
804
|
+
scope.push(rs);
|
|
805
|
+
if (j.using && outerJoin) {
|
|
806
|
+
j.using.forEach((c, k) => {
|
|
807
|
+
const lo = on.left[k], ro = rs.names.get(c);
|
|
808
|
+
let out = j.how === 'left' ? lo : ro;
|
|
809
|
+
if (j.how === 'full') { // either side's key
|
|
810
|
+
out = `__u${n.hidden++}`;
|
|
811
|
+
q = kinds.get(lo) === 'text' ? q.textmap(out, { inputs: [lo, ro], fn: (a, b) => (a !== '' ? a : b), label: 'coalesce' }) : q.derive(out, `coalesce(${lo}, ${ro})`);
|
|
812
|
+
kinds.set(out, kinds.get(lo));
|
|
813
|
+
types.set(out, types.get(lo));
|
|
814
|
+
}
|
|
815
|
+
usingMap.set(c.toLowerCase(), out);
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
for (const c of extra) { const t = translate(c, plain); q = q.filter(src(t)); } // (translating may join: q after it)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// RIGHT / FULL JOIN: the matching pairs (FULL: and the left rows without a
|
|
822
|
+
// match: a LEFT join), then the right rows whose keys no left row has (the
|
|
823
|
+
// right side joined to the left's distinct keys), with the left's columns
|
|
824
|
+
// missing; both in the same column order, one after the other.
|
|
825
|
+
function outerJoinG(rq, on, how, leftNames, rightCols, rightOut) {
|
|
826
|
+
const tag = n.hidden++;
|
|
827
|
+
const leftQ = q.select(...leftNames);
|
|
828
|
+
const keysOn = on.left.every((x, k) => x === on.right[k]) ? on.left : on;
|
|
829
|
+
const order = [...leftNames, ...rightOut];
|
|
830
|
+
if (!findDataset(leftQ.node) && !findDataset(rq.node)) { // in memory: the join's own probe says which right rows matched
|
|
831
|
+
const state = {};
|
|
832
|
+
const joined = leftQ.join(rq, { on: keysOn, how: how === 'full' ? 'left' : 'inner', view: true, unmatched: state });
|
|
833
|
+
return Query.union([joined.select(...order), outerRest(Query.unmatched(state, rightCols), tag, leftNames, rightCols, rightOut)]);
|
|
834
|
+
}
|
|
835
|
+
const fresh = on.left.map((_, k) => `__a${tag}_${k}`), mark = `__am${tag}`;
|
|
836
|
+
let lkeys = leftQ;
|
|
837
|
+
on.left.forEach((x, k) => { lkeys = lkeys.derive(fresh[k], x); });
|
|
838
|
+
lkeys = lkeys.select(...fresh).groupBy(fresh).agg({ [`__ac${tag}`]: 'count()' }).derive(mark, '1').select(...fresh, mark);
|
|
839
|
+
const lone = rq.join(lkeys, { on: { left: on.right, right: fresh }, how: 'left', view: true }).filter(`!(${mark} == 1)`);
|
|
840
|
+
const joined = leftQ.join(rq, { on: keysOn, how: how === 'full' ? 'left' : 'inner', view: true }); // (unique right keys: right columns looked up)
|
|
841
|
+
return Query.union([joined.select(...order), outerRest(lone, tag, leftNames, rightCols, rightOut)]);
|
|
842
|
+
}
|
|
843
|
+
// the unmatched right rows (columns rightCols) with the left's columns missing, in the join's output order
|
|
844
|
+
function outerRest(lone, tag, leftNames, rightCols, rightOut) {
|
|
845
|
+
const tmp = rightCols.map((_, k) => `__p${tag}_${k}`);
|
|
846
|
+
rightCols.forEach((x, k) => { lone = lone.derive(tmp[k], x); });
|
|
847
|
+
lone = lone.select(...tmp);
|
|
848
|
+
for (const L of leftNames) lone = kinds.get(L) === 'text' ? lone.textmap(L, { fn: () => '', label: 'null' }) : lone.derive(L, 'NaN');
|
|
849
|
+
rightOut.forEach((o, k) => { lone = lone.derive(o, tmp[k]); });
|
|
850
|
+
return lone.select(...leftNames, ...rightOut);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ---- subqueries in expressions: joins that add a column ----------------------------------
|
|
854
|
+
// The subquery's WHERE equalities with outer columns are its correlation
|
|
855
|
+
// keys (taken out of it); anything else from outside is an error.
|
|
856
|
+
const outerHere = { canSee: (ref) => canSee(ref) || !!outer?.canSee(ref) };
|
|
857
|
+
function correlate(sub) {
|
|
858
|
+
const body = sub.body;
|
|
859
|
+
if (body.k !== 'select' || sub.ctes.length) return { inner: sub, pairs: [] }; // (only a plain SELECT can be correlated)
|
|
860
|
+
const innerScope = scopeNamesOf(body, env);
|
|
861
|
+
const isInner = (ref) => (ref.table ? innerScope.some((s) => s.alias.toLowerCase() === ref.table.toLowerCase()) : innerScope.some((s) => s.names.some((x) => x.toLowerCase() === ref.name.toLowerCase())));
|
|
862
|
+
const isOuter = (ref) => !isInner(ref) && canSee(ref);
|
|
863
|
+
const pairs = [], keep = [];
|
|
864
|
+
for (const c of conjuncts(body.where)) {
|
|
865
|
+
if (c.k === 'bin' && c.op === '=' && c.l.k === 'col' && c.r.k === 'col') {
|
|
866
|
+
const lo = isOuter(c.l), ro = isOuter(c.r);
|
|
867
|
+
if (lo !== ro) { pairs.push(lo ? { outer: c.l, inner: c.r } : { outer: c.r, inner: c.l }); continue; }
|
|
868
|
+
}
|
|
869
|
+
keep.push(c);
|
|
870
|
+
}
|
|
871
|
+
return { inner: { ...sub, body: { ...body, where: andAll(keep) } }, pairs };
|
|
872
|
+
}
|
|
873
|
+
// join rel (its columns `cols`) on outer key ASTs = rel key columns; the
|
|
874
|
+
// rel's columns get fresh names; returns them
|
|
875
|
+
function attach(rel, outerKeys, relKeys, cols) {
|
|
876
|
+
const tag = n.hidden++;
|
|
877
|
+
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)
|
|
886
|
+
return cols.map(fresh);
|
|
887
|
+
}
|
|
888
|
+
function subqueryValue(e, ctx) {
|
|
889
|
+
if (e.k === 'exists') {
|
|
890
|
+
const { inner, pairs } = correlate(e.q);
|
|
891
|
+
const body = inner.body;
|
|
892
|
+
const keyItems = pairs.map((pr, i) => ({ e: pr.inner, alias: `__k${i}` }));
|
|
893
|
+
const sel = { ...body, distinct: true, items: [...keyItems, { e: { k: 'num', v: 1 }, alias: '__m' }], groupBy: [] };
|
|
894
|
+
if (body.groupBy?.length || body.having) fail('EXISTS (SELECT ... GROUP BY ...) is not supported');
|
|
895
|
+
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']);
|
|
897
|
+
return bin('==', v(m), num(1));
|
|
898
|
+
}
|
|
899
|
+
if (e.k === 'insub') {
|
|
900
|
+
const { inner, pairs } = correlate(e.q);
|
|
901
|
+
const body = inner.body;
|
|
902
|
+
if (body.k !== 'select' || body.items.length !== 1 || body.items[0].e.k === 'star') {
|
|
903
|
+
if (!pairs.length) { // a UNION or such: its one column
|
|
904
|
+
const rel = compileQuery(inner, env, outerHere);
|
|
905
|
+
if (rel.names.length !== 1) fail('IN (SELECT ...) needs exactly one column');
|
|
906
|
+
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']);
|
|
908
|
+
return bin('==', v(m), num(1));
|
|
909
|
+
}
|
|
910
|
+
fail('IN (SELECT ...) needs exactly one column');
|
|
911
|
+
}
|
|
912
|
+
const keyItems = pairs.map((pr, i) => ({ e: pr.inner, alias: `__k${i}` }));
|
|
913
|
+
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
|
+
let rel = compileQuery({ ...inner, body: sel, orderBy: [], limit: null, offset: 0 }, env, outerHere);
|
|
915
|
+
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']);
|
|
917
|
+
return bin('==', v(m), num(1));
|
|
918
|
+
}
|
|
919
|
+
// (SELECT one value)
|
|
920
|
+
const { inner, pairs } = correlate(e.q);
|
|
921
|
+
const body = inner.body;
|
|
922
|
+
if (body.k === 'select' && body.items.length !== 1) fail('a subquery used as a value needs exactly one column');
|
|
923
|
+
if (!pairs.length) {
|
|
924
|
+
const rel = compileQuery(inner, env, outerHere);
|
|
925
|
+
if (rel.names.length !== 1) fail('a subquery used as a value needs exactly one column');
|
|
926
|
+
const [c] = attach(rel, [], [], [rel.names[0]]);
|
|
927
|
+
return v(c);
|
|
928
|
+
}
|
|
929
|
+
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
|
+
const keyItems = pairs.map((pr, i) => ({ e: pr.inner, alias: `__k${i}` }));
|
|
931
|
+
const sel = { ...body, items: [...keyItems, { e: body.items[0].e, alias: '__v' }], groupBy: keyItems.map((it) => it.e) };
|
|
932
|
+
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']);
|
|
934
|
+
const it = body.items[0].e;
|
|
935
|
+
return it.k === 'call' && it.fn === 'COUNT' ? call('coalesce', v(c), num(0)) : v(c); // (no rows: COUNT is 0)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ---- translating expressions; `ctx.col(ref)` turns a column into an AST -----------------
|
|
939
|
+
// (ctx.step: a new column the expression needs, made now, or after grouping for post)
|
|
940
|
+
const plain = { col: (ref) => v(resolve(ref)), agg: () => fail('aggregates (SUM, COUNT...) belong in SELECT, HAVING or ORDER BY, not here'), window: () => fail('window functions (... OVER (...)) belong in SELECT or ORDER BY'), step: (st) => st.run() };
|
|
941
|
+
const mostKind = (ks) => (ks.includes('text') ? 'text' : ks.includes('date') ? 'date' : 'number');
|
|
942
|
+
function kindOf(e, ctx) {
|
|
943
|
+
switch (e.k) {
|
|
944
|
+
case 'col': { const sv = sessionRef(e); if (sv) return sv.kind; return ctx.kindOfCol?.(e) ?? kinds.get(e.resolved ? e.name : resolve(e)); }
|
|
945
|
+
case 'date': return 'date';
|
|
946
|
+
case 'str': return 'text';
|
|
947
|
+
case 'window': return ctx.windowKind?.(e) ?? 'number';
|
|
948
|
+
case 'cast': return TEXT_TYPES.has(e.type) ? 'text' : DATE_TYPES.has(e.type) ? 'date' : 'number';
|
|
949
|
+
case 'case': return mostKind([...e.whens.map((w) => kindOf(w.v, ctx)), kindOf(e.otherwise, ctx)]);
|
|
950
|
+
case 'call':
|
|
951
|
+
if (TEXT_FUNCTIONS[e.fn] && !isAgg(e)) return TEXT_FUNCTIONS[e.fn].out === 'text' ? 'text' : 'number';
|
|
952
|
+
if (['COALESCE', 'IFNULL', 'NVL', 'NULLIF'].includes(e.fn)) return mostKind(e.args.map((a) => kindOf(a, ctx)));
|
|
953
|
+
if (e.fn === 'DATE_TRUNC') return 'date';
|
|
954
|
+
if (sessionFn(e)) return sessionFn(e).kind;
|
|
955
|
+
if (extraFn(e)) return extraFn(e).out === 'text' ? 'text' : 'number';
|
|
956
|
+
return 'number';
|
|
957
|
+
default: return 'number';
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
// version(), current_user...: { value, kind } or null
|
|
961
|
+
function sessionFn(e) {
|
|
962
|
+
const k = e.fn?.toLowerCase();
|
|
963
|
+
if (!env.session || !k || !Object.hasOwn(env.session, k) || isAgg(e)) return null;
|
|
964
|
+
const value = env.session[k];
|
|
965
|
+
return { value, kind: typeof value === 'string' ? 'text' : 'number' };
|
|
966
|
+
}
|
|
967
|
+
function extraFn(e) { const k = e.fn?.toLowerCase().replace(/^pg_catalog\./, ''); return env.functions && k && Object.hasOwn(env.functions, k) ? env.functions[k] : null; }
|
|
968
|
+
function sessionRef(e) {
|
|
969
|
+
if (e.table || !env.session || !SESSION_NAMES.has(e.name.toLowerCase()) || !Object.hasOwn(env.session, e.name.toLowerCase())) return null;
|
|
970
|
+
let out = null;
|
|
971
|
+
try { out = find(e); } catch { return null; }
|
|
972
|
+
return out === null ? sessionFn({ fn: e.name, args: [] }) : null;
|
|
973
|
+
}
|
|
974
|
+
const constValue = (sv, ctx) => (sv.kind === 'text' ? valueColumn(ctx, [], () => sv.value, { label: 'session' }) : num(typeof sv.value === 'boolean' ? +sv.value : sv.value));
|
|
975
|
+
|
|
976
|
+
// ---- the type of each answer column (opts.types) -----------------------------------
|
|
977
|
+
function typeOfExpr(e, ctx) {
|
|
978
|
+
const T = (x) => typeOfExpr(x, ctx);
|
|
979
|
+
const kind = kindOf(e, ctx);
|
|
980
|
+
switch (e.k) {
|
|
981
|
+
case 'col': {
|
|
982
|
+
const sv = sessionRef(e);
|
|
983
|
+
if (sv) return sv.kind === 'text' ? 'text' : 'int';
|
|
984
|
+
let out = null;
|
|
985
|
+
try { out = e.resolved ? e.name : find(e); } catch { out = null; }
|
|
986
|
+
const t = out !== null ? types.get(out) : null;
|
|
987
|
+
return t ? t() : kind === 'text' ? 'text' : kind === 'date' ? 'date' : 'float';
|
|
988
|
+
}
|
|
989
|
+
case 'str': return 'text';
|
|
990
|
+
case 'date': return e.v.trim().length <= 10 ? 'day' : 'date';
|
|
991
|
+
case 'num': return e.bool ? 'bool' : Number.isInteger(e.v) ? 'int' : 'float';
|
|
992
|
+
case 'null': return 'text';
|
|
993
|
+
case 'neg': return T(e.x) === 'int' ? 'int' : 'float';
|
|
994
|
+
case 'not': case 'isnull': case 'like': case 'in': case 'between': case 'exists': case 'insub': return 'bool';
|
|
995
|
+
case 'bin':
|
|
996
|
+
if (['AND', 'OR', 'IS', '=', '<>', '!=', '<', '<=', '>', '>=', '~', '~*', '!~', '!~*'].includes(e.op)) return 'bool';
|
|
997
|
+
if (e.op === '/') return 'float';
|
|
998
|
+
return merge([T(e.l), T(e.r)]) === 'int' ? 'int' : 'float';
|
|
999
|
+
case 'case': return merge([...e.whens.map((w) => T(w.v)), e.otherwise.k === 'null' ? null : T(e.otherwise)]);
|
|
1000
|
+
case 'cast': return TEXT_TYPES.has(e.type) ? 'text' : INT_TYPES.has(e.type) ? 'int' : BOOL_TYPES.has(e.type) ? 'bool' : e.type === 'DATE' ? 'day' : DATE_TYPES.has(e.type) ? 'date' : 'float';
|
|
1001
|
+
case 'subq': return kind === 'text' ? 'text' : 'float';
|
|
1002
|
+
case 'window': {
|
|
1003
|
+
const fn = WINDOW_FNS[e.call.fn], arg = e.call.args[0];
|
|
1004
|
+
if (['row_number', 'rank', 'dense_rank', 'ntile', 'count'].includes(fn)) return 'int';
|
|
1005
|
+
if (['percent_rank', 'cume_dist', 'avg'].includes(fn)) return 'float';
|
|
1006
|
+
if (fn === 'sum') return arg && T(arg) === 'int' ? 'int' : 'float';
|
|
1007
|
+
return arg ? T(arg) : 'float';
|
|
1008
|
+
}
|
|
1009
|
+
case 'call': {
|
|
1010
|
+
const arg = e.args[0];
|
|
1011
|
+
if (isAgg(e)) {
|
|
1012
|
+
if (e.fn === 'COUNT' || e.fn === 'APPROX_COUNT_DISTINCT') return 'int';
|
|
1013
|
+
if (e.fn === 'SUM') return arg && merge([T(arg)]) === 'int' || (arg && T(arg) === 'bool') ? 'int' : 'float';
|
|
1014
|
+
if (e.fn === 'MIN' || e.fn === 'MAX') return arg ? T(arg) : 'float';
|
|
1015
|
+
if (e.fn === 'PERCENTILE_DISC' || e.fn === 'QUANTILE_DISC') return T(e.within?.e ?? arg);
|
|
1016
|
+
return kind === 'date' ? 'date' : 'float';
|
|
1017
|
+
}
|
|
1018
|
+
const sv = sessionFn(e);
|
|
1019
|
+
if (sv) return sv.kind === 'text' ? 'text' : typeof sv.value === 'boolean' ? 'bool' : Number.isInteger(sv.value) ? 'int' : 'float';
|
|
1020
|
+
if (extraFn(e)) return extraFn(e).out === 'text' ? 'text' : extraFn(e).out === 'bool' ? 'bool' : 'int';
|
|
1021
|
+
if (TEXT_FUNCTIONS[e.fn]) return TEXT_FUNCTIONS[e.fn].out === 'text' ? 'text' : ['STARTS_WITH', 'ENDS_WITH', 'CONTAINS', 'PREFIX', 'SUFFIX', 'REGEXP_MATCHES', 'REGEXP_LIKE'].includes(e.fn) ? 'bool' : 'int';
|
|
1022
|
+
if (DATE_PART[e.fn] || ['DOW', 'DAYOFWEEK', 'WEEKDAY', 'ISODOW', 'FLOOR', 'CEIL', 'CEILING'].includes(e.fn)) return 'int';
|
|
1023
|
+
if (e.fn === 'ROUND') return e.args.length > 1 && !(e.args[1].k === 'num' && e.args[1].v <= 0) ? 'float' : 'int';
|
|
1024
|
+
if (e.fn === 'ABS') return arg ? (T(arg) === 'int' ? 'int' : 'float') : 'float';
|
|
1025
|
+
if (['LEAST', 'GREATEST', 'COALESCE', 'IFNULL', 'NVL'].includes(e.fn)) return merge(e.args.map(T));
|
|
1026
|
+
if (e.fn === 'NULLIF') return arg ? T(arg) : 'float';
|
|
1027
|
+
if (e.fn === 'DATE_TRUNC') return ['day', 'week', 'month', 'year', 'quarter'].includes(String(e.args[0]?.v).toLowerCase()) ? 'day' : 'date';
|
|
1028
|
+
if (e.fn === 'NOW' || e.fn === 'CURRENT_TIMESTAMP') return 'date';
|
|
1029
|
+
return kind === 'text' ? 'text' : kind === 'date' ? 'date' : 'float';
|
|
1030
|
+
}
|
|
1031
|
+
default: return kind === 'text' ? 'text' : kind === 'date' ? 'date' : 'float';
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ---- text: columns computed once per distinct value (Table._textmap) ------------------
|
|
1036
|
+
// a column for an AST (an expression becomes a hidden derived column)
|
|
1037
|
+
function materialize(t, ctx) {
|
|
1038
|
+
if (t.t === 'var') return t.name;
|
|
1039
|
+
const name = `__h${n.hidden++}`;
|
|
1040
|
+
ctx.step({ name, inputs: astVars(t), run: () => { q = q.derive(name, src(t)); } });
|
|
1041
|
+
return name;
|
|
1042
|
+
}
|
|
1043
|
+
// an argument: a constant { value, kind } or a column { col, kind }
|
|
1044
|
+
function argOf(e, ctx) {
|
|
1045
|
+
if (e.k === 'str') return { value: e.v, kind: 'text' };
|
|
1046
|
+
if (e.k === 'num') return { value: e.v, kind: 'number' };
|
|
1047
|
+
if (e.k === 'null') return { value: null, kind: 'number' };
|
|
1048
|
+
if (e.k === 'neg' && e.x.k === 'num') return { value: -e.x.v, kind: 'number' };
|
|
1049
|
+
if (e.k === 'date') return { value: textToDate(e.v), kind: 'date' };
|
|
1050
|
+
const kind = kindOf(e, ctx) ?? 'number', t = translate(e, ctx);
|
|
1051
|
+
if (t.t === 'num') return { value: t.v, kind: 'number' };
|
|
1052
|
+
if (t.t === 'str') return { value: t.v, kind: 'text' };
|
|
1053
|
+
return { col: materialize(t, ctx), kind };
|
|
1054
|
+
}
|
|
1055
|
+
// a new column: f(values, kinds) of the arguments' values (null where missing,
|
|
1056
|
+
// and then the answer is missing unless skip)
|
|
1057
|
+
function valueColumn(ctx, args, f, { text = true, skip = false, date = false, label = 'fn' } = {}) {
|
|
1058
|
+
const inputs = [...new Set(args.filter((a) => a.col).map((a) => a.col))];
|
|
1059
|
+
const at = args.map((a) => (a.col ? inputs.indexOf(a.col) : -1)), ks = args.map((a) => a.kind);
|
|
1060
|
+
const fn = (...vals) => {
|
|
1061
|
+
const xs = args.map((a, i) => (at[i] >= 0 ? (isNull(vals[at[i]], a.kind) ? null : vals[at[i]]) : a.value));
|
|
1062
|
+
if (!skip && xs.includes(null)) return text ? '' : NaN;
|
|
1063
|
+
return f(xs, ks);
|
|
1064
|
+
};
|
|
1065
|
+
const name = `__t${n.hidden++}`;
|
|
1066
|
+
ctx.step({ name, inputs, run: () => { q = q.textmap(name, { inputs, fn, text, date, label }); } });
|
|
1067
|
+
return v(name);
|
|
1068
|
+
}
|
|
1069
|
+
const asType = (x, kind, type) => (x === null ? null : type === 's' ? toText(x, kind) : kind === 'text' ? (type === 't' ? textToDate(x) : textToNumber(x)) : x);
|
|
1070
|
+
function textCall(e, ctx) {
|
|
1071
|
+
const spec = TEXT_FUNCTIONS[e.fn], min = spec.min ?? spec.args.length;
|
|
1072
|
+
const LABEL_TESTS = { STARTS_WITH: 'startsWith', PREFIX: 'startsWith', ENDS_WITH: 'endsWith', SUFFIX: 'endsWith', CONTAINS: 'contains' };
|
|
1073
|
+
if (LABEL_TESTS[e.fn] && e.args.length === 2 && e.args[0].k === 'col' && e.args[1].k === 'str' && kindOf(e.args[0], ctx) === 'text')
|
|
1074
|
+
{ const x = translate(e.args[0], ctx); return cond(bin('==', x, { t: 'str', v: '' }), num(NaN), cond(call(LABEL_TESTS[e.fn], x, { t: 'str', v: e.args[1].v }), num(1), num(0))); } // (a column: label by label, in the fused pass; missing stays missing)
|
|
1075
|
+
if (e.fn === 'STRFTIME' && e.args.length === 2 && e.args[1].k === 'str') { // times rounded to what the format shows: fewer distinct values to format
|
|
1076
|
+
const f = e.args[1].v.replace(/%%/g, '');
|
|
1077
|
+
const unit = /%[MSfTcXsz]/.test(f) ? null : /%[HIpk]/.test(f) ? 'hourStart' : /%[dejaAwuUWVGgFDx]/.test(f) ? 'dayStart' : /%[mbBh]/.test(f) ? 'monthStart' : /%[Yy]/.test(f) ? 'yearStart' : null;
|
|
1078
|
+
if (unit && kindOf(e.args[0], ctx) !== 'text') {
|
|
1079
|
+
const t = call(unit, translate(e.args[0], ctx));
|
|
1080
|
+
return valueColumn(ctx, [{ col: materialize(t, ctx), kind: 'date' }, { value: e.args[1].v, kind: 'text' }], ([x, fmt]) => spec.f(x, fmt), { label: 'strftime' });
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (e.args.length < min || (!spec.rest && e.args.length > spec.args.length)) fail(`${e.fn}() takes ${min === spec.args.length && !spec.rest ? min : `${min}${spec.rest ? ' or more' : ` to ${spec.args.length}`}`} value(s)`);
|
|
1084
|
+
const args = e.args.map((a) => argOf(a, ctx)), type = (i) => spec.args[i] ?? spec.rest;
|
|
1085
|
+
return valueColumn(ctx, args, (xs, ks) => spec.f(...xs.map((x, i) => asType(x, ks[i], type(i)))), { text: spec.out === 'text', skip: spec.skip, label: e.fn.toLowerCase() });
|
|
1086
|
+
}
|
|
1087
|
+
// comparing text: as text; text with a number or a date: as numbers or dates
|
|
1088
|
+
function compareValues(op, e, ctx) {
|
|
1089
|
+
const args = [argOf(e.l, ctx), argOf(e.r, ctx)];
|
|
1090
|
+
const asOf = args.some((a) => a.kind === 'date') ? 'date' : args.some((a) => a.kind === 'number' && a.value !== null) ? 'number' : 'text';
|
|
1091
|
+
const conv = (x, k) => (asOf === 'text' ? toText(x, k) : k !== 'text' ? x : asOf === 'date' ? textToDate(x) : textToNumber(x));
|
|
1092
|
+
const test = { '==': (c) => c === 0, '!=': (c) => c !== 0, '<': (c) => c < 0, '<=': (c) => c <= 0, '>': (c) => c > 0, '>=': (c) => c >= 0 }[op];
|
|
1093
|
+
return valueColumn(ctx, args, ([a, b], [ka, kb]) => {
|
|
1094
|
+
const x = conv(a, ka), y = conv(b, kb);
|
|
1095
|
+
if (x !== x || y !== y) return NaN;
|
|
1096
|
+
return test(x < y ? -1 : x > y ? 1 : 0) ? 1 : 0;
|
|
1097
|
+
}, { text: false, label: op });
|
|
1098
|
+
}
|
|
1099
|
+
// true where a value is there (SQL's NULL is NaN for numbers, '' for text); null: always there (a literal)
|
|
1100
|
+
function present(e, ctx) {
|
|
1101
|
+
if (e.k === 'num' || e.k === 'str' || e.k === 'date' || e.k === 'interval') return num(1);
|
|
1102
|
+
const x = translate(e, ctx);
|
|
1103
|
+
return kindOf(e, ctx) === 'text' ? bin('!=', x, { t: 'str', v: '' }) : bin('==', x, x);
|
|
1104
|
+
}
|
|
1105
|
+
// NOT x with SQL's three values: where x is unknown (a missing value), so is NOT x, and the
|
|
1106
|
+
// row is dropped. Comparisons become their opposites (false on missing values already).
|
|
1107
|
+
const FLIP = { '=': '<>', '<>': '=', '!=': '=', '<': '>=', '<=': '>', '>': '<=', '>=': '<' };
|
|
1108
|
+
function negated(x, ctx) {
|
|
1109
|
+
const T = (y) => translate(y, ctx);
|
|
1110
|
+
switch (x.k) {
|
|
1111
|
+
case 'not': return T(x.x);
|
|
1112
|
+
case 'bin':
|
|
1113
|
+
if (x.op === 'AND') return bin('||', negated(x.l, ctx), negated(x.r, ctx));
|
|
1114
|
+
if (x.op === 'OR') return bin('&&', negated(x.l, ctx), negated(x.r, ctx));
|
|
1115
|
+
if (FLIP[x.op]) return T({ ...x, op: FLIP[x.op] });
|
|
1116
|
+
return { t: 'un', op: '!', x: T(x) }; // (IS, regular expressions: true or false, never unknown)
|
|
1117
|
+
case 'between': return T({ k: 'bin', op: 'OR', l: { k: 'bin', op: '<', l: x.x, r: x.lo }, r: { k: 'bin', op: '>', l: x.x, r: x.hi } });
|
|
1118
|
+
case 'in':
|
|
1119
|
+
if (kindOf(x.x, ctx) === 'text') return bin('&&', { t: 'un', op: '!', x: T(x) }, present(x.x, ctx));
|
|
1120
|
+
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));
|
|
1122
|
+
case 'exists': case 'isnull': return { t: 'un', op: '!', x: T(x) };
|
|
1123
|
+
default: { // a true / false value (1 / 0): NOT of a missing one stays missing
|
|
1124
|
+
const v = T(x);
|
|
1125
|
+
return bin('==', v, num(0));
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function translate(e, ctx) {
|
|
1130
|
+
const T = (x) => translate(x, ctx);
|
|
1131
|
+
switch (e.k) {
|
|
1132
|
+
case 'num': return num(e.v);
|
|
1133
|
+
case 'null': return num(NaN);
|
|
1134
|
+
case 'str': return { t: 'str', v: e.v };
|
|
1135
|
+
case 'date': return call('date', { t: 'str', v: e.v.trim().replace(' ', 'T') });
|
|
1136
|
+
case 'interval': {
|
|
1137
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*([a-z]+?)s?\s*$/i.exec(e.unit ? `${e.v} ${e.unit}` : e.v);
|
|
1138
|
+
const unit = m && INTERVAL_MS[m[2].toLowerCase()];
|
|
1139
|
+
if (!unit) fail(`INTERVAL '${e.v}': use a number of milliseconds, seconds, minutes, hours, days or weeks (months and years vary in length)`);
|
|
1140
|
+
return num(Number(m[1]) * unit);
|
|
1141
|
+
}
|
|
1142
|
+
case 'col': { const sv = sessionRef(e); return sv ? constValue(sv, ctx) : ctx.col(e); }
|
|
1143
|
+
case 'star': fail('* only works as a whole SELECT item (or COUNT(*))');
|
|
1144
|
+
case 'neg': return { t: 'un', op: '-', x: T(e.x) };
|
|
1145
|
+
case 'not': return negated(e.x, ctx);
|
|
1146
|
+
case 'exists': case 'insub': case 'subq': return subqueryValue(e, ctx);
|
|
1147
|
+
case 'window': return ctx.window(e);
|
|
1148
|
+
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));
|
|
1151
|
+
const op = { '=': '==', '<>': '!=' }[e.op] ?? e.op;
|
|
1152
|
+
if (op === 'IS') { // a IS b: equal, or both missing
|
|
1153
|
+
const a = T(e.l), b = T(e.r);
|
|
1154
|
+
return bin('||', bin('==', a, b), bin('&&', call('isMissing', a), call('isMissing', b)));
|
|
1155
|
+
}
|
|
1156
|
+
if (op === '~' || op === '~*' || op === '!~' || op === '!~*') { // regular expressions
|
|
1157
|
+
const flags = op.endsWith('*') ? 'i' : '';
|
|
1158
|
+
const m = valueColumn(ctx, [argOf(e.l, ctx), argOf(e.r, ctx)], ([a, b], [ka, kb]) => (TEXT_FUNCTIONS.REGEXP_MATCHES.f(toText(a, ka), toText(b, kb), flags) ? 1 : 0), { text: false, label: op });
|
|
1159
|
+
return op.startsWith('!') ? bin('==', m, num(0)) : bin('==', m, num(1));
|
|
1160
|
+
}
|
|
1161
|
+
if (['==', '!=', '<', '<=', '>', '>='].includes(op)) {
|
|
1162
|
+
const lk = kindOf(e.l, ctx), rk = kindOf(e.r, ctx);
|
|
1163
|
+
if (lk === 'text' || rk === 'text') {
|
|
1164
|
+
if ((lk === 'date' && e.r.k === 'str') || (rk === 'date' && e.l.k === 'str')) // a date written as text
|
|
1165
|
+
return bin(op, e.l.k === 'str' ? T({ k: 'date', v: e.l.v }) : T(e.l), e.r.k === 'str' ? T({ k: 'date', v: e.r.v }) : T(e.r));
|
|
1166
|
+
const lit = e.l.k === 'str' ? e.r : e.r.k === 'str' ? e.l : null;
|
|
1167
|
+
if ((op === '==' || op === '!=') && lit && lit.k !== 'str' && kindOf(lit, ctx) === 'text') { // (a column and a label: by code)
|
|
1168
|
+
const c = bin(op, T(e.l), T(e.r));
|
|
1169
|
+
return op === '!=' ? bin('&&', c, present(lit, ctx)) : c;
|
|
1170
|
+
}
|
|
1171
|
+
return bin('==', compareValues(op, e, ctx), num(1));
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (op === '!=') return bin('&&', bin('!=', T(e.l), T(e.r)), bin('&&', present(e.l, ctx), present(e.r, ctx))); // (missing on either side: unknown, so not kept)
|
|
1175
|
+
if (op === '%') { // SQL: the sign of the dividend (truncate the quotient toward 0)
|
|
1176
|
+
const a = T(e.l), b = T(e.r), qt = bin('/', a, b);
|
|
1177
|
+
return bin('-', a, bin('*', cond(bin('>=', qt, num(0)), call('floor', qt), call('ceil', qt)), b));
|
|
1178
|
+
}
|
|
1179
|
+
return bin(op, T(e.l), T(e.r));
|
|
1180
|
+
}
|
|
1181
|
+
case 'between': {
|
|
1182
|
+
if (kindOf(e.x, ctx) === 'text') return T({ k: 'bin', op: 'AND', l: { k: 'bin', op: '>=', l: e.x, r: e.lo }, r: { k: 'bin', op: '<=', l: e.x, r: e.hi } });
|
|
1183
|
+
const x = T(e.x);
|
|
1184
|
+
return bin('&&', bin('>=', x, T(e.lo)), bin('<=', x, T(e.hi)));
|
|
1185
|
+
}
|
|
1186
|
+
case 'in': {
|
|
1187
|
+
const x = T(e.x);
|
|
1188
|
+
if (kindOf(e.x, ctx) === 'text') {
|
|
1189
|
+
if (e.list.some((i) => i.k !== 'str')) fail('IN on a text column takes text values in quotes');
|
|
1190
|
+
return call('in', x, ...e.list.map((i) => ({ t: 'str', v: i.v })));
|
|
1191
|
+
}
|
|
1192
|
+
return e.list.map((i) => bin('==', x, T(i))).reduce((a, b) => bin('||', a, b));
|
|
1193
|
+
}
|
|
1194
|
+
case 'like': {
|
|
1195
|
+
if (e.pattern.k !== 'str') fail(`${e.ci ? 'ILIKE' : 'LIKE'} needs a pattern in quotes`);
|
|
1196
|
+
if (kindOf(e.x, ctx) !== 'text') fail(`${e.ci ? 'ILIKE' : 'LIKE'} works on text`);
|
|
1197
|
+
const there = present(e.x, ctx); // (missing text matches no pattern: unknown)
|
|
1198
|
+
if (e.ci && e.x.k === 'col') return bin('&&', call('ilike', T(e.x), { t: 'str', v: e.pattern.v }), there); // (a column: label by label)
|
|
1199
|
+
if (e.ci) return bin('&&', T({ k: 'like', x: { k: 'call', fn: 'LOWER', args: [e.x] }, pattern: { k: 'str', v: e.pattern.v.toLowerCase() } }), there);
|
|
1200
|
+
return bin('&&', call('like', T(e.x), { t: 'str', v: e.pattern.v }), there);
|
|
1201
|
+
}
|
|
1202
|
+
case 'isnull': return kindOf(e.x, ctx) === 'text' ? bin('==', T(e.x), { t: 'str', v: '' }) : call('isMissing', T(e.x));
|
|
1203
|
+
case 'case': {
|
|
1204
|
+
if (kindOf(e, ctx) === 'text') { // which branch (a number), then that branch's value as text
|
|
1205
|
+
const branches = [...e.whens.map((w) => w.v), e.otherwise];
|
|
1206
|
+
if (branches.every((b) => b.k === 'str' || b.k === 'null')) { // labels in quotes only: each row's label code, on every thread
|
|
1207
|
+
const labels = [...new Set(branches.map((b) => (b.k === 'str' ? b.v : '')))], codeOf = (b) => num(labels.indexOf(b.k === 'str' ? b.v : ''));
|
|
1208
|
+
let code = codeOf(e.otherwise);
|
|
1209
|
+
for (let i = e.whens.length - 1; i >= 0; i--) code = cond(e.base ? T({ k: 'bin', op: '=', l: e.base, r: e.whens[i].c }) : T(e.whens[i].c), codeOf(e.whens[i].v), code);
|
|
1210
|
+
if (code.t !== 'num') {
|
|
1211
|
+
const col = materialize(code, ctx), name = `__t${n.hidden++}`;
|
|
1212
|
+
ctx.step({ name, inputs: [col], run: () => { q = q.textmap(name, { inputs: [col], labels, label: 'case' }); } });
|
|
1213
|
+
return v(name);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
let which = num(branches.length - 1);
|
|
1217
|
+
for (let i = e.whens.length - 1; i >= 0; i--) which = cond(e.base ? T({ k: 'bin', op: '=', l: e.base, r: e.whens[i].c }) : T(e.whens[i].c), num(i), which);
|
|
1218
|
+
const args = [which.t === 'num' ? { value: which.v, kind: 'number' } : { col: materialize(which, ctx), kind: 'number' }, ...branches.map((b) => argOf(b, ctx))];
|
|
1219
|
+
return valueColumn(ctx, args, (xs, ks) => { const x = xs[1 + xs[0]]; return x === null ? '' : toText(x, ks[1 + xs[0]]); }, { skip: true, label: 'case' });
|
|
1220
|
+
}
|
|
1221
|
+
let out = T(e.otherwise);
|
|
1222
|
+
for (let i = e.whens.length - 1; i >= 0; i--) {
|
|
1223
|
+
const c = e.base ? T({ k: 'bin', op: '=', l: e.base, r: e.whens[i].c }) : T(e.whens[i].c);
|
|
1224
|
+
out = cond(c, T(e.whens[i].v), out);
|
|
1225
|
+
}
|
|
1226
|
+
return out;
|
|
1227
|
+
}
|
|
1228
|
+
case 'cast': {
|
|
1229
|
+
const from = kindOf(e.x, ctx);
|
|
1230
|
+
if (TEXT_TYPES.has(e.type)) return from === 'text' ? T(e.x) : valueColumn(ctx, [argOf(e.x, ctx)], ([x], [k]) => toText(x, k), { label: 'text' });
|
|
1231
|
+
const asBool = (v) => (/^\s*(t|true|y|yes|on|1)\s*$/i.test(v) ? 1 : /^\s*(f|false|n|no|off|0)\s*$/i.test(v) ? 0 : NaN);
|
|
1232
|
+
if (e.x.k === 'str' && !DATE_TYPES.has(e.type)) {
|
|
1233
|
+
if (BOOL_TYPES.has(e.type)) return num(asBool(e.x.v));
|
|
1234
|
+
if (!INT_TYPES.has(e.type) && !FLOAT_TYPES.has(e.type)) fail(`CAST to ${e.type} is not supported`);
|
|
1235
|
+
const x = textToNumber(e.x.v);
|
|
1236
|
+
return num(INT_TYPES.has(e.type) ? Math.trunc(x) : x);
|
|
1237
|
+
}
|
|
1238
|
+
if (from === 'text' && e.x.k !== 'str') { // text to a number or a date (missing where it isn't one)
|
|
1239
|
+
const whole = INT_TYPES.has(e.type), date = DATE_TYPES.has(e.type), bool = BOOL_TYPES.has(e.type);
|
|
1240
|
+
if (!whole && !date && !bool && !FLOAT_TYPES.has(e.type)) fail(`CAST to ${e.type} is not supported`);
|
|
1241
|
+
return valueColumn(ctx, [argOf(e.x, ctx)], ([x]) => { if (bool) return asBool(x); if (date) { const t = textToDate(x); return e.type === 'DATE' ? Math.floor(t / 86_400_000) * 86_400_000 : t; } const v = textToNumber(x); return whole ? Math.trunc(v) : v; }, { text: false, date, label: e.type.toLowerCase() });
|
|
1242
|
+
}
|
|
1243
|
+
const x = T(e.x);
|
|
1244
|
+
if (INT_TYPES.has(e.type)) return cond(bin('>=', x, num(0)), call('floor', x), call('ceil', x));
|
|
1245
|
+
if (FLOAT_TYPES.has(e.type)) return x;
|
|
1246
|
+
if (BOOL_TYPES.has(e.type)) return cond(bin('==', x, x), cond(bin('!=', x, num(0)), num(1), num(0)), num(NaN));
|
|
1247
|
+
if (DATE_TYPES.has(e.type)) return e.x.k === 'str' ? translate({ k: 'date', v: e.x.v }, ctx) : e.type === 'DATE' ? call('dayStart', x) : x;
|
|
1248
|
+
fail(`CAST to ${e.type} is not supported`);
|
|
1249
|
+
}
|
|
1250
|
+
case 'call': {
|
|
1251
|
+
if (isAgg(e)) return ctx.agg(e);
|
|
1252
|
+
if (TEXT_FUNCTIONS[e.fn]) return textCall(e, ctx);
|
|
1253
|
+
if (['COALESCE', 'IFNULL', 'NVL', 'NULLIF'].includes(e.fn) && kindOf(e, ctx) === 'text') {
|
|
1254
|
+
if (e.args.length < 2 || (e.fn === 'NULLIF' && e.args.length !== 2)) fail(`${e.fn}() needs ${e.fn === 'NULLIF' ? 'two values' : 'two values or more'}`);
|
|
1255
|
+
const args = e.args.map((x) => argOf(x, ctx));
|
|
1256
|
+
if (e.fn === 'NULLIF') return valueColumn(ctx, args, ([a, b], [ka, kb]) => (a === null ? '' : b !== null && toText(a, ka) === toText(b, kb) ? '' : toText(a, ka)), { skip: true, label: 'nullif' });
|
|
1257
|
+
return valueColumn(ctx, args, (xs, ks) => { const i = xs.findIndex((x) => x !== null); return i < 0 ? '' : toText(xs[i], ks[i]); }, { skip: true, label: 'coalesce' });
|
|
1258
|
+
}
|
|
1259
|
+
const a = e.args.map(T), need = (k) => { if (a.length !== k) fail(`${e.fn}() takes ${k} value(s)`); };
|
|
1260
|
+
switch (e.fn) {
|
|
1261
|
+
case 'ABS': case 'SQRT': case 'FLOOR': need(1); return call(e.fn.toLowerCase(), a[0]);
|
|
1262
|
+
case 'CEIL': case 'CEILING': need(1); return call('ceil', a[0]);
|
|
1263
|
+
case 'ROUND': {
|
|
1264
|
+
const f = a.length > 1 ? 10 ** (e.args[1].k === 'num' ? e.args[1].v : fail('ROUND(x, digits) needs a number of digits')) : 1;
|
|
1265
|
+
const s = bin('*', a[0], num(f));
|
|
1266
|
+
return bin('/', cond(bin('>=', s, num(0)), call('floor', bin('+', s, num(0.5))), call('ceil', bin('-', s, num(0.5)))), num(f));
|
|
1267
|
+
}
|
|
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));
|
|
1269
|
+
case 'COALESCE': case 'IFNULL': case 'NVL': if (a.length < 2) fail(`${e.fn}() needs two values or more`); return call('coalesce', ...a);
|
|
1270
|
+
case 'NULLIF': need(2); return cond(bin('==', a[0], a[1]), num(NaN), a[0]);
|
|
1271
|
+
case 'DATE_TRUNC': {
|
|
1272
|
+
need(2);
|
|
1273
|
+
const part = e.args[0].k === 'str' ? e.args[0].v.toLowerCase() : null;
|
|
1274
|
+
if (!TRUNC[part]) fail(`DATE_TRUNC('${part}', ...) : use 'year', 'month', 'week', 'day' or 'hour'`);
|
|
1275
|
+
return call(TRUNC[part], a[1]);
|
|
1276
|
+
}
|
|
1277
|
+
case 'DOW': case 'DAYOFWEEK': need(1); { const w = call('weekday', a[0]); return bin('-', w, bin('*', call('floor', bin('/', w, num(7))), num(7))); } // 0 = Sunday
|
|
1278
|
+
case 'WEEKDAY': case 'ISODOW': need(1); return call('weekday', a[0]);
|
|
1279
|
+
case 'NOW': case 'CURRENT_TIMESTAMP': return num(Date.now());
|
|
1280
|
+
case 'EPOCH': need(1); return bin('/', a[0], num(1000)); // EXTRACT(EPOCH FROM ts): seconds since 1970
|
|
1281
|
+
case 'DATE_PART': { // date_part('year', ts)
|
|
1282
|
+
need(2);
|
|
1283
|
+
const part = e.args[0].k === 'str' ? e.args[0].v.toUpperCase() : null;
|
|
1284
|
+
if (part === 'EPOCH') return bin('/', a[1], num(1000));
|
|
1285
|
+
if (!DATE_PART[part]) fail(`DATE_PART('${e.args[0].v}', ...): use year, month, day, hour, minute, second or epoch`);
|
|
1286
|
+
return call(DATE_PART[part], a[1]);
|
|
1287
|
+
}
|
|
1288
|
+
default:
|
|
1289
|
+
if (sessionFn(e)) return constValue(sessionFn(e), ctx);
|
|
1290
|
+
if (extraFn(e)) { const fx = extraFn(e); return valueColumn(ctx, e.args.map((x) => argOf(x, ctx)), (xs) => fx.f(...xs), { text: fx.out === 'text', skip: true, label: e.fn.toLowerCase() }); }
|
|
1291
|
+
if (DATE_PART[e.fn]) { need(1); return call(DATE_PART[e.fn], a[0]); }
|
|
1292
|
+
if (WINDOW_FNS[e.fn] && !AGG[e.fn]) fail(`${e.fn}() needs OVER (...)`);
|
|
1293
|
+
fail(`unknown function ${e.fn}()`);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
fail(`can't use ${e.k} here`);
|
|
1298
|
+
}
|
|
1299
|
+
const src = (a) => toSourceAst(a);
|
|
1300
|
+
|
|
1301
|
+
for (const j of ast.joins) addJoin(j);
|
|
1302
|
+
|
|
1303
|
+
if (ast.where) {
|
|
1304
|
+
if (hasAgg(ast.where)) fail('aggregates (SUM, COUNT...) can\'t be used in WHERE: use HAVING');
|
|
1305
|
+
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);
|
|
1307
|
+
q = q.filter(src(t));
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// SELECT items (* expanded)
|
|
1311
|
+
const items = [];
|
|
1312
|
+
ast.items.forEach(({ e, alias }) => {
|
|
1313
|
+
if (e.k === 'star') {
|
|
1314
|
+
const from = e.table ? scope.filter((s) => s.alias.toLowerCase() === e.table.toLowerCase()) : scope;
|
|
1315
|
+
if (!from.length) fail(`no table or alias "${e.table}"`);
|
|
1316
|
+
const seen = new Set(items.map((i) => i.name));
|
|
1317
|
+
for (const s of from) {
|
|
1318
|
+
for (const [orig, out] of s.names) {
|
|
1319
|
+
const u = e.table ? null : usingMap.get(orig.toLowerCase()); // (a USING key once)
|
|
1320
|
+
const label = u ? orig : out;
|
|
1321
|
+
if (seen.has(label) || (!u && out.startsWith('__') && !out.startsWith('__q_'))) continue;
|
|
1322
|
+
seen.add(label);
|
|
1323
|
+
items.push({ e: { k: 'col', name: u ?? out, resolved: true }, name: label });
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
items.push({ e, alias, name: alias ?? null });
|
|
1329
|
+
});
|
|
1330
|
+
const used = new Set();
|
|
1331
|
+
items.forEach((it, i) => { // output names: alias, column, sum_price, col3...
|
|
1332
|
+
if (!it.name) {
|
|
1333
|
+
const c = it.e.k === 'window' ? it.e.call : it.e;
|
|
1334
|
+
if (it.e.k === 'col') it.name = it.e.name;
|
|
1335
|
+
else if (env.pgNames && (c.k === 'call' || it.e.k === 'window')) it.name = c.star && c.fn === 'COUNT' ? 'count' : c.fn.toLowerCase(); // (PostgreSQL's names: count, sum, version)
|
|
1336
|
+
else if (env.pgNames && it.e.k === 'cast') it.name = it.e.type.toLowerCase().replace(/ /g, '_');
|
|
1337
|
+
else if (isAgg(c) || it.e.k === 'window') it.name = `${c.star ? 'count' : AGG[c.fn] === 'mean' ? 'avg' : c.distinct ? 'count_distinct' : c.fn.toLowerCase()}${c.args[0]?.k === 'col' ? `_${c.args[0].name}` : ''}`;
|
|
1338
|
+
else it.name = `col${i + 1}`;
|
|
1339
|
+
}
|
|
1340
|
+
let nm = it.name, k = 2;
|
|
1341
|
+
while (used.has(nm)) nm = `${it.name}_${k++}`;
|
|
1342
|
+
nm = encodeName(nm); // ("sum:price:ok": kept under a plain name, shown as written)
|
|
1343
|
+
it.name = nm;
|
|
1344
|
+
used.add(nm);
|
|
1345
|
+
});
|
|
1346
|
+
const colOf = (e) => { if (e.resolved) return v(e.name); const sv = sessionRef(e); return sv ? constValue(sv, plain) : v(resolve(e)); };
|
|
1347
|
+
const orderBy = tail.orderBy ?? [];
|
|
1348
|
+
|
|
1349
|
+
// ---- window functions: after grouping, before ORDER BY / DISTINCT / LIMIT ---------------
|
|
1350
|
+
// (their inputs are translated first, so aggregates inside them are known)
|
|
1351
|
+
const windowNodes = [];
|
|
1352
|
+
const collectWindows = (e) => { const visit = (x) => { if (!x || typeof x !== 'object') return; if (x.k === 'window') { if (!windowNodes.some((w) => key(w) === key(x))) windowNodes.push(x); return; } if (x.k === 'subq' || x.k === 'exists' || x.k === 'insub') return; for (const val of Object.values(x)) if (Array.isArray(val)) val.forEach(visit); else if (val && typeof val === 'object') visit(val); }; visit(e); };
|
|
1353
|
+
items.forEach((it) => collectWindows(it.e));
|
|
1354
|
+
orderBy.forEach((o) => collectWindows(o.e));
|
|
1355
|
+
if (ast.having && hasWindow(ast.having)) fail('window functions can\'t be used in HAVING (filter on them in an outer query)');
|
|
1356
|
+
let windowPlan = null; // [{ spec key, partition ASTs, order, items }]
|
|
1357
|
+
const windowVars = new Map(), windowKinds = new Map();
|
|
1358
|
+
function planWindows(tr, kindCtx) {
|
|
1359
|
+
const specs = new Map();
|
|
1360
|
+
for (const w of windowNodes) {
|
|
1361
|
+
const c = w.call, fn = WINDOW_FNS[c.fn] ?? (PERCENTILE.has(c.fn) ? null : null);
|
|
1362
|
+
if (!fn) fail(`${c.fn}() can't be used with OVER (window functions: ${Object.keys(WINDOW_FNS).join(', ')})`);
|
|
1363
|
+
if (c.distinct) fail(`${c.fn}(DISTINCT ...) OVER (...) is not supported`);
|
|
1364
|
+
const partition = w.partition.map(tr), order = w.order.map((o) => ({ ast: tr(o.e), desc: o.desc, nullsFirst: o.nullsFirst }));
|
|
1365
|
+
const sk = key({ p: partition, o: order });
|
|
1366
|
+
if (!specs.has(sk)) specs.set(sk, { partition, order, items: [] });
|
|
1367
|
+
const constArg = (i, what) => { const x = c.args[i]; if (!x) return undefined; const k = constNum(tr(x)); if (k === null) fail(`${c.fn}(): the ${what} must be a number`); return k; };
|
|
1368
|
+
const item = { node: w, fn, name: `__w${n.hidden++}` };
|
|
1369
|
+
if (['lag', 'lead'].includes(fn)) { item.arg = tr(c.args[0] ?? fail(`${c.fn}() needs a value`)); item.offset = constArg(1, 'offset') ?? 1; item.def = constArg(2, 'default'); }
|
|
1370
|
+
else if (fn === 'ntile') { item.k = constArg(0, 'number of tiles'); if (!(item.k >= 1)) fail('NTILE(n) needs n >= 1'); }
|
|
1371
|
+
else if (fn === 'nth_value') { item.arg = tr(c.args[0]); item.k = constArg(1, 'position'); }
|
|
1372
|
+
else if (['first_value', 'last_value', 'sum', 'avg', 'min', 'max'].includes(fn)) item.arg = tr(c.args[0] ?? fail(`${c.fn}() needs a value`));
|
|
1373
|
+
else if (fn === 'count') item.arg = c.star || !c.args.length ? null : tr(c.args[0]);
|
|
1374
|
+
if (w.frame) {
|
|
1375
|
+
const bound = (b) => (b.x === undefined ? { kind: b.kind } : { kind: b.kind, n: (() => { const t = tr(b.x); if (t.t !== 'num' || !(t.v >= 0)) fail('a window frame needs a number of rows (or a distance) like 3 PRECEDING'); return t.v; })() });
|
|
1376
|
+
item.frame = { unit: w.frame.unit, start: bound(w.frame.start), end: bound(w.frame.end) };
|
|
1377
|
+
if (item.frame.start.kind === 'unbounded' && w.frame.start.kind === 'unbounded' && false) item.frame.start.kind = 'unbounded';
|
|
1378
|
+
}
|
|
1379
|
+
specs.get(sk).items.push(item);
|
|
1380
|
+
windowVars.set(key(w), item.name);
|
|
1381
|
+
const argKind = c.args[0] ? kindCtx(c.args[0]) : 'number';
|
|
1382
|
+
windowKinds.set(key(w), KEEPS_KIND.has(fn) ? argKind : 'number');
|
|
1383
|
+
}
|
|
1384
|
+
windowPlan = [...specs.values()];
|
|
1385
|
+
}
|
|
1386
|
+
function applyWindows() {
|
|
1387
|
+
for (const spec of windowPlan ?? []) {
|
|
1388
|
+
const partition = spec.partition.map((t) => hide(t, '__wp'));
|
|
1389
|
+
const order = [];
|
|
1390
|
+
for (const o of spec.order) {
|
|
1391
|
+
const name = hide(o.ast, '__wo');
|
|
1392
|
+
if (o.nullsFirst) { const h = `__nf${n.hidden++}`; q = q.derive(h, `${name} == ${name} ? 1 : 0`); order.push({ name: h }); }
|
|
1393
|
+
order.push({ name, desc: o.desc });
|
|
1394
|
+
}
|
|
1395
|
+
const its = spec.items.map((it) => ({ name: it.name, fn: it.fn, arg: it.arg ? hide(it.arg, '__wa') : null, frame: it.frame, offset: it.offset, def: it.def, k: it.k }));
|
|
1396
|
+
q = q.window({ partition, order, items: its });
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
const windowCtx = (base) => ({ ...base, window: (e) => v(windowVars.get(key(e)) ?? fail('internal: window not planned')), windowKind: (e) => windowKinds.get(key(e)) });
|
|
1400
|
+
|
|
1401
|
+
// ---- percentiles: a window over each group's rows, then min() of it -----------------
|
|
1402
|
+
function percentileAgg(e, groupKeyNames, tr) {
|
|
1403
|
+
let p, arg;
|
|
1404
|
+
if (e.fn === 'MEDIAN') { if (e.args.length !== 1) fail('MEDIAN(x) takes one value'); p = 0.5; arg = e.args[0]; }
|
|
1405
|
+
else if (e.fn === 'PERCENTILE_CONT' || e.fn === 'PERCENTILE_DISC') {
|
|
1406
|
+
if (!e.within) fail(`${e.fn}(p) needs WITHIN GROUP (ORDER BY x)`);
|
|
1407
|
+
const t = tr(e.args[0] ?? fail(`${e.fn}(p) needs a fraction p`));
|
|
1408
|
+
if (t.t !== 'num' || !(t.v >= 0 && t.v <= 1)) fail(`${e.fn}(p): p must be a number from 0 to 1`);
|
|
1409
|
+
p = e.within.desc ? 1 - t.v : t.v;
|
|
1410
|
+
arg = e.within.e;
|
|
1411
|
+
} else { // QUANTILE_CONT(x, p) (DuckDB)
|
|
1412
|
+
const t = tr(e.args[1] ?? fail(`${e.fn}(x, p) needs a fraction p`));
|
|
1413
|
+
if (t.t !== 'num' || !(t.v >= 0 && t.v <= 1)) fail(`${e.fn}(x, p): p must be a number from 0 to 1`);
|
|
1414
|
+
p = t.v; arg = e.args[0];
|
|
1415
|
+
}
|
|
1416
|
+
const disc = e.fn === 'PERCENTILE_DISC' || e.fn === 'QUANTILE_DISC';
|
|
1417
|
+
const argName = hide(tr(arg), '__qa');
|
|
1418
|
+
const name = `__q${n.hidden++}`;
|
|
1419
|
+
q = q.window({ partition: groupKeyNames, order: [{ name: argName }], items: [{ name, fn: disc ? 'percentile_disc' : 'percentile_cont', arg: argName, p }] });
|
|
1420
|
+
return name;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const grouped = ast.groupBy.length > 0 || items.some((it) => hasAgg(it.e)) || (ast.having && hasAgg(ast.having));
|
|
1424
|
+
const sortKeys = [];
|
|
1425
|
+
if (grouped) {
|
|
1426
|
+
// group keys: ordinal, select alias or expression
|
|
1427
|
+
const keys = []; // { name, sqlKey, kind }
|
|
1428
|
+
for (const g of ast.groupBy) {
|
|
1429
|
+
let e = g;
|
|
1430
|
+
if (g.k === 'num') e = items[g.v - 1]?.e ?? fail(`GROUP BY ${g.v}: there is no select item ${g.v}`);
|
|
1431
|
+
else if (g.k === 'col' && !g.table && items.some((it) => it.alias === g.name) && !scope.some((s) => s.names.has(g.name))) e = items.find((it) => it.alias === g.name).e;
|
|
1432
|
+
if (hasAgg(e)) fail('GROUP BY can\'t use aggregates');
|
|
1433
|
+
if (hasWindow(e)) fail('GROUP BY can\'t use window functions');
|
|
1434
|
+
const t = e.k === 'col' ? colOf(e) : translate(e, plain);
|
|
1435
|
+
let name;
|
|
1436
|
+
if (t.t === 'var') name = t.name;
|
|
1437
|
+
else { name = `__g${keys.length}_${n.hidden++}`; q = q.derive(name, src(t)); }
|
|
1438
|
+
keys.push({ name, sqlKey: key(e), colName: t.t === 'var' ? t.name : null, kind: e.k === 'col' ? kinds.get(t.name) : kindOf(e, plain) });
|
|
1439
|
+
}
|
|
1440
|
+
// aggregates, each once
|
|
1441
|
+
const aggs = new Map(); // sqlKey -> name
|
|
1442
|
+
const spec = {}, extraKeys = [];
|
|
1443
|
+
const aggOf = (e) => {
|
|
1444
|
+
const k = key(e);
|
|
1445
|
+
if (aggs.has(k)) return v(aggs.get(k));
|
|
1446
|
+
const name = `__a${aggs.size}_${n.hidden++}`;
|
|
1447
|
+
aggs.set(k, name);
|
|
1448
|
+
if (e.args.some(hasAgg)) fail('aggregates can\'t contain aggregates');
|
|
1449
|
+
if (PERCENTILE.has(e.fn)) {
|
|
1450
|
+
const qn = percentileAgg(e, keys.map((x) => x.name), (x) => translate(x, plain));
|
|
1451
|
+
const arg = e.fn === 'MEDIAN' || e.fn.startsWith('QUANTILE') ? e.args[0] : e.within.e;
|
|
1452
|
+
if (kindOf(arg, plain) === 'text') { extraKeys.push(qn); aggs.set(k, qn); return v(qn); } // (one value per group: a key, so it stays text)
|
|
1453
|
+
spec[name] = `min(${qn})`;
|
|
1454
|
+
return v(name);
|
|
1455
|
+
}
|
|
1456
|
+
if ((e.fn === 'MIN' || e.fn === 'MAX' || e.fn === 'SUM' || e.fn === 'AVG' || e.fn === 'MEAN') && e.args[0] && kindOf(e.args[0], plain) === 'text') fail(`${e.fn}() of text is not supported yet (COUNT and COUNT(DISTINCT) are)`);
|
|
1457
|
+
const fn = e.fn === 'COUNT' && e.star ? 'count' : e.fn === 'COUNT' && e.distinct ? 'countDistinct' : AGG[e.fn];
|
|
1458
|
+
if (e.distinct && e.fn !== 'COUNT') fail(`${e.fn}(DISTINCT ...) is not supported (COUNT(DISTINCT x) is)`);
|
|
1459
|
+
if (!e.star && e.args.length !== 1) fail(`${e.fn}() takes one value`);
|
|
1460
|
+
const arg = e.star ? null : src(translate(e.args[0], plain));
|
|
1461
|
+
spec[name] = e.star ? 'count()' : `${fn}(${arg})`;
|
|
1462
|
+
if (fn === 'sum') { spec[`${name}_n`] = `count(${arg})`; return cond(bin('>', v(`${name}_n`), num(0)), v(name), num(NaN)); } // (SUM of no values: NULL)
|
|
1463
|
+
return v(name);
|
|
1464
|
+
};
|
|
1465
|
+
// after grouping: aggregates and keys by name; other columns must be keys
|
|
1466
|
+
const deferred = []; // columns made from grouped values: after grouping
|
|
1467
|
+
const post = windowCtx({
|
|
1468
|
+
step: (st) => deferred.push(st),
|
|
1469
|
+
agg: aggOf,
|
|
1470
|
+
col: (ref) => {
|
|
1471
|
+
const k = keys.find((x) => x.sqlKey === key(ref) || (x.colName && x.colName === (ref.resolved ? ref.name : resolve(ref))));
|
|
1472
|
+
if (!k) fail(`column "${ref.table ? `${ref.table}.` : ''}${ref.name}" must be in GROUP BY or inside an aggregate`);
|
|
1473
|
+
return v(k.name);
|
|
1474
|
+
},
|
|
1475
|
+
kindOfCol: (ref) => keys.find((x) => x.colName && x.colName === resolve(ref))?.kind,
|
|
1476
|
+
});
|
|
1477
|
+
const translatePost = (e) => {
|
|
1478
|
+
const k = keys.find((x) => x.sqlKey === key(e));
|
|
1479
|
+
if (k && e.k !== 'col') return v(k.name);
|
|
1480
|
+
return translate(e, post);
|
|
1481
|
+
};
|
|
1482
|
+
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;
|
|
1485
|
+
const orderAsts = orderBy.map((o) => ({ ...o, ast: orderTarget(o.e, outs, translatePost) }));
|
|
1486
|
+
q = q.groupBy([...keys.map((k) => k.name), ...extraKeys]).agg(Object.keys(spec).length ? spec : { __a_rows: 'count()' });
|
|
1487
|
+
const late = new Set(windowVars.values()), after = []; // (those using window values: after the windows)
|
|
1488
|
+
for (const st of deferred) if (st.inputs.some((x) => late.has(x))) { late.add(st.name); after.push(st); } else st.run();
|
|
1489
|
+
if (havingAst) q = q.filter(src(havingAst));
|
|
1490
|
+
applyWindows();
|
|
1491
|
+
for (const st of after) st.run();
|
|
1492
|
+
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);
|
|
1494
|
+
return finish(outs, (o) => (o.e.k === 'col' ? post.kindOfCol(o.e) ?? kinds.get(resolveSafe(o.e)) : kindOf(o.e, post)), post);
|
|
1495
|
+
}
|
|
1496
|
+
const pre = windowCtx(plain);
|
|
1497
|
+
planWindows((e) => (e.k === 'col' ? colOf(e) : translate(e, pre)), (x) => kindOf(x, pre));
|
|
1498
|
+
applyWindows();
|
|
1499
|
+
const outs = withCols(items.map((it) => ({ ...it, ast: textConst(it.e.k === 'col' ? colOf(it.e) : translate(it.e, pre), pre) })));
|
|
1500
|
+
const orderAsts = orderBy.map((o) => ({ ...o, ast: orderTarget(o.e, outs, (e) => (e.k === 'col' ? colOf(e) : translate(e, pre))) }));
|
|
1501
|
+
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);
|
|
1503
|
+
if (ast.distinct) q = q.groupBy([...new Set(outs.map((o) => o.col))]).agg({ __distinct_rows: 'count()' });
|
|
1504
|
+
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
|
+
|
|
1506
|
+
// a text literal as an answer column: a column of that text
|
|
1507
|
+
function textConst(ast, ctx) { return ast.t === 'str' ? valueColumn(ctx, [], () => ast.v, { label: 'text' }) : ast; }
|
|
1508
|
+
// each output's column while the query runs: a column as it is, or a
|
|
1509
|
+
// hidden one (renamed at the end: an output may take a name in use)
|
|
1510
|
+
function withCols(outs) { for (const o of outs) o.col = o.ast.t === 'var' ? o.ast.name : `__s${n.hidden++}`; return outs; }
|
|
1511
|
+
function resolveSafe(e) { try { return e.resolved ? e.name : resolve(e); } catch { return null; } }
|
|
1512
|
+
// ORDER BY item -> an output name or an expression
|
|
1513
|
+
function orderTarget(e, outs, tr) {
|
|
1514
|
+
if (e.k === 'num') { const o = outs[e.v - 1]; if (!o) fail(`ORDER BY ${e.v}: there is no select item ${e.v}`); return v(o.col); }
|
|
1515
|
+
if (e.k === 'col' && !e.table) { const o = outs.find((x) => x.name === e.name); if (o) return v(o.col); }
|
|
1516
|
+
const t = tr(e), same = outs.find((o) => toSourceAst(o.ast) === toSourceAst(t));
|
|
1517
|
+
return same ? v(same.col) : t;
|
|
1518
|
+
}
|
|
1519
|
+
function sortKey(o) {
|
|
1520
|
+
let k;
|
|
1521
|
+
if (o.ast.t === 'var') k = o.ast.name;
|
|
1522
|
+
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); }
|
|
1524
|
+
sortKeys.push(o.desc ? `-${k}` : k);
|
|
1525
|
+
}
|
|
1526
|
+
function finish(outs, kindFn, ctx) {
|
|
1527
|
+
let out = q;
|
|
1528
|
+
if (sortKeys.length) out = out.sort(sortKeys);
|
|
1529
|
+
if (tail.limit !== null && tail.limit !== undefined) out = out.limit(tail.limit, tail.offset);
|
|
1530
|
+
else if (tail.offset) fail('OFFSET needs LIMIT');
|
|
1531
|
+
const names = outs.map((o) => o.name);
|
|
1532
|
+
const outKinds = new Map(outs.map((o) => [o.name, o.e.k === 'null' ? 'null' : kindFn(o) ?? 'number'])); // (a NULL column matches any kind in a UNION)
|
|
1533
|
+
if (outs.some((o) => o.col !== o.name)) { // (through fresh names: an output may be named like another's column)
|
|
1534
|
+
const tmp = new Map([...new Set(outs.map((o) => o.col))].map((c) => [c, `__f${n.hidden++}`]));
|
|
1535
|
+
for (const [c, t] of tmp) out = out.derive(t, c);
|
|
1536
|
+
out = out.select(...tmp.values());
|
|
1537
|
+
for (const o of outs) out = out.derive(o.name, tmp.get(o.col));
|
|
1538
|
+
}
|
|
1539
|
+
const outTypes = env.wantTypes ? new Map(outs.map((o) => [o.name, typeOfExpr(o.e, ctx)])) : null;
|
|
1540
|
+
return { query: out.select(...names), names, kinds: outKinds, typeOf: (nm) => outTypes?.get(nm) ?? 'float' };
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// AST -> expression text (as fuse.mjs toSource, with text quoted for the parser)
|
|
1545
|
+
function toSourceAst(n) {
|
|
1546
|
+
switch (n.t) {
|
|
1547
|
+
case 'num': return Number.isNaN(n.v) ? 'NaN' : n.v === Infinity ? 'Infinity' : n.v === -Infinity ? '(-Infinity)' : n.v < 0 ? `(${n.v})` : String(n.v);
|
|
1548
|
+
case 'str': return quoteText(n.v);
|
|
1549
|
+
case 'var': return n.name;
|
|
1550
|
+
case 'un': return `${n.op}(${toSourceAst(n.x)})`;
|
|
1551
|
+
case 'bin': return `(${toSourceAst(n.l)} ${n.op} ${toSourceAst(n.r)})`;
|
|
1552
|
+
case 'call': return `${n.fn}(${n.args.map(toSourceAst).join(', ')})`;
|
|
1553
|
+
case 'cond': return `(${toSourceAst(n.c)} ? ${toSourceAst(n.x)} : ${toSourceAst(n.y)})`;
|
|
1554
|
+
}
|
|
1555
|
+
throw new Error(`bad node ${n.t}`);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
export { Query, namesOf, columnType };
|