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

178 рядки
6.5 KiB
TypeScript

/**
* Language support of query consoles beyond completion, answered from the
* data source's schema model without a language server: unresolved-name
* inspections, Go to Declaration (aliases and CTEs in the text, tables and
* columns in the Database tool window), Quick Documentation and Reformat Code.
*/
import { EditorState, type Extension } from "@codemirror/state";
import { ViewPlugin } from "@codemirror/view";
import {
CONTRIBUTED_DEFINITION,
CONTRIBUTED_FORMAT,
CONTRIBUTED_HOVER,
type ContributedDefinition,
type ContributedFormatter,
type ContributedHover,
} from "@/editor/api";
import { setFileDiagnostics } from "@/editor/lsp/diagnostics";
import { showHint } from "@/editor/lsp/hint";
import { offsetToPos } from "@/editor/lsp/positions";
import type { DbCompletion, DbCompletionTable, DbDriver } from "@/platform/ipc";
import { formatSql } from "./sql/format";
import { SqlResolver, type SqlSymbol } from "./sql/resolve";
import { consoleSource, revealInTree, sourceById, useDatabase } from "./store";
/** Producer id of the console's diagnostics (Problems view, hover). */
const INSPECTIONS = "sql-inspections";
const INSPECT_DELAY = 400;
function sourceOf(path: string): { id: string | null; driver: DbDriver; model: DbCompletion | null } {
const id = consoleSource(path);
return {
id,
driver: sourceById(id)?.driver ?? "sqlite",
model: id ? (useDatabase.getState().completion[id] ?? null) : null,
};
}
function resolver(path: string, state: EditorState): SqlResolver {
const { driver, model } = sourceOf(path);
return new SqlResolver(state.doc.toString(), driver, model);
}
/** Unresolved tables / columns / aliases as error diagnostics, re-checked after edits and schema loads. */
function inspections(path: string): Extension {
return ViewPlugin.define((view) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const run = () => {
const doc = view.state.doc;
const problems = sourceOf(path).model ? resolver(path, view.state).problems() : [];
setFileDiagnostics(
path,
INSPECTIONS,
problems.map((p) => ({
range: { start: offsetToPos(doc, p.from), end: offsetToPos(doc, p.to) },
severity: "error",
message: p.message,
source: "SQL",
code: null,
})),
);
};
const schedule = () => {
clearTimeout(timer);
timer = setTimeout(run, INSPECT_DELAY);
};
schedule();
// The schema model arrives (or the console is bound to another source) after the editor opens.
const unsubscribe = useDatabase.subscribe((s, prev) => {
if (s.completion !== prev.completion || s.consoles !== prev.consoles) schedule();
});
return {
update: (u) => {
if (u.docChanged) schedule();
},
destroy: () => {
clearTimeout(timer);
unsubscribe();
setFileDiagnostics(path, INSPECTIONS, []);
},
};
});
}
function definition(path: string): ContributedDefinition {
return (view, pos) => {
const r = resolver(path, view.state).resolveAt(pos);
const sym = r?.symbol;
const id = consoleSource(path);
const reveal = (t: DbCompletionTable, column?: string) => {
if (id) void revealInTree(id, t.schema, t.name, t.kind === "view", column);
};
if (!sym) {
showHint(view, "Cannot find declaration to go to");
return true;
}
switch (sym.kind) {
case "alias":
case "cte":
// On the definition itself: its table, as IDEA does.
if (pos >= sym.def.from && pos <= sym.def.to) {
if (sym.kind === "alias" && sym.table) reveal(sym.table);
else showHint(view, "Cannot find declaration to go to");
} else view.dispatch({ selection: { anchor: sym.def.from, head: sym.def.to }, scrollIntoView: true });
return true;
case "table":
reveal(sym.table);
return true;
case "column":
if (sym.table) reveal(sym.table, sym.name);
else showHint(view, "Cannot find declaration to go to");
return true;
case "schema":
showHint(view, "Cannot find declaration to go to");
return true;
}
};
}
const code = (s: string) => `\`${s.replace(/`/g, "")}\``;
function tableDoc(t: DbCompletionTable, model: DbCompletion | null): string {
const fks = (model?.foreignKeys ?? []).filter((k) => k.schema === t.schema && k.table === t.name);
const refOf = (c: string) => {
const k = fks.find((f) => f.columns.includes(c));
return k ? `→ ${k.refTable} (${k.refColumns[k.columns.indexOf(c)] ?? ""})` : "";
};
const rows = t.columns.map(
(c) =>
`| ${code(c.name)} | ${c.dataType} | ${[c.primaryKey ? "PK" : "", c.nullable ? "" : "not null", c.hasDefault ? "default" : "", refOf(c.name)]
.filter(Boolean)
.join(", ")} |`,
);
return [`${t.kind === "view" ? "view" : "table"} **${t.name}** in ${code(t.schema)}`, "", "| column | type | |", "|---|---|---|", ...rows].join(
"\n",
);
}
function symbolDoc(sym: SqlSymbol, model: DbCompletion | null): string | null {
switch (sym.kind) {
case "table":
return tableDoc(sym.table, model);
case "alias":
return sym.table ? `alias ${code(sym.name)} of\n\n${tableDoc(sym.table, model)}` : `alias ${code(sym.name)}`;
case "cte":
return `common table expression ${code(sym.name)}`;
case "schema":
return `schema ${code(sym.name)}`;
case "column": {
const c = sym.column;
const owner = sym.table ? `${sym.table.schema}.${sym.table.name}` : sym.owner;
if (!c) return `column ${code(sym.name)} of ${code(owner)}`;
const traits = [c.primaryKey ? "primary key" : "", c.nullable ? "" : "not null", c.hasDefault ? "has default" : ""].filter(Boolean);
return `column **${c.name}** ${code(c.dataType)}${traits.length ? ` ${traits.join(", ")}` : ""}\n\nof ${code(owner)}`;
}
}
}
function hover(path: string): ContributedHover {
return (state, pos) => {
const r = resolver(path, state).resolveAt(pos);
const markdown = r?.symbol ? symbolDoc(r.symbol, sourceOf(path).model) : null;
return r && markdown ? { from: r.from, to: r.to, markdown } : null;
};
}
function formatter(path: string): ContributedFormatter {
return (state, range) => formatSql(state.doc.toString(), sourceOf(path).driver, range);
}
export function sqlSupport(path: string): Extension {
const data = {
[CONTRIBUTED_DEFINITION]: definition(path),
[CONTRIBUTED_HOVER]: hover(path),
[CONTRIBUTED_FORMAT]: formatter(path),
};
return [inspections(path), EditorState.languageData.of(() => [data])];
}