Skip to content
Draft
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
1 change: 1 addition & 0 deletions .tabularium
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"views": true,
"routines": true,
"routine_management": true,
"table_query_templates": true,
"triggers": true,
"user_management": true,
"file_based": false,
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions docs/query-templates.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/driver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
78 changes: 78 additions & 0 deletions src/driver/query_templates.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub kind: TemplateKind,
#[serde(default)]
pub columns: Vec<String>,
pub limit: Option<u32>,
}

pub fn build(request: &TemplateRequest) -> Result<String, String> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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;
99 changes: 99 additions & 0 deletions src/driver/query_templates/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use super::*;
use serde_json::json;

fn request(kind: &str, columns: &[&str], limit: Option<u32>) -> 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::<serde_json::Value>(include_str!("../../../.tabularium")).unwrap()
["capabilities"]["table_query_templates"]
.as_bool()
.unwrap()
);
}
1 change: 1 addition & 0 deletions src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/handlers/query_templates.rs
Original file line number Diff line number Diff line change
@@ -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::<TemplateRequest>(params, "request")
.and_then(|request| query_templates::build(&request));
respond(id, result)
}
11 changes: 9 additions & 2 deletions src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -125,6 +127,9 @@ pub async fn handle_line(line: &str) -> Value {
"save_blob_to_file" => blob::save_blob_to_file(id, &params).await,
"fetch_blob_as_data_url" => blob::fetch_blob_as_data_url(id, &params).await,

// Optional query previews (no SQL execution).
"get_table_query_template" => query_templates::get_table_query_template(id, &params),

// DDL.
"get_create_table_sql" => ddl::get_create_table_sql(id, &params).await,
"get_add_column_sql" => ddl::get_add_column_sql(id, &params).await,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions tests/capture_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ fn every_recorded_result_deserializes_into_the_host_target() {
assert_deserializes::<String>(
&[
"get_view_definition",
"get_table_query_template",
"get_routine_definition",
"build_routine_call_sql",
"routine_create_template",
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/conformance/get_table_query_template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"id": 1,
"jsonrpc": "2.0",
"result": "SELECT TOP (100)\n [id],\n [value]\nFROM [ss044].[generated_table];"
}
Loading