From 5bfb07eef0f8793deca2333b0de28a2b5193c05b Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:55:54 +0800 Subject: [PATCH] fix(xugu): place public synonyms after schemas --- .../src/lib/__tests__/table/tableTree.spec.ts | 12 +++++- .../__tests__/xuguPublicSynonyms.spec.ts | 19 ++++++++- .../src/lib/sidebar/xuguPublicSynonyms.ts | 21 ++++++++++ apps/desktop/src/stores/connectionStore.ts | 41 ++++++++++++++----- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts index 4f4eb7e571..5fb5a6c4df 100644 --- a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { appendTableTreeLoadMoreNode, buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, mergeTableInfosIntoObjects, mergeTableTreePageChildren, tablePartitionGroups, withoutTableTreeLoadMoreNodes } from "@/lib/table/tableTree"; +import { appendTableTreeLoadMoreNode, buildGroupedObjectTreeNodes, buildObjectGroupPlaceholderNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, mergeTableInfosIntoObjects, mergeTableTreePageChildren, tablePartitionGroups, withoutTableTreeLoadMoreNodes } from "@/lib/table/tableTree"; import type { ObjectInfo, TableInfo, TreeNode } from "@/types/database"; const context = { @@ -117,6 +117,16 @@ describe("PostgreSQL custom type metadata", () => { }); describe("programmable database objects", () => { + it("renders only the synonym group for the Xugu public-synonym scope", () => { + const groups = buildObjectGroupPlaceholderNodes({ + ...context, + schema: "\u0000DBX_XUGU_PUBLIC_SYNONYMS", + objectTypes: ["SYNONYM"], + }); + + expect(groups).toEqual([expect.objectContaining({ type: "group-synonyms", label: "tree.synonyms" })]); + }); + it("coalesces Xugu package specification and body into one top-level node", () => { const objects: ObjectInfo[] = [ { name: "DBX_UI_PKG", object_type: "PACKAGE", schema: "APP", valid: true }, diff --git a/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts index 15b588ccce..cc97992814 100644 --- a/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts +++ b/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isXuguPublicSynonymScope, isXuguPublicSynonymTreeNode, XUGU_PUBLIC_SYNONYM_SCOPE, xuguSchemaDisplayName } from "@/lib/sidebar/xuguPublicSynonyms"; +import { isXuguPublicSynonymScope, isXuguPublicSynonymTreeNode, sortXuguSchemaInfos, XUGU_PUBLIC_SYNONYM_SCOPE, xuguSchemaDisplayName } from "@/lib/sidebar/xuguPublicSynonyms"; +import { compareSidebarNames } from "@/lib/database/databaseTree"; describe("Xugu public synonym scope", () => { it("uses an independent protocol identity", () => { @@ -22,4 +23,20 @@ describe("Xugu public synonym scope", () => { expect(isXuguPublicSynonymTreeNode("postgres", "schema", XUGU_PUBLIC_SYNONYM_SCOPE)).toBe(false); expect(isXuguPublicSynonymTreeNode("xugu", "database", XUGU_PUBLIC_SYNONYM_SCOPE)).toBe(false); }); + + it("keeps the public synonym scope after every real schema", () => { + const schemas = [ + { name: XUGU_PUBLIC_SYNONYM_SCOPE, comment: null }, + { name: "SYSDBA", comment: null }, + { name: "AppSchema", comment: null }, + { name: "GUEST", comment: null }, + ]; + + expect(sortXuguSchemaInfos(schemas, compareSidebarNames).map((schema) => schema.name)).toEqual(["AppSchema", "GUEST", "SYSDBA", XUGU_PUBLIC_SYNONYM_SCOPE]); + }); + + it("does not treat the real GUEST schema as a public-synonym scope", () => { + const schemas = [{ name: "GUEST" }, { name: XUGU_PUBLIC_SYNONYM_SCOPE }]; + expect(sortXuguSchemaInfos(schemas, compareSidebarNames).map((schema) => schema.name)).toEqual(["GUEST", XUGU_PUBLIC_SYNONYM_SCOPE]); + }); }); diff --git a/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts b/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts index 0957937659..d1520e6e9c 100644 --- a/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts +++ b/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts @@ -6,6 +6,27 @@ export const XUGU_PUBLIC_SYNONYM_SCOPE = "\u0000DBX_XUGU_PUBLIC_SYNONYMS"; export const XUGU_PUBLIC_SYNONYM_SCOPE_LABEL = "Public synonyms"; +/** + * Keep the synthetic public-synonym scope visually separate from real + * schemas. The scope is deliberately sorted after every real schema so a + * database-global namespace cannot be mistaken for an ordinary owner/schema. + */ +export function sortXuguSchemaInfos(schemas: readonly T[], compareNames: (left: string, right: string) => number): T[] { + const realSchemas: T[] = []; + const publicSynonymScopes: T[] = []; + + for (const schema of schemas) { + if (isXuguPublicSynonymScope(schema.name)) { + publicSynonymScopes.push(schema); + } else { + realSchemas.push(schema); + } + } + + realSchemas.sort((left, right) => compareNames(left.name, right.name)); + return [...realSchemas, ...publicSynonymScopes]; +} + export function isXuguPublicSynonymScope(schema: string | null | undefined): boolean { return schema === XUGU_PUBLIC_SYNONYM_SCOPE; } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index da1e3a46e6..87d1913df1 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -119,7 +119,7 @@ import { appendConnectionErrorHints, isMysqlMissingPasswordFailure } from "@/lib import { connectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; -import { isXuguPublicSynonymScope, xuguSchemaDisplayName, XUGU_PUBLIC_SYNONYM_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms"; +import { isXuguPublicSynonymScope, sortXuguSchemaInfos, xuguSchemaDisplayName, XUGU_PUBLIC_SYNONYM_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms"; import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility"; import { buildPackageMemberNodes, markPackageNodesExpandable } from "@/lib/sidebar/packageMembers"; import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo"; @@ -1543,6 +1543,18 @@ export const useConnectionStore = defineStore("connection", () => { return sidebarObjectKindsForDatabase(dbType); } + function sidebarObjectTypesForScope(config: ConnectionConfig | undefined, schema?: string): DatabaseObjectTreeKind[] { + if (config?.db_type === "xugu" && isXuguPublicSynonymScope(schema)) { + return ["SYNONYM"]; + } + return supportedSidebarObjectTypes(config); + } + + function objectTreeCacheVersion(config: ConnectionConfig | undefined, schema: string | undefined, baseVersion: string): string { + const scopedVersion = config?.db_type === "xugu" && isXuguPublicSynonymScope(schema) ? `${baseVersion}-public-synonyms` : baseVersion; + return ownerAwareMetadataCacheVersion(config, scopedVersion); + } + function sortSidebarSchemaInfos(schemas: readonly SchemaInfo[]): SchemaInfo[] { const byName = new Map(); for (const schema of schemas) { @@ -1550,7 +1562,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!name) continue; byName.set(name, { name, comment: schema.comment ?? null }); } - return sortSidebarNames([...byName.keys()]).map((name) => byName.get(name)!); + return sortXuguSchemaInfos([...byName.values()], compareSidebarNames); } function buildExtensionManagementNode(connectionId: string, database: string): TreeNode { @@ -1570,7 +1582,7 @@ export const useConnectionStore = defineStore("connection", () => { const objectTreeProfileCacheKey = driverProfileObjectTreeProfileForConnection(config)?.cacheKey; // objects-v8: object-group listing SQL gained a pg_type branch for // PostgreSQL-family user-defined types; older cached lists miss TYPE nodes. - const baseCacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "oracle" ? "objects-v7" : "objects-v8"); + const baseCacheVersion = objectTreeCacheVersion(config, node.schema, config?.db_type === "oracle" ? "objects-v7" : "objects-v8"); const cacheVersion = objectTreeProfileCacheKey ? `${baseCacheVersion}:${objectTreeProfileCacheKey}` : baseCacheVersion; return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, cacheVersion); } @@ -4353,7 +4365,9 @@ export const useConnectionStore = defineStore("connection", () => { if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); const showSystemSchemas = config?.show_system_schemas === true; - const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "xugu" ? "schemas-v4" : "schemas-v3"); + // schemas-v5 invalidates cached children created before the public + // synonym scope was placed after all real schemas. + const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "xugu" ? "schemas-v5" : "schemas-v3"); const cacheKey = schemaCacheKey(connectionId, database, cacheVersion, showSystemSchemas ? "show-system" : "hide-system"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); @@ -4723,7 +4737,7 @@ export const useConnectionStore = defineStore("connection", () => { async function loadTables(connectionId: string, database: string, schema?: string, options?: LoadTreeOptions) { const configForScope = getConfig(connectionId); const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; - const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined; + const objectTypesForScope = simpleObjectDisplayForScope ? sidebarObjectTypesForScope(configForScope, schema) : undefined; const searchFilter = activeTreeLoadSearchFilter(options); const querySchemaForScope = connectionObjectTreeQuerySchema(configForScope, database, schema); const effectiveSchemaForScope = connectionObjectTreeNodeSchema(configForScope, database, schema); @@ -4735,7 +4749,7 @@ export const useConnectionStore = defineStore("connection", () => { }); if (!options?.force && simpleObjectDisplayForScope && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) { const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`; - const cacheKey = schemaCacheKey(connectionId, database, schema || "", ownerAwareMetadataCacheVersion(configForScope, "objects-simple-v8")); + const cacheKey = schemaCacheKey(connectionId, database, schema || "", objectTreeCacheVersion(configForScope, schema, "objects-simple-v8")); if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) { void loadTables(connectionId, database, schema, { ...options, force: true }).catch(() => undefined); return; @@ -4769,7 +4783,8 @@ export const useConnectionStore = defineStore("connection", () => { const searchFilter = activeTreeLoadSearchFilter(options); const config = getConfig(connectionId); const objectTreeProfile = driverProfileObjectTreeProfileForConnection(config); - const baseCacheVersion = ownerAwareMetadataCacheVersion(config, simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8"); + const isPublicSynonymScope = config?.db_type === "xugu" && isXuguPublicSynonymScope(schema); + const baseCacheVersion = objectTreeCacheVersion(config, schema, simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8"); const cacheVersion = !simpleObjectDisplay && objectTreeProfile?.cacheKey ? `${baseCacheVersion}:${objectTreeProfile.cacheKey}` : baseCacheVersion; const cacheKey = schemaCacheKey(connectionId, database, schema || "", cacheVersion); const querySchema = connectionObjectTreeQuerySchema(config, database, schema); @@ -4789,10 +4804,10 @@ export const useConnectionStore = defineStore("connection", () => { } } - const nonTableObjectTypes = simpleObjectDisplay ? supportedSidebarObjectTypes(config).filter((objectType) => objectType !== "TABLE") : []; + const nonTableObjectTypes = simpleObjectDisplay ? sidebarObjectTypesForScope(config, schema).filter((objectType) => objectType !== "TABLE") : []; let children: TreeNode[]; let nextObjectCount: number | undefined; - if (simpleObjectDisplay) { + if (simpleObjectDisplay && !isPublicSynonymScope) { const pageSize = sidebarObjectGroupPageSize(); const page = await loadPagedSimpleTableChildren({ nodeId, @@ -4808,13 +4823,19 @@ export const useConnectionStore = defineStore("connection", () => { }); children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children; nextObjectCount = page.objectCount; + } else if (simpleObjectDisplay) { + // The synthetic public scope contains no tables. Avoid issuing a + // table-list query against the protocol namespace; supplemental + // object loading below will add only its SYNONYM entries. + children = []; + nextObjectCount = 0; } else { children = buildObjectGroupPlaceholderNodes({ nodeId, connectionId, database, schema: effectiveSchema, - objectTypes: supportedSidebarObjectTypes(config), + objectTypes: sidebarObjectTypesForScope(config, schema), groupOverrides: objectTreeProfile?.groupOverrides, }); if (!schema && isPostgresLikeForExtensions(config?.db_type)) {