fix(import): restore DBeaver connection folders
This commit is contained in:
parent
6ace11b097
commit
8a546be58d
|
|
@ -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"] }] }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })) ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue