fix(import): preserve DataGrip groups and Kingbase connections

This commit is contained in:
Maxon Phong authored and GitHub committed 2026-07-31 16:24:29 +08:00
1 parent 71090d56c0
commit 7b95f0ca17
10 files changed
+208 -19

No files matched your search

+124 -1
View File
@@ -2,7 +2,7 @@
import { describe, expect, it } from "vitest";
import type { SidebarLayout, SidebarOrderEntry } from "@/types/database";
import { parseDataGripConnections, parseDataGripImport, type DataGripImportPayload } from "@/lib/imports/datagripImport";
import { matchDataGripImportFiles, parseDataGripConnections, parseDataGripImport, type DataGripImportPayload } from "@/lib/imports/datagripImport";
function payload(dataSources: string, dataSourcesLocal?: string, dbForestConfig?: string): DataGripImportPayload {
return { format: "datagrip-import", dataSources, dataSourcesLocal, dbForestConfig };
@@ -65,6 +65,59 @@ describe("DataGrip connection import", () => {
});
});
it("imports Kingbase data sources configured by URL without a driver-ref", () => {
// DataGrip has no built-in Kingbase driver, so every Kingbase connection is
// a custom driver. Real exports use <configured-by-url>true</configured-by-url>
// with NO <driver-ref> element — the driver identity lives only in
// <jdbc-driver> + <jdbc-url>. Such connections must still import.
const connections = parseDataGripConnections(
payload(`
<project>
<component name="DataSourceManagerImpl">
<data-source name="Kingbase Dev" uuid="kingbase-url">
<configured-by-url>true</configured-by-url>
<jdbc-driver>com.kingbase8.Driver</jdbc-driver>
<jdbc-url>jdbc:kingbase8://192.0.2.1:54321/app</jdbc-url>
</data-source>
</component>
</project>
`),
);
expect(connections).toHaveLength(1);
expect(connections[0]).toMatchObject({
name: "Kingbase Dev",
db_type: "kingbase",
driver_profile: "kingbase",
driver_label: "KingbaseES",
host: "192.0.2.1",
port: 54321,
database: "app",
username: "SYSTEM",
});
});
it("drops unknown custom drivers configured by URL", () => {
// No <driver-ref> + an unrecognised driver class/subprotocol must NOT leak
// in as a generic JDBC connection. It stays dropped. This guards the
// mergeFragments "" sentinel contract (see parseDataGripImport guard).
const connections = parseDataGripConnections(
payload(`
<project>
<component name="DataSourceManagerImpl">
<data-source name="Mystery DB" uuid="mystery-1">
<configured-by-url>true</configured-by-url>
<jdbc-driver>com.example.mystery.Driver</jdbc-driver>
<jdbc-url>jdbc:mystery://10.0.0.1:9999/db</jdbc-url>
</data-source>
</component>
</project>
`),
);
expect(connections).toHaveLength(0);
});
it("preserves DataGrip connection groups as sidebar groups", () => {
const result = parseDataGripImport(
payload(
@@ -136,6 +189,34 @@ describe("DataGrip connection import", () => {
expect(layoutLabels(result.layout!, names)).toEqual([{ group: "Legacy Group", children: ["Legacy"] }]);
});
it("preserves the modern DataGrip group attribute", () => {
// Real DataGrip exports use a `group` attribute on <data-source> (not the
// legacy `group-name`). Connections sharing a group land under one folder.
const result = parseDataGripImport(
payload(`
<project>
<component name="DataSourceManagerImpl">
<data-source name="Prod" uuid="mysql-prod" group="production">
<driver-ref>mysql</driver-ref>
<jdbc-url>jdbc:mysql://prod.example.com:3306/app</jdbc-url>
</data-source>
<data-source name="Dev" uuid="mysql-dev" group="development">
<driver-ref>mysql</driver-ref>
<jdbc-url>jdbc:mysql://dev.example.com:3306/app</jdbc-url>
</data-source>
<data-source name="Lonely" uuid="mysql-solo">
<driver-ref>mysql</driver-ref>
<jdbc-url>jdbc:mysql://localhost:3306/app</jdbc-url>
</data-source>
</component>
</project>
`),
);
const names = new Map(result.connections.map((connection) => [connection.id, connection.name]));
expect(layoutLabels(result.layout!, names)).toEqual([{ group: "production", children: ["Prod"] }, { group: "development", children: ["Dev"] }, "Lonely"]);
});
it("keeps the connection-only API and empty layout behavior", () => {
const importPayload = payload(`
<project>
@@ -152,3 +233,45 @@ describe("DataGrip connection import", () => {
expect(parseDataGripImport(importPayload).layout).toBeUndefined();
});
});
describe("matchDataGripImportFiles", () => {
it("picks the three DataGrip config files by name regardless of order", () => {
const paths = ["C:/proj/.idea/db-forest-config.xml", "C:/proj/.idea/dataSources.local.xml", "C:/proj/.idea/dataSources.xml"];
expect(matchDataGripImportFiles(paths)).toEqual({
dataSources: "C:/proj/.idea/dataSources.xml",
local: "C:/proj/.idea/dataSources.local.xml",
forest: "C:/proj/.idea/db-forest-config.xml",
});
});
it("allows missing optional local and forest files", () => {
expect(matchDataGripImportFiles(["C:/proj/.idea/dataSources.xml"])).toEqual({
dataSources: "C:/proj/.idea/dataSources.xml",
local: undefined,
forest: undefined,
});
});
it("throws a coded error when dataSources.xml is not among the selected files", () => {
let caught: unknown;
try {
matchDataGripImportFiles(["C:/proj/other.xml", "C:/proj/dataSources.local.xml"]);
} catch (error) {
caught = error;
}
expect((caught as Error).message).toMatch(/dataSources\.xml/i);
expect((caught as Error & { code?: string }).code).toBe("DATAGRIP_IMPORT_MISSING_DATASOURCES");
});
it("matches file names case-insensitively", () => {
const result = matchDataGripImportFiles(["C:/proj/.idea/DataSources.XML", "C:/proj/.idea/datasources.local.xml"]);
expect(result.dataSources).toBe("C:/proj/.idea/DataSources.XML");
expect(result.local).toBe("C:/proj/.idea/datasources.local.xml");
});
it("handles Windows backslash paths", () => {
const result = matchDataGripImportFiles(["C:\\proj\\.idea\\dataSources.xml", "C:\\proj\\.idea\\dataSources.local.xml"]);
expect(result.dataSources).toBe("C:\\proj\\.idea\\dataSources.xml");
expect(result.local).toBe("C:\\proj\\.idea\\dataSources.local.xml");
});
});
+43 -3
View File
@@ -300,7 +300,9 @@ function parseDataSourcesXml(xml: string): Map<string, Partial<DataSourceFragmen
const userName = getText(element, "user-name");
if (userName) fragment.username = userName;
const groupName = element.getAttribute("group-name") || undefined;
// DataGrip stores the folder grouping as a `group` attribute on each
// <data-source>. `group-name` is kept as a legacy fallback for older exports.
const groupName = element.getAttribute("group") || element.getAttribute("group-name") || undefined;
if (groupName) fragment.groupName = groupName;
result.set(uuidVal, fragment);
@@ -419,11 +421,11 @@ function mergeFragments(shared: Map<string, Partial<DataSourceFragment>>, local:
// Resolve and filter
const resolved: DataSourceFragment[] = [];
for (const frag of merged.values()) {
if (!frag.uuid || !frag.driverRef || !frag.jdbcUrl) continue;
if (!frag.uuid || !frag.jdbcUrl) continue;
resolved.push({
uuid: frag.uuid,
name: frag.name || frag.uuid,
driverRef: frag.driverRef,
driverRef: frag.driverRef || "",
jdbcUrl: frag.jdbcUrl,
driverClass: frag.driverClass || "",
username: frag.username || "",
@@ -494,6 +496,13 @@ export function parseDataGripImport(payload: DataGripImportPayload): DataGripImp
for (const fragment of fragments) {
const config = buildConnection(fragment);
// Custom-driver sources without a <driver-ref> are kept only when a concrete
// database type is recognised. DataGrip ships no built-in Kingbase driver, so
// every Kingbase connection is custom (configured by URL, no driver-ref).
// An unrecognised custom driver falls back to "jdbc" and is dropped here to
// avoid importing unusable half-baked JDBC connections. `fragment.driverRef
// === ""` is the sentinel mergeFragments sets when <driver-ref> was absent.
if (fragment.driverRef === "" && config.db_type === "jdbc") continue;
const key = [config.name, config.db_type, config.host, config.port, config.database || ""].join("\u0000");
if (seen.has(key)) continue;
seen.add(key);
@@ -516,6 +525,34 @@ export function parseDataGripConnections(payload: DataGripImportPayload): Connec
return parseDataGripImport(payload).connections;
}
/**
* Pick DataGrip config file paths by name from a dialog multi-select list.
* `dataSources.xml` is required; `dataSources.local.xml` (usernames) and
* `db-forest-config.xml` (legacy group tree) are optional and stay undefined
* when not selected. Names are matched case-insensitively on both path styles.
*/
export function matchDataGripImportFiles(paths: string[]): {
dataSources: string;
local?: string;
forest?: string;
} {
const fileName = (path: string) => (path.split(/[\\/]/).pop() ?? "").toLowerCase();
const find = (name: string) => paths.find((path) => fileName(path) === name.toLowerCase());
const dataSources = find("dataSources.xml");
if (!dataSources) {
// Library layer keeps a readable fallback message; UI layers translate the
// coded error (see readDataGripImportFile) instead of showing this raw text.
const error = new Error("Select dataSources.xml (required); optionally dataSources.local.xml and db-forest-config.xml");
(error as Error & { code?: string }).code = "DATAGRIP_IMPORT_MISSING_DATASOURCES";
throw error;
}
return {
dataSources,
local: find("dataSources.local.xml"),
forest: find("db-forest-config.xml"),
};
}
/** Returns a map of dedup key (name\0host\0port\0db) → DataGrip UUID for Keychain lookup. */
export function getDataGripUuidMap(payload: DataGripImportPayload): Map<string, string> {
const shared = parseDataSourcesXml(payload.dataSources);
@@ -527,6 +564,9 @@ export function getDataGripUuidMap(payload: DataGripImportPayload): Map<string,
for (const fragment of fragments) {
const profile = inferProfile(fragment.driverRef, extractSubprotocol(fragment.jdbcUrl), fragment.driverClass, fragment.product);
// Stay in sync with parseDataGripImport: skip custom-driver sources (no
// <driver-ref>) that don't resolve to a concrete database type.
if (fragment.driverRef === "" && profile.dbType === "jdbc") continue;
const parsed = parseJdbcUrl(fragment.jdbcUrl);
const host = parsed.host || (profile.dbType === "sqlite" ? "" : "127.0.0.1");
const port = parsed.port || profile.port;