Initial open-source release

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-22 18:55:10 +08:00
коміт 9a2025b76e
2034 files changed
+243381

No files matched your search

+543
Переглянути файл
@@ -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;
}
+482
Переглянути файл
@@ -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")}`;
}
+267
Переглянути файл
@@ -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];
}
+67
Переглянути файл
@@ -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;" }]);
});
});
+213
Переглянути файл
@@ -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;
}
+152
Переглянути файл
@@ -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);
}
+49
Переглянути файл
@@ -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);
}
+288
Переглянути файл
@@ -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";
}
+397
Переглянути файл
@@ -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");
});
});