From f8fbcdd4ebd74d1e68c00b5374d7c5cf834c22a8 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 2 Jul 2026 15:20:40 +0200 Subject: [PATCH 1/5] feat(routines): add stored routine management with plugin support Adds full management for stored procedures and functions, gated by a new routine_management driver capability (enabled for MySQL and PostgreSQL, opt-in for plugins via manifest): - Run with parameters: a modal collects IN/INOUT values (NULL checkbox, raw toggle defaulting on numeric types) and the driver builds a reviewable invocation script. MySQL OUT/INOUT parameters go through session variables (SET / CALL / SELECT @var); PostgreSQL functions run as SELECT * FROM fn(...) so set-returning functions come back as a result set. - Create from template: dialect-aware starter scripts (DELIMITER-wrapped for MySQL, CREATE OR REPLACE with dollar quoting for PostgreSQL). - Edit definition: re-runnable script (MySQL wraps SHOW CREATE in DROP IF EXISTS + DELIMITER; PostgreSQL reuses pg_get_functiondef). - Drop with confirmation. PostgreSQL resolves the exact identity signature via pg_get_function_identity_arguments and refuses to guess between overloads. The four new DatabaseDriver methods ship dialect-neutral defaults, so existing drivers keep working. For plugin drivers they are OPTIONAL JSON-RPC methods: on "method not found" the host falls back to the same generic SQL, so a plugin only overrides what its dialect needs. The manifest JSON schema documents the new capability. Navigation-initiated auto-runs are now multi-statement aware: scripts are split and routed through the batch path (single pooled connection), which session-variable invocations require. New i18n keys land in all 8 locales; SQL builders are covered by Rust unit tests and the parameter-assembly helper by Vitest. --- .../skills/tabularis-plugin-driver/SKILL.md | 21 ++ plugins/manifest.schema.json | 5 + src-tauri/src/commands.rs | 81 ++++++ src-tauri/src/drivers/common.rs | 4 + src-tauri/src/drivers/common/routines.rs | 74 ++++++ src-tauri/src/drivers/common/tests.rs | 44 ++++ src-tauri/src/drivers/driver_trait.rs | 89 ++++++- src-tauri/src/drivers/mysql/mod.rs | 49 ++++ src-tauri/src/drivers/mysql/routines.rs | 144 +++++++++++ src-tauri/src/drivers/mysql/tests.rs | 114 ++++++++ src-tauri/src/drivers/postgres/mod.rs | 83 ++++++ src-tauri/src/drivers/postgres/routines.rs | 83 ++++++ src-tauri/src/drivers/postgres/tests.rs | 59 +++++ src-tauri/src/drivers/sqlite/mod.rs | 1 + src-tauri/src/lib.rs | 4 + src-tauri/src/models.rs | 13 + src-tauri/src/plugins/driver.rs | 128 +++++++++ src/components/layout/ExplorerSidebar.tsx | 199 +++++++++++--- .../layout/sidebar/SidebarRoutineItem.tsx | 4 +- src/components/modals/RunRoutineModal.tsx | 243 ++++++++++++++++++ src/i18n/locales/de.json | 22 ++ src/i18n/locales/en.json | 22 ++ src/i18n/locales/es.json | 22 ++ src/i18n/locales/fr.json | 22 ++ src/i18n/locales/it.json | 22 ++ src/i18n/locales/ja.json | 22 ++ src/i18n/locales/ru.json | 22 ++ src/i18n/locales/zh.json | 22 ++ src/pages/Editor.tsx | 25 +- src/types/plugins.ts | 2 + src/types/sidebar.ts | 6 +- src/utils/routineCall.ts | 77 ++++++ tests/utils/routineCall.test.ts | 138 ++++++++++ 33 files changed, 1829 insertions(+), 37 deletions(-) create mode 100644 src-tauri/src/drivers/common/routines.rs create mode 100644 src-tauri/src/drivers/mysql/routines.rs create mode 100644 src-tauri/src/drivers/postgres/routines.rs create mode 100644 src/components/modals/RunRoutineModal.tsx create mode 100644 src/utils/routineCall.ts create mode 100644 tests/utils/routineCall.test.ts 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..d381d99b 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; @@ -1491,6 +1492,7 @@ impl MysqlDriver { views: true, materialized_views: false, routines: true, + routine_management: true, file_based: false, folder_based: false, connection_string: true, @@ -1824,6 +1826,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..3645d4e8 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -61,6 +61,7 @@ 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"; @@ -181,6 +182,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 +421,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", { @@ -1674,6 +1714,18 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar onToggle={() => setRoutinesOpen(!routinesOpen)} actions={
+ {activeCapabilities?.routine_management === true && ( + + )} +
+ + {/* 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..08b2d32c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1610,5 +1610,27 @@ "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" } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3f126659..b83b7b7e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1686,5 +1686,27 @@ "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" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d4f3aa41..e9a00ae7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1567,5 +1567,27 @@ "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" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index eedf2cf2..67901f4c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1610,5 +1610,27 @@ "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" } } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4bf32e0c..b2a24359 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1593,5 +1593,27 @@ "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" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e8f79120..1b45c59c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1629,5 +1629,27 @@ "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": "実行" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7611649a..39696268 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1682,5 +1682,27 @@ "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": "Выполнить" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7dabdba7..d674bb06 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1551,5 +1551,27 @@ "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": "运行" } } 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, + }); + }); + }); +}); From 6532bdce7df58871224180e4ffcba0911b3ccf3e Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 2 Jul 2026 15:34:39 +0200 Subject: [PATCH 2/5] fix(sidebar): align Routines section styling with Views and Triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Routines section introduced its own visual language: the Functions / Procedures group headers used accordion-weight styling (uppercase, semibold, tracking-wider) that competed with the section header, parameter rows had a fixed-width mode column with a tiny icon, the empty state was untranslated, and the header action buttons missed the mr-2.5 the other sections use. - Extract SidebarRoutineGroupHeader (shared by ExplorerSidebar, SidebarSchemaItem, SidebarDatabaseItem) styled like the inner group headers of table/view items. - Restyle parameter rows to match SidebarColumnItem (font-mono, name + mode + type layout) behind a "parameters" group header mirroring the views' "columns" one; the function return value row is labelled via a new routines.returnValue key instead of rendering blank. - MySQL: information_schema.parameters exposes the function return value at ordinal position 0 (NULL name/mode) which duplicated the row the driver already adds explicitly — filter it out. - i18n: sidebar.parameters, sidebar.noParameters, routines.returnValue in all 8 locales; hover accent aligned to the triggers' yellow-400. --- src-tauri/src/drivers/mysql/mod.rs | 6 +- src/components/layout/ExplorerSidebar.tsx | 31 +++++----- .../layout/sidebar/SidebarDatabaseItem.tsx | 29 ++++----- .../sidebar/SidebarRoutineGroupHeader.tsx | 31 ++++++++++ .../layout/sidebar/SidebarRoutineItem.tsx | 61 +++++++++++++------ .../layout/sidebar/SidebarSchemaItem.tsx | 29 ++++----- src/i18n/locales/de.json | 6 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/es.json | 6 +- src/i18n/locales/fr.json | 6 +- src/i18n/locales/it.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/zh.json | 6 +- 14 files changed, 149 insertions(+), 86 deletions(-) create mode 100644 src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index d381d99b..95bf197e 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1079,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 "#; diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index 3645d4e8..a776705e 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -67,6 +67,7 @@ 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"; @@ -1713,7 +1714,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar isOpen={routinesOpen} onToggle={() => setRoutinesOpen(!routinesOpen)} actions={ -
+
{activeCapabilities?.routine_management === true && ( + setFunctionsOpen(!functionsOpen)} + /> {functionsOpen && groupedRoutines.functions.map((routine) => ( 0 && (
- + setProceduresOpen(!proceduresOpen)} + /> {proceduresOpen && groupedRoutines.procedures.map((routine) => ( {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 7f8d5879..01f95f88 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, @@ -102,7 +103,7 @@ export const SidebarRoutineItem = ({ - + {routine.name}
{isExpanded && ( @@ -115,27 +116,47 @@ 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")} + + {parameters.length} + +
+
+ {parameters.map((param) => { + const mode = + param.mode || + (routine.routine_type === "FUNCTION" && !param.name + ? "OUT" + : ""); + return ( +
+ + + {param.name || t("routines.returnValue")} + + {mode && ( + + {mode} + + )} + + {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) => ( Date: Thu, 2 Jul 2026 15:35:37 +0200 Subject: [PATCH 3/5] fix(sidebar): routines header button order and group count alignment Match the other sections: refresh comes first and the new-routine plus button after it; the Functions / Procedures group counts get the same mr-2.5 as the section-header action icons so their right edges line up. --- src/components/layout/ExplorerSidebar.tsx | 20 +++++++++---------- .../sidebar/SidebarRoutineGroupHeader.tsx | 4 +++- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index a776705e..c6bbcd89 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -1715,6 +1715,16 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar onToggle={() => setRoutinesOpen(!routinesOpen)} actions={
+ {activeCapabilities?.routine_management === true && ( )} -
} > diff --git a/src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx b/src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx index 1b7b46cd..e6994915 100644 --- a/src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx +++ b/src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx @@ -26,6 +26,8 @@ export const SidebarRoutineGroupHeader = ({ > {isOpen ? : } {label} - {count} + {/* mr-2.5 keeps the count's right edge aligned with the section-header + action icons, which sit inside an mr-2.5 container. */} + {count} ); From 9dcefad2a9422bc4924249e13441205ce3d4b5d7 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 2 Jul 2026 15:36:44 +0200 Subject: [PATCH 4/5] fix(sidebar): align routine parameter counts and types to one right edge The nested "parameters" count (px-2, 8px) and the parameter type column (px-3, 12px) ended short of the Functions / Procedures group counts (18px). Compensate with mr-2.5 and mr-1.5 so every number and type in the Routines section shares the same right edge. --- src/components/layout/sidebar/SidebarRoutineItem.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/layout/sidebar/SidebarRoutineItem.tsx b/src/components/layout/sidebar/SidebarRoutineItem.tsx index 01f95f88..8e0af1c8 100644 --- a/src/components/layout/sidebar/SidebarRoutineItem.tsx +++ b/src/components/layout/sidebar/SidebarRoutineItem.tsx @@ -120,7 +120,9 @@ export const SidebarRoutineItem = ({
{t("sidebar.parameters")} - + {/* mr-2.5 lines the count up with the group counts of + Functions / Procedures (px-2 + mr-2.5 = same edge). */} + {parameters.length}
@@ -146,7 +148,9 @@ export const SidebarRoutineItem = ({ {mode} )} - + {/* px-3 (12px) + mr-1.5 (6px) ends at the same + 18px right edge as the group / parameter counts. */} + {param.data_type}
From 9534c050ceb798297d76ace6953592e7bbe11cf0 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 2 Jul 2026 15:37:31 +0200 Subject: [PATCH 5/5] fix(sidebar): align routine counts with the section action icon glyphs The counts lined up with the plus button's box, but the button has p-1, so the + glyph ends 4px further in. Shift counts and type column by 4px (mr-2.5 -> mr-3.5, mr-1.5 -> mr-2.5) so they share the glyph's edge. --- .../layout/sidebar/SidebarRoutineGroupHeader.tsx | 7 ++++--- src/components/layout/sidebar/SidebarRoutineItem.tsx | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx b/src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx index e6994915..d3d01876 100644 --- a/src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx +++ b/src/components/layout/sidebar/SidebarRoutineGroupHeader.tsx @@ -26,8 +26,9 @@ export const SidebarRoutineGroupHeader = ({ > {isOpen ? : } {label} - {/* mr-2.5 keeps the count's right edge aligned with the section-header - action icons, which sit inside an mr-2.5 container. */} - {count} + {/* mr-3.5 aligns the count's right edge with the section-header action + icon glyphs: their p-1 buttons sit in an mr-2.5 container, so the + glyphs end 4px further in (10px + 4px = 14px = mr-3.5). */} + {count} ); diff --git a/src/components/layout/sidebar/SidebarRoutineItem.tsx b/src/components/layout/sidebar/SidebarRoutineItem.tsx index 8e0af1c8..baee293d 100644 --- a/src/components/layout/sidebar/SidebarRoutineItem.tsx +++ b/src/components/layout/sidebar/SidebarRoutineItem.tsx @@ -120,9 +120,9 @@ export const SidebarRoutineItem = ({
{t("sidebar.parameters")} - {/* mr-2.5 lines the count up with the group counts of - Functions / Procedures (px-2 + mr-2.5 = same edge). */} - + {/* mr-3.5 lines the count up with the group counts of + Functions / Procedures (px-2 + mr-3.5 = same edge). */} + {parameters.length}
@@ -148,9 +148,9 @@ export const SidebarRoutineItem = ({ {mode} )} - {/* px-3 (12px) + mr-1.5 (6px) ends at the same - 18px right edge as the group / parameter counts. */} - + {/* px-3 (12px) + mr-2.5 (10px) ends at the same + 22px right edge as the group / parameter counts. */} + {param.data_type}