From 95be4fb1953a8b45432b6addb5494faaedff4ffb Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 30 Jun 2026 17:31:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20SQL=20Server=20?= =?UTF-8?q?=E5=88=97=E6=B3=A8=E9=87=8A=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/composables/useDataGridActions.ts | 4 +- .../src/composables/useNavigationTargets.ts | 6 +- apps/desktop/src/lib/jdbcDialect.ts | 6 + apps/desktop/src/stores/queryStore.ts | 5 +- crates/dbx-core/src/db/sqlserver.rs | 57 +++++-- packages/app-tests/queryStore.test.ts | 143 ++++++++++++++++-- 6 files changed, 186 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 746d56e658..a9fcb97b95 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -9,7 +9,7 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta"; import * as api from "@/lib/api"; import type { QueryTab } from "@/types/database"; import { useToast } from "@/composables/useToast"; -import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; +import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/jdbcDialect"; import { uuid } from "@/lib/utils"; import type { DataGridSortMode } from "@/lib/dataGridSort"; @@ -55,7 +55,7 @@ export function useDataGridActions(activeTab: ComputedRef) await connectionStore.ensureConnected(tab.connectionId); console.info("[DBX][reloadData:metadata:ensure-connected:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed() }); const config = connectionStore.getConfig(tab.connectionId); - const querySchema = connectionObjectTreeQuerySchema(config, tab.database, tableMeta.schema); + const querySchema = metadataSchemaForConnection(config, tab.database, tableMeta.schema); console.info("[DBX][reloadData:metadata:get-columns:start]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), schema: querySchema, table: tableMeta.tableName }); const columns = await api.getColumns(tab.connectionId, tab.database, querySchema, tableMeta.tableName); const indexes = await api.listIndexes(tab.connectionId, tab.database, querySchema, tableMeta.tableName).catch(() => []); diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 90b1f9170c..743820e80c 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -1,5 +1,5 @@ import * as api from "@/lib/api"; -import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; +import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/jdbcDialect"; import { buildTableSelectSql } from "@/lib/tableSelectSql"; import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -52,7 +52,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab await connectionStore.ensureConnected(target.connectionId); if (!config) throw new Error("Connection config not found"); const effectiveDbType = effectiveDatabaseTypeForConnection(config); - const querySchema = connectionObjectTreeQuerySchema(config, target.database, target.schema); + const querySchema = metadataSchemaForConnection(config, target.database, target.schema); if (config.db_type === "neo4j") { const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName); const primaryKeys = editableRowIdentifierColumns(effectiveDbType, columns); @@ -157,7 +157,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: for (const tab of matchingDataTabs) { try { const connection = connectionStore.getConfig(tab.connectionId); - const metadataSchema = connectionObjectTreeQuerySchema(connection, tab.database, tab.tableMeta?.schema); + const metadataSchema = metadataSchemaForConnection(connection, tab.database, tab.tableMeta?.schema); const columns = await api.getColumns(tab.connectionId, tab.database, metadataSchema, tab.tableMeta!.tableName); const indexes = await api.listIndexes(tab.connectionId, tab.database, metadataSchema, tab.tableMeta!.tableName).catch(() => []); queryStore.setTableMeta(tab.id, { diff --git a/apps/desktop/src/lib/jdbcDialect.ts b/apps/desktop/src/lib/jdbcDialect.ts index a7b5b74485..5ec3d9aa77 100644 --- a/apps/desktop/src/lib/jdbcDialect.ts +++ b/apps/desktop/src/lib/jdbcDialect.ts @@ -62,6 +62,12 @@ export function connectionObjectTreeQuerySchema(connection: JdbcDialectConnectio return schema || database; } +export function metadataSchemaForConnection(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string { + const type = effectiveDatabaseTypeForConnection(connection); + if (type === "sqlserver") return schema || "dbo"; + return connectionObjectTreeQuerySchema(connection, database, schema); +} + export function connectionObjectTreeNodeSchema(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string | undefined { if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database; if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database; diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 64f88dba55..dd64a42182 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -34,7 +34,7 @@ import { canUseKeylessRowPredicate, editableRowIdentifierColumns } from "@/lib/t import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/tableDataExport"; import { tableMetaForDataTab } from "@/lib/tableDataTabMeta"; import { quoteTableIdentifier } from "@/lib/tableSelectSql"; -import { connectionUsesDatabaseObjectTreeMode, connectionUsesSchemaExecutionContext, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; +import { connectionUsesDatabaseObjectTreeMode, connectionUsesSchemaExecutionContext, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/jdbcDialect"; import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout"; import { sortDataGridRows, type DataGridSortDirection } from "@/lib/dataGridSort"; import { normalizeResultPageSize } from "@/lib/paginationPageSize"; @@ -1288,7 +1288,8 @@ export const useQueryStore = defineStore("query", () => { if (dbType === "postgres" || dbType === "kwdb") schema = "public"; else schema = ""; } - const metadataSchema = normalizeOracleLikeMetadataIdentifier(dbType, schema || undefined, analysis.schema ? analysis.schemaQuoted : false) || ""; + const resolvedSchema = metadataSchemaForConnection(conn, tab.database, schema || undefined); + const metadataSchema = normalizeOracleLikeMetadataIdentifier(dbType, resolvedSchema || undefined, analysis.schema ? analysis.schemaQuoted : false) || ""; const metadataTableName = normalizeOracleLikeMetadataIdentifier(dbType, analysis.tableName, analysis.tableNameQuoted)!; const metadataAnalysis = normalizeOracleLikeQueryAnalysis(dbType, analysis, metadataSchema || undefined, metadataTableName); diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index dc218d3a39..b53e5e86b4 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -1366,19 +1366,36 @@ fn sqlserver_columns_sql(schema: &str, table: &str) -> String { let s = schema.replace('\'', "''"); let t = table.replace('\'', "''"); format!( - "SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, \ - CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \ - c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH, c.DATETIME_PRECISION, \ - ident.extra AS COLUMN_EXTRA, \ + "SELECT c.name AS COLUMN_NAME, \ + ty.name AS DATA_TYPE, \ + CASE WHEN c.is_nullable = 1 THEN 'YES' ELSE 'NO' END AS IS_NULLABLE, \ + dc.definition AS COLUMN_DEFAULT, \ + CASE WHEN pk.column_id IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \ + CONVERT(INT, c.precision) AS NUMERIC_PRECISION, \ + CONVERT(INT, c.scale) AS NUMERIC_SCALE, \ + CASE \ + WHEN ty.name IN ('nchar','nvarchar') AND c.max_length > 0 THEN CONVERT(INT, c.max_length / 2) \ + WHEN c.max_length = -1 THEN -1 \ + ELSE CONVERT(INT, c.max_length) \ + END AS CHARACTER_MAXIMUM_LENGTH, \ + CONVERT(INT, c.scale) AS DATETIME_PRECISION, \ + CASE WHEN ic.column_id IS NOT NULL THEN 'identity(' + CONVERT(VARCHAR(38), ic.seed_value) + ',' + CONVERT(VARCHAR(38), ic.increment_value) + ')' ELSE NULL END AS COLUMN_EXTRA, \ ep.value AS COLUMN_COMMENT \ - FROM INFORMATION_SCHEMA.COLUMNS c \ - LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu \ - ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME \ - AND kcu.CONSTRAINT_NAME IN (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_TYPE = 'PRIMARY KEY' AND TABLE_SCHEMA = '{s}' AND TABLE_NAME = '{t}') \ - OUTER APPLY (SELECT 'identity(' + CONVERT(VARCHAR(38), ic.seed_value) + ',' + CONVERT(VARCHAR(38), ic.increment_value) + ')' AS extra FROM sys.identity_columns ic WHERE ic.object_id = OBJECT_ID(QUOTENAME('{s}') + '.' + QUOTENAME('{t}')) AND ic.name = c.COLUMN_NAME) ident \ - OUTER APPLY (SELECT CAST(ep.value AS NVARCHAR(MAX)) AS value FROM sys.extended_properties ep WHERE ep.major_id = OBJECT_ID(QUOTENAME('{s}') + '.' + QUOTENAME('{t}')) AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(QUOTENAME('{s}') + '.' + QUOTENAME('{t}')), c.COLUMN_NAME, 'ColumnId') AND ep.name = N'MS_Description') ep \ - WHERE c.TABLE_SCHEMA = '{s}' AND c.TABLE_NAME = '{t}' \ - ORDER BY c.ORDINAL_POSITION" + FROM sys.objects o \ + JOIN sys.schemas s ON s.schema_id = o.schema_id \ + JOIN sys.columns c ON c.object_id = o.object_id \ + JOIN sys.types ty ON ty.user_type_id = c.user_type_id \ + LEFT JOIN sys.default_constraints dc ON dc.object_id = c.default_object_id \ + LEFT JOIN sys.identity_columns ic ON ic.object_id = c.object_id AND ic.column_id = c.column_id \ + LEFT JOIN ( \ + SELECT ic.object_id, ic.column_id \ + FROM sys.indexes i \ + JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \ + WHERE i.is_primary_key = 1 \ + ) pk ON pk.object_id = c.object_id AND pk.column_id = c.column_id \ + OUTER APPLY (SELECT CAST(ep.value AS NVARCHAR(MAX)) AS value FROM sys.extended_properties ep WHERE ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = N'MS_Description') ep \ + WHERE s.name = '{s}' AND o.name = '{t}' AND o.type IN ('U','V') \ + ORDER BY c.column_id" ) } @@ -1875,6 +1892,18 @@ mod tests { assert!(sql.contains("MS_Description")); } + #[test] + fn sqlserver_columns_sql_reads_column_comment_by_column_id() { + let sql = sqlserver_columns_sql("dbo", "orders"); + + assert!(sql.contains("FROM sys.objects o")); + assert!(sql.contains("JOIN sys.columns c ON c.object_id = o.object_id")); + assert!(sql.contains("sys.extended_properties ep")); + assert!(sql.contains("ep.major_id = c.object_id")); + assert!(sql.contains("ep.minor_id = c.column_id")); + assert!(sql.contains("MS_Description")); + } + #[test] fn sqlserver_table_comment_sql_queries_extended_properties() { let sql = sqlserver_table_comment_sql("dbo", "users"); @@ -1891,8 +1920,8 @@ mod tests { let columns_sql = sqlserver_columns_sql("d'bo", "t'able"); let indexes_sql = sqlserver_indexes_sql("d'bo", "t'able"); - assert!(columns_sql.contains("TABLE_SCHEMA = 'd''bo'")); - assert!(columns_sql.contains("TABLE_NAME = 't''able'")); + assert!(columns_sql.contains("s.name = 'd''bo'")); + assert!(columns_sql.contains("o.name = 't''able'")); assert!(columns_sql.contains("sys.identity_columns")); assert!(indexes_sql.contains("OBJECT_ID('d''bo.t''able')")); } diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index b1d73c8e1b..42dd7dda43 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -47,6 +47,15 @@ function oracleConn(id: string): ConnectionConfig { }; } +function sqlServerConn(id: string): ConnectionConfig { + return { + ...conn(id), + db_type: "sqlserver", + port: 1433, + username: "sa", + }; +} + function withConnectionHealthMock(handler: typeof fetch): typeof fetch { return async (input, init) => { if (String(input) === "/api/connection/check-health") { @@ -826,6 +835,106 @@ test("normalizes unquoted Oracle query identifiers before loading editable metad } }); +test("uses dbo as SQL Server metadata schema when query omits schema", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const columnRequests: Array<{ schema: string | null; table: string | null }> = []; + + connectionStore.addEphemeralConnection(sqlServerConn("sqlserver-1")); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/execute-multi") { + return new Response( + JSON.stringify([ + { + columns: ["ID", "NAME"], + rows: [[1, "Ada"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/analyze-editability") { + const body = JSON.parse(String(init?.body ?? "{}")); + assert.equal(body.sql, "select id, name from users"); + return new Response( + JSON.stringify({ + editable: true, + analysis: { + schema: undefined, + schemaQuoted: false, + tableName: "users", + tableNameQuoted: false, + tableAlias: undefined, + selectStar: false, + columns: [ + { sourceName: "id", sourceNameQuoted: false, resultName: "ID", expression: "id" }, + { sourceName: "name", sourceNameQuoted: false, resultName: "NAME", expression: "name" }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url.startsWith("/api/schema/columns?")) { + const params = new URL(url, "http://localhost").searchParams; + columnRequests.push({ schema: params.get("schema"), table: params.get("table") }); + return new Response( + JSON.stringify([ + { + name: "ID", + data_type: "int", + is_nullable: false, + column_default: null, + is_primary_key: true, + extra: null, + comment: "编号", + }, + { + name: "NAME", + data_type: "nvarchar(100)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + comment: "姓名", + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("sqlserver-1", "app", "Query 1", "query"); + await store.executeTabSql(tabId, "select id, name from users"); + + const tab = store.tabs.find((item) => item.id === tabId); + await waitFor(() => columnRequests.length > 0 && tab?.tableMeta?.tableName === "users"); + assert.deepEqual(columnRequests, [{ schema: "dbo", table: "users" }]); + assert.equal(tab?.tableMeta?.schema, "dbo"); + assert.equal(tab?.tableMeta?.columns[0]?.comment, "编号"); + assert.equal(tab?.tableMeta?.columns[1]?.comment, "姓名"); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("evicting cached tab results releases multi-result payloads and sessions", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); @@ -2500,14 +2609,14 @@ test("query results keep readable table source labels with active database conte { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }, { columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }, ] - : currentSql === "select * from public.users" - ? [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }] - : currentSql === "select u.id from users u join orders o on o.user_id = u.id" + : currentSql === "select * from public.users" ? [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }] - : [ - { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }, - { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }, - ]; + : currentSql === "select u.id from users u join orders o on o.user_id = u.id" + ? [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }] + : [ + { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }, + { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }, + ]; return new Response(JSON.stringify(results), { status: 200, headers: { "Content-Type": "application/json" } }); } if (url === "/api/query/analyze-editability") { @@ -2522,15 +2631,21 @@ test("query results keep readable table source labels with active database conte try { await store.executeTabSql(tabId, "select * from users; select * from orders"); let tab = store.tabs.find((item) => item.id === tabId); - assert.deepEqual(tab?.results?.map((result) => result.sourceLabel), ["db.users", "db.orders"]); - assert.deepEqual(tab?.results?.map((result) => result.sourceStatement), ["select * from users", "select * from orders"]); - - await store.executeTabSql( - defaultDatabaseTabId, - "SELECT *\nFROM apis AS ap\nLIMIT 10;\n\nSELECT *\nFROM menus AS mn\nLIMIT 10;", + assert.deepEqual( + tab?.results?.map((result) => result.sourceLabel), + ["db.users", "db.orders"], ); + assert.deepEqual( + tab?.results?.map((result) => result.sourceStatement), + ["select * from users", "select * from orders"], + ); + + await store.executeTabSql(defaultDatabaseTabId, "SELECT *\nFROM apis AS ap\nLIMIT 10;\n\nSELECT *\nFROM menus AS mn\nLIMIT 10;"); tab = store.tabs.find((item) => item.id === defaultDatabaseTabId); - assert.deepEqual(tab?.results?.map((result) => result.sourceLabel), ["aaa.apis", "aaa.menus"]); + assert.deepEqual( + tab?.results?.map((result) => result.sourceLabel), + ["aaa.apis", "aaa.menus"], + ); await store.executeTabSql(tabId, "select * from public.users"); tab = store.tabs.find((item) => item.id === tabId);