fix(gbase8s): support CREATE DATABASE and open mixed-locale databases

This commit is contained in:
弈秋
2026-09-16 11:54:56 +08:00
committed by GitHub
parent 4bf073d5d1
commit 72be4ad9eb
5 changed files with 329 additions and 6 deletions
@@ -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<TableInfo> tableCache = Collections.emptyList();
private ConnectParams databaseListParams;
private final Map<String, String> 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.
*
* <p>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<String> 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<String> queryDatabaseNamesFromSysmaster() {
ConnectParams params = databaseListParams;
if (params == null) {
@@ -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<String> sql = new ArrayList<>();
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation";
type Conn = Parameters<typeof connectionNamespaceCreationTarget>[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");
});
});
@@ -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<DatabaseType, DatabaseNamespaceCreationMatrixEntry>;
function namespaceCreationMatrixEntry(connection: NonNullable<CreationConnection>): DatabaseNamespaceCreationMatrixEntry {
// GBase 8s shares `db_type: "gbase"` with the MySQL-based GBase 8a but is Informix-derived:
// it has no `CREATE SCHEMA <name>` (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;
}
+97 -3
View File
@@ -274,9 +274,18 @@ pub fn build_create_database_sql(options: CreateDatabaseSqlOptions) -> Result<St
}
fn build_create_database_statement(options: &CreateDatabaseSqlOptions) -> Result<String, String> {
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 <name>`. 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<DatabaseType>) -> 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 <name>` 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<DatabaseType>, 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<DatabaseType>, 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<DatabaseType>) -> 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 <name>, 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!(