mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(gbase8s): support CREATE DATABASE and open mixed-locale databases
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user