From 72be4ad9eb38db626d02f0c43b8a936ddf1e83ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=88=E7=A7=8B?= <49514152+yiqiui@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:54:56 +0800 Subject: [PATCH] fix(gbase8s): support CREATE DATABASE and open mixed-locale databases --- .../com/dbx/agent/gbase8s/Gbase8sAgent.java | 127 ++++++++++++++++++ .../dbx/agent/gbase8s/Gbase8sAgentTest.java | 55 ++++++++ .../databaseNamespaceCreation.spec.ts | 36 +++++ .../lib/database/databaseNamespaceCreation.ts | 17 ++- crates/dbx-core/src/db_admin_sql.rs | 100 +++++++++++++- 5 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/database/databaseNamespaceCreation.spec.ts diff --git a/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java b/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java index 8e1ab2edb..12193b95b 100644 --- a/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java +++ b/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; public final class Gbase8sAgent extends ConfiguredJdbcAgent { private static final long METADATA_CACHE_TTL_MILLIS = 10_000L; @@ -47,6 +48,7 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { private long tableCacheTimeMillis; private List tableCache = Collections.emptyList(); private ConnectParams databaseListParams; + private final Map collateByDatabase = new ConcurrentHashMap<>(); public Gbase8sAgent() { super(GBASE8S_PROFILE); @@ -121,6 +123,79 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { return buildUrl(params); } + @Override + protected Connection openConnection(ConnectParams params) throws Exception { + return super.openConnection(withResolvedDatabaseLocale(params)); + } + + /** + * Return a copy of {@code params} whose {@code CLIENT_LOCALE}/{@code DB_LOCALE} match the real + * collation of the target database, so one connection profile can open every database on the + * instance regardless of locale. Falls back to the configured parameters whenever the collation + * cannot be resolved (custom connection string, catalog unavailable, or lookup error). + */ + private ConnectParams withResolvedDatabaseLocale(ConnectParams params) { + if (!params.getConnection_string().trim().isEmpty()) { + return params; + } + String database = params.getDatabase().trim().isEmpty() ? "sysmaster" : params.getDatabase().trim(); + String collate = resolveCollate(params, database); + if (collate.isEmpty() || collate.equalsIgnoreCase(currentLocaleOf(params))) { + return params; + } + ConnectParams localized = new ConnectParams( + params.getHost(), + params.getPort(), + params.getDatabase(), + params.getUsername(), + params.getPassword(), + overrideLocaleParams(params.getUrl_params(), collate), + params.getConnection_string(), + params.isMysql_compat_mode(), + params.getJdbc_driver_class(), + params.getJdbc_driver_paths() + ); + localized.setGbase_server(getGbaseServer(params)); + return localized; + } + + private String resolveCollate(ConnectParams params, String database) { + String key = params.getHost() + "|" + params.getPort() + "|" + getGbaseServer(params) + + "|" + database.toLowerCase(Locale.ROOT); + String cached = collateByDatabase.get(key); + if (cached != null) { + return cached; + } + String collate = ""; + try (Connection connection = super.openConnection(paramsForDatabase(params, "sysmaster")); + PreparedStatement stmt = connection.prepareStatement( + "SELECT dbs_collate FROM sysmaster:sysdbslocale WHERE LOWER(dbs_dbsname) = ?")) { + stmt.setString(1, database.toLowerCase(Locale.ROOT)); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + collate = trim(rs.getString(1)); + } + } + } catch (Exception ignored) { + // Keep the configured locale when the collation catalog is unreachable. + } + collateByDatabase.put(key, collate); + return collate; + } + + private static String currentLocaleOf(ConnectParams params) { + for (String segment : params.getUrl_params().split(";")) { + int equals = segment.indexOf('='); + if (equals < 0) { + continue; + } + if (segment.substring(0, equals).trim().equalsIgnoreCase("DB_LOCALE")) { + return segment.substring(equals + 1).trim(); + } + } + return ""; + } + @Override protected void afterConnect(ConnectParams params, Connection connection) { super.afterConnect(params, connection); @@ -131,6 +206,7 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { @Override protected void afterDisconnect() { databaseListParams = null; + collateByDatabase.clear(); clearMetadataCache(); } @@ -459,6 +535,57 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT)); } + /** + * Force {@code CLIENT_LOCALE} and {@code DB_LOCALE} in a {@code ;}-separated JDBC parameter + * string to {@code collate}, leaving every other parameter (and their order) untouched. + * + *

A GBase 8s / Informix instance can host databases with different locales, and the JDBC + * driver rejects a connection whose {@code DB_LOCALE} does not match the target database's real + * locale with "database locale information mismatch" (数据库地点信息不匹配). A single connection + * profile only pins one locale, so reusing it to open a differently-locale database fails. The + * agent therefore resolves each database's real collation from {@code sysmaster.sysdbslocale} + * and rewrites both locale parameters to it, so every database under the connection opens with + * its own locale. Setting {@code CLIENT_LOCALE} to the same value keeps client and database + * codesets identical, which avoids the cross-codeset conversion the bundled client cannot load. + */ + static String overrideLocaleParams(String jdbcParams, String collate) { + if (collate == null || collate.trim().isEmpty()) { + return jdbcParams; + } + String value = collate.trim(); + List segments = new ArrayList<>(); + if (jdbcParams != null && !jdbcParams.isEmpty()) { + for (String segment : jdbcParams.split(";")) { + int equals = segment.indexOf('='); + String key = (equals >= 0 ? segment.substring(0, equals) : segment).trim(); + if (key.equalsIgnoreCase("CLIENT_LOCALE")) { + segments.add("CLIENT_LOCALE=" + value); + } else if (key.equalsIgnoreCase("DB_LOCALE")) { + segments.add("DB_LOCALE=" + value); + } else { + segments.add(segment); + } + } + } + boolean hasClient = false; + boolean hasDb = false; + for (String segment : segments) { + String key = segment.substring(0, Math.max(segment.indexOf('='), 0)).trim(); + if (key.equalsIgnoreCase("CLIENT_LOCALE")) { + hasClient = true; + } else if (key.equalsIgnoreCase("DB_LOCALE")) { + hasDb = true; + } + } + if (!hasClient) { + segments.add("CLIENT_LOCALE=" + value); + } + if (!hasDb) { + segments.add("DB_LOCALE=" + value); + } + return String.join(";", segments); + } + private List queryDatabaseNamesFromSysmaster() { ConnectParams params = databaseListParams; if (params == null) { diff --git a/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java b/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java index 6cb030eca..3708a9caa 100644 --- a/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java +++ b/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java @@ -167,6 +167,61 @@ class Gbase8sAgentTest { ); } + @Test + void overrideLocaleParamsRewritesBothLocalesPreservingOthers() { + Assertions.assertEquals( + "GBASEDBTSERVER=gbase01;CLIENT_LOCALE=en_US.819;DB_LOCALE=en_US.819;NEWCODESET=UTF8,utf8,57372", + Gbase8sAgent.overrideLocaleParams( + "GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_CN.utf8;DB_LOCALE=zh_CN.utf8;NEWCODESET=UTF8,utf8,57372", + "en_US.819" + ) + ); + // Appends both when neither is present. + Assertions.assertEquals( + "GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_CN.57372;DB_LOCALE=zh_CN.57372", + Gbase8sAgent.overrideLocaleParams("GBASEDBTSERVER=gbase01", "zh_CN.57372") + ); + // Blank collate is a no-op. + Assertions.assertEquals( + "DB_LOCALE=zh_CN.utf8", + Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", " ") + ); + } + + @Test + void rewritesLocaleToTargetDatabaseCollateSoCrossLocaleDatabaseOpens() { + // The reported connection pins DB_LOCALE=zh_CN.utf8 for `dcss`; opening the differently + // locale `gbase8s` database (real collate en_US.819) must rewrite the locale to en_US.819. + ConnectParams params = new ConnectParams( + "192.168.5.65", + 9088, + "dcss", + "gbasedbt", + "secret", + "GBASEDBTSERVER=gbaseserver;DB_LOCALE=zh_CN.utf8;CLIENT_LOCALE=zh_CN.utf8;NEWCODESET=UTF8,utf8,57372;DELIMIDENT=y", + "", + false + ); + + String url = Gbase8sAgent.buildUrl( + new ConnectParams( + params.getHost(), + params.getPort(), + "gbase8s", + params.getUsername(), + params.getPassword(), + Gbase8sAgent.overrideLocaleParams(params.getUrl_params(), "en_US.819"), + params.getConnection_string(), + false + ) + ); + + Assertions.assertEquals( + "jdbc:gbasedbt-sqli://192.168.5.65:9088/gbase8s:GBASEDBTSERVER=gbaseserver;DB_LOCALE=en_US.819;CLIENT_LOCALE=en_US.819;NEWCODESET=UTF8,utf8,57372;DELIMIDENT=y", + url + ); + } + @Test void omitsOwnerSchemasWhenTheDatabaseCannotUseThemInDml() { List sql = new ArrayList<>(); diff --git a/apps/desktop/src/lib/__tests__/database/databaseNamespaceCreation.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseNamespaceCreation.spec.ts new file mode 100644 index 000000000..79074a8e4 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/databaseNamespaceCreation.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation"; + +type Conn = Parameters[0]; + +const databaseNode = { type: "database" as const, database: "dcss" }; + +describe("database namespace creation targets", () => { + it("offers Create Database (not Create Schema) for GBase 8s", () => { + const gbase8s = { db_type: "gbase", driver_profile: "gbase8s" } as Conn; + + expect(connectionNamespaceCreationTarget(gbase8s)).toBe("database"); + expect(databaseNodeNamespaceCreationTarget(gbase8s, databaseNode)).toBeNull(); + }); + + it("keeps the MySQL-style Create Schema target for GBase 8a", () => { + const gbase8a = { db_type: "gbase", driver_profile: "gbase8a" } as Conn; + + expect(connectionNamespaceCreationTarget(gbase8a)).toBeNull(); + expect(databaseNodeNamespaceCreationTarget(gbase8a, databaseNode)).toBe("schema"); + }); + + it("offers Create Database for standalone Informix", () => { + const informix = { db_type: "informix" } as Conn; + + expect(connectionNamespaceCreationTarget(informix)).toBe("database"); + expect(databaseNodeNamespaceCreationTarget(informix, databaseNode)).toBeNull(); + }); + + it("leaves unrelated engines on their existing targets", () => { + const postgres = { db_type: "postgres" } as Conn; + + expect(connectionNamespaceCreationTarget(postgres)).toBe("database"); + expect(databaseNodeNamespaceCreationTarget(postgres, databaseNode)).toBe("schema"); + }); +}); diff --git a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts index 3c616ae65..82b53a31d 100644 --- a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts +++ b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts @@ -73,7 +73,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = { impala: { deferred: "Impala database creation needs dedicated metadata validation first" }, spark: { deferred: "Spark database creation needs agent metadata validation first" }, db2: { database: "schema" }, - informix: { database: "schema" }, + informix: { connection: "database" }, neo4j: { deferred: "database creation depends on edition/admin privileges" }, cassandra: { deferred: "keyspace creation requires replication options" }, bigquery: { deferred: "dataset creation needs project/location options" }, @@ -100,19 +100,30 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = { mqtt: { deferred: "MQTT topics are managed via the MQTT console" }, } satisfies Record; +function namespaceCreationMatrixEntry(connection: NonNullable): DatabaseNamespaceCreationMatrixEntry { + // GBase 8s shares `db_type: "gbase"` with the MySQL-based GBase 8a but is Informix-derived: + // it has no `CREATE SCHEMA ` (a schema is the table owner) yet does support + // `CREATE DATABASE`. Route it to the Informix-family semantics instead of the shared `gbase` + // entry, which is written for GBase 8a. + if (connection.db_type === "gbase" && connection.driver_profile === "gbase8s") { + return { connection: "database" }; + } + return DATABASE_NAMESPACE_CREATION_MATRIX[connection.db_type]; +} + export function connectionNamespaceCreationTarget(connection: CreationConnection): ConnectionCreationTarget | null { if (!connection || connectionIsEffectivelyReadOnly(connection)) return null; if (connection.db_type === "mongodb" && isMongoLegacyDriverProfile(connection.driver_profile)) return null; if (connection.db_type === "sqlite" && (connection.host?.trim().toLowerCase() === ":memory:" || Boolean(connection.password))) { return null; } - const entry: DatabaseNamespaceCreationMatrixEntry = DATABASE_NAMESPACE_CREATION_MATRIX[connection.db_type]; + const entry: DatabaseNamespaceCreationMatrixEntry = namespaceCreationMatrixEntry(connection); return entry.connection ?? null; } export function databaseNodeNamespaceCreationTarget(connection: CreationConnection, node: Pick<{ type: TreeNodeType; database?: string | null }, "type" | "database">): DatabaseNodeCreationTarget | null { if (!connection || connectionIsEffectivelyReadOnly(connection) || node.type !== "database" || !node.database) return null; - const entry: DatabaseNamespaceCreationMatrixEntry = DATABASE_NAMESPACE_CREATION_MATRIX[connection.db_type]; + const entry: DatabaseNamespaceCreationMatrixEntry = namespaceCreationMatrixEntry(connection); return entry.database ?? null; } diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index 6cdeeadf7..d99a613b7 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -274,9 +274,18 @@ pub fn build_create_database_sql(options: CreateDatabaseSqlOptions) -> Result Result { - if !supports_create_database_target(options.database_type) { + if !supports_create_database_target(options.database_type, options.driver_profile.as_deref()) { return Err(format!("Creating databases is not supported for {}.", database_label(options.database_type))); } + if is_informix_family(options.database_type, options.driver_profile.as_deref()) { + // Informix / GBase 8s accept only a bare `CREATE DATABASE `. The new database + // inherits the instance default locale, and the MySQL `CHARACTER SET`/`COLLATE` + // clauses are invalid syntax here. Database names are ordinary identifiers, so quote + // them with the Informix rule (unquoted for simple identifiers) rather than the + // default double-quote path that `DatabaseType::Gbase` would otherwise take. + let name = quote_table_identifier(Some(DatabaseType::Informix), &options.name); + return Ok(format!("CREATE DATABASE {name};")); + } let name = quote_table_identifier(options.database_type, &options.name); let charset = clean_sql_option(options.charset.as_deref()); let collation = clean_sql_option(options.collation.as_deref()); @@ -288,7 +297,25 @@ fn build_create_database_statement(options: &CreateDatabaseSqlOptions) -> Result Ok(format!("CREATE DATABASE {name} CHARACTER SET {charset}{collate_clause};")) } -pub fn supports_create_database_target(database_type: Option) -> bool { +/// Whether the connection belongs to the Informix family: standalone Informix, or GBase 8s +/// (which shares `DatabaseType::Gbase` with the MySQL-based GBase 8a and is only distinguishable +/// through the `gbase8s` driver profile). Informix-family servers accept `CREATE DATABASE` but +/// have no `CREATE SCHEMA ` statement (a "schema" is the table owner), so the create +/// targets differ from the rest of the `Gbase` family. +fn is_informix_family(database_type: Option, driver_profile: Option<&str>) -> bool { + match database_type { + Some(DatabaseType::Informix) => true, + Some(DatabaseType::Gbase) => driver_profile.is_some_and(|profile| profile.eq_ignore_ascii_case("gbase8s")), + _ => false, + } +} + +pub fn supports_create_database_target(database_type: Option, driver_profile: Option<&str>) -> bool { + // Informix / GBase 8s create namespaces with `CREATE DATABASE`, so they are valid targets + // even though they are absent from the explicit list below. + if is_informix_family(database_type, driver_profile) { + return true; + } matches!( database_type, Some( @@ -341,7 +368,6 @@ pub fn supports_create_schema_target(database_type: Option) -> boo | DatabaseType::Trino | DatabaseType::PrestoSql | DatabaseType::H2 - | DatabaseType::Informix | DatabaseType::Xugu | DatabaseType::Oscar | DatabaseType::Iris @@ -1406,6 +1432,74 @@ mod tests { .contains("Creating databases is not supported")); } + #[test] + fn builds_informix_family_create_database_without_mysql_clause() { + // GBase 8s shares DatabaseType::Gbase with GBase 8a and is only identified by the + // gbase8s driver profile; it must emit a bare, unquoted CREATE DATABASE. + assert_eq!( + build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::Gbase), + driver_profile: Some("gbase8s".to_string()), + target: None, + parent: None, + name: "app_db".to_string(), + charset: Some("utf8mb4".to_string()), + collation: Some("utf8mb4_unicode_ci".to_string()), + }) + .unwrap(), + "CREATE DATABASE app_db;" + ); + // Standalone Informix behaves the same. + assert_eq!( + build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::Informix), + driver_profile: None, + target: None, + parent: None, + name: "app_db".to_string(), + charset: None, + collation: None, + }) + .unwrap(), + "CREATE DATABASE app_db;" + ); + } + + #[test] + fn gbase8a_is_not_a_create_database_target() { + assert!(build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::Gbase), + driver_profile: Some("gbase8a".to_string()), + target: None, + parent: None, + name: "app_db".to_string(), + charset: None, + collation: None, + }) + .unwrap_err() + .contains("Creating databases is not supported")); + } + + #[test] + fn rejects_create_schema_for_informix_effective_dialect() { + // The frontend collapses GBase 8s to the Informix dialect for schema DDL; Informix has no + // CREATE SCHEMA , so it must be rejected rather than emitting invalid SQL. + assert!(build_create_schema_sql(SchemaNameSqlOptions { + database_type: Some(DatabaseType::Informix), + name: "app".to_string(), + }) + .unwrap_err() + .contains("Creating schemas is not supported")); + } + + #[test] + fn create_database_target_distinguishes_gbase_profiles() { + assert!(supports_create_database_target(Some(DatabaseType::Gbase), Some("gbase8s"))); + assert!(!supports_create_database_target(Some(DatabaseType::Gbase), Some("gbase8a"))); + assert!(!supports_create_database_target(Some(DatabaseType::Gbase), None)); + assert!(supports_create_database_target(Some(DatabaseType::Informix), None)); + } + #[test] fn builds_mysql_database_property_charset_sql() { assert_eq!(