diff --git a/package.json b/package.json index e1648076..8fb25b63 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", "pretty-bytes": "^7.1.0", + "sql-formatter": "^9.0.0", "vue": "^3.5.35", "vue-router": "5.0.7", "xterm-theme": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7f25da8..8229e560 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: pretty-bytes: specifier: ^7.1.0 version: 7.1.0 + sql-formatter: + specifier: ^9.0.0 + version: 9.2.0 vue: specifier: ^3.5.35 version: 3.5.35(typescript@6.0.3) @@ -6528,6 +6531,10 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + sql-formatter@9.2.0: + resolution: {integrity: sha512-Dn4lEpUeAhfNDR2LnEs9Uaq92TSHjhcNrzhllPuMnp188P4sLU7UcdcB9UqIfMfcN62gWXJlJ3KocaAf/SOzXQ==} + hasBin: true + srvx@0.11.16: resolution: {integrity: sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==} engines: {node: '>=20.16.0'} @@ -14198,6 +14205,10 @@ snapshots: speakingurl@14.0.1: {} + sql-formatter@9.2.0: + dependencies: + argparse: 2.0.1 + srvx@0.11.16: {} stable-hash-x@0.2.0: {} diff --git a/ui/chen/api/index.ts b/ui/chen/api/index.ts index 12cf8628..b26d7aab 100644 --- a/ui/chen/api/index.ts +++ b/ui/chen/api/index.ts @@ -1,4 +1,4 @@ -import type { ChenActionItem, ChenAuthResponse, ChenProfile, ChenTreeNode } from "~/chen/types"; +import type { ChenActionItem, ChenAuthResponse, ChenProfile, ChenSqlHints, ChenTreeNode } from "~/chen/types"; const buildHeaders = (token?: string, init?: HeadersInit) => ({ ...getWebApiHeaders(), @@ -68,6 +68,35 @@ export async function uploadChenSqlFile( return { path: result.path }; } +export async function fetchChenSqlHints( + chenToken: string, + nodeKey: string, + context: string, + fetchImpl: typeof fetch = fetch +): Promise { + const response = await fetchImpl(chenPath("/api/resources/hints"), { + method: "POST", + credentials: "include", + headers: { + ...buildHeaders(chenToken, getWebApiMutationHeaders()), + "Content-Type": "application/json" + }, + body: JSON.stringify({ nodeKey, context }) + }); + const result = await readJson(response); + if (!result || typeof result !== "object" || Array.isArray(result)) { + throw new Error("Chen returned malformed SQL hints"); + } + + return Object.fromEntries( + Object.entries(result) + .filter((entry): entry is [string, string[]] => ( + Array.isArray(entry[1]) && entry[1].every((column) => typeof column === "string") + )) + .map(([table, columns]) => [table, [...columns]]) + ); +} + export function sanitizeChenExportFileName(value: string, fallback = "chen-export") { const withoutControls = Array.from(value) .filter((character) => { diff --git a/ui/chen/components/QueryConsolePanel.vue b/ui/chen/components/QueryConsolePanel.vue index 0f8fd4c3..dbdf4bc8 100644 --- a/ui/chen/components/QueryConsolePanel.vue +++ b/ui/chen/components/QueryConsolePanel.vue @@ -7,6 +7,7 @@ import ChenSqlEditor from "~/chen/components/SqlEditor.client.vue"; import SqlSnippetSaveDialog from "~/chen/components/SqlSnippetSaveDialog.vue"; import SqlSnippetSelectDialog from "~/chen/components/SqlSnippetSelectDialog.vue"; import { useChenSqlSnippets } from "~/chen/composables/useChenSqlSnippets"; +import { formatChenSql } from "~/chen/utils/sqlFormat"; const props = defineProps<{ tab: ChenQueryConsoleTab @@ -17,6 +18,7 @@ const props = defineProps<{ const emit = defineEmits<{ run: [tab: ChenQueryConsoleTab, selectedSql: string] cancel: [tab: ChenQueryConsoleTab] + changeContext: [tab: ChenQueryConsoleTab, context: string] uploadSql: [tab: ChenQueryConsoleTab, file: File] dataViewAction: [tab: ChenQueryConsoleTab, result: ChenQueryResultTab, action: ChenDataViewAction, data?: ChenDataViewActionData] dismissMessage: [tab: ChenQueryConsoleTab] @@ -25,7 +27,10 @@ const emit = defineEmits<{ closeResult: [tab: ChenQueryConsoleTab, title: string] }>(); -const sqlEditor = ref<{ selectedText: () => string } | null>(null); +const sqlEditor = ref<{ + replaceDocument: (value: string) => void + selectedText: () => string +} | null>(null); const sqlUploadInput = ref(null); const hasSelection = ref(false); const messageOpen = ref(false); @@ -36,6 +41,13 @@ let messageCloseTimer: ReturnType | null = null; const toast = useToast(); const sqlSnippets = useChenSqlSnippets(() => props.dbType); const queryBusy = computed(() => Boolean(props.tab.state.loading || props.tab.state.inQuery)); +const contextBusy = computed(() => Boolean(queryBusy.value || props.tab.state.editorLoading)); +const contextItems = computed(() => (props.tab.state.contexts || []).map((context) => ({ + label: context, + icon: context === props.tab.state.currentContext ? "i-lucide-check" : undefined, + disabled: contextBusy.value || context === props.tab.state.currentContext, + onSelect: () => emit("changeContext", props.tab, context) +}))); const messageColor = computed(() => { if (props.tab.message?.type === "error") return "error"; @@ -52,6 +64,22 @@ function runSelectedQuery() { emit("run", props.tab, sqlEditor.value?.selectedText() || ""); } +function formatStatement() { + if (contextBusy.value) return; + const original = props.tab.statement; + try { + const formatted = formatChenSql(original, props.dbType); + if (formatted === original) return; + sqlEditor.value?.replaceDocument(formatted); + } catch (cause) { + toast.add({ + title: "SQL format failed", + description: requestErrorMessage(cause), + color: "error" + }); + } +} + function requestErrorMessage(cause: unknown) { return cause instanceof Error ? cause.message : String(cause); } @@ -185,6 +213,15 @@ onBeforeUnmount(clearMessageTimer); :disabled="!tab.state.canCancel" @click="emit('cancel', tab)" /> + + Format + Upload SQL + + + {{ tab.state.currentContext || "Context" }} + +
import type { Extension } from "@codemirror/state"; +import type { ChenSqlHints } from "~/chen/types"; import { sql } from "@codemirror/lang-sql"; import { Compartment, EditorState, Prec } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; import { basicSetup } from "codemirror"; +import { chenSqlConfig, replaceChenSqlDocument } from "~/chen/utils/sqlEditor"; import { createCodeMirrorSyntaxTheme, createCodeMirrorTheme } from "~/shared/theme/adapters/codemirror"; const props = withDefaults(defineProps<{ modelValue: string + dbType?: string + hints?: ChenSqlHints readOnly?: boolean }>(), { + dbType: "", + hints: () => ({}), readOnly: false }); const emit = defineEmits<{ "update:modelValue": [value: string] selectionChange: [hasSelection: boolean] + format: [] openSnippets: [] run: [] saveSnippet: [] @@ -26,16 +33,24 @@ const colorMode = useColorMode(); const container = ref(null); const themeSlot = new Compartment(); const editableSlot = new Compartment(); +const sqlLanguageSlot = new Compartment(); let editor: EditorView | null = null; let applyingExternalValue = false; const editorExtensions: Extension[] = [ basicSetup, - sql(), + sqlLanguageSlot.of(sql(chenSqlConfig(props.dbType, props.hints))), createCodeMirrorSyntaxTheme(), EditorView.lineWrapping, EditorState.tabSize.of(2), Prec.highest(keymap.of([ + { + key: "Mod-l", + run: () => { + emit("format"); + return true; + } + }, { key: "Ctrl-r", run: () => { @@ -86,6 +101,11 @@ function focus() { editor?.focus(); } +function replaceDocument(value: string) { + if (!editor) return; + replaceChenSqlDocument(editor, value); +} + onMounted(() => { if (!container.value) return; editor = new EditorView({ @@ -112,6 +132,10 @@ watch(() => props.readOnly, (value) => { editor?.dispatch({ effects: editableSlot.reconfigure(EditorView.editable.of(!value)) }); }); +watch(() => [props.dbType, props.hints] as const, ([dbType, hints]) => { + editor?.dispatch({ effects: sqlLanguageSlot.reconfigure(sql(chenSqlConfig(dbType, hints))) }); +}); + watch(() => colorMode.value, () => { editor?.dispatch({ effects: themeSlot.reconfigure(createCodeMirrorTheme()) }); }); @@ -120,6 +144,7 @@ onBeforeUnmount(() => editor?.destroy()); defineExpose({ focus, + replaceDocument, selectedText }); diff --git a/ui/chen/composables/useChenQueryConsole.ts b/ui/chen/composables/useChenQueryConsole.ts index 1acb51fd..ea0fb7bf 100644 --- a/ui/chen/composables/useChenQueryConsole.ts +++ b/ui/chen/composables/useChenQueryConsole.ts @@ -159,6 +159,24 @@ export function useChenQueryConsole( }); } + function changeQueryContext(tab: ChenQueryConsoleTab, context: string) { + if ( + tab.state.loading + || tab.state.inQuery + || tab.state.editorLoading + || !context.trim() + || context === tab.state.currentContext + || !tab.state.contexts?.includes(context) + ) { + return; + } + + sendConsoleAction(tab, "query_console_action", { + action: "change_current_context", + data: context + }); + } + function runConsoleTab(tab: ChenPromptConsoleTab) { const sql = tab.pendingSql.trim(); if (!sql) return; @@ -181,6 +199,7 @@ export function useChenQueryConsole( return { appendLog, cancelQueryLikeTab, + changeQueryContext, closeQueryResult, dismissQueryMessage, handleQueryConsolePacket, diff --git a/ui/chen/composables/useChenSqlHints.ts b/ui/chen/composables/useChenSqlHints.ts new file mode 100644 index 00000000..9253d0c2 --- /dev/null +++ b/ui/chen/composables/useChenSqlHints.ts @@ -0,0 +1,26 @@ +import type { ChenQueryConsoleTab, ChenSqlHints } from "~/chen/types"; + +export function useChenSqlHints( + fetchHints: (tab: ChenQueryConsoleTab, context: string) => Promise, + onError: (cause: unknown) => void = () => {} +) { + async function load(tab: ChenQueryConsoleTab, context: string) { + const generation = ++tab.hintsRequestGeneration; + tab.hintsLoading = true; + try { + const hints = await fetchHints(tab, context); + if (generation === tab.hintsRequestGeneration && context === tab.state.currentContext) { + tab.sqlHints = hints; + tab.hintsContext = context; + } + } catch (cause) { + if (generation === tab.hintsRequestGeneration && context === tab.state.currentContext) { + onError(cause); + } + } finally { + if (generation === tab.hintsRequestGeneration) tab.hintsLoading = false; + } + } + + return { load }; +} diff --git a/ui/chen/composables/useChenWorkspaceTabs.ts b/ui/chen/composables/useChenWorkspaceTabs.ts index a33c034c..fc4de458 100644 --- a/ui/chen/composables/useChenWorkspaceTabs.ts +++ b/ui/chen/composables/useChenWorkspaceTabs.ts @@ -55,6 +55,10 @@ export function useChenWorkspaceTabs() { nodeKey, statement: "", uploadingSql: false, + sqlHints: {}, + hintsContext: "", + hintsLoading: false, + hintsRequestGeneration: 0, state: {}, logs: [], message: null, diff --git a/ui/chen/types.ts b/ui/chen/types.ts index e1e423e5..54f9def4 100644 --- a/ui/chen/types.ts +++ b/ui/chen/types.ts @@ -74,9 +74,12 @@ export interface ChenDataViewDataset { data: Array> } +export type ChenSqlHints = Record; + export interface ChenConsoleState { loading?: boolean inQuery?: boolean + editorLoading?: boolean canCancel?: boolean currentContext?: string contexts?: string[] @@ -114,6 +117,10 @@ export interface ChenQueryConsoleTab extends ChenTabDefinition { kind: "query" statement: string uploadingSql: boolean + sqlHints: ChenSqlHints + hintsContext: string + hintsLoading: boolean + hintsRequestGeneration: number state: ChenConsoleState logs: string[] message: ChenConsoleMessage | null diff --git a/ui/chen/utils/sqlEditor.ts b/ui/chen/utils/sqlEditor.ts new file mode 100644 index 00000000..b1d74175 --- /dev/null +++ b/ui/chen/utils/sqlEditor.ts @@ -0,0 +1,34 @@ +import type { EditorView } from "@codemirror/view"; +import type { ChenSqlHints } from "~/chen/types"; +import { MariaSQL, MSSQL, MySQL, PLSQL, PostgreSQL, StandardSQL } from "@codemirror/lang-sql"; + +function chenSqlDialect(dbType: string) { + switch (dbType.trim().toLowerCase()) { + case "mysql": + return MySQL; + case "mariadb": + return MariaSQL; + case "postgresql": + return PostgreSQL; + case "oracle": + return PLSQL; + case "sqlserver": + case "sql server": + case "mssql": + return MSSQL; + default: + return StandardSQL; + } +} + +export function chenSqlConfig(dbType: string, hints: ChenSqlHints) { + return { dialect: chenSqlDialect(dbType), schema: hints }; +} + +export function replaceChenSqlDocument( + editor: Pick, + value: string +) { + if (editor.state.doc.toString() === value) return; + editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } }); +} diff --git a/ui/chen/utils/sqlFormat.ts b/ui/chen/utils/sqlFormat.ts new file mode 100644 index 00000000..720ea54a --- /dev/null +++ b/ui/chen/utils/sqlFormat.ts @@ -0,0 +1,26 @@ +import type { SqlLanguage } from "sql-formatter"; +import { format } from "sql-formatter"; + +const formatterLanguageMap = { + clickhouse: "sql", + mariadb: "mariadb", + mysql: "mysql", + postgresql: "postgresql", + oracle: "plsql", + sqlserver: "tsql", + db2: "db2", + // ponytail: Dameng reuses PL/SQL only for display formatting. Add a dedicated + // formatter before supporting stored procedures or Dameng-specific DDL. + dameng: "plsql" +} as const; + +export function formatChenSql(statement: string, dbType: string) { + if (!statement.trim()) return statement; + const normalizedDbType = dbType.trim().toLowerCase() as keyof typeof formatterLanguageMap; + const language = formatterLanguageMap[normalizedDbType]; + try { + return format(statement, { language: language as SqlLanguage }); + } catch { + return statement; + } +} diff --git a/ui/chen/workspaces/DatabaseSessionSurface.vue b/ui/chen/workspaces/DatabaseSessionSurface.vue index 279cc9ec..f131ce99 100644 --- a/ui/chen/workspaces/DatabaseSessionSurface.vue +++ b/ui/chen/workspaces/DatabaseSessionSurface.vue @@ -15,7 +15,7 @@ import type { } from "~/chen/types"; import type { WorkspaceSessionTab } from "~/composables/useWorkspaceTabs"; -import { fetchChenActions, fetchChenExport, uploadChenSqlFile } from "~/chen/api"; +import { fetchChenActions, fetchChenExport, fetchChenSqlHints, uploadChenSqlFile } from "~/chen/api"; import ChenSessionState from "~/chen/components/ChenSessionState.vue"; import ConsolePanel from "~/chen/components/ConsolePanel.vue"; import DataViewPanel from "~/chen/components/DataViewPanel.vue"; @@ -28,6 +28,7 @@ import { useChenDataView } from "~/chen/composables/useChenDataView"; import { useChenQueryConsole } from "~/chen/composables/useChenQueryConsole"; import { useChenResourceTree } from "~/chen/composables/useChenResourceTree"; import { useChenSession } from "~/chen/composables/useChenSession"; +import { useChenSqlHints } from "~/chen/composables/useChenSqlHints"; import { useChenWebSocket } from "~/chen/composables/useChenWebSocket"; import { useChenWorkspaceTabs } from "~/chen/composables/useChenWorkspaceTabs"; import { saveChenExport } from "~/chen/runtime/download"; @@ -85,6 +86,16 @@ const activeDataViewTab = computed(() => { const activeConnectionError = computed(() => workspace.activeWorkspaceTab.value?.connectionError || ""); const queryConsole = useChenQueryConsole(sendConsoleAction); +const queryHints = useChenSqlHints( + (tab, context) => fetchChenSqlHints(auth.chenToken.value, tab.nodeKey, context), + (cause) => { + toast.add({ + title: "Failed to load SQL hints", + description: cause instanceof Error ? cause.message : String(cause), + color: "warning" + }); + } +); const session = useChenSession({ authenticate: auth.authenticate, markConnected: () => markSessionConnected(props.tab.id), @@ -180,9 +191,14 @@ function sendConsoleAction(tab: ChenWorkspaceTab, type: string, data?: any) { } function handleConsolePacket(tab: ChenWorkspaceTab, packet: ChenPacket) { + const previousContext = tab.kind === "query" ? tab.state.currentContext : undefined; if (tab.kind === "query" || tab.kind === "console") { queryConsole.handleQueryConsolePacket(tab, packet); } + if (tab.kind === "query" && packet.type === "update_state") { + const currentContext = tab.state.currentContext || ""; + if (currentContext && currentContext !== previousContext) void queryHints.load(tab, currentContext); + } switch (packet.type) { case "init": @@ -384,6 +400,10 @@ function cancelQueryLikeTab(tab: ChenQueryLikeWorkspaceTab) { queryConsole.cancelQueryLikeTab(tab); } +function changeQueryContext(tab: ChenQueryConsoleTab, context: string) { + queryConsole.changeQueryContext(tab, context); +} + function updateQueryStatement(tab: ChenQueryConsoleTab, value: string) { tab.statement = value; } @@ -509,6 +529,7 @@ defineExpose({ focus }); :can-copy="auth.profile.value?.canCopy === true" @run="runQueryTab" @cancel="cancelQueryLikeTab" + @change-context="changeQueryContext" @upload-sql="uploadQuerySql" @data-view-action="runQueryDataViewAction" @dismiss-message="dismissQueryMessage"