From ac4313afee4b5f9490629eeb97fc9de11b64c86b Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 27 May 2026 23:07:47 +0800 Subject: [PATCH] fix(sqlserver): avoid false unknown column diagnostics --- .../src/components/editor/QueryEditor.vue | 12 +- .../desktop/src/lib/sqlSemanticDiagnostics.ts | 4 +- apps/desktop/src/stores/connectionStore.ts | 3 + crates/dbx-core/tests/sql_analysis.rs | 14 +++ .../sqlserverSemanticDiagnostics.test.ts | 104 ++++++++++++++++++ 5 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 packages/app-tests/sqlserverSemanticDiagnostics.test.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index b79e0089c..9d6e5b05e 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -393,6 +393,7 @@ async function ensureColumnsForTable(table: { name: string; schema?: string | nu table.name, table.schema ?? undefined, ); + if (columns.length === 0) return; cachedColumnsByTable.set(cacheKey, columns); } @@ -754,10 +755,12 @@ async function provideSqlCompletions( completionContext.insertSchema, ); if (epoch !== completionEpoch) return null; - const insertKey = completionContext.insertSchema - ? `${completionContext.insertSchema}.${completionContext.insertTable}` - : completionContext.insertTable; - insertColumnsByTable.set(insertKey, insertCols); + if (insertCols.length > 0) { + const insertKey = completionContext.insertSchema + ? `${completionContext.insertSchema}.${completionContext.insertTable}` + : completionContext.insertTable; + insertColumnsByTable.set(insertKey, insertCols); + } } catch { // ignore } @@ -865,6 +868,7 @@ async function provideSqlCompletions( refTable.schema, ); if (epoch !== completionEpoch) return; + if (columns.length === 0) return; cachedColumnsByTable.set(cacheKey, columns); } catch (e) { console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e); diff --git a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts index 22a7bdde4..ba116ac29 100644 --- a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts +++ b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts @@ -119,7 +119,9 @@ function columnsForTable( const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name]; for (const key of keys) { const columns = columnsByTable.get(key) ?? columnsByTable.get(normalizeName(key)); - if (columns) return columns; + // Empty metadata usually means the upstream schema lookup was inconclusive, + // so avoid surfacing a false "unknown column" warning. + if (columns && columns.length > 0) return columns; } return null; } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index b5eec1b94..9da1e33e0 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1419,6 +1419,9 @@ export const useConnectionStore = defineStore("connection", () => { table: string, schema?: string, ): Promise { + if (isSchemaAwareDatabase(connectionId) && !schema) { + return []; + } const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`; if (!completionColumnsCache.value[cacheKey]) { await ensureConnected(connectionId); diff --git a/crates/dbx-core/tests/sql_analysis.rs b/crates/dbx-core/tests/sql_analysis.rs index d9ec95ccb..6adaee276 100644 --- a/crates/dbx-core/tests/sql_analysis.rs +++ b/crates/dbx-core/tests/sql_analysis.rs @@ -21,3 +21,17 @@ fn extracts_unqualified_columns_from_single_table_select() { analysis.columns.iter().map(|column| (column.qualifier.as_deref(), column.name.as_str())).collect(); assert_eq!(columns, vec![(None, "missing"), (None, "id")]); } + +#[test] +fn extracts_unqualified_order_by_columns_for_sqlserver_queries() { + let analysis = + analyze_sql_references("SELECT * FROM Evt_GCM_Qop_Info ORDER BY PDReceiveDatePartInfo DESC", Some("sqlserver")) + .unwrap(); + + assert_eq!(analysis.tables.len(), 1); + assert_eq!(analysis.tables[0].name, "Evt_GCM_Qop_Info"); + + let columns: Vec<_> = + analysis.columns.iter().map(|column| (column.qualifier.as_deref(), column.name.as_str())).collect(); + assert_eq!(columns, vec![(None, "PDReceiveDatePartInfo")]); +} diff --git a/packages/app-tests/sqlserverSemanticDiagnostics.test.ts b/packages/app-tests/sqlserverSemanticDiagnostics.test.ts new file mode 100644 index 000000000..4a5765c12 --- /dev/null +++ b/packages/app-tests/sqlserverSemanticDiagnostics.test.ts @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createPinia, setActivePinia } from "pinia"; +import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; +import { buildSqlSemanticDiagnostics } from "../../apps/desktop/src/lib/sqlSemanticDiagnostics.ts"; +import type { ConnectionConfig, SqlReferenceAnalysis } from "../../apps/desktop/src/types/database.ts"; + +const span = (startColumn: number, endColumn: number) => ({ + start_line: 1, + start_column: startColumn, + end_line: 1, + end_column: endColumn, +}); + +function installMemoryStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }, + }); + return { + restore() { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); + }, + }; +} + +function sqlServerConn(): ConnectionConfig { + return { + id: "sqlserver-1", + name: "SQL Server", + db_type: "sqlserver", + host: "localhost", + port: 1433, + username: "sa", + password: "", + database: "appdb", + }; +} + +test("semantic diagnostics skip warnings when column metadata is inconclusive", () => { + const analysis: SqlReferenceAnalysis = { + tables: [{ name: "Evt_GCM_Qop_Info", span: span(15, 30) }], + columns: [{ name: "PDReceiveDatePartInfo", span: span(40, 60) }], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [{ name: "Evt_GCM_Qop_Info", type: "table" }], + columnsByTable: new Map([["Evt_GCM_Qop_Info", []]]), + }); + + assert.deepEqual(diagnostics, []); +}); + +test("sqlserver completion columns do not query using database name as schema fallback", async () => { + const storage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + const requests: string[] = []; + + globalThis.fetch = (async (input) => { + const url = String(input); + requests.push(url); + if (url === "/api/connection/list") { + return new Response(JSON.stringify([sqlServerConn()]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/layout/sidebar") { + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/connection/connect") { + return new Response(JSON.stringify("ok"), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.startsWith("/api/schema/columns?")) { + throw new Error(`unexpected columns lookup: ${url}`); + } + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const columns = await store.listCompletionColumns("sqlserver-1", "appdb", "Evt_GCM_Qop_Info"); + + assert.deepEqual(columns, []); + assert.equal( + requests.some((url) => url.startsWith("/api/schema/columns?")), + false, + ); + } finally { + globalThis.fetch = originalFetch; + storage.restore(); + } +});