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