diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9829748..918aee9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -220,10 +220,30 @@ pub fn find_connection_by_id( // --- Commands --- +#[tauri::command] +pub async fn get_schemas( + app: AppHandle, + connection_id: String, +) -> Result, String> { + log::info!("Fetching schemas for connection: {}", connection_id); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + match saved_conn.params.driver.as_str() { + "mysql" => mysql::get_schemas(¶ms).await, + "postgres" => postgres::get_schemas(¶ms).await, + "sqlite" => sqlite::get_schemas(¶ms).await, + _ => Err("Unsupported driver".into()), + } +} + #[tauri::command] pub async fn get_routines( app: AppHandle, connection_id: String, + schema: Option, ) -> Result, String> { log::info!("Fetching routines for connection: {}", connection_id); @@ -233,7 +253,7 @@ pub async fn get_routines( match saved_conn.params.driver.as_str() { "mysql" => mysql::get_routines(¶ms).await, - "postgres" => postgres::get_routines(¶ms).await, + "postgres" => postgres::get_routines(¶ms, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_routines(¶ms).await, _ => Err("Unsupported driver".into()), } @@ -244,6 +264,7 @@ pub async fn get_routine_parameters( app: AppHandle, connection_id: String, routine_name: String, + schema: Option, ) -> Result, String> { log::info!( "Fetching routine parameters for: {} on connection: {}", @@ -257,7 +278,7 @@ pub async fn get_routine_parameters( match saved_conn.params.driver.as_str() { "mysql" => mysql::get_routine_parameters(¶ms, &routine_name).await, - "postgres" => postgres::get_routine_parameters(¶ms, &routine_name).await, + "postgres" => postgres::get_routine_parameters(¶ms, &routine_name, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_routine_parameters(¶ms, &routine_name).await, _ => Err("Unsupported driver".into()), } @@ -269,6 +290,7 @@ pub async fn get_routine_definition( connection_id: String, routine_name: String, routine_type: String, // "PROCEDURE" or "FUNCTION" - mainly for MySQL SHOW CREATE + schema: Option, ) -> Result { log::info!( "Fetching routine definition for: {} ({}) on connection: {}", @@ -283,7 +305,7 @@ pub async fn get_routine_definition( match saved_conn.params.driver.as_str() { "mysql" => mysql::get_routine_definition(¶ms, &routine_name, &routine_type).await, - "postgres" => postgres::get_routine_definition(¶ms, &routine_name, &routine_type).await, + "postgres" => postgres::get_routine_definition(¶ms, &routine_name, &routine_type, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_routine_definition(¶ms, &routine_name, &routine_type).await, _ => Err("Unsupported driver".into()), } @@ -293,22 +315,24 @@ pub async fn get_routine_definition( pub async fn get_schema_snapshot( app: AppHandle, connection_id: String, + schema: Option, ) -> Result, String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; let driver = saved_conn.params.driver.clone(); + let pg_schema = schema.as_deref().unwrap_or("public"); // 1. Get Tables let tables = match driver.as_str() { "mysql" => mysql::get_tables(¶ms).await, - "postgres" => postgres::get_tables(¶ms).await, + "postgres" => postgres::get_tables(¶ms, pg_schema).await, "sqlite" => sqlite::get_tables(¶ms).await, _ => Err("Unsupported driver".into()), }?; // 2. Fetch ALL columns and foreign keys in batch (2 queries instead of N*2) - let schema = match driver.as_str() { + let result = match driver.as_str() { "mysql" => { let mut columns_map = mysql::get_all_columns_batch(¶ms).await?; let mut fks_map = mysql::get_all_foreign_keys_batch(¶ms).await?; @@ -323,8 +347,8 @@ pub async fn get_schema_snapshot( .collect() } "postgres" => { - let mut columns_map = postgres::get_all_columns_batch(¶ms).await?; - let mut fks_map = postgres::get_all_foreign_keys_batch(¶ms).await?; + let mut columns_map = postgres::get_all_columns_batch(¶ms, pg_schema).await?; + let mut fks_map = postgres::get_all_foreign_keys_batch(¶ms, pg_schema).await?; tables .into_iter() @@ -352,7 +376,7 @@ pub async fn get_schema_snapshot( _ => return Err("Unsupported driver".into()), }; - Ok(schema) + Ok(result) } #[tauri::command] @@ -1535,6 +1559,7 @@ pub async fn list_databases( pub async fn get_tables( app: AppHandle, connection_id: String, + schema: Option, ) -> Result, String> { log::info!("Fetching tables for connection: {}", connection_id); @@ -1550,7 +1575,7 @@ pub async fn get_tables( let result = match saved_conn.params.driver.as_str() { "mysql" => mysql::get_tables(¶ms).await, - "postgres" => postgres::get_tables(¶ms).await, + "postgres" => postgres::get_tables(¶ms, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_tables(¶ms).await, _ => Err("Unsupported driver".into()), }; @@ -1568,13 +1593,14 @@ pub async fn get_columns( app: AppHandle, connection_id: String, table_name: String, + schema: Option, ) -> Result, String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; match saved_conn.params.driver.as_str() { "mysql" => mysql::get_columns(¶ms, &table_name).await, - "postgres" => postgres::get_columns(¶ms, &table_name).await, + "postgres" => postgres::get_columns(¶ms, &table_name, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_columns(¶ms, &table_name).await, _ => Err("Unsupported driver".into()), } @@ -1585,13 +1611,14 @@ pub async fn get_foreign_keys( app: AppHandle, connection_id: String, table_name: String, + schema: Option, ) -> Result, String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; match saved_conn.params.driver.as_str() { "mysql" => mysql::get_foreign_keys(¶ms, &table_name).await, - "postgres" => postgres::get_foreign_keys(¶ms, &table_name).await, + "postgres" => postgres::get_foreign_keys(¶ms, &table_name, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_foreign_keys(¶ms, &table_name).await, _ => Err("Unsupported driver".into()), } @@ -1602,13 +1629,14 @@ pub async fn get_indexes( app: AppHandle, connection_id: String, table_name: String, + schema: Option, ) -> Result, String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; match saved_conn.params.driver.as_str() { "mysql" => mysql::get_indexes(¶ms, &table_name).await, - "postgres" => postgres::get_indexes(¶ms, &table_name).await, + "postgres" => postgres::get_indexes(¶ms, &table_name, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_indexes(¶ms, &table_name).await, _ => Err("Unsupported driver".into()), } @@ -1621,13 +1649,14 @@ pub async fn delete_record( table: String, pk_col: String, pk_val: serde_json::Value, + schema: Option, ) -> Result { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; match saved_conn.params.driver.as_str() { "mysql" => mysql::delete_record(¶ms, &table, &pk_col, pk_val).await, - "postgres" => postgres::delete_record(¶ms, &table, &pk_col, pk_val).await, + "postgres" => postgres::delete_record(¶ms, &table, &pk_col, pk_val, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::delete_record(¶ms, &table, &pk_col, pk_val).await, _ => Err("Unsupported driver".into()), } @@ -1642,6 +1671,7 @@ pub async fn update_record( pk_val: serde_json::Value, col_name: String, new_val: serde_json::Value, + schema: Option, ) -> Result { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; @@ -1649,7 +1679,7 @@ pub async fn update_record( match saved_conn.params.driver.as_str() { "mysql" => mysql::update_record(¶ms, &table, &pk_col, pk_val, &col_name, new_val).await, "postgres" => { - postgres::update_record(¶ms, &table, &pk_col, pk_val, &col_name, new_val).await + postgres::update_record(¶ms, &table, &pk_col, pk_val, &col_name, new_val, schema.as_deref().unwrap_or("public")).await } "sqlite" => { sqlite::update_record(¶ms, &table, &pk_col, pk_val, &col_name, new_val).await @@ -1664,13 +1694,14 @@ pub async fn insert_record( connection_id: String, table: String, data: std::collections::HashMap, + schema: Option, ) -> Result { let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; match saved_conn.params.driver.as_str() { "mysql" => mysql::insert_record(¶ms, &table, data).await, - "postgres" => postgres::insert_record(¶ms, &table, data).await, + "postgres" => postgres::insert_record(¶ms, &table, data, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::insert_record(¶ms, &table, data).await, _ => Err("Unsupported driver".into()), } @@ -1698,6 +1729,7 @@ pub async fn execute_query( query: String, limit: Option, page: Option, + schema: Option, ) -> Result { log::info!( "Executing query on connection: {} | Query: {}", @@ -1705,8 +1737,15 @@ pub async fn execute_query( query.chars().take(200).collect::() ); - // 1. Sanitize Query (Ignore trailing semicolon) - let sanitized_query = query.trim().trim_end_matches(';').to_string(); + // 1. Sanitize Query (Ignore trailing semicolon + normalize smart quotes) + let sanitized_query = query + .trim() + .trim_end_matches(';') + .replace('\u{2018}', "'") // Left single quotation mark + .replace('\u{2019}', "'") // Right single quotation mark + .replace('\u{201C}', "\"") // Left double quotation mark + .replace('\u{201D}', "\"") // Right double quotation mark + .to_string(); let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; @@ -1719,7 +1758,14 @@ pub async fn execute_query( mysql::execute_query(¶ms, &sanitized_query, limit, page.unwrap_or(1)).await } "postgres" => { - postgres::execute_query(¶ms, &sanitized_query, limit, page.unwrap_or(1)).await + postgres::execute_query( + ¶ms, + &sanitized_query, + limit, + page.unwrap_or(1), + schema.as_deref(), + ) + .await } "sqlite" => { sqlite::execute_query(¶ms, &sanitized_query, limit, page.unwrap_or(1)).await @@ -1825,11 +1871,13 @@ pub async fn open_er_diagram_window( connection_name: String, database_name: String, focus_table: Option, + schema: Option, ) -> Result<(), String> { use tauri::{WebviewUrl, WebviewWindowBuilder}; use urlencoding::encode; - let title = format!("tabularis - {} ({})", database_name, connection_name); + let schema_suffix = schema.as_deref().map(|s| format!("/{}", s)).unwrap_or_default(); + let title = format!("tabularis - {} ({}{})", database_name, connection_name, schema_suffix); let mut url = format!( "/schema-diagram?connectionId={}&connectionName={}&databaseName={}", encode(&connection_id), @@ -1837,11 +1885,14 @@ pub async fn open_er_diagram_window( encode(&database_name) ); - // Aggiungi il parametro focusTable se presente if let Some(table) = focus_table { url.push_str(&format!("&focusTable={}", encode(&table))); } + if let Some(s) = &schema { + url.push_str(&format!("&schema={}", encode(s))); + } + let _webview = WebviewWindowBuilder::new(&app, "er-diagram", WebviewUrl::App(url.into())) .title(&title) .inner_size(1200.0, 800.0) @@ -1969,6 +2020,7 @@ fn resolve_ssh_test_password( pub async fn get_views( app: AppHandle, connection_id: String, + schema: Option, ) -> Result, String> { log::info!("Fetching views for connection: {}", connection_id); @@ -1984,7 +2036,7 @@ pub async fn get_views( let result = match saved_conn.params.driver.as_str() { "mysql" => mysql::get_views(¶ms).await, - "postgres" => postgres::get_views(¶ms).await, + "postgres" => postgres::get_views(¶ms, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_views(¶ms).await, _ => Err("Unsupported driver".into()), }; @@ -2002,6 +2054,7 @@ pub async fn get_view_definition( app: AppHandle, connection_id: String, view_name: String, + schema: Option, ) -> Result { log::info!( "Fetching view definition for: {} on connection: {}", @@ -2015,7 +2068,7 @@ pub async fn get_view_definition( let result = match saved_conn.params.driver.as_str() { "mysql" => mysql::get_view_definition(¶ms, &view_name).await, - "postgres" => postgres::get_view_definition(¶ms, &view_name).await, + "postgres" => postgres::get_view_definition(¶ms, &view_name, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_view_definition(¶ms, &view_name).await, _ => Err("Unsupported driver".into()), }; @@ -2034,6 +2087,7 @@ pub async fn create_view( connection_id: String, view_name: String, definition: String, + schema: Option, ) -> Result<(), String> { log::info!( "Creating view: {} on connection: {}", @@ -2047,7 +2101,7 @@ pub async fn create_view( let result = match saved_conn.params.driver.as_str() { "mysql" => mysql::create_view(¶ms, &view_name, &definition).await, - "postgres" => postgres::create_view(¶ms, &view_name, &definition).await, + "postgres" => postgres::create_view(¶ms, &view_name, &definition, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::create_view(¶ms, &view_name, &definition).await, _ => Err("Unsupported driver".into()), }; @@ -2066,6 +2120,7 @@ pub async fn alter_view( connection_id: String, view_name: String, definition: String, + schema: Option, ) -> Result<(), String> { log::info!( "Altering view: {} on connection: {}", @@ -2079,7 +2134,7 @@ pub async fn alter_view( let result = match saved_conn.params.driver.as_str() { "mysql" => mysql::alter_view(¶ms, &view_name, &definition).await, - "postgres" => postgres::alter_view(¶ms, &view_name, &definition).await, + "postgres" => postgres::alter_view(¶ms, &view_name, &definition, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::alter_view(¶ms, &view_name, &definition).await, _ => Err("Unsupported driver".into()), }; @@ -2097,6 +2152,7 @@ pub async fn drop_view( app: AppHandle, connection_id: String, view_name: String, + schema: Option, ) -> Result<(), String> { log::info!( "Dropping view: {} on connection: {}", @@ -2110,7 +2166,7 @@ pub async fn drop_view( let result = match saved_conn.params.driver.as_str() { "mysql" => mysql::drop_view(¶ms, &view_name).await, - "postgres" => postgres::drop_view(¶ms, &view_name).await, + "postgres" => postgres::drop_view(¶ms, &view_name, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::drop_view(¶ms, &view_name).await, _ => Err("Unsupported driver".into()), }; @@ -2128,6 +2184,7 @@ pub async fn get_view_columns( app: AppHandle, connection_id: String, view_name: String, + schema: Option, ) -> Result, String> { log::info!( "Fetching view columns for: {} on connection: {}", @@ -2141,7 +2198,7 @@ pub async fn get_view_columns( let result = match saved_conn.params.driver.as_str() { "mysql" => mysql::get_view_columns(¶ms, &view_name).await, - "postgres" => postgres::get_view_columns(¶ms, &view_name).await, + "postgres" => postgres::get_view_columns(¶ms, &view_name, schema.as_deref().unwrap_or("public")).await, "sqlite" => sqlite::get_view_columns(¶ms, &view_name).await, _ => Err("Unsupported driver".into()), }; diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 3f92eef..cc5ecad 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -26,6 +26,8 @@ pub struct AppConfig { pub auto_check_updates_on_startup: Option, pub last_dismissed_version: Option, pub er_diagram_default_layout: Option, + pub schema_preferences: Option>, + pub selected_schemas: Option>>, } pub fn get_config_dir(app: &AppHandle) -> Option { @@ -112,6 +114,12 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { if config.er_diagram_default_layout.is_some() { existing_config.er_diagram_default_layout = config.er_diagram_default_layout; } + if config.schema_preferences.is_some() { + existing_config.schema_preferences = config.schema_preferences; + } + if config.selected_schemas.is_some() { + existing_config.selected_schemas = config.selected_schemas; + } let content = serde_json::to_string_pretty(&existing_config).map_err(|e| e.to_string())?; fs::write(config_path, content).map_err(|e| e.to_string())?; @@ -121,6 +129,65 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { } } +#[tauri::command] +pub fn get_schema_preference(app: AppHandle, connection_id: String) -> Option { + let config = load_config_internal(&app); + config.schema_preferences.and_then(|prefs| prefs.get(&connection_id).cloned()) +} + +#[tauri::command] +pub fn set_schema_preference(app: AppHandle, connection_id: String, schema: String) -> Result<(), String> { + if let Some(config_dir) = get_config_dir(&app) { + if !config_dir.exists() { + fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; + } + let config_path = config_dir.join("config.json"); + let mut config = load_config_internal(&app); + let prefs = config.schema_preferences.get_or_insert_with(HashMap::new); + prefs.insert(connection_id, schema); + let content = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + fs::write(config_path, content).map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("Could not resolve config directory".to_string()) + } +} + +#[tauri::command] +pub fn get_selected_schemas(app: AppHandle, connection_id: String) -> Vec { + let config = load_config_internal(&app); + config + .selected_schemas + .and_then(|map| map.get(&connection_id).cloned()) + .unwrap_or_default() +} + +#[tauri::command] +pub fn set_selected_schemas( + app: AppHandle, + connection_id: String, + schemas: Vec, +) -> Result<(), String> { + if let Some(config_dir) = get_config_dir(&app) { + if !config_dir.exists() { + fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; + } + let config_path = config_dir.join("config.json"); + let mut config = load_config_internal(&app); + let map = config.selected_schemas.get_or_insert_with(HashMap::new); + if schemas.is_empty() { + map.remove(&connection_id); + } else { + map.insert(connection_id, schemas); + } + let content = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + fs::write(config_path, content).map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("Could not resolve config directory".to_string()) + } +} + #[tauri::command] pub fn set_ai_key(provider: String, key: String) -> Result<(), String> { keychain_utils::set_ai_key(&provider, &key) @@ -301,3 +368,66 @@ pub fn reset_explain_prompt(app: AppHandle) -> Result { } Ok(DEFAULT_EXPLAIN_PROMPT.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_schemas_default_is_none() { + let config = AppConfig::default(); + assert!(config.selected_schemas.is_none()); + } + + #[test] + fn selected_schemas_serialization_round_trip() { + let mut config = AppConfig::default(); + let mut map = HashMap::new(); + map.insert("conn-1".to_string(), vec!["public".to_string(), "analytics".to_string()]); + config.selected_schemas = Some(map); + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: AppConfig = serde_json::from_str(&json).unwrap(); + + let schemas = deserialized.selected_schemas.unwrap(); + let conn1 = schemas.get("conn-1").unwrap(); + assert_eq!(conn1, &vec!["public".to_string(), "analytics".to_string()]); + } + + #[test] + fn selected_schemas_camel_case_in_json() { + let mut config = AppConfig::default(); + let mut map = HashMap::new(); + map.insert("conn-1".to_string(), vec!["public".to_string()]); + config.selected_schemas = Some(map); + + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("selectedSchemas")); + assert!(!json.contains("selected_schemas")); + } + + #[test] + fn multiple_connections_independent_selected_schemas() { + let mut config = AppConfig::default(); + let mut map = HashMap::new(); + map.insert("conn-1".to_string(), vec!["public".to_string()]); + map.insert("conn-2".to_string(), vec!["staging".to_string(), "prod".to_string()]); + config.selected_schemas = Some(map); + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: AppConfig = serde_json::from_str(&json).unwrap(); + + let schemas = deserialized.selected_schemas.unwrap(); + assert_eq!(schemas.get("conn-1").unwrap(), &vec!["public".to_string()]); + assert_eq!(schemas.get("conn-2").unwrap(), &vec!["staging".to_string(), "prod".to_string()]); + } + + #[test] + fn old_hidden_schemas_json_deserializes_without_error() { + // Ensure old config files with hiddenSchemas don't break deserialization + let json = r#"{"hiddenSchemas":{"conn-1":["secret"]}}"#; + let config: AppConfig = serde_json::from_str(json).unwrap(); + // hiddenSchemas is no longer a field, so it's ignored; selectedSchemas is None + assert!(config.selected_schemas.is_none()); + } +} diff --git a/src-tauri/src/drivers/mysql.rs b/src-tauri/src/drivers/mysql.rs index ad77038..2645c7f 100644 --- a/src-tauri/src/drivers/mysql.rs +++ b/src-tauri/src/drivers/mysql.rs @@ -11,16 +11,42 @@ fn escape_identifier(name: &str) -> String { name.replace('`', "``") } +/// Read a string from a MySQL row by index. +/// MySQL 8 information_schema returns VARBINARY/BLOB instead of VARCHAR, +/// so try_get:: fails silently. This falls back to reading raw bytes. +fn mysql_row_str(row: &sqlx::mysql::MySqlRow, idx: usize) -> String { + row.try_get::(idx).unwrap_or_else(|_| { + row.try_get::, _>(idx) + .map(|bytes| String::from_utf8_lossy(&bytes).to_string()) + .unwrap_or_default() + }) +} + +/// Optional string variant of mysql_row_str. +fn mysql_row_str_opt(row: &sqlx::mysql::MySqlRow, idx: usize) -> Option { + match row.try_get::, _>(idx) { + Ok(val) => val, + Err(_) => row + .try_get::>, _>(idx) + .ok() + .flatten() + .map(|bytes| String::from_utf8_lossy(&bytes).to_string()), + } +} + +pub async fn get_schemas(_params: &ConnectionParams) -> Result, String> { + Ok(vec![]) +} + pub async fn get_databases(params: &ConnectionParams) -> Result, String> { let pool = get_mysql_pool(params).await?; let rows = sqlx::query("SHOW DATABASES") .fetch_all(&pool) .await .map_err(|e| e.to_string())?; - // Already using column index - good for Windows/MySQL 8 compatibility Ok(rows .iter() - .map(|r| r.try_get(0).unwrap_or_default()) + .map(|r| mysql_row_str(r, 0)) .collect()) } @@ -36,7 +62,7 @@ pub async fn get_tables(params: &ConnectionParams) -> Result, Str let tables: Vec = rows .iter() .map(|r| TableInfo { - name: r.try_get(0).unwrap_or_default(), + name: mysql_row_str(r, 0), }) .collect(); log::debug!( @@ -69,18 +95,15 @@ pub async fn get_columns( Ok(rows .iter() .map(|r| { - // Use column indices instead of names for Windows/MySQL 8 compatibility - let column_name: String = r.try_get(0).unwrap_or_default(); // column_name - let data_type: String = r.try_get(1).unwrap_or_default(); // data_type - let key: String = r.try_get(2).unwrap_or_default(); // column_key - let null_str: String = r.try_get(3).unwrap_or_default(); // is_nullable - let extra: String = r.try_get(4).unwrap_or_default(); // extra - let default_val: Option = r.try_get(5).ok(); // column_default + let column_name = mysql_row_str(r, 0); + let data_type = mysql_row_str(r, 1); + let key = mysql_row_str(r, 2); + let null_str = mysql_row_str(r, 3); + let extra = mysql_row_str(r, 4); + let default_val = mysql_row_str_opt(r, 5); let is_auto_increment = extra.contains("auto_increment"); - // Only set default_value if not auto-increment, value exists, and not NULL - // Filter out NULL defaults (MySQL may return "NULL" string for nullable without default) let default_value = if !is_auto_increment { match default_val { Some(val) if !val.is_empty() && !val.eq_ignore_ascii_case("null") => Some(val), @@ -135,13 +158,12 @@ pub async fn get_foreign_keys( Ok(rows .iter() .map(|r| ForeignKey { - // Use column indices instead of names for Windows/MySQL 8 compatibility - name: r.try_get(0).unwrap_or_default(), // CONSTRAINT_NAME - column_name: r.try_get(1).unwrap_or_default(), // COLUMN_NAME - ref_table: r.try_get(2).unwrap_or_default(), // REFERENCED_TABLE_NAME - ref_column: r.try_get(3).unwrap_or_default(), // REFERENCED_COLUMN_NAME - on_update: r.try_get(4).ok(), // UPDATE_RULE - on_delete: r.try_get(5).ok(), // DELETE_RULE + name: mysql_row_str(r, 0), + column_name: mysql_row_str(r, 1), + ref_table: mysql_row_str(r, 2), + ref_column: mysql_row_str(r, 3), + on_update: mysql_row_str_opt(r, 4), + on_delete: mysql_row_str_opt(r, 5), }) .collect()) } @@ -167,20 +189,17 @@ pub async fn get_all_columns_batch( let mut result: HashMap> = HashMap::new(); - for row in rows { - // Use column indices instead of names for Windows/MySQL 8 compatibility - let table_name: String = row.try_get(0).unwrap_or_default(); // table_name - let column_name: String = row.try_get(1).unwrap_or_default(); // column_name - let data_type: String = row.try_get(2).unwrap_or_default(); // data_type - let key: String = row.try_get(3).unwrap_or_default(); // column_key - let null_str: String = row.try_get(4).unwrap_or_default(); // is_nullable - let extra: String = row.try_get(5).unwrap_or_default(); // extra - let default_val: Option = row.try_get(6).ok(); // column_default + for row in &rows { + let table_name = mysql_row_str(row, 0); + let column_name = mysql_row_str(row, 1); + let data_type = mysql_row_str(row, 2); + let key = mysql_row_str(row, 3); + let null_str = mysql_row_str(row, 4); + let extra = mysql_row_str(row, 5); + let default_val = mysql_row_str_opt(row, 6); let is_auto_increment = extra.contains("auto_increment"); - // Only set default_value if not auto-increment, value exists, and not NULL - // Filter out NULL defaults (MySQL may return "NULL" string for nullable without default) let default_value = if !is_auto_increment { match default_val { Some(val) if !val.is_empty() && !val.eq_ignore_ascii_case("null") => Some(val), @@ -240,17 +259,16 @@ pub async fn get_all_foreign_keys_batch( let mut result: HashMap> = HashMap::new(); - for row in rows { - // Use column indices instead of names for Windows/MySQL 8 compatibility - let table_name: String = row.try_get(0).unwrap_or_default(); // TABLE_NAME + for row in &rows { + let table_name = mysql_row_str(row, 0); let fk = ForeignKey { - name: row.try_get(1).unwrap_or_default(), // CONSTRAINT_NAME - column_name: row.try_get(2).unwrap_or_default(), // COLUMN_NAME - ref_table: row.try_get(3).unwrap_or_default(), // REFERENCED_TABLE_NAME - ref_column: row.try_get(4).unwrap_or_default(), // REFERENCED_COLUMN_NAME - on_update: row.try_get(5).ok(), // UPDATE_RULE - on_delete: row.try_get(6).ok(), // DELETE_RULE + name: mysql_row_str(row, 1), + column_name: mysql_row_str(row, 2), + ref_table: mysql_row_str(row, 3), + ref_column: mysql_row_str(row, 4), + on_update: mysql_row_str_opt(row, 5), + on_delete: mysql_row_str_opt(row, 6), }; result.entry(table_name).or_insert_with(Vec::new).push(fk); @@ -286,15 +304,14 @@ pub async fn get_indexes( Ok(rows .iter() .map(|r| { - // Use column indices instead of names for Windows/MySQL 8 compatibility - let index_name: String = r.try_get(0).unwrap_or_default(); // INDEX_NAME - let non_unique: i64 = r.try_get(2).unwrap_or(1); // NON_UNIQUE + let index_name = mysql_row_str(r, 0); + let non_unique: i64 = r.try_get(2).unwrap_or(1); Index { name: index_name.clone(), - column_name: r.try_get(1).unwrap_or_default(), // COLUMN_NAME + column_name: mysql_row_str(r, 1), is_unique: non_unique == 0, is_primary: index_name == "PRIMARY", - seq_in_index: r.try_get::(3).unwrap_or(0) as i32, // SEQ_IN_INDEX + seq_in_index: r.try_get::(3).unwrap_or(0) as i32, } }) .collect()) @@ -469,7 +486,7 @@ pub async fn get_table_ddl(params: &ConnectionParams, table_name: &str) -> Resul .await .map_err(|e| e.to_string())?; - let create_sql: String = row.try_get(1).unwrap_or_default(); + let create_sql = mysql_row_str(&row, 1); Ok(format!("{};", create_sql)) } @@ -485,8 +502,7 @@ pub async fn get_views(params: &ConnectionParams) -> Result, Strin let views: Vec = rows .iter() .map(|r| ViewInfo { - // Use column index instead of name for Windows/MySQL 8 compatibility - name: r.try_get(0).unwrap_or_default(), + name: mysql_row_str(r, 0), definition: None, }) .collect(); @@ -505,7 +521,7 @@ pub async fn get_view_definition( .fetch_one(&pool) .await .map_err(|e| format!("Failed to get view definition: {}", e))?; - let definition: String = row.try_get(1).unwrap_or_default(); + let definition = mysql_row_str(&row, 1); Ok(definition) } @@ -574,18 +590,15 @@ pub async fn get_view_columns( Ok(rows .iter() .map(|r| { - // Use column indices instead of names for Windows/MySQL 8 compatibility - let column_name: String = r.try_get(0).unwrap_or_default(); // column_name - let data_type: String = r.try_get(1).unwrap_or_default(); // data_type - let key: String = r.try_get(2).unwrap_or_default(); // column_key - let null_str: String = r.try_get(3).unwrap_or_default(); // is_nullable - let extra: String = r.try_get(4).unwrap_or_default(); // extra - let default_val: Option = r.try_get(5).ok(); // column_default + let column_name = mysql_row_str(r, 0); + let data_type = mysql_row_str(r, 1); + let key = mysql_row_str(r, 2); + let null_str = mysql_row_str(r, 3); + let extra = mysql_row_str(r, 4); + let default_val = mysql_row_str_opt(r, 5); let is_auto_increment = extra.contains("auto_increment"); - // Only set default_value if not auto-increment, value exists, and not NULL - // Filter out NULL defaults (MySQL may return "NULL" string for nullable without default) let default_value = if !is_auto_increment { match default_val { Some(val) if !val.is_empty() && !val.eq_ignore_ascii_case("null") => Some(val), @@ -624,10 +637,9 @@ pub async fn get_routines(params: &ConnectionParams) -> Result, Ok(rows .iter() .map(|r| RoutineInfo { - // Use column indices instead of names for Windows/MySQL 8 compatibility - name: r.try_get(0).unwrap_or_default(), // routine_name - routine_type: r.try_get(1).unwrap_or_default(), // routine_type - definition: r.try_get(2).ok(), // routine_definition + name: mysql_row_str(r, 0), + routine_type: mysql_row_str(r, 1), + definition: mysql_row_str_opt(r, 2), }) .collect()) } @@ -654,9 +666,8 @@ pub async fn get_routine_parameters( let mut parameters = Vec::new(); if let Some(info) = routine_info { - // Use column indices instead of names for Windows/MySQL 8 compatibility - let data_type: String = info.try_get(0).unwrap_or_default(); // DATA_TYPE - let routine_type: String = info.try_get(1).unwrap_or_default(); // ROUTINE_TYPE + let data_type = mysql_row_str(&info, 0); + let routine_type = mysql_row_str(&info, 1); if routine_type == "FUNCTION" { if !data_type.is_empty() { parameters.push(RoutineParameter { @@ -684,11 +695,10 @@ pub async fn get_routine_parameters( .map_err(|e| e.to_string())?; parameters.extend(rows.iter().map(|r| RoutineParameter { - // Use column indices instead of names for Windows/MySQL 8 compatibility - name: r.try_get(0).unwrap_or_default(), // parameter_name - data_type: r.try_get(1).unwrap_or_default(), // data_type - mode: r.try_get(2).unwrap_or_default(), // parameter_mode - ordinal_position: r.try_get(3).unwrap_or(0), // ordinal_position + name: mysql_row_str(r, 0), + data_type: mysql_row_str(r, 1), + mode: mysql_row_str(r, 2), + ordinal_position: r.try_get(3).unwrap_or(0), })); Ok(parameters) @@ -711,11 +721,7 @@ pub async fn get_routine_definition( .await .map_err(|e| e.to_string())?; - // The column name is "Create Procedure" or "Create Function" or retrieved by index 2 - let definition: String = row.try_get(2).unwrap_or_else(|_| { - row.try_get(format!("Create {}", routine_type).as_str()) - .unwrap_or_default() - }); + let definition = mysql_row_str(&row, 2); Ok(definition) } diff --git a/src-tauri/src/drivers/postgres.rs b/src-tauri/src/drivers/postgres.rs index 5c1671c..f239044 100644 --- a/src-tauri/src/drivers/postgres.rs +++ b/src-tauri/src/drivers/postgres.rs @@ -11,6 +11,24 @@ fn escape_identifier(name: &str) -> String { name.replace('"', "\"\"") } +pub async fn get_schemas(params: &ConnectionParams) -> Result, String> { + let pool = get_postgres_pool(params).await?; + let rows = sqlx::query( + "SELECT schema_name::text FROM information_schema.schemata \ + WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \ + AND schema_name NOT LIKE 'pg_temp_%' \ + AND schema_name NOT LIKE 'pg_toast_temp_%' \ + ORDER BY schema_name", + ) + .fetch_all(&pool) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .iter() + .map(|r| r.try_get("schema_name").unwrap_or_default()) + .collect()) +} + pub async fn get_databases(params: &ConnectionParams) -> Result, String> { let pool = get_postgres_pool(params).await?; let rows = sqlx::query( @@ -25,15 +43,17 @@ pub async fn get_databases(params: &ConnectionParams) -> Result, Str .collect()) } -pub async fn get_tables(params: &ConnectionParams) -> Result, String> { +pub async fn get_tables(params: &ConnectionParams, schema: &str) -> Result, String> { log::debug!( - "PostgreSQL: Fetching tables for database: {}", - params.database + "PostgreSQL: Fetching tables for database: {} schema: {}", + params.database, + schema ); let pool = get_postgres_pool(params).await?; let rows = sqlx::query( - "SELECT table_name as name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name ASC", + "SELECT table_name as name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY table_name ASC", ) + .bind(schema) .fetch_all(&pool) .await .map_err(|e| e.to_string())?; @@ -54,6 +74,7 @@ pub async fn get_tables(params: &ConnectionParams) -> Result, Str pub async fn get_columns( params: &ConnectionParams, table_name: &str, + schema: &str, ) -> Result, String> { let pool = get_postgres_pool(params).await?; @@ -67,15 +88,18 @@ pub async fn get_columns( c.is_identity, (SELECT COUNT(*) FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name) > 0 as is_pk FROM information_schema.columns c - WHERE c.table_schema = 'public' AND c.table_name = $1 + WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position "#; let rows = sqlx::query(query) + .bind(schema) .bind(table_name) .fetch_all(&pool) .await @@ -118,6 +142,7 @@ pub async fn get_columns( pub async fn get_foreign_keys( params: &ConnectionParams, table_name: &str, + schema: &str, ) -> Result, String> { let pool = get_postgres_pool(params).await?; @@ -139,11 +164,14 @@ pub async fn get_foreign_keys( AND ccu.table_schema = tc.table_schema JOIN information_schema.referential_constraints AS rc ON rc.constraint_name = tc.constraint_name + AND rc.constraint_schema = tc.table_schema WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_name = $1 + AND tc.table_schema = $1 + AND tc.table_name = $2 "#; let rows = sqlx::query(query) + .bind(schema) .bind(table_name) .fetch_all(&pool) .await @@ -165,6 +193,7 @@ pub async fn get_foreign_keys( // Batch function: Get all columns for all tables in one query pub async fn get_all_columns_batch( params: &ConnectionParams, + schema: &str, ) -> Result>, String> { use std::collections::HashMap; let pool = get_postgres_pool(params).await?; @@ -179,15 +208,18 @@ pub async fn get_all_columns_batch( c.is_identity, (SELECT COUNT(*) FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name) > 0 as is_pk FROM information_schema.columns c - WHERE c.table_schema = 'public' + WHERE c.table_schema = $1 ORDER BY c.table_name, c.ordinal_position "#; let rows = sqlx::query(query) + .bind(schema) .fetch_all(&pool) .await .map_err(|e| e.to_string())?; @@ -236,6 +268,7 @@ pub async fn get_all_columns_batch( // Batch function: Get all foreign keys for all tables in one query pub async fn get_all_foreign_keys_batch( params: &ConnectionParams, + schema: &str, ) -> Result>, String> { use std::collections::HashMap; let pool = get_postgres_pool(params).await?; @@ -259,11 +292,13 @@ pub async fn get_all_foreign_keys_batch( AND ccu.table_schema = tc.table_schema JOIN information_schema.referential_constraints AS rc ON rc.constraint_name = tc.constraint_name + AND rc.constraint_schema = tc.table_schema WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = 'public' + AND tc.table_schema = $1 "#; let rows = sqlx::query(query) + .bind(schema) .fetch_all(&pool) .await .map_err(|e| e.to_string())?; @@ -291,6 +326,7 @@ pub async fn get_all_foreign_keys_batch( pub async fn get_indexes( params: &ConnectionParams, table_name: &str, + schema: &str, ) -> Result, String> { let pool = get_postgres_pool(params).await?; @@ -302,17 +338,15 @@ pub async fn get_indexes( ix.indisprimary as is_primary, array_position(ix.indkey, a.attnum) as seq_in_index FROM - pg_class t, - pg_class i, - pg_index ix, - pg_attribute a + pg_class t + JOIN pg_namespace n ON t.relnamespace = n.oid + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) WHERE - t.oid = ix.indrelid - AND i.oid = ix.indexrelid - AND a.attrelid = t.oid - AND a.attnum = ANY(ix.indkey) - AND t.relkind = 'r' - AND t.relname = $1 + t.relkind = 'r' + AND n.nspname = $1 + AND t.relname = $2 ORDER BY t.relname, i.relname, @@ -320,6 +354,7 @@ pub async fn get_indexes( "#; let rows = sqlx::query(query) + .bind(schema) .bind(table_name) .fetch_all(&pool) .await @@ -342,10 +377,16 @@ pub async fn delete_record( table: &str, pk_col: &str, pk_val: serde_json::Value, + schema: &str, ) -> Result { let pool = get_postgres_pool(params).await?; - let query = format!("DELETE FROM \"{}\" WHERE \"{}\" = $1", table, pk_col); + let query = format!( + "DELETE FROM \"{}\".\"{}\" WHERE \"{}\" = $1", + escape_identifier(schema), + escape_identifier(table), + escape_identifier(pk_col) + ); let result = match pk_val { serde_json::Value::Number(n) => { @@ -369,10 +410,16 @@ pub async fn update_record( pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, + schema: &str, ) -> Result { let pool = get_postgres_pool(params).await?; - let mut qb = sqlx::QueryBuilder::new(format!("UPDATE \"{}\" SET \"{}\" = ", table, col_name)); + let mut qb = sqlx::QueryBuilder::new(format!( + "UPDATE \"{}\".\"{}\" SET \"{}\" = ", + escape_identifier(schema), + escape_identifier(table), + escape_identifier(col_name) + )); match new_val { serde_json::Value::Number(n) => { @@ -424,6 +471,7 @@ pub async fn insert_record( params: &ConnectionParams, table: &str, data: std::collections::HashMap, + schema: &str, ) -> Result { let pool = get_postgres_pool(params).await?; @@ -437,11 +485,16 @@ pub async fn insert_record( // Allow empty inserts for auto-generated values (e.g., auto-increment PKs) let mut qb = if cols.is_empty() { - sqlx::QueryBuilder::new(format!("INSERT INTO \"{}\" DEFAULT VALUES", table)) + sqlx::QueryBuilder::new(format!( + "INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", + escape_identifier(schema), + escape_identifier(table) + )) } else { let mut qb = sqlx::QueryBuilder::new(format!( - "INSERT INTO \"{}\" ({}) VALUES (", - table, + "INSERT INTO \"{}\".\"{}\" ({}) VALUES (", + escape_identifier(schema), + escape_identifier(table), cols.join(", ") )); @@ -496,8 +549,8 @@ fn remove_order_by(query: &str) -> String { } } -pub async fn get_table_ddl(params: &ConnectionParams, table_name: &str) -> Result { - let cols = get_columns(params, table_name).await?; +pub async fn get_table_ddl(params: &ConnectionParams, table_name: &str, schema: &str) -> Result { + let cols = get_columns(params, table_name, schema).await?; if cols.is_empty() { return Err(format!("Table {} not found or empty", table_name)); } @@ -523,8 +576,9 @@ pub async fn get_table_ddl(params: &ConnectionParams, table_name: &str) -> Resul } Ok(format!( - "CREATE TABLE public.\"{}\" (\n {}\n);", - table_name, + "CREATE TABLE \"{}\".\"{}\" (\n {}\n);", + escape_identifier(schema), + escape_identifier(table_name), defs.join(",\n ") )) } @@ -534,10 +588,22 @@ pub async fn execute_query( query: &str, limit: Option, page: u32, + schema: Option<&str>, ) -> Result { let pool = get_postgres_pool(params).await?; let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; + if let Some(schema) = schema { + let search_path = format!( + "SET search_path TO \"{}\"", + escape_identifier(schema) + ); + sqlx::query(&search_path) + .execute(&mut *conn) + .await + .map_err(|e| e.to_string())?; + } + let is_select = query.trim_start().to_uppercase().starts_with("SELECT"); let mut pagination: Option = None; let final_query: String; @@ -630,15 +696,17 @@ pub async fn execute_query( }) } -pub async fn get_views(params: &ConnectionParams) -> Result, String> { +pub async fn get_views(params: &ConnectionParams, schema: &str) -> Result, String> { log::debug!( - "PostgreSQL: Fetching views for database: {}", - params.database + "PostgreSQL: Fetching views for database: {} schema: {}", + params.database, + schema ); let pool = get_postgres_pool(params).await?; let rows = sqlx::query( - "SELECT viewname as name FROM pg_views WHERE schemaname = 'public' ORDER BY viewname ASC", + "SELECT viewname as name FROM pg_views WHERE schemaname = $1 ORDER BY viewname ASC", ) + .bind(schema) .fetch_all(&pool) .await .map_err(|e| e.to_string())?; @@ -660,11 +728,13 @@ pub async fn get_views(params: &ConnectionParams) -> Result, Strin pub async fn get_view_definition( params: &ConnectionParams, view_name: &str, + schema: &str, ) -> Result { let pool = get_postgres_pool(params).await?; - let query = "SELECT pg_get_viewdef($1, true) as definition"; + let qualified = format!("\"{}\".\"{}\"", escape_identifier(schema), escape_identifier(view_name)); + let query = "SELECT pg_get_viewdef($1::regclass, true) as definition"; let row = sqlx::query(query) - .bind(view_name) + .bind(&qualified) .fetch_one(&pool) .await .map_err(|e| format!("Failed to get view definition: {}", e))?; @@ -672,7 +742,7 @@ pub async fn get_view_definition( let definition: String = row.try_get("definition").unwrap_or_default(); Ok(format!( "CREATE OR REPLACE VIEW {} AS\n{}", - view_name, definition + qualified, definition )) } @@ -680,10 +750,15 @@ pub async fn create_view( params: &ConnectionParams, view_name: &str, definition: &str, + schema: &str, ) -> Result<(), String> { let pool = get_postgres_pool(params).await?; - let escaped_name = escape_identifier(view_name); - let query = format!("CREATE VIEW \"{}\" AS {}", escaped_name, definition); + let query = format!( + "CREATE VIEW \"{}\".\"{}\" AS {}", + escape_identifier(schema), + escape_identifier(view_name), + definition + ); sqlx::query(&query) .execute(&pool) .await @@ -695,12 +770,14 @@ pub async fn alter_view( params: &ConnectionParams, view_name: &str, definition: &str, + schema: &str, ) -> Result<(), String> { let pool = get_postgres_pool(params).await?; - let escaped_name = escape_identifier(view_name); let query = format!( - "CREATE OR REPLACE VIEW \"{}\" AS {}", - escaped_name, definition + "CREATE OR REPLACE VIEW \"{}\".\"{}\" AS {}", + escape_identifier(schema), + escape_identifier(view_name), + definition ); sqlx::query(&query) .execute(&pool) @@ -709,10 +786,13 @@ pub async fn alter_view( Ok(()) } -pub async fn drop_view(params: &ConnectionParams, view_name: &str) -> Result<(), String> { +pub async fn drop_view(params: &ConnectionParams, view_name: &str, schema: &str) -> Result<(), String> { let pool = get_postgres_pool(params).await?; - let escaped_name = escape_identifier(view_name); - let query = format!("DROP VIEW IF EXISTS \"{}\"", escaped_name); + let query = format!( + "DROP VIEW IF EXISTS \"{}\".\"{}\"", + escape_identifier(schema), + escape_identifier(view_name) + ); sqlx::query(&query) .execute(&pool) .await @@ -723,6 +803,7 @@ pub async fn drop_view(params: &ConnectionParams, view_name: &str) -> Result<(), pub async fn get_view_columns( params: &ConnectionParams, view_name: &str, + schema: &str, ) -> Result, String> { let pool = get_postgres_pool(params).await?; @@ -735,15 +816,18 @@ pub async fn get_view_columns( c.is_identity, (SELECT COUNT(*) FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name) > 0 as is_pk FROM information_schema.columns c - WHERE c.table_schema = 'public' AND c.table_name = $1 + WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position "#; let rows = sqlx::query(query) + .bind(schema) .bind(view_name) .fetch_all(&pool) .await @@ -783,17 +867,18 @@ pub async fn get_view_columns( .collect()) } -pub async fn get_routines(params: &ConnectionParams) -> Result, String> { +pub async fn get_routines(params: &ConnectionParams, schema: &str) -> Result, String> { let pool = get_postgres_pool(params).await?; let query = r#" SELECT proname, prokind FROM pg_proc - WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public') + WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) AND prokind IN ('f', 'p') ORDER BY proname "#; let rows = sqlx::query(query) + .bind(schema) .fetch_all(&pool) .await .map_err(|e| e.to_string())?; @@ -819,6 +904,7 @@ pub async fn get_routines(params: &ConnectionParams) -> Result, pub async fn get_routine_parameters( params: &ConnectionParams, routine_name: &str, + schema: &str, ) -> Result, String> { let pool = get_postgres_pool(params).await?; @@ -826,11 +912,12 @@ pub async fn get_routine_parameters( let return_type_query = r#" SELECT data_type, routine_type FROM information_schema.routines - WHERE routine_schema = 'public' AND routine_name = $1 + WHERE routine_schema = $1 AND routine_name = $2 LIMIT 1 "#; let routine_info = sqlx::query(return_type_query) + .bind(schema) .bind(routine_name) .fetch_optional(&pool) .await @@ -860,11 +947,12 @@ pub async fn get_routine_parameters( SELECT p.parameter_name, p.data_type, p.parameter_mode, p.ordinal_position FROM information_schema.parameters p JOIN information_schema.routines r ON p.specific_name = r.specific_name - WHERE r.routine_schema = 'public' AND r.routine_name = $1 + WHERE r.routine_schema = $1 AND r.routine_name = $2 ORDER BY p.ordinal_position "#; let rows = sqlx::query(query) + .bind(schema) .bind(routine_name) .fetch_all(&pool) .await @@ -884,6 +972,7 @@ pub async fn get_routine_definition( params: &ConnectionParams, routine_name: &str, _routine_type: &str, + schema: &str, ) -> Result { let pool = get_postgres_pool(params).await?; @@ -891,11 +980,12 @@ pub async fn get_routine_definition( SELECT pg_get_functiondef(p.oid) as definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.proname = $1 + WHERE n.nspname = $1 AND p.proname = $2 LIMIT 1 "#; let row = sqlx::query(query) + .bind(schema) .bind(routine_name) .fetch_one(&pool) .await diff --git a/src-tauri/src/drivers/sqlite.rs b/src-tauri/src/drivers/sqlite.rs index 6bf0c1c..e6ad700 100644 --- a/src-tauri/src/drivers/sqlite.rs +++ b/src-tauri/src/drivers/sqlite.rs @@ -11,6 +11,10 @@ fn escape_identifier(name: &str) -> String { name.replace('"', "\"\"") } +pub async fn get_schemas(_params: &ConnectionParams) -> Result, String> { + Ok(vec![]) +} + pub async fn get_databases(_params: &ConnectionParams) -> Result, String> { // SQLite doesn't support multiple databases in the same connection Ok(vec![]) diff --git a/src-tauri/src/dump_commands.rs b/src-tauri/src/dump_commands.rs index 3face41..45eddf4 100644 --- a/src-tauri/src/dump_commands.rs +++ b/src-tauri/src/dump_commands.rs @@ -46,10 +46,12 @@ pub async fn dump_database( connection_id: String, file_path: String, options: DumpOptions, + schema: Option, ) -> Result<(), String> { let saved_conn = find_connection_by_id(&app, &connection_id)?; let params = resolve_connection_params(&saved_conn.params)?; let driver = saved_conn.params.driver.clone(); + let pg_schema = schema.unwrap_or_else(|| "public".to_string()); // Spawn the dump process let task = tokio::spawn(async move { @@ -65,7 +67,7 @@ pub async fn dump_database( // Get tables let all_tables = match driver.as_str() { "mysql" => mysql::get_tables(¶ms).await?, - "postgres" => postgres::get_tables(¶ms).await?, + "postgres" => postgres::get_tables(¶ms, &pg_schema).await?, "sqlite" => sqlite::get_tables(¶ms).await?, _ => return Err("Unsupported driver".into()), }; @@ -84,7 +86,7 @@ pub async fn dump_database( let ddl = match driver.as_str() { "mysql" => mysql::get_table_ddl(¶ms, &table).await?, - "postgres" => postgres::get_table_ddl(¶ms, &table).await?, + "postgres" => postgres::get_table_ddl(¶ms, &table, &pg_schema).await?, "sqlite" => sqlite::get_table_ddl(¶ms, &table).await?, _ => return Err("Unsupported driver".into()), }; @@ -94,7 +96,7 @@ pub async fn dump_database( if options.data { writeln!(writer, "-- Data for table `{}`", table).map_err(|e| e.to_string())?; - export_table_data(&mut writer, ¶ms, &driver, &table).await?; + export_table_data(&mut writer, ¶ms, &driver, &table, &pg_schema).await?; writeln!(writer, "\n").map_err(|e| e.to_string())?; } } @@ -130,6 +132,7 @@ async fn export_table_data( params: &ConnectionParams, driver: &str, table: &str, + pg_schema: &str, ) -> Result<(), String> { // We need to implement streaming fetch manually here because we need raw values, not JSON strings if possible, // or we parse JSON strings back to SQL literals. @@ -141,7 +144,7 @@ async fn export_table_data( "SELECT * FROM {}", match driver { "mysql" => format!("`{}`", table), - "postgres" => format!("\"{}\"", table), // public schema assumed + "postgres" => format!("\"{}\".\"{}\"", pg_schema, table), "sqlite" => format!("\"{}\"", table), _ => table.to_string(), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b13242..5d1c93c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -152,6 +152,7 @@ pub fn run() { commands::update_ssh_connection, commands::delete_ssh_connection, commands::test_ssh_connection, + commands::get_schemas, commands::get_tables, commands::get_columns, commands::get_foreign_keys, @@ -176,6 +177,10 @@ pub fn run() { saved_queries::update_saved_query, saved_queries::delete_saved_query, // Config + config::get_schema_preference, + config::set_schema_preference, + config::get_selected_schemas, + config::set_selected_schemas, config::get_config, config::save_config, config::set_ai_key, diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 4c94a1b..f463b00 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -228,7 +228,7 @@ async fn handle_read_resource( let tables = match conn.params.driver.as_str() { "mysql" => mysql::get_tables(¶ms).await, - "postgres" => postgres::get_tables(¶ms).await, + "postgres" => postgres::get_tables(¶ms, "public").await, "sqlite" => sqlite::get_tables(¶ms).await, _ => Err("Unsupported driver".into()), } @@ -341,7 +341,7 @@ async fn handle_call_tool( let result = match conn.params.driver.as_str() { "mysql" => mysql::execute_query(&db_params, query, Some(100), 1).await, - "postgres" => postgres::execute_query(&db_params, query, Some(100), 1).await, + "postgres" => postgres::execute_query(&db_params, query, Some(100), 1, None).await, "sqlite" => sqlite::execute_query(&db_params, query, Some(100), 1).await, _ => Err("Unsupported driver".into()), } diff --git a/src-tauri/tests/integration_tests.rs b/src-tauri/tests/integration_tests.rs index 1b3cb46..70c8d5c 100644 --- a/src-tauri/tests/integration_tests.rs +++ b/src-tauri/tests/integration_tests.rs @@ -121,7 +121,7 @@ async fn test_postgres_integration_flow() { // 1. Wait for DB let mut connected = false; for _ in 0..10 { - if postgres::get_tables(¶ms).await.is_ok() { + if postgres::get_tables(¶ms, "public").await.is_ok() { connected = true; break; } @@ -139,7 +139,7 @@ async fn test_postgres_integration_flow() { name TEXT, email TEXT )"; - let res = postgres::execute_query(¶ms, create_sql, None, 1).await; + let res = postgres::execute_query(¶ms, create_sql, None, 1, None).await; assert!( res.is_ok(), "Failed to create table in Postgres: {:?}", @@ -147,17 +147,17 @@ async fn test_postgres_integration_flow() { ); // 3. Clean table - let _ = postgres::execute_query(¶ms, "TRUNCATE TABLE test_users", None, 1).await; + let _ = postgres::execute_query(¶ms, "TRUNCATE TABLE test_users", None, 1, None).await; // 4. Insert Data let insert_sql = "INSERT INTO test_users (name, email) VALUES ('Luigi Verdi', 'luigi@test.com')"; - let res = postgres::execute_query(¶ms, insert_sql, None, 1).await; + let res = postgres::execute_query(¶ms, insert_sql, None, 1, None).await; assert!(res.is_ok(), "Failed to insert data in Postgres"); // 5. Select Data let select_sql = "SELECT * FROM test_users WHERE email = 'luigi@test.com'"; - let res = postgres::execute_query(¶ms, select_sql, None, 1).await; + let res = postgres::execute_query(¶ms, select_sql, None, 1, None).await; match res { Ok(data) => { assert_eq!(data.rows.len(), 1, "Expected 1 row"); @@ -173,5 +173,5 @@ async fn test_postgres_integration_flow() { } // 6. Cleanup - let _ = postgres::execute_query(¶ms, "DROP TABLE test_users", None, 1).await; + let _ = postgres::execute_query(¶ms, "DROP TABLE test_users", None, 1, None).await; } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e565b1c..07e215d 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { quoteIdentifier } from "../../utils/identifiers"; +import { quoteIdentifier, quoteTableRef } from "../../utils/identifiers"; import { invoke } from "@tauri-apps/api/core"; import { DISCORD_URL } from "../../config/links"; import { @@ -27,6 +27,10 @@ import { ChevronDown, RefreshCw, ChevronRight, + Settings2, + Check, + CheckSquare, + Square, } from "lucide-react"; import { DiscordIcon } from "../icons/DiscordIcon"; import { ask, message, open } from "@tauri-apps/plugin-dialog"; @@ -54,6 +58,7 @@ import { Accordion } from "./sidebar/Accordion"; import { SidebarTableItem } from "./sidebar/SidebarTableItem"; import { SidebarViewItem } from "./sidebar/SidebarViewItem"; import { SidebarRoutineItem } from "./sidebar/SidebarRoutineItem"; +import { SidebarSchemaItem } from "./sidebar/SidebarSchemaItem"; // Hooks & Types import { useSidebarResize } from "../../hooks/useSidebarResize"; @@ -61,6 +66,7 @@ import type { TableColumn } from "../../types/schema"; import type { ContextMenuData } from "../../types/sidebar"; import type { RoutineInfo } from "../../contexts/DatabaseContext"; import { groupRoutinesByType } from "../../utils/routines"; +import { formatObjectCount } from "../../utils/schema"; export const Sidebar = () => { const { t } = useTranslation(); @@ -80,6 +86,16 @@ export const Sidebar = () => { refreshRoutines, activeConnectionName, activeDatabaseName, + // Schema support (PostgreSQL) + schemas, + isLoadingSchemas, + schemaDataMap, + activeSchema, + loadSchemaData, + refreshSchemaData, + selectedSchemas, + setSelectedSchemas, + needsSchemaSelection, } = useDatabase(); const { queries, deleteQuery, updateQuery } = useSavedQueries(); const navigate = useNavigate(); @@ -94,7 +110,7 @@ export const Sidebar = () => { label: string; data?: ContextMenuData; } | null>(null); - const [schemaModalTable, setSchemaModalTable] = useState(null); + const [schemaModal, setSchemaModal] = useState<{ tableName: string; schema?: string } | null>(null); const [isCreateTableModalOpen, setIsCreateTableModalOpen] = useState(false); const [modifyColumnModal, setModifyColumnModal] = useState<{ isOpen: boolean; @@ -131,6 +147,8 @@ export const Sidebar = () => { filePath: string; }>({ isOpen: false, filePath: "" }); const [isActionsDropdownOpen, setIsActionsDropdownOpen] = useState(false); + const [isSchemaFilterOpen, setIsSchemaFilterOpen] = useState(false); + const [pendingSchemaSelection, setPendingSchemaSelection] = useState>(new Set()); const [viewEditorModal, setViewEditorModal] = useState<{ isOpen: boolean; viewName?: string; @@ -142,22 +160,26 @@ export const Sidebar = () => { const groupedRoutines = routines ? groupRoutinesByType(routines) : { procedures: [], functions: [] }; - const runQuery = (sql: string, queryName?: string, tableName?: string, preventAutoRun: boolean = false) => { + const runQuery = (sql: string, queryName?: string, tableName?: string, preventAutoRun: boolean = false, schema?: string) => { navigate("/editor", { - state: { initialQuery: sql, queryName, tableName, preventAutoRun }, + state: { initialQuery: sql, queryName, tableName, preventAutoRun, schema }, }); }; - const handleTableClick = (tableName: string) => { - setActiveTable(tableName); + const handleTableClick = (tableName: string, schema?: string) => { + setActiveTable(tableName, schema); }; - const handleOpenTable = (tableName: string) => { - const quotedTable = quoteIdentifier(tableName, activeDriver); + const handleOpenTable = (tableName: string, schema?: string) => { + if (schema) { + setActiveTable(tableName, schema); + } + const quotedTable = quoteTableRef(tableName, activeDriver, schema); navigate("/editor", { state: { initialQuery: `SELECT * FROM ${quotedTable}`, tableName: tableName, + schema, }, }); }; @@ -166,22 +188,24 @@ export const Sidebar = () => { setActiveView(viewName); }; - const handleOpenView = (viewName: string) => { - const quotedView = quoteIdentifier(viewName, activeDriver); + const handleOpenView = (viewName: string, schema?: string) => { + const quotedView = quoteTableRef(viewName, activeDriver, schema); navigate("/editor", { state: { initialQuery: `SELECT * FROM ${quotedView}`, tableName: viewName, + schema, }, }); }; - const handleRoutineDoubleClick = async (routine: RoutineInfo) => { + const handleRoutineDoubleClick = async (routine: RoutineInfo, schema?: string) => { try { const definition = await invoke("get_routine_definition", { connectionId: activeConnectionId, routineName: routine.name, routineType: routine.routine_type, + ...(schema ? { schema } : {}), }); runQuery(definition, `${routine.name} Definition`, undefined, true); } catch (e) { @@ -367,6 +391,7 @@ export const Sidebar = () => { connectionName: activeConnectionName || "Unknown", databaseName: activeDatabaseName || "Unknown", + ...(activeSchema ? { schema: activeSchema } : {}), }); } catch (e) { console.error( @@ -412,6 +437,7 @@ export const Sidebar = () => { connectionId: activeConnectionId || "", connectionName: activeConnectionName || "Unknown", databaseName: activeDatabaseName || "Unknown", + ...(activeSchema ? { schema: activeSchema } : {}), }); } catch (e) { console.error("Failed to open ER Diagram window:", e); @@ -435,7 +461,7 @@ export const Sidebar = () => {
- {isLoadingTables ? ( + {(isLoadingTables || isLoadingSchemas) ? (
{t("sidebar.loadingSchema")} @@ -475,267 +501,585 @@ export const Sidebar = () => { )} - {/* Tables */} - setTablesOpen(!tablesOpen)} - actions={ -
- - -
- } - > - {tables.length === 0 ? ( -
- {t("sidebar.noTables")} -
- ) : ( -
- {tables.map((table) => ( - - setModifyColumnModal({ - isOpen: true, - tableName: t_name, - column: null, - }) - } - onEditColumn={(t_name, c) => - setModifyColumnModal({ - isOpen: true, - tableName: t_name, - column: c, - }) - } - onAddIndex={(t_name) => - setCreateIndexModal({ - isOpen: true, - tableName: t_name, - }) + {/* PostgreSQL: Schema tree layout */} + {activeDriver === "postgres" && schemas.length > 0 ? ( +
+ {needsSchemaSelection ? ( + /* Schema picker (first connect, no saved preference) */ +
+
+ {t("sidebar.schemas")} +
+
+ {t("sidebar.selectSchemasHint")} +
+
+
+ {schemas.map((schemaName) => { + const isSelected = pendingSchemaSelection.has(schemaName); + return ( +
{ + const next = new Set(pendingSchemaSelection); + if (isSelected) { + next.delete(schemaName); + } else { + next.add(schemaName); + } + setPendingSchemaSelection(next); + }} + className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer transition-colors ${ + isSelected + ? "text-primary hover:bg-surface-secondary" + : "text-muted hover:bg-surface-secondary" + }`} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+ + {schemaName} + +
+ ); + })} +
+
+
+ + +
+
+ ) : ( + <> + {/* Schema selection header */} +
+ + {t("sidebar.schemas")} ({selectedSchemas.length}/{schemas.length}) + +
+ + {isSchemaFilterOpen && ( + <> +
setIsSchemaFilterOpen(false)} + /> +
+
+ + {t("sidebar.editSchemas")} + + +
+
+ {schemas.map((schemaName) => { + const isSelected = pendingSchemaSelection.has(schemaName); + return ( +
{ + const next = new Set(pendingSchemaSelection); + if (isSelected) { + next.delete(schemaName); + } else { + next.add(schemaName); + } + setPendingSchemaSelection(next); + }} + className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer transition-colors ${ + isSelected + ? "text-primary hover:bg-surface-secondary" + : "text-muted hover:bg-surface-secondary" + }`} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+ + {schemaName} + +
+ ); + })} +
+
+ +
+
+ + )} +
+
+ {selectedSchemas.map((schemaName) => ( + handleTableClick(name, schema)} + onTableDoubleClick={(name, schema) => handleOpenTable(name, schema)} + onViewClick={handleViewClick} + onViewDoubleClick={(name, schema) => handleOpenView(name, schema)} + onRoutineDoubleClick={(routine, schema) => handleRoutineDoubleClick(routine, schema)} + onContextMenu={handleContextMenu} + onAddColumn={(t_name) => + setModifyColumnModal({ + isOpen: true, + tableName: t_name, + column: null, + }) + } + onEditColumn={(t_name, c) => + setModifyColumnModal({ + isOpen: true, + tableName: t_name, + column: c, + }) + } + onAddIndex={(t_name) => + setCreateIndexModal({ + isOpen: true, + tableName: t_name, + }) + } + onDropIndex={async (_t_name, name) => { + if ( + await ask( + t("sidebar.deleteIndexConfirm", { name }), + { + title: t("sidebar.deleteIndex"), + kind: "warning", + }, + ) + ) { + const q = `DROP INDEX ${quoteTableRef(name, activeDriver, schemaName)}`; + await invoke("execute_query", { + connectionId: activeConnectionId, + query: q, + ...(schemaName ? { schema: schemaName } : {}), + }).catch(console.error); + setSchemaVersion((v) => v + 1); } - onDropIndex={async (t_name, name) => { - if ( - await ask( - t("sidebar.deleteIndexConfirm", { name }), - { - title: t("sidebar.deleteIndex"), - kind: "warning", - }, - ) - ) { - const q = - activeDriver === "mysql" || - activeDriver === "mariadb" - ? `DROP INDEX \`${name}\` ON \`${t_name}\`` - : `DROP INDEX "${name}"`; - await invoke("execute_query", { - connectionId: activeConnectionId, - query: q, - }).catch(console.error); - setSchemaVersion((v) => v + 1); - } - }} - onAddForeignKey={(t_name) => - setCreateForeignKeyModal({ - isOpen: true, - tableName: t_name, - }) + }} + onAddForeignKey={(t_name) => + setCreateForeignKeyModal({ + isOpen: true, + tableName: t_name, + }) + } + onDropForeignKey={async (t_name, name) => { + if ( + await ask( + t("sidebar.deleteFkConfirm", { name }), + { + title: t("sidebar.deleteFk"), + kind: "warning", + }, + ) + ) { + const q = `ALTER TABLE ${quoteTableRef(t_name, activeDriver, schemaName)} DROP CONSTRAINT ${quoteIdentifier( + name, + activeDriver, + )}`; + await invoke("execute_query", { + connectionId: activeConnectionId, + query: q, + ...(schemaName ? { schema: schemaName } : {}), + }).catch(console.error); + setSchemaVersion((v) => v + 1); } - onDropForeignKey={async (t_name, name) => { - if ( - await ask( - t("sidebar.deleteFkConfirm", { name }), - { - title: t("sidebar.deleteFk"), - kind: "warning", - }, - ) - ) { - if (activeDriver === "sqlite") { - await message(t("sidebar.sqliteFkError"), { - kind: "error", - }); - return; - } - const q = - activeDriver === "mysql" || - activeDriver === "mariadb" - ? `ALTER TABLE \`${t_name}\` DROP FOREIGN KEY \`${name}\`` - : `ALTER TABLE "${t_name}" DROP CONSTRAINT "${name}"`; - await invoke("execute_query", { - connectionId: activeConnectionId, - query: q, - }).catch(console.error); - setSchemaVersion((v) => v + 1); - } - }} - schemaVersion={schemaVersion} - /> - ))} -
- )} - - - {/* Views */} - setViewsOpen(!viewsOpen)} - actions={ -
- - -
- } - > - {views.length === 0 ? ( -
- {t("sidebar.noViews")} -
- ) : ( -
- {views.map((view) => ( - - ))} + }) + } + /> + ))} + + )} +
+ ) : ( + <> + {/* MySQL/SQLite: Flat layout */} + {/* Object count summary */} +
+ + {t("sidebar.objectSummary")} + + + {formatObjectCount(tables.length, views.length, routines.length)} +
- )} -
+ {/* Tables */} + setTablesOpen(!tablesOpen)} + actions={ +
+ + +
+ } + > + {tables.length === 0 ? ( +
+ {t("sidebar.noTables")} +
+ ) : ( +
+ {tables.map((table) => ( + + setModifyColumnModal({ + isOpen: true, + tableName: t_name, + column: null, + }) + } + onEditColumn={(t_name, c) => + setModifyColumnModal({ + isOpen: true, + tableName: t_name, + column: c, + }) + } + onAddIndex={(t_name) => + setCreateIndexModal({ + isOpen: true, + tableName: t_name, + }) + } + onDropIndex={async (t_name, name) => { + if ( + await ask( + t("sidebar.deleteIndexConfirm", { name }), + { + title: t("sidebar.deleteIndex"), + kind: "warning", + }, + ) + ) { + const q = + activeDriver === "mysql" || + activeDriver === "mariadb" + ? `DROP INDEX ${quoteIdentifier(name, activeDriver)} ON ${quoteTableRef( + t_name, + activeDriver, + )}` + : `DROP INDEX ${quoteIdentifier(name, activeDriver)}`; + await invoke("execute_query", { + connectionId: activeConnectionId, + query: q, + }).catch(console.error); + setSchemaVersion((v) => v + 1); + } + }} + onAddForeignKey={(t_name) => + setCreateForeignKeyModal({ + isOpen: true, + tableName: t_name, + }) + } + onDropForeignKey={async (t_name, name) => { + if ( + await ask( + t("sidebar.deleteFkConfirm", { name }), + { + title: t("sidebar.deleteFk"), + kind: "warning", + }, + ) + ) { + if (activeDriver === "sqlite") { + await message(t("sidebar.sqliteFkError"), { + kind: "error", + }); + return; + } + const q = + activeDriver === "mysql" || + activeDriver === "mariadb" + ? `ALTER TABLE ${quoteTableRef(t_name, activeDriver)} DROP FOREIGN KEY ${quoteIdentifier( + name, + activeDriver, + )}` + : `ALTER TABLE ${quoteTableRef(t_name, activeDriver)} DROP CONSTRAINT ${quoteIdentifier( + name, + activeDriver, + )}`; + await invoke("execute_query", { + connectionId: activeConnectionId, + query: q, + }).catch(console.error); + setSchemaVersion((v) => v + 1); + } + }} + schemaVersion={schemaVersion} + /> + ))} +
+ )} +
- {/* Routines */} - {activeDriver !== "sqlite" && ( - setRoutinesOpen(!routinesOpen)} - actions={ -
- -
- } - > - {routines.length === 0 ? ( -
- {t("sidebar.noRoutines")} -
- ) : ( -
- {/* Functions */} - {groupedRoutines.functions.length > 0 && ( -
- + +
+ } + > + {views.length === 0 ? ( +
+ {t("sidebar.noViews")} +
+ ) : ( +
+ {views.map((view) => ( + + ))} +
+ )} + + + {/* Routines */} + {activeDriver !== "sqlite" && ( + setRoutinesOpen(!routinesOpen)} + actions={ +
+ - - {functionsOpen && groupedRoutines.functions.map((routine) => ( - - ))}
- )} + } + > + {routines.length === 0 ? ( +
+ {t("sidebar.noRoutines")} +
+ ) : ( +
+ {/* Functions */} + {groupedRoutines.functions.length > 0 && ( +
+ - {/* Procedures */} - {groupedRoutines.procedures.length > 0 && ( -
- - - {proceduresOpen && groupedRoutines.procedures.map((routine) => ( - - ))} + {functionsOpen && groupedRoutines.functions.map((routine) => ( + + ))} +
+ )} + + {/* Procedures */} + {groupedRoutines.procedures.length > 0 && ( +
+ + + {proceduresOpen && groupedRoutines.procedures.map((routine) => ( + + ))} +
+ )}
)} -
+
)} - + )} )} @@ -770,16 +1114,20 @@ export const Sidebar = () => { onClose={() => setContextMenu(null)} items={ contextMenu.type === "table" - ? [ + ? (() => { + const ctxSchema = contextMenu.data && "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + return [ { label: t("sidebar.showData"), icon: PlaySquare, action: () => { - const quotedTable = quoteIdentifier(contextMenu.id, activeDriver); + const quotedTable = quoteTableRef(contextMenu.id, activeDriver, ctxSchema); runQuery( `SELECT * FROM ${quotedTable}`, undefined, contextMenu.id, + false, + ctxSchema, ); }, }, @@ -787,17 +1135,21 @@ export const Sidebar = () => { label: t("sidebar.countRows"), icon: Hash, action: () => { - const quotedTable = quoteIdentifier(contextMenu.id, activeDriver); + const quotedTable = quoteTableRef(contextMenu.id, activeDriver, ctxSchema); // Don't pass tableName for aggregate queries - let extractTableName handle it runQuery( `SELECT COUNT(*) as count FROM ${quotedTable}`, + undefined, + undefined, + false, + ctxSchema, ); }, }, { label: t("sidebar.viewSchema"), icon: FileText, - action: () => setSchemaModalTable(contextMenu.id), + action: () => setSchemaModal({ tableName: contextMenu.id, schema: ctxSchema }), }, { label: t("sidebar.viewERDiagram"), @@ -809,6 +1161,7 @@ export const Sidebar = () => { connectionName: activeConnectionName || "Unknown", databaseName: activeDatabaseName || "Unknown", focusTable: contextMenu.id, + ...(ctxSchema ? { schema: ctxSchema } : {}), }); } catch (e) { console.error("Failed to open ER Diagram window:", e); @@ -840,7 +1193,7 @@ export const Sidebar = () => { icon: Trash2, danger: true, action: async () => { - const quotedTable = quoteIdentifier(contextMenu.id, activeDriver); + const quotedTable = quoteTableRef(contextMenu.id, activeDriver, ctxSchema); if ( await ask( t("sidebar.deleteTableConfirm", { @@ -853,6 +1206,7 @@ export const Sidebar = () => { await invoke("execute_query", { connectionId: activeConnectionId, query: `DROP TABLE ${quotedTable}`, + ...(ctxSchema ? { schema: ctxSchema } : {}), }); if (refreshTables) refreshTables(); } catch (e) { @@ -867,7 +1221,8 @@ export const Sidebar = () => { } }, }, - ] + ]; + })() : contextMenu.type === "index" ? [ { @@ -886,6 +1241,8 @@ export const Sidebar = () => { "tableName" in contextMenu.data ) { const t_name = contextMenu.data.tableName; + const ctxSchema = + "schema" in contextMenu.data ? contextMenu.data.schema : undefined; if ( await ask( t("sidebar.deleteIndexConfirm", { @@ -901,12 +1258,17 @@ export const Sidebar = () => { const q = activeDriver === "mysql" || activeDriver === "mariadb" - ? `DROP INDEX \`${contextMenu.id}\` ON \`${t_name}\`` - : `DROP INDEX "${contextMenu.id}"`; + ? `DROP INDEX ${quoteIdentifier(contextMenu.id, activeDriver)} ON ${quoteTableRef( + t_name, + activeDriver, + ctxSchema, + )}` + : `DROP INDEX ${quoteTableRef(contextMenu.id, activeDriver, ctxSchema)}`; await invoke("execute_query", { connectionId: activeConnectionId, query: q, + ...(ctxSchema ? { schema: ctxSchema } : {}), }); setSchemaVersion((v) => v + 1); @@ -933,44 +1295,53 @@ export const Sidebar = () => { navigator.clipboard.writeText(contextMenu.id), }, { - label: t("sidebar.deleteFk"), - icon: Trash2, - danger: true, - action: async () => { + label: t("sidebar.deleteFk"), + icon: Trash2, + danger: true, + action: async () => { + if ( + contextMenu.data && + "tableName" in contextMenu.data + ) { + const t_name = contextMenu.data.tableName; + const ctxSchema = + "schema" in contextMenu.data ? contextMenu.data.schema : undefined; if ( - contextMenu.data && - "tableName" in contextMenu.data + await ask( + t("sidebar.deleteFkConfirm", { + name: contextMenu.id, + }), + { + title: t("sidebar.deleteFk"), + kind: "warning", + }, + ) ) { - const t_name = contextMenu.data.tableName; - if ( - await ask( - t("sidebar.deleteFkConfirm", { - name: contextMenu.id, - }), - { - title: t("sidebar.deleteFk"), - kind: "warning", - }, - ) - ) { - if (activeDriver === "sqlite") { - await message(t("sidebar.sqliteFkError"), { - kind: "error", - }); - return; - } - const q = - activeDriver === "mysql" || - activeDriver === "mariadb" - ? `ALTER TABLE \`${t_name}\` DROP FOREIGN KEY \`${contextMenu.id}\`` - : `ALTER TABLE "${t_name}" DROP CONSTRAINT "${contextMenu.id}"`; - await invoke("execute_query", { - connectionId: activeConnectionId, - query: q, - }).catch(console.error); + if (activeDriver === "sqlite") { + await message(t("sidebar.sqliteFkError"), { + kind: "error", + }); + return; } + const q = + activeDriver === "mysql" || + activeDriver === "mariadb" + ? `ALTER TABLE ${quoteTableRef(t_name, activeDriver, ctxSchema)} DROP FOREIGN KEY ${quoteIdentifier( + contextMenu.id, + activeDriver, + )}` + : `ALTER TABLE ${quoteTableRef(t_name, activeDriver, ctxSchema)} DROP CONSTRAINT ${quoteIdentifier( + contextMenu.id, + activeDriver, + )}`; + await invoke("execute_query", { + connectionId: activeConnectionId, + query: q, + ...(ctxSchema ? { schema: ctxSchema } : {}), + }).catch(console.error); } - }, + } + }, }, ] : contextMenu.type === "folder_indexes" @@ -1010,12 +1381,14 @@ export const Sidebar = () => { }, ] : contextMenu.type === "view" - ? [ + ? (() => { + const viewCtxSchema = contextMenu.data && "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + return [ { label: t("sidebar.showData"), icon: PlaySquare, action: () => { - const quotedView = quoteIdentifier(contextMenu.id, activeDriver); + const quotedView = quoteTableRef(contextMenu.id, activeDriver, viewCtxSchema); runQuery( `SELECT * FROM ${quotedView}`, undefined, @@ -1027,7 +1400,7 @@ export const Sidebar = () => { label: t("sidebar.countRows"), icon: Hash, action: () => { - const quotedView = quoteIdentifier(contextMenu.id, activeDriver); + const quotedView = quoteTableRef(contextMenu.id, activeDriver, viewCtxSchema); runQuery( `SELECT COUNT(*) as count FROM ${quotedView}`, ); @@ -1070,6 +1443,7 @@ export const Sidebar = () => { await invoke("drop_view", { connectionId: activeConnectionId, viewName: contextMenu.id, + ...(activeSchema ? { schema: activeSchema } : {}), }); if (refreshViews) refreshViews(); } catch (e) { @@ -1084,7 +1458,8 @@ export const Sidebar = () => { } }, }, - ] + ]; + })() : contextMenu.type === "routine" ? [ { @@ -1100,6 +1475,7 @@ export const Sidebar = () => { connectionId: activeConnectionId, routineName: contextMenu.id, routineType: routineType, + ...(activeSchema ? { schema: activeSchema } : {}), }); // Show definition in modal or editor? // For now, let's open in editor as comment or just text @@ -1177,11 +1553,12 @@ export const Sidebar = () => { /> )} - {schemaModalTable && ( + {schemaModal && ( setSchemaModalTable(null)} + tableName={schemaModal.tableName} + schema={schemaModal.schema} + onClose={() => setSchemaModal(null)} /> )} diff --git a/src/components/layout/sidebar/SidebarColumnItem.tsx b/src/components/layout/sidebar/SidebarColumnItem.tsx index a7750fc..95d188a 100644 --- a/src/components/layout/sidebar/SidebarColumnItem.tsx +++ b/src/components/layout/sidebar/SidebarColumnItem.tsx @@ -6,6 +6,7 @@ import { Key, Columns, Edit, Copy, Trash2 } from "lucide-react"; import clsx from "clsx"; import { ContextMenu } from "../../ui/ContextMenu"; import type { TableColumn } from "../../../types/schema"; +import { quoteIdentifier, quoteTableRef } from "../../../utils/identifiers"; interface SidebarColumnItemProps { column: TableColumn; @@ -15,6 +16,7 @@ interface SidebarColumnItemProps { onRefresh: () => void; onEdit: (column: TableColumn) => void; isView?: boolean; + schema?: string; } export const SidebarColumnItem = ({ @@ -25,6 +27,7 @@ export const SidebarColumnItem = ({ onRefresh, onEdit, isView = false, + schema, }: SidebarColumnItemProps) => { const { t } = useTranslation(); const [contextMenu, setContextMenu] = useState<{ @@ -49,12 +52,14 @@ export const SidebarColumnItem = ({ if (confirmed) { try { - const q = driver === "mysql" || driver === "mariadb" ? "`" : '"'; - const query = `ALTER TABLE ${q}${tableName}${q} DROP COLUMN ${q}${column.name}${q}`; + const quotedTable = quoteTableRef(tableName, driver, schema); + const quotedColumn = quoteIdentifier(column.name, driver); + const query = `ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`; await invoke("execute_query", { connectionId, query, + ...(schema ? { schema } : {}), }); onRefresh(); diff --git a/src/components/layout/sidebar/SidebarRoutineItem.tsx b/src/components/layout/sidebar/SidebarRoutineItem.tsx index 5824f1c..01ec520 100644 --- a/src/components/layout/sidebar/SidebarRoutineItem.tsx +++ b/src/components/layout/sidebar/SidebarRoutineItem.tsx @@ -30,6 +30,7 @@ interface SidebarRoutineItemProps { ) => void; onDoubleClick: (routine: RoutineInfo) => void; connectionId: string; + schema?: string; } export const SidebarRoutineItem = ({ @@ -37,6 +38,7 @@ export const SidebarRoutineItem = ({ onContextMenu, onDoubleClick, connectionId, + schema, }: SidebarRoutineItemProps) => { const { t } = useTranslation(); @@ -53,6 +55,7 @@ export const SidebarRoutineItem = ({ { connectionId, routineName: routine.name, + ...(schema ? { schema } : {}), }, ); setParameters(params); @@ -61,7 +64,7 @@ export const SidebarRoutineItem = ({ } finally { setIsLoading(false); } - }, [connectionId, routine.name]); + }, [connectionId, routine.name, schema]); useEffect(() => { if (isExpanded) { diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx new file mode 100644 index 0000000..4d36380 --- /dev/null +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -0,0 +1,333 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Loader2, + ChevronDown, + ChevronRight, + Layers, + Plus, + RefreshCw, +} from "lucide-react"; +import { Accordion } from "./Accordion"; +import { SidebarTableItem } from "./SidebarTableItem"; +import { SidebarViewItem } from "./SidebarViewItem"; +import { SidebarRoutineItem } from "./SidebarRoutineItem"; +import type { SchemaData, RoutineInfo } from "../../../contexts/DatabaseContext"; +import type { TableColumn } from "../../../types/schema"; +import type { ContextMenuData } from "../../../types/sidebar"; +import { groupRoutinesByType } from "../../../utils/routines"; +import { formatObjectCount } from "../../../utils/schema"; + +interface SidebarSchemaItemProps { + schemaName: string; + schemaData: SchemaData | undefined; + activeTable: string | null; + activeSchema: string | null; + connectionId: string; + driver: string; + schemaVersion: number; + onLoadSchema: (schema: string) => void; + onRefreshSchema: (schema: string) => void; + onTableClick: (name: string, schema: string) => void; + onTableDoubleClick: (name: string, schema: string) => void; + onViewClick: (name: string) => void; + onViewDoubleClick: (name: string, schema: string) => void; + onRoutineDoubleClick: (routine: RoutineInfo, schema: string) => void; + onContextMenu: ( + e: React.MouseEvent, + type: string, + id: string, + label: string, + data?: ContextMenuData, + ) => void; + onAddColumn: (tableName: string) => void; + onEditColumn: (tableName: string, col: TableColumn) => void; + onAddIndex: (tableName: string) => void; + onDropIndex: (tableName: string, indexName: string) => void; + onAddForeignKey: (tableName: string) => void; + onDropForeignKey: (tableName: string, fkName: string) => void; + onCreateTable: () => void; + onCreateView: () => void; +} + +export const SidebarSchemaItem = ({ + schemaName, + schemaData, + activeTable, + activeSchema, + connectionId, + driver, + schemaVersion, + onLoadSchema, + onRefreshSchema, + onTableClick, + onTableDoubleClick, + onViewClick, + onViewDoubleClick, + onRoutineDoubleClick, + onContextMenu, + onAddColumn, + onEditColumn, + onAddIndex, + onDropIndex, + onAddForeignKey, + onDropForeignKey, + onCreateTable, + onCreateView, +}: SidebarSchemaItemProps) => { + const { t } = useTranslation(); + + const [isExpanded, setIsExpanded] = useState( + activeSchema === schemaName, + ); + const [prevActiveSchema, setPrevActiveSchema] = useState(activeSchema); + const [tablesOpen, setTablesOpen] = useState(true); + const [viewsOpen, setViewsOpen] = useState(true); + const [routinesOpen, setRoutinesOpen] = useState(false); + const [functionsOpen, setFunctionsOpen] = useState(true); + const [proceduresOpen, setProceduresOpen] = useState(true); + + // Adjust isExpanded during render when activeSchema changes (avoids useEffect) + if (activeSchema !== prevActiveSchema) { + setPrevActiveSchema(activeSchema); + if (activeSchema === schemaName) { + setIsExpanded(true); + } + } + + const tables = schemaData?.tables ?? []; + const views = schemaData?.views ?? []; + const routines = schemaData?.routines ?? []; + const isLoading = schemaData?.isLoading ?? false; + const isLoaded = schemaData?.isLoaded ?? false; + + const groupedRoutines = routines.length > 0 ? groupRoutinesByType(routines) : { procedures: [], functions: [] }; + + const handleToggle = () => { + const willExpand = !isExpanded; + setIsExpanded(willExpand); + if (willExpand && !isLoaded && !isLoading) { + onLoadSchema(schemaName); + } + }; + + const itemCount = isLoaded + ? formatObjectCount(tables.length, views.length, routines.length) + : ""; + + return ( +
+ {/* Schema header */} +
+
+ {isExpanded ? ( + + ) : ( + + )} + + + {schemaName} + + {isLoaded && ( + + {itemCount} + + )} +
+ {isExpanded && ( + + )} +
+ + {/* Schema contents */} + {isExpanded && ( +
+ {isLoading && !isLoaded ? ( +
+ + {t("sidebar.loadingSchema")} +
+ ) : ( + <> + {/* Tables */} + setTablesOpen(!tablesOpen)} + actions={ +
+ +
+ } + > + {tables.length === 0 ? ( +
+ {t("sidebar.noTables")} +
+ ) : ( +
+ {tables.map((table) => ( + onTableClick(name, schemaName)} + onTableDoubleClick={(name) => onTableDoubleClick(name, schemaName)} + onContextMenu={onContextMenu} + connectionId={connectionId} + driver={driver} + onAddColumn={onAddColumn} + onEditColumn={onEditColumn} + onAddIndex={onAddIndex} + onDropIndex={onDropIndex} + onAddForeignKey={onAddForeignKey} + onDropForeignKey={onDropForeignKey} + schemaVersion={schemaVersion} + schema={schemaName} + /> + ))} +
+ )} +
+ + {/* Views */} + setViewsOpen(!viewsOpen)} + actions={ +
+ +
+ } + > + {views.length === 0 ? ( +
+ {t("sidebar.noViews")} +
+ ) : ( +
+ {views.map((view) => ( + onViewDoubleClick(name, schemaName)} + onContextMenu={onContextMenu} + connectionId={connectionId} + driver={driver} + schema={schemaName} + /> + ))} +
+ )} +
+ + {/* Routines */} + setRoutinesOpen(!routinesOpen)} + > + {routines.length === 0 ? ( +
+ {t("sidebar.noRoutines")} +
+ ) : ( +
+ {/* Functions */} + {groupedRoutines.functions.length > 0 && ( +
+ + {functionsOpen && groupedRoutines.functions.map((routine) => ( + onRoutineDoubleClick(r, schemaName)} + schema={schemaName} + /> + ))} +
+ )} + + {/* Procedures */} + {groupedRoutines.procedures.length > 0 && ( +
+ + {proceduresOpen && groupedRoutines.procedures.map((routine) => ( + onRoutineDoubleClick(r, schemaName)} + schema={schemaName} + /> + ))} +
+ )} +
+ )} +
+ + )} +
+ )} +
+ ); +}; diff --git a/src/components/layout/sidebar/SidebarTableItem.tsx b/src/components/layout/sidebar/SidebarTableItem.tsx index b698350..5aa05f6 100644 --- a/src/components/layout/sidebar/SidebarTableItem.tsx +++ b/src/components/layout/sidebar/SidebarTableItem.tsx @@ -37,6 +37,7 @@ interface SidebarTableItemProps { onAddForeignKey: (tableName: string) => void; onDropForeignKey: (tableName: string, fkName: string) => void; schemaVersion: number; + schema?: string; } export const SidebarTableItem = ({ @@ -54,6 +55,7 @@ export const SidebarTableItem = ({ onAddForeignKey, onDropForeignKey, schemaVersion, + schema, }: SidebarTableItemProps) => { const { t } = useTranslation(); // Prevent unused variable warning @@ -78,12 +80,18 @@ export const SidebarTableItem = ({ invoke("get_columns", { connectionId, tableName: table.name, + ...(schema ? { schema } : {}), }), invoke("get_foreign_keys", { connectionId, tableName: table.name, + ...(schema ? { schema } : {}), + }), + invoke("get_indexes", { + connectionId, + tableName: table.name, + ...(schema ? { schema } : {}), }), - invoke("get_indexes", { connectionId, tableName: table.name }), ]); setColumns(cols); @@ -94,7 +102,7 @@ export const SidebarTableItem = ({ } finally { setIsLoading(false); } - }, [connectionId, table.name]); + }, [connectionId, table.name, schema]); useEffect(() => { if (isExpanded) { @@ -121,7 +129,7 @@ export const SidebarTableItem = ({ const showContextMenu = (e: React.MouseEvent, type: string, name: string) => { e.preventDefault(); e.stopPropagation(); - onContextMenu(e, type, name, name, { tableName: table.name }); + onContextMenu(e, type, name, name, { tableName: table.name, schema }); }; // Group indexes by name since API returns one row per column @@ -212,6 +220,7 @@ export const SidebarTableItem = ({ driver={driver} onRefresh={refreshMetadata} onEdit={(c) => onEditColumn(table.name, c)} + schema={schema} /> ))}
diff --git a/src/components/layout/sidebar/SidebarViewItem.tsx b/src/components/layout/sidebar/SidebarViewItem.tsx index ec93dbe..003731f 100644 --- a/src/components/layout/sidebar/SidebarViewItem.tsx +++ b/src/components/layout/sidebar/SidebarViewItem.tsx @@ -11,6 +11,7 @@ import { import clsx from "clsx"; import { SidebarColumnItem } from "./SidebarColumnItem"; import type { TableColumn } from "../../../types/schema"; +import type { ContextMenuData } from "../../../types/sidebar"; interface SidebarViewItemProps { view: { name: string }; @@ -22,9 +23,11 @@ interface SidebarViewItemProps { type: string, id: string, label: string, + data?: ContextMenuData, ) => void; connectionId: string; driver: string; + schema?: string; } export const SidebarViewItem = ({ @@ -35,6 +38,7 @@ export const SidebarViewItem = ({ onContextMenu, connectionId, driver, + schema, }: SidebarViewItemProps) => { const { t } = useTranslation(); @@ -49,6 +53,7 @@ export const SidebarViewItem = ({ const cols = await invoke("get_view_columns", { connectionId, viewName: view.name, + ...(schema ? { schema } : {}), }); setColumns(cols); } catch (err) { @@ -56,7 +61,7 @@ export const SidebarViewItem = ({ } finally { setIsLoading(false); } - }, [connectionId, view.name]); + }, [connectionId, view.name, schema]); useEffect(() => { if (isExpanded) { @@ -78,7 +83,7 @@ export const SidebarViewItem = ({ const showContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - onContextMenu(e, "view", view.name, view.name); + onContextMenu(e, "view", view.name, view.name, { tableName: view.name, schema }); }; return ( diff --git a/src/components/modals/AiQueryModal.tsx b/src/components/modals/AiQueryModal.tsx index 04907fe..5907415 100644 --- a/src/components/modals/AiQueryModal.tsx +++ b/src/components/modals/AiQueryModal.tsx @@ -16,7 +16,7 @@ interface TableColumn { } export const AiQueryModal = ({ isOpen, onClose, onInsert }: AiQueryModalProps) => { - const { activeConnectionId, tables } = useDatabase(); + const { activeConnectionId, tables, activeSchema } = useDatabase(); const { settings } = useSettings(); const [prompt, setPrompt] = useState(""); @@ -38,6 +38,7 @@ export const AiQueryModal = ({ isOpen, onClose, onInsert }: AiQueryModalProps) = const cols = await invoke("get_columns", { connectionId: activeConnectionId, tableName: table.name, + ...(activeSchema ? { schema: activeSchema } : {}), }); return `Table: ${table.name} (${cols.map(c => `${c.name} ${c.data_type}`).join(", ")})`; }); @@ -56,7 +57,7 @@ export const AiQueryModal = ({ isOpen, onClose, onInsert }: AiQueryModalProps) = } finally { setIsSchemaLoading(false); } - }, [activeConnectionId, tables]); + }, [activeConnectionId, tables, activeSchema]); useEffect(() => { if (isOpen && activeConnectionId && tables.length > 0) { diff --git a/src/components/modals/CreateForeignKeyModal.tsx b/src/components/modals/CreateForeignKeyModal.tsx index e9b07e2..d0da24f 100644 --- a/src/components/modals/CreateForeignKeyModal.tsx +++ b/src/components/modals/CreateForeignKeyModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { X, Save, Loader2, AlertTriangle } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { SqlPreview } from '../ui/SqlPreview'; +import { useDatabase } from '../../hooks/useDatabase'; interface CreateForeignKeyModalProps { isOpen: boolean; @@ -32,6 +33,7 @@ export const CreateForeignKeyModal = ({ driver }: CreateForeignKeyModalProps) => { const { t } = useTranslation(); + const { activeSchema } = useDatabase(); const [fkName, setFkName] = useState(''); const [localColumn, setLocalColumn] = useState(''); const [refTable, setRefTable] = useState(''); @@ -59,9 +61,10 @@ export const CreateForeignKeyModal = ({ setError(''); // Fetch tables and local columns + const schemaParam = activeSchema ? { schema: activeSchema } : {}; Promise.all([ - invoke('get_tables', { connectionId }), - invoke('get_columns', { connectionId, tableName }) + invoke('get_tables', { connectionId, ...schemaParam }), + invoke('get_columns', { connectionId, tableName, ...schemaParam }) ]).then(([tbls, cols]) => { setTables(tbls); setLocalColumns(cols); @@ -69,13 +72,13 @@ export const CreateForeignKeyModal = ({ if (tbls.length > 0) setRefTable(tbls[0].name); // Default first table }).catch(e => setError(String(e))); } - }, [isOpen, connectionId, tableName]); + }, [isOpen, connectionId, tableName, activeSchema]); // Fetch ref columns when refTable changes useEffect(() => { if (refTable && isOpen) { setFetchingRefCols(true); - invoke('get_columns', { connectionId, tableName: refTable }) + invoke('get_columns', { connectionId, tableName: refTable, ...(activeSchema ? { schema: activeSchema } : {}) }) .then(cols => { setRefColumns(cols); if (cols.length > 0) setRefColumn(cols[0].name); @@ -83,7 +86,7 @@ export const CreateForeignKeyModal = ({ .catch(e => console.error(e)) .finally(() => setFetchingRefCols(false)); } - }, [refTable, isOpen, connectionId]); + }, [refTable, isOpen, connectionId, activeSchema]); // Auto-generate name based on selection useEffect(() => { @@ -112,7 +115,11 @@ export const CreateForeignKeyModal = ({ setLoading(true); setError(''); try { - await invoke('execute_query', { connectionId, query: sqlPreview }); + await invoke('execute_query', { + connectionId, + query: sqlPreview, + ...(activeSchema ? { schema: activeSchema } : {}), + }); onSuccess(); onClose(); } catch (e) { diff --git a/src/components/modals/CreateIndexModal.tsx b/src/components/modals/CreateIndexModal.tsx index 4831392..14b269a 100644 --- a/src/components/modals/CreateIndexModal.tsx +++ b/src/components/modals/CreateIndexModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { X, Save, Loader2 } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { SqlPreview } from '../ui/SqlPreview'; +import { useDatabase } from '../../hooks/useDatabase'; interface CreateIndexModalProps { isOpen: boolean; @@ -26,6 +27,7 @@ export const CreateIndexModal = ({ driver }: CreateIndexModalProps) => { const { t } = useTranslation(); + const { activeSchema } = useDatabase(); const [indexName, setIndexName] = useState(''); const [isUnique, setIsUnique] = useState(false); const [selectedColumns, setSelectedColumns] = useState([]); @@ -42,12 +44,12 @@ export const CreateIndexModal = ({ setIsUnique(false); setError(''); - invoke('get_columns', { connectionId, tableName }) + invoke('get_columns', { connectionId, tableName, ...(activeSchema ? { schema: activeSchema } : {}) }) .then(cols => setAvailableColumns(cols)) .catch(e => console.error(e)) .finally(() => setFetchingCols(false)); } - }, [isOpen, connectionId, tableName]); + }, [isOpen, connectionId, tableName, activeSchema]); const toggleColumn = (colName: string) => { if (selectedColumns.includes(colName)) { @@ -79,7 +81,11 @@ export const CreateIndexModal = ({ setLoading(true); setError(''); try { - await invoke('execute_query', { connectionId, query: sqlPreview }); + await invoke('execute_query', { + connectionId, + query: sqlPreview, + ...(activeSchema ? { schema: activeSchema } : {}), + }); onSuccess(); onClose(); } catch (e) { diff --git a/src/components/modals/CreateTableModal.tsx b/src/components/modals/CreateTableModal.tsx index 3341cd5..e84cd5d 100644 --- a/src/components/modals/CreateTableModal.tsx +++ b/src/components/modals/CreateTableModal.tsx @@ -29,7 +29,7 @@ interface CreateTableModalProps { export const CreateTableModal = ({ isOpen, onClose, onSuccess }: CreateTableModalProps) => { const { t } = useTranslation(); - const { activeConnectionId, activeDriver } = useDatabase(); + const { activeConnectionId, activeDriver, activeSchema } = useDatabase(); const [tableName, setTableName] = useState(''); const [columns, setColumns] = useState([ @@ -140,7 +140,8 @@ export const CreateTableModal = ({ isOpen, onClose, onSuccess }: CreateTableModa try { await invoke('execute_query', { connectionId: activeConnectionId, - query: sqlPreview + query: sqlPreview, + ...(activeSchema ? { schema: activeSchema } : {}), }); onSuccess(); onClose(); diff --git a/src/components/modals/DumpDatabaseModal.tsx b/src/components/modals/DumpDatabaseModal.tsx index 2ea1408..bb6f073 100644 --- a/src/components/modals/DumpDatabaseModal.tsx +++ b/src/components/modals/DumpDatabaseModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; import { save } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog"; +import { useDatabase } from "../../hooks/useDatabase"; import { Loader2, Download, Database, Square, CheckSquare } from "lucide-react"; import { validateDumpOptions, @@ -27,6 +28,7 @@ export const DumpDatabaseModal = ({ tables, }: DumpDatabaseModalProps) => { const { t } = useTranslation(); + const { activeSchema } = useDatabase(); const [includeStructure, setIncludeStructure] = useState(true); const [includeData, setIncludeData] = useState(true); const [selectedTables, setSelectedTables] = useState>( @@ -102,6 +104,7 @@ export const DumpDatabaseModal = ({ data: includeData, tables: Array.from(selectedTables), }, + ...(activeSchema ? { schema: activeSchema } : {}), }); await message(t("dump.success"), { kind: "info" }); diff --git a/src/components/modals/GenerateSQLModal.tsx b/src/components/modals/GenerateSQLModal.tsx index 33feaea..dd058a8 100644 --- a/src/components/modals/GenerateSQLModal.tsx +++ b/src/components/modals/GenerateSQLModal.tsx @@ -25,7 +25,7 @@ export const GenerateSQLModal = ({ tableName, }: GenerateSQLModalProps) => { const { t } = useTranslation(); - const { activeConnectionId, activeDriver } = useDatabase(); + const { activeConnectionId, activeDriver, activeSchema } = useDatabase(); const [sql, setSql] = useState(""); const [loading, setLoading] = useState(false); const [copied, setCopied] = useState(false); @@ -36,18 +36,22 @@ export const GenerateSQLModal = ({ const generateSQL = async () => { setLoading(true); try { + const schemaParam = activeSchema ? { schema: activeSchema } : {}; const [columns, foreignKeys, indexes] = await Promise.all([ invoke("get_columns", { connectionId: activeConnectionId, tableName, + ...schemaParam, }), invoke("get_foreign_keys", { connectionId: activeConnectionId, tableName, + ...schemaParam, }), invoke("get_indexes", { connectionId: activeConnectionId, tableName, + ...schemaParam, }), ]); @@ -68,7 +72,7 @@ export const GenerateSQLModal = ({ }; void generateSQL(); - }, [isOpen, activeConnectionId, tableName, activeDriver, t]); + }, [isOpen, activeConnectionId, tableName, activeDriver, t, activeSchema]); const handleCopy = async () => { await navigator.clipboard.writeText(sql); diff --git a/src/components/modals/ModifyColumnModal.tsx b/src/components/modals/ModifyColumnModal.tsx index 4e296ac..37127ca 100644 --- a/src/components/modals/ModifyColumnModal.tsx +++ b/src/components/modals/ModifyColumnModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { X, Save, Loader2, AlertTriangle } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { SqlPreview } from "../ui/SqlPreview"; +import { useDatabase } from "../../hooks/useDatabase"; const COMMON_TYPES = [ "INTEGER", @@ -56,6 +57,7 @@ export const ModifyColumnModal = ({ column, }: ModifyColumnModalProps) => { const { t } = useTranslation(); + const { activeSchema } = useDatabase(); const isEdit = !!column; // Parse initial type/length from column.data_type if possible @@ -232,6 +234,7 @@ export const ModifyColumnModal = ({ await invoke("execute_query", { connectionId, query: `ALTER TABLE ${q}${tableName}${q} RENAME COLUMN ${q}${column?.name}${q} TO ${q}${form.name}${q}`, + ...(activeSchema ? { schema: activeSchema } : {}), }); } else if (driver === "sqlite" && isEdit) { throw new Error(t("modifyColumn.sqliteWarn")); @@ -248,10 +251,18 @@ export const ModifyColumnModal = ({ .split("\n") .filter((s) => s.trim() && !s.startsWith("--")); for (const sql of statements) { - await invoke("execute_query", { connectionId, query: sql }); + await invoke("execute_query", { + connectionId, + query: sql, + ...(activeSchema ? { schema: activeSchema } : {}), + }); } } else { - await invoke("execute_query", { connectionId, query: sqlPreview }); + await invoke("execute_query", { + connectionId, + query: sqlPreview, + ...(activeSchema ? { schema: activeSchema } : {}), + }); } } diff --git a/src/components/modals/NewRowModal.tsx b/src/components/modals/NewRowModal.tsx index 881bb68..ee8a71f 100644 --- a/src/components/modals/NewRowModal.tsx +++ b/src/components/modals/NewRowModal.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { X, Loader2, Plus } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { useDatabase } from "../../hooks/useDatabase"; -import { quoteIdentifier } from "../../utils/identifiers"; +import { quoteTableRef } from "../../utils/identifiers"; interface TableColumn { name: string; @@ -34,7 +34,7 @@ export const NewRowModal = ({ onSaveSuccess, }: NewRowModalProps) => { const { t } = useTranslation(); - const { activeConnectionId, activeDriver } = useDatabase(); + const { activeConnectionId, activeDriver, activeSchema } = useDatabase(); const [columns, setColumns] = useState([]); const [formData, setFormData] = useState>({}); const [loading, setLoading] = useState(false); @@ -54,13 +54,14 @@ export const NewRowModal = ({ setLoadingFk((prev) => ({ ...prev, [fk.column_name]: true })); setFkErrors((prev) => ({ ...prev, [fk.column_name]: "" })); try { - const quotedTable = quoteIdentifier(fk.ref_table, activeDriver); + const quotedTable = quoteTableRef(fk.ref_table, activeDriver, activeSchema); // Select * from referenced table to get context const query = `SELECT * FROM ${quotedTable} LIMIT 100`; const result = await invoke<{ columns: string[], rows: unknown[][] }>("execute_query", { connectionId: activeConnectionId, query, + ...(activeSchema ? { schema: activeSchema } : {}), }); const options = result.rows.map((rowArray) => { @@ -104,21 +105,24 @@ export const NewRowModal = ({ } finally { setLoadingFk((prev) => ({ ...prev, [fk.column_name]: false })); } - }, [activeConnectionId, activeDriver]); + }, [activeConnectionId, activeDriver, activeSchema]); useEffect(() => { if (isOpen && activeConnectionId && tableName) { setSchemaLoading(true); // Fetch columns and FKs in parallel + const schemaParam = activeSchema ? { schema: activeSchema } : {}; Promise.all([ invoke("get_columns", { connectionId: activeConnectionId, tableName, + ...schemaParam, }), invoke("get_foreign_keys", { connectionId: activeConnectionId, tableName, + ...schemaParam, }), ]) .then(([cols, fks]) => { @@ -140,7 +144,7 @@ export const NewRowModal = ({ .catch((err) => setError(t("newRow.failLoad") + err)) .finally(() => setSchemaLoading(false)); } - }, [isOpen, activeConnectionId, tableName, fetchFkOptions, t]); + }, [isOpen, activeConnectionId, tableName, fetchFkOptions, t, activeSchema]); if (!isOpen) return null; @@ -220,6 +224,7 @@ export const NewRowModal = ({ connectionId: activeConnectionId, table: tableName, data: dataToSend, + ...(activeSchema ? { schema: activeSchema } : {}), }); onSaveSuccess(); diff --git a/src/components/modals/SchemaModal.tsx b/src/components/modals/SchemaModal.tsx index 7a0415a..2e7cdb2 100644 --- a/src/components/modals/SchemaModal.tsx +++ b/src/components/modals/SchemaModal.tsx @@ -15,13 +15,15 @@ interface SchemaModalProps { isOpen: boolean; onClose: () => void; tableName: string; + schema?: string | null; } -export const SchemaModal = ({ isOpen, onClose, tableName }: SchemaModalProps) => { +export const SchemaModal = ({ isOpen, onClose, tableName, schema }: SchemaModalProps) => { const { t } = useTranslation(); - const { activeConnectionId } = useDatabase(); + const { activeConnectionId, activeSchema } = useDatabase(); const [columns, setColumns] = useState([]); const [loading, setLoading] = useState(false); + const resolvedSchema = schema ?? activeSchema; useEffect(() => { if (!isOpen || !activeConnectionId || !tableName) return; @@ -29,9 +31,10 @@ export const SchemaModal = ({ isOpen, onClose, tableName }: SchemaModalProps) => const loadSchema = async () => { setLoading(true); try { - const cols = await invoke('get_columns', { - connectionId: activeConnectionId, - tableName + const cols = await invoke('get_columns', { + connectionId: activeConnectionId, + tableName, + ...(resolvedSchema ? { schema: resolvedSchema } : {}), }); setColumns(cols); } catch (err) { @@ -42,7 +45,7 @@ export const SchemaModal = ({ isOpen, onClose, tableName }: SchemaModalProps) => }; void loadSchema(); - }, [isOpen, activeConnectionId, tableName]); + }, [isOpen, activeConnectionId, tableName, resolvedSchema]); if (!isOpen) return null; diff --git a/src/components/modals/ViewEditorModal.tsx b/src/components/modals/ViewEditorModal.tsx index 74cde62..447b29d 100644 --- a/src/components/modals/ViewEditorModal.tsx +++ b/src/components/modals/ViewEditorModal.tsx @@ -4,6 +4,7 @@ import { X, Loader2, Eye, AlertCircle, Play } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { message, ask } from "@tauri-apps/plugin-dialog"; import { SqlEditorWrapper } from "../ui/SqlEditorWrapper"; +import { useDatabase } from "../../hooks/useDatabase"; interface ViewEditorModalProps { isOpen: boolean; @@ -23,6 +24,7 @@ export const ViewEditorModal = ({ onSuccess, }: ViewEditorModalProps) => { const { t } = useTranslation(); + const { activeSchema } = useDatabase(); const [name, setName] = useState(""); const [definition, setDefinition] = useState(""); const [originalDefinition, setOriginalDefinition] = useState(""); @@ -42,6 +44,7 @@ export const ViewEditorModal = ({ const def = await invoke("get_view_definition", { connectionId, viewName: vName, + ...(activeSchema ? { schema: activeSchema } : {}), }); // Extract just the SELECT part for editing let selectPart = def; @@ -56,7 +59,7 @@ export const ViewEditorModal = ({ } finally { setLoading(false); } - }, [connectionId, t]); + }, [connectionId, t, activeSchema]); useEffect(() => { if (isOpen) { @@ -88,6 +91,7 @@ export const ViewEditorModal = ({ query: definition, limit: 10, page: 1, + ...(activeSchema ? { schema: activeSchema } : {}), }); setPreviewResult({ columns: result.columns, @@ -121,6 +125,7 @@ export const ViewEditorModal = ({ connectionId, viewName: name, definition, + ...(activeSchema ? { schema: activeSchema } : {}), }); await message(t("views.createSuccess"), { kind: "info" }); } else { @@ -140,6 +145,7 @@ export const ViewEditorModal = ({ connectionId, viewName: name, definition, + ...(activeSchema ? { schema: activeSchema } : {}), }); await message(t("views.alterSuccess"), { kind: "info" }); } diff --git a/src/components/ui/DataGrid.tsx b/src/components/ui/DataGrid.tsx index dc06145..40cedb1 100644 --- a/src/components/ui/DataGrid.tsx +++ b/src/components/ui/DataGrid.tsx @@ -23,6 +23,7 @@ import { type MergedRow, type ColumnDisplayInfo, } from "../../utils/dataGrid"; +import { useDatabase } from "../../hooks/useDatabase"; import { rowToTSV, rowsToTSV, getSelectedRows, copyTextToClipboard } from "../../utils/clipboard"; import type { PendingInsertion } from "../../types/editor"; @@ -81,8 +82,7 @@ export const DataGrid = React.memo(({ onSort, }: DataGridProps) => { const { t } = useTranslation(); - - + const { activeSchema } = useDatabase(); const [contextMenu, setContextMenu] = useState<{ x: number; @@ -294,6 +294,7 @@ export const DataGrid = React.memo(({ pkVal, colName, newVal: value, + ...(activeSchema ? { schema: activeSchema } : {}), }); if (onRefresh) onRefresh(); } catch (e) { @@ -421,7 +422,7 @@ export const DataGrid = React.memo(({ }, }), ), - [columns, columnHelper, t, sortClause, onSort, autoIncrementColumns, defaultValueColumns, nullableColumns], + [columns, columnHelper, t, sortClause, onSort], ); const parentRef = useRef(null); diff --git a/src/components/ui/SchemaDiagram.tsx b/src/components/ui/SchemaDiagram.tsx index 88f4d4c..47dc76a 100644 --- a/src/components/ui/SchemaDiagram.tsx +++ b/src/components/ui/SchemaDiagram.tsx @@ -79,11 +79,13 @@ const getLayoutedElements = ( interface SchemaDiagramContentProps { connectionId: string; refreshTrigger: number; + schema?: string; } const SchemaDiagramContent = ({ connectionId, refreshTrigger, + schema, }: SchemaDiagramContentProps) => { const { t } = useTranslation(); const { getSchema } = useEditor(); @@ -182,7 +184,7 @@ const SchemaDiagramContent = ({ setLoading(true); try { - const fetchedSchema = await getSchema(connectionId); + const fetchedSchema = await getSchema(connectionId, undefined, schema); if (!isMounted) return; // Build nodes and edges with optimizations @@ -266,6 +268,7 @@ const SchemaDiagramContent = ({ setNodes, setEdges, layoutDirection, + schema, ]); // Effetto per filtrare i nodi quando una tabella è selezionata @@ -442,16 +445,19 @@ const SchemaDiagramContent = ({ interface SchemaDiagramProps { connectionId: string; refreshTrigger: number; + schema?: string; } export const SchemaDiagram = ({ connectionId, refreshTrigger, + schema, }: SchemaDiagramProps) => ( ); diff --git a/src/components/ui/VisualQueryBuilder.tsx b/src/components/ui/VisualQueryBuilder.tsx index 8dda207..6bf177b 100644 --- a/src/components/ui/VisualQueryBuilder.tsx +++ b/src/components/ui/VisualQueryBuilder.tsx @@ -38,7 +38,7 @@ interface TableColumn { } const VisualQueryBuilderContent = () => { - const { activeConnectionId } = useDatabase(); + const { activeConnectionId, activeSchema } = useDatabase(); const { activeTab, activeTabId, updateTab } = useEditor(); const { screenToFlowPosition } = useReactFlow(); @@ -203,7 +203,7 @@ const VisualQueryBuilderContent = () => { }); try { - const columns = await invoke("get_columns", { connectionId: activeConnectionId, tableName }); + const columns = await invoke("get_columns", { connectionId: activeConnectionId, tableName, ...(activeSchema ? { schema: activeSchema } : {}) }); const newNodeId = `${tableName}-${Date.now()}`; const newNode: Node = { @@ -228,7 +228,7 @@ const VisualQueryBuilderContent = () => { console.error("Failed to fetch columns", e); } }, - [activeConnectionId, screenToFlowPosition, setNodes, onColumnCheck, onColumnAggregation, onColumnAlias, deleteNode], + [activeConnectionId, screenToFlowPosition, setNodes, onColumnCheck, onColumnAggregation, onColumnAlias, deleteNode, activeSchema], ); // Get all available columns from all nodes diff --git a/src/contexts/DatabaseContext.ts b/src/contexts/DatabaseContext.ts index 7b412fc..fb7844f 100644 --- a/src/contexts/DatabaseContext.ts +++ b/src/contexts/DatabaseContext.ts @@ -25,6 +25,14 @@ export interface SavedConnection { }; } +export interface SchemaData { + tables: TableInfo[]; + views: ViewInfo[]; + routines: RoutineInfo[]; + isLoading: boolean; + isLoaded: boolean; +} + export interface DatabaseContextType { activeConnectionId: string | null; activeDriver: string | null; @@ -39,10 +47,21 @@ export interface DatabaseContextType { isLoadingRoutines: boolean; connect: (connectionId: string) => Promise; disconnect: () => void; - setActiveTable: (table: string | null) => void; + setActiveTable: (table: string | null, schema?: string | null) => void; refreshTables: () => Promise; refreshViews: () => Promise; refreshRoutines: () => Promise; + // Schema support (PostgreSQL) + schemas: string[]; + isLoadingSchemas: boolean; + schemaDataMap: Record; + activeSchema: string | null; + loadSchemaData: (schema: string) => Promise; + refreshSchemaData: (schema: string) => Promise; + // Schema selection (PostgreSQL) + selectedSchemas: string[]; + setSelectedSchemas: (schemas: string[]) => Promise; + needsSchemaSelection: boolean; } export const DatabaseContext = createContext(undefined); diff --git a/src/contexts/DatabaseProvider.tsx b/src/contexts/DatabaseProvider.tsx index 12a3292..dd2e60b 100644 --- a/src/contexts/DatabaseProvider.tsx +++ b/src/contexts/DatabaseProvider.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { DatabaseContext, type TableInfo, type ViewInfo, type RoutineInfo, type SavedConnection } from './DatabaseContext'; +import { DatabaseContext, type TableInfo, type ViewInfo, type RoutineInfo, type SavedConnection, type SchemaData } from './DatabaseContext'; import type { ReactNode } from 'react'; import { clearAutocompleteCache } from '../utils/autocomplete'; @@ -17,22 +17,32 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { const [isLoadingViews, setIsLoadingViews] = useState(false); const [isLoadingRoutines, setIsLoadingRoutines] = useState(false); + // Schema support (PostgreSQL) + const [schemas, setSchemas] = useState([]); + const [isLoadingSchemas, setIsLoadingSchemas] = useState(false); + const [schemaDataMap, setSchemaDataMap] = useState>({}); + const [activeSchema, setActiveSchema] = useState(null); + const [selectedSchemas, setSelectedSchemasState] = useState([]); + const [needsSchemaSelection, setNeedsSchemaSelection] = useState(false); + // Sync Window Title with active connection // WORKAROUND: Using custom Tauri command instead of window.setTitle() for Wayland support // See: https://github.com/tauri-apps/tauri/issues/13749 useEffect(() => { const updateTitle = async () => { try { - const title = (activeConnectionName && activeDatabaseName) - ? `tabularis - ${activeConnectionName} (${activeDatabaseName})` - : 'tabularis'; + let title = 'tabularis'; + if (activeConnectionName && activeDatabaseName) { + const schemaSuffix = activeSchema && activeDriver === 'postgres' ? `/${activeSchema}` : ''; + title = `tabularis - ${activeConnectionName} (${activeDatabaseName}${schemaSuffix})`; + } await invoke('set_window_title', { title }); } catch (e) { console.error('Failed to update window title', e); } }; updateTitle(); - }, [activeConnectionName, activeDatabaseName]); + }, [activeConnectionName, activeDatabaseName, activeSchema, activeDriver]); const refreshTables = async () => { if (!activeConnectionId) return; @@ -73,6 +83,117 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { } }; + const loadSchemaData = useCallback(async (schema: string) => { + if (!activeConnectionId) return; + + // Skip if already loaded or currently loading + setSchemaDataMap(prev => { + const existing = prev[schema]; + if (existing?.isLoaded || existing?.isLoading) return prev; + return { + ...prev, + [schema]: { tables: [], views: [], routines: [], isLoading: true, isLoaded: false }, + }; + }); + + // Check outside setState to decide whether to proceed + const existing = schemaDataMap[schema]; + if (existing?.isLoaded || existing?.isLoading) return; + + try { + const [tablesResult, viewsResult, routinesResult] = await Promise.all([ + invoke('get_tables', { connectionId: activeConnectionId, schema }), + invoke('get_views', { connectionId: activeConnectionId, schema }), + invoke('get_routines', { connectionId: activeConnectionId, schema }), + ]); + + setSchemaDataMap(prev => ({ + ...prev, + [schema]: { + tables: tablesResult, + views: viewsResult, + routines: routinesResult, + isLoading: false, + isLoaded: true, + }, + })); + } catch (e) { + console.error(`Failed to load schema data for ${schema}:`, e); + setSchemaDataMap(prev => ({ + ...prev, + [schema]: { tables: [], views: [], routines: [], isLoading: false, isLoaded: false }, + })); + } + }, [activeConnectionId, schemaDataMap]); + + const refreshSchemaData = useCallback(async (schema: string) => { + if (!activeConnectionId) return; + + setSchemaDataMap(prev => ({ + ...prev, + [schema]: { ...(prev[schema] || { tables: [], views: [], routines: [], isLoaded: false }), isLoading: true }, + })); + + try { + const [tablesResult, viewsResult, routinesResult] = await Promise.all([ + invoke('get_tables', { connectionId: activeConnectionId, schema }), + invoke('get_views', { connectionId: activeConnectionId, schema }), + invoke('get_routines', { connectionId: activeConnectionId, schema }), + ]); + + setSchemaDataMap(prev => ({ + ...prev, + [schema]: { + tables: tablesResult, + views: viewsResult, + routines: routinesResult, + isLoading: false, + isLoaded: true, + }, + })); + } catch (e) { + console.error(`Failed to refresh schema data for ${schema}:`, e); + setSchemaDataMap(prev => ({ + ...prev, + [schema]: { ...(prev[schema] || { tables: [], views: [], routines: [], isLoaded: false }), isLoading: false }, + })); + } + }, [activeConnectionId]); + + const setSelectedSchemas = useCallback(async (newSchemas: string[]) => { + setSelectedSchemasState(newSchemas); + setNeedsSchemaSelection(false); + + if (activeConnectionId) { + // Persist selection + try { + await invoke('set_selected_schemas', { + connectionId: activeConnectionId, + schemas: newSchemas, + }); + } catch (e) { + console.error('Failed to persist selected schemas:', e); + } + + // Load data for newly-added schemas + for (const schema of newSchemas) { + const existing = schemaDataMap[schema]; + if (!existing?.isLoaded && !existing?.isLoading) { + loadSchemaData(schema); + } + } + + // Update activeSchema if missing or no longer in the selection + if (!activeSchema || !newSchemas.includes(activeSchema)) { + const nextSchema = newSchemas[0] || null; + setActiveSchema(nextSchema); + if (nextSchema && activeConnectionId) { + invoke('set_schema_preference', { connectionId: activeConnectionId, schema: nextSchema }).catch(() => {}); + } + } + } + }, [activeConnectionId, schemaDataMap, loadSchemaData, activeSchema]); + const connect = async (connectionId: string) => { setActiveConnectionId(connectionId); setIsLoadingTables(true); @@ -85,26 +206,97 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { setActiveTable(null); setActiveConnectionName(null); setActiveDatabaseName(null); + setSchemas([]); + setSchemaDataMap({}); + setActiveSchema(null); + setSelectedSchemasState([]); + setNeedsSchemaSelection(false); try { // 1. Get driver info const connections = await invoke('get_connections'); const conn = connections.find(c => c.id === connectionId); + let driver: string | null = null; if (conn) { - setActiveDriver(conn.params.driver); + driver = conn.params.driver; + setActiveDriver(driver); setActiveConnectionName(conn.name); setActiveDatabaseName(conn.params.database); } - // 2. Get tables and views in parallel - const [tablesResult, viewsResult, routinesResult] = await Promise.all([ - invoke('get_tables', { connectionId }), - invoke('get_views', { connectionId }), - invoke('get_routines', { connectionId }) - ]); - setTables(tablesResult); - setViews(viewsResult); - setRoutines(routinesResult); + // 2. For PostgreSQL: fetch schemas, then check saved selection + if (driver === 'postgres') { + setIsLoadingSchemas(true); + try { + const schemasResult = await invoke('get_schemas', { connectionId }); + setSchemas(schemasResult); + + // Check for saved schema selection + let savedSelection: string[] = []; + try { + savedSelection = await invoke('get_selected_schemas', { connectionId }); + } catch { + // Ignore errors + } + + // Filter saved selection to only include schemas that still exist + const validSelection = savedSelection.filter(s => schemasResult.includes(s)); + + if (validSelection.length > 0) { + // Saved selection exists: load those schemas + setSelectedSchemasState(validSelection); + setNeedsSchemaSelection(false); + + // Get saved preferred schema or use first selected + let preferredSchema = validSelection[0]; + try { + const saved = await invoke('get_schema_preference', { connectionId }); + if (saved && validSelection.includes(saved)) { + preferredSchema = saved; + } + } catch { + // Ignore preference errors + } + + setActiveSchema(preferredSchema); + + // Auto-load the preferred schema's data + const [tablesResult, viewsResult, routinesResult] = await Promise.all([ + invoke('get_tables', { connectionId, schema: preferredSchema }), + invoke('get_views', { connectionId, schema: preferredSchema }), + invoke('get_routines', { connectionId, schema: preferredSchema }), + ]); + + setSchemaDataMap({ + [preferredSchema]: { + tables: tablesResult, + views: viewsResult, + routines: routinesResult, + isLoading: false, + isLoaded: true, + }, + }); + } else { + // No saved selection: user must pick schemas + setSelectedSchemasState([]); + setNeedsSchemaSelection(true); + } + } catch (e) { + console.error('Failed to fetch schemas:', e); + } finally { + setIsLoadingSchemas(false); + } + } else { + // 3. MySQL/SQLite: fetch flat tables/views/routines + const [tablesResult, viewsResult, routinesResult] = await Promise.all([ + invoke('get_tables', { connectionId }), + invoke('get_views', { connectionId }), + invoke('get_routines', { connectionId }) + ]); + setTables(tablesResult); + setViews(viewsResult); + setRoutines(routinesResult); + } } catch (error) { console.error('Failed to fetch tables/views/routines:', error); setActiveConnectionId(null); @@ -119,8 +311,18 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { } }; + const setActiveTableWithSchema = useCallback((table: string | null, schema?: string | null) => { + setActiveTable(table); + if (schema !== undefined && schema !== null) { + setActiveSchema(schema); + // Save preference + if (activeConnectionId) { + invoke('set_schema_preference', { connectionId: activeConnectionId, schema }).catch(() => {}); + } + } + }, [activeConnectionId]); + const disconnect = () => { - // Clear autocomplete cache for this connection if (activeConnectionId) { clearAutocompleteCache(activeConnectionId); } @@ -133,6 +335,12 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { setTables([]); setViews([]); setRoutines([]); + setSchemas([]); + setSchemaDataMap({}); + setActiveSchema(null); + setSelectedSchemasState([]); + setNeedsSchemaSelection(false); + setIsLoadingSchemas(false); }; return ( @@ -150,10 +358,21 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { isLoadingRoutines, connect, disconnect, - setActiveTable, + setActiveTable: setActiveTableWithSchema, refreshTables, refreshViews, - refreshRoutines + refreshRoutines, + // Schema support + schemas, + isLoadingSchemas, + schemaDataMap, + activeSchema, + loadSchemaData, + refreshSchemaData, + // Schema selection + selectedSchemas, + setSelectedSchemas, + needsSchemaSelection, }}> {children} diff --git a/src/contexts/EditorContext.ts b/src/contexts/EditorContext.ts index 1b98cf7..6967388 100644 --- a/src/contexts/EditorContext.ts +++ b/src/contexts/EditorContext.ts @@ -13,7 +13,7 @@ export interface EditorContextType { closeTabsToRight: (id: string) => void; updateTab: (id: string, partial: Partial) => void; setActiveTabId: (id: string) => void; - getSchema: (connectionId: string, schemaVersion?: number) => Promise; + getSchema: (connectionId: string, schemaVersion?: number, schema?: string) => Promise; } export const EditorContext = createContext(undefined); diff --git a/src/contexts/EditorProvider.tsx b/src/contexts/EditorProvider.tsx index 25d5664..9d46a53 100644 --- a/src/contexts/EditorProvider.tsx +++ b/src/contexts/EditorProvider.tsx @@ -125,6 +125,7 @@ export const EditorProvider = ({ children }: { children: ReactNode }) => { tabsRef.current, activeConnectionId, partial?.activeTable || undefined, + partial?.schema, ); if (existing) { setActiveTabId(existing.id); @@ -270,25 +271,28 @@ export const EditorProvider = ({ children }: { children: ReactNode }) => { async ( connectionId: string, schemaVersion?: number, + schema?: string, ): Promise => { - const cached = schemaCacheRef.current[connectionId]; + const cacheKey = schema ? `${connectionId}:${schema}` : connectionId; + const cached = schemaCacheRef.current[cacheKey]; // Cache hit: same version, less than 5 minutes old if (shouldUseCachedSchema(cached, schemaVersion)) { - console.log("Using cached schema for", connectionId); + console.log("Using cached schema for", cacheKey); return cached!.data; } // Cache miss: fetch from backend - console.log("Fetching schema from backend for", connectionId); + console.log("Fetching schema from backend for", cacheKey); const data = await invoke("get_schema_snapshot", { connectionId, + ...(schema ? { schema } : {}), }); // Update cache in ref (no state update = no re-render) schemaCacheRef.current = { ...schemaCacheRef.current, - [connectionId]: createSchemaCacheEntry(data, schemaVersion || 0), + [cacheKey]: createSchemaCacheEntry(data, schemaVersion || 0), }; return data; diff --git a/src/contexts/SettingsContext.ts b/src/contexts/SettingsContext.ts index 2d9e407..cefa6e6 100644 --- a/src/contexts/SettingsContext.ts +++ b/src/contexts/SettingsContext.ts @@ -1,6 +1,6 @@ import { createContext } from "react"; -export type AppLanguage = "auto" | "en" | "it"; +export type AppLanguage = "auto" | "en" | "it" | "es"; export type AiProvider = | "openai" | "anthropic" diff --git a/src/i18n/config.ts b/src/i18n/config.ts index f333879..f21a397 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -4,6 +4,7 @@ import LanguageDetector from 'i18next-browser-languagedetector'; import en from './locales/en.json'; import it from './locales/it.json'; +import es from './locales/es.json'; i18n .use(LanguageDetector) @@ -12,6 +13,7 @@ i18n resources: { en: { translation: en }, it: { translation: it }, + es: { translation: es }, }, fallbackLng: 'en', interpolation: { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5f8dcb6..6983833 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -69,7 +69,18 @@ "procedures": "Procedures", "noRoutines": "No routines found", "refreshRoutines": "Refresh Routines", - "failGetRoutineDefinition": "Failed to get routine definition: " + "objectSummary": "Objects", + "failGetRoutineDefinition": "Failed to get routine definition: ", + "schemas": "Schemas", + "noSchemas": "No schemas found", + "loadingSchemas": "Loading schemas...", + "expandExplorer": "Expand Explorer", + "selectSchemas": "Select Schemas", + "selectSchemasHint": "Select schemas to load:", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "confirmSelection": "Confirm", + "editSchemas": "Edit Schemas" }, "mcp": { "title": "MCP Server Integration", @@ -135,6 +146,7 @@ "auto": "Auto (System)", "english": "English", "italian": "Italiano", + "spanish": "Spanish", "projectStatus": "Project Status", "roadmapDesc": "This project is a Work In Progress (WIP). Core features are stable, but we have big plans.", "support": "Support the Development", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json new file mode 100644 index 0000000..4c86793 --- /dev/null +++ b/src/i18n/locales/es.json @@ -0,0 +1,571 @@ +{ + "common": { + "save": "Guardar", + "close": "Cerrar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "clone": "Clonar", + "connect": "Conectar", + "disconnect": "Desconectar", + "loading": "Cargando...", + "search": "Buscar...", + "error": "Error", + "success": "Éxito" + }, + "sidebar": { + "connections": "Conexiones", + "settings": "Configuración", + "savedQueries": "Consultas Guardadas", + "tables": "Tablas", + "newConsole": "Nueva Consola", + "newVisualQuery": "Nueva Consulta Visual", + "refresh": "Actualizar", + "refreshTables": "Actualizar Tablas", + "refreshViews": "Actualizar Vistas", + "createView": "Crear Nueva Vista", + "views": "Vistas", + "noViews": "No se encontraron vistas", + "editView": "Editar Vista", + "viewDefinition": "Definición de Vista", + "dropView": "Eliminar Vista", + "dropViewConfirm": "¿Estás seguro de que deseas eliminar la vista \"{{view}}\"?", + "failGetViewDefinition": "Error al obtener la definición de la vista: ", + "failDropView": "Error al eliminar la vista: ", + "explorer": "Explorador", + "sqlEditor": "Editor SQL", + "loadingSchema": "Cargando esquema...", + "noSavedQueries": "No hay consultas guardadas", + "noTables": "No se encontraron tablas", + "columns": "columnas", + "keys": "claves", + "foreignKeys": "claves foráneas", + "indexes": "índices", + "deleteColumn": "Eliminar Columna", + "deleteColumnConfirm": "¿Estás seguro de que deseas eliminar la columna \"{{column}}\" de la tabla \"{{table}}\"?\n\nADVERTENCIA: Esto eliminará permanentemente todos los datos de esta columna. Esta acción no se puede deshacer.", + "failDeleteColumn": "Error al eliminar la columna: ", + "failDeleteIndex": "Error al eliminar el índice: ", + "modifyColumn": "Modificar Columna", + "copyName": "Copiar Nombre", + "deleteTable": "Eliminar Tabla", + "deleteTableConfirm": "¿Estás seguro de que deseas eliminar la tabla \"{{table}}\"?", + "failDeleteTable": "Error al eliminar la tabla: ", + "showData": "Mostrar Datos", + "countRows": "Contar Filas", + "viewSchema": "Ver Esquema", + "viewERDiagram": "Ver Diagrama ER", + "generateSQL": "Generar SQL", + "addColumn": "Agregar Columna", + "addIndex": "Agregar Índice", + "deleteIndex": "Eliminar Índice", + "deleteIndexConfirm": "¿Eliminar el índice \"{{name}}\"?", + "addFk": "Agregar Clave Foránea", + "deleteFk": "Eliminar FK", + "deleteFkConfirm": "¿Eliminar la clave foránea \"{{name}}\"?", + "sqliteFkError": "SQLite no soporta la eliminación de FKs mediante ALTER TABLE.", + "mcpServer": "Servidor MCP", + "routines": "Rutinas", + "functions": "Funciones", + "procedures": "Procedimientos", + "noRoutines": "No se encontraron rutinas", + "refreshRoutines": "Actualizar Rutinas", + "objectSummary": "Objetos", + "failGetRoutineDefinition": "Error al obtener la definición de la rutina: ", + "schemas": "Esquemas", + "noSchemas": "No se encontraron esquemas", + "loadingSchemas": "Cargando esquemas...", + "expandExplorer": "Expandir Explorador", + "selectSchemas": "Seleccionar Esquemas", + "selectSchemasHint": "Selecciona los esquemas a cargar:", + "selectAll": "Seleccionar Todo", + "deselectAll": "Deseleccionar Todo", + "confirmSelection": "Confirmar", + "editSchemas": "Editar Esquemas" + }, + "mcp": { + "title": "Integración del Servidor MCP", + "subtitle": "Conecta Tabularis a Claude Desktop, Cursor y más", + "description": "El Model Context Protocol (MCP) permite a los asistentes de IA (como Claude) conectarse a tus herramientas locales. Tabularis expone un servidor MCP que permite a la IA leer el esquema de tu base de datos y ejecutar consultas de forma segura.", + "checking": "Verificando configuración...", + "configPath": "Configuración de Claude Desktop", + "notFound": "Archivo de configuración no encontrado (crear manualmente)", + "installed": "Instalado", + "install": "Instalar Configuración", + "manualConfig": "CONFIGURACIÓN MANUAL", + "manualText": "Agrega esto a tu claude_desktop_config.json manualmente si la instalación automática falla.", + "successTitle": "Éxito", + "successMsg": "¡Configuración instalada correctamente! Reinicia Claude Desktop para aplicar.", + "errorTitle": "Instalación Fallida" + }, + "connections": { + "title": "Conexiones", + "addConnection": "Agregar Conexión", + "noConnections": "No hay conexiones activas", + "createFirst": "Crea tu primera conexión", + "active": "Activa", + "sshEnabled": "Túnel SSH habilitado", + "disconnect": "Desconectar", + "connect": "Conectar", + "edit": "Editar", + "duplicate": "Duplicar", + "delete": "Eliminar", + "clone": "Clonar", + "confirmDelete": "¿Estás seguro de que deseas eliminar esta conexión?", + "deleteTitle": "Confirmar eliminación", + "failConnect": "Error al conectar a {{name}}. Verifica tu configuración o asegúrate de que la base de datos esté en ejecución.", + "failDuplicate": "Error al duplicar la conexión", + "connecting": "Conectando..." + }, + "settings": { + "title": "Configuración", + "general": "General", + "info": "Información", + "dataEditor": "Editor de Datos", + "pageSize": "Tamaño de Página de Resultados (Límite)", + "pageSizeDesc": "Limita el número de filas obtenidas por consulta para prevenir problemas de rendimiento. Establece en 0 para desactivar (no recomendado).", + "rows": "filas", + "appearance": "Apariencia", + "localization": "Localización", + "themeSelection": "Selección de Tema", + "fontFamily": "Familia de Fuente", + "fonts": { + "system": "Sistema", + "systemDesc": "Usa la fuente predeterminada del sistema", + "custom": "Fuente Personalizada", + "customPlaceholder": "ej. Comic Sans MS", + "enterFontName": "Ingresa el nombre de la fuente arriba" + }, + "fontSize": "Tamaño de Fuente", + "fontSizeLabel": "Tamaño de Fuente", + "fontSizeDesc": "Ajusta el tamaño base de fuente usado en la aplicación (10-20px).", + "preview": "Vista Previa", + "fontPreviewText": "El veloz zorro marrón salta sobre el perro perezoso", + "language": "Idioma", + "languageDesc": "Elige tu idioma preferido. 'Auto' usará el idioma del sistema.", + "auto": "Auto (Sistema)", + "english": "English", + "italian": "Italiano", + "spanish": "Español", + "projectStatus": "Estado del Proyecto", + "roadmapDesc": "Este proyecto es un Work In Progress (WIP). Las funciones principales son estables, pero tenemos grandes planes.", + "support": "Apoya el Desarrollo", + "supportDesc": "Si te gusta tabularis y quieres ver más funcionalidades, considera apoyar el proyecto contribuyendo código, reportando errores o dando una estrella en GitHub.", + "version": "Versión", + "starOnGithub": "Estrella en GitHub", + "ai": { + "title": "Configuración de IA", + "description": "Configura los proveedores de IA para habilitar la generación de SQL desde lenguaje natural. Las claves se almacenan de forma segura en el llavero del sistema.", + "enable": "Habilitar Funciones de IA", + "enableDesc": "Mostrar los botones de Asistente IA y Explicar en el editor", + "defaultProvider": "Proveedor Predeterminado", + "defaultModel": "Modelo Predeterminado", + "configuration": "Configuración", + "selectProviderFirst": "Selecciona un proveedor primero", + "modelDesc": "Selecciona el modelo a utilizar para la generación y explicación.", + "manageKeys": "Gestionar Claves API", + "apiKey": "Clave API de {{provider}}", + "configured": "Configurado", + "notConfigured": "No configurado", + "enterKey": "Ingresa la Clave de {{provider}}", + "keyStoredSecurely": "La clave API se almacena de forma segura en el llavero del sistema. Establecer una clave aquí sobrescribe la variable de entorno.", + "fromEnv": "Variable de Entorno", + "fromEnvTooltip": "Esta clave se carga desde una variable de entorno", + "envVariableDetected": "Se detectó una variable de entorno, pero puedes sobrescribirla estableciendo una clave arriba.", + "reset": "Restablecer", + "resetKey": "Eliminar clave personalizada y volver a la variable de entorno (si existe)", + "keyResetSuccess": "Clave personalizada eliminada correctamente", + "systemPrompt": "Prompt del Sistema", + "systemPromptDesc": "Personaliza las instrucciones dadas a la IA. Usa {{SCHEMA}} como marcador para la estructura de la base de datos.", + "enterSystemPrompt": "Ingresa el prompt del sistema...", + "resetDefault": "Restablecer Predeterminado", + "savePrompt": "Guardar Prompt", + "explainPrompt": "Prompt de Explicación", + "explainPromptDesc": "Personaliza las instrucciones para \"Explicar Consulta\". Usa {{LANGUAGE}} como marcador para el idioma de salida (Inglés/Español).", + "enterExplainPrompt": "Ingresa el prompt de explicación...", + "keySaved": "Clave API guardada de forma segura", + "promptSaved": "Prompt del sistema guardado correctamente", + "explainPromptSaved": "Prompt de explicación guardado correctamente", + "promptReset": "Prompt del sistema restablecido al predeterminado", + "explainPromptReset": "Prompt de explicación restablecido al predeterminado", + "modelPlaceholder": "Selecciona un modelo", + "searchPlaceholder": "Buscar modelos...", + "noResults": "No se encontraron modelos", + "refresh": "Actualizar Modelos", + "refreshSuccess": "Modelos de IA actualizados desde los proveedores", + "refreshError": "Error al actualizar los modelos", + "ollamaConnected": "Ollama conectado ({{count}} modelos encontrados)", + "ollamaNotDetected": "Ollama no detectado en el puerto {{port}}. ¿Está en ejecución?", + "ollamaPort": "Puerto de Ollama", + "modelNotFound": "Modelo {{model}} no encontrado en {{provider}}. Puede que no funcione correctamente.", + "customOpenaiEndpoint": "Endpoint Personalizado", + "endpointUrl": "URL del Endpoint", + "endpointUrlDesc": "La URL base de tu API compatible con OpenAI. Ejemplos: https://api.groq.com/openai/v1, http://localhost:8000/v1", + "customOpenaiModelPlaceholder": "ej., llama3-70b-8192, mixtral-8x7b", + "customOpenaiModelDesc": "Ingresa el nombre del modelo proporcionado por tu servicio compatible con OpenAI.", + "customOpenaiModelHelp": "Ingresa el nombre exacto del modelo para tu proveedor compatible con OpenAI." + }, + "updates": "Actualizaciones", + "autoCheckUpdates": "Buscar actualizaciones al inicio", + "autoCheckUpdatesDesc": "Buscar automáticamente nuevas versiones al iniciar la aplicación", + "checkNow": "Buscar Actualizaciones Ahora", + "checking": "Verificando...", + "currentVersion": "Versión Actual", + "logs": "Registros", + "logSettings": "Configuración de Registros", + "enableLogging": "Habilitar Registros", + "enableLoggingDesc": "Recopilar registros de la aplicación en memoria para depuración", + "maxLogEntries": "Máximo de Entradas de Registro", + "maxLogEntriesDesc": "Cuántos registros mantener en memoria (1-10000)", + "currentLogCount": "Registros Actuales", + "clearLogs": "Limpiar Registros", + "clearLogsConfirm": "¿Estás seguro de que deseas borrar todos los registros?", + "exportLogs": "Exportar Registros", + "exportLogsSuccess": "Registros exportados al portapapeles", + "noLogs": "No hay registros disponibles", + "refreshLogs": "Actualizar", + "logLevel": "Nivel", + "logMessage": "Mensaje", + "logTimestamp": "Marca de Tiempo", + "filterByLevel": "Filtrar por nivel", + "allLevels": "Todos los niveles", + "debug": "Debug", + "info": "Info", + "warn": "Warn", + "error": "Error", + "erDiagram": "Diagrama ER", + "erDiagramDefaultLayout": "Diseño Predeterminado", + "erDiagramDefaultLayoutDesc": "Elige la dirección de diseño predeterminada para los diagramas ER" + }, + "update": { + "newVersionAvailable": "Nueva Versión Disponible", + "version": "Versión", + "releaseNotes": "Notas de la Versión", + "downloads": "Descargas", + "download": "Descargar", + "downloadAndInstall": "Descargar e Instalar", + "downloading": "Descargando...", + "installing": "Instalando...", + "installingMessage": "La aplicación se reiniciará automáticamente después de la instalación", + "viewOnGitHub": "Ver en GitHub", + "remindLater": "Recordar Después", + "skipVersion": "Omitir Esta Versión", + "checkingForUpdates": "Buscando actualizaciones...", + "upToDate": "Estás actualizado", + "updateAvailable": "La versión {{version}} está disponible", + "error": "Error de Actualización", + "currentVersion": "Versión actual" + }, + "ai": { + "assist": "Asistente IA", + "explain": "Explicar", + "generateSql": "Generar SQL", + "generating": "Generando...", + "explainQuery": "Explicación de Consulta IA", + "queryLabel": "Consulta", + "explanationLabel": "Explicación", + "generatingExplanation": "Generando explicación...", + "configRequired": "⚠️ Proveedor de IA no configurado. Ve a Configuración > IA.", + "enterPrompt": "Describe tu consulta en lenguaje natural", + "promptPlaceholder": "ej. Encuentra todos los usuarios que se registraron el mes pasado y pidieron un plan 'Premium'...", + "readingSchema": "Leyendo esquema de la base de datos...", + "schemaError": "Error al cargar el contexto del esquema de la base de datos", + "configError": "Configura el proveedor de IA en Configuración e ingresa un prompt." + }, + "newConnection": { + "titleNew": "Nueva Conexión", + "titleEdit": "Editar Conexión", + "subtitle": "Configurar los ajustes de conexión a la base de datos", + "name": "Nombre de Conexión", + "namePlaceholder": "Mi BD de Producción", + "nameRequired": "El nombre de la conexión es requerido", + "dbNameRequired": "El nombre de la base de datos es requerido", + "dbType": "Tipo de Base de Datos", + "host": "Host", + "port": "Puerto", + "username": "Usuario", + "password": "Contraseña", + "passwordMissing": "Contraseña faltante. Por favor, ingrésala de nuevo.", + "passwordPlaceholder": "Ingresa la contraseña", + "filePath": "Ruta del Archivo", + "dbName": "Nombre de la Base de Datos", + "dbNamePlaceholder": "Nombre de la base de datos", + "loadDatabases": "Cargar Bases de Datos", + "loadingDatabases": "Cargando...", + "selectDatabase": "Selecciona una base de datos", + "noDatabasesFound": "No se encontraron bases de datos", + "failLoadDatabases": "Error al cargar las bases de datos. Verifica tus credenciales.", + "filePathPlaceholder": "/ruta/absoluta/db.sqlite", + "useSsh": "Usar Túnel SSH", + "sshHost": "Host SSH", + "sshPort": "Puerto SSH", + "sshUser": "Usuario SSH", + "sshPassword": "Contraseña SSH", + "sshPasswordMissing": "Contraseña SSH faltante. Por favor, ingrésala de nuevo.", + "sshPasswordPlaceholder": "Ingresa la contraseña SSH", + "sshKeyFile": "Archivo de Clave SSH (Opcional)", + "sshKeyFilePlaceholder": "/ruta/a/id_rsa", + "sshKeyPassphrase": "Frase de Paso de Clave SSH (Opcional)", + "sshKeyPassphrasePlaceholder": "Ingresa la frase de paso si la clave está cifrada", + "saveKeychain": "Guardar contraseñas en el Llavero", + "testConnection": "Probar Conexión", + "save": "Guardar", + "failSave": "Error al guardar la conexión", + "selectSshConnection": "Seleccionar Conexión SSH", + "useSshConnection": "Usar Conexión SSH Existente", + "createInlineSsh": "Configurar SSH en Línea", + "manageSshConnections": "Gestionar Conexiones SSH", + "noSshConnections": "No hay conexiones SSH disponibles" + }, + "sshConnections": { + "title": "Conexiones SSH", + "createNew": "Crear Nueva Conexión SSH", + "noConnections": "No hay conexiones SSH configuradas", + "name": "Nombre de Conexión", + "namePlaceholder": "Mi Servidor SSH", + "authType": "Tipo de Autenticación", + "authTypePassword": "Contraseña", + "authTypeSshKey": "Clave SSH", + "edit": "Editar", + "delete": "Eliminar", + "save": "Guardar", + "update": "Actualizar", + "cancel": "Cancelar", + "confirmDelete": "¿Estás seguro de que deseas eliminar esta conexión SSH?", + "failSave": "Error al guardar la conexión SSH", + "failDelete": "Error al eliminar la conexión SSH", + "fillRequired": "Por favor, completa todos los campos requeridos", + "keyFile": "Archivo de clave", + "quickTest": "Prueba rápida de conexión", + "testFailed": "La prueba de conexión falló", + "savedInKeychain": "Contraseña guardada en el llavero del sistema" + }, + "dataGrid": { + "noData": "No hay datos para mostrar", + "editRow": "Editar Fila", + "deleteRow": "Eliminar Fila", + "confirmDelete": "¿Estás seguro de que deseas eliminar esta fila?", + "deleteTitle": "Eliminar Fila", + "updateFailed": "Error en la actualización: ", + "deleteFailed": "Error al eliminar la fila: ", + "null": "null", + "sortByAsc": "Ordenar por {{col}} ASC", + "sortByDesc": "Ordenar por {{col}} DESC", + "clearSort": "Quitar ordenamiento", + "copyCell": "Copiar Celda", + "copyRow": "Copiar Fila", + "copyCells": "Copiar {{count}} Celdas", + "copyRows": "Copiar {{count}} Filas", + "copied": "Copiado al portapapeles" + }, + "editRow": { + "title": "Editar Fila", + "save": "Guardar Cambios", + "cancel": "Cancelar", + "success": "Fila actualizada correctamente", + "failLoad": "Error al cargar el esquema para editar", + "failUpdate": "Error al actualizar la fila: ", + "loading": "Cargando...", + "selectValue": "Seleccionar Valor...", + "noOptions": "No se encontraron opciones", + "current": "Actual" + }, + "newRow": { + "title": "Nueva Fila", + "insert": "Insertar", + "cancel": "Cancelar", + "success": "Fila insertada correctamente", + "failInsert": "Error al insertar la fila: ", + "failLoad": "Error al cargar el esquema: ", + "loading": "Cargando...", + "selectValue": "Seleccionar Valor...", + "noOptions": "No se encontraron opciones", + "autoGenerated": "(Auto-generado)", + "required": "Requerido", + "primaryKey": "Clave Primaria", + "auto": "Auto" + }, + "editor": { + "noTabs": "No hay pestañas abiertas para esta conexión.", + "newConsole": "Nueva Consola", + "noActiveSession": "No hay sesión activa. Selecciona una conexión.", + "stop": "Detener", + "run": "Ejecutar", + "export": "Exportar", + "connected": "Conectado", + "disconnected": "Desconectado", + "newRow": "Nueva Fila", + "editing": "Editando: {{table}}", + "rowsRetrieved": "{{count}} filas obtenidas", + "autoPaginated": "Paginación automática", + "pageOf": "Página {{current}} de {{total}}", + "jumpToPage": "Clic para ir a la página", + "executePrompt": "Ejecuta una consulta para ver resultados", + "closeTab": "Cerrar Pestaña", + "closeOthers": "Cerrar Otras Pestañas", + "closeRight": "Cerrar Pestañas a la Derecha", + "closeLeft": "Cerrar Pestañas a la Izquierda", + "closeAll": "Cerrar Todas las Pestañas", + "saveQuery": "Guardar Consulta", + "saveThisQuery": "Guardar esta consulta", + "noValidQueries": "No se encontraron consultas válidas", + "queryFailed": "La consulta falló.", + "newVisualQuery": "Nueva Consulta Visual", + "submitChanges": "Enviar Cambios", + "rollbackChanges": "Revertir Cambios", + "applyToAll": "Aplicar a todo", + "executingQuery": "Ejecutando consulta...", + "exporting": "Exportando...", + "rowsProcessed": "Filas Procesadas", + "queryParameters": "Parámetros de Consulta", + "convertToConsole": "Convertir a Consola", + "parameters": "Parámetros", + "paramValuePlaceholder": "Valor (ej. 'texto' o 123)" + }, + "createTable": { + "title": "Crear Nueva Tabla", + "tableName": "Nombre de Tabla", + "tableNamePlaceholder": "ej. usuarios, pedidos, productos", + "columns": "Columnas", + "addColumn": "Agregar Columna", + "colName": "Nombre", + "colType": "Tipo", + "colLen": "Long", + "colPk": "PK", + "colNn": "NN", + "colAi": "AI", + "colDefault": "Predeterminado", + "showSql": "Mostrar Vista Previa SQL", + "hideSql": "Ocultar Vista Previa SQL", + "create": "Crear Tabla", + "cancel": "Cancelar", + "nameRequired": "El nombre de la tabla es requerido", + "colRequired": "Se requiere al menos una columna", + "failCreate": "Error al crear la tabla: " + }, + "schema": { + "title": "Esquema: {{table}}", + "loading": "Cargando esquema...", + "colName": "Nombre", + "colType": "Tipo", + "colNullable": "Nullable", + "colKey": "Clave", + "yes": "SÍ", + "no": "NO" + }, + "generateSQL": { + "title": "SQL Generado: {{table}}", + "loading": "Generando SQL...", + "copy": "Copiar SQL", + "copied": "¡Copiado!" + }, + "modifyColumn": { + "titleAdd": "Agregar Columna", + "titleEdit": "Modificar Columna", + "sqliteWarn": "SQLite solo soporta renombrar columnas. Otras modificaciones requieren recrear la tabla manualmente.", + "name": "Nombre", + "type": "Tipo", + "length": "Longitud", + "default": "Valor Predeterminado", + "notNull": "Not Null", + "primaryKey": "Clave Primaria", + "autoInc": "Auto Incremento", + "sqlPreview": "Vista Previa SQL", + "save": "Guardar Cambios", + "add": "Agregar Columna", + "cancel": "Cancelar", + "nameRequired": "El nombre de la columna es requerido", + "fail": "Error: ", + "noChanges": "No se detectaron cambios", + "unsupported": "Driver no soportado" + }, + "createIndex": { + "title": "Crear Índice", + "name": "Nombre del Índice", + "columns": "Columnas", + "unique": "Índice Único", + "sqlPreview": "Vista Previa SQL", + "create": "Crear Índice", + "cancel": "Cancelar", + "nameRequired": "El nombre del índice es requerido", + "colRequired": "Se debe seleccionar al menos una columna" + }, + "createFk": { + "title": "Crear Clave Foránea", + "name": "Nombre FK (Opcional)", + "column": "Columna Local", + "refTable": "Tabla Referenciada", + "refColumn": "Columna Referenciada", + "onDelete": "Al Eliminar", + "onUpdate": "Al Actualizar", + "sqlPreview": "Vista Previa SQL", + "create": "Crear Clave Foránea", + "cancel": "Cancelar", + "colRequired": "Selecciona las columnas locales y referenciadas", + "tableRequired": "Selecciona una tabla referenciada" + }, + "erDiagram": { + "title": "Diagrama ER", + "enterFullscreen": "Pantalla Completa", + "exitFullscreen": "Salir de Pantalla Completa", + "noConnection": "Sin ID de Conexión", + "noConnectionDesc": "No se puede mostrar el diagrama sin un ID de conexión.", + "switchToVertical": "Cambiar a Diseño Vertical", + "switchToHorizontal": "Cambiar a Diseño Horizontal", + "vertical": "Vertical", + "horizontal": "Horizontal", + "showAllTables": "Mostrar Todas las Tablas", + "showAll": "Mostrar Todo", + "focusedOn": "Enfocado en", + "focusOnTable": "Enfocar en Tabla" + }, + "views": { + "createView": "Crear Vista", + "editView": "Editar Vista", + "createSubtitle": "Crear una nueva vista de base de datos", + "editSubtitle": "Editando vista: {{name}}", + "viewName": "Nombre de Vista", + "viewNamePlaceholder": "ej. usuarios_activos, resumen_pedidos", + "viewDefinition": "Definición de Vista (SQL)", + "definitionPlaceholder": "SELECT * FROM ...", + "preview": "Vista Previa", + "runPreview": "Ejecutar Vista Previa", + "previewEmpty": "Haz clic en 'Ejecutar Vista Previa' para ver resultados", + "moreRows": "+{{count}} filas más", + "create": "Crear Vista", + "save": "Guardar Cambios", + "nameRequired": "El nombre de la vista es requerido", + "definitionRequired": "La definición de la vista es requerida", + "failLoadDefinition": "Error al cargar la definición de la vista: ", + "previewError": "Error en la vista previa: ", + "createSuccess": "Vista creada correctamente", + "alterSuccess": "Vista actualizada correctamente", + "saveError": "Error al guardar la vista: ", + "confirmAlter": "¿Estás seguro de que deseas modificar la vista \"{{view}}\"?" + }, + "dump": { + "title": "Exportar Base de Datos", + "dumpDatabase": "Exportar Base de Datos", + "importDatabase": "Ejecutar Archivo SQL...", + "importTitle": "Importar Base de Datos", + "includeStructure": "Estructura (DDL)", + "includeData": "Datos (INSERT)", + "selectTables": "Seleccionar Tablas", + "selectAll": "Seleccionar Todo", + "deselectAll": "Deseleccionar Todo", + "export": "Exportar", + "exporting": "Exportando...", + "success": "Base de datos exportada correctamente", + "failure": "Error en la exportación: ", + "errorNoOption": "Selecciona al menos Estructura o Datos", + "errorNoTables": "Selecciona al menos una tabla", + "importStarted": "Importación iniciada...", + "importSuccess": "Archivo SQL ejecutado correctamente", + "importFailure": "Error en la importación: ", + "importCancelled": "Importación cancelada", + "importFailed": "Importación fallida", + "importingFrom": "Importando desde", + "statementsExecuted": "{{count}} / {{total}} sentencias", + "confirmImport": "¿Estás seguro de que deseas importar \"{{file}}\"?\nEsto podría sobrescribir los datos existentes.", + "elapsedTime": "Tiempo transcurrido" + } +} diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 8406940..6370a52 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -69,7 +69,18 @@ "procedures": "Procedure", "noRoutines": "Nessuna routine trovata", "refreshRoutines": "Aggiorna Routine", - "failGetRoutineDefinition": "Impossibile recuperare la definizione della routine: " + "objectSummary": "Oggetti", + "failGetRoutineDefinition": "Impossibile recuperare la definizione della routine: ", + "schemas": "Schemi", + "noSchemas": "Nessuno schema trovato", + "loadingSchemas": "Caricamento schemi...", + "expandExplorer": "Espandi Explorer", + "selectSchemas": "Seleziona Schemi", + "selectSchemasHint": "Seleziona gli schemi da caricare:", + "selectAll": "Seleziona Tutti", + "deselectAll": "Deseleziona Tutti", + "confirmSelection": "Conferma", + "editSchemas": "Modifica Schemi" }, "mcp": { "title": "Integrazione Server MCP", @@ -134,6 +145,7 @@ "auto": "Auto (Sistema)", "english": "Inglese", "italian": "Italiano", + "spanish": "Spagnolo", "projectStatus": "Stato del Progetto", "roadmapDesc": "Questo progetto è un Work In Progress (WIP). Le funzioni principali sono stabili, ma abbiamo grandi piani.", "support": "Supporta lo Sviluppo", diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index d27ee6f..3603201 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { quoteIdentifier } from "../utils/identifiers"; +import { quoteTableRef } from "../utils/identifiers"; import { generateTempId, initializeNewRow, @@ -73,6 +73,7 @@ interface EditorState { tableName?: string; queryName?: string; preventAutoRun?: boolean; + schema?: string; } interface ExportProgress { @@ -81,7 +82,7 @@ interface ExportProgress { export const Editor = () => { const { t } = useTranslation(); - const { activeConnectionId, tables, activeDriver } = useDatabase(); + const { activeConnectionId, tables, activeDriver, activeSchema } = useDatabase(); const { settings } = useSettings(); const { saveQuery } = useSavedQueries(); const { @@ -150,7 +151,7 @@ export const Editor = () => { if (tab.type === "table" && tab.activeTable) { const filter = tab.filterClause ? `WHERE ${tab.filterClause}` : ""; const sort = tab.sortClause ? `ORDER BY ${tab.sortClause}` : ""; - const quotedTable = quoteIdentifier(tab.activeTable, activeDriver); + const quotedTable = quoteTableRef(tab.activeTable, activeDriver, tab.schema); let baseQuery = `SELECT * FROM ${quotedTable} ${filter} ${sort}`; @@ -309,6 +310,7 @@ export const Editor = () => { const cols = await invoke("get_columns", { connectionId: activeConnectionId, tableName: table, + ...(activeSchema ? { schema: activeSchema } : {}), }); const pk = cols.find((c) => c.is_pk); const autoInc = cols @@ -336,7 +338,7 @@ export const Editor = () => { updateTab(targetId, { pkColumn: null, autoIncrementColumns: [], defaultValueColumns: [], nullableColumns: [] }); } }, - [activeConnectionId, activeTabId, updateTab], + [activeConnectionId, activeTabId, updateTab, activeSchema], ); const stopQuery = useCallback(async () => { @@ -388,7 +390,7 @@ export const Editor = () => { const filter = filterClause ? `WHERE ${filterClause}` : ""; const sort = sortClause ? `ORDER BY ${sortClause}` : ""; - const quotedTable = quoteIdentifier(targetTab.activeTable, activeDriver); + const quotedTable = quoteTableRef(targetTab.activeTable, activeDriver, targetTab.schema); const baseQuery = `SELECT * FROM ${quotedTable} ${filter} ${sort}`; @@ -459,11 +461,13 @@ export const Editor = () => { ? settings.resultPageSize : 100; + const schema = targetTab?.schema ?? activeSchema; const res = await invoke("execute_query", { connectionId: activeConnectionId, query: textToRun, limit: pageSize, page: pageNum, + ...(schema ? { schema } : {}), }); const end = performance.now(); @@ -496,7 +500,7 @@ export const Editor = () => { }); } }, - [activeConnectionId, updateTab, settings.resultPageSize, fetchPkColumn, t, activeDriver], + [activeConnectionId, updateTab, settings.resultPageSize, fetchPkColumn, t, activeDriver, activeSchema], ); const handleRunButton = useCallback(() => { @@ -823,6 +827,7 @@ export const Editor = () => { const columns = await invoke("get_columns", { connectionId: activeConnectionId, tableName: activeTab.activeTable, + ...(activeSchema ? { schema: activeSchema } : {}), }); if (!columns || columns.length === 0) { @@ -911,7 +916,7 @@ export const Editor = () => { kind: "error", }); } - }, [activeConnectionId, activeTab, updateTab, t, settings.resultPageSize]); + }, [activeConnectionId, activeTab, updateTab, t, settings.resultPageSize, activeSchema]); const handleSubmitChanges = useCallback(async () => { if ( @@ -977,6 +982,7 @@ export const Editor = () => { const columns = await invoke("get_columns", { connectionId: activeConnectionId, tableName: activeTable, + ...(activeSchema ? { schema: activeSchema } : {}), }); const selectedDisplayIndices = new Set(); @@ -1043,6 +1049,7 @@ export const Editor = () => { table: activeTable, pkCol: pkColumn, pkVal, + ...(activeSchema ? { schema: activeSchema } : {}), }), ), ); @@ -1059,6 +1066,7 @@ export const Editor = () => { pkVal: u.pkVal, colName: u.colName, newVal: u.newVal, + ...(activeSchema ? { schema: activeSchema } : {}), }), ), ); @@ -1072,6 +1080,7 @@ export const Editor = () => { connectionId: activeConnectionId, table: activeTable, data: insertion.data, + ...(activeSchema ? { schema: activeSchema } : {}), }), ), ); @@ -1131,7 +1140,7 @@ export const Editor = () => { kind: "error", }); } - }, [activeTab, activeConnectionId, updateActiveTab, runQuery, t, applyToAll]); + }, [activeTab, activeConnectionId, updateActiveTab, runQuery, t, applyToAll, activeSchema]); const handleParamsSubmit = useCallback( (values: Record) => { @@ -1279,10 +1288,11 @@ export const Editor = () => { monacoInstance, activeConnectionId, tables, + activeSchema, ); return () => disposable.dispose(); } - }, [monacoInstance, activeConnectionId, tables]); + }, [monacoInstance, activeConnectionId, tables, activeSchema]); useEffect(() => { const state = location.state as EditorState; @@ -1293,13 +1303,14 @@ export const Editor = () => { if (processingRef.current === queryKey) return; processingRef.current = queryKey; - const { initialQuery: sql, tableName: table, queryName, preventAutoRun } = state; + const { initialQuery: sql, tableName: table, queryName, preventAutoRun, schema: navSchema } = state; const tabId = addTab({ type: table ? "table" : "console", title: queryName || table || (table ? table : t("sidebar.newConsole")), query: sql, activeTable: table, + schema: navSchema, }); if (tabId && !preventAutoRun) { @@ -1390,7 +1401,7 @@ export const Editor = () => { const sort = activeTab.sortClause ? `ORDER BY ${activeTab.sortClause}` : ""; - const quotedTable = quoteIdentifier(activeTab.activeTable, activeDriver); + const quotedTable = quoteTableRef(activeTab.activeTable, activeDriver, activeTab.schema); // Base query let baseQuery = `SELECT * FROM ${quotedTable} ${filter} ${sort}`; diff --git a/src/pages/SchemaDiagramPage.tsx b/src/pages/SchemaDiagramPage.tsx index 43a3b26..618e5a4 100644 --- a/src/pages/SchemaDiagramPage.tsx +++ b/src/pages/SchemaDiagramPage.tsx @@ -14,6 +14,7 @@ export const SchemaDiagramPage = () => { const connectionId = searchParams.get('connectionId'); const connectionName = searchParams.get('connectionName') || 'Unknown'; const databaseName = searchParams.get('databaseName') || 'Unknown'; + const schema = searchParams.get('schema') || undefined; const toggleFullscreen = () => { if (!document.fullscreenElement) { @@ -64,7 +65,7 @@ export const SchemaDiagramPage = () => { {/* Minimal Header */}

- {databaseName} ({connectionName}) + {databaseName}{schema ? ` / ${schema}` : ''} ({connectionName})

diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ca52a59..65251e1 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -503,6 +503,7 @@ export const Settings = () => { { id: "auto", label: t("settings.auto") }, { id: "en", label: t("settings.english") }, { id: "it", label: t("settings.italian") }, + { id: "es", label: t("settings.spanish") }, ]; const providers: Array<{ id: AiProvider; label: string }> = [ diff --git a/src/types/editor.ts b/src/types/editor.ts index 54f7b8f..f15144a 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -82,6 +82,7 @@ export interface Tab { sortClause?: string; // SQL ORDER BY clause (without "ORDER BY") limitClause?: number; // SQL LIMIT value queryParams?: Record; // Saved values for query parameters + schema?: string; // Schema name (PostgreSQL) for query reconstruction } export interface EditorPreferences { diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts index e35a193..99d398e 100644 --- a/src/types/sidebar.ts +++ b/src/types/sidebar.ts @@ -1,4 +1,4 @@ import type { SavedQuery } from "../contexts/SavedQueriesContext"; import type { RoutineInfo } from "../contexts/DatabaseContext"; -export type ContextMenuData = SavedQuery | { tableName: string } | RoutineInfo; +export type ContextMenuData = SavedQuery | { tableName: string; schema?: string } | RoutineInfo; diff --git a/src/utils/autocomplete.ts b/src/utils/autocomplete.ts index e665e27..f60dea4 100644 --- a/src/utils/autocomplete.ts +++ b/src/utils/autocomplete.ts @@ -45,12 +45,12 @@ const cleanupCache = () => { } }; -const getTableColumns = async (connectionId: string, tableName: string) => { +const getTableColumns = async (connectionId: string, tableName: string, schema?: string | null) => { if (!connectionId || !tableName) return []; - const cacheKey = `${connectionId}:${tableName}`; + const cacheKey = schema ? `${connectionId}:${schema}:${tableName}` : `${connectionId}:${tableName}`; const cached = columnsCache.get(cacheKey); - + // Return cached data if valid if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) { return cached.data; @@ -60,6 +60,7 @@ const getTableColumns = async (connectionId: string, tableName: string) => { const cols = await invoke>("get_columns", { connectionId, tableName, + ...(schema ? { schema } : {}), }); if (!Array.isArray(cols)) { @@ -100,7 +101,8 @@ export const clearAutocompleteCache = (connectionId?: string) => { export const registerSqlAutocomplete = ( monaco: Monaco, connectionId: string | null, - tables: TableInfo[] + tables: TableInfo[], + schema?: string | null, ) => { const provider = monaco.languages.registerCompletionItemProvider("sql", { triggerCharacters: [".", " ", "\n"], @@ -147,7 +149,7 @@ export const registerSqlAutocomplete = ( } if (actualTableName) { - const columns = await getTableColumns(connectionId, actualTableName); + const columns = await getTableColumns(connectionId, actualTableName, schema); // Calculate range for column name after dot const columnRange = { @@ -196,7 +198,7 @@ export const registerSqlAutocomplete = ( } const results = await Promise.all( - matchingTables.map(t => getTableColumns(connectionId, t.name)) + matchingTables.map(t => getTableColumns(connectionId, t.name, schema)) ); const seenColumns = new Set(); diff --git a/src/utils/editor.ts b/src/utils/editor.ts index 64c3ab9..9ced020 100644 --- a/src/utils/editor.ts +++ b/src/utils/editor.ts @@ -4,7 +4,7 @@ import type { TableSchema, EditorPreferences, } from "../types/editor"; -import { quoteIdentifier } from "./identifiers"; +import { quoteTableRef } from "./identifiers"; import { invoke } from "@tauri-apps/api/core"; import { cleanTabForStorage, restoreTabFromStorage } from "./tabCleaner"; import { @@ -140,13 +140,15 @@ export function findExistingTableTab( tabs: Tab[], connectionId: string, tableName: string | undefined, + schema?: string, ): Tab | undefined { if (!tableName) return undefined; return tabs.find( (t) => t.connectionId === connectionId && t.type === "table" && - t.activeTable === tableName, + t.activeTable === tableName && + (t.schema || undefined) === (schema || undefined), ); } @@ -341,7 +343,7 @@ export function reconstructTableQuery(tab: Tab, driver?: string): string { const filter = tab.filterClause ? `WHERE ${tab.filterClause}` : ""; const sort = tab.sortClause ? `ORDER BY ${tab.sortClause}` : ""; - const quotedTable = quoteIdentifier(tab.activeTable, driver); + const quotedTable = quoteTableRef(tab.activeTable, driver, tab.schema); let baseQuery = `SELECT * FROM ${quotedTable} ${filter} ${sort}`.trim(); diff --git a/src/utils/identifiers.ts b/src/utils/identifiers.ts index 9c4a1a1..45609b1 100644 --- a/src/utils/identifiers.ts +++ b/src/utils/identifiers.ts @@ -20,5 +20,22 @@ export function getQuoteChar(driver: string | null | undefined): string { */ export function quoteIdentifier(identifier: string, driver: string | null | undefined): string { const quote = getQuoteChar(driver); - return `${quote}${identifier}${quote}`; + const escaped = quote === "`" ? identifier.replace(/`/g, "``") : identifier.replace(/"/g, '""'); + return `${quote}${escaped}${quote}`; +} + +/** + * Returns a schema-qualified, quoted table reference for use in SQL queries. + * When a schema is provided, returns "schema"."table" (or `schema`.`table` for MySQL). + * Otherwise returns just the quoted table name. + */ +export function quoteTableRef( + table: string, + driver: string | null | undefined, + schema?: string | null, +): string { + if (schema) { + return `${quoteIdentifier(schema, driver)}.${quoteIdentifier(table, driver)}`; + } + return quoteIdentifier(table, driver); } diff --git a/src/utils/schema.ts b/src/utils/schema.ts new file mode 100644 index 0000000..e41237f --- /dev/null +++ b/src/utils/schema.ts @@ -0,0 +1,37 @@ +/** + * Formats the count of database objects (tables, views, routines) into a compact summary string. + * + * @example formatObjectCount(3, 2, 1) // "3T / 2V / 1R" + */ +export function formatObjectCount( + tables: number, + views: number, + routines: number, +): string { + return `${tables}T / ${views}V / ${routines}R`; +} + +/** + * Filters a list of saved/selected schema names against the schemas actually + * available in the current database, removing any stale entries. + */ +export function filterValidSchemas( + saved: string[], + available: string[], +): string[] { + const availableSet = new Set(available); + return saved.filter((s) => availableSet.has(s)); +} + +/** + * Returns a sensible default schema from a list of available schemas. + * Prefers "public" (the PostgreSQL default) when present; otherwise returns the first entry. + * Returns undefined when the list is empty. + */ +export function getDefaultSchema( + schemas: string[], +): string | undefined { + if (schemas.length === 0) return undefined; + if (schemas.includes("public")) return "public"; + return schemas[0]; +} diff --git a/tests/contexts/DatabaseProvider.test.tsx b/tests/contexts/DatabaseProvider.test.tsx index 848940d..f4cf160 100644 --- a/tests/contexts/DatabaseProvider.test.tsx +++ b/tests/contexts/DatabaseProvider.test.tsx @@ -335,4 +335,213 @@ describe('DatabaseProvider', () => { expect(result.current.views).toHaveLength(0); }); }); + + describe('PostgreSQL Schema Selection', () => { + const mockPgConnections = [ + { + id: 'pg-conn', + name: 'Local Postgres', + params: { + driver: 'postgres', + host: 'localhost', + database: 'mydb', + }, + }, + ]; + + const mockSchemas = ['public', 'analytics', 'staging']; + const mockPgTables = [{ name: 'users' }, { name: 'orders' }]; + const mockPgViews = [{ name: 'active_users' }]; + const mockPgRoutines = [{ name: 'calc', routine_type: 'FUNCTION' }]; + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(DatabaseProvider, null, children); + + it('should set needsSchemaSelection=true when no saved selection exists', async () => { + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === 'get_connections') return Promise.resolve(mockPgConnections); + if (cmd === 'get_schemas') return Promise.resolve(mockSchemas); + if (cmd === 'get_selected_schemas') return Promise.resolve([]); + if (cmd === 'set_window_title') return Promise.resolve(undefined); + return Promise.resolve([]); + }); + + const { result } = renderHook(() => useDatabase(), { wrapper }); + + await act(async () => { + await result.current.connect('pg-conn'); + }); + + await waitFor(() => { + expect(result.current.needsSchemaSelection).toBe(true); + expect(result.current.selectedSchemas).toEqual([]); + }); + + // Should NOT load tables/views when no schemas selected + expect(invoke).not.toHaveBeenCalledWith('get_tables', expect.objectContaining({ connectionId: 'pg-conn' })); + }); + + it('should load only selected schemas when saved selection exists', async () => { + vi.mocked(invoke).mockImplementation((cmd: string, args?: Record) => { + if (cmd === 'get_connections') return Promise.resolve(mockPgConnections); + if (cmd === 'get_schemas') return Promise.resolve(mockSchemas); + if (cmd === 'get_selected_schemas') return Promise.resolve(['public']); + if (cmd === 'get_schema_preference') return Promise.resolve('public'); + if (cmd === 'get_tables') return Promise.resolve(mockPgTables); + if (cmd === 'get_views') return Promise.resolve(mockPgViews); + if (cmd === 'get_routines') return Promise.resolve(mockPgRoutines); + if (cmd === 'set_window_title') return Promise.resolve(undefined); + return Promise.resolve(undefined); + }); + + const { result } = renderHook(() => useDatabase(), { wrapper }); + + await act(async () => { + await result.current.connect('pg-conn'); + }); + + await waitFor(() => { + expect(result.current.needsSchemaSelection).toBe(false); + expect(result.current.selectedSchemas).toEqual(['public']); + expect(result.current.activeSchema).toBe('public'); + }); + + // Should have loaded tables for 'public' schema + expect(invoke).toHaveBeenCalledWith('get_tables', { connectionId: 'pg-conn', schema: 'public' }); + }); + + it('should persist and load data when setSelectedSchemas is called', async () => { + // Start with no saved selection (needsSchemaSelection=true) + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === 'get_connections') return Promise.resolve(mockPgConnections); + if (cmd === 'get_schemas') return Promise.resolve(mockSchemas); + if (cmd === 'get_selected_schemas') return Promise.resolve([]); + if (cmd === 'set_selected_schemas') return Promise.resolve(undefined); + if (cmd === 'set_schema_preference') return Promise.resolve(undefined); + if (cmd === 'set_window_title') return Promise.resolve(undefined); + if (cmd === 'get_tables') return Promise.resolve(mockPgTables); + if (cmd === 'get_views') return Promise.resolve(mockPgViews); + if (cmd === 'get_routines') return Promise.resolve(mockPgRoutines); + return Promise.resolve(undefined); + }); + + const { result } = renderHook(() => useDatabase(), { wrapper }); + + await act(async () => { + await result.current.connect('pg-conn'); + }); + + await waitFor(() => { + expect(result.current.needsSchemaSelection).toBe(true); + }); + + // User selects schemas + await act(async () => { + await result.current.setSelectedSchemas(['public', 'analytics']); + }); + + await waitFor(() => { + expect(result.current.needsSchemaSelection).toBe(false); + expect(result.current.selectedSchemas).toEqual(['public', 'analytics']); + }); + + // Should have persisted + expect(invoke).toHaveBeenCalledWith('set_selected_schemas', { + connectionId: 'pg-conn', + schemas: ['public', 'analytics'], + }); + }); + + it('should update activeSchema when removed from selection', async () => { + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === 'get_connections') return Promise.resolve(mockPgConnections); + if (cmd === 'get_schemas') return Promise.resolve(mockSchemas); + if (cmd === 'get_selected_schemas') return Promise.resolve(['public', 'analytics']); + if (cmd === 'get_schema_preference') return Promise.resolve('analytics'); + if (cmd === 'set_selected_schemas') return Promise.resolve(undefined); + if (cmd === 'set_schema_preference') return Promise.resolve(undefined); + if (cmd === 'set_window_title') return Promise.resolve(undefined); + if (cmd === 'get_tables') return Promise.resolve(mockPgTables); + if (cmd === 'get_views') return Promise.resolve(mockPgViews); + if (cmd === 'get_routines') return Promise.resolve(mockPgRoutines); + return Promise.resolve(undefined); + }); + + const { result } = renderHook(() => useDatabase(), { wrapper }); + + await act(async () => { + await result.current.connect('pg-conn'); + }); + + await waitFor(() => { + expect(result.current.activeSchema).toBe('analytics'); + }); + + // Remove analytics from selection + await act(async () => { + await result.current.setSelectedSchemas(['public']); + }); + + await waitFor(() => { + expect(result.current.activeSchema).toBe('public'); + }); + }); + + it('should reset schema selection state on disconnect', async () => { + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === 'get_connections') return Promise.resolve(mockPgConnections); + if (cmd === 'get_schemas') return Promise.resolve(mockSchemas); + if (cmd === 'get_selected_schemas') return Promise.resolve(['public']); + if (cmd === 'get_schema_preference') return Promise.resolve('public'); + if (cmd === 'set_window_title') return Promise.resolve(undefined); + if (cmd === 'get_tables') return Promise.resolve(mockPgTables); + if (cmd === 'get_views') return Promise.resolve(mockPgViews); + if (cmd === 'get_routines') return Promise.resolve(mockPgRoutines); + return Promise.resolve(undefined); + }); + + const { result } = renderHook(() => useDatabase(), { wrapper }); + + await act(async () => { + await result.current.connect('pg-conn'); + }); + + await waitFor(() => { + expect(result.current.selectedSchemas).toEqual(['public']); + }); + + act(() => { + result.current.disconnect(); + }); + + expect(result.current.selectedSchemas).toEqual([]); + expect(result.current.needsSchemaSelection).toBe(false); + }); + + it('should filter saved selection against available schemas', async () => { + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === 'get_connections') return Promise.resolve(mockPgConnections); + if (cmd === 'get_schemas') return Promise.resolve(['public', 'analytics']); // 'deleted_schema' not available + if (cmd === 'get_selected_schemas') return Promise.resolve(['public', 'deleted_schema']); // saved has stale entry + if (cmd === 'get_schema_preference') return Promise.resolve('public'); + if (cmd === 'set_window_title') return Promise.resolve(undefined); + if (cmd === 'get_tables') return Promise.resolve(mockPgTables); + if (cmd === 'get_views') return Promise.resolve(mockPgViews); + if (cmd === 'get_routines') return Promise.resolve(mockPgRoutines); + return Promise.resolve(undefined); + }); + + const { result } = renderHook(() => useDatabase(), { wrapper }); + + await act(async () => { + await result.current.connect('pg-conn'); + }); + + await waitFor(() => { + // Only 'public' should remain (deleted_schema filtered out) + expect(result.current.selectedSchemas).toEqual(['public']); + expect(result.current.needsSchemaSelection).toBe(false); + }); + }); + }); }); diff --git a/tests/utils/identifiers.test.ts b/tests/utils/identifiers.test.ts new file mode 100644 index 0000000..306cfc3 --- /dev/null +++ b/tests/utils/identifiers.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { + getQuoteChar, + quoteIdentifier, + quoteTableRef, +} from '../../src/utils/identifiers'; + +describe('getQuoteChar', () => { + it('should return backtick for mysql', () => { + expect(getQuoteChar('mysql')).toBe('`'); + }); + + it('should return backtick for mariadb', () => { + expect(getQuoteChar('mariadb')).toBe('`'); + }); + + it('should return double quote for postgres', () => { + expect(getQuoteChar('postgres')).toBe('"'); + }); + + it('should return double quote for sqlite', () => { + expect(getQuoteChar('sqlite')).toBe('"'); + }); + + it('should return double quote for null driver', () => { + expect(getQuoteChar(null)).toBe('"'); + }); + + it('should return double quote for undefined driver', () => { + expect(getQuoteChar(undefined)).toBe('"'); + }); + + it('should return double quote for unknown driver', () => { + expect(getQuoteChar('oracle')).toBe('"'); + }); +}); + +describe('quoteIdentifier', () => { + it('should quote with backticks for mysql', () => { + expect(quoteIdentifier('my_table', 'mysql')).toBe('`my_table`'); + }); + + it('should quote with double quotes for postgres', () => { + expect(quoteIdentifier('my_table', 'postgres')).toBe('"my_table"'); + }); + + it('should quote with double quotes for sqlite', () => { + expect(quoteIdentifier('my_table', 'sqlite')).toBe('"my_table"'); + }); + + it('should escape backticks inside mysql identifiers', () => { + expect(quoteIdentifier('my`table', 'mysql')).toBe('`my``table`'); + }); + + it('should escape double quotes inside postgres identifiers', () => { + expect(quoteIdentifier('my"table', 'postgres')).toBe('"my""table"'); + }); + + it('should handle empty string', () => { + expect(quoteIdentifier('', 'mysql')).toBe('``'); + expect(quoteIdentifier('', 'postgres')).toBe('""'); + }); + + it('should handle identifiers with spaces', () => { + expect(quoteIdentifier('my table', 'mysql')).toBe('`my table`'); + expect(quoteIdentifier('my table', 'postgres')).toBe('"my table"'); + }); + + it('should handle identifiers with special characters', () => { + expect(quoteIdentifier('table-name.v2', 'postgres')).toBe('"table-name.v2"'); + }); +}); + +describe('quoteTableRef', () => { + it('should return just quoted table when no schema', () => { + expect(quoteTableRef('users', 'postgres')).toBe('"users"'); + }); + + it('should return schema-qualified reference when schema is provided', () => { + expect(quoteTableRef('users', 'postgres', 'public')).toBe('"public"."users"'); + }); + + it('should use backticks for mysql schema-qualified reference', () => { + expect(quoteTableRef('users', 'mysql', 'mydb')).toBe('`mydb`.`users`'); + }); + + it('should return just quoted table when schema is null', () => { + expect(quoteTableRef('users', 'postgres', null)).toBe('"users"'); + }); + + it('should return just quoted table when schema is undefined', () => { + expect(quoteTableRef('users', 'postgres', undefined)).toBe('"users"'); + }); + + it('should return just quoted table when schema is empty string', () => { + expect(quoteTableRef('users', 'postgres', '')).toBe('"users"'); + }); + + it('should escape special chars in both schema and table', () => { + expect(quoteTableRef('my"table', 'postgres', 'my"schema')).toBe('"my""schema"."my""table"'); + }); +}); diff --git a/tests/utils/schema.test.ts b/tests/utils/schema.test.ts new file mode 100644 index 0000000..4e67dcc --- /dev/null +++ b/tests/utils/schema.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { + formatObjectCount, + filterValidSchemas, + getDefaultSchema, +} from '../../src/utils/schema'; + +describe('formatObjectCount', () => { + it('should format counts with proper labels', () => { + expect(formatObjectCount(3, 2, 1)).toBe('3T / 2V / 1R'); + }); + + it('should handle zero counts', () => { + expect(formatObjectCount(0, 0, 0)).toBe('0T / 0V / 0R'); + }); + + it('should handle large numbers', () => { + expect(formatObjectCount(100, 50, 25)).toBe('100T / 50V / 25R'); + }); + + it('should handle mixed zero and non-zero', () => { + expect(formatObjectCount(5, 0, 3)).toBe('5T / 0V / 3R'); + }); +}); + +describe('filterValidSchemas', () => { + it('should keep only schemas that exist in available list', () => { + const saved = ['public', 'analytics', 'stale_schema']; + const available = ['public', 'analytics', 'audit']; + expect(filterValidSchemas(saved, available)).toEqual(['public', 'analytics']); + }); + + it('should return empty array when no saved schemas are available', () => { + expect(filterValidSchemas(['old1', 'old2'], ['new1', 'new2'])).toEqual([]); + }); + + it('should return empty array when saved list is empty', () => { + expect(filterValidSchemas([], ['public', 'analytics'])).toEqual([]); + }); + + it('should return empty array when available list is empty', () => { + expect(filterValidSchemas(['public'], [])).toEqual([]); + }); + + it('should preserve order of saved schemas', () => { + const saved = ['z_schema', 'a_schema', 'm_schema']; + const available = ['a_schema', 'm_schema', 'z_schema']; + expect(filterValidSchemas(saved, available)).toEqual(['z_schema', 'a_schema', 'm_schema']); + }); +}); + +describe('getDefaultSchema', () => { + it('should return "public" when available', () => { + expect(getDefaultSchema(['analytics', 'public', 'audit'])).toBe('public'); + }); + + it('should return first schema when "public" is not available', () => { + expect(getDefaultSchema(['analytics', 'audit'])).toBe('analytics'); + }); + + it('should return undefined for empty list', () => { + expect(getDefaultSchema([])).toBeUndefined(); + }); + + it('should return "public" even when it is the only schema', () => { + expect(getDefaultSchema(['public'])).toBe('public'); + }); + + it('should return first schema for single non-public schema', () => { + expect(getDefaultSchema(['custom'])).toBe('custom'); + }); +});