From b85097336dfa3b137d6716efb63a212f26a51731 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Wed, 25 Mar 2026 16:17:39 +0800 Subject: [PATCH 01/21] refactor(ai): refactor prompt build to use system messages and independent history assembly Change prompt construction from user message mode to system message mode to clearly distinguish system context and dialog history. Extract message assembly logic to independent functions to improve testability and maintainability. Update tests to reflect role changes and new assembly features. --- src-tauri/src/ai/prompt.rs | 30 ++++++++++++++-------- src-tauri/src/commands/ai.rs | 49 +++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/ai/prompt.rs b/src-tauri/src/ai/prompt.rs index 0211472..92d78b5 100644 --- a/src-tauri/src/ai/prompt.rs +++ b/src-tauri/src/ai/prompt.rs @@ -5,8 +5,8 @@ const MAX_TABLES: usize = 8; const MAX_COLUMNS: usize = 12; const MAX_SCHEMA_CHARS: usize = 6000; -/// Build a minimal prompt bundle without restrictive rules. -/// User input is passed directly to the AI with schema context attached. +/// Build a minimal prompt bundle with system context only. +/// User and assistant turns should come from persisted conversation history. pub fn build_prompt_bundle( _scenario: &str, input: &str, @@ -15,17 +15,22 @@ pub fn build_prompt_bundle( let selected = select_tables(input, schema_overview); let schema_text = render_schema_summary(&selected); - // Simple user message with schema context attached + let mut content = + "Use the conversation history as the source of truth for user and assistant turns." + .to_string(); + let content = if schema_text.is_empty() || schema_text == "(No schema provided)" { - input.to_string() + content } else { - format!("{}\n\nDatabase schema:\n{}", input, schema_text) + content.push_str("\n\nDatabase schema:\n"); + content.push_str(&schema_text); + content }; AiPromptBundle { prompt_version: PROMPT_VERSION.to_string(), messages: vec![AiChatMessage { - role: "user".to_string(), + role: "system".to_string(), content, }], } @@ -234,8 +239,10 @@ mod tests { let bundle = build_prompt_bundle("sql_generate", "List all users", Some(&overview)); assert_eq!(bundle.messages.len(), 1); - assert_eq!(bundle.messages[0].role, "user"); - assert!(bundle.messages[0].content.contains("List all users")); + assert_eq!(bundle.messages[0].role, "system"); + assert!(bundle.messages[0] + .content + .contains("Use the conversation history as the source of truth")); assert!(bundle.messages[0].content.contains("Database schema:")); assert!(bundle.messages[0].content.contains("public.users")); } @@ -245,7 +252,10 @@ mod tests { let bundle = build_prompt_bundle("sql_generate", "Hello", None); assert_eq!(bundle.messages.len(), 1); - assert_eq!(bundle.messages[0].role, "user"); - assert_eq!(bundle.messages[0].content, "Hello"); + assert_eq!(bundle.messages[0].role, "system"); + assert_eq!( + bundle.messages[0].content, + "Use the conversation history as the source of truth for user and assistant turns." + ); } } diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index d2891f5..4ff6cd7 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -95,6 +95,16 @@ fn map_history_load_error(conversation_id: i64, e: &str) -> String { "Failed to load conversation history".to_string() } +fn assemble_final_messages( + bundle: &[AiChatMessage], + history: &[AiChatMessage], +) -> Vec { + let mut final_messages = Vec::with_capacity(bundle.len() + history.len()); + final_messages.extend(bundle.iter().cloned()); + final_messages.extend(history.iter().cloned()); + final_messages +} + async fn get_db(state: &State<'_, AppState>) -> Result, String> { let local_db = { let lock = state.local_db.lock().await; @@ -372,8 +382,7 @@ async fn run_chat( } } - let mut final_messages = bundle.messages.clone(); - final_messages.extend(history); + let final_messages = assemble_final_messages(&bundle.messages, &history); let _ = app.emit( "ai/started", @@ -476,9 +485,11 @@ pub async fn ai_delete_conversation( #[cfg(test)] mod tests { use super::{ - ensure_provider_enabled, map_default_provider_error, map_history_load_error, - map_provider_lookup_error, normalize_provider_type, validate_conversation_requirement, + assemble_final_messages, ensure_provider_enabled, map_default_provider_error, + map_history_load_error, map_provider_lookup_error, normalize_provider_type, + validate_conversation_requirement, }; + use crate::ai::types::AiChatMessage; #[test] fn normalize_provider_type_rejects_empty_value() { @@ -551,4 +562,34 @@ mod tests { "Failed to load conversation history" ); } + + #[test] + fn assemble_final_messages_keeps_context_before_history() { + let bundle = vec![AiChatMessage { + role: "system".to_string(), + content: "schema".to_string(), + }]; + let history = vec![ + AiChatMessage { + role: "user".to_string(), + content: "older question".to_string(), + }, + AiChatMessage { + role: "assistant".to_string(), + content: "older answer".to_string(), + }, + AiChatMessage { + role: "user".to_string(), + content: "latest question".to_string(), + }, + ]; + + let final_messages = assemble_final_messages(&bundle, &history); + + assert_eq!(final_messages.len(), 4); + assert_eq!(final_messages[0].role, "system"); + assert_eq!(final_messages[1].content, "older question"); + assert_eq!(final_messages[2].content, "older answer"); + assert_eq!(final_messages[3].content, "latest question"); + } } From cdcf75f744bb923a7efda0f5513ed4fea057912d Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Wed, 25 Mar 2026 16:18:12 +0800 Subject: [PATCH 02/21] fix(DataGrid): fix the problem that the selected status is not reset correctly after deletion After the deletion operation, the selectedRows and selectedCell statuses are not completely synchronized and updated, which may lead to inconsistent UI statuses. Ensure status synchronization by updating ref and state at the same time, and clear selectedCellRef. --- src/components/business/DataGrid/TableView.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/business/DataGrid/TableView.tsx b/src/components/business/DataGrid/TableView.tsx index f24b556..3bda138 100644 --- a/src/components/business/DataGrid/TableView.tsx +++ b/src/components/business/DataGrid/TableView.tsx @@ -803,7 +803,10 @@ export function TableView({ "table_view_save", ); setDeleteDialogOpen(false); - setSelectedRows(new Set()); + const nextSelectedRows = new Set(); + selectedRowsRef.current = nextSelectedRows; + setSelectedRows(nextSelectedRows); + selectedCellRef.current = null; setSelectedCell(null); setEditingCell(null); onDataRefresh?.(); From 3e1e8fcc5028fed06a70aea2dc309cb4990eeddb Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Wed, 25 Mar 2026 16:18:42 +0800 Subject: [PATCH 03/21] fix:fix the processing of empty passwords during the generation and normalization of connection strings --- src-tauri/src/commands/connection.rs | 21 ++++++++++++ src-tauri/src/connection_input/mod.rs | 28 +++++++++++++-- src-tauri/src/db/drivers/mysql.rs | 49 +++++++++++++++++++++++++++ src-tauri/src/db/drivers/postgres.rs | 28 +++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 91de5ba..fad8535 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -345,6 +345,8 @@ pub async fn delete_connection(state: State<'_, AppState>, id: i64) -> Result<() #[cfg(test)] mod tests { + use crate::connection_input::normalize_connection_form; + use crate::models::ConnectionForm; use super::{ build_mssql_create_database_sql, build_mysql_create_database_sql, build_postgres_create_database_sql, validate_database_name, CreateDatabasePayload, @@ -406,6 +408,25 @@ mod tests { assert!(perm.contains("[PERMISSION_DENIED]")); } + #[test] + fn mysql_ephemeral_flow_preserves_empty_password_through_normalization() { + let form = ConnectionForm { + driver: "mysql".to_string(), + host: Some(" localhost ".to_string()), + port: Some(3306), + username: Some(" root ".to_string()), + password: Some(" ".to_string()), + database: Some(" app ".to_string()), + ..Default::default() + }; + + let normalized = normalize_connection_form(form).unwrap(); + let dsn = crate::db::drivers::mysql::build_test_dsn(&normalized).unwrap(); + + assert_eq!(normalized.password, Some(String::new())); + assert_eq!(dsn, "mysql://root:@localhost:3306/app"); + } + #[test] fn quote_idents_escape_driver_specific_characters() { assert_eq!(quote_mysql_ident("a`b"), "`a``b`"); diff --git a/src-tauri/src/connection_input/mod.rs b/src-tauri/src/connection_input/mod.rs index 8e5af95..8a347fb 100644 --- a/src-tauri/src/connection_input/mod.rs +++ b/src-tauri/src/connection_input/mod.rs @@ -6,6 +6,10 @@ fn trim_to_option(value: Option) -> Option { .and_then(|v| if v.is_empty() { None } else { Some(v) }) } +fn trim_preserve_empty(value: Option) -> Option { + value.map(|v| v.trim().to_string()) +} + fn parse_host_embedded_port(host: &str, fallback_port: Option) -> (String, Option) { if host.starts_with('[') || host.contains(' ') || host.matches(':').count() != 1 { return (host.to_string(), fallback_port); @@ -42,12 +46,12 @@ pub fn normalize_connection_form(mut form: ConnectionForm) -> Result Result { Ok(build_dsn_and_ca_path(form)?.0) } +#[cfg(test)] +pub(crate) fn build_test_dsn(form: &ConnectionForm) -> Result { + build_dsn(form) +} + fn build_dsn_with_ca_path(form: &ConnectionForm) -> Result<(String, Option), String> { build_dsn_and_ca_path(form) } @@ -1039,6 +1044,22 @@ mod tests { assert_eq!(conn_str, "mysql://user:pass@127.0.0.1:3307"); } + #[test] + fn test_conn_string_allows_empty_password_when_present() { + let form = ConnectionForm { + driver: "mysql".to_string(), + host: Some("127.0.0.1".to_string()), + port: Some(3307), + username: Some("user".to_string()), + password: Some(String::new()), + database: None, + ..Default::default() + }; + + let conn_str = build_dsn(&form).unwrap(); + assert_eq!(conn_str, "mysql://user:@127.0.0.1:3307"); + } + #[test] fn test_conn_string_strips_host_embedded_port() { let form = ConnectionForm { @@ -1090,6 +1111,34 @@ mod tests { ); } + #[test] + fn test_conn_string_encodes_credentials_when_ssh_rewrites_target_host() { + let mut form = ConnectionForm { + driver: "mysql".to_string(), + host: Some("db.internal".to_string()), + port: Some(3306), + username: Some("user@name".to_string()), + password: Some("p#ss*@)".to_string()), + database: Some("test_db".to_string()), + ssh_enabled: Some(true), + ssh_host: Some("bastion.internal".to_string()), + ssh_port: Some(22), + ssh_username: Some("jump".to_string()), + ssh_password: Some("ssh#pass".to_string()), + ..Default::default() + }; + + // Match the production flow after the SSH tunnel assigns a local endpoint. + form.host = Some("127.0.0.1".to_string()); + form.port = Some(4406); + + let conn_str = build_dsn(&form).unwrap(); + assert_eq!( + conn_str, + "mysql://user%40name:p%23ss%2A%40%29@127.0.0.1:4406/test_db" + ); + } + #[test] fn test_conn_string_missing_fields() { let form = ConnectionForm { diff --git a/src-tauri/src/db/drivers/postgres.rs b/src-tauri/src/db/drivers/postgres.rs index b31e055..5ddc4df 100644 --- a/src-tauri/src/db/drivers/postgres.rs +++ b/src-tauri/src/db/drivers/postgres.rs @@ -1208,6 +1208,34 @@ mod tests { ); } + #[test] + fn test_conn_string_encodes_credentials_when_ssh_rewrites_target_host() { + let mut form = ConnectionForm { + driver: "postgres".to_string(), + host: Some("db.internal".to_string()), + port: Some(5432), + username: Some("user@name".to_string()), + password: Some("p#ss*@)".to_string()), + database: Some("mydb".to_string()), + ssh_enabled: Some(true), + ssh_host: Some("bastion.internal".to_string()), + ssh_port: Some(22), + ssh_username: Some("jump".to_string()), + ssh_password: Some("ssh#pass".to_string()), + ..Default::default() + }; + + // Match the production flow after the SSH tunnel assigns a local endpoint. + form.host = Some("127.0.0.1".to_string()); + form.port = Some(55432); + + let dsn = build_dsn(&form).unwrap(); + assert_eq!( + dsn, + "postgres://user%40name:p%23ss%2A%40%29@127.0.0.1:55432/mydb" + ); + } + #[test] fn test_conn_string_missing_fields() { let form = ConnectionForm { From e1293e45562046e9b3230dbb5917246daa13a431 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Thu, 26 Mar 2026 13:29:09 +0800 Subject: [PATCH 04/21] feat(db/ssh): Improve connection error handling and SSH tunnel default port - Added a new universal connection error handling function to provide targeted prompts based on the error content. - Set the correct SSH tunnel default port for different database drivers - Disable SSL by default for MySQL connections to avoid TLS handshake failures - Update related tests to verify new features --- src-tauri/src/db/drivers/mod.rs | 87 +++++++++++++++++++++++++++- src-tauri/src/db/drivers/mysql.rs | 34 ++++++----- src-tauri/src/db/drivers/postgres.rs | 6 +- src-tauri/src/ssh.rs | 58 ++++++++++++++++++- 4 files changed, 162 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/db/drivers/mod.rs b/src-tauri/src/db/drivers/mod.rs index 442041f..617f233 100644 --- a/src-tauri/src/db/drivers/mod.rs +++ b/src-tauri/src/db/drivers/mod.rs @@ -17,6 +17,50 @@ pub mod mysql; pub mod postgres; pub mod sqlite; +/// Build a `[CONN_FAILED]` error message with a context-aware hint derived from the +/// underlying error text, so users are not misled by a generic credential warning +/// when the actual problem is TLS incompatibility, a network issue, etc. +pub(crate) fn conn_failed_error(e: &dyn std::fmt::Display) -> String { + let raw = e.to_string(); + let lower = raw.to_ascii_lowercase(); + + let hint = if lower.contains("handshake") + || lower.contains("fatal alert") + || lower.contains("tls") + || lower.contains("ssl") + || lower.contains("certificate") + { + "hint: TLS/SSL handshake failed — the server may use a TLS version or cipher suite \ + incompatible with the client (TLS 1.2+ required); try disabling SSL in the connection settings" + } else if lower.contains("access denied") + || lower.contains("authentication") + || lower.contains("password") + || lower.contains("login failed") + || lower.contains("invalid password") + || lower.contains("1045") + { + "hint: authentication failed — verify the username/password are correct; \ + if they contain special characters they must be URL-encoded" + } else if lower.contains("connection refused") + || lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("broken pipe") + || lower.contains("network unreachable") + { + "hint: could not reach the server — check host, port, firewall rules, and SSH tunnel settings" + } else if lower.contains("name resolution") + || lower.contains("no such host") + || lower.contains("failed to lookup") + || lower.contains("dns") + { + "hint: hostname could not be resolved — check that the host address is correct" + } else { + "hint: check host, port, credentials, and SSL settings" + }; + + format!("[CONN_FAILED] {raw} ({hint})") +} + pub(crate) fn strip_trailing_statement_terminator(sql: &str) -> &str { let mut out = sql.trim_end(); while let Some(stripped) = out.strip_suffix(';') { @@ -121,7 +165,48 @@ pub async fn connect(form: &ConnectionForm) -> Result, S #[cfg(test)] mod tests { - use super::strip_trailing_statement_terminator; + use super::{conn_failed_error, strip_trailing_statement_terminator}; + + #[test] + fn conn_failed_error_tls_hint() { + let msg = conn_failed_error(&"error communicating with database: received fatal alert: HandshakeFailure"); + assert!(msg.starts_with("[CONN_FAILED]")); + assert!(msg.contains("TLS/SSL handshake failed")); + assert!(!msg.contains("username/password")); + } + + #[test] + fn conn_failed_error_auth_hint() { + let msg = conn_failed_error(&"Access denied for user 'root'@'localhost'"); + assert!(msg.contains("authentication failed")); + assert!(msg.contains("URL-encoded")); + } + + #[test] + fn conn_failed_error_connection_refused_hint() { + let msg = conn_failed_error(&"Connection refused (os error 111)"); + assert!(msg.contains("could not reach the server")); + } + + #[test] + fn conn_failed_error_timeout_hint() { + let msg = conn_failed_error(&"connection timed out"); + assert!(msg.contains("could not reach the server")); + } + + #[test] + fn conn_failed_error_dns_hint() { + let msg = conn_failed_error(&"failed to lookup address information: no such host"); + assert!(msg.contains("hostname could not be resolved")); + } + + #[test] + fn conn_failed_error_generic_hint() { + let msg = conn_failed_error(&"some unknown database error"); + assert!(msg.starts_with("[CONN_FAILED]")); + assert!(msg.contains("hint:")); + assert!(!msg.contains("username/password")); + } #[test] fn strip_trailing_statement_terminator_removes_single_semicolon() { diff --git a/src-tauri/src/db/drivers/mysql.rs b/src-tauri/src/db/drivers/mysql.rs index 57a0292..edf34b0 100644 --- a/src-tauri/src/db/drivers/mysql.rs +++ b/src-tauri/src/db/drivers/mysql.rs @@ -132,6 +132,10 @@ fn build_dsn_and_ca_path(form: &ConnectionForm) -> Result<(String, Option Result { .and_then(|v| if v.trim().is_empty() { None } else { Some(v) }); let target_host = config.host.clone().unwrap_or("localhost".to_string()); - let target_port = config.port.unwrap_or(5432); + let default_port: i64 = match config.driver.to_ascii_lowercase().as_str() { + "mysql" => 3306, + "mssql" => 1433, + "clickhouse" => 9000, + "sqlite" => 0, + _ => 5432, // postgres and unknown drivers + }; + let target_port = config.port.unwrap_or(default_port); if target_port < 1 || target_port > 65535 { return Err("Target port must be between 1 and 65535".to_string()); } @@ -244,6 +251,55 @@ mod tests { use super::*; use crate::models::ConnectionForm; + #[test] + fn test_target_port_default_by_driver() { + // Verify driver-specific default ports are applied when port is None. + // We can only test port validation since start_ssh_tunnel requires a real host; + // use an out-of-range port to force early validation failure and confirm the + // default port resolution branch is NOT taken (port=None should NOT produce 5432 for MySQL). + + // For MySQL with no port set, the default must be 3306 (not 5432). + // We verify indirectly: if port is None and driver is mysql, target_port = 3306 which + // passes validation (1..=65535). The tunnel will fail to connect (no real host), but + // the validation itself won't error with "Target port must be between 1 and 65535". + let config_mysql = ConnectionForm { + driver: "mysql".to_string(), + host: Some("127.0.0.1".to_string()), + port: None, // deliberately omitted — should default to 3306 + ssh_host: Some("127.0.0.1".to_string()), + ssh_port: Some(22), + ssh_username: Some("user".to_string()), + ssh_password: Some("pass".to_string()), + ..Default::default() + }; + let result = start_ssh_tunnel(&config_mysql); + // Should fail with a network/connect error, NOT a port validation error + if let Err(e) = result { + assert!( + !e.contains("Target port must be between 1 and 65535"), + "MySQL default port (3306) should pass validation, got: {e}" + ); + } + + let config_mssql = ConnectionForm { + driver: "mssql".to_string(), + host: Some("127.0.0.1".to_string()), + port: None, // should default to 1433 + ssh_host: Some("127.0.0.1".to_string()), + ssh_port: Some(22), + ssh_username: Some("user".to_string()), + ssh_password: Some("pass".to_string()), + ..Default::default() + }; + let result = start_ssh_tunnel(&config_mssql); + if let Err(e) = result { + assert!( + !e.contains("Target port must be between 1 and 65535"), + "MSSQL default port (1433) should pass validation, got: {e}" + ); + } + } + #[test] fn test_ssh_port_validation() { let mut config = ConnectionForm::default(); From 90dd2da9249259c8b0dc9ec43193d79b3b766765 Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Fri, 27 Mar 2026 12:57:21 +0800 Subject: [PATCH 05/21] feat(transfer): supports importing SQL files to MySQL/PostgreSQL and full rollback Added SQL file import function, supporting MySQL and PostgreSQL drivers. The import process is executed in a transaction. If any statement fails, all changes will be rolled back automatically. File size and statement number limits have been added to ensure security. The front-end adds import dialog boxes, menu items and multi language support, and the back-end implements SQL statement parsing and error handling. --- README.md | 1 + README_CN.md | 1 + src-tauri/src/commands/transfer.rs | 404 +++++++++++++++++- src-tauri/src/db/drivers/mysql.rs | 80 +++- src-tauri/src/lib.rs | 1 + .../business/Sidebar/ConnectionList.tsx | 151 +++++++ src/lib/i18n/locales/en.ts | 14 + src/lib/i18n/locales/ja.ts | 15 + src/lib/i18n/locales/zh.ts | 13 + src/services/api.ts | 16 + src/services/mocks.ts | 17 + 11 files changed, 696 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 506fe62..cf19463 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ English | [简体中文](README_CN.md) | [日本語](README_JA.md) - Connect to PostgreSQL, MySQL, MariaDB (MySQL-compatible), TiDB (MySQL-compatible), SQLite, SQL Server, and ClickHouse (preview, currently read-only) - Write and run SQL with syntax highlighting, auto-completion, and one-click formatting - Browse query results in a data grid with filtering, sorting, pagination, and export +- Import `.sql` files into MySQL/PostgreSQL with all-or-nothing rollback - Save and reuse frequently used SQL scripts with Saved Queries - Use the AI sidebar to draft SQL and explain queries (optional) - Access remote databases through SSH tunneling diff --git a/README_CN.md b/README_CN.md index f7e4bc4..fdfd5cf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -36,6 +36,7 @@ - 连接 PostgreSQL、MySQL、MariaDB(MySQL 兼容)、TiDB(MySQL 兼容)、SQLite、SQL Server 与 ClickHouse(预览版,当前只读) - 编写与执行 SQL:语法高亮、自动补全、一键格式化 - 在数据网格中浏览结果,支持过滤、排序、分页与导出 +- 支持将 `.sql` 文件导入 MySQL/PostgreSQL,并在失败时全量回滚 - 使用 Saved Queries 保存并复用常用 SQL 脚本 - 使用 AI 侧边栏辅助写 SQL、解释查询(可选) - 通过 SSH 隧道访问远程数据库 diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index 067152b..e5d2018 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -3,10 +3,12 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::fs::{self, File}; use std::io::{BufWriter, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tauri::State; const DEFAULT_CHUNK_SIZE: i64 = 2000; +const MAX_IMPORT_FILE_SIZE_BYTES: u64 = 20 * 1024 * 1024; +const MAX_IMPORT_STATEMENTS: usize = 50_000; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] @@ -31,6 +33,18 @@ pub struct ExportResult { pub row_count: i64, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportSqlResult { + pub file_path: String, + pub total_statements: i64, + pub success_statements: i64, + pub failed_at: Option, + pub error: Option, + pub time_taken_ms: i64, + pub rolled_back: bool, +} + #[tauri::command] pub async fn export_table_data( state: State<'_, AppState>, @@ -200,6 +214,104 @@ pub async fn export_query_result( .await } +#[tauri::command] +pub async fn import_sql_file( + state: State<'_, AppState>, + id: i64, + database: Option, + file_path: String, + driver: String, +) -> Result { + let normalized_driver = driver.trim().to_ascii_lowercase(); + if normalized_driver != "postgres" && normalized_driver != "mysql" { + return Err(format!( + "[UNSUPPORTED] Driver {} is not supported for SQL import", + driver + )); + } + + let import_path = PathBuf::from(file_path.trim()); + validate_import_path(&import_path)?; + validate_import_file_size(&import_path)?; + + let source = fs::read_to_string(&import_path) + .map_err(|e| format!("[IMPORT_ERROR] failed to read sql file: {e}"))?; + let source = source + .strip_prefix('\u{feff}') + .unwrap_or(&source) + .to_string(); + + let statements = parse_sql_statements(&source, &normalized_driver)?; + if statements.is_empty() { + return Err("[IMPORT_ERROR] SQL file does not contain executable statements".to_string()); + } + if statements.len() > MAX_IMPORT_STATEMENTS { + return Err(format!( + "[IMPORT_ERROR] statement count exceeds limit ({} > {})", + statements.len(), + MAX_IMPORT_STATEMENTS + )); + } + + let started_at = std::time::Instant::now(); + let total_statements = statements.len() as i64; + + super::execute_with_retry(&state, id, database, |db_driver| { + let statements = statements.clone(); + let import_path = import_path.clone(); + async move { + db_driver + .execute_query("BEGIN".to_string()) + .await + .map_err(|e| format!("[IMPORT_ERROR] failed to start transaction: {e}"))?; + + let mut success_statements = 0i64; + for (idx, statement) in statements.iter().enumerate() { + if let Err(e) = db_driver.execute_query(statement.clone()).await { + let _ = db_driver.execute_query("ROLLBACK".to_string()).await; + return Ok(ImportSqlResult { + file_path: import_path.to_string_lossy().to_string(), + total_statements, + success_statements, + failed_at: Some((idx + 1) as i64), + error: Some(truncate_error_message(&e)), + time_taken_ms: started_at.elapsed().as_millis() as i64, + rolled_back: true, + }); + } + success_statements += 1; + } + + if let Err(e) = db_driver.execute_query("COMMIT".to_string()).await { + let _ = db_driver.execute_query("ROLLBACK".to_string()).await; + return Ok(ImportSqlResult { + file_path: import_path.to_string_lossy().to_string(), + total_statements, + success_statements, + failed_at: None, + error: Some(format!( + "[IMPORT_ERROR] failed to commit transaction: {}", + truncate_error_message(&e) + )), + time_taken_ms: started_at.elapsed().as_millis() as i64, + rolled_back: true, + }); + } + + Ok(ImportSqlResult { + file_path: import_path.to_string_lossy().to_string(), + total_statements, + success_statements: total_statements, + failed_at: None, + error: None, + time_taken_ms: started_at.elapsed().as_millis() as i64, + rolled_back: false, + }) + } + }) + .await +} + fn extension_for_format(format: &ExportFormat) -> &'static str { match format { ExportFormat::Csv => "csv", @@ -232,6 +344,250 @@ fn resolve_output_path( Ok(path) } +fn validate_import_path(path: &Path) -> Result<(), String> { + if path.as_os_str().is_empty() { + return Err("[IMPORT_ERROR] Invalid import path".to_string()); + } + if path.is_dir() { + return Err("[IMPORT_ERROR] Import path points to a directory".to_string()); + } + if !path.exists() { + return Err("[IMPORT_ERROR] Import file does not exist".to_string()); + } + let Some(ext) = path.extension().and_then(|v| v.to_str()) else { + return Err("[IMPORT_ERROR] Import file must use .sql extension".to_string()); + }; + if !ext.eq_ignore_ascii_case("sql") { + return Err("[IMPORT_ERROR] Import file must use .sql extension".to_string()); + } + Ok(()) +} + +fn validate_import_file_size(path: &Path) -> Result<(), String> { + let metadata = fs::metadata(path) + .map_err(|e| format!("[IMPORT_ERROR] failed to read file metadata: {e}"))?; + if metadata.len() > MAX_IMPORT_FILE_SIZE_BYTES { + return Err(format!( + "[IMPORT_ERROR] file is too large (max {} bytes)", + MAX_IMPORT_FILE_SIZE_BYTES + )); + } + Ok(()) +} + +#[derive(Debug, Clone)] +enum SqlScanState { + Normal, + SingleQuoted, + DoubleQuoted, + BacktickQuoted, + DollarQuoted(String), + LineComment, + BlockComment, +} + +fn parse_sql_statements(sql: &str, driver: &str) -> Result, String> { + let mysql_style_hash_comment = matches!(driver, "mysql" | "mariadb" | "tidb"); + let chars: Vec = sql.chars().collect(); + let mut out = Vec::new(); + let mut current = String::new(); + let mut state = SqlScanState::Normal; + let mut i = 0usize; + + while i < chars.len() { + match &state { + SqlScanState::Normal => { + let ch = chars[i]; + let next = chars.get(i + 1).copied(); + + if ch == '-' && next == Some('-') { + state = SqlScanState::LineComment; + i += 2; + continue; + } + if mysql_style_hash_comment && ch == '#' { + state = SqlScanState::LineComment; + i += 1; + continue; + } + if ch == '/' && next == Some('*') { + state = SqlScanState::BlockComment; + i += 2; + continue; + } + if ch == '\'' { + current.push(ch); + state = SqlScanState::SingleQuoted; + i += 1; + continue; + } + if ch == '"' { + current.push(ch); + state = SqlScanState::DoubleQuoted; + i += 1; + continue; + } + if ch == '`' { + current.push(ch); + state = SqlScanState::BacktickQuoted; + i += 1; + continue; + } + if ch == '$' { + if let Some((tag, end_idx)) = parse_dollar_quote_tag(&chars, i) { + current.push_str(&tag); + state = SqlScanState::DollarQuoted(tag); + i = end_idx + 1; + continue; + } + } + if ch == ';' { + let statement = current.trim(); + if !statement.is_empty() { + out.push(statement.to_string()); + } + current.clear(); + i += 1; + continue; + } + current.push(ch); + i += 1; + } + SqlScanState::SingleQuoted => { + let ch = chars[i]; + current.push(ch); + if ch == '\\' { + if let Some(next) = chars.get(i + 1) { + current.push(*next); + i += 2; + continue; + } + } + if ch == '\'' { + if chars.get(i + 1) == Some(&'\'') { + current.push('\''); + i += 2; + continue; + } + state = SqlScanState::Normal; + } + i += 1; + } + SqlScanState::DoubleQuoted => { + let ch = chars[i]; + current.push(ch); + if ch == '"' { + if chars.get(i + 1) == Some(&'"') { + current.push('"'); + i += 2; + continue; + } + state = SqlScanState::Normal; + } + i += 1; + } + SqlScanState::BacktickQuoted => { + let ch = chars[i]; + current.push(ch); + if ch == '`' { + if chars.get(i + 1) == Some(&'`') { + current.push('`'); + i += 2; + continue; + } + state = SqlScanState::Normal; + } + i += 1; + } + SqlScanState::DollarQuoted(tag) => { + if starts_with_tag(&chars, i, tag) { + current.push_str(tag); + i += tag.chars().count(); + state = SqlScanState::Normal; + continue; + } + current.push(chars[i]); + i += 1; + } + SqlScanState::LineComment => { + if chars[i] == '\n' { + current.push('\n'); + state = SqlScanState::Normal; + } + i += 1; + } + SqlScanState::BlockComment => { + if chars[i] == '*' && chars.get(i + 1) == Some(&'/') { + state = SqlScanState::Normal; + i += 2; + } else { + i += 1; + } + } + } + } + + match state { + SqlScanState::Normal | SqlScanState::LineComment => {} + SqlScanState::BlockComment => { + return Err("[IMPORT_ERROR] Unterminated block comment in SQL file".to_string()); + } + SqlScanState::SingleQuoted + | SqlScanState::DoubleQuoted + | SqlScanState::BacktickQuoted + | SqlScanState::DollarQuoted(_) => { + return Err("[IMPORT_ERROR] Unterminated string literal in SQL file".to_string()); + } + } + + let tail = current.trim(); + if !tail.is_empty() { + out.push(tail.to_string()); + } + Ok(out) +} + +fn parse_dollar_quote_tag(chars: &[char], start: usize) -> Option<(String, usize)> { + if chars.get(start) != Some(&'$') { + return None; + } + let mut idx = start + 1; + while idx < chars.len() && (chars[idx].is_ascii_alphanumeric() || chars[idx] == '_') { + idx += 1; + } + if idx < chars.len() && chars[idx] == '$' { + let tag: String = chars[start..=idx].iter().collect(); + return Some((tag, idx)); + } + None +} + +fn starts_with_tag(chars: &[char], idx: usize, tag: &str) -> bool { + let tag_chars: Vec = tag.chars().collect(); + if idx + tag_chars.len() > chars.len() { + return false; + } + for (offset, ch) in tag_chars.iter().enumerate() { + if chars[idx + offset] != *ch { + return false; + } + } + true +} + +fn truncate_error_message(message: &str) -> String { + const MAX_CHARS: usize = 500; + let mut out = String::new(); + for (idx, ch) in message.chars().enumerate() { + if idx >= MAX_CHARS { + out.push_str("..."); + break; + } + out.push(ch); + } + out +} + fn validate_output_path(path: &PathBuf) -> Result<(), String> { if path.as_os_str().is_empty() { return Err("[EXPORT_ERROR] Invalid output path".to_string()); @@ -508,7 +864,10 @@ mod tests { sql_value(&Value::String("O'Reilly".to_string())), "'O''Reilly'" ); - assert_eq!(sql_value(&Value::Number(serde_json::Number::from(42))), "42"); + assert_eq!( + sql_value(&Value::Number(serde_json::Number::from(42))), + "42" + ); assert_eq!(sql_value(&Value::Bool(false)), "FALSE"); } @@ -613,4 +972,45 @@ mod tests { assert_eq!(err, "[EXPORT_ERROR] row is not a JSON object"); let _ = fs::remove_file(path); } + + #[test] + fn parse_sql_statements_handles_quotes_and_comments() { + let sql = r#" + -- comment 1 + INSERT INTO users (name, note) VALUES ('alice', 'hello;world'); + /* block comment ; ; */ + INSERT INTO users (name) VALUES ("bob"); + # mysql style comment + INSERT INTO users(name) VALUES ($tag$semi;inside$tag$); + "#; + + let statements = parse_sql_statements(sql, "mysql").unwrap(); + assert_eq!(statements.len(), 3); + assert!(statements[0].starts_with("INSERT INTO users")); + assert!(statements[1].contains("\"bob\"")); + assert!(statements[2].contains("$tag$semi;inside$tag$")); + } + + #[test] + fn parse_sql_statements_rejects_unterminated_block_comment() { + let err = parse_sql_statements("INSERT INTO t VALUES (1); /*", "mysql").unwrap_err(); + assert!(err.contains("Unterminated block comment")); + } + + #[test] + fn parse_sql_statements_preserves_hash_for_postgres() { + let sql = "SELECT 1 # 2;\nSELECT '#not_comment';"; + let statements = parse_sql_statements(sql, "postgres").unwrap(); + assert_eq!(statements.len(), 2); + assert_eq!(statements[0], "SELECT 1 # 2"); + assert_eq!(statements[1], "SELECT '#not_comment'"); + } + + #[test] + fn truncate_error_message_caps_length() { + let source = "x".repeat(600); + let truncated = truncate_error_message(&source); + assert!(truncated.len() <= 503); + assert!(truncated.ends_with("...")); + } } diff --git a/src-tauri/src/db/drivers/mysql.rs b/src-tauri/src/db/drivers/mysql.rs index edf34b0..7c80fef 100644 --- a/src-tauri/src/db/drivers/mysql.rs +++ b/src-tauri/src/db/drivers/mysql.rs @@ -165,6 +165,11 @@ fn cleanup_ca_file_opt(path: Option<&PathBuf>) { } } +fn is_prepared_protocol_unsupported_error(err: &str) -> bool { + let lower = err.to_ascii_lowercase(); + lower.contains("1295") || lower.contains("prepared statement protocol") +} + impl Drop for MysqlDriver { fn drop(&mut self) { cleanup_ca_file_opt(self.ca_cert_path.as_ref()); @@ -388,7 +393,10 @@ fn normalize_mysql_row_json( Ok(()) } -fn decode_mysql_json_cell(row: &sqlx::mysql::MySqlRow, column_name: &str) -> Result { +fn decode_mysql_json_cell( + row: &sqlx::mysql::MySqlRow, + column_name: &str, +) -> Result { if let Ok(v) = row.try_get::, _>(column_name) { return Ok(v.0); } @@ -403,10 +411,7 @@ fn decode_mysql_json_cell(row: &sqlx::mysql::MySqlRow, column_name: &str) -> Res Err("[QUERY_ERROR] Failed to decode MySQL JSON cell".to_string()) } -fn build_mysql_json_object_expr( - columns: &[(String, String)], - table_alias: Option<&str>, -) -> String { +fn build_mysql_json_object_expr(columns: &[(String, String)], table_alias: Option<&str>) -> String { if columns.is_empty() { return "JSON_OBJECT()".to_string(); } @@ -857,10 +862,23 @@ impl DatabaseDriver for MysqlDriver { let row_count = data.len() as i64; (columns, data, row_count) } else { - let rows = sqlx::query(&sql) - .fetch_all(&self.pool) - .await - .map_err(|e| format!("[QUERY_ERROR] {e}"))?; + let mut executed_with_raw_sql = false; + let rows = match sqlx::query(&sql).fetch_all(&self.pool).await { + Ok(rows) => rows, + Err(e) => { + let error_text = e.to_string(); + if is_prepared_protocol_unsupported_error(&error_text) { + sqlx::raw_sql(&sql) + .execute(&self.pool) + .await + .map_err(|raw_err| format!("[QUERY_ERROR] {raw_err}"))?; + executed_with_raw_sql = true; + Vec::new() + } else { + return Err(format!("[QUERY_ERROR] {e}")); + } + } + }; let columns = if let Some(first_row) = rows.first() { first_row .columns() @@ -870,6 +888,8 @@ impl DatabaseDriver for MysqlDriver { r#type: col.type_info().to_string(), }) .collect() + } else if executed_with_raw_sql { + Vec::new() } else { self.describe_query_columns(&sql).await? }; @@ -1025,7 +1045,10 @@ mod tests { }; let conn_str = build_dsn(&form).unwrap(); - assert_eq!(conn_str, "mysql://root:password@localhost:3306/test_db?ssl-mode=DISABLED"); + assert_eq!( + conn_str, + "mysql://root:password@localhost:3306/test_db?ssl-mode=DISABLED" + ); } #[test] @@ -1041,7 +1064,10 @@ mod tests { }; let conn_str = build_dsn(&form).unwrap(); - assert_eq!(conn_str, "mysql://user:pass@127.0.0.1:3307?ssl-mode=DISABLED"); + assert_eq!( + conn_str, + "mysql://user:pass@127.0.0.1:3307?ssl-mode=DISABLED" + ); } #[test] @@ -1073,7 +1099,10 @@ mod tests { }; let conn_str = build_dsn(&form).unwrap(); - assert_eq!(conn_str, "mysql://user:pass@127.0.0.1:3307?ssl-mode=DISABLED"); + assert_eq!( + conn_str, + "mysql://user:pass@127.0.0.1:3307?ssl-mode=DISABLED" + ); } #[test] @@ -1089,7 +1118,10 @@ mod tests { }; let conn_str = build_dsn(&form).unwrap(); - assert_eq!(conn_str, "mysql://root:password@localhost:3308/test_db?ssl-mode=DISABLED"); + assert_eq!( + conn_str, + "mysql://root:password@localhost:3308/test_db?ssl-mode=DISABLED" + ); } #[test] @@ -1245,7 +1277,9 @@ mod tests { #[test] fn test_is_json_projectable_statement() { assert!(is_json_projectable_statement("SELECT 1")); - assert!(is_json_projectable_statement(" WITH t AS (SELECT 1) SELECT * FROM t")); + assert!(is_json_projectable_statement( + " WITH t AS (SELECT 1) SELECT * FROM t" + )); assert!(!is_json_projectable_statement("SHOW TABLES")); assert!(!is_json_projectable_statement("UPDATE t SET a = 1")); } @@ -1279,7 +1313,10 @@ mod tests { normalize_mysql_row_json(&mut row, &high_precision_cols).unwrap(); - assert_eq!(row.get("id").and_then(|v| v.as_str()), Some("9223372036854775807")); + assert_eq!( + row.get("id").and_then(|v| v.as_str()), + Some("9223372036854775807") + ); assert_eq!(row.get("amount").and_then(|v| v.as_str()), Some("1234.56")); assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("demo")); assert!(row.get("nullable").unwrap().is_null()); @@ -1298,4 +1335,17 @@ mod tests { assert!(sql.contains("FROM (SELECT * FROM t) AS `__dbpaw_row`")); assert!(!sql.contains(";) AS `__dbpaw_row`")); } + + #[test] + fn test_is_prepared_protocol_unsupported_error() { + assert!(is_prepared_protocol_unsupported_error( + "error returned from database: 1295 (HY000): This command is not supported in the prepared statement protocol yet" + )); + assert!(is_prepared_protocol_unsupported_error( + "prepared statement protocol is unsupported" + )); + assert!(!is_prepared_protocol_unsupported_error( + "syntax error near ...", + )); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 587e950..d4fb21f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -142,6 +142,7 @@ pub fn run() { commands::ai::ai_delete_conversation, commands::transfer::export_table_data, commands::transfer::export_query_result, + commands::transfer::import_sql_file, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/src/components/business/Sidebar/ConnectionList.tsx b/src/components/business/Sidebar/ConnectionList.tsx index 1f9f889..0b2a7d7 100644 --- a/src/components/business/Sidebar/ConnectionList.tsx +++ b/src/components/business/Sidebar/ConnectionList.tsx @@ -16,6 +16,7 @@ import { Search, Download, FolderOpen, + Upload, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -189,6 +190,7 @@ const mssqlCollationOptions = [ "Japanese_CI_AS", ]; const schemaNodeDrivers: Driver[] = ["postgres", "mssql"]; +const importSupportedDrivers: Driver[] = ["postgres", "mysql"]; interface ConnectionListProps { onTableSelect?: ( @@ -283,6 +285,7 @@ export function ConnectionList({ const [isSavingEdit, setIsSavingEdit] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isCreatingDatabase, setIsCreatingDatabase] = useState(false); + const [isImportingSql, setIsImportingSql] = useState(false); const [deleteTargetConnectionId, setDeleteTargetConnectionId] = useState< string | null >(null); @@ -308,11 +311,20 @@ export function ConnectionList({ const [savedQueriesByConnection, setSavedQueriesByConnection] = useState< Record >({}); + const [pendingImport, setPendingImport] = useState<{ + connectionId: string; + databaseName: string; + driver: Driver; + filePath: string; + } | null>(null); + const [isImportConfirmOpen, setIsImportConfirmOpen] = useState(false); const supportsCreateDatabaseForDriver = (driver: Driver) => createDatabaseSupportedDrivers.includes(driver); const supportsSchemaNodeForDriver = (driver: Driver) => schemaNodeDrivers.includes(driver); + const supportsImportForDriver = (driver: Driver) => + importSupportedDrivers.includes(driver); const getSchemaNodeKey = (databaseKey: string, schema: string) => `${databaseKey}::${schema}`; const getTableNodeKey = ( @@ -1614,9 +1626,86 @@ export function ConnectionList({ } }; + const handleDatabaseImport = async ( + connectionId: string, + databaseName: string, + ) => { + const connection = connections.find((conn) => conn.id === connectionId); + if (!connection) return; + + if (!supportsImportForDriver(connection.type)) { + toast.error(t("connection.toast.importUnsupportedDriver")); + return; + } + + if (!isTauri()) { + toast.error(t("connection.toast.importDesktopOnly")); + return; + } + + const selectedPath = await pickSingleFile({ + title: t("connection.toast.selectImportSqlFile"), + filters: [{ name: "SQL", extensions: ["sql"] }], + }); + if (!selectedPath) return; + + setPendingImport({ + connectionId, + databaseName, + driver: connection.type, + filePath: selectedPath, + }); + setIsImportConfirmOpen(true); + }; + + const handleConfirmImport = async () => { + if (!pendingImport) return; + + setIsImportingSql(true); + try { + const result = await api.transfer.importSqlFile({ + id: Number(pendingImport.connectionId), + database: pendingImport.databaseName, + filePath: pendingImport.filePath, + driver: pendingImport.driver, + }); + + if (result.error || result.failedAt) { + toast.error(t("connection.toast.importFailed"), { + description: result.error || t("common.unknown"), + }); + } else { + toast.success( + t("connection.toast.importSuccess", { + count: result.successStatements, + }), + { + description: pendingImport.filePath, + }, + ); + } + + await handleRefreshDatabaseTables( + pendingImport.connectionId, + pendingImport.databaseName, + ); + } catch (e) { + toast.error(t("connection.toast.importFailed"), { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setIsImportingSql(false); + setIsImportConfirmOpen(false); + setPendingImport(null); + } + }; + const contextMenuConnection = contextMenu.connectionId ? connections.find((conn) => conn.id === contextMenu.connectionId) : null; + const contextMenuDatabaseConnection = contextMenu.connectionId + ? connections.find((conn) => conn.id === contextMenu.connectionId) + : null; return (
@@ -2762,6 +2851,24 @@ export function ConnectionList({ {t("connection.menu.refreshTables")} + {contextMenu.connectionId && + contextMenu.databaseName && + contextMenuDatabaseConnection && + supportsImportForDriver(contextMenuDatabaseConnection.type) ? ( + + ) : null}
); } diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index e2d3302..77d62b3 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -286,10 +286,18 @@ export const en = { refreshTables: "Refresh Tables", newQuery: "New Query", newDatabase: "New Database", + importSql: "Import SQL", exportCsv: "Export as CSV", exportJson: "Export as JSON", exportSql: "Export as SQL", }, + importDialog: { + title: "Import SQL", + description: + "Import SQL into database {{database}}. Any statement failure will roll back all changes.", + confirm: "Import", + importing: "Importing…", + }, createDbDialog: { title: "Create Database", fields: { @@ -331,6 +339,12 @@ export const en = { duplicateFailed: "Failed to duplicate connection", createDatabaseSuccess: "Database created successfully", createDatabaseFailed: "Failed to create database", + importDesktopOnly: "SQL import is only available in Tauri desktop mode.", + importUnsupportedDriver: + "SQL import is currently supported only for MySQL and PostgreSQL.", + selectImportSqlFile: "Select SQL File to Import", + importSuccess: "Import completed ({{count}} statements)", + importFailed: "Import failed", exportDesktopOnly: "Export dialog is only available in Tauri desktop mode.", saveExportFile: "Save Export File", openSaveDialogFailed: "Failed to open save dialog", diff --git a/src/lib/i18n/locales/ja.ts b/src/lib/i18n/locales/ja.ts index b0e9bd7..f56410d 100644 --- a/src/lib/i18n/locales/ja.ts +++ b/src/lib/i18n/locales/ja.ts @@ -291,10 +291,18 @@ export const ja: Translations = { refreshTables: "テーブルを更新", newQuery: "新規クエリ", newDatabase: "新規 Database", + importSql: "SQL をインポート", exportCsv: "CSV としてエクスポート", exportJson: "JSON としてエクスポート", exportSql: "SQL としてエクスポート", }, + importDialog: { + title: "SQL をインポート", + description: + "データベース {{database}} に SQL をインポートします。途中で失敗した場合は全件ロールバックされます。", + confirm: "インポート", + importing: "インポート中…", + }, createDbDialog: { title: "Database を作成", fields: { @@ -337,6 +345,13 @@ export const ja: Translations = { duplicateFailed: "接続の複製に失敗しました", createDatabaseSuccess: "Database を作成しました", createDatabaseFailed: "Database の作成に失敗しました", + importDesktopOnly: + "SQL インポートは Tauri デスクトップモードでのみ利用できます。", + importUnsupportedDriver: + "現在の SQL インポートは MySQL / PostgreSQL のみ対応しています。", + selectImportSqlFile: "インポートする SQL ファイルを選択", + importSuccess: "インポート完了({{count}} 文)", + importFailed: "インポートに失敗しました", exportDesktopOnly: "エクスポートダイアログは Tauri デスクトップモードでのみ利用できます。", saveExportFile: "エクスポートファイルを保存", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index cc77faa..0afbefd 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -282,10 +282,18 @@ export const zh: Translations = { refreshTables: "刷新数据表", newQuery: "新建查询", newDatabase: "新建 Database", + importSql: "导入 SQL", exportCsv: "导出为 CSV", exportJson: "导出为 JSON", exportSql: "导出为 SQL", }, + importDialog: { + title: "导入 SQL", + description: + "将 SQL 导入到数据库 {{database}}。任一语句失败将全量回滚。", + confirm: "开始导入", + importing: "导入中…", + }, createDbDialog: { title: "新建 Database", fields: { @@ -326,6 +334,11 @@ export const zh: Translations = { duplicateFailed: "连接复制失败", createDatabaseSuccess: "Database 创建成功", createDatabaseFailed: "Database 创建失败", + importDesktopOnly: "SQL 导入仅在 Tauri 桌面模式可用。", + importUnsupportedDriver: "当前仅支持 MySQL / PostgreSQL 的 SQL 导入。", + selectImportSqlFile: "选择要导入的 SQL 文件", + importSuccess: "导入完成({{count}} 条语句)", + importFailed: "导入失败", exportDesktopOnly: "导出对话框仅在 Tauri 桌面模式可用。", saveExportFile: "保存导出文件", openSaveDialogFailed: "打开保存对话框失败", diff --git a/src/services/api.ts b/src/services/api.ts index 083c7e1..c40b144 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -298,6 +298,16 @@ export interface ExportResult { rowCount: number; } +export interface ImportSqlResult { + filePath: string; + totalStatements: number; + successStatements: number; + failedAt?: number; + error?: string; + timeTakenMs: number; + rolledBack: boolean; +} + export const api = { query: { execute: ( @@ -426,6 +436,12 @@ export const api = { format: TransferFormat; filePath?: string; }) => invoke("export_query_result", params), + importSqlFile: (params: { + id: number; + database?: string; + filePath: string; + driver: string; + }) => invoke("import_sql_file", params), }, connections: { list: () => invoke("get_connections"), diff --git a/src/services/mocks.ts b/src/services/mocks.ts index e52564d..8280be1 100644 --- a/src/services/mocks.ts +++ b/src/services/mocks.ts @@ -7,6 +7,7 @@ import { TestConnectionResult, SavedQuery, ExportResult, + ImportSqlResult, AIProviderConfig, AIConversation, AIConversationDetail, @@ -953,6 +954,19 @@ export async function mockExportQueryResult( }; } +export async function mockImportSqlFile(_params: any): Promise { + await new Promise((resolve) => setTimeout(resolve, 160)); + return { + filePath: _params?.filePath || `/tmp/dbpaw-import-${Date.now()}.sql`, + totalStatements: 3, + successStatements: 3, + failedAt: undefined, + error: undefined, + timeTakenMs: 120, + rolledBack: false, + }; +} + /** * Invoke corresponding mock handler function by command name */ @@ -1073,6 +1087,9 @@ export async function invokeMock(cmd: string, args?: any): Promise { case "export_query_result": return mockExportQueryResult(args) as Promise; + case "import_sql_file": + return mockImportSqlFile(args) as Promise; + case "ai_list_providers": return Promise.resolve([...mockAiProviders]) as Promise; From d2867cb29a05e6bec3fd14a2814582fd7987848a Mon Sep 17 00:00:00 2001 From: codeErrorSleep Date: Fri, 27 Mar 2026 12:58:13 +0800 Subject: [PATCH 06/21] style: Format code to conform to the project code style specification --- src-tauri/src/commands/connection.rs | 10 +- src-tauri/src/commands/query.rs | 5 +- src-tauri/src/db/drivers/clickhouse.rs | 9 +- src-tauri/src/db/drivers/duckdb.rs | 10 +- src-tauri/src/db/drivers/mod.rs | 4 +- src-tauri/src/db/drivers/mssql.rs | 13 +- src-tauri/src/db/drivers/postgres.rs | 23 +- src-tauri/src/db/drivers/sqlite.rs | 31 +- src-tauri/src/ssh.rs | 9 +- src/App.tsx | 261 +++--- .../business/DataGrid/TableView.tsx | 32 +- .../business/DataGrid/tableView/utils.ts | 6 +- .../DataGrid/tableView/utils.unit.test.ts | 36 +- src/components/business/Editor/SqlEditor.tsx | 31 +- .../business/Editor/codemirrorTheme.ts | 2 +- .../business/Metadata/TableMetadataView.tsx | 12 +- .../business/Sidebar/ConnectionList.tsx | 745 ++++++++++-------- .../business/Sidebar/SavedQueriesList.tsx | 19 +- src/components/business/Sidebar/Sidebar.tsx | 6 +- .../Sidebar/connection-list/TreeNode.tsx | 4 +- .../Sidebar/connection-list/helpers.tsx | 9 +- .../SqlLogs/SqlExecutionLogsDialog.tsx | 10 +- src/components/settings/LanguageSelector.tsx | 5 +- src/components/settings/SettingsDialog.tsx | 133 ++-- src/components/updater-checker.tsx | 107 ++- src/lib/connection-form/rules.ts | 4 +- src/lib/connection-form/validate.ts | 28 +- src/lib/connection-form/validate.unit.test.ts | 4 +- src/lib/i18n/locales/en.ts | 27 +- src/lib/i18n/locales/ja.ts | 6 +- src/lib/i18n/locales/zh.ts | 10 +- src/lib/queryExecutionState.unit.test.ts | 72 +- src/services/api.ts | 4 +- src/services/mocks.service.test.ts | 15 +- src/services/mocks.ts | 10 +- src/services/updater.ts | 44 +- 36 files changed, 1047 insertions(+), 709 deletions(-) diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index fad8535..49f5b00 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -345,8 +345,6 @@ pub async fn delete_connection(state: State<'_, AppState>, id: i64) -> Result<() #[cfg(test)] mod tests { - use crate::connection_input::normalize_connection_form; - use crate::models::ConnectionForm; use super::{ build_mssql_create_database_sql, build_mysql_create_database_sql, build_postgres_create_database_sql, validate_database_name, CreateDatabasePayload, @@ -355,6 +353,8 @@ mod tests { normalize_create_database_error, normalize_option_token, quote_mssql_ident, quote_mysql_ident, quote_pg_ident, }; + use crate::connection_input::normalize_connection_form; + use crate::models::ConnectionForm; #[test] fn validate_database_name_rejects_empty_and_null() { @@ -395,10 +395,8 @@ mod tests { ); assert!(already.contains("[ALREADY_EXISTS]")); - let postgres = normalize_create_database_error( - "ERROR: 42P04 duplicate_database".to_string(), - "app", - ); + let postgres = + normalize_create_database_error("ERROR: 42P04 duplicate_database".to_string(), "app"); assert!(postgres.contains("[ALREADY_EXISTS]")); let perm = normalize_create_database_error( diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index d103541..b56dff3 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -1054,9 +1054,8 @@ mod tests { #[test] fn collect_top_level_keywords_skips_subqueries_and_strings() { - let tokens = collect_top_level_keywords( - "WITH cte AS (SELECT 'from' AS v) SELECT * FROM cte", - ); + let tokens = + collect_top_level_keywords("WITH cte AS (SELECT 'from' AS v) SELECT * FROM cte"); assert_eq!(tokens.first().map(String::as_str), Some("with")); assert!(tokens.contains(&"select".to_string())); assert!(tokens.contains(&"from".to_string())); diff --git a/src-tauri/src/db/drivers/clickhouse.rs b/src-tauri/src/db/drivers/clickhouse.rs index 5bcea9e..6a370a4 100644 --- a/src-tauri/src/db/drivers/clickhouse.rs +++ b/src-tauri/src/db/drivers/clickhouse.rs @@ -231,9 +231,12 @@ fn required_i64_from_json_row( key: &str, context_sql: &str, ) -> Result { - let value = row - .and_then(|v| v.get(key)) - .ok_or_else(|| format!("[PARSE_ERROR] Missing '{}' in response for SQL: {}", key, context_sql))?; + let value = row.and_then(|v| v.get(key)).ok_or_else(|| { + format!( + "[PARSE_ERROR] Missing '{}' in response for SQL: {}", + key, context_sql + ) + })?; value_to_i64(value).ok_or_else(|| { format!( "[PARSE_ERROR] Invalid '{}' value {:?} for SQL: {}", diff --git a/src-tauri/src/db/drivers/duckdb.rs b/src-tauri/src/db/drivers/duckdb.rs index 0c1ac48..0238483 100644 --- a/src-tauri/src/db/drivers/duckdb.rs +++ b/src-tauri/src/db/drivers/duckdb.rs @@ -859,7 +859,10 @@ mod tests { .unwrap(); assert_eq!(returning_result.row_count, 1); assert_eq!(returning_result.columns.len(), 2); - assert_eq!(returning_result.data[0]["id"], serde_json::Value::String("3".to_string())); + assert_eq!( + returning_result.data[0]["id"], + serde_json::Value::String("3".to_string()) + ); assert_eq!( returning_result.data[0]["name"], serde_json::Value::String("c".to_string()) @@ -871,7 +874,10 @@ mod tests { #[test] fn test_number_from_f64_nan_and_inf_are_stringified() { - assert_eq!(number_from_f64(f64::NAN), serde_json::Value::String("NaN".to_string())); + assert_eq!( + number_from_f64(f64::NAN), + serde_json::Value::String("NaN".to_string()) + ); assert_eq!( number_from_f64(f64::INFINITY), serde_json::Value::String("inf".to_string()) diff --git a/src-tauri/src/db/drivers/mod.rs b/src-tauri/src/db/drivers/mod.rs index 617f233..f83d407 100644 --- a/src-tauri/src/db/drivers/mod.rs +++ b/src-tauri/src/db/drivers/mod.rs @@ -169,7 +169,9 @@ mod tests { #[test] fn conn_failed_error_tls_hint() { - let msg = conn_failed_error(&"error communicating with database: received fatal alert: HandshakeFailure"); + let msg = conn_failed_error( + &"error communicating with database: received fatal alert: HandshakeFailure", + ); assert!(msg.starts_with("[CONN_FAILED]")); assert!(msg.contains("TLS/SSL handshake failed")); assert!(!msg.contains("username/password")); diff --git a/src-tauri/src/db/drivers/mssql.rs b/src-tauri/src/db/drivers/mssql.rs index 23811f7..9695cc2 100644 --- a/src-tauri/src/db/drivers/mssql.rs +++ b/src-tauri/src/db/drivers/mssql.rs @@ -438,7 +438,10 @@ mod tests { }); let hp = HashSet::from(["ID".to_string(), "amount".to_string()]); super::normalize_mssql_row_json(&mut row, &hp).unwrap(); - assert_eq!(row.get("id").and_then(|v| v.as_str()), Some("9223372036854775807")); + assert_eq!( + row.get("id").and_then(|v| v.as_str()), + Some("9223372036854775807") + ); assert_eq!(row.get("amount").and_then(|v| v.as_str()), Some("1234.56")); assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("x")); } @@ -750,7 +753,9 @@ impl DatabaseDriver for MssqlDriver { qualified, where_clause, order_clause, offset, safe_limit ); let json_sql = Self::build_for_json_query(&sql); - let data = self.fetch_json_rows(&json_sql, &high_precision_cols).await?; + let data = self + .fetch_json_rows(&json_sql, &high_precision_cols) + .await?; Ok(TableDataResponse { data, @@ -801,7 +806,9 @@ impl DatabaseDriver for MssqlDriver { .map(|col| col.name.clone()) .collect(); let json_sql = Self::build_for_json_query(&sql); - let data = self.fetch_json_rows(&json_sql, &high_precision_cols).await?; + let data = self + .fetch_json_rows(&json_sql, &high_precision_cols) + .await?; return Ok(QueryResult { row_count: data.len() as i64, diff --git a/src-tauri/src/db/drivers/postgres.rs b/src-tauri/src/db/drivers/postgres.rs index 5f16dee..e1d1bad 100644 --- a/src-tauri/src/db/drivers/postgres.rs +++ b/src-tauri/src/db/drivers/postgres.rs @@ -255,10 +255,7 @@ fn is_high_precision_pg_type(data_type: &str, udt_name: &str) -> bool { matches!( data_type.as_str(), "bigint" | "numeric" | "decimal" | "money" - ) || matches!( - udt_name.as_str(), - "int8" | "numeric" | "decimal" | "money" - ) + ) || matches!(udt_name.as_str(), "int8" | "numeric" | "decimal" | "money") } fn normalize_postgres_row_json( @@ -469,9 +466,7 @@ fn first_sql_keyword(sql: &str) -> Option { return None; } let mut end = start; - while end < bytes.len() - && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') - { + while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') { end += 1; } if end == start { @@ -1330,10 +1325,7 @@ mod tests { let sql = "CREATE TYPE mood_enum AS ENUM ('sad', 'ok'); CREATE TYPE address_type AS (street VARCHAR(100));"; let statements = split_sql_statements(sql); assert_eq!(statements.len(), 2); - assert_eq!( - statements[0], - "CREATE TYPE mood_enum AS ENUM ('sad', 'ok')" - ); + assert_eq!(statements[0], "CREATE TYPE mood_enum AS ENUM ('sad', 'ok')"); assert_eq!( statements[1], "CREATE TYPE address_type AS (street VARCHAR(100))" @@ -1391,7 +1383,10 @@ CREATE TABLE pg_data_type_test ( row.get("col_bigint").and_then(|v| v.as_str()), Some("9007199254740993") ); - assert_eq!(row.get("col_numeric").and_then(|v| v.as_str()), Some("1234.56")); + assert_eq!( + row.get("col_numeric").and_then(|v| v.as_str()), + Some("1234.56") + ); assert_eq!(row.get("col_text").and_then(|v| v.as_str()), Some("hello")); assert!(row.get("col_null").unwrap().is_null()); } @@ -1406,7 +1401,9 @@ CREATE TABLE pg_data_type_test ( #[test] fn test_is_json_projectable_statement() { assert!(is_json_projectable_statement("SELECT 1")); - assert!(is_json_projectable_statement(" -- a\nWITH t AS (SELECT 1) SELECT * FROM t")); + assert!(is_json_projectable_statement( + " -- a\nWITH t AS (SELECT 1) SELECT * FROM t" + )); assert!(is_json_projectable_statement("VALUES (1), (2)")); assert!(is_json_projectable_statement("TABLE my_table")); assert!(!is_json_projectable_statement("INSERT INTO t VALUES (1)")); diff --git a/src-tauri/src/db/drivers/sqlite.rs b/src-tauri/src/db/drivers/sqlite.rs index 04702fd..475fd09 100644 --- a/src-tauri/src/db/drivers/sqlite.rs +++ b/src-tauri/src/db/drivers/sqlite.rs @@ -146,9 +146,12 @@ fn sqlite_cell_to_json( let temporal_kind = sqlite_temporal_decl_kind(declared_type); let declared_bool = sqlite_declared_bool(declared_type); - let raw = row - .try_get_raw(column_name) - .map_err(|e| format!("[QUERY_ERROR] Failed to read SQLite column '{}': {}", column_name, e))?; + let raw = row.try_get_raw(column_name).map_err(|e| { + format!( + "[QUERY_ERROR] Failed to read SQLite column '{}': {}", + column_name, e + ) + })?; if raw.is_null() { return Ok(serde_json::Value::Null); } @@ -170,13 +173,11 @@ fn sqlite_cell_to_json( let maybe_date = if (-200_000..=200_000).contains(&v) { sqlite_format_date_from_days(v) } else { - sqlite_format_datetime_from_unix_seconds_f64(v as f64).and_then( - |s| { - NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f") - .ok() - .map(|dt| dt.date().format("%F").to_string()) - }, - ) + sqlite_format_datetime_from_unix_seconds_f64(v as f64).and_then(|s| { + NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f") + .ok() + .map(|dt| dt.date().format("%F").to_string()) + }) }; maybe_date .map(serde_json::Value::String) @@ -642,10 +643,7 @@ impl DatabaseDriver for SqliteDriver { .map(|s| s.as_str()) .or(Some(col.type_info().name())); let value = sqlite_cell_to_json(row, name, declared_type)?; - obj.insert( - name.to_string(), - value, - ); + obj.insert(name.to_string(), value); } data.push(serde_json::Value::Object(obj)); } @@ -718,10 +716,7 @@ impl DatabaseDriver for SqliteDriver { for col in row.columns() { let name = col.name(); let value = sqlite_cell_to_json(row, name, Some(col.type_info().name()))?; - obj.insert( - name.to_string(), - value, - ); + obj.insert(name.to_string(), value); } data.push(serde_json::Value::Object(obj)); } diff --git a/src-tauri/src/ssh.rs b/src-tauri/src/ssh.rs index 0349d7f..3c91e17 100644 --- a/src-tauri/src/ssh.rs +++ b/src-tauri/src/ssh.rs @@ -38,10 +38,11 @@ pub fn start_ssh_tunnel(config: &ConnectionForm) -> Result { .clone() .ok_or("SSH Username is required")?; let ssh_password = config.ssh_password.clone(); - let ssh_key_path = config - .ssh_key_path - .clone() - .and_then(|v| if v.trim().is_empty() { None } else { Some(v) }); + let ssh_key_path = + config + .ssh_key_path + .clone() + .and_then(|v| if v.trim().is_empty() { None } else { Some(v) }); let target_host = config.host.clone().unwrap_or("localhost".to_string()); let default_port: i64 = match config.driver.to_ascii_lowercase().as_str() { diff --git a/src/App.tsx b/src/App.tsx index 9ec60ae..d6a35ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -146,7 +146,9 @@ function LazyPanelFallback({ className?: string; }) { return ( -
+
{label}
); @@ -270,11 +272,13 @@ export default function App() { className="h-7 w-7 p-0" onClick={() => setAiVisible((v) => !v)} title={ + aiVisible ? t("app.window.hideAiPanel") : t("app.window.showAiPanel") + } + aria-label={ aiVisible - ? t("app.window.hideAiPanel") - : t("app.window.showAiPanel") + ? t("app.window.hideAiPanelAria") + : t("app.window.showAiPanelAria") } - aria-label={aiVisible ? t("app.window.hideAiPanelAria") : t("app.window.showAiPanelAria")} > @@ -373,54 +377,53 @@ export default function App() { Promise.allSettled([ fetchEditorDatabases(connectionId, initialDatabase), fetchEditorSchemaOverview(connectionId, initialDatabase), - ]) - .then(([availableDatabasesResult, schemaOverviewResult]) => { - if (availableDatabasesResult.status === "rejected") { - console.error( - "Failed to load editor databases:", - availableDatabasesResult.reason instanceof Error - ? availableDatabasesResult.reason.message - : String(availableDatabasesResult.reason), - ); - } - if (schemaOverviewResult.status === "rejected") { - console.error( - "Failed to load schema overview:", - schemaOverviewResult.reason instanceof Error - ? schemaOverviewResult.reason.message - : String(schemaOverviewResult.reason), - ); - } + ]).then(([availableDatabasesResult, schemaOverviewResult]) => { + if (availableDatabasesResult.status === "rejected") { + console.error( + "Failed to load editor databases:", + availableDatabasesResult.reason instanceof Error + ? availableDatabasesResult.reason.message + : String(availableDatabasesResult.reason), + ); + } + if (schemaOverviewResult.status === "rejected") { + console.error( + "Failed to load schema overview:", + schemaOverviewResult.reason instanceof Error + ? schemaOverviewResult.reason.message + : String(schemaOverviewResult.reason), + ); + } - const availableDatabases = - availableDatabasesResult.status === "fulfilled" - ? availableDatabasesResult.value - : normalizeDatabaseOptions( - initialDatabase ? [initialDatabase] : [], - initialDatabase, - ); - const schemaOverview = - schemaOverviewResult.status === "fulfilled" - ? schemaOverviewResult.value - : undefined; + const availableDatabases = + availableDatabasesResult.status === "fulfilled" + ? availableDatabasesResult.value + : normalizeDatabaseOptions( + initialDatabase ? [initialDatabase] : [], + initialDatabase, + ); + const schemaOverview = + schemaOverviewResult.status === "fulfilled" + ? schemaOverviewResult.value + : undefined; - setTabs((prev) => - prev.map((t) => - t.id === newTabId - ? { - ...t, - database: resolvePreferredDatabase({ - preferredDatabase: initialDatabase, - connectionDatabase: initialDatabase, - availableDatabases, - }), + setTabs((prev) => + prev.map((t) => + t.id === newTabId + ? { + ...t, + database: resolvePreferredDatabase({ + preferredDatabase: initialDatabase, + connectionDatabase: initialDatabase, availableDatabases, - schemaOverview, - } - : t, - ), - ); - }); + }), + availableDatabases, + schemaOverview, + } + : t, + ), + ); + }); }; const handleOpenSavedQuery = async (query: SavedQuery) => { @@ -578,14 +581,16 @@ export default function App() { tab.connectionId, database, ); - if (schemaOverviewRequestKeysRef.current.get(tabId) !== requestKey) return; + if (schemaOverviewRequestKeysRef.current.get(tabId) !== requestKey) + return; setTabs((prev) => prev.map((item) => item.id === tabId ? { ...item, schemaOverview } : item, ), ); } catch (e) { - if (schemaOverviewRequestKeysRef.current.get(tabId) !== requestKey) return; + if (schemaOverviewRequestKeysRef.current.get(tabId) !== requestKey) + return; const errorMessage = e instanceof Error ? e.message : String(e); console.error("Failed to switch editor database", errorMessage); toast.error(t("app.error.loadSchemaOverview"), { @@ -623,7 +628,9 @@ export default function App() { .slice(2, 8)}`; setTabs((prev) => prev.map((t) => - t.id === tabId ? { ...t, activeQueryId: queryId, lastQueryId: queryId } : t, + t.id === tabId + ? { ...t, activeQueryId: queryId, lastQueryId: queryId } + : t, ), ); try { @@ -762,9 +769,12 @@ export default function App() { scope: "full_table", filePath, }); - toast.success(t("app.success.exportCompleted", { count: result.rowCount }), { - description: result.filePath, - }); + toast.success( + t("app.success.exportCompleted", { count: result.rowCount }), + { + description: result.filePath, + }, + ); } catch (e) { toast.error(t("app.error.exportFailed"), { description: e instanceof Error ? e.message : String(e), @@ -1447,67 +1457,72 @@ export default function App() { : tab.title; return ( - - - {/* Wrapper avoids data-state conflict: ContextMenu and Tabs both set it; only the trigger must get Tabs' data-state=active for the indicator bar */} - - { - if (e.button === 1) { - e.preventDefault(); - handleCloseTab(tab.id); - } - }} - > -
- {tab.type === "table" ? ( - - ) : ( - - )} - - - {title} - - {tab.type === "editor" && tab.isDirty && ( - - )} - - - - - - - - handleCloseTab(tab.id)} - > - {t("app.tab.closeTab")} - - handleCloseOtherTabs(tab.id)} - > - {t("app.tab.closeOtherTabs")} - - - - + } + }} + > +
+ {tab.type === "table" ? ( +
+ ) : ( + + )} + + + {title} + + {tab.type === "editor" && + tab.isDirty && ( + + )} + + + + + + + + handleCloseTab(tab.id)} + > + {t("app.tab.closeTab")} + + handleCloseOtherTabs(tab.id)} + > + {t("app.tab.closeOtherTabs")} + + + + ); })} @@ -1529,9 +1544,7 @@ export default function App() {
-

- {t("app.empty.hint")} -

+

{t("app.empty.hint")}

) : ( @@ -1544,9 +1557,7 @@ export default function App() { {tab.type === "editor" ? ( + } > - } + fallback={} > { - const target = containerRef.current?.querySelector( - selector, - ); + const target = + containerRef.current?.querySelector(selector); if (!target) return; target.scrollIntoView({ behavior: "smooth", @@ -1757,15 +1758,17 @@ export function TableView({ {tableContext && (!isEditable || hasLocalClientSort) && - (primaryKeys.length === 0 || isReadOnlyDriver || hasLocalClientSort) && ( + (primaryKeys.length === 0 || + isReadOnlyDriver || + hasLocalClientSort) && ( Read-only @@ -1775,15 +1778,17 @@ export function TableView({ ) : ( tableContext && (!isEditable || hasLocalClientSort) && - (primaryKeys.length === 0 || isReadOnlyDriver || hasLocalClientSort) && ( + (primaryKeys.length === 0 || + isReadOnlyDriver || + hasLocalClientSort) && ( Read-only @@ -1945,7 +1950,10 @@ export function TableView({ }} onClick={() => handleCellClick(rowIndex, column)} onContextMenu={() => { - if (selectedRows.size > 1 && selectedRows.has(rowIndex)) { + if ( + selectedRows.size > 1 && + selectedRows.has(rowIndex) + ) { return; } handleCellClick(rowIndex, column); @@ -1972,7 +1980,7 @@ export function TableView({ ) : (
{displayValue !== null && - displayValue !== undefined ? ( + displayValue !== undefined ? ( any, + getCellDisplayValue: ( + rowIndex: number, + column: string, + originalValue: any, + ) => any, ): SearchMatch[] { if (!normalizedSearchKeyword) { return []; diff --git a/src/components/business/DataGrid/tableView/utils.unit.test.ts b/src/components/business/DataGrid/tableView/utils.unit.test.ts index 9e63303..076bc2e 100644 --- a/src/components/business/DataGrid/tableView/utils.unit.test.ts +++ b/src/components/business/DataGrid/tableView/utils.unit.test.ts @@ -56,7 +56,11 @@ describe("formatInsertSQLValue", () => { test("formats boolean values", () => { expect( - formatInsertSQLValue("true", { name: "enabled", type: "boolean" }, "postgres"), + formatInsertSQLValue( + "true", + { name: "enabled", type: "boolean" }, + "postgres", + ), ).toBe("TRUE"); expect( formatInsertSQLValue("0", { name: "enabled", type: "boolean" }, "mssql"), @@ -65,7 +69,11 @@ describe("formatInsertSQLValue", () => { test("throws for invalid boolean values", () => { expect(() => - formatInsertSQLValue("yes", { name: "enabled", type: "boolean" }, "postgres"), + formatInsertSQLValue( + "yes", + { name: "enabled", type: "boolean" }, + "postgres", + ), ).toThrow('Invalid boolean value for column "enabled": "yes"'); }); @@ -84,9 +92,9 @@ describe("isInsertColumnRequired", () => { }); test("returns false when nullable", () => { - expect( - isInsertColumnRequired({ nullable: true, defaultValue: null }), - ).toBe(false); + expect(isInsertColumnRequired({ nullable: true, defaultValue: null })).toBe( + false, + ); }); test("returns false when default value exists", () => { @@ -101,7 +109,9 @@ describe("isInsertColumnRequired", () => { describe("getQualifiedTableName", () => { test("uses unqualified table with backticks for tidb", () => { - expect(getQualifiedTableName("tidb", "analytics", "events")).toBe("`events`"); + expect(getQualifiedTableName("tidb", "analytics", "events")).toBe( + "`events`", + ); }); test("uses unqualified table with backticks for mariadb", () => { @@ -111,20 +121,20 @@ describe("getQualifiedTableName", () => { }); test("does not qualify sqlite main/public schema", () => { - expect(getQualifiedTableName("sqlite", "main", "users")).toBe("\"users\""); - expect(getQualifiedTableName("sqlite", "public", "users")).toBe("\"users\""); - expect(getQualifiedTableName("sqlite", "", "users")).toBe("\"users\""); + expect(getQualifiedTableName("sqlite", "main", "users")).toBe('"users"'); + expect(getQualifiedTableName("sqlite", "public", "users")).toBe('"users"'); + expect(getQualifiedTableName("sqlite", "", "users")).toBe('"users"'); }); test("keeps non-main sqlite schema qualification", () => { expect(getQualifiedTableName("sqlite", "analytics", "events")).toBe( - "\"analytics\".\"events\"", + '"analytics"."events"', ); }); test("does not qualify duckdb main/public schema", () => { - expect(getQualifiedTableName("duckdb", "main", "users")).toBe("\"users\""); - expect(getQualifiedTableName("duckdb", "public", "users")).toBe("\"users\""); - expect(getQualifiedTableName("duckdb", "", "users")).toBe("\"users\""); + expect(getQualifiedTableName("duckdb", "main", "users")).toBe('"users"'); + expect(getQualifiedTableName("duckdb", "public", "users")).toBe('"users"'); + expect(getQualifiedTableName("duckdb", "", "users")).toBe('"users"'); }); }); diff --git a/src/components/business/Editor/SqlEditor.tsx b/src/components/business/Editor/SqlEditor.tsx index 5eb67e1..b2a34fa 100644 --- a/src/components/business/Editor/SqlEditor.tsx +++ b/src/components/business/Editor/SqlEditor.tsx @@ -479,9 +479,12 @@ export function SqlEditor({ format, filePath, }); - toast.success(t("sqlEditor.export.completed", { count: result.rowCount }), { - description: result.filePath, - }); + toast.success( + t("sqlEditor.export.completed", { count: result.rowCount }), + { + description: result.filePath, + }, + ); } catch (e) { toast.error(t("sqlEditor.export.failed"), { description: e instanceof Error ? e.message : String(e), @@ -494,7 +497,10 @@ export function SqlEditor({ const triggerSave = useCallback(() => { const currentId = savedQueryIdRef.current; if (currentId) { - executeSave(initialName || t("sqlEditor.untitled"), initialDescription || ""); + executeSave( + initialName || t("sqlEditor.untitled"), + initialDescription || "", + ); } else { setIsSaveDialogOpen(true); } @@ -603,7 +609,10 @@ export function SqlEditor({ .filter((item): item is CompletionResult => !!item); if (!results.length) return null; - const from = results.reduce((min, curr) => Math.min(min, curr.from), results[0].from); + const from = results.reduce( + (min, curr) => Math.min(min, curr.from), + results[0].from, + ); const options: NonNullable[number][] = []; const seen = new Set(); for (const result of results) { @@ -691,16 +700,13 @@ export function SqlEditor({
- {databaseName && ( - canSwitchDatabase ? ( + {databaseName && + (canSwitchDatabase ? (
- )}
- ) - )} + ))}
diff --git a/src/components/business/Editor/codemirrorTheme.ts b/src/components/business/Editor/codemirrorTheme.ts index 792c346..c26e0ad 100644 --- a/src/components/business/Editor/codemirrorTheme.ts +++ b/src/components/business/Editor/codemirrorTheme.ts @@ -32,7 +32,7 @@ const baseThemeSpec: Parameters[0] = { }, ".cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection, &.cm-focused .cm-content ::selection": { - backgroundColor: "var(--editor-selection-bg) !important", + backgroundColor: "var(--editor-selection-bg) !important", }, ".cm-tooltip": { backgroundColor: "var(--popover)", diff --git a/src/components/business/Metadata/TableMetadataView.tsx b/src/components/business/Metadata/TableMetadataView.tsx index 7d56ca8..93aee78 100644 --- a/src/components/business/Metadata/TableMetadataView.tsx +++ b/src/components/business/Metadata/TableMetadataView.tsx @@ -207,7 +207,9 @@ export function TableMetadataView({
{clickhouseExtra.partitionKey && (
-
Partition Key
+
+ Partition Key +
{clickhouseExtra.partitionKey}
@@ -215,7 +217,9 @@ export function TableMetadataView({ )} {clickhouseExtra.sortingKey && (
-
Sorting Key
+
+ Sorting Key +
{clickhouseExtra.sortingKey}
@@ -233,7 +237,9 @@ export function TableMetadataView({ )} {clickhouseExtra.samplingKey && (
-
Sampling Key
+
+ Sampling Key +
{clickhouseExtra.samplingKey}
diff --git a/src/components/business/Sidebar/ConnectionList.tsx b/src/components/business/Sidebar/ConnectionList.tsx index 0b2a7d7..3750a81 100644 --- a/src/components/business/Sidebar/ConnectionList.tsx +++ b/src/components/business/Sidebar/ConnectionList.tsx @@ -259,7 +259,9 @@ export function ConnectionList({ const [expandedQueryGroups, setExpandedQueryGroups] = useState>( new Set(), ); - const [expandedSchemas, setExpandedSchemas] = useState>(new Set()); + const [expandedSchemas, setExpandedSchemas] = useState>( + new Set(), + ); const [expandedTables, setExpandedTables] = useState>(new Set()); const [selectedTableKey, setSelectedTableKey] = useState(null); const [autoScrollRequest, setAutoScrollRequest] = useState<{ @@ -289,14 +291,14 @@ export function ConnectionList({ const [deleteTargetConnectionId, setDeleteTargetConnectionId] = useState< string | null >(null); - const [createDbConnectionId, setCreateDbConnectionId] = useState( - null, - ); + const [createDbConnectionId, setCreateDbConnectionId] = useState< + string | null + >(null); const [isCreateDbDialogOpen, setIsCreateDbDialogOpen] = useState(false); const [showCreateDbAdvanced, setShowCreateDbAdvanced] = useState(false); - const [createDbValidationMsg, setCreateDbValidationMsg] = useState( - null, - ); + const [createDbValidationMsg, setCreateDbValidationMsg] = useState< + string | null + >(null); const [createDbForm, setCreateDbForm] = useState( defaultCreateDatabaseForm, ); @@ -437,7 +439,12 @@ export function ConnectionList({ return null; }) .filter(Boolean) as Connection[]; - }, [connections, savedQueriesByConnection, searchTerm, showSavedQueriesInTree]); + }, [ + connections, + savedQueriesByConnection, + searchTerm, + showSavedQueriesInTree, + ]); useEffect(() => { if (searchTerm) { @@ -486,11 +493,7 @@ export function ConnectionList({ }); } } - }, [ - searchTerm, - filteredConnections, - showSavedQueriesInTree, - ]); + }, [searchTerm, filteredConnections, showSavedQueriesInTree]); useEffect( () => () => { @@ -515,7 +518,10 @@ export function ConnectionList({ form.driver !== "mariadb" ); }, [form.driver]); - const normalizedForm = useMemo(() => normalizeConnectionFormInput(form), [form]); + const normalizedForm = useMemo( + () => normalizeConnectionFormInput(form), + [form], + ); const validationIssues = useMemo( () => validateConnectionFormInput( @@ -576,7 +582,10 @@ export function ConnectionList({ const selectedPath = await pickSingleFile({ title: t("connection.dialog.sslCaFileDialogTitle"), filters: [ - { name: t("connection.dialog.fileFilterCert"), extensions: ["pem", "crt", "cer"] }, + { + name: t("connection.dialog.fileFilterCert"), + extensions: ["pem", "crt", "cer"], + }, { name: t("connection.dialog.fileFilterAll"), extensions: ["*"] }, ], }); @@ -595,7 +604,10 @@ export function ConnectionList({ const selectedPath = await pickSingleFile({ title: t("connection.dialog.sshKeyFileDialogTitle"), filters: [ - { name: t("connection.dialog.fileFilterPem"), extensions: ["pem", "key", "ppk"] }, + { + name: t("connection.dialog.fileFilterPem"), + extensions: ["pem", "key", "ppk"], + }, { name: t("connection.dialog.fileFilterAll"), extensions: ["*"] }, ], }); @@ -676,7 +688,8 @@ export function ConnectionList({ const toggleConnection = (id: string) => { const connection = connections.find((conn) => conn.id === id); if (!connection) return; - if (connection.connectState !== "success" && !showSavedQueriesInTree) return; + if (connection.connectState !== "success" && !showSavedQueriesInTree) + return; const newExpanded = new Set(expandedConnections); if (newExpanded.has(id)) { @@ -1245,24 +1258,30 @@ export function ConnectionList({ conn.id === connectionId ? { ...conn, databases: [] } : conn, ), ); - setExpandedDatabases((prev) => - new Set([...prev].filter((key) => !key.startsWith(`${connectionId}-`))), + setExpandedDatabases( + (prev) => + new Set([...prev].filter((key) => !key.startsWith(`${connectionId}-`))), ); - setExpandedSchemas((prev) => - new Set([...prev].filter((key) => !key.startsWith(`${connectionId}-`))), + setExpandedSchemas( + (prev) => + new Set([...prev].filter((key) => !key.startsWith(`${connectionId}-`))), ); - setExpandedTables((prev) => - new Set([...prev].filter((key) => !key.startsWith(`${connectionId}-`))), + setExpandedTables( + (prev) => + new Set([...prev].filter((key) => !key.startsWith(`${connectionId}-`))), ); }; const handleCreateDatabase = async () => { const connection = createDbTargetConnection; - if (!connection || !supportsCreateDatabaseForDriver(connection.type)) return; + if (!connection || !supportsCreateDatabaseForDriver(connection.type)) + return; const name = createDbForm.name.trim(); if (!name) { - setCreateDbValidationMsg(t("connection.createDbDialog.validation.requiredName")); + setCreateDbValidationMsg( + t("connection.createDbDialog.validation.requiredName"), + ); return; } @@ -1271,16 +1290,19 @@ export function ConnectionList({ ifNotExists: createDbForm.ifNotExists, }; if (isMySqlFamilyCreateDb) { - if (createDbForm.charset.trim()) payload.charset = createDbForm.charset.trim(); + if (createDbForm.charset.trim()) + payload.charset = createDbForm.charset.trim(); if (createDbForm.collation.trim()) { payload.collation = createDbForm.collation.trim(); } } else if (isPostgresCreateDb) { - if (createDbForm.encoding.trim()) payload.encoding = createDbForm.encoding.trim(); + if (createDbForm.encoding.trim()) + payload.encoding = createDbForm.encoding.trim(); if (createDbForm.lcCollate.trim()) { payload.lcCollate = createDbForm.lcCollate.trim(); } - if (createDbForm.lcCtype.trim()) payload.lcCtype = createDbForm.lcCtype.trim(); + if (createDbForm.lcCtype.trim()) + payload.lcCtype = createDbForm.lcCtype.trim(); } else if (isMssqlCreateDb) { if (createDbForm.collation.trim()) { payload.collation = createDbForm.collation.trim(); @@ -1751,7 +1773,9 @@ export function ConnectionList({
- +
- + @@ -1866,16 +1896,19 @@ export function ConnectionList({
- +
- + ({ ...f, ssl: checked === true })) } /> - +
{form.ssl && supportsSslCa && (
@@ -1968,7 +2007,9 @@ export function ConnectionList({