From d4596281412bb0708686be5b7503767f70dfa65d Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 31 Aug 2026 14:03:54 +0800 Subject: [PATCH] fix(transfer): support gbase jdbc connections --- .../transfer/DataTransferDialog.vue | 11 +- .../__tests__/DataTransferDialog.spec.ts | 4 +- .../__tests__/useDatabaseOptions.spec.ts | 16 ++ .../src/composables/useDatabaseOptions.ts | 6 +- .../__tests__/database/jdbcDialect.spec.ts | 34 ++++ apps/desktop/src/lib/database/jdbcDialect.ts | 3 + .../src/lib/database/transferObjectKinds.ts | 1 + crates/dbx-core/src/query.rs | 2 +- crates/dbx-core/src/transfer.rs | 176 +++++++++++++++++- 9 files changed, 240 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/transfer/DataTransferDialog.vue b/apps/desktop/src/components/transfer/DataTransferDialog.vue index 01edf9dc06..18e89b688f 100644 --- a/apps/desktop/src/components/transfer/DataTransferDialog.vue +++ b/apps/desktop/src/components/transfer/DataTransferDialog.vue @@ -21,6 +21,7 @@ import TransferTaskTree from "@/components/transfer/TransferTaskTree.vue"; import type { DatabaseType } from "@/types/database"; import type { TransferTask, TransferTaskConfig } from "@/types/database"; import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities"; +import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { isDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport"; import { decodeTransferDatabaseOption, encodeTransferDatabaseOptions, isSameTransferDatabase, isTransferDatabaseSelected, normalizeTransferCatalog } from "@/lib/database/dataTransferSelection"; import { formatDatabaseLabel } from "@/lib/database/defaultDatabase"; @@ -59,7 +60,7 @@ const transferDialogStyle = { const store = useConnectionStore(); -const sqlConnections = computed(() => store.connections.filter((c) => supportsTransfer(c.db_type))); +const sqlConnections = computed(() => store.connections.filter((c) => supportsTransfer(effectiveDatabaseTypeForConnection(c)))); // Source state const sourceConnectionId = ref(""); @@ -114,7 +115,7 @@ const treeDisabledGroups = computed(() => { } const sourceConfig = store.getConfig(sourceConnectionId.value); const targetConfig = store.getConfig(targetConnectionId.value); - const allowed = crossFamilyTransferableKinds(sourceConfig?.db_type, targetConfig?.db_type); + const allowed = crossFamilyTransferableKinds(effectiveDatabaseTypeForConnection(sourceConfig), effectiveDatabaseTypeForConnection(targetConfig)); return presentKinds.filter((k) => k !== "TABLE" && !allowed.includes(k)); }); @@ -132,9 +133,9 @@ const showCrossFamilyViewHint = computed(() => { const targetConfig = store.getConfig(targetConnectionId.value); if (transferContent.value === "dataOnly") return false; if (!sourceConfig || !targetConfig) return false; - const allowed = crossFamilyTransferableKinds(sourceConfig.db_type, targetConfig.db_type); + const allowed = crossFamilyTransferableKinds(effectiveDatabaseTypeForConnection(sourceConfig), effectiveDatabaseTypeForConnection(targetConfig)); if (!allowed.includes("VIEW")) return false; - return !isSameTransferFamily(sourceConfig.db_type, targetConfig.db_type) && (selectedObjects.value.VIEW?.size ?? 0) > 0; + return !isSameTransferFamily(effectiveDatabaseTypeForConnection(sourceConfig), effectiveDatabaseTypeForConnection(targetConfig)) && (selectedObjects.value.VIEW?.size ?? 0) > 0; }); const pendingSourceSchemaPrefill = ref(""); const pendingSelectedTablesPrefill = ref(null); @@ -408,7 +409,7 @@ async function loadObjects(isCancelled: () => boolean = () => false) { const config = store.getConfig(connectionId); const needsSchema = isSchemaAware(config?.db_type); const schema = needsSchema && schemaValue ? schemaValue : database; - const kinds = transferObjectKindsForDatabase(config?.db_type); + const kinds = transferObjectKindsForDatabase(effectiveDatabaseTypeForConnection(config)); const groups: Partial> = {}; for (const kind of kinds) { try { diff --git a/apps/desktop/src/components/transfer/__tests__/DataTransferDialog.spec.ts b/apps/desktop/src/components/transfer/__tests__/DataTransferDialog.spec.ts index e9ab0902cc..eba89698d6 100644 --- a/apps/desktop/src/components/transfer/__tests__/DataTransferDialog.spec.ts +++ b/apps/desktop/src/components/transfer/__tests__/DataTransferDialog.spec.ts @@ -67,7 +67,7 @@ describe("DataTransferDialog transfer prefill", () => { }); it("routes table-only database kinds through the table-list request", () => { - expect(dialogSource).toContain("const kinds = transferObjectKindsForDatabase(config?.db_type)"); + expect(dialogSource).toContain("const kinds = transferObjectKindsForDatabase(effectiveDatabaseTypeForConnection(config))"); expect(dialogSource).toContain("for (const kind of kinds)"); expect(dialogSource).toContain('if (kind === "TABLE")'); expect(dialogSource).toContain("await api.listTables(connectionId, database, schema"); @@ -75,7 +75,7 @@ describe("DataTransferDialog transfer prefill", () => { it("keeps MongoDB collection loading ahead of the generic object-kind path", () => { const mongoCollectionBranch = dialogSource.indexOf("if (isMongoConnection(connectionId))"); - const genericObjectKinds = dialogSource.indexOf("const kinds = transferObjectKindsForDatabase(config?.db_type)"); + const genericObjectKinds = dialogSource.indexOf("const kinds = transferObjectKindsForDatabase(effectiveDatabaseTypeForConnection(config))"); expect(mongoCollectionBranch).toBeGreaterThan(-1); expect(dialogSource).toContain("await api.mongoListCollections(connectionId, database)"); diff --git a/apps/desktop/src/composables/__tests__/useDatabaseOptions.spec.ts b/apps/desktop/src/composables/__tests__/useDatabaseOptions.spec.ts index 0ef3f09dac..29988c64d2 100644 --- a/apps/desktop/src/composables/__tests__/useDatabaseOptions.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDatabaseOptions.spec.ts @@ -135,6 +135,22 @@ describe("namespace options", () => { ).toEqual(["analytics"]); }); + it("falls back to the configured database when JDBC metadata returns no databases", () => { + expect( + databaseOptionsForConnection([], { + db_type: "jdbc", + database: "gbase_demo", + }), + ).toEqual(["gbase_demo"]); + + expect( + databaseOptionsForConnection([], { + db_type: "jdbc", + database_info: { currentDatabase: "gbase_from_metadata" }, + }), + ).toEqual(["gbase_from_metadata"]); + }); + it("preserves visible database filtering for catalog-scoped transfer options", async () => { mocks.listDorisCatalogDatabases.mockResolvedValue([{ name: "app" }, { name: "analytics" }]); diff --git a/apps/desktop/src/composables/useDatabaseOptions.ts b/apps/desktop/src/composables/useDatabaseOptions.ts index 026ca623a3..28ce03bcb0 100644 --- a/apps/desktop/src/composables/useDatabaseOptions.ts +++ b/apps/desktop/src/composables/useDatabaseOptions.ts @@ -7,7 +7,7 @@ import { isInternalDorisCatalog } from "@/lib/database/databaseFeatureSupport"; import type { CatalogInfo, ConnectionConfig } from "@/types/database"; import * as api from "@/lib/backend/api"; -type NamespaceOptionsConnection = Pick; +type NamespaceOptionsConnection = Pick & Partial>; export function catalogDatabaseOptionsKey(connectionId: string, catalog: string): string { return `${connectionId}:${catalog}`; } @@ -31,8 +31,10 @@ export function databaseAfterCatalogChange(currentDatabase: string, databaseOpti return databaseOptions.includes(currentDatabase) ? currentDatabase : ""; } -export function databaseOptionsForConnection(databaseNames: string[], connection: Pick | undefined): string[] { +export function databaseOptionsForConnection(databaseNames: string[], connection: (Pick & Partial>) | undefined): string[] { const names = filterDatabaseNamesForConnection(databaseNames, connection); + const configuredDatabase = connection?.database?.trim() || connection?.database_info?.currentDatabase?.trim(); + if (names.length === 0 && configuredDatabase) return [configuredDatabase]; if (names.length === 0 && usesTreeSchemaMode(connection?.db_type)) return [""]; return names; } diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index 6af9b15388..4cefce84d0 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -189,6 +189,40 @@ describe("jdbc dialect inference", () => { expect(inferJdbcDialect(damengConnection)).toBe("dameng"); }); + it("detects GBase JDBC connections for transfer dialect selection", () => { + const gbaseConnection = { + db_type: "jdbc" as const, + connection_string: "jdbc:gbase://localhost:5258/dbx_test", + jdbc_driver_class: "cn.gbase.Driver", + }; + + expect(inferJdbcDialect(gbaseConnection)).toBe("gbase"); + expect(effectiveDatabaseTypeForConnection(gbaseConnection)).toBe("gbase"); + + // GBase 8s is Informix-based and must stay on generic jdbc (no MySQL-family transfer dialect). + const gbase8sByUrl = { + db_type: "jdbc" as const, + connection_string: "jdbc:gbasedbt-sqli://localhost:9088/dbx_test:INFORMIXSERVER=ol_gbasedbt", + jdbc_driver_class: "com.gbasedbt.jdbc.Driver", + }; + const gbase8sByProfile = { + db_type: "jdbc" as const, + driver_profile: "gbase8s", + }; + const gbase8sProfileWithLegacyGbaseUrl = { + db_type: "jdbc" as const, + driver_profile: "gbase8s", + connection_string: "jdbc:gbase://localhost:5258/dbx_test", + jdbc_driver_class: "cn.gbase.Driver", + }; + expect(inferJdbcDialect(gbase8sByUrl)).toBe("informix"); + expect(effectiveDatabaseTypeForConnection(gbase8sByUrl)).toBe("informix"); + expect(inferJdbcDialect(gbase8sByProfile)).toBeUndefined(); + expect(effectiveDatabaseTypeForConnection(gbase8sByProfile)).toBe("jdbc"); + expect(inferJdbcDialect(gbase8sProfileWithLegacyGbaseUrl)).toBeUndefined(); + expect(effectiveDatabaseTypeForConnection(gbase8sProfileWithLegacyGbaseUrl)).toBe("jdbc"); + }); + it("uses Hive tree and execution semantics for Inceptor JDBC metadata", () => { const connection = { db_type: "jdbc" as const, diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 2ad0f1276b..0673100ba3 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -26,6 +26,8 @@ const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> = { type: "jdbc", patterns: [/phoenix/i] }, { type: "doris", patterns: [/doris/i] }, { type: "goldendb", patterns: [/jdbc:goldendb:/i, /goldendb/i] }, + // GBase 8a only: 8s (`gbasedbt`/`jdbc:gbasedbt-sqli`) is Informix-based and must stay on jdbc. + { type: "gbase", patterns: [/jdbc:gbase:/i, /cn\.gbase\./i, /gbase(?!dbt)(?!8s).*jdbc/i] }, { type: "mysql", patterns: [/kyuubi/i] }, { type: "hive", patterns: [/inceptor/i, /\bapache\s+hive\b/i, /org\.apache\.hive\.jdbc\.HiveDriver/i, /hive-jdbc/i] }, { type: "mysql", patterns: [/jdbc:mysql:/i, /mysql/i, /mariadb/i, /hive2/i] }, @@ -50,6 +52,7 @@ const JDBC_ASE_PROFILE_PATTERNS = [/(?:^|[\s_-])ase(?:$|[\s_-])/i, /\bsap[\s_-]+ export function inferJdbcDialect(connection?: JdbcDialectConnection): DatabaseType | undefined { if (!connection || connection.db_type !== "jdbc") return undefined; + if (isGbase8sProfile(connection.driver_profile)) return undefined; const haystack = [connection.driver_profile, connection.driver_label, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? []), connection.database_info?.productName, connection.database_info?.serverComment, connection.database_info?.driverName] .filter(Boolean) .join("\n"); diff --git a/apps/desktop/src/lib/database/transferObjectKinds.ts b/apps/desktop/src/lib/database/transferObjectKinds.ts index 6da60f956a..22b02658fd 100644 --- a/apps/desktop/src/lib/database/transferObjectKinds.ts +++ b/apps/desktop/src/lib/database/transferObjectKinds.ts @@ -17,6 +17,7 @@ const SQLSERVER_KINDS: TransferObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FU const FAMILY_BY_DB = new Map([ ["mysql", TransferObjectFamily.Mysql], + ["gbase", TransferObjectFamily.Mysql], ["postgres", TransferObjectFamily.Postgres], ["kingbase", TransferObjectFamily.Postgres], ["gaussdb", TransferObjectFamily.Postgres], diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index f369cb310c..c351e8d958 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -2502,7 +2502,7 @@ fn is_external_driver_method_unsupported(error: &str, method: &str) -> bool { || normalized.contains("method not found")) } -fn external_driver_query_params( +pub(crate) fn external_driver_query_params( config: &crate::models::connection::ConnectionConfig, sql: &str, database: &str, diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index bd313bc8cf..1f9002172b 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -7,7 +7,7 @@ use crate::connection::{config_for_pool_key, AppState, PoolKind}; use crate::db; use crate::db::agent_driver::AgentTableReadStartParams; use crate::db::mongo_driver::MongoDocumentResult; -use crate::models::connection::DatabaseType; +use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::object_source_sql::{build_executable_object_source_statements, EditableObjectSourceSqlInput}; use crate::query::{ agent_execute_query_params, pool_error_action, PoolErrorAction, QueryExecutionOptions, AGENT_PROTOCOL_MAX_ROWS, @@ -125,7 +125,7 @@ pub enum TransferObjectFamily { pub fn transfer_object_family(db_type: &DatabaseType) -> Option { match db_type { - DatabaseType::Mysql => Some(TransferObjectFamily::Mysql), + DatabaseType::Mysql | DatabaseType::Gbase => Some(TransferObjectFamily::Mysql), DatabaseType::Postgres | DatabaseType::Kingbase | DatabaseType::Gaussdb @@ -4864,6 +4864,19 @@ async fn execute_on_pool_once( ); client.execute_query(params).await } + PoolKind::ExternalDriver { config, session, .. } => { + let database = database_from_pool_key(pool_key) + .map(str::to_string) + .unwrap_or_else(|| config.effective_database().unwrap_or("").to_string()); + let params = crate::query::external_driver_query_params( + config.as_ref(), + sql, + &database, + None, + &QueryExecutionOptions { max_rows, fetch_size: max_rows, ..QueryExecutionOptions::default() }, + ); + session.invoke_with_timeout("executeQuery", params, None).await + } #[cfg(feature = "duckdb-sidecar")] PoolKind::DuckDbWorker(client) => { let client = client.clone(); @@ -4888,7 +4901,39 @@ fn catalog_from_pool_key(pool_key: &str) -> Option<&str> { pub async fn get_db_type(state: &AppState, connection_id: &str) -> Result { let configs = state.configs.read().await; - configs.get(connection_id).map(|c| c.db_type).ok_or_else(|| format!("Connection config not found: {connection_id}")) + configs + .get(connection_id) + .map(effective_transfer_database_type) + .ok_or_else(|| format!("Connection config not found: {connection_id}")) +} + +fn effective_transfer_database_type(config: &ConnectionConfig) -> DatabaseType { + if config.db_type != DatabaseType::Jdbc { + return config.db_type; + } + if config.driver_profile.as_deref().is_some_and(|profile| profile.eq_ignore_ascii_case("gbase8s")) { + return DatabaseType::Jdbc; + } + + let jdbc_identity = [ + config.driver_profile.as_deref().unwrap_or(""), + config.connection_string.as_deref().unwrap_or(""), + config.jdbc_driver_class.as_deref().unwrap_or(""), + ] + .join("\n") + .to_ascii_lowercase(); + // GBase 8a only: 8s (`gbasedbt`/`jdbc:gbasedbt-sqli`) is Informix-based and + // must not be mapped into the MySQL transfer family. + let is_gbase_8a = jdbc_identity.contains("jdbc:gbase:") + || jdbc_identity.contains("cn.gbase.") + || (jdbc_identity.contains("gbase") + && !jdbc_identity.contains("gbasedbt") + && !jdbc_identity.contains("gbase8s")); + if is_gbase_8a { + DatabaseType::Gbase + } else { + DatabaseType::Jdbc + } } pub async fn get_columns_for_transfer( @@ -4943,6 +4988,26 @@ pub async fn get_columns_for_transfer( let mut client = client.lock().await; return client.get_columns(&database, &schema, &table, None).await; } + if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(pool_key) { + let config = config.clone(); + let session = session.clone(); + let database = database.to_string(); + let schema = schema.to_string(); + let table = table.to_string(); + drop(connections); + return session + .invoke_with_timeout( + "getColumns", + serde_json::json!({ + "connection": config.as_ref(), + "database": database, + "schema": schema, + "table": table, + }), + None, + ) + .await; + } let pool = connections.get(pool_key).ok_or("Pool not found")?; let schema = schema.to_string(); let table = table.to_string(); @@ -8028,6 +8093,97 @@ where #[cfg(test)] mod tests { use super::*; + + fn jdbc_transfer_config(connection_string: &str, driver_class: &str, profile: &str) -> ConnectionConfig { + ConnectionConfig { + id: "test-jdbc".to_string(), + name: "Test JDBC".to_string(), + note: String::new(), + db_type: DatabaseType::Jdbc, + driver_profile: if profile.is_empty() { None } else { Some(profile.to_string()) }, + driver_label: None, + url_params: None, + agent_java_options: Vec::new(), + host: String::new(), + port: 0, + username: String::new(), + password: String::new(), + database: None, + default_schema: None, + visible_databases: None, + visible_database_patterns: None, + visible_schemas: None, + show_system_schemas: false, + attached_databases: Vec::new(), + init_script: None, + color: None, + transport_layers: Vec::new(), + connect_timeout_secs: 5, + query_timeout_secs: 30, + idle_timeout_secs: 60, + keepalive_interval_secs: 0, + ssl: false, + ca_cert_path: String::new(), + client_cert_path: String::new(), + client_key_path: String::new(), + sysdba: false, + oracle_connection_type: None, + connection_string: if connection_string.is_empty() { None } else { Some(connection_string.to_string()) }, + redis_connection_mode: None, + redis_sentinel_master: String::new(), + redis_sentinel_nodes: String::new(), + redis_sentinel_username: String::new(), + redis_sentinel_password: String::new(), + redis_sentinel_tls: false, + redis_cluster_nodes: String::new(), + redis_key_separator: crate::models::connection::default_redis_key_separator(), + redis_scan_page_size: None, + redis_database_aliases: Default::default(), + redis_key_templates: Vec::new(), + etcd_endpoints: String::new(), + gbase_server: String::new(), + informix_server: String::new(), + external_config: None, + jdbc_driver_class: if driver_class.is_empty() { None } else { Some(driver_class.to_string()) }, + jdbc_driver_paths: Vec::new(), + one_time: false, + save_password: true, + read_only: false, + is_production: false, + production_databases: vec![], + database_info: None, + docs_notes_path: None, + } + } + + #[test] + fn gbase_8s_jdbc_stays_generic_for_transfer() { + assert_eq!( + effective_transfer_database_type(&jdbc_transfer_config( + "jdbc:gbasedbt-sqli://localhost:9088/dbx_test:INFORMIXSERVER=ol_gbasedbt", + "com.gbasedbt.jdbc.Driver", + "" + )), + DatabaseType::Jdbc + ); + assert_eq!( + effective_transfer_database_type(&jdbc_transfer_config( + "jdbc:gbase://localhost:5258/dbx_test", + "cn.gbase.Driver", + "gbase8s" + )), + DatabaseType::Jdbc + ); + assert_eq!( + effective_transfer_database_type(&jdbc_transfer_config( + "jdbc:gbase://localhost:5258/dbx_test", + "com.gbase.jdbc.Driver", + "" + )), + DatabaseType::Gbase + ); + } + use serde_json::json; fn test_column(name: &str, data_type: &str) -> db::ColumnInfo { @@ -8345,6 +8501,7 @@ mod tests { assert!(same.contains(&Event)); // unsupported databases assert!(cross_family_transferable_object_kinds(&DatabaseType::Sqlite, &DatabaseType::Mysql).is_empty()); + assert_eq!(transfer_object_family(&DatabaseType::Gbase), Some(TransferObjectFamily::Mysql)); // postgres is not a validated cross-family source or target: // the executor rejects postgres sources and no dialect-aware // conversion exists for it (postgres <-> postgres stays same-family) @@ -12069,6 +12226,19 @@ SELECT 1 FROM dual"# production_databases: vec![], database_info: None, }; + let mut gbase_jdbc = config.clone(); + gbase_jdbc.db_type = DatabaseType::Jdbc; + gbase_jdbc.connection_string = Some("jdbc:gbase://127.0.0.1:5258/dbx_test".to_string()); + gbase_jdbc.jdbc_driver_class = Some("cn.gbase.Driver".to_string()); + assert_eq!(effective_transfer_database_type(&gbase_jdbc), DatabaseType::Gbase); + assert_eq!( + transfer_object_family(&effective_transfer_database_type(&gbase_jdbc)), + Some(TransferObjectFamily::Mysql) + ); + + gbase_jdbc.connection_string = Some("jdbc:unknown://127.0.0.1:1234/dbx_test".to_string()); + gbase_jdbc.jdbc_driver_class = Some("com.example.Driver".to_string()); + assert_eq!(effective_transfer_database_type(&gbase_jdbc), DatabaseType::Jdbc); assert_eq!(resolve_external_transfer_catalog_for_config(Some("paimon"), &config), Some("paimon")); assert_eq!(resolve_external_transfer_catalog_for_config(Some("default_catalog"), &config), None); }