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

+645
Переглянути файл
@@ -0,0 +1,645 @@
/* ---- Grid ---------------------------------------------------------------- */
.grid {
position: relative;
height: 100%;
overflow: auto;
outline: none;
font-family: var(--font-mono);
font-size: var(--fs-small);
background: var(--bg-editor);
color: var(--fg-editor);
}
.gridInner {
position: relative;
min-width: 100%;
}
.gridRow {
display: grid;
height: 24px;
position: absolute;
top: 0;
left: 0;
right: 0;
}
.gridHeader {
position: sticky;
z-index: 1;
background: var(--bg-island);
border-bottom: 1px solid var(--border);
}
.headerCell {
position: relative;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
border-right: 1px solid var(--border);
overflow: hidden;
white-space: nowrap;
color: var(--fg-strong);
}
.sortable {
cursor: pointer;
}
.sortable:hover {
background: var(--bg-hover);
}
.headerName {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
}
.headerIcon {
flex: none;
color: var(--fg-secondary);
}
.headerSort {
flex: none;
margin-left: auto;
color: var(--fg-muted);
}
.cellNumber {
justify-content: flex-end;
}
.resizer {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
}
.rowNumber {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 6px;
color: var(--fg-line-number);
background: var(--bg-editor-gutter);
border-right: 1px solid var(--border);
user-select: none;
}
.cell {
display: flex;
align-items: center;
padding: 0 6px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
overflow: hidden;
white-space: pre;
text-overflow: ellipsis;
user-select: none;
}
.cellSelected {
background: var(--bg-editor-selection);
}
.cellModified {
color: var(--gutter-modified);
}
.rowDeleted .cell {
text-decoration: line-through;
color: var(--fg-muted);
}
.rowInserted .cell {
color: var(--gutter-added);
}
.null {
color: var(--fg-muted);
font-style: italic;
}
.cellEditor {
min-width: 0;
height: 24px;
padding: 0 5px;
border: 1px solid var(--focus-ring);
background: var(--bg-input);
color: var(--fg);
font: inherit;
outline: none;
}
/* ---- Tool windows ------------------------------------------------------------ */
.view {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 2px;
height: var(--h-header);
padding: 0 6px;
flex: none;
border-bottom: 1px solid var(--border);
}
.spacer {
flex: 1;
}
.fill {
flex: 1;
min-height: 0;
}
.treeDetail {
color: var(--fg-muted);
margin-left: 6px;
}
/* IDEA: counts after folders, column types in a smaller grey face, key rows in grey. */
.treeCount {
color: var(--fg-muted);
font-size: var(--fs-small);
}
.treeType {
color: var(--fg-muted);
font-size: var(--fs-small);
letter-spacing: 0.02em;
}
.keyLabel {
color: var(--fg-secondary);
white-space: nowrap;
}
.treeError {
color: var(--danger);
}
.nodeIcon {
position: relative;
display: inline-flex;
flex: none;
}
/* On the data source icon's bottom-right corner, like IDEA's. */
.connectedDot {
position: absolute;
right: -2px;
bottom: -1px;
width: 4px;
height: 4px;
box-sizing: content-box;
border-radius: 50%;
background: var(--db-connected);
border: 0.5px solid var(--db-connected-ring);
}
/* The schema count of a data source (IDEA draws it in a small frame). */
.treeBadge {
padding: 0 4px;
line-height: 14px;
font-size: var(--fs-small);
color: var(--fg-muted);
border: 1px solid var(--border);
border-radius: var(--radius-small);
}
.textTool {
flex: none;
height: 20px;
padding: 0 3px;
border-radius: var(--radius-small);
font-size: var(--fs-small);
font-weight: 500;
letter-spacing: 0.02em;
color: var(--fg-strong);
}
.textTool:not(:disabled):hover {
background: var(--bg-toolbar-hover);
}
.textTool:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
.textTool:disabled {
opacity: 0.4;
cursor: default;
}
.dbView {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.dbToolbar {
height: 32px;
flex: none;
display: flex;
align-items: center;
gap: 2px;
padding: 0 6px;
}
.dbToolbarSep {
width: 1px;
height: 16px;
margin: 0 4px;
background: var(--border);
}
.treeHost {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.treeHost > * {
flex: 1;
min-height: 0;
}
.nodeRow {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.nodeLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Results. */
.error {
margin: 0;
padding: 8px;
color: var(--danger);
font-family: var(--font-mono);
font-size: var(--fs-code);
white-space: pre-wrap;
-webkit-user-select: text;
user-select: text;
}
/* Console toolbar panel above SQL editors. */
.consoleBar {
display: flex;
align-items: center;
gap: 4px;
height: var(--h-header);
padding: 0 8px;
background: var(--bg-editor);
border-bottom: 1px solid var(--border);
font-family: var(--font-ui);
font-size: var(--fs-ui);
color: var(--fg-secondary);
}
/* Inline statement errors in consoles. */
:global(.cm-openidea-db-error) {
text-decoration: underline wavy var(--danger);
text-underline-offset: 3px;
}
/* Table editor. */
.filter {
flex: 1;
min-width: 120px;
}
.pager {
display: flex;
align-items: center;
gap: 2px;
color: var(--fg-secondary);
font-size: var(--fs-small);
white-space: nowrap;
}
.notice {
padding: 4px 8px;
font-size: var(--fs-small);
color: var(--fg-secondary);
background: var(--bg-editor-banner);
border-bottom: 1px solid var(--border);
}
/* Dialogs. */
.form {
display: grid;
grid-template-columns: max-content 1fr;
align-items: center;
gap: 8px 12px;
min-width: 440px;
padding: 14px;
}
.formLabel {
color: var(--fg-secondary);
text-align: right;
}
.dialogText {
padding: 14px;
max-width: 420px;
line-height: 1.5;
}
.formHint {
font-size: var(--fs-small);
color: var(--fg-muted);
}
.paramInput {
font-family: var(--openidea-editor-font, var(--font-mono));
}
.formRow {
display: flex;
gap: 8px;
align-items: center;
}
.testResult {
grid-column: 1 / -1;
font-size: var(--fs-small);
min-height: 16px;
word-break: break-word;
}
.ok {
color: var(--success);
}
.bad {
color: var(--danger);
}
:global(.cm-openidea-db-doc) {
margin: 0;
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-code);
color: var(--fg-editor, var(--fg-strong));
white-space: pre;
}
/* ---- Console output and results (Services › Database) ---------------------- */
.toolbarSeparator {
width: 1px;
height: 16px;
margin: 0 4px;
background: var(--border);
flex: none;
}
.output {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 8px;
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-code);
line-height: 1.5;
color: var(--fg-editor, var(--fg-strong));
background: var(--bg-editor);
-webkit-user-select: text;
user-select: text;
cursor: text;
}
.outputLine {
white-space: pre-wrap;
word-break: break-word;
}
.outputTime {
color: var(--link);
}
.outputPrompt {
color: var(--fg-secondary);
}
.outputStatement {
color: var(--fg-editor, var(--fg-strong));
}
.outputError,
.outputError .outputTime {
color: var(--danger);
}
.gridHost {
position: relative;
}
/* IDEA's floating row count at the bottom of a result. */
.rowPill {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
padding: 3px 10px;
border: 1px solid var(--border-popup, var(--border));
border-radius: var(--radius-small);
background: var(--bg-popup, var(--bg-elev));
color: var(--fg-secondary);
font-size: var(--fs-small);
box-shadow: var(--shadow-popup, none);
pointer-events: none;
}
.resultSource {
color: var(--fg-muted);
font-size: var(--fs-small);
padding-right: 4px;
}
/* Console run feedback: timing after a statement, gutter check / error, error banner. */
:global(.cm-openidea-db-timing) {
margin-left: 1.5em;
color: var(--fg-muted);
font-family: var(--font-ui);
font-size: var(--fs-small);
user-select: none;
}
:global(.cm-openidea-db-gutter) {
width: 16px;
}
:global(.cm-openidea-db-gutter .cm-gutterElement) {
display: flex;
align-items: center;
justify-content: center;
}
:global(.cm-openidea-db-run-ok svg) {
color: var(--success);
}
:global(.cm-openidea-db-banner) {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 6px 0 10px;
background: color-mix(in srgb, var(--danger) 10%, var(--bg-editor));
border-top: 1px solid var(--border);
color: var(--fg-strong);
font-family: var(--font-ui);
font-size: var(--fs-ui);
}
:global(.cm-openidea-db-banner-text) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.cm-openidea-db-banner-close) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-secondary);
cursor: pointer;
}
:global(.cm-openidea-db-banner-close:hover) {
background: var(--bg-hover);
}
/* Parameter info in `VALUES (…)`. */
:global(.cm-openidea-db-params) {
padding: 4px 8px;
font-family: var(--font-ui);
font-size: var(--fs-ui);
color: var(--fg-secondary);
}
:global(.cm-openidea-db-param-current) {
font-weight: 700;
color: var(--fg-strong);
}
.dialogTabs {
padding: 0 10px;
border-bottom: 1px solid var(--border);
}
/* Object dialogs (Rename, Confirm Drop, Modify Table) and Quick Documentation. */
.dialogBody {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
min-width: 440px;
}
.dialogLead {
line-height: 1.5;
}
.formLabelLeft {
color: var(--fg-secondary);
}
.previewBlock {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.previewTitle {
font-size: var(--fs-small);
color: var(--fg-secondary);
}
.sqlPreview {
margin: 0;
padding: 8px 10px;
min-height: 48px;
max-height: 220px;
overflow: auto;
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-small);
line-height: 1.5;
white-space: pre;
color: var(--fg-editor);
background: var(--bg-editor);
border: 1px solid var(--border);
border-radius: var(--radius-small);
-webkit-user-select: text;
user-select: text;
}
.sqlPreview :global(.tok-keyword),
.sqlPreview :global(.tok-operatorKeyword) {
color: var(--syn-keyword);
}
.sqlPreview :global(.tok-string),
.sqlPreview :global(.tok-string2) {
color: var(--syn-string);
}
.sqlPreview :global(.tok-number),
.sqlPreview :global(.tok-bool),
.sqlPreview :global(.tok-atom) {
color: var(--syn-number);
}
.sqlPreview :global(.tok-typeName) {
color: var(--syn-keyword);
}
.sqlPreview :global(.tok-comment) {
color: var(--syn-comment);
font-style: italic;
}
.sqlPreview :global(.tok-punctuation),
.sqlPreview :global(.tok-operator) {
color: var(--syn-punct);
}
.modifyBody {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
}
.modifyMain {
display: grid;
grid-template-columns: 260px 1fr;
height: 240px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
overflow: hidden;
}
.modifyColumns {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--border);
}
.modifyToolbar {
display: flex;
gap: 2px;
padding: 2px 4px;
border-bottom: 1px solid var(--border);
}
.modifyList {
flex: 1;
overflow: auto;
padding: 2px 0;
}
.modifyItem {
display: flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
cursor: default;
white-space: nowrap;
}
.modifyItem:hover {
background: var(--bg-hover);
}
.modifyItem[aria-selected="true"] {
background: var(--bg-selected);
}
.modifyItem[data-dropped] .nodeLabel {
text-decoration: line-through;
color: var(--fg-muted);
}
.modifyProps {
display: grid;
grid-template-columns: max-content 1fr;
align-content: start;
align-items: center;
gap: 8px 12px;
padding: 12px;
overflow: auto;
}
.quickDoc {
width: 460px;
max-width: calc(100vw - 32px);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.quickDocRows {
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.quickDocRows > div {
display: flex;
gap: 6px;
}
.quickDocRows dt {
font-weight: 600;
color: var(--fg-strong);
}
.quickDocRows dd {
margin: 0;
-webkit-user-select: text;
user-select: text;
}
.quickDocLinks {
display: flex;
justify-content: flex-start;
}
+384
Переглянути файл
@@ -0,0 +1,384 @@
/**
* Database tool window (IDEA's layout): data source → database (PostgreSQL) → schema → `tables` /
* `views` folders → table → `columns` / `keys` / `foreign keys` / `indexes`,
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
* source to open its query console.
*/
import { useEffect, useMemo, useRef } from "react";
import type { DbTableDetails } from "@/platform/ipc";
import { setContext } from "@/platform/context";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { Button, EmptyState, Icon, IconButton, Menu, Tree, showContextMenu, tooltipAttrs, type TreeHandle } from "@/ui";
import { databaseKey, driverIcon, splitTarget, targetOf } from "./model";
import { nodeTarget, type DbNode as Node, type Group } from "./nodes";
import {
connect,
detailsKey,
listsDatabases,
loadDatabases,
loadDetails,
loadSchemas,
loadSources,
loadTables,
openConsole,
tablesKey,
useDatabase,
type Loaded,
} from "./store";
import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
import { newMenu, nodeMenu } from "./treeMenu";
import styles from "./Database.module.css";
const GROUP_LABELS: Record<Group, string> = { columns: "columns", keys: "keys", foreignKeys: "foreign keys", indexes: "indexes" };
function status<T>(key: string, l: Loaded<T> | undefined, map: (v: T) => Node[]): Node[] | undefined {
if (!l) return undefined;
if (l.status === "loading") return undefined;
if (l.status === "error") return [{ kind: "message", key: `${key}/err`, text: l.message, error: true }];
const nodes = map(l.value);
return nodes.length ? nodes : [{ kind: "message", key: `${key}/empty`, text: "Empty" }];
}
const primaryKey = (d: DbTableDetails) => d.columns.filter((c) => c.primaryKey).map((c) => c.name);
/** Rows under a table: IDEA's columns / keys / foreign keys / indexes folders (empty ones left out). */
function groupsOf(d: DbTableDetails): Group[] {
const g: Group[] = ["columns"];
if (primaryKey(d).length) g.push("keys");
if (d.foreignKeys.length) g.push("foreignKeys");
if (d.indexes.length) g.push("indexes");
return g;
}
function groupSize(d: DbTableDetails, g: Group): number {
if (g === "columns") return d.columns.length;
if (g === "keys") return primaryKey(d).length ? 1 : 0;
if (g === "foreignKeys") return d.foreignKeys.length;
return d.indexes.length;
}
export function DatabaseView() {
const st = useDatabase();
const tree = useRef<TreeHandle>(null);
useEffect(() => {
if (!st.loaded) void loadSources();
}, [st.loaded]);
// Go to Declaration from a console: the rows exist once the expanded nodes render.
useEffect(() => {
if (!st.treeReveal) return;
const key = st.treeReveal.key;
const frame = requestAnimationFrame(() => {
tree.current?.reveal(key);
tree.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, [st.treeReveal]);
const roots = useMemo<Node[]>(
() => st.sources.map((source) => ({ kind: "source", key: source.id ?? "", source })),
[st.sources],
);
const tablesOf = (id: string, schema: string) => {
const t = st.tables[tablesKey(id, schema)];
return t?.status === "ok" ? t.value : null;
};
const detailsOf = (id: string, schema: string, table: string) => {
const d = st.details[detailsKey(id, schema, table)];
return d?.status === "ok" ? d.value : null;
};
const schemasOf = (key: string, id: string) =>
status(key, st.schemas[id], (v) => v.map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node));
const children = (n: Node): readonly Node[] | undefined => {
switch (n.kind) {
case "source": {
const id = n.source.id ?? "";
if (listsDatabases(n.source))
return status(n.key, st.databases[id], (v) =>
v.names.map((name) => ({
kind: "database",
key: databaseKey(id, name),
id: targetOf(id, name, v.current),
name,
current: name === v.current,
})),
);
return schemasOf(n.key, id);
}
case "database":
return schemasOf(n.key, n.id);
case "schema":
return status(n.key, st.tables[tablesKey(n.id, n.schema)], (v) =>
(["tables", "views"] as const)
.filter((f) => v.some((t) => (t.kind === "view") === (f === "views")))
.map((folder) => ({ kind: "folder", key: `${n.key}/${folder}`, id: n.id, schema: n.schema, folder })),
);
case "folder":
return (tablesOf(n.id, n.schema) ?? [])
.filter((t) => (t.kind === "view") === (n.folder === "views"))
.map((table) => ({ kind: "table", key: `${n.key}/${table.name}`, id: n.id, schema: n.schema, table }));
case "table": {
const d = st.details[detailsKey(n.id, n.schema, n.table.name)];
if (!d || d.status === "loading") return undefined;
if (d.status === "error") return [{ kind: "message", key: `${n.key}/err`, text: d.message, error: true }];
return groupsOf(d.value).map((group) => ({ kind: "group", key: `${n.key}/${group}`, id: n.id, schema: n.schema, table: n.table.name, group }));
}
case "group": {
const d = detailsOf(n.id, n.schema, n.table);
if (!d) return undefined;
const fkCols = new Set(d.foreignKeys.flatMap((k) => k.columns));
const at = { id: n.id, schema: n.schema, table: n.table };
switch (n.group) {
case "columns":
return d.columns.map((column) => ({ kind: "column", key: `${n.key}/${column.name}`, column, foreignKey: fkCols.has(column.name), ...at }));
case "keys":
return [{ kind: "key", key: `${n.key}/pk`, columns: primaryKey(d), ...at }];
case "foreignKeys":
return d.foreignKeys.map((fk, i) => ({ kind: "foreignKey", key: `${n.key}/${i}`, fk, ...at }));
case "indexes":
return d.indexes.map((index) => ({ kind: "index", key: `${n.key}/${index.name}`, index, ...at }));
}
return undefined;
}
default:
return undefined;
}
};
const load = async (n: Node) => {
if (n.kind === "source") {
const id = n.source.id ?? "";
if (!(await connect(id))) return;
if (listsDatabases(n.source)) await loadDatabases(id);
else await loadSchemas(id);
} else if (n.kind === "database") {
if (await connect(n.id)) await loadSchemas(n.id);
} else if (n.kind === "schema") await loadTables(n.id, n.schema);
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
};
const label = (n: Node): string => {
switch (n.kind) {
case "source":
return n.source.name ?? "";
case "database":
return n.name;
case "schema":
return n.schema;
case "folder":
return n.folder;
case "table":
return n.table.name;
case "group":
return GROUP_LABELS[n.group];
case "column":
return n.column.name;
// Unnamed (SQLite) keys are numbered like IDEA's `key #1` / `foreign-key #1`.
case "key":
return "key #1";
case "foreignKey":
return `foreign-key #${Number(n.key.slice(n.key.lastIndexOf("/") + 1)) + 1}`;
case "index":
return n.index.name;
case "message":
return n.text;
}
};
/** Grey text after the label: counts, column types, key columns. */
const detail = (n: Node): string | undefined => {
switch (n.kind) {
case "source": {
const id = n.source.id ?? "";
if (listsDatabases(n.source)) {
const d = st.databases[id];
return d?.status === "ok" ? String(d.value.names.length) : undefined;
}
const s = st.schemas[id];
return s?.status === "ok" ? String(s.value.length) : undefined;
}
case "database": {
const s = st.schemas[n.id];
return s?.status === "ok" ? String(s.value.length) : undefined;
}
case "folder":
return String((tablesOf(n.id, n.schema) ?? []).filter((t) => (t.kind === "view") === (n.folder === "views")).length);
case "group": {
const d = detailsOf(n.id, n.schema, n.table);
return d ? String(groupSize(d, n.group)) : undefined;
}
case "column":
return n.column.dataType;
case "key":
return `(${n.columns.join(", ")})`;
case "foreignKey":
return `(${n.fk.columns.join(", ")}) → ${n.fk.refTable} (${n.fk.refColumns.join(", ")})`;
case "index":
return `${n.index.unique ? "unique " : ""}(${n.index.columns.join(", ")})`;
default:
return undefined;
}
};
const icon = (n: Node): string | undefined => {
switch (n.kind) {
case "source":
return driverIcon(n.source.driver);
case "database":
return "database";
case "schema":
return "db-schema";
case "folder":
case "group":
return "db-folder";
case "table":
return n.table.kind === "view" ? "db-view" : "db-table";
case "column":
return n.column.primaryKey ? "db-column-key" : n.foreignKey ? "db-column-fk" : "db-column";
case "key":
return "db-key";
case "foreignKey":
return "db-foreign-key";
case "index":
return "db-index";
case "message":
return n.error ? "error" : undefined;
}
};
if (st.loaded && !st.sources.length)
return (
<div className={styles.dbView}>
<DatabaseToolbar />
<EmptyState title="No data sources">
<Button variant="ghost" onClick={() => void executeAction("db.newDataSource")}>
New Data Source…
</Button>
</EmptyState>
</div>
);
return (
<div className={styles.dbView}>
<DatabaseToolbar />
<div
className={styles.treeHost}
onFocus={(e) => setContext(DB_VIEW_FOCUS, (e.target as HTMLElement).getAttribute("role") === "tree")}
onBlur={() => setContext(DB_VIEW_FOCUS, false)}
>
<Tree<Node>
aria-label="Data sources"
roots={roots}
handleRef={tree}
expanded={st.treeExpanded}
onExpandedChange={(treeExpanded) => useDatabase.setState({ treeExpanded })}
getKey={(n) => n.key}
getLabel={label}
getChildren={children}
isExpandable={(n) =>
n.kind === "source" || n.kind === "database" || n.kind === "schema" || n.kind === "folder" || n.kind === "table" || n.kind === "group"}
loadChildren={load}
getIcon={(n) => icon(n)}
getDetail={detail}
getColor={(n) => (n.kind === "message" && n.error ? "var(--danger)" : undefined)}
renderNode={(n: Node) => {
const i = icon(n);
const d = detail(n);
const counted = n.kind === "source" || n.kind === "database" || n.kind === "folder" || n.kind === "group";
const labelClass =
n.kind === "message" && n.error ? styles.treeError : n.kind === "key" || n.kind === "foreignKey" ? styles.keyLabel : styles.nodeLabel;
const live = n.kind === "source" && [...st.connected].some((t) => splitTarget(t).id === n.source.id);
return (
<span className={styles.nodeRow}>
{i && (
<span className={styles.nodeIcon}>
<Icon name={i} size={16} />
{/* IDEA: a green dot on the data source icon while connected. */}
{live && <span className={styles.connectedDot} title="Connected" />}
</span>
)}
<span className={labelClass}>{label(n)}</span>
{d && (
<span className={n.kind === "source" ? styles.treeBadge : counted ? styles.treeCount : n.kind === "column" ? styles.treeType : styles.treeDetail}>
{d}
</span>
)}
</span>
);
}}
onSelectionChange={(_, lead) => useDatabase.setState({ selectedSource: lead ? nodeTarget(lead) : null, selectedNode: lead })}
onActivate={(n) => {
if (n.kind === "table") openTableData(n.id, n.schema, n.table.name);
else if (n.kind === "source") void openConsole(n.source.id ?? "");
else if (n.kind === "database") void openConsole(n.id);
}}
activateExpandable={(n) => n.kind === "table"}
onContextMenu={(n, _sel, e) => {
const items = nodeMenu(n);
if (items.length) showContextMenu(e, items);
}}
speedSearch
/>
</div>
</div>
);
}
/**
* The toolbar row of IDEA's Database tool window, always shown under the
* title: New ▸, Properties | Refresh, Deactivate | Jump to Query Console,
* Edit Data, DDL.
*/
function DatabaseToolbar() {
const node = useDatabase((s) => s.selectedNode);
const connected = useDatabase((s) => s.connected);
const sources = useDatabase((s) => s.sources);
const plus = useRef<HTMLButtonElement>(null);
const newOpen = useDatabase((s) => s.newMenuOpen);
const setNewOpen = (v: boolean) => useDatabase.setState({ newMenuOpen: v });
const table = node?.kind === "table" || node?.kind === "column" || node?.kind === "group";
const target = node ? nodeTarget(node) : sources.length === 1 ? (sources[0]!.id ?? null) : null;
const active = !!target && [...connected].some((t) => splitTarget(t).id === splitTarget(target).id);
const button = { size: "small", iconSize: 16 } as const;
return (
<div className={styles.dbToolbar} role="toolbar" aria-label="Database actions">
<IconButton
ref={plus}
{...button}
icon="db-add"
title="New"
shortcut={shortcutLabel("db.new")}
aria-haspopup="menu"
aria-expanded={newOpen}
onClick={() => setNewOpen(!newOpen)}
/>
<Menu open={newOpen} anchor={plus} items={newMenu()} placement="bottom-start" onClose={() => setNewOpen(false)} aria-label="New" />
<IconButton {...button} icon="db-properties" action="db.properties" disabled={!sources.length} />
<span className={styles.dbToolbarSep} />
<IconButton {...button} icon="refresh" action="db.refresh" disabled={!sources.length} />
<IconButton {...button} icon="db-disconnect" action="db.disconnect" disabled={node ? !active : !connected.size} />
<span className={styles.dbToolbarSep} />
<IconButton {...button} icon="db-console" action="db.jumpToConsole" disabled={!target} />
<IconButton {...button} icon="db-table" action="db.editData" disabled={!table} />
{/* IDEA labels this one with text. */}
<button
type="button"
className={styles.textTool}
aria-label="Go to DDL"
disabled={!table}
onClick={() => void executeAction("db.goToDdl")}
{...tooltipAttrs("Go to DDL", shortcutLabel("db.goToDdl"))}
>
DDL
</button>
</div>
);
}
/** Header controls (shown on hover, like IDEA's): Collapse All. */
export function DatabaseHeaderActions() {
return <IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />;
}
+558
Переглянути файл
@@ -0,0 +1,558 @@
/** Data source (add / edit / test connection) and password dialogs. */
import { Fragment, useState } from "react";
import {
call,
commands,
IpcError,
type DataSource,
type DbDriver,
type DbSsh,
type DbSshAuth,
type DbSslMode,
} from "@/platform/ipc";
import { Button, Checkbox, Dialog, Input, Select, Tabs, lazyComponent } from "@/ui";
import { connectRemedy, fileSourceName, newSourceId, sshLabel } from "./model";
import { closeDialog, saveSource, useDatabase } from "./store";
import styles from "./Database.module.css";
const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs));
const DRIVERS: Array<{ value: DbDriver; label: string }> = [
{ value: "postgres", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL / MariaDB" },
{ value: "sqlite", label: "SQLite" },
];
const SSL: Array<{ value: DbSslMode; label: string }> = [
{ value: "prefer", label: "Prefer" },
{ value: "require", label: "Require" },
{ value: "disable", label: "Disable" },
];
const SSH_AUTH: Array<{ value: DbSshAuth; label: string }> = [
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair" },
];
const blankSsh = (): DbSsh => ({
enabled: false,
host: "",
port: null,
user: "",
auth: "openSsh",
keyFile: "",
saveSecret: true,
});
const parsePort = (v: string) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 && n < 65536 ? n : null;
};
function blank(): DataSource {
return {
id: newSourceId(),
name: "",
driver: "postgres",
host: "localhost",
port: null,
database: "",
user: "",
file: null,
ssl: "prefer",
savePassword: true,
readOnly: false,
};
}
function SourceDialog({
initial,
preset,
presetPassword,
}: {
initial: DataSource | null;
preset?: Partial<DataSource>;
presetPassword?: string | null;
}) {
const [s, setS] = useState<DataSource>(() => ({ ...blank(), ...preset, ...initial }));
// `undefined`: unchanged (editing keeps the stored password).
const [password, setPassword] = useState<string | undefined>(presetPassword ?? undefined);
// Same convention for the SSH password / passphrase.
const [sshSecret, setSshSecret] = useState<string | undefined>(undefined);
const [tab, setTab] = useState<"general" | "ssh">("general");
const [test, setTest] = useState<{ ok: boolean; text: string; trust?: boolean } | null>(null);
const [busy, setBusy] = useState(false);
const set = (patch: Partial<DataSource>) => setS((x) => ({ ...x, ...patch }));
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const sqlite = s.driver === "sqlite";
const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${s.database || s.user || "db"}@${s.host}`);
const sshOn = !sqlite && !!ssh.enabled;
const valid = sqlite ? !!s.file?.trim() : !!s.host?.trim() && (!sshOn || !!ssh.host?.trim());
const runTest = async () => {
setBusy(true);
setTest(null);
try {
const v = await call(commands.dbTest({ ...s, name }, password ?? null, sshOn ? (sshSecret ?? null) : null));
setTest({ ok: true, text: `Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
setTest({ ok: false, text, trust });
} finally {
setBusy(false);
}
};
const trustAndRetry = async () => {
await call(commands.dbTrustSshHost(s.id ?? ""));
await runTest();
};
const save = async () => {
setBusy(true);
try {
await saveSource(
{ ...s, name },
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null,
);
closeDialog();
} catch (e) {
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "SQLite Database File" });
if (typeof file === "string") set({ file });
};
const browseKey = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "Private Key File" });
if (typeof file === "string") setSsh({ keyFile: file });
};
return (
<Dialog
open
onClose={closeDialog}
title={initial ? `Data Source: ${initial.name}` : "New Data Source"}
width={560}
footerStart={
<Button onClick={() => void runTest()} disabled={busy || !valid}>
Test Connection
</Button>
}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy || !valid, onClick: () => void save() },
]}
>
{!sqlite && (
<Tabs
variant="compact"
className={styles.dialogTabs}
activeId={tab}
onSelect={(id) => setTab(id as "general" | "ssh")}
aria-label="Data source settings"
panelIdPrefix="db-source"
items={[
{ id: "general", label: "General" },
{ id: "ssh", label: "SSH/SSL" },
]}
/>
)}
<div className={styles.form} role={sqlite ? undefined : "tabpanel"} id={sqlite ? undefined : `db-source-panel-${tab}`}>
{sqlite || tab === "general" ? (
<>
<label className={styles.formLabel} htmlFor="db-name">
Name:
</label>
<Input id="db-name" value={s.name ?? ""} placeholder={name} onChange={(e) => set({ name: e.target.value })} />
<span className={styles.formLabel}>Driver:</span>
<Select aria-label="Driver" value={s.driver ?? "postgres"} options={DRIVERS} onChange={(driver) => set({ driver })} />
{sqlite ? (
<>
<label className={styles.formLabel} htmlFor="db-file">
File:
</label>
<div className={styles.formRow}>
<Input
id="db-file"
className={styles.fill}
value={s.file ?? ""}
placeholder="path/to/database.db (absolute or project-relative)"
onChange={(e) => set({ file: e.target.value || null })}
/>
<Button onClick={() => void browse()}>Browse…</Button>
</div>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-host">
Host:
</label>
<div className={styles.formRow}>
<Input id="db-host" className={styles.fill} value={s.host ?? ""} onChange={(e) => set({ host: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-port">
Port:
</label>
<Input
id="db-port"
style={{ width: 80 }}
inputMode="numeric"
value={s.port ?? ""}
placeholder={s.driver === "mysql" ? "3306" : "5432"}
onChange={(e) => set({ port: parsePort(e.target.value) })}
/>
</div>
{sshOn && (
<>
<span />
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
</>
)}
<label className={styles.formLabel} htmlFor="db-user">
User:
</label>
<Input id="db-user" value={s.user ?? ""} onChange={(e) => set({ user: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-password">
Password:
</label>
<div className={styles.formRow}>
<Input
id="db-password"
className={styles.fill}
type="password"
value={password ?? ""}
placeholder={initial && password === undefined ? "<unchanged>" : ""}
onChange={(e) => setPassword(e.target.value)}
/>
<Checkbox
checked={!!s.savePassword}
onChange={(savePassword) => set({ savePassword })}
label="Save password"
/>
</div>
<label className={styles.formLabel} htmlFor="db-database">
Database:
</label>
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
</>
)}
<span />
<Checkbox checked={!!s.readOnly} onChange={(readOnly) => set({ readOnly })} label="Read-only" />
</>
) : (
<>
<span />
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
<label className={styles.formLabel} htmlFor="db-ssh-host">
Host:
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
Port:
</label>
<Input
id="db-ssh-port"
style={{ width: 80 }}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
User:
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>Authentication:</span>
<Select
aria-label="SSH authentication type"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
/>
{ssh.auth === "keyPair" && (
<>
<label className={styles.formLabel} htmlFor="db-ssh-key">
Private key:
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-key"
className={styles.fill}
disabled={!sshOn}
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
onChange={(e) => setSsh({ keyFile: e.target.value })}
/>
<Button disabled={!sshOn} onClick={() => void browseKey()}>
Browse…
</Button>
</div>
</>
)}
{ssh.auth === "openSsh" ? (
<>
<span />
<span className={styles.formHint}>
Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent.
</span>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-ssh-secret">
{ssh.auth === "password" ? "Password:" : "Passphrase:"}
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-secret"
className={styles.fill}
type="password"
disabled={!sshOn}
value={sshSecret ?? ""}
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
onChange={(e) => setSshSecret(e.target.value)}
/>
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
</div>
</>
)}
<span className={styles.formLabel}>SSL:</span>
<Select aria-label="SSL mode" value={s.ssl ?? "prefer"} options={SSL} onChange={(ssl) => set({ ssl })} />
</>
)}
<div className={`${styles.testResult} ${test?.ok ? styles.ok : styles.bad}`} role="status">
{busy && !test ? "Connecting…" : test?.text}
{test?.trust && (
<>
{" "}
<Button size="small" onClick={() => void trustAndRetry()} disabled={busy}>
Trust Host and Retry
</Button>
</>
)}
</div>
</div>
</Dialog>
);
}
function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok: boolean) => void }) {
const [password, setPassword] = useState("");
const [save, setSave] = useState(!!source.savePassword);
const done = (ok: boolean) => {
closeDialog();
resolve(ok);
};
const submit = async () => {
await call(commands.dbSetPassword(source.id ?? "", password, save));
done(true);
};
return (
<Dialog
open
onClose={() => done(false)}
title={`Connect to ${source.name}`}
width={400}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Connect", role: "default", onClick: () => void submit() },
]}
>
<div className={styles.form}>
<span className={styles.formLabel}>User:</span>
<span>{source.user || "(default)"}</span>
<label className={styles.formLabel} htmlFor="db-ask-password">
Password:
</label>
<Input id="db-ask-password" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} />
<span />
<Checkbox checked={save} onChange={setSave} label="Save password" />
</div>
</Dialog>
);
}
/** SSH password / key passphrase asked on connect. */
function SshSecretDialog({ source, prompt, resolve }: { source: DataSource; prompt: string; resolve: (ok: boolean) => void }) {
const [secret, setSecret] = useState("");
const [save, setSave] = useState(!!source.ssh?.saveSecret);
const password = source.ssh?.auth === "password" || /password required/.test(prompt);
const done = (ok: boolean) => {
closeDialog();
resolve(ok);
};
const submit = async () => {
await call(commands.dbSetSshSecret(source.id ?? "", secret, save));
done(true);
};
return (
<Dialog
open
onClose={() => done(false)}
title={`SSH: ${sshLabel(source) || source.name}`}
width={440}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Connect", role: "default", onClick: () => void submit() },
]}
>
<div className={styles.form}>
<span />
<span className={styles.formHint}>{prompt}</span>
<label className={styles.formLabel} htmlFor="db-ask-ssh-secret">
{password ? "Password:" : "Passphrase:"}
</label>
<Input id="db-ask-ssh-secret" type="password" autoFocus value={secret} onChange={(e) => setSecret(e.target.value)} />
<span />
<Checkbox checked={save} onChange={setSave} label="Save password" />
</div>
</Dialog>
);
}
/** First connection to an SSH host: trust its key (added to ~/.ssh/known_hosts)? */
function HostKeyDialog({ prompt, resolve }: { prompt: string; resolve: (ok: boolean) => void }) {
const done = (ok: boolean) => {
closeDialog();
resolve(ok);
};
return (
<Dialog
open
onClose={() => done(false)}
title="Unknown SSH Host"
width={480}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Trust and Connect", role: "default", onClick: () => done(true) },
]}
>
<div className={styles.dialogText}>
{prompt}. The authenticity of the host cannot be established. Trust it and add the key to ~/.ssh/known_hosts?
</div>
</Dialog>
);
}
/** Overlay host: whichever dialog the store asks for. */
/** IDEA's parameter dialog: one SQL value per query parameter, remembered for the next run. */
function ParamsDialog({ names, resolve }: { names: string[]; resolve: (values: Record<string, string> | null) => void }) {
const [values, setValues] = useState<Record<string, string>>(() => {
const last = useDatabase.getState().paramValues;
return Object.fromEntries(names.map((n) => [n, last[n] ?? ""]));
});
const done = (v: Record<string, string> | null) => {
closeDialog();
resolve(v);
};
return (
<Dialog
open
onClose={() => done(null)}
title="Parameters"
width={480}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Execute", role: "default", onClick: () => done(values) },
]}
>
<div
className={styles.form}
onKeyDown={(e) => {
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
e.preventDefault();
done(values);
}}
>
{names.map((n, i) => (
<Fragment key={n}>
<label className={styles.formLabel} htmlFor={`db-param-${i}`}>
{n}
</label>
<Input
id={`db-param-${i}`}
className={styles.paramInput}
autoFocus={i === 0}
value={values[n] ?? ""}
placeholder="NULL"
onChange={(e) => setValues((v) => ({ ...v, [n]: e.target.value }))}
/>
</Fragment>
))}
<span />
<span className={styles.formHint}>Values are inserted as SQL: quote strings, e.g. 'abc'.</span>
</div>
</Dialog>
);
}
/** Leaving manual commit with changes pending (IDEA asks the same). */
function EndTransactionDialog({ source, resolve }: { source: DataSource; resolve: (commit: boolean | null) => void }) {
const done = (v: boolean | null) => {
closeDialog();
resolve(v);
};
return (
<Dialog
open
onClose={() => done(null)}
title="Switch to Auto-Commit"
width={420}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Roll Back", onClick: () => done(false) },
{ label: "Commit", role: "default", onClick: () => done(true) },
]}
>
<div className={styles.dialogText}>
{source.name} has uncommitted changes. Commit or roll them back before switching to auto-commit?
</div>
</Dialog>
);
}
export function DatabaseDialogs() {
const dialog = useDatabase((s) => s.dialog);
if (!dialog) return null;
if (dialog.kind === "source")
return <SourceDialog key={dialog.source?.id ?? "new"} initial={dialog.source} preset={dialog.preset} presetPassword={dialog.password} />;
if (dialog.kind === "params") return <ParamsDialog names={dialog.names} resolve={dialog.resolve} />;
if (dialog.kind === "endTransaction") return <EndTransactionDialog source={dialog.source} resolve={dialog.resolve} />;
if (dialog.kind === "sshSecret")
return <SshSecretDialog key={dialog.source.id} source={dialog.source} prompt={dialog.prompt} resolve={dialog.resolve} />;
if (dialog.kind === "hostKey") return <HostKeyDialog prompt={dialog.prompt} resolve={dialog.resolve} />;
if (dialog.kind === "password") return <PasswordDialog key={dialog.source.id} source={dialog.source} resolve={dialog.resolve} />;
return <ObjectDialogs />;
}
+319
Переглянути файл
@@ -0,0 +1,319 @@
/**
* Result / table data grid: virtualised rows, resizable columns, rectangular
* cell selection (click, ⇧-click, arrows, ⌘A), copy as TSV / CSV, `<null>`
* rendering, sortable headers and in-place editing (table editor).
*/
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import clsx from "clsx";
import { Icon, showContextMenu, type MenuItem } from "@/ui";
import { toCsv, toTsv, type Cell } from "./model";
import styles from "./Database.module.css";
export interface GridColumn {
name: string;
type: string;
primaryKey?: boolean;
foreignKey?: boolean;
}
const NUMERIC_TYPE = /int|num|dec|real|float|double|money|serial|^bit$/i;
const NUMBER = /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i;
/** Numbers are right-aligned (IDEA): by type, else when every sampled value is one. */
export function isNumericColumn(type: string, sample: readonly Cell[]): boolean {
if (type && !/^(null|unknown)?$/i.test(type)) return NUMERIC_TYPE.test(type) && !/point|interval/i.test(type);
const values = sample.filter((v): v is string => v !== null);
return values.length > 0 && values.every((v) => NUMBER.test(v));
}
export interface GridProps {
columns: readonly GridColumn[];
rowCount: number;
/** Displayed value (pending edits applied). */
value: (row: number, col: number) => Cell;
rowState?: (row: number) => "deleted" | "inserted" | null;
isModified?: (row: number, col: number) => boolean;
editable?: boolean;
onEdit?: (row: number, col: number, value: Cell) => void;
sort?: ReadonlyArray<{ column: string; descending: boolean }>;
onSort?: (column: string, add: boolean) => void;
/** Extra context menu items for the selected rows. */
rowMenu?: (rows: number[]) => MenuItem[];
/** Row number shown for row 0 (paging). */
firstRowNumber?: number;
"aria-label"?: string;
}
interface Sel {
r0: number;
c0: number;
r1: number;
c1: number;
}
const ROW_H = 24;
const NUM_W = 48;
function initialWidth(c: GridColumn, sample: Cell[]): number {
const longest = Math.max(c.name.length + 2, ...sample.map((v) => Math.min((v ?? "<null>").length, 60)));
return Math.max(64, Math.min(320, longest * 7.5 + 20));
}
function norm(s: Sel) {
return {
top: Math.min(s.r0, s.r1),
bottom: Math.max(s.r0, s.r1),
left: Math.min(s.c0, s.c1),
right: Math.max(s.c0, s.c1),
};
}
export function Grid(props: GridProps) {
const { columns, rowCount, value, editable, onEdit, sort, onSort, rowMenu, firstRowNumber = 1 } = props;
const scrollRef = useRef<HTMLDivElement>(null);
const [sel, setSel] = useState<Sel | null>(null);
const [editing, setEditing] = useState<{ row: number; col: number; text: string } | null>(null);
const columnKey = columns.map((c) => c.name).join("\u0000");
const [widths, setWidths] = useState<number[]>([]);
const [numeric, setNumeric] = useState<boolean[]>([]);
useEffect(() => {
const sampleRows = Math.min(rowCount, 50);
const samples = columns.map((_, ci) => Array.from({ length: sampleRows }, (_, r) => value(r, ci)));
setWidths(columns.map((c, ci) => initialWidth(c, samples[ci]!)));
setNumeric(columns.map((c, ci) => isNumericColumn(c.type, samples[ci]!)));
setSel(null);
setEditing(null);
// Re-measure only when the column set changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnKey]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_H,
overscan: 12,
});
const totalWidth = useMemo(() => NUM_W + widths.reduce((a, b) => a + b, 0), [widths]);
const template = `${NUM_W}px ${widths.map((w) => `${w}px`).join(" ")}`;
const selected = (r: number, c: number) => {
if (!sel) return false;
const n = norm(sel);
return r >= n.top && r <= n.bottom && c >= n.left && c <= n.right;
};
const selectedRows = (): number[] => {
if (!sel) return [];
const n = norm(sel);
return Array.from({ length: n.bottom - n.top + 1 }, (_, i) => n.top + i);
};
const copy = (format: "tsv" | "csv", header: boolean, wholeRows = false) => {
if (!sel) return;
const n = norm(sel);
const cols = wholeRows ? columns.map((_, i) => i) : Array.from({ length: n.right - n.left + 1 }, (_, i) => n.left + i);
const rows = selectedRows().map((r) => cols.map((c) => value(r, c)));
const head = header ? cols.map((c) => columns[c]!.name) : null;
const text = format === "tsv" ? toTsv(head, rows) : toCsv(head, rows);
void navigator.clipboard?.writeText(text);
};
const commit = () => {
if (!editing) return;
const prev = value(editing.row, editing.col);
if (editing.text !== (prev ?? "") || prev === null) {
if (!(prev === null && editing.text === "")) onEdit?.(editing.row, editing.col, editing.text);
}
setEditing(null);
scrollRef.current?.focus();
};
const startEdit = (row: number, col: number) => {
if (!editable || props.rowState?.(row) === "deleted") return;
setEditing({ row, col, text: value(row, col) ?? "" });
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (editing) return;
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === "c") {
e.preventDefault();
copy("tsv", false);
return;
}
if (mod && e.key.toLowerCase() === "a") {
e.preventDefault();
if (rowCount && columns.length) setSel({ r0: 0, c0: 0, r1: rowCount - 1, c1: columns.length - 1 });
return;
}
if (!sel) return;
if ((e.key === "Enter" || e.key === "F2") && editable) {
e.preventDefault();
startEdit(sel.r1, sel.c1);
return;
}
const moves: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
const m = moves[e.key];
if (!m) return;
e.preventDefault();
const r = Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0]));
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
virtualizer.scrollToIndex(r, { align: "auto" });
};
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
if (e.button !== 0) {
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
return;
}
if (editing) commit();
setSel(e.shiftKey && sel ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
};
const onRowNumberMouseDown = (e: MouseEvent, r: number) => {
if (e.button !== 0 && sel && selected(r, 0)) return;
const last = columns.length - 1;
setSel(e.shiftKey && sel ? { ...sel, r1: r, c1: last } : { r0: r, c0: 0, r1: r, c1: last });
};
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
if (!sel) return;
const items: MenuItem[] = [
{ label: "Copy", shortcut: "⌘C", run: () => copy("tsv", false) },
{ label: "Copy with Header", run: () => copy("tsv", true) },
{ label: "Copy Rows as TSV", run: () => copy("tsv", true, true) },
{ label: "Copy Rows as CSV", run: () => copy("csv", true, true) },
];
if (editable && onEdit) {
const n = norm(sel);
items.push(
{ kind: "separator" },
{ label: "Edit Cell", disabled: n.top !== n.bottom || n.left !== n.right, run: () => startEdit(n.top, n.left) },
{
label: "Set NULL",
run: () => {
for (let r = n.top; r <= n.bottom; r++) for (let c = n.left; c <= n.right; c++) onEdit(r, c, null);
},
},
);
}
const extra = rowMenu?.(selectedRows()) ?? [];
if (extra.length) items.push({ kind: "separator" }, ...extra);
showContextMenu(e, items);
};
const startResize = (e: MouseEvent, col: number) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const start = widths[col] ?? 100;
const move = (ev: globalThis.MouseEvent) =>
setWidths((w) => w.map((x, i) => (i === col ? Math.max(40, start + ev.clientX - startX) : x)));
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
return (
<div
ref={scrollRef}
className={styles.grid}
tabIndex={0}
role="grid"
aria-label={props["aria-label"] ?? "Results"}
aria-rowcount={rowCount}
onKeyDown={onKeyDown}
onContextMenu={onContextMenu}
>
<div className={styles.gridInner} style={{ width: totalWidth, height: virtualizer.getTotalSize() + ROW_H }}>
<div className={clsx(styles.gridRow, styles.gridHeader)} style={{ gridTemplateColumns: template }} role="row">
<div className={styles.rowNumber} />
{columns.map((c, ci) => {
const s = sort?.find((k) => k.column === c.name);
return (
<div
key={ci}
role="columnheader"
className={clsx(styles.headerCell, onSort && styles.sortable)}
title={`${c.name}: ${c.type}${onSort ? "\nClick to sort, ⇧-click to add to the sort" : ""}`}
onClick={(e) => onSort?.(c.name, e.shiftKey)}
>
<Icon
name={c.primaryKey ? "db-column-key" : c.foreignKey ? "db-column-fk" : "db-column"}
size={16}
className={styles.headerIcon}
/>
<span className={styles.headerName}>{c.name}</span>
{s && <Icon name={s.descending ? "arrow-down" : "arrow-up"} size={12} className={styles.headerSort} />}
<span className={styles.resizer} onMouseDown={(e) => startResize(e, ci)} onClick={(e) => e.stopPropagation()} />
</div>
);
})}
</div>
{virtualizer.getVirtualItems().map((v) => {
const r = v.index;
const state = props.rowState?.(r) ?? null;
return (
<div
key={v.key}
role="row"
className={clsx(styles.gridRow, state === "deleted" && styles.rowDeleted, state === "inserted" && styles.rowInserted)}
style={{ gridTemplateColumns: template, transform: `translateY(${v.start + ROW_H}px)` }}
>
<div className={styles.rowNumber} onMouseDown={(e) => onRowNumberMouseDown(e, r)}>
{state === "inserted" ? "+" : firstRowNumber + r}
</div>
{columns.map((_, c) => {
if (editing && editing.row === r && editing.col === c) {
return (
<input
key={c}
className={styles.cellEditor}
autoFocus
aria-label={`Edit ${columns[c]!.name}`}
value={editing.text}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
onBlur={commit}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setEditing(null);
scrollRef.current?.focus();
}
}}
/>
);
}
const val = value(r, c);
return (
<div
key={c}
role="gridcell"
className={clsx(
styles.cell,
numeric[c] && styles.cellNumber,
selected(r, c) && styles.cellSelected,
props.isModified?.(r, c) && styles.cellModified,
)}
onMouseDown={(e) => onCellMouseDown(e, r, c)}
onDoubleClick={() => startEdit(r, c)}
title={val != null && val.length > 40 ? val.slice(0, 2000) : undefined}
>
{val === null ? <span className={styles.null}>&lt;null&gt;</span> : val}
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}
+608
Переглянути файл
@@ -0,0 +1,608 @@
/**
* Dialogs of the Database tree's object actions, after IDEA's: Rename, Confirm
* Drop, Truncate, Modify Table / New Table (with a Preview of the SQL they
* run), Remove Data Source, Data Source from URL, and the Quick Documentation popup.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { call, commands, type DataSource, type DbColumnInfo, type DbDriver } from "@/platform/ipc";
import { highlightInto } from "@/editor/lsp/markdown";
import { Button, Checkbox, Dialog, IconButton, Input, Popup, Icon } from "@/ui";
import { parseSourceUrl, sourceLabel, splitTarget } from "./model";
import { nodeTarget, tableOf, type DbNode } from "./nodes";
import {
alterTableSql,
canAlterColumn,
createTableSql,
draftOf,
dropSql,
ident,
reference,
renameSql,
truncateSql,
type ColumnDraft,
type DbObjectRef,
} from "./objects";
import {
closeDialog,
deleteSource,
detailsKey,
disconnect,
connectedTargets,
loadDetails,
openSourceDialog,
readableDdl,
renameSource,
runSql,
sourceById,
useDatabase,
withConnection,
type DialogState,
} from "./store";
import { openTableData } from "./treeActions";
import styles from "./Database.module.css";
/** Read-only SQL with the editor's syntax colours (IDEA's dialog Preview pane). */
function SqlPreview({ sql, label = "Preview" }: { sql: string; label?: string }) {
const ref = useRef<HTMLPreElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.textContent = sql;
void highlightInto(el, sql, "SQL");
}, [sql]);
return (
<div className={styles.previewBlock}>
<div className={styles.previewTitle}>{label}</div>
<pre ref={ref} className={styles.sqlPreview} aria-label={`${label} SQL`} />
</div>
);
}
const driverOf = (id: string): DbDriver => sourceById(id)?.driver ?? "sqlite";
const KIND_LABEL: Record<DbObjectRef["kind"], string> = {
table: "table",
view: "view",
column: "column",
index: "index",
schema: "schema",
};
function objectName(o: DbObjectRef): string {
return o.kind === "column" ? (o.column ?? "") : o.name;
}
function useRunner() {
const [busy, setBusy] = useState(false);
const running = useRef(false);
const runAndClose = async (id: string, statements: string[]) => {
if (running.current) return;
running.current = true;
setBusy(true);
const ok = await runSql(id, statements);
running.current = false;
setBusy(false);
if (ok) closeDialog();
};
return { busy, runAndClose };
}
// ---- Rename ------------------------------------------------------------------
function RenameDialog({ id, object }: { id: string; object: DbObjectRef | null }) {
const source = sourceById(id);
const initial = object ? objectName(object) : (source?.name ?? "");
const [name, setName] = useState(initial);
const { busy, runAndClose } = useRunner();
const driver = driverOf(id);
const changed = !!name.trim() && name.trim() !== initial;
const sql = object && changed ? renameSql(driver, object, name.trim()) : "";
const submit = () => {
if (!changed) return closeDialog();
if (!object) return void renameSource(id, name).then(closeDialog);
void runAndClose(id, [sql]);
};
const what = object ? KIND_LABEL[object.kind] : "data source";
return (
<Dialog
open
onClose={closeDialog}
title="Rename"
width={480}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Refactor", role: "default", disabled: busy || !name.trim(), onClick: submit },
]}
>
<div className={styles.dialogBody}>
<label className={styles.formLabelLeft} htmlFor="db-rename">
Rename {what} '{initial}' to:
</label>
<Input
id="db-rename"
autoFocus
value={name}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setName(e.target.value)}
/>
{object && <SqlPreview sql={sql || `-- ${what} keeps its name`} />}
</div>
</Dialog>
);
}
// ---- Drop / Truncate -----------------------------------------------------------
function DropDialog({ id, object }: { id: string; object: DbObjectRef }) {
const driver = driverOf(id);
const [ifExists, setIfExists] = useState(false);
const [cascade, setCascade] = useState(false);
const { busy, runAndClose } = useRunner();
const sql = dropSql(driver, object, { ifExists, cascade });
return (
<Dialog
open
onClose={closeDialog}
title="Confirm Drop"
width={520}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy, onClick: () => void runAndClose(id, [sql]) },
]}
>
<div className={styles.dialogBody}>
<div className={styles.dialogLead}>
Drop {KIND_LABEL[object.kind]} <strong>{reference(driver, object)}</strong>? This cannot be undone.
</div>
<Checkbox
checked={ifExists}
onChange={setIfExists}
label="Use IF EXISTS syntax"
disabled={(object.kind === "column" && driver !== "postgres") || (object.kind === "index" && driver === "mysql")}
/>
<Checkbox checked={cascade} onChange={setCascade} label="Use DROP CASCADE syntax" disabled={driver !== "postgres"} />
<SqlPreview sql={sql} />
</div>
</Dialog>
);
}
function TruncateDialog({ id, schema, table }: { id: string; schema: string; table: string }) {
const driver = driverOf(id);
const [restartIdentity, setRestart] = useState(false);
const [cascade, setCascade] = useState(false);
const { busy, runAndClose } = useRunner();
const sql = truncateSql(driver, schema, table, { restartIdentity, cascade });
return (
<Dialog
open
onClose={closeDialog}
title="Truncate"
width={520}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy, onClick: () => void runAndClose(id, [sql]) },
]}
>
<div className={styles.dialogBody}>
<div className={styles.dialogLead}>
Delete all rows of <strong>{table}</strong>?
</div>
{driver === "postgres" && (
<>
<Checkbox checked={restartIdentity} onChange={setRestart} label="Restart identity" />
<Checkbox checked={cascade} onChange={setCascade} label="Cascade" />
</>
)}
<SqlPreview sql={sql} />
</div>
</Dialog>
);
}
// ---- Modify Table / New Table -----------------------------------------------
const TYPES: Record<DbDriver, string[]> = {
sqlite: ["INTEGER", "TEXT", "REAL", "NUMERIC", "BLOB", "BOOLEAN", "DATE", "DATETIME"],
postgres: [
"integer",
"bigint",
"smallint",
"serial",
"bigserial",
"numeric",
"real",
"double precision",
"boolean",
"text",
"varchar(255)",
"uuid",
"date",
"timestamp",
"timestamptz",
"jsonb",
"bytea",
],
mysql: ["int", "bigint", "smallint", "tinyint(1)", "decimal(10,2)", "double", "varchar(255)", "text", "date", "datetime", "timestamp", "json", "blob"],
};
interface ModifyProps {
id: string;
schema: string;
table: string | null;
focusColumn?: string;
addColumn?: boolean;
}
let nextDraftKey = 1000;
function blankColumn(driver: DbDriver, n: number): ColumnDraft {
return {
key: nextDraftKey++,
original: null,
name: `column_${n}`,
dataType: TYPES[driver][0]!,
notNull: false,
default: "",
primaryKey: false,
dropped: false,
};
}
/** Loads the table's columns, then shows the Modify Table form. */
function ModifyTableDialog(props: ModifyProps) {
const { id, schema, table } = props;
const key = table ? detailsKey(id, schema, table) : null;
const details = useDatabase((s) => (key ? s.details[key] : undefined));
useEffect(() => {
if (table && !details) void loadDetails(id, schema, table);
}, [details, id, schema, table]);
if (!table) return <ModifyTableForm {...props} initial={null} />;
if (details?.status === "ok") return <ModifyTableForm {...props} initial={details.value.columns} />;
return (
<Dialog open onClose={closeDialog} title={`Modify Table ${table}`} width={440} buttons={[{ label: "Cancel", role: "cancel" }]}>
<div className={styles.dialogText}>{details?.status === "error" ? details.message : "Loading…"}</div>
</Dialog>
);
}
function ModifyTableForm({ id, schema, table, focusColumn, addColumn, initial }: ModifyProps & { initial: DbColumnInfo[] | null }) {
const driver = driverOf(id);
const [name, setName] = useState(table ?? "table_name");
const [columns, setColumns] = useState<ColumnDraft[]>(() => {
if (!initial) return [{ ...blankColumn(driver, 1), name: "id", primaryKey: true, notNull: true }];
const drafts = initial.map(draftOf);
if (addColumn) drafts.push(blankColumn(driver, drafts.length + 1));
return drafts;
});
const [selected, setSelected] = useState<number | null>(() => {
const focus = addColumn ? columns.length - 1 : columns.findIndex((c) => c.name === focusColumn);
return columns[Math.max(0, focus)]?.key ?? null;
});
const { busy, runAndClose } = useRunner();
const statements = useMemo(() => {
if (!table) return [createTableSql(driver, schema, name.trim() || "table_name", columns)];
return alterTableSql(driver, schema, table, name, columns);
}, [columns, driver, schema, table, name]);
const current = columns.find((c) => c.key === selected) ?? null;
const patch = (p: Partial<ColumnDraft>) => setColumns((cs) => cs.map((c) => (c.key === selected ? { ...c, ...p } : c)));
const add = () => {
const c = blankColumn(driver, columns.length + 1);
setColumns((cs) => [...cs, c]);
setSelected(c.key);
};
const remove = () => {
if (!current) return;
if (current.original) patch({ dropped: !current.dropped });
else {
const rest = columns.filter((c) => c.key !== current.key);
setColumns(rest);
setSelected(rest.at(-1)?.key ?? null);
}
};
const locked = !!current && !canAlterColumn(driver, current);
const title = table ? `Modify Table ${table}` : "Create Table";
const typeList = `db-types-${driver}`;
return (
<Dialog
open
onClose={closeDialog}
title={title}
width={760}
buttons={[
{ label: "Cancel", role: "cancel" },
{
label: "OK",
role: "default",
disabled: busy || !statements.length || !name.trim(),
onClick: () => void runAndClose(id, statements),
},
]}
>
<div className={styles.modifyBody}>
<div className={styles.formRow}>
<label className={styles.formLabel} htmlFor="db-table-name">
Table:
</label>
<Input id="db-table-name" className={styles.fill} value={name} onChange={(e) => setName(e.target.value)} autoFocus={!table} />
<span className={styles.formHint}>{schema}</span>
</div>
<div className={styles.modifyMain}>
<div className={styles.modifyColumns}>
<div className={styles.modifyToolbar}>
<IconButton icon="plus" title="Add Column" size="small" onClick={add} />
<IconButton
icon="minus"
title={current?.dropped ? "Keep Column" : "Drop Column"}
size="small"
disabled={!current}
onClick={remove}
/>
</div>
<div role="listbox" aria-label="Columns" className={styles.modifyList}>
{columns.map((c) => (
<div
key={c.key}
role="option"
aria-selected={c.key === selected}
tabIndex={-1}
className={styles.modifyItem}
data-dropped={c.dropped || undefined}
onMouseDown={() => setSelected(c.key)}
>
<Icon name={c.primaryKey ? "db-column-key" : "db-column"} size={16} />
<span className={styles.nodeLabel}>{c.name || "<unnamed>"}</span>
<span className={styles.treeType}>{c.dataType}</span>
</div>
))}
</div>
</div>
<div className={styles.modifyProps}>
{current ? (
<>
<label className={styles.formLabel} htmlFor="db-col-name">
Name
</label>
<Input id="db-col-name" value={current.name} disabled={current.dropped} onChange={(e) => patch({ name: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-col-type">
Data type
</label>
<Input
id="db-col-type"
mono
list={typeList}
value={current.dataType}
disabled={current.dropped || locked}
onChange={(e) => patch({ dataType: e.target.value })}
/>
<span />
<Checkbox checked={current.notNull} disabled={current.dropped || locked} onChange={(notNull) => patch({ notNull })} label="Not null" />
{!table && (
<>
<span />
<Checkbox checked={current.primaryKey} onChange={(primaryKey) => patch({ primaryKey })} label="Primary key" />
</>
)}
<label className={styles.formLabel} htmlFor="db-col-default">
Default
</label>
<Input
id="db-col-default"
mono
placeholder="expression"
value={current.default}
disabled={current.dropped || locked}
onChange={(e) => patch({ default: e.target.value })}
/>
{locked && (
<>
<span />
<span className={styles.formHint}>SQLite can rename or drop an existing column, not change its type, NOT NULL or default.</span>
</>
)}
</>
) : (
<span className={styles.formHint}>Select a column</span>
)}
</div>
</div>
<datalist id={typeList}>
{TYPES[driver].map((t) => (
<option key={t} value={t} />
))}
</datalist>
<SqlPreview sql={statements.join("\n") || "-- no changes"} />
</div>
</Dialog>
);
}
// ---- Data sources -------------------------------------------------------------
function RemoveSourceDialog({ source }: { source: DataSource }) {
const remove = async () => {
const id = source.id ?? "";
closeDialog();
if (connectedTargets(id).length) await disconnect(id);
await deleteSource(id);
};
return (
<Dialog
open
onClose={closeDialog}
title="Remove Data Source"
width={440}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Remove", role: "default", onClick: () => void remove() },
]}
>
<div className={styles.dialogText}>
Remove data source <strong>{source.name}</strong>? Its query console file stays on disk.
</div>
</Dialog>
);
}
function SourceUrlDialog() {
const [url, setUrl] = useState("");
const parsed = url.trim() ? parseSourceUrl(url) : null;
const next = () => {
if (!parsed) return;
closeDialog();
const s = parsed.source;
const name = s.driver === "sqlite" ? s.name : `${s.database || s.user || "db"}@${s.host}`;
openSourceDialog(null, { ...s, name }, parsed.password);
};
return (
<Dialog
open
onClose={closeDialog}
title="Data Source from URL"
width={560}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: !parsed, onClick: next },
]}
>
<div className={styles.dialogBody}>
<label className={styles.formLabelLeft} htmlFor="db-url">
URL:
</label>
<Input
id="db-url"
mono
autoFocus
invalid={!!url.trim() && !parsed}
placeholder="postgresql://user@localhost:5432/db"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<span className={styles.formHint}>
{url.trim() && !parsed
? "Not a PostgreSQL, MySQL / MariaDB or SQLite URL"
: "postgresql://, mysql://, mariadb://, jdbc:…, sqlite:path"}
</span>
</div>
</Dialog>
);
}
// ---- Quick Documentation ------------------------------------------------------
function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: number } }) {
const id = nodeTarget(node) ?? "";
const source = sourceById(id);
const t = tableOf(node);
const [ddl, setDdl] = useState<string | null>(null);
const showDdl = node.kind === "table";
useEffect(() => {
if (!showDdl || !t) return;
let live = true;
withConnection(t.id, () => call(commands.dbDdl(t.id, t.schema, t.table)))
.then((d) => live && setDdl(readableDdl(d, driverOf(t.id))))
.catch((e: unknown) => live && setDdl(`-- ${e instanceof Error ? e.message : String(e)}`));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.key]);
const rows: Array<[string, string]> = [];
if (source) rows.push(["Data Source", source.name ?? ""]);
const db = splitTarget(id).database;
if (db) rows.push(["Database", db]);
switch (node.kind) {
case "source":
rows.push(["Driver", sourceLabel(node.source).replace(/^.*\((.*)\)$/, "$1")]);
rows.push(
node.source.driver === "sqlite"
? ["File", node.source.file ?? ""]
: ["Host", `${node.source.host ?? ""}${node.source.port ? `:${node.source.port}` : ""}${node.source.database ? `/${node.source.database}` : ""}`],
);
rows.push(["Status", connectedTargets(node.source.id ?? "").length ? "Connected" : "Not connected"]);
break;
case "database":
rows.push(["Database", node.name]);
break;
case "schema":
case "folder":
rows.push(["Schema", node.schema]);
break;
default:
if (t) rows.push(["Schema", t.schema], [t.view ? "View" : "Table", t.table]);
}
let definition: string | null = null;
if (node.kind === "column") {
const c = node.column;
const d = driverOf(id);
definition = `${ident(d, c.name)} ${c.dataType}${c.nullable ? "" : " not null"}${c.default ? ` default ${c.default}` : ""}${c.primaryKey ? " primary key" : ""}`;
} else if (node.kind === "index") {
definition = `${node.index.unique ? "unique " : ""}index ${node.index.name} (${node.index.columns.join(", ")})`;
} else if (node.kind === "foreignKey") {
definition = `foreign key (${node.fk.columns.join(", ")}) references ${node.fk.refTable} (${node.fk.refColumns.join(", ")})`;
} else if (node.kind === "key") {
definition = `primary key (${node.columns.join(", ")})`;
}
return (
<Popup open onClose={closeDialog} anchor={anchor} placement="bottom-start" className={styles.quickDoc} role="dialog" aria-label="Quick Documentation">
<dl className={styles.quickDocRows}>
{rows.map(([k, v]) => (
<div key={k}>
<dt>{k}:</dt>
<dd>{v}</dd>
</div>
))}
</dl>
{definition && <SqlPreview sql={definition} label="Definition" />}
{showDdl && <SqlPreview sql={ddl ?? "-- loading…"} label="DDL" />}
{t && (
<div className={styles.quickDocLinks}>
<Button
variant="ghost"
onClick={() => {
closeDialog();
openTableData(t.id, t.schema, t.table);
}}
>
Show table data
</Button>
</div>
)}
</Popup>
);
}
export function ObjectDialogs() {
const dialog = useDatabase((s) => s.dialog) as DialogState | null;
if (!dialog) return null;
switch (dialog.kind) {
case "rename":
return <RenameDialog key={`${dialog.id}`} id={dialog.id} object={dialog.object} />;
case "drop":
return <DropDialog id={dialog.id} object={dialog.object} />;
case "truncate":
return <TruncateDialog id={dialog.id} schema={dialog.schema} table={dialog.table} />;
case "modifyTable":
return (
<ModifyTableDialog
id={dialog.id}
schema={dialog.schema}
table={dialog.table}
focusColumn={dialog.focusColumn}
addColumn={dialog.addColumn}
/>
);
case "removeSource":
return <RemoveSourceDialog source={dialog.source} />;
case "sourceUrl":
return <SourceUrlDialog />;
case "quickDoc":
return <QuickDoc key={dialog.node.key} node={dialog.node} anchor={dialog.anchor} />;
default:
return null;
}
}
+164
Переглянути файл
@@ -0,0 +1,164 @@
/**
* Database Console service view (IDEA's): an Output tab logging every
* executed statement with its outcome, and a `Result N` tab per result set —
* a toolbar (re-run, export / copy as CSV, JSON, SQL…), the grid and a floating row count.
*/
import { useEffect, useRef } from "react";
import clsx from "clsx";
import { call, commands } from "@/platform/ipc";
import { EmptyState, IconButton, Spinner, Tabs, showContextMenu, type MenuItem } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export";
import { Grid } from "./Grid";
import { analyze } from "./sql/analyze";
import {
OUTPUT_TAB,
clearOutput,
closeResult,
outcomeSummary,
rerunResult,
sourceById,
targetLabel,
useDatabase,
type OutputLine,
type ResultTab,
} from "./store";
import styles from "./Database.module.css";
function time(t: number): string {
const d = new Date(t);
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
function Output() {
const lines = useDatabase((s) => s.output);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length]);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<span className={styles.spacer} />
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
</div>
<div ref={ref} className={styles.output} role="log" aria-label="Database output">
{lines.map((l: OutputLine) => (
<div key={l.id} className={clsx(styles.outputLine, l.kind === "error" && styles.outputError)}>
<span className={styles.outputTime}>[{time(l.time)}]</span>{" "}
{l.prompt && <span className={styles.outputPrompt}>{l.prompt} </span>}
<span className={l.kind === "statement" ? styles.outputStatement : undefined}>{l.text}</span>
</div>
))}
</div>
</div>
);
}
/** The table a query reads (target of exported SQL inserts), else IDEA's placeholder. */
function queryTable(text: string, data: Pick<ExportData, "driver">): string {
const a = analyze(text, text.length, data.driver);
return a.refs.find((r) => r.depth === 0 && r.kind === "table")?.name ?? "MY_TABLE";
}
async function saveExport(format: ExportFormat, data: ExportData) {
const f = EXPORT_FORMATS.find((x) => x.id === format)!;
let path: string | null;
try {
const { save } = await import("@tauri-apps/plugin-dialog");
path = await save({ defaultPath: `${data.table}.${f.extension}`, filters: [{ name: f.label, extensions: [f.extension] }] });
} catch {
return;
}
if (!path) return;
try {
await call(commands.dbExportFile(path, exportData(format, data)));
notifications().notify({ level: "info", title: `${data.rows.length} rows exported`, body: path, key: "db-export" });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot export data", body: e instanceof Error ? e.message : String(e) });
}
}
function exportMenu(data: ExportData): MenuItem[] {
return [
...EXPORT_FORMATS.map(
(f): MenuItem => ({
label: `Copy as ${f.label}`,
icon: "copy",
run: () => void navigator.clipboard?.writeText(exportData(f.id, data)),
}),
),
{ kind: "separator" },
...EXPORT_FORMATS.map((f): MenuItem => ({ label: `Export to ${f.label} File…`, run: () => void saveExport(f.id, data) })),
];
}
function Result({ tab }: { tab: ResultTab }) {
const r = tab.outcome.result;
if (!r) return null;
const driver = sourceById(tab.sourceId)?.driver ?? "sqlite";
const data = (): ExportData => ({
columns: r.columns.map((c) => ({ name: c.name, type: c.typeName })),
rows: r.rows,
driver,
table: queryTable(tab.outcome.statement.text, { driver }),
});
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Rerun Query" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
<span className={styles.toolbarSeparator} />
<IconButton icon="export" title="Export Data" onClick={(e) => showContextMenu(e, exportMenu(data()))} />
{tab.busy && <Spinner size={12} />}
<span className={styles.spacer} />
<span className={styles.resultSource} title={tab.outcome.statement.text}>
{targetLabel(tab.sourceId)}
</span>
</div>
<div className={clsx(styles.fill, styles.gridHost)}>
<Grid
columns={r.columns.map((c) => ({ name: c.name, type: c.typeName }))}
rowCount={r.rows.length}
value={(row, col) => r.rows[row]?.[col] ?? null}
/>
<div className={styles.rowPill} role="status" title={outcomeSummary(tab.outcome)}>
{r.rows.length} row{r.rows.length === 1 ? "" : "s"}
{r.hasMore ? " (limit reached)" : ""}
</div>
</div>
</div>
);
}
export function ResultsView() {
const results = useDatabase((s) => s.results);
const active = useDatabase((s) => s.activeResult);
const hasOutput = useDatabase((s) => s.output.length > 0);
if (!results.length && !hasOutput)
return <EmptyState title="No results">Execute a statement in a query console with ⌘↩.</EmptyState>;
const tab = results.find((r) => r.id === active);
return (
<div className={styles.view}>
<Tabs
variant="compact"
aria-label="Results"
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
onClose={(id) => closeResult(Number(id))}
items={[
{ id: String(OUTPUT_TAB), label: "Output", icon: "db-console", closable: false },
...results.map((r) => ({
id: String(r.id),
label: r.title,
icon: "db-table",
closable: true,
tooltip: r.outcome.statement.text,
})),
]}
/>
<div className={styles.fill}>{tab ? <Result key={tab.id} tab={tab} /> : <Output />}</div>
</div>
);
}
+241
Переглянути файл
@@ -0,0 +1,241 @@
/**
* Table data editor (editor tab): pages of rows with sorting (header click),
* a WHERE filter, and pending cell edits / added / deleted rows submitted
* together in one transaction. Tables without a primary key are read-only.
*/
import { useEffect, useState } from "react";
import { call, commands, isCancelled, nextRequestId, type DbSortKey, type DbTablePage, type RequestId } from "@/platform/ipc";
import { Button, EmptyState, IconButton, Input, Select, Spinner } from "@/ui";
import { notifications, type EditorInput } from "@/workbench/contributions";
import { Grid } from "./Grid";
import { buildRowEdits, emptyEdits, hasEdits, type Cell, type PendingEdits } from "./model";
import { sourceById, withConnection } from "./store";
import type { TableRef } from "./tableInput";
import styles from "./Database.module.css";
const PAGE_SIZES = ["100", "500", "1000", "5000"];
export { tableTitle } from "./tableInput";
export function TableEditor({ input }: { input: EditorInput }) {
const ref = input.data as TableRef;
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(500);
const [sort, setSort] = useState<DbSortKey[]>([]);
const [filterText, setFilterText] = useState("");
const [filter, setFilter] = useState("");
const [nonce, setNonce] = useState(0);
const [edits, setEdits] = useState<PendingEdits>(emptyEdits);
// The page for the current parameters (`key`); stale while loading.
const [loadedPage, setLoadedPage] = useState<{ key: string; page: DbTablePage | null; error: string | null } | null>(
null,
);
const key = JSON.stringify([ref.id, ref.schema, ref.table, filter, sort, offset, limit, nonce]);
const loading = loadedPage?.key !== key;
const page = loadedPage?.page ?? null;
const error = loadedPage?.error ?? null;
const reload = () => setNonce((n) => n + 1);
useEffect(() => {
const id: RequestId = nextRequestId();
let done = false;
void (async () => {
try {
const p = await withConnection(ref.id, () =>
call(commands.dbTablePage(id, ref.id, ref.schema, ref.table, filter || null, sort, offset, limit)),
);
setLoadedPage({ key, page: p, error: null });
setEdits(emptyEdits());
} catch (e) {
if (!isCancelled(e)) setLoadedPage((prev) => ({ key, page: prev?.page ?? null, error: e instanceof Error ? e.message : String(e) }));
} finally {
done = true;
}
})();
return () => {
if (!done) void commands.cancelRequest(id);
};
}, [key, ref.id, ref.schema, ref.table, filter, sort, offset, limit]);
const columns = page?.result.columns ?? [];
const names = columns.map((c) => c.name);
const pk = page?.details.columns.filter((c) => c.primaryKey).map((c) => c.name) ?? [];
const fkColumns = new Set(page?.details.foreignKeys.flatMap((k) => k.columns) ?? []);
const editable = pk.length > 0 && !sourceById(ref.id)?.readOnly;
const loaded = page?.result.rows.length ?? 0;
const rowCount = loaded + edits.inserts.length;
const value = (row: number, col: number): Cell => {
const name = names[col]!;
if (row >= loaded) {
const ins = edits.inserts[row - loaded];
return ins?.has(name) ? ins.get(name)! : null;
}
const upd = edits.updates.get(row);
if (upd?.has(name)) return upd.get(name)!;
return page?.result.rows[row]?.[col] ?? null;
};
const onEdit = (row: number, col: number, v: Cell) => {
const name = names[col]!;
setEdits((e) => {
const next: PendingEdits = { updates: new Map(e.updates), deletes: new Set(e.deletes), inserts: [...e.inserts] };
if (row >= loaded) {
const ins = new Map(next.inserts[row - loaded]);
ins.set(name, v);
next.inserts[row - loaded] = ins;
} else {
const upd = new Map(next.updates.get(row));
if ((page?.result.rows[row]?.[col] ?? null) === v) upd.delete(name);
else upd.set(name, v);
if (upd.size) next.updates.set(row, upd);
else next.updates.delete(row);
}
return next;
});
};
const deleteRows = (rows: number[]) =>
setEdits((e) => {
const deletes = new Set(e.deletes);
const insertsGone = new Set(rows.filter((r) => r >= loaded).map((r) => r - loaded));
for (const r of rows) if (r < loaded) deletes.add(r);
return { ...e, deletes, inserts: e.inserts.filter((_, i) => !insertsGone.has(i)) };
});
const submit = async () => {
if (!page) return;
try {
const n = await call(
commands.dbApplyEdits(ref.id, ref.schema, ref.table, buildRowEdits(names, page.result.rows, pk, edits)),
);
notifications().notify({ level: "info", title: `${ref.table}: ${n} row(s) changed`, key: "db-submit" });
reload();
} catch (e) {
notifications().notify({
level: "error",
title: "Changes were not submitted",
body: e instanceof Error ? e.message : String(e),
key: "db-submit",
});
}
};
const onSort = (column: string, add: boolean) =>
setSort((s) => {
const cur = s.find((k) => k.column === column);
// asc → desc → none.
const next = !cur ? { column, descending: false } : !cur.descending ? { column, descending: true } : null;
const rest = add ? s.filter((k) => k.column !== column) : [];
return next ? [...rest, next] : rest;
});
const dirty = hasEdits(edits);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Reload Page" onClick={reload} disabled={loading} />
<div className={styles.pager}>
<IconButton
icon="chevron-left"
title="Previous Page"
disabled={offset === 0 || loading}
onClick={() => setOffset(Math.max(0, offset - limit))}
/>
<span>
{loaded ? `${offset + 1}–${offset + loaded}` : "0"}
{page?.result.hasMore ? " of more" : ""}
</span>
<IconButton
icon="chevron-right"
title="Next Page"
disabled={!page?.result.hasMore || loading}
onClick={() => setOffset(offset + limit)}
/>
<Select
aria-label="Page size"
value={String(limit)}
options={PAGE_SIZES.map((v) => ({ value: v, label: v }))}
onChange={(v) => {
setOffset(0);
setLimit(Number(v));
}}
/>
</div>
<Input
className={styles.filter}
mono
aria-label="WHERE filter"
placeholder="WHERE (e.g. id > 10)"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOffset(0);
setFilter(filterText.trim());
}
}}
/>
{editable && (
<>
<IconButton
icon="plus"
title="Add Row"
onClick={() => setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))}
/>
<Button size="small" variant="primary" disabled={!dirty} onClick={() => void submit()}>
Submit
</Button>
<Button size="small" disabled={!dirty} onClick={() => setEdits(emptyEdits())}>
Revert
</Button>
</>
)}
{loading && <Spinner size={12} />}
</div>
{page && !editable && (
<div className={styles.notice}>
{pk.length ? "Read-only data source." : "Read-only: the table has no primary key to identify rows."}
</div>
)}
{error ? (
<pre className={styles.error}>{error}</pre>
) : !page ? (
<EmptyState title="Loading…" />
) : (
<div className={styles.fill}>
<Grid
aria-label={`${ref.table} data`}
columns={columns.map((c) => ({
name: c.name,
type: c.typeName,
primaryKey: pk.includes(c.name),
foreignKey: fkColumns.has(c.name),
}))}
rowCount={rowCount}
value={value}
firstRowNumber={offset + 1}
rowState={(r) => (r >= loaded ? "inserted" : edits.deletes.has(r) ? "deleted" : null)}
isModified={(r, c) => !!edits.updates.get(r)?.has(names[c]!)}
editable={editable}
onEdit={onEdit}
sort={sort}
onSort={onSort}
rowMenu={(rows) =>
editable
? [
{ label: "Delete Rows", icon: "trash", run: () => deleteRows(rows) },
{
label: "Restore Rows",
run: () =>
setEdits((e) => ({ ...e, deletes: new Set([...e.deletes].filter((d) => !rows.includes(d))) })),
},
]
: []
}
/>
</div>
)}
</div>
);
}
+484
Переглянути файл
@@ -0,0 +1,484 @@
/**
* Query console integration for SQL editors: a toolbar panel binding the file
* to a data source (execute / stop / row limit), run feedback on the text
* (gutter check / error, timing, error banner) and context-aware completion
* from the introspected schema.
*/
import {
hasNextSnippetField,
hasPrevSnippetField,
insertCompletionText,
nextSnippetField,
pickedCompletion,
snippet,
startCompletion,
type Completion,
type CompletionContext,
type CompletionResult,
type CompletionSource,
} from "@codemirror/autocomplete";
import { EditorState, Facet, Prec, RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
import {
Decoration,
EditorView,
GutterMarker,
WidgetType,
gutter,
showPanel,
showTooltip,
type Panel,
type Tooltip,
} from "@codemirror/view";
import { useEffect, useMemo } from "react";
import { createRoot } from "react-dom/client";
import type { EditorDocContext } from "@/editor/api";
import { iconNode } from "@/editor/lsp/iconMarkup";
import type { DbDriver, DbStatementOutcome } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { IconButton, Select } from "@/ui";
import { sourceLabel, splitTarget, targetOf } from "./model";
import { analyze, templateContexts, valuesSlot } from "./sql/analyze";
import { sqlCandidates, type CandidateKind, type SqlCandidate } from "./sql/complete";
import { sqlSupport } from "./sqlSupport";
import {
bindConsole,
cancelConsole,
consoleSource,
executeConsole,
finishTransaction,
loadCompletion,
listsDatabases,
loadDatabases,
loadSources,
setManualCommit,
sourceById,
targetLabel,
useDatabase,
} from "./store";
import styles from "./Database.module.css";
/** `templates` feature's language-data key for finer template contexts. */
const TEMPLATE_CONTEXT_KEY = "openideaTemplateContexts";
export const SQL_LANGUAGE = "sql";
const ROW_LIMITS = ["100", "500", "1000", "5000", "50000"];
/** Statement outcomes of the last run, kept on the text (IDEA's gutter check / error and timing). */
interface RunMark {
from: number;
to: number;
/** After the statement's `;` when it has one. */
timingAt: number;
error: string | null;
ms: number | null;
}
interface RunState {
marks: readonly RunMark[];
/** Message of the bottom error banner (`null`: hidden). */
banner: string | null;
}
const setRun = StateEffect.define<RunState>();
const runField = StateField.define<RunState>({
create: () => ({ marks: [], banner: null }),
update(value, tr) {
for (const e of tr.effects) if (e.is(setRun)) return e.value;
if (!tr.docChanged) return value;
// Editing a statement drops its mark.
const marks: RunMark[] = [];
for (const m of value.marks) {
let touched = false;
tr.changes.iterChangedRanges((a, b) => {
if (a <= m.to && b >= m.from) touched = true;
});
if (!touched)
marks.push({ ...m, from: tr.changes.mapPos(m.from), to: tr.changes.mapPos(m.to), timingAt: tr.changes.mapPos(m.timingAt) });
}
return { ...value, marks };
},
provide: (f) => [
EditorView.decorations.from(f, (st) => {
const ranges = [];
for (const m of st.marks) {
if (m.error) ranges.push(Decoration.mark({ class: "cm-openidea-db-error", attributes: { title: m.error } }).range(m.from, m.to));
if (m.ms !== null) ranges.push(Decoration.widget({ widget: new TimingWidget(m.ms), side: 1 }).range(m.timingAt));
}
return Decoration.set(ranges, true);
}),
showPanel.from(f, (st) => (st.banner ? errorBanner(st.banner) : null)),
],
});
class TimingWidget extends WidgetType {
constructor(readonly ms: number) {
super();
}
eq(other: TimingWidget) {
return other.ms === this.ms;
}
toDOM() {
const span = document.createElement("span");
span.className = "cm-openidea-db-timing";
span.textContent = this.ms < 1000 ? `${Math.round(this.ms)} ms` : `${(this.ms / 1000).toFixed(1)} s`;
return span;
}
}
class RunMarker extends GutterMarker {
constructor(readonly error: string | null) {
super();
}
eq(other: RunMarker) {
return other.error === this.error;
}
toDOM() {
const el = iconNode(this.error ? "error" : "check", 14, this.error ? "cm-openidea-db-run-error" : "cm-openidea-db-run-ok");
el.title = this.error ?? "Executed";
return el;
}
}
const runGutter = gutter({
class: "cm-openidea-db-gutter",
markers: (view) => {
const st = view.state.field(runField);
const doc = view.state.doc;
const ranges = st.marks
.filter((m) => m.from <= doc.length)
.map((m) => new RunMarker(m.error).range(doc.lineAt(m.from).from));
return RangeSet.of(ranges, true);
},
});
function errorBanner(message: string) {
return (view: EditorView): Panel => {
const dom = document.createElement("div");
dom.className = "cm-openidea-db-banner";
dom.setAttribute("role", "alert");
const text = document.createElement("span");
text.className = "cm-openidea-db-banner-text";
text.textContent = message;
text.title = message;
const close = document.createElement("button");
close.type = "button";
close.className = "cm-openidea-db-banner-close";
close.setAttribute("aria-label", "Close");
close.appendChild(iconNode("close", 14));
close.onclick = () => view.dispatch({ effects: setRun.of({ ...view.state.field(runField), banner: null }) });
dom.append(text, close);
return { dom, top: false };
};
}
/** Marks the statements that ran: gutter check or error, timing, the error banner. */
export function markOutcomes(view: EditorView, outcomes: readonly DbStatementOutcome[]) {
const len = view.state.doc.length;
const marks = outcomes
.filter((o) => o.statement.to <= len && o.statement.from < o.statement.to)
.map((o) => ({
from: o.statement.from,
to: o.statement.to,
timingAt: view.state.sliceDoc(o.statement.to, o.statement.to + 1) === ";" ? o.statement.to + 1 : o.statement.to,
error: o.error ?? null,
ms: o.result?.elapsedMs ?? null,
}));
const failed = outcomes.find((o) => o.error);
const name = failed ? (sourceById(consoleSource(view.state.facet(consolePath)))?.name ?? "") : "";
view.dispatch({ effects: setRun.of({ marks, banner: failed ? `[${name}] ${failed.error}` : null }) });
}
/** Runs the statement at the caret (or the selected ones) of `view`. */
export async function executeInView(view: EditorView, path: string) {
const sel = view.state.selection.main;
const outcomes = await executeConsole(path, view.state.doc.toString(), sel.from, sel.to);
if (outcomes.length) markOutcomes(view, outcomes);
}
const consolePath = Facet.define<string, string>({ combine: (v) => v[0] ?? "" });
function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
const sources = useDatabase((s) => s.sources);
const loaded = useDatabase((s) => s.loaded);
const bound = useDatabase(() => consoleSource(path));
const running = useDatabase((s) => !!s.running[path]);
const maxRows = useDatabase((s) => s.maxRows);
const manual = useDatabase((s) => !!bound && s.manualCommit.has(bound));
const pending = useDatabase((s) => !!bound && s.pendingTx.has(bound));
const databases = useDatabase((s) => s.databases);
const boundId = bound ? splitTarget(bound).id : null;
useEffect(() => {
if (boundId && listsDatabases(sourceById(boundId)) && !useDatabase.getState().databases[boundId]) void loadDatabases(boundId);
}, [boundId]);
const targets = useMemo(() => {
const out: Array<{ value: string; label: string; detail?: string; icon: string }> = [];
for (const s of sources) {
const id = s.id ?? "";
const dbs = databases[id];
const host = sourceLabel(s).slice((s.name ?? "").length + 1);
if (listsDatabases(s) && dbs?.status === "ok") {
for (const name of dbs.value.names)
out.push({ value: targetOf(id, name, dbs.value.current), label: `${s.name ?? ""}: ${name}`, detail: host, icon: "database" });
} else out.push({ value: id, label: s.name ?? "", detail: host, icon: "db-schema" });
}
// A console bound to a database whose list is not loaded yet still shows it.
if (bound && !out.some((o) => o.value === bound)) out.push({ value: bound, label: targetLabel(bound), icon: "database" });
return out;
}, [sources, databases, bound]);
useEffect(() => {
if (!loaded) void loadSources();
}, [loaded]);
useEffect(() => {
if (bound) void loadCompletion(bound);
}, [bound]);
return (
<div className={styles.consoleBar}>
<IconButton
icon="run"
title="Execute"
shortcut={shortcutLabel("db.execute")}
disabled={!bound || running}
onClick={() => void executeInView(view, path)}
/>
<IconButton icon="stop" title="Cancel Running Statement" disabled={!running} onClick={() => cancelConsole(path)} />
<span className={styles.toolbarSeparator} />
<Select
aria-label="Row limit"
value={String(maxRows)}
options={ROW_LIMITS.map((v) => ({ value: v, label: `${Number(v).toLocaleString("en")} rows` }))}
onChange={(v) => useDatabase.setState({ maxRows: Number(v) })}
/>
<span className={styles.toolbarSeparator} />
<Select
aria-label="Transaction mode"
value={manual ? "manual" : "auto"}
options={[
{ value: "auto", label: "Tx: Auto" },
{ value: "manual", label: "Tx: Manual" },
]}
onChange={(v) => bound && void setManualCommit(bound, v === "manual")}
disabled={!bound}
/>
{manual && (
<>
<IconButton icon="commit" title="Commit" disabled={!pending || running} onClick={() => bound && void finishTransaction(bound, true)} />
<IconButton icon="revert" title="Roll Back" disabled={!pending || running} onClick={() => bound && void finishTransaction(bound, false)} />
</>
)}
<span className={styles.spacer} />
<Select
aria-label="Data source"
value={bound ?? null}
placeholder={sources.length ? "<data source>" : "None configured"}
options={targets}
onChange={(id) => bindConsole(path, id)}
disabled={!sources.length}
/>
</div>
);
}
function consolePanel(path: string) {
return (view: EditorView): Panel => {
const dom = document.createElement("div");
const root = createRoot(dom);
root.render(<ConsoleBar path={path} view={view} />);
return { dom, top: true, destroy: () => queueMicrotask(() => root.unmount()) };
};
}
const ICONS: Record<CandidateKind, string | null> = {
table: "db-table",
view: "db-view",
column: "db-column",
columns: "db-columns",
keyword: null,
function: "symbol-function",
schema: "db-schema",
alias: null,
join: "db-column-fk",
type: null,
};
function iconOf(c: SqlCandidate): string | null {
if (c.kind !== "column") return ICONS[c.kind];
return c.primaryKey ? "db-column-key" : c.foreignKey ? "db-column-fk" : "db-column";
}
function docNode(text: string): Node {
const pre = document.createElement("pre");
pre.className = "cm-openidea-db-doc";
pre.textContent = text;
return pre;
}
function applyCandidate(c: SqlCandidate, driver: DbDriver) {
return (view: EditorView, completion: Completion, from: number, to: number) => {
// A selected template placeholder is replaced, as CodeMirror's own string completions do.
const sel = view.state.selection.main;
if (to === sel.from) to = sel.to;
if (c.snippet) {
snippet(c.snippet)(view, completion, from, to);
return;
}
const text = c.insert ?? c.label;
if (!c.addFrom) {
// Every cursor, so a template variable used twice (`$alias$`) follows along.
view.dispatch({
...insertCompletionText(view.state, text, from, to),
userEvent: "input.complete",
annotations: pickedCompletion.of(completion),
});
if (c.retrigger) setTimeout(() => startCompletion(view), 0);
else finishTemplateField(view);
return;
}
const changes: Array<{ from: number; to?: number; insert: string }> = [{ from, to, insert: text }];
// Re-read the statement as it is now: the user may have typed on since the popup opened.
const a = analyze(view.state.doc.toString(), to, driver);
if (a.fromInsert !== null) {
const clause = ` ${a.lowerKeywords ? "from" : "FROM"} ${c.addFrom}`;
if (a.fromInsert <= to) changes[0]!.insert += clause;
else changes.push({ from: a.fromInsert, insert: clause });
}
view.dispatch({
changes,
selection: { anchor: from + text.length },
userEvent: "input.complete",
annotations: pickedCompletion.of(completion),
scrollIntoView: true,
});
};
}
/** Picking an item for a template field moves on to the next field, as in IDEA. */
function finishTemplateField(view: EditorView) {
if (hasNextSnippetField(view.state)) nextSnippetField(view);
}
/**
* Context-aware SQL completion against the bound data source's schema (see
* `sql/analyze` and `sql/complete`); keywords and functions without one.
*/
function completionSource(path: string): CompletionSource {
return (ctx: CompletionContext): CompletionResult | null => {
const id = consoleSource(path);
const source = sourceById(id);
const driver: DbDriver = source?.driver ?? "sqlite";
const model = id ? (useDatabase.getState().completion[id] ?? null) : null;
if (id && !model) void loadCompletion(id);
// A selected template placeholder (`alias`) is about to be replaced: analyze without it.
const sel = ctx.state.selection.main;
const placeholder = !sel.empty && (ctx.pos === sel.from || ctx.pos === sel.to);
const doc = ctx.state.doc.toString();
const analysis = placeholder
? analyze(doc.slice(0, sel.from) + doc.slice(sel.to), sel.from, driver)
: analyze(doc, ctx.pos, driver);
const typed = analysis.prefix.text;
const afterDot = !!analysis.qualifier && !typed;
if (!ctx.explicit && ((!typed && !afterDot) || /^\d/.test(typed))) return null;
const candidates = sqlCandidates({ model, analysis, driver, sourceName: source?.name ?? undefined });
if (!candidates.length) return null;
return {
from: analysis.prefix.from,
options: candidates.map((c) => ({
label: c.label,
detail: c.tail,
typeText: c.type,
icon: iconOf(c),
type: c.kind === "keyword" ? "keyword" : c.kind,
boost: Math.max(-99, Math.min(99, c.boost)),
info: c.doc ? () => docNode(c.doc!) : undefined,
apply: applyCandidate(c, driver),
})),
validFor: /^[\p{L}\p{N}_$]*$/u,
};
};
}
const noKeywords: CompletionSource = (ctx) => ({ from: ctx.pos, options: [] });
function driverOf(path: string): DbDriver {
return sourceById(consoleSource(path))?.driver ?? "sqlite";
}
/** Live template contexts at a position (`sql-statement` / `sql-query` / `sql-code`). */
function templateContextsAt(path: string) {
return (state: EditorState, pos: number): readonly string[] =>
templateContexts(analyze(state.doc.toString(), pos, driverOf(path)));
}
/**
* A template ending right after a qualifier (`selw`: `where o.|`) opens column
* completion there; its other fields complete through the templates feature.
*/
const completeAfterTemplate = EditorView.updateListener.of((u) => {
if (u.docChanged || !u.selectionSet || u.transactions.some((tr) => tr.isUserEvent("select"))) return;
const active = (s: EditorState) => hasNextSnippetField(s) || hasPrevSnippetField(s);
if (!active(u.startState) || active(u.state)) return;
const head = u.state.selection.main.head;
if (!u.state.selection.main.empty || u.state.sliceDoc(head - 1, head) !== ".") return;
setTimeout(() => {
if (u.view.state.selection === u.state.selection) startCompletion(u.view);
}, 0);
});
/** IDEA's parameter info inside `VALUES (…)`: the target columns, the current one in bold. */
function valuesInfo(path: string) {
const compute = (state: EditorState): Tooltip | null => {
const sel = state.selection.main;
if (!sel.empty) return null;
const id = consoleSource(path);
const slot = valuesSlot(state.doc.toString(), sel.head, driverOf(path));
if (!slot) return null;
const table = id
? useDatabase
.getState()
.completion[id]?.tables.find((t) => t.name.toLowerCase() === slot.target.name.toLowerCase())
: undefined;
const names = slot.columns ?? table?.columns.map((c) => c.name) ?? [];
if (!names.length) return null;
const typeOf = (n: string) => table?.columns.find((c) => c.name.toLowerCase() === n.toLowerCase())?.dataType;
return {
pos: sel.head,
above: true,
create: () => {
const dom = document.createElement("div");
dom.className = "cm-openidea-db-params";
names.forEach((n, i) => {
if (i) dom.append(", ");
const span = document.createElement("span");
if (i === slot.index) span.className = "cm-openidea-db-param-current";
const type = typeOf(n);
span.textContent = type ? `${n}:${type}` : n;
dom.append(span);
});
return { dom };
},
};
};
return StateField.define<Tooltip | null>({
create: compute,
update: (value, tr) => (tr.docChanged || tr.selection ? compute(tr.state) : value),
provide: (f) => showTooltip.from(f),
});
}
export function consoleExtension(ctx: EditorDocContext): Extension {
if (ctx.language !== SQL_LANGUAGE || ctx.virtual || ctx.readOnly) return [];
return [
consolePath.of(ctx.path),
runField,
runGutter,
showPanel.of(consolePanel(ctx.path)),
EditorState.languageData.of(() => [
{ openideaCompletion: completionSource(ctx.path), [TEMPLATE_CONTEXT_KEY]: templateContextsAt(ctx.path) },
]),
completeAfterTemplate,
valuesInfo(ctx.path),
sqlSupport(ctx.path),
// The grammar's keyword list would duplicate ours (it is the fallback without a language server).
Prec.highest(EditorState.languageData.of(() => [{ autocomplete: noKeywords }])),
];
}
+133
Переглянути файл
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { exportData, type ExportData } from "./export";
import { buildRowEdits, connectRemedy, emptyEdits, hasEdits, parseSourceUrl, sourceLabel, splitTarget, sshLabel, targetOf, toCsv, toTsv } from "./model";
describe("clipboard formats", () => {
it("copies TSV with NULL as empty and whitespace flattened", () => {
expect(toTsv(["a", "b"], [["1", null], ["x\ty", "line\nbreak"]])).toBe("a\tb\n1\t\nx y\tline break");
expect(toTsv(null, [["1"]])).toBe("1");
});
it("copies RFC 4180 CSV", () => {
expect(toCsv(["id", "note"], [["1", 'say "hi", ok'], ["2", null]])).toBe('id,note\r\n1,"say ""hi"", ok"\r\n2,');
});
});
describe("table edits", () => {
const columns = ["id", "name", "note"];
const rows = [
["1", "ann", null],
["2", "bob", "x"],
];
it("turns pending changes into keyed updates, deletes and inserts", () => {
const e = emptyEdits();
expect(hasEdits(e)).toBe(false);
e.updates.set(0, new Map([["note", "hello"]]));
e.updates.set(1, new Map([["name", "bobby"]]));
e.deletes.add(1);
e.inserts.push(new Map([["name", "cy"]]));
expect(hasEdits(e)).toBe(true);
expect(buildRowEdits(columns, rows, ["id"], e)).toEqual([
{ kind: "update", key: [{ column: "id", value: "1" }], values: [{ column: "note", value: "hello" }] },
{ kind: "delete", key: [{ column: "id", value: "2" }] },
{ kind: "insert", values: [{ column: "name", value: "cy" }] },
]);
});
});
describe("labels", () => {
it("labels data sources", () => {
expect(sourceLabel({ name: "app", driver: "postgres" })).toBe("app (PostgreSQL)");
});
});
describe("export formats", () => {
const data: ExportData = {
columns: [
{ name: "id", type: "INTEGER" },
{ name: "name", type: "TEXT" },
],
rows: [
["1", "O'Hara"],
["2", null],
],
table: "order items",
driver: "postgres",
};
it("writes JSON with numbers and nulls", () => {
expect(JSON.parse(exportData("json", data))).toEqual([
{ id: 1, name: "O'Hara" },
{ id: 2, name: null },
]);
});
it("writes SQL inserts with quoted names and literals", () => {
expect(exportData("sql", data)).toBe(
`INSERT INTO "order items" (id, name) VALUES (1, 'O''Hara');\nINSERT INTO "order items" (id, name) VALUES (2, NULL);`,
);
});
it("writes a Markdown table, numbers right-aligned", () => {
expect(exportData("markdown", { ...data, rows: [["1", "a|b"]] })).toBe("| id | name |\n| --: | --- |\n| 1 | a\\|b |");
});
});
describe("connection errors", () => {
const ssh = (auth: "password" | "keyPair" | "openSsh") => ({ ssh: { enabled: true, host: "bastion", auth } });
it("asks for what the failed connection needs", () => {
expect(connectRemedy("protocol", "Unknown SSH host key for bastion:22 (ssh-ed25519 SHA256:abc)")).toBe("trustHostKey");
expect(connectRemedy("permissionDenied", "SSH password required for me@bastion:22", ssh("password"))).toBe("sshSecret");
expect(connectRemedy("permissionDenied", "SSH key passphrase required for /k", ssh("keyPair"))).toBe("sshSecret");
expect(connectRemedy("permissionDenied", "SSH authentication failed for me@bastion:22", ssh("password"))).toBe(
"sshSecret",
);
expect(connectRemedy("permissionDenied", "SSH authentication failed for me@bastion:22", ssh("openSsh"))).toBeNull();
expect(connectRemedy("permissionDenied", "[28P01] password authentication failed")).toBe("password");
expect(connectRemedy("io", "connection refused")).toBeNull();
});
it("never offers to trust a changed host key", () => {
expect(
connectRemedy("permissionDenied", "SSH host key of bastion:22 has changed (known_hosts line 3); possible attack"),
).toBeNull();
});
it("labels the SSH host", () => {
expect(sshLabel({ ssh: { host: "bastion", user: "ops", port: 2222 } })).toBe("ops@bastion:2222");
expect(sshLabel({ ssh: null })).toBe("");
});
});
describe("connection targets", () => {
it("names the default database by the source id alone", () => {
expect(targetOf("pg", null, "app")).toBe("pg");
expect(targetOf("pg", "app", "app")).toBe("pg");
expect(targetOf("pg", "other", "app")).toBe("pg:other");
});
it("splits a target back into source and database", () => {
expect(splitTarget("pg")).toEqual({ id: "pg", database: null });
expect(splitTarget("pg:my:db")).toEqual({ id: "pg", database: "my:db" });
});
});
describe("parseSourceUrl", () => {
it("reads PostgreSQL / MySQL URLs, plain and JDBC", () => {
expect(parseSourceUrl("postgresql://ann:s%[email protected]:5433/shop?sslmode=require")).toEqual({
source: { driver: "postgres", host: "db.local", port: 5433, user: "ann", database: "shop", ssl: "require" },
password: "s@cret",
});
expect(parseSourceUrl("jdbc:mysql://localhost/app?user=root")).toEqual({
source: { driver: "mysql", host: "localhost", port: null, user: "root", database: "app" },
password: null,
});
});
it("reads SQLite files and rejects other drivers", () => {
expect(parseSourceUrl("jdbc:sqlite:C:/data/shop.db")?.source).toEqual({ driver: "sqlite", file: "C:/data/shop.db", name: "shop" });
expect(parseSourceUrl("oracle://x")).toBeNull();
});
});
+62
Переглянути файл
@@ -0,0 +1,62 @@
/**
* Result-set export formats (IDEA's data extractors): CSV, TSV, JSON, SQL
* INSERT statements and a Markdown table. Values arrive as text; numeric
* columns are written unquoted where the format distinguishes them.
*/
import type { DbDriver } from "@/platform/ipc";
import { isNumericColumn } from "./Grid";
import { toCsv, toTsv, type Cell } from "./model";
import { quoteIdent } from "./sql/complete";
export type ExportFormat = "csv" | "tsv" | "json" | "sql" | "markdown";
export const EXPORT_FORMATS: ReadonlyArray<{ id: ExportFormat; label: string; extension: string }> = [
{ id: "csv", label: "CSV", extension: "csv" },
{ id: "tsv", label: "TSV", extension: "tsv" },
{ id: "json", label: "JSON", extension: "json" },
{ id: "sql", label: "SQL Inserts", extension: "sql" },
{ id: "markdown", label: "Markdown", extension: "md" },
];
export interface ExportData {
columns: ReadonlyArray<{ name: string; type: string }>;
rows: ReadonlyArray<readonly Cell[]>;
/** Target of SQL inserts (the query's table, when it reads one). */
table: string;
driver: DbDriver;
}
export function exportData(format: ExportFormat, d: ExportData): string {
const names = d.columns.map((c) => c.name);
const numeric = d.columns.map((c, i) =>
isNumericColumn(
c.type,
d.rows.slice(0, 50).map((r) => r[i] ?? null),
),
);
switch (format) {
case "csv":
return toCsv(names, d.rows);
case "tsv":
return toTsv(names, d.rows);
case "json": {
const value = (v: Cell, i: number) => (v === null ? null : numeric[i] && Number.isFinite(Number(v)) ? Number(v) : v);
return JSON.stringify(
d.rows.map((r) => Object.fromEntries(names.map((n, i) => [n, value(r[i] ?? null, i)]))),
null,
2,
);
}
case "sql": {
const literal = (v: Cell, i: number) =>
v === null ? "NULL" : numeric[i] && Number.isFinite(Number(v)) ? v : `'${v.replace(/'/g, "''")}'`;
const head = `INSERT INTO ${quoteIdent(d.table, d.driver)} (${names.map((n) => quoteIdent(n, d.driver)).join(", ")}) VALUES`;
return d.rows.map((r) => `${head} (${r.map(literal).join(", ")});`).join("\n");
}
case "markdown": {
const cell = (v: Cell) => (v ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
const align = numeric.map((n) => (n ? "--:" : "---"));
return [names.map(cell), align, ...d.rows.map((r) => r.map(cell))].map((r) => `| ${r.join(" | ")} |`).join("\n");
}
}
}
+133
Переглянути файл
@@ -0,0 +1,133 @@
/**
* Database tools (IDEA's Database tool window, scoped): data sources for
* SQLite / PostgreSQL / MySQL-MariaDB, a schema tree, query consoles with
* results grids, and a table data editor. Connections, introspection and
* statements run in Rust (`ide-db`).
*/
import { lazyComponent } from "@/ui";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerEditorKind, registerOverlay, registerServiceView, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import { SQL_LANGUAGE, consoleExtension, executeInView } from "./console";
import { DatabaseDialogs } from "./Dialogs";
import { TABLE_KIND, tableTitle } from "./tableInput";
import { nodeTarget, type DbNode } from "./nodes";
import { registerDatabaseTreeActions } from "./treeActions";
import {
DB_TOOL_WINDOW,
RESULTS_VIEW,
consoleSource,
loadSources,
openConsole,
openSourceDialog,
useDatabase,
} from "./store";
export function activate(): () => void {
const disposers = [
registerToolWindow({
id: DB_TOOL_WINDOW,
title: "Database",
icon: "toolwindow-database",
anchor: "right",
order: 20,
component: lazyComponent(() => import("./DatabaseView").then((m) => m.DatabaseView)),
headerActions: lazyComponent(() => import("./DatabaseView").then((m) => m.DatabaseHeaderActions)),
}),
registerServiceView({
id: RESULTS_VIEW,
title: "Database",
icon: "database",
order: 20,
onUse: true,
component: lazyComponent(() => import("./ResultsView").then((m) => m.ResultsView)),
}),
registerEditorKind({ kind: TABLE_KIND, component: lazyComponent(() => import("./TableEditor").then((m) => m.TableEditor)), title: tableTitle, icon: () => "database" }),
registerOverlay({ id: "database.dialogs", component: DatabaseDialogs }),
registerEditorContribution({ id: "database.console", order: 57, extensions: consoleExtension }),
// A reopened project starts with a fresh backend: forget the old one's state.
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root) return;
useDatabase.setState({
sources: [],
loaded: false,
connected: new Set(),
databases: {},
schemas: {},
tables: {},
details: {},
completion: {},
results: [],
output: [],
activeResult: null,
selectedSource: null,
selectedNode: null,
treeExpanded: new Set(),
treeReveal: null,
manualCommit: new Set(),
pendingTx: new Set(),
});
}),
registerDatabaseTreeActions(),
registerActions([
{
id: "db.showToolWindow",
title: "Database",
category: "Tools",
icon: "toolwindow-database",
keywords: ["data sources", "sql", "tables"],
when: "projectOpen",
run: () => toolWindowService().show(DB_TOOL_WINDOW, true),
},
{
id: "db.newDataSource",
title: "New Data Source…",
category: "Tools",
icon: "plus",
keywords: ["database", "connection", "postgres", "mysql", "sqlite"],
when: "projectOpen",
run: async () => {
if (!useDatabase.getState().loaded) await loadSources();
openSourceDialog(null);
},
},
{
id: "db.openConsole",
title: "Query Console",
category: "Tools",
icon: "file-sql",
keywords: ["database", "sql console"],
when: "projectOpen",
run: async ({ arg }) => {
const s = useDatabase.getState();
if (!s.loaded) await loadSources();
const { sources, selectedSource } = useDatabase.getState();
// A tree node (New ▸ Query Console) opens its data source's console.
const node = arg && typeof arg === "object" && "kind" in arg ? nodeTarget(arg as DbNode) : null;
const id = node ?? selectedSource ?? (sources.length === 1 ? sources[0]!.id : null);
if (id) await openConsole(id);
else toolWindowService().show(DB_TOOL_WINDOW, true);
},
},
{
id: "db.execute",
title: "Execute SQL Statement",
category: "Tools",
icon: "run",
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
if (!consoleSource(e.ctx.path) && !useDatabase.getState().sources.length) {
openSourceDialog(null);
return;
}
await executeInView(e.view, e.ctx.path);
},
},
]),
];
return () => disposers.reverse().forEach((d) => d());
}
+153
Переглянути файл
@@ -0,0 +1,153 @@
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
import type { DbCellValue, DbDriver, DbRowEdit, DataSource } from "@/platform/ipc";
export type Cell = string | null;
/** Tab-separated values (Excel / IDEA "Copy"): NULL is empty, tabs / newlines become spaces. */
export function toTsv(header: readonly string[] | null, rows: ReadonlyArray<readonly Cell[]>): string {
const clean = (v: Cell) => (v ?? "").replace(/[\t\r\n]+/g, " ");
const lines = rows.map((r) => r.map(clean).join("\t"));
if (header) lines.unshift(header.map(clean).join("\t"));
return lines.join("\n");
}
/** RFC 4180 CSV; NULL is an empty field. */
export function toCsv(header: readonly string[] | null, rows: ReadonlyArray<readonly Cell[]>): string {
const field = (v: Cell) => {
const s = v ?? "";
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = rows.map((r) => r.map(field).join(","));
if (header) lines.unshift(header.map(field).join(","));
return lines.join("\r\n");
}
/** Pending changes of the table editor. Row indices refer to the loaded page. */
export interface PendingEdits {
/** row → column → new value. */
updates: Map<number, Map<string, Cell>>;
deletes: Set<number>;
/** New rows: column → value (absent columns use their default). */
inserts: Array<Map<string, Cell>>;
}
export const emptyEdits = (): PendingEdits => ({ updates: new Map(), deletes: new Set(), inserts: [] });
export function hasEdits(e: PendingEdits): boolean {
return e.updates.size > 0 || e.deletes.size > 0 || e.inserts.length > 0;
}
/** The DML requests for `edits` of a page with `columns` / `rows`, keyed by `primaryKey`. */
export function buildRowEdits(
columns: readonly string[],
rows: ReadonlyArray<readonly Cell[]>,
primaryKey: readonly string[],
edits: PendingEdits,
): DbRowEdit[] {
const keyOf = (row: number): DbCellValue[] =>
primaryKey.map((c) => ({ column: c, value: rows[row]?.[columns.indexOf(c)] ?? null }));
const out: DbRowEdit[] = [];
for (const [row, changes] of edits.updates) {
if (edits.deletes.has(row) || !changes.size) continue;
out.push({ kind: "update", key: keyOf(row), values: [...changes].map(([column, value]) => ({ column, value })) });
}
for (const row of [...edits.deletes].sort((a, b) => a - b)) out.push({ kind: "delete", key: keyOf(row) });
for (const ins of edits.inserts) out.push({ kind: "insert", values: [...ins].map(([column, value]) => ({ column, value })) });
return out;
}
/** IDEA's default name of a file data source: the file name without its extension (`shop.db` → `shop`). */
export function fileSourceName(file: string): string {
const base = file.split(/[\\/]/).pop() ?? file;
return base.replace(/\.[^.]+$/, "") || base;
}
/** The tree / menu icon of a driver (IDEA shows the vendor's mark). */
export function driverIcon(driver: DbDriver | null | undefined): string {
return driver === "postgres" ? "db-postgres" : driver === "mysql" ? "db-mysql" : "db-sqlite";
}
export function sourceLabel(s: Pick<DataSource, "name" | "driver">): string {
const kind = s.driver === "postgres" ? "PostgreSQL" : s.driver === "mysql" ? "MySQL" : "SQLite";
return `${s.name} (${kind})`;
}
export function newSourceId(): string {
const rnd = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
return rnd.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 36);
}
/** What a failed connection needs from the user before retrying. */
export type ConnectRemedy = "password" | "sshSecret" | "trustHostKey" | null;
/**
* Classifies a connection error: an unknown SSH host key to confirm, a missing
* or rejected SSH password / passphrase, or a database password. A changed host
* key is never offered for trust.
*/
export function connectRemedy(kind: string | undefined, message: string, source?: Pick<DataSource, "ssh">): ConnectRemedy {
if (/^Unknown SSH host key/.test(message)) return "trustHostKey";
if (kind !== "permissionDenied") return null;
if (/^SSH host key .* has changed/.test(message)) return null;
if (/^SSH (password|key passphrase) required|^cannot decrypt SSH key/.test(message)) return "sshSecret";
if (/^SSH authentication failed/.test(message))
// With OpenSSH config + agent there is nothing to type unless a key is encrypted.
return source?.ssh?.auth === "openSsh" ? null : "sshSecret";
return "password";
}
/** The SSH user@host of a data source, for prompts. */
export function sshLabel(source: Pick<DataSource, "ssh">): string {
const ssh = source.ssh;
if (!ssh?.host) return "";
return `${ssh.user ? `${ssh.user}@` : ""}${ssh.host}${ssh.port ? `:${ssh.port}` : ""}`;
}
/**
* Connection target (backend `split_target`): a data source id for its configured
* database, or `<id>:<database>` for another database of the same server.
*/
export function splitTarget(target: string): { id: string; database: string | null } {
const i = target.indexOf(":");
return i < 0 ? { id: target, database: null } : { id: target.slice(0, i), database: target.slice(i + 1) || null };
}
/** The target of database `database` of data source `id`; the connected one is the data source itself. */
export function targetOf(id: string, database: string | null, current: string | null): string {
return !database || database === current ? id : `${id}:${database}`;
}
/** Key of the Database tree node of database `name` of data source `id`. */
export const databaseKey = (id: string, name: string) => `${id}/@${name}`;
/**
* Data Source from URL: `postgres://user:pass@host:5432/db`, `mysql://…`,
* `mariadb://…`, the `jdbc:` forms of those, `jdbc:sqlite:path` / `sqlite:path`.
* Null when the URL names no supported driver.
*/
export function parseSourceUrl(raw: string): { source: Partial<DataSource>; password: string | null } | null {
const url = raw.trim().replace(/^jdbc:/i, "");
const sqlite = /^sqlite:(?:\/\/)?(.+)$/i.exec(url);
if (sqlite) {
const file = sqlite[1]!.replace(/^\/([A-Za-z]:)/, "$1");
return { source: { driver: "sqlite", file, name: fileSourceName(file) }, password: null };
}
const m = /^(postgres(?:ql)?|mysql|mariadb):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?]+|\[[^\]]+\])(?::(\d+))?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
if (!m) return null;
const dec = (s: string | undefined) => {
try {
return s ? decodeURIComponent(s) : "";
} catch {
return s ?? "";
}
};
const driver = m[1]!.toLowerCase().startsWith("postgres") ? "postgres" : "mysql";
const params = new URLSearchParams(m[7] ?? "");
const user = dec(m[2]) || params.get("user") || "";
const password = m[3] !== undefined ? dec(m[3]) : params.get("password");
const port = m[5] ? parseInt(m[5], 10) : null;
const source: Partial<DataSource> = { driver, host: m[4]!, port, user, database: dec(m[6]) };
const ssl = params.get("sslmode")?.toLowerCase();
if (ssl === "disable" || ssl === "require" || ssl === "prefer") source.ssl = ssl;
return { source, password };
}
+56
Переглянути файл
@@ -0,0 +1,56 @@
/** Nodes of the Database tool window tree and the objects they stand for. */
import type { DataSource, DbColumnInfo, DbForeignKey, DbIndexInfo, DbTableInfo } from "@/platform/ipc";
import type { DbObjectRef } from "./objects";
export type Group = "columns" | "keys" | "foreignKeys" | "indexes";
/** Where a table-level node lives: connection target, schema, table. */
interface InTable {
id: string;
schema: string;
table: string;
}
export type DbNode =
| { kind: "source"; key: string; source: DataSource }
/** A database of a PostgreSQL server; `id` is its connection target. */
| { kind: "database"; key: string; id: string; name: string; current: boolean }
| { kind: "schema"; key: string; id: string; schema: string }
| { kind: "folder"; key: string; id: string; schema: string; folder: "tables" | "views" }
| { kind: "table"; key: string; id: string; schema: string; table: DbTableInfo }
| ({ kind: "group"; key: string; group: Group } & InTable)
| ({ kind: "column"; key: string; column: DbColumnInfo; foreignKey: boolean } & InTable)
| ({ kind: "key"; key: string; columns: string[] } & InTable)
| ({ kind: "foreignKey"; key: string; fk: DbForeignKey } & InTable)
| ({ kind: "index"; key: string; index: DbIndexInfo } & InTable)
| { kind: "message"; key: string; text: string; error?: boolean };
/** The connection target a node belongs to. */
export function nodeTarget(n: DbNode): string | null {
if (n.kind === "source") return n.source.id ?? null;
if ("id" in n && typeof n.id === "string") return n.id;
return n.key.split("/")[0] ?? null;
}
/** The table (or view) a node is, or is inside of. */
export function tableOf(n: DbNode): { id: string; schema: string; table: string; view: boolean } | null {
if (n.kind === "table") return { id: n.id, schema: n.schema, table: n.table.name, view: n.table.kind === "view" };
if ("table" in n && typeof n.table === "string") return { id: n.id, schema: n.schema, table: n.table, view: false };
return null;
}
/** The schema object a node stands for (Rename, Drop, Copy Reference). */
export function objectOf(n: DbNode): DbObjectRef | null {
switch (n.kind) {
case "schema":
return { kind: "schema", schema: n.schema, name: n.schema };
case "table":
return { kind: n.table.kind === "view" ? "view" : "table", schema: n.schema, name: n.table.name };
case "column":
return { kind: "column", schema: n.schema, name: n.table, column: n.column.name };
case "index":
return { kind: "index", schema: n.schema, name: n.index.name, table: n.table };
default:
return null;
}
}
+103
Переглянути файл
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import type { DbColumnInfo } from "@/platform/ipc";
import {
alterTableSql,
createTableSql,
draftOf,
dropSql,
ident,
reference,
renameSql,
scriptSql,
truncateSql,
type ColumnDraft,
} from "./objects";
const col = (name: string, dataType: string, extra: Partial<DbColumnInfo> = {}): DbColumnInfo => ({
name,
dataType,
nullable: true,
default: null,
primaryKey: false,
...extra,
});
const users = [col("id", "INTEGER", { primaryKey: true, nullable: false }), col("email", "TEXT", { nullable: false }), col("name", "TEXT")];
describe("database object SQL", () => {
it("quotes only when needed", () => {
expect(ident("postgres", "users")).toBe("users");
expect(ident("postgres", "Users")).toBe('"Users"');
expect(ident("sqlite", "order")).toBe('"order"');
expect(ident("mysql", "my table")).toBe("`my table`");
});
it("copies references", () => {
expect(reference("sqlite", { kind: "table", schema: "main", name: "users" })).toBe("users");
expect(reference("postgres", { kind: "column", schema: "public", name: "users", column: "email" })).toBe("public.users.email");
});
it("renames per dialect", () => {
expect(renameSql("sqlite", { kind: "table", schema: "main", name: "users" }, "people")).toBe("alter table users rename to people;");
expect(renameSql("mysql", { kind: "table", schema: "app", name: "users" }, "people")).toBe("rename table app.users to app.people;");
expect(renameSql("postgres", { kind: "column", schema: "public", name: "users", column: "email" }, "mail")).toBe(
"alter table public.users rename column email to mail;",
);
});
it("drops with IF EXISTS / CASCADE", () => {
const t = { kind: "table" as const, schema: "public", name: "users" };
expect(dropSql("postgres", t, { ifExists: true, cascade: true })).toBe("drop table if exists public.users cascade;");
expect(dropSql("sqlite", { ...t, schema: "main" }, { ifExists: false, cascade: true })).toBe("drop table users;");
expect(dropSql("mysql", { kind: "index", schema: "app", name: "ix", table: "users" }, { ifExists: false, cascade: false })).toBe(
"drop index ix on app.users;",
);
expect(dropSql("sqlite", { kind: "column", schema: "main", name: "users", column: "name" }, { ifExists: true, cascade: false })).toBe(
"alter table users drop column name;",
);
});
it("truncates (SQLite deletes)", () => {
expect(truncateSql("sqlite", "main", "users", { restartIdentity: true, cascade: true })).toBe("delete from users;");
expect(truncateSql("postgres", "public", "users", { restartIdentity: true, cascade: false })).toBe(
"truncate table public.users restart identity;",
);
});
it("writes script templates keyed by the primary key", () => {
expect(scriptSql("sqlite", "selectFirst", "main", "users", users)).toBe("select *\nfrom users\nlimit 10;\n");
expect(scriptSql("sqlite", "insert", "main", "users", users)).toBe("insert into users (id, email, name)\nvalues (?, ?, ?);\n");
expect(scriptSql("sqlite", "update", "main", "users", users)).toBe("update users\nset email = ?,\n name = ?\nwhere id = ?;\n");
expect(scriptSql("sqlite", "delete", "main", "users", users)).toBe("delete\nfrom users\nwhere id = ?;\n");
});
it("creates tables", () => {
const drafts: ColumnDraft[] = users.map(draftOf);
expect(createTableSql("sqlite", "main", "people", drafts)).toBe(
"create table people\n(\n id INTEGER not null,\n email TEXT not null,\n name TEXT,\n primary key (id)\n);\n",
);
});
it("alters tables: SQLite renames / adds / drops, PostgreSQL also changes columns", () => {
const d = users.map(draftOf);
d[1] = { ...d[1]!, name: "mail" };
d[2] = { ...d[2]!, dropped: true };
d.push({ key: 9, original: null, name: "age", dataType: "INTEGER", notNull: false, default: "0", primaryKey: false, dropped: false });
expect(alterTableSql("sqlite", "main", "users", "people", d)).toEqual([
"alter table users rename column email to mail;",
"alter table users drop column name;",
"alter table users add column age INTEGER default 0;",
"alter table users rename to people;",
]);
const p = users.map(draftOf);
p[2] = { ...p[2]!, dataType: "varchar(40)", notNull: true, default: "''" };
expect(alterTableSql("postgres", "public", "users", "users", p)).toEqual([
"alter table public.users alter column name type varchar(40);",
"alter table public.users alter column name set not null;",
"alter table public.users alter column name set default '';",
]);
expect(alterTableSql("mysql", "app", "users", "users", p)).toEqual([
"alter table app.users change column name name varchar(40) default '' not null;",
]);
});
});
+225
Переглянути файл
@@ -0,0 +1,225 @@
/**
* SQL that the Database tree's object actions run or paste (IDEA: Rename,
* Drop, Truncate, SQL Scripts › …, Modify Table, Copy Reference), per dialect.
* Pure functions.
*/
import type { DbColumnInfo, DbDriver } from "@/platform/ipc";
export function quoteIdent(driver: DbDriver, name: string): string {
return driver === "mysql" ? `\`${name.replace(/`/g, "``")}\`` : `"${name.replace(/"/g, '""')}"`;
}
/** Quotes only when needed: lower-case (PostgreSQL) / plain words stay bare, like IDEA's generated SQL. */
export function ident(driver: DbDriver, name: string): string {
const plain = driver === "postgres" ? /^[a-z_][a-z0-9_$]*$/ : /^[A-Za-z_][A-Za-z0-9_$]*$/;
return plain.test(name) && !RESERVED.has(name.toUpperCase()) ? name : quoteIdent(driver, name);
}
const RESERVED = new Set(
(
"ALL AND AS ASC BETWEEN BY CASE CHECK COLUMN CONSTRAINT CREATE CROSS DEFAULT DELETE DESC DISTINCT DROP " +
"ELSE END EXISTS FOREIGN FROM FULL GROUP HAVING IN INDEX INNER INSERT INTO IS JOIN KEY LEFT LIKE LIMIT NOT " +
"NULL OFFSET ON OR ORDER OUTER PRIMARY REFERENCES RIGHT SELECT SET TABLE THEN TO UNION UNIQUE UPDATE USER " +
"USING VALUES VIEW WHEN WHERE WITH"
).split(" "),
);
/** `schema.name`; SQLite's `main` and an empty schema are left out. */
export function qualifiedName(driver: DbDriver, schema: string, name: string): string {
if (!schema || (driver === "sqlite" && schema === "main")) return ident(driver, name);
return `${ident(driver, schema)}.${ident(driver, name)}`;
}
export type DbObjectKind = "table" | "view" | "column" | "index" | "schema";
/** A schema object of a data source, as the tree actions see it. */
export interface DbObjectRef {
kind: DbObjectKind;
schema: string;
/** Table / view / index / schema name; the column's table for a column. */
name: string;
/** Column name (kind `column`). */
column?: string;
/** Table of an index (MySQL drops an index `ON` its table). */
table?: string;
}
/** IDEA's Copy Reference: the object's qualified name. */
export function reference(driver: DbDriver, o: DbObjectRef): string {
if (o.kind === "schema") return ident(driver, o.name);
const q = qualifiedName(driver, o.schema, o.name);
return o.kind === "column" && o.column ? `${q}.${ident(driver, o.column)}` : q;
}
export const canRename = (driver: DbDriver, o: DbObjectRef) =>
o.kind === "table" || o.kind === "column" || (o.kind === "view" && driver !== "sqlite") || (o.kind === "index" && driver === "postgres");
/** The statement renaming `o` to `to`. */
export function renameSql(driver: DbDriver, o: DbObjectRef, to: string): string {
const q = qualifiedName(driver, o.schema, o.name);
switch (o.kind) {
case "column":
return `alter table ${q} rename column ${ident(driver, o.column ?? "")} to ${ident(driver, to)};`;
case "index":
return `alter index ${q} rename to ${ident(driver, to)};`;
case "view":
if (driver === "postgres") return `alter view ${q} rename to ${ident(driver, to)};`;
return `rename table ${q} to ${qualifiedName(driver, o.schema, to)};`;
default:
if (driver === "mysql") return `rename table ${q} to ${qualifiedName(driver, o.schema, to)};`;
return `alter table ${q} rename to ${ident(driver, to)};`;
}
}
export interface DropOptions {
ifExists: boolean;
/** PostgreSQL `CASCADE`. */
cascade: boolean;
}
export const canDrop = (driver: DbDriver, o: DbObjectRef) => !(o.kind === "schema" && driver === "sqlite");
/** IDEA's Confirm Drop preview. */
export function dropSql(driver: DbDriver, o: DbObjectRef, opts: DropOptions): string {
const ifExists = opts.ifExists ? "if exists " : "";
const cascade = opts.cascade && driver === "postgres" ? " cascade" : "";
switch (o.kind) {
case "column":
return `alter table ${qualifiedName(driver, o.schema, o.name)} drop column ${ifExists && driver === "postgres" ? ifExists : ""}${ident(driver, o.column ?? "")}${cascade};`;
case "index":
if (driver === "mysql") return `drop index ${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")};`;
return `drop index ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
case "schema":
return `drop ${driver === "mysql" ? "database" : "schema"} ${ifExists}${ident(driver, o.name)}${cascade};`;
default:
return `drop ${o.kind} ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
}
}
/** Object Actions › Truncate (SQLite has no TRUNCATE: `delete from`). */
export function truncateSql(driver: DbDriver, schema: string, table: string, opts: { restartIdentity: boolean; cascade: boolean }): string {
const q = qualifiedName(driver, schema, table);
if (driver === "sqlite") return `delete from ${q};`;
if (driver === "mysql") return `truncate table ${q};`;
return `truncate table ${q}${opts.restartIdentity ? " restart identity" : ""}${opts.cascade ? " cascade" : ""};`;
}
export type ScriptKind = "selectAll" | "selectFirst" | "insert" | "update" | "delete";
export const SCRIPT_LABELS: Record<ScriptKind, string> = {
selectAll: "Select all rows from a table",
selectFirst: "Select first N rows from a table",
insert: "Insert rows into a table",
update: "Update values in a table",
delete: "Delete rows from a table",
};
/** SQL Scripts › … templates (IDEA's live templates `sel`, `selc`, `ins`, `upd`, `del`). */
export function scriptSql(driver: DbDriver, kind: ScriptKind, schema: string, table: string, columns: readonly DbColumnInfo[]): string {
const q = qualifiedName(driver, schema, table);
const names = columns.map((c) => ident(driver, c.name));
const keys = columns.filter((c) => c.primaryKey).map((c) => ident(driver, c.name));
const where = (keys.length ? keys : names.slice(0, 1)).map((k) => `${k} = ?`).join(" and ") || "1 = 0";
switch (kind) {
case "selectAll":
return `select *\nfrom ${q};\n`;
case "selectFirst":
return `select *\nfrom ${q}\nlimit 10;\n`;
case "insert":
return `insert into ${q} (${names.join(", ")})\nvalues (${names.map(() => "?").join(", ")});\n`;
case "update": {
const set = columns.filter((c) => !c.primaryKey).map((c) => `${ident(driver, c.name)} = ?`);
return `update ${q}\nset ${(set.length ? set : names.map((n) => `${n} = ?`)).join(",\n ")}\nwhere ${where};\n`;
}
case "delete":
return `delete\nfrom ${q}\nwhere ${where};\n`;
}
}
// ---- Modify Table ------------------------------------------------------------
/** A column row of the Modify Table dialog; `original` is the column it started as (absent: new). */
export interface ColumnDraft {
key: number;
original: DbColumnInfo | null;
name: string;
dataType: string;
notNull: boolean;
/** SQL expression; empty for none. */
default: string;
primaryKey: boolean;
dropped: boolean;
}
export function draftOf(c: DbColumnInfo, key: number): ColumnDraft {
return {
key,
original: c,
name: c.name,
dataType: c.dataType,
notNull: !c.nullable,
default: c.default ?? "",
primaryKey: c.primaryKey,
dropped: false,
};
}
function columnDefinition(driver: DbDriver, c: ColumnDraft): string {
let s = `${ident(driver, c.name)} ${c.dataType.trim() || (driver === "sqlite" ? "TEXT" : "text")}`;
if (c.default.trim()) s += ` default ${c.default.trim()}`;
if (c.notNull) s += " not null";
return s;
}
/** `create table` for New › Table. */
export function createTableSql(driver: DbDriver, schema: string, name: string, columns: readonly ColumnDraft[]): string {
const live = columns.filter((c) => !c.dropped && c.name.trim());
const keys = live.filter((c) => c.primaryKey).map((c) => ident(driver, c.name));
const items = live.map((c) => ` ${columnDefinition(driver, c)}`);
if (keys.length) items.push(` primary key (${keys.join(", ")})`);
return `create table ${qualifiedName(driver, schema, name)}\n(\n${items.join(",\n")}\n);\n`;
}
/** Whether `alterTableSql` can express changing column `c` (SQLite only renames, adds and drops). */
export function canAlterColumn(driver: DbDriver, c: ColumnDraft): boolean {
return driver !== "sqlite" || !c.original;
}
/**
* The statements turning the table `table` into `name` with `columns`
* (Modify Table): renames, added / dropped columns, and — PostgreSQL / MySQL —
* type, nullability and default changes.
*/
export function alterTableSql(driver: DbDriver, schema: string, table: string, name: string, columns: readonly ColumnDraft[]): string[] {
const out: string[] = [];
const q = () => qualifiedName(driver, schema, table);
for (const c of columns) {
const o = c.original;
if (!o) {
if (!c.dropped && c.name.trim()) out.push(`alter table ${q()} add column ${columnDefinition(driver, c)};`);
continue;
}
if (c.dropped) {
out.push(`alter table ${q()} drop column ${ident(driver, o.name)};`);
continue;
}
const renamed = c.name.trim() && c.name !== o.name;
const changed =
c.dataType.trim() !== o.dataType || c.notNull !== !o.nullable || c.default.trim() !== (o.default ?? "").trim();
if (driver === "mysql" && (renamed || changed)) {
out.push(`alter table ${q()} change column ${ident(driver, o.name)} ${columnDefinition(driver, c)};`);
continue;
}
if (renamed) out.push(`alter table ${q()} rename column ${ident(driver, o.name)} to ${ident(driver, c.name)};`);
if (driver !== "postgres") continue;
const col = ident(driver, renamed ? c.name : o.name);
if (c.dataType.trim() && c.dataType.trim() !== o.dataType)
out.push(`alter table ${q()} alter column ${col} type ${c.dataType.trim()};`);
if (c.notNull !== !o.nullable) out.push(`alter table ${q()} alter column ${col} ${c.notNull ? "set" : "drop"} not null;`);
if (c.default.trim() !== (o.default ?? "").trim())
out.push(`alter table ${q()} alter column ${col} ${c.default.trim() ? `set default ${c.default.trim()}` : "drop default"};`);
}
// Renamed last: the column statements above name the table as it was.
if (name.trim() && name.trim() !== table) out.push(renameSql(driver, { kind: "table", schema, name: table }, name.trim()));
return out;
}
+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");
});
});
+177
Переглянути файл
@@ -0,0 +1,177 @@
/**
* 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])];
}
+790
Переглянути файл
@@ -0,0 +1,790 @@
/**
* Database tools state: data sources, connection status, the introspection
* cache of the tree, query consoles (file → data source) and their results.
*/
import { create } from "zustand";
import {
IpcError,
call,
commands,
isCancelled,
nextRequestId,
type DataSource,
type DbCompletion,
type DbDatabases,
type DbDriver,
type DbStatementOutcome,
type DbTableDetails,
type DbTableInfo,
type RequestId,
type SqlStatement,
} from "@/platform/ipc";
import { editorsFor } from "@/editor/api";
import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions";
import { connectRemedy, databaseKey, newSourceId, splitTarget } from "./model";
import type { DbNode } from "./nodes";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
import { bindParams, findParams } from "./sql/params";
export const DB_TOOL_WINDOW = "database";
export const RESULTS_VIEW = "dbResults";
const BINDINGS_KEY = "openidea.database.consoles";
const MAX_TABS = 12;
export interface ResultTab {
id: number;
/** `Result N` (numbered per window, like IDEA). */
title: string;
/** Console file the statement came from. */
path: string;
sourceId: string;
outcome: DbStatementOutcome;
/** Being re-run. */
busy?: boolean;
}
/** One line of the consoles' Output log (IDEA's Database Console output). */
export interface OutputLine {
id: number;
time: number;
kind: "info" | "statement" | "result" | "error";
text: string;
/** Prompt before a statement: the schema it ran in (`main>`). */
prompt?: string;
}
/** `activeResult` value of the Output tab. */
export const OUTPUT_TAB = 0;
const MAX_OUTPUT = 2000;
/** Load state of a tree level. */
export type Loaded<T> = { status: "loading" } | { status: "error"; message: string } | { status: "ok"; value: T };
export type DialogState =
/** Data source properties; `preset` seeds a new one (New › Data Source › PostgreSQL). */
| { kind: "source"; source: DataSource | null; preset?: Partial<DataSource>; password?: string | null }
/** Data Source from URL. */
| { kind: "sourceUrl" }
| { kind: "removeSource"; source: DataSource }
/** Rename a data source (`object` null) or a schema object of target `id`. */
| { kind: "rename"; id: string; object: DbObjectRef | null }
/** Confirm Drop / Truncate, with a preview of the statement. */
| { kind: "drop"; id: string; object: DbObjectRef }
| { kind: "truncate"; id: string; schema: string; table: string }
/** Modify Table (`table` null: New › Table); `focusColumn` selects a column, `addColumn` starts a new one. */
| { kind: "modifyTable"; id: string; schema: string; table: string | null; focusColumn?: string; addColumn?: boolean }
/** Quick Documentation of a tree node, shown at `anchor` (client coordinates). */
| { kind: "quickDoc"; node: DbNode; anchor: { x: number; y: number } }
| { kind: "password"; source: DataSource; resolve: (ok: boolean) => void }
/** SSH password or key passphrase (`prompt` is the backend's reason). */
| { kind: "sshSecret"; source: DataSource; prompt: string; resolve: (ok: boolean) => void }
/** Confirm an unknown SSH host key (`prompt` names it with its fingerprint). */
| { kind: "hostKey"; source: DataSource; prompt: string; resolve: (ok: boolean) => void }
/** Values of query parameters (`null`: cancelled). */
| { kind: "params"; names: string[]; resolve: (values: Record<string, string> | null) => void }
/** Leaving manual commit with changes pending: commit, roll back or stay. */
| { kind: "endTransaction"; source: DataSource; resolve: (commit: boolean | null) => void };
interface DatabaseState {
sources: DataSource[];
loaded: boolean;
/** Connected targets (see `splitTarget`). */
connected: Set<string>;
/** Data source id → databases of its server (PostgreSQL). */
databases: Record<string, Loaded<DbDatabases>>;
/** Target → schemas. */
schemas: Record<string, Loaded<string[]>>;
/** `${id}\u0000${schema}` → tables. */
tables: Record<string, Loaded<DbTableInfo[]>>;
/** `${id}\u0000${schema}\u0000${table}` → columns / indexes. */
details: Record<string, Loaded<DbTableDetails>>;
/** Console / SQL file path → data source id. */
consoles: Record<string, string>;
completion: Record<string, DbCompletion>;
results: ResultTab[];
/** Result tab id, or `OUTPUT_TAB`. */
activeResult: number | null;
output: OutputLine[];
/** Console path → running request. */
running: Record<string, RequestId>;
maxRows: number;
dialog: DialogState | null;
/** Target of the tree's lead node. */
selectedSource: string | null;
/** The tree's lead node: what the tree actions act on. */
selectedNode: DbNode | null;
/** The toolbar's New popup is open. */
newMenuOpen: boolean;
/** Expanded nodes of the Database tree. */
treeExpanded: Set<string>;
/** A tree node to select and scroll to (Go to Declaration from a console). */
treeReveal: { key: string; seq: number } | null;
/** Last value entered per query parameter name. */
paramValues: Record<string, string>;
/** Data sources in manual-commit mode. */
manualCommit: Set<string>;
/** Manual-commit data sources with statements not yet committed. */
pendingTx: Set<string>;
}
function readBindings(): Record<string, string> {
try {
const raw = localStorage.getItem(BINDINGS_KEY);
return raw ? (JSON.parse(raw) as Record<string, string>) : {};
} catch {
return {};
}
}
export const useDatabase = create<DatabaseState>(() => ({
sources: [],
loaded: false,
connected: new Set(),
databases: {},
schemas: {},
tables: {},
details: {},
consoles: readBindings(),
completion: {},
results: [],
activeResult: null,
output: [],
running: {},
maxRows: 500,
dialog: null,
selectedSource: null,
selectedNode: null,
newMenuOpen: false,
treeExpanded: new Set(),
treeReveal: null,
paramValues: {},
manualCommit: new Set(),
pendingTx: new Set(),
}));
export const tablesKey = (id: string, schema: string) => `${id}\u0000${schema}`;
export const detailsKey = (id: string, schema: string, table: string) => `${id}\u0000${schema}\u0000${table}`;
function message(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/** The data source of a target (`<id>` or `<id>:<database>`). */
export function sourceById(id: string | null | undefined): DataSource | undefined {
if (!id) return undefined;
const sourceId = splitTarget(id).id;
return useDatabase.getState().sources.find((s) => s.id === sourceId);
}
/** `name` or `name: database` for messages and pickers. */
export function targetLabel(target: string): string {
const name = sourceById(target)?.name ?? target;
const db = splitTarget(target).database;
return db ? `${name}: ${db}` : name;
}
/** Targets of data source `id` that are connected. */
export function connectedTargets(id: string): string[] {
return [...useDatabase.getState().connected].filter((t) => splitTarget(t).id === id);
}
export async function loadSources(): Promise<void> {
try {
const [sources, connected] = await Promise.all([call(commands.dbSources()), call(commands.dbConnected())]);
useDatabase.setState({ sources, connected: new Set(connected), loaded: true });
} catch (e) {
useDatabase.setState({ loaded: true });
notifications().notify({ level: "error", title: "Cannot read data sources", body: message(e), key: "db-sources" });
}
}
/** Drops cached metadata of target `id` (refresh, disconnect); a data source id drops all its databases. */
function forget(id: string) {
const whole = !splitTarget(id).database;
const ofTarget = (t: string) => t === id || (whole && splitTarget(t).id === id);
const dropKeys = <T>(m: Record<string, T>) =>
Object.fromEntries(Object.entries(m).filter(([k]) => !ofTarget(k.split("\u0000")[0]!)));
useDatabase.setState((s) => {
const databases = { ...s.databases };
if (whole) delete databases[id];
return {
databases,
schemas: dropKeys(s.schemas),
completion: dropKeys(s.completion),
tables: dropKeys(s.tables),
details: dropKeys(s.details),
};
});
}
export async function saveSource(
source: DataSource,
password: string | null | undefined,
sshSecret: string | null | undefined = undefined,
): Promise<void> {
const sources = useDatabase.getState().sources;
const next = sources.some((s) => s.id === source.id)
? sources.map((s) => (s.id === source.id ? source : s))
: [...sources, source];
await call(commands.dbSaveSources(next));
// `undefined`: keep; `null`: forget; string: store.
const id = source.id ?? "";
if (password !== undefined) await call(commands.dbSetPassword(id, password, !!source.savePassword));
if (sshSecret !== undefined) await call(commands.dbSetSshSecret(id, sshSecret, !!source.ssh?.saveSecret));
forget(id);
await loadSources();
}
/** Copy/Paste › Duplicate: a copy of data source `id` named `<name> [2]` (its password is not copied). */
export async function duplicateSource(id: string): Promise<void> {
const src = sourceById(id);
if (!src) return;
const names = new Set(useDatabase.getState().sources.map((s) => s.name));
let n = 2;
while (names.has(`${src.name} [${n}]`)) n++;
try {
await saveSource({ ...src, id: newSourceId(), name: `${src.name} [${n}]` }, undefined);
} catch (e) {
notifications().notify({ level: "error", title: "Cannot duplicate the data source", body: message(e) });
}
}
export async function renameSource(id: string, name: string): Promise<void> {
const src = sourceById(id);
if (!src || !name.trim() || name.trim() === src.name) return;
const sources = useDatabase.getState().sources.map((s) => (s.id === src.id ? { ...s, name: name.trim() } : s));
await call(commands.dbSaveSources(sources));
await loadSources();
}
export async function deleteSource(id: string): Promise<void> {
await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id)));
forget(id);
await loadSources();
}
export function openSourceDialog(source: DataSource | null, preset?: Partial<DataSource>, password?: string | null) {
useDatabase.setState({ dialog: { kind: "source", source, preset, password } });
}
export function openDialog(dialog: DialogState) {
useDatabase.setState({ dialog });
}
export function closeDialog() {
useDatabase.setState({ dialog: null });
}
/** Asks for the password of `source` (ask-on-connect); resolves false when cancelled. */
function askPassword(source: DataSource): Promise<boolean> {
return new Promise((resolve) => useDatabase.setState({ dialog: { kind: "password", source, resolve } }));
}
function ask(kind: "sshSecret" | "hostKey", source: DataSource, prompt: string): Promise<boolean> {
return new Promise((resolve) => useDatabase.setState({ dialog: { kind, source, prompt, resolve } }));
}
/** Asks the user what a failed connection needs (password, SSH secret, host key trust); false = give up. */
export async function resolveConnectError(e: unknown, source: DataSource): Promise<boolean> {
if (!(e instanceof IpcError)) return false;
switch (connectRemedy(e.kind, e.message, source)) {
case "password":
return askPassword(source);
case "sshSecret":
return ask("sshSecret", source, e.message);
case "trustHostKey":
if (!(await ask("hostKey", source, e.message))) return false;
await call(commands.dbTrustSshHost(source.id ?? ""));
return true;
default:
return false;
}
}
/** Runs `op`, asking for passwords / host key trust and retrying when the connection needs them. */
export async function withConnection<T>(id: string, op: () => Promise<T>): Promise<T> {
for (;;) {
try {
const r = await op();
if (!useDatabase.getState().connected.has(id))
useDatabase.setState((s) => ({ connected: new Set(s.connected).add(id) }));
return r;
} catch (e) {
const source = sourceById(id);
if (!source || !(await resolveConnectError(e, source))) throw e;
}
}
}
export async function connect(id: string): Promise<boolean> {
try {
await withConnection(id, () => call(commands.dbConnect(id)));
return true;
} catch (e) {
if (!isCancelled(e))
notifications().notify({ level: "error", title: `Cannot connect to ${targetLabel(id)}`, body: message(e) });
return false;
}
}
/** Disconnects target `id`; a data source id disconnects all its databases. */
export async function disconnect(id: string): Promise<void> {
const targets = splitTarget(id).database ? [id] : connectedTargets(id);
if (!targets.includes(id)) targets.push(id);
await Promise.all(targets.map((t) => call(commands.dbDisconnect(t))));
forget(id);
useDatabase.setState((s) => {
const connected = new Set(s.connected);
for (const t of targets) connected.delete(t);
return { connected };
});
}
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
export function listsDatabases(source: DataSource | undefined): boolean {
return source?.driver === "postgres";
}
/**
* Re-reads the metadata of target `id` (a data source id: all its databases),
* reloading the levels that were loaded so the tree keeps its expanded nodes.
*/
export async function refresh(id: string): Promise<void> {
const whole = !splitTarget(id).database;
const ofTarget = (k: string) => {
const t = k.split("\u0000")[0]!;
return t === id || (whole && splitTarget(t).id === id);
};
const st = useDatabase.getState();
const loaded = (m: Record<string, Loaded<unknown>>) => Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
const schemas = loaded(st.schemas);
const tables = loaded(st.tables).map((k) => k.split("\u0000") as [string, string]);
const details = loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]);
forget(id);
if (whole && listsDatabases(sourceById(id))) await loadDatabases(id);
else if (!schemas.includes(id)) schemas.push(id);
await Promise.all(schemas.map((t) => loadSchemas(t)));
await Promise.all(tables.map(([t, schema]) => loadTables(t, schema)));
const exists = (t: string, schema: string, table: string) => {
const l = useDatabase.getState().tables[tablesKey(t, schema)];
return l?.status === "ok" && l.value.some((x) => x.name === table);
};
await Promise.all(details.filter((d) => exists(...d)).map(([t, schema, table]) => loadDetails(t, schema, table)));
}
export async function loadDatabases(id: string): Promise<void> {
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbDatabases(id)));
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "ok", value } } }));
} catch (e) {
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "error", message: message(e) } } }));
}
}
export async function loadSchemas(id: string): Promise<void> {
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbSchemas(id)));
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "ok", value } } }));
} catch (e) {
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "error", message: message(e) } } }));
}
}
export async function loadTables(id: string, schema: string): Promise<void> {
const key = tablesKey(id, schema);
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbTables(id, schema)));
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "ok", value } } }));
} catch (e) {
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "error", message: message(e) } } }));
}
}
export async function loadDetails(id: string, schema: string, table: string): Promise<void> {
const key = detailsKey(id, schema, table);
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbTableDetails(id, schema, table)));
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "ok", value } } }));
} catch (e) {
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "error", message: message(e) } } }));
}
}
/**
* Selects a table (or one of its columns) in the Database tool window,
* loading and expanding the nodes that lead to it.
*/
export async function revealInTree(id: string, schema: string, table: string, view: boolean, column?: string): Promise<void> {
const loaded = (l: Loaded<unknown> | undefined) => l?.status === "ok";
const { id: sourceId, database } = splitTarget(id);
let databaseNode: string[] = [];
if (listsDatabases(sourceById(id))) {
if (!loaded(useDatabase.getState().databases[sourceId])) {
if (!(await connect(sourceId))) return;
await loadDatabases(sourceId);
}
const dbs = useDatabase.getState().databases[sourceId];
const name = database ?? (dbs?.status === "ok" ? dbs.value.current : null);
if (name) databaseNode = [databaseKey(sourceId, name)];
}
if (!loaded(useDatabase.getState().schemas[id])) {
if (!(await connect(id))) return;
await loadSchemas(id);
}
if (!loaded(useDatabase.getState().tables[tablesKey(id, schema)])) await loadTables(id, schema);
if (column && !loaded(useDatabase.getState().details[detailsKey(id, schema, table)])) await loadDetails(id, schema, table);
const folder = `${id}/${schema}/${view ? "views" : "tables"}`;
const tableKey = `${folder}/${table}`;
const open = [sourceId, ...databaseNode, `${id}/${schema}`, folder, ...(column ? [tableKey, `${tableKey}/columns`] : [])];
useDatabase.setState((s) => ({
treeExpanded: new Set([...s.treeExpanded, ...open]),
treeReveal: { key: column ? `${tableKey}/columns/${column}` : tableKey, seq: (s.treeReveal?.seq ?? 0) + 1 },
}));
toolWindowService().show(DB_TOOL_WINDOW, true);
}
const completionLoads = new Map<string, Promise<DbCompletion | null>>();
/** The schema model of `id` for completion (cached; one load at a time). */
export function loadCompletion(id: string): Promise<DbCompletion | null> {
const cached = useDatabase.getState().completion[id];
if (cached) return Promise.resolve(cached);
let p = completionLoads.get(id);
if (!p) {
p = call(commands.dbCompletion(id))
.then((model) => {
useDatabase.setState((s) => ({ completion: { ...s.completion, [id]: model } }));
return model;
})
.catch(() => null)
.finally(() => completionLoads.delete(id));
completionLoads.set(id, p);
}
return p;
}
// ---- consoles ----------------------------------------------------------------
export function bindConsole(path: string, id: string | null) {
useDatabase.setState((s) => {
const consoles = { ...s.consoles };
if (id) consoles[path] = id;
else delete consoles[path];
try {
localStorage.setItem(BINDINGS_KEY, JSON.stringify(consoles));
} catch {
// Per-viewer convenience only.
}
return { consoles };
});
}
/** The data source a console / SQL file runs on: its binding, else the only data source. */
export function consoleSource(path: string): string | null {
const s = useDatabase.getState();
const bound = s.consoles[path];
if (bound && s.sources.some((x) => x.id === splitTarget(bound).id)) return bound;
return s.sources.length === 1 ? (s.sources[0]!.id ?? null) : null;
}
/** Opens (creating it on first use) the query console of data source `id`. */
export async function openConsole(id: string): Promise<void> {
try {
const path = await call(commands.dbConsolePath(id));
bindConsole(path, id);
await editorService().openFile(path, { focus: true });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot open the query console", body: message(e) });
}
}
let nextTab = 1;
let nextLine = 1;
export function log(kind: OutputLine["kind"], text: string, prompt?: string) {
const line: OutputLine = { id: nextLine++, time: Date.now(), kind, text, prompt };
useDatabase.setState((s) => ({ output: [...s.output, line].slice(-MAX_OUTPUT) }));
}
export function clearOutput() {
useDatabase.setState({ output: [] });
}
function elapsed(ms: number): string {
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
}
/** IDEA's summary line of an executed statement. */
export function outcomeSummary(o: DbStatementOutcome): string {
const r = o.result;
if (!r) return o.error ?? "";
const n = r.columns.length ? r.rows.length : (r.affected ?? 0);
const rows = `${n} row${n === 1 ? "" : "s"}`;
if (!r.columns.length) return `${rows} affected in ${elapsed(r.elapsedMs ?? 0)}`;
return `${rows} retrieved starting from 1 in ${elapsed(r.elapsedMs ?? 0)}${r.hasMore ? " (limit reached)" : ""}`;
}
/** The schema shown as the Output prompt (`main>`). */
function promptOf(id: string): string {
return useDatabase.getState().completion[id]?.defaultSchema || sourceById(id)?.name || id;
}
/** Runs `statements` on `id`, logging to Output; errors stop the batch (backend). */
async function run(id: string, statements: SqlStatement[], request: RequestId): Promise<DbStatementOutcome[]> {
const wasConnected = useDatabase.getState().connected.has(id);
const outcomes = await withConnection(id, () => call(commands.dbExecute(request, id, statements, useDatabase.getState().maxRows)));
if (!wasConnected) log("info", "Connected");
for (const o of outcomes) {
log("statement", o.statement.text, `${promptOf(id)}>`);
log(o.error ? "error" : "result", o.error ? `[${targetLabel(id)}] ${o.error}` : outcomeSummary(o));
}
if (useDatabase.getState().manualCommit.has(id) && outcomes.some((o) => !o.error && !READS.test(o.statement.text)))
useDatabase.setState((s) => ({ pendingTx: new Set(s.pendingTx).add(id) }));
if (outcomes.some((o) => /^\s*(create|drop|alter|rename)\b/i.test(o.statement.text) && !o.error)) {
// Schema changed: the tree and completion re-read it.
void refresh(id);
}
return outcomes;
}
/**
* Runs statements the tree's object actions generated (Drop, Rename, Modify
* Table…) on `id`, logging them to Output; false (and a notification) on error.
*/
export async function runSql(id: string, texts: string[]): Promise<boolean> {
const statements = texts.map((t) => {
const text = t.trim().replace(/;$/, "");
return { from: 0, to: text.length, text };
});
try {
const outcomes = await run(id, statements, nextRequestId());
const failed = outcomes.find((o) => o.error);
if (failed) {
notifications().notify({ level: "error", title: `[${targetLabel(id)}] ${failed.statement.text}`, body: failed.error ?? "" });
return false;
}
return true;
} catch (e) {
if (!isCancelled(e)) notifications().notify({ level: "error", title: `Cannot execute on ${targetLabel(id)}`, body: message(e) });
return false;
}
}
/** Opens the query console of `id` with `text` appended and selected (SQL Scripts › …). */
export async function openConsoleWith(id: string, text: string): Promise<void> {
let path: string;
try {
path = await call(commands.dbConsolePath(id));
} catch (e) {
notifications().notify({ level: "error", title: "Cannot open the query console", body: message(e) });
return;
}
await openConsole(id);
// The editor mounts after `openFile` resolves.
for (let i = 0; i < 40; i++) {
const view = editorsFor(path)[0]?.view;
if (view) {
const doc = view.state.doc.toString();
const sep = !doc || doc.endsWith("\n\n") ? "" : doc.endsWith("\n") ? "\n" : "\n\n";
const from = doc.length + sep.length;
view.dispatch({
changes: { from: doc.length, insert: sep + text },
selection: { anchor: from, head: from + text.trimEnd().length },
scrollIntoView: true,
});
view.focus();
return;
}
await new Promise((r) => setTimeout(r, 50));
}
}
/** DDL laid out like IDEA's: SQLite returns the statements as they were typed. */
export function readableDdl(text: string, driver: DbDriver | undefined): string {
return driver === "sqlite" ? formatted(text, driver) : text;
}
/** Go to DDL: the definition of a table / view, in a SQL file next to the data source's console. */
export async function openDdl(id: string, schema: string, name: string): Promise<void> {
try {
const [text, console] = await Promise.all([
withConnection(id, () => call(commands.dbDdl(id, schema, name))),
call(commands.dbConsolePath(id)),
]);
const safe = (s: string) => s.replace(/[^\w.-]+/g, "_");
const path = console.replace(/[^\\/]*$/, `${safe(schema)}.${safe(name)}.sql`);
await call(commands.dbExportFile(path, readableDdl(text, sourceById(id)?.driver)));
bindConsole(path, id);
await editorService().openFile(path, { focus: true });
} catch (e) {
notifications().notify({ level: "error", title: `Cannot get the DDL of ${name}`, body: message(e) });
}
}
/**
* Runs the statement at `from` (or the selection `from..to`, UTF-16 offsets)
* of console `path` on its data source. Result sets open as `Result N` tabs
* of the Services view, everything is logged to its Output tab; returns the
* outcomes (errors included) for the editor's marks.
*/
/** Asks for the values of the statements' query parameters and binds them; `[]` when cancelled. */
async function withParams(statements: SqlStatement[], driver: DbDriver): Promise<SqlStatement[]> {
let numbered = 0;
const found = statements.map((st) => {
const params = findParams(st.text, driver, numbered);
numbered += params.filter((p) => p.positional).length;
return params;
});
const names = [...new Set(found.flat().map((p) => p.name))];
if (!names.length) return statements;
const values = await new Promise<Record<string, string> | null>((resolve) =>
useDatabase.setState({ dialog: { kind: "params", names, resolve } }),
);
if (!values) return [];
useDatabase.setState((s) => ({ paramValues: { ...s.paramValues, ...values } }));
return statements.map((st, i) => ({ ...st, text: bindParams(st.text, found[i]!, values) }));
}
export async function executeConsole(path: string, text: string, from: number, to: number): Promise<DbStatementOutcome[]> {
const id = consoleSource(path);
if (!id) {
notifications().notify({
level: "info",
title: useDatabase.getState().sources.length ? "Choose a data source for this console" : "No data sources",
body: useDatabase.getState().sources.length
? "Pick one in the toolbar above the editor."
: "Add one in the Database tool window.",
key: "db-execute",
});
return [];
}
let statements: SqlStatement[] = await call(commands.dbStatements(id, text, from, to));
if (!statements.length) return [];
statements = await withParams(statements, sourceById(id)?.driver ?? "sqlite");
if (!statements.length) return [];
const prev = useDatabase.getState().running[path];
if (prev) void commands.cancelRequest(prev);
const request = nextRequestId();
useDatabase.setState((s) => ({ running: { ...s.running, [path]: request } }));
try {
const outcomes = await run(id, statements, request);
const tabs: ResultTab[] = outcomes
.filter((o) => o.result?.columns.length)
.map((outcome) => {
const n = nextTab++;
return { id: n, title: `Result ${n}`, path, sourceId: id, outcome };
});
const failed = outcomes.some((o) => o.error);
useDatabase.setState((s) => ({
results: [...s.results, ...tabs].slice(-MAX_TABS),
activeResult: failed || !tabs.length ? OUTPUT_TAB : tabs.at(-1)!.id,
}));
showServiceView(RESULTS_VIEW, false);
return outcomes;
} catch (e) {
if (!isCancelled(e)) {
log("error", message(e));
notifications().notify({ level: "error", title: "Cannot execute", body: message(e), key: "db-execute" });
} else {
log("info", "Cancelled");
// The backend dropped the connection, and its transaction with it.
if (useDatabase.getState().pendingTx.has(id)) {
log("info", "Transaction rolled back");
setPending(id, false);
}
}
return [];
} finally {
useDatabase.setState((s) => {
if (s.running[path] !== request) return s;
const running = { ...s.running };
delete running[path];
return { running };
});
}
}
/** Re-runs the statement of result tab `tabId`, replacing its rows. */
export async function rerunResult(tabId: number): Promise<void> {
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
if (!tab || tab.busy) return;
const patch = (p: Partial<ResultTab>) =>
useDatabase.setState((s) => ({ results: s.results.map((r) => (r.id === tabId ? { ...r, ...p } : r)) }));
patch({ busy: true });
try {
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId());
patch(outcome?.result ? { outcome, busy: false } : { busy: false });
if (outcome?.error) useDatabase.setState({ activeResult: OUTPUT_TAB });
} catch (e) {
patch({ busy: false });
if (!isCancelled(e)) log("error", message(e));
}
}
/** Statements that only read (they leave a manual transaction clean). */
const READS = /^\s*(select|show|pragma|explain|describe|desc|values|table)\b/i;
function setPending(id: string, pending: boolean) {
useDatabase.setState((s) => {
const pendingTx = new Set(s.pendingTx);
if (pending) pendingTx.add(id);
else pendingTx.delete(id);
return { pendingTx };
});
}
/** Commits or rolls back the open transaction of a manual-commit data source. */
export async function finishTransaction(id: string, commit: boolean): Promise<boolean> {
try {
await call(commands.dbFinishTransaction(id, commit));
log("statement", commit ? "commit" : "rollback", `${promptOf(id)}>`);
log("result", commit ? "Transaction committed" : "Transaction rolled back");
setPending(id, false);
return true;
} catch (e) {
log("error", `[${targetLabel(id)}] ${message(e)}`);
notifications().notify({ level: "error", title: commit ? "Cannot commit" : "Cannot roll back", body: message(e) });
return false;
}
}
/**
* IDEA's Tx: Auto / Manual. Leaving manual mode with changes pending asks
* whether to commit or roll them back.
*/
export async function setManualCommit(id: string, manual: boolean): Promise<void> {
const st = useDatabase.getState();
if (manual === st.manualCommit.has(id)) return;
if (!manual && st.pendingTx.has(id)) {
const source = sourceById(id);
const commit = source
? await new Promise<boolean | null>((resolve) => useDatabase.setState({ dialog: { kind: "endTransaction", source, resolve } }))
: false;
if (commit === null || !(await finishTransaction(id, commit))) return;
}
await call(commands.dbSetManualCommit(id, manual));
useDatabase.setState((s) => {
const manualCommit = new Set(s.manualCommit);
if (manual) manualCommit.add(id);
else manualCommit.delete(id);
return { manualCommit };
});
setPending(id, false);
}
export function cancelConsole(path: string) {
const r = useDatabase.getState().running[path];
if (r) void commands.cancelRequest(r);
}
export function closeResult(id: number) {
useDatabase.setState((s) => {
const results = s.results.filter((r) => r.id !== id);
return { results, activeResult: s.activeResult === id ? (results.at(-1)?.id ?? OUTPUT_TAB) : s.activeResult };
});
}
+18
Переглянути файл
@@ -0,0 +1,18 @@
import type { EditorInput } from "@/workbench/contributions";
export const TABLE_KIND = "db-table";
export interface TableRef {
id: string;
schema: string;
table: string;
}
export function tableTitle(input: EditorInput): string {
return (input.data as TableRef).table;
}
export function tableInput(id: string, schema: string, table: string): EditorInput {
const data: TableRef = { id, schema, table };
return { kind: TABLE_KIND, key: `${TABLE_KIND}:${id}:${schema}.${table}`, data };
}
+405
Переглянути файл
@@ -0,0 +1,405 @@
/**
* Actions of the Database tool window (IDEA's Database view actions): they
* act on the node passed as the argument (context menus) or on the tree's
* lead node (toolbar, shortcuts while the tree has focus).
*/
import { call, commands, type DataSource, type DbDriver, type DbTableInfo } from "@/platform/ipc";
import { registerActions, type Action } from "@/platform/actions";
import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions";
import { splitTarget } from "./model";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { canDrop, canRename, reference, scriptSql, type ScriptKind } from "./objects";
import {
DB_TOOL_WINDOW,
OUTPUT_TAB,
RESULTS_VIEW,
detailsKey,
disconnect,
duplicateSource,
loadDetails,
loadSchemas,
loadSources,
loadTables,
openConsole,
openConsoleWith,
openDdl,
openDialog,
openSourceDialog,
readableDdl,
refresh,
revealInTree,
runSql,
sourceById,
tablesKey,
useDatabase,
withConnection,
} from "./store";
import { tableInput } from "./tableInput";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
const OPEN = "projectOpen";
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
/** The node an action runs on: its argument, else the tree's lead node. */
function nodeFrom(arg: unknown): DbNode | null {
return isNode(arg) ? arg : useDatabase.getState().selectedNode;
}
function driverOf(id: string): DbDriver {
return sourceById(id)?.driver ?? "sqlite";
}
function sourceOf(n: DbNode | null): DataSource | undefined {
return n ? sourceById(nodeTarget(n)) : undefined;
}
async function copy(text: string, what: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
notifications().notify({ level: "error", title: `Cannot copy the ${what}` });
}
}
/** Where Quick Documentation opens: below the tree's selected row. */
function rowAnchor(): { x: number; y: number } {
const row = document.querySelector('[role="tree"][aria-label="Data sources"] [role="treeitem"][aria-selected="true"]');
const r = row?.getBoundingClientRect();
return r ? { x: r.left + 24, y: r.bottom + 2 } : { x: window.innerWidth / 2, y: window.innerHeight / 3 };
}
export function openTableData(id: string, schema: string, table: string) {
void editorService().openInput(tableInput(id, schema, table), { focus: true });
}
/** Tables of a schema, loading them if needed. */
async function tablesIn(id: string, schema: string): Promise<DbTableInfo[]> {
if (useDatabase.getState().tables[tablesKey(id, schema)]?.status !== "ok") await loadTables(id, schema);
const l = useDatabase.getState().tables[tablesKey(id, schema)];
return l?.status === "ok" ? l.value : [];
}
/** DDL of a table / view, or of every table and view of a schema or data source. */
async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
const id = nodeTarget(n);
if (!id) return null;
const one = async (schema: string, table: string) =>
readableDdl(await withConnection(id, () => call(commands.dbDdl(id, schema, table))), driverOf(id));
try {
const t = tableOf(n);
if (t) return { id, text: await one(t.schema, t.table) };
let schemas: string[] = [];
if (n.kind === "schema" || n.kind === "folder") schemas = [n.schema];
else if (n.kind === "source" || n.kind === "database") {
if (useDatabase.getState().schemas[id]?.status !== "ok") await loadSchemas(id);
const l = useDatabase.getState().schemas[id];
schemas = l?.status === "ok" ? l.value : [];
}
const parts: string[] = [];
for (const schema of schemas) {
const tables = (await tablesIn(id, schema)).filter((t) => n.kind !== "folder" || (t.kind === "view") === (n.folder === "views"));
// Tables before views: views select from them.
tables.sort((a, b) => Number(a.kind === "view") - Number(b.kind === "view"));
for (const t of tables) parts.push(await one(schema, t.name));
}
return { id, text: parts.join("\n") };
} catch (e) {
notifications().notify({ level: "error", title: "Cannot generate the DDL", body: e instanceof Error ? e.message : String(e) });
return null;
}
}
/** SQL Scripts › Select / Insert / Update / Delete templates into the console. */
export async function insertScript(n: DbNode, kind: ScriptKind) {
const t = tableOf(n);
if (!t) return;
const key = detailsKey(t.id, t.schema, t.table);
if (useDatabase.getState().details[key]?.status !== "ok") await loadDetails(t.id, t.schema, t.table);
const d = useDatabase.getState().details[key];
const columns = d?.status === "ok" ? d.value.columns : [];
await openConsoleWith(t.id, scriptSql(driverOf(t.id), kind, t.schema, t.table, columns));
}
/** SQL Scripts › Run SQL Script…: picks a `.sql` file and runs all its statements. */
async function runScriptFile(id: string) {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "Run SQL Script", filters: [{ name: "SQL", extensions: ["sql", "ddl"] }] });
if (typeof file !== "string") return;
try {
const text = await call(commands.fsReadText(file));
const statements = await call(commands.dbStatements(id, text, 0, text.length));
useDatabase.setState({ activeResult: OUTPUT_TAB });
showServiceView(RESULTS_VIEW, false);
if (await runSql(id, statements.map((s) => s.text)))
notifications().notify({ level: "info", title: `Script finished: ${statements.length} statement${statements.length === 1 ? "" : "s"}` });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot run the script", body: e instanceof Error ? e.message : String(e) });
}
}
function refreshNode(n: DbNode | null) {
if (!n) {
void loadSources();
for (const c of useDatabase.getState().connected) void refresh(c);
return;
}
const id = nodeTarget(n);
if (!id) return;
if (n.kind === "schema" || n.kind === "folder") void loadTables(id, n.schema);
else if (tableOf(n)) {
const t = tableOf(n)!;
void loadDetails(t.id, t.schema, t.table);
} else void refresh(id);
}
export function registerDatabaseTreeActions(): () => void {
const tree = (a: Omit<Action, "category">): Action => ({ category: "Database", when: OPEN, keyWhen: DB_VIEW_FOCUS, ...a });
return registerActions([
tree({
id: "db.properties",
title: "Data Source Properties…",
icon: "db-properties",
keywords: ["data sources", "connection settings"],
run: ({ arg }) => {
const src = sourceOf(nodeFrom(arg));
const { sources } = useDatabase.getState();
openSourceDialog(src ?? (sources.length === 1 ? sources[0]! : null));
},
}),
tree({
id: "db.refresh",
title: "Refresh",
icon: "refresh",
keywords: ["synchronize", "introspect"],
run: ({ arg }) => refreshNode(nodeFrom(arg)),
}),
tree({
id: "db.disconnect",
title: "Deactivate",
icon: "db-disconnect",
keywords: ["disconnect", "close connection"],
run: ({ arg }) => {
const n = nodeFrom(arg);
const id = n ? nodeTarget(n) : null;
if (!id) {
for (const c of useDatabase.getState().connected) void disconnect(c);
return;
}
// A database node closes its own connection; anything else the data source's.
void disconnect(n?.kind === "database" ? id : splitTarget(id).id);
},
}),
tree({
id: "db.jumpToConsole",
title: "Jump to Query Console…",
icon: "db-console",
run: ({ arg }) => {
const n = nodeFrom(arg);
const id = n ? nodeTarget(n) : null;
const { sources } = useDatabase.getState();
const target = id ?? (sources.length === 1 ? (sources[0]!.id ?? null) : null);
if (target) void openConsole(target);
else toolWindowService().show(DB_TOOL_WINDOW, true);
},
}),
tree({
id: "db.editData",
title: "Edit Data",
icon: "db-table",
keywords: ["open table", "table editor"],
run: ({ arg }) => {
const n = nodeFrom(arg);
const t = n && tableOf(n);
if (t) openTableData(t.id, t.schema, t.table);
},
}),
tree({
id: "db.goToDdl",
title: "Go to DDL",
icon: "file-sql",
keywords: ["edit source", "definition", "create table"],
run: ({ arg }) => {
const n = nodeFrom(arg);
const t = n && tableOf(n);
if (t) void openDdl(t.id, t.schema, t.table);
},
}),
tree({
id: "db.quickDoc",
title: "Quick Documentation",
icon: "info",
run: ({ arg }) => {
const n = nodeFrom(arg);
if (n && n.kind !== "message") openDialog({ kind: "quickDoc", node: n, anchor: rowAnchor() });
},
}),
tree({
id: "db.rename",
title: "Rename…",
icon: "edit",
run: ({ arg }) => {
const n = nodeFrom(arg);
if (!n) return;
const id = nodeTarget(n);
if (n.kind === "source" && id) return openDialog({ kind: "rename", id, object: null });
const o = objectOf(n);
if (id && o && canRename(driverOf(id), o)) openDialog({ kind: "rename", id, object: o });
},
}),
tree({
id: "db.drop",
title: "Drop…",
icon: "trash",
keywords: ["delete", "remove data source"],
run: ({ arg }) => {
const n = nodeFrom(arg);
if (!n) return;
const id = nodeTarget(n);
if (n.kind === "source") return openDialog({ kind: "removeSource", source: n.source });
const o = objectOf(n);
if (id && o && canDrop(driverOf(id), o)) openDialog({ kind: "drop", id, object: o });
},
}),
tree({
id: "db.truncate",
title: "Truncate…",
keywords: ["delete all rows", "empty table"],
run: ({ arg }) => {
const n = nodeFrom(arg);
if (n?.kind === "table" && n.table.kind !== "view") openDialog({ kind: "truncate", id: n.id, schema: n.schema, table: n.table.name });
},
}),
tree({
id: "db.modifyTable",
title: "Modify Table…",
icon: "edit",
keywords: ["alter table", "add column", "modify column"],
run: ({ arg }) => {
const n = nodeFrom(arg);
const t = n && tableOf(n);
if (!t || t.view) return;
openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, focusColumn: n.kind === "column" ? n.column.name : undefined });
},
}),
tree({
id: "db.newTable",
title: "Table",
icon: "db-table",
keywords: ["create table", "new table"],
run: ({ arg }) => {
const n = nodeFrom(arg);
const id = n && nodeTarget(n);
if (!n || !id) return;
const schema = "schema" in n && typeof n.schema === "string" ? n.schema : driverOf(id) === "sqlite" ? "main" : null;
if (schema !== null) openDialog({ kind: "modifyTable", id, schema, table: null });
},
}),
tree({
id: "db.newColumn",
title: "Column",
icon: "db-column",
keywords: ["add column"],
run: ({ arg }) => {
const n = nodeFrom(arg);
const t = n && tableOf(n);
if (t && !t.view) openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, addColumn: true });
},
}),
tree({
id: "db.copyReference",
title: "Copy Reference",
icon: "copy",
keywords: ["qualified name"],
run: ({ arg }) => {
const n = nodeFrom(arg);
if (!n) return;
const id = nodeTarget(n);
if (n.kind === "source") return void copy(n.source.name ?? "", "name");
if (n.kind === "database") return void copy(n.name, "name");
const o = objectOf(n);
if (id && o) void copy(reference(driverOf(id), o), "reference");
},
}),
tree({
id: "db.duplicateSource",
title: "Duplicate",
icon: "copy",
keywords: ["copy data source"],
run: ({ arg }) => {
const src = sourceOf(nodeFrom(arg));
if (src?.id) void duplicateSource(src.id);
},
}),
tree({
id: "db.ddlToClipboard",
title: "Generate DDL to Clipboard",
icon: "copy",
keywords: ["create table", "sql script"],
run: async ({ arg }) => {
const n = nodeFrom(arg);
const ddl = n && (await ddlOf(n));
if (ddl) await copy(ddl.text, "DDL");
},
}),
tree({
id: "db.ddlToConsole",
title: "Generate DDL to Query Console",
icon: "db-console",
keywords: ["create table", "sql script"],
run: async ({ arg }) => {
const n = nodeFrom(arg);
const ddl = n && (await ddlOf(n));
if (ddl) await openConsoleWith(ddl.id, ddl.text);
},
}),
tree({
id: "db.runSqlScript",
title: "Run SQL Script…",
icon: "run",
keywords: ["execute file", "import sql"],
run: ({ arg }) => {
const n = nodeFrom(arg);
const { sources } = useDatabase.getState();
const id = (n && nodeTarget(n)) ?? (sources.length === 1 ? sources[0]!.id : null);
if (id) void runScriptFile(id);
},
}),
tree({
id: "db.sqlScript",
title: "SQL Script Template",
hidden: true,
run: ({ arg }) => {
const a = (arg ?? {}) as { node?: DbNode; kind?: ScriptKind };
const n = a.node ?? useDatabase.getState().selectedNode;
if (n && a.kind) void insertScript(n, a.kind);
},
}),
tree({
id: "db.goToReferencedTable",
title: "Go to Referenced Table",
keywords: ["foreign key"],
run: ({ arg }) => {
const n = nodeFrom(arg);
if (n?.kind === "foreignKey") void revealInTree(n.id, n.fk.refSchema, n.fk.refTable, false);
},
}),
tree({
id: "db.new",
title: "New…",
icon: "plus",
keywords: ["query console", "data source"],
run: () => {
toolWindowService().show(DB_TOOL_WINDOW, false);
useDatabase.setState({ newMenuOpen: true });
},
}),
tree({
id: "db.collapseAll",
title: "Collapse All",
icon: "collapse-all",
run: () => useDatabase.setState({ treeExpanded: new Set() }),
}),
]);
}
+155
Переглянути файл
@@ -0,0 +1,155 @@
/** Menus of the Database tool window, laid out like IDEA's (New ▸, Copy/Paste ▸, SQL Scripts ▸ …). */
import type { DbDriver } from "@/platform/ipc";
import type { MenuItem } from "@/ui";
import { driverIcon, fileSourceName } from "./model";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { SCRIPT_LABELS, canDrop, canRename, type ScriptKind } from "./objects";
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
const act = (action: string, node: DbNode | null, extra: Partial<{ label: string; disabled: boolean; icon: string }> = {}): MenuItem => ({
kind: "action",
action,
arg: node ?? undefined,
...extra,
});
const sep: MenuItem = { kind: "separator" };
async function sqliteFromFile() {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "SQLite Database File" });
if (typeof file === "string") openSourceDialog(null, { driver: "sqlite", file, name: fileSourceName(file) });
}
/** New ▸ Data Source ▸ — the drivers the backend speaks. */
function dataSourceItems(): MenuItem[] {
const preset = (label: string, driver: DbDriver): MenuItem => ({
label,
icon: driverIcon(driver),
run: () => openSourceDialog(null, { driver }),
});
return [
{
label: "Data Source",
icon: "database",
submenu: [preset("MariaDB", "mysql"), preset("MySQL", "mysql"), preset("PostgreSQL", "postgres"), preset("SQLite", "sqlite")],
},
{ label: "Data Source from File…", icon: "folder", run: () => void sqliteFromFile() },
{ label: "Data Source from URL…", icon: "external-link", run: () => openDialog({ kind: "sourceUrl" }) },
];
}
/** The toolbar's "+" menu (IDEA: New). */
export function newMenu(): MenuItem[] {
return [{ kind: "action", action: "db.openConsole", label: "Query Console" }, sep, ...dataSourceItems()];
}
function sqlScripts(n: DbNode, table: boolean): MenuItem {
const scripts: MenuItem[] = table
? (Object.keys(SCRIPT_LABELS) as ScriptKind[]).map((kind) => ({
kind: "action",
action: "db.sqlScript",
arg: { node: n, kind },
label: SCRIPT_LABELS[kind],
}))
: [act("db.runSqlScript", n)];
return { label: "SQL Scripts", submenu: [act("db.ddlToClipboard", n), act("db.ddlToConsole", n), sep, ...scripts] };
}
/** Context menu of a tree node (`null`: the empty area). */
export function nodeMenu(n: DbNode | null): MenuItem[] {
if (!n) return [{ label: "New", icon: "plus", submenu: newMenu() }];
const id = nodeTarget(n) ?? "";
const driver = sourceById(id)?.driver ?? "sqlite";
const o = objectOf(n);
const t = tableOf(n);
const newConsole = act("db.openConsole", n, { label: "Query Console", icon: "db-console" });
const refresh = act("db.refresh", n);
const quickDoc = act("db.quickDoc", n);
const rename = act("db.rename", n, { disabled: !(n.kind === "source" || (o && canRename(driver, o))) });
const drop = act("db.drop", n, { disabled: !o || !canDrop(driver, o) });
const copyRef = act("db.copyReference", n);
const navigation = (extra: MenuItem[] = []): MenuItem => ({ label: "Navigation", submenu: [...extra, act("db.jumpToConsole", n)] });
switch (n.kind) {
case "source": {
const connected = connectedTargets(n.source.id ?? "").length > 0;
return [
act("db.properties", n, { label: "Properties" }),
sep,
{ label: "New", icon: "plus", submenu: [newConsole, sep, ...dataSourceItems()] },
{ label: "Copy/Paste", submenu: [copyRef, act("db.duplicateSource", n)] },
refresh,
act("db.disconnect", n, { disabled: !connected }),
sep,
rename,
sep,
quickDoc,
navigation(),
sep,
sqlScripts(n, false),
sep,
act("db.drop", n, { label: "Remove Data Source…" }),
];
}
case "database":
return [
{ label: "New", icon: "plus", submenu: [newConsole] },
{ label: "Copy/Paste", submenu: [copyRef] },
refresh,
act("db.disconnect", n, { disabled: !useDatabase.getState().connected.has(id) || n.current }),
sep,
navigation(),
sqlScripts(n, false),
];
case "schema":
case "folder":
return [
{ label: "New", icon: "plus", submenu: [newConsole, sep, act("db.newTable", n)] },
...(n.kind === "schema" ? [{ label: "Copy/Paste", submenu: [copyRef] }] : []),
refresh,
sep,
...(n.kind === "schema" ? [quickDoc] : []),
navigation(),
sep,
sqlScripts(n, false),
...(n.kind === "schema" ? [sep, drop] : []),
];
case "table":
case "column": {
const view = !!t?.view;
const column = n.kind === "column";
return [
{ label: "New", icon: "plus", submenu: [newConsole, sep, act("db.newColumn", n, { disabled: view }), act("db.newTable", n)] },
act("db.modifyTable", n, { label: column ? "Modify Column…" : "Modify Table…", disabled: view }),
{ label: "Copy/Paste", submenu: [copyRef] },
act("db.editData", n),
refresh,
sep,
rename,
sep,
quickDoc,
navigation([act("db.goToDdl", n)]),
sep,
sqlScripts(n, true),
...(n.kind === "table" && !view ? [{ label: "Object Actions", submenu: [act("db.truncate", n)] }] : []),
sep,
drop,
];
}
case "index":
return [{ label: "Copy/Paste", submenu: [copyRef] }, refresh, sep, rename, sep, navigation([act("db.goToDdl", n)]), sep, drop];
case "foreignKey":
return [refresh, sep, { label: "Navigation", submenu: [act("db.goToReferencedTable", n), act("db.goToDdl", n)] }];
case "group":
case "key":
return [
...(n.kind === "group" && n.group === "columns" ? [{ label: "New", icon: "plus", submenu: [act("db.newColumn", n)] }] : []),
refresh,
sep,
{ label: "Navigation", submenu: [act("db.goToDdl", n)] },
];
default:
return [];
}
}