дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Initial open-source release
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
коміт
9a2025b76e
2034 files changed
+243381
No files matched your search
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Caret analysis for SQL completion: the statement and query block around the
|
||||
* caret, the tables it can see (FROM / JOIN items with aliases, CTEs, derived
|
||||
* tables, enclosing queries for correlated subqueries) and what is expected at
|
||||
* the caret (a statement, a table, a column expression, a join condition…).
|
||||
* Purely lexical: works on half-typed statements a parser would reject.
|
||||
*/
|
||||
import type { DbDriver } from "@/platform/ipc";
|
||||
import { CLAUSE_WORDS, isReserved } from "./dialect";
|
||||
import { identName, inLiteral, lex, type Lexed, type Token } from "./lexer";
|
||||
|
||||
export interface TableRef {
|
||||
/** Schema as written (`null`: unqualified). */
|
||||
schema: string | null;
|
||||
/** Table / CTE name; a derived table's alias. */
|
||||
name: string;
|
||||
alias: string | null;
|
||||
/** Offsets of the alias token. */
|
||||
aliasAt: { from: number; to: number } | null;
|
||||
kind: "table" | "cte" | "derived";
|
||||
/** Columns known from the query text (CTEs, derived tables); `null` for catalog tables. */
|
||||
columns: string[] | null;
|
||||
/** Offsets of the (last part of the) name. */
|
||||
from: number;
|
||||
to: number;
|
||||
/** 0: the caret's query block; 1+: enclosing blocks (correlated references). */
|
||||
depth: number;
|
||||
/** Introduced by JOIN (so `ON` may follow). */
|
||||
viaJoin: boolean;
|
||||
}
|
||||
|
||||
export type Clause =
|
||||
| "select"
|
||||
| "from"
|
||||
| "where"
|
||||
| "group"
|
||||
| "order"
|
||||
| "having"
|
||||
| "limit"
|
||||
| "set"
|
||||
| "values"
|
||||
| "on"
|
||||
| "returning"
|
||||
| "other";
|
||||
|
||||
export type SqlContext =
|
||||
| { kind: "none" }
|
||||
| { kind: "statement" }
|
||||
| { kind: "table"; join: boolean }
|
||||
/** Right after a FROM / JOIN item (alias, JOIN, WHERE…). */
|
||||
| { kind: "afterTable"; ref: TableRef }
|
||||
| { kind: "column"; clause: Clause }
|
||||
| { kind: "joinCondition"; ref: TableRef }
|
||||
| { kind: "insertColumns"; target: TableRef }
|
||||
| { kind: "afterInsertColumns" };
|
||||
|
||||
export interface Analysis {
|
||||
context: SqlContext;
|
||||
/** Identifier chain before the caret word: `s.t.na|` → `["s", "t"]`. */
|
||||
qualifier: string[] | null;
|
||||
/** The word being typed (start offset and text up to the caret). */
|
||||
prefix: { from: number; text: string };
|
||||
/** Tables visible at the caret, innermost block first. */
|
||||
refs: TableRef[];
|
||||
ctes: TableRef[];
|
||||
statement: { from: number; to: number; text: string };
|
||||
/** First word of the statement, upper-cased. */
|
||||
verb: string;
|
||||
/** Where `FROM t` goes for a SELECT that has no FROM yet (`null`: it has one). */
|
||||
fromInsert: number | null;
|
||||
/** Keyword case the statement is written in. */
|
||||
lowerKeywords: boolean;
|
||||
/** The token before the caret word (and its qualifier), upper-cased: `(`, `UNION`… */
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
const WORD_BEFORE = /[\p{L}\p{N}_$]*$/u;
|
||||
const TABLE_INTRO = new Set(["FROM", "JOIN", "UPDATE", "INTO", "TABLE", "TRUNCATE", "DESCRIBE", "STRAIGHT_JOIN"]);
|
||||
const CLAUSES: Record<string, Clause> = {
|
||||
SELECT: "select",
|
||||
FROM: "from",
|
||||
JOIN: "from",
|
||||
STRAIGHT_JOIN: "from",
|
||||
WHERE: "where",
|
||||
GROUP: "group",
|
||||
ORDER: "order",
|
||||
HAVING: "having",
|
||||
LIMIT: "limit",
|
||||
OFFSET: "limit",
|
||||
SET: "set",
|
||||
VALUES: "values",
|
||||
ON: "on",
|
||||
USING: "on",
|
||||
RETURNING: "returning",
|
||||
WINDOW: "other",
|
||||
};
|
||||
const SET_OPS = new Set(["UNION", "INTERSECT", "EXCEPT"]);
|
||||
const QUERY_START = new Set(["SELECT", "WITH", "VALUES", "TABLE"]);
|
||||
|
||||
const isIdent = (t: Token | undefined): t is Token =>
|
||||
!!t && (t.kind === "quoted" || (t.kind === "word" && !CLAUSE_WORDS.has(t.upper)));
|
||||
const isP = (t: Token | undefined, p: string) => !!t && t.kind === "punct" && t.text === p;
|
||||
const isW = (t: Token | undefined, ...w: string[]) => !!t && t.kind === "word" && w.includes(t.upper);
|
||||
|
||||
/** Bracket structure of a token list. */
|
||||
class Parens {
|
||||
/** `(` index → matching `)` index (or `tokens.length` when unclosed). */
|
||||
readonly close = new Map<number, number>();
|
||||
/** Token index → index of the innermost enclosing `(` (−1 at top level). */
|
||||
readonly parent: number[];
|
||||
constructor(readonly t: Token[]) {
|
||||
const stack: number[] = [];
|
||||
this.parent = t.map((tok, i) => {
|
||||
const p = stack.at(-1) ?? -1;
|
||||
if (isP(tok, "(")) stack.push(i);
|
||||
else if (isP(tok, ")") && stack.length) {
|
||||
this.close.set(stack.pop()!, i);
|
||||
return stack.at(-1) ?? -1;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
for (const open of stack) this.close.set(open, t.length);
|
||||
}
|
||||
/** Indices in `a..b` directly inside group `g` (not nested deeper). */
|
||||
level(g: number, a: number, b: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = a; i < b; i++) if (this.parent[i] === g) out.push(i);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export function analyze(text: string, pos: number, driver: DbDriver, lexed: Lexed = lex(text, driver)): Analysis {
|
||||
const word = WORD_BEFORE.exec(text.slice(Math.max(0, pos - 256), pos))![0];
|
||||
const prefix = { from: pos - word.length, text: word };
|
||||
const all = lexed.tokens;
|
||||
|
||||
// The statement: between the `;` before the caret word and the next one.
|
||||
let a = 0;
|
||||
let b = all.length;
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
if (!isP(all[i], ";")) continue;
|
||||
if (all[i]!.to <= prefix.from) a = i + 1;
|
||||
else {
|
||||
b = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const st = all.slice(a, b);
|
||||
const sFrom = a > 0 ? all[a - 1]!.to : 0;
|
||||
const sTo = b < all.length ? all[b]!.from : text.length;
|
||||
const statement = { from: sFrom, to: sTo, text: text.slice(sFrom, sTo) };
|
||||
const verb = st[0]?.kind === "word" ? st[0].upper : "";
|
||||
const firstKeyword = st.find((t) => t.kind === "word" && CLAUSE_WORDS.has(t.upper));
|
||||
const lowerKeywords = !!firstKeyword && firstKeyword.text === firstKeyword.text.toLowerCase();
|
||||
|
||||
const parens = new Parens(st);
|
||||
// Tokens before the caret word (a word the caret is inside of is the prefix).
|
||||
let nb = 0;
|
||||
while (nb < st.length && st[nb]!.to <= prefix.from) nb++;
|
||||
const before = st.slice(0, nb);
|
||||
|
||||
// Open parens around the caret, innermost last.
|
||||
const open: number[] = [];
|
||||
for (let i = 0; i < nb; i++) {
|
||||
if (isP(st[i], "(")) open.push(i);
|
||||
else if (isP(st[i], ")")) open.pop();
|
||||
}
|
||||
const isQuery = (p: number) => p === -1 || (st[p + 1]?.kind === "word" && QUERY_START.has(st[p + 1]!.upper));
|
||||
const blocks = [...open.filter(isQuery).reverse(), -1];
|
||||
|
||||
const ctes = parseCtes(st, parens);
|
||||
const refs: TableRef[] = [];
|
||||
const refEnds = new Map<number, TableRef>();
|
||||
blocks.forEach((g, depth) => {
|
||||
// Within an enclosing block, the "caret" is the paren of the block nested in it.
|
||||
const [ra, rb] = blockRange(st, parens, g, depth === 0 ? nb : blocks[depth - 1]!);
|
||||
for (const r of parseRefs(st, parens, g, ra, rb, ctes, depth)) {
|
||||
refs.push(r.ref);
|
||||
if (depth === 0) refEnds.set(r.end, r.ref);
|
||||
}
|
||||
});
|
||||
|
||||
const literal = inLiteral(lexed, pos);
|
||||
const qualifier: string[] = [];
|
||||
let k = before.length - 1;
|
||||
while (
|
||||
isP(before[k], ".") &&
|
||||
(before[k - 1]?.kind === "word" || before[k - 1]?.kind === "quoted") &&
|
||||
before[k]!.to === (k + 1 < before.length ? before[k + 1]!.from : prefix.from)
|
||||
) {
|
||||
qualifier.unshift(identName(before[k - 1]!));
|
||||
k -= 2;
|
||||
}
|
||||
const p = before[k];
|
||||
const pi = k;
|
||||
const block = blocks[0]!;
|
||||
const clause = clauseAt(st, parens, block, open.length ? open.at(-1)! : -1, nb);
|
||||
|
||||
let context: SqlContext;
|
||||
if (literal) context = { kind: "none" };
|
||||
else if (!p) context = qualifier.length ? { kind: "column", clause: "other" } : { kind: "statement" };
|
||||
else if (
|
||||
p.kind === "word" &&
|
||||
TABLE_INTRO.has(p.upper) &&
|
||||
!(p.upper === "TABLE" && verb !== "TABLE" && !isW(before[pi - 1], "ALTER", "DROP", "TRUNCATE", "LOCK"))
|
||||
)
|
||||
context = { kind: "table", join: p.upper === "JOIN" || p.upper === "STRAIGHT_JOIN" };
|
||||
else if (isW(p, "LATERAL", "ONLY") && isW(before[pi - 1], "JOIN", "FROM"))
|
||||
context = { kind: "table", join: isW(before[pi - 1], "JOIN") };
|
||||
else if (isP(p, ",") && clause === "from" && parens.parent[pi] === block) context = { kind: "table", join: false };
|
||||
else if (isW(p, "AS") || isP(p, "::")) context = { kind: "none" };
|
||||
else if (refEnds.has(pi) && !qualifier.length && (clause === "from" || clause === "other")) {
|
||||
const ref = refEnds.get(pi)!;
|
||||
context = { kind: "afterTable", ref };
|
||||
} else if (isW(p, "ON") || (clause === "on" && isW(p, "AND", "OR"))) {
|
||||
const joined = [...refs].filter((r) => r.depth === 0 && r.from < (p?.from ?? pos)).at(-1);
|
||||
context = joined ? { kind: "joinCondition", ref: joined } : { kind: "column", clause: "on" };
|
||||
} else {
|
||||
const ins = insertTarget(st, parens, open.at(-1), refEnds);
|
||||
if (ins && (isP(p, "(") || isP(p, ","))) context = { kind: "insertColumns", target: ins };
|
||||
else if (isP(p, ")") && insertTarget(st, parens, [...parens.close].find(([, c]) => c === pi)?.[0], refEnds))
|
||||
context = { kind: "afterInsertColumns" };
|
||||
else context = { kind: "column", clause };
|
||||
}
|
||||
|
||||
return {
|
||||
context,
|
||||
qualifier: qualifier.length ? qualifier : null,
|
||||
prefix,
|
||||
refs,
|
||||
ctes,
|
||||
statement,
|
||||
verb,
|
||||
fromInsert: fromInsertPos(st, parens, block, pos),
|
||||
lowerKeywords,
|
||||
previous: p ? p.upper : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Token range of block `g`, cut to the UNION / INTERSECT / EXCEPT branch containing `caret` (a token index). */
|
||||
function blockRange(st: Token[], parens: Parens, g: number, caret: number): [number, number] {
|
||||
let a = g + 1;
|
||||
let b = g === -1 ? st.length : (parens.close.get(g) ?? st.length);
|
||||
for (const i of parens.level(g, a, b)) {
|
||||
if (!SET_OPS.has(st[i]!.upper) || st[i]!.kind !== "word") continue;
|
||||
if (i < caret) a = i + 1;
|
||||
else {
|
||||
b = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
/** The clause the caret is in: the last clause keyword of its block before it. */
|
||||
function clauseAt(st: Token[], parens: Parens, block: number, innermost: number, caret: number): Clause {
|
||||
// Inside a non-query paren (function call, IN list), the clause is the one around that paren.
|
||||
const limit = innermost > block ? innermost : caret;
|
||||
let clause: Clause = "other";
|
||||
for (const i of parens.level(block, block + 1, limit)) {
|
||||
const t = st[i]!;
|
||||
if (t.kind !== "word") continue;
|
||||
if (SET_OPS.has(t.upper)) clause = "other";
|
||||
const c = CLAUSES[t.upper];
|
||||
if (c) clause = c;
|
||||
}
|
||||
return clause;
|
||||
}
|
||||
|
||||
function parseCtes(st: Token[], parens: Parens): TableRef[] {
|
||||
const out: TableRef[] = [];
|
||||
if (!isW(st[0], "WITH")) return out;
|
||||
let i = 1;
|
||||
if (isW(st[i], "RECURSIVE")) i++;
|
||||
while (i < st.length && isIdent(st[i])) {
|
||||
const nameTok = st[i]!;
|
||||
i++;
|
||||
let columns: string[] | null = null;
|
||||
if (isP(st[i], "(")) {
|
||||
const c = parens.close.get(i)!;
|
||||
columns = parens
|
||||
.level(i, i + 1, c)
|
||||
.filter((j) => isIdent(st[j]))
|
||||
.map((j) => identName(st[j]!));
|
||||
i = c + 1;
|
||||
}
|
||||
if (!isW(st[i], "AS")) break;
|
||||
i++;
|
||||
while (isW(st[i], "NOT", "MATERIALIZED")) i++;
|
||||
if (!isP(st[i], "(")) break;
|
||||
const c = parens.close.get(i)!;
|
||||
columns ??= outputColumns(st, parens, i, i + 1, c);
|
||||
out.push({
|
||||
schema: null,
|
||||
name: identName(nameTok),
|
||||
alias: null,
|
||||
aliasAt: null,
|
||||
kind: "cte",
|
||||
columns,
|
||||
from: nameTok.from,
|
||||
to: nameTok.to,
|
||||
depth: 0,
|
||||
viaJoin: false,
|
||||
});
|
||||
i = c + 1;
|
||||
if (!isP(st[i], ",")) break;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Output column names of the query in group `g` (`a..b`): aliases and plain column references. */
|
||||
export function outputColumns(st: Token[], parens: Parens, g: number, a: number, b: number): string[] {
|
||||
const level = parens.level(g, a, b);
|
||||
const sel = level.find((i) => isW(st[i], "SELECT"));
|
||||
if (sel === undefined) return [];
|
||||
const out: string[] = [];
|
||||
let item: number[] = [];
|
||||
const flush = () => {
|
||||
const last = st[item.at(-1)!];
|
||||
const prev = item.length > 1 ? st[item.at(-2)!] : undefined;
|
||||
if (last && (last.kind === "word" || last.kind === "quoted") && !isW(last, "DISTINCT")) {
|
||||
if (
|
||||
item.length === 1 ||
|
||||
isW(prev, "AS") ||
|
||||
isP(prev, ".") ||
|
||||
isP(prev, ")") ||
|
||||
prev?.kind === "word" ||
|
||||
prev?.kind === "quoted"
|
||||
)
|
||||
out.push(identName(last));
|
||||
}
|
||||
item = [];
|
||||
};
|
||||
for (const i of level) {
|
||||
if (i <= sel) continue;
|
||||
const t = st[i]!;
|
||||
if (
|
||||
t.kind === "word" &&
|
||||
(t.upper === "FROM" ||
|
||||
t.upper === "WHERE" ||
|
||||
t.upper === "GROUP" ||
|
||||
t.upper === "ORDER" ||
|
||||
t.upper === "LIMIT" ||
|
||||
SET_OPS.has(t.upper))
|
||||
)
|
||||
break;
|
||||
if (isW(t, "DISTINCT", "ALL") && !item.length) continue;
|
||||
if (isP(t, ",")) flush();
|
||||
else item.push(i);
|
||||
}
|
||||
if (item.length) flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** FROM / JOIN / UPDATE / INTO items of block `g` within `a..b`, each with the index of its last token. */
|
||||
function parseRefs(
|
||||
st: Token[],
|
||||
parens: Parens,
|
||||
g: number,
|
||||
a: number,
|
||||
b: number,
|
||||
ctes: TableRef[],
|
||||
depth: number,
|
||||
): Array<{ ref: TableRef; end: number }> {
|
||||
const out: Array<{ ref: TableRef; end: number }> = [];
|
||||
const level = parens.level(g, a, b);
|
||||
let clause: Clause = "other";
|
||||
const item = (j: number, viaJoin = false, into = false) => {
|
||||
while (isW(st[j], "LATERAL", "ONLY")) j++;
|
||||
const t = st[j];
|
||||
if (!t || j >= b) return;
|
||||
if (isP(t, "(")) {
|
||||
const c = parens.close.get(j)!;
|
||||
const columns = outputColumns(st, parens, j, j + 1, c);
|
||||
const al = alias(st, c + 1, b);
|
||||
if (al)
|
||||
out.push({
|
||||
ref: {
|
||||
schema: null,
|
||||
name: al.name,
|
||||
alias: al.name,
|
||||
aliasAt: { from: al.tok.from, to: al.tok.to },
|
||||
kind: "derived",
|
||||
columns,
|
||||
from: al.tok.from,
|
||||
to: al.tok.to,
|
||||
depth,
|
||||
viaJoin,
|
||||
},
|
||||
end: al.end,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isIdent(t)) return;
|
||||
const parts = [t];
|
||||
let k = j + 1;
|
||||
while (isP(st[k], ".") && (st[k + 1]?.kind === "word" || st[k + 1]?.kind === "quoted")) {
|
||||
parts.push(st[k + 1]!);
|
||||
k += 2;
|
||||
}
|
||||
let end = k - 1;
|
||||
if (isP(st[k], "(") && !into) {
|
||||
// Table function: `generate_series(1, 3) g` (`INSERT INTO t (` is a column list).
|
||||
end = parens.close.get(k) ?? k;
|
||||
k = end + 1;
|
||||
}
|
||||
const last = parts.at(-1)!;
|
||||
const name = identName(last);
|
||||
const schema = parts.length > 1 ? identName(parts.at(-2)!) : null;
|
||||
const al = alias(st, k, b);
|
||||
const cte = schema === null ? ctes.find((c) => c.name.toLowerCase() === name.toLowerCase()) : undefined;
|
||||
out.push({
|
||||
ref: {
|
||||
schema,
|
||||
name,
|
||||
alias: al?.name ?? null,
|
||||
aliasAt: al ? { from: al.tok.from, to: al.tok.to } : null,
|
||||
kind: cte ? "cte" : "table",
|
||||
columns: cte?.columns ?? null,
|
||||
from: last.from,
|
||||
to: last.to,
|
||||
depth,
|
||||
viaJoin,
|
||||
},
|
||||
end: al?.end ?? end,
|
||||
});
|
||||
};
|
||||
for (const i of level) {
|
||||
const t = st[i]!;
|
||||
if (t.kind === "word") {
|
||||
if (TABLE_INTRO.has(t.upper) && t.upper !== "TABLE") {
|
||||
// `INSERT INTO`, `DELETE FROM`, `SELECT … INTO var` (MySQL) — only tables matter here.
|
||||
clause = "from";
|
||||
item(i + 1, t.upper === "JOIN" || t.upper === "STRAIGHT_JOIN", t.upper === "INTO");
|
||||
continue;
|
||||
}
|
||||
const c = CLAUSES[t.upper];
|
||||
if (c) clause = c;
|
||||
} else if (isP(t, ",") && clause === "from") item(i + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** An alias at `k` (`AS x`, or a bare non-keyword identifier). */
|
||||
function alias(st: Token[], k: number, b: number): { name: string; tok: Token; end: number } | null {
|
||||
let j = k;
|
||||
const as = isW(st[j], "AS");
|
||||
if (as) j++;
|
||||
const t = st[j];
|
||||
if (j >= b || !t) return null;
|
||||
if (t.kind === "quoted" || (t.kind === "word" && !CLAUSE_WORDS.has(t.upper) && (as || !isReserved(t.text)))) {
|
||||
return { name: identName(t), tok: t, end: j };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The INSERT target when `open` is the `(` right after it (`INSERT INTO t (`). */
|
||||
function insertTarget(
|
||||
st: Token[],
|
||||
parens: Parens,
|
||||
open: number | undefined,
|
||||
refEnds: Map<number, TableRef>,
|
||||
): TableRef | null {
|
||||
if (open === undefined || !isP(st[open], "(")) return null;
|
||||
const ref = refEnds.get(open - 1);
|
||||
if (!ref) return null;
|
||||
// The ref must be introduced by INTO.
|
||||
for (let i = open - 1; i >= 0; i--) {
|
||||
if (isW(st[i], "INTO")) return parens.parent[i] === parens.parent[open] ? ref : null;
|
||||
if (st[i]!.kind === "word" && CLAUSE_WORDS.has(st[i]!.upper)) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** For a SELECT block without FROM: the offset to append `FROM t` at (end of its select list). */
|
||||
function fromInsertPos(st: Token[], parens: Parens, block: number, pos: number): number | null {
|
||||
const end = block === -1 ? st.length : (parens.close.get(block) ?? st.length);
|
||||
const level = parens.level(block, block + 1, end);
|
||||
if (!level.length || !isW(st[level[0]!], "SELECT")) return null;
|
||||
for (const i of level) {
|
||||
const t = st[i]!;
|
||||
if (t.kind === "word" && CLAUSES[t.upper] && t.upper !== "SELECT") return null;
|
||||
if (t.kind === "word" && (SET_OPS.has(t.upper) || t.upper === "INTO")) return null;
|
||||
}
|
||||
const last = st[end - 1];
|
||||
return Math.max(pos, last && end - 1 >= 0 ? last.to : pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live template contexts at the analysed caret (IDEA's SQL ones): `sql-statement`
|
||||
* where a statement starts, `sql-query` where a query may (subqueries, set
|
||||
* operations), `sql-code` anywhere in code.
|
||||
*/
|
||||
export function templateContexts(a: Analysis): string[] {
|
||||
if (a.context.kind === "none" || a.qualifier) return [];
|
||||
const out = ["sql-code"];
|
||||
if (a.context.kind === "statement") out.push("sql-statement", "sql-query");
|
||||
else if (a.previous && (a.previous === "(" || SET_OPS.has(a.previous) || a.previous === "ALL")) out.push("sql-query");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The VALUES tuple slot at the caret of an INSERT: its target, explicit column list and value index. */
|
||||
export interface ValuesSlot {
|
||||
target: TableRef;
|
||||
/** The `(a, b)` list after the table; `null` when the INSERT lists none. */
|
||||
columns: string[] | null;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function valuesSlot(text: string, pos: number, driver: DbDriver): ValuesSlot | null {
|
||||
const a = analyze(text, pos, driver);
|
||||
if (a.verb !== "INSERT" && a.verb !== "REPLACE") return null;
|
||||
const target = a.refs.find((r) => r.depth === 0 && r.kind === "table");
|
||||
if (!target) return null;
|
||||
const toks = lex(a.statement.text, driver).tokens;
|
||||
const caret = pos - a.statement.from;
|
||||
let depth = 0;
|
||||
let columns: string[] | null = null;
|
||||
let afterValues = false;
|
||||
let index = -1;
|
||||
let inTuple = false;
|
||||
for (const t of toks) {
|
||||
if (t.from >= caret) break;
|
||||
if (isP(t, "(")) {
|
||||
depth++;
|
||||
if (depth === 1 && afterValues) {
|
||||
inTuple = true;
|
||||
index = 0;
|
||||
} else if (depth === 1 && !afterValues && columns === null && t.from > target.from) columns = [];
|
||||
continue;
|
||||
}
|
||||
if (isP(t, ")")) {
|
||||
depth--;
|
||||
if (depth === 0) inTuple = false;
|
||||
continue;
|
||||
}
|
||||
if (depth === 0 && isW(t, "VALUES")) afterValues = true;
|
||||
else if (depth === 1 && !afterValues && columns && (t.kind === "word" || t.kind === "quoted")) columns.push(identName(t));
|
||||
else if (depth === 1 && inTuple && isP(t, ",")) index++;
|
||||
}
|
||||
return inTuple ? { target, columns: columns?.length ? columns : null, index } : null;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* SQL completion candidates (DataGrip-style): what fits the caret context
|
||||
* from the analysed statement and the data source's schema model — tables
|
||||
* after FROM / JOIN (with ready-made join clauses from foreign keys), columns
|
||||
* of the tables in scope with their owner and type, join conditions after ON,
|
||||
* dialect functions and the keywords that may follow.
|
||||
*/
|
||||
import type { DbCompletion, DbCompletionColumn, DbCompletionTable, DbDriver } from "@/platform/ipc";
|
||||
import type { Analysis, Clause, TableRef } from "./analyze";
|
||||
import { DATA_TYPES, functions, isReserved, joinKeywords, predicateKeywords, statementStarters } from "./dialect";
|
||||
|
||||
export type CandidateKind =
|
||||
| "table"
|
||||
| "view"
|
||||
| "column"
|
||||
/** Several columns at once (`id, email, name`). */
|
||||
| "columns"
|
||||
| "keyword"
|
||||
| "function"
|
||||
| "schema"
|
||||
| "alias"
|
||||
| "join"
|
||||
| "type";
|
||||
|
||||
export interface SqlCandidate {
|
||||
/** Filter / display text. */
|
||||
label: string;
|
||||
kind: CandidateKind;
|
||||
/** Grey text after the label: `(o)`, `(main)`, a signature. */
|
||||
tail?: string;
|
||||
/** Right-aligned: column type, data source. */
|
||||
type?: string;
|
||||
/** Text inserted instead of `label`. */
|
||||
insert?: string;
|
||||
/** Inserted as a snippet: `${}` marks the caret. */
|
||||
snippet?: string;
|
||||
/** Ranking within the popup (higher first). */
|
||||
boost: number;
|
||||
/** Also append `FROM <this>` to the statement (column picked before any FROM). */
|
||||
addFrom?: string;
|
||||
/** Re-open completion after inserting (`schema.`). */
|
||||
retrigger?: boolean;
|
||||
/** Column decorations. */
|
||||
primaryKey?: boolean;
|
||||
foreignKey?: boolean;
|
||||
/** Side-panel documentation (plain text). */
|
||||
doc?: string;
|
||||
}
|
||||
|
||||
const MAX_ALL_COLUMNS = 3000;
|
||||
|
||||
const eq = (a: string | null | undefined, b: string | null | undefined) =>
|
||||
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
/** `name`, quoted when the dialect needs it (case, characters, reserved words). */
|
||||
export function quoteIdent(name: string, driver: DbDriver): string {
|
||||
const plain = driver === "postgres" ? /^[a-z_][a-z0-9_$]*$/ : /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
if (plain.test(name) && !isReserved(name)) return name;
|
||||
const q = driver === "mysql" ? "`" : '"';
|
||||
return q + name.split(q).join(q + q) + q;
|
||||
}
|
||||
|
||||
/** A short alias for `table` not in `taken`: `order_items` → `oi`, `users` → `u`. */
|
||||
export function aliasFor(table: string, taken: ReadonlySet<string>): string {
|
||||
const words = table.split(/[_\s.$-]+|(?<=[a-z])(?=[A-Z])/).filter(Boolean);
|
||||
let base =
|
||||
words
|
||||
.map((w) => w[0]!)
|
||||
.join("")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "") || "t";
|
||||
if (/^\d/.test(base)) base = `t${base}`;
|
||||
if (isReserved(base)) base = `${base}1`;
|
||||
let a = base;
|
||||
for (let n = 1; taken.has(a.toLowerCase()); n++) a = `${base}${n}`;
|
||||
return a;
|
||||
}
|
||||
|
||||
export interface CompletionInput {
|
||||
model: DbCompletion | null;
|
||||
analysis: Analysis;
|
||||
driver: DbDriver;
|
||||
/** Data source name, shown next to tables like IDEA does. */
|
||||
sourceName?: string;
|
||||
}
|
||||
|
||||
export function sqlCandidates({ model, analysis: a, driver, sourceName }: CompletionInput): SqlCandidate[] {
|
||||
const ctx = a.context;
|
||||
if (ctx.kind === "none") return [];
|
||||
const typedLower = /[a-z]/.test(a.prefix.text) && !/[A-Z]/.test(a.prefix.text);
|
||||
const typedUpper = /[A-Z]/.test(a.prefix.text) && !/[a-z]/.test(a.prefix.text);
|
||||
const lower = typedLower || (!typedUpper && a.lowerKeywords);
|
||||
const kw = (k: string) => (lower ? k.toLowerCase() : k);
|
||||
const tables = model?.tables ?? [];
|
||||
const defaultSchema = model?.defaultSchema ?? "";
|
||||
const q = (n: string) => quoteIdent(n, driver);
|
||||
const out: SqlCandidate[] = [];
|
||||
const keywords = (list: readonly string[], boost: number) => {
|
||||
for (const k of list) out.push({ label: kw(k), kind: "keyword", boost });
|
||||
};
|
||||
|
||||
const findTable = (schema: string | null, name: string): DbCompletionTable | undefined =>
|
||||
schema
|
||||
? tables.find((t) => eq(t.schema, schema) && eq(t.name, name))
|
||||
: (tables.find((t) => eq(t.schema, defaultSchema) && eq(t.name, name)) ?? tables.find((t) => eq(t.name, name)));
|
||||
const columnsOf = (r: TableRef): Array<Pick<DbCompletionColumn, "name"> & Partial<DbCompletionColumn>> =>
|
||||
r.columns ? r.columns.map((name) => ({ name })) : (findTable(r.schema, r.name)?.columns ?? []);
|
||||
const refName = (r: TableRef) => r.alias ?? r.name;
|
||||
/** Owner shown after a column: the alias, else the table qualified by its schema (IDEA's `(main.users)`). */
|
||||
const ownerOf = (r: TableRef) => {
|
||||
if (r.alias || r.kind !== "table") return refName(r);
|
||||
const t = findTable(r.schema, r.name);
|
||||
return t?.schema ? `${t.schema}.${t.name}` : r.name;
|
||||
};
|
||||
/** One candidate inserting several columns: `id, email, name`. */
|
||||
const columnsCandidate = (r: TableRef, names: readonly string[], boost: number, qualify: boolean): SqlCandidate => ({
|
||||
label: names.join(", "),
|
||||
kind: "columns",
|
||||
tail: `(${ownerOf(r)})`,
|
||||
insert: names.map((n) => (qualify ? `${q(refName(r))}.${q(n)}` : q(n))).join(", "),
|
||||
boost,
|
||||
});
|
||||
const fkColumns = new Set(
|
||||
(model?.foreignKeys ?? []).flatMap((k) => k.columns.map((c) => `${k.schema}\u0000${k.table}\u0000${c}`)),
|
||||
);
|
||||
const isFk = (t: DbCompletionTable | undefined, c: string) =>
|
||||
!!t && fkColumns.has(`${t.schema}\u0000${t.name}\u0000${c}`);
|
||||
/** How a table is written from here: unqualified in the default schema. */
|
||||
const tableText = (t: DbCompletionTable) =>
|
||||
eq(t.schema, defaultSchema) || driver === "sqlite" ? q(t.name) : `${q(t.schema)}.${q(t.name)}`;
|
||||
const tableCandidate = (t: DbCompletionTable, boost: number, qualified = false): SqlCandidate => ({
|
||||
label: t.name,
|
||||
kind: t.kind === "view" ? "view" : "table",
|
||||
tail: t.schema ? `(${t.schema})` : undefined,
|
||||
type: sourceName,
|
||||
insert: qualified ? `${q(t.schema)}.${q(t.name)}` : tableText(t),
|
||||
boost,
|
||||
doc: tableDoc(t),
|
||||
});
|
||||
const columnCandidate = (
|
||||
c: Partial<DbCompletionColumn> & { name: string },
|
||||
owner: string,
|
||||
table: DbCompletionTable | undefined,
|
||||
boost: number,
|
||||
insert?: string,
|
||||
): SqlCandidate => ({
|
||||
label: c.name,
|
||||
kind: "column",
|
||||
tail: `(${owner})`,
|
||||
type: c.dataType ? c.dataType.toUpperCase() : undefined,
|
||||
insert: insert ?? q(c.name),
|
||||
boost,
|
||||
primaryKey: !!c.primaryKey,
|
||||
foreignKey: isFk(table, c.name),
|
||||
doc: c.dataType
|
||||
? `${owner}.${c.name} ${c.dataType}${c.nullable === false ? " NOT NULL" : ""}${c.primaryKey ? " PRIMARY KEY" : ""}`
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// ---- `qualifier.` ----------------------------------------------------------
|
||||
if (a.qualifier) {
|
||||
const [first, second] = a.qualifier;
|
||||
if (a.qualifier.length === 1 && ctx.kind !== "table") {
|
||||
const ref = a.refs.find((r) => eq(r.alias, first) || (!r.alias && eq(r.name, first)));
|
||||
if (ref) {
|
||||
const t = ref.kind === "table" ? findTable(ref.schema, ref.name) : undefined;
|
||||
return columnsOf(ref).map((c) => columnCandidate(c, refName(ref), t, 50));
|
||||
}
|
||||
}
|
||||
if (a.qualifier.length === 1) {
|
||||
const inSchema = tables.filter((t) => eq(t.schema, first));
|
||||
if (inSchema.length) return inSchema.map((t) => ({ ...tableCandidate(t, 30), insert: q(t.name) }));
|
||||
const t = findTable(null, first!);
|
||||
if (t && ctx.kind !== "table") return t.columns.map((c) => columnCandidate(c, t.name, t, 50));
|
||||
return [];
|
||||
}
|
||||
const t = findTable(first!, second!);
|
||||
return t ? t.columns.map((c) => columnCandidate(c, t.name, t, 50)) : [];
|
||||
}
|
||||
|
||||
switch (ctx.kind) {
|
||||
case "statement":
|
||||
// Below the live templates (`sel`, `ins`…), as in IDEA.
|
||||
keywords(statementStarters(driver), -10);
|
||||
return out;
|
||||
|
||||
case "table": {
|
||||
const taken = new Set(a.refs.flatMap((r) => [refName(r).toLowerCase(), r.name.toLowerCase()]));
|
||||
if (ctx.join) out.push(...joinCandidates(a, model, driver, taken, kw));
|
||||
for (const c of a.ctes) out.push({ label: c.name, kind: "table", tail: "(CTE)", boost: 40 });
|
||||
const schemas = new Set<string>();
|
||||
for (const t of tables) {
|
||||
schemas.add(t.schema);
|
||||
const local = eq(t.schema, defaultSchema) || driver === "sqlite";
|
||||
out.push(tableCandidate(t, local ? (t.kind === "view" ? 28 : 30) : 0, !local));
|
||||
}
|
||||
for (const s of schemas)
|
||||
if (!eq(s, defaultSchema) || schemas.size > 1)
|
||||
out.push({ label: s, kind: "schema", type: sourceName, insert: `${q(s)}.`, retrigger: true, boost: 5 });
|
||||
if (ctx.join) keywords(["LATERAL"], -10);
|
||||
return out;
|
||||
}
|
||||
|
||||
case "afterTable": {
|
||||
if (!ctx.ref.alias && ctx.ref.kind === "table" && a.verb !== "INSERT" && a.verb !== "REPLACE") {
|
||||
// IDEA suggests an alias first.
|
||||
const taken = new Set(a.refs.filter((r) => r !== ctx.ref).flatMap((r) => [refName(r).toLowerCase(), r.name.toLowerCase()]));
|
||||
out.push({ label: aliasFor(ctx.ref.name, taken), kind: "alias", boost: 45 });
|
||||
}
|
||||
if (a.verb === "UPDATE") keywords(["SET", "AS"], 20);
|
||||
else if (a.verb === "INSERT" || a.verb === "REPLACE") keywords(["VALUES", "SELECT", "DEFAULT VALUES"], 20);
|
||||
else if (a.verb === "DELETE") keywords(["WHERE", "AS", ...(driver === "postgres" ? ["USING"] : [])], 20);
|
||||
else {
|
||||
if (ctx.ref.viaJoin) {
|
||||
out.push(
|
||||
...conditionCandidates(a, model, ctx.ref, kw).map((c) => ({
|
||||
...c,
|
||||
label: `${kw("ON")} ${c.label}`,
|
||||
insert: `${kw("ON")} ${c.insert ?? c.label}`,
|
||||
})),
|
||||
);
|
||||
keywords(["ON", "USING"], 25);
|
||||
}
|
||||
keywords(["AS", "WHERE", ...joinKeywords(driver), "GROUP BY", "ORDER BY", "LIMIT", "UNION", "UNION ALL"], 10);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case "joinCondition":
|
||||
out.push(...conditionCandidates(a, model, ctx.ref, kw));
|
||||
columnContext("on");
|
||||
return out;
|
||||
|
||||
case "insertColumns": {
|
||||
const t = ctx.target.kind === "table" ? findTable(ctx.target.schema, ctx.target.name) : undefined;
|
||||
const all = columnsOf(ctx.target);
|
||||
// The columns an INSERT needs (no default), then all of them.
|
||||
const required = all.filter((c) => !c.hasDefault).map((c) => c.name);
|
||||
if (required.length > 1) out.push(columnsCandidate(ctx.target, required, 60, false));
|
||||
if (all.length > 1 && all.length !== required.length) out.push(columnsCandidate(ctx.target, all.map((c) => c.name), 55, false));
|
||||
out.push(...all.map((c) => columnCandidate(c, ownerOf(ctx.target), t, 50)));
|
||||
keywords(["SELECT", "VALUES"], 5);
|
||||
return out;
|
||||
}
|
||||
|
||||
case "afterInsertColumns":
|
||||
keywords(["VALUES", "SELECT"], 20);
|
||||
return out;
|
||||
|
||||
case "column":
|
||||
columnContext(ctx.clause);
|
||||
return out;
|
||||
}
|
||||
|
||||
function columnContext(clause: Clause) {
|
||||
const visible = a.refs;
|
||||
// `SET |` / `SET a = 1, |`: only the columns being assigned.
|
||||
const assigning = clause === "set" && (a.previous === "SET" || a.previous === ",");
|
||||
const inner = visible.filter((r) => r.depth === 0);
|
||||
// Column names in several tables of the block are inserted qualified.
|
||||
const count = new Map<string, number>();
|
||||
for (const r of inner)
|
||||
for (const c of columnsOf(r)) count.set(c.name.toLowerCase(), (count.get(c.name.toLowerCase()) ?? 0) + 1);
|
||||
const seen = new Set<string>();
|
||||
for (const r of visible) {
|
||||
const t = r.kind === "table" ? findTable(r.schema, r.name) : undefined;
|
||||
const owner = ownerOf(r);
|
||||
for (const c of columnsOf(r)) {
|
||||
const key = `${owner}\u0000${c.name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const ambiguous = r.depth > 0 || (count.get(c.name.toLowerCase()) ?? 0) > 1;
|
||||
out.push(
|
||||
columnCandidate(c, owner, t, r.depth === 0 ? 40 : 20, ambiguous ? `${q(refName(r))}.${q(c.name)}` : undefined),
|
||||
);
|
||||
}
|
||||
if (assigning) continue;
|
||||
out.push({ label: refName(r), kind: "alias", boost: 35 });
|
||||
// The whole column list (IDEA's "expand *" item in the select list).
|
||||
const names = columnsOf(r).map((c) => c.name);
|
||||
if (clause === "select" && r.depth === 0 && names.length > 1) out.push(columnsCandidate(r, names, 30, inner.length > 1));
|
||||
}
|
||||
if (assigning) return;
|
||||
if (!inner.length && a.fromInsert !== null && clause === "select") {
|
||||
// No FROM yet: any column, and accepting it adds the FROM.
|
||||
let n = 0;
|
||||
for (const t of tables) {
|
||||
if (!eq(t.schema, defaultSchema) && driver !== "sqlite") continue;
|
||||
for (const c of t.columns) {
|
||||
if (n++ > MAX_ALL_COLUMNS) break;
|
||||
out.push({ ...columnCandidate(c, t.name, t, 0), addFrom: tableText(t) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const c of a.ctes)
|
||||
if (!visible.some((r) => eq(r.name, c.name))) out.push({ label: c.name, kind: "table", tail: "(CTE)", boost: 0 });
|
||||
const fnBoost = clause === "select" || clause === "having" ? 15 : 8;
|
||||
for (const f of functions(driver)) {
|
||||
const name = lower ? f.name : f.name.toUpperCase();
|
||||
out.push({
|
||||
label: name,
|
||||
kind: "function",
|
||||
tail: `(${f.params})`,
|
||||
type: f.returns?.toUpperCase(),
|
||||
snippet: f.params ? `${name}(\${})` : `${name}()`,
|
||||
boost: f.aggregate && clause !== "select" && clause !== "having" ? fnBoost - 4 : fnBoost,
|
||||
});
|
||||
}
|
||||
keywords(clauseKeywords(clause, driver, a), 5);
|
||||
}
|
||||
}
|
||||
|
||||
function clauseKeywords(clause: Clause, driver: DbDriver, a: Analysis): string[] {
|
||||
const pred = predicateKeywords(driver);
|
||||
switch (clause) {
|
||||
case "select":
|
||||
return [
|
||||
"FROM",
|
||||
"DISTINCT",
|
||||
"AS",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
"NULL",
|
||||
"NOT",
|
||||
"EXISTS",
|
||||
"CAST",
|
||||
...(a.fromInsert !== null ? [] : ["WHERE"]),
|
||||
];
|
||||
case "where":
|
||||
case "on":
|
||||
case "having":
|
||||
return [...pred, "GROUP BY", "ORDER BY", "LIMIT"];
|
||||
case "group":
|
||||
return ["HAVING", "ORDER BY", "LIMIT"];
|
||||
case "order":
|
||||
return ["ASC", "DESC", "LIMIT", "OFFSET", ...(driver === "mysql" ? [] : ["NULLS FIRST", "NULLS LAST"])];
|
||||
case "set":
|
||||
return ["WHERE", "DEFAULT", "NULL"];
|
||||
case "values":
|
||||
return ["DEFAULT", "NULL", "TRUE", "FALSE"];
|
||||
case "limit":
|
||||
return ["OFFSET"];
|
||||
case "returning":
|
||||
return ["AS"];
|
||||
default:
|
||||
return [...pred, ...DATA_TYPES[driver].map((t) => t.toUpperCase())];
|
||||
}
|
||||
}
|
||||
|
||||
type Kw = (k: string) => string;
|
||||
|
||||
/**
|
||||
* `JOIN |` → whole join clauses to tables related to the ones in scope: by
|
||||
* foreign key either way, else by equal column names (IDEA does both).
|
||||
*/
|
||||
function joinCandidates(
|
||||
a: Analysis,
|
||||
model: DbCompletion | null,
|
||||
driver: DbDriver,
|
||||
taken: Set<string>,
|
||||
kw: Kw,
|
||||
): SqlCandidate[] {
|
||||
if (!model) return [];
|
||||
const out: SqlCandidate[] = [];
|
||||
const q = (n: string) => quoteIdent(n, driver);
|
||||
const seen = new Set<string>();
|
||||
const local = (s: string) => eq(s, model.defaultSchema) || driver === "sqlite";
|
||||
const tableText = (schema: string, name: string) => (local(schema) ? q(name) : `${q(schema)}.${q(name)}`);
|
||||
for (const r of a.refs.filter((x) => x.depth === 0 && x.kind === "table")) {
|
||||
const rt = model.tables.find((t) => eq(t.name, r.name) && (r.schema ? eq(t.schema, r.schema) : true));
|
||||
if (!rt) continue;
|
||||
const ra = r.alias ?? r.name;
|
||||
const add = (schema: string, name: string, pairs: Array<[string, string]>, boost: number) => {
|
||||
const key = `${schema}.${name}:${pairs.join()}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const alias = aliasFor(name, taken);
|
||||
const cond = pairs.map(([mine, theirs]) => `${q(ra)}.${q(mine)} = ${alias}.${q(theirs)}`).join(` ${kw("AND")} `);
|
||||
const text = `${tableText(schema, name)} ${alias} ${kw("ON")} ${cond}`;
|
||||
out.push({
|
||||
label: text,
|
||||
kind: "join",
|
||||
boost,
|
||||
type: undefined,
|
||||
doc: `Join ${name} on ${pairs.map(([m, t]) => `${ra}.${m} = ${t}`).join(", ")}`,
|
||||
});
|
||||
};
|
||||
for (const fk of model.foreignKeys) {
|
||||
// r references it: `orders o JOIN users u ON u.id = o.user_id`.
|
||||
if (eq(fk.schema, rt.schema) && eq(fk.table, rt.name))
|
||||
add(
|
||||
fk.refSchema,
|
||||
fk.refTable,
|
||||
fk.columns.map((c, i) => [c, fk.refColumns[i]!]),
|
||||
60,
|
||||
);
|
||||
// It references r: `users u JOIN orders o ON o.user_id = u.id`.
|
||||
if (eq(fk.refSchema, rt.schema) && eq(fk.refTable, rt.name))
|
||||
add(
|
||||
fk.schema,
|
||||
fk.table,
|
||||
fk.refColumns.map((c, i) => [c, fk.columns[i]!]),
|
||||
60,
|
||||
);
|
||||
}
|
||||
// Same-named key-ish columns (`user_id` ↔ `user_id`) with no declared key.
|
||||
const keyish = rt.columns.filter((c) => c.primaryKey || /_id$/i.test(c.name));
|
||||
for (const t of model.tables) {
|
||||
if (t === rt || !local(t.schema)) continue;
|
||||
for (const c of keyish) {
|
||||
if (c.primaryKey && eq(c.name, "id")) continue;
|
||||
if (t.columns.some((x) => eq(x.name, c.name))) add(t.schema, t.name, [[c.name, c.name]], 45);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `JOIN t x ON |` → conditions relating `ref` to the other tables in scope. */
|
||||
function conditionCandidates(a: Analysis, model: DbCompletion | null, ref: TableRef, kw: Kw): SqlCandidate[] {
|
||||
if (!model || ref.kind !== "table") return [];
|
||||
const out: SqlCandidate[] = [];
|
||||
const find = (r: TableRef) =>
|
||||
model.tables.find((t) => eq(t.name, r.name) && (r.schema ? eq(t.schema, r.schema) : true));
|
||||
const mine = find(ref);
|
||||
if (!mine) return out;
|
||||
const ma = ref.alias ?? ref.name;
|
||||
const seen = new Set<string>();
|
||||
const add = (pairs: Array<[string, string]>, other: string, boost: number) => {
|
||||
const text = pairs.map(([m, o]) => `${other}.${o} = ${ma}.${m}`).join(` ${kw("AND")} `);
|
||||
if (seen.has(text)) return;
|
||||
seen.add(text);
|
||||
out.push({ label: text, kind: "join", boost });
|
||||
};
|
||||
for (const r of a.refs) {
|
||||
if (r === ref || r.depth !== 0 || r.from > ref.from) continue;
|
||||
const t = r.kind === "table" ? find(r) : undefined;
|
||||
const oa = r.alias ?? r.name;
|
||||
if (t) {
|
||||
for (const fk of model.foreignKeys) {
|
||||
if (
|
||||
eq(fk.table, mine.name) &&
|
||||
eq(fk.schema, mine.schema) &&
|
||||
eq(fk.refTable, t.name) &&
|
||||
eq(fk.refSchema, t.schema)
|
||||
)
|
||||
add(
|
||||
fk.columns.map((c, i) => [c, fk.refColumns[i]!]),
|
||||
oa,
|
||||
60,
|
||||
);
|
||||
if (
|
||||
eq(fk.table, t.name) &&
|
||||
eq(fk.schema, t.schema) &&
|
||||
eq(fk.refTable, mine.name) &&
|
||||
eq(fk.refSchema, mine.schema)
|
||||
)
|
||||
add(
|
||||
fk.refColumns.map((c, i) => [c, fk.columns[i]!]),
|
||||
oa,
|
||||
60,
|
||||
);
|
||||
}
|
||||
}
|
||||
const theirs = t?.columns.map((c) => c.name) ?? r.columns ?? [];
|
||||
for (const c of mine.columns)
|
||||
if ((c.primaryKey && !eq(c.name, "id")) || /_id$/i.test(c.name))
|
||||
if (theirs.some((x) => eq(x, c.name))) add([[c.name, c.name]], oa, 45);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tableDoc(t: DbCompletionTable): string {
|
||||
const width = Math.max(...t.columns.map((c) => c.name.length), 4);
|
||||
const cols = t.columns.map(
|
||||
(c) => ` ${c.name.padEnd(width)} ${c.dataType}${c.nullable ? "" : " NOT NULL"}${c.primaryKey ? " PK" : ""}`,
|
||||
);
|
||||
return `${t.kind === "view" ? "VIEW" : "TABLE"} ${t.schema}.${t.name}\n${cols.join("\n")}`;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/** Per-dialect SQL vocabulary: reserved words, statement starters and built-in functions. */
|
||||
import type { DbDriver } from "@/platform/ipc";
|
||||
|
||||
export interface SqlFunction {
|
||||
name: string;
|
||||
/** Parameter list shown in the popup, without parentheses. */
|
||||
params: string;
|
||||
returns?: string;
|
||||
aggregate?: boolean;
|
||||
}
|
||||
|
||||
/** Words that must be quoted when used as identifiers (the common core of the three dialects). */
|
||||
const RESERVED = new Set(
|
||||
(
|
||||
"ALL AND ANY AS ASC BETWEEN BY CASE CAST CHECK COLLATE COLUMN CONSTRAINT CREATE CROSS CURRENT_DATE " +
|
||||
"CURRENT_TIME CURRENT_TIMESTAMP DEFAULT DELETE DESC DISTINCT DROP ELSE END EXCEPT EXISTS FALSE FETCH FOR " +
|
||||
"FOREIGN FROM FULL GRANT GROUP HAVING IN INNER INSERT INTERSECT INTO IS JOIN LEFT LIKE LIMIT NATURAL NOT " +
|
||||
"NULL OFFSET ON OR ORDER OUTER PRIMARY REFERENCES RIGHT SELECT SET TABLE THEN TO TRUE UNION UNIQUE UPDATE " +
|
||||
"USING VALUES WHEN WHERE WINDOW WITH"
|
||||
).split(" "),
|
||||
);
|
||||
|
||||
export function isReserved(word: string): boolean {
|
||||
return RESERVED.has(word.toUpperCase());
|
||||
}
|
||||
|
||||
/** Clause keywords: a word in this set ends a table alias or expression. */
|
||||
export const CLAUSE_WORDS = new Set(
|
||||
(
|
||||
"SELECT FROM WHERE GROUP ORDER HAVING LIMIT OFFSET UNION INTERSECT EXCEPT JOIN INNER LEFT RIGHT FULL CROSS " +
|
||||
"NATURAL OUTER ON USING SET VALUES RETURNING INTO WINDOW FETCH FOR LATERAL AS WITH UPDATE DELETE INSERT " +
|
||||
"AND OR NOT WHEN THEN ELSE END CASE BY ASC DESC IS IN LIKE ILIKE BETWEEN EXISTS NULL STRAIGHT_JOIN"
|
||||
).split(" "),
|
||||
);
|
||||
|
||||
export function statementStarters(driver: DbDriver): string[] {
|
||||
const common = [
|
||||
"SELECT",
|
||||
"INSERT INTO",
|
||||
"UPDATE",
|
||||
"DELETE FROM",
|
||||
"WITH",
|
||||
"CREATE TABLE",
|
||||
"CREATE INDEX",
|
||||
"CREATE VIEW",
|
||||
"ALTER TABLE",
|
||||
"DROP TABLE",
|
||||
"DROP VIEW",
|
||||
"DROP INDEX",
|
||||
"EXPLAIN",
|
||||
"BEGIN",
|
||||
"COMMIT",
|
||||
"ROLLBACK",
|
||||
];
|
||||
if (driver === "sqlite") return [...common, "PRAGMA", "VACUUM", "ATTACH DATABASE", "REPLACE INTO", "CREATE TRIGGER"];
|
||||
if (driver === "mysql")
|
||||
return [
|
||||
...common,
|
||||
"SHOW TABLES",
|
||||
"SHOW CREATE TABLE",
|
||||
"DESCRIBE",
|
||||
"TRUNCATE TABLE",
|
||||
"REPLACE INTO",
|
||||
"USE",
|
||||
"CREATE DATABASE",
|
||||
"START TRANSACTION",
|
||||
];
|
||||
return [
|
||||
...common,
|
||||
"TRUNCATE",
|
||||
"CREATE SCHEMA",
|
||||
"CREATE FUNCTION",
|
||||
"CREATE MATERIALIZED VIEW",
|
||||
"SET search_path TO",
|
||||
"COPY",
|
||||
"ANALYZE",
|
||||
"VACUUM",
|
||||
];
|
||||
}
|
||||
|
||||
export function joinKeywords(driver: DbDriver): string[] {
|
||||
const j = ["JOIN", "LEFT JOIN", "INNER JOIN", "CROSS JOIN", "RIGHT JOIN"];
|
||||
if (driver !== "mysql") j.push("FULL JOIN");
|
||||
if (driver === "postgres") j.push("JOIN LATERAL", "LEFT JOIN LATERAL");
|
||||
return j;
|
||||
}
|
||||
|
||||
/** Comparison / predicate keywords of WHERE, ON and HAVING. */
|
||||
export function predicateKeywords(driver: DbDriver): string[] {
|
||||
const k = [
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"IS NULL",
|
||||
"IS NOT NULL",
|
||||
"LIKE",
|
||||
"BETWEEN",
|
||||
"EXISTS",
|
||||
"NULL",
|
||||
"TRUE",
|
||||
"FALSE",
|
||||
"CASE",
|
||||
];
|
||||
if (driver === "postgres") k.push("ILIKE", "IS DISTINCT FROM", "ANY", "SIMILAR TO");
|
||||
if (driver === "mysql") k.push("REGEXP");
|
||||
if (driver === "sqlite") k.push("GLOB", "REGEXP");
|
||||
return k;
|
||||
}
|
||||
|
||||
export const DATA_TYPES: Record<DbDriver, string[]> = {
|
||||
sqlite: ["INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME"],
|
||||
postgres: [
|
||||
"integer",
|
||||
"bigint",
|
||||
"smallint",
|
||||
"serial",
|
||||
"bigserial",
|
||||
"text",
|
||||
"varchar",
|
||||
"char",
|
||||
"boolean",
|
||||
"numeric",
|
||||
"real",
|
||||
"double precision",
|
||||
"date",
|
||||
"timestamp",
|
||||
"timestamptz",
|
||||
"time",
|
||||
"interval",
|
||||
"uuid",
|
||||
"json",
|
||||
"jsonb",
|
||||
"bytea",
|
||||
],
|
||||
mysql: [
|
||||
"INT",
|
||||
"BIGINT",
|
||||
"SMALLINT",
|
||||
"TINYINT",
|
||||
"VARCHAR",
|
||||
"CHAR",
|
||||
"TEXT",
|
||||
"LONGTEXT",
|
||||
"DECIMAL",
|
||||
"DOUBLE",
|
||||
"FLOAT",
|
||||
"DATE",
|
||||
"DATETIME",
|
||||
"TIMESTAMP",
|
||||
"TIME",
|
||||
"JSON",
|
||||
"BLOB",
|
||||
"BOOLEAN",
|
||||
"ENUM",
|
||||
],
|
||||
};
|
||||
|
||||
const f = (name: string, params: string, returns?: string, aggregate?: boolean): SqlFunction => ({
|
||||
name,
|
||||
params,
|
||||
returns,
|
||||
aggregate,
|
||||
});
|
||||
|
||||
const COMMON: SqlFunction[] = [
|
||||
f("count", "*", "bigint", true),
|
||||
f("sum", "expr", undefined, true),
|
||||
f("avg", "expr", undefined, true),
|
||||
f("min", "expr", undefined, true),
|
||||
f("max", "expr", undefined, true),
|
||||
f("coalesce", "value, ...", undefined),
|
||||
f("nullif", "a, b"),
|
||||
f("lower", "text", "text"),
|
||||
f("upper", "text", "text"),
|
||||
f("length", "text", "integer"),
|
||||
f("trim", "text", "text"),
|
||||
f("replace", "text, from, to", "text"),
|
||||
f("substr", "text, start, length", "text"),
|
||||
f("abs", "number"),
|
||||
f("round", "number, digits"),
|
||||
f("cast", "expr AS type"),
|
||||
];
|
||||
|
||||
const FUNCTIONS: Record<DbDriver, SqlFunction[]> = {
|
||||
sqlite: [
|
||||
...COMMON,
|
||||
f("group_concat", "expr, separator", "text", true),
|
||||
f("ifnull", "a, b"),
|
||||
f("iif", "cond, then, else"),
|
||||
f("instr", "text, search", "integer"),
|
||||
f("printf", "format, ...", "text"),
|
||||
f("date", "time, modifier...", "text"),
|
||||
f("datetime", "time, modifier...", "text"),
|
||||
f("strftime", "format, time, modifier...", "text"),
|
||||
f("julianday", "time", "real"),
|
||||
f("json_extract", "json, path"),
|
||||
f("json_object", "key, value, ...", "text"),
|
||||
f("random", "", "integer"),
|
||||
f("typeof", "expr", "text"),
|
||||
f("last_insert_rowid", "", "integer"),
|
||||
f("total", "expr", "real", true),
|
||||
],
|
||||
postgres: [
|
||||
...COMMON,
|
||||
f("string_agg", "expr, delimiter", "text", true),
|
||||
f("array_agg", "expr", "anyarray", true),
|
||||
f("json_agg", "expr", "json", true),
|
||||
f("jsonb_agg", "expr", "jsonb", true),
|
||||
f("bool_and", "expr", "boolean", true),
|
||||
f("bool_or", "expr", "boolean", true),
|
||||
f("now", "", "timestamptz"),
|
||||
f("current_date", "", "date"),
|
||||
f("date_trunc", "field, source", "timestamp"),
|
||||
f("extract", "field FROM source", "numeric"),
|
||||
f("age", "timestamp, timestamp", "interval"),
|
||||
f("to_char", "value, format", "text"),
|
||||
f("to_timestamp", "text, format", "timestamptz"),
|
||||
f("concat", "value, ...", "text"),
|
||||
f("concat_ws", "separator, value, ...", "text"),
|
||||
f("split_part", "text, delimiter, n", "text"),
|
||||
f("regexp_replace", "text, pattern, replacement", "text"),
|
||||
f("position", "substring IN text", "integer"),
|
||||
f("greatest", "value, ..."),
|
||||
f("least", "value, ..."),
|
||||
f("gen_random_uuid", "", "uuid"),
|
||||
f("jsonb_build_object", "key, value, ...", "jsonb"),
|
||||
f("json_build_object", "key, value, ...", "json"),
|
||||
f("unnest", "anyarray", "setof"),
|
||||
f("generate_series", "start, stop, step", "setof"),
|
||||
f("row_number", "", "bigint"),
|
||||
f("rank", "", "bigint"),
|
||||
f("dense_rank", "", "bigint"),
|
||||
f("lag", "value, offset, default"),
|
||||
f("lead", "value, offset, default"),
|
||||
f("pg_size_pretty", "bigint", "text"),
|
||||
],
|
||||
mysql: [
|
||||
...COMMON,
|
||||
f("group_concat", "expr SEPARATOR ','", "text", true),
|
||||
f("ifnull", "a, b"),
|
||||
f("if", "cond, then, else"),
|
||||
f("concat", "value, ...", "text"),
|
||||
f("concat_ws", "separator, value, ...", "text"),
|
||||
f("now", "", "datetime"),
|
||||
f("curdate", "", "date"),
|
||||
f("date_format", "date, format", "text"),
|
||||
f("date_add", "date, INTERVAL n unit", "datetime"),
|
||||
f("date_sub", "date, INTERVAL n unit", "datetime"),
|
||||
f("datediff", "a, b", "int"),
|
||||
f("unix_timestamp", "date", "bigint"),
|
||||
f("from_unixtime", "seconds", "datetime"),
|
||||
f("json_extract", "json, path"),
|
||||
f("json_object", "key, value, ...", "json"),
|
||||
f("json_arrayagg", "expr", "json", true),
|
||||
f("greatest", "value, ..."),
|
||||
f("least", "value, ..."),
|
||||
f("uuid", "", "varchar"),
|
||||
f("last_insert_id", "", "bigint"),
|
||||
f("row_number", "", "bigint"),
|
||||
f("rank", "", "bigint"),
|
||||
],
|
||||
};
|
||||
|
||||
export function functions(driver: DbDriver): SqlFunction[] {
|
||||
return FUNCTIONS[driver];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatSql } from "./format";
|
||||
|
||||
const fmt = (sql: string) => {
|
||||
let out = sql;
|
||||
for (const e of formatSql(sql, "sqlite").reverse()) out = out.slice(0, e.from) + e.insert + out.slice(e.to);
|
||||
return out;
|
||||
};
|
||||
|
||||
describe("formatSql", () => {
|
||||
it("lays out a query like IDEA", () => {
|
||||
expect(
|
||||
fmt("SELECT u.id,u.name , count( * ) AS n FROM users u LEFT JOIN orders o ON o.user_id=u.id WHERE u.id>1 AND o.total BETWEEN 1 AND 5 OR o.status='x' GROUP BY u.id ORDER BY n DESC LIMIT 10;"),
|
||||
).toBe(
|
||||
[
|
||||
"SELECT u.id,",
|
||||
" u.name,",
|
||||
" count(*) AS n",
|
||||
"FROM users u",
|
||||
" LEFT JOIN orders o ON o.user_id = u.id",
|
||||
"WHERE u.id > 1",
|
||||
" AND o.total BETWEEN 1 AND 5",
|
||||
" OR o.status = 'x'",
|
||||
"GROUP BY u.id",
|
||||
"ORDER BY n DESC",
|
||||
"LIMIT 10;",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("aligns subqueries and CTEs after their parenthesis", () => {
|
||||
expect(fmt("with big as (select user_id, total from orders where total > 10) select * from big where exists (select 1 from users u where u.id = big.user_id);")).toBe(
|
||||
[
|
||||
"with big as (select user_id,",
|
||||
" total",
|
||||
" from orders",
|
||||
" where total > 10)",
|
||||
"select *",
|
||||
"from big",
|
||||
"where exists (select 1",
|
||||
" from users u",
|
||||
" where u.id = big.user_id);",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats DML and DDL", () => {
|
||||
expect(fmt("insert into users(email,name) values('a', -1),('b',cast(2 as text));")).toBe(
|
||||
"insert into users (email, name)\nvalues ('a', -1),\n ('b', cast(2 as text));",
|
||||
);
|
||||
expect(fmt("update users set name='x', email = 'y' where id=1;")).toBe(
|
||||
"update users\nset name = 'x',\n email = 'y'\nwhere id = 1;",
|
||||
);
|
||||
expect(fmt("delete from orders where id = 1;")).toBe("delete\nfrom orders\nwhere id = 1;");
|
||||
expect(fmt("create table t (id integer primary key, name varchar(10) not null);")).toBe(
|
||||
"create table t\n(\n id integer primary key,\n name varchar(10) not null\n);",
|
||||
);
|
||||
expect(fmt("select 1 union all select 2;")).toBe("select 1\nunion all\nselect 2;");
|
||||
});
|
||||
|
||||
it("keeps comments, text between statements and the range", () => {
|
||||
const sql = "-- head\nselect a -- why\n, b from t;\n\n\nselect 1;";
|
||||
expect(fmt(sql)).toBe("-- head\nselect a, -- why\n b\nfrom t;\n\n\nselect 1;");
|
||||
const edits = formatSql(sql, "sqlite", { from: sql.length - 2, to: sql.length - 1 });
|
||||
expect(edits).toEqual([{ from: sql.lastIndexOf("select"), to: sql.length, insert: "select 1;" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Reformat Code for SQL, laid out like IDEA's default SQL code style: clauses
|
||||
* on their own lines, select / set / values items one per line aligned after
|
||||
* their keyword, joins indented under FROM, AND / OR of WHERE right-aligned,
|
||||
* subqueries aligned after their parenthesis, CREATE TABLE bodies one
|
||||
* definition per line. Keyword case and comments are kept; each statement is
|
||||
* replaced on its own, so text between statements is left alone.
|
||||
*/
|
||||
import type { DbDriver } from "@/platform/ipc";
|
||||
import { isReserved } from "./dialect";
|
||||
import { lex, type Token } from "./lexer";
|
||||
|
||||
export interface SqlEdit {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
kind: Token["kind"] | "comment";
|
||||
text: string;
|
||||
upper: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** Edits formatting every statement (or those overlapping `range`). */
|
||||
export function formatSql(text: string, driver: DbDriver, range: { from: number; to: number } | null = null): SqlEdit[] {
|
||||
const lexed = lex(text, driver);
|
||||
const items: Item[] = [
|
||||
...lexed.tokens,
|
||||
...lexed.comments.map((c) => ({ kind: "comment" as const, text: text.slice(c.from, c.to), upper: "", from: c.from, to: c.to })),
|
||||
].sort((a, b) => a.from - b.from);
|
||||
|
||||
const edits: SqlEdit[] = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
const flush = (end: number) => {
|
||||
// Comments before a statement stay where they are.
|
||||
let a = start;
|
||||
while (a < end && items[a]!.kind === "comment") a++;
|
||||
if (a >= end) return;
|
||||
const first = items[a]!;
|
||||
const last = items[end - 1]!;
|
||||
if (range && (last.to < range.from || first.from > range.to)) return;
|
||||
const line = text.lastIndexOf("\n", first.from - 1) + 1;
|
||||
const lead = text.slice(line, first.from);
|
||||
const indent = /^[ \t]*$/.test(lead) ? lead : "";
|
||||
edits.push({ from: first.from, to: last.to, insert: formatStatement(items.slice(a, end), indent) });
|
||||
};
|
||||
items.forEach((it, i) => {
|
||||
if (it.kind === "punct" && it.text === "(") depth++;
|
||||
else if (it.kind === "punct" && it.text === ")") depth = Math.max(0, depth - 1);
|
||||
else if (it.kind === "punct" && it.text === ";" && depth === 0) {
|
||||
flush(i + 1);
|
||||
start = i + 1;
|
||||
}
|
||||
});
|
||||
flush(items.length);
|
||||
return edits;
|
||||
}
|
||||
|
||||
const NEWLINE_CLAUSES = new Set(["SELECT", "FROM", "WHERE", "GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", "WINDOW", "RETURNING", "VALUES", "SET", "UNION", "INTERSECT", "EXCEPT"]);
|
||||
const JOIN_START = new Set(["JOIN", "LEFT", "RIGHT", "FULL", "INNER", "CROSS", "NATURAL", "STRAIGHT_JOIN"]);
|
||||
const JOIN_MODIFIERS = new Set(["LEFT", "RIGHT", "FULL", "INNER", "CROSS", "NATURAL", "OUTER"]);
|
||||
/** Where a list item continues: the width of `select `, `from `, `set `, `values `. */
|
||||
const ITEM_COLUMN: Record<string, number> = { SELECT: 7, FROM: 5, SET: 4, VALUES: 7 };
|
||||
/** Keywords written like functions: `cast(x as int)`. */
|
||||
const CALL_KEYWORDS = new Set(["CAST"]);
|
||||
|
||||
interface Frame {
|
||||
kind: "query" | "create" | "plain";
|
||||
/** Column where the block's clauses start. */
|
||||
indent: number;
|
||||
clause: string;
|
||||
/** Tokens written in this frame so far. */
|
||||
count: number;
|
||||
/** A BETWEEN waits for its AND. */
|
||||
between: boolean;
|
||||
}
|
||||
|
||||
function formatStatement(items: Item[], base: string): string {
|
||||
const lines: string[] = [];
|
||||
let line = "";
|
||||
const newline = (column: number) => {
|
||||
lines.push(line.trimEnd());
|
||||
line = " ".repeat(column);
|
||||
};
|
||||
const atLineStart = () => line.trim() === "";
|
||||
const create = isCreateTable(items);
|
||||
const frames: Frame[] = [{ kind: "query", indent: 0, clause: "", count: 0, between: false }];
|
||||
let prev: Item | null = null;
|
||||
let unary = false;
|
||||
|
||||
items.forEach((it, i) => {
|
||||
const frame = frames.at(-1)!;
|
||||
const word = it.kind === "word" ? it.upper : "";
|
||||
const isQuery = frame.kind === "query";
|
||||
|
||||
// Line breaks before the token.
|
||||
const afterLineComment = prev?.kind === "comment" && /^(--|#)/.test(prev.text);
|
||||
if (afterLineComment) {
|
||||
newline(isQuery && frame.clause in ITEM_COLUMN ? frame.indent + ITEM_COLUMN[frame.clause]! : frame.indent);
|
||||
} else if (it.kind === "punct" && it.text === ")" && frame.kind === "create") {
|
||||
newline(frames.at(-2)!.indent);
|
||||
} else if (isQuery && word && frame.count > 0) {
|
||||
if (NEWLINE_CLAUSES.has(word)) {
|
||||
if (!atLineStart()) newline(frame.indent);
|
||||
} else if (word === "ON" && frame.clause === "VALUES") {
|
||||
newline(frame.indent);
|
||||
} else if (JOIN_START.has(word) && !JOIN_MODIFIERS.has(prev?.upper ?? "") && frame.clause === "FROM") {
|
||||
newline(frame.indent + 9);
|
||||
} else if ((word === "AND" || word === "OR") && (frame.clause === "WHERE" || frame.clause === "HAVING")) {
|
||||
if (word === "AND" && frame.between) frame.between = false;
|
||||
else newline(frame.indent + (word === "AND" ? 2 : 3));
|
||||
} else if ((word === "INSERT" || word === "UPDATE" || word === "DELETE") && isP(prev, ")")) {
|
||||
// The statement after `with x as (…)`.
|
||||
newline(frame.indent);
|
||||
}
|
||||
}
|
||||
|
||||
// The token itself; a comma after a line comment moves before it (`a, -- why`).
|
||||
if (afterLineComment && isP(it, ",")) {
|
||||
const last = lines.at(-1)!;
|
||||
const at = last.lastIndexOf(prev!.text);
|
||||
lines[lines.length - 1] = `${last.slice(0, at).trimEnd()}, ${last.slice(at)}`;
|
||||
} else {
|
||||
if (!atLineStart() && spaceBefore(prev, it, items[i - 2], unary)) line += " ";
|
||||
line += it.text;
|
||||
}
|
||||
unary = it.kind === "punct" && (it.text === "-" || it.text === "+") && (!prev || isOperatorLike(prev));
|
||||
|
||||
// Bookkeeping and breaks after the token.
|
||||
frame.count++;
|
||||
if (isQuery && word) {
|
||||
if (NEWLINE_CLAUSES.has(word)) frame.clause = word;
|
||||
else if (word === "ON" && frame.clause === "VALUES") frame.clause = "CONFLICT";
|
||||
if (word === "BETWEEN") frame.between = true;
|
||||
}
|
||||
if (it.kind === "punct" && it.text === "(") {
|
||||
const next = items[i + 1];
|
||||
if (create && frames.length === 1 && isCreateBodyParen(items, i)) {
|
||||
// `create table t\n(\n a int,\n …\n)`.
|
||||
line = line.slice(0, -1).trimEnd();
|
||||
newline(frame.indent);
|
||||
line += "(";
|
||||
frames.push({ kind: "create", indent: frame.indent + 4, clause: "", count: 0, between: false });
|
||||
newline(frame.indent + 4);
|
||||
} else if (next?.kind === "word" && (next.upper === "SELECT" || next.upper === "WITH")) {
|
||||
frames.push({ kind: "query", indent: line.length, clause: "", count: 0, between: false });
|
||||
} else {
|
||||
frames.push({ kind: "plain", indent: frame.indent, clause: "", count: 0, between: false });
|
||||
}
|
||||
} else if (it.kind === "punct" && it.text === ")" && frames.length > 1) {
|
||||
frames.pop();
|
||||
} else if (it.kind === "punct" && it.text === ",") {
|
||||
if (atLineStart()) {
|
||||
// Already broken after a line comment.
|
||||
} else if (frame.kind === "create") newline(frame.indent);
|
||||
else if (isQuery && frame.clause in ITEM_COLUMN) newline(frame.indent + ITEM_COLUMN[frame.clause]!);
|
||||
}
|
||||
prev = it;
|
||||
});
|
||||
lines.push(line.trimEnd());
|
||||
return lines.map((l, i) => (i === 0 ? l.trimStart() : l ? base + l : l)).join("\n");
|
||||
}
|
||||
|
||||
const isP = (t: Item | null | undefined, p: string) => !!t && t.kind === "punct" && t.text === p;
|
||||
|
||||
function isOperatorLike(t: Item): boolean {
|
||||
if (t.kind === "punct") return t.text !== ")";
|
||||
return t.kind === "word" && isReserved(t.text);
|
||||
}
|
||||
|
||||
function spaceBefore(prev: Item | null, it: Item, beforePrev: Item | undefined, unary: boolean): boolean {
|
||||
if (!prev) return false;
|
||||
if (unary) return false;
|
||||
if (it.kind === "comment" || prev.kind === "comment") return true;
|
||||
if (it.kind === "punct" && [",", ";", ")", ".", "::"].includes(it.text)) return false;
|
||||
if (prev.kind === "punct" && ["(", ".", "::"].includes(prev.text)) return false;
|
||||
if (it.kind === "punct" && it.text === "(") {
|
||||
if (prev.kind === "word" && CALL_KEYWORDS.has(prev.upper)) return false;
|
||||
if (prev.kind === "word" && !isReserved(prev.text)) return isInsertTarget(beforePrev);
|
||||
if (prev.kind === "quoted") return isInsertTarget(beforePrev);
|
||||
return true;
|
||||
}
|
||||
if (it.kind === "punct" && it.text === "*" && prev.kind === "punct" && prev.text === ".") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `into users (` — a column list, written with a space. */
|
||||
function isInsertTarget(beforePrev: Item | undefined): boolean {
|
||||
return beforePrev?.kind === "word" && (beforePrev.upper === "INTO" || beforePrev.upper === "TABLE" || beforePrev.upper === "EXISTS");
|
||||
}
|
||||
|
||||
function isCreateTable(items: Item[]): boolean {
|
||||
const words = items.filter((t) => t.kind === "word").slice(0, 3).map((t) => t.upper);
|
||||
return words[0] === "CREATE" && words.includes("TABLE");
|
||||
}
|
||||
|
||||
/** The first top-level `(` of `CREATE TABLE name (`, not `AS (select…)`. */
|
||||
function isCreateBodyParen(items: Item[], i: number): boolean {
|
||||
for (let j = 0; j < i; j++) if (isP(items[j], "(")) return false;
|
||||
const prev = items[i - 1];
|
||||
return !!prev && (prev.kind === "word" || prev.kind === "quoted") && prev.upper !== "AS";
|
||||
}
|
||||
|
||||
/** `text` with every statement formatted. */
|
||||
export function formatted(text: string, driver: DbDriver): string {
|
||||
let out = text;
|
||||
for (const e of formatSql(text, driver).reverse()) out = out.slice(0, e.from) + e.insert + out.slice(e.to);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* SQL tokens for completion and resolution: words, quoted identifiers, string
|
||||
* literals, numbers and punctuation, with comments and whitespace dropped.
|
||||
* Dialect-aware (MySQL backticks / `#` comments / backslash escapes,
|
||||
* PostgreSQL dollar quoting). Offsets are UTF-16, like the editor's.
|
||||
*/
|
||||
import type { DbDriver } from "@/platform/ipc";
|
||||
|
||||
export type TokenKind = "word" | "quoted" | "string" | "number" | "param" | "punct";
|
||||
|
||||
export interface Token {
|
||||
kind: TokenKind;
|
||||
/** Source text (quotes included for `quoted` / `string`). */
|
||||
text: string;
|
||||
from: number;
|
||||
to: number;
|
||||
/** Upper-cased `text` of words, for keyword checks. */
|
||||
upper: string;
|
||||
}
|
||||
|
||||
export interface Lexed {
|
||||
tokens: Token[];
|
||||
/** Comments; a caret at `to` is still inside line comments and unterminated block comments. */
|
||||
comments: Array<{ from: number; to: number; open: boolean }>;
|
||||
}
|
||||
|
||||
const WORD_START = /[\p{L}_]/u;
|
||||
const WORD_PART = /[\p{L}\p{N}_$]/u;
|
||||
const TWO_CHAR = new Set(["::", "<=", ">=", "<>", "!=", "||", "->", "=>", ":="]);
|
||||
|
||||
export function lex(text: string, driver: DbDriver = "sqlite"): Lexed {
|
||||
const mysql = driver === "mysql";
|
||||
const tokens: Token[] = [];
|
||||
const comments: Lexed["comments"] = [];
|
||||
const n = text.length;
|
||||
let i = 0;
|
||||
const push = (kind: TokenKind, from: number, to: number) => {
|
||||
const t = text.slice(from, to);
|
||||
tokens.push({ kind, text: t, from, to, upper: kind === "word" ? t.toUpperCase() : t });
|
||||
};
|
||||
/** End of a quoted run starting at `i` (closing char doubled = escaped). */
|
||||
const quoted = (start: number, close: string, backslash: boolean): number => {
|
||||
let j = start + 1;
|
||||
while (j < n) {
|
||||
const c = text[j]!;
|
||||
if (backslash && c === "\\") j += 2;
|
||||
else if (c === close) {
|
||||
if (text[j + 1] === close) j += 2;
|
||||
else return j + 1;
|
||||
} else j++;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
while (i < n) {
|
||||
const c = text[i]!;
|
||||
const next = text[i + 1];
|
||||
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
||||
i++;
|
||||
} else if ((c === "-" && next === "-") || (c === "#" && mysql)) {
|
||||
const end = text.indexOf("\n", i);
|
||||
const to = end < 0 ? n : end;
|
||||
comments.push({ from: i, to, open: true });
|
||||
i = to;
|
||||
} else if (c === "/" && next === "*") {
|
||||
const end = text.indexOf("*/", i + 2);
|
||||
const to = end < 0 ? n : end + 2;
|
||||
comments.push({ from: i, to, open: end < 0 });
|
||||
i = to;
|
||||
} else if (c === "'") {
|
||||
const to = quoted(i, "'", mysql);
|
||||
push("string", i, to);
|
||||
i = to;
|
||||
} else if (c === '"') {
|
||||
const to = quoted(i, '"', false);
|
||||
push(mysql ? "string" : "quoted", i, to);
|
||||
i = to;
|
||||
} else if (c === "`" && mysql) {
|
||||
const to = quoted(i, "`", false);
|
||||
push("quoted", i, to);
|
||||
i = to;
|
||||
} else if (c === "$" && driver === "postgres") {
|
||||
const m = /^\$([\p{L}_][\p{L}\p{N}_]*)?\$/u.exec(text.slice(i, i + 64));
|
||||
if (m) {
|
||||
const tag = m[0];
|
||||
const close = text.indexOf(tag, i + tag.length);
|
||||
const to = close < 0 ? n : close + tag.length;
|
||||
push("string", i, to);
|
||||
i = to;
|
||||
} else {
|
||||
let j = i + 1;
|
||||
while (j < n && /\d/.test(text[j]!)) j++;
|
||||
push("param", i, j);
|
||||
i = j;
|
||||
}
|
||||
} else if (/\d/.test(c) || (c === "." && next !== undefined && /\d/.test(next))) {
|
||||
let j = i + 1;
|
||||
while (j < n && /[\d.eE_xXa-fA-F]/.test(text[j]!)) j++;
|
||||
push("number", i, j);
|
||||
i = j;
|
||||
} else if (WORD_START.test(c)) {
|
||||
let j = i + 1;
|
||||
while (j < n && WORD_PART.test(text[j]!)) j++;
|
||||
push("word", i, j);
|
||||
i = j;
|
||||
} else if (
|
||||
(c === "?" || c === ":" || c === "@") &&
|
||||
next !== undefined &&
|
||||
WORD_START.test(next) &&
|
||||
!(c === ":" && text[i - 1] === ":")
|
||||
) {
|
||||
let j = i + 1;
|
||||
while (j < n && WORD_PART.test(text[j]!)) j++;
|
||||
push("param", i, j);
|
||||
i = j;
|
||||
} else {
|
||||
const two = text.slice(i, i + 2);
|
||||
const len = TWO_CHAR.has(two) ? 2 : 1;
|
||||
push("punct", i, i + len);
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
return { tokens, comments };
|
||||
}
|
||||
|
||||
/** Whether `pos` lies inside a string literal, quoted identifier or comment (not at its edge). */
|
||||
export function inLiteral(lexed: Lexed, pos: number): "string" | "quoted" | "comment" | null {
|
||||
for (const c of lexed.comments) if (pos > c.from && (pos < c.to || (c.open && pos === c.to))) return "comment";
|
||||
for (const t of lexed.tokens) {
|
||||
if (t.from >= pos) break;
|
||||
if ((t.kind === "string" || t.kind === "quoted") && pos > t.from && (pos < t.to || !closed(t))) return t.kind;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a quoted token has its closing quote. */
|
||||
function closed(t: Token): boolean {
|
||||
if (t.text.length < 2) return false;
|
||||
const open = t.text[0]!;
|
||||
if (open === "$") {
|
||||
const tag = /^\$[\p{L}\p{N}_]*\$/u.exec(t.text)?.[0] ?? "$";
|
||||
return t.text.length >= tag.length * 2 && t.text.endsWith(tag);
|
||||
}
|
||||
return t.text.endsWith(open);
|
||||
}
|
||||
|
||||
/** The identifier a word / quoted token names (quotes removed, doubled quotes collapsed). */
|
||||
export function identName(t: Token): string {
|
||||
if (t.kind !== "quoted") return t.text;
|
||||
const q = t.text[0]!;
|
||||
const body = t.text.endsWith(q) && t.text.length > 1 ? t.text.slice(1, -1) : t.text.slice(1);
|
||||
return body.split(q + q).join(q);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Query parameters of console statements (`:id`, `?`, `?1`, `@name`, `$1`),
|
||||
* filled in before execution as IDEA does: each value is inserted as SQL
|
||||
* text, so strings carry their quotes. Named parameters share one value;
|
||||
* every bare `?` is its own.
|
||||
*/
|
||||
import type { DbDriver } from "@/platform/ipc";
|
||||
import { lex } from "./lexer";
|
||||
|
||||
export interface SqlParam {
|
||||
/** Shown in the dialog and the key of its value (`:id`, `?2`). */
|
||||
name: string;
|
||||
from: number;
|
||||
to: number;
|
||||
/** A bare `?`, numbered in order. */
|
||||
positional?: boolean;
|
||||
}
|
||||
|
||||
/** Parameters of `text`; bare `?` are numbered from `numbered + 1` (earlier statements' count). */
|
||||
export function findParams(text: string, driver: DbDriver, numbered = 0): SqlParam[] {
|
||||
const toks = lex(text, driver).tokens;
|
||||
const out: SqlParam[] = [];
|
||||
let positional = numbered;
|
||||
toks.forEach((t, i) => {
|
||||
if (t.kind === "param") {
|
||||
// MySQL `@var` / `@@var` are session variables.
|
||||
if (driver === "mysql" && t.text.startsWith("@")) return;
|
||||
if (toks[i - 1]?.text === "@") return;
|
||||
out.push({ name: t.text, from: t.from, to: t.to });
|
||||
} else if (t.kind === "punct" && t.text === "?" && driver !== "postgres") {
|
||||
// PostgreSQL's `?` is a jsonb operator; its parameters are `$1`.
|
||||
const n = toks[i + 1];
|
||||
if (n?.kind === "number" && n.from === t.to) out.push({ name: `?${n.text}`, from: t.from, to: n.to });
|
||||
else out.push({ name: `?${++positional}`, from: t.from, to: t.to, positional: true });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `text` with each parameter replaced by its value (empty: `NULL`). */
|
||||
export function bindParams(text: string, params: readonly SqlParam[], values: Readonly<Record<string, string>>): string {
|
||||
let out = "";
|
||||
let at = 0;
|
||||
for (const p of params) {
|
||||
out += text.slice(at, p.from) + (values[p.name]?.trim() || "NULL");
|
||||
at = p.to;
|
||||
}
|
||||
return out + text.slice(at);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Name resolution over console SQL: what an identifier refers to (table, CTE,
|
||||
* alias, column) for inspections (IDEA's "Unable to resolve…"), Go to
|
||||
* Declaration and Quick Documentation. Built on the caret analysis, so it is
|
||||
* as lexical and forgiving as completion; names it cannot judge (unknown
|
||||
* tables' columns, dialect pseudo-columns) are left alone.
|
||||
*/
|
||||
import type { DbCompletion, DbCompletionColumn, DbCompletionTable, DbDriver } from "@/platform/ipc";
|
||||
import { analyze, type TableRef } from "./analyze";
|
||||
import { CLAUSE_WORDS, DATA_TYPES, functions, isReserved } from "./dialect";
|
||||
import { identName, lex, type Lexed, type Token } from "./lexer";
|
||||
|
||||
export type SqlSymbol =
|
||||
| { kind: "table"; table: DbCompletionTable }
|
||||
| { kind: "cte"; name: string; def: Span }
|
||||
/** A FROM item's alias (or a derived table); `table` when it aliases a catalog table. */
|
||||
| { kind: "alias"; name: string; def: Span; table?: DbCompletionTable }
|
||||
| { kind: "column"; name: string; column?: DbCompletionColumn; table?: DbCompletionTable; owner: string }
|
||||
| { kind: "schema"; name: string };
|
||||
|
||||
export interface Span {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface Resolved extends Span {
|
||||
symbol: SqlSymbol | null;
|
||||
/** Set when the name should resolve but doesn't. */
|
||||
problem?: string;
|
||||
}
|
||||
|
||||
export interface SqlProblem extends Span {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Statements whose names are checked; DDL and dialect commands are left alone. */
|
||||
const CHECKED = new Set(["SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "REPLACE"]);
|
||||
/** Qualifiers with a meaning of their own (upserts, triggers). */
|
||||
const PSEUDO_TABLES = new Set(["EXCLUDED", "NEW", "OLD"]);
|
||||
/** Bare words that are not column references. */
|
||||
const NOT_COLUMNS = new Set(
|
||||
(
|
||||
"TRUE FALSE NULL DEFAULT UNKNOWN CURRENT_DATE CURRENT_TIME CURRENT_TIMESTAMP LOCALTIME LOCALTIMESTAMP " +
|
||||
"CURRENT_USER SESSION_USER USER INTERVAL ESCAPE COLLATE NULLS FIRST LAST DO NOTHING CONFLICT RECURSIVE " +
|
||||
"ROWID OID CTID _ROWID_ GLOB REGEXP MATCH ROWS RANGE GROUPS PRECEDING FOLLOWING UNBOUNDED CURRENT ROW OVER " +
|
||||
"PARTITION FILTER WITHIN ONLY TIES NEXT IGNORE ABORT FAIL ROLLBACK DUPLICATE KEY ISNULL NOTNULL SIMILAR " +
|
||||
"TOP PERCENT ANY SOME ALL DISTINCT ZONE YEAR MONTH DAY HOUR MINUTE SECOND EPOCH SEPARATOR PRIOR LEADING " +
|
||||
"TRAILING BOTH NOWAIT SKIP LOCKED SHARE MODE BINARY UNSIGNED SIGNED"
|
||||
).split(" "),
|
||||
);
|
||||
|
||||
const eq = (a: string | null | undefined, b: string | null | undefined) =>
|
||||
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
export function findTable(model: DbCompletion | null, schema: string | null, name: string): DbCompletionTable | undefined {
|
||||
const tables = model?.tables ?? [];
|
||||
if (schema) return tables.find((t) => eq(t.schema, schema) && eq(t.name, name));
|
||||
return tables.find((t) => eq(t.schema, model?.defaultSchema) && eq(t.name, name)) ?? tables.find((t) => eq(t.name, name));
|
||||
}
|
||||
|
||||
const isName = (t: Token | undefined): t is Token => !!t && (t.kind === "word" || t.kind === "quoted");
|
||||
const isP = (t: Token | undefined, p: string) => !!t && t.kind === "punct" && t.text === p;
|
||||
|
||||
export class SqlResolver {
|
||||
readonly lexed: Lexed;
|
||||
private readonly words: Set<string>;
|
||||
/** Token ranges `[a, b)` of the statements (`;` included), and each token's statement. */
|
||||
private readonly statements: Array<[number, number]> = [];
|
||||
private readonly statementOf: number[] = [];
|
||||
private readonly statementLexed = new Map<number, Lexed>();
|
||||
|
||||
constructor(
|
||||
readonly text: string,
|
||||
readonly driver: DbDriver,
|
||||
readonly model: DbCompletion | null,
|
||||
) {
|
||||
this.lexed = lex(text, driver);
|
||||
this.words = new Set([
|
||||
...DATA_TYPES[driver].map((t) => t.toUpperCase()),
|
||||
...functions(driver).map((f) => f.name.toUpperCase()),
|
||||
]);
|
||||
let a = 0;
|
||||
this.lexed.tokens.forEach((t, i) => {
|
||||
this.statementOf.push(this.statements.length);
|
||||
if (isP(t, ";")) {
|
||||
this.statements.push([a, i + 1]);
|
||||
a = i + 1;
|
||||
}
|
||||
});
|
||||
this.statements.push([a, this.lexed.tokens.length]);
|
||||
}
|
||||
|
||||
/** The tokens of token `i`'s statement: analysing only those keeps a long console linear. */
|
||||
private lexedFor(i: number): Lexed {
|
||||
const s = this.statementOf[i]!;
|
||||
let l = this.statementLexed.get(s);
|
||||
if (!l) {
|
||||
const [a, b] = this.statements[s]!;
|
||||
l = { tokens: this.lexed.tokens.slice(a, b), comments: this.lexed.comments };
|
||||
this.statementLexed.set(s, l);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
/** The name token at `pos` (caret inside or at either edge). */
|
||||
tokenAt(pos: number): number {
|
||||
const toks = this.lexed.tokens;
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i]!;
|
||||
if (t.from > pos) break;
|
||||
if (pos <= t.to && isName(t)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
resolveAt(pos: number): Resolved | null {
|
||||
const i = this.tokenAt(pos);
|
||||
return i < 0 ? null : this.resolve(i);
|
||||
}
|
||||
|
||||
/** Resolves token `i`; `null` when it is no name (keywords, function names…). */
|
||||
resolve(i: number): Resolved | null {
|
||||
const toks = this.lexed.tokens;
|
||||
const t = toks[i]!;
|
||||
if (!isName(t) || (t.kind === "word" && CLAUSE_WORDS.has(t.upper))) return null;
|
||||
const prev = toks[i - 1];
|
||||
const next = toks[i + 1];
|
||||
const name = identName(t);
|
||||
const span = { from: t.from, to: t.to };
|
||||
const a = analyze(this.text, t.from, this.driver, this.lexedFor(i));
|
||||
const checked = CHECKED.has(a.verb);
|
||||
const loaded = !!this.model?.tables.length;
|
||||
const ctx = a.context;
|
||||
const qualifier = a.qualifier;
|
||||
|
||||
// `x.` — a table reference (or schema) qualifying the next name.
|
||||
if (isP(next, ".") && next!.from === t.to && isName(toks[i + 2])) {
|
||||
if (ctx.kind === "table" || (qualifier && qualifier.length)) return this.schema(name, span);
|
||||
const ref = this.findRef(a.refs, null, name);
|
||||
if (ref) return { ...span, symbol: this.refSymbol(ref) };
|
||||
const cte = a.ctes.find((c) => eq(c.name, name));
|
||||
if (cte) return { ...span, symbol: { kind: "cte", name: cte.name, def: { from: cte.from, to: cte.to } } };
|
||||
if (this.model?.tables.some((x) => eq(x.schema, name))) return this.schema(name, span);
|
||||
if (!checked || PSEUDO_TABLES.has(name.toUpperCase()) || ctx.kind === "none") return { ...span, symbol: null };
|
||||
return { ...span, symbol: null, problem: `Unable to resolve symbol '${name}'` };
|
||||
}
|
||||
|
||||
// An alias being defined.
|
||||
const defined = a.refs.find((r) => r.aliasAt?.from === t.from);
|
||||
if (defined) return { ...span, symbol: this.refSymbol(defined) };
|
||||
|
||||
switch (ctx.kind) {
|
||||
case "none":
|
||||
case "statement":
|
||||
return null;
|
||||
case "table": {
|
||||
// Table functions (`generate_series(1, 3)`), `DROP TABLE IF EXISTS`.
|
||||
if (isP(next, "(") && !isIntoTarget(toks, i)) return null;
|
||||
if (t.kind === "word" && (t.upper === "IF" || t.upper === "ONLY" || t.upper === "LATERAL")) return null;
|
||||
const schema = qualifier?.at(-1) ?? null;
|
||||
const cte = schema ? undefined : a.ctes.find((c) => eq(c.name, name));
|
||||
if (cte) return { ...span, symbol: { kind: "cte", name: cte.name, def: { from: cte.from, to: cte.to } } };
|
||||
const table = findTable(this.model, schema, name);
|
||||
if (table) return { ...span, symbol: { kind: "table", table } };
|
||||
if (!loaded || (a.verb !== "DROP" && a.verb !== "ALTER" && !checked)) return { ...span, symbol: null };
|
||||
if (schema && !this.model!.tables.some((x) => eq(x.schema, schema))) return { ...span, symbol: null };
|
||||
return { ...span, symbol: null, problem: `Unable to resolve table '${name}'` };
|
||||
}
|
||||
case "afterTable":
|
||||
return null;
|
||||
case "insertColumns": {
|
||||
const cols = this.columnsOf(ctx.target);
|
||||
const col = cols?.find((c) => eq(c.name, name));
|
||||
if (col || !cols) return { ...span, symbol: col ? this.columnSymbol(ctx.target, col) : null };
|
||||
return { ...span, symbol: null, problem: `Unable to resolve column '${name}'` };
|
||||
}
|
||||
case "afterInsertColumns":
|
||||
return null;
|
||||
case "column":
|
||||
case "joinCondition":
|
||||
break;
|
||||
}
|
||||
|
||||
if (qualifier) {
|
||||
const ref =
|
||||
qualifier.length === 1
|
||||
? this.findRef(a.refs, null, qualifier[0]!)
|
||||
: this.findRef(a.refs, qualifier.at(-2)!, qualifier.at(-1)!);
|
||||
if (!ref) return { ...span, symbol: null };
|
||||
const cols = this.columnsOf(ref);
|
||||
const col = cols?.find((c) => eq(c.name, name));
|
||||
if (col) return { ...span, symbol: this.columnSymbol(ref, col) };
|
||||
if (!cols || !checked) return { ...span, symbol: null };
|
||||
return { ...span, symbol: null, problem: `Unable to resolve column '${name}'` };
|
||||
}
|
||||
|
||||
// A bare word: a column unless it is a function, a type, a keyword or an alias being defined.
|
||||
if (isP(next, "(") || isP(prev, "::") || (prev?.kind === "word" && prev.upper === "AS")) return null;
|
||||
if (t.kind === "word" && (isReserved(t.text) || NOT_COLUMNS.has(t.upper) || this.words.has(t.upper))) return null;
|
||||
// `count(*) n`, `x total`: an alias after an expression.
|
||||
if (
|
||||
prev &&
|
||||
(prev.kind === "quoted" ||
|
||||
prev.kind === "number" ||
|
||||
prev.kind === "string" ||
|
||||
isP(prev, ")") ||
|
||||
(prev.kind === "word" && !CLAUSE_WORDS.has(prev.upper)))
|
||||
)
|
||||
return null;
|
||||
for (const ref of a.refs) {
|
||||
const col = this.columnsOf(ref)?.find((c) => eq(c.name, name));
|
||||
if (col) return { ...span, symbol: this.columnSymbol(ref, col) };
|
||||
}
|
||||
const ref = this.findRef(a.refs, null, name);
|
||||
if (ref) return { ...span, symbol: this.refSymbol(ref) };
|
||||
const clause = ctx.kind === "column" ? ctx.clause : "on";
|
||||
const judged =
|
||||
checked &&
|
||||
loaded &&
|
||||
a.refs.length > 0 &&
|
||||
a.refs.every((r) => this.columnsOf(r) !== null) &&
|
||||
clause !== "other" &&
|
||||
clause !== "values" &&
|
||||
clause !== "limit" &&
|
||||
!this.outputAliases(i).has(name.toLowerCase());
|
||||
return judged ? { ...span, symbol: null, problem: `Unable to resolve column '${name}'` } : { ...span, symbol: null };
|
||||
}
|
||||
|
||||
/** Every unresolved name of the text. */
|
||||
problems(): SqlProblem[] {
|
||||
const out: SqlProblem[] = [];
|
||||
if (!this.model?.tables.length) return out;
|
||||
this.lexed.tokens.forEach((t, i) => {
|
||||
if (!isName(t)) return;
|
||||
const r = this.resolve(i);
|
||||
if (r?.problem) out.push({ from: r.from, to: r.to, message: r.problem });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
private schema(name: string, span: Span): Resolved {
|
||||
return { ...span, symbol: this.model?.tables.some((x) => eq(x.schema, name)) ? { kind: "schema", name } : null };
|
||||
}
|
||||
|
||||
private findRef(refs: readonly TableRef[], schema: string | null, name: string): TableRef | undefined {
|
||||
return refs.find((r) => (r.alias ? eq(r.alias, name) : eq(r.name, name) && (!schema || eq(r.schema, schema))));
|
||||
}
|
||||
|
||||
private refSymbol(ref: TableRef): SqlSymbol {
|
||||
const table = ref.kind === "table" ? findTable(this.model, ref.schema, ref.name) : undefined;
|
||||
if (ref.alias && ref.aliasAt) return { kind: "alias", name: ref.alias, def: ref.aliasAt, table };
|
||||
if (table) return { kind: "table", table };
|
||||
return { kind: "cte", name: ref.name, def: { from: ref.from, to: ref.to } };
|
||||
}
|
||||
|
||||
/** Columns of a FROM item; `null` when unknown (not in the model, `select *` subqueries). */
|
||||
private columnsOf(ref: TableRef): Array<{ name: string; column?: DbCompletionColumn }> | null {
|
||||
if (ref.columns) return ref.columns.length ? ref.columns.map((name) => ({ name })) : null;
|
||||
const table = findTable(this.model, ref.schema, ref.name);
|
||||
return table ? table.columns.map((c) => ({ name: c.name, column: c })) : null;
|
||||
}
|
||||
|
||||
private columnSymbol(ref: TableRef, col: { name: string; column?: DbCompletionColumn }): SqlSymbol {
|
||||
const table = ref.kind === "table" ? findTable(this.model, ref.schema, ref.name) : undefined;
|
||||
return { kind: "column", name: col.name, column: col.column, table, owner: ref.alias ?? ref.name };
|
||||
}
|
||||
|
||||
/** Select-list aliases of token `i`'s statement (usable in ORDER BY / GROUP BY / HAVING). */
|
||||
private outputAliases(i: number): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const toks = this.lexedFor(i).tokens;
|
||||
for (let k = 1; k < toks.length; k++) {
|
||||
const t = toks[k]!;
|
||||
if (!isName(t)) continue;
|
||||
const prev = toks[k - 1]!;
|
||||
if ((prev.kind === "word" && prev.upper === "AS") || isP(prev, ")") || prev.kind === "number" || prev.kind === "string")
|
||||
out.add(identName(t).toLowerCase());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether token `i` follows INSERT / REPLACE INTO (so a `(` after it lists columns). */
|
||||
function isIntoTarget(toks: readonly Token[], i: number): boolean {
|
||||
let j = i - 1;
|
||||
while (isP(toks[j], ".") && isName(toks[j - 1])) j -= 2;
|
||||
return toks[j]?.kind === "word" && toks[j]!.upper === "INTO";
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DbCompletion } from "@/platform/ipc";
|
||||
import { analyze, templateContexts, valuesSlot } from "./analyze";
|
||||
import { aliasFor, quoteIdent, sqlCandidates, type SqlCandidate } from "./complete";
|
||||
import { inLiteral, lex } from "./lexer";
|
||||
import { bindParams, findParams } from "./params";
|
||||
import { SqlResolver } from "./resolve";
|
||||
|
||||
const col = (name: string, dataType = "INTEGER", primaryKey = false, hasDefault = false) => ({
|
||||
name,
|
||||
dataType,
|
||||
nullable: !primaryKey,
|
||||
primaryKey,
|
||||
hasDefault,
|
||||
});
|
||||
|
||||
const model: DbCompletion = {
|
||||
defaultSchema: "main",
|
||||
tables: [
|
||||
{
|
||||
schema: "main",
|
||||
name: "users",
|
||||
kind: "table",
|
||||
columns: [
|
||||
col("id", "INTEGER", true),
|
||||
col("email", "TEXT"),
|
||||
col("name", "TEXT"),
|
||||
col("created_at", "TEXT", false, true),
|
||||
],
|
||||
},
|
||||
{
|
||||
schema: "main",
|
||||
name: "orders",
|
||||
kind: "table",
|
||||
columns: [col("id", "INTEGER", true), col("user_id"), col("status", "TEXT"), col("total", "NUMERIC")],
|
||||
},
|
||||
{
|
||||
schema: "main",
|
||||
name: "order_items",
|
||||
kind: "table",
|
||||
columns: [col("order_id", "INTEGER", true), col("product_id", "INTEGER", true), col("qty")],
|
||||
},
|
||||
{ schema: "main", name: "products", kind: "table", columns: [col("id", "INTEGER", true), col("title", "TEXT")] },
|
||||
{ schema: "main", name: "big_orders", kind: "view", columns: [col("id"), col("user_id"), col("total", "NUMERIC")] },
|
||||
],
|
||||
foreignKeys: [
|
||||
{ schema: "main", table: "orders", columns: ["user_id"], refSchema: "main", refTable: "users", refColumns: ["id"] },
|
||||
{
|
||||
schema: "main",
|
||||
table: "order_items",
|
||||
columns: ["order_id"],
|
||||
refSchema: "main",
|
||||
refTable: "orders",
|
||||
refColumns: ["id"],
|
||||
},
|
||||
{
|
||||
schema: "main",
|
||||
table: "order_items",
|
||||
columns: ["product_id"],
|
||||
refSchema: "main",
|
||||
refTable: "products",
|
||||
refColumns: ["id"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Analysis at the `|` of `sql`. */
|
||||
function at(sql: string, driver: "sqlite" | "postgres" | "mysql" = "sqlite") {
|
||||
const pos = sql.indexOf("|");
|
||||
const text = sql.slice(0, pos) + sql.slice(pos + 1);
|
||||
return analyze(text, pos, driver);
|
||||
}
|
||||
|
||||
function complete(
|
||||
sql: string,
|
||||
driver: "sqlite" | "postgres" | "mysql" = "sqlite",
|
||||
m: DbCompletion = model,
|
||||
): SqlCandidate[] {
|
||||
return sqlCandidates({ model: m, analysis: at(sql, driver), driver, sourceName: "shop" }).sort(
|
||||
(a, b) => b.boost - a.boost,
|
||||
);
|
||||
}
|
||||
const labels = (c: SqlCandidate[], kind?: string) => c.filter((x) => !kind || x.kind === kind).map((x) => x.label);
|
||||
|
||||
describe("lexer", () => {
|
||||
it("skips comments and knows literals", () => {
|
||||
const l = lex("select 'a;b', \"Col\" -- c\nfrom t /* x */ where a::int = $1", "postgres");
|
||||
expect(l.tokens.map((t) => t.text)).toEqual([
|
||||
"select",
|
||||
"'a;b'",
|
||||
",",
|
||||
'"Col"',
|
||||
"from",
|
||||
"t",
|
||||
"where",
|
||||
"a",
|
||||
"::",
|
||||
"int",
|
||||
"=",
|
||||
"$1",
|
||||
]);
|
||||
expect(inLiteral(l, 9)).toBe("string");
|
||||
expect(inLiteral(l, 23)).toBe("comment");
|
||||
expect(inLiteral(l, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles dialect quoting", () => {
|
||||
expect(lex("select `a b` # c\n", "mysql").tokens.map((t) => t.kind)).toEqual(["word", "quoted"]);
|
||||
expect(lex("do $x$ begin; end $x$", "postgres").tokens.map((t) => t.kind)).toEqual(["word", "string"]);
|
||||
expect(inLiteral(lex("select 'abc"), 11)).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis", () => {
|
||||
it("knows what is expected at the caret", () => {
|
||||
expect(at("|").context.kind).toBe("statement");
|
||||
expect(at("select 1; sel|").context.kind).toBe("statement");
|
||||
expect(at("select * from |").context).toEqual({ kind: "table", join: false });
|
||||
expect(at("select * from users u join |").context).toEqual({ kind: "table", join: true });
|
||||
expect(at("select * from users u, |").context.kind).toBe("table");
|
||||
expect(at("select | from users").context).toEqual({ kind: "column", clause: "select" });
|
||||
expect(at("select * from users where |").context).toEqual({ kind: "column", clause: "where" });
|
||||
expect(at("select * from users u |").context.kind).toBe("afterTable");
|
||||
expect(at("select * from orders o join users u on |").context.kind).toBe("joinCondition");
|
||||
expect(at("insert into users (|)").context.kind).toBe("insertColumns");
|
||||
expect(at("insert into users (id, |").context.kind).toBe("insertColumns");
|
||||
expect(at("insert into users (id) |").context.kind).toBe("afterInsertColumns");
|
||||
expect(at("select 'x|'").context.kind).toBe("none");
|
||||
expect(at("select a as |").context.kind).toBe("none");
|
||||
expect(at("create table |").context.kind).not.toBe("table");
|
||||
});
|
||||
|
||||
it("resolves aliases, CTEs, derived tables and enclosing queries", () => {
|
||||
const a = at(
|
||||
"with recent as (select id, total as amount from orders) select r.| from recent r join users u on u.id = r.id",
|
||||
);
|
||||
expect(a.qualifier).toEqual(["r"]);
|
||||
const r = a.refs.find((x) => x.alias === "r")!;
|
||||
expect(r.kind).toBe("cte");
|
||||
expect(r.columns).toEqual(["id", "amount"]);
|
||||
|
||||
const d = at("select x.| from (select name n, email from users) x");
|
||||
expect(d.refs[0]).toMatchObject({ kind: "derived", name: "x", columns: ["n", "email"] });
|
||||
|
||||
const corr = at("select * from users u where exists (select 1 from orders o where o.user_id = |)");
|
||||
expect(corr.refs.map((x) => [x.name, x.depth])).toEqual([
|
||||
["orders", 0],
|
||||
["users", 1],
|
||||
]);
|
||||
|
||||
const union = at("select id from users union select | from orders");
|
||||
expect(union.refs.map((x) => x.name)).toEqual(["orders"]);
|
||||
});
|
||||
|
||||
it("finds where FROM goes when a SELECT has none", () => {
|
||||
expect(at("select na|").fromInsert).toBe(9);
|
||||
expect(at("select na| from users").fromInsert).toBeNull();
|
||||
});
|
||||
|
||||
it("follows the statement's keyword case", () => {
|
||||
expect(at("select * from |").lowerKeywords).toBe(true);
|
||||
expect(at("SELECT * FROM |").lowerKeywords).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completion candidates", () => {
|
||||
it("offers statement starters in the user's case", () => {
|
||||
expect(labels(complete("|"))).toContain("SELECT");
|
||||
expect(labels(complete("select 1; ins|"))).toContain("insert into");
|
||||
});
|
||||
|
||||
it("offers tables after FROM, with schema and data source", () => {
|
||||
const c = complete("select * from |");
|
||||
expect(labels(c, "table")).toEqual(expect.arrayContaining(["users", "orders", "order_items", "products"]));
|
||||
expect(c.find((x) => x.label === "big_orders")).toMatchObject({ kind: "view", tail: "(main)", type: "shop" });
|
||||
});
|
||||
|
||||
it("suggests whole join clauses from foreign keys, both directions, first", () => {
|
||||
const c = complete("select * from orders o join |");
|
||||
const joins = labels(c, "join");
|
||||
expect(joins.slice(0, 2).sort()).toEqual(["order_items oi on o.id = oi.order_id", "users u on o.user_id = u.id"]);
|
||||
expect(joins).toContain("big_orders bo on o.user_id = bo.user_id");
|
||||
expect(c[0]!.kind).toBe("join");
|
||||
});
|
||||
|
||||
it("avoids alias clashes", () => {
|
||||
expect(aliasFor("users", new Set(["u"]))).toBe("u1");
|
||||
expect(aliasFor("order_items", new Set())).toBe("oi");
|
||||
expect(aliasFor("OrderItems", new Set())).toBe("oi");
|
||||
expect(aliasFor("on_hold", new Set())).toBe("oh");
|
||||
});
|
||||
|
||||
it("suggests the join condition after ON", () => {
|
||||
const c = complete("select * from orders o join users u on |");
|
||||
expect(c[0]).toMatchObject({ kind: "join", label: "o.user_id = u.id" });
|
||||
expect(c.find((x) => x.label === "status")).toMatchObject({ kind: "column", tail: "(o)", type: "TEXT" });
|
||||
// `id` is in both tables: inserted qualified.
|
||||
expect(
|
||||
c
|
||||
.filter((x) => x.label === "id")
|
||||
.map((x) => x.insert)
|
||||
.sort(),
|
||||
).toEqual(["o.id", "u.id"]);
|
||||
});
|
||||
|
||||
it("offers ON with conditions right after a joined table", () => {
|
||||
const c = complete("select * from orders o join users u |");
|
||||
expect(c[0]).toMatchObject({ label: "on o.user_id = u.id" });
|
||||
expect(labels(c, "keyword")).toEqual(expect.arrayContaining(["on", "where", "left join"]));
|
||||
});
|
||||
|
||||
it("completes columns of an alias after a dot, with key markers", () => {
|
||||
const c = complete("select o.| from orders o");
|
||||
expect(labels(c)).toEqual(["id", "user_id", "status", "total"]);
|
||||
expect(c.find((x) => x.label === "id")).toMatchObject({ primaryKey: true, foreignKey: false });
|
||||
expect(c.find((x) => x.label === "user_id")).toMatchObject({ primaryKey: false, foreignKey: true });
|
||||
});
|
||||
|
||||
it("completes CTE and derived table columns", () => {
|
||||
expect(labels(complete("with r as (select id, total amount from orders) select r.| from r"))).toEqual([
|
||||
"id",
|
||||
"amount",
|
||||
]);
|
||||
});
|
||||
|
||||
it("completes the columns an INSERT needs, all of them, then each one", () => {
|
||||
const c = complete("insert into users (|)");
|
||||
expect(c.slice(0, 2).map((x) => [x.kind, x.label, x.tail])).toEqual([
|
||||
["columns", "id, email, name", "(main.users)"],
|
||||
["columns", "id, email, name, created_at", "(main.users)"],
|
||||
]);
|
||||
expect(labels(c, "column")).toEqual(["id", "email", "name", "created_at"]);
|
||||
});
|
||||
|
||||
it("suggests an alias right after a table, first", () => {
|
||||
expect(complete("select * from order_items |")[0]).toMatchObject({ kind: "alias", label: "oi" });
|
||||
expect(complete("select * from users u |")[0]?.kind).not.toBe("alias");
|
||||
});
|
||||
|
||||
it("offers the whole column list in the select list", () => {
|
||||
const c = complete("select | from users");
|
||||
expect(c.find((x) => x.kind === "columns")).toMatchObject({
|
||||
label: "id, email, name, created_at",
|
||||
tail: "(main.users)",
|
||||
insert: "id, email, name, created_at",
|
||||
});
|
||||
const two = complete("select | from users u join orders o on o.user_id = u.id");
|
||||
expect(two.find((x) => x.kind === "columns" && x.tail === "(u)")?.insert).toBe("u.id, u.email, u.name, u.created_at");
|
||||
});
|
||||
|
||||
it("names unaliased tables by schema in column rows", () => {
|
||||
expect(complete("update users set |").find((x) => x.label === "email")?.tail).toBe("(main.users)");
|
||||
});
|
||||
|
||||
it("offers only columns as SET targets", () => {
|
||||
for (const text of ["update users set |", "update users set name = 'a', |"])
|
||||
expect(new Set(complete(text).map((x) => x.kind))).toEqual(new Set(["column"]));
|
||||
expect(complete("update users set name = |").some((x) => x.kind === "function")).toBe(true);
|
||||
});
|
||||
|
||||
it("offers any column before FROM and adds the FROM", () => {
|
||||
const c = complete("select emai|");
|
||||
expect(c.find((x) => x.label === "email")).toMatchObject({ addFrom: "users", tail: "(users)" });
|
||||
});
|
||||
|
||||
it("orders scope columns before functions before keywords", () => {
|
||||
const c = complete("select | from users");
|
||||
const first = (k: string) => c.findIndex((x) => x.kind === k);
|
||||
expect(first("column")).toBeLessThan(first("function"));
|
||||
expect(first("function")).toBeLessThan(first("keyword"));
|
||||
expect(c.find((x) => x.label === "count")).toMatchObject({ snippet: "count(${})" });
|
||||
});
|
||||
|
||||
it("quotes identifiers that need it", () => {
|
||||
expect(quoteIdent("Users", "postgres")).toBe('"Users"');
|
||||
expect(quoteIdent("Users", "mysql")).toBe("Users");
|
||||
expect(quoteIdent("order", "mysql")).toBe("`order`");
|
||||
expect(quoteIdent("my col", "sqlite")).toBe('"my col"');
|
||||
});
|
||||
|
||||
it("qualifies tables outside the default schema", () => {
|
||||
const pg: DbCompletion = {
|
||||
defaultSchema: "public",
|
||||
tables: [
|
||||
{ schema: "public", name: "users", kind: "table", columns: [col("id")] },
|
||||
{ schema: "audit", name: "log", kind: "table", columns: [col("id")] },
|
||||
],
|
||||
foreignKeys: [],
|
||||
};
|
||||
const c = complete("select * from |", "postgres", pg);
|
||||
expect(c.find((x) => x.label === "log")?.insert).toBe("audit.log");
|
||||
expect(c.find((x) => x.label === "users")?.insert).toBe("users");
|
||||
expect(labels(complete("select * from audit.|", "postgres", pg))).toEqual(["log"]);
|
||||
expect(c.find((x) => x.kind === "schema" && x.label === "audit")).toMatchObject({
|
||||
insert: "audit.",
|
||||
retrigger: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("is silent in strings and comments", () => {
|
||||
expect(complete("select '|'")).toEqual([]);
|
||||
expect(complete("-- sel|")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live template contexts", () => {
|
||||
it("knows where statements, queries and code go", () => {
|
||||
expect(templateContexts(at("sel|"))).toEqual(["sql-code", "sql-statement", "sql-query"]);
|
||||
expect(templateContexts(at("select 1; sel|"))).toContain("sql-statement");
|
||||
expect(templateContexts(at("select * from (sel|"))).toEqual(["sql-code", "sql-query"]);
|
||||
expect(templateContexts(at("select 1 union sel|"))).toEqual(["sql-code", "sql-query"]);
|
||||
expect(templateContexts(at("select * from users where sel|"))).toEqual(["sql-code"]);
|
||||
expect(templateContexts(at("select 'sel|'"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VALUES parameter info", () => {
|
||||
const slot = (sql: string) => {
|
||||
const pos = sql.indexOf("|");
|
||||
return valuesSlot(sql.slice(0, pos) + sql.slice(pos + 1), pos, "sqlite");
|
||||
};
|
||||
it("finds the column of the value at the caret", () => {
|
||||
expect(slot("insert into users (email, name) values ('a', |)")).toMatchObject({ columns: ["email", "name"], index: 1 });
|
||||
expect(slot("insert into users values (|)")).toMatchObject({ columns: null, index: 0 });
|
||||
expect(slot("insert into users (email) values ('a'), (|")).toMatchObject({ index: 0 });
|
||||
expect(slot("insert into users (email|)")).toBeNull();
|
||||
expect(slot("select (|)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
const problems = (sql: string, driver: "sqlite" | "postgres" | "mysql" = "sqlite") =>
|
||||
new SqlResolver(sql, driver, model).problems().map((p) => `${sql.slice(p.from, p.to)}: ${p.message}`);
|
||||
|
||||
it("accepts resolvable statements", () => {
|
||||
for (const sql of [
|
||||
"select u.id, u.email, count(*) cnt from users u join orders o on o.user_id = u.id group by u.id order by cnt desc;",
|
||||
"select id, name as n from users where name like 'a%' order by n;",
|
||||
"with big as (select user_id, total from orders where total > 10) select b.user_id, total from big b;",
|
||||
"select * from (select id, email from users) x where x.email is not null;",
|
||||
"insert into users (email, name) values ('a', 'b');",
|
||||
"update users set name = upper(name) where id = 1;",
|
||||
"delete from orders where status = 'new' and total between 1 and 2;",
|
||||
"select cast(total as text), coalesce(status, 'x') from orders where created is null;".replace("created", "id"),
|
||||
"select exists (select 1 from orders o where o.user_id = u.id) from users u;",
|
||||
"create table t (a int, b text); drop table if exists t;",
|
||||
"insert into users (email) values ('x') on conflict (email) do update set email = excluded.email;",
|
||||
])
|
||||
expect(problems(sql), sql).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags unresolved tables, columns and qualifiers", () => {
|
||||
expect(problems("select * from usres;")).toEqual(["usres: Unable to resolve table 'usres'"]);
|
||||
expect(problems("select u.emial from users u;")).toEqual(["emial: Unable to resolve column 'emial'"]);
|
||||
expect(problems("select x.id from users u;")).toEqual(["x: Unable to resolve symbol 'x'"]);
|
||||
expect(problems("select nope from users;")).toEqual(["nope: Unable to resolve column 'nope'"]);
|
||||
expect(problems("insert into users (emial) values (1);")).toEqual(["emial: Unable to resolve column 'emial'"]);
|
||||
expect(problems("update users set nme = 1;")).toEqual(["nme: Unable to resolve column 'nme'"]);
|
||||
});
|
||||
|
||||
it("leaves names alone when it cannot judge them", () => {
|
||||
expect(problems("select foo from unknown_table;")).toEqual(["unknown_table: Unable to resolve table 'unknown_table'"]);
|
||||
expect(problems("select * from (select * from users) x where x.whatever = 1;")).toEqual([]);
|
||||
expect(problems("select rowid, * from users;")).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves symbols for navigation and documentation", () => {
|
||||
const sql = "select u.email from users u join orders o on o.user_id = u.id;";
|
||||
const r = new SqlResolver(sql, "sqlite", model);
|
||||
const at = (needle: string, nth = 0) => {
|
||||
let i = -1;
|
||||
for (let k = 0; k <= nth; k++) i = sql.indexOf(needle, i + 1);
|
||||
return r.resolveAt(i + 1)?.symbol;
|
||||
};
|
||||
expect(at("u.email")).toMatchObject({ kind: "alias", name: "u", def: { from: sql.indexOf("u join") } });
|
||||
expect(at("email")).toMatchObject({ kind: "column", name: "email", owner: "u", table: { name: "users" } });
|
||||
expect(at("users")).toMatchObject({ kind: "table", table: { name: "users" } });
|
||||
expect(at("u join")).toMatchObject({ kind: "alias", table: { name: "users" } });
|
||||
expect(at("user_id")).toMatchObject({ kind: "column", owner: "o", column: { dataType: "INTEGER" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("params", () => {
|
||||
it("finds parameters per dialect and binds values", () => {
|
||||
const names = (sql: string, driver: "sqlite" | "postgres" | "mysql" = "sqlite", n = 0) =>
|
||||
findParams(sql, driver, n).map((p) => p.name);
|
||||
expect(names("select * from t where a = :id and b = ? and c = ?3 and d = @x and e = '?' -- ?")).toEqual([":id", "?1", "?3", "@x"]);
|
||||
expect(names("select * from t where a = ? and b = ?", "sqlite", 2)).toEqual(["?3", "?4"]);
|
||||
expect(names("select @x := 1, @@version, ? from t", "mysql")).toEqual(["?1"]);
|
||||
expect(names("select j ? 'k', $1, a::int from t where b = :name", "postgres")).toEqual(["$1", ":name"]);
|
||||
const sql = "update t set a = :v where id = :id and b = :v";
|
||||
expect(bindParams(sql, findParams(sql, "sqlite"), { ":v": "'x'", ":id": " 3 " })).toBe(
|
||||
"update t set a = 'x' where id = 3 and b = 'x'",
|
||||
);
|
||||
expect(bindParams("select ?", findParams("select ?", "sqlite"), {})).toBe("select NULL");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача