From bacd5dc8592045b5e8c228e6ebc51809daa8d1b6 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 19 May 2026 15:06:25 +0800 Subject: [PATCH] fix(sidebar): support catalogless schema trees --- .../src/components/sidebar/TreeItem.vue | 35 ++++++++--- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/lib/databaseTree.ts | 45 ++++++++++---- apps/desktop/src/lib/treeNodeContext.ts | 42 +++++++++++++ apps/desktop/src/stores/connectionStore.ts | 59 +++++++++++++------ packages/app-tests/databaseTree.test.ts | 30 ++++++++-- packages/app-tests/treeNodeContext.test.ts | 50 ++++++++++++++++ 9 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/src/lib/treeNodeContext.ts create mode 100644 packages/app-tests/treeNodeContext.test.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index af30975aa..e137b4988 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -105,6 +105,7 @@ import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/objectSourceEditor"; import { hexToRgba } from "@/lib/color"; import { focusSidebarRenameInput, shouldPreventRenameCloseAutoFocus } from "@/lib/sidebarRenameFocus"; +import { hasTreeNodeDatabaseContext } from "@/lib/treeNodeContext"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; @@ -250,6 +251,7 @@ function isGroupLabel(node: TreeNode): boolean { function displayLabel(node: TreeNode): string { if (node.type === "object-browser") return t(node.label, { count: node.objectCount ?? 0 }); + if (node.label === "tree.defaultDatabase") return t(node.label); return isGroupLabel(node) ? t(node.label) : node.label; } @@ -308,7 +310,7 @@ async function toggle() { const tabTitle = `${node.database}.${node.label}`; const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo"); queryStore.updateSql(tab, node.label); - } else if (node.type === "database" && node.connectionId && node.database) { + } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { const config = connectionStore.getConfig(node.connectionId); if (config?.db_type === "sqlserver") { await connectionStore.loadSqlServerDatabaseObjects(node.connectionId, node.database); @@ -317,17 +319,36 @@ async function toggle() { } else { await connectionStore.loadTables(node.connectionId, node.database); } - } else if (node.type === "schema" && node.connectionId && node.database && node.schema) { + } else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await connectionStore.loadTables(node.connectionId, node.database, node.schema); - } else if ((node.type === "table" || node.type === "view") && node.connectionId && node.database) { + } else if ( + (node.type === "table" || node.type === "view") && + node.connectionId && + hasTreeNodeDatabaseContext(node) + ) { await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id); - } else if (node.type === "group-columns" && node.connectionId && node.database && node.tableName) { + } else if ( + node.type === "group-columns" && + node.connectionId && + hasTreeNodeDatabaseContext(node) && + node.tableName + ) { await connectionStore.loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id); - } else if (node.type === "group-indexes" && node.connectionId && node.database && node.tableName) { + } else if ( + node.type === "group-indexes" && + node.connectionId && + hasTreeNodeDatabaseContext(node) && + node.tableName + ) { await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id); - } else if (node.type === "group-fkeys" && node.connectionId && node.database && node.tableName) { + } else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id); - } else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) { + } else if ( + node.type === "group-triggers" && + node.connectionId && + hasTreeNodeDatabaseContext(node) && + node.tableName + ) { await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id); } emit("node-toggled", node, wasExpanded); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8f7304521..0684185d6 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -744,6 +744,7 @@ export default { }, tree: { savedSql: "SQL Library", + defaultDatabase: "Default DB", columns: "Columns", indexes: "Indexes", foreignKeys: "Foreign Keys", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 830eafc0a..3f2f9f3ce 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -641,6 +641,7 @@ export default { }, tree: { savedSql: "Biblioteca SQL", + defaultDatabase: "Base predeterminada", columns: "Columnas", indexes: "Índices", foreignKeys: "Claves foráneas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b602f625b..0b09a0586 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -725,6 +725,7 @@ export default { }, tree: { savedSql: "SQL 库", + defaultDatabase: "默认库", columns: "字段", indexes: "索引", foreignKeys: "外键", diff --git a/apps/desktop/src/lib/databaseTree.ts b/apps/desktop/src/lib/databaseTree.ts index 80c6a8ed8..da20e777b 100644 --- a/apps/desktop/src/lib/databaseTree.ts +++ b/apps/desktop/src/lib/databaseTree.ts @@ -1,13 +1,38 @@ import type { DatabaseInfo, TreeNode } from "@/types/database"; +import { DEFAULT_DATABASE_TREE_LABEL } from "./treeNodeContext"; -export function buildDatabaseTreeNodes(connectionId: string, databases: DatabaseInfo[]): TreeNode[] { - return databases.map((db) => ({ - id: `${connectionId}:${db.name}`, - label: db.name, - type: "database" as const, - connectionId, - database: db.name, - isExpanded: false, - children: [], - })); +export function buildDatabaseTreeNodes( + connectionId: string, + databases: DatabaseInfo[], + options: { includeDefaultWhenEmpty?: boolean } = {}, +): TreeNode[] { + const nodes = databases.flatMap((db) => { + const name = db.name.trim(); + if (!name) return []; + return [ + { + id: `${connectionId}:${name}`, + label: name, + type: "database" as const, + connectionId, + database: name, + isExpanded: false, + children: [], + }, + ]; + }); + + if (nodes.length > 0 || !options.includeDefaultWhenEmpty) return nodes; + + return [ + { + id: `${connectionId}:`, + label: DEFAULT_DATABASE_TREE_LABEL, + type: "database" as const, + connectionId, + database: "", + isExpanded: false, + children: [], + }, + ]; } diff --git a/apps/desktop/src/lib/treeNodeContext.ts b/apps/desktop/src/lib/treeNodeContext.ts new file mode 100644 index 000000000..b1259fae1 --- /dev/null +++ b/apps/desktop/src/lib/treeNodeContext.ts @@ -0,0 +1,42 @@ +import type { TreeNode } from "@/types/database"; + +export const DEFAULT_DATABASE_TREE_LABEL = "tree.defaultDatabase"; + +export function hasTreeNodeDatabaseContext(node: Pick): node is Pick & { + database: string; +} { + return node.database != null; +} + +function schemaCacheKey(...parts: string[]): string { + return parts.map((part) => encodeURIComponent(part)).join(":"); +} + +export function treeNodeSchemaCachePrefix(node: TreeNode): string | null { + if (node.type === "connection" && node.connectionId) { + return `${schemaCacheKey(node.connectionId)}:`; + } + if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { + return `${schemaCacheKey(node.connectionId, node.database)}:`; + } + if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { + return `${schemaCacheKey(node.connectionId, node.database, node.schema)}:`; + } + return null; +} + +export function normalizeCataloglessDatabaseNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.map((node) => { + const normalized = + node.type === "database" && node.database === "" && !node.label.trim() + ? { ...node, label: DEFAULT_DATABASE_TREE_LABEL } + : node; + + return normalized.children + ? { + ...normalized, + children: normalizeCataloglessDatabaseNodes(normalized.children), + } + : normalized; + }); +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 3aaab2f47..7af9938d7 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -32,6 +32,11 @@ import { expandCachedObjectBrowserNodes, objectGroupRefreshParentId, } from "@/lib/tableTree"; +import { + hasTreeNodeDatabaseContext, + normalizeCataloglessDatabaseNodes, + treeNodeSchemaCachePrefix, +} from "@/lib/treeNodeContext"; import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCache"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; @@ -355,7 +360,7 @@ export const useConnectionStore = defineStore("connection", () => { const payload = await api.loadSchemaCache(cacheKey).catch(() => null); const decoded = decodeSchemaTreeCache(payload); if (!decoded) return { hit: false, isStale: false }; - const normalizedChildren = expandCachedObjectBrowserNodes(decoded.children); + const normalizedChildren = normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children)); setChildren( node, node.type === "connection" && node.connectionId @@ -394,16 +399,7 @@ export const useConnectionStore = defineStore("connection", () => { } function schemaCachePrefixForNode(node: TreeNode): string | null { - if (node.type === "connection" && node.connectionId) { - return `${schemaCacheKey(node.connectionId)}:`; - } - if (node.type === "database" && node.connectionId && node.database) { - return `${schemaCacheKey(node.connectionId, node.database)}:`; - } - if (node.type === "schema" && node.connectionId && node.database && node.schema) { - return `${schemaCacheKey(node.connectionId, node.database, node.schema)}:`; - } - return null; + return treeNodeSchemaCachePrefix(node); } async function clearPersistedTreeCacheForNode(node: TreeNode) { @@ -692,7 +688,13 @@ export const useConnectionStore = defineStore("connection", () => { ); const visibleNameSet = new Set(visibleNames); const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); - const children = withSavedSqlRoot(connectionId, buildDatabaseTreeNodes(connectionId, visibleDatabases), node); + const children = withSavedSqlRoot( + connectionId, + buildDatabaseTreeNodes(connectionId, visibleDatabases, { + includeDefaultWhenEmpty: usesTreeSchemaMode(config?.db_type), + }), + node, + ); setChildren(node, children); await savePersistedTreeChildren(cacheKey, children); } @@ -1161,7 +1163,7 @@ export const useConnectionStore = defineStore("connection", () => { } } else if (node.type === "mongo-db" && node.connectionId && node.database) { await loadMongoCollections(node.connectionId, node.database); - } else if (node.type === "database" && node.connectionId && node.database) { + } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { const config = getConfig(node.connectionId); if (config?.db_type === "sqlserver") { await loadSqlServerDatabaseObjects(node.connectionId, node.database, options); @@ -1170,17 +1172,36 @@ export const useConnectionStore = defineStore("connection", () => { } else { await loadTables(node.connectionId, node.database, undefined, options); } - } else if (node.type === "schema" && node.connectionId && node.database && node.schema) { + } else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await loadTables(node.connectionId, node.database, node.schema, options); - } else if ((node.type === "table" || node.type === "view") && node.connectionId && node.database) { + } else if ( + (node.type === "table" || node.type === "view") && + node.connectionId && + hasTreeNodeDatabaseContext(node) + ) { await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id); - } else if (node.type === "group-columns" && node.connectionId && node.database && node.tableName) { + } else if ( + node.type === "group-columns" && + node.connectionId && + hasTreeNodeDatabaseContext(node) && + node.tableName + ) { await loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id); - } else if (node.type === "group-indexes" && node.connectionId && node.database && node.tableName) { + } else if ( + node.type === "group-indexes" && + node.connectionId && + hasTreeNodeDatabaseContext(node) && + node.tableName + ) { await loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id); - } else if (node.type === "group-fkeys" && node.connectionId && node.database && node.tableName) { + } else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id); - } else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) { + } else if ( + node.type === "group-triggers" && + node.connectionId && + hasTreeNodeDatabaseContext(node) && + node.tableName + ) { await loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id); } else if ( node.type === "group-tables" || diff --git a/packages/app-tests/databaseTree.test.ts b/packages/app-tests/databaseTree.test.ts index 2011f2fe3..7cae943b7 100644 --- a/packages/app-tests/databaseTree.test.ts +++ b/packages/app-tests/databaseTree.test.ts @@ -3,11 +3,7 @@ import test from "node:test"; import { buildDatabaseTreeNodes } from "../../apps/desktop/src/lib/databaseTree.ts"; test("设置默认库后侧边栏数据库树仍保留全部数据库", () => { - const nodes = buildDatabaseTreeNodes("conn-1", [ - { name: "campaign_data" }, - { name: "cms" }, - { name: "mk_campaign" }, - ]); + const nodes = buildDatabaseTreeNodes("conn-1", [{ name: "campaign_data" }, { name: "cms" }, { name: "mk_campaign" }]); assert.deepEqual( nodes.map((node) => node.database), @@ -15,3 +11,27 @@ test("设置默认库后侧边栏数据库树仍保留全部数据库", () => { ); assert.equal(nodes.find((node) => node.database === "mk_campaign")?.id, "conn-1:mk_campaign"); }); + +test("catalogless database metadata gets a visible default node", () => { + const nodes = buildDatabaseTreeNodes("conn-1", [{ name: " " }], { includeDefaultWhenEmpty: true }); + + assert.deepEqual(nodes, [ + { + id: "conn-1:", + label: "tree.defaultDatabase", + type: "database", + connectionId: "conn-1", + database: "", + isExpanded: false, + children: [], + }, + ]); +}); + +test("tree schema mode can show a default node when no catalog is returned", () => { + const nodes = buildDatabaseTreeNodes("conn-1", [], { includeDefaultWhenEmpty: true }); + + assert.equal(nodes.length, 1); + assert.equal(nodes[0].database, ""); + assert.equal(nodes[0].label, "tree.defaultDatabase"); +}); diff --git a/packages/app-tests/treeNodeContext.test.ts b/packages/app-tests/treeNodeContext.test.ts new file mode 100644 index 000000000..72cacb794 --- /dev/null +++ b/packages/app-tests/treeNodeContext.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + hasTreeNodeDatabaseContext, + normalizeCataloglessDatabaseNodes, + treeNodeSchemaCachePrefix, +} from "../../apps/desktop/src/lib/treeNodeContext.ts"; +import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; + +test("treats empty database string as a valid catalogless context", () => { + assert.equal(hasTreeNodeDatabaseContext({ database: "" }), true); + assert.equal(hasTreeNodeDatabaseContext({ database: "app" }), true); + assert.equal(hasTreeNodeDatabaseContext({}), false); +}); + +test("builds cache prefixes for catalogless database and schema nodes", () => { + const databaseNode: TreeNode = { + id: "conn:", + label: "tree.defaultDatabase", + type: "database", + connectionId: "conn", + database: "", + }; + const schemaNode: TreeNode = { + id: "conn::APP", + label: "APP", + type: "schema", + connectionId: "conn", + database: "", + schema: "APP", + }; + + assert.equal(treeNodeSchemaCachePrefix(databaseNode), "conn::"); + assert.equal(treeNodeSchemaCachePrefix(schemaNode), "conn::APP:"); +}); + +test("normalizes legacy blank cached catalogless database labels", () => { + const nodes = normalizeCataloglessDatabaseNodes([ + { + id: "conn:", + label: "", + type: "database", + connectionId: "conn", + database: "", + children: [], + }, + ]); + + assert.equal(nodes[0].label, "tree.defaultDatabase"); +});