diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 01d577ec74..fad2fe83e1 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -70,7 +70,7 @@ import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, Inde import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort"; import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities"; import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport"; -import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, objectListSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation"; import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; @@ -2817,6 +2817,10 @@ watch([() => props.initialEventName, () => props.initialEventOpenRequestId, () = openInitialEventIfNeeded(); }); +// 达梦的对象列表 SQL 固定 `WHERE o.OWNER = ?`(DamengAgent),空 schema 必然 +// 匹配 0 行;schema 解析失败(loadSchemas 抛错或返回空列表)时标签会整体空白 +// (#8301)。经 objectListSchemaForConnection 回退到连接用户名(大写),仅限 +// 达梦;oracle/oceanbase-oracle 维持空 schema 由后端解析当前 schema。 async function loadObjects(options?: { allowCached?: boolean; preserveExistingRows?: boolean }) { error.value = ""; // A new load supersedes any in-flight one, so reset the transient refresh flags @@ -2830,7 +2834,7 @@ async function loadObjects(options?: { allowCached?: boolean; preserveExistingRo // same-instance refresh) — a failure then keeps the rows and raises a non-blocking // banner instead of replacing the whole list with a full-area error. let scaffoldRefresh = false; - const schema = needsSchema.value ? selectedSchema.value || "" : props.database; + const schema = needsSchema.value ? objectListSchemaForConnection(props.connection, selectedSchema.value) : props.database; const request = objectBrowserRowsLoadGuard.start(objectBrowserRowsCacheScope(schema)); const cacheWriteToken = createObjectBrowserRowsCacheWriteToken(request.scope); @@ -2943,7 +2947,15 @@ function normalizeStatisticNumber(value: number | null | undefined): number | nu async function reload(options?: { allowCachedObjects?: boolean; contextEpoch?: number; preserveExistingRows?: boolean }) { const epoch = options?.contextEpoch ?? objectBrowserRowsLoadGuard.invalidate(); - if (!(await loadSchemas(epoch))) return; + try { + if (!(await loadSchemas(epoch))) return; + } catch (e) { + // listSchemas 失败(如共享连接上 SET SCHEMA 后的二次元数据请求)时, + // loadSchemas 不会触碰 selectedSchema,这里也不清空它:保留 props.schema + // 或用户已选值,继续尝试加载对象列表(达梦走用户名回退),避免标签静默 + // 空白 (#8301)。对象列表若同样失败,loadObjects 自身的 catch 会展示错误。 + console.warn("[ObjectBrowser] loadSchemas failed, keeping selected schema for", props.connection.id, e); + } if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return; await loadObjects({ allowCached: options?.allowCachedObjects, preserveExistingRows: options?.preserveExistingRows }); } diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.schemaFallback.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.schemaFallback.spec.ts new file mode 100644 index 0000000000..019f3c99eb --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.schemaFallback.spec.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8"); + +function functionBody(name: string, source: string): string { + const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source); + if (!signature) throw new Error(`Missing function ${name}`); + const bodyStart = signature.index + signature[0].length; + let depth = 1; + for (let index = bodyStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") depth -= 1; + if (depth === 0) return source.slice(bodyStart, index); + } + throw new Error(`Unclosed function ${name}`); +} + +describe("ObjectBrowser object-list schema fallback (#8301)", () => { + it("resolves the listObjects schema through the shared Dameng-aware helper", () => { + const loadObjects = functionBody("loadObjects", objectBrowserSource); + expect(loadObjects).toContain("needsSchema.value ? objectListSchemaForConnection(props.connection, selectedSchema.value) : props.database"); + expect(objectBrowserSource).toContain('objectListSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"'); + }); + + it("routes the schema through the request scope used by listObjects", () => { + const loadSqlObjectBrowserRows = functionBody("loadSqlObjectBrowserRows", objectBrowserSource); + expect(loadSqlObjectBrowserRows).toContain("api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema"); + const loadObjects = functionBody("loadObjects", objectBrowserSource); + expect(loadObjects).toContain("objectBrowserRowsCacheScope(schema)"); + }); + + it("keeps the reload chain alive when loadSchemas rejects", () => { + const reload = functionBody("reload", objectBrowserSource); + expect(reload).toMatch(/try \{\s*if \(!\(await loadSchemas\(epoch\)\)\) return;\s*\} catch/); + const catchClause = reload.slice(reload.indexOf("} catch")); + // The failure path must not clear the selection (props.schema / current value) + // and must still load objects — Dameng then falls back to the username. + expect(catchClause).not.toContain("selectedSchema.value ="); + expect(catchClause).toContain("console.warn"); + expect(reload.lastIndexOf("await loadObjects")).toBeGreaterThan(reload.indexOf("} catch")); + }); + + it("leaves the selected schema untouched when listSchemas itself rejects", () => { + const loadSchemas = functionBody("loadSchemas", objectBrowserSource); + // Every selectedSchema assignment in the try block sits behind the awaited + // listSchemas call, so a rejection keeps the current selection intact. + const tryStart = loadSchemas.indexOf("try {"); + const apiCall = loadSchemas.indexOf("await api.listSchemas", tryStart); + const firstAssignment = loadSchemas.indexOf("selectedSchema.value =", apiCall); + expect(apiCall).toBeGreaterThan(tryStart); + expect(firstAssignment).toBeGreaterThan(apiCall); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index d15626fd51..8bb3334924 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -20,6 +20,7 @@ import { gaussdbTargetServerType, inferJdbcDialect, metadataSchemaForConnection, + objectListSchemaForConnection, setGaussdbConnectionMode, setGaussdbCountQueryDop, setGaussdbIdentifierQuoteStyle, @@ -488,3 +489,28 @@ describe("object tree node schema", () => { expect(connectionDatabaseMetadataSchema({ db_type: "spanner" }, "projects/p/instances/i/databases/db", "public")).toBe("public"); }); }); + +describe("object list schema", () => { + it("falls back to the uppercased Dameng connection username when no schema is selected", () => { + // Dameng's object SQL filters on a fixed WHERE o.OWNER = ?, so a blank schema + // matches nothing and the object tab renders empty (#8301). + expect(objectListSchemaForConnection({ db_type: "dameng", username: "sales_app" })).toBe("SALES_APP"); + }); + + it("applies the Dameng fallback to generic jdbc:dm connections", () => { + expect(objectListSchemaForConnection({ db_type: "jdbc", connection_string: "jdbc:dm://localhost:5236", username: "sysdba" })).toBe("SYSDBA"); + }); + + it("keeps an explicitly selected schema for Dameng", () => { + expect(objectListSchemaForConnection({ db_type: "dameng", username: "sales_app" }, "OTHER_SCHEMA")).toBe("OTHER_SCHEMA"); + }); + + it("returns a blank schema for Dameng when the connection has no usable username", () => { + expect(objectListSchemaForConnection({ db_type: "dameng" })).toBe(""); + expect(objectListSchemaForConnection({ db_type: "dameng", username: " " })).toBe(""); + }); + + it.each(["oracle", "oceanbase-oracle", "postgres", "mysql"] as const)("does not fall back to the username for %s", (dbType) => { + expect(objectListSchemaForConnection({ db_type: dbType, username: "app_user" })).toBe(""); + }); +}); diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 9823d2a1e7..51acd5fb2e 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -2,7 +2,7 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { isSchemaAware, spannerObjectTreeSchema, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; -type JdbcDialectConnection = Partial>; +type JdbcDialectConnection = Partial>; export type GaussdbIdentifierQuoteStyle = "auto" | "double" | "backtick"; export type GaussdbConnectionMode = "native" | "m-jdbc"; @@ -264,6 +264,21 @@ export function connectionObjectTreeQuerySchema(connection: JdbcDialectConnectio return schema || database; } +/** + * Schema sent with object-list (listObjects) requests. Dameng's object SQL + * filters on a fixed `WHERE o.OWNER = ?`, so a blank schema matches nothing and + * the object tab renders empty when the schema could not be resolved (#8301). + * Mirror the completion path (connectionStore.listCompletionColumns) and fall + * back to the connection username — uppercased, the Oracle-family storage + * convention for unquoted Dameng users. Other Oracle-family types keep the + * blank schema so the backend resolves the current schema itself. + */ +export function objectListSchemaForConnection(connection: JdbcDialectConnection | undefined, schema?: string): string { + if (schema) return schema; + if (effectiveDatabaseTypeForConnection(connection) !== "dameng") return ""; + return connection?.username?.trim().toUpperCase() || ""; +} + /** * Metadata schema for query paths that treat the database name as the schema when a * connection exposes no schema level (MySQL, HBase, and the other flat engines).