fix(import): preserve DataGrip groups and Kingbase connections
This commit is contained in:
1 parent
71090d56c0
commit
7b95f0ca17
10 files changed
+208
-19
No files matched your search
+124
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user