diff --git a/.claude/skills/tabularis-plugin-driver/SKILL.md b/.claude/skills/tabularis-plugin-driver/SKILL.md index ae704095..17b18240 100644 --- a/.claude/skills/tabularis-plugin-driver/SKILL.md +++ b/.claude/skills/tabularis-plugin-driver/SKILL.md @@ -168,6 +168,27 @@ Prioritize these methods: If a method is not safe to emulate, fail explicitly instead of faking success. +#### Optional routine-management methods + +When the manifest declares `"routine_management": true`, the host shows +run / create / edit / drop actions for routines. The backing RPC methods are +**optional**: if the plugin does not implement one, the host answers the +JSON-RPC "method not found" error (-32601) with a dialect-neutral fallback +(`CALL name(args)` / `SELECT name(args)`, generic `DROP`, the raw definition +as edit script). Implement only what the dialect needs: + +- `build_routine_call_sql({ params, routine_name, routine_type, args, schema }) -> string` — + `args` is an ordered list of `{ name, mode, value: string|null, is_raw: bool }`; + return an executable invocation script (it is opened in the editor, so + multi-statement scripts are fine — e.g. MySQL's `SET @out` / `CALL` / `SELECT @out`). +- `routine_create_template({ routine_type, schema }) -> string` — starter + script for a new PROCEDURE/FUNCTION in the plugin's dialect. +- `get_routine_edit_script({ params, routine_name, routine_type, schema }) -> string` — + re-runnable script to alter the routine (e.g. `DROP` + `CREATE`, or + `CREATE OR REPLACE`). +- `drop_routine({ params, routine_name, routine_type, schema }) -> null` — + drop the routine; resolve overloads yourself if the dialect has them. + ### 5. Add settings only when they solve a real problem Typical useful settings: diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json index e5ad31e1..d9f0fbab 100644 --- a/plugins/manifest.schema.json +++ b/plugins/manifest.schema.json @@ -75,6 +75,11 @@ "type": "boolean", "description": "true to enable stored procedures and functions in the database explorer." }, + "routine_management": { + "type": "boolean", + "default": false, + "description": "true to enable routine management actions (run with parameters, create from template, edit, drop). The backing RPCs (build_routine_call_sql, routine_create_template, get_routine_edit_script, drop_routine) are optional: the host falls back to dialect-neutral SQL when the plugin does not implement them." + }, "triggers": { "type": "boolean", "default": false, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 959996a8..00d62d81 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -564,6 +564,87 @@ pub async fn get_routine_definition( .await } +#[tauri::command] +pub async fn build_routine_call_sql( + app: AppHandle, + connection_id: String, + routine_name: String, + routine_type: String, + args: Vec, + 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 expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + drv.build_routine_call_sql( + ¶ms, + &routine_name, + &routine_type, + &args, + schema.as_deref(), + ) + .await +} + +#[tauri::command] +pub async fn get_routine_create_template( + app: AppHandle, + connection_id: String, + routine_type: String, + schema: Option, +) -> Result { + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let drv = driver_for(&saved_conn.params.driver).await?; + drv.routine_create_template(&routine_type, schema.as_deref()) + .await +} + +#[tauri::command] +pub async fn get_routine_edit_script( + app: AppHandle, + connection_id: String, + routine_name: String, + routine_type: String, + 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 expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_routine_edit_script(¶ms, &routine_name, &routine_type, schema.as_deref()) + .await +} + +#[tauri::command] +pub async fn drop_routine( + app: AppHandle, + connection_id: String, + routine_name: String, + routine_type: String, + schema: Option, +) -> Result<(), String> { + log::info!( + "Dropping routine: {} ({}) on connection: {}", + routine_name, + routine_type, + 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 expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + drv.drop_routine(¶ms, &routine_name, &routine_type, schema.as_deref()) + .await +} + #[tauri::command] pub async fn get_schema_snapshot( app: AppHandle, diff --git a/src-tauri/src/drivers/common.rs b/src-tauri/src/drivers/common.rs index 281659fb..4dcee5d8 100644 --- a/src-tauri/src/drivers/common.rs +++ b/src-tauri/src/drivers/common.rs @@ -1,5 +1,6 @@ mod blob; mod query; +mod routines; mod safe_int; #[cfg(test)] @@ -14,6 +15,9 @@ pub use query::{ is_explainable_query, is_select_query, returns_result_set, strip_leading_sql_comments, strip_limit_offset, }; +pub use routines::{ + generic_drop_routine_sql, generic_routine_call_sql, quote_qualified, render_sql_literal, +}; pub use safe_int::{ i64_to_json, parse_unsafe_bigint_string, u64_to_json, JS_MAX_SAFE_INTEGER, JS_MAX_SAFE_UINT, }; diff --git a/src-tauri/src/drivers/common/routines.rs b/src-tauri/src/drivers/common/routines.rs new file mode 100644 index 00000000..b5880c5f --- /dev/null +++ b/src-tauri/src/drivers/common/routines.rs @@ -0,0 +1,74 @@ +//! Dialect-neutral SQL builders for stored-routine management. +//! +//! These back the `DatabaseDriver` trait defaults and the `RpcDriver` +//! fallback for plugins that do not implement the optional routine-management +//! RPC methods. Built-in drivers override them with dialect-aware variants. + +use crate::models::RoutineCallArg; + +/// Renders one argument value as a SQL literal: `NULL` when absent, verbatim +/// when marked raw (numbers, expressions), otherwise a single-quoted string +/// with embedded quotes doubled — the only escaping form every SQL dialect +/// shares. +pub fn render_sql_literal(arg: &RoutineCallArg) -> String { + match &arg.value { + None => "NULL".to_string(), + Some(v) if arg.is_raw => v.clone(), + Some(v) => format!("'{}'", v.replace('\'', "''")), + } +} + +/// Quotes one identifier with the driver's quote character (doubling embedded +/// quote characters), optionally schema-qualified. +pub fn quote_qualified(name: &str, schema: Option<&str>, quote: &str) -> String { + let q = |part: &str| { + if quote.is_empty() { + part.to_string() + } else { + format!("{q}{}{q}", part.replace(quote, "e.repeat(2)), q = quote) + } + }; + match schema { + Some(s) if !s.is_empty() => format!("{}.{}", q(s), q(name)), + _ => q(name), + } +} + +/// Generic invocation script: `CALL proc(args);` for procedures and +/// `SELECT fn(args);` for functions. Dialects with richer conventions +/// (MySQL OUT variables, PostgreSQL set-returning functions) override this. +pub fn generic_routine_call_sql( + routine_name: &str, + routine_type: &str, + args: &[RoutineCallArg], + schema: Option<&str>, + quote: &str, +) -> String { + let name = quote_qualified(routine_name, schema, quote); + let rendered: Vec = args.iter().map(render_sql_literal).collect(); + let arg_list = rendered.join(", "); + if routine_type.eq_ignore_ascii_case("FUNCTION") { + format!("SELECT {}({}) AS result;", name, arg_list) + } else { + format!("CALL {}({});", name, arg_list) + } +} + +/// Generic `DROP PROCEDURE|FUNCTION` statement. +pub fn generic_drop_routine_sql( + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + quote: &str, +) -> String { + let keyword = if routine_type.eq_ignore_ascii_case("FUNCTION") { + "FUNCTION" + } else { + "PROCEDURE" + }; + format!( + "DROP {} {}", + keyword, + quote_qualified(routine_name, schema, quote) + ) +} diff --git a/src-tauri/src/drivers/common/tests.rs b/src-tauri/src/drivers/common/tests.rs index 0b0d8695..8b54fe9e 100644 --- a/src-tauri/src/drivers/common/tests.rs +++ b/src-tauri/src/drivers/common/tests.rs @@ -712,3 +712,47 @@ fn test_parse_unsafe_bigint_string_ignores_non_integer_strings() { // driver-level cast still handles them via implicit conversion. assert_eq!(parse_unsafe_bigint_string("18446744073709551615"), None); } + +mod routine_builders { + use super::super::routines::{ + generic_drop_routine_sql, generic_routine_call_sql, quote_qualified, render_sql_literal, + }; + use crate::models::RoutineCallArg; + + fn arg(value: Option<&str>, is_raw: bool) -> RoutineCallArg { + RoutineCallArg { + name: "p".to_string(), + mode: "IN".to_string(), + value: value.map(|v| v.to_string()), + is_raw, + } + } + + #[test] + fn literal_null_raw_and_quoted() { + assert_eq!(render_sql_literal(&arg(None, false)), "NULL"); + assert_eq!(render_sql_literal(&arg(Some("42"), true)), "42"); + assert_eq!(render_sql_literal(&arg(Some("a'b"), false)), "'a''b'"); + } + + #[test] + fn quote_qualified_handles_schema_and_embedded_quotes() { + assert_eq!(quote_qualified("fn", None, "\""), "\"fn\""); + assert_eq!(quote_qualified("fn", Some("s"), "\""), "\"s\".\"fn\""); + assert_eq!(quote_qualified("a\"b", None, "\""), "\"a\"\"b\""); + assert_eq!(quote_qualified("fn", Some(""), "\""), "\"fn\""); + assert_eq!(quote_qualified("fn", None, ""), "fn"); + } + + #[test] + fn generic_call_and_drop() { + let sql = generic_routine_call_sql("p", "PROCEDURE", &[arg(Some("x"), false)], None, "\""); + assert_eq!(sql, "CALL \"p\"('x');"); + let sql = generic_routine_call_sql("f", "function", &[], Some("s"), "\""); + assert_eq!(sql, "SELECT \"s\".\"f\"() AS result;"); + assert_eq!( + generic_drop_routine_sql("f", "FUNCTION", Some("s"), "\""), + "DROP FUNCTION \"s\".\"f\"" + ); + } +} diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index 4eccf61a..b73afabb 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -8,7 +8,8 @@ use std::str::FromStr; use crate::models::{ BatchStatementResult, ColumnDefinition, ConnectionParams, DataTypeInfo, ExplainPlan, - ForeignKey, Index, QueryResult, RoutineInfo, RoutineParameter, TableColumn, TableInfo, + ForeignKey, Index, QueryResult, RoutineCallArg, RoutineInfo, RoutineParameter, TableColumn, + TableInfo, TableSchema, TriggerInfo, ViewInfo, }; @@ -113,6 +114,11 @@ pub struct DriverCapabilities { /// Supports listing and managing database triggers. #[serde(default)] pub triggers: bool, + /// Supports managing stored routines (run with parameters, create from + /// template, edit definition, drop). Requires `routines` to be useful; + /// plugins opt in via their manifest. Defaults to `false`. + #[serde(default, alias = "routineManagement")] + pub routine_management: bool, /// Supports the SSL/TLS configuration tab (mode + CA/client cert/key) in the /// connection modal. Built-in network drivers set this; plugins opt in via /// their manifest. Defaults to `false`. @@ -404,6 +410,87 @@ pub trait DatabaseDriver: Send + Sync { schema: Option<&str>, ) -> Result; + // --- Routine management (gated by `DriverCapabilities::routine_management`) + + /// Builds an executable invocation script for a routine from the + /// argument values collected in the run-routine UI. The script is opened + /// in an editor tab so the user can review it before running. + /// + /// The default covers the common shape (`CALL proc(...)` / + /// `SELECT fn(...)`); dialects with richer conventions (MySQL `OUT` + /// session variables, PostgreSQL set-returning functions) override it. + async fn build_routine_call_sql( + &self, + _params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + args: &[RoutineCallArg], + schema: Option<&str>, + ) -> Result { + Ok(crate::drivers::common::generic_routine_call_sql( + routine_name, + routine_type, + args, + schema, + &self.manifest().capabilities.identifier_quote, + )) + } + + /// Returns a starter script for creating a new routine of the given + /// type, opened in an editor tab. Dialect-specific (delimiters, body + /// quoting), so the default is a bare ISO-ish skeleton. + async fn routine_create_template( + &self, + routine_type: &str, + _schema: Option<&str>, + ) -> Result { + let keyword = if routine_type.eq_ignore_ascii_case("FUNCTION") { + "FUNCTION" + } else { + "PROCEDURE" + }; + Ok(format!( + "CREATE {keyword} my_routine()\nBEGIN\n -- routine body\nEND" + )) + } + + /// Returns an executable script for editing an existing routine. The + /// default assumes `get_routine_definition` already yields a re-runnable + /// statement (true for PostgreSQL's `CREATE OR REPLACE`); dialects whose + /// definition is not directly re-executable (MySQL needs `DROP` + + /// `DELIMITER` wrapping) override it. + async fn get_routine_edit_script( + &self, + params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + ) -> Result { + self.get_routine_definition(params, routine_name, routine_type, schema) + .await + } + + /// Drops a routine. The default issues a generic + /// `DROP PROCEDURE|FUNCTION`; dialects that identify routines by + /// signature (PostgreSQL overloads) override it. + async fn drop_routine( + &self, + params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + ) -> Result<(), String> { + let sql = crate::drivers::common::generic_drop_routine_sql( + routine_name, + routine_type, + schema, + &self.manifest().capabilities.identifier_quote, + ); + self.execute_query(params, &sql, None, 1, schema) + .await + .map(|_| ()) + } + // --- Query execution ---------------------------------------------------- async fn execute_query( diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 63935cc6..95bf197e 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -4,6 +4,7 @@ pub mod types; mod explain; mod helpers; +mod routines; #[cfg(test)] mod tests; @@ -1078,11 +1079,15 @@ pub async fn get_routine_parameters( } } - // 2. Get parameters + // 2. Get parameters. Position 0 is the function's return value, which + // MySQL also exposes here (NULL name / NULL mode) — step 1 already + // reported it from information_schema.routines, so skip it to avoid a + // duplicated return-value row. let query = r#" SELECT parameter_name, data_type, parameter_mode, ordinal_position FROM information_schema.parameters WHERE specific_schema = ? AND specific_name = ? + AND ordinal_position >= 1 ORDER BY ordinal_position "#; @@ -1491,6 +1496,7 @@ impl MysqlDriver { views: true, materialized_views: false, routines: true, + routine_management: true, file_based: false, folder_based: false, connection_string: true, @@ -1824,6 +1830,53 @@ impl DatabaseDriver for MysqlDriver { get_trigger_definition(params, trigger_name, schema).await } + async fn build_routine_call_sql( + &self, + _params: &crate::models::ConnectionParams, + routine_name: &str, + routine_type: &str, + args: &[crate::models::RoutineCallArg], + _schema: Option<&str>, + ) -> Result { + Ok(routines::routine_call_sql(routine_name, routine_type, args)) + } + + async fn routine_create_template( + &self, + routine_type: &str, + _schema: Option<&str>, + ) -> Result { + Ok(routines::routine_create_template(routine_type)) + } + + async fn get_routine_edit_script( + &self, + params: &crate::models::ConnectionParams, + routine_name: &str, + routine_type: &str, + _schema: Option<&str>, + ) -> Result { + let definition = get_routine_definition(params, routine_name, routine_type).await?; + Ok(routines::routine_edit_script( + routine_name, + routine_type, + &definition, + )) + } + + async fn drop_routine( + &self, + params: &crate::models::ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + ) -> Result<(), String> { + let sql = routines::drop_routine_sql(routine_name, routine_type); + execute_query(params, &sql, None, 1, schema) + .await + .map(|_| ()) + } + async fn create_trigger( &self, params: &crate::models::ConnectionParams, diff --git a/src-tauri/src/drivers/mysql/routines.rs b/src-tauri/src/drivers/mysql/routines.rs new file mode 100644 index 00000000..ae55d2de --- /dev/null +++ b/src-tauri/src/drivers/mysql/routines.rs @@ -0,0 +1,144 @@ +//! MySQL-dialect SQL builders for stored-routine management. +//! +//! Pure string builders; the trait overrides in `mod.rs` delegate here so +//! the generation logic stays unit-testable without a live server. + +use super::helpers::{escape_identifier, mysql_string_literal}; +use crate::models::RoutineCallArg; + +fn quoted(name: &str) -> String { + format!("`{}`", escape_identifier(name)) +} + +/// Session-variable name for an OUT/INOUT parameter: parameter names come +/// from information_schema but are embedded unquoted after `@`, so anything +/// outside [A-Za-z0-9_] is normalised away. +fn session_var(param_name: &str, position: usize) -> String { + let sanitized: String = param_name + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if sanitized.is_empty() { + format!("@param_{}", position + 1) + } else { + format!("@{}", sanitized) + } +} + +fn literal(arg: &RoutineCallArg) -> String { + match &arg.value { + None => "NULL".to_string(), + Some(v) if arg.is_raw => v.clone(), + // The script runs through the editor with the default sql_mode + // assumption (backslash is an escape character). + Some(v) => mysql_string_literal(v, false), + } +} + +/// Builds the invocation script. Functions become a plain `SELECT`; +/// procedures with OUT/INOUT parameters get the session-variable dance: +/// `SET` for INOUT inputs, `@var` placeholders in the `CALL`, and a final +/// `SELECT` exposing the outputs as a result set. +pub(super) fn routine_call_sql( + routine_name: &str, + routine_type: &str, + args: &[RoutineCallArg], +) -> String { + let name = quoted(routine_name); + + if routine_type.eq_ignore_ascii_case("FUNCTION") { + let list: Vec = args.iter().map(literal).collect(); + return format!("SELECT {}({}) AS result;", name, list.join(", ")); + } + + let mut set_lines: Vec = Vec::new(); + let mut call_args: Vec = Vec::new(); + let mut out_vars: Vec<(String, String)> = Vec::new(); // (var, label) + + for (i, arg) in args.iter().enumerate() { + let mode = arg.mode.to_uppercase(); + if mode == "OUT" || mode == "INOUT" { + let var = session_var(&arg.name, i); + if mode == "INOUT" { + set_lines.push(format!("SET {} = {};", var, literal(arg))); + } + let label = if arg.name.is_empty() { + var.trim_start_matches('@').to_string() + } else { + arg.name.clone() + }; + out_vars.push((var.clone(), label)); + call_args.push(var); + } else { + call_args.push(literal(arg)); + } + } + + let mut script = String::new(); + for line in &set_lines { + script.push_str(line); + script.push('\n'); + } + script.push_str(&format!("CALL {}({});", name, call_args.join(", "))); + if !out_vars.is_empty() { + let selects: Vec = out_vars + .iter() + .map(|(var, label)| format!("{} AS {}", var, quoted(label))) + .collect(); + script.push_str(&format!("\nSELECT {};", selects.join(", "))); + } + script +} + +/// Starter script for a new routine, wrapped in the `DELIMITER` dance the +/// statement splitter needs to send the body as one statement. +pub(super) fn routine_create_template(routine_type: &str) -> String { + if routine_type.eq_ignore_ascii_case("FUNCTION") { + r"DELIMITER // +CREATE FUNCTION my_function(p_value INT) RETURNS INT +DETERMINISTIC +BEGIN + RETURN p_value; +END// +DELIMITER ; +" + .to_string() + } else { + r"DELIMITER // +CREATE PROCEDURE my_procedure(IN p_value INT) +BEGIN + SELECT p_value; +END// +DELIMITER ; +" + .to_string() + } +} + +/// Wraps a `SHOW CREATE` definition into a re-runnable edit script: +/// drop the existing routine, then recreate it inside a `DELIMITER` block. +pub(super) fn routine_edit_script( + routine_name: &str, + routine_type: &str, + definition: &str, +) -> String { + let keyword = drop_keyword(routine_type); + format!( + "DROP {} IF EXISTS {};\n\nDELIMITER //\n{}//\nDELIMITER ;\n", + keyword, + quoted(routine_name), + definition.trim_end() + ) +} + +pub(super) fn drop_routine_sql(routine_name: &str, routine_type: &str) -> String { + format!("DROP {} {}", drop_keyword(routine_type), quoted(routine_name)) +} + +fn drop_keyword(routine_type: &str) -> &'static str { + if routine_type.eq_ignore_ascii_case("FUNCTION") { + "FUNCTION" + } else { + "PROCEDURE" + } +} diff --git a/src-tauri/src/drivers/mysql/tests.rs b/src-tauri/src/drivers/mysql/tests.rs index e9f57e2b..ff8b276d 100644 --- a/src-tauri/src/drivers/mysql/tests.rs +++ b/src-tauri/src/drivers/mysql/tests.rs @@ -705,3 +705,117 @@ mod build_mysql_pk_where_tests { assert!(build_mysql_pk_where(&pk_map).is_err()); } } + +mod routine_management { + use super::super::routines::{ + drop_routine_sql, routine_call_sql, routine_create_template, routine_edit_script, + }; + use crate::models::RoutineCallArg; + + fn arg(name: &str, mode: &str, value: Option<&str>, is_raw: bool) -> RoutineCallArg { + RoutineCallArg { + name: name.to_string(), + mode: mode.to_string(), + value: value.map(|v| v.to_string()), + is_raw, + } + } + + #[test] + fn call_procedure_with_in_params_quotes_strings() { + let sql = routine_call_sql( + "sp_test", + "PROCEDURE", + &[arg("p_name", "IN", Some("O'Brien"), false)], + ); + assert_eq!(sql, "CALL `sp_test`('O\\'Brien');"); + } + + #[test] + fn call_procedure_raw_and_null_values() { + let sql = routine_call_sql( + "sp_test", + "PROCEDURE", + &[ + arg("p_id", "IN", Some("42"), true), + arg("p_note", "IN", None, false), + ], + ); + assert_eq!(sql, "CALL `sp_test`(42, NULL);"); + } + + #[test] + fn call_procedure_with_out_params_uses_session_vars() { + let sql = routine_call_sql( + "sp_out", + "PROCEDURE", + &[ + arg("p_in", "IN", Some("1"), true), + arg("p_out", "OUT", None, false), + ], + ); + assert_eq!( + sql, + "CALL `sp_out`(1, @p_out);\nSELECT @p_out AS `p_out`;" + ); + } + + #[test] + fn call_procedure_inout_sets_variable_first() { + let sql = routine_call_sql( + "sp_inout", + "PROCEDURE", + &[arg("p_counter", "INOUT", Some("5"), true)], + ); + assert_eq!( + sql, + "SET @p_counter = 5;\nCALL `sp_inout`(@p_counter);\nSELECT @p_counter AS `p_counter`;" + ); + } + + #[test] + fn call_function_uses_select() { + let sql = routine_call_sql("fn_add", "FUNCTION", &[arg("a", "IN", Some("2"), true)]); + assert_eq!(sql, "SELECT `fn_add`(2) AS result;"); + } + + #[test] + fn out_param_with_hostile_name_is_sanitized() { + let sql = routine_call_sql( + "sp", + "PROCEDURE", + &[arg("evil; DROP--", "OUT", None, false)], + ); + assert!(sql.contains("@evilDROP"), "got: {sql}"); + } + + #[test] + fn create_templates_wrap_in_delimiter() { + for kind in ["PROCEDURE", "FUNCTION"] { + let tpl = routine_create_template(kind); + assert!(tpl.starts_with("DELIMITER //"), "{kind}: {tpl}"); + assert!(tpl.contains(&format!("CREATE {kind}")), "{kind}"); + assert!(tpl.trim_end().ends_with("DELIMITER ;"), "{kind}"); + } + } + + #[test] + fn edit_script_drops_then_recreates_in_delimiter_block() { + let script = routine_edit_script( + "sp_test", + "PROCEDURE", + "CREATE PROCEDURE `sp_test`()\nBEGIN\n SELECT 1;\nEND", + ); + assert!(script.starts_with("DROP PROCEDURE IF EXISTS `sp_test`;")); + assert!(script.contains("DELIMITER //\nCREATE PROCEDURE")); + assert!(script.contains("END//\nDELIMITER ;")); + } + + #[test] + fn drop_sql_escapes_identifier() { + assert_eq!( + drop_routine_sql("weird`name", "FUNCTION"), + "DROP FUNCTION `weird``name`" + ); + } +} diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 695c7754..8f8b217b 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -7,6 +7,7 @@ mod binding; mod client; mod explain; mod helpers; +mod routines; #[cfg(test)] mod tests; @@ -1418,6 +1419,47 @@ pub async fn get_routine_definition( Ok(definition) } +/// Drops a routine, resolving its exact identity signature first: PostgreSQL +/// identifies routines by name *and* argument types, so a bare +/// `DROP FUNCTION name` fails as soon as overloads exist. +pub async fn drop_routine( + params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: &str, +) -> Result<(), String> { + let pool = get_postgres_pool(params).await?; + + let query = r#" + SELECT pg_get_function_identity_arguments(p.oid) AS args + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname = $1 AND p.proname = $2 + "#; + let rows = query_all(&pool, query, &[&schema, &routine_name]).await?; + + match rows.len() { + 0 => Err(format!( + "Routine '{}' not found in schema '{}'", + routine_name, schema + )), + 1 => { + let identity_args: String = rows[0].try_get("args").unwrap_or_default(); + let sql = routines::drop_routine_sql( + routine_name, + routine_type, + &identity_args, + Some(schema), + ); + execute(&pool, &sql, &[]).await.map(|_| ()) + } + n => Err(format!( + "Routine '{}' has {} overloads; drop it manually specifying the argument types", + routine_name, n + )), + } +} + pub async fn get_triggers( params: &ConnectionParams, schema: &str, @@ -1540,6 +1582,7 @@ impl PostgresDriver { views: true, materialized_views: true, routines: true, + routine_management: true, file_based: false, folder_based: false, connection_string: true, @@ -1799,6 +1842,46 @@ impl DatabaseDriver for PostgresDriver { .await } + async fn build_routine_call_sql( + &self, + _params: &crate::models::ConnectionParams, + routine_name: &str, + routine_type: &str, + args: &[crate::models::RoutineCallArg], + schema: Option<&str>, + ) -> Result { + Ok(routines::routine_call_sql( + routine_name, + routine_type, + args, + Some(self.resolve_schema(schema)), + )) + } + + async fn routine_create_template( + &self, + routine_type: &str, + schema: Option<&str>, + ) -> Result { + Ok(routines::routine_create_template( + routine_type, + Some(self.resolve_schema(schema)), + )) + } + + // `get_routine_edit_script` keeps the trait default: `pg_get_functiondef` + // already returns a re-runnable `CREATE OR REPLACE` statement. + + async fn drop_routine( + &self, + params: &crate::models::ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + ) -> Result<(), String> { + drop_routine(params, routine_name, routine_type, self.resolve_schema(schema)).await + } + async fn get_triggers( &self, params: &crate::models::ConnectionParams, diff --git a/src-tauri/src/drivers/postgres/routines.rs b/src-tauri/src/drivers/postgres/routines.rs new file mode 100644 index 00000000..f2fad18d --- /dev/null +++ b/src-tauri/src/drivers/postgres/routines.rs @@ -0,0 +1,83 @@ +//! PostgreSQL-dialect SQL builders for stored-routine management. +//! +//! Pure string builders; the trait overrides in `mod.rs` delegate here so +//! the generation logic stays unit-testable without a live server. + +use crate::drivers::common::{quote_qualified, render_sql_literal}; +use crate::models::RoutineCallArg; + +/// Builds the invocation script. Functions go through `SELECT * FROM` so +/// both scalar and set-returning functions come back as a result set. +/// Procedures use `CALL`; OUT parameters are rendered as `NULL` placeholders +/// (PostgreSQL requires them in the argument list) and INOUT values are +/// echoed back by the server as the procedure's result row. +pub(super) fn routine_call_sql( + routine_name: &str, + routine_type: &str, + args: &[RoutineCallArg], + schema: Option<&str>, +) -> String { + let name = quote_qualified(routine_name, schema, "\""); + let rendered: Vec = args.iter().map(render_sql_literal).collect(); + let arg_list = rendered.join(", "); + if routine_type.eq_ignore_ascii_case("FUNCTION") { + format!("SELECT * FROM {}({});", name, arg_list) + } else { + format!("CALL {}({});", name, arg_list) + } +} + +/// Starter script for a new routine. `CREATE OR REPLACE` keeps the script +/// re-runnable while iterating on the body. +pub(super) fn routine_create_template(routine_type: &str, schema: Option<&str>) -> String { + let prefix = match schema { + Some(s) if !s.is_empty() => format!("\"{}\".", s.replace('"', "\"\"")), + _ => String::new(), + }; + if routine_type.eq_ignore_ascii_case("FUNCTION") { + format!( + r#"CREATE OR REPLACE FUNCTION {prefix}my_function(p_value integer) +RETURNS integer +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN p_value; +END; +$$; +"# + ) + } else { + format!( + r#"CREATE OR REPLACE PROCEDURE {prefix}my_procedure(p_value integer) +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE NOTICE 'value: %', p_value; +END; +$$; +"# + ) + } +} + +/// `DROP` statement for a routine identified by its exact signature (the +/// output of `pg_get_function_identity_arguments`), which is how PostgreSQL +/// disambiguates overloads. +pub(super) fn drop_routine_sql( + routine_name: &str, + routine_type: &str, + identity_args: &str, + schema: Option<&str>, +) -> String { + let keyword = if routine_type.eq_ignore_ascii_case("PROCEDURE") { + "PROCEDURE" + } else { + "FUNCTION" + }; + format!( + "DROP {} {}({})", + keyword, + quote_qualified(routine_name, schema, "\""), + identity_args + ) +} diff --git a/src-tauri/src/drivers/postgres/tests.rs b/src-tauri/src/drivers/postgres/tests.rs index 17b8dc98..f2746ebd 100644 --- a/src-tauri/src/drivers/postgres/tests.rs +++ b/src-tauri/src/drivers/postgres/tests.rs @@ -832,3 +832,62 @@ mod live_pg_temporal_and_uuid_regression { // clause uses the same uuid-cast binding path. } } + +mod routine_management { + use super::super::routines::{drop_routine_sql, routine_call_sql, routine_create_template}; + use crate::models::RoutineCallArg; + + fn arg(name: &str, mode: &str, value: Option<&str>, is_raw: bool) -> RoutineCallArg { + RoutineCallArg { + name: name.to_string(), + mode: mode.to_string(), + value: value.map(|v| v.to_string()), + is_raw, + } + } + + #[test] + fn function_call_uses_select_star_from() { + let sql = routine_call_sql( + "fn_report", + "FUNCTION", + &[arg("p_year", "IN", Some("2026"), true)], + Some("public"), + ); + assert_eq!(sql, "SELECT * FROM \"public\".\"fn_report\"(2026);"); + } + + #[test] + fn procedure_call_renders_out_params_as_null() { + let sql = routine_call_sql( + "sp_test", + "PROCEDURE", + &[ + arg("p_in", "IN", Some("it's"), false), + arg("p_out", "OUT", None, false), + ], + Some("public"), + ); + assert_eq!(sql, "CALL \"public\".\"sp_test\"('it''s', NULL);"); + } + + #[test] + fn create_templates_are_schema_qualified_or_replace() { + let tpl = routine_create_template("FUNCTION", Some("app")); + assert!(tpl.starts_with("CREATE OR REPLACE FUNCTION \"app\"."), "{tpl}"); + let tpl = routine_create_template("PROCEDURE", None); + assert!(tpl.starts_with("CREATE OR REPLACE PROCEDURE my_procedure"), "{tpl}"); + } + + #[test] + fn drop_sql_includes_identity_signature() { + assert_eq!( + drop_routine_sql("fn_add", "FUNCTION", "integer, integer", Some("public")), + "DROP FUNCTION \"public\".\"fn_add\"(integer, integer)" + ); + assert_eq!( + drop_routine_sql("sp", "PROCEDURE", "", None), + "DROP PROCEDURE \"sp\"()" + ); + } +} diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index 3c7479ab..e9500957 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -881,6 +881,7 @@ impl SqliteDriver { views: true, materialized_views: false, routines: false, + routine_management: false, file_based: true, folder_based: false, connection_string: false, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 61f4f8d2..0ca44cbe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -418,6 +418,10 @@ pub fn run() { commands::get_routines, commands::get_routine_parameters, commands::get_routine_definition, + commands::build_routine_call_sql, + commands::get_routine_create_template, + commands::get_routine_edit_script, + commands::drop_routine, // Triggers commands::get_triggers, commands::get_trigger_definition, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index f33e1551..2f060e30 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -452,6 +452,19 @@ pub struct RoutineParameter { pub ordinal_position: i32, } +/// One argument for invoking a stored routine, as collected by the +/// run-routine UI. `value: None` means SQL `NULL`; `is_raw` skips string +/// quoting so numbers and expressions pass through verbatim. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RoutineCallArg { + pub name: String, + pub mode: String, // "IN", "OUT", "INOUT" + #[serde(default)] + pub value: Option, + #[serde(default)] + pub is_raw: bool, +} + #[derive(Debug, Serialize, Deserialize)] pub struct ViewInfo { pub name: String, diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 30808fb2..acdb313f 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -26,6 +26,14 @@ const PLUGIN_CALL_TIMEOUT: Duration = Duration::from_secs(120); /// plugin cannot stall MCP server startup indefinitely. const PLUGIN_INIT_TIMEOUT: Duration = Duration::from_secs(15); +/// Heuristic for the JSON-RPC "method not found" error (code -32601). Only +/// the error *message* survives the response plumbing, so optional-method +/// fallbacks match on the standard wording (and the code, for SDKs that +/// embed it in the message). +fn is_method_not_found(err: &str) -> bool { + err.to_lowercase().contains("method not found") || err.contains("-32601") +} + /// Message sent to the management task that owns the plugin child process. enum PluginCommand { /// Dispatch a JSON-RPC request and route the response back via the sender. @@ -500,6 +508,126 @@ impl DatabaseDriver for RpcDriver { serde_json::from_value(res).map_err(|e| e.to_string()) } + // --- Routine management --------------------------------------------- + // + // These RPC methods are OPTIONAL for plugins that declare the + // `routine_management` capability: when the plugin does not implement + // one, the host falls back to the same dialect-neutral SQL the trait + // defaults produce, so a plugin only overrides what its dialect needs. + + async fn build_routine_call_sql( + &self, + params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + args: &[crate::models::RoutineCallArg], + schema: Option<&str>, + ) -> Result { + let res = self + .process + .call( + "build_routine_call_sql", + json!({ "params": params, "routine_name": routine_name, "routine_type": routine_type, "args": args, "schema": schema }), + ) + .await; + match res { + Ok(v) => serde_json::from_value(v).map_err(|e| e.to_string()), + Err(e) if is_method_not_found(&e) => { + Ok(crate::drivers::common::generic_routine_call_sql( + routine_name, + routine_type, + args, + schema, + &self.manifest.capabilities.identifier_quote, + )) + } + Err(e) => Err(e), + } + } + + async fn routine_create_template( + &self, + routine_type: &str, + schema: Option<&str>, + ) -> Result { + let res = self + .process + .call( + "routine_create_template", + json!({ "routine_type": routine_type, "schema": schema }), + ) + .await; + match res { + Ok(v) => serde_json::from_value(v).map_err(|e| e.to_string()), + Err(e) if is_method_not_found(&e) => { + let keyword = if routine_type.eq_ignore_ascii_case("FUNCTION") { + "FUNCTION" + } else { + "PROCEDURE" + }; + Ok(format!( + "CREATE {keyword} my_routine()\nBEGIN\n -- routine body\nEND" + )) + } + Err(e) => Err(e), + } + } + + async fn get_routine_edit_script( + &self, + params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + ) -> Result { + let res = self + .process + .call( + "get_routine_edit_script", + json!({ "params": params, "routine_name": routine_name, "routine_type": routine_type, "schema": schema }), + ) + .await; + match res { + Ok(v) => serde_json::from_value(v).map_err(|e| e.to_string()), + Err(e) if is_method_not_found(&e) => { + self.get_routine_definition(params, routine_name, routine_type, schema) + .await + } + Err(e) => Err(e), + } + } + + async fn drop_routine( + &self, + params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + ) -> Result<(), String> { + let res = self + .process + .call( + "drop_routine", + json!({ "params": params, "routine_name": routine_name, "routine_type": routine_type, "schema": schema }), + ) + .await; + match res { + Ok(v) => serde_json::from_value(v).map_err(|e| e.to_string()), + Err(e) if is_method_not_found(&e) => { + let sql = crate::drivers::common::generic_drop_routine_sql( + routine_name, + routine_type, + schema, + &self.manifest.capabilities.identifier_quote, + ); + self.execute_query(params, &sql, None, 1, schema) + .await + .map(|_| ()) + } + Err(e) => Err(e), + } + } + async fn execute_query( &self, params: &ConnectionParams, diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index 835a00b3..c6bbcd89 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -61,11 +61,13 @@ import { ClipboardImportModal } from "../modals/ClipboardImportModal"; import { ViewEditorModal } from "../modals/ViewEditorModal"; import { TriggerEditorModal } from "../modals/TriggerEditorModal"; import { ConfirmModal } from "../modals/ConfirmModal"; +import { RunRoutineModal } from "../modals/RunRoutineModal"; import { Accordion } from "./sidebar/Accordion"; import { SidebarTableItem } from "./sidebar/SidebarTableItem"; import { buildTableItemSelector } from "../../utils/sidebarTableItem"; import { SidebarViewItem } from "./sidebar/SidebarViewItem"; import { SidebarRoutineItem } from "./sidebar/SidebarRoutineItem"; +import { SidebarRoutineGroupHeader } from "./sidebar/SidebarRoutineGroupHeader"; import { SidebarSchemaItem } from "./sidebar/SidebarSchemaItem"; import { SidebarDatabaseItem } from "./sidebar/SidebarDatabaseItem"; import { SidebarTriggerItem } from "./sidebar/SidebarTriggerItem"; @@ -181,6 +183,8 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar data?: ContextMenuData; } | null>(null); const [schemaModal, setSchemaModal] = useState<{ tableName: string; schema?: string } | null>(null); + const [runRoutineModal, setRunRoutineModal] = useState<{ routine: RoutineInfo; schema?: string } | null>(null); + const [routineDropConfirm, setRoutineDropConfirm] = useState<{ name: string; routineType: string; schema?: string } | null>(null); const [isCreateTableModalOpen, setIsCreateTableModalOpen] = useState(false); const [createTableTarget, setCreateTableTarget] = useState(DEFAULT_CREATE_TABLE_TARGET); const [isClipboardImportOpen, setIsClipboardImportOpen] = useState(false); @@ -418,6 +422,43 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar } }; + const handleNewRoutine = async (routineType: string) => { + try { + const template = await invoke("get_routine_create_template", { + connectionId: activeConnectionId, + routineType, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + const tabName = + routineType === "FUNCTION" + ? t("routines.newFunction") + : t("routines.newProcedure"); + runQuery(template, tabName, undefined, true, activeSchema ?? undefined); + } catch (e) { + console.error(e); + showAlert(t("routines.templateError") + String(e), { kind: "error" }); + } + }; + + const handleDropRoutine = async () => { + if (!routineDropConfirm) return; + const { name, routineType, schema } = routineDropConfirm; + setRoutineDropConfirm(null); + try { + await invoke("drop_routine", { + connectionId: activeConnectionId, + routineName: name, + routineType, + ...(schema ? { schema } : {}), + }); + showAlert(t("routines.dropSuccess", { name }), { kind: "info" }); + if (refreshRoutines) refreshRoutines(); + } catch (e) { + console.error(e); + showAlert(t("routines.dropError") + String(e), { kind: "error" }); + } + }; + const handleTriggerDoubleClick = async (trigger: TriggerInfo, schema?: string) => { try { const definition = await invoke("get_trigger_definition", { @@ -1673,7 +1714,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar isOpen={routinesOpen} onToggle={() => setRoutinesOpen(!routinesOpen)} actions={ -
+
+ {activeCapabilities?.routine_management === true && ( + + )}
} > @@ -1696,14 +1749,12 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar {/* Functions */} {groupedRoutines.functions.length > 0 && (
- + setFunctionsOpen(!functionsOpen)} + /> {functionsOpen && groupedRoutines.functions.map((routine) => ( 0 && (
- + setProceduresOpen(!proceduresOpen)} + /> {proceduresOpen && groupedRoutines.procedures.map((routine) => ( { - try { - const routineType = - contextMenu.data && 'routine_type' in contextMenu.data - ? (contextMenu.data).routine_type - : "PROCEDURE"; - const definition = await invoke("get_routine_definition", { - connectionId: activeConnectionId, - routineName: contextMenu.id, - routineType: routineType, - ...(activeSchema ? { schema: activeSchema } : {}), + ? (() => { + const routineData = + contextMenu.data && 'routine_type' in contextMenu.data + ? (contextMenu.data as RoutineInfo & { schema?: string }) + : null; + const routineType = routineData?.routine_type ?? "PROCEDURE"; + const routineSchema = routineData?.schema ?? activeSchema ?? undefined; + const canManageRoutines = + activeCapabilities?.routine_management === true; + return [ + canManageRoutines ? { + label: t("routines.menuRun"), + icon: Play, + action: () => { + if (routineData) { + setRunRoutineModal({ + routine: routineData, + schema: routineSchema, + }); + } + }, + } : null, + { + label: t("sidebar.viewDefinition"), + icon: FileText, + action: async () => { + try { + const definition = await invoke("get_routine_definition", { + connectionId: activeConnectionId, + routineName: contextMenu.id, + routineType: routineType, + ...(routineSchema ? { schema: routineSchema } : {}), + }); + runQuery(definition, `${contextMenu.id} Definition`, undefined, true, routineSchema); + } catch (e) { + console.error(e); + showAlert( + t("sidebar.failGetRoutineDefinition") + String(e), + { kind: "error" } + ); + } + }, + }, + canManageRoutines ? { + label: t("routines.menuEdit"), + icon: Edit, + action: async () => { + try { + const script = await invoke("get_routine_edit_script", { + connectionId: activeConnectionId, + routineName: contextMenu.id, + routineType: routineType, + ...(routineSchema ? { schema: routineSchema } : {}), + }); + runQuery(script, `${contextMenu.id} Edit`, undefined, true, routineSchema); + } catch (e) { + console.error(e); + showAlert( + t("sidebar.failGetRoutineDefinition") + String(e), + { kind: "error" } + ); + } + }, + } : null, + canManageRoutines ? { + label: t("routines.menuDrop"), + icon: Trash2, + danger: true, + action: () => { + setRoutineDropConfirm({ + name: contextMenu.id, + routineType, + schema: routineSchema, }); - runQuery(definition, `${contextMenu.id} Definition`, undefined, true); - } catch (e) { - console.error(e); - showAlert( - t("sidebar.failGetRoutineDefinition") + String(e), - { kind: "error" } - ); - } + }, + } : null, + { + label: t("sidebar.copyName"), + icon: Copy, + action: () => navigator.clipboard.writeText(contextMenu.id), }, - }, - { - label: t("sidebar.copyName"), - icon: Copy, - action: () => navigator.clipboard.writeText(contextMenu.id), - }, - ] + ].filter(Boolean) as ContextMenuItem[]; + })() + : contextMenu.type === "routines-new" + ? [ + { + label: t("routines.newProcedure"), + icon: FileCode, + action: () => handleNewRoutine("PROCEDURE"), + }, + { + label: t("routines.newFunction"), + icon: FileCode, + action: () => handleNewRoutine("FUNCTION"), + }, + ] : contextMenu.type === "trigger" ? (() => { const triggerData = contextMenu.data && 'table_name' in contextMenu.data @@ -2510,6 +2623,29 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar setHistoryClearConfirm(false); }} /> + + {/* Run routine with parameters */} + {runRoutineModal && activeConnectionId && ( + setRunRoutineModal(null)} + connectionId={activeConnectionId} + routine={runRoutineModal.routine} + schema={runRoutineModal.schema} + onRun={(sql) => { + runQuery(sql, `${t("routines.runTabPrefix")} ${runRoutineModal.routine.name}`, undefined, false, runRoutineModal.schema); + }} + /> + )} + + {/* Drop routine confirmation */} + setRoutineDropConfirm(null)} + title={t("routines.dropConfirmTitle")} + message={t("routines.dropConfirmMessage", { name: routineDropConfirm?.name ?? "" })} + onConfirm={handleDropRoutine} + /> ); }; diff --git a/src/components/layout/sidebar/SidebarDatabaseItem.tsx b/src/components/layout/sidebar/SidebarDatabaseItem.tsx index ea7e9d1e..305784c5 100644 --- a/src/components/layout/sidebar/SidebarDatabaseItem.tsx +++ b/src/components/layout/sidebar/SidebarDatabaseItem.tsx @@ -18,6 +18,7 @@ import { Accordion } from "./Accordion"; import { SidebarTableItem } from "./SidebarTableItem"; import { SidebarViewItem } from "./SidebarViewItem"; import { SidebarRoutineItem } from "./SidebarRoutineItem"; +import { SidebarRoutineGroupHeader } from "./SidebarRoutineGroupHeader"; import { SidebarTriggerItem } from "./SidebarTriggerItem"; import type { SchemaData, RoutineInfo, TriggerInfo } from "../../../contexts/DatabaseContext"; import type { TableColumn } from "../../../types/schema"; @@ -438,14 +439,12 @@ export const SidebarDatabaseItem = ({
{groupedRoutines.functions.length > 0 && (
- + setFunctionsOpen(!functionsOpen)} + /> {functionsOpen && groupedRoutines.functions.map((routine) => ( 0 && (
- + setProceduresOpen(!proceduresOpen)} + /> {proceduresOpen && groupedRoutines.procedures.map((routine) => ( void; +} + +/** + * Collapsible header for the Functions / Procedures groups inside the + * Routines section. Styled with the same visual weight as the inner group + * headers of table and view items (e.g. the "columns" row), so the Routines + * section reads like the Views and Triggers sections instead of introducing + * a second accordion-style hierarchy. + */ +export const SidebarRoutineGroupHeader = ({ + label, + count, + isOpen, + onToggle, +}: SidebarRoutineGroupHeaderProps) => ( + +); diff --git a/src/components/layout/sidebar/SidebarRoutineItem.tsx b/src/components/layout/sidebar/SidebarRoutineItem.tsx index 8440e5fb..baee293d 100644 --- a/src/components/layout/sidebar/SidebarRoutineItem.tsx +++ b/src/components/layout/sidebar/SidebarRoutineItem.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; import { Code2, + Folder, Loader2, ChevronDown, ChevronRight, @@ -80,7 +81,9 @@ export const SidebarRoutineItem = ({ const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - onContextMenu(e, "routine", routine.name, routine.name, routine); + // Forward the item's schema so menu actions (run / edit / drop) target + // the right namespace even outside the connection's active schema. + onContextMenu(e, "routine", routine.name, routine.name, { ...routine, schema }); }; return ( @@ -100,7 +103,7 @@ export const SidebarRoutineItem = ({ - + {routine.name}
{isExpanded && ( @@ -113,27 +116,51 @@ export const SidebarRoutineItem = ({ ) : (
{parameters.length > 0 ? ( -
- {parameters.map((param) => ( -
- - - {param.mode || (routine.routine_type === "FUNCTION" && !param.name ? "OUT" : "")} - - {param.name} - - {param.data_type} - -
- ))} -
+ <> +
+ + {t("sidebar.parameters")} + {/* mr-3.5 lines the count up with the group counts of + Functions / Procedures (px-2 + mr-3.5 = same edge). */} + + {parameters.length} + +
+
+ {parameters.map((param) => { + const mode = + param.mode || + (routine.routine_type === "FUNCTION" && !param.name + ? "OUT" + : ""); + return ( +
+ + + {param.name || t("routines.returnValue")} + + {mode && ( + + {mode} + + )} + {/* px-3 (12px) + mr-2.5 (10px) ends at the same + 22px right edge as the group / parameter counts. */} + + {param.data_type} + +
+ ); + })} +
+ ) : (
- No parameters + {t("sidebar.noParameters")}
)}
diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx index bb1abd3c..be6e1dee 100644 --- a/src/components/layout/sidebar/SidebarSchemaItem.tsx +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -14,6 +14,7 @@ import { Accordion } from "./Accordion"; import { SidebarTableItem } from "./SidebarTableItem"; import { SidebarViewItem } from "./SidebarViewItem"; import { SidebarRoutineItem } from "./SidebarRoutineItem"; +import { SidebarRoutineGroupHeader } from "./SidebarRoutineGroupHeader"; import { SidebarTriggerItem } from "./SidebarTriggerItem"; import type { SchemaData, RoutineInfo, TriggerInfo } from "../../../contexts/DatabaseContext"; import type { TableColumn } from "../../../types/schema"; @@ -421,14 +422,12 @@ export const SidebarSchemaItem = ({ {/* Functions */} {groupedRoutines.functions.length > 0 && (
- + setFunctionsOpen(!functionsOpen)} + /> {functionsOpen && groupedRoutines.functions.map((routine) => ( 0 && (
- + setProceduresOpen(!proceduresOpen)} + /> {proceduresOpen && groupedRoutines.procedures.map((routine) => ( void; + connectionId: string; + routine: RoutineInfo; + schema?: string; + /** Receives the generated invocation script, ready to execute. */ + onRun: (sql: string) => void; +} + +export const RunRoutineModal = ({ + isOpen, + onClose, + connectionId, + routine, + schema, + onRun, +}: RunRoutineModalProps) => { + const { t } = useTranslation(); + const [parameters, setParameters] = useState([]); + const [inputs, setInputs] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isBuilding, setIsBuilding] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + setIsLoading(true); + setError(""); + invoke("get_routine_parameters", { + connectionId, + routineName: routine.name, + ...(schema ? { schema } : {}), + }) + .then((params) => { + if (cancelled) return; + const callParams = params.filter(isCallParameter); + setParameters(callParams); + const initial: Record = {}; + for (const p of callParams) { + initial[p.ordinal_position] = { + value: "", + isNull: isOutputOnly(p), + isRaw: isNumericDataType(p.data_type), + }; + } + setInputs(initial); + }) + .catch((err) => { + if (!cancelled) setError(String(err)); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isOpen, connectionId, routine.name, schema]); + + const updateInput = useCallback( + (position: number, partial: Partial) => { + setInputs((prev) => ({ + ...prev, + [position]: { ...prev[position], ...partial }, + })); + }, + [], + ); + + if (!isOpen) return null; + + const handleRun = async () => { + setIsBuilding(true); + setError(""); + try { + const args = buildRoutineCallArgs(parameters, inputs); + const sql = await invoke("build_routine_call_sql", { + connectionId, + routineName: routine.name, + routineType: routine.routine_type, + args, + ...(schema ? { schema } : {}), + }); + onRun(sql); + onClose(); + } catch (err) { + setError(String(err)); + } finally { + setIsBuilding(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {t("routines.runTitle", { name: routine.name })} +

+

+ {t("routines.runSubtitle")} +

+
+
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ + {t("routines.loadingParameters")} +
+ ) : parameters.length === 0 ? ( +
+

+ {t("routines.noParameters")} +

+
+ ) : ( + parameters.map((param) => { + const input = inputs[param.ordinal_position] ?? { + value: "", + isNull: false, + isRaw: false, + }; + const outputOnly = isOutputOnly(param); + return ( +
+ + {outputOnly ? ( +
+ {t("routines.outputParameter")} +
+ ) : ( +
+ + updateInput(param.ordinal_position, { + value: e.target.value, + }) + } + className="flex-1 px-3 py-2 bg-base border border-strong rounded-lg text-primary focus:border-blue-500 focus:outline-none disabled:opacity-50" + placeholder={param.data_type} + /> + + +
+ )} +
+ ); + }) + )} + + {error && ( +
+ {error} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6bf12985..4155525d 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -76,6 +76,8 @@ "expandTable": "Tabelle {{name}} erweitern", "collapseTable": "Tabelle {{name}} einklappen", "columns": "Spalten", + "parameters": "Parameter", + "noParameters": "Keine Parameter", "keys": "Schlüssel", "foreignKeys": "Fremdschlüssel", "indexes": "Indizes", @@ -717,7 +719,6 @@ "selectTypeFirst": "Zuerst Kontext/Namespace/Typ auswählen", "useK8s": "Kubernetes-Port-Forward verwenden", "useK8sConnection": "Gespeicherte Verbindung", - "advanced": "Erweitert", "startupScript": "Startskript", "startupScriptDescription": "SQL, das bei jeder neuen Verbindung zu dieser Datenquelle ausgeführt wird. Verwenden Sie es für Sitzungseinstellungen wie SET / set_config (z. B. zum Umgehen von RLS). Trennen Sie Anweisungen mit Semikolons.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1610,5 +1611,28 @@ "resourceNameRequired": "Der Ressourcenname ist erforderlich", "portInvalid": "Der Port muss zwischen 1 und 65535 liegen" } + }, + "routines": { + "menuRun": "Ausführen…", + "menuEdit": "Bearbeiten", + "menuDrop": "Löschen", + "newRoutine": "Neue Routine", + "newProcedure": "Neue Prozedur", + "newFunction": "Neue Funktion", + "templateError": "Routinen-Vorlage konnte nicht geladen werden: ", + "dropConfirmTitle": "Routine löschen", + "dropConfirmMessage": "Routine \"{{name}}\" löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "dropSuccess": "Routine {{name}} gelöscht", + "dropError": "Routine konnte nicht gelöscht werden: ", + "runTitle": "{{name}} ausführen", + "runSubtitle": "Parameterwerte eingeben und das generierte SQL im Editor prüfen", + "loadingParameters": "Parameter werden geladen…", + "noParameters": "Diese Routine hat keine Parameter. Das Aufrufskript wird im Editor geöffnet.", + "outputParameter": "Ausgabeparameter — wird als Ergebnis zurückgegeben", + "rawLabel": "Raw", + "rawHint": "Wert unverändert einfügen, ohne String-Quoting (Zahlen, Ausdrücke)", + "runButton": "Ausführen", + "runTabPrefix": "Ausführen", + "returnValue": "Rückgabe" } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3f126659..6258cbe3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -76,6 +76,8 @@ "expandTable": "Expand table {{name}}", "collapseTable": "Collapse table {{name}}", "columns": "columns", + "parameters": "parameters", + "noParameters": "No parameters", "keys": "keys", "foreignKeys": "foreign keys", "indexes": "indexes", @@ -751,7 +753,6 @@ "selectTypeFirst": "Select context/namespace/type first", "useK8s": "Use Kubernetes Port-Forward", "useK8sConnection": "Saved Connection", - "advanced": "Advanced", "startupScript": "Startup Script", "startupScriptDescription": "SQL run on every new connection to this data source. Use it for session settings such as SET / set_config (e.g. bypassing RLS). Separate statements with semicolons.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1686,5 +1687,28 @@ "resourceNameRequired": "Resource name is required", "portInvalid": "Port must be between 1 and 65535" } + }, + "routines": { + "menuRun": "Run…", + "menuEdit": "Edit", + "menuDrop": "Drop", + "newRoutine": "New routine", + "newProcedure": "New procedure", + "newFunction": "New function", + "templateError": "Failed to load routine template: ", + "dropConfirmTitle": "Drop routine", + "dropConfirmMessage": "Drop routine \"{{name}}\"? This action cannot be undone.", + "dropSuccess": "Routine {{name}} dropped", + "dropError": "Failed to drop routine: ", + "runTitle": "Run {{name}}", + "runSubtitle": "Provide the parameter values, then review the generated SQL in the editor", + "loadingParameters": "Loading parameters…", + "noParameters": "This routine takes no parameters. The invocation script will open in the editor.", + "outputParameter": "Output parameter — returned as a result", + "rawLabel": "Raw", + "rawHint": "Insert the value verbatim, without string quoting (numbers, expressions)", + "runButton": "Run", + "runTabPrefix": "Run", + "returnValue": "returns" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d4f3aa41..6f463041 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -76,6 +76,8 @@ "expandTable": "Expandir tabla {{name}}", "collapseTable": "Contraer tabla {{name}}", "columns": "columnas", + "parameters": "parámetros", + "noParameters": "Sin parámetros", "keys": "claves", "foreignKeys": "claves foráneas", "indexes": "índices", @@ -722,7 +724,6 @@ "selectTypeFirst": "Selecciona primero contexto/namespace/tipo", "useK8s": "Usar Port-Forward de Kubernetes", "useK8sConnection": "Conexión guardada", - "advanced": "Avanzado", "startupScript": "Script de inicio", "startupScriptDescription": "SQL que se ejecuta en cada nueva conexión a esta fuente de datos. Úsalo para ajustes de sesión como SET / set_config (p. ej., para omitir RLS). Separa las sentencias con punto y coma.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1567,5 +1568,28 @@ "resourceNameRequired": "El nombre del recurso es obligatorio", "portInvalid": "El puerto debe estar entre 1 y 65535" } + }, + "routines": { + "menuRun": "Ejecutar…", + "menuEdit": "Editar", + "menuDrop": "Eliminar", + "newRoutine": "Nueva rutina", + "newProcedure": "Nuevo procedimiento", + "newFunction": "Nueva función", + "templateError": "No se pudo cargar la plantilla de la rutina: ", + "dropConfirmTitle": "Eliminar rutina", + "dropConfirmMessage": "¿Eliminar la rutina \"{{name}}\"? Esta acción no se puede deshacer.", + "dropSuccess": "Rutina {{name}} eliminada", + "dropError": "No se pudo eliminar la rutina: ", + "runTitle": "Ejecutar {{name}}", + "runSubtitle": "Introduce los valores de los parámetros y revisa el SQL generado en el editor", + "loadingParameters": "Cargando parámetros…", + "noParameters": "Esta rutina no tiene parámetros. El script de invocación se abrirá en el editor.", + "outputParameter": "Parámetro de salida — devuelto como resultado", + "rawLabel": "Raw", + "rawHint": "Inserta el valor tal cual, sin comillas de cadena (números, expresiones)", + "runButton": "Ejecutar", + "runTabPrefix": "Ejecutar", + "returnValue": "devuelve" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index eedf2cf2..d8db5f45 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -76,6 +76,8 @@ "expandTable": "Développer la table {{name}}", "collapseTable": "Réduire la table {{name}}", "columns": "colonnes", + "parameters": "paramètres", + "noParameters": "Aucun paramètre", "keys": "clés", "foreignKeys": "clés étrangères", "indexes": "index", @@ -717,7 +719,6 @@ "selectTypeFirst": "Sélectionnez d'abord contexte/namespace/type", "useK8s": "Utiliser le Port-Forward Kubernetes", "useK8sConnection": "Connexion enregistrée", - "advanced": "Avancé", "startupScript": "Script de démarrage", "startupScriptDescription": "SQL exécuté à chaque nouvelle connexion à cette source de données. Utilisez-le pour des paramètres de session tels que SET / set_config (par exemple, pour contourner la RLS). Séparez les instructions par des points-virgules.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1610,5 +1611,28 @@ "resourceNameRequired": "Le nom de la ressource est requis", "portInvalid": "Le port doit être compris entre 1 et 65535" } + }, + "routines": { + "menuRun": "Exécuter…", + "menuEdit": "Modifier", + "menuDrop": "Supprimer", + "newRoutine": "Nouvelle routine", + "newProcedure": "Nouvelle procédure", + "newFunction": "Nouvelle fonction", + "templateError": "Impossible de charger le modèle de routine : ", + "dropConfirmTitle": "Supprimer la routine", + "dropConfirmMessage": "Supprimer la routine \"{{name}}\" ? Cette action est irréversible.", + "dropSuccess": "Routine {{name}} supprimée", + "dropError": "Impossible de supprimer la routine : ", + "runTitle": "Exécuter {{name}}", + "runSubtitle": "Saisissez les valeurs des paramètres, puis vérifiez le SQL généré dans l'éditeur", + "loadingParameters": "Chargement des paramètres…", + "noParameters": "Cette routine n'a aucun paramètre. Le script d'invocation s'ouvrira dans l'éditeur.", + "outputParameter": "Paramètre de sortie — renvoyé comme résultat", + "rawLabel": "Brut", + "rawHint": "Insère la valeur telle quelle, sans guillemets de chaîne (nombres, expressions)", + "runButton": "Exécuter", + "runTabPrefix": "Exécuter", + "returnValue": "retourne" } } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4bf32e0c..792b511a 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -76,6 +76,8 @@ "expandTable": "Espandi tabella {{name}}", "collapseTable": "Comprimi tabella {{name}}", "columns": "colonne", + "parameters": "parametri", + "noParameters": "Nessun parametro", "keys": "chiavi", "foreignKeys": "chiavi esterne", "indexes": "indici", @@ -722,7 +724,6 @@ "selectTypeFirst": "Seleziona prima contesto/namespace/tipo", "useK8s": "Usa Port-Forward Kubernetes", "useK8sConnection": "Connessione salvata", - "advanced": "Avanzate", "startupScript": "Script di avvio", "startupScriptDescription": "SQL eseguito a ogni nuova connessione a questa origine dati. Usalo per le impostazioni di sessione come SET / set_config (ad es. per bypassare la RLS). Separa le istruzioni con punto e virgola.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1593,5 +1594,28 @@ "resourceNameRequired": "Il nome della risorsa è obbligatorio", "portInvalid": "La porta deve essere compresa tra 1 e 65535" } + }, + "routines": { + "menuRun": "Esegui…", + "menuEdit": "Modifica", + "menuDrop": "Elimina", + "newRoutine": "Nuova routine", + "newProcedure": "Nuova procedura", + "newFunction": "Nuova funzione", + "templateError": "Impossibile caricare il template della routine: ", + "dropConfirmTitle": "Elimina routine", + "dropConfirmMessage": "Eliminare la routine \"{{name}}\"? L'azione non può essere annullata.", + "dropSuccess": "Routine {{name}} eliminata", + "dropError": "Impossibile eliminare la routine: ", + "runTitle": "Esegui {{name}}", + "runSubtitle": "Inserisci i valori dei parametri, poi rivedi la SQL generata nell'editor", + "loadingParameters": "Caricamento parametri…", + "noParameters": "Questa routine non ha parametri. Lo script di invocazione si aprirà nell'editor.", + "outputParameter": "Parametro di output — restituito come risultato", + "rawLabel": "Raw", + "rawHint": "Inserisce il valore così com'è, senza quoting stringa (numeri, espressioni)", + "runButton": "Esegui", + "runTabPrefix": "Esegui", + "returnValue": "ritorna" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e8f79120..98b60454 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -76,6 +76,8 @@ "expandTable": "テーブル {{name}} を展開", "collapseTable": "テーブル {{name}} を折りたたむ", "columns": "カラム", + "parameters": "パラメータ", + "noParameters": "パラメータなし", "keys": "キー", "foreignKeys": "外部キー", "indexes": "インデックス", @@ -731,7 +733,6 @@ "selectTypeFirst": "先にコンテキスト/ネームスペース/タイプを選択してください", "useK8s": "Kubernetes ポートフォワードを使用", "useK8sConnection": "保存された接続", - "advanced": "詳細設定", "startupScript": "起動スクリプト", "startupScriptDescription": "このデータソースへの新規接続ごとに実行される SQL です。SET / set_config(例: RLS のバイパス)などのセッション設定に使用します。複数のステートメントはセミコロンで区切ってください。", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1629,5 +1630,28 @@ "resourceNameRequired": "リソース名は必須です", "portInvalid": "ポートは 1 から 65535 の範囲で指定してください" } + }, + "routines": { + "menuRun": "実行…", + "menuEdit": "編集", + "menuDrop": "削除", + "newRoutine": "新しいルーチン", + "newProcedure": "新しいプロシージャ", + "newFunction": "新しい関数", + "templateError": "ルーチンテンプレートの読み込みに失敗しました: ", + "dropConfirmTitle": "ルーチンを削除", + "dropConfirmMessage": "ルーチン「{{name}}」を削除しますか?この操作は元に戻せません。", + "dropSuccess": "ルーチン {{name}} を削除しました", + "dropError": "ルーチンの削除に失敗しました: ", + "runTitle": "{{name}} を実行", + "runSubtitle": "パラメータ値を入力し、生成されたSQLをエディタで確認してください", + "loadingParameters": "パラメータを読み込み中…", + "noParameters": "このルーチンにはパラメータがありません。呼び出しスクリプトがエディタで開きます。", + "outputParameter": "出力パラメータ — 結果として返されます", + "rawLabel": "Raw", + "rawHint": "文字列クォートなしでそのまま挿入します(数値・式)", + "runButton": "実行", + "runTabPrefix": "実行", + "returnValue": "戻り値" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7611649a..574f4fff 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -76,6 +76,8 @@ "expandTable": "Развернуть таблицу {{name}}", "collapseTable": "Свернуть таблицу {{name}}", "columns": "столбцы", + "parameters": "параметры", + "noParameters": "Нет параметров", "keys": "ключи", "foreignKeys": "внешние ключи", "indexes": "индексы", @@ -710,7 +712,6 @@ "useK8s": "Использовать проброс портов Kubernetes", "useK8sConnection": "Сохранённое подключение", "appearance": "Внешний вид", - "advanced": "Дополнительно", "startupScript": "Сценарий запуска", "startupScriptDescription": "SQL, выполняемый при каждом новом подключении к этому источнику данных. Используйте его для настроек сеанса, таких как SET / set_config (например, для обхода RLS). Разделяйте операторы точкой с запятой.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1682,5 +1683,28 @@ "resourceNameRequired": "Имя ресурса обязательно", "portInvalid": "Порт должен быть в диапазоне от 1 до 65535" } + }, + "routines": { + "menuRun": "Выполнить…", + "menuEdit": "Изменить", + "menuDrop": "Удалить", + "newRoutine": "Новая процедура/функция", + "newProcedure": "Новая процедура", + "newFunction": "Новая функция", + "templateError": "Не удалось загрузить шаблон: ", + "dropConfirmTitle": "Удалить процедуру", + "dropConfirmMessage": "Удалить \"{{name}}\"? Это действие нельзя отменить.", + "dropSuccess": "{{name}} удалена", + "dropError": "Не удалось удалить: ", + "runTitle": "Выполнить {{name}}", + "runSubtitle": "Укажите значения параметров, затем проверьте сгенерированный SQL в редакторе", + "loadingParameters": "Загрузка параметров…", + "noParameters": "У этой процедуры нет параметров. Скрипт вызова откроется в редакторе.", + "outputParameter": "Выходной параметр — возвращается как результат", + "rawLabel": "Raw", + "rawHint": "Вставить значение как есть, без строковых кавычек (числа, выражения)", + "runButton": "Выполнить", + "runTabPrefix": "Выполнить", + "returnValue": "возвращает" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7dabdba7..8d6bdc87 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -75,6 +75,8 @@ "expandTable": "展开表 {{name}}", "collapseTable": "折叠表 {{name}}", "columns": "列", + "parameters": "参数", + "noParameters": "无参数", "keys": "键", "foreignKeys": "外键", "indexes": "索引", @@ -685,7 +687,6 @@ "selectTypeFirst": "请先选择上下文/命名空间/类型", "useK8s": "使用 Kubernetes 端口转发", "useK8sConnection": "已保存的连接", - "advanced": "高级", "startupScript": "启动脚本", "startupScriptDescription": "每次新建到此数据源的连接时执行的 SQL。可用于会话设置,例如 SET / set_config(如绕过 RLS)。多条语句请用分号分隔。", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1551,5 +1552,28 @@ "resourceNameRequired": "资源名称为必填项", "portInvalid": "端口必须介于 1 和 65535 之间" } + }, + "routines": { + "menuRun": "运行…", + "menuEdit": "编辑", + "menuDrop": "删除", + "newRoutine": "新建例程", + "newProcedure": "新建存储过程", + "newFunction": "新建函数", + "templateError": "无法加载例程模板:", + "dropConfirmTitle": "删除例程", + "dropConfirmMessage": "确定删除例程 \"{{name}}\" 吗?此操作无法撤销。", + "dropSuccess": "例程 {{name}} 已删除", + "dropError": "删除例程失败:", + "runTitle": "运行 {{name}}", + "runSubtitle": "输入参数值,然后在编辑器中检查生成的 SQL", + "loadingParameters": "正在加载参数…", + "noParameters": "此例程没有参数。调用脚本将在编辑器中打开。", + "outputParameter": "输出参数 — 作为结果返回", + "rawLabel": "原始值", + "rawHint": "按原样插入值,不加字符串引号(数字、表达式)", + "runButton": "运行", + "runTabPrefix": "运行", + "returnValue": "返回值" } } diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 96a971a1..65eadd62 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -1032,6 +1032,23 @@ export const Editor = () => { [activeConnectionId, updateTab, patchResultEntry, settings.resultPageSize, activeSchema, t, isMultiDb, activeDatabaseName, addHistoryEntry], ); + // Auto-run entry point for navigation-initiated executions (sidebar "open + // and run" flows). Multi-statement scripts — e.g. a routine invocation with + // OUT session variables (SET / CALL / SELECT) — must go through the batch + // path so every statement shares one connection and session state survives; + // a single statement keeps the plain runQuery path. + const runAutoQuery = useCallback( + (sql: string, page: number, tabId: string) => { + const statements = splitQueries(sql, activeDialect); + if (statements.length > 1) { + runMultipleQueries(statements); + } else { + runQuery(sql, page, tabId); + } + }, + [activeDialect, runMultipleQueries, runQuery], + ); + const runResultEntryPage = useCallback( async (entryId: string, pageNum: number, tabIdArg?: string) => { const targetTabId = tabIdArg ?? activeTabIdRef.current; @@ -2539,7 +2556,7 @@ export const Editor = () => { // Try immediate execution if tab exists (reused) const existingTab = tabsRef.current.find((t) => t.id === tabId); if (existingTab) { - runQuery(sql, 1, tabId); + runAutoQuery(sql || "", 1, tabId); delete pendingExecutionsRef.current[tabId]; } } @@ -2557,7 +2574,7 @@ export const Editor = () => { addTab, updateTab, navigate, - runQuery, + runAutoQuery, t, ]); @@ -2567,11 +2584,11 @@ export const Editor = () => { const tab = tabs.find((t) => t.id === tabId); if (tab) { const { sql, page } = pendingExecutionsRef.current[tabId]; - runQuery(sql, page, tabId); + runAutoQuery(sql, page, tabId); delete pendingExecutionsRef.current[tabId]; } }); - }, [tabs, runQuery]); + }, [tabs, runAutoQuery]); const startResize = () => { isDragging.current = true; diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 0b46a2dd..9c2e5712 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -31,6 +31,8 @@ export interface DriverCapabilities { readonly?: boolean; /** Supports listing and managing database triggers. Defaults to false. */ triggers?: boolean; + /** Supports managing stored routines (run with parameters, create from template, edit, drop). Defaults to false. */ + routine_management?: boolean; /** Supports materialized views (e.g. PostgreSQL). When false, the frontend skips fetching materialized views entirely. Defaults to false. */ materialized_views?: boolean; /** Shows the SSL/TLS configuration tab (mode + CA/client cert/key) in the connection modal. diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts index 6d515991..25972ddd 100644 --- a/src/types/sidebar.ts +++ b/src/types/sidebar.ts @@ -2,4 +2,8 @@ import type { SavedQuery } from "../contexts/SavedQueriesContext"; import type { RoutineInfo } from "../contexts/DatabaseContext"; import type { QueryHistoryEntry } from "./queryHistory"; -export type ContextMenuData = SavedQuery | { tableName: string; schema?: string } | RoutineInfo | QueryHistoryEntry; +export type ContextMenuData = + | SavedQuery + | { tableName: string; schema?: string } + | (RoutineInfo & { schema?: string }) + | QueryHistoryEntry; diff --git a/src/utils/routineCall.ts b/src/utils/routineCall.ts new file mode 100644 index 00000000..c17d9b6e --- /dev/null +++ b/src/utils/routineCall.ts @@ -0,0 +1,77 @@ +/** + * Pure helpers behind the Run Routine modal: parameter classification and + * assembly of the argument list sent to the `build_routine_call_sql` + * backend command. + */ + +export interface RoutineParameterInfo { + name: string; + data_type: string; + mode: string; // "IN" | "OUT" | "INOUT" + ordinal_position: number; +} + +/** One parameter's UI state in the Run Routine modal. */ +export interface RoutineArgInput { + value: string; + isNull: boolean; + isRaw: boolean; +} + +/** Mirror of `src-tauri/src/models.rs::RoutineCallArg`. */ +export interface RoutineCallArg { + name: string; + mode: string; + value: string | null; + is_raw: boolean; +} + +/** + * information_schema reports a routine's return value as a pseudo-parameter + * at ordinal position 0 (empty name, OUT mode). Only positions >= 1 are + * actual call arguments. + */ +export function isCallParameter(param: RoutineParameterInfo): boolean { + return param.ordinal_position >= 1; +} + +/** OUT parameters receive no input value: the invocation script surfaces + * them as a result (session variable or procedure result row). */ +export function isOutputOnly(param: RoutineParameterInfo): boolean { + return param.mode.toUpperCase() === "OUT"; +} + +const NUMERIC_TYPE_PATTERN = + /^(tinyint|smallint|mediumint|int|integer|bigint|decimal|numeric|float|double|real|double precision|bit|boolean|bool|serial|smallserial|bigserial|money)\b/i; + +/** + * True for data types whose values read naturally without string quoting; + * used as the default for the per-parameter "raw" toggle. + */ +export function isNumericDataType(dataType: string): boolean { + return NUMERIC_TYPE_PATTERN.test(dataType.trim()); +} + +/** + * Assembles the ordered argument list for `build_routine_call_sql` from the + * modal's per-parameter inputs. Output-only parameters and NULL-checked + * inputs are sent without a value (SQL `NULL` / dialect placeholder). + */ +export function buildRoutineCallArgs( + parameters: RoutineParameterInfo[], + inputs: Record, +): RoutineCallArg[] { + return [...parameters] + .filter(isCallParameter) + .sort((a, b) => a.ordinal_position - b.ordinal_position) + .map((param) => { + const input = inputs[param.ordinal_position]; + const omitValue = isOutputOnly(param) || !input || input.isNull; + return { + name: param.name, + mode: param.mode, + value: omitValue ? null : input.value, + is_raw: input?.isRaw ?? false, + }; + }); +} diff --git a/tests/utils/routineCall.test.ts b/tests/utils/routineCall.test.ts new file mode 100644 index 00000000..6031aa5a --- /dev/null +++ b/tests/utils/routineCall.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { + buildRoutineCallArgs, + isCallParameter, + isNumericDataType, + isOutputOnly, + type RoutineArgInput, + type RoutineParameterInfo, +} from "../../src/utils/routineCall"; + +function param( + overrides: Partial = {}, +): RoutineParameterInfo { + return { + name: "p_value", + data_type: "varchar", + mode: "IN", + ordinal_position: 1, + ...overrides, + }; +} + +function input(overrides: Partial = {}): RoutineArgInput { + return { value: "", isNull: false, isRaw: false, ...overrides }; +} + +describe("routineCall", () => { + describe("isCallParameter", () => { + it("should accept positions >= 1", () => { + expect(isCallParameter(param({ ordinal_position: 1 }))).toBe(true); + expect(isCallParameter(param({ ordinal_position: 3 }))).toBe(true); + }); + + it("should reject the return-value pseudo-parameter at position 0", () => { + expect( + isCallParameter(param({ ordinal_position: 0, name: "", mode: "OUT" })), + ).toBe(false); + }); + }); + + describe("isOutputOnly", () => { + it("should detect OUT mode case-insensitively", () => { + expect(isOutputOnly(param({ mode: "OUT" }))).toBe(true); + expect(isOutputOnly(param({ mode: "out" }))).toBe(true); + }); + + it("should treat IN and INOUT as inputs", () => { + expect(isOutputOnly(param({ mode: "IN" }))).toBe(false); + expect(isOutputOnly(param({ mode: "INOUT" }))).toBe(false); + }); + }); + + describe("isNumericDataType", () => { + it("should match numeric families", () => { + for (const t of [ + "int", + "INTEGER", + "bigint", + "decimal(10,2)", + "double precision", + "numeric", + "boolean", + " float ", + ]) { + expect(isNumericDataType(t), t).toBe(true); + } + }); + + it("should not match string or temporal types", () => { + for (const t of ["varchar(50)", "text", "datetime", "json", "interval"]) { + expect(isNumericDataType(t), t).toBe(false); + } + }); + + it("should not match types merely containing a numeric word", () => { + expect(isNumericDataType("varchar_int")).toBe(false); + expect(isNumericDataType("pointer")).toBe(false); + }); + }); + + describe("buildRoutineCallArgs", () => { + it("should order arguments by ordinal position", () => { + const params = [ + param({ name: "b", ordinal_position: 2 }), + param({ name: "a", ordinal_position: 1 }), + ]; + const args = buildRoutineCallArgs(params, { + 1: input({ value: "first" }), + 2: input({ value: "second" }), + }); + expect(args.map((a) => a.name)).toEqual(["a", "b"]); + expect(args.map((a) => a.value)).toEqual(["first", "second"]); + }); + + it("should exclude the return-value pseudo-parameter", () => { + const params = [ + param({ name: "", mode: "OUT", ordinal_position: 0 }), + param({ ordinal_position: 1 }), + ]; + const args = buildRoutineCallArgs(params, { 1: input({ value: "x" }) }); + expect(args).toHaveLength(1); + expect(args[0].name).toBe("p_value"); + }); + + it("should send null for OUT parameters regardless of input state", () => { + const params = [param({ name: "p_out", mode: "OUT" })]; + const args = buildRoutineCallArgs(params, { + 1: input({ value: "ignored" }), + }); + expect(args[0].value).toBeNull(); + }); + + it("should send null when the NULL checkbox is set or input is missing", () => { + const params = [ + param({ name: "a", ordinal_position: 1 }), + param({ name: "b", ordinal_position: 2 }), + ]; + const args = buildRoutineCallArgs(params, { + 1: input({ value: "x", isNull: true }), + }); + expect(args[0].value).toBeNull(); + expect(args[1].value).toBeNull(); + }); + + it("should forward the raw flag", () => { + const params = [param({ data_type: "int" })]; + const args = buildRoutineCallArgs(params, { + 1: input({ value: "42", isRaw: true }), + }); + expect(args[0]).toEqual({ + name: "p_value", + mode: "IN", + value: "42", + is_raw: true, + }); + }); + }); +});