fix(import): restore DBeaver connection folders

This commit is contained in:
t8y2 2026-07-21 17:05:22 +08:00
parent 6ace11b097
commit 8a546be58d
5 changed files with 224 additions and 11 deletions

View File

@ -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<string, unknown>) {
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<string, string>): 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"] }] }]);
});
});

View File

@ -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<ConnectionConfig, "id">;
@ -13,12 +14,18 @@ type DbeaverImportPayload = {
type DbeaverConnectionEntry = {
id: string;
name?: string;
folder?: string;
provider?: string;
driver?: string;
configuration?: Record<string, any>;
[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<string, any>), 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<string, any>).parent);
return [parent ? `${parent}/${name}` : name];
});
}
function buildConnection(entry: DbeaverConnectionEntry, credentials: ReturnType<typeof readCredentials>): 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<ConnectionConfig[]> {
export async function parseDbeaverImport(content: string): Promise<DbeaverImportResult> {
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<Connecti
const dataSources = JSON.parse(payload.dataSources);
const encryptedCredentials = await decryptCredentialsFile(payload.credentialsBase64);
const configs: ConnectionConfig[] = [];
const connectionFolderPaths = new Map<string, string>();
const seen = new Set<string>();
for (const entry of extractConnections(dataSources)) {
@ -257,7 +278,21 @@ export async function parseDbeaverConnections(content: string): Promise<Connecti
if (seen.has(key)) continue;
seen.add(key);
configs.push(config);
const folderPath = getString(entry.folder);
if (folderPath) connectionFolderPaths.set(config.id, folderPath);
}
return configs;
// DBeaver stores declared folders separately and connections reference full
// slash-delimited paths. Missing ancestors are created during DBeaver load,
// so mirror that behavior when producing DBX's nested sidebar layout.
const layout = buildSidebarLayoutFromFolderPaths(
configs.map((config) => config.id),
extractFolderPaths(dataSources),
connectionFolderPaths,
);
return { connections: configs, layout };
}
export async function parseDbeaverConnections(content: string): Promise<ConnectionConfig[]> {
return (await parseDbeaverImport(content)).connections;
}

View File

@ -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<string>, connectionFolderPaths: ReadonlyMap<string, string>): SidebarLayout | undefined {
const groups: ConnectionGroup[] = [];
const order: SidebarOrderEntry[] = [];
const groupEntries = new Map<string, Extract<SidebarOrderEntry, { type: "group" }>>();
const ensureFolder = (path: string | undefined) => {
const segments = folderPathSegments(path);
let parentEntry: Extract<SidebarOrderEntry, { type: "group" }> | 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, { type: "group" }>): SidebarOrderEntry[] {
return entry.children ?? entry.connectionIds?.map((id) => ({ type: "connection" as const, id })) ?? [];
}

View File

@ -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);

View File

@ -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();
}
});