Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { literal, q, SQL_KEYWORDS, type SqlToken } from '@wellformed/shared';
/**
* Substitute bound parameters back into a statement for display.
*
* Execution never uses this string — it exists so the SQL bar can show the
* query with real values instead of a row of question marks. Placeholders
* inside string literals are skipped, and values are escaped the same way
* SQLite would.
*/
export function inlineParams(sql: string, params: readonly unknown[]): string {
let out = '';
let paramIndex = 0;
let inString = false;
for (let i = 0; i < sql.length; i++) {
const ch = sql[i]!;
if (inString) {
out += ch;
if (ch === "'") {
if (sql[i + 1] === "'") {
// Doubled quote: an escaped apostrophe, still inside the literal.
out += "'";
i++;
} else {
inString = false;
}
}
continue;
}
if (ch === "'") {
inString = true;
out += ch;
continue;
}
if (ch === '?') {
out += paramIndex < params.length ? literal(params[paramIndex]) : '?';
paramIndex++;
continue;
}
out += ch;
}
return out;
}
const TOKEN_RE =
/('(?:[^']|'')*')|(\b\d+(?:\.\d+)?\b)|("(?:[^"]|"")*")|([A-Za-z_][A-Za-z0-9_]*)|([(),.*/+\-<>=|]+)|(\s+)/g;
const FUNCTION_NAMES = new Set([
'COUNT',
'SUM',
'AVG',
'MIN',
'MAX',
'COALESCE',
'ROUND',
'ABS',
'UPPER',
'LOWER',
'TRIM',
'LENGTH',
'CAST',
'DATE',
'DATETIME',
'JULIANDAY',
'STRFTIME',
'IFNULL',
'NULLIF',
]);
/* ---- the artificial variable layer -------------------------------------- */
/** A computed column's name and its expression as display SQL, literals inlined. */
export interface SqlDef {
name: string;
sql: string;
}
/** Collect a definition once; later sightings of the same column add nothing. */
export function addDef(defs: SqlDef[], name: string, sql: string): void {
if (!defs.some((d) => d.name === name)) defs.push({ name, sql });
}
/**
* Marks a folded-in variable: `\x00name\x00`. A sentinel rather than printable
* syntax, so no column name — however SQL-shaped — can collide with real
* statement text; the token pass turns it into `:name` at the very end.
*/
const VAR_MARK = '\x00';
function varMark(name: string): string {
return `${VAR_MARK}${name}${VAR_MARK}`;
}
/**
* Split into quoted and unquoted stretches, so alias detection and clause
* breaking never act on the inside of a string, a quoted identifier, or a
* folded-in variable name.
*/
function segments(sql: string): { text: string; quoted: boolean }[] {
const out: { text: string; quoted: boolean }[] = [];
let plain = '';
for (let i = 0; i < sql.length; i++) {
const ch = sql[i]!;
if (ch === VAR_MARK) {
if (plain) {
out.push({ text: plain, quoted: false });
plain = '';
}
let s = ch;
for (i++; i < sql.length; i++) {
s += sql[i];
if (sql[i] === VAR_MARK) break;
}
out.push({ text: s, quoted: true });
continue;
}
if (ch !== "'" && ch !== '"') {
plain += ch;
continue;
}
if (plain) {
out.push({ text: plain, quoted: false });
plain = '';
}
let s = ch;
for (i++; i < sql.length; i++) {
s += sql[i];
if (sql[i] === ch) {
if (sql[i + 1] === ch) {
s += ch;
i++;
} else break;
}
}
out.push({ text: s, quoted: true });
}
if (plain) out.push({ text: plain, quoted: false });
return out;
}
/** The compiler's aliases: t for the root, j for joins, r for subqueries. */
const ALIAS_RE = /\b[tjr]\d+\b/g;
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* A regex matching this definition's SQL wherever it reappears in a statement,
* however the aliases were renumbered there — the compiler hands every
* occurrence of the same column fresh ones. Each alias becomes a capture group
* on first sight and a backreference after, so a match must rename them
* consistently or not at all.
*/
function defPattern(defSql: string): RegExp {
let src = '';
const seen = new Map<string, number>();
for (const seg of segments(defSql)) {
if (seg.quoted) {
src += escapeRe(seg.text);
continue;
}
let last = 0;
for (const m of seg.text.matchAll(ALIAS_RE)) {
src += escapeRe(seg.text.slice(last, m.index));
const group = seen.get(m[0]);
if (group !== undefined) src += `\\${group}`;
else {
seen.set(m[0], seen.size + 1);
src += String.raw`(\b[tjr]\d+\b)`;
}
last = m.index + m[0].length;
}
src += escapeRe(seg.text.slice(last));
}
return new RegExp(src, 'g');
}
function foldOnce(text: string, def: SqlDef): string {
const folded = text.replace(defPattern(def.sql), varMark(def.name));
// In the SELECT list the fold leaves `:X AS "X"`, which says its one thing twice.
return folded.split(`${varMark(def.name)} AS ${q(def.name)}`).join(varMark(def.name));
}
/**
* Replace the compiler's aliases with the names of the tables they stand for.
* Where the alias would only repeat its table (`FROM "orders" orders`) it is
* dropped — the table name is its own alias — and a second use of the same
* name gets a numbered one, so a self-join or a rollup over its own table
* cannot capture the outer row's name.
*
* `inherited` carries the enclosing statement's names into a definition's
* body, but only the ones the body actually mentions claim their name there.
*/
function nameAliases(
text: string,
inherited: ReadonlyMap<string, string>,
): { text: string; names: Map<string, string> } {
const segs = segments(text);
const names = new Map<string, string>();
const taken = new Set<string>();
for (const seg of segs) {
if (seg.quoted) continue;
for (const m of seg.text.matchAll(ALIAS_RE)) {
const outer = inherited.get(m[0]);
if (outer !== undefined && !names.has(m[0])) {
names.set(m[0], outer);
taken.add(outer);
}
}
}
// Introductions in order: FROM or JOIN, the quoted table, then the alias.
const intros = new Map<number, string>();
for (let i = 0; i + 2 < segs.length; i++) {
const before = segs[i]!;
const table = segs[i + 1]!;
const after = segs[i + 2]!;
if (before.quoted || !/\b(?:FROM|JOIN)\s+$/.test(before.text)) continue;
if (!table.quoted || !table.text.startsWith('"')) continue;
const alias = /^ ([tjr]\d+)\b/.exec(after.quoted ? '' : after.text)?.[1];
if (!alias || names.has(alias)) continue;
const base = table.text.slice(1, -1).replaceAll('""', '"');
let name = base;
for (let n = 2; taken.has(name); n++) name = `${base}${n}`;
names.set(alias, name);
taken.add(name);
if (name === base) intros.set(i + 2, alias);
}
let out = '';
for (let i = 0; i < segs.length; i++) {
const seg = segs[i]!;
if (seg.quoted) {
out += seg.text;
continue;
}
let t = seg.text;
const dropped = intros.get(i);
if (dropped) t = t.replace(new RegExp(`^ ${dropped}\\b`), '');
out += t.replace(ALIAS_RE, (alias) => names.get(alias) ?? alias);
}
return { text: out, names };
}
/** Clause keywords that start a new line when they occur at nesting depth zero. */
const CLAUSE_STARTS = ['FROM', 'LEFT JOIN', 'WHERE', 'AND', 'GROUP BY', 'ORDER BY', 'LIMIT'];
/**
* For the main statement clauses break at depth zero, flush left; a
* definition's body is one parenthesised subquery, so its clauses sit at
* depth one and their lines indent under the `:name =` that owns them.
*/
function breakClauses(sql: string, atDepth = 0, indent = ''): string {
let out = '';
let depth = 0;
// The main SELECT list gets a line per column, tab-indented; it runs from
// the opening SELECT until the first clause break ends it.
let inSelect = false;
let eatSpace = false;
for (const seg of segments(sql)) {
if (seg.quoted) {
out += seg.text;
eatSpace = false;
continue;
}
const t = seg.text;
for (let i = 0; i < t.length; i++) {
const ch = t[i]!;
if (eatSpace) {
eatSpace = false;
if (ch === ' ') continue;
}
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (depth === atDepth) {
if (ch === ' ') {
if (atDepth === 0 && out.endsWith('SELECT')) {
out += '\n\t';
inSelect = true;
continue;
}
const rest = t.slice(i + 1);
if (CLAUSE_STARTS.some((k) => rest.startsWith(k + ' '))) {
out += '\n' + indent;
inSelect = false;
continue;
}
} else if (ch === ',' && inSelect) {
out += ',\n\t';
eatSpace = true;
continue;
}
}
out += ch;
}
}
return out;
}
/**
* The tokens the SQL bar actually shows: computed columns folded back into
* `:name` variables — which SQL itself does not have — each defined once above
* a statement broken along its clauses, with the compiler's aliases renamed to
* the tables they stand for. A faithful renaming of the statement that ran,
* not a different query; COPY still copies the statement verbatim.
*/
export function displayTokens(displaySql: string, defs: SqlDef[]): SqlToken[] {
// Longest first: an expression containing another column's expansion must
// fold as itself before the smaller one can eat its inside.
const ordered = [...defs].sort((a, b) => b.sql.length - a.sql.length);
let text = displaySql;
for (const def of ordered) text = foldOnce(text, def);
const main = nameAliases(text, new Map());
// Definitions fold into each other too, so a rollup built on another
// computed column shows that relationship instead of repeating its body.
const lines = defs.map((def) => {
let body = def.sql;
for (const other of ordered) {
if (other.name !== def.name && other.sql.length < def.sql.length) {
body = foldOnce(body, other);
}
}
return `${varMark(def.name)} = ${breakClauses(nameAliases(body, main.names).text, 1, '\t')}`;
});
const full = (lines.length ? lines.join('\n') + '\n\n' : '') + breakClauses(main.text);
// Chunks alternate statement text and variable names; names become `:name`.
const tokens: SqlToken[] = [];
const parts = full.split(VAR_MARK);
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 1) tokens.push({ c: 'var', s: `:${parts[i]!}` });
else if (parts[i]) tokens.push(...highlight(parts[i]!));
}
return tokens;
}
/** Split a statement into coloured tokens for the client's SQL bar. */
export function highlight(sql: string): SqlToken[] {
const tokens: SqlToken[] = [];
const push = (c: SqlToken['c'], s: string) => {
const last = tokens[tokens.length - 1];
if (last && last.c === c) last.s += s;
else tokens.push({ c, s });
};
let lastIndex = 0;
for (const m of sql.matchAll(TOKEN_RE)) {
if (m.index > lastIndex) push('plain', sql.slice(lastIndex, m.index));
lastIndex = m.index + m[0].length;
const [, str, num, quoted, word, punct, space] = m;
if (str !== undefined) push('str', str);
else if (num !== undefined) push('num', num);
else if (quoted !== undefined) push('id', quoted);
else if (word !== undefined) {
const upper = word.toUpperCase();
if (SQL_KEYWORDS.has(upper)) push('kw', word);
else if (FUNCTION_NAMES.has(upper)) push('fn', word);
else push('id', word);
} else if (punct !== undefined) push('op', punct);
else if (space !== undefined) push('plain', space);
}
if (lastIndex < sql.length) push('plain', sql.slice(lastIndex));
return tokens;
}