diff --git a/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts b/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts new file mode 100644 index 000000000..730bb06e6 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { SidebarLayout, SidebarOrderEntry } from "@/types/database"; +import { parseDbeaverConnections, parseDbeaverImport } from "@/lib/imports/dbeaverImport"; + +function payload(dataSources: Record) { + return JSON.stringify({ format: "dbeaver-import", dataSources: JSON.stringify(dataSources) }); +} + +function mysqlConnection(id: string, name: string, folder?: string) { + return { + id, + name, + folder, + provider: "mysql", + driver: "mysql", + configuration: { host: "127.0.0.1", port: 3306, database: name }, + }; +} + +function layoutLabels(layout: SidebarLayout, connectionNames: Map): unknown[] { + const groupNames = new Map(layout.groups.map((group) => [group.id, group.name])); + const visit = (entries: SidebarOrderEntry[]): unknown[] => entries.map((entry) => (entry.type === "connection" ? connectionNames.get(entry.id) : { group: groupNames.get(entry.id), children: visit(entry.children ?? []) })); + return visit(layout.order); +} + +describe("DBeaver folder import", () => { + it("keeps parseDbeaverConnections compatible when no folders exist", async () => { + const connections = await parseDbeaverConnections(payload({ connections: { root: mysqlConnection("root", "Root") } })); + + expect(connections).toHaveLength(1); + expect(connections[0]?.name).toBe("Root"); + expect((await parseDbeaverImport(payload({ connections: {} }))).layout).toBeUndefined(); + }); + + it("builds nested groups from declared folders and connection folder paths", async () => { + const result = await parseDbeaverImport( + payload({ + folders: { + Environment: {}, + Region: { parent: "Environment" }, + Team: { parent: "Environment/Region" }, + }, + connections: { + nested: mysqlConnection("nested", "Nested", "Environment/Region/Team"), + root: mysqlConnection("root", "Root"), + }, + }), + ); + + const names = new Map(result.connections.map((connection) => [connection.id, connection.name])); + expect(layoutLabels(result.layout!, names)).toEqual([ + { + group: "Environment", + children: [{ group: "Region", children: [{ group: "Team", children: ["Nested"] }] }], + }, + "Root", + ]); + }); + + it("creates missing parent folders declared only by a child folder", async () => { + const result = await parseDbeaverImport( + payload({ + folders: { Leaf: { parent: "Missing/Parent" } }, + connections: { nested: mysqlConnection("nested", "Nested", "Missing/Parent/Leaf") }, + }), + ); + + const names = new Map(result.connections.map((connection) => [connection.id, connection.name])); + expect(layoutLabels(result.layout!, names)).toEqual([ + { + group: "Missing", + children: [{ group: "Parent", children: [{ group: "Leaf", children: ["Nested"] }] }], + }, + ]); + }); + + it("creates unknown folders referenced only by a connection", async () => { + const result = await parseDbeaverImport(payload({ connections: { nested: mysqlConnection("nested", "Nested", "Ad hoc/Production") } })); + + const names = new Map(result.connections.map((connection) => [connection.id, connection.name])); + expect(layoutLabels(result.layout!, names)).toEqual([{ group: "Ad hoc", children: [{ group: "Production", children: ["Nested"] }] }]); + }); +}); diff --git a/apps/desktop/src/lib/imports/dbeaverImport.ts b/apps/desktop/src/lib/imports/dbeaverImport.ts index 923ad1796..a68631443 100644 --- a/apps/desktop/src/lib/imports/dbeaverImport.ts +++ b/apps/desktop/src/lib/imports/dbeaverImport.ts @@ -1,6 +1,7 @@ -import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, SidebarLayout } from "@/types/database"; import { uuid } from "@/lib/common/utils"; import { JDBCX_JDBC_DRIVER_CLASS } from "@/lib/database/jdbcxBuiltinDriver"; +import { buildSidebarLayoutFromFolderPaths } from "@/lib/sidebar/sidebarLayout"; type PartialConnection = Omit; @@ -13,12 +14,18 @@ type DbeaverImportPayload = { type DbeaverConnectionEntry = { id: string; name?: string; + folder?: string; provider?: string; driver?: string; configuration?: Record; [key: string]: any; }; +export type DbeaverImportResult = { + connections: ConnectionConfig[]; + layout?: SidebarLayout; +}; + type ConnectionProfile = { dbType: DatabaseType; profile: string; @@ -195,6 +202,19 @@ function extractConnections(parsed: any): DbeaverConnectionEntry[] { .map(([id, entry]) => ({ ...(entry as Record), id: getString((entry as any).id || id) })); } +function extractFolderPaths(parsed: any): string[] { + const folders = parsed?.folders; + if (!folders || typeof folders !== "object") return []; + + const entries = Array.isArray(folders) ? folders.map((folder) => [getString(folder?.name), folder] as const) : Object.entries(folders); + + return entries.flatMap(([name, folder]) => { + if (!name || !folder || typeof folder !== "object") return []; + const parent = getString((folder as Record).parent); + return [parent ? `${parent}/${name}` : name]; + }); +} + function buildConnection(entry: DbeaverConnectionEntry, credentials: ReturnType): ConnectionConfig | null { const profile = inferProfile(entry); const config = entry.configuration || {}; @@ -239,7 +259,7 @@ export function isDbeaverImportPayload(content: string) { } } -export async function parseDbeaverConnections(content: string): Promise { +export async function parseDbeaverImport(content: string): Promise { const payload = JSON.parse(content) as DbeaverImportPayload; if (payload.format !== "dbeaver-import" || !payload.dataSources) { throw new Error("Invalid DBeaver import payload"); @@ -248,6 +268,7 @@ export async function parseDbeaverConnections(content: string): Promise(); const seen = new Set(); for (const entry of extractConnections(dataSources)) { @@ -257,7 +278,21 @@ export async function parseDbeaverConnections(content: string): Promise config.id), + extractFolderPaths(dataSources), + connectionFolderPaths, + ); + return { connections: configs, layout }; +} + +export async function parseDbeaverConnections(content: string): Promise { + return (await parseDbeaverImport(content)).connections; } diff --git a/apps/desktop/src/lib/sidebar/sidebarLayout.ts b/apps/desktop/src/lib/sidebar/sidebarLayout.ts index 350bf3771..1116c8e45 100644 --- a/apps/desktop/src/lib/sidebar/sidebarLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarLayout.ts @@ -6,6 +6,49 @@ export function emptyLayout(): SidebarLayout { return { groups: [], order: [] }; } +function folderPathSegments(path: string | undefined): string[] { + return (path ?? "").split("/").filter((segment) => segment.length > 0); +} + +export function buildSidebarLayoutFromFolderPaths(connectionIds: string[], folderPaths: Iterable, connectionFolderPaths: ReadonlyMap): SidebarLayout | undefined { + const groups: ConnectionGroup[] = []; + const order: SidebarOrderEntry[] = []; + const groupEntries = new Map>(); + + const ensureFolder = (path: string | undefined) => { + const segments = folderPathSegments(path); + let parentEntry: Extract | undefined; + let currentPath = ""; + + for (const segment of segments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + let entry = groupEntries.get(currentPath); + if (!entry) { + const groupId = uuid(); + entry = { type: "group", id: groupId, children: [] }; + groupEntries.set(currentPath, entry); + groups.push({ id: groupId, name: segment, collapsed: false }); + if (parentEntry) parentEntry.children!.push(entry); + else order.push(entry); + } + parentEntry = entry; + } + + return parentEntry; + }; + + for (const folderPath of folderPaths) ensureFolder(folderPath); + + for (const connectionId of connectionIds) { + const connectionEntry: SidebarOrderEntry = { type: "connection", id: connectionId }; + const folderEntry = ensureFolder(connectionFolderPaths.get(connectionId)); + if (folderEntry) folderEntry.children!.push(connectionEntry); + else order.push(connectionEntry); + } + + return groups.length ? { groups, order } : undefined; +} + function entryChildren(entry: Extract): SidebarOrderEntry[] { return entry.children ?? entry.connectionIds?.map((id) => ({ type: "connection" as const, id })) ?? []; } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 53162703a..2d50ff1c4 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -5230,7 +5230,7 @@ export const useConnectionStore = defineStore("connection", () => { const { parseNavicatConnections } = await import("@/lib/imports/navicatImport"); imported = await parseNavicatConnections(content); } else if (!passphrase) { - const { isDbeaverImportPayload, parseDbeaverConnections } = await import("@/lib/imports/dbeaverImport"); + const { isDbeaverImportPayload, parseDbeaverImport } = await import("@/lib/imports/dbeaverImport"); const { isDataGripImportPayload, parseDataGripConnections } = await import("@/lib/imports/datagripImport"); if (isDataGripImportPayload(content)) { const payload = JSON.parse(content) as { @@ -5241,7 +5241,9 @@ export const useConnectionStore = defineStore("connection", () => { pendingDataGripPayload = payload; imported = parseDataGripConnections(payload); } else if (isDbeaverImportPayload(content)) { - imported = await parseDbeaverConnections(content); + const result = await parseDbeaverImport(content); + imported = result.connections; + importedLayout = result.layout; } else { const parsed = JSON.parse(content); diff --git a/packages/app-tests/connectionStoreGroupedConnect.test.ts b/packages/app-tests/connectionStoreGroupedConnect.test.ts index c1cc76b7d..20ff6cdcf 100644 --- a/packages/app-tests/connectionStoreGroupedConnect.test.ts +++ b/packages/app-tests/connectionStoreGroupedConnect.test.ts @@ -156,12 +156,7 @@ test("duplicating a grouped connection keeps the copy in the same group", async test("reloading connections preserves the current grouped layout when the saved layout is temporarily unavailable", async () => { const originalFetch = globalThis.fetch; const storage = installMemoryStorage(); - const savedConnections: ConnectionConfig[] = [ - conn("pg", "pg"), - conn("pg2", "pg2"), - conn("pg3", "pg3"), - conn("pg4", "pg4"), - ]; + const savedConnections: ConnectionConfig[] = [conn("pg", "pg"), conn("pg2", "pg2"), conn("pg3", "pg3"), conn("pg4", "pg4")]; let savedLayout: SidebarLayout | null = { groups: [ { id: "group-a", name: "dir[a]", collapsed: false }, @@ -271,3 +266,58 @@ test("importing grouped dbx connections remaps exported layout to new connection storage.restore(); } }); + +test("importing DBeaver connections remaps and applies nested folder layout", async () => { + const originalFetch = globalThis.fetch; + const storage = installMemoryStorage(); + let savedConnections: ConnectionConfig[] = []; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url === "/api/connection/list") { + return new Response(JSON.stringify(savedConnections), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/layout/sidebar") { + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/connection/save") { + savedConnections = JSON.parse(String(init?.body ?? "[]")); + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + const dataSources = JSON.stringify({ + folders: { Parent: {}, Child: { parent: "Parent" } }, + connections: { + imported: { + id: "dbeaver-connection", + name: "Imported MySQL", + folder: "Parent/Child", + provider: "mysql", + driver: "mysql", + configuration: { host: "127.0.0.1", port: 3306, database: "app" }, + }, + }, + }); + const content = JSON.stringify({ format: "dbeaver-import", dataSources }); + + const result = await store.importConnectionsFromFile(content, null); + assert.equal(result.count, 1); + assert.ok(result.layout); + store.applySidebarLayout(result.layout!); + + assert.equal(store.treeNodes[0]?.label, "Parent"); + assert.equal(store.treeNodes[0]?.children?.[0]?.label, "Child"); + assert.equal(store.treeNodes[0]?.children?.[0]?.children?.[0]?.label, "Imported MySQL"); + assert.notEqual(store.treeNodes[0]?.children?.[0]?.children?.[0]?.id, "dbeaver-connection"); + } finally { + globalThis.fetch = originalFetch; + storage.restore(); + } +});