From 6eb6ff9a31525fcb01fb36c521460d4fdbbb7307 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Wed, 8 Apr 2026 15:29:02 +0800 Subject: [PATCH 01/15] feat: expand create database charset and collation options --- src-tauri/src/commands/connection.rs | 147 ++++++++++++++++- src-tauri/src/lib.rs | 2 + .../tests/mariadb_command_integration.rs | 81 ++++++++++ .../mysql_stateful_command_integration.rs | 140 ++++++++++++++++ .../business/Sidebar/ConnectionList.tsx | 150 ++++++++++++++++-- src/services/api.ts | 4 + src/services/api.unit.test.ts | 9 ++ src/services/mocks.service.test.ts | 101 +++++++++++- src/services/mocks.ts | 44 +++++ 9 files changed, 663 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 452f34e0..12483119 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -419,6 +419,118 @@ pub async fn test_connection_ephemeral( }) } +#[tauri::command] +pub async fn get_mysql_charsets_by_id( + state: State<'_, AppState>, + id: i64, +) -> Result, String> { + super::execute_with_retry(&state, id, None, |driver| async move { + let result = driver.execute_query("SHOW CHARACTER SET".to_string()).await?; + let mut charsets: Vec = result + .data + .iter() + .filter_map(|row| { + row.get("Charset") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + charsets.sort(); + Ok(charsets) + }) + .await +} + +#[tauri::command] +pub async fn get_mysql_collations_by_id( + state: State<'_, AppState>, + id: i64, + charset: Option, +) -> Result, String> { + let sql = match &charset { + Some(cs) if is_safe_option_token(cs) => { + format!("SHOW COLLATION WHERE Charset = '{}'", cs) + } + Some(cs) => { + return Err(format!("[VALIDATION_ERROR] Invalid charset: {}", cs)); + } + None => "SHOW COLLATION".to_string(), + }; + super::execute_with_retry(&state, id, None, |driver| { + let sql = sql.clone(); + async move { + let result = driver.execute_query(sql).await?; + let mut collations: Vec = result + .data + .iter() + .filter_map(|row| { + row.get("Collation") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + collations.sort(); + Ok(collations) + } + }) + .await +} + +pub async fn get_mysql_charsets_by_id_direct( + state: &AppState, + id: i64, +) -> Result, String> { + super::execute_with_retry_from_app_state(state, id, None, |driver| async move { + let result = driver.execute_query("SHOW CHARACTER SET".to_string()).await?; + let mut charsets: Vec = result + .data + .iter() + .filter_map(|row| { + row.get("Charset") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + charsets.sort(); + Ok(charsets) + }) + .await +} + +pub async fn get_mysql_collations_by_id_direct( + state: &AppState, + id: i64, + charset: Option, +) -> Result, String> { + let sql = match &charset { + Some(cs) if is_safe_option_token(cs) => { + format!("SHOW COLLATION WHERE Charset = '{}'", cs) + } + Some(cs) => { + return Err(format!("[VALIDATION_ERROR] Invalid charset: {}", cs)); + } + None => "SHOW COLLATION".to_string(), + }; + super::execute_with_retry_from_app_state(state, id, None, |driver| { + let sql = sql.clone(); + async move { + let result = driver.execute_query(sql).await?; + let mut collations: Vec = result + .data + .iter() + .filter_map(|row| { + row.get("Collation") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + collations.sort(); + Ok(collations) + } + }) + .await +} + #[tauri::command] pub async fn get_connections(state: State<'_, AppState>) -> Result, String> { let local_db = { @@ -553,8 +665,8 @@ mod tests { validate_database_name, CreateDatabasePayload, }; use super::{ - normalize_create_database_error, normalize_option_token, quote_clickhouse_ident, - quote_mssql_ident, quote_mysql_ident, quote_pg_ident, + is_safe_option_token, normalize_create_database_error, normalize_option_token, + quote_clickhouse_ident, quote_mssql_ident, quote_mysql_ident, quote_pg_ident, }; use crate::connection_input::normalize_connection_form; use crate::models::ConnectionForm; @@ -734,4 +846,35 @@ mod tests { .unwrap_err(); assert!(err.contains("does not support charset option")); } + + #[test] + fn get_mysql_collations_charset_validation_rejects_unsafe_tokens() { + // Verify the validation logic used by get_mysql_collations_by_id/_direct. + // A charset with spaces or semicolons must be rejected. + assert!(!is_safe_option_token("utf8 mb4")); + assert!(!is_safe_option_token("utf8;drop")); + assert!(!is_safe_option_token("")); + } + + #[test] + fn get_mysql_collations_charset_validation_accepts_valid_charsets() { + // All standard MySQL charset names must pass the token check. + let valid = [ + "utf8mb4", + "utf8", + "latin1", + "gbk", + "gb18030", + "ascii", + "binary", + "utf8mb4_0900_ai_ci", + ]; + for cs in valid { + assert!( + is_safe_option_token(cs), + "expected '{}' to be accepted", + cs + ); + } + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d4fb21f2..7f1d7d6b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -125,6 +125,8 @@ pub fn run() { commands::connection::list_databases, commands::connection::list_databases_by_id, commands::connection::create_database_by_id, + commands::connection::get_mysql_charsets_by_id, + commands::connection::get_mysql_collations_by_id, commands::storage::save_query, commands::storage::get_saved_queries, commands::storage::update_saved_query, diff --git a/src-tauri/tests/mariadb_command_integration.rs b/src-tauri/tests/mariadb_command_integration.rs index bba658a9..7940a60d 100644 --- a/src-tauri/tests/mariadb_command_integration.rs +++ b/src-tauri/tests/mariadb_command_integration.rs @@ -335,3 +335,84 @@ async fn test_mariadb_command_get_table_data_by_conn_invalid_pagination_returns_ cleanup_table(&form, &table).await; } + +#[tokio::test] +#[ignore] +async fn test_mariadb_show_character_set_returns_standard_charsets() { + let docker = (!mariadb_context::should_reuse_local_db()).then(Cli::default); + let (_mariadb_container, form) = + mariadb_context::mariadb_form_from_test_context(docker.as_ref()); + wait_until_mariadb_ready(&form).await; + + let driver = MysqlDriver::connect(&form) + .await + .expect("failed to connect mariadb driver"); + + let result = driver + .execute_query("SHOW CHARACTER SET".to_string()) + .await + .expect("SHOW CHARACTER SET should succeed"); + + let charsets: Vec = result + .data + .iter() + .filter_map(|row| { + row.get("Charset") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + + assert!(!charsets.is_empty(), "charset list must not be empty"); + assert!(charsets.iter().any(|c| c == "utf8mb4"), "utf8mb4 must be present"); + assert!(charsets.iter().any(|c| c == "latin1"), "latin1 must be present"); + assert!( + charsets.iter().all(|c| !c.trim().is_empty()), + "all charset names must be non-empty" + ); + + driver.close().await; +} + +#[tokio::test] +#[ignore] +async fn test_mariadb_show_collation_for_utf8mb4_returns_matching_collations() { + let docker = (!mariadb_context::should_reuse_local_db()).then(Cli::default); + let (_mariadb_container, form) = + mariadb_context::mariadb_form_from_test_context(docker.as_ref()); + wait_until_mariadb_ready(&form).await; + + let driver = MysqlDriver::connect(&form) + .await + .expect("failed to connect mariadb driver"); + + let result = driver + .execute_query("SHOW COLLATION WHERE Charset = 'utf8mb4'".to_string()) + .await + .expect("SHOW COLLATION should succeed"); + + let collations: Vec = result + .data + .iter() + .filter_map(|row| { + row.get("Collation") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + + assert!(!collations.is_empty(), "utf8mb4 collation list must not be empty"); + assert!( + collations.iter().any(|c| c == "utf8mb4_general_ci"), + "utf8mb4_general_ci must be present" + ); + for col in &collations { + assert!( + col.starts_with("utf8mb4"), + "collation '{}' does not belong to utf8mb4", + col + ); + } + + driver.close().await; +} diff --git a/src-tauri/tests/mysql_stateful_command_integration.rs b/src-tauri/tests/mysql_stateful_command_integration.rs index fcbdb796..07e7e767 100644 --- a/src-tauri/tests/mysql_stateful_command_integration.rs +++ b/src-tauri/tests/mysql_stateful_command_integration.rs @@ -856,3 +856,143 @@ async fn test_mysql_command_ai_minimal_provider_conversation_and_chat_flow() { .await .expect("ai_delete_provider should succeed"); } + +#[tokio::test] +#[ignore] +async fn test_mysql_command_get_charsets_by_id_returns_standard_charsets() { + let docker = (!mysql_context::should_reuse_local_db()).then(Cli::default); + let (_mysql_container, form) = mysql_context::mysql_form_from_test_context(docker.as_ref()); + wait_until_mysql_ready(&form).await; + let state = init_state_with_local_db().await; + let conn_id = create_mysql_connection_for_state(&state, &form, "get-charsets").await; + + let charsets = connection::get_mysql_charsets_by_id_direct(&state, conn_id) + .await + .expect("get_mysql_charsets_by_id should succeed"); + + assert!(!charsets.is_empty(), "charset list must not be empty"); + assert!( + charsets.iter().any(|c| c == "utf8mb4"), + "utf8mb4 must be present" + ); + assert!( + charsets.iter().any(|c| c == "utf8"), + "utf8 must be present" + ); + assert!( + charsets.iter().any(|c| c == "latin1"), + "latin1 must be present" + ); + assert!( + charsets.windows(2).all(|w| w[0] <= w[1]), + "charsets must be sorted" + ); + assert!( + charsets.iter().all(|c| !c.trim().is_empty()), + "all charset names must be non-empty" + ); + + let _ = connection::delete_connection_direct(&state, conn_id).await; +} + +#[tokio::test] +#[ignore] +async fn test_mysql_command_get_charsets_by_id_invalid_connection_returns_error() { + let state = init_state_with_local_db().await; + let result = connection::get_mysql_charsets_by_id_direct(&state, -999_999).await; + assert!(result.is_err()); + assert!(!result.err().unwrap_or_default().trim().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn test_mysql_command_get_collations_by_id_without_charset_returns_all() { + let docker = (!mysql_context::should_reuse_local_db()).then(Cli::default); + let (_mysql_container, form) = mysql_context::mysql_form_from_test_context(docker.as_ref()); + wait_until_mysql_ready(&form).await; + let state = init_state_with_local_db().await; + let conn_id = create_mysql_connection_for_state(&state, &form, "get-collations-all").await; + + let collations = connection::get_mysql_collations_by_id_direct(&state, conn_id, None) + .await + .expect("get_mysql_collations_by_id should succeed without charset filter"); + + assert!(!collations.is_empty(), "collation list must not be empty"); + assert!( + collations.iter().any(|c| c == "utf8mb4_general_ci"), + "utf8mb4_general_ci must be present" + ); + assert!( + collations.iter().any(|c| c == "utf8_general_ci"), + "utf8_general_ci must be present" + ); + assert!( + collations.windows(2).all(|w| w[0] <= w[1]), + "collations must be sorted" + ); + + let _ = connection::delete_connection_direct(&state, conn_id).await; +} + +#[tokio::test] +#[ignore] +async fn test_mysql_command_get_collations_by_id_with_charset_returns_only_matching() { + let docker = (!mysql_context::should_reuse_local_db()).then(Cli::default); + let (_mysql_container, form) = mysql_context::mysql_form_from_test_context(docker.as_ref()); + wait_until_mysql_ready(&form).await; + let state = init_state_with_local_db().await; + let conn_id = + create_mysql_connection_for_state(&state, &form, "get-collations-filtered").await; + + let collations = connection::get_mysql_collations_by_id_direct( + &state, + conn_id, + Some("utf8mb4".to_string()), + ) + .await + .expect("get_mysql_collations_by_id should succeed with charset filter"); + + assert!(!collations.is_empty(), "utf8mb4 collation list must not be empty"); + assert!( + collations.iter().any(|c| c == "utf8mb4_general_ci"), + "utf8mb4_general_ci must be present" + ); + // All returned collations must start with the requested charset prefix + for col in &collations { + assert!( + col.starts_with("utf8mb4"), + "collation '{}' does not belong to utf8mb4", + col + ); + } + + let _ = connection::delete_connection_direct(&state, conn_id).await; +} + +#[tokio::test] +#[ignore] +async fn test_mysql_command_get_collations_by_id_with_invalid_charset_returns_error() { + let docker = (!mysql_context::should_reuse_local_db()).then(Cli::default); + let (_mysql_container, form) = mysql_context::mysql_form_from_test_context(docker.as_ref()); + wait_until_mysql_ready(&form).await; + let state = init_state_with_local_db().await; + let conn_id = + create_mysql_connection_for_state(&state, &form, "get-collations-invalid-cs").await; + + let result = connection::get_mysql_collations_by_id_direct( + &state, + conn_id, + Some("utf8 mb4; DROP TABLE users".to_string()), + ) + .await; + + assert!(result.is_err()); + let err = result.err().unwrap_or_default(); + assert!( + err.contains("[VALIDATION_ERROR]"), + "expected VALIDATION_ERROR, got: {}", + err + ); + + let _ = connection::delete_connection_direct(&state, conn_id).await; +} diff --git a/src/components/business/Sidebar/ConnectionList.tsx b/src/components/business/Sidebar/ConnectionList.tsx index c0756bfc..f7f608ed 100644 --- a/src/components/business/Sidebar/ConnectionList.tsx +++ b/src/components/business/Sidebar/ConnectionList.tsx @@ -178,14 +178,50 @@ const defaultCreateDatabaseForm: CreateDatabaseForm = { }; const createDbNoneOption = "__none__"; -const mysqlCharsetOptions = ["utf8mb4", "utf8", "latin1"]; -const mysqlCollationOptions = [ - "utf8mb4_general_ci", - "utf8mb4_unicode_ci", - "utf8_general_ci", - "latin1_swedish_ci", +const postgresEncodingOptions = [ + "UTF8", + "SQL_ASCII", + "BIG5", + "EUC_CN", + "EUC_JP", + "EUC_JIS_2004", + "EUC_KR", + "EUC_TW", + "GB18030", + "GBK", + "ISO_8859_5", + "ISO_8859_6", + "ISO_8859_7", + "ISO_8859_8", + "JOHAB", + "KOI8R", + "KOI8U", + "LATIN1", + "LATIN2", + "LATIN3", + "LATIN4", + "LATIN5", + "LATIN6", + "LATIN7", + "LATIN8", + "LATIN9", + "LATIN10", + "MULE_INTERNAL", + "SHIFT_JIS_2004", + "SJIS", + "UHC", + "WIN866", + "WIN874", + "WIN1250", + "WIN1251", + "WIN1252", + "WIN1253", + "WIN1254", + "WIN1255", + "WIN1256", + "WIN1257", + "WIN1258", ]; -const postgresEncodingOptions = ["UTF8", "LATIN1", "SQL_ASCII"]; const postgresLocaleOptions = [ "en_US.UTF-8", "C", @@ -195,9 +231,60 @@ const postgresLocaleOptions = [ ]; const mssqlCollationOptions = [ "SQL_Latin1_General_CP1_CI_AS", + "SQL_Latin1_General_CP1_CS_AS", + "SQL_Latin1_General_CP1_CI_AI", + "SQL_Latin1_General_CP1_CS_AI", + "Latin1_General_CI_AS", + "Latin1_General_CS_AS", + "Latin1_General_BIN", + "Latin1_General_BIN2", + "Latin1_General_100_CI_AS", + "Latin1_General_100_CS_AS", + "Latin1_General_100_CI_AI", + "Latin1_General_100_BIN2", "Latin1_General_100_CI_AS_SC", + "Latin1_General_100_CS_AS_SC", + "Latin1_General_100_CI_AI_SC", + "Latin1_General_100_BIN2_UTF8", + "Latin1_General_100_CI_AS_SC_UTF8", + "Latin1_General_100_CI_AI_SC_UTF8", + "SQL_Latin1_General_CP850_CI_AS", + "Modern_Spanish_CI_AS", + "Modern_Spanish_100_CI_AS", + "French_CI_AS", + "French_100_CI_AS", + "German_PhoneBook_CI_AS", + "German_PhoneBook_100_CI_AS", + "Turkish_CI_AS", + "Turkish_100_CI_AS", + "Cyrillic_General_CI_AS", + "Cyrillic_General_100_CI_AS", "Chinese_PRC_CI_AS", + "Chinese_PRC_CS_AS", + "Chinese_PRC_100_CI_AS", + "Chinese_PRC_100_CS_AS", + "Chinese_PRC_100_BIN2", + "Chinese_PRC_100_CI_AS_SC", + "Chinese_PRC_100_CI_AS_SC_UTF8", + "Chinese_Simplified_Pinyin_100_CI_AS", + "Chinese_Simplified_Pinyin_100_CS_AS", + "Chinese_Traditional_Stroke_Order_100_CI_AS", "Japanese_CI_AS", + "Japanese_CS_AS", + "Japanese_BIN2", + "Japanese_XJIS_100_CI_AS", + "Japanese_XJIS_100_CS_AS", + "Japanese_XJIS_100_BIN2", + "Japanese_XJIS_140_CI_AS", + "Japanese_XJIS_140_CI_AS_KS_WS", + "Japanese_Bushu_Kakusu_100_CI_AS", + "Japanese_Bushu_Kakusu_140_CI_AS", + "Korean_Wansung_CI_AS", + "Korean_Wansung_100_CI_AS", + "Korean_Wansung_140_CI_AS", + "Korean_Unicode_CI_AS", + "Korean_Unicode_100_CI_AS", + "Korean_Unicode_140_CI_AS", ]; interface ConnectionListProps { onTableSelect?: ( @@ -324,6 +411,9 @@ export function ConnectionList({ const [createDbForm, setCreateDbForm] = useState( defaultCreateDatabaseForm, ); + const [mysqlCharsets, setMysqlCharsets] = useState([]); + const [mysqlCollations, setMysqlCollations] = useState([]); + const [loadingMysqlOptions, setLoadingMysqlOptions] = useState(false); const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string; @@ -368,6 +458,34 @@ export function ConnectionList({ const isPostgresCreateDb = createDbTargetDriver === "postgres"; const isMssqlCreateDb = createDbTargetDriver === "mssql"; + useEffect(() => { + if (!isCreateDbDialogOpen || !isMySqlFamilyCreateDb || !createDbConnectionId) + return; + setLoadingMysqlOptions(true); + api.connections + .getMysqlCharsets(Number(createDbConnectionId)) + .then(setMysqlCharsets) + .catch(() => setMysqlCharsets(["utf8mb4", "utf8", "latin1"])) + .finally(() => setLoadingMysqlOptions(false)); + }, [isCreateDbDialogOpen, isMySqlFamilyCreateDb, createDbConnectionId]); + + useEffect(() => { + if (!isCreateDbDialogOpen || !isMySqlFamilyCreateDb || !createDbConnectionId) + return; + api.connections + .getMysqlCollations( + Number(createDbConnectionId), + createDbForm.charset || undefined, + ) + .then(setMysqlCollations) + .catch(() => setMysqlCollations([])); + }, [ + isCreateDbDialogOpen, + isMySqlFamilyCreateDb, + createDbConnectionId, + createDbForm.charset, + ]); + const getConnectionStatusLabel = (connection: Connection) => { if (connection.connectState === "success") { return t("connection.status.connected"); @@ -3034,6 +3152,8 @@ export function ConnectionList({ setCreateDbConnectionId(null); setShowCreateDbAdvanced(false); setCreateDbForm(defaultCreateDatabaseForm); + setMysqlCharsets([]); + setMysqlCollations([]); } }} > @@ -3093,25 +3213,31 @@ export function ConnectionList({