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 50d1ae339e..40c39ec785 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 @@ -15,6 +15,7 @@ import com.dbx.agent.TableInfo; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.Statement; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -26,6 +27,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; import java.util.regex.Pattern; public final class Gbase8sAgent extends ConfiguredJdbcAgent { @@ -38,6 +40,17 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { // Upper bound on the sysmaster locale-probe connection so a hung probe cannot stall connect. private static final int LOCALE_PROBE_LOGIN_TIMEOUT_SECS = 10; + /** + * Leading directive comment emitted by the Rust admin-SQL layer for GBase 8s / Informix + * "Create Database" when the user picks a character set, e.g. + * {@code -- DBX_DB_LOCALE=zh_CN.utf8\nCREATE DATABASE mydb;}. Informix cannot express a new + * database's codeset in {@code CREATE DATABASE} — it inherits the creating session's DB_LOCALE — + * so the chosen locale is carried out-of-band and honored here by opening a sysmaster session + * pinned to that DB_LOCALE. + */ + private static final Pattern CREATE_DATABASE_LOCALE_DIRECTIVE = Pattern.compile( + "^\\s*--\\s*DBX_DB_LOCALE\\s*=\\s*(\\S+)\\s*\\r?\\n(.*)$", Pattern.DOTALL); + public static final JdbcAgentProfile GBASE8S_PROFILE = new JdbcAgentProfile( "com.gbasedbt.jdbc.Driver", "jdbc:gbasedbt-sqli://{host}:{port}/{database}:GBASEDBTSERVER=gbase8s", @@ -254,6 +267,12 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { @Override public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + CreateDatabaseLocaleDirective directive = parseCreateDatabaseLocaleDirective(sql); + if (directive != null) { + runCreateDatabaseWithLocale(directive.statement(), directive.locale()); + clearMetadataCache(); + return new QueryResult(Collections.emptyList(), Collections.emptyList(), 0, 0); + } QueryResult result = super.executeQuery(sql, schema, options); if (mayChangeMetadata(sql)) { clearMetadataCache(); @@ -261,6 +280,71 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { return result; } + /** + * A {@code CREATE DATABASE} statement plus the DB_LOCALE carried by its leading directive + * comment. Package visible so the routing decision is directly testable without a live + * connection. + */ + record CreateDatabaseLocaleDirective(String locale, String statement) { + } + + /** + * Parse {@code sql} as a directive-prefixed {@code CREATE DATABASE} (as emitted by the Rust + * admin-SQL layer), returning {@code null} for anything else so it falls through to the + * normal query path. + */ + static CreateDatabaseLocaleDirective parseCreateDatabaseLocaleDirective(String sql) { + Matcher directive = sql == null ? null : CREATE_DATABASE_LOCALE_DIRECTIVE.matcher(sql); + if (directive == null || !directive.matches()) { + return null; + } + String statement = directive.group(2).trim(); + if (!statement.regionMatches(true, 0, "CREATE DATABASE", 0, "CREATE DATABASE".length())) { + return null; + } + return new CreateDatabaseLocaleDirective(directive.group(1).trim(), statement); + } + + /** + * Run a {@code CREATE DATABASE} statement on a sysmaster session whose {@code DB_LOCALE} is + * pinned to {@code locale}, so the freshly created database inherits that codeset (Informix has + * no charset clause in {@code CREATE DATABASE}). Falls back to the configured locale when + * {@code locale} is blank or unsafe. + */ + private void runCreateDatabaseWithLocale(String statement, String locale) { + ConnectParams base = databaseListParams; + if (base == null) { + throw new IllegalStateException("Not connected"); + } + ConnectParams pinned = base; + if (locale != null && SAFE_DATABASE_LOCALE.matcher(locale).matches()) { + pinned = new ConnectParams( + base.getHost(), + base.getPort(), + base.getDatabase(), + base.getUsername(), + base.getPassword(), + overrideLocaleParams(base.getUrl_params(), locale), + base.getConnection_string(), + base.isMysql_compat_mode(), + base.getJdbc_driver_class(), + base.getJdbc_driver_paths() + ); + pinned.setGbase_server(base.getGbase_server()); + } + try (Connection connection = super.openConnection(pinned); + Statement stmt = connection.createStatement()) { + stmt.execute(stripTrailingSemicolon(statement)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String stripTrailingSemicolon(String sql) { + String trimmed = sql.trim(); + return trimmed.endsWith(";") ? trimmed.substring(0, trimmed.length() - 1).trim() : trimmed; + } + @Override public List listDatabases() { List cached = cachedDatabases(); 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 373d267fc4..64a08345c1 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 @@ -240,6 +240,23 @@ class Gbase8sAgentTest { ); } + @Test + void createDatabaseLocaleDirectiveIsRoutedToLocaleSession() { + // The directive branch must fire only for a leading DBX_DB_LOCALE directive on a CREATE + // DATABASE statement; anything else returns null and falls through to the normal query + // path. Parsing is tested directly because both paths throw identically when unconnected. + Gbase8sAgent.CreateDatabaseLocaleDirective directive = Gbase8sAgent.parseCreateDatabaseLocaleDirective( + "-- DBX_DB_LOCALE=zh_CN.utf8\nCREATE DATABASE app_db;" + ); + Assertions.assertNotNull(directive); + Assertions.assertEquals("zh_CN.utf8", directive.locale()); + Assertions.assertEquals("CREATE DATABASE app_db;", directive.statement()); + Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective("CREATE DATABASE app_db;")); + Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective( + "-- DBX_DB_LOCALE=zh_CN.utf8\nDROP DATABASE app_db;")); + Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective(null)); + } + @Test void omitsOwnerSchemasWhenTheDatabaseCannotUseThemInDml() { List sql = new ArrayList<>(); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue index d71803a9b9..8df4c4e263 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue @@ -538,7 +538,9 @@ watch( -
+ +
{ return connectionNamespaceCreationTarget(config) === "database" && supportsCreateDatabaseCharset(config?.db_type, config?.driver_profile); }); +const canSetCreateDatabaseLocale = computed(() => { + const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; + return connectionNamespaceCreationTarget(config) === "database" && supportsCreateDatabaseLocale(config?.db_type, config?.driver_profile); +}); + const canDropDatabase = computed(() => { const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; return activeNode.value.type === "database" && !isSqlServerLinkedNode(activeNode.value) && supportsDatabaseCreation(config?.db_type); @@ -3805,7 +3810,15 @@ function openCreateDatabaseDialog() { createDatabaseCharsetOptions.value = fallbackCreateDatabaseCharset.charsets; createDatabaseCollationsByCharset.value = fallbackCreateDatabaseCharset.collationsByCharset; showCreateDatabaseDialog.value = true; - if (canSetCreateDatabaseCharset.value) { + if (canSetCreateDatabaseLocale.value) { + // GBase 8s / Informix: the "charset" control selects the new database's DB_LOCALE, which the + // agent applies by opening the CREATE DATABASE session with it. Default to UTF-8 so new + // databases can store Chinese; no server charset catalog query is needed. + createDatabaseCharsetOptions.value = [...GBASE8S_DATABASE_LOCALES]; + createDatabaseCollationsByCharset.value = {}; + createDatabaseCharset.value = DEFAULT_GBASE8S_DATABASE_LOCALE; + createDatabaseCollation.value = ""; + } else if (canSetCreateDatabaseCharset.value) { void loadCreateDatabaseCharsetMetadata(); } void loadCreateDatabaseUsers(); @@ -5130,7 +5143,7 @@ function databaseDialogCapabilities() { return { showCreateDatabaseDialog, createDatabaseName, - canSetCreateDatabaseCharset: canSetCreateDatabaseCharset.value, + canSetCreateDatabaseCharset: canSetCreateDatabaseCharset.value || canSetCreateDatabaseLocale.value, createDatabaseCharset, createDatabaseCharsetOptions, createDatabaseCharsetLoading, diff --git a/apps/desktop/src/lib/__tests__/database/createDatabaseLocale.spec.ts b/apps/desktop/src/lib/__tests__/database/createDatabaseLocale.spec.ts new file mode 100644 index 0000000000..4c4c11fa09 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/createDatabaseLocale.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { supportsCreateDatabaseCharset, supportsCreateDatabaseLocale } from "@/lib/database/createDatabaseSql"; +import { DEFAULT_GBASE8S_DATABASE_LOCALE, GBASE8S_DATABASE_LOCALES } from "@/lib/database/createDatabaseCharsetOptions"; + +describe("GBase 8s / Informix create-database locale support", () => { + it("offers a locale picker only for the gbase8s profile", () => { + expect(supportsCreateDatabaseLocale("gbase", "gbase8s")).toBe(true); + // Plain Informix's agent has no directive handling, so the locale picker must stay hidden + // there until InformixAgent implements it; showing it would silently ignore the choice. + expect(supportsCreateDatabaseLocale("informix", undefined)).toBe(false); + }); + + it("does not treat GBase 8a or MySQL as locale-picking", () => { + expect(supportsCreateDatabaseLocale("gbase", "gbase8a")).toBe(false); + expect(supportsCreateDatabaseLocale("mysql", "mysql")).toBe(false); + // The MySQL charset path stays independent of the Informix locale path. + expect(supportsCreateDatabaseCharset("mysql", "mysql")).toBe(true); + expect(supportsCreateDatabaseCharset("gbase", "gbase8s")).toBe(false); + }); + + it("defaults to a Chinese-capable UTF-8 locale", () => { + expect(GBASE8S_DATABASE_LOCALES).toContain("zh_CN.utf8"); + expect(DEFAULT_GBASE8S_DATABASE_LOCALE).toBe("zh_CN.utf8"); + }); +}); diff --git a/apps/desktop/src/lib/database/createDatabaseCharsetOptions.ts b/apps/desktop/src/lib/database/createDatabaseCharsetOptions.ts index d84e662963..eb92dc429c 100644 --- a/apps/desktop/src/lib/database/createDatabaseCharsetOptions.ts +++ b/apps/desktop/src/lib/database/createDatabaseCharsetOptions.ts @@ -10,6 +10,12 @@ export interface CreateDatabaseCharsetMetadata { export const CREATE_DATABASE_CHARSET_OPTIONS = ["utf8mb4", "utf8", "gbk", "latin1", "ascii", "big5", "gb2312", "utf16", "utf32"] as const; +// Common GBase 8s / Informix database locales (language_territory.codeset). The default is a +// UTF-8 locale so newly created databases can store Chinese; en_US.819 is a typical Informix +// instance default and cannot hold CJK characters. +export const GBASE8S_DATABASE_LOCALES = ["zh_CN.utf8", "en_US.utf8", "ja_JP.utf8", "ko_KR.utf8", "zh_CN.gbk", "zh_CN.gb18030", "en_US.819"] as const; +export const DEFAULT_GBASE8S_DATABASE_LOCALE = "zh_CN.utf8"; + const FALLBACK_COLLATION_OPTIONS: Record = { ascii: ["ascii_general_ci", "ascii_bin"], big5: ["big5_chinese_ci", "big5_bin"], diff --git a/apps/desktop/src/lib/database/createDatabaseSql.ts b/apps/desktop/src/lib/database/createDatabaseSql.ts index c829d8938c..c2e4afb874 100644 --- a/apps/desktop/src/lib/database/createDatabaseSql.ts +++ b/apps/desktop/src/lib/database/createDatabaseSql.ts @@ -18,6 +18,14 @@ export function supportsCreateDatabaseCharset(databaseType?: DatabaseType, drive return MYSQL_COMPATIBLE_TYPES.has(databaseType as DatabaseType) || (!!driverProfile && MYSQL_COMPATIBLE_PROFILES.has(driverProfile.toLowerCase())); } +// GBase 8s creates a database whose codeset follows the creating session's DB_LOCALE; there is +// no charset clause in the SQL. The create dialog still offers a locale picker, and the chosen +// value is applied by the gbase8s agent opening the CREATE DATABASE session with that DB_LOCALE. +// Plain Informix is excluded until InformixAgent learns to honor the locale directive. +export function supportsCreateDatabaseLocale(databaseType?: DatabaseType, driverProfile?: string | null): boolean { + return databaseType === "gbase" && driverProfile === "gbase8s"; +} + export function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise { return api.buildCreateDatabaseSql(options); } diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index d99a613b75..06b727dba4 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -284,7 +284,14 @@ fn build_create_database_statement(options: &CreateDatabaseSqlOptions) -> Result // 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 locale = clean_sql_option(options.charset.as_deref()); + if locale.is_empty() { + return Ok(format!("CREATE DATABASE {name};")); + } + // Informix has no charset clause in `CREATE DATABASE`; the new database inherits the + // creating session's DB_LOCALE. Carry the chosen locale as a directive the GBase 8s agent + // honors by running the statement on a sysmaster session pinned to that DB_LOCALE. + return Ok(format!("-- DBX_DB_LOCALE={locale}\nCREATE DATABASE {name};")); } let name = quote_table_identifier(options.database_type, &options.name); let charset = clean_sql_option(options.charset.as_deref()); @@ -1435,7 +1442,7 @@ mod tests { #[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. + // gbase8s driver profile; with no locale chosen it emits a bare, unquoted CREATE DATABASE. assert_eq!( build_create_database_sql(CreateDatabaseSqlOptions { database_type: Some(DatabaseType::Gbase), @@ -1443,8 +1450,8 @@ mod tests { target: None, parent: None, name: "app_db".to_string(), - charset: Some("utf8mb4".to_string()), - collation: Some("utf8mb4_unicode_ci".to_string()), + charset: None, + collation: None, }) .unwrap(), "CREATE DATABASE app_db;" @@ -1465,6 +1472,25 @@ mod tests { ); } + #[test] + fn informix_family_create_database_carries_locale_directive() { + // A chosen charset becomes the new database's DB_LOCALE via a directive the agent honors, + // because Informix cannot express a codeset in 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("zh_CN.utf8".to_string()), + collation: None, + }) + .unwrap(), + "-- DBX_DB_LOCALE=zh_CN.utf8\nCREATE DATABASE app_db;" + ); + } + #[test] fn gbase8a_is_not_a_create_database_target() { assert!(build_create_database_sql(CreateDatabaseSqlOptions {