Файли
openidea/src/features/database/sql/complete.ts
T

483 рядки
19 KiB
TypeScript

/**
* 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")}`;
}