Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .claude/skills/tabularis-plugin-driver/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions plugins/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,87 @@ pub async fn get_routine_definition<R: Runtime>(
.await
}

#[tauri::command]
pub async fn build_routine_call_sql<R: Runtime>(
app: AppHandle<R>,
connection_id: String,
routine_name: String,
routine_type: String,
args: Vec<crate::models::RoutineCallArg>,
schema: Option<String>,
) -> Result<String, String> {
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(
&params,
&routine_name,
&routine_type,
&args,
schema.as_deref(),
)
.await
}

#[tauri::command]
pub async fn get_routine_create_template<R: Runtime>(
app: AppHandle<R>,
connection_id: String,
routine_type: String,
schema: Option<String>,
) -> Result<String, String> {
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<R: Runtime>(
app: AppHandle<R>,
connection_id: String,
routine_name: String,
routine_type: String,
schema: Option<String>,
) -> Result<String, String> {
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(&params, &routine_name, &routine_type, schema.as_deref())
.await
}

#[tauri::command]
pub async fn drop_routine<R: Runtime>(
app: AppHandle<R>,
connection_id: String,
routine_name: String,
routine_type: String,
schema: Option<String>,
) -> 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(&params, &routine_name, &routine_type, schema.as_deref())
.await
}

#[tauri::command]
pub async fn get_schema_snapshot<R: Runtime>(
app: AppHandle<R>,
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/drivers/common.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod blob;
mod query;
mod routines;
mod safe_int;

#[cfg(test)]
Expand All @@ -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,
};
74 changes: 74 additions & 0 deletions src-tauri/src/drivers/common/routines.rs
Original file line number Diff line number Diff line change
@@ -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, &quote.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<String> = 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)
)
}
44 changes: 44 additions & 0 deletions src-tauri/src/drivers/common/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\""
);
}
}
89 changes: 88 additions & 1 deletion src-tauri/src/drivers/driver_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -404,6 +410,87 @@ pub trait DatabaseDriver: Send + Sync {
schema: Option<&str>,
) -> Result<String, String>;

// --- 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<String, String> {
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<String, String> {
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<String, String> {
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(
Expand Down
Loading