fix: 修复 SQL Server 列注释显示

This commit is contained in:
zipg authored and GitHub committed 2026-06-30 17:31:57 +08:00
1 parent 5d3e692b6b
commit 95be4fb195
6 files changed
+186 -35

No files matched your search

@@ -9,7 +9,7 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import * as api from "@/lib/api"; import * as api from "@/lib/api";
import type { QueryTab } from "@/types/database"; import type { QueryTab } from "@/types/database";
import { useToast } from "@/composables/useToast"; import { useToast } from "@/composables/useToast";
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/jdbcDialect";
import { uuid } from "@/lib/utils"; import { uuid } from "@/lib/utils";
import type { DataGridSortMode } from "@/lib/dataGridSort"; import type { DataGridSortMode } from "@/lib/dataGridSort";
@@ -55,7 +55,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
await connectionStore.ensureConnected(tab.connectionId); await connectionStore.ensureConnected(tab.connectionId);
console.info("[DBX][reloadData:metadata:ensure-connected:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed() }); console.info("[DBX][reloadData:metadata:ensure-connected:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed() });
const config = connectionStore.getConfig(tab.connectionId); 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 }); 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 columns = await api.getColumns(tab.connectionId, tab.database, querySchema, tableMeta.tableName);
const indexes = await api.listIndexes(tab.connectionId, tab.database, querySchema, tableMeta.tableName).catch(() => []); const indexes = await api.listIndexes(tab.connectionId, tab.database, querySchema, tableMeta.tableName).catch(() => []);
@@ -1,5 +1,5 @@
import * as api from "@/lib/api"; import * as api from "@/lib/api";
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/jdbcDialect";
import { buildTableSelectSql } from "@/lib/tableSelectSql"; import { buildTableSelectSql } from "@/lib/tableSelectSql";
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing"; import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { useConnectionStore } from "@/stores/connectionStore"; import { useConnectionStore } from "@/stores/connectionStore";
@@ -52,7 +52,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
await connectionStore.ensureConnected(target.connectionId); await connectionStore.ensureConnected(target.connectionId);
if (!config) throw new Error("Connection config not found"); if (!config) throw new Error("Connection config not found");
const effectiveDbType = effectiveDatabaseTypeForConnection(config); 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") { if (config.db_type === "neo4j") {
const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName); const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName);
const primaryKeys = editableRowIdentifierColumns(effectiveDbType, columns); const primaryKeys = editableRowIdentifierColumns(effectiveDbType, columns);
@@ -157,7 +157,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
for (const tab of matchingDataTabs) { for (const tab of matchingDataTabs) {
try { try {
const connection = connectionStore.getConfig(tab.connectionId); 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 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(() => []); const indexes = await api.listIndexes(tab.connectionId, tab.database, metadataSchema, tab.tableMeta!.tableName).catch(() => []);
queryStore.setTableMeta(tab.id, { queryStore.setTableMeta(tab.id, {
+6
View File
@@ -62,6 +62,12 @@ export function connectionObjectTreeQuerySchema(connection: JdbcDialectConnectio
return schema || database; 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 { 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;
if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database; if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database;
+3 -2
View File
@@ -34,7 +34,7 @@ import { canUseKeylessRowPredicate, editableRowIdentifierColumns } from "@/lib/t
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/tableDataExport"; import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/tableDataExport";
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta"; import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { quoteTableIdentifier } from "@/lib/tableSelectSql"; 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 { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { sortDataGridRows, type DataGridSortDirection } from "@/lib/dataGridSort"; import { sortDataGridRows, type DataGridSortDirection } from "@/lib/dataGridSort";
import { normalizeResultPageSize } from "@/lib/paginationPageSize"; import { normalizeResultPageSize } from "@/lib/paginationPageSize";
@@ -1288,7 +1288,8 @@ export const useQueryStore = defineStore("query", () => {
if (dbType === "postgres" || dbType === "kwdb") schema = "public"; if (dbType === "postgres" || dbType === "kwdb") schema = "public";
else schema = ""; 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 metadataTableName = normalizeOracleLikeMetadataIdentifier(dbType, analysis.tableName, analysis.tableNameQuoted)!;
const metadataAnalysis = normalizeOracleLikeQueryAnalysis(dbType, analysis, metadataSchema || undefined, metadataTableName); const metadataAnalysis = normalizeOracleLikeQueryAnalysis(dbType, analysis, metadataSchema || undefined, metadataTableName);
+43 -14
View File
@@ -1366,19 +1366,36 @@ fn sqlserver_columns_sql(schema: &str, table: &str) -> String {
let s = schema.replace('\'', "''"); let s = schema.replace('\'', "''");
let t = table.replace('\'', "''"); let t = table.replace('\'', "''");
format!( format!(
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, \ "SELECT c.name AS COLUMN_NAME, \
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \ ty.name AS DATA_TYPE, \
c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH, c.DATETIME_PRECISION, \ CASE WHEN c.is_nullable = 1 THEN 'YES' ELSE 'NO' END AS IS_NULLABLE, \
ident.extra AS COLUMN_EXTRA, \ 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 \ ep.value AS COLUMN_COMMENT \
FROM INFORMATION_SCHEMA.COLUMNS c \ FROM sys.objects o \
LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu \ JOIN sys.schemas s ON s.schema_id = o.schema_id \
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME \ JOIN sys.columns c ON c.object_id = o.object_id \
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}') \ JOIN sys.types ty ON ty.user_type_id = c.user_type_id \
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 \ LEFT JOIN sys.default_constraints dc ON dc.object_id = c.default_object_id \
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 \ LEFT JOIN sys.identity_columns ic ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
WHERE c.TABLE_SCHEMA = '{s}' AND c.TABLE_NAME = '{t}' \ LEFT JOIN ( \
ORDER BY c.ORDINAL_POSITION" 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")); 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] #[test]
fn sqlserver_table_comment_sql_queries_extended_properties() { fn sqlserver_table_comment_sql_queries_extended_properties() {
let sql = sqlserver_table_comment_sql("dbo", "users"); 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 columns_sql = sqlserver_columns_sql("d'bo", "t'able");
let indexes_sql = sqlserver_indexes_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("s.name = 'd''bo'"));
assert!(columns_sql.contains("TABLE_NAME = 't''able'")); assert!(columns_sql.contains("o.name = 't''able'"));
assert!(columns_sql.contains("sys.identity_columns")); assert!(columns_sql.contains("sys.identity_columns"));
assert!(indexes_sql.contains("OBJECT_ID('d''bo.t''able')")); assert!(indexes_sql.contains("OBJECT_ID('d''bo.t''able')"));
} }
+129 -14
View File
@@ -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 { function withConnectionHealthMock(handler: typeof fetch): typeof fetch {
return async (input, init) => { return async (input, init) => {
if (String(input) === "/api/connection/check-health") { 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 () => { test("evicting cached tab results releases multi-result payloads and sessions", async () => {
const restoreStorage = installMemoryStorage(); const restoreStorage = installMemoryStorage();
setActivePinia(createPinia()); 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: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }, { columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
] ]
: currentSql === "select * from public.users" : 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"
? [{ columns: ["id"], rows: [[1]], affected_rows: 0, 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: [], rows: [], affected_rows: 1, execution_time_ms: 1 }, ? [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]
{ 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" } }); return new Response(JSON.stringify(results), { status: 200, headers: { "Content-Type": "application/json" } });
} }
if (url === "/api/query/analyze-editability") { if (url === "/api/query/analyze-editability") {
@@ -2522,15 +2631,21 @@ test("query results keep readable table source labels with active database conte
try { try {
await store.executeTabSql(tabId, "select * from users; select * from orders"); await store.executeTabSql(tabId, "select * from users; select * from orders");
let tab = store.tabs.find((item) => item.id === tabId); let tab = store.tabs.find((item) => item.id === tabId);
assert.deepEqual(tab?.results?.map((result) => result.sourceLabel), ["db.users", "db.orders"]); assert.deepEqual(
assert.deepEqual(tab?.results?.map((result) => result.sourceStatement), ["select * from users", "select * from orders"]); tab?.results?.map((result) => result.sourceLabel),
["db.users", "db.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.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); 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"); await store.executeTabSql(tabId, "select * from public.users");
tab = store.tabs.find((item) => item.id === tabId); tab = store.tabs.find((item) => item.id === tabId);