From e7a10baccd782b411f7c4af7cdfccbc055115c24 Mon Sep 17 00:00:00 2001 From: Hand Sonic <8078023+HandSonic@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:07:37 +0800 Subject: [PATCH] fix(kingbase): reconfigure editor language and completion by mode (#12834) --- .../src/components/editor/QueryEditor.vue | 26 +++- .../QueryEditor.kingbase.integration.spec.ts | 126 ++++++++++++++++++ .../useQueryEditorDiagnostics.spec.ts | 12 +- .../editor/queryEditorSqlExtensions.ts | 8 +- .../useQueryEditorCompletionMetadata.ts | 8 +- .../editor/useQueryEditorDiagnostics.ts | 5 +- .../useQueryEditorStatementBoundaries.ts | 21 +-- .../src/components/layout/ContentArea.vue | 4 +- .../__tests__/database/jdbcDialect.spec.ts | 29 ++++ .../editor/codemirrorSqlDialect.spec.ts | 9 ++ apps/desktop/src/lib/database/jdbcDialect.ts | 7 +- .../src/lib/editor/codemirrorSqlDialect.ts | 9 +- .../app-tests/codemirrorSqlDialect.test.ts | 10 ++ 13 files changed, 248 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/QueryEditor.kingbase.integration.spec.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 183d7eac62..a0ce40757a 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -147,16 +147,25 @@ import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { isMacOS } from "@/lib/backend/platform"; import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect"; +import { kingbaseSqlDatabaseType } from "@/lib/database/jdbcDialect"; import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion"; const props = defineProps(); function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined { + const mode = kingbaseSqlType.value; + if (props.databaseType === "kingbase") return mode === "oracle" ? undefined : (mode ?? "postgres"); // clickhouse and soql ride the SQL editor but have no matching behavior dialect; // fall back to the connection's dialect (undefined for Salesforce). + if (props.syntaxDialect === "oracle") return undefined; return props.syntaxDialect === "clickhouse" || props.syntaxDialect === "soql" ? props.dialect : (props.syntaxDialect ?? props.dialect); } +function sqlSyntaxDialect() { + if (props.databaseType === "kingbase") return kingbaseSqlType.value ?? "postgres"; + return props.syntaxDialect ?? props.dialect; +} + function queryEditorSelectionLanguage(): "sql" | "text" { const databaseType = props.databaseType; return databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "solr" || databaseType === "victoriametrics" ? "text" : "sql"; @@ -247,12 +256,14 @@ let contextMenuDoc: Text | null = null; let contextMenuDocText = ""; const connectionStore = useConnectionStore(); const settingsStore = useSettingsStore(); +const kingbaseConfig = computed(() => (props.databaseType === "kingbase" && props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined)); +const kingbaseSqlType = computed(() => kingbaseSqlDatabaseType(kingbaseConfig.value)); function sqlStatementParameterOptions() { const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, props.databaseType, settingsStore.editorSettings.sqlVariableSubstitutionEnabled); return { - databaseType: props.databaseType, - compatibilityMode: props.databaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(props.connectionId, props.database) : undefined, + databaseType: kingbaseSqlType.value ?? props.databaseType, + compatibilityMode: props.databaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(props.connectionId, props.database) : kingbaseConfig.value?.database_info?.compatibilityMode, enabledSyntaxes: enabledSqlParameterSyntaxes(toggles), }; } @@ -511,6 +522,7 @@ const statementBoundaries = useQueryEditorStatementBoundaries({ props, view, sqlStatementParameterOptions, + syntaxDialect: sqlSyntaxDialect, runtime: codeMirrorRuntime, driverProfile: () => sqlDriverProfile.value, cache: { @@ -534,6 +546,7 @@ const completionMetadata = useQueryEditorCompletionMetadata({ view, connectionStore, sqlBehaviorDialect, + syntaxDialect: sqlSyntaxDialect, remoteLatencyBudgetMs: COMPLETION_REMOTE_LATENCY_BUDGET_MS, maxCompletionTables: MAX_COMPLETION_TABLES, onDemandMinPrefix: PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX, @@ -1694,6 +1707,7 @@ const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnos sqlDriverProfile, sqlStatementParameterOptions, sqlBehaviorDialect, + sqlSyntaxDialect, queryEditorSelectionLanguage, semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED, maxCompletionTables: MAX_COMPLETION_TABLES, @@ -2025,6 +2039,8 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ t, sqlBehaviorDialect, sqlDriverProfile, + sqlFunctionDatabaseType: () => kingbaseSqlType.value ?? props.databaseType, + sqlSyntaxDialect, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache: { @@ -2589,8 +2605,11 @@ watch([() => props.clientSessionId, () => props.completionContextVersion], () => }); watch( - [() => props.tabId, () => props.databaseType, () => props.dialect, () => props.syntaxDialect, sqlDriverProfile], + [() => props.tabId, () => props.databaseType, () => props.dialect, sqlSyntaxDialect, sqlDriverProfile, () => kingbaseConfig.value?.database_info?.compatibilityMode], () => { + completion.invalidateRequests(); + refreshCompletionCache(); + setSemanticDiagnostics([]); executableStatementRangeCache = null; statementBoundaries.invalidate(); if (!view.value || !codeMirrorRuntime.sqlLanguageComp || !codeMirrorRuntime.buildSqlLanguageExtension || !codeMirrorRuntime.sqlSemanticHighlightComp || !codeMirrorRuntime.buildSqlSemanticHighlightExtension || !codeMirrorRuntime.sqlSignatureComp || !codeMirrorRuntime.buildSqlSignatureExtension) @@ -2604,6 +2623,7 @@ watch( ], }); applyEditorCompletionExtension(); + scheduleSemanticDiagnostics(0); }, { flush: "post" }, ); diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.kingbase.integration.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.kingbase.integration.spec.ts new file mode 100644 index 0000000000..0ac92ec15d --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.kingbase.integration.spec.ts @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +import { createApp, h, nextTick, shallowRef } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { syntaxTree } from "@codemirror/language"; +import { EditorView, showTooltip } from "@codemirror/view"; +import { completionStatus, currentCompletions, startCompletion } from "@codemirror/autocomplete"; +import { afterEach, describe, expect, it } from "vitest"; +import QueryEditor from "../QueryEditor.vue"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useSettingsStore } from "@/stores/settingsStore"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) cleanup(); +}); + +async function mountEditor(mode?: string) { + const pinia = createPinia(); + setActivePinia(pinia); + const settings = useSettingsStore(); + settings.editorSettings.showExecutionTargetPicker = false; + settings.editorSettings.completionTriggerMode = "manual"; + settings.editorSettings.sqlSemanticDiagnosticsEnabled = false; + const store = useConnectionStore(); + store.connections.push({ id: "kingbase-synthetic", name: "Synthetic Kingbase", db_type: "kingbase", host: "example.test", port: 54321, username: "synthetic", password: "", database_info: { compatibilityMode: mode } }); + const sql = shallowRef("SELECT "); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ + render: () => + h(QueryEditor, { + modelValue: sql.value, + connectionId: "kingbase-synthetic", + databaseType: "kingbase", + dialect: "postgres", + autoFocus: false, + "onUpdate:modelValue": (value: string) => { + sql.value = value; + }, + }), + }); + app.use(pinia); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + cleanups.push(() => { + app.unmount(); + host.remove(); + }); + await expect.poll(() => host.querySelector(".cm-editor")).not.toBeNull(); + const view = EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!; + return { view, store, host, sql }; +} + +function signatureText(view: EditorView): string { + return view.state + .facet(showTooltip) + .flatMap((tooltip) => (tooltip ? [tooltip.create(view).dom.textContent ?? ""] : [])) + .join("\n"); +} + +describe("Kingbase editor mode integration (synthetic metadata)", () => { + it("invalidates active completion results when the reported mode changes", async () => { + const { view, store, sql } = await mountEditor("pg"); + sql.value = "SELECT IF"; + await nextTick(); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + startCompletion(view); + await expect.poll(() => completionStatus(view.state)).toBe("active"); + expect(currentCompletions(view.state).map((option) => option.label)).not.toContain("IFNULL"); + store.getConfig("kingbase-synthetic")!.database_info = { compatibilityMode: "mysql" }; + await nextTick(); + startCompletion(view); + await expect.poll(() => currentCompletions(view.state).map((option) => option.label)).toContain("IFNULL"); + }); + + it.each([ + ["pg", "JSONB_BUILD_OBJECT"], + ["oracle", "NVL"], + ["mysql", "IFNULL"], + ["sqlserver", "GETDATE"], + ])("uses %s function completion and signatures without changing the native connection", async (mode, functionName) => { + const { view, store, sql } = await mountEditor(mode); + sql.value = `SELECT ${functionName.slice(0, 3)}`; + await nextTick(); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + expect(startCompletion(view)).toBe(true); + await expect.poll(() => completionStatus(view.state)).toBe("active"); + expect(currentCompletions(view.state).map((option) => option.label)).toContain(functionName); + sql.value = `SELECT ${functionName}(`; + await nextTick(); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + await expect.poll(() => signatureText(view)).toContain(`${functionName}(`); + expect(store.getConfig("kingbase-synthetic")?.db_type).toBe("kingbase"); + }); + + it("removes a previous mode's function signature when the mode changes", async () => { + const { view, store, sql } = await mountEditor("mysql"); + sql.value = "SELECT IFNULL("; + await nextTick(); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + await expect.poll(() => signatureText(view)).toContain("IFNULL("); + store.getConfig("kingbase-synthetic")!.database_info = { compatibilityMode: "pg" }; + await nextTick(); + await expect.poll(() => signatureText(view)).not.toContain("IFNULL("); + }); + + it("reconfigures the same mounted editor when mode metadata arrives and changes", async () => { + const { view, store, host, sql } = await mountEditor(); + sql.value = "SELECT `name` FROM t"; + await nextTick(); + const tokenStart = sql.value.indexOf("`name`") + 1; + expect(syntaxTree(view.state).resolveInner(tokenStart).name).not.toBe("QuotedIdentifier"); + store.getConfig("kingbase-synthetic")!.database_info = { compatibilityMode: "mysql" }; + await nextTick(); + expect(syntaxTree(view.state).resolveInner(tokenStart).name).toBe("QuotedIdentifier"); + store.getConfig("kingbase-synthetic")!.database_info = { compatibilityMode: "sqlserver" }; + sql.value = "SELECT [name] FROM #temp"; + await nextTick(); + expect(syntaxTree(view.state).resolveInner(sql.value.indexOf("#temp") + 1).name).toBe("SpecialVar"); + const word = view.state.wordAt(sql.value.indexOf("#temp") + 1)!; + expect(view.state.sliceDoc(word.from, word.to)).toBe("#temp"); + expect(host.querySelector(".cm-editor")).toBe(view.dom); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts index 54a7bf3fe1..1434d54e7d 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts @@ -35,7 +35,7 @@ afterEach(() => { vi.clearAllMocks(); }); -function createHarness(overrides: Partial = {}) { +function createHarness(overrides: Partial = {}, syntaxDialect?: QueryEditorProps["syntaxDialect"]) { const props = reactive({ modelValue: "SELECT missing;", databaseType: "mysql", dialect: "mysql", connectionId: "connection", database: "demo", ...overrides }); const parent = document.createElement("div"); document.body.append(parent); @@ -62,10 +62,11 @@ function createHarness(overrides: Partial = {}) { view, runtime, settingsStore, - connectionStore: { connectionIdentifierQuote: () => "`" } as Options["connectionStore"], + connectionStore: { connectionIdentifierQuote: () => (props.databaseType === "kingbase" && syntaxDialect !== "mysql" ? '"' : "`") } as Options["connectionStore"], sqlDriverProfile: computed(() => undefined), sqlStatementParameterOptions: () => ({}), sqlBehaviorDialect: () => props.dialect, + sqlSyntaxDialect: () => syntaxDialect, semanticCompletionEnabled: false, maxCompletionTables: 100, metadata, @@ -89,6 +90,13 @@ function createHarness(overrides: Partial = {}) { } describe("QueryEditor diagnostic scheduling ownership", () => { + it.each(["oracle", "postgres", "mysql", "sqlserver"] as const)("uses the detected Kingbase %s mode for reference analysis", async (mode) => { + const { diagnostics } = createHarness({ databaseType: "kingbase", dialect: "postgres", formatDialect: "postgresql" }, mode); + diagnostics.scheduleSemanticDiagnostics(0); + await vi.advanceTimersByTimeAsync(0); + expect(analyze).toHaveBeenCalledWith("SELECT missing", mode); + }); + it.each(graphQueryDatabases)("does not send %s editor text to SQL semantic diagnostics", async (databaseType) => { const { diagnostics, metadata } = createHarness({ databaseType }); diagnostics.scheduleSemanticDiagnostics(0); diff --git a/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts b/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts index 95d4ec9121..db911c2504 100644 --- a/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts +++ b/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts @@ -43,6 +43,8 @@ interface QueryEditorSqlExtensionsOptions { t: Composer["t"]; sqlBehaviorDialect: () => QueryEditorProps["dialect"]; sqlDriverProfile: ComputedRef; + sqlFunctionDatabaseType?: () => DatabaseType | undefined; + sqlSyntaxDialect?: () => QueryEditorProps["syntaxDialect"]; sqlStatementParameterOptions: () => SqlParameterOptions; queryEditorSelectionLanguage: () => "sql" | "text"; cache: { value: ExecutableStatementRangeCache | null }; @@ -305,7 +307,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi const source = currentState.doc.sliceString(windowFrom, cursor); const signature = isGraphQueryDatabase(props.databaseType) ? getGraphFunctionSignatureHelp(source, cursor - windowFrom, props.databaseType, { truncatedPrefix: windowFrom > 0 }) - : getSqlFunctionSignatureHelp(source, cursor - windowFrom, props.databaseType, sqlDriverProfile.value, { truncatedPrefix: windowFrom > 0 }); + : getSqlFunctionSignatureHelp(source, cursor - windowFrom, options.sqlFunctionDatabaseType?.() ?? props.databaseType, sqlDriverProfile.value, { truncatedPrefix: windowFrom > 0 }); if (!signature) return null; return { pos: cursor, @@ -341,13 +343,13 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi ? graphQueryLanguage(props.databaseType) : supportsQueryEditorSqlLanguage(props.databaseType) ? langSql.sql({ - dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value), + dialect: createDbxCodeMirrorSqlDialect(langSql, options.sqlSyntaxDialect?.() ?? props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value), }) : [], // Non-SQL editors (MongoDB shell) keep the SQL grammar for highlighting, so override the // comment marker that toggleLineComment reads from language data. Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }])), - Prec.highest(EditorState.languageData.of(() => queryEditorWordLanguageData(props.databaseType))), + Prec.highest(EditorState.languageData.of(() => queryEditorWordLanguageData(options.sqlFunctionDatabaseType?.() ?? props.databaseType))), // The SQL grammar does not tokenize `//`, so those comments are highlighted by hand. queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : [], ]; diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts b/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts index 35401211b7..aa7f336e44 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts @@ -34,6 +34,7 @@ interface QueryEditorCompletionMetadataOptions { view: ShallowRef; connectionStore: ReturnType; sqlBehaviorDialect: () => QueryEditorProps["dialect"]; + syntaxDialect?: () => QueryEditorProps["syntaxDialect"]; remoteLatencyBudgetMs: number; maxCompletionTables: number; onDemandMinPrefix: number; @@ -121,6 +122,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM const dialect = sqlBehaviorDialect(); const doc = editorState?.doc; const driverProfile = props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined; + const syntaxDialect = options.syntaxDialect?.() ?? props.syntaxDialect; if ( doc && editorCompletionAnalysisCache?.doc === doc && @@ -129,7 +131,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM editorCompletionAnalysisCache.databaseType === props.databaseType && editorCompletionAnalysisCache.dialect === dialect && editorCompletionAnalysisCache.driverProfile === driverProfile && - editorCompletionAnalysisCache.syntaxDialect === props.syntaxDialect + editorCompletionAnalysisCache.syntaxDialect === syntaxDialect ) { return editorCompletionAnalysisCache.result; } @@ -140,7 +142,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM databaseType: props.databaseType, dialect, semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED, - syntaxDialect: props.syntaxDialect, + syntaxDialect, driverProfile, }; const result = editorState && shouldUseQueryEditorLargeDocumentModeForSize(editorState.doc.length, editorState.doc.lines) ? completionAnalysisWorker.analyze(request, editorState) : Promise.resolve(analyzeSqlCompletion(request, editorState)); @@ -152,7 +154,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM databaseType: props.databaseType, dialect, driverProfile, - syntaxDialect: props.syntaxDialect, + syntaxDialect, result, }; } diff --git a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts index 8e74794720..08d2f07cd4 100644 --- a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts +++ b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts @@ -70,6 +70,7 @@ interface QueryEditorDiagnosticsOptions { sqlDriverProfile: ComputedRef; sqlStatementParameterOptions: () => SqlParameterOptions; sqlBehaviorDialect: () => QueryEditorProps["dialect"]; + sqlSyntaxDialect?: () => QueryEditorProps["syntaxDialect"]; queryEditorSelectionLanguage: () => "sql" | "text"; semanticCompletionEnabled: boolean; maxCompletionTables: number; @@ -441,7 +442,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions sqlReferenceAnalysisDialectFor({ databaseType: props.databaseType, identifierQuote: connectionStore.connectionIdentifierQuote(props.connectionId), - fallbackDialect: props.formatDialect ?? props.dialect ?? "generic", + fallbackDialect: props.databaseType === "kingbase" ? (options.sqlSyntaxDialect?.() ?? sqlBehaviorDialect() ?? "postgres") : (props.formatDialect ?? props.dialect ?? "generic"), }), ); if (runId !== semanticDiagnosticRunId) return; @@ -583,7 +584,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions sqlReferenceAnalysisDialectFor({ databaseType: props.databaseType, identifierQuote: connectionStore.connectionIdentifierQuote(props.connectionId), - fallbackDialect: props.formatDialect ?? props.dialect ?? "generic", + fallbackDialect: props.databaseType === "kingbase" ? (options.sqlSyntaxDialect?.() ?? sqlBehaviorDialect() ?? "postgres") : (props.formatDialect ?? props.dialect ?? "generic"), }), ); if (currentView.state.doc !== doc) return []; diff --git a/apps/desktop/src/components/editor/useQueryEditorStatementBoundaries.ts b/apps/desktop/src/components/editor/useQueryEditorStatementBoundaries.ts index d88c536a34..eacee4da5c 100644 --- a/apps/desktop/src/components/editor/useQueryEditorStatementBoundaries.ts +++ b/apps/desktop/src/components/editor/useQueryEditorStatementBoundaries.ts @@ -23,6 +23,7 @@ interface QueryEditorStatementBoundariesOptions { cache: { value: ExecutableStatementRangeCache | null }; runtime: QueryEditorCodeMirrorRuntime; driverProfile?: () => string | undefined; + syntaxDialect?: () => QueryEditorProps["syntaxDialect"]; } export function useQueryEditorStatementBoundaries(options: QueryEditorStatementBoundariesOptions) { @@ -85,15 +86,17 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB function ensureStatementCache(state: EditorState): Promise { if (pendingAnalysis?.doc === state.doc && pendingAnalysis.generation === statementBoundariesGeneration) return pendingAnalysis.result; const generation = statementBoundariesGeneration; - const result = analysisWorker.analyze({ sql: state.doc.toString(), databaseType: props.databaseType, parameterOptions: sqlStatementParameterOptions(), includeFolds: true, syntaxDialect: props.syntaxDialect ?? props.dialect, driverProfile: options.driverProfile?.() }).then((analysis) => { - const currentView = view.value ?? trackedView; - if (!analysis || generation !== statementBoundariesGeneration || currentView?.state.doc !== state.doc) return null; - const resolved = { ...analysis, doc: state.doc }; - installStatementBoundaries(state, resolved, analysis.folds); - const refreshEffect = codeMirrorRuntime.statementBoundariesRefreshEffect; - if (refreshEffect) currentView.dispatch({ effects: refreshEffect.of(null) }); - return resolved; - }); + const result = analysisWorker + .analyze({ sql: state.doc.toString(), databaseType: props.databaseType, parameterOptions: sqlStatementParameterOptions(), includeFolds: true, syntaxDialect: options.syntaxDialect?.() ?? props.syntaxDialect ?? props.dialect, driverProfile: options.driverProfile?.() }) + .then((analysis) => { + const currentView = view.value ?? trackedView; + if (!analysis || generation !== statementBoundariesGeneration || currentView?.state.doc !== state.doc) return null; + const resolved = { ...analysis, doc: state.doc }; + installStatementBoundaries(state, resolved, analysis.folds); + const refreshEffect = codeMirrorRuntime.statementBoundariesRefreshEffect; + if (refreshEffect) currentView.dispatch({ effects: refreshEffect.of(null) }); + return resolved; + }); pendingAnalysis = { doc: state.doc, generation, result }; return result; } diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 31801c9720..bf2b4e8908 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -187,7 +187,7 @@ import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; -import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection, kingbaseSqlDatabaseType } from "@/lib/database/jdbcDialect"; import { supportsTableImport } from "@/lib/database/databaseFeatureSupport"; import { chartableColumnIndexes } from "@/lib/dataGrid/chartData"; import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse"; @@ -485,7 +485,7 @@ const activeTabDimension = computed(() => { return meta && "dimension" in meta ? (meta as VectorCollectionMeta).dimension : undefined; }); -const activeSqlFormatDialect = computed(() => sqlFormatDialectForDbType(activeEffectiveDatabaseType.value)); +const activeSqlFormatDialect = computed(() => sqlFormatDialectForDbType(kingbaseSqlDatabaseType(props.activeConnection) ?? activeEffectiveDatabaseType.value)); const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => codeMirrorSqlDialect(activeEffectiveDatabaseType.value)); const editorSyntaxDialect = computed(() => codeMirrorSqlDialectForConnection(props.activeConnection)); diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index dda3447c46..7684a6f594 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -31,10 +31,39 @@ import { setGaussdbTargetServerType, supportsGaussdbIdentifierQuoteStyle, transferDatabaseTypeForConnection, + codeMirrorSqlDialectForConnection, + sqlSnippetDatabaseTypeForConnection, + kingbaseSqlDatabaseType, } from "@/lib/database/jdbcDialect"; import { supportsTransfer } from "@/lib/database/databaseFeatureSupport"; import { transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds"; +describe("Kingbase compatibility mode routing", () => { + it.each([undefined, "", "future_mode"])("does not infer a mode from a version, URL, or unknown mode (%s)", (mode) => { + const config = { db_type: "kingbase" as const, connection_string: "jdbc:kingbase8://example.test/demo", database_info: { compatibilityMode: mode, productVersion: "KingbaseES V009R001" } }; + expect(kingbaseSqlDatabaseType(config)).toBeUndefined(); + expect(codeMirrorSqlDialectForConnection(config)).toBe("postgres"); + expect(effectiveDatabaseTypeForConnection(config)).toBe("kingbase"); + }); + + it.each(["postgres", "mysql", "oracle", "sqlserver", "jdbc"] as const)("ignores Kingbase mode metadata on a %s connection", (db_type) => { + expect(kingbaseSqlDatabaseType({ db_type, database_info: { compatibilityMode: "oracle" } })).toBeUndefined(); + }); + + it.each([ + ["pg", "postgres", "postgres"], + ["postgresql", "postgres", "postgres"], + ["oracle", "oracle", "oracle"], + ["mysql", "mysql", "mysql"], + ["sqlserver", "sqlserver", "sqlserver"], + ] as const)("keeps the %s editor profile separate from the physical database", (mode, type, syntax) => { + const connection = { db_type: "kingbase" as const, database_info: { compatibilityMode: mode } }; + expect(sqlSnippetDatabaseTypeForConnection(connection)).toBe(type); + expect(codeMirrorSqlDialectForConnection(connection)).toBe(syntax); + expect(effectiveDatabaseTypeForConnection(connection)).toBe("kingbase"); + }); +}); + describe("jdbc dialect inference", () => { it("detects InterSystems IRIS and Caché JDBC connections", () => { expect( diff --git a/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts b/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts index 7da78dd539..6fe4dc1f71 100644 --- a/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts @@ -14,6 +14,15 @@ function nodeNameAt(dialect: langSql.SQLDialect, statement: string, word: string } describe("codemirrorSqlDialect", () => { + it.each([ + ["oracle", "SELECT NVL(\"name\", 'n/a') FROM t", '"name"', "QuotedIdentifier"], + ["mysql", "SELECT IFNULL(`name`, 'n/a') FROM t", "`name`", "QuotedIdentifier"], + ["postgres", "SELECT now(), data #>> '{name}' FROM t", "now", "Builtin"], + ["sqlserver", "SELECT GETDATE() FROM #temp", "#temp", "SpecialVar"], + ] as const)("uses the %s lexer for Kingbase without replacing its physical type", (mode, sql, token, expected) => { + expect(nodeNameAt(createDbxCodeMirrorSqlDialect(langSql, mode, "kingbase"), sql, token)).toBe(expected); + }); + it("keeps common PostgreSQL identifier names out of keyword highlighting", () => { const keywords = new Set(postgresKeywordSyntaxTerms(langSql.PostgreSQL.spec.keywords || "").split(/\s+/)); diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 8c36534c5a..a5005c6b6b 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -244,6 +244,8 @@ export function sqlSnippetDatabaseTypeForConnection(connection?: JdbcDialectConn // ASE uses T-SQL snippets, but mapping it globally to SQL Server would also // enable incompatible SQL Server metadata and pagination behavior. if (isJdbcAseProfile(connection)) return "sqlserver"; + const kingbaseType = kingbaseSqlDatabaseType(connection); + if (kingbaseType) return kingbaseType; return effectiveDatabaseTypeForConnection(connection); } @@ -388,14 +390,17 @@ export function connectionTableSqlSchema(connection: JdbcDialectConnection | und /** Maps a database type to the corresponding CodeMirror SQL dialect name used by QueryEditor and DdlViewDialog. */ export function codeMirrorSqlDialect(dbType: DatabaseType | undefined): "mysql" | "postgres" | "sqlserver" { - if (dbType === "postgres" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss") return "postgres"; + if (dbType === "postgres" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss" || dbType === "kingbase") return "postgres"; if (dbType === "sqlserver") return "sqlserver"; return "mysql"; } export function codeMirrorSqlDialectForConnection(connection?: JdbcDialectConnection): CodeMirrorSqlDialectName { if (isJdbcAseProfile(connection)) return "sqlserver"; + const kingbaseType = kingbaseSqlDatabaseType(connection); + if (kingbaseType) return kingbaseType; const databaseType = effectiveDatabaseTypeForConnection(connection); + if (databaseType === "kingbase") return "postgres"; if (databaseType === "clickhouse") return "clickhouse"; return codeMirrorSqlDialect(databaseType); } diff --git a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts index 151f780d0d..f022643356 100644 --- a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts +++ b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts @@ -11,7 +11,7 @@ import { driverProfileSqlBuiltinTerms } from "@/lib/database/driverProfileExtens // identically to unquoted identifiers. const SQL_DIALECT_LANGUAGE_PROPS = [styleTags({ QuotedIdentifier: tags.name })]; -export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse" | "soql"; +export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "oracle" | "clickhouse" | "soql"; export function supportsQueryEditorSqlLanguage(databaseType?: DatabaseType): boolean { return databaseType !== "qdrant"; @@ -351,6 +351,12 @@ function standardSqlKeywordSyntaxTerms(langSql: CodeMirrorSqlLanguageModule): st function codeMirrorBaseDialect(langSql: CodeMirrorSqlLanguageModule, dialectName: CodeMirrorSqlDialectName, databaseType?: DatabaseType): SQLDialect { if (databaseType) { + if (databaseType === "kingbase") { + if (dialectName === "oracle") return langSql.PLSQL; + if (dialectName === "mysql") return langSql.MySQL; + if (dialectName === "sqlserver") return langSql.MSSQL; + return langSql.PostgreSQL; + } if (databaseType === "clickhouse") return langSql.StandardSQL; if (MYSQL_CODEMIRROR_DATABASE_TYPES.has(databaseType)) return langSql.MySQL; if (POSTGRES_CODEMIRROR_DATABASE_TYPES.has(databaseType)) return langSql.PostgreSQL; @@ -362,6 +368,7 @@ function codeMirrorBaseDialect(langSql: CodeMirrorSqlLanguageModule, dialectName return langSql.StandardSQL; } if (dialectName === "clickhouse") return langSql.StandardSQL; + if (dialectName === "oracle") return langSql.PLSQL; return dialectName === "postgres" ? langSql.PostgreSQL : dialectName === "sqlserver" ? langSql.MSSQL : langSql.MySQL; } diff --git a/packages/app-tests/codemirrorSqlDialect.test.ts b/packages/app-tests/codemirrorSqlDialect.test.ts index b371c8c78a..fe41894e4e 100644 --- a/packages/app-tests/codemirrorSqlDialect.test.ts +++ b/packages/app-tests/codemirrorSqlDialect.test.ts @@ -140,3 +140,13 @@ test("keeps MySQL-compatible double-dash whitespace rules", () => { assert.equal(countParsedNodes(dialect, "-- SELECT 1", "LineComment", "-- SELECT 1"), 1, databaseType); } }); + +test("selects Kingbase comment rules by reported mode while legacy callers default to PG", () => { + assert.equal(codeMirrorSqlDialect("kingbase"), "postgres"); + for (const mode of [undefined, "pg", "oracle", "mysql", "sqlserver"]) { + const syntax = codeMirrorSqlDialectForConnection({ db_type: "kingbase", database_info: { compatibilityMode: mode } }); + const dialect = createDbxCodeMirrorSqlDialect(langSql, syntax, "kingbase"); + assert.equal(countParsedNodes(dialect, "--SELECT 1", "LineComment", "--SELECT 1"), mode === "mysql" ? 0 : 1, String(mode)); + assert.equal(countParsedNodes(dialect, "-- SELECT 1", "LineComment", "-- SELECT 1"), 1, String(mode)); + } +});