From 42fc99b1283ca89326a76cdcd395e1430bbc7d2a Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 24 Sep 2026 17:43:20 +0200 Subject: [PATCH] feat: add optional SQL Server table query templates (#26) --- .tabularium | 1 + CHANGELOG.md | 7 ++ README.md | 1 + docs/query-templates.md | 82 +++++++++++++++ src/driver/mod.rs | 1 + src/driver/query_templates.rs | 78 +++++++++++++++ src/driver/query_templates/tests.rs | 99 +++++++++++++++++++ src/handlers/mod.rs | 1 + src/handlers/query_templates.rs | 11 +++ src/rpc.rs | 11 ++- tests/capture_conformance.py | 12 +++ tests/conformance.rs | 1 + .../conformance/get_table_query_template.json | 5 + tests/live_db.rs | 26 +++++ 14 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 docs/query-templates.md create mode 100644 src/driver/query_templates.rs create mode 100644 src/driver/query_templates/tests.rs create mode 100644 src/handlers/query_templates.rs create mode 100644 tests/fixtures/conformance/get_table_query_template.json diff --git a/.tabularium b/.tabularium index 85e457b..e90433e 100644 --- a/.tabularium +++ b/.tabularium @@ -76,6 +76,7 @@ "views": true, "routines": true, "routine_management": true, + "table_query_templates": true, "triggers": true, "user_management": true, "file_based": false, diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b80df..621945c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Opt-in `get_table_query_template` RPC for driver-owned SELECT, UPDATE and + DELETE previews, with SQL Server `TOP`, schema qualification and identifier + quoting (#26). Older hosts keep their existing generation path; no minimum + runtime version increase is required for this optional extension. + ### Fixed - Preserve explicit outer `TOP` and `OFFSET/FETCH` limits instead of adding diff --git a/README.md b/README.md index abdb484..963ae79 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ This plugin enables Tabularis to connect to SQL Server instances, providing sche - Microsoft's `mssql-tds` protocol implementation through `mssql-tiberius-bridge`, with `deadpool` connection pooling, session reset (`sp_reset_connection`), startup scripts, and pool lifecycle handling - Schema, table, column, PK/FK, index, view, routine, and trigger introspection - Query execution with pagination, CTE/DML classification, multiple result sets, and session-preserving batches +- Driver-owned SELECT/UPDATE/DELETE previews on hosts supporting optional [SQL templates](docs/query-templates.md), including SQL Server `TOP` syntax - Accurate affected rows, including multi-statement DML and DML `OUTPUT` - INSERT/UPDATE/DELETE with composite primary keys and safe `IDENTITY_INSERT` recovery - Table/view/index/foreign-key DDL and safe `ALTER COLUMN` generation diff --git a/docs/query-templates.md b/docs/query-templates.md new file mode 100644 index 0000000..56e5427 --- /dev/null +++ b/docs/query-templates.md @@ -0,0 +1,82 @@ +# Optional table query templates + +The plugin advertises `capabilities.table_query_templates: true` and implements +`get_table_query_template` for compatible Tabularis hosts. The request and result +are additive: existing RPC methods and the minimum runtime version are unchanged. + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "get_table_query_template", + "params": { + "params": { "driver": "sqlserver" }, + "request": { + "table": "orders", + "schema": "sales", + "kind": "select", + "columns": ["id", "status"], + "limit": 100 + } + } +} +``` + +The result is a string: + +```sql +SELECT TOP (100) + [id], + [status] +FROM [sales].[orders]; +``` + +- `kind`: `select`, `update` or `delete`. +- Names are unquoted identifiers. The plugin applies SQL Server bracket escaping. +- `columns` defaults to `[]`; SELECT then uses `*`, UPDATE emits a placeholder. +- Missing/null `schema` uses `dbo`; missing/null `limit` leaves SELECT unbounded. +- `limit` is a non-negative u32, including zero, and is rejected for UPDATE/DELETE. +- UPDATE emits unique `:value_N` host-editor placeholders. Both UPDATE and DELETE + include `WHERE 1 = 0` so the preview cannot accidentally modify all rows. +- This method does not open a database connection or execute SQL. + +## Compatibility and rollout + +1. Merge the pagination fix in [PR #30](https://github.com/TabularisDB/tabularis-sqlserver-plugin/pull/30). +2. Register the optional capability in Tabularium's driver-kind schema (below). +3. Release Tabularis with the optional template RPC. Drivers without the capability + retain legacy generation. Only a remote `-32601` selects the legacy fallback; + real errors and malformed results are surfaced. +4. Release this plugin's template support. The full issue #26 fix requires both + the host extension and the plugin pagination fix. Older hosts continue working, + but their Generate SQL dialog still uses the old generation logic. + +CREATE TABLE inspection remains unchanged in this extension. It is separate from +these query templates and from the existing DDL RPC contract. + +## Tabularium + +Tabularium distributes and validates the manifest; it does not participate in +runtime SQL generation. Register the following optional property under the driver +kind's `capabilities.properties` for validation and generated docs: + +```json +{ + "table_query_templates": { + "type": "boolean", + "default": false, + "description": "Generate SQL SELECT/UPDATE/DELETE previews through the optional get_table_query_template RPC." + } +} +``` + +Do not replace the other capability definitions or add this flag to `required`. +Although the registry's current capabilities schema allows additional properties, +its ingestion calls `validateManifest` with `lenient: true` (AJV +`removeAdditional: 'all'`). Undeclared capability keys are therefore stripped from +the normalized registry metadata. Register the property **before publishing** the +plugin release; if it was already ingested, refresh its manifest afterward. + +This is a registry administrator's schema/configuration update, not a backend or +SDK protocol change. No database migration, new API endpoint, historical release +archive rewrite or minimum-host-version increase is needed. diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 5239ecd..08cb71a 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -13,6 +13,7 @@ pub mod helpers; pub mod introspection; pub mod ops; pub mod pool; +pub mod query_templates; pub mod routines; pub mod triggers; pub mod types; diff --git a/src/driver/query_templates.rs b/src/driver/query_templates.rs new file mode 100644 index 0000000..391f7be --- /dev/null +++ b/src/driver/query_templates.rs @@ -0,0 +1,78 @@ +//! Pure, opt-in SQL previews for the host's Generate SQL dialog. + +use serde::Deserialize; + +use super::helpers::{bracket_quote, qualify}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TemplateKind { + Select, + Update, + Delete, +} + +#[derive(Debug, Deserialize)] +pub struct TemplateRequest { + pub table: String, + pub schema: Option, + pub kind: TemplateKind, + #[serde(default)] + pub columns: Vec, + pub limit: Option, +} + +pub fn build(request: &TemplateRequest) -> Result { + if request.table.trim().is_empty() || request.columns.iter().any(|name| name.trim().is_empty()) + { + return Err("Table and column names must not be empty".into()); + } + let target = qualify(request.schema.as_deref(), &request.table); + match request.kind { + TemplateKind::Select => { + let top = request + .limit + .map(|limit| format!(" TOP ({limit})")) + .unwrap_or_default(); + let fields = if request.columns.is_empty() { + " *".to_string() + } else { + format!( + "\n{}", + request + .columns + .iter() + .map(|name| format!(" {}", bracket_quote(name))) + .collect::>() + .join(",\n") + ) + }; + Ok(format!("SELECT{top}{fields}\nFROM {target};")) + } + TemplateKind::Update | TemplateKind::Delete if request.limit.is_some() => { + Err("Template limit is only supported for SELECT".into()) + } + TemplateKind::Update => { + let assignments = if request.columns.is_empty() { + " [column] = :value_1".to_string() + } else { + request + .columns + .iter() + .enumerate() + .map(|(index, name)| { + // Host editor placeholders, not SQL Server @parameters. + // Ordinals prevent collisions for similarly named columns. + format!(" {} = :value_{}", bracket_quote(name), index + 1) + }) + .collect::>() + .join(",\n") + }; + Ok(format!("UPDATE {target}\nSET\n{assignments}\nWHERE 1 = 0;")) + } + TemplateKind::Delete => Ok(format!("DELETE\nFROM {target}\nWHERE 1 = 0;")), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/driver/query_templates/tests.rs b/src/driver/query_templates/tests.rs new file mode 100644 index 0000000..a3a878e --- /dev/null +++ b/src/driver/query_templates/tests.rs @@ -0,0 +1,99 @@ +use super::*; +use serde_json::json; + +fn request(kind: &str, columns: &[&str], limit: Option) -> TemplateRequest { + serde_json::from_value(json!({ + "table": "order]details", "schema": "sales]archive", "kind": kind, + "columns": columns, "limit": limit, + })) + .unwrap() +} + +#[test] +fn select_templates_use_top_and_quote_every_identifier() { + assert_eq!( + build(&request("select", &["id", "order]name"], Some(100))).unwrap(), + "SELECT TOP (100)\n [id],\n [order]]name]\nFROM [sales]]archive].[order]]details];" + ); + assert_eq!( + build(&request("select", &[], Some(100))).unwrap(), + "SELECT TOP (100) *\nFROM [sales]]archive].[order]]details];" + ); + assert_eq!( + build(&request("select", &[], None)).unwrap(), + "SELECT *\nFROM [sales]]archive].[order]]details];" + ); + assert!(build(&request("select", &[], Some(0))) + .unwrap() + .contains("TOP (0)")); +} + +#[test] +fn modification_templates_are_guarded_and_have_unique_host_placeholders() { + assert_eq!(build(&request("update", &["a b", "a-b", "1"], None)).unwrap(), + "UPDATE [sales]]archive].[order]]details]\nSET\n [a b] = :value_1,\n [a-b] = :value_2,\n [1] = :value_3\nWHERE 1 = 0;"); + assert!(build(&request("update", &[], None)) + .unwrap() + .contains("[column] = :value_1")); + assert_eq!( + build(&request("delete", &[], None)).unwrap(), + "DELETE\nFROM [sales]]archive].[order]]details]\nWHERE 1 = 0;" + ); + assert!(build(&request("update", &[], Some(100))).is_err()); + assert!(build(&request("delete", &[], Some(100))).is_err()); +} + +#[test] +fn omitted_optional_fields_keep_default_schema_and_unbounded_select() { + let request = serde_json::from_value(json!({ "kind": "select", "table": "users" })).unwrap(); + assert_eq!(build(&request).unwrap(), "SELECT *\nFROM [dbo].[users];"); + let mut invalid = request; + invalid.table = " ".into(); + assert!(build(&invalid).is_err()); + assert!(build(&self::request("select", &[""], None)).is_err()); +} + +#[test] +fn rpc_generates_without_a_connection_and_rejects_malformed_inputs() { + // Match the production worker stack: the shared async dispatcher also + // contains large driver futures unrelated to this pure generation RPC. + let response = std::thread::Builder::new() + .stack_size(crate::WORKER_STACK_SIZE) + .spawn(|| { + let line = json!({ + "jsonrpc": "2.0", "id": 17, "method": "get_table_query_template", + "params": { "request": { "table": "users", "kind": "select", "limit": 100 } }, + }) + .to_string(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(Box::pin(crate::rpc::handle_line(&line))) + }) + .unwrap() + .join() + .unwrap(); + assert_eq!(response["id"], 17); + assert_eq!( + response["result"], + "SELECT TOP (100) *\nFROM [dbo].[users];" + ); + for request in [ + json!({ "table": "users", "kind": "drop" }), + json!({ "table": "users", "kind": "select", "limit": -1 }), + json!({ "table": "users", "kind": "select", "limit": "100; DROP TABLE users" }), + ] { + let response = crate::handlers::query_templates::get_table_query_template( + json!(1), + &json!({ "request": request }), + ); + assert!(response.get("error").is_some()); + } + assert!( + serde_json::from_str::(include_str!("../../../.tabularium")).unwrap() + ["capabilities"]["table_query_templates"] + .as_bool() + .unwrap() + ); +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 26f91e5..07a3113 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -3,6 +3,7 @@ pub mod crud; pub mod ddl; pub mod metadata; pub mod query; +pub mod query_templates; pub mod routines; pub mod triggers; pub mod users; diff --git a/src/handlers/query_templates.rs b/src/handlers/query_templates.rs new file mode 100644 index 0000000..3965b47 --- /dev/null +++ b/src/handlers/query_templates.rs @@ -0,0 +1,11 @@ +use serde_json::Value; + +use crate::driver::query_templates::{self, TemplateRequest}; +use crate::rpc::{req_field, respond}; + +/// Generation is pure: do not acquire a connection or execute the preview. +pub fn get_table_query_template(id: Value, params: &Value) -> Value { + let result = req_field::(params, "request") + .and_then(|request| query_templates::build(&request)); + respond(id, result) +} diff --git a/src/rpc.rs b/src/rpc.rs index 2bfb00c..926a423 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -5,7 +5,9 @@ use serde_json::{json, Value}; use crate::connection::resolve_connection_params; use crate::driver::error::redact_connection_secrets; -use crate::handlers::{blob, crud, ddl, metadata, query, routines, triggers, users, views}; +use crate::handlers::{ + blob, crud, ddl, metadata, query, query_templates, routines, triggers, users, views, +}; use crate::models::ConnectionParams; use crate::{pool_manager, settings}; @@ -125,6 +127,9 @@ pub async fn handle_line(line: &str) -> Value { "save_blob_to_file" => blob::save_blob_to_file(id, ¶ms).await, "fetch_blob_as_data_url" => blob::fetch_blob_as_data_url(id, ¶ms).await, + // Optional query previews (no SQL execution). + "get_table_query_template" => query_templates::get_table_query_template(id, ¶ms), + // DDL. "get_create_table_sql" => ddl::get_create_table_sql(id, ¶ms).await, "get_add_column_sql" => ddl::get_add_column_sql(id, ¶ms).await, @@ -227,7 +232,8 @@ mod tests { /// Snapshot extracted from every literal `PluginProcess::call` and /// `call_with_timeout` in Tabularis - /// `src-tauri/src/plugins/driver.rs` at core commit 9e6975aa. + /// `src-tauri/src/plugins/driver.rs` at core commit 9e6975aa, + /// plus the optional get_table_query_template extension for issue #26. const HOST_METHODS: &[&str] = &[ "initialize", "ping", @@ -263,6 +269,7 @@ mod tests { "delete_record", "save_blob_to_file", "fetch_blob_as_data_url", + "get_table_query_template", "get_create_table_sql", "get_add_column_sql", "get_alter_column_sql", diff --git a/tests/capture_conformance.py b/tests/capture_conformance.py index a2983af..d386fca 100644 --- a/tests/capture_conformance.py +++ b/tests/capture_conformance.py @@ -360,6 +360,17 @@ def capture(method: str, values: dict[str, Any]) -> Any: ), ) + capture( + "get_table_query_template", + rpc_params(request={ + "schema": SCHEMA, + "table": "generated_table", + "kind": "select", + "columns": ["id", "value"], + "limit": 100, + }), + ) + column = { "name": "value", "data_type": "NVARCHAR(40)", @@ -481,6 +492,7 @@ def capture(method: str, values: dict[str, Any]) -> Any: "delete_record", "save_blob_to_file", "fetch_blob_as_data_url", + "get_table_query_template", "get_create_table_sql", "get_add_column_sql", "get_alter_column_sql", diff --git a/tests/conformance.rs b/tests/conformance.rs index 011ad6f..0c03211 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -315,6 +315,7 @@ fn every_recorded_result_deserializes_into_the_host_target() { assert_deserializes::( &[ "get_view_definition", + "get_table_query_template", "get_routine_definition", "build_routine_call_sql", "routine_create_template", diff --git a/tests/fixtures/conformance/get_table_query_template.json b/tests/fixtures/conformance/get_table_query_template.json new file mode 100644 index 0000000..5970be5 --- /dev/null +++ b/tests/fixtures/conformance/get_table_query_template.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "SELECT TOP (100)\n [id],\n [value]\nFROM [ss044].[generated_table];" +} diff --git a/tests/live_db.rs b/tests/live_db.rs index 935c883..b2f318d 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -1371,6 +1371,32 @@ fn explicit_row_limits_bypass_host_pagination_in_single_and_batch_queries() { } } +#[test] +fn generated_select_template_executes_without_conflicting_host_pagination() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table("query_templates", "id INT PRIMARY KEY, label NVARCHAR(30)"); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[query_templates] VALUES (1, N'one'), (2, N'two'), (3, N'three')" + )); + let template = plugin.call_ok( + "get_table_query_template", + json!({ + "params": connection_params(), + "request": { "table": "query_templates", "schema": TEST_SCHEMA, + "kind": "select", "columns": ["id", "label"], "limit": 2 } + }), + ); + assert!(template.as_str().unwrap().starts_with("SELECT TOP (2)")); + let result = plugin.call_ok( + "execute_query", + json!({ "params": connection_params(), "query": template, "limit": 1, "page": 4 }), + ); + assert_eq!(result["columns"], json!(["id", "label"])); + assert_eq!(result_rows(&result).len(), 2); + assert_eq!(result["pagination"], Value::Null); + assert_eq!(result["truncated"], false); +} + #[test] fn million_row_query_is_bounded_and_marks_truncation() { let mut plugin = Plugin::with_scratch_database();