From b2ea9afb479e464c1dc1a4dbfb59b418232ac7e2 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 11:07:15 +0000 Subject: [PATCH 01/23] docs: record plugin completeness gaps --- docs/completeness.md | 78 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/completeness.md diff --git a/docs/completeness.md b/docs/completeness.md new file mode 100644 index 0000000..d501b39 --- /dev/null +++ b/docs/completeness.md @@ -0,0 +1,78 @@ +# Plugin completeness + +This document records the remaining gaps between the SQL Server plugin and the +Tabularis host protocol and plugin registry. It is the repository-local +checklist for the completeness work; task identifiers refer to the project +plan used to deliver that work. + +## Host protocol + +The plugin already supports connection testing, schema introspection, query +execution, CRUD, DDL, views, routines, triggers, and visual EXPLAIN. The +remaining protocol gaps are: + +- `initialize` accepts the request but ignores its settings. `SS-013` will + validate and apply plugin settings. +- `save_blob_to_file` and `fetch_blob_as_data_url` return JSON-RPC `-32601`. + `SS-012` will add BLOB export and preview. +- The database-user and privilege surface is absent. `SS-014` will implement + `get_db_privilege_catalog`, `get_db_users`, `create_db_user`, `drop_db_user`, + `set_db_user_password`, `get_db_user_privileges`, + `apply_db_user_privileges`, and `get_db_user_grants`. +- `shutdown` and the materialized-view methods return `-32601`. `SS-015` will + make these deliberate refusals, with tests and documentation: the host does + not send `shutdown`, and SQL Server has indexed views rather than + materialized views. +- `explain_query` currently returns an in-process parsed plan. `SS-035` will + return raw `sqlserver-showplan-xml` after the plugin-owned parser contract + and host support are available. + +## Connection parameters + +The manifest advertises connection-string support, but `ConnectionParams` +does not contain `connection_string`; only discrete host, port, username, +password, and database fields work. `SS-011` will make the declared capability +functional. + +SQL authentication remains the supported authentication mechanism. Azure AD, +Windows Integrated Authentication, custom CA files, and client certificates +are outside the completion scope. + +## Manifest and settings + +The current `.tabularium` file is a scaffold manifest rather than a +registry-grade manifest. It still needs: + +- the registry schema URL and the `kind`, `engine`, `paradigms`, `category`, + `tags`, and `license` metadata; +- icon, color, and screenshot assets; +- readme, homepage, documentation, and support links; +- `min_runtime_version` and type mappings; +- a settings declaration for the `initialize` implementation; and +- the plugin-owned `explain_parsers` declaration once the host supports it. + +`SS-013`, `SS-020`, `SS-021`, `SS-030`, `SS-034`, and `SS-035` cover these +items. + +## CI and release packaging + +The Rust build, test, Clippy, formatting, live SQL Server integration, and +scheduled security-audit checks exist. Release readiness still requires: + +- registry manifest validation; +- Conventional Commit pull-request title checks; +- version-suggestion and Markdown lint jobs; +- Dependabot configuration; +- release validation that the tag and manifest versions agree; +- build and test coverage for the future `explain/` package; +- corrected developer install recipes; and +- registry assets and per-platform release archives. + +These gaps are covered by `SS-022` through `SS-024` and `SS-034`. + +## Distribution + +There is no SQL Server entry in the Tabularis plugin registry, no published +GitHub release containing per-platform archives and a manifest asset, and no +published `@tabularis/explain-sqlserver` package. `SS-024` and `SS-034` add +those distribution paths after their prerequisites land. From 8dfc0a465a9d86b78cba12624714ab919d9b47b7 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 11:16:02 +0000 Subject: [PATCH 02/23] feat: support SQL Server connection strings --- README.md | 30 ++ src/connection.rs | 774 ++++++++++++++++++++++++++++++++++++++++++++ src/driver/pool.rs | 7 +- src/main.rs | 1 + src/models.rs | 3 + src/pool_manager.rs | 28 +- src/rpc.rs | 7 +- tests/live_db.rs | 59 +++- 8 files changed, 884 insertions(+), 25 deletions(-) create mode 100644 src/connection.rs diff --git a/README.md b/README.md index d6cf602..f4888e8 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,38 @@ The client was swapped to Microsoft's protocol implementation to align the plugi | Username | `sa` | SQL authentication only | | Password | — | | | Database | — | The database the pool connects to | +| Connection string | — | `sqlserver://…` URL or ADO.NET/ODBC keyword syntax | | Startup script | — | SQL run on every new pooled connection (e.g. `SET` options) | +### Connection strings + +The connection string accepts either URL syntax: + +```text +sqlserver://sa:p%40ssword@localhost:1433/master?Encrypt=true&TrustServerCertificate=true +``` + +or ADO.NET/ODBC keyword syntax. Keyword names are case-insensitive, common +aliases (`Data Source`, `Initial Catalog`, `UID`, and `PWD`) are accepted, and +braces preserve semicolons inside values: + +```text +Server=tcp:localhost,1433;Database=master;User Id=sa;Password={p;assword};Encrypt=true;TrustServerCertificate=true; +``` + +A connection string may be combined with discrete fields. Values explicitly +present in the string are authoritative, while discrete fields fill only +fields the string omits. Repeating the same value is allowed; contradictory +values are rejected with an error that identifies the discrete and +connection-string values instead of silently choosing one. Password values +are redacted in contradiction errors. + +`Encrypt=false` maps to `ssl_mode=disable`; encrypted connections with +`TrustServerCertificate=true` map to `require`; encrypted connections that +verify the certificate map to `verify-full`. Custom CA and client-certificate +keywords are rejected under the same limitations as their discrete-field +counterparts. + ### TLS modes The standard Tabularis `ssl_mode` values map onto the TDS encryption policy: diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..96573eb --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,774 @@ +//! SQL Server connection-string parsing and reconciliation. +//! +//! Tabularis may send discrete connection fields, a connection string, or a +//! mixture of both. Connection-string values are authoritative, while +//! discrete values fill fields omitted by the string. Supplying two different +//! values for the same field is rejected instead of choosing one silently. + +use crate::models::{ConnectionParams, DatabaseSelection}; + +const CUSTOM_CA_ERROR: &str = + "SQL Server custom CA files are not supported; use verify-full with the system trust store"; + +#[derive(Debug, Default, PartialEq)] +struct ParsedConnectionString { + host: Option, + port: Option, + username: Option, + password: Option, + database: Option, + ssl_mode: Option, + ssl_ca: Option, + ssl_cert: Option, + ssl_key: Option, + encrypt: Option, + trust_server_certificate: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EncryptSetting { + Disabled, + Enabled, + Strict, +} + +/// Return canonical connection fields suitable for both config construction +/// and pool-cache keying. +pub fn resolve_connection_params(params: &ConnectionParams) -> Result { + let mut resolved = params.clone(); + if resolved.driver.trim().is_empty() { + resolved.driver = "sqlserver".into(); + } + resolved.ssl_mode = non_empty(resolved.ssl_mode.take()) + .map(|mode| normalize_ssl_mode(&mode)) + .transpose()?; + + let Some(connection_string) = params + .connection_string + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(resolved); + }; + + let parsed = ParsedConnectionString::parse(connection_string)?; + reconcile_string( + "host", + &mut resolved.host, + parsed.host, + |left, right| left.eq_ignore_ascii_case(right), + false, + )?; + reconcile_value("port", &mut resolved.port, parsed.port)?; + reconcile_string( + "username", + &mut resolved.username, + parsed.username, + str::eq, + false, + )?; + reconcile_string( + "password", + &mut resolved.password, + parsed.password, + str::eq, + true, + )?; + + if let Some(database) = parsed.database { + let discrete = resolved.database.primary().trim(); + if !discrete.is_empty() && discrete != database { + return Err(contradiction("database", discrete, &database, false)); + } + resolved.database = DatabaseSelection::Single(database); + } + + reconcile_string( + "ssl_mode", + &mut resolved.ssl_mode, + parsed.ssl_mode, + str::eq, + false, + )?; + reconcile_string( + "ssl_ca", + &mut resolved.ssl_ca, + parsed.ssl_ca, + str::eq, + false, + )?; + reconcile_string( + "ssl_cert", + &mut resolved.ssl_cert, + parsed.ssl_cert, + str::eq, + false, + )?; + reconcile_string( + "ssl_key", + &mut resolved.ssl_key, + parsed.ssl_key, + str::eq, + false, + )?; + + Ok(resolved) +} + +impl ParsedConnectionString { + fn parse(input: &str) -> Result { + let mut parsed = if starts_with_ignore_ascii_case(input, "sqlserver://") { + Self::parse_url(input)? + } else if input.contains("://") { + return Err( + "invalid SQL Server connection string: URL scheme must be sqlserver".to_string(), + ); + } else { + Self::parse_keywords(input)? + }; + parsed.finish_tls()?; + Ok(parsed) + } + + fn parse_url(input: &str) -> Result { + let rest = &input["sqlserver://".len()..]; + if rest.contains('#') { + return Err( + "invalid SQL Server URL connection string: fragments are not supported".into(), + ); + } + let (authority_and_path, query) = rest.split_once('?').unwrap_or((rest, "")); + let (authority, path) = authority_and_path + .split_once('/') + .map_or((authority_and_path, None), |(authority, path)| { + (authority, Some(path)) + }); + if authority.is_empty() { + return Err("invalid SQL Server URL connection string: host is missing".into()); + } + + let mut parsed = Self::default(); + let host_port = if let Some((userinfo, host_port)) = authority.rsplit_once('@') { + if userinfo.is_empty() { + return Err("invalid SQL Server URL connection string: username is empty".into()); + } + let (username, password) = userinfo + .split_once(':') + .map_or((userinfo, None), |(username, password)| { + (username, Some(password)) + }); + parsed.username = Some(percent_decode(username, false)?); + if let Some(password) = password { + parsed.password = Some(percent_decode(password, false)?); + } + host_port + } else { + authority + }; + let (host, port) = parse_url_host_port(host_port)?; + parsed.host = Some(percent_decode(&host, false)?); + parsed.port = port; + + if let Some(path) = path.filter(|path| !path.is_empty()) { + if path.contains('/') { + return Err( + "invalid SQL Server URL connection string: database must be one path segment" + .into(), + ); + } + parsed.database = Some(percent_decode(path, false)?); + } + + if !query.is_empty() { + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (key, value) = pair.split_once('=').ok_or_else(|| { + format!( + "invalid SQL Server URL connection string: query parameter '{pair}' has no value" + ) + })?; + parsed.apply_keyword(&percent_decode(key, true)?, percent_decode(value, true)?)?; + } + } + Ok(parsed) + } + + fn parse_keywords(input: &str) -> Result { + let pairs = parse_keyword_pairs(input)?; + if pairs.is_empty() { + return Err("invalid SQL Server keyword connection string: no key/value pairs".into()); + } + let mut parsed = Self::default(); + for (key, value) in pairs { + parsed.apply_keyword(&key, value)?; + } + Ok(parsed) + } + + fn apply_keyword(&mut self, key: &str, value: String) -> Result<(), String> { + let canonical = canonical_key(key); + match canonical.as_str() { + "server" | "datasource" | "address" | "addr" | "networkaddress" | "host" => { + let (host, port) = parse_server_value(&value)?; + set_string(&mut self.host, host, "server")?; + if let Some(port) = port { + set_value(&mut self.port, port, "port")?; + } + } + "port" => { + let port = parse_port(&value)?; + set_value(&mut self.port, port, "port")?; + } + "database" | "initialcatalog" => { + set_string(&mut self.database, value, "database")?; + } + "userid" | "uid" | "user" | "username" => { + set_string(&mut self.username, value, "username")?; + } + "password" | "pwd" => set_string(&mut self.password, value, "password")?, + "encrypt" => { + let encrypt = parse_encrypt(&value)?; + set_value(&mut self.encrypt, encrypt, "Encrypt")?; + } + "trustservercertificate" => { + let trust = parse_bool("TrustServerCertificate", &value)?; + set_value( + &mut self.trust_server_certificate, + trust, + "TrustServerCertificate", + )?; + } + "sslmode" => { + let mode = normalize_ssl_mode(&value)?; + set_string(&mut self.ssl_mode, mode, "ssl_mode")?; + } + "sslca" | "cafile" | "truststore" | "servercertificate" => { + set_string(&mut self.ssl_ca, value, "ssl_ca")?; + } + "sslcert" | "clientcertificate" => { + set_string(&mut self.ssl_cert, value, "ssl_cert")?; + } + "sslkey" | "clientkey" => set_string(&mut self.ssl_key, value, "ssl_key")?, + "integratedsecurity" | "trustedconnection" => { + if parse_bool(key, &value)? { + return Err( + "SQL Server Integrated Authentication is not supported; use User Id and Password" + .into(), + ); + } + } + "authentication" => { + if !value.eq_ignore_ascii_case("SqlPassword") + && !value.eq_ignore_ascii_case("NotSpecified") + { + return Err(format!( + "SQL Server authentication mode '{value}' is not supported; use SqlPassword" + )); + } + } + // These common client-side options do not change the server, + // credentials, database, or TLS identity represented by a pool. + "driver" + | "applicationname" + | "connecttimeout" + | "connectiontimeout" + | "timeout" + | "multipleactiveresultsets" + | "marsconnection" + | "persistsecurityinfo" + | "pooling" => {} + _ => { + return Err(format!( + "unsupported SQL Server connection string keyword '{key}'" + )); + } + } + Ok(()) + } + + fn finish_tls(&mut self) -> Result<(), String> { + let from_keywords = match (self.encrypt, self.trust_server_certificate) { + (Some(EncryptSetting::Disabled), _) => Some("disable"), + (Some(EncryptSetting::Enabled), Some(true)) => Some("require"), + (Some(EncryptSetting::Enabled), _) => Some("verify-full"), + (Some(EncryptSetting::Strict), Some(true)) => { + return Err( + "invalid SQL Server TLS settings: Encrypt=Strict contradicts TrustServerCertificate=true" + .into(), + ); + } + (Some(EncryptSetting::Strict), _) => Some("verify-full"), + (None, Some(true)) => Some("prefer"), + (None, Some(false)) => Some("verify-full"), + (None, None) => None, + }; + if let Some(mode) = from_keywords { + set_string(&mut self.ssl_mode, mode.to_string(), "ssl_mode")?; + } + Ok(()) + } +} + +fn parse_keyword_pairs(input: &str) -> Result, String> { + let bytes = input.as_bytes(); + let mut index = 0; + let mut pairs = Vec::new(); + + while index < bytes.len() { + while index < bytes.len() && (bytes[index] == b';' || bytes[index].is_ascii_whitespace()) { + index += 1; + } + if index == bytes.len() { + break; + } + + let key_start = index; + while index < bytes.len() && bytes[index] != b'=' && bytes[index] != b';' { + index += 1; + } + if index == bytes.len() || bytes[index] != b'=' { + let segment = input[key_start..index].trim(); + return Err(format!( + "invalid SQL Server keyword connection string: '{segment}' has no '='" + )); + } + let key = input[key_start..index].trim(); + if key.is_empty() { + return Err("invalid SQL Server keyword connection string: empty keyword".into()); + } + index += 1; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + + let value = if index < bytes.len() && bytes[index] == b'{' { + index += 1; + let mut value = String::new(); + let mut closed = false; + while index < bytes.len() { + if bytes[index] == b'}' { + if index + 1 < bytes.len() && bytes[index + 1] == b'}' { + value.push('}'); + index += 2; + } else { + index += 1; + closed = true; + break; + } + } else { + let character = input[index..] + .chars() + .next() + .expect("index is within the input"); + value.push(character); + index += character.len_utf8(); + } + } + if !closed { + return Err(format!( + "invalid SQL Server keyword connection string: unclosed braced value for '{key}'" + )); + } + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + if index < bytes.len() && bytes[index] != b';' { + return Err(format!( + "invalid SQL Server keyword connection string: unexpected text after braced value for '{key}'" + )); + } + value + } else { + let value_start = index; + while index < bytes.len() && bytes[index] != b';' { + index += 1; + } + input[value_start..index].trim().to_string() + }; + pairs.push((key.to_string(), value)); + if index < bytes.len() { + index += 1; + } + } + + Ok(pairs) +} + +fn parse_url_host_port(value: &str) -> Result<(String, Option), String> { + if let Some(rest) = value.strip_prefix('[') { + let closing = rest.find(']').ok_or_else(|| { + "invalid SQL Server URL connection string: unclosed IPv6 host".to_string() + })?; + let host = &rest[..closing]; + if host.is_empty() { + return Err("invalid SQL Server URL connection string: host is empty".into()); + } + let suffix = &rest[closing + 1..]; + let port = if suffix.is_empty() { + None + } else { + let raw = suffix.strip_prefix(':').ok_or_else(|| { + "invalid SQL Server URL connection string: unexpected text after host".to_string() + })?; + Some(parse_port(raw)?) + }; + return Ok((host.to_string(), port)); + } + + if value.matches(':').count() > 1 { + return Err( + "invalid SQL Server URL connection string: IPv6 hosts must use brackets".into(), + ); + } + let (host, port) = value + .rsplit_once(':') + .map_or((value, None), |(host, port)| (host, Some(port))); + if host.is_empty() { + return Err("invalid SQL Server URL connection string: host is empty".into()); + } + Ok((host.to_string(), port.map(parse_port).transpose()?)) +} + +fn parse_server_value(value: &str) -> Result<(String, Option), String> { + let value = value.trim(); + let value = if starts_with_ignore_ascii_case(value, "tcp:") { + &value[4..] + } else { + value + }; + if value.is_empty() { + return Err("invalid SQL Server connection string: Server is empty".into()); + } + if value.contains('\\') { + return Err( + "SQL Server named instances are not supported; specify Server=host,port".into(), + ); + } + let (host, port) = value + .rsplit_once(',') + .map_or((value, None), |(host, port)| (host.trim(), Some(port))); + if host.is_empty() { + return Err("invalid SQL Server connection string: Server host is empty".into()); + } + Ok((host.to_string(), port.map(parse_port).transpose()?)) +} + +fn parse_port(value: &str) -> Result { + value.trim().parse::().map_err(|_| { + format!( + "invalid SQL Server connection string port '{}'", + value.trim() + ) + }) +} + +fn parse_encrypt(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" | "mandatory" => Ok(EncryptSetting::Enabled), + "false" | "no" | "off" | "0" | "optional" => Ok(EncryptSetting::Disabled), + "strict" => Ok(EncryptSetting::Strict), + _ => Err(format!( + "invalid Encrypt value '{value}'; expected true, false, optional, mandatory, or strict" + )), + } +} + +fn parse_bool(key: &str, value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Ok(true), + "false" | "no" | "off" | "0" => Ok(false), + _ => Err(format!( + "invalid {key} value '{value}'; expected true or false" + )), + } +} + +fn normalize_ssl_mode(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "disable" | "disabled" => Ok("disable".into()), + "prefer" | "preferred" => Ok("prefer".into()), + "require" | "required" => Ok("require".into()), + "verify-full" | "verify_identity" => Ok("verify-full".into()), + "verify-ca" | "verify_ca" => Ok("verify-ca".into()), + _ => Err(format!("unsupported SQL Server ssl_mode '{value}'")), + } +} + +fn percent_decode(input: &str, plus_as_space: bool) -> Result { + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(format!( + "invalid percent encoding in SQL Server URL component '{input}'" + )); + } + let high = hex_value(bytes[index + 1]); + let low = hex_value(bytes[index + 2]); + let (Some(high), Some(low)) = (high, low) else { + return Err(format!( + "invalid percent encoding in SQL Server URL component '{input}'" + )); + }; + decoded.push((high << 4) | low); + index += 3; + } + b'+' if plus_as_space => { + decoded.push(b' '); + index += 1; + } + value => { + decoded.push(value); + index += 1; + } + } + } + String::from_utf8(decoded) + .map_err(|_| "SQL Server URL contains percent-encoded non-UTF-8 data".to_string()) +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn canonical_key(key: &str) -> String { + key.chars() + .filter(|character| !character.is_ascii_whitespace() && !matches!(character, '_' | '-')) + .flat_map(char::to_lowercase) + .collect() +} + +fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool { + value + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) +} + +fn set_string(slot: &mut Option, value: String, field: &str) -> Result<(), String> { + if let Some(existing) = slot { + if existing != &value { + return Err(format!( + "SQL Server connection string specifies conflicting {field} values '{existing}' and '{value}'" + )); + } + } else { + *slot = Some(value); + } + Ok(()) +} + +fn set_value(slot: &mut Option, value: T, field: &str) -> Result<(), String> +where + T: Copy + PartialEq + std::fmt::Debug, +{ + if let Some(existing) = slot { + if existing != &value { + return Err(format!( + "SQL Server connection string specifies conflicting {field} values '{existing:?}' and '{value:?}'" + )); + } + } else { + *slot = Some(value); + } + Ok(()) +} + +fn reconcile_string( + field: &str, + discrete: &mut Option, + from_string: Option, + equals: impl Fn(&str, &str) -> bool, + sensitive: bool, +) -> Result<(), String> { + let Some(from_string) = from_string else { + *discrete = non_empty(discrete.take()); + return Ok(()); + }; + if let Some(value) = discrete.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + if !equals(value, &from_string) { + return Err(contradiction(field, value, &from_string, sensitive)); + } + } + *discrete = Some(from_string); + Ok(()) +} + +fn reconcile_value( + field: &str, + discrete: &mut Option, + from_string: Option, +) -> Result<(), String> +where + T: Copy + PartialEq + std::fmt::Display, +{ + if let Some(from_string) = from_string { + if let Some(discrete) = discrete { + if *discrete != from_string { + return Err(contradiction( + field, + &discrete.to_string(), + &from_string.to_string(), + false, + )); + } + } + *discrete = Some(from_string); + } + Ok(()) +} + +fn contradiction(field: &str, discrete: &str, from_string: &str, sensitive: bool) -> String { + let (discrete, from_string) = if sensitive { + ("", "") + } else { + (discrete, from_string) + }; + format!( + "connection parameter '{field}' contradicts the connection string: discrete value '{discrete}', connection-string value '{from_string}'" + ) +} + +fn non_empty(value: Option) -> Option { + value.filter(|value| !value.trim().is_empty()) +} + +pub fn custom_ca_error() -> &'static str { + CUSTOM_CA_ERROR +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(connection_string: &str) -> ConnectionParams { + ConnectionParams { + connection_string: Some(connection_string.into()), + ..Default::default() + } + } + + #[test] + fn parses_url_with_percent_encoded_credentials_and_tls_query() { + let resolved = resolve_connection_params(¶ms( + "sqlserver://user:p%40ss%3Aword@db.example:1444/catalog%20name?Encrypt=true&TrustServerCertificate=true", + )) + .unwrap(); + + assert_eq!(resolved.driver, "sqlserver"); + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1444)); + assert_eq!(resolved.username.as_deref(), Some("user")); + assert_eq!(resolved.password.as_deref(), Some("p@ss:word")); + assert_eq!(resolved.database.primary(), "catalog name"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn url_allows_omitted_port_and_database_and_uses_discrete_fallbacks() { + let mut input = params("sqlserver://url-user:url-password@db.example"); + input.port = Some(1433); + input.database = DatabaseSelection::Single("fallback_db".into()); + input.ssl_mode = Some("required".into()); + + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1433)); + assert_eq!(resolved.database.primary(), "fallback_db"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn parses_case_insensitive_keyword_aliases_and_braced_semicolon() { + let resolved = resolve_connection_params(¶ms( + "Data Source=tcp:db.example,1444;Initial Catalog=app;UID=sa;PWD={p;a}}ss};Encrypt=YES;Trust Server Certificate=TRUE;", + )) + .unwrap(); + + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1444)); + assert_eq!(resolved.database.primary(), "app"); + assert_eq!(resolved.username.as_deref(), Some("sa")); + assert_eq!(resolved.password.as_deref(), Some("p;a}ss")); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn tls_keywords_map_to_canonical_ssl_modes() { + let cases = [ + ("Encrypt=false", "disable"), + ("Encrypt=true;TrustServerCertificate=true", "require"), + ("Encrypt=true;TrustServerCertificate=false", "verify-full"), + ("Encrypt=strict", "verify-full"), + ("TrustServerCertificate=true", "prefer"), + ]; + for (connection_string, expected) in cases { + let mut input = params(connection_string); + input.host = Some("localhost".into()); + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.ssl_mode.as_deref(), Some(expected)); + } + } + + #[test] + fn equal_discrete_values_are_accepted_and_missing_values_fill_in() { + let mut input = params("Server=db.example,1433;Database=app;User Id=sa;Password=secret"); + input.host = Some("DB.EXAMPLE".into()); + input.port = Some(1433); + input.username = Some("sa".into()); + input.password = Some("secret".into()); + input.ssl_mode = Some("require".into()); + + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.database.primary(), "app"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn contradictory_values_name_both_sources() { + let mut input = params("Server=from-string;Database=app"); + input.host = Some("from-discrete".into()); + + let error = resolve_connection_params(&input).unwrap_err(); + assert!(error.contains("host")); + assert!(error.contains("from-discrete")); + assert!(error.contains("from-string")); + } + + #[test] + fn malformed_connection_strings_are_rejected() { + for connection_string in [ + "not-a-connection-string", + "postgres://sa:secret@localhost/master", + "sqlserver://sa:bad%ZZ@localhost/master", + "sqlserver://sa:secret@localhost:not-a-port/master", + "Server=localhost;Password={unclosed", + "Server=localhost;Encrypt=perhaps", + "Server=localhost;UnknownSetting=true", + ] { + assert!( + resolve_connection_params(¶ms(connection_string)).is_err(), + "expected malformed input to fail: {connection_string}" + ); + } + } + + #[test] + fn custom_ca_keyword_is_preserved_for_the_config_rejection_path() { + let resolved = + resolve_connection_params(¶ms("Server=localhost;SslCa=/tmp/custom-ca.pem")) + .unwrap(); + assert_eq!(resolved.ssl_ca.as_deref(), Some("/tmp/custom-ca.pem")); + assert_eq!(custom_ca_error(), CUSTOM_CA_ERROR); + } +} diff --git a/src/driver/pool.rs b/src/driver/pool.rs index f191d22..95ae969 100644 --- a/src/driver/pool.rs +++ b/src/driver/pool.rs @@ -10,6 +10,7 @@ //! `require` encrypts while accepting the server certificate, and `prefer` //! requests encrypted local-development-compatible connections. +use crate::connection::{custom_ca_error, resolve_connection_params}; use crate::models::ConnectionParams; use deadpool::managed::{Manager, Metrics, RecycleError, RecycleResult}; use mssql_tiberius_bridge::{AuthMethod, Client, Config, EncryptionLevel, Error}; @@ -79,6 +80,7 @@ impl Manager for BridgeManager { /// SQL Server authentication is currently username/password only. TLS maps /// the standard `ssl_mode` values onto the bridge's encryption policy. pub fn build_config(params: &ConnectionParams) -> Result { + let params = resolve_connection_params(params)?; let mut cfg = Config::new(); cfg.host(params.host.as_deref().unwrap_or("localhost")); cfg.port(params.port.unwrap_or(1433)); @@ -93,10 +95,7 @@ pub fn build_config(params: &ConnectionParams) -> Result { .as_deref() .is_some_and(|path| !path.is_empty()) { - return Err( - "SQL Server custom CA files are not supported; use verify-full with the system trust store" - .into(), - ); + return Err(custom_ca_error().into()); } if params .ssl_cert diff --git a/src/main.rs b/src/main.rs index 64b6139..754552f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use tokio::{ }; mod common; +mod connection; mod driver; mod handlers; mod models; diff --git a/src/models.rs b/src/models.rs index 6386b2a..2289e2a 100644 --- a/src/models.rs +++ b/src/models.rs @@ -53,6 +53,9 @@ pub struct ConnectionParams { pub ssl_ca: Option, pub ssl_cert: Option, pub ssl_key: Option, + /// URL or ADO.NET/ODBC keyword connection string. It is parsed and + /// reconciled with the discrete fields before a pool is selected. + pub connection_string: Option, /// SQL run on every new physical connection in the pool. Statements are /// separated by `;`. Runs per pooled connection so the setting applies to /// every query regardless of which connection the pool hands out. diff --git a/src/pool_manager.rs b/src/pool_manager.rs index bc0a253..8b879c2 100644 --- a/src/pool_manager.rs +++ b/src/pool_manager.rs @@ -10,6 +10,7 @@ use deadpool::managed::Pool as DeadPool; use once_cell::sync::Lazy; use tokio::sync::RwLock; +use crate::connection::resolve_connection_params; use crate::driver::pool::{build_config, BridgeManager}; use crate::models::ConnectionParams; @@ -36,7 +37,7 @@ fn build_connection_key(params: &ConnectionParams) -> String { "{}:{}:{}:{}:{}", params.driver, params.host.as_deref().unwrap_or("localhost"), - params.port.unwrap_or(0), + params.port.unwrap_or(1433), params.username.as_deref().unwrap_or(""), params.database ) @@ -54,13 +55,14 @@ fn startup_script(params: &ConnectionParams) -> Option { } pub async fn get_sqlserver_pool(params: &ConnectionParams) -> Result { - let key = build_connection_key(params); + let params = resolve_connection_params(params)?; + let key = build_connection_key(¶ms); let mut pools = SQLSERVER_POOLS.write().await; if let Some(pool) = pools.get(&key).cloned() { return Ok(pool); } - let manager = BridgeManager::new(build_config(params)?, startup_script(params)); + let manager = BridgeManager::new(build_config(¶ms)?, startup_script(¶ms)); let pool = DeadPool::builder(manager) .max_size(10) .build() @@ -105,4 +107,24 @@ mod tests { let key = build_connection_key(&p); assert_eq!(key, "sqlserver:localhost:1433:sa:master:ssl:require"); } + + #[test] + fn equivalent_discrete_and_connection_string_params_share_a_key() { + let mut discrete = params(None); + discrete.ssl_mode = Some("require".into()); + let discrete = resolve_connection_params(&discrete).unwrap(); + let from_string = resolve_connection_params(&ConnectionParams { + connection_string: Some( + "sqlserver://sa@localhost/master?Encrypt=true&TrustServerCertificate=true".into(), + ), + password: Some(String::new()), + ..Default::default() + }) + .unwrap(); + + assert_eq!( + build_connection_key(&discrete), + build_connection_key(&from_string) + ); + } } diff --git a/src/rpc.rs b/src/rpc.rs index 0dcc7ed..b5ef8d0 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -3,6 +3,7 @@ use serde::Serialize; use serde_json::{json, Value}; +use crate::connection::resolve_connection_params; use crate::handlers::{crud, ddl, metadata, query, routines, triggers, views}; use crate::models::ConnectionParams; @@ -124,8 +125,10 @@ pub fn respond(id: Value, outcome: Result) -> Value { /// Deserialize the nested `params.params` connection object every RPC method /// receives. pub fn conn_params(params: &Value) -> Result { - serde_json::from_value(params.get("params").cloned().unwrap_or(Value::Null)) - .map_err(|err| format!("invalid connection params: {err}")) + let params = serde_json::from_value(params.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|err| format!("invalid connection params: {err}"))?; + resolve_connection_params(¶ms) + .map_err(|error| format!("invalid connection params: {error}")) } pub fn opt_str<'a>(params: &'a Value, key: &str) -> Option<&'a str> { diff --git a/tests/live_db.rs b/tests/live_db.rs index 4680c78..6731801 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -57,6 +57,22 @@ fn string_literal(value: &str) -> String { value.replace('\'', "''") } +fn url_encode_component(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn brace_connection_value(value: &str) -> String { + format!("{{{}}}", value.replace('}', "}}")) +} + /// A running plugin process driven through real newline-delimited JSON-RPC. struct Plugin { child: Child, @@ -672,24 +688,35 @@ fn startup_script_runs_on_pooled_connections() { } #[test] -fn connection_string_only_is_rejected_until_ss_011() { +fn connection_string_only_connects_for_url_and_keyword_syntaxes() { let mut plugin = Plugin::with_scratch_database(); let params = connection_params(); - let connection_string = format!( - "sqlserver://{}:{}@{}:{}/{}", - params["username"].as_str().expect("username"), - params["password"].as_str().expect("password"), - params["host"].as_str().expect("host"), - params["port"].as_u64().expect("port"), - params["database"].as_str().expect("database"), + let username = params["username"].as_str().expect("username"); + let password = params["password"].as_str().expect("password"); + let host = params["host"].as_str().expect("host"); + let port = params["port"].as_u64().expect("port"); + let database = params["database"].as_str().expect("database"); + + let url = format!( + "sqlserver://{}:{}@{}:{}/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(password), + host, + port, + url_encode_component(database), ); - - // TODO(SS-011): change this to call_ok once ConnectionParams accepts and - // parses connection_string. Today serde ignores the field and the plugin - // attempts its empty/default discrete connection, which must fail. - let error = plugin.call_error( - "test_connection", - json!({ "params": { "connection_string": connection_string } }), + let keyword = format!( + "Server=tcp:{host},{port};Database={};User Id={};Password={};Encrypt=true;TrustServerCertificate=true;", + brace_connection_value(database), + brace_connection_value(username), + brace_connection_value(password), ); - assert!(!error.is_empty()); + + for (syntax, connection_string) in [("URL", url), ("keyword", keyword)] { + let result = plugin.call_ok( + "test_connection", + json!({ "params": { "connection_string": connection_string } }), + ); + assert_eq!(result, json!({ "success": true }), "{syntax} syntax"); + } } From 0fad03bff8a227829a9ddac4333cb178124cf175 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 11:24:10 +0000 Subject: [PATCH 03/23] feat: add SQL Server BLOB export and preview --- Cargo.lock | 37 +++++++++ Cargo.toml | 1 + README.md | 14 ++++ docs/completeness.md | 22 +++++- src/driver/blob.rs | 161 +++++++++++++++++++++++++++++++++++++++ src/driver/blob/tests.rs | 57 ++++++++++++++ src/driver/mod.rs | 1 + src/driver/ops.rs | 33 +++++++- src/handlers/blob.rs | 76 ++++++++++++++++++ src/handlers/mod.rs | 1 + src/rpc.rs | 6 +- tests/live_db.rs | 142 ++++++++++++++++++++++++++++++++++ 12 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 src/driver/blob.rs create mode 100644 src/driver/blob/tests.rs create mode 100644 src/handlers/blob.rs diff --git a/Cargo.lock b/Cargo.lock index fc61a48..610b32b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -382,6 +393,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foreign-types" version = "0.3.2" @@ -586,6 +603,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1251,6 +1277,7 @@ dependencies = [ "base64", "chrono", "deadpool", + "infer", "mssql-tds-preview", "mssql-tiberius-bridge", "once_cell", @@ -1590,6 +1617,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index fbf12ce..343522d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ publish = false base64 = "0.22" chrono = "0.4" deadpool = "0.12" +infer = "0.22" # SQL Server driver — Microsoft's mssql-tds protocol implementation behind a # tiberius-compatible API, pooled with deadpool. Keep the exact preview pin: # preview releases may change bridge semantics without a stable-version signal. diff --git a/README.md b/README.md index f4888e8..cd1a35a 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,20 @@ All common SQL Server types are supported for column creation and value extracti `BIGINT` values outside JavaScript's safe integer range are delivered as strings so they round-trip without precision loss. +### Binary export and preview + +`BINARY`, `VARBINARY` including `VARBINARY(MAX)`, and legacy `IMAGE` values can +be exported as raw files or previewed with MIME detection. `NULL` returns a +clear error instead of creating an empty file. `ROWVERSION` and its deprecated +`TIMESTAMP` synonym are excluded because they are server-generated concurrency +tokens, not user BLOB data. + +BLOB previews are bounded by `max_blob_size` (100 MiB when the host does not +provide a value). SQL Server checks `DATALENGTH` before returning the bytes; an +oversized value produces an error with the actual and configured sizes and can +still be exported directly to a file without passing through base64 or a +JSON-RPC response. + ## Installation ### Automatic (via Tabularis) diff --git a/docs/completeness.md b/docs/completeness.md index d501b39..1b18e72 100644 --- a/docs/completeness.md +++ b/docs/completeness.md @@ -13,8 +13,10 @@ remaining protocol gaps are: - `initialize` accepts the request but ignores its settings. `SS-013` will validate and apply plugin settings. -- `save_blob_to_file` and `fetch_blob_as_data_url` return JSON-RPC `-32601`. - `SS-012` will add BLOB export and preview. +- `save_blob_to_file` and `fetch_blob_as_data_url` support raw export and + MIME-sniffed preview for `BINARY`, `VARBINARY` including `VARBINARY(MAX)`, + and legacy `IMAGE` values. Composite primary keys are parameterized and + normalized in deterministic column order. - The database-user and privilege surface is absent. `SS-014` will implement `get_db_privilege_catalog`, `get_db_users`, `create_db_user`, `drop_db_user`, `set_db_user_password`, `get_db_user_privileges`, @@ -27,6 +29,22 @@ remaining protocol gaps are: return raw `sqlserver-showplan-xml` after the plugin-owned parser contract and host support are available. +## BLOB policy + +`NULL` binary values return an explicit error and never become an empty file. +`ROWVERSION` and its deprecated `TIMESTAMP` synonym are not offered as BLOBs: +their eight bytes are server-generated concurrency tokens rather than user +file data. Direct RPC attempts against those types return an explanatory +error. + +Preview requests accept the same top-level `max_blob_size` byte ceiling used +by BLOB write paths. The query checks `DATALENGTH` and omits the binary value +from the SQL result when it exceeds the ceiling, so an oversized +`VARBINARY(MAX)` is neither transferred over TDS nor base64-encoded into the +JSON-RPC line. The error reports the actual and configured sizes and suggests +file export, which remains unbounded. For compatibility with hosts that omit +the field, the plugin uses Tabularis' 100 MiB default. + ## Connection parameters The manifest advertises connection-string support, but `ConnectionParams` diff --git a/src/driver/blob.rs b/src/driver/blob.rs new file mode 100644 index 0000000..7ab9498 --- /dev/null +++ b/src/driver/blob.rs @@ -0,0 +1,161 @@ +//! Binary-column export and bounded preview support. +//! +//! SQL Server's `binary`, `varbinary`, and legacy `image` types are user BLOB +//! data. `rowversion` and its deprecated `timestamp` synonym are deliberately +//! rejected: their eight bytes are generated by SQL Server as concurrency +//! tokens, not user-owned file content. + +use base64::Engine as _; +use mssql_tiberius_bridge::ToSql; + +use crate::driver::helpers::{bracket_quote, build_pk_where_clause, qualify, value_to_sql_param}; +use crate::driver::{acquire, introspection}; +use crate::models::{ConnectionParams, PkMap}; + +/// Matches Tabularis' host-side default. Newer hosts may forward their +/// configured `max_blob_size` with the preview request; older hosts omit it. +pub const DEFAULT_MAX_BLOB_SIZE: u64 = 100 * 1024 * 1024; + +pub async fn fetch_blob_bytes( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + max_preview_size: Option, +) -> Result, String> { + let mut conn = acquire(params).await?; + let columns = introspection::get_columns(&mut conn, table, schema).await?; + let column = columns + .iter() + .find(|column| column.name == col_name) + .ok_or_else(|| { + format!( + "SQL Server BLOB column {}.{} was not found", + qualify(schema, table), + bracket_quote(col_name) + ) + })?; + validate_blob_data_type(&column.data_type)?; + + let mut primary_keys: Vec<_> = pk_map.iter().collect(); + primary_keys.sort_by_key(|&(column, _)| column); + let pk_columns: Vec = primary_keys + .iter() + .map(|(column, _)| (*column).clone()) + .collect(); + let first_pk_marker = if max_preview_size.is_some() { 2 } else { 1 }; + let predicate = build_pk_where_clause(&pk_columns, first_pk_marker).ok_or_else(|| { + "SQL Server: BLOB lookup requires at least one primary-key column".to_string() + })?; + + let mut owned_params: Vec> = Vec::with_capacity(primary_keys.len() + 1); + let sql = if let Some(max_size) = max_preview_size { + let sql_limit = i64::try_from(max_size).unwrap_or(i64::MAX); + owned_params.push(Box::new(sql_limit)); + format!( + "SELECT CAST(DATALENGTH({column}) AS BIGINT), \ + CASE WHEN CAST(DATALENGTH({column}) AS BIGINT) <= @P1 \ + THEN CONVERT(VARBINARY(MAX), {column}) END \ + FROM {table} WHERE {predicate}", + column = bracket_quote(col_name), + table = qualify(schema, table), + ) + } else { + format!( + "SELECT CONVERT(VARBINARY(MAX), {}) FROM {} WHERE {}", + bracket_quote(col_name), + qualify(schema, table), + predicate, + ) + }; + + for (_, value) in primary_keys { + owned_params.push(value_to_sql_param(value)?); + } + let bound: Vec<&dyn ToSql> = owned_params.iter().map(|value| value.as_ref()).collect(); + let rows = conn + .query(sql, &bound) + .await + .map_err(|error| format!("Failed to fetch SQL Server BLOB: {error}"))? + .into_first_result(); + let row = rows + .first() + .ok_or_else(|| "SQL Server BLOB row was not found".to_string())?; + + if let Some(max_size) = max_preview_size { + let size = row + .try_get::(0) + .map_err(|error| format!("Failed to read SQL Server BLOB size: {error}"))? + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string())?; + let size = u64::try_from(size) + .map_err(|_| format!("SQL Server returned an invalid BLOB size: {size}"))?; + ensure_preview_size(size, max_size)?; + return row + .try_get::<&[u8], _>(1) + .map_err(|error| format!("Failed to read SQL Server BLOB value: {error}"))? + .map(<[u8]>::to_vec) + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string()); + } + + row.try_get::<&[u8], _>(0) + .map_err(|error| format!("Failed to read SQL Server BLOB value: {error}"))? + .map(<[u8]>::to_vec) + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string()) +} + +pub fn encode_blob_full(data: &[u8], max_preview_size: u64) -> Result { + ensure_preview_size(data.len() as u64, max_preview_size)?; + let mime_type = infer::get(data) + .map(|kind| kind.mime_type()) + .unwrap_or("application/octet-stream"); + let encoded = base64::engine::general_purpose::STANDARD.encode(data); + Ok(format!("BLOB:{}:{mime_type}:{encoded}", data.len())) +} + +pub fn validate_writable_file_path(file_path: &str) -> Result<(), String> { + if file_path.trim().is_empty() { + return Err("file_path must not be empty".to_string()); + } + let path = std::path::Path::new(file_path); + if path.is_dir() { + return Err(format!( + "file_path '{file_path}' is a directory, not a file" + )); + } + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() && !parent.is_dir() => Err(format!( + "file_path '{file_path}': parent directory '{}' does not exist", + parent.display() + )), + _ => Ok(()), + } +} + +fn ensure_preview_size(size: u64, max_preview_size: u64) -> Result<(), String> { + if size > max_preview_size { + return Err(format!( + "SQL Server BLOB preview is {size} bytes, exceeding max_blob_size of \ + {max_preview_size} bytes; export the value to a file instead" + )); + } + Ok(()) +} + +fn validate_blob_data_type(data_type: &str) -> Result<(), String> { + let normalized = data_type.trim().to_ascii_uppercase(); + let base_type = normalized.split('(').next().unwrap_or(normalized.as_str()); + match base_type { + "BINARY" | "VARBINARY" | "IMAGE" => Ok(()), + "ROWVERSION" | "TIMESTAMP" => Err(format!( + "SQL Server {base_type} is a server-generated concurrency token, not user BLOB data" + )), + _ => Err(format!( + "SQL Server column type {data_type} is not supported for BLOB export or preview" + )), + } +} + +#[cfg(test)] +#[path = "blob/tests.rs"] +mod tests; diff --git a/src/driver/blob/tests.rs b/src/driver/blob/tests.rs new file mode 100644 index 0000000..bda9681 --- /dev/null +++ b/src/driver/blob/tests.rs @@ -0,0 +1,57 @@ +use super::*; + +#[test] +fn wire_format_contains_size_mime_and_base64() { + let bytes = [0xCA, 0xFE, 0xBA, 0xBE]; + assert_eq!( + encode_blob_full(&bytes, 4).unwrap(), + "BLOB:4:application/octet-stream:yv66vg==" + ); +} + +#[test] +fn wire_format_sniffs_png_magic_bytes() { + let png_signature = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let wire = encode_blob_full(&png_signature, 8).unwrap(); + assert!(wire.starts_with("BLOB:8:image/png:")); +} + +#[test] +fn preview_size_ceiling_accepts_exact_limit_and_rejects_larger_value() { + assert!(encode_blob_full(&[1, 2, 3, 4], 4).is_ok()); + + let error = encode_blob_full(&[1, 2, 3, 4], 3).unwrap_err(); + assert!(error.contains("4 bytes")); + assert!(error.contains("max_blob_size of 3 bytes")); +} + +#[test] +fn binary_varbinary_and_image_are_user_blob_types() { + for data_type in ["binary(8)", "VARBINARY(MAX)", "image"] { + assert!( + validate_blob_data_type(data_type).is_ok(), + "rejected {data_type}" + ); + } +} + +#[test] +fn rowversion_and_timestamp_are_not_offered_as_blobs() { + for data_type in ["ROWVERSION", "timestamp"] { + let error = validate_blob_data_type(data_type).unwrap_err(); + assert!(error.contains("concurrency token")); + } +} + +#[test] +fn writable_path_validation_rejects_empty_directory_and_missing_parent() { + assert!(validate_writable_file_path("").is_err()); + assert!(validate_writable_file_path("/tmp").is_err()); + assert!(validate_writable_file_path("/this-directory-must-not-exist-ss012/out.bin").is_err()); +} + +#[test] +fn writable_path_validation_accepts_existing_parent_and_bare_filename() { + assert!(validate_writable_file_path("/tmp/ss012-output.bin").is_ok()); + assert!(validate_writable_file_path("ss012-output.bin").is_ok()); +} diff --git a/src/driver/mod.rs b/src/driver/mod.rs index f0f649c..3a84001 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -4,6 +4,7 @@ //! The driver supports schema introspection, table/view DDL, foreign keys, //! triggers, and stored-routine management. +pub mod blob; pub mod ddl; pub mod explain; pub mod extract; diff --git a/src/driver/ops.rs b/src/driver/ops.rs index b0f8d6a..a323669 100644 --- a/src/driver/ops.rs +++ b/src/driver/ops.rs @@ -9,7 +9,7 @@ use crate::driver::helpers::{ bracket_quote, build_delete_composite_sql, build_update_composite_sql, qualify, }; use crate::driver::{ - acquire, ddl, execute_on_connection, explain, helpers, introspection, routines, triggers, + acquire, blob, ddl, execute_on_connection, explain, helpers, introspection, routines, triggers, }; use crate::models::{ AiSchemaContext, BatchStatementResult, ColumnDefinition, ConnectionParams, ForeignKey, Index, @@ -427,6 +427,37 @@ pub async fn delete_record( crate::driver::affected_rows_from_query(result) } +// --- BLOB export and preview -------------------------------------------- + +pub async fn save_blob_to_file( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + file_path: &str, +) -> Result<(), String> { + blob::validate_writable_file_path(file_path)?; + let bytes = blob::fetch_blob_bytes(params, table, col_name, pk_map, schema, None).await?; + tokio::fs::write(file_path, bytes) + .await + .map_err(|error| format!("Failed to write SQL Server BLOB to '{file_path}': {error}")) +} + +pub async fn fetch_blob_as_data_url( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + max_blob_size: u64, +) -> Result { + let bytes = + blob::fetch_blob_bytes(params, table, col_name, pk_map, schema, Some(max_blob_size)) + .await?; + blob::encode_blob_full(&bytes, max_blob_size) +} + // --- DDL generation ----------------------------------------------------- pub fn get_create_table_sql( diff --git a/src/handlers/blob.rs b/src/handlers/blob.rs new file mode 100644 index 0000000..27bb23e --- /dev/null +++ b/src/handlers/blob.rs @@ -0,0 +1,76 @@ +//! JSON-RPC adapters for SQL Server binary-column export and preview. + +use serde_json::Value; + +use crate::driver::{blob as driver_blob, ops}; +use crate::models::PkMap; +use crate::rpc::{conn_params, opt_str, req_field, req_str, respond}; + +pub async fn save_blob_to_file(id: Value, params: &Value) -> Value { + let (conn, table, col_name, file_path) = match ( + conn_params(params), + req_str(params, "table"), + req_str(params, "col_name"), + req_str(params, "file_path"), + ) { + (Ok(conn), Ok(table), Ok(col_name), Ok(file_path)) => (conn, table, col_name, file_path), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + let pk_map: PkMap = match req_field(params, "pk_map") { + Ok(pk_map) => pk_map, + Err(error) => return respond::<()>(id, Err(error)), + }; + + respond( + id, + ops::save_blob_to_file( + &conn, + table, + col_name, + &pk_map, + opt_str(params, "schema"), + file_path, + ) + .await, + ) +} + +pub async fn fetch_blob_as_data_url(id: Value, params: &Value) -> Value { + let (conn, table, col_name) = match ( + conn_params(params), + req_str(params, "table"), + req_str(params, "col_name"), + ) { + (Ok(conn), Ok(table), Ok(col_name)) => (conn, table, col_name), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + let pk_map: PkMap = match req_field(params, "pk_map") { + Ok(pk_map) => pk_map, + Err(error) => return respond::<()>(id, Err(error)), + }; + let max_blob_size = match params.get("max_blob_size") { + Some(_) => match req_field(params, "max_blob_size") { + Ok(max_blob_size) => max_blob_size, + Err(error) => return respond::<()>(id, Err(error)), + }, + None => driver_blob::DEFAULT_MAX_BLOB_SIZE, + }; + + respond( + id, + ops::fetch_blob_as_data_url( + &conn, + table, + col_name, + &pk_map, + opt_str(params, "schema"), + max_blob_size, + ) + .await, + ) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 6300487..14b8be7 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod blob; pub mod crud; pub mod ddl; pub mod metadata; diff --git a/src/rpc.rs b/src/rpc.rs index b5ef8d0..45b339e 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -4,7 +4,7 @@ use serde::Serialize; use serde_json::{json, Value}; use crate::connection::resolve_connection_params; -use crate::handlers::{crud, ddl, metadata, query, routines, triggers, views}; +use crate::handlers::{blob, crud, ddl, metadata, query, routines, triggers, views}; use crate::models::ConnectionParams; /// Parse one JSON-RPC line and return the response value (serialised @@ -74,6 +74,10 @@ pub async fn handle_line(line: &str) -> Value { "update_record" => crud::update_record(id, ¶ms).await, "delete_record" => crud::delete_record(id, ¶ms).await, + // BLOB export and preview. + "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, + // 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, diff --git a/tests/live_db.rs b/tests/live_db.rs index 6731801..f915013 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -18,6 +18,7 @@ use std::collections::BTreeSet; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; +use base64::Engine as _; use serde_json::{json, Value}; const TEST_SCHEMA: &str = "ss003"; @@ -672,6 +673,147 @@ fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { } } +#[test] +fn blob_png_round_trip_supports_composite_keys_image_and_clean_null_errors() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "blob_round_trip", + "tenant_id INT NOT NULL, record_id INT NOT NULL, \ + png VARBINARY(MAX) NOT NULL, legacy IMAGE NULL, nullable VARBINARY(MAX) NULL, \ + version ROWVERSION, PRIMARY KEY (tenant_id, record_id)", + ); + let png = base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") + .expect("valid PNG fixture"); + let png_hex: String = png.iter().map(|byte| format!("{byte:02X}")).collect(); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[blob_round_trip] \ + (tenant_id, record_id, png, legacy, nullable) \ + VALUES (7, 9, 0x{png_hex}, 0x{png_hex}, NULL)" + )); + let row = json!({ "tenant_id": 7, "record_id": 9 }); + + let wire = plugin.call_ok( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "png", "pk_map": row, + "max_blob_size": png.len() + }), + ); + assert_eq!( + wire, + json!(format!( + "BLOB:{}:image/png:{}", + png.len(), + base64::engine::general_purpose::STANDARD.encode(&png) + )) + ); + + let legacy_wire = plugin.call_ok( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "legacy", "pk_map": row, + "max_blob_size": png.len() + }), + ); + assert!(legacy_wire + .as_str() + .expect("IMAGE preview wire string") + .starts_with(&format!("BLOB:{}:image/png:", png.len()))); + + let export_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-png-{}.png", + std::process::id() + )); + let _ = std::fs::remove_file(&export_path); + assert_eq!( + plugin.call_ok( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "png", "pk_map": row, + "file_path": export_path.to_string_lossy() + }), + ), + Value::Null + ); + assert_eq!(std::fs::read(&export_path).expect("exported PNG"), png); + std::fs::remove_file(&export_path).expect("remove exported PNG"); + + let null_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-null-{}.bin", + std::process::id() + )); + let _ = std::fs::remove_file(&null_path); + let null_error = plugin.call_error( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "nullable", "pk_map": row, + "file_path": null_path.to_string_lossy() + }), + ); + assert!(null_error.contains("NULL"), "{null_error}"); + assert!(!null_path.exists(), "NULL must not create a zero-byte file"); + + let rowversion_error = plugin.call_error( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "version", "pk_map": row, + "max_blob_size": 8 + }), + ); + assert!(rowversion_error.contains("concurrency token")); +} + +#[test] +fn varbinary_max_preview_ceiling_rejects_before_encoding_but_export_still_works() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "blob_ceiling", + "id INT PRIMARY KEY, payload VARBINARY(MAX) NOT NULL", + ); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[blob_ceiling] (id, payload) \ + VALUES (1, CONVERT(VARBINARY(MAX), REPLICATE(CAST('x' AS VARCHAR(MAX)), 4096)))" + )); + + let error = plugin.call_error( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_ceiling", "col_name": "payload", "pk_map": { "id": 1 }, + "max_blob_size": 1024 + }), + ); + assert!(error.contains("4096 bytes"), "{error}"); + assert!(error.contains("max_blob_size of 1024 bytes"), "{error}"); + + let export_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-large-{}.bin", + std::process::id() + )); + let _ = std::fs::remove_file(&export_path); + plugin.call_ok( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_ceiling", "col_name": "payload", "pk_map": { "id": 1 }, + "file_path": export_path.to_string_lossy() + }), + ); + assert_eq!( + std::fs::metadata(&export_path) + .expect("exported large VARBINARY(MAX)") + .len(), + 4096 + ); + std::fs::remove_file(export_path).expect("remove large BLOB export"); +} + #[test] fn startup_script_runs_on_pooled_connections() { let mut plugin = Plugin::with_scratch_database(); From eaf4a6af8f1e31e37b28f255da38f5c85010b66a Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 11:33:13 +0000 Subject: [PATCH 04/23] feat: apply plugin initialization settings --- .tabularium | 44 +++++++ README.md | 24 ++++ docs/completeness.md | 9 +- src/driver/mod.rs | 3 +- src/driver/pool.rs | 101 +++++++++++++-- src/driver/pool/tests.rs | 80 ++++++++++-- src/main.rs | 20 ++- src/pool_manager.rs | 12 +- src/rpc.rs | 8 +- src/settings.rs | 270 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 536 insertions(+), 35 deletions(-) create mode 100644 src/settings.rs diff --git a/.tabularium b/.tabularium index aa5ff9b..83f8e2b 100644 --- a/.tabularium +++ b/.tabularium @@ -31,6 +31,50 @@ "supports_ssl": true, "explain": true }, + "settings": [ + { + "key": "max_pool_size", + "label": "Maximum Pool Size", + "type": "number", + "default": 10, + "description": "Maximum number of SQL Server sessions in each connection pool." + }, + { + "key": "connect_timeout_seconds", + "label": "Connection Timeout (seconds)", + "type": "number", + "default": 15, + "description": "Maximum time allowed to establish and authenticate a new SQL Server session." + }, + { + "key": "query_timeout_seconds", + "label": "Query Timeout (seconds)", + "type": "number", + "default": 0, + "description": "Maximum query duration; 0 disables the query timeout." + }, + { + "key": "application_name", + "label": "Application Name", + "type": "string", + "default": "Tabularis", + "description": "Application name reported for SQL Server sessions." + }, + { + "key": "trust_server_certificate", + "label": "Trust Server Certificate", + "type": "boolean", + "default": false, + "description": "Accept a self-signed server certificate without validation. Use only for trusted development servers." + }, + { + "key": "pool_idle_eviction_minutes", + "label": "Pool Idle Eviction (minutes)", + "type": "number", + "default": 10, + "description": "Interval for evicting connection pools that have no checked-out sessions." + } + ], "data_types": [ { "name": "TINYINT", diff --git a/README.md b/README.md index cd1a35a..49c6509 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The client was swapped to Microsoft's protocol implementation to align the plugi - [Features](#features) - [Connection Configuration](#connection-configuration) +- [Plugin Settings](#plugin-settings) - [Supported Data Types](#supported-data-types) - [Installation](#installation) - [Known Limitations](#known-limitations) @@ -101,6 +102,29 @@ The standard Tabularis `ssl_mode` values map onto the TDS encryption policy: Custom CA files and client certificates are rejected explicitly; strict verification uses the system trust store. +## Plugin Settings + +Tabularis sends these process-wide settings through `initialize` when the +plugin starts: + +| Setting | Default | Effect | +|---------|---------|--------| +| `max_pool_size` | `10` | Maximum physical SQL Server sessions in each connection pool | +| `connect_timeout_seconds` | `15` | Maximum time to establish and authenticate a new session | +| `query_timeout_seconds` | `0` | Maximum query duration in seconds; `0` disables the timeout | +| `application_name` | `Tabularis` | TDS application name visible to DBAs in SQL Server session metadata | +| `trust_server_certificate` | `false` | Forces acceptance of a self-signed certificate without validation; use only for trusted development servers | +| `pool_idle_eviction_minutes` | `10` | Interval for removing pools with no checked-out sessions | + +Malformed values produce a warning in the plugin log and fall back to the +default; unknown settings are ignored for forward compatibility. Settings are +snapshotted when a pool is created. Changing a setting takes effect on the next +connection after the plugin is restarted, not on live pooled sessions. + +`trust_server_certificate` is an explicit escape hatch for self-signed +certificates in a verifying TLS mode. The `prefer` and `require` modes already +accept the server certificate as described above. + ## Supported Data Types All common SQL Server types are supported for column creation and value extraction, including exact/approximate numerics (`TINYINT` … `BIGINT`, `DECIMAL`, `MONEY`, `FLOAT`), strings (`CHAR`/`VARCHAR`/`NVARCHAR` incl. `MAX`, `TEXT`/`NTEXT`), binary (`BINARY`/`VARBINARY`/`IMAGE`), date/time (`DATE`, `TIME`, `DATETIME`, `DATETIME2`, `SMALLDATETIME`, `DATETIMEOFFSET`), `BIT`, `UNIQUEIDENTIFIER`, `XML`, `SQL_VARIANT`, `ROWVERSION`, `HIERARCHYID`, and spatial (`GEOGRAPHY`, `GEOMETRY`). diff --git a/docs/completeness.md b/docs/completeness.md index 1b18e72..357e2e4 100644 --- a/docs/completeness.md +++ b/docs/completeness.md @@ -11,8 +11,9 @@ The plugin already supports connection testing, schema introspection, query execution, CRUD, DDL, views, routines, triggers, and visual EXPLAIN. The remaining protocol gaps are: -- `initialize` accepts the request but ignores its settings. `SS-013` will - validate and apply plugin settings. +- `initialize` applies forgiving process settings for pool sizing, connection + and query timeouts, the TDS application name, certificate trust, and idle + pool eviction. Unknown keys are ignored and malformed values use defaults. - `save_blob_to_file` and `fetch_blob_as_data_url` support raw export and MIME-sniffed preview for `BINARY`, `VARBINARY` including `VARBINARY(MAX)`, and legacy `IMAGE` values. Composite primary keys are parameterized and @@ -66,11 +67,9 @@ registry-grade manifest. It still needs: - icon, color, and screenshot assets; - readme, homepage, documentation, and support links; - `min_runtime_version` and type mappings; -- a settings declaration for the `initialize` implementation; and - the plugin-owned `explain_parsers` declaration once the host supports it. -`SS-013`, `SS-020`, `SS-021`, `SS-030`, `SS-034`, and `SS-035` cover these -items. +`SS-020`, `SS-021`, `SS-030`, `SS-034`, and `SS-035` cover these items. ## CI and release packaging diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 3a84001..d9c5688 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -57,6 +57,7 @@ async fn run_query_collecting( conn: &mut pool::BridgeConnection, query: &str, ) -> Result, String> { + let query_timeout_seconds = conn.query_timeout_seconds(); let client = conn.inner_mut(); // Drain any leftover state from a prior query / dropped stream so we // don't hit "open batch" errors when re-using the client. @@ -65,7 +66,7 @@ async fn run_query_collecting( .await .map_err(|error| error.to_string())?; client - .execute(query.to_string(), None, None) + .execute(query.to_string(), query_timeout_seconds, None) .await .map_err(|error| error.to_string())?; diff --git a/src/driver/pool.rs b/src/driver/pool.rs index 95ae969..a165011 100644 --- a/src/driver/pool.rs +++ b/src/driver/pool.rs @@ -10,26 +10,90 @@ //! `require` encrypts while accepting the server certificate, and `prefer` //! requests encrypted local-development-compatible connections. +use std::future::Future; +use std::time::Duration; + use crate::connection::{custom_ca_error, resolve_connection_params}; use crate::models::ConnectionParams; +use crate::settings::PluginSettings; use deadpool::managed::{Manager, Metrics, RecycleError, RecycleResult}; -use mssql_tiberius_bridge::{AuthMethod, Client, Config, EncryptionLevel, Error}; +use mssql_tiberius_bridge::TdsClient; +use mssql_tiberius_bridge::{ + AuthMethod, Client, Config, EncryptionLevel, Error, ExecuteResult, QueryResult, ToSql, +}; +use tokio::time::timeout; + +/// A live bridge client with the query timeout snapshotted by its pool. +pub struct BridgeConnection { + client: Client, + query_timeout_seconds: Option, +} + +impl BridgeConnection { + async fn with_query_timeout( + seconds: Option, + operation: impl Future>, + ) -> Result { + match seconds { + Some(seconds) => timeout(Duration::from_secs(u64::from(seconds)), operation) + .await + .map_err(|_| { + Error::Conversion(format!("Query timed out after {seconds} seconds")) + })?, + None => operation.await, + } + } + + pub async fn simple_query(&mut self, sql: impl Into) -> Result { + let operation = self.client.simple_query(sql.into()); + Self::with_query_timeout(self.query_timeout_seconds, operation).await + } + + pub async fn query( + &mut self, + sql: impl Into, + params: &[&dyn ToSql], + ) -> Result { + let operation = self.client.query(sql.into(), params); + Self::with_query_timeout(self.query_timeout_seconds, operation).await + } + + pub async fn execute( + &mut self, + sql: impl Into, + params: &[&dyn ToSql], + ) -> Result { + let operation = self.client.execute(sql.into(), params); + Self::with_query_timeout(self.query_timeout_seconds, operation).await + } + + pub fn inner_mut(&mut self) -> &mut TdsClient { + self.client.inner_mut() + } -/// A live bridge client. `deadpool` hands one of these out per checkout. -pub type BridgeConnection = Client; + pub fn query_timeout_seconds(&self) -> Option { + self.query_timeout_seconds + } +} /// Deadpool `Manager` for bridge connections. #[derive(Debug, Clone)] pub struct BridgeManager { config: Config, startup_script: Option, + connect_timeout: Duration, + query_timeout_seconds: Option, } impl BridgeManager { - pub fn new(config: Config, startup_script: Option) -> Self { + pub fn new(config: Config, startup_script: Option, settings: &PluginSettings) -> Self { Self { config, startup_script, + connect_timeout: Duration::from_secs(u64::from(settings.connect_timeout_seconds)), + query_timeout_seconds: settings + .query_timeout() + .map(|_| settings.query_timeout_seconds), } } @@ -54,9 +118,20 @@ impl Manager for BridgeManager { type Error = Error; async fn create(&self) -> Result { - let mut client = Client::connect(&self.config).await?; - self.apply_startup_script(&mut client).await?; - Ok(client) + let client = timeout(self.connect_timeout, Client::connect(&self.config)) + .await + .map_err(|_| { + Error::Conversion(format!( + "Connection timed out after {} seconds", + self.connect_timeout.as_secs() + )) + })??; + let mut connection = BridgeConnection { + client, + query_timeout_seconds: self.query_timeout_seconds, + }; + self.apply_startup_script(&mut connection).await?; + Ok(connection) } async fn recycle(&self, conn: &mut Self::Type, _: &Metrics) -> RecycleResult { @@ -79,7 +154,10 @@ impl Manager for BridgeManager { /// Consumes the shared connection fields used by current Tabularis drivers. /// SQL Server authentication is currently username/password only. TLS maps /// the standard `ssl_mode` values onto the bridge's encryption policy. -pub fn build_config(params: &ConnectionParams) -> Result { +pub fn build_config( + params: &ConnectionParams, + settings: &PluginSettings, +) -> Result { let params = resolve_connection_params(params)?; let mut cfg = Config::new(); cfg.host(params.host.as_deref().unwrap_or("localhost")); @@ -89,6 +167,7 @@ pub fn build_config(params: &ConnectionParams) -> Result { params.username.as_deref().unwrap_or("sa"), params.password.as_deref().unwrap_or(""), )); + cfg.application_name(&settings.application_name); if params .ssl_ca @@ -132,6 +211,12 @@ pub fn build_config(params: &ConnectionParams) -> Result { } } + // Explicitly permit self-signed certificates even with a verifying TLS + // mode. `require` and `prefer` already trust certificates by definition. + if settings.trust_server_certificate { + cfg.trust_cert(); + } + Ok(cfg) } diff --git a/src/driver/pool/tests.rs b/src/driver/pool/tests.rs index 0f95144..2d08b62 100644 --- a/src/driver/pool/tests.rs +++ b/src/driver/pool/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::{ConnectionParams, DatabaseSelection}; +use crate::settings::PluginSettings; fn base_params(host: Option<&str>, port: Option, db: &str) -> ConnectionParams { ConnectionParams { @@ -15,20 +16,31 @@ fn base_params(host: Option<&str>, port: Option, db: &str) -> ConnectionPar #[test] fn build_config_uses_explicit_host_port() { - let cfg = build_config(&base_params(Some("db.internal"), Some(1445), "master")) - .expect("config builds"); + let cfg = build_config( + &base_params(Some("db.internal"), Some(1445), "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "db.internal:1445"); } #[test] fn build_config_defaults_host_to_localhost() { - let cfg = build_config(&base_params(None, Some(1433), "master")).expect("config builds"); + let cfg = build_config( + &base_params(None, Some(1433), "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } #[test] fn build_config_defaults_port_to_1433() { - let cfg = build_config(&base_params(Some("localhost"), None, "master")).expect("config builds"); + let cfg = build_config( + &base_params(Some("localhost"), None, "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } @@ -37,7 +49,7 @@ fn build_config_empty_credentials_do_not_panic() { let mut params = base_params(Some("localhost"), Some(1433), "master"); params.username = None; params.password = None; - assert!(build_config(¶ms).is_ok()); + assert!(build_config(¶ms, &PluginSettings::default()).is_ok()); } #[test] @@ -52,15 +64,55 @@ fn manager_is_clone_send_sync() { #[test] fn manager_new_stores_config() { - let cfg = build_config(&base_params(Some("example.com"), Some(1433), "master")) - .expect("config builds"); - let mgr = BridgeManager::new(cfg, Some("SET NOCOUNT ON".into())); + let settings = PluginSettings::default(); + let cfg = build_config( + &base_params(Some("example.com"), Some(1433), "master"), + &settings, + ) + .expect("config builds"); + let mgr = BridgeManager::new(cfg, Some("SET NOCOUNT ON".into()), &settings); let cloned = mgr.clone(); let original = format!("{:?}", mgr); let cloned_dbg = format!("{:?}", cloned); assert_eq!(original, cloned_dbg); } +#[test] +fn build_config_applies_application_name_and_certificate_override() { + let settings = PluginSettings { + application_name: "Tabularis Test".into(), + trust_server_certificate: true, + ..PluginSettings::default() + }; + let mut params = base_params(Some("localhost"), Some(1433), "master"); + params.ssl_mode = Some("verify-full".into()); + + let debug = format!( + "{:?}", + build_config(¶ms, &settings).expect("config builds") + ); + assert!(debug.contains("Tabularis Test")); + assert!(debug.contains("trust_cert: true")); +} + +#[test] +fn manager_snapshots_timeout_settings() { + let settings = PluginSettings { + connect_timeout_seconds: 8, + query_timeout_seconds: 42, + ..PluginSettings::default() + }; + let cfg = build_config( + &base_params(Some("localhost"), Some(1433), "master"), + &settings, + ) + .expect("config builds"); + let manager = BridgeManager::new(cfg, None, &settings); + + assert_eq!(manager.connect_timeout, Duration::from_secs(8)); + assert_eq!(manager.query_timeout_seconds, Some(42)); +} + #[test] fn build_config_accepts_supported_tls_modes() { for mode in [ @@ -75,7 +127,7 @@ fn build_config_accepts_supported_tls_modes() { ] { let mut params = base_params(Some("localhost"), Some(1433), "master"); params.ssl_mode = Some(mode.into()); - let cfg = build_config(¶ms).expect("config builds"); + let cfg = build_config(¶ms, &PluginSettings::default()).expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } } @@ -84,15 +136,19 @@ fn build_config_accepts_supported_tls_modes() { fn build_config_rejects_unsupported_tls_inputs() { let mut verify_ca = base_params(Some("localhost"), Some(1433), "master"); verify_ca.ssl_mode = Some("verify_ca".into()); - assert!(build_config(&verify_ca).unwrap_err().contains("verify-ca")); + assert!(build_config(&verify_ca, &PluginSettings::default()) + .unwrap_err() + .contains("verify-ca")); let mut custom_ca = base_params(Some("localhost"), Some(1433), "master"); custom_ca.ssl_ca = Some("/tmp/ca.pem".into()); - assert!(build_config(&custom_ca).unwrap_err().contains("custom CA")); + assert!(build_config(&custom_ca, &PluginSettings::default()) + .unwrap_err() + .contains("custom CA")); let mut client_cert = base_params(Some("localhost"), Some(1433), "master"); client_cert.ssl_cert = Some("/tmp/client.pem".into()); - assert!(build_config(&client_cert) + assert!(build_config(&client_cert, &PluginSettings::default()) .unwrap_err() .contains("client certificates")); } diff --git a/src/main.rs b/src/main.rs index 754552f..416a112 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,12 +5,12 @@ //! are funneled through a single writer task so concurrent handlers never //! interleave bytes on stdout. -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, sync::{mpsc, watch, Mutex}, - time::interval, + time::sleep, }; mod common; @@ -20,15 +20,16 @@ mod handlers; mod models; mod pool_manager; mod rpc; +mod settings; +// This controls JSON-RPC dispatch concurrency rather than database +// connection concurrency, which is configured separately by max_pool_size. const WORKER_POOL_SIZE: usize = 4; // Bounded so a burst of requests applies backpressure to the stdin reader // instead of buffering unboundedly in memory. const REQUEST_QUEUE_CAPACITY: usize = 64; -const POOL_CLEANUP_INTERVAL: Duration = Duration::from_secs(600); // 10 minutes - // The TDS client's async call chains produce large futures (especially in // debug builds). A local SQL Server 2022 execute_query probe overflowed // tokio's default 2 MiB stack while 4 MiB completed; 16 MiB is therefore a @@ -74,10 +75,17 @@ async fn run() { } async fn run_pool_cleanup(mut shutdown_rx: watch::Receiver) { - let mut timer = interval(POOL_CLEANUP_INTERVAL); + let mut settings_rx = settings::subscribe(); loop { + let cleanup_interval = settings::current().pool_idle_eviction_interval(); tokio::select! { - _ = timer.tick() => pool_manager::cleanup_idle_pools().await, + _ = sleep(cleanup_interval) => pool_manager::cleanup_idle_pools().await, + // Reset the timer immediately when initialize supplies an override. + result = settings_rx.changed() => { + if result.is_err() { + break; + } + }, _ = shutdown_rx.changed() => break, } } diff --git a/src/pool_manager.rs b/src/pool_manager.rs index 8b879c2..95b08a9 100644 --- a/src/pool_manager.rs +++ b/src/pool_manager.rs @@ -13,6 +13,7 @@ use tokio::sync::RwLock; use crate::connection::resolve_connection_params; use crate::driver::pool::{build_config, BridgeManager}; use crate::models::ConnectionParams; +use crate::settings; pub type SqlServerPool = DeadPool; type SqlServerPoolMap = Arc>>; @@ -62,9 +63,16 @@ pub async fn get_sqlserver_pool(params: &ConnectionParams) -> Result Value { let params = request.get("params").cloned().unwrap_or(Value::Null); match method.as_str() { - "initialize" => ok_response(id, Value::Null), + "initialize" => { + // Initialization is intentionally infallible: malformed known + // values warn and fall back, while unknown keys are ignored. + settings::initialize(¶ms); + ok_response(id, Value::Null) + } "ping" => query::ping(id, ¶ms).await, "test_connection" => query::test_connection(id, ¶ms).await, diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..4aa8469 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,270 @@ +//! Process-wide plugin settings received through the `initialize` RPC. +//! +//! The host initializes a plugin once, before sending connection requests. +//! Pools snapshot these values when they are created; updating this state does +//! not mutate live pooled sessions. + +use std::sync::RwLock; +use std::time::Duration; + +use once_cell::sync::Lazy; +use serde_json::{Map, Value}; +use tokio::sync::watch; + +pub const DEFAULT_MAX_POOL_SIZE: usize = 10; +pub const DEFAULT_CONNECT_TIMEOUT_SECONDS: u32 = 15; +pub const DEFAULT_QUERY_TIMEOUT_SECONDS: u32 = 0; +pub const DEFAULT_APPLICATION_NAME: &str = "Tabularis"; +pub const DEFAULT_TRUST_SERVER_CERTIFICATE: bool = false; +pub const DEFAULT_POOL_IDLE_EVICTION_MINUTES: u32 = 10; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginSettings { + pub max_pool_size: usize, + pub connect_timeout_seconds: u32, + pub query_timeout_seconds: u32, + pub application_name: String, + pub trust_server_certificate: bool, + pub pool_idle_eviction_minutes: u32, +} + +impl Default for PluginSettings { + fn default() -> Self { + Self { + max_pool_size: DEFAULT_MAX_POOL_SIZE, + connect_timeout_seconds: DEFAULT_CONNECT_TIMEOUT_SECONDS, + query_timeout_seconds: DEFAULT_QUERY_TIMEOUT_SECONDS, + application_name: DEFAULT_APPLICATION_NAME.to_owned(), + trust_server_certificate: DEFAULT_TRUST_SERVER_CERTIFICATE, + pool_idle_eviction_minutes: DEFAULT_POOL_IDLE_EVICTION_MINUTES, + } + } +} + +impl PluginSettings { + fn from_initialize_params(params: &Value) -> Self { + let defaults = Self::default(); + let Some(settings) = params.get("settings") else { + return defaults; + }; + let Some(settings) = settings.as_object() else { + eprintln!("invalid plugin setting 'settings': expected an object, using all defaults"); + return defaults; + }; + + Self { + max_pool_size: positive_usize(settings, "max_pool_size", defaults.max_pool_size), + connect_timeout_seconds: positive_u32( + settings, + "connect_timeout_seconds", + defaults.connect_timeout_seconds, + ), + query_timeout_seconds: nonnegative_u32( + settings, + "query_timeout_seconds", + defaults.query_timeout_seconds, + ), + application_name: string_setting( + settings, + "application_name", + &defaults.application_name, + ), + trust_server_certificate: boolean_setting( + settings, + "trust_server_certificate", + defaults.trust_server_certificate, + ), + pool_idle_eviction_minutes: positive_u32( + settings, + "pool_idle_eviction_minutes", + defaults.pool_idle_eviction_minutes, + ), + } + } + + pub fn query_timeout(&self) -> Option { + (self.query_timeout_seconds > 0) + .then(|| Duration::from_secs(u64::from(self.query_timeout_seconds))) + } + + pub fn pool_idle_eviction_interval(&self) -> Duration { + Duration::from_secs(u64::from(self.pool_idle_eviction_minutes) * 60) + } +} + +static SETTINGS: Lazy> = + Lazy::new(|| RwLock::new(PluginSettings::default())); +static SETTINGS_VERSION: Lazy> = Lazy::new(|| { + let (sender, _) = watch::channel(0); + sender +}); + +/// Apply one forgiving `initialize` payload. Unknown keys are deliberately +/// ignored and each malformed known value falls back independently. +pub fn initialize(params: &Value) { + let new_settings = PluginSettings::from_initialize_params(params); + match SETTINGS.write() { + Ok(mut settings) => *settings = new_settings, + Err(poisoned) => *poisoned.into_inner() = new_settings, + } + SETTINGS_VERSION.send_modify(|version| *version = version.wrapping_add(1)); +} + +/// Return a snapshot suitable for a newly created pool. +pub fn current() -> PluginSettings { + match SETTINGS.read() { + Ok(settings) => settings.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } +} + +/// Notify long-lived maintenance tasks when `initialize` changes settings. +pub fn subscribe() -> watch::Receiver { + SETTINGS_VERSION.subscribe() +} + +fn positive_usize(settings: &Map, key: &str, default: usize) -> usize { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| usize::try_from(value).ok()) { + Some(value) if value > 0 => value, + _ => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn positive_u32(settings: &Map, key: &str, default: u32) -> u32 { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| u32::try_from(value).ok()) { + Some(value) if value > 0 => value, + _ => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn nonnegative_u32(settings: &Map, key: &str, default: u32) -> u32 { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| u32::try_from(value).ok()) { + Some(value) => value, + None => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn string_setting(settings: &Map, key: &str, default: &str) -> String { + match settings.get(key) { + None => default.to_owned(), + Some(value) => match value.as_str() { + Some(value) => value.to_owned(), + None => { + warn_fallback(key, value, default); + default.to_owned() + } + }, + } +} + +fn boolean_setting(settings: &Map, key: &str, default: bool) -> bool { + match settings.get(key) { + None => default, + Some(value) => match value.as_bool() { + Some(value) => value, + None => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn warn_fallback(key: &str, value: &Value, default: impl std::fmt::Display) { + eprintln!("invalid plugin setting '{key}': got {value}, using default {default}"); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn defaults_apply_when_initialize_is_never_called() { + assert_eq!( + PluginSettings::default(), + PluginSettings { + max_pool_size: 10, + connect_timeout_seconds: 15, + query_timeout_seconds: 0, + application_name: "Tabularis".into(), + trust_server_certificate: false, + pool_idle_eviction_minutes: 10, + } + ); + } + + #[test] + fn empty_initialize_settings_use_defaults() { + assert_eq!( + PluginSettings::from_initialize_params(&json!({})), + PluginSettings::default() + ); + assert_eq!( + PluginSettings::from_initialize_params(&json!({ "settings": {} })), + PluginSettings::default() + ); + } + + #[test] + fn initialize_settings_override_every_default() { + let parsed = PluginSettings::from_initialize_params(&json!({ + "settings": { + "max_pool_size": 24, + "connect_timeout_seconds": 7, + "query_timeout_seconds": 90, + "application_name": "Tabularis CI", + "trust_server_certificate": true, + "pool_idle_eviction_minutes": 3, + "future_setting": "ignored" + } + })); + + assert_eq!( + parsed, + PluginSettings { + max_pool_size: 24, + connect_timeout_seconds: 7, + query_timeout_seconds: 90, + application_name: "Tabularis CI".into(), + trust_server_certificate: true, + pool_idle_eviction_minutes: 3, + } + ); + } + + #[test] + fn malformed_initialize_values_fall_back_independently() { + let parsed = PluginSettings::from_initialize_params(&json!({ + "settings": { + "max_pool_size": 0, + "connect_timeout_seconds": "soon", + "query_timeout_seconds": -1, + "application_name": false, + "trust_server_certificate": "yes", + "pool_idle_eviction_minutes": 1.5 + } + })); + + assert_eq!(parsed, PluginSettings::default()); + } +} From ddcbd98fa00fcb9b57c20a5bf6abfd9773284056 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 11:49:02 +0000 Subject: [PATCH 05/23] feat: add SQL Server user management --- .tabularium | 1 + README.md | 36 ++ src/driver/mod.rs | 1 + src/driver/ops.rs | 89 ++++- src/driver/users.rs | 727 ++++++++++++++++++++++++++++++++++++++ src/driver/users/tests.rs | 120 +++++++ src/handlers/mod.rs | 1 + src/handlers/users.rs | 129 +++++++ src/models.rs | 27 ++ src/rpc.rs | 12 +- tests/live_db.rs | 182 ++++++++++ 11 files changed, 1321 insertions(+), 4 deletions(-) create mode 100644 src/driver/users.rs create mode 100644 src/driver/users/tests.rs create mode 100644 src/handlers/users.rs diff --git a/.tabularium b/.tabularium index 83f8e2b..0551336 100644 --- a/.tabularium +++ b/.tabularium @@ -13,6 +13,7 @@ "routines": true, "routine_management": true, "triggers": true, + "user_management": true, "file_based": false, "folder_based": false, "no_connection_required": false, diff --git a/README.md b/README.md index 49c6509..e8d4fa8 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The client was swapped to Microsoft's protocol implementation to align the plugi - [Connection Configuration](#connection-configuration) - [Plugin Settings](#plugin-settings) - [Supported Data Types](#supported-data-types) +- [Database Users and Privileges](#database-users-and-privileges) - [Installation](#installation) - [Known Limitations](#known-limitations) - [Building from Source](#building-from-source) @@ -43,6 +44,7 @@ The client was swapped to Microsoft's protocol implementation to align the plugi - INSERT/UPDATE/DELETE with composite primary keys and safe `IDENTITY_INSERT` recovery - Table/view/index/foreign-key DDL and safe `ALTER COLUMN` generation - Trigger creation, editing, and removal +- SQL-authenticated database-user, login, role, and privilege management - Procedure/function management, typed `OUT`/`INOUT` variables, and table-valued functions - Static and runtime execution plans through `SHOWPLAN_XML` / `STATISTICS XML`, rendered in Tabularis's Visual EXPLAIN - JavaScript-safe `BIGINT` extraction and broad SQL Server type handling @@ -145,6 +147,40 @@ oversized value produces an error with the actual and configured sizes and can still be exported directly to a file without passing through base64 or a JSON-RPC response. +## Database Users and Privileges + +For this plugin a **database user** means a database-scoped SQL user mapped to +a server-scoped SQL login. In Tabularis's account display, `user` is the +principal in the connected database and the host-shaped field after `@` is the +mapped login name; it is not a network host. Windows, Azure AD, certificate, +contained, orphaned, and login-less users are intentionally not listed or +managed. Creating an account creates the login first and then its mapped user; +dropping it drops the user first and then the login. SQL Server's own ownership +checks are preserved, so a user that owns a schema or object must have that +ownership transferred before it can be dropped. + +The host protocol's three MySQL-named scope shapes map to SQL Server as follows: + +| Host wire scope | SQL Server scope | +|-----------------|------------------| +| `database = null`, `table = null` | Connected database | +| `database = schema`, `table = null` | Schema | +| `database = schema`, `table = object` | Object | + +The privilege catalog follows the same mapping: its `global` entries are the +extra database-only permissions, `database` entries are permissions shared by +database and schema scopes, and `table` entries are object permissions. +Tabularis computes a requested checkbox diff, and the plugin checks the current +direct permissions again before applying only the required `GRANT` or `REVOKE` +statements in a transaction. + +The parsed checkbox view contains direct grants only. The raw grants view also +labels role memberships, permissions inherited through roles, grants with +grant option, and direct `DENY` entries, so inherited rights are never shown as +if they were direct grants. Because SQL Server `DENY` overrides `GRANT`, the +plugin refuses to alter a denied permission; remove that `DENY` explicitly in +SQL before managing the permission through Tabularis. + ## Installation ### Automatic (via Tabularis) diff --git a/src/driver/mod.rs b/src/driver/mod.rs index d9c5688..534af21 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -16,6 +16,7 @@ pub mod routines; pub mod showplan; pub mod triggers; pub mod types; +pub mod users; pub mod version; use mssql_tds::connection::tds_client::{ResultSet, ResultSetClient}; diff --git a/src/driver/ops.rs b/src/driver/ops.rs index a323669..34fc071 100644 --- a/src/driver/ops.rs +++ b/src/driver/ops.rs @@ -10,11 +10,12 @@ use crate::driver::helpers::{ }; use crate::driver::{ acquire, blob, ddl, execute_on_connection, explain, helpers, introspection, routines, triggers, + users, }; use crate::models::{ - AiSchemaContext, BatchStatementResult, ColumnDefinition, ConnectionParams, ForeignKey, Index, - PkMap, QueryResult, RoutineCallArg, RoutineInfo, RoutineParameter, TableColumn, TableInfo, - TableSchema, TriggerInfo, ViewInfo, + AiSchemaContext, BatchStatementResult, ColumnDefinition, ConnectionParams, DbPrivilegeCatalog, + DbUserGrantSet, DbUserInfo, ForeignKey, Index, PkMap, QueryResult, RoutineCallArg, RoutineInfo, + RoutineParameter, TableColumn, TableInfo, TableSchema, TriggerInfo, ViewInfo, }; pub async fn test_connection(params: &ConnectionParams) -> Result<(), String> { @@ -266,6 +267,88 @@ pub async fn drop_routine( execute_query(params, &sql, None, 1).await.map(|_| ()) } +// --- Database users and privileges ------------------------------------ + +pub fn get_db_privilege_catalog() -> DbPrivilegeCatalog { + users::privilege_catalog() +} + +pub async fn get_db_users(params: &ConnectionParams) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_users(&mut conn).await +} + +pub async fn create_db_user( + params: &ConnectionParams, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::create_user(&mut conn, user, login, password).await +} + +pub async fn drop_db_user( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::drop_user(&mut conn, user, login).await +} + +pub async fn set_db_user_password( + params: &ConnectionParams, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::set_password(&mut conn, user, login, password).await +} + +pub async fn get_db_user_grants( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_grants(&mut conn, user, login).await +} + +pub async fn get_db_user_privileges( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_privileges(&mut conn, user, login).await +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_db_user_privileges( + params: &ConnectionParams, + user: &str, + login: &str, + database: Option<&str>, + table: Option<&str>, + privileges: &[String], + grant: bool, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::apply_privileges( + &mut conn, + params.database.primary(), + user, + login, + database, + table, + privileges, + grant, + ) + .await +} + // --- Query execution --------------------------------------------------- pub async fn execute_query( diff --git a/src/driver/users.rs b/src/driver/users.rs new file mode 100644 index 0000000..59fc315 --- /dev/null +++ b/src/driver/users.rs @@ -0,0 +1,727 @@ +//! SQL Server database-user and database-permission management. +//! +//! Tabularis models MySQL-style global/database/table scopes. For SQL Server +//! we map those three wire shapes to database/schema/object respectively: +//! `(None, None)`, `(Some(schema), None)`, and +//! `(Some(schema), Some(object))`. + +use std::collections::BTreeMap; + +use mssql_tds::message::transaction_management::TransactionIsolationLevel; +use mssql_tiberius_bridge::Row; + +use crate::driver::helpers::bracket_quote; +use crate::driver::pool::BridgeConnection; +use crate::models::{DbPrivilegeCatalog, DbUserGrantSet, DbUserInfo}; + +const DATABASE_AND_SCHEMA_PRIVILEGES: &[&str] = &[ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION", +]; + +const DATABASE_ONLY_PRIVILEGES: &[&str] = &[ + "AUTHENTICATE", + "BACKUP DATABASE", + "BACKUP LOG", + "CHECKPOINT", + "CONNECT", + "CREATE FUNCTION", + "CREATE PROCEDURE", + "CREATE ROLE", + "CREATE SCHEMA", + "CREATE SYNONYM", + "CREATE TABLE", + "CREATE TYPE", + "CREATE VIEW", + "SHOWPLAN", + "SUBSCRIBE QUERY NOTIFICATIONS", + "UNMASK", + "VIEW DATABASE STATE", +]; + +const OBJECT_PRIVILEGES: &[&str] = &[ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "RECEIVE", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION", +]; + +const LIST_USERS: &str = r#" +SELECT dp.name AS user_name, + sp.name AS login_name, + CAST(ISNULL(LOGINPROPERTY(sp.name, 'IsLocked'), 0) AS bit) AS is_locked +FROM sys.database_principals AS dp +JOIN sys.server_principals AS sp + ON sp.sid = dp.sid AND sp.type = 'S' +WHERE dp.type = 'S' + AND dp.authentication_type = 1 + AND dp.principal_id > 4 + AND dp.name NOT IN ('dbo', 'guest', 'INFORMATION_SCHEMA', 'sys') +ORDER BY dp.name, sp.name +"#; + +const ACCOUNT_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 + FROM sys.database_principals AS dp + JOIN sys.server_principals AS sp ON sp.sid = dp.sid AND sp.type = 'S' + WHERE dp.type = 'S' AND dp.authentication_type = 1 + AND dp.name = @P1 AND sp.name = @P2 +) THEN 1 ELSE 0 END AS bit) +"#; + +const LOGIN_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 FROM sys.server_principals WHERE type = 'S' AND name = @P1 +) THEN 1 ELSE 0 END AS bit) +"#; + +const USER_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 FROM sys.database_principals WHERE name = @P1 +) THEN 1 ELSE 0 END AS bit) +"#; + +const DIRECT_PERMISSIONS: &str = r#" +SELECT CAST('DIRECT' AS nvarchar(128)) AS source_name, + p.state_desc, + p.permission_name, + p.class_desc, + CASE WHEN p.class = 3 THEN SCHEMA_NAME(p.major_id) + WHEN p.class = 1 THEN OBJECT_SCHEMA_NAME(p.major_id) + ELSE DB_NAME() END AS scope_name, + CASE WHEN p.class = 1 THEN OBJECT_NAME(p.major_id) ELSE NULL END AS object_name +FROM sys.database_permissions AS p +JOIN sys.database_principals AS grantee ON grantee.principal_id = p.grantee_principal_id +WHERE grantee.name = @P1 + AND p.class IN (0, 1, 3) + AND (p.class <> 1 OR p.minor_id = 0) +ORDER BY p.class, scope_name, object_name, p.permission_name +"#; + +const INHERITED_PERMISSIONS: &str = r#" +WITH role_tree AS ( + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN sys.database_principals AS member + ON member.principal_id = drm.member_principal_id + WHERE member.name = @P1 + UNION ALL + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN role_tree AS child ON child.role_principal_id = drm.member_principal_id +) +SELECT role.name AS source_name, + p.state_desc, + p.permission_name, + p.class_desc, + CASE WHEN p.class = 3 THEN SCHEMA_NAME(p.major_id) + WHEN p.class = 1 THEN OBJECT_SCHEMA_NAME(p.major_id) + ELSE DB_NAME() END AS scope_name, + CASE WHEN p.class = 1 THEN OBJECT_NAME(p.major_id) ELSE NULL END AS object_name +FROM role_tree AS tree +JOIN sys.database_principals AS role ON role.principal_id = tree.role_principal_id +JOIN sys.database_permissions AS p ON p.grantee_principal_id = role.principal_id +WHERE p.class IN (0, 1, 3) + AND (p.class <> 1 OR p.minor_id = 0) +ORDER BY role.name, p.class, scope_name, object_name, p.permission_name +OPTION (MAXRECURSION 32) +"#; + +const ROLE_MEMBERSHIPS: &str = r#" +WITH role_tree AS ( + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN sys.database_principals AS member + ON member.principal_id = drm.member_principal_id + WHERE member.name = @P1 + UNION ALL + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN role_tree AS child ON child.role_principal_id = drm.member_principal_id +) +SELECT DISTINCT role.name +FROM role_tree AS tree +JOIN sys.database_principals AS role ON role.principal_id = tree.role_principal_id +ORDER BY role.name +OPTION (MAXRECURSION 32) +"#; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PermissionScope { + Database(String), + Schema(String), + Object { schema: String, object: String }, +} + +#[derive(Debug, Clone)] +struct Permission { + source: String, + state: String, + name: String, + scope: PermissionScope, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RequestedScope { + Database, + Schema(String), + Object { schema: String, object: String }, +} + +impl RequestedScope { + fn from_wire(database: Option<&str>, table: Option<&str>) -> Result { + match (database, table) { + (None, None) => Ok(Self::Database), + (Some(schema), None) if !schema.trim().is_empty() => { + Ok(Self::Schema(schema.to_string())) + } + (Some(schema), Some(object)) + if !schema.trim().is_empty() && !object.trim().is_empty() => + { + Ok(Self::Object { + schema: schema.to_string(), + object: object.to_string(), + }) + } + (None, Some(_)) => Err("An object scope requires a schema".to_string()), + _ => Err("Schema and object names cannot be empty".to_string()), + } + } + + fn target_sql(&self, database_name: &str) -> String { + match self { + Self::Database => format!("DATABASE::{}", bracket_quote(database_name)), + Self::Schema(schema) => format!("SCHEMA::{}", bracket_quote(schema)), + Self::Object { schema, object } => format!( + "OBJECT::{}.{}", + bracket_quote(schema), + bracket_quote(object) + ), + } + } + + fn allows(&self, privilege: &str) -> bool { + match self { + Self::Database => { + DATABASE_AND_SCHEMA_PRIVILEGES.contains(&privilege) + || DATABASE_ONLY_PRIVILEGES.contains(&privilege) + } + Self::Schema(_) => DATABASE_AND_SCHEMA_PRIVILEGES.contains(&privilege), + Self::Object { .. } => OBJECT_PRIVILEGES.contains(&privilege), + } + } + + fn matches(&self, permission: &PermissionScope) -> bool { + match (self, permission) { + (Self::Database, PermissionScope::Database(_)) => true, + (Self::Schema(requested), PermissionScope::Schema(actual)) => { + requested.eq_ignore_ascii_case(actual) + } + ( + Self::Object { + schema: requested_schema, + object: requested_object, + }, + PermissionScope::Object { + schema: actual_schema, + object: actual_object, + }, + ) => { + requested_schema.eq_ignore_ascii_case(actual_schema) + && requested_object.eq_ignore_ascii_case(actual_object) + } + _ => false, + } + } +} + +pub fn privilege_catalog() -> DbPrivilegeCatalog { + DbPrivilegeCatalog { + // The frontend shows `database + global` for its top-level card and + // `database` for its middle card. We use those as database and schema + // respectively, so `global` contains database-only permissions. + database: strings(DATABASE_AND_SCHEMA_PRIVILEGES), + global: strings(DATABASE_ONLY_PRIVILEGES), + table: strings(OBJECT_PRIVILEGES), + } +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn validate_account(user: &str, login: &str) -> Result<(), String> { + if user.trim().is_empty() { + return Err("Database user name cannot be empty".to_string()); + } + if login.trim().is_empty() { + return Err("SQL Server login name cannot be empty".to_string()); + } + Ok(()) +} + +fn password_literal(password: &str) -> String { + format!("N'{}'", password.replace('\'', "''")) +} + +fn redact_password(mut message: String, password: &str) -> String { + if !password.is_empty() { + message = message.replace(password, "[REDACTED]"); + let escaped = password.replace('\'', "''"); + if escaped != password { + message = message.replace(&escaped, "[REDACTED]"); + } + } + message +} + +async fn execute_transaction_batch( + conn: &mut BridgeConnection, + statements: &[String], +) -> Result<(), String> { + // SQL BEGIN/COMMIT sent as a regular batch triggers SQL Server error 3981 + // with this preview client. Its TDS transaction-management API carries the + // transaction descriptor correctly, so use that API around raw language + // batches and explicitly roll back the first failed statement. + let query_timeout_seconds = conn.query_timeout_seconds(); + let client = conn.inner_mut(); + client + .close_query() + .await + .map_err(|error| error.to_string())?; + client + .begin_transaction(TransactionIsolationLevel::ReadCommitted, None) + .await + .map_err(|error| error.to_string())?; + + for statement in statements { + let outcome = match client + .execute(statement.clone(), query_timeout_seconds, None) + .await + { + Ok(()) => client.close_query().await, + Err(error) => Err(error), + }; + if let Err(error) = outcome { + let _ = client.close_query().await; + let rollback = client.rollback_transaction(None, None).await; + return Err(match rollback { + Ok(()) => error.to_string(), + Err(rollback_error) => { + format!("{error}; transaction rollback also failed: {rollback_error}") + } + }); + } + } + + client + .commit_transaction(None, None) + .await + .map_err(|error| error.to_string()) +} + +async fn query_bool( + conn: &mut BridgeConnection, + query: &str, + values: &[&dyn mssql_tiberius_bridge::ToSql], +) -> Result { + Ok(conn + .query(query, values) + .await + .map_err(|error| error.to_string())? + .into_first_result() + .first() + .and_then(|row| row.get::(0)) + .unwrap_or(false)) +} + +async fn ensure_account( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result<(), String> { + validate_account(user, login)?; + if query_bool(conn, ACCOUNT_EXISTS, &[&user, &login]).await? { + Ok(()) + } else { + Err(format!( + "Database user {} is not mapped to SQL Server login {} in the current database", + bracket_quote(user), + bracket_quote(login) + )) + } +} + +pub async fn get_users(conn: &mut BridgeConnection) -> Result, String> { + let rows = conn + .simple_query(LIST_USERS) + .await + .map_err(|error| format!("Failed to list SQL Server database users: {error}"))? + .into_first_result(); + Ok(rows + .into_iter() + .filter_map(|row| { + Some(DbUserInfo { + user: row.get::<&str, _>("user_name")?.to_string(), + host: row.get::<&str, _>("login_name")?.to_string(), + locked: row.get::("is_locked").unwrap_or(false), + }) + }) + .collect()) +} + +pub async fn create_user( + conn: &mut BridgeConnection, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + validate_account(user, login)?; + if query_bool(conn, LOGIN_EXISTS, &[&login]).await? { + return Err(format!( + "SQL Server login {} already exists", + bracket_quote(login) + )); + } + if query_bool(conn, USER_EXISTS, &[&user]).await? { + return Err(format!( + "Database principal {} already exists in the current database", + bracket_quote(user) + )); + } + + let create_login = format!( + "CREATE LOGIN {} WITH PASSWORD = {}, CHECK_POLICY = ON, CHECK_EXPIRATION = OFF", + bracket_quote(login), + password_literal(password) + ); + conn.simple_query(create_login) + .await + .map_err(|error| { + redact_password( + format!( + "Failed to create SQL Server login {}: {error}", + bracket_quote(login) + ), + password, + ) + })? + .into_results(); + + let create_database_user = format!( + "CREATE USER {} FOR LOGIN {}", + bracket_quote(user), + bracket_quote(login) + ); + if let Err(error) = conn.simple_query(create_database_user).await { + let cleanup = conn + .simple_query(format!("DROP LOGIN {}", bracket_quote(login))) + .await; + let cleanup_note = cleanup + .err() + .map(|cleanup_error| format!("; login cleanup also failed: {cleanup_error}")) + .unwrap_or_default(); + return Err(redact_password( + format!( + "Failed to create database user {} for login {}: {error}{cleanup_note}", + bracket_quote(user), + bracket_quote(login) + ), + password, + )); + } + Ok(()) +} + +pub async fn drop_user(conn: &mut BridgeConnection, user: &str, login: &str) -> Result<(), String> { + ensure_account(conn, user, login).await?; + conn.simple_query(format!("DROP USER {}", bracket_quote(user))) + .await + .map_err(|error| { + format!( + "Failed to drop database user {}. SQL Server may be protecting a schema or object owned by this user: {error}", + bracket_quote(user) + ) + })? + .into_results(); + conn.simple_query(format!("DROP LOGIN {}", bracket_quote(login))) + .await + .map_err(|error| { + format!( + "Database user {} was dropped, but its SQL Server login {} could not be dropped: {error}", + bracket_quote(user), + bracket_quote(login) + ) + })? + .into_results(); + Ok(()) +} + +pub async fn set_password( + conn: &mut BridgeConnection, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + ensure_account(conn, user, login).await?; + let sql = format!( + "ALTER LOGIN {} WITH PASSWORD = {}", + bracket_quote(login), + password_literal(password) + ); + conn.simple_query(sql) + .await + .map_err(|error| { + redact_password( + format!( + "Failed to change password for SQL Server login {}: {error}", + bracket_quote(login) + ), + password, + ) + })? + .into_results(); + Ok(()) +} + +fn permission_from_row(row: &Row) -> Option { + let class = row.get::<&str, _>("class_desc")?; + let scope_name = row.get::<&str, _>("scope_name").unwrap_or(""); + let scope = match class { + "DATABASE" => PermissionScope::Database(scope_name.to_string()), + "SCHEMA" => PermissionScope::Schema(scope_name.to_string()), + "OBJECT_OR_COLUMN" => PermissionScope::Object { + schema: scope_name.to_string(), + object: row.get::<&str, _>("object_name")?.to_string(), + }, + _ => return None, + }; + Some(Permission { + source: row.get::<&str, _>("source_name")?.to_string(), + state: row.get::<&str, _>("state_desc")?.to_string(), + name: row.get::<&str, _>("permission_name")?.to_string(), + scope, + }) +} + +async fn permissions( + conn: &mut BridgeConnection, + user: &str, + inherited: bool, +) -> Result, String> { + let sql = if inherited { + INHERITED_PERMISSIONS + } else { + DIRECT_PERMISSIONS + }; + Ok(conn + .query(sql, &[&user]) + .await + .map_err(|error| format!("Failed to inspect SQL Server permissions: {error}"))? + .into_first_result() + .iter() + .filter_map(permission_from_row) + .collect()) +} + +fn scope_wire(scope: &PermissionScope) -> (Option, Option) { + match scope { + PermissionScope::Database(_) => (None, None), + PermissionScope::Schema(schema) => (Some(schema.clone()), None), + PermissionScope::Object { schema, object } => (Some(schema.clone()), Some(object.clone())), + } +} + +fn permission_sql(permission: &Permission, user: &str) -> String { + let target = match &permission.scope { + PermissionScope::Database(database) => { + format!("DATABASE::{}", bracket_quote(database)) + } + PermissionScope::Schema(schema) => { + format!("SCHEMA::{}", bracket_quote(schema)) + } + PermissionScope::Object { schema, object } => format!( + "OBJECT::{}.{}", + bracket_quote(schema), + bracket_quote(object) + ), + }; + let verb = if permission.state == "DENY" { + "DENY" + } else { + "GRANT" + }; + let suffix = if permission.state == "GRANT_WITH_GRANT_OPTION" { + " WITH GRANT OPTION" + } else { + "" + }; + format!( + "{verb} {} ON {target} TO {}{suffix}", + permission.name, + bracket_quote(user) + ) +} + +pub async fn get_grants( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result, String> { + ensure_account(conn, user, login).await?; + let direct = permissions(conn, user, false).await?; + let inherited = permissions(conn, user, true).await?; + let role_rows = conn + .query(ROLE_MEMBERSHIPS, &[&user]) + .await + .map_err(|error| format!("Failed to inspect SQL Server role memberships: {error}"))? + .into_first_result(); + + let mut lines = direct + .iter() + .map(|permission| permission_sql(permission, user)) + .collect::>(); + lines.extend(role_rows.iter().filter_map(|row| { + row.get::<&str, _>(0).map(|role| { + format!( + "ROLE MEMBERSHIP: ALTER ROLE {} ADD MEMBER {}", + bracket_quote(role), + bracket_quote(user) + ) + }) + })); + lines.extend(inherited.iter().map(|permission| { + format!( + "INHERITED VIA ROLE {}: {}", + bracket_quote(&permission.source), + permission_sql(permission, &permission.source) + ) + })); + Ok(lines) +} + +pub async fn get_privileges( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result, String> { + ensure_account(conn, user, login).await?; + let mut grouped: BTreeMap<(Option, Option), Vec> = BTreeMap::new(); + for permission in permissions(conn, user, false).await? { + // DENY and inherited role rights stay in the raw grants view. Showing + // either as a checked direct grant would make the editor lie about + // what a REVOKE can remove. + if permission.state != "GRANT" && permission.state != "GRANT_WITH_GRANT_OPTION" { + continue; + } + let names = grouped.entry(scope_wire(&permission.scope)).or_default(); + if !names.contains(&permission.name) { + names.push(permission.name); + names.sort(); + } + } + Ok(grouped + .into_iter() + .map(|((database, table), privileges)| DbUserGrantSet { + database, + table, + privileges, + }) + .collect()) +} + +fn canonical_privileges( + scope: &RequestedScope, + privileges: &[String], +) -> Result, String> { + if privileges.is_empty() { + return Err("No privileges selected".to_string()); + } + let mut canonical = Vec::with_capacity(privileges.len()); + for privilege in privileges { + let name = privilege.trim().to_uppercase(); + if !scope.allows(name.as_str()) { + return Err(format!( + "Unsupported SQL Server privilege '{privilege}' for this scope" + )); + } + if !canonical.contains(&name) { + canonical.push(name); + } + } + Ok(canonical) +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_privileges( + conn: &mut BridgeConnection, + database_name: &str, + user: &str, + login: &str, + database: Option<&str>, + table: Option<&str>, + privileges: &[String], + grant: bool, +) -> Result<(), String> { + ensure_account(conn, user, login).await?; + let scope = RequestedScope::from_wire(database, table)?; + let requested = canonical_privileges(&scope, privileges)?; + let current = permissions(conn, user, false).await?; + + let mut statements = Vec::new(); + for privilege in requested { + let states = current + .iter() + .filter(|permission| { + scope.matches(&permission.scope) && permission.name.eq_ignore_ascii_case(&privilege) + }) + .map(|permission| permission.state.as_str()) + .collect::>(); + if states.contains(&"DENY") { + return Err(format!( + "Cannot manage denied permission '{privilege}': remove the SQL Server DENY explicitly before using Tabularis" + )); + } + let already_granted = states + .iter() + .any(|state| matches!(*state, "GRANT" | "GRANT_WITH_GRANT_OPTION")); + if already_granted == grant { + continue; + } + let verb = if grant { "GRANT" } else { "REVOKE" }; + let preposition = if grant { "TO" } else { "FROM" }; + statements.push(format!( + "{verb} {privilege} ON {} {preposition} {}", + scope.target_sql(database_name), + bracket_quote(user) + )); + } + + if statements.is_empty() { + return Ok(()); + } + execute_transaction_batch(conn, &statements) + .await + .map_err(|error| format!("Failed to apply SQL Server privilege diff: {error}")) +} + +#[cfg(test)] +mod tests; diff --git a/src/driver/users/tests.rs b/src/driver/users/tests.rs new file mode 100644 index 0000000..7d3ffde --- /dev/null +++ b/src/driver/users/tests.rs @@ -0,0 +1,120 @@ +use super::*; + +#[test] +fn catalog_separates_database_schema_and_object_permissions() { + let catalog = privilege_catalog(); + assert!(catalog.database.contains(&"SELECT".to_string())); + assert!(catalog.global.contains(&"CREATE TABLE".to_string())); + assert!(!catalog.database.contains(&"CREATE TABLE".to_string())); + assert!(catalog.table.contains(&"RECEIVE".to_string())); +} + +#[test] +fn wire_scopes_map_to_database_schema_and_object() { + assert_eq!( + RequestedScope::from_wire(None, None).unwrap(), + RequestedScope::Database + ); + assert_eq!( + RequestedScope::from_wire(Some("sales"), None).unwrap(), + RequestedScope::Schema("sales".to_string()) + ); + assert_eq!( + RequestedScope::from_wire(Some("sales"), Some("orders")).unwrap(), + RequestedScope::Object { + schema: "sales".to_string(), + object: "orders".to_string() + } + ); + assert!(RequestedScope::from_wire(None, Some("orders")).is_err()); +} + +#[test] +fn targets_bracket_quote_every_identifier() { + assert_eq!( + RequestedScope::Database.target_sql("db]name"), + "DATABASE::[db]]name]" + ); + assert_eq!( + RequestedScope::Schema("schema]name".to_string()).target_sql("ignored"), + "SCHEMA::[schema]]name]" + ); + assert_eq!( + RequestedScope::Object { + schema: "odd]schema".to_string(), + object: "odd]object".to_string(), + } + .target_sql("ignored"), + "OBJECT::[odd]]schema].[odd]]object]" + ); +} + +#[test] +fn privileges_are_scope_validated_and_deduplicated() { + let database = canonical_privileges( + &RequestedScope::Database, + &[ + "select".to_string(), + " SELECT ".to_string(), + "SHOWPLAN".to_string(), + ], + ) + .unwrap(); + assert_eq!(database, ["SELECT", "SHOWPLAN"]); + + let schema = canonical_privileges( + &RequestedScope::Schema("dbo".to_string()), + &["CREATE TABLE".to_string()], + ); + assert!(schema.unwrap_err().contains("Unsupported")); +} + +#[test] +fn password_errors_are_redacted() { + let password = "Secret'Value"; + let error = redact_password( + "server rejected Secret'Value represented as Secret''Value".to_string(), + password, + ); + assert!(!error.contains("Secret")); + assert!(error.contains("[REDACTED]")); +} + +#[test] +fn permission_rendering_marks_scope_deny_and_grant_option() { + let database = Permission { + source: "DIRECT".to_string(), + state: "GRANT".to_string(), + name: "CONNECT".to_string(), + scope: PermissionScope::Database("db]name".to_string()), + }; + assert_eq!( + permission_sql(&database, "reader"), + "GRANT CONNECT ON DATABASE::[db]]name] TO [reader]" + ); + + let denied = Permission { + source: "DIRECT".to_string(), + state: "DENY".to_string(), + name: "DELETE".to_string(), + scope: PermissionScope::Schema("sales".to_string()), + }; + assert_eq!( + permission_sql(&denied, "reader]name"), + "DENY DELETE ON SCHEMA::[sales] TO [reader]]name]" + ); + + let grantable = Permission { + source: "DIRECT".to_string(), + state: "GRANT_WITH_GRANT_OPTION".to_string(), + name: "SELECT".to_string(), + scope: PermissionScope::Object { + schema: "sales".to_string(), + object: "orders".to_string(), + }, + }; + assert_eq!( + permission_sql(&grantable, "reader"), + "GRANT SELECT ON OBJECT::[sales].[orders] TO [reader] WITH GRANT OPTION" + ); +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 14b8be7..26f91e5 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -5,4 +5,5 @@ pub mod metadata; pub mod query; pub mod routines; pub mod triggers; +pub mod users; pub mod views; diff --git a/src/handlers/users.rs b/src/handlers/users.rs new file mode 100644 index 0000000..c40ec7b --- /dev/null +++ b/src/handlers/users.rs @@ -0,0 +1,129 @@ +//! JSON-RPC adapters for SQL Server database users and privileges. + +use serde_json::Value; + +use crate::driver::ops; +use crate::rpc::{conn_params, opt_str, req_field, req_str, respond}; + +pub async fn get_db_privilege_catalog(id: Value) -> Value { + respond(id, Ok(ops::get_db_privilege_catalog())) +} + +pub async fn get_db_users(id: Value, params: &Value) -> Value { + let conn = match conn_params(params) { + Ok(conn) => conn, + Err(error) => return respond::<()>(id, Err(error)), + }; + respond(id, ops::get_db_users(&conn).await) +} + +pub async fn create_db_user(id: Value, params: &Value) -> Value { + let (conn, user, login, password) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + req_str(params, "password"), + ) { + (Ok(conn), Ok(user), Ok(login), Ok(password)) => (conn, user, login, password), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + respond(id, ops::create_db_user(&conn, user, login, password).await) +} + +pub async fn drop_db_user(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::drop_db_user(&conn, user, login).await) +} + +pub async fn set_db_user_password(id: Value, params: &Value) -> Value { + let (conn, user, login, password) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + req_str(params, "password"), + ) { + (Ok(conn), Ok(user), Ok(login), Ok(password)) => (conn, user, login, password), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + respond( + id, + ops::set_db_user_password(&conn, user, login, password).await, + ) +} + +pub async fn get_db_user_grants(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::get_db_user_grants(&conn, user, login).await) +} + +pub async fn get_db_user_privileges(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::get_db_user_privileges(&conn, user, login).await) +} + +pub async fn apply_db_user_privileges(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + let privileges: Vec = match req_field(params, "privileges") { + Ok(privileges) => privileges, + Err(error) => return respond::<()>(id, Err(error)), + }; + let grant: bool = match req_field(params, "grant") { + Ok(grant) => grant, + Err(error) => return respond::<()>(id, Err(error)), + }; + respond( + id, + ops::apply_db_user_privileges( + &conn, + user, + login, + opt_str(params, "database"), + opt_str(params, "table"), + &privileges, + grant, + ) + .await, + ) +} diff --git a/src/models.rs b/src/models.rs index 2289e2a..e2e9dcc 100644 --- a/src/models.rs +++ b/src/models.rs @@ -208,6 +208,33 @@ pub struct TriggerInfo { pub definition: Option, } +/// One database principal backed by a SQL Server login. The host's `host` +/// field carries the mapped login name for SQL Server. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DbUserInfo { + pub user: String, + pub host: String, + pub locked: bool, +} + +/// SQL Server privilege names accepted by the three host scope lists. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct DbPrivilegeCatalog { + pub database: Vec, + pub global: Vec, + pub table: Vec, +} + +/// Direct grants at one database, schema, or object scope. SQL Server maps +/// those levels to `(None, None)`, `(Some(schema), None)`, and +/// `(Some(schema), Some(object))` on the host wire shape. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DbUserGrantSet { + pub database: Option, + pub table: Option, + pub privileges: Vec, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ColumnDefinition { pub name: String, diff --git a/src/rpc.rs b/src/rpc.rs index 8d5a4fd..03adf0b 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -4,7 +4,7 @@ use serde::Serialize; use serde_json::{json, Value}; use crate::connection::resolve_connection_params; -use crate::handlers::{blob, crud, ddl, metadata, query, routines, triggers, views}; +use crate::handlers::{blob, crud, ddl, metadata, query, routines, triggers, users, views}; use crate::models::ConnectionParams; use crate::settings; @@ -70,6 +70,16 @@ pub async fn handle_line(line: &str) -> Value { "create_trigger" => triggers::create_trigger(id, ¶ms).await, "drop_trigger" => triggers::drop_trigger(id, ¶ms).await, + // Database users and privileges. + "get_db_privilege_catalog" => users::get_db_privilege_catalog(id).await, + "get_db_users" => users::get_db_users(id, ¶ms).await, + "create_db_user" => users::create_db_user(id, ¶ms).await, + "drop_db_user" => users::drop_db_user(id, ¶ms).await, + "set_db_user_password" => users::set_db_user_password(id, ¶ms).await, + "get_db_user_grants" => users::get_db_user_grants(id, ¶ms).await, + "get_db_user_privileges" => users::get_db_user_privileges(id, ¶ms).await, + "apply_db_user_privileges" => users::apply_db_user_privileges(id, ¶ms).await, + // Query execution. "execute_query" => query::execute_query(id, ¶ms).await, "execute_query_batch" => query::execute_query_batch(id, ¶ms).await, diff --git a/tests/live_db.rs b/tests/live_db.rs index f915013..6421fe1 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -862,3 +862,185 @@ fn connection_string_only_connects_for_url_and_keyword_syntaxes() { assert_eq!(result, json!({ "success": true }), "{syntax} syntax"); } } + +#[test] +fn database_user_lifecycle_privilege_diff_roles_and_ownership_guard() { + const LOGIN: &str = "ss014_login"; + const USER: &str = "ss014_user"; + const ROLE: &str = "ss014_role"; + const OWNED_SCHEMA: &str = "ss014_owned"; + const PASSWORD_1: &str = "Ss014!InitialPass9"; + const PASSWORD_2: &str = "Ss014!ChangedPass9"; + + let mut plugin = Plugin::with_scratch_database(); + plugin.execute(format!( + "IF SCHEMA_ID(N'{OWNED_SCHEMA}') IS NOT NULL BEGIN \ + ALTER AUTHORIZATION ON SCHEMA::[{OWNED_SCHEMA}] TO [dbo]; \ + DROP SCHEMA [{OWNED_SCHEMA}]; \ + END; \ + IF DATABASE_PRINCIPAL_ID(N'{ROLE}') IS NOT NULL \ + AND DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL \ + ALTER ROLE [{ROLE}] DROP MEMBER [{USER}]; \ + IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; \ + IF DATABASE_PRINCIPAL_ID(N'{ROLE}') IS NOT NULL DROP ROLE [{ROLE}]; \ + IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]; \ + DROP TABLE IF EXISTS [{TEST_SCHEMA}].[ss014_permissions]; \ + CREATE TABLE [{TEST_SCHEMA}].[ss014_permissions] \ + (id INT PRIMARY KEY, value NVARCHAR(20) NOT NULL)" + )); + + let catalog = plugin.call_ok("get_db_privilege_catalog", json!({})); + assert!(catalog["database"] + .as_array() + .expect("database catalog") + .contains(&json!("SELECT"))); + assert!(catalog["global"] + .as_array() + .expect("database-only catalog") + .contains(&json!("SHOWPLAN"))); + assert!(catalog["table"] + .as_array() + .expect("object catalog") + .contains(&json!("UPDATE"))); + + plugin.call_ok( + "create_db_user", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "password": PASSWORD_1 + }), + ); + let users = plugin.call_ok("get_db_users", json!({ "params": connection_params() })); + assert!(users + .as_array() + .expect("users array") + .iter() + .any(|account| { account == &json!({ "user": USER, "host": LOGIN, "locked": false }) })); + + plugin.call_ok( + "set_db_user_password", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "password": PASSWORD_2 + }), + ); + plugin.execute(format!( + "CREATE ROLE [{ROLE}]; \ + GRANT UPDATE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] TO [{ROLE}]; \ + ALTER ROLE [{ROLE}] ADD MEMBER [{USER}]" + )); + for (database, table, privileges) in [ + (Value::Null, Value::Null, vec!["SELECT"]), + (json!(TEST_SCHEMA), Value::Null, vec!["EXECUTE"]), + ( + json!(TEST_SCHEMA), + json!("ss014_permissions"), + vec!["SELECT", "INSERT"], + ), + ] { + let request = json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": database, "table": table, + "privileges": privileges, "grant": true + }); + plugin.call_ok("apply_db_user_privileges", request.clone()); + // Applying an already-satisfied request exercises the server-side diff. + plugin.call_ok("apply_db_user_privileges", request); + } + + let parsed = plugin.call_ok( + "get_db_user_privileges", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let object_scope = parsed + .as_array() + .expect("grant sets") + .iter() + .find(|scope| scope["database"] == TEST_SCHEMA && scope["table"] == "ss014_permissions") + .expect("direct object grant"); + assert!(object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("SELECT"))); + assert!(object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("INSERT"))); + assert!( + !object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("UPDATE")), + "inherited rights must not look direct" + ); + + let raw = plugin.call_ok( + "get_db_user_grants", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let raw = raw.as_array().expect("raw grants"); + assert!(raw.iter().any(|line| line + .as_str() + .is_some_and(|line| { line.contains("ROLE MEMBERSHIP") && line.contains(ROLE) }))); + assert!(raw.iter().any(|line| line + .as_str() + .is_some_and(|line| { line.contains("INHERITED VIA ROLE") && line.contains("UPDATE") }))); + + plugin.call_ok( + "apply_db_user_privileges", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": TEST_SCHEMA, "table": "ss014_permissions", + "privileges": ["SELECT", "INSERT"], "grant": false + }), + ); + plugin.execute(format!( + "DENY DELETE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] TO [{USER}]" + )); + let deny_error = plugin.call_error( + "apply_db_user_privileges", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": TEST_SCHEMA, "table": "ss014_permissions", + "privileges": ["DELETE"], "grant": true + }), + ); + assert!(deny_error.contains("DENY"), "{deny_error}"); + plugin.execute(format!( + "REVOKE DELETE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] FROM [{USER}]" + )); + plugin.execute(format!( + "CREATE SCHEMA [{OWNED_SCHEMA}] AUTHORIZATION [{USER}]" + )); + + let ownership_error = plugin.call_error( + "drop_db_user", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + assert!( + ownership_error.contains("schema or object"), + "{ownership_error}" + ); + assert!(ownership_error.contains("owns"), "{ownership_error}"); + + plugin.execute(format!( + "ALTER AUTHORIZATION ON SCHEMA::[{OWNED_SCHEMA}] TO [dbo]; \ + DROP SCHEMA [{OWNED_SCHEMA}]; \ + ALTER ROLE [{ROLE}] DROP MEMBER [{USER}]; \ + DROP ROLE [{ROLE}]" + )); + plugin.call_ok( + "drop_db_user", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let users = plugin.call_ok("get_db_users", json!({ "params": connection_params() })); + assert!(!users + .as_array() + .expect("users array") + .iter() + .any(|account| { account["user"] == USER || account["host"] == LOGIN })); + let login = plugin.execute(format!( + "SELECT COUNT(*) AS login_count FROM sys.server_principals WHERE name = N'{LOGIN}'" + )); + assert_eq!(login["rows"], json!([[0]])); +} From 3f70b91da8546acedd4127611ef64a05ab225579 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 11:57:58 +0000 Subject: [PATCH 06/23] feat: make unsupported RPC methods explicit --- README.md | 2 + docs/completeness.md | 21 ++-- src/pool_manager.rs | 18 +++ src/rpc.rs | 273 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 302 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e8d4fa8..b0826e6 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,8 @@ Open **Settings → Plugins** in Tabularis and install *SQL Server* from the plu - SQL authentication only; Azure AD and Windows Integrated Authentication are follow-up work. - Primary-key membership changes are disabled: the single-column alteration API cannot safely preserve composite PKs and referencing foreign keys. - Custom CA files are rejected explicitly; strict verification uses the system trust store. +- SQL Server has indexed views, not materialized views. Indexed views are maintained synchronously and have no refresh operation, so the four materialized-view RPCs deliberately return `-32601` rather than pretending the features are equivalent. +- Unknown JSON-RPC methods return `-32601` with an error naming both the method and the SQL Server plugin. ## Building from Source diff --git a/docs/completeness.md b/docs/completeness.md index 357e2e4..4c2d4a7 100644 --- a/docs/completeness.md +++ b/docs/completeness.md @@ -18,14 +18,19 @@ remaining protocol gaps are: MIME-sniffed preview for `BINARY`, `VARBINARY` including `VARBINARY(MAX)`, and legacy `IMAGE` values. Composite primary keys are parameterized and normalized in deterministic column order. -- The database-user and privilege surface is absent. `SS-014` will implement - `get_db_privilege_catalog`, `get_db_users`, `create_db_user`, `drop_db_user`, - `set_db_user_password`, `get_db_user_privileges`, - `apply_db_user_privileges`, and `get_db_user_grants`. -- `shutdown` and the materialized-view methods return `-32601`. `SS-015` will - make these deliberate refusals, with tests and documentation: the host does - not send `shutdown`, and SQL Server has indexed views rather than - materialized views. +- All eight database-user and privilege methods manage mapped SQL + login/database-user pairs, direct and inherited grants, and DENY-safe + privilege changes. +- The host currently terminates plugin processes directly and never sends an + RPC `shutdown`; the courtesy method is implemented anyway and closes and + removes every cached pool before replying `null`. +- The four materialized-view methods deliberately return a reasoned `-32601`. + SQL Server indexed views are synchronously maintained views with clustered + indexes, not refreshable materialized views, so mapping between them would + misrepresent both lifecycle and semantics. +- A host-method coverage test snapshots every RPC sent by the host and requires + each one to be dispatched or included in the reasoned `NOT_IMPLEMENTED` + table. Unknown methods also return `-32601` naming the method and plugin. - `explain_query` currently returns an in-process parsed plan. `SS-035` will return raw `sqlserver-showplan-xml` after the plugin-owned parser contract and host support are available. diff --git a/src/pool_manager.rs b/src/pool_manager.rs index 95b08a9..bcb94e9 100644 --- a/src/pool_manager.rs +++ b/src/pool_manager.rs @@ -86,6 +86,24 @@ pub async fn cleanup_idle_pools() { pools.retain(|_, pool| pool.status().size > pool.status().available); } +/// Close every cached pool and remove it from the process-wide registry. +pub async fn shutdown() { + let pools: Vec<_> = SQLSERVER_POOLS + .write() + .await + .drain() + .map(|(_, pool)| pool) + .collect(); + for pool in pools { + pool.close(); + } +} + +#[cfg(test)] +pub async fn pool_count() -> usize { + SQLSERVER_POOLS.read().await.len() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rpc.rs b/src/rpc.rs index 03adf0b..a5f9acf 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -6,7 +6,33 @@ use serde_json::{json, Value}; use crate::connection::resolve_connection_params; use crate::handlers::{blob, crud, ddl, metadata, query, routines, triggers, users, views}; use crate::models::ConnectionParams; -use crate::settings; +use crate::{pool_manager, settings}; + +const PLUGIN_NAME: &str = "SQL Server plugin"; + +/// Host RPCs that SQL Server deliberately does not implement. +/// +/// Keep this list limited to methods present in the host protocol. The +/// coverage tests below require every host method to be dispatched or listed +/// here with a non-empty reason. +const NOT_IMPLEMENTED: &[(&str, &str)] = &[ + ( + "get_materialized_views", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "get_materialized_view_columns", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "get_materialized_view_definition", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "refresh_materialized_view", + "SQL Server indexed views are maintained synchronously and cannot be refreshed as materialized views", + ), +]; /// Parse one JSON-RPC line and return the response value (serialised /// downstream by `main.rs`). Never panics — parse errors and method @@ -34,6 +60,10 @@ pub async fn handle_line(line: &str) -> Value { } "ping" => query::ping(id, ¶ms).await, "test_connection" => query::test_connection(id, ¶ms).await, + "shutdown" => { + pool_manager::shutdown().await; + ok_response(id, Value::Null) + } // Metadata. "get_databases" => metadata::get_databases(id, ¶ms).await, @@ -103,7 +133,10 @@ pub async fn handle_line(line: &str) -> Value { "drop_index" => ddl::drop_index(id, ¶ms).await, "drop_foreign_key" => ddl::drop_foreign_key(id, ¶ms).await, - other => not_implemented(id, other), + other => match not_implemented_reason(other) { + Some(reason) => not_implemented(id, other, reason), + None => method_not_found(id, other), + }, } } @@ -123,11 +156,29 @@ pub fn error_response(id: Value, code: i64, message: &str) -> Value { }) } -pub fn not_implemented(id: Value, method: &str) -> Value { +fn not_implemented_reason(method: &str) -> Option<&'static str> { + NOT_IMPLEMENTED + .iter() + .find_map(|(candidate, reason)| (*candidate == method).then_some(*reason)) +} + +fn not_implemented(id: Value, method: &str, reason: &str) -> Value { error_response( id, -32601, - &format!("method '{method}' is not implemented by this plugin"), + &format!( + "Method not found (-32601): '{method}' is not implemented by {PLUGIN_NAME}: {reason}" + ), + ) +} + +fn method_not_found(id: Value, method: &str) -> Value { + error_response( + id, + -32601, + &format!( + "Method not found (-32601): '{method}' is not implemented by {PLUGIN_NAME}: unknown JSON-RPC method" + ), ) } @@ -164,3 +215,217 @@ pub fn req_field(params: &Value, key: &str) -> R serde_json::from_value(params.get(key).cloned().unwrap_or(Value::Null)) .map_err(|err| format!("invalid parameter '{key}': {err}")) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + /// Snapshot extracted from every literal `PluginProcess::call` and + /// `call_with_timeout` in Tabularis + /// `src-tauri/src/plugins/driver.rs` at core commit 9e6975aa. + const HOST_METHODS: &[&str] = &[ + "initialize", + "ping", + "test_connection", + "get_databases", + "get_schemas", + "get_tables", + "get_columns", + "get_foreign_keys", + "get_indexes", + "get_views", + "get_view_definition", + "get_view_columns", + "create_view", + "alter_view", + "drop_view", + "get_materialized_views", + "get_materialized_view_columns", + "get_materialized_view_definition", + "refresh_materialized_view", + "get_routines", + "get_routine_parameters", + "get_routine_definition", + "build_routine_call_sql", + "routine_create_template", + "get_routine_edit_script", + "drop_routine", + "execute_query", + "execute_query_batch", + "explain_query", + "insert_record", + "update_record", + "delete_record", + "save_blob_to_file", + "fetch_blob_as_data_url", + "get_create_table_sql", + "get_add_column_sql", + "get_alter_column_sql", + "get_create_index_sql", + "get_create_foreign_key_sql", + "drop_index", + "drop_foreign_key", + "get_triggers", + "get_db_privilege_catalog", + "get_db_users", + "get_db_user_grants", + "create_db_user", + "drop_db_user", + "set_db_user_password", + "get_db_user_privileges", + "apply_db_user_privileges", + "get_trigger_definition", + "create_trigger", + "drop_trigger", + "get_schema_snapshot", + "get_ai_schema_context", + "get_all_columns_batch", + "get_all_foreign_keys_batch", + ]; + + fn dispatched_match_arms() -> BTreeSet<&'static str> { + include_str!("rpc.rs") + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix('"')? + .split_once("\" =>") + .map(|(method, _)| method) + }) + .collect() + } + + fn handle_line_on_worker_stack(line: String) -> Value { + std::thread::Builder::new() + .stack_size(crate::WORKER_STACK_SIZE) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(Box::pin(handle_line(&line))) + }) + .unwrap() + .join() + .unwrap() + } + + #[test] + fn every_host_method_is_dispatched_or_deliberately_not_implemented() { + let dispatched = dispatched_match_arms(); + let host_methods: BTreeSet<_> = HOST_METHODS.iter().copied().collect(); + let not_implemented: BTreeSet<_> = NOT_IMPLEMENTED + .iter() + .map(|(method, reason)| { + assert!(!reason.trim().is_empty(), "{method} needs a reason"); + *method + }) + .collect(); + + assert_eq!( + host_methods.len(), + HOST_METHODS.len(), + "duplicate host method" + ); + assert_eq!(not_implemented.len(), NOT_IMPLEMENTED.len()); + + let uncovered: Vec<_> = host_methods + .difference(&dispatched) + .filter(|method| !not_implemented.contains(*method)) + .copied() + .collect(); + assert!( + uncovered.is_empty(), + "host methods are neither dispatched nor deliberately unsupported: {uncovered:?}" + ); + + let stale_exclusions: Vec<_> = not_implemented.difference(&host_methods).copied().collect(); + assert!( + stale_exclusions.is_empty(), + "NOT_IMPLEMENTED contains methods outside the host contract: {stale_exclusions:?}" + ); + + let plugin_only: Vec<_> = dispatched + .difference(&host_methods) + .filter(|method| **method != "shutdown") + .copied() + .collect(); + assert!( + plugin_only.is_empty(), + "dispatch contains methods outside the host contract: {plugin_only:?}" + ); + assert!(dispatched.contains("shutdown")); + } + + #[test] + fn deliberate_exclusions_return_named_reasoned_errors() { + for (method, reason) in NOT_IMPLEMENTED { + let request = json!({ "jsonrpc": "2.0", "method": method, "id": 7 }); + let response = handle_line_on_worker_stack(request.to_string()); + + assert_eq!(response["error"]["code"], -32601, "{method}"); + let message = response["error"]["message"].as_str().unwrap(); + assert!(message.contains(method), "{message}"); + assert!(message.contains("-32601"), "{message}"); + assert!(message.contains(PLUGIN_NAME), "{message}"); + assert!(message.contains(reason), "{message}"); + } + } + + #[test] + fn unknown_method_error_names_the_method_and_plugin() { + let request = json!({ "jsonrpc": "2.0", "method": "future_host_rpc", "id": 9 }); + let response = handle_line_on_worker_stack(request.to_string()); + + assert_eq!(response["error"]["code"], -32601); + let message = response["error"]["message"].as_str().unwrap(); + assert!(message.contains("future_host_rpc")); + assert!(message.contains("-32601")); + assert!(message.contains(PLUGIN_NAME)); + assert!(message.contains("unknown JSON-RPC method")); + } + + #[test] + fn shutdown_closes_cached_pools_and_returns_null() { + std::thread::Builder::new() + .stack_size(crate::WORKER_STACK_SIZE) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let pool = pool_manager::get_sqlserver_pool(&ConnectionParams { + driver: "sqlserver".into(), + host: Some("localhost".into()), + port: Some(1433), + username: Some("sa".into()), + password: Some("test-password".into()), + database: crate::models::DatabaseSelection::Single("master".into()), + connection_id: Some("shutdown-rpc-test".into()), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(pool_manager::pool_count().await, 1); + + let response = Box::pin(handle_line( + r#"{"jsonrpc":"2.0","method":"shutdown","id":11}"#, + )) + .await; + + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "result": null, "id": 11 }) + ); + assert_eq!(pool_manager::pool_count().await, 0); + assert!(pool.is_closed()); + }); + }) + .unwrap() + .join() + .unwrap(); + } +} From 574d76dbf71a16fd5974b10c75fec992bf52a545 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 12:04:36 +0000 Subject: [PATCH 07/23] feat: add registry-grade plugin manifest --- .github/workflows/ci.yml | 9 ++++++ .tabularium | 60 +++++++++++++++++++++++++++------------ README.md | 4 +++ docs/completeness.md | 25 ++++++++-------- src/driver/types.rs | 28 +++++++++--------- src/driver/types/tests.rs | 18 ++++++++++++ src/models.rs | 2 +- 7 files changed, 100 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 125dc9f..4bb4a08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,15 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + validate-manifest: + name: Validate .tabularium manifest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Validate against the live registry schema + run: npx --yes @tabularium/cli validate .tabularium --registry https://registry.tabularis.dev --kind driver + live-db-integration: name: Live SQL Server integration runs-on: ubuntu-latest diff --git a/.tabularium b/.tabularium index 0551336..fe94eb0 100644 --- a/.tabularium +++ b/.tabularium @@ -1,9 +1,24 @@ { - "$schema": "https://tabularis.dev/schemas/plugin-manifest.json", - "id": "sqlserver", - "name": "SQL Server", + "$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver", + "name": "sqlserver", "version": "0.1.0", - "description": "Microsoft SQL Server driver for Tabularis", + "description": "Full-featured Microsoft SQL Server driver for Tabularis with schema browsing, query execution, visual plans, type-aware row editing, DDL, routines, triggers, BLOBs, and database-user management.", + "category": "database", + "tags": ["driver", "sqlserver", "mssql", "sql", "relational", "database-driver"], + "license": "Apache-2.0", + "icon": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/sqlserver-icon.svg", + "color": "#CC2927", + "screenshots": [], + "readme": "README.md", + "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin", + "documentation_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin#readme", + "min_runtime_version": "0.20.0", + "support": { + "issues_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues" + }, + "kind": "driver", + "engine": "sqlserver", + "paradigms": ["relational"], "default_port": 1433, "default_username": "sa", "executable": "sqlserver-plugin", @@ -32,6 +47,15 @@ "supports_ssl": true, "explain": true }, + "type_mappings": { + "TIMESTAMP": "DATETIME2", + "BOOLEAN": "BIT", + "TEXT": "NVARCHAR(MAX)", + "BLOB": "VARBINARY(MAX)", + "SERIAL": "INT IDENTITY(1,1)", + "UUID": "UNIQUEIDENTIFIER", + "JSON": "NVARCHAR(MAX)" + }, "settings": [ { "key": "max_pool_size", @@ -145,53 +169,53 @@ }, { "name": "CHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "1" }, { "name": "VARCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "255" }, { "name": "VARCHAR(MAX)", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "TEXT", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "NCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "1" }, { "name": "NVARCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "255" }, { "name": "NVARCHAR(MAX)", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "NTEXT", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, @@ -223,37 +247,37 @@ }, { "name": "DATE", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "TIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIME2", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "SMALLDATETIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIMEOFFSET", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, diff --git a/README.md b/README.md index b0826e6..bea5ae8 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ accept the server certificate as described above. All common SQL Server types are supported for column creation and value extraction, including exact/approximate numerics (`TINYINT` … `BIGINT`, `DECIMAL`, `MONEY`, `FLOAT`), strings (`CHAR`/`VARCHAR`/`NVARCHAR` incl. `MAX`, `TEXT`/`NTEXT`), binary (`BINARY`/`VARBINARY`/`IMAGE`), date/time (`DATE`, `TIME`, `DATETIME`, `DATETIME2`, `SMALLDATETIME`, `DATETIMEOFFSET`), `BIT`, `UNIQUEIDENTIFIER`, `XML`, `SQL_VARIANT`, `ROWVERSION`, `HIERARCHYID`, and spatial (`GEOGRAPHY`, `GEOMETRY`). +Generic DDL types emitted by Tabularis map to SQL Server-native spellings. In +particular, generic `TIMESTAMP` maps to `DATETIME2`; SQL Server's own +`TIMESTAMP` type remains a deprecated `ROWVERSION` synonym, not a date/time. + `BIGINT` values outside JavaScript's safe integer range are delivered as strings so they round-trip without precision loss. ### Binary export and preview diff --git a/docs/completeness.md b/docs/completeness.md index 4c2d4a7..beb4060 100644 --- a/docs/completeness.md +++ b/docs/completeness.md @@ -64,24 +64,23 @@ are outside the completion scope. ## Manifest and settings -The current `.tabularium` file is a scaffold manifest rather than a -registry-grade manifest. It still needs: +The `.tabularium` file validates against the live registry driver schema. It +now carries the registry metadata and links, SQL Server branding, runtime +floor, generic-to-native type mappings, and capabilities verified against the +implemented RPC surface. A unit test keeps its `data_types` list synchronized +with `driver/types.rs`; the registry-compatible `string` and `date` categories +replace the scaffold's unsupported `text` and `datetime` labels. -- the registry schema URL and the `kind`, `engine`, `paradigms`, `category`, - `tags`, and `license` metadata; -- icon, color, and screenshot assets; -- readme, homepage, documentation, and support links; -- `min_runtime_version` and type mappings; -- the plugin-owned `explain_parsers` declaration once the host supports it. - -`SS-020`, `SS-021`, `SS-030`, `SS-034`, and `SS-035` cover these items. +The remaining manifest work is limited to screenshot and icon assets in +`SS-021`, plus the plugin-owned `explain_parsers` declaration and corresponding +runtime-version bump in `SS-030`, `SS-034`, and `SS-035`. ## CI and release packaging -The Rust build, test, Clippy, formatting, live SQL Server integration, and -scheduled security-audit checks exist. Release readiness still requires: +The Rust build, test, Clippy, formatting, live SQL Server integration, +registry manifest validation, and scheduled security-audit checks exist. +Release readiness still requires: -- registry manifest validation; - Conventional Commit pull-request title checks; - version-suggestion and Markdown lint jobs; - Dependabot configuration; diff --git a/src/driver/types.rs b/src/driver/types.rs index 839082e..6e69081 100644 --- a/src/driver/types.rs +++ b/src/driver/types.rs @@ -103,7 +103,7 @@ pub fn get_data_types() -> Vec { // Character strings DataTypeInfo { name: "CHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("1".to_string()), @@ -112,7 +112,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "VARCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("255".to_string()), @@ -121,7 +121,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "VARCHAR(MAX)".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -130,7 +130,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "TEXT".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -140,7 +140,7 @@ pub fn get_data_types() -> Vec { // Unicode strings DataTypeInfo { name: "NCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("1".to_string()), @@ -149,7 +149,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NVARCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("255".to_string()), @@ -158,7 +158,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NVARCHAR(MAX)".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -167,7 +167,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NTEXT".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -214,7 +214,7 @@ pub fn get_data_types() -> Vec { // Date / time DataTypeInfo { name: "DATE".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -223,7 +223,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "TIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -232,7 +232,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -241,7 +241,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIME2".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -250,7 +250,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "SMALLDATETIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -259,7 +259,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIMEOFFSET".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, diff --git a/src/driver/types/tests.rs b/src/driver/types/tests.rs index 0c93b7c..4347a53 100644 --- a/src/driver/types/tests.rs +++ b/src/driver/types/tests.rs @@ -1,5 +1,23 @@ use super::*; +#[test] +fn manifest_data_types_match_driver_types() { + let manifest: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/.tabularium" + ))) + .expect(".tabularium must be valid JSON"); + let manifest_types: Vec = serde_json::from_value( + manifest + .get("data_types") + .cloned() + .expect(".tabularium must declare data_types"), + ) + .expect(".tabularium data_types must use the host data-type shape"); + + assert_eq!(manifest_types, get_data_types()); +} + #[test] fn types_list_is_non_empty() { let types = get_data_types(); diff --git a/src/models.rs b/src/models.rs index e2e9dcc..a8027a4 100644 --- a/src/models.rs +++ b/src/models.rs @@ -245,7 +245,7 @@ pub struct ColumnDefinition { pub default_value: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct DataTypeInfo { pub name: String, pub category: String, From 4f15013dfa292a648579b09ae62726eb08654e0c Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 12:12:43 +0000 Subject: [PATCH 08/23] docs: add registry assets and user guide --- .markdownlint.json | 8 ++ .markdownlintignore | 2 + .tabularium | 43 +++++- CLAUDE.md | 2 +- README.md | 129 +++++++++++++++--- assets/screenshots/01-fresh-install.png | Bin 0 -> 85923 bytes assets/screenshots/02-database-picker.png | Bin 0 -> 80549 bytes assets/screenshots/03-connection-form.png | Bin 0 -> 87256 bytes .../04-test-connection-success.png | Bin 0 -> 92032 bytes assets/screenshots/05-connections-list.png | Bin 0 -> 47195 bytes assets/screenshots/06-schema-browser.png | Bin 0 -> 54120 bytes assets/screenshots/07-table-data.png | Bin 0 -> 68247 bytes assets/screenshots/08-visual-explain.png | Bin 0 -> 45815 bytes docs/completeness.md | 7 +- sqlserver-icon.svg | 7 + 15 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 .markdownlint.json create mode 100644 .markdownlintignore create mode 100644 assets/screenshots/01-fresh-install.png create mode 100644 assets/screenshots/02-database-picker.png create mode 100644 assets/screenshots/03-connection-form.png create mode 100644 assets/screenshots/04-test-connection-success.png create mode 100644 assets/screenshots/05-connections-list.png create mode 100644 assets/screenshots/06-schema-browser.png create mode 100644 assets/screenshots/07-table-data.png create mode 100644 assets/screenshots/08-visual-explain.png create mode 100644 sqlserver-icon.svg diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..f0dfe2e --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "default": true, + "MD013": false, + "MD024": { "siblings_only": true }, + "MD033": false, + "MD041": false, + "MD060": false +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..297fdef --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,2 @@ +target/ +node_modules/ diff --git a/.tabularium b/.tabularium index fe94eb0..81069e0 100644 --- a/.tabularium +++ b/.tabularium @@ -8,7 +8,48 @@ "license": "Apache-2.0", "icon": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/sqlserver-icon.svg", "color": "#CC2927", - "screenshots": [], + "screenshots": [ + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/01-fresh-install.png", + "caption": "Database Manager on first launch", + "alt": "Tabularis Database Manager showing no active connections" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/02-database-picker.png", + "caption": "SQL Server listed in the database picker", + "alt": "Choose a database dialog showing SQL Server installed under SQL and Relational categories" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/03-connection-form.png", + "caption": "Connection configuration form", + "alt": "SQL Server connection form with connection string, host, port, username, password, and database fields filled in" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/04-test-connection-success.png", + "caption": "Successful connection test", + "alt": "SQL Server connection form showing a green Connection successful message" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/05-connections-list.png", + "caption": "Saved connection in the Database Manager", + "alt": "Database Manager showing one saved SQL Server connection card" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/06-schema-browser.png", + "caption": "Multi-schema browsing with tables, views, routines, and triggers", + "alt": "Sidebar schema tree showing SQL Server schemas with tables, views, routines, and triggers" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/07-table-data.png", + "caption": "Data grid with filtering and sorting", + "alt": "SQL Server customer table data grid filtered to the West region" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/08-visual-explain.png", + "caption": "Visual EXPLAIN for a SQL Server SHOWPLAN", + "alt": "Visual EXPLAIN graph showing SQL Server SELECT, hash match, clustered index scan, and index seek operators" + } + ], "readme": "README.md", "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin", "documentation_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin#readme", diff --git a/CLAUDE.md b/CLAUDE.md index 63b6ab4..4a1c5a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Run a single test: `cargo test `. ## Architecture -``` +```text src/ main.rs # tokio entrypoint: stdin reader → worker pool → stdout writer rpc.rs # JSON-RPC dispatch + response/param helpers diff --git a/README.md b/README.md index bea5ae8..d9ddf72 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@
- + Tabularis logo + SQL Server plugin icon
# tabularis-sqlserver-plugin

-![](https://img.shields.io/github/release/tabularisDB/tabularis-sqlserver-plugin.svg?style=flat) -![](https://img.shields.io/github/downloads/tabularisDB/tabularis-sqlserver-plugin/total.svg?style=flat) +![Release](https://img.shields.io/github/release/TabularisDB/tabularis-sqlserver-plugin.svg?style=flat) +![Downloads](https://img.shields.io/github/downloads/TabularisDB/tabularis-sqlserver-plugin/total.svg?style=flat) ![Build & Release](https://github.com/tabularisDB/tabularis-sqlserver-plugin/workflows/Release/badge.svg) [![Discord](https://img.shields.io/discord/1502944695808950282?color=5865F2&logo=discord&logoColor=white)](https://discord.com/invite/K2hmhfHRSt) @@ -15,23 +16,29 @@ A [Microsoft SQL Server](https://www.microsoft.com/sql-server) plugin for [Tabularis](https://github.com/TabularisDB/tabularis), the lightweight database management tool. -This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. +This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, BLOB handling, database-user management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. -The client was swapped to Microsoft's protocol implementation to align the plugin with the actively developed upstream SQL Server stack while the bridge preserves the API the driver uses. This is an internal transport change: connection settings and user-facing behaviour are unchanged, and existing users do not need to migrate anything. +> **Requires Tabularis v0.20.0 or later.** This plugin relies on the plugin +> runtime introduced in that release and will not load on earlier versions. -**Discord** - [Join our discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. +**Discord** — [Join our Discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. ## Table of Contents - [Features](#features) +- [Screenshots](#screenshots) - [Connection Configuration](#connection-configuration) - [Plugin Settings](#plugin-settings) - [Supported Data Types](#supported-data-types) - [Database Users and Privileges](#database-users-and-privileges) - [Installation](#installation) +- [How It Works](#how-it-works) +- [Supported Operations](#supported-operations) - [Known Limitations](#known-limitations) - [Building from Source](#building-from-source) - [Development](#development) +- [Contributing](#contributing) +- [Changelog](#changelog) - [Credits](#credits) - [License](#license) @@ -48,18 +55,35 @@ The client was swapped to Microsoft's protocol implementation to align the plugi - Procedure/function management, typed `OUT`/`INOUT` variables, and table-valued functions - Static and runtime execution plans through `SHOWPLAN_XML` / `STATISTICS XML`, rendered in Tabularis's Visual EXPLAIN - JavaScript-safe `BIGINT` extraction and broad SQL Server type handling +- Pre-built release targets for Linux x86_64, macOS x86_64 and Apple Silicon, and Windows x86_64 + +## Screenshots + + + + + + + + + + +
SQL Server listed in the database picker
SQL Server in the database picker
SQL Server connection configuration form
Connection configuration
SQL Server schema browser with tables, views, routines, and triggers
Multi-schema browsing
Visual EXPLAIN graph of a SQL Server SHOWPLAN
Visual EXPLAIN for SHOWPLAN
## Connection Configuration -| Parameter | Default | Notes | -|-----------|---------|-------| -| Host | `localhost` | | -| Port | `1433` | | -| Username | `sa` | SQL authentication only | -| Password | — | | -| Database | — | The database the pool connects to | -| Connection string | — | `sqlserver://…` URL or ADO.NET/ODBC keyword syntax | -| Startup script | — | SQL run on every new pooled connection (e.g. `SET` options) | +| Parameter | Default | Required | Description | +| --- | --- | --- | --- | +| `host` | `localhost` | Yes unless using `connection_string` | SQL Server hostname or IP address | +| `port` | `1433` | No | TDS port | +| `database` | — | Yes unless using `connection_string` | Database the pool connects to | +| `username` | `sa` | Yes unless using `connection_string` | SQL-authenticated login | +| `password` | — | If required by the server | Login password; redacted from connection errors | +| `ssl_mode` | `prefer` | No | `disable`, `prefer`, `require`, or `verify-full` | +| `ssl_ca` | — | No | Rejected; strict TLS uses the system trust store | +| `ssl_cert` / `ssl_key` | — | No | Rejected; client-certificate authentication is not supported | +| `connection_string` | — | No | `sqlserver://…` URL or ADO.NET/ODBC keyword syntax | +| `startup_script` | — | No | SQL run on every new pooled connection, such as session `SET` options | ### Connection strings @@ -201,13 +225,45 @@ Open **Settings → Plugins** in Tabularis and install *SQL Server* from the plu 3. On Linux/macOS, make the binary executable: `chmod +x sqlserver-plugin` 4. Restart Tabularis — *SQL Server* appears in the connection picker. +## How It Works + +The plugin is a standalone Rust binary that communicates with Tabularis through +**newline-delimited JSON-RPC 2.0 over stdio**: + +1. Tabularis starts `sqlserver-plugin` as a child process and calls + `initialize` with the manifest-backed process settings. +2. The plugin normalizes the discrete connection fields or connection string, + then reuses a matching in-process `deadpool` pool. +3. New sessions connect through Microsoft's `mssql-tds` implementation, run + the optional startup script, and are reset with `sp_reset_connection` + before reuse. +4. Requests and responses stay on stdin and stdout; diagnostics go to stderr. + The plugin opens no listening port and keeps no persistent state. + +Pool identity includes every connection and TLS field that changes session +behaviour, plus the startup script. The idle-eviction task removes unused +pools at the configured interval, and the courtesy `shutdown` RPC drains all +remaining pools. + +## Supported Operations + +| Method group | Operations | +| --- | --- | +| Lifecycle and connection | `initialize`, `shutdown`, `ping`, `test_connection`, database discovery | +| Schema metadata | Schemas, tables, columns, keys, indexes, views, routines, triggers, snapshots, and batch metadata | +| Query execution | Paginated queries, session-preserving batches, affected rows, and Visual EXPLAIN | +| Row editing | Insert, update, and delete with composite primary keys and type-aware values | +| DDL | Table, column, index, foreign-key, view, routine, and trigger generation or lifecycle operations | +| BLOBs | Raw file export and bounded MIME-sniffed data-URL preview | +| Security | SQL login and mapped database-user lifecycle, password changes, privilege catalog, grants, roles, and inherited rights | + ## Known Limitations - SQL authentication only; Azure AD and Windows Integrated Authentication are follow-up work. - Primary-key membership changes are disabled: the single-column alteration API cannot safely preserve composite PKs and referencing foreign keys. - Custom CA files are rejected explicitly; strict verification uses the system trust store. -- SQL Server has indexed views, not materialized views. Indexed views are maintained synchronously and have no refresh operation, so the four materialized-view RPCs deliberately return `-32601` rather than pretending the features are equivalent. -- Unknown JSON-RPC methods return `-32601` with an error naming both the method and the SQL Server plugin. +- SQL Server has indexed views, not materialized views. Indexed views are maintained synchronously and have no refresh operation, so `get_materialized_views`, `get_materialized_view_columns`, `get_materialized_view_definition`, and `refresh_materialized_view` deliberately return `-32601` rather than pretending the features are equivalent. +- All host RPC methods outside those four materialized-view operations are implemented, including the courtesy `shutdown` method even though the current host terminates the process directly. Truly unknown JSON-RPC methods return `-32601` with an error naming both the method and the SQL Server plugin. ## Building from Source @@ -262,6 +318,45 @@ just run-sqlserver # SQL Server 2022 in Docker (sa / Str0ng!Passw0rd) just seed-sqlserver # create and seed the tabularis_test database ``` +The live JSON-RPC integration suite uses the same container: + +```bash +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ +SQLSERVER_TEST_HOST=127.0.0.1 \ +SQLSERVER_TEST_PASSWORD='Str0ng!Passw0rd' \ +cargo test --test live_db -- --test-threads=1 +``` + +## Contributing + +Pull-request titles must follow [Conventional Commits](https://www.conventionalcommits.org/): +`type: subject`, `type(scope): subject`, or `type!: subject` for a breaking +change. Add a `BREAKING CHANGE:` footer to the PR description when the title +cannot communicate the full impact. + +Every PR must have exactly one `prerelease:alpha`, `prerelease:beta`, +`prerelease:rc`, or `prerelease:stable` label. CI uses the title and that label +to suggest the next version and release channel; there is no default channel, +so a missing or ambiguous label fails the version-suggestion check. + +| PR title type | Version impact | +| --- | --- | +| `feat` | minor | +| `fix`, `refactor`, `perf` | patch | +| `docs`, `style`, `chore`, `test`, `ci`, `build` | none | +| any type with `!` or a `BREAKING CHANGE:` footer | major | + +Before opening a PR, run: + +```bash +just fmt +just lint +just test +npx markdownlint-cli "**/*.md" +``` + +## [Changelog](./CHANGELOG.md) + ## Credits The SQL Server driver implementation was contributed by [Fabio Malpezzi](https://github.com/FabioMalpezzi), originally developed as a built-in Tabularis driver and adapted here to the plugin architecture. diff --git a/assets/screenshots/01-fresh-install.png b/assets/screenshots/01-fresh-install.png new file mode 100644 index 0000000000000000000000000000000000000000..c8810222fb10ec76c1039a6f086d5d28ce618aef GIT binary patch literal 85923 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 literal 0 HcmV?d00001 diff --git a/assets/screenshots/02-database-picker.png b/assets/screenshots/02-database-picker.png new file mode 100644 index 0000000000000000000000000000000000000000..30f51a529b6e6210bec149bf37ab7accf7393065 GIT binary patch literal 80549 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 literal 0 HcmV?d00001 diff --git a/assets/screenshots/04-test-connection-success.png b/assets/screenshots/04-test-connection-success.png new file mode 100644 index 0000000000000000000000000000000000000000..942b06862214f5a2b2851a4fb96ed0dd4049f21f GIT binary patch literal 92032 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 z z)$1g&eIzZK`_dkxPJfB22)lk9lm40`f(=3d{-s88LzTac`{=E&U0My81*qB;-(%bC zTe2oqdX%_or#nkL;3?-^*0=?05QAEt^u8hZiA-D6Vo;~VwC}~qA4bJ@2#Pv)E})-L zjw9^)6B^YBpygj1;{bz5&(5~UjhI6#`iW>rms!NY>kH4;7!6E>*65wpo?xv6+-mRE(9?VzsD{x zHiFbS+J2RBA?~$sNG;UJaL+EYBHD_j|M^EvoSF-n@V@*`YfX*dVNzZVDzt zn^CY_={Klb8_?Y0;mHWsdcVdq!0m7p^i8%XK2F7C(3}lKs22GQLedRO@VLZfS5S*e z_LE2-BVtyk2JcGWL#7cEqfJ7Lt^>7^? zRx+J8`WVLHoBoV&=aGJgZvvhZhd+f%9CZv6syPyxy-p+Q!AsMN)!(?+2i(n`dp@3x z4&=bWXMTCB+2R8d*0m<<6dD*5gN`WU(siyWo;FYeS+qLmB!1>Ez|DZyFmuf@O0HDg zS|?ejeHe+!KzP)1w>zk&n{jtI26+6mMS&s`)k1fe*+>$3Jb0XjL@68$rmZgG|t z-a8@;Z+KZocdZOLbRO0Y%8iDu@BNG5*!fJkXnHh3X=%ZHjlUDhWAd@1qLU*n3ppC$ znM!R#uF=m;yIW2F7~Ecr=oX@+R}0}%6dK*nArv_N4D=7Jh|4>V!BQJA%26X>s~rE90i zmAg|EGoqeXs1lrJzK-<{g(#mNsu2|>55*mZg-OH0R%otXH4=g@aw5R0t^&fylWX_jLx32_ir}UrK?@AP`;Io1n%xzO`e3Kbh#OinPnHjS5`Q9ArLx8gc zBN%x2NFItMAc`7_wPUzDcD%X1rkvmW@de$uksxv+UTFEey_U#=s9vk)1Ic3#LM5iX zbC@F?2dppp9vUiekLE7$^kKF^M~Xu;nvz(HT1;lE`b;j1>0ZL5TG-b5A1lPyg4gA? zNV>1Q&-|Ciz5Y-4S)lOJC`CmiR-_;b{2Gz|#}Zbo=_o;eCb=`nr@ln&h~EQ_fbd^= z^>#|=Eh9&Q#+FQHMm`tynT2QCKb8>8X^$576|JAVdUb|cscL$ubT1W4ju9m2#dTRu zuvq5!0m`ynA+oWoQX?^R)AYA&lgzJwc&e0qV|u)7U51`yb#FA=Bp|`Iac2z9>ldDM H`13yi*EQXp literal 0 HcmV?d00001 diff --git a/assets/screenshots/06-schema-browser.png b/assets/screenshots/06-schema-browser.png new file mode 100644 index 0000000000000000000000000000000000000000..97020dde8b1b71e7947ba27446f64df7d52262f0 GIT binary patch literal 54120 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 ka-ab{-yQGroV!z7GWqy*``hYkS+6%ZpG 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�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 literal 0 HcmV?d00001 diff --git a/docs/completeness.md b/docs/completeness.md index beb4060..3835294 100644 --- a/docs/completeness.md +++ b/docs/completeness.md @@ -71,9 +71,10 @@ implemented RPC surface. A unit test keeps its `data_types` list synchronized with `driver/types.rs`; the registry-compatible `string` and `date` categories replace the scaffold's unsupported `text` and `datetime` labels. -The remaining manifest work is limited to screenshot and icon assets in -`SS-021`, plus the plugin-owned `explain_parsers` declaration and corresponding -runtime-version bump in `SS-030`, `SS-034`, and `SS-035`. +The manifest now links the scalable SQL Server icon and the complete eight-image +registry screenshot set. Remaining work is limited to the plugin-owned +`explain_parsers` declaration and corresponding runtime-version bump in +`SS-030`, `SS-034`, and `SS-035`. ## CI and release packaging diff --git a/sqlserver-icon.svg b/sqlserver-icon.svg new file mode 100644 index 0000000..f8fecd6 --- /dev/null +++ b/sqlserver-icon.svg @@ -0,0 +1,7 @@ + + Microsoft SQL Server + White database cylinder on a SQL Server red rounded square + + + + From 0aedbe051a6b80e69004432563e8d5e986a286f8 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 12:17:51 +0000 Subject: [PATCH 09/23] ci: align validation and release workflows --- .github/dependabot.yml | 10 ++ .github/workflows/ci.yml | 223 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 49 +++++++- 3 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..98e44ee --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bb4a08..9e594c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: branches: [main] + # Default types miss title-only edits. Label changes must also re-run the + # version suggestion because the prerelease channel comes from a label. + types: [opened, edited, reopened, synchronize, labeled, unlabeled] schedule: # Weekly cargo-audit sweep for advisories disclosed after dependencies land. # Use an off-peak minute rather than :00 or :30. @@ -82,6 +85,226 @@ jobs: SQLSERVER_TEST_DATABASE: tabularis_test run: cargo test --test live_db -- --test-threads=1 + pr-title: + name: PR title (Conventional Commits) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + version-suggestion: + name: Version suggestion + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # Classify the PR title's Conventional Commits type + breaking-change + # flag into a version-bump class. Requires the prerelease:* label to + # know which channel (alpha/beta/rc/stable) to suggest — see README's + # "Contributing: PR Titles & Versioning" for the full convention. + - name: Classify PR title and resolve prerelease channel + id: classify + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ toJson(github.event.pull_request.labels) }} + run: | + PATTERN='^([a-zA-Z]+)(\(([^)]+)\))?(!)?: (.+)$' + if [[ "$PR_TITLE" =~ $PATTERN ]]; then + TYPE="${BASH_REMATCH[1]}" + BANG="${BASH_REMATCH[4]}" + else + echo "::error::PR title does not match Conventional Commits format (type: subject) — cannot classify." + exit 1 + fi + + BREAKING=false + [ -n "$BANG" ] && BREAKING=true + if echo "$PR_BODY" | grep -qiE "^BREAKING[ -]CHANGE:"; then + BREAKING=true + fi + + case "$TYPE" in + feat) CLASS=minor ;; + fix|refactor|perf) CLASS=patch ;; + docs|style|chore|test|ci|build) CLASS=none ;; + *) CLASS=none ;; + esac + [ "$BREAKING" = true ] && CLASS=major + + CHANNEL=$(echo "$PR_LABELS" | jq -r '[.[] | select(.name | startswith("prerelease:")) | .name][0] // ""' | sed 's/^prerelease://') + if [ -z "$CHANNEL" ]; then + echo "::error::No prerelease:alpha|beta|rc|stable label found on this PR. Add one so the version suggestion knows which channel to target — see README's 'Contributing: PR Titles & Versioning'." + exit 1 + fi + case "$CHANNEL" in + alpha|beta|rc|stable) ;; + *) echo "::error::Unrecognized prerelease label value '$CHANNEL' — expected alpha, beta, rc, or stable."; exit 1 ;; + esac + + echo "type=$TYPE" >> "$GITHUB_OUTPUT" + echo "breaking=$BREAKING" >> "$GITHUB_OUTPUT" + echo "class=$CLASS" >> "$GITHUB_OUTPUT" + echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" + + - name: Resolve baseline version + id: baseline + run: | + git fetch origin main --tags --quiet + TAG=$(git -C . describe --tags --abbrev=0 origin/main 2>/dev/null || true) + if [ -n "$TAG" ]; then + BASELINE="${TAG#v}" + else + BASELINE=$(git show origin/main:.tabularium | jq -r .version) + fi + echo "version=$BASELINE" >> "$GITHUB_OUTPUT" + + - name: Compute suggestion, manage comment + uses: actions/github-script@v9 + with: + script: | + const classification = "${{ steps.classify.outputs.class }}"; + const channel = "${{ steps.classify.outputs.channel }}"; + const type = "${{ steps.classify.outputs.type }}"; + const breaking = "${{ steps.classify.outputs.breaking }}" === "true"; + const baselineStr = "${{ steps.baseline.outputs.version }}"; + const marker = "`, + }); + } + // Otherwise: never suggested anything, or already said "none" — stay silent. + return; + } + + if (previous && previousClassification === currentClassification) { + // Meaningful classification hasn't changed since the last comment. + return; + } + + async function minimizePrevious() { + if (!previous) return; + // REST comment objects expose node_id directly — no separate + // lookup needed to get the GraphQL node id. + await github.graphql( + `mutation($id: ID!) { minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) { clientMutationId } }`, + { id: previous.node_id } + ); + } + + const suggested = computeNextVersion(baselineStr, classification, channel); + const tag = `v${suggested}`; + + await minimizePrevious(); + + const breakingNote = breaking ? " (breaking change)" : ""; + const body = [ + `### Version suggestion`, + ``, + `Based on this PR's title (\`${type}\`${breakingNote}) and the \`prerelease:${channel}\` label:`, + ``, + `| | |`, + `|---|---|`, + `| Current | \`${baselineStr}\` |`, + `| Suggested next tag | \`${tag}\` |`, + ``, + `This is informational only — no tag or release is created automatically yet.`, + ``, + `${marker} classification=${currentClassification} -->`, + ].join("\n"); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + + markdownlint: + name: Markdown lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Run markdownlint + run: npx --yes markdownlint-cli "**/*.md" + audit: name: Security audit runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41d0285..3bd486b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,25 @@ on: - "v*" jobs: + validate: + name: Validate release version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Check tag matches manifest version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + TABULARIUM_VERSION=$(jq -r .version .tabularium) + + if [ "$TAG_VERSION" != "$TABULARIUM_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match .tabularium version ($TABULARIUM_VERSION)" + exit 1 + fi + build: name: ${{ matrix.platform-label }} + needs: validate runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -41,7 +58,7 @@ jobs: binary-suffix: ".exe" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -58,8 +75,18 @@ jobs: echo "present=false" >> "$GITHUB_OUTPUT" fi + - name: Check for EXPLAIN parser + id: explain + shell: bash + run: | + if [ -f explain/package.json ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Node - if: steps.ui.outputs.present == 'true' + if: steps.ui.outputs.present == 'true' || steps.explain.outputs.present == 'true' uses: actions/setup-node@v5 with: node-version: "20" @@ -72,6 +99,14 @@ jobs: npm install --no-audit --no-fund npm run build + - name: Build EXPLAIN parser + if: steps.explain.outputs.present == 'true' + shell: bash + working-directory: explain + run: | + npm install --no-audit --no-fund + npm run build + - name: Install cross (linux-arm64 only) if: matrix.cross run: cargo install cross --locked @@ -98,6 +133,10 @@ jobs: mkdir -p "$STAGE/ui/dist" cp ui/dist/index.js "$STAGE/ui/dist/" fi + if [ -f explain/dist/index.js ]; then + mkdir -p "$STAGE/explain/dist" + cp explain/dist/index.js "$STAGE/explain/dist/" + fi (cd "$STAGE" && zip -r ../sqlserver-plugin-${{ matrix.platform-label }}.zip .) - name: Package (windows) @@ -115,6 +154,10 @@ jobs: New-Item -ItemType Directory -Force -Path "$stage\ui\dist" | Out-Null Copy-Item "ui\dist\index.js" "$stage\ui\dist" } + if (Test-Path "explain\dist\index.js") { + New-Item -ItemType Directory -Force -Path "$stage\explain\dist" | Out-Null + Copy-Item "explain\dist\index.js" "$stage\explain\dist" + } Compress-Archive -Path "$stage\*" -DestinationPath "sqlserver-plugin-${{ matrix.platform-label }}.zip" - name: Stash artifact @@ -132,7 +175,7 @@ jobs: contents: write steps: - name: Checkout (for the .tabularium manifest asset) - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Download all build artifacts uses: actions/download-artifact@v5 From 70c2b5aafb54348a6b21066f9b9d57009acf0e82 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 12:24:30 +0000 Subject: [PATCH 10/23] fix(tooling): repair developer recipes --- README.md | 3 +- justfile | 120 +++++++++++++++++++++++++++++++++--------------------- 2 files changed, 76 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index d9ddf72..b8be6fc 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ just release # release build (what the GitHub Actions workflow ships) ### Install Locally ```bash -just dev-install # build + copy binary and manifest into the Tabularis plugins dir +just dev-install # build + copy the binary, manifest and optional bundles just uninstall # remove the installed plugin ``` @@ -316,6 +316,7 @@ just repl ```bash just run-sqlserver # SQL Server 2022 in Docker (sa / Str0ng!Passw0rd) just seed-sqlserver # create and seed the tabularis_test database +just stop-sqlserver # stop and remove the container ``` The live JSON-RPC integration suite uses the same container: diff --git a/justfile b/justfile index c8df0de..97779de 100644 --- a/justfile +++ b/justfile @@ -1,22 +1,28 @@ set shell := ["bash", "-cu"] set windows-shell := ["powershell.exe", "-NoLogo", "-NoProfile", "-Command"] -# Run SQL Server 2022 via Docker (accept the EULA, set a strong SA password) +# Run SQL Server 2022 via Docker (accept the EULA, set a strong SA password). run-sqlserver: - docker run -d --name sqlserver-dev -p 1433:1433 \ - -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" \ - mcr.microsoft.com/mssql/server:2022-latest + docker run -d --name sqlserver-dev -p 1433:1433 \ + -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" \ + mcr.microsoft.com/mssql/server:2022-latest -# Seed a test database into the local SQL Server container +# Stop and remove the local SQL Server container. +stop-sqlserver: + docker rm -f sqlserver-dev + +# Seed a test database into the local SQL Server container. CREATE DATABASE +# must finish in its own batch before sqlcmd selects the new database. seed-sqlserver: - docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ - -S localhost -U sa -P "Str0ng!Passw0rd" -C -Q \ - "IF DB_ID('tabularis_test') IS NULL CREATE DATABASE tabularis_test; \ - USE tabularis_test; \ - IF OBJECT_ID('dbo.users') IS NULL BEGIN \ - CREATE TABLE dbo.users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100) NOT NULL, email NVARCHAR(255) NOT NULL); \ - INSERT INTO dbo.users (name, email) VALUES (N'Alice', N'alice@example.com'), (N'Bob', N'bob@example.com'); \ - END" + docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "Str0ng!Passw0rd" -C -Q \ + "IF DB_ID('tabularis_test') IS NULL CREATE DATABASE tabularis_test;" + docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "Str0ng!Passw0rd" -C -d tabularis_test -Q \ + "IF OBJECT_ID('dbo.users') IS NULL BEGIN \ + CREATE TABLE dbo.users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100) NOT NULL, email NVARCHAR(255) NOT NULL); \ + INSERT INTO dbo.users (name, email) VALUES (N'Alice', N'alice@example.com'), (N'Bob', N'bob@example.com'); \ + END" # --------------------------------------------------------------------------- # Cross-platform recipes (only shell-agnostic tooling — cargo, npm). @@ -49,6 +55,15 @@ fmt: # --------------------------------------------------------------------------- # Platform-specific recipes (file operations + plugin-dir conventions). +# +# Host source: tabularis/src-tauri/src/plugins/manager.rs loads the directory +# returned by tabularis/src-tauri/src/plugins/installer.rs::get_plugins_dir, +# which appends `plugins` to tabularis/src-tauri/src/paths.rs::get_app_data_dir. +# paths.rs uses ProjectDirs("", "", "tabularis") and removes the directories +# crate's Windows `data` leaf. The resulting roots are +# ${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins on Linux, +# $HOME/Library/Application Support/tabularis/plugins on macOS, and +# %APPDATA%\tabularis\plugins on Windows. # --------------------------------------------------------------------------- # Build the UI extension if present (no-op otherwise). @@ -61,67 +76,80 @@ build-ui: [windows] build-ui: - if (Test-Path ui/package.json) { - Write-Host "Building UI extension..." - Push-Location ui - try { - npm install --no-audit --no-fund - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - npm run build - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } finally { - Pop-Location - } + if (Test-Path "ui\package.json") { \ + Write-Host "Building UI extension..."; \ + Push-Location ui; \ + try { \ + npm install --no-audit --no-fund; \ + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + npm run build; \ + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + } finally { \ + Pop-Location; \ + }; \ } -# Build + copy binary, manifest and (if present) UI bundle into Tabularis's plugin folder. +# Build + copy binary, manifest and optional bundles into Tabularis's plugin folder. [linux] dev-install: build - mkdir -p ~/.local/share/tabularis/plugins/sqlserver - cp target/debug/sqlserver-plugin ~/.local/share/tabularis/plugins/sqlserver/ - cp .tabularium ~/.local/share/tabularis/plugins/sqlserver/ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" + cp target/debug/sqlserver-plugin "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/" + cp .tabularium "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/" @if [ -f ui/dist/index.js ]; then \ - mkdir -p ~/.local/share/tabularis/plugins/sqlserver/ui/dist; \ - cp ui/dist/index.js ~/.local/share/tabularis/plugins/sqlserver/ui/dist/; \ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/ui/dist"; \ + cp ui/dist/index.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/ui/dist/"; \ + fi + @if [ -f explain/dist/index.js ]; then \ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist"; \ + cp explain/dist/index.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist/"; \ fi - @echo "Installed to ~/.local/share/tabularis/plugins/sqlserver" + @echo "Installed to ${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" @echo "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." [macos] dev-install: build - mkdir -p "$HOME/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver" cp target/debug/sqlserver-plugin "$HOME/Library/Application Support/tabularis/plugins/sqlserver/" cp .tabularium "$HOME/Library/Application Support/tabularis/plugins/sqlserver/" @if [ -f ui/dist/index.js ]; then \ mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver/ui/dist"; \ cp ui/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/ui/dist/"; \ fi - @echo "Installed to ~/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + @if [ -f explain/dist/index.js ]; then \ + mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist"; \ + cp explain/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist/"; \ + fi + @echo "Installed to ~/Library/Application Support/tabularis/plugins/sqlserver" @echo "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." +# Each recipe line runs in a fresh shell, so this must be one logical command. [windows] dev-install: build - $dest = Join-Path $env:APPDATA "debba\tabularis\data\plugins\sqlserver" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - Copy-Item "target\debug\sqlserver-plugin.exe" $dest - Copy-Item ".tabularium" $dest - if (Test-Path "ui\dist\index.js") { - New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null - Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist") - } - Write-Host "Installed to $dest" + $dest = Join-Path $env:APPDATA "tabularis\plugins\sqlserver"; \ + New-Item -ItemType Directory -Force -Path $dest | Out-Null; \ + Copy-Item "target\debug\sqlserver-plugin.exe" $dest; \ + Copy-Item ".tabularium" $dest; \ + if (Test-Path "ui\dist\index.js") { \ + New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null; \ + Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist"); \ + }; \ + if (Test-Path "explain\dist\index.js") { \ + New-Item -ItemType Directory -Force -Path (Join-Path $dest "explain\dist") | Out-Null; \ + Copy-Item "explain\dist\index.js" (Join-Path $dest "explain\dist"); \ + }; \ + Write-Host "Installed to $dest"; \ Write-Host "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." -# Remove the installed plugin. +# Remove the installed plugin from the same host-defined directory. [linux] uninstall: - rm -rf ~/.local/share/tabularis/plugins/sqlserver + rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" [macos] uninstall: - rm -rf "$HOME/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + rm -rf "$HOME/Library/Application Support/tabularis/plugins/sqlserver" [windows] uninstall: - $dest = Join-Path $env:APPDATA "debba\tabularis\data\plugins\sqlserver" + $dest = Join-Path $env:APPDATA "tabularis\plugins\sqlserver"; \ if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } From 1718b3149f5e982109062c2ef40682252fbd9fb6 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 12:30:37 +0000 Subject: [PATCH 11/23] chore(release): prepare 1.0.0 beta --- .github/workflows/release.yml | 11 +++++++++ .tabularium | 2 +- CHANGELOG.md | 43 +++++++++++++++++++++++++++-------- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/registry-entry.json | 22 ++++++++++++++++++ 6 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 docs/registry-entry.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bd486b..f79f5b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,6 +183,15 @@ jobs: path: artifacts merge-multiple: true + - name: Detect prerelease from tag + id: meta + run: | + if [[ "${{ github.ref_name }}" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Publish release uses: softprops/action-gh-release@v2 with: @@ -191,3 +200,5 @@ jobs: files: | artifacts/*.zip .tabularium + prerelease: ${{ steps.meta.outputs.prerelease == 'true' }} + make_latest: ${{ steps.meta.outputs.prerelease == 'false' }} diff --git a/.tabularium b/.tabularium index 81069e0..2551260 100644 --- a/.tabularium +++ b/.tabularium @@ -1,7 +1,7 @@ { "$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver", "name": "sqlserver", - "version": "0.1.0", + "version": "1.0.0-beta.1", "description": "Full-featured Microsoft SQL Server driver for Tabularis with schema browsing, query execution, visual plans, type-aware row editing, DDL, routines, triggers, BLOBs, and database-user management.", "category": "database", "tags": ["driver", "sqlserver", "mssql", "sql", "relational", "database-driver"], diff --git a/CHANGELOG.md b/CHANGELOG.md index b23c9c9..32b92d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-beta.1] - 2026-08-30 + ### Changed -- Replaced the `tiberius` TDS client with Microsoft's `mssql-tds` implementation through `mssql-tiberius-bridge`, preserving the plugin's user-facing connection and query behaviour +- Replaced the `tiberius` TDS client with Microsoft's `mssql-tds` + implementation through `mssql-tiberius-bridge`, preserving result-set + metadata, affected-row reporting, session recovery, pagination, and static + and runtime execution-plan capture. +- Adopted the `1.0.0-beta.N` prerelease line for the completed driver instead + of retaining the scaffold's `0.1.0` version. Pull-request + `prerelease:alpha`, `prerelease:beta`, `prerelease:rc`, and + `prerelease:stable` labels drive version suggestions. ### Added -- Automated live SQL Server 2022 JSON-RPC integration tests for TLS, DDL, CRUD, result-set metadata, affected rows, identity recovery, pagination, error recovery, execution plans, and startup scripts -- Initial SQL Server driver with `deadpool` pooling, TLS modes, session reset, and startup scripts -- Schema, table, column, PK/FK, index, view, routine, and trigger introspection -- Query execution with pagination, CTE/DML classification, multiple result sets, and accurate affected rows (incl. 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 -- Procedure/function management, typed `OUT`/`INOUT` variables, and table-valued functions -- Static and runtime execution plans through `SHOWPLAN_XML` / `STATISTICS XML`, parsed into the visual-plan model -- JavaScript-safe `BIGINT` extraction and broad SQL Server type handling +- URL and ADO.NET/ODBC connection strings with deterministic reconciliation + against discrete connection fields and normalized pool keys. +- Raw BLOB export and bounded MIME-sniffed previews for SQL Server binary + types, including composite-primary-key lookup and oversized-value guards. +- Manifest-backed initialization settings for pool sizing, connection and + query timeouts, TDS application identity, certificate trust, and idle pool + eviction. +- SQL-authenticated login and database-user lifecycle management, privilege + catalogs, direct and inherited grant reporting, and transactional + privilege changes. +- Registry-grade manifest metadata, SQL Server branding and screenshots, + native type mappings, synchronized data-type declarations, and release + archives for five desktop platforms. +- CI checks for formatting, Clippy, unit and live SQL Server 2022 tests, + manifest and Markdown validation, Conventional Commit pull-request titles, + version suggestions, dependency updates, RustSec advisories, and release + tag/version agreement. +- Schema and object introspection, query and batch execution, CRUD, DDL, + views, routines, triggers, visual execution plans, JavaScript-safe integer + extraction, and broad SQL Server type handling. + +[Unreleased]: https://github.com/TabularisDB/tabularis-sqlserver-plugin/compare/v1.0.0-beta.1...HEAD +[1.0.0-beta.1]: https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/tag/v1.0.0-beta.1 diff --git a/Cargo.lock b/Cargo.lock index 610b32b..d5a23cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1272,7 +1272,7 @@ dependencies = [ [[package]] name = "sqlserver-plugin" -version = "0.1.0" +version = "1.0.0-beta.1" dependencies = [ "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 343522d..0fe784f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlserver-plugin" -version = "0.1.0" +version = "1.0.0-beta.1" edition = "2021" description = "Tabularis driver plugin for Microsoft SQL Server" license = "Apache-2.0" diff --git a/docs/registry-entry.json b/docs/registry-entry.json new file mode 100644 index 0000000..ede16fb --- /dev/null +++ b/docs/registry-entry.json @@ -0,0 +1,22 @@ +{ + "$comment": "TODO: Before opening the registry PR, confirm that Tabularis 0.22.0 includes SS-032 and SS-033, then remove this field.", + "id": "sqlserver", + "name": "Microsoft SQL Server", + "description": "Full-featured Microsoft SQL Server driver for Tabularis with schema browsing, query execution, visual plans, type-aware row editing, DDL, routines, triggers, BLOBs, and database-user management.", + "author": "Andrea Debernardi ", + "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin", + "latest_version": "1.0.0-beta.1", + "releases": [ + { + "version": "1.0.0-beta.1", + "min_tabularis_version": "0.22.0", + "assets": { + "linux-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-linux-x64.zip", + "linux-arm64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-linux-arm64.zip", + "darwin-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-darwin-x64.zip", + "darwin-arm64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-darwin-arm64.zip", + "win-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-win-x64.zip" + } + } + ] +} From ba1c9683d88373f5281d76ffed31b85aa70cf4de Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 12:40:49 +0000 Subject: [PATCH 12/23] docs(explain): freeze plugin parser contract --- CLAUDE.md | 4 +- docs/explain-architecture.md | 505 +++++++++++++++++++++++++++++++++++ 2 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 docs/explain-architecture.md diff --git a/CLAUDE.md b/CLAUDE.md index 4a1c5a0..a66c346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co A [Tabularis](https://github.com/TabularisDB/tabularis) driver plugin, written in Rust, that lets Tabularis connect to Microsoft SQL Server. Tabularis launches the compiled binary as a subprocess and talks to it over stdio using JSON-RPC (one JSON object per line in, one JSON object per line out). The plugin has no server of its own and no persistent state beyond an in-process connection-pool cache. -Full plugin contract (required RPC methods, manifest schema) lives in the upstream guide: `https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md`. +Full plugin contract (required RPC methods, manifest schema) lives in the upstream guide: `https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md`. The frozen contract for plugin-owned EXPLAIN parser work is [`docs/explain-architecture.md`](docs/explain-architecture.md). ## Commands @@ -51,4 +51,4 @@ Key invariants: - JSON emitted by handlers must deserialize into the host's model structs — `models.rs` mirrors the host's serde shapes; don't change field names or nullability casually. - `.tabularium` `data_types` mirrors `driver/types.rs::get_data_types()`; keep them in sync. - `update_record`/`delete_record` receive a `pk_map` (composite PKs supported); ordering is normalized by sorting column names. -- Unlike built-in drivers, a plugin's `explain_query` result passes through to the frontend untouched — hence the in-process SHOWPLAN parser. +- Pending `SS-035`, this plugin still returns an in-process parsed SHOWPLAN. After `SS-035`, it returns raw `sqlserver-showplan-xml`; the host wraps it as raw EXPLAIN output and the plugin-owned TypeScript parser registered from `explain/dist/index.iife.js` produces the visual plan. Keep the raw shape, manifest declaration, parser bundle and runtime version floor aligned with `docs/explain-architecture.md`. diff --git a/docs/explain-architecture.md b/docs/explain-architecture.md new file mode 100644 index 0000000..7047c23 --- /dev/null +++ b/docs/explain-architecture.md @@ -0,0 +1,505 @@ +# Plugin-owned EXPLAIN parsers — frozen contract + +This document is the implementation contract for `SS-031` through `SS-036`. +It was frozen by `SS-030` on 2026-08-30 after checking Tabularis core commit +`9e6975aa5ef1d9667c0d7a27488b55adfe3cf584` and SQL Server plugin commit +`1718b3149f5e982109062c2ef40682252fbd9fb6`. + +Statements in §1 describe that baseline and include source anchors. The later +sections are normative decisions for the implementation tasks; they do not +claim that the code exists at the frozen commits. + +## 1. Verified baseline + +The current split is real, but several details in the initial design needed +correction. + +| Claim | Verified source | +| --- | --- | +| Raw built-in output has five closed format literals and is dispatched by an exhaustive `switch`. | `tabularis/packages/explain/src/raw.ts:22-27` and `:62-74` | +| Standalone source parsing has a separate four-entry parser array, a closed three-engine union and a second dispatch path. | `tabularis/packages/explain/src/parsers/source.ts:17-54` and `:72-160` | +| The only format-related switches under `packages/explain/src` are the raw-format switch and the source engine switch. | `raw.ts:63` and `parsers/source.ts:112` at the frozen core commit | +| Plugin `explain_query` output is always wrapped as `Plan`. | `tabularis/src-tauri/src/plugins/driver.rs:800-816` | +| The Rust host already has serializable `RawExplainOutput` and tagged `ExplainQueryOutput` models; `original_query` is currently required. | `tabularis/src-tauri/src/models.rs:526-548` | +| SQL Server captures estimated or runtime XML, then parses it in process. | `src/driver/explain.rs:11-50` and `src/driver/ops.rs:379-392` | +| The Rust SHOWPLAN parser uses the first `RelOp`, respects nested-operator ownership and aggregates runtime counters by thread. | `src/driver/showplan.rs:12-178`, especially `:105-138` | +| The existing parser stores `EstimatedTotalSubtreeCost` directly as `total_cost`; it does not subtract child cost or use `AvgRowSize`. | `src/driver/showplan.rs:165-168` | +| `read_plugin_file` accepts nested relative UTF-8 text paths and rejects paths containing `..` or beginning with `/` or `\`. | `tabularis/src-tauri/src/plugins/commands.rs:402-419` | +| UI IIFEs are actually read and evaluated in `PluginSlotProvider`; `pluginModuleLoader.ts` is a separate dynamic-loader abstraction and is not the production IIFE evaluator. | `tabularis/src/contexts/PluginSlotProvider.tsx:62-137` and `src/utils/pluginModuleLoader.ts:14-75` | +| The frontend already has an enabled-plugin manifest effect where parser loading can be attached. | `tabularis/src/contexts/PluginSlotProvider.tsx:140-192` | +| Runtime manifests pass `ui_extensions` through Rust and TypeScript models, even though the local legacy schema does not declare that field. | `tabularis/src-tauri/src/plugins/manager.rs:31-69`, `src-tauri/src/drivers/driver_trait.rs:210-263` and `src/types/plugins.ts:73-117` | +| The local manifest schema has `additionalProperties: false` and declares neither `ui_extensions` nor `explain_parsers`. | `tabularis/plugins/manifest.schema.json:1-259` | + +`read_plugin_file` is sufficient for the parser bundle because JavaScript is +UTF-8 text and `explain/dist/index.iife.js` is a valid nested relative path. +Its validation is lexical, not canonical: the current command does not prove +that a symlink target remains below the plugin directory. This contract does +not overstate that guarantee. + +Opening only `RawExplainFormat` while retaining the `switch` in `raw.ts` would +make `parseRawPayload` non-exhaustive. The registry replaces that switch. +Supporting third-party source detection also requires opening +`ExplainEngine` and `ExplainSourceFormat`; the initial design omitted those +two type changes. No other current switch needs changing. + +The current Rust parser already implements the per-thread aggregation credited +to the closed core PR #560: sum `ActualRows` and `ActualExecutions`, and take +the maximum `ActualElapsedms`. Neither issue #2 nor PR #560 specifies +subtracting child subtree costs or mapping `AvgRowSize`; those were erroneous +claims in the initial design and are not part of this contract. + +## 2. Goals and ownership + +A SQL Server plan has one parser implementation in this repository, written in +TypeScript. It is built into: + +1. an IIFE shipped in each plugin archive for the desktop; and +2. an ESM npm package consumed by the standalone visualizer. + +SQL Server parsing remains owned and released by the SQL Server plugin. Core +`@tabularis/explain` gains only an engine-neutral parser registry. This differs +from issue #2's original proposal, which would put SQL Server parsing directly +in the core package. + +This split allows any third-party plugin to supply a parser without waiting for +a core release, while the npm artifact makes the same parser available where +there is no plugin process. Renderer-only changes already apply to parsed +plugin plans today; the concrete problems solved here are duplicated parser +implementations, parser/model evolution tied to a Rust binary, and the +standalone site's inability to reach that binary. + +## 3. `@tabularis/explain` registry (`SS-031`) + +### 3.1 Public API + +Add `packages/explain/src/registry.ts` and export these symbols from the +package root: + +```ts +export interface RegisteredExplainParser { + /** Canonical engine id, for example "sqlserver". */ + readonly engine: string; + /** Globally unique wire-format tag. */ + readonly format: string; + /** Human label for format pickers. */ + readonly label?: string; + /** Parse the raw payload or throw an Error. */ + parse(payload: string): ExplainPlan; + /** Cheap, side-effect-free source detection. */ + sniff?(payload: string): boolean; +} + +export function registerExplainParser( + parser: RegisteredExplainParser, +): void; +export function unregisterExplainParser(format: string): void; +export function getExplainParser( + format: string, +): RegisteredExplainParser | null; +export function listExplainParsers(): readonly RegisteredExplainParser[]; +``` + +The existing public types become open while preserving literal autocomplete: + +```ts +export type BuiltinRawExplainFormat = + | "postgres-json" + | "mysql-json" + | "mysql-analyze-text" + | "mysql-tabular-rows" + | "sqlite-eqp-rows"; +export type RawExplainFormat = + | BuiltinRawExplainFormat + | (string & {}); + +export type BuiltinExplainEngine = "postgres" | "mysql" | "sqlite"; +export type ExplainEngine = BuiltinExplainEngine | (string & {}); + +export type BuiltinExplainSourceFormat = + | "postgres-json" + | "postgres-text" + | "mysql-json" + | "mysql-text"; +export type ExplainSourceFormat = + | BuiltinExplainSourceFormat + | (string & {}); +``` + +### 3.2 Built-ins and mutation rules + +The effective registry has an immutable built-in baseline and a mutable +registration overlay. The baseline contains all existing dispatch tags, not +only the five raw wire tags: + +- `postgres-json` +- `postgres-text` +- `mysql-json` +- `mysql-text` +- `mysql-analyze-text` +- `mysql-tabular-rows` +- `sqlite-eqp-rows` + +Aliases that share a parser remain separate format entries. Dispatch for raw +host output and standalone source parsing therefore reaches the same effective +registry. + +Registration rules are exact: + +- `engine` and `format` must be non-empty after trimming and `parse` must be a + function. Invalid registrations throw `TypeError` before mutating state. +- A new custom format is appended in registration order. +- Registering an already effective format installs or replaces its overlay and + emits exactly one `console.warn` for that call: + `EXPLAIN parser format '' is already registered; replacing it.` +- Replacement keeps the format's existing position. This makes an in-place + plugin upgrade deterministic. +- `unregisterExplainParser` removes only the mutable overlay. It is a no-op for + an absent overlay; removing an override reveals the immutable built-in. +- `listExplainParsers` returns an immutable snapshot of effective entries: + built-in order first, followed by custom registration order. A built-in + override occupies the built-in's original position. +- Parser exceptions propagate unchanged to the caller. + +These rules prevent tests or plugin unloads from accidentally deleting core +parsers while still allowing a deliberate override. + +### 3.3 Dispatch, source detection and exact errors + +`parseRawExplain` looks up `raw.format` in the registry and invokes its +`parse`. If there is no entry, it throws exactly: + +```text +No EXPLAIN parser registered for format '' (engine ''). Import the parser package for '' before parsing. +``` + +It then stamps `driver` and `original_query` exactly as it does today. + +`parsers/source.ts` also uses the registry for final parser dispatch. Detection +preserves the current built-in behavior before consulting custom sniffers: + +- With a built-in engine hint, existing Postgres, MySQL and SQLite decisions + and error text remain unchanged. +- With a custom engine hint, consider effective parsers whose `engine` matches + case-insensitively and whose `sniff` returns true, in registration order. +- Without a hint, run the existing Postgres detection first. If it does not + match, run custom sniffers in registration order. +- A throwing sniffer is treated as `false`; detection continues. Parsing is + not attempted during sniffing. +- Because the historical unhinted JSON heuristic chooses Postgres, a custom + JSON format should be parsed with an engine hint unless it can be + distinguished before that heuristic in a future, separately reviewed + change. + +`explainEngineFromDriverName` retains the current built-in aliases first. It +then returns the canonical `engine` of the first registered parser whose +engine equals the trimmed driver name case-insensitively. Unknown names still +return `null`. + +Built-in behavior must remain byte-for-byte compatible when no mutable parsers +are registered. Tests must cover raw dispatch through a custom parser, +replacement and its one warning, unregister and built-in restoration, the +exact unknown-format error, custom source detection with and without an engine +hint, a throwing sniffer, engine lookup, and all existing raw/source fixtures. + +### 3.4 Import graph + +The initial claim that `registry.ts` could import only `types.ts` while also +seeding built-ins was inconsistent. Use this acyclic graph instead: + +```text +raw.ts ───────────────┐ +parsers/source.ts ────┼──> registry.ts ──> parsers/builtins.ts + │ │ + │ ├──> parsers/postgres.ts + │ ├──> parsers/mysql.ts + │ └──> parsers/sqlite.ts + └────────────────────────────> types.ts +``` + +`parsers/builtins.ts` owns row-payload JSON adapters now local to `raw.ts`. +Leaf parsers and `types.ts` must not import `raw.ts`, `source.ts` or the +registry. This graph has no cycle. + +## 4. Raw plugin protocol (`SS-032`) + +A plugin may return either its historical parsed-plan object or this raw +object from the JSON-RPC `explain_query` method: + +```ts +interface PluginRawExplainOutput { + engine: string; + format: string; + payload: string; + original_query?: string | null; +} +``` + +`RpcDriver::explain_query` performs structural detection on the JSON value: + +1. If `engine`, `format` and `payload` are all strings, construct + `RawExplainOutput` and return `ExplainQueryOutput::Raw`. +2. Preserve a string `original_query`. If it is absent or `null`, fill it from + the `query` argument supplied to the host. +3. If the three required strings identify a raw object but + `original_query` is present with another type, return + `Plugin raw EXPLAIN field 'original_query' must be a string or null`. +4. Additional fields are ignored. +5. If any required field is absent or is not a string, preserve the complete + old fallback: return `ExplainQueryOutput::Plan { plan: res }` unchanged. + +Detection is structural, not based on plan fields, XML contents or format +names. Tests must cover all branches, including a parsed plan that happens to +contain `engine` or `format` but not all three required strings. + +Compatibility is intentionally asymmetric: + +- old plugin plus new host remains a `Plan` and works unchanged; +- new plugin plus old host is wrapped as a plan object and cannot render; +- therefore `SS-035` must raise the plugin's runtime floor to the first + Tabularis release containing both raw-plugin support and bundle loading. + +`plugins/PLUGIN_GUIDE.md` documents both result shapes. This task does not add +SQL Server-specific knowledge to the Rust host. + +## 5. Manifest contract (`SS-033` and `SS-034`) + +The optional additive field is: + +```json +"explain_parsers": [ + { + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "label": "SQL Server SHOWPLAN XML", + "module": "explain/dist/index.iife.js" + } +] +``` + +Each item requires non-empty string `engine`, `format` and `module`; `label` is +an optional non-empty string. Unknown item properties are rejected. A plugin +without the field behaves exactly as it does today. + +`SS-033` adds this shape to all core surfaces that currently carry +`ui_extensions`: + +- `plugins/manifest.schema.json` for the legacy/runtime manifest; +- `plugins/tabularium-extensions.schema.json` for the live merged registry + schema used by `.tabularium`; +- Rust `ConfigManifest` and `PluginManifest`, including every constructor; +- frontend `PluginManifest` types. + +`SS-034` adds the field to this plugin's `.tabularium` file. The IIFE filename +is intentionally different from the ESM package entry. Existing provisional +plugin tooling that copies `explain/dist/index.js` must be corrected in +`SS-034` to package `index.iife.js` as well as the npm files where appropriate. + +The `module` value is passed to `read_plugin_file`. It is relative to the +installed plugin directory and must satisfy that command's existing path +rules. No network import or arbitrary absolute path is allowed. + +## 6. Desktop bundle and loading (`SS-033` and `SS-034`) + +### 6.1 Artifact convention + +| Property | UI extension today | EXPLAIN parser contract | +| --- | --- | --- | +| Format | IIFE | IIFE | +| Output variable | `__tabularis_plugin__` | `__tabularis_explain_parser__` | +| External host API | `__TABULARIS_API__` | `__TABULARIS_EXPLAIN__` | +| Disk command | `read_plugin_file` | `read_plugin_file` | +| Evaluator | `PluginSlotProvider` | new `pluginExplainLoader.ts` | +| Trigger | enabled-plugin manifest effect | same enabled-plugin manifest effect | + +The parser IIFE externalizes `@tabularis/explain` to +`__TABULARIS_EXPLAIN__`. The desktop passes the imported package namespace as +a `new Function` parameter, just as the UI loader passes React and the plugin +API. It returns the value assigned to `__tabularis_explain_parser__`. + +Do not make one entry point both self-register and return a parser; that would +register it twice. The package uses separate thin entries over one parser: + +```text +explain/src/showplan.ts parser implementation +explain/src/parser.ts parser descriptor +explain/src/index.ts ESM: registers descriptor, exports direct API +explain/src/iife.ts IIFE: default-exports descriptor, no registration +``` + +Build outputs are `dist/index.js`, `dist/index.d.ts` and +`dist/index.iife.js`. + +### 6.2 Loader behavior + +The IIFE default export may be one `RegisteredExplainParser` or an array. The +loader groups manifest entries by module so it reads and evaluates a file once. +For every manifest declaration it finds exactly one exported descriptor with +the same `engine` and `format`, validates non-empty strings and a callable +`parse`, applies the manifest label when supplied, and registers it. Undeclared +exports and invalid or missing matches are warned and skipped. + +Plugin ids are processed in sorted order and manifest entries in manifest +order, making collision replacement deterministic. When the enabled set +changes, the provider unregisters formats loaded by its previous pass before +loading the new set. Built-in parsers reappear automatically because registry +unregistration removes only overlays. + +Each module read/evaluation is isolated. If reading or evaluating a bundle +throws, log exactly this prefix with the error as a separate argument, skip +that module, and continue: + +```text +[PluginExplain] Failed to load module "" for plugin "": +``` + +An invalid descriptor warns and skips only that descriptor. A parser's own +exception during a later parse is not swallowed; existing Visual EXPLAIN error +handling displays it. One broken plugin must not prevent other parser bundles +or built-ins from loading. + +The current frontend trigger is the enabled-plugin manifest effect in +`PluginSlotProvider`, not a nonexistent JavaScript callback from Rust driver +registration. `SS-033` extends that lifecycle (or a sibling provider sharing +it) after `get_plugin_manifest` succeeds. Startup plugin loading completes in +Tauri setup before the frontend runs, and hot enable/install completes its +backend load before updating `activeExternalDrivers`, so this is the available +driver-load synchronization point. + +## 7. npm package (`SS-034`) + +The package is `@tabularis/explain-sqlserver`. Its version tracks the plugin +version. Its relevant metadata is: + +```jsonc +{ + "name": "@tabularis/explain-sqlserver", + "type": "module", + "sideEffects": ["./dist/index.js"], + "peerDependencies": { + "@tabularis/explain": "^0.2.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} +``` + +`@tabularis/explain` 0.2.0 is the first version with the registry. The XML +parser's chosen library is a normal runtime dependency of this package, not a +core dependency. + +Both usage forms are supported: + +```ts +import "@tabularis/explain-sqlserver"; +import { parseShowplanXml } from "@tabularis/explain-sqlserver"; +``` + +The first form must survive tree shaking, hence the explicit `sideEffects` +metadata. The package's ESM entry registers once on evaluation and exports the +parser and descriptor. The IIFE entry does not self-register. + +Publishing is independent from the Rust release and is triggered by an +`explain-v*` tag. A Rust release does not force an npm publish, nor vice versa. + +## 8. SQL Server parser semantics (`SS-034`) + +The initial TypeScript port is behavior-compatible with +`src/driver/showplan.rs`: + +- namespace-insensitive XML parsing and the first `RelOp` as root; +- direct child operators without crossing nested `RelOp` ownership; +- `PhysicalOp`, falling back to `LogicalOp`, then `Unknown`; +- ids prefixed with `sqlserver-`, including deterministic fallback ids; +- the first owned `Object@Table` with square brackets removed as `relation`; +- the first owned `ScalarOperator@ScalarString` as `filter`; +- logical operations containing `join` as `join_type` and in + `extra.logical_operation`; +- `EstimateRows` as `plan_rows` and `EstimatedTotalSubtreeCost` directly as + `total_cost`; +- sum per-thread `ActualRows` and `ActualExecutions`, maximum per-thread + `ActualElapsedms`; +- root elapsed time as `execution_time_ms`, root actual rows deciding + `has_analyze_data`, and the original XML as `raw_output`; +- `planning_time_ms`, startup costs, buffers, index/hash conditions and fields + not listed above remain `null`; +- a multi-statement document uses its first `RelOp`, matching the Rust parser; +- malformed XML and a document without `RelOp` retain the current error + prefixes. + +There is no child-subtree cost subtraction and no `AvgRowSize` mapping in this +port. Missing-index data is not synthesized into the shared model; its fixture +proves that such a real document remains parseable and preserves raw output. +Any semantic expansion is a later, separately tested change. + +Real SQL Server 2022 fixtures under `explain/tests/fixtures/` cover at least a +trivial scan, an index seek with key lookup, a parallel hash join with multiple +`RunTimeCountersPerThread` elements, `STATISTICS XML`, a missing-index +suggestion and a multi-statement batch. Fixtures are captured documents, not +hand-authored XML. Tests compare the TypeScript result with committed expected +plans and explicitly assert the aggregation and first-statement behavior. + +The registered descriptor is: + +```ts +{ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + parse: parseShowplanXml, + sniff: (payload) => /<(?:\w+:)?ShowPlanXML(?:\s|>)/.test(payload.slice(0, 4096)), +} +``` + +The production parser still validates the full XML; sniffing is only a cheap +selection heuristic. + +## 9. Plugin handoff and version floor (`SS-035`) + +After core `SS-031` through `SS-033` and plugin `SS-034` are available, the +plugin returns: + +```json +{ + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "payload": "...", + "original_query": "SELECT ..." +} +``` + +`src/driver/explain.rs` remains responsible only for safe SHOWPLAN capture and +session cleanup. `src/driver/showplan.rs` and its call from `ops.rs` are then +removed. + +`min_runtime_version` and the prepared registry entry's +`min_tabularis_version` must name the first released Tabularis version that +contains all three core tasks. Do not guess that version before the core +release is assigned. This floor and the raw handoff land together. + +## 10. Ordering and blast radius + +```text +SS-030 freeze this contract + │ + ├── SS-031 registry and open types ─┐ + ├── SS-032 raw output from plugin drivers ├─ core PR + ├── SS-033 manifest plumbing and desktop loader ─┘ + │ + ├── SS-034 SQL Server parser package and IIFE ─┐ + ├── SS-035 plugin returns raw SHOWPLAN XML ─┘ plugin PR + │ + └── SS-036 standalone site imports npm package site PR +``` + +`SS-031` is behavior-preserving without mutable registrations. `SS-032` and +`SS-033` are inert for manifests without `explain_parsers`. `SS-035` is the +compatibility boundary and cannot land without the runtime floor and packaged +IIFE. + +The seam is engine-neutral. Future first- or third-party plugins can ship their +own parser bundles and npm packages without adding engine code to Tabularis +core. From 1af7488614bc01c4ae8f049c09fa8cdc6df50139 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 21:32:57 +0000 Subject: [PATCH 13/23] feat(explain): add SQL Server parser package --- .github/dependabot.yml | 4 + .github/workflows/ci.yml | 30 + .github/workflows/publish-explain.yml | 54 + .github/workflows/release.yml | 17 +- .gitignore | 1 + .tabularium | 8 + README.md | 4 +- explain/LICENSE | 202 +++ explain/README.md | 73 + explain/package.json | 61 + explain/pnpm-lock.yaml | 1591 +++++++++++++++++ explain/pnpm-workspace.yaml | 2 + explain/src/iife.ts | 5 + explain/src/index.ts | 8 + explain/src/parser.ts | 13 + explain/src/showplan.ts | 354 ++++ .../expected/index-seek-key-lookup.json | 72 + .../fixtures/expected/missing-index.json | 51 + .../fixtures/expected/multi-statement.json | 29 + .../fixtures/expected/parallel-hash-join.json | 204 +++ .../fixtures/expected/statistics-xml.json | 29 + .../tests/fixtures/expected/trivial-scan.json | 29 + .../tests/fixtures/index-seek-key-lookup.xml | 1 + explain/tests/fixtures/missing-index.xml | 1 + explain/tests/fixtures/multi-statement.xml | 1 + explain/tests/fixtures/parallel-hash-join.xml | 1 + explain/tests/fixtures/statistics-xml.xml | 1 + explain/tests/fixtures/trivial-scan.xml | 1 + explain/tests/host/index.d.ts | 40 + explain/tests/host/index.js | 5 + explain/tests/host/package.json | 11 + explain/tests/package.test.ts | 50 + explain/tests/showplan.test.ts | 89 + explain/tsconfig.json | 25 + explain/tsup.config.ts | 30 + justfile | 30 +- src/driver/showplan.rs | 25 + 37 files changed, 3135 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/publish-explain.yml create mode 100644 explain/LICENSE create mode 100644 explain/README.md create mode 100644 explain/package.json create mode 100644 explain/pnpm-lock.yaml create mode 100644 explain/pnpm-workspace.yaml create mode 100644 explain/src/iife.ts create mode 100644 explain/src/index.ts create mode 100644 explain/src/parser.ts create mode 100644 explain/src/showplan.ts create mode 100644 explain/tests/fixtures/expected/index-seek-key-lookup.json create mode 100644 explain/tests/fixtures/expected/missing-index.json create mode 100644 explain/tests/fixtures/expected/multi-statement.json create mode 100644 explain/tests/fixtures/expected/parallel-hash-join.json create mode 100644 explain/tests/fixtures/expected/statistics-xml.json create mode 100644 explain/tests/fixtures/expected/trivial-scan.json create mode 100644 explain/tests/fixtures/index-seek-key-lookup.xml create mode 100644 explain/tests/fixtures/missing-index.xml create mode 100644 explain/tests/fixtures/multi-statement.xml create mode 100644 explain/tests/fixtures/parallel-hash-join.xml create mode 100644 explain/tests/fixtures/statistics-xml.xml create mode 100644 explain/tests/fixtures/trivial-scan.xml create mode 100644 explain/tests/host/index.d.ts create mode 100644 explain/tests/host/index.js create mode 100644 explain/tests/host/package.json create mode 100644 explain/tests/package.test.ts create mode 100644 explain/tests/showplan.test.ts create mode 100644 explain/tsconfig.json create mode 100644 explain/tsup.config.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 98e44ee..353e722 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,7 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "npm" + directory: "/explain" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e594c7..5d6c7de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,36 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + explain-package: + name: EXPLAIN parser package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: "20" + + - name: Enable pnpm + run: corepack enable + + - name: Install dependencies + working-directory: explain + run: pnpm install --frozen-lockfile + + - name: Typecheck + working-directory: explain + run: pnpm typecheck + + - name: Test + working-directory: explain + run: pnpm test + + - name: Build + working-directory: explain + run: pnpm build + validate-manifest: name: Validate .tabularium manifest runs-on: ubuntu-latest diff --git a/.github/workflows/publish-explain.yml b/.github/workflows/publish-explain.yml new file mode 100644 index 0000000..be54415 --- /dev/null +++ b/.github/workflows/publish-explain.yml @@ -0,0 +1,54 @@ +name: Publish EXPLAIN parser + +on: + push: + tags: + - "explain-v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish npm package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Enable pnpm + run: corepack enable + + - name: Check tag matches package and plugin versions + run: | + TAG_VERSION="${GITHUB_REF_NAME#explain-v}" + PACKAGE_VERSION=$(jq -r .version explain/package.json) + PLUGIN_VERSION=$(jq -r .version .tabularium) + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match explain/package.json ($PACKAGE_VERSION)" + exit 1 + fi + if [ "$PACKAGE_VERSION" != "$PLUGIN_VERSION" ]; then + echo "::error::EXPLAIN package version ($PACKAGE_VERSION) does not match plugin version ($PLUGIN_VERSION)" + exit 1 + fi + + - name: Install, typecheck, test and build + working-directory: explain + run: | + pnpm install --frozen-lockfile + pnpm typecheck + pnpm test + pnpm build + + - name: Publish + working-directory: explain + run: pnpm publish --access public --provenance --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f79f5b7..495603d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,13 +99,18 @@ jobs: npm install --no-audit --no-fund npm run build + - name: Enable pnpm + if: steps.explain.outputs.present == 'true' + shell: bash + run: corepack enable + - name: Build EXPLAIN parser if: steps.explain.outputs.present == 'true' shell: bash working-directory: explain run: | - npm install --no-audit --no-fund - npm run build + pnpm install --frozen-lockfile + pnpm run build - name: Install cross (linux-arm64 only) if: matrix.cross @@ -133,9 +138,9 @@ jobs: mkdir -p "$STAGE/ui/dist" cp ui/dist/index.js "$STAGE/ui/dist/" fi - if [ -f explain/dist/index.js ]; then + if [ -f explain/dist/index.iife.js ]; then mkdir -p "$STAGE/explain/dist" - cp explain/dist/index.js "$STAGE/explain/dist/" + cp explain/dist/index.iife.js "$STAGE/explain/dist/" fi (cd "$STAGE" && zip -r ../sqlserver-plugin-${{ matrix.platform-label }}.zip .) @@ -154,9 +159,9 @@ jobs: New-Item -ItemType Directory -Force -Path "$stage\ui\dist" | Out-Null Copy-Item "ui\dist\index.js" "$stage\ui\dist" } - if (Test-Path "explain\dist\index.js") { + if (Test-Path "explain\dist\index.iife.js") { New-Item -ItemType Directory -Force -Path "$stage\explain\dist" | Out-Null - Copy-Item "explain\dist\index.js" "$stage\explain\dist" + Copy-Item "explain\dist\index.iife.js" "$stage\explain\dist" } Compress-Archive -Path "$stage\*" -DestinationPath "sqlserver-plugin-${{ matrix.platform-label }}.zip" diff --git a/.gitignore b/.gitignore index a338ac4..a66c687 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist/ *.zip ui/node_modules/ ui/dist/ +explain/node_modules/ .DS_Store diff --git a/.tabularium b/.tabularium index 2551260..74c1ef6 100644 --- a/.tabularium +++ b/.tabularium @@ -59,6 +59,14 @@ }, "kind": "driver", "engine": "sqlserver", + "explain_parsers": [ + { + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "label": "SQL Server SHOWPLAN XML", + "module": "explain/dist/index.iife.js" + } + ], "paradigms": ["relational"], "default_port": 1433, "default_username": "sa", diff --git a/README.md b/README.md index b8be6fc..3e79272 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,8 @@ remaining pools. ### Prerequisites - Rust (stable, see `rust-toolchain.toml`) -- [`just`](https://github.com/casey/just) (optional, wraps the common cargo invocations) +- Node.js 20 or newer and pnpm for the bundled Visual EXPLAIN parser +- [`just`](https://github.com/casey/just) (optional, wraps the common build and test commands) ### Build @@ -294,6 +295,7 @@ Unit tests need no database: ```bash just test +just test-explain just lint just fmt ``` diff --git a/explain/LICENSE b/explain/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/explain/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/explain/README.md b/explain/README.md new file mode 100644 index 0000000..a4e7ff0 --- /dev/null +++ b/explain/README.md @@ -0,0 +1,73 @@ +# `@tabularis/explain-sqlserver` + +SQL Server `SHOWPLAN_XML` and `STATISTICS XML` parser for Tabularis Visual +EXPLAIN. This package is maintained and versioned with the +[Tabularis SQL Server plugin](https://github.com/TabularisDB/tabularis-sqlserver-plugin). + +## Install + +```bash +pnpm add @tabularis/explain @tabularis/explain-sqlserver +``` + +`@tabularis/explain` 0.2.0 or newer is required because that release introduced +the parser registry. + +## Register on import + +Import the package for its registration side effect before parsing SQL Server +raw output through `@tabularis/explain`: + +```ts +import "@tabularis/explain-sqlserver"; +import { parseRawExplain } from "@tabularis/explain"; + +const plan = parseRawExplain({ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + payload: showplanXml, + original_query: "SELECT * FROM dbo.orders", +}); +``` + +The package declares `dist/index.js` as side-effectful so bundlers retain the +registration import. + +## Parse directly + +The parser is also exported for callers that already have a SHOWPLAN document: + +```ts +import { parseShowplanXml } from "@tabularis/explain-sqlserver"; + +const plan = parseShowplanXml(showplanXml); +``` + +Direct parsing leaves `original_query` empty. The registry's raw-output path +adds the original query supplied by the caller. + +## Plugin bundle + +The build also produces `dist/index.iife.js`. Tabularis loads that file from +the installed SQL Server plugin, evaluates it with the +`__TABULARIS_EXPLAIN__` host API, and reads the parser descriptor from +`__tabularis_explain_parser__`. The IIFE does not register itself; the desktop +loader validates it against the plugin manifest and performs registration. + +## XML support + +The parser has no runtime dependencies and uses no Node built-ins. Its small +XML reader validates element nesting, quoted attributes, entities, comments, +CDATA and processing instructions while treating XML namespace prefixes as +irrelevant to SHOWPLAN element names. This keeps the same source usable in a +browser tab, the desktop IIFE, and server-side JavaScript. + +The golden fixtures under `tests/fixtures/` were captured from SQL Server 2022. +Their expected plans are generated by the plugin's Rust parser with: + +```bash +cargo test write_showplan_fixture_goldens -- --ignored +``` + +They cover a scan, an index seek with key lookup, a parallel hash join, +`STATISTICS XML`, a missing-index recommendation, and a multi-statement batch. diff --git a/explain/package.json b/explain/package.json new file mode 100644 index 0000000..59be7e6 --- /dev/null +++ b/explain/package.json @@ -0,0 +1,61 @@ +{ + "name": "@tabularis/explain-sqlserver", + "version": "1.0.0-beta.1", + "description": "SQL Server SHOWPLAN XML parser for Tabularis Visual EXPLAIN", + "license": "Apache-2.0", + "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/tree/main/explain", + "repository": { + "type": "git", + "url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin.git", + "directory": "explain" + }, + "bugs": { + "url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues" + }, + "keywords": [ + "explain", + "showplan", + "sqlserver", + "query-plan", + "tabularis" + ], + "type": "module", + "sideEffects": [ + "./dist/index.js" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "test": "pnpm run build && vitest run", + "prepublishOnly": "pnpm run clean && pnpm run typecheck && pnpm run test" + }, + "peerDependencies": { + "@tabularis/explain": "^0.2.0" + }, + "devDependencies": { + "@tabularis/explain": "file:tests/host", + "@types/node": "^22.0.0", + "tsup": "^8.3.5", + "typescript": "~5.9.3", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@11.23.0" +} diff --git a/explain/pnpm-lock.yaml b/explain/pnpm-lock.yaml new file mode 100644 index 0000000..dbad825 --- /dev/null +++ b/explain/pnpm-lock.yaml @@ -0,0 +1,1591 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@tabularis/explain': + specifier: file:tests/host + version: file:tests/host + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@22.20.1) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@tabularis/explain@file:tests/host': + resolution: {directory: tests/host, type: directory} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@tabularis/explain@file:tests/host': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.18.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.63.1 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + object-assign@4.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.26): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.26 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.26)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.26) + resolve-from: 5.0.0 + rollup: 4.63.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.26 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.20.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.1): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@22.20.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1) + vite-node: 3.2.4(@types/node@22.20.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/explain/pnpm-workspace.yaml b/explain/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/explain/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/explain/src/iife.ts b/explain/src/iife.ts new file mode 100644 index 0000000..63f10b2 --- /dev/null +++ b/explain/src/iife.ts @@ -0,0 +1,5 @@ +import { sqlServerExplainParser } from "./parser"; + +// The desktop loader registers this descriptor after matching it to the +// plugin manifest. This entry intentionally has no registration side effect. +export default sqlServerExplainParser; diff --git a/explain/src/index.ts b/explain/src/index.ts new file mode 100644 index 0000000..6e6849f --- /dev/null +++ b/explain/src/index.ts @@ -0,0 +1,8 @@ +import { registerExplainParser } from "@tabularis/explain"; + +import { sqlServerExplainParser } from "./parser"; + +registerExplainParser(sqlServerExplainParser); + +export { sqlServerExplainParser } from "./parser"; +export { parseShowplanXml } from "./showplan"; diff --git a/explain/src/parser.ts b/explain/src/parser.ts new file mode 100644 index 0000000..9c5a49e --- /dev/null +++ b/explain/src/parser.ts @@ -0,0 +1,13 @@ +import type { RegisteredExplainParser } from "@tabularis/explain"; + +import { parseShowplanXml } from "./showplan"; + +/** SQL Server parser descriptor consumed by both package and plugin loaders. */ +export const sqlServerExplainParser: RegisteredExplainParser = { + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + parse: parseShowplanXml, + sniff: (payload) => + /<(?:\w+:)?ShowPlanXML(?:\s|>)/.test(payload.slice(0, 4096)), +}; diff --git a/explain/src/showplan.ts b/explain/src/showplan.ts new file mode 100644 index 0000000..421432c --- /dev/null +++ b/explain/src/showplan.ts @@ -0,0 +1,354 @@ +import type { ExplainNode, ExplainPlan } from "@tabularis/explain"; + +interface XmlElement { + readonly qualifiedName: string; + readonly name: string; + readonly attributes: ReadonlyMap; + readonly children: XmlElement[]; +} + +class XmlParseError extends Error {} + +function localName(qualifiedName: string): string { + const separator = qualifiedName.lastIndexOf(":"); + return separator === -1 ? qualifiedName : qualifiedName.slice(separator + 1); +} + +function decodeXmlEntities(value: string): string { + let decoded = ""; + let position = 0; + while (position < value.length) { + const ampersand = value.indexOf("&", position); + if (ampersand === -1) return decoded + value.slice(position); + decoded += value.slice(position, ampersand); + + const semicolon = value.indexOf(";", ampersand + 1); + if (semicolon === -1) throw new XmlParseError("unterminated XML entity"); + const entity = value.slice(ampersand + 1, semicolon); + switch (entity) { + case "amp": + decoded += "&"; + break; + case "lt": + decoded += "<"; + break; + case "gt": + decoded += ">"; + break; + case "quot": + decoded += '"'; + break; + case "apos": + decoded += "'"; + break; + default: { + const numeric = /^#x[0-9a-f]+$/i.test(entity) + ? Number.parseInt(entity.slice(2), 16) + : /^#[0-9]+$/.test(entity) + ? Number.parseInt(entity.slice(1), 10) + : Number.NaN; + if ( + !Number.isInteger(numeric) || + numeric <= 0 || + numeric > 0x10ffff || + (numeric >= 0xd800 && numeric <= 0xdfff) + ) { + throw new XmlParseError(`invalid XML entity &${entity};`); + } + decoded += String.fromCodePoint(numeric); + } + } + position = semicolon + 1; + } + return decoded; +} + +function findTagEnd(xml: string, start: number): number { + let quote: string | null = null; + for (let index = start; index < xml.length; index += 1) { + const character = xml[index]; + if (quote !== null) { + if (character === quote) quote = null; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === ">") { + return index; + } + } + throw new XmlParseError("unterminated XML tag"); +} + +function parseStartTag(source: string): { + qualifiedName: string; + attributes: Map; + selfClosing: boolean; +} { + let end = source.length; + while (end > 0 && /\s/.test(source[end - 1] ?? "")) end -= 1; + const selfClosing = source[end - 1] === "/"; + if (selfClosing) { + end -= 1; + while (end > 0 && /\s/.test(source[end - 1] ?? "")) end -= 1; + } + + let index = 0; + while (index < end && /\s/.test(source[index] ?? "")) index += 1; + const nameStart = index; + while (index < end && !/[\s/=]/.test(source[index] ?? "")) index += 1; + const qualifiedName = source.slice(nameStart, index); + if (!/^[A-Za-z_][\w.:-]*$/.test(qualifiedName)) { + throw new XmlParseError(`invalid element name '${qualifiedName}'`); + } + + const attributes = new Map(); + while (index < end) { + while (index < end && /\s/.test(source[index] ?? "")) index += 1; + if (index >= end) break; + + const attributeStart = index; + while (index < end && !/[\s=]/.test(source[index] ?? "")) index += 1; + const attributeName = source.slice(attributeStart, index); + if (!/^[A-Za-z_][\w.:-]*$/.test(attributeName)) { + throw new XmlParseError(`invalid attribute name '${attributeName}'`); + } + while (index < end && /\s/.test(source[index] ?? "")) index += 1; + if (source[index] !== "=") { + throw new XmlParseError(`attribute '${attributeName}' has no value`); + } + index += 1; + while (index < end && /\s/.test(source[index] ?? "")) index += 1; + + const quote = source[index]; + if (quote !== '"' && quote !== "'") { + throw new XmlParseError(`attribute '${attributeName}' is not quoted`); + } + index += 1; + const valueStart = index; + while (index < end && source[index] !== quote) index += 1; + if (index >= end) { + throw new XmlParseError(`unterminated attribute '${attributeName}'`); + } + if (attributes.has(attributeName)) { + throw new XmlParseError(`duplicate attribute '${attributeName}'`); + } + const rawValue = source.slice(valueStart, index); + if (rawValue.includes("<")) { + throw new XmlParseError(`attribute '${attributeName}' contains '<'`); + } + attributes.set(localName(attributeName), decodeXmlEntities(rawValue)); + index += 1; + } + + return { qualifiedName, attributes, selfClosing }; +} + +/** + * Parse the XML subset used by SQL Server SHOWPLAN without Node built-ins. + * The parser validates tag nesting and attributes and is shared by browser and + * server-side consumers, avoiding a runtime XML dependency in either bundle. + */ +function parseXml(xml: string): XmlElement { + let root: XmlElement | null = null; + let position = 0; + const stack: XmlElement[] = []; + + while (position < xml.length) { + const tagStart = xml.indexOf("<", position); + const textEnd = tagStart === -1 ? xml.length : tagStart; + const text = xml.slice(position, textEnd); + if (stack.length === 0 && text.trim() !== "") { + throw new XmlParseError("text outside the document element"); + } + decodeXmlEntities(text); + if (tagStart === -1) break; + + if (xml.startsWith("", tagStart + 4); + if (end === -1) throw new XmlParseError("unterminated XML comment"); + if (xml.slice(tagStart + 4, end).includes("--")) { + throw new XmlParseError("invalid '--' inside XML comment"); + } + position = end + 3; + continue; + } + if (xml.startsWith("", tagStart + 9); + if (end === -1) throw new XmlParseError("unterminated CDATA section"); + position = end + 3; + continue; + } + if (xml.startsWith("", tagStart + 2); + if (end === -1) throw new XmlParseError("unterminated processing instruction"); + position = end + 2; + continue; + } + if (xml.startsWith(" { + for (const child of element.children) { + if (child.name === "RelOp") operators.push(child); + else visit(child); + } + }; + visit(operator); + return operators; +} + +function attributeNumber(element: XmlElement, name: string): number | null { + const text = element.attributes.get(name); + if (text === undefined || text.trim() === "") return null; + const value = Number(text); + return Number.isFinite(value) ? value : null; +} + +function runtimeMetrics(operator: XmlElement): { + actualRows: number | null; + actualTimeMs: number | null; + actualLoops: number | null; +} { + const runtime = ownedDescendant(operator, "RunTimeInformation"); + if (runtime === null) { + return { actualRows: null, actualTimeMs: null, actualLoops: null }; + } + const counters = runtime.children.filter( + (child) => child.name === "RunTimeCountersPerThread", + ); + if (counters.length === 0) { + return { actualRows: null, actualTimeMs: null, actualLoops: null }; + } + const metric = (counter: XmlElement, name: string): number => + attributeNumber(counter, name) ?? 0; + + return { + actualRows: counters.reduce((sum, counter) => sum + metric(counter, "ActualRows"), 0), + actualTimeMs: Math.max(...counters.map((counter) => metric(counter, "ActualElapsedms"))), + actualLoops: counters.reduce( + (sum, counter) => sum + metric(counter, "ActualExecutions"), + 0, + ), + }; +} + +function parseOperator(operator: XmlElement, fallbackId: number): ExplainNode { + const physical = + operator.attributes.get("PhysicalOp") ?? + operator.attributes.get("LogicalOp") ?? + "Unknown"; + const logical = operator.attributes.get("LogicalOp") ?? physical; + const runtime = runtimeMetrics(operator); + const object = ownedDescendant(operator, "Object"); + const scalar = ownedDescendant(operator, "ScalarOperator"); + const children = childOperators(operator).map((child, index) => + parseOperator(child, fallbackId * 10 + index + 1), + ); + + return { + id: `sqlserver-${operator.attributes.get("NodeId") ?? String(fallbackId)}`, + node_type: physical, + relation: object?.attributes.get("Table")?.replace(/[\[\]]/g, "") ?? null, + startup_cost: null, + total_cost: attributeNumber(operator, "EstimatedTotalSubtreeCost"), + plan_rows: attributeNumber(operator, "EstimateRows"), + actual_rows: runtime.actualRows, + actual_time_ms: runtime.actualTimeMs, + actual_loops: runtime.actualLoops, + buffers_hit: null, + buffers_read: null, + filter: scalar?.attributes.get("ScalarString") ?? null, + index_condition: null, + join_type: logical.toLowerCase().includes("join") ? logical : null, + hash_condition: null, + extra: { logical_operation: logical }, + children, + }; +} + +/** Parse SQL Server SHOWPLAN XML into the shared Tabularis visual-plan model. */ +export function parseShowplanXml(xml: string): ExplainPlan { + let document: XmlElement; + try { + document = parseXml(xml); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse SQL Server SHOWPLAN_XML: ${message}`); + } + + const operator = firstDescendant(document, "RelOp"); + if (operator === null) { + throw new Error("SQL Server SHOWPLAN_XML does not contain a RelOp"); + } + const root = parseOperator(operator, 0); + + return { + root, + planning_time_ms: null, + execution_time_ms: root.actual_time_ms, + original_query: "", + driver: "sqlserver", + has_analyze_data: root.actual_rows !== null, + raw_output: xml, + }; +} diff --git a/explain/tests/fixtures/expected/index-seek-key-lookup.json b/explain/tests/fixtures/expected/index-seek-key-lookup.json new file mode 100644 index 0000000..1df77f2 --- /dev/null +++ b/explain/tests/fixtures/expected/index-seek-key-lookup.json @@ -0,0 +1,72 @@ +{ + "driver": "sqlserver", + "execution_time_ms": null, + "has_analyze_data": false, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Index Seek" + }, + "filter": "(999)", + "hash_condition": null, + "id": "sqlserver-1", + "index_condition": null, + "join_type": null, + "node_type": "Index Seek", + "plan_rows": 1.0, + "relation": "ss034_lookup", + "startup_cost": null, + "total_cost": 0.0032831 + }, + { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Clustered Index Seek" + }, + "filter": "[tabularis_test].[dbo].[ss034_lookup].[id]", + "hash_condition": null, + "id": "sqlserver-3", + "index_condition": null, + "join_type": null, + "node_type": "Clustered Index Seek", + "plan_rows": 1.0, + "relation": "ss034_lookup", + "startup_cost": null, + "total_cost": 0.0032831 + } + ], + "extra": { + "logical_operation": "Inner Join" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": "Inner Join", + "node_type": "Nested Loops", + "plan_rows": 1.0, + "relation": null, + "startup_cost": null, + "total_cost": 0.00657038 + } +} diff --git a/explain/tests/fixtures/expected/missing-index.json b/explain/tests/fixtures/expected/missing-index.json new file mode 100644 index 0000000..87fae6b --- /dev/null +++ b/explain/tests/fixtures/expected/missing-index.json @@ -0,0 +1,51 @@ +{ + "driver": "sqlserver", + "execution_time_ms": null, + "has_analyze_data": false, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": "[tabularis_test].[dbo].[ss034_missing].[lookup_value]=CONVERT_IMPLICIT(int,[@1],0)", + "hash_condition": null, + "id": "sqlserver-1", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 50.0, + "relation": "ss034_missing", + "startup_cost": null, + "total_cost": 19.6333 + } + ], + "extra": { + "logical_operation": "Gather Streams" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Parallelism", + "plan_rows": 50.0, + "relation": null, + "startup_cost": null, + "total_cost": 19.7222 + } +} diff --git a/explain/tests/fixtures/expected/multi-statement.json b/explain/tests/fixtures/expected/multi-statement.json new file mode 100644 index 0000000..3efa8e0 --- /dev/null +++ b/explain/tests/fixtures/expected/multi-statement.json @@ -0,0 +1,29 @@ +{ + "driver": "sqlserver", + "execution_time_ms": null, + "has_analyze_data": false, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": "[tabularis_test].[dbo].[ss034_small].[id]=CONVERT_IMPLICIT(int,[@1],0)", + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 1.0, + "relation": "ss034_small", + "startup_cost": null, + "total_cost": 0.0032853 + } +} diff --git a/explain/tests/fixtures/expected/parallel-hash-join.json b/explain/tests/fixtures/expected/parallel-hash-join.json new file mode 100644 index 0000000..22399f2 --- /dev/null +++ b/explain/tests/fixtures/expected/parallel-hash-join.json @@ -0,0 +1,204 @@ +{ + "driver": "sqlserver", + "execution_time_ms": 36.0, + "has_analyze_data": true, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": 1.0, + "actual_rows": 1.0, + "actual_time_ms": 36.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 1.0, + "actual_time_ms": 0.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 1.0, + "actual_time_ms": 1.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 900000.0, + "actual_time_ms": 1.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 900000.0, + "actual_time_ms": 16.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 300000.0, + "actual_time_ms": 0.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 300000.0, + "actual_time_ms": 9.0, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-6", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 300000.0, + "relation": "ss034_big_a", + "startup_cost": null, + "total_cost": 0.552331 + } + ], + "extra": { + "logical_operation": "Compute Scalar" + }, + "filter": "CONVERT(bigint,[tabularis_test].[dbo].[ss034_big_a].[payload] as [a].[payload],0)", + "hash_condition": null, + "id": "sqlserver-5", + "index_condition": null, + "join_type": null, + "node_type": "Compute Scalar", + "plan_rows": 300000.0, + "relation": null, + "startup_cost": null, + "total_cost": 0.553081 + }, + { + "actual_loops": 4.0, + "actual_rows": 300000.0, + "actual_time_ms": 0.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 300000.0, + "actual_time_ms": 8.0, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-8", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 300000.0, + "relation": "ss034_big_b", + "startup_cost": null, + "total_cost": 0.552331 + } + ], + "extra": { + "logical_operation": "Compute Scalar" + }, + "filter": "CONVERT_IMPLICIT(bigint,[tabularis_test].[dbo].[ss034_big_b].[payload] as [b].[payload],0)", + "hash_condition": null, + "id": "sqlserver-7", + "index_condition": null, + "join_type": null, + "node_type": "Compute Scalar", + "plan_rows": 300000.0, + "relation": null, + "startup_cost": null, + "total_cost": 0.553081 + } + ], + "extra": { + "logical_operation": "Inner Join" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-4", + "index_condition": null, + "join_type": "Inner Join", + "node_type": "Hash Match", + "plan_rows": 900000.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.31514 + } + ], + "extra": { + "logical_operation": "Compute Scalar" + }, + "filter": "[Expr1005]+[Expr1006]", + "hash_condition": null, + "id": "sqlserver-3", + "index_condition": null, + "join_type": null, + "node_type": "Compute Scalar", + "plan_rows": 900000.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.31739 + } + ], + "extra": { + "logical_operation": "Aggregate" + }, + "filter": "COUNT_BIG([Expr1012])", + "hash_condition": null, + "id": "sqlserver-2", + "index_condition": null, + "join_type": null, + "node_type": "Hash Match", + "plan_rows": 1.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.32817 + } + ], + "extra": { + "logical_operation": "Compute Scalar" + }, + "filter": "CASE WHEN [Expr1013]=(0) THEN NULL ELSE [Expr1014] END", + "hash_condition": null, + "id": "sqlserver-1", + "index_condition": null, + "join_type": null, + "node_type": "Compute Scalar", + "plan_rows": 1.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.32817 + } + ], + "extra": { + "logical_operation": "Gather Streams" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Parallelism", + "plan_rows": 1.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.35667 + } +} diff --git a/explain/tests/fixtures/expected/statistics-xml.json b/explain/tests/fixtures/expected/statistics-xml.json new file mode 100644 index 0000000..760a351 --- /dev/null +++ b/explain/tests/fixtures/expected/statistics-xml.json @@ -0,0 +1,29 @@ +{ + "driver": "sqlserver", + "execution_time_ms": 0.0, + "has_analyze_data": true, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": 1.0, + "actual_rows": 2.0, + "actual_time_ms": 0.0, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": "[tabularis_test].[dbo].[ss034_small].[id]>=CONVERT_IMPLICIT(int,[@1],0)", + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 2.0, + "relation": "ss034_small", + "startup_cost": null, + "total_cost": 0.0032853 + } +} diff --git a/explain/tests/fixtures/expected/trivial-scan.json b/explain/tests/fixtures/expected/trivial-scan.json new file mode 100644 index 0000000..5bd42c3 --- /dev/null +++ b/explain/tests/fixtures/expected/trivial-scan.json @@ -0,0 +1,29 @@ +{ + "driver": "sqlserver", + "execution_time_ms": null, + "has_analyze_data": false, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 3.0, + "relation": "ss034_small", + "startup_cost": null, + "total_cost": 0.0032853 + } +} diff --git a/explain/tests/fixtures/index-seek-key-lookup.xml b/explain/tests/fixtures/index-seek-key-lookup.xml new file mode 100644 index 0000000..e0b8118 --- /dev/null +++ b/explain/tests/fixtures/index-seek-key-lookup.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/missing-index.xml b/explain/tests/fixtures/missing-index.xml new file mode 100644 index 0000000..49fd764 --- /dev/null +++ b/explain/tests/fixtures/missing-index.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/multi-statement.xml b/explain/tests/fixtures/multi-statement.xml new file mode 100644 index 0000000..8a38e1f --- /dev/null +++ b/explain/tests/fixtures/multi-statement.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/parallel-hash-join.xml b/explain/tests/fixtures/parallel-hash-join.xml new file mode 100644 index 0000000..d78458a --- /dev/null +++ b/explain/tests/fixtures/parallel-hash-join.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/statistics-xml.xml b/explain/tests/fixtures/statistics-xml.xml new file mode 100644 index 0000000..8a35cd5 --- /dev/null +++ b/explain/tests/fixtures/statistics-xml.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/trivial-scan.xml b/explain/tests/fixtures/trivial-scan.xml new file mode 100644 index 0000000..c85f8ef --- /dev/null +++ b/explain/tests/fixtures/trivial-scan.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/host/index.d.ts b/explain/tests/host/index.d.ts new file mode 100644 index 0000000..047f79e --- /dev/null +++ b/explain/tests/host/index.d.ts @@ -0,0 +1,40 @@ +export interface ExplainNode { + id: string; + node_type: string; + relation: string | null; + startup_cost: number | null; + total_cost: number | null; + plan_rows: number | null; + actual_rows: number | null; + actual_time_ms: number | null; + actual_loops: number | null; + buffers_hit: number | null; + buffers_read: number | null; + filter: string | null; + index_condition: string | null; + join_type: string | null; + hash_condition: string | null; + extra: Record; + children: ExplainNode[]; +} + +export interface ExplainPlan { + root: ExplainNode; + planning_time_ms: number | null; + execution_time_ms: number | null; + original_query: string; + driver: string; + has_analyze_data: boolean; + raw_output: string | null; +} + +export interface RegisteredExplainParser { + readonly engine: string; + readonly format: string; + readonly label?: string; + parse(payload: string): ExplainPlan; + sniff?(payload: string): boolean; +} + +export const registrations: RegisteredExplainParser[]; +export function registerExplainParser(parser: RegisteredExplainParser): void; diff --git a/explain/tests/host/index.js b/explain/tests/host/index.js new file mode 100644 index 0000000..b2b4ce3 --- /dev/null +++ b/explain/tests/host/index.js @@ -0,0 +1,5 @@ +export const registrations = []; + +export function registerExplainParser(parser) { + registrations.push(parser); +} diff --git a/explain/tests/host/package.json b/explain/tests/host/package.json new file mode 100644 index 0000000..7f4b8d9 --- /dev/null +++ b/explain/tests/host/package.json @@ -0,0 +1,11 @@ +{ + "name": "@tabularis/explain", + "version": "0.2.0", + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js" + } + } +} diff --git a/explain/tests/package.test.ts b/explain/tests/package.test.ts new file mode 100644 index 0000000..adc2d31 --- /dev/null +++ b/explain/tests/package.test.ts @@ -0,0 +1,50 @@ +import { readFile } from "node:fs/promises"; + +import { + registrations, + type RegisteredExplainParser, +} from "@tabularis/explain"; +import { describe, expect, it } from "vitest"; + +import { + parseShowplanXml, + sqlServerExplainParser, +} from "../src/index"; + +const fixtureUrl = new URL("./fixtures/trivial-scan.xml", import.meta.url); + +describe("package entry points", () => { + it("registers on ESM import and exports the direct parser API", async () => { + expect(registrations).toEqual([sqlServerExplainParser]); + expect(sqlServerExplainParser).toMatchObject({ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + parse: parseShowplanXml, + }); + expect(sqlServerExplainParser.sniff?.(await readFile(fixtureUrl, "utf8"))).toBe(true); + expect(sqlServerExplainParser.sniff?.("")).toBe(false); + }); + + it("builds an isolated IIFE descriptor without self-registration", async () => { + const source = await readFile( + new URL("../dist/index.iife.js", import.meta.url), + "utf8", + ); + const registrationsBeforeEvaluation = registrations.length; + const evaluate = new Function( + "__TABULARIS_EXPLAIN__", + `${source}\nreturn typeof __tabularis_explain_parser__ !== "undefined" ? __tabularis_explain_parser__ : null;`, + ); + const raw = evaluate({}) as Record; + const descriptor = (raw.default ?? raw) as RegisteredExplainParser; + + expect(descriptor).toMatchObject({ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + }); + expect(descriptor.parse(await readFile(fixtureUrl, "utf8")).driver).toBe("sqlserver"); + expect(registrations).toHaveLength(registrationsBeforeEvaluation); + }); +}); diff --git a/explain/tests/showplan.test.ts b/explain/tests/showplan.test.ts new file mode 100644 index 0000000..ceabc5a --- /dev/null +++ b/explain/tests/showplan.test.ts @@ -0,0 +1,89 @@ +import { readFile } from "node:fs/promises"; + +import type { ExplainNode, ExplainPlan } from "@tabularis/explain"; +import { describe, expect, it } from "vitest"; + +import { parseShowplanXml } from "../src/showplan"; + +const fixtureNames = [ + "trivial-scan", + "index-seek-key-lookup", + "parallel-hash-join", + "statistics-xml", + "missing-index", + "multi-statement", +] as const; +const fixtureDirectory = new URL("./fixtures/", import.meta.url); + +async function readFixture(name: string): Promise { + return readFile(new URL(`${name}.xml`, fixtureDirectory), "utf8"); +} + +function flatten(node: ExplainNode): ExplainNode[] { + return [node, ...node.children.flatMap(flatten)]; +} + +describe("parseShowplanXml", () => { + it.each(fixtureNames)("matches the Rust parser golden for %s", async (name) => { + const xml = await readFixture(name); + const expected = JSON.parse( + await readFile(new URL(`expected/${name}.json`, fixtureDirectory), "utf8"), + ) as ExplainPlan; + + expect(parseShowplanXml(xml)).toEqual(expected); + }); + + it("sums rows and executions and takes maximum elapsed time across threads", async () => { + const plan = parseShowplanXml(await readFixture("parallel-hash-join")); + const hashJoin = flatten(plan.root).find((node) => node.id === "sqlserver-4"); + + expect(hashJoin).toMatchObject({ + node_type: "Hash Match", + join_type: "Inner Join", + actual_rows: 900_000, + actual_loops: 4, + actual_time_ms: 16, + }); + expect(plan.execution_time_ms).toBe(36); + expect(plan.has_analyze_data).toBe(true); + }); + + it("uses only the first statement's first operator", async () => { + const plan = parseShowplanXml(await readFixture("multi-statement")); + + expect(plan.root).toMatchObject({ + id: "sqlserver-0", + node_type: "Table Scan", + relation: "ss034_small", + }); + expect(plan.root.children).toEqual([]); + }); + + it("keeps missing-index documents parseable without synthesizing model fields", async () => { + const xml = await readFixture("missing-index"); + const plan = parseShowplanXml(xml); + + expect(xml).toContain(""); + expect(plan.raw_output).toBe(xml); + expect(plan.root.extra).toEqual({ logical_operation: "Gather Streams" }); + }); + + it("is namespace-insensitive and assigns deterministic fallback ids", () => { + const xml = + ''; + const plan = parseShowplanXml(xml); + + expect(plan.root.id).toBe("sqlserver-0"); + expect(plan.root.children[0]?.id).toBe("sqlserver-1"); + expect(plan.root.children[0]?.node_type).toBe("Table Scan"); + }); + + it("retains the Rust parser's error prefixes", () => { + expect(() => parseShowplanXml("")).toThrowError( + /^Failed to parse SQL Server SHOWPLAN_XML:/, + ); + expect(() => parseShowplanXml("")).toThrowError( + "SQL Server SHOWPLAN_XML does not contain a RelOp", + ); + }); +}); diff --git a/explain/tsconfig.json b/explain/tsconfig.json new file mode 100644 index 0000000..e20274f --- /dev/null +++ b/explain/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "noEmit": true, + "types": ["vitest/globals"] + }, + "include": ["src/**/*", "tests/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/explain/tsup.config.ts b/explain/tsup.config.ts new file mode 100644 index 0000000..39d8d46 --- /dev/null +++ b/explain/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "tsup"; + +const common = { + platform: "browser" as const, + target: "es2022" as const, + sourcemap: false, + minify: false, + splitting: false, + treeshake: true, + external: ["@tabularis/explain"], +}; + +export default defineConfig([ + { + ...common, + entry: { index: "src/index.ts" }, + format: ["esm"], + dts: true, + clean: true, + }, + { + ...common, + entry: { "index.iife": "src/iife.ts" }, + format: ["iife"], + globalName: "__tabularis_explain_parser__", + dts: false, + clean: false, + outExtension: () => ({ js: ".js" }), + }, +]); diff --git a/justfile b/justfile index 97779de..b6fb6dc 100644 --- a/justfile +++ b/justfile @@ -25,17 +25,27 @@ seed-sqlserver: END" # --------------------------------------------------------------------------- -# Cross-platform recipes (only shell-agnostic tooling — cargo, npm). +# Cross-platform recipes (only shell-agnostic tooling — cargo, npm, pnpm). # --------------------------------------------------------------------------- -# Build the plugin binary in debug mode (plus UI if present). -build: build-ui +# Build the plugin binary and its optional JavaScript artifacts. +build: build-ui build-explain cargo build # Build for release (what the GitHub Actions workflow ships). -release: build-ui +release: build-ui build-explain cargo build --release +# Build and test the browser-safe SQL Server SHOWPLAN parser package. +build-explain: + pnpm --dir explain install --frozen-lockfile + pnpm --dir explain build + +test-explain: + pnpm --dir explain install --frozen-lockfile + pnpm --dir explain typecheck + pnpm --dir explain test + # Run unit tests only. This crate is binary-only, so --lib would fail; --bins # also keeps tests/live_db.rs out of the default run. test: @@ -99,9 +109,9 @@ dev-install: build mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/ui/dist"; \ cp ui/dist/index.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/ui/dist/"; \ fi - @if [ -f explain/dist/index.js ]; then \ + @if [ -f explain/dist/index.iife.js ]; then \ mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist"; \ - cp explain/dist/index.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist/"; \ + cp explain/dist/index.iife.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist/"; \ fi @echo "Installed to ${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" @echo "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." @@ -115,9 +125,9 @@ dev-install: build mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver/ui/dist"; \ cp ui/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/ui/dist/"; \ fi - @if [ -f explain/dist/index.js ]; then \ + @if [ -f explain/dist/index.iife.js ]; then \ mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist"; \ - cp explain/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist/"; \ + cp explain/dist/index.iife.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist/"; \ fi @echo "Installed to ~/Library/Application Support/tabularis/plugins/sqlserver" @echo "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." @@ -133,9 +143,9 @@ dev-install: build New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null; \ Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist"); \ }; \ - if (Test-Path "explain\dist\index.js") { \ + if (Test-Path "explain\dist\index.iife.js") { \ New-Item -ItemType Directory -Force -Path (Join-Path $dest "explain\dist") | Out-Null; \ - Copy-Item "explain\dist\index.js" (Join-Path $dest "explain\dist"); \ + Copy-Item "explain\dist\index.iife.js" (Join-Path $dest "explain\dist"); \ }; \ Write-Host "Installed to $dest"; \ Write-Host "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." diff --git a/src/driver/showplan.rs b/src/driver/showplan.rs index 10736b2..40eb19e 100644 --- a/src/driver/showplan.rs +++ b/src/driver/showplan.rs @@ -237,4 +237,29 @@ mod tests { let err = parse_showplan_xml("", "").unwrap_err(); assert!(err.contains("RelOp")); } + + /// Regenerate the TypeScript port's committed golden files with the Rust + /// parser that they must match until SS-035 removes this implementation. + #[test] + #[ignore = "writes committed explain fixture expectations"] + fn write_showplan_fixture_goldens() { + let fixture_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("explain/tests/fixtures"); + let expected_dir = fixture_dir.join("expected"); + std::fs::create_dir_all(&expected_dir).unwrap(); + + for entry in std::fs::read_dir(&fixture_dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("xml") { + continue; + } + + let xml = std::fs::read_to_string(&path).unwrap(); + let plan = parse_showplan_xml(&xml, "").unwrap(); + let mut json = serde_json::to_string_pretty(&plan).unwrap(); + json.push('\n'); + let name = path.file_stem().unwrap(); + std::fs::write(expected_dir.join(name).with_extension("json"), json).unwrap(); + } + } } From e56e5e21d942e97e2d9f1c50658fd968e5e2cda7 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 21:45:19 +0000 Subject: [PATCH 14/23] feat(explain): return raw SHOWPLAN XML --- .tabularium | 2 +- CHANGELOG.md | 7 + CLAUDE.md | 6 +- Cargo.lock | 7 - Cargo.toml | 1 - README.md | 4 +- docs/registry-entry.json | 3 +- explain/tests/showplan.test.ts | 4 +- src/driver/mod.rs | 1 - src/driver/ops.rs | 15 +- src/driver/showplan.rs | 265 --------------------------------- tests/live_db.rs | 23 ++- 12 files changed, 42 insertions(+), 296 deletions(-) delete mode 100644 src/driver/showplan.rs diff --git a/.tabularium b/.tabularium index 74c1ef6..f53801a 100644 --- a/.tabularium +++ b/.tabularium @@ -53,7 +53,7 @@ "readme": "README.md", "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin", "documentation_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin#readme", - "min_runtime_version": "0.20.0", + "min_runtime_version": "0.23.0", "support": { "issues_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b92d5..60e2212 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] +### BREAKING + +- Visual EXPLAIN now returns raw `sqlserver-showplan-xml` for the plugin-owned + TypeScript parser instead of parsing SHOWPLAN in the Rust process. This + requires Tabularis 0.23.0 or later, the first runtime with raw plugin + EXPLAIN output and plugin parser-bundle loading. + ## [1.0.0-beta.1] - 2026-08-30 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index a66c346..8f23b80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,8 @@ src/ pool.rs # Microsoft mssql-tds client via bridge + deadpool Manager (TLS modes, startup scripts) introspection.rs, helpers.rs, ddl/, routines/, triggers/, types.rs, version.rs extract/ # row → JSON value extraction (incl. temporal types) - explain.rs # SHOWPLAN_XML / STATISTICS XML capture - showplan.rs # SHOWPLAN XML → visual-plan JSON (plugins return parsed plans) + explain.rs # raw SHOWPLAN_XML / STATISTICS XML capture + explain/ # TypeScript SHOWPLAN parser, plugin IIFE and npm package ``` Key invariants: @@ -51,4 +51,4 @@ Key invariants: - JSON emitted by handlers must deserialize into the host's model structs — `models.rs` mirrors the host's serde shapes; don't change field names or nullability casually. - `.tabularium` `data_types` mirrors `driver/types.rs::get_data_types()`; keep them in sync. - `update_record`/`delete_record` receive a `pk_map` (composite PKs supported); ordering is normalized by sorting column names. -- Pending `SS-035`, this plugin still returns an in-process parsed SHOWPLAN. After `SS-035`, it returns raw `sqlserver-showplan-xml`; the host wraps it as raw EXPLAIN output and the plugin-owned TypeScript parser registered from `explain/dist/index.iife.js` produces the visual plan. Keep the raw shape, manifest declaration, parser bundle and runtime version floor aligned with `docs/explain-architecture.md`. +- `explain_query` returns raw `sqlserver-showplan-xml`; compatible hosts recognize that shape as raw EXPLAIN output and dispatch its payload to the plugin-owned TypeScript parser registered from `explain/dist/index.iife.js`. Keep the raw shape, manifest declaration, parser bundle and runtime version floor aligned with `docs/explain-architecture.md`. diff --git a/Cargo.lock b/Cargo.lock index d5a23cb..73a13f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,12 +1078,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - [[package]] name = "rust_decimal" version = "1.42.1" @@ -1281,7 +1275,6 @@ dependencies = [ "mssql-tds-preview", "mssql-tiberius-bridge", "once_cell", - "roxmltree", "rust_decimal", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0fe784f..3d52e11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,6 @@ mssql-tiberius-bridge = "=0.1.0-preview.3" # authentication is disabled because this plugin supports SQL auth only. mssql-tds-preview = { version = "=0.1.0-preview.1", default-features = false } once_cell = "1" -roxmltree = "0.20" rust_decimal = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 3e79272..55d3cde 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ A [Microsoft SQL Server](https://www.microsoft.com/sql-server) plugin for [Tabul This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, BLOB handling, database-user management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. -> **Requires Tabularis v0.20.0 or later.** This plugin relies on the plugin -> runtime introduced in that release and will not load on earlier versions. +> **Requires Tabularis v0.23.0 or later.** This plugin relies on raw plugin +> EXPLAIN output and plugin-provided parser bundle loading from that release. **Discord** — [Join our Discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. diff --git a/docs/registry-entry.json b/docs/registry-entry.json index ede16fb..e273fb4 100644 --- a/docs/registry-entry.json +++ b/docs/registry-entry.json @@ -1,5 +1,4 @@ { - "$comment": "TODO: Before opening the registry PR, confirm that Tabularis 0.22.0 includes SS-032 and SS-033, then remove this field.", "id": "sqlserver", "name": "Microsoft SQL Server", "description": "Full-featured Microsoft SQL Server driver for Tabularis with schema browsing, query execution, visual plans, type-aware row editing, DDL, routines, triggers, BLOBs, and database-user management.", @@ -9,7 +8,7 @@ "releases": [ { "version": "1.0.0-beta.1", - "min_tabularis_version": "0.22.0", + "min_tabularis_version": "0.23.0", "assets": { "linux-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-linux-x64.zip", "linux-arm64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-linux-arm64.zip", diff --git a/explain/tests/showplan.test.ts b/explain/tests/showplan.test.ts index ceabc5a..ecc64ef 100644 --- a/explain/tests/showplan.test.ts +++ b/explain/tests/showplan.test.ts @@ -24,7 +24,7 @@ function flatten(node: ExplainNode): ExplainNode[] { } describe("parseShowplanXml", () => { - it.each(fixtureNames)("matches the Rust parser golden for %s", async (name) => { + it.each(fixtureNames)("matches the committed SHOWPLAN golden for %s", async (name) => { const xml = await readFixture(name); const expected = JSON.parse( await readFile(new URL(`expected/${name}.json`, fixtureDirectory), "utf8"), @@ -78,7 +78,7 @@ describe("parseShowplanXml", () => { expect(plan.root.children[0]?.node_type).toBe("Table Scan"); }); - it("retains the Rust parser's error prefixes", () => { + it("retains the established SHOWPLAN error prefixes", () => { expect(() => parseShowplanXml("")).toThrowError( /^Failed to parse SQL Server SHOWPLAN_XML:/, ); diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 534af21..e0283b5 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -13,7 +13,6 @@ pub mod introspection; pub mod ops; pub mod pool; pub mod routines; -pub mod showplan; pub mod triggers; pub mod types; pub mod users; diff --git a/src/driver/ops.rs b/src/driver/ops.rs index 34fc071..dcafc70 100644 --- a/src/driver/ops.rs +++ b/src/driver/ops.rs @@ -377,11 +377,9 @@ pub async fn execute_batch( Ok(results) } -/// Run SHOWPLAN_XML / STATISTICS XML and parse the document into the visual -/// plan model the frontend renders. Unlike the built-in driver — whose raw -/// XML is parsed by `@tabularis/explain` in the frontend — a plugin's -/// `explain_query` result passes through to the frontend untouched, so the -/// parsing happens here. +/// Capture SHOWPLAN_XML / STATISTICS XML and return the raw plugin EXPLAIN +/// shape. Compatible hosts dispatch the tagged payload to the parser declared +/// in `.tabularium` instead of interpreting it in this Rust process. pub async fn explain_query( params: &ConnectionParams, query: &str, @@ -389,7 +387,12 @@ pub async fn explain_query( ) -> Result { let mut conn = acquire(params).await?; let payload = explain::explain_showplan_xml(&mut conn, query, analyze).await?; - crate::driver::showplan::parse_showplan_xml(&payload, query) + Ok(serde_json::json!({ + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "payload": payload, + "original_query": query, + })) } // --- CRUD ---------------------------------------------------------------- diff --git a/src/driver/showplan.rs b/src/driver/showplan.rs deleted file mode 100644 index 40eb19e..0000000 --- a/src/driver/showplan.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! SHOWPLAN_XML → visual-plan JSON. -//! -//! Parses SQL Server's SHOWPLAN XML document into the `ExplainPlan` shape the -//! Tabularis frontend renders (`@tabularis/explain`'s plan model). The host -//! passes a plugin's `explain_query` result through to the frontend -//! untouched, so the plan must arrive already parsed. - -use roxmltree::{Document, Node}; -use serde_json::{json, Value}; - -/// Parse a SHOWPLAN XML document into the shared visual-plan model. -pub fn parse_showplan_xml(raw: &str, original_query: &str) -> Result { - let document = Document::parse(raw) - .map_err(|error| format!("Failed to parse SQL Server SHOWPLAN_XML: {error}"))?; - let operator = document - .descendants() - .find(|node| node.is_element() && node.tag_name().name() == "RelOp") - .ok_or_else(|| "SQL Server SHOWPLAN_XML does not contain a RelOp".to_string())?; - - let root = parse_operator(operator, 0); - let actual_time_ms = root.get("actual_time_ms").cloned().unwrap_or(Value::Null); - let has_analyze_data = !root.get("actual_rows").map(Value::is_null).unwrap_or(true); - Ok(json!({ - "root": root, - "planning_time_ms": Value::Null, - "execution_time_ms": actual_time_ms, - "original_query": original_query, - "driver": "sqlserver", - "has_analyze_data": has_analyze_data, - "raw_output": raw, - })) -} - -fn attr<'a>(node: Node<'a, '_>, name: &str) -> Option<&'a str> { - node.attributes() - .find(|a| a.name() == name) - .map(|a| a.value()) -} - -fn attr_number(node: Node, name: &str) -> Value { - attr(node, name) - .and_then(|text| text.parse::().ok()) - .filter(|value| value.is_finite()) - .and_then(serde_json::Number::from_f64) - .map(Value::Number) - .unwrap_or(Value::Null) -} - -/// Descendants of `operator` that do not cross into a nested `RelOp` -/// subtree, i.e. the parts of the plan node that belong to this operator -/// rather than to one of its children. -fn owned_descendant<'a>(operator: Node<'a, 'a>, name: &str) -> Option> { - fn visit<'a>(node: Node<'a, 'a>, name: &str) -> Option> { - for child in node.children().filter(Node::is_element) { - if child.tag_name().name() == "RelOp" { - continue; - } - if child.tag_name().name() == name { - return Some(child); - } - if let Some(found) = visit(child, name) { - return Some(found); - } - } - None - } - visit(operator, name) -} - -/// Direct child operators: descendant `RelOp` elements whose path from this -/// operator contains no other `RelOp`. -fn child_operators<'a>(operator: Node<'a, 'a>) -> Vec> { - fn visit<'a>(node: Node<'a, 'a>, out: &mut Vec>) { - for child in node.children().filter(Node::is_element) { - if child.tag_name().name() == "RelOp" { - out.push(child); - } else { - visit(child, out); - } - } - } - let mut out = Vec::new(); - visit(operator, &mut out); - out -} - -fn relation_name(operator: Node) -> Value { - match owned_descendant(operator, "Object").and_then(|target| attr(target, "Table")) { - Some(table) => Value::String(table.replace(['[', ']'], "")), - None => Value::Null, - } -} - -fn predicate(operator: Node) -> Value { - match owned_descendant(operator, "ScalarOperator") - .and_then(|scalar| attr(scalar, "ScalarString")) - { - Some(text) => Value::String(text.to_string()), - None => Value::Null, - } -} - -/// Actual rows / elapsed time / executions summed and maxed across the -/// per-thread runtime counters, when the plan carries analyze data. -fn runtime_metrics(operator: Node) -> (Value, Value, Value) { - let Some(runtime) = owned_descendant(operator, "RunTimeInformation") else { - return (Value::Null, Value::Null, Value::Null); - }; - let counters: Vec = runtime - .children() - .filter(|node| node.is_element() && node.tag_name().name() == "RunTimeCountersPerThread") - .collect(); - if counters.is_empty() { - return (Value::Null, Value::Null, Value::Null); - } - let number = |node: Node, name: &str| -> f64 { - attr(node, name) - .and_then(|text| text.parse::().ok()) - .filter(|value| value.is_finite()) - .unwrap_or(0.0) - }; - let rows: f64 = counters.iter().map(|c| number(*c, "ActualRows")).sum(); - let time = counters - .iter() - .map(|c| number(*c, "ActualElapsedms")) - .fold(f64::NEG_INFINITY, f64::max); - let loops: f64 = counters - .iter() - .map(|c| number(*c, "ActualExecutions")) - .sum(); - let to_value = |value: f64| { - serde_json::Number::from_f64(value) - .map(Value::Number) - .unwrap_or(Value::Null) - }; - (to_value(rows), to_value(time), to_value(loops)) -} - -fn parse_operator(operator: Node, fallback_id: u64) -> Value { - let physical = attr(operator, "PhysicalOp") - .or_else(|| attr(operator, "LogicalOp")) - .unwrap_or("Unknown") - .to_string(); - let logical = attr(operator, "LogicalOp") - .unwrap_or(physical.as_str()) - .to_string(); - let (actual_rows, actual_time_ms, actual_loops) = runtime_metrics(operator); - let id = attr(operator, "NodeId") - .map(str::to_string) - .unwrap_or_else(|| fallback_id.to_string()); - let children: Vec = child_operators(operator) - .into_iter() - .enumerate() - .map(|(index, child)| parse_operator(child, fallback_id * 10 + index as u64 + 1)) - .collect(); - let join_type = if logical.to_lowercase().contains("join") { - Value::String(logical.clone()) - } else { - Value::Null - }; - - json!({ - "id": format!("sqlserver-{id}"), - "node_type": physical, - "relation": relation_name(operator), - "startup_cost": Value::Null, - "total_cost": attr_number(operator, "EstimatedTotalSubtreeCost"), - "plan_rows": attr_number(operator, "EstimateRows"), - "actual_rows": actual_rows, - "actual_time_ms": actual_time_ms, - "actual_loops": actual_loops, - "buffers_hit": Value::Null, - "buffers_read": Value::Null, - "filter": predicate(operator), - "index_condition": Value::Null, - "join_type": join_type, - "hash_condition": Value::Null, - "extra": { "logical_operation": logical }, - "children": children, - }) -} - -#[cfg(test)] -mod tests { - use super::parse_showplan_xml; - - const SAMPLE: &str = r#" - - - - - - - - - - - - - - - - - - - - - -"#; - - #[test] - fn parses_root_operator_and_children() { - let plan = parse_showplan_xml(SAMPLE, "SELECT * FROM t").unwrap(); - assert_eq!(plan["driver"], "sqlserver"); - assert_eq!(plan["original_query"], "SELECT * FROM t"); - let root = &plan["root"]; - assert_eq!(root["id"], "sqlserver-0"); - assert_eq!(root["node_type"], "Nested Loops"); - assert_eq!(root["join_type"], "Inner Join"); - assert_eq!(root["children"].as_array().unwrap().len(), 1); - } - - #[test] - fn extracts_relation_predicate_and_runtime_metrics() { - let plan = parse_showplan_xml(SAMPLE, "").unwrap(); - let child = &plan["root"]["children"][0]; - assert_eq!(child["relation"], "t"); - assert_eq!(child["filter"], "[db].[dbo].[t].[id]>(5)"); - assert_eq!(child["actual_rows"], 9.0); - assert_eq!(child["actual_time_ms"], 5.0); - assert_eq!(child["actual_loops"], 2.0); - assert_eq!(plan["has_analyze_data"], false); // root has no runtime info - } - - #[test] - fn rejects_documents_without_relop() { - let err = parse_showplan_xml("", "").unwrap_err(); - assert!(err.contains("RelOp")); - } - - /// Regenerate the TypeScript port's committed golden files with the Rust - /// parser that they must match until SS-035 removes this implementation. - #[test] - #[ignore = "writes committed explain fixture expectations"] - fn write_showplan_fixture_goldens() { - let fixture_dir = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("explain/tests/fixtures"); - let expected_dir = fixture_dir.join("expected"); - std::fs::create_dir_all(&expected_dir).unwrap(); - - for entry in std::fs::read_dir(&fixture_dir).unwrap() { - let path = entry.unwrap().path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("xml") { - continue; - } - - let xml = std::fs::read_to_string(&path).unwrap(); - let plan = parse_showplan_xml(&xml, "").unwrap(); - let mut json = serde_json::to_string_pretty(&plan).unwrap(); - json.push('\n'); - let name = path.file_stem().unwrap(); - std::fs::write(expected_dir.join(name).with_extension("json"), json).unwrap(); - } - } -} diff --git a/tests/live_db.rs b/tests/live_db.rs index 6421fe1..727f117 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -648,7 +648,7 @@ fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { } #[test] -fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { +fn explain_query_returns_raw_showplan_xml_for_estimate_and_analyze() { let mut plugin = Plugin::with_scratch_database(); plugin.reset_table("explain", "id INT PRIMARY KEY, value INT NOT NULL"); plugin.execute(format!( @@ -657,7 +657,7 @@ fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { let query = format!("SELECT value FROM [{TEST_SCHEMA}].[explain] WHERE id = 1"); for analyze in [false, true] { - let plan = plugin.call_ok( + let raw = plugin.call_ok( "explain_query", json!({ "params": connection_params(), @@ -665,11 +665,22 @@ fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { "analyze": analyze }), ); - let raw = plan["raw_output"] + let object = raw + .as_object() + .expect("raw EXPLAIN result must be an object"); + assert_eq!(object.len(), 4, "raw EXPLAIN shape changed: {raw}"); + assert_eq!(raw["engine"], "sqlserver"); + assert_eq!(raw["format"], "sqlserver-showplan-xml"); + assert_eq!(raw["original_query"], query); + + let payload = raw["payload"] .as_str() - .expect("parsed plan must retain its raw SHOWPLAN XML"); - assert!(raw.contains("ShowPlanXML"), "analyze={analyze}: {raw}"); - assert_eq!(plan["driver"], "sqlserver"); + .expect("raw EXPLAIN payload must be a SHOWPLAN XML string"); + assert!( + payload.trim_start().starts_with(""), + "analyze={analyze}: {payload}" + ); } } From 2fa0949ca3d8d785a43e8695fb0a99d0b39ef23a Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 22:06:39 +0000 Subject: [PATCH 15/23] fix(sql): harden identifier construction --- docs/sql-audit.md | 184 +++++++++++++++++++++++ src/driver/helpers.rs | 83 +++++----- src/driver/helpers/tests.rs | 26 ++-- src/driver/mod.rs | 3 + src/driver/ops.rs | 77 ++++++---- src/driver/sql_audit_tests.rs | 276 ++++++++++++++++++++++++++++++++++ src/driver/users.rs | 97 ++++++++---- tests/live_db.rs | 70 +++++++++ 8 files changed, 698 insertions(+), 118 deletions(-) create mode 100644 docs/sql-audit.md create mode 100644 src/driver/sql_audit_tests.rs diff --git a/docs/sql-audit.md b/docs/sql-audit.md new file mode 100644 index 0000000..9899622 --- /dev/null +++ b/docs/sql-audit.md @@ -0,0 +1,184 @@ +# SQL construction and identifier audit + +This audit covers `src/driver/` at `SS-040`. It classifies every production +`format!` call that emits SQL or an SQL fragment. The audit found one unsafe +API boundary: `build_insert_sql` accepted an already-rendered table target. +It now accepts `schema` and `table` separately and applies `qualify` itself. +The directly executed view, index, foreign-key, user, and login statements now +also have pure builders so their quoting can be regression-tested without a +database connection. + +## Classification rules + +| Code | Class | Required handling | +| --- | --- | --- | +| I | Identifier | Always pass the original identifier through `bracket_quote` or `qualify`. Identifiers cannot be bound as TDS values. | +| L | Literal value | Bind with `@Pn` whenever the RPC and SQL grammar allow it. Otherwise use a dedicated escaping helper. | +| K | Fixed syntax | Hard-coded text, numeric values, generated parameter markers, or a keyword selected from a closed allowlist. | +| S | SQL source | An explicit SQL expression, definition, or query supplied through an SQL-editing API. It is not reclassified as an identifier or literal. | + +`S` is intentionally separate from scalar values. Escaping an SQL definition +as a literal would change the operation rather than make it safer. The raw SQL +boundaries are listed below and are never used for identifier-shaped fields. + +## `format!` call-site inventory + +Line anchors identify the audited call, not an API stability promise. +Non-SQL formatting (errors, version labels, temporal/JSON rendering, BLOB wire +encoding, and tests) is excluded. + +### `helpers.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `helpers.rs:62` | Multipart object reference | schema I via `bracket_quote`; object I via `bracket_quote` | Safe | +| `helpers.rs:74` | Affected-row `SELECT` | expression K from two private constants; result alias I/K constant | Safe | +| `helpers.rs:80` | DML plus row-count sentinel | sql S from query execution; sentinel K | Intentional SQL boundary | +| `helpers.rs:114` | `@Pn` marker | ordinal K integer | Safe | +| `helpers.rs:117` | `INSERT` | target I from `qualify`; columns I from `bracket_quote`; markers K | Safe after SS-040 API fix | +| `helpers.rs:133` | Identity-insert batch | target I from `qualify`; insert and row-count select K built internally | Safe | +| `helpers.rs:149` | Plain insert plus row-count select | insert and select K built internally | Safe | +| `helpers.rs:231` | Primary-key predicate | column I via `bracket_quote`; ordinal K integer | Safe; values are bound | +| `helpers.rs:245` | Composite-key `DELETE` | table I via `qualify`; predicate internally built | Safe; values are bound | +| `helpers.rs:263` | Composite-key `UPDATE` | table I via `qualify`; column I via `bracket_quote`; predicate internally built | Safe; values are bound | +| `helpers.rs:274` | Column definition head | column I via `bracket_quote`; data type S from the DDL editor/model | Intentional reviewed DDL source | +| `helpers.rs:284` | Column default | default S from the DDL editor/model | Intentional reviewed DDL source | +| `helpers.rs:346` | Paginated query | query S; optional order clause K; offset/fetch K integers | Intentional query boundary | + +### `ddl/mod.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `ddl/mod.rs:31` | Multipart name passed to `sp_rename` | schema, table, old column I via `bracket_quote` | Safe intermediate | +| `ddl/mod.rs:37` | `sp_rename` invocation | old multipart name L and new name L via `escape_single_quoted`; object kind K | Safe; returned script has no parameter channel | +| `ddl/mod.rs:45` | `ALTER COLUMN` | table I via `qualify`; column I via `bracket_quote`; type S; nullability K | Intentional reviewed DDL source | +| `ddl/mod.rs:60` | Add default constraint | table I; generated constraint I; default S; column I | Intentional reviewed DDL source | +| `ddl/mod.rs:72` | Generated default-constraint name | prefix K; table and column I inputs | Safe intermediate; quoted at use | +| `ddl/mod.rs:80` | Truncated constraint name | head I input; hash K hexadecimal | Safe intermediate; quoted at use | +| `ddl/mod.rs:84` | Find and drop default constraint | object name L and column name L escaped; table I; discovered constraint I via server `QUOTENAME` | Safe; returned script has no parameter channel | +| `ddl/mod.rs:94` | Object name for `OBJECT_ID` | schema and table I via `bracket_quote`, then L via `escape_single_quoted` | Safe nested literal | +| `ddl/mod.rs:130` | Add foreign key | table, constraint, columns, referenced table I via quoting helpers | Safe | +| `ddl/mod.rs:139` | `ON DELETE` action | action K from four-value allowlist | Safe | +| `ddl/mod.rs:142` | `ON UPDATE` action | action K from four-value allowlist | Safe | + +### `blob.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `blob.rs:56` | Size-limited BLOB lookup | column I via `bracket_quote`; table I via `qualify`; predicate internally built | Safe; size and keys are bound | +| `blob.rs:65` | Full BLOB lookup | column I via `bracket_quote`; table I via `qualify`; predicate internally built | Safe; keys are bound | + +### `ops.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `ops.rs:116` | `CREATE VIEW` | view I via `qualify`; definition S from view editor | Intentional SQL-definition boundary | +| `ops.rs:124` | `ALTER VIEW` | view I via `qualify`; definition S from view editor | Intentional SQL-definition boundary | +| `ops.rs:128` | `DROP VIEW` | view I via `qualify` | Safe; executed directly | +| `ops.rs:568` | Table primary-key clause | columns I, each already bracket-quoted | Safe | +| `ops.rs:572` | `CREATE TABLE` | table I via `qualify`; definitions internally built | Safe identifiers; reviewed type/default source | +| `ops.rs:584` | `ADD COLUMN` | table I via `qualify`; definition internally built | Safe identifiers; reviewed type/default source | +| `ops.rs:616` | `CREATE INDEX` | uniqueness K boolean; index, table, columns I via quoting helpers | Safe; returned for review | +| `ops.rs:640` | `DROP INDEX` | index I via `bracket_quote`; table I via `qualify` | Safe; executed directly | +| `ops.rs:666` | Drop foreign-key constraint | table I via `qualify`; constraint I via `bracket_quote` | Safe; executed directly | + +### `routines/mod.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `routines/mod.rs:8` | Routine argument literal | value L via `escape_single_quoted` | Safe; builder returns SQL and cannot return bindings | +| `routines/mod.rs:28` | Table-valued function call | target I via `qualify`; values L or explicit S from arguments | Safe within documented raw boundary | +| `routines/mod.rs:30` | Scalar function call | target I via `qualify`; values L or explicit S from arguments | Safe within documented raw boundary | +| `routines/mod.rs:46` | Named argument marker | validated ASCII parameter name K | Safe | +| `routines/mod.rs:58` | Output variable | index K integer | Safe | +| `routines/mod.rs:59` | Output declaration | variable K; type S from server metadata; initial value L or explicit S | Safe within metadata/raw boundary | +| `routines/mod.rs:63` | Output assignment | binding and variable K built internally | Safe | +| `routines/mod.rs:64` | Output projection | variable K; alias I via `bracket_quote` | Safe | +| `routines/mod.rs:66` | Input assignment | binding K; value L or explicit S | Safe within documented raw boundary | +| `routines/mod.rs:71` | Procedure call without arguments | target I via `qualify` | Safe | +| `routines/mod.rs:73` | Procedure call with arguments | target I; assignments internally built | Safe within documented raw boundary | +| `routines/mod.rs:76` | Output `SELECT` | projections internally built | Safe | +| `routines/mod.rs:84` | Function template | schema I via `bracket_quote`; remainder K | Safe | +| `routines/mod.rs:88` | Procedure template | schema I via `bracket_quote`; remainder K | Safe | +| `routines/mod.rs:104` | Convert create definition to alter | definition S accepted only with the `CREATE` prefix | Intentional routine-editor boundary | +| `routines/mod.rs:115` | Drop routine | object kind K from function/procedure branch; routine I via `qualify` | Safe; executed directly | + +`RoutineCallArg.is_raw` is the only argument-value bypass. `false` renders a +Unicode string literal with embedded quotes doubled; `None` renders fixed +`NULL`. `true` preserves the value as an SQL expression so callers can enter +values such as `SYSDATETIME()` or `DEFAULT`. This method returns editable SQL +to the host and does not execute it. The hostile-value test proves that the +bypass occurs only when the wire flag is explicitly true. + +### `triggers/mod.rs` and `explain.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `triggers/mod.rs:50` | `DROP TRIGGER` | trigger I via `qualify` | Safe; executed directly | +| `explain.rs:21` | Enable plan capture | option K from `SHOWPLAN_XML` or `STATISTICS XML` branch | Safe | +| `explain.rs:32` | Disable plan capture | option K from the same closed branch | Safe | + +`create_trigger` accepts a complete trigger definition from the trigger SQL +editor and sends it unchanged. It has no `format!` site and is an intentional +SQL-definition boundary. + +### `users.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `users.rs:211` | Database permission target | database I via `bracket_quote` | Safe | +| `users.rs:212` | Schema permission target | schema I via `bracket_quote` | Safe | +| `users.rs:213` | Object permission target | schema and object I via `bracket_quote` | Safe | +| `users.rs:282` | Login password literal | password L with apostrophes doubled | Safe grammar-required literal | +| `users.rs:286` | `CREATE LOGIN` | login I via `bracket_quote`; password escaped L; options K | Safe | +| `users.rs:294` | `CREATE USER` | user and login I via `bracket_quote` | Safe | +| `users.rs:302` | `DROP USER` | user I via `bracket_quote` | Safe; executed directly | +| `users.rs:306` | `DROP LOGIN` | login I via `bracket_quote` | Safe; executed directly | +| `users.rs:310` | `ALTER LOGIN` password | login I; password escaped L | Safe | +| `users.rs:572` | Displayed database permission target | database I via `bracket_quote` | Safe | +| `users.rs:575` | Displayed schema permission target | schema I via `bracket_quote` | Safe | +| `users.rs:577` | Displayed object permission target | schema and object I via `bracket_quote` | Safe | +| `users.rs:593` | Displayed grant/deny SQL | verb K from state; privilege K from server catalog; target built safely; user I; suffix K | Safe | +| `users.rs:620` | Displayed role-membership SQL | role and user I via `bracket_quote`; remainder K | Safe | +| `users.rs:628` | Inherited-grant display line | role I; permission SQL internally built | Safe, display only | +| `users.rs:704` | Applied grant/revoke statement | verb and preposition K boolean branches; privilege K from scope allowlist; target safe; user I | Safe; executed transactionally | + +SQL Server's `CREATE LOGIN` and `ALTER LOGIN` grammar requires the password in +the statement's `PASSWORD = 'password'` clause; it does not accept a TDS value +parameter in that grammar position. Those two statements therefore use the +small `password_literal` escape routine, and errors are redacted. Account +existence checks, user permission queries, and role queries bind user/login +literals with `@P1` and `@P2`. + +## Parameter binding and static-query review + +All ordinary scalar data uses TDS parameters: + +- insert, update, delete, and BLOB key/value paths use + `value_to_sql_param` and generated `@Pn` markers; +- BLOB preview size is bound as `@P1`; +- every interpolated metadata search in `introspection.rs` is instead a static + query with bound parameters; +- account existence, permissions, roles, schemas, tables, routines, views, + indexes, and trigger listing use bound parameters. + +Escaping remains only where binding is impossible or there is no binding +channel: generated DDL scripts returned to the host, routine-call SQL returned +to the host, and login password DDL. Identifiers are never treated as values; +SQL Server does not permit parameter markers in identifier positions. + +Complete SQL text is accepted only by APIs whose purpose is editing or running +SQL: `execute_query`, batch execution, view definitions, trigger definitions, +routine edit scripts, startup scripts, EXPLAIN's input query, column type and +default expressions, and routine arguments explicitly marked `is_raw`. These +boundaries do not weaken identifier handling elsewhere. + +## Regression coverage + +`src/driver/sql_audit_tests.rs` passes identifiers containing `]`, embedded +double and single quotes, Unicode, leading digits, and the reserved word +`order` through the pure CRUD, DDL, view, routine, trigger, drop, and account +statement builders. `tests/live_db.rs` creates the table literally named +`[weird"name]]`, with columns `order` and `9Δ"value]`, then creates, inserts, +updates, selects, deletes, and drops it through the JSON-RPC boundary against +SQL Server 2022. diff --git a/src/driver/helpers.rs b/src/driver/helpers.rs index 9aecb5d..0684d15 100644 --- a/src/driver/helpers.rs +++ b/src/driver/helpers.rs @@ -82,16 +82,14 @@ pub fn wrap_dml_with_rowcount(sql: &str) -> String { /// Build a parameterized SQL Server `INSERT` statement. /// -/// `qualified` is expected to already be a `[schema].[table]` produced by -/// [`qualify`]. `columns` is the in-order list of column names that will be -/// bound to `@P1, @P2, ...` (callers must bind values in the same order). +/// `schema`, `table`, and every entry in `columns` are identifiers and are +/// bracket-quoted here. Values are bound to `@P1, @P2, ...` by the caller in +/// the same order; this helper never accepts a pre-rendered table reference. /// -/// When `wrap_identity_insert` is `Some(target)`, the resulting batch toggles -/// `SET IDENTITY_INSERT ON` around the insert and is wrapped in -/// `BEGIN TRY / BEGIN CATCH` so the session-scoped setting is always cleared, -/// even if the insert fails. `target` should also be a `[schema].[table]` -/// reference (typically the same as `qualified`); accepting it as a parameter -/// keeps the helper pure and easy to unit-test. +/// When `wrap_identity_insert` is true, the resulting batch toggles +/// `SET IDENTITY_INSERT` around the insert and is wrapped in `BEGIN TRY / +/// BEGIN CATCH` so the session-scoped setting is always cleared, even if the +/// insert fails. /// /// The batch always ends by selecting the insert's `@@ROWCOUNT` as /// [`AFFECTED_ROWS_COLUMN`]. In the identity-wrapped variant the count is @@ -101,10 +99,12 @@ pub fn wrap_dml_with_rowcount(sql: &str) -> String { /// Returns the SQL batch. The number of placeholders always matches /// `columns.len()`. pub fn build_insert_sql( - qualified: &str, + schema: Option<&str>, + table: &str, columns: &[String], - wrap_identity_insert: Option<&str>, + wrap_identity_insert: bool, ) -> String { + let target = qualify(schema, table); let col_list = columns .iter() .map(|c| bracket_quote(c)) @@ -116,40 +116,37 @@ pub fn build_insert_sql( .join(", "); let insert = format!( "INSERT INTO {} ({}) VALUES ({})", - qualified, col_list, placeholders + target, col_list, placeholders ); - match wrap_identity_insert { - None => format!("{};\n{}", insert, select_affected_rows("@@ROWCOUNT")), - Some(target) => { - // SET IDENTITY_INSERT is session-scoped and is *not* transactional, - // so the CATCH block must explicitly turn it OFF before re-raising. - // Setting OFF on a table that is already OFF is a no-op in SQL - // Server, so this is safe even if the failure occurs before the ON - // statement executes. The success and CATCH paths both turn it - // OFF; SS-003 verifies a failed insert does not poison the reused - // pooled session. No explicit transaction is needed — a single - // INSERT is atomic on its own, and the TDS client rejects - // BEGIN TRAN / COMMIT inside an `sp_executesql` RPC batch - // (error 3981). - format!( - "DECLARE @tabularis_affected BIGINT = 0;\n\ - BEGIN TRY\n\ - SET IDENTITY_INSERT {target} ON;\n\ - {insert};\n\ - SET @tabularis_affected = @@ROWCOUNT;\n\ - SET IDENTITY_INSERT {target} OFF;\n\ - END TRY\n\ - BEGIN CATCH\n\ - SET IDENTITY_INSERT {target} OFF;\n\ - THROW;\n\ - END CATCH;\n\ - {select}", - target = target, - insert = insert, - select = select_affected_rows("@tabularis_affected"), - ) - } + if wrap_identity_insert { + // SET IDENTITY_INSERT is session-scoped and is *not* transactional, + // so the CATCH block must explicitly turn it OFF before re-raising. + // Setting OFF on a table that is already OFF is a no-op in SQL + // Server, so this is safe even if the failure occurs before the ON + // statement executes. The success and CATCH paths both turn it + // OFF; SS-003 verifies a failed insert does not poison the reused + // pooled session. No explicit transaction is needed — a single + // INSERT is atomic on its own, and the TDS client rejects + // BEGIN TRAN / COMMIT inside an `sp_executesql` RPC batch + // (error 3981). + format!( + "DECLARE @tabularis_affected BIGINT = 0;\n\ + BEGIN TRY\n\ + SET IDENTITY_INSERT {target} ON;\n\ + {insert};\n\ + SET @tabularis_affected = @@ROWCOUNT;\n\ + SET IDENTITY_INSERT {target} OFF;\n\ + END TRY\n\ + BEGIN CATCH\n\ + SET IDENTITY_INSERT {target} OFF;\n\ + THROW;\n\ + END CATCH;\n\ + {select}", + select = select_affected_rows("@tabularis_affected"), + ) + } else { + format!("{};\n{}", insert, select_affected_rows("@@ROWCOUNT")) } } diff --git a/src/driver/helpers/tests.rs b/src/driver/helpers/tests.rs index cf11895..8280e96 100644 --- a/src/driver/helpers/tests.rs +++ b/src/driver/helpers/tests.rs @@ -78,9 +78,10 @@ fn bracket_quote_is_round_trip_safe_through_itself() { #[test] fn build_insert_sql_plain_emits_positional_placeholders() { let sql = build_insert_sql( - "[dbo].[Users]", - &["id".to_string(), "name".to_string(), "email".to_string()], None, + "Users", + &["id".to_string(), "name".to_string(), "email".to_string()], + false, ); assert_eq!( sql, @@ -102,9 +103,10 @@ fn wrap_dml_with_rowcount_keeps_multi_statement_batch_and_single_sentinel() { #[test] fn build_insert_sql_plain_quotes_column_identifiers() { let sql = build_insert_sql( - "[sales].[Orders]", + Some("sales"), + "Orders", &["order id".to_string(), "weird]col".to_string()], - None, + false, ); assert!(sql.contains("([order id], [weird]]col])")); assert!(sql.contains("VALUES (@P1, @P2)")); @@ -112,11 +114,7 @@ fn build_insert_sql_plain_quotes_column_identifiers() { #[test] fn build_insert_sql_with_identity_wraps_in_try_catch() { - let sql = build_insert_sql( - "[dbo].[Users]", - &["id".to_string(), "name".to_string()], - Some("[dbo].[Users]"), - ); + let sql = build_insert_sql(None, "Users", &["id".to_string(), "name".to_string()], true); assert!(sql.contains("BEGIN TRY")); assert!(sql.contains("SET IDENTITY_INSERT [dbo].[Users] ON;")); assert!(sql.contains("INSERT INTO [dbo].[Users] ([id], [name]) VALUES (@P1, @P2);")); @@ -146,12 +144,10 @@ fn build_insert_sql_with_identity_wraps_in_try_catch() { } #[test] -fn build_insert_sql_with_identity_uses_provided_target() { - // Caller may pass a different qualified name as the IDENTITY_INSERT - // target (e.g. for round-trip tests with escaped identifiers). - let sql = build_insert_sql("[dbo].[T]", &["k".to_string()], Some("[s].[we]]ird]")); - assert!(sql.contains("SET IDENTITY_INSERT [s].[we]]ird] ON;")); - assert!(sql.contains("SET IDENTITY_INSERT [s].[we]]ird] OFF;")); +fn build_insert_sql_with_identity_quotes_its_target() { + let sql = build_insert_sql(Some("9schéma]"), "weird\"name]", &["k".to_string()], true); + assert!(sql.contains("SET IDENTITY_INSERT [9schéma]]].[weird\"name]]] ON;")); + assert!(sql.contains("SET IDENTITY_INSERT [9schéma]]].[weird\"name]]] OFF;")); } #[test] diff --git a/src/driver/mod.rs b/src/driver/mod.rs index e0283b5..2ffc21c 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -224,6 +224,9 @@ pub async fn execute_on_connection( Ok(first) } +#[cfg(test)] +mod sql_audit_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/src/driver/ops.rs b/src/driver/ops.rs index dcafc70..cece9dc 100644 --- a/src/driver/ops.rs +++ b/src/driver/ops.rs @@ -108,6 +108,26 @@ pub async fn get_indexes( // --- Views -------------------------------------------------------------- +pub(crate) fn build_create_view_sql( + view_name: &str, + definition: &str, + schema: Option<&str>, +) -> String { + format!("CREATE VIEW {} AS {definition}", qualify(schema, view_name)) +} + +pub(crate) fn build_alter_view_sql( + view_name: &str, + definition: &str, + schema: Option<&str>, +) -> String { + format!("ALTER VIEW {} AS {definition}", qualify(schema, view_name)) +} + +pub(crate) fn build_drop_view_sql(view_name: &str, schema: Option<&str>) -> String { + format!("DROP VIEW IF EXISTS {}", qualify(schema, view_name)) +} + pub async fn get_views( params: &ConnectionParams, schema: Option<&str>, @@ -143,11 +163,7 @@ pub async fn create_view( definition: &str, schema: Option<&str>, ) -> Result<(), String> { - let sql = format!( - "CREATE VIEW {} AS {}", - qualify(schema, view_name), - definition - ); + let sql = build_create_view_sql(view_name, definition, schema); let mut conn = acquire(params).await?; conn.simple_query(sql) .await @@ -162,11 +178,7 @@ pub async fn alter_view( definition: &str, schema: Option<&str>, ) -> Result<(), String> { - let sql = format!( - "ALTER VIEW {} AS {}", - qualify(schema, view_name), - definition - ); + let sql = build_alter_view_sql(view_name, definition, schema); let mut conn = acquire(params).await?; conn.simple_query(sql) .await @@ -180,7 +192,7 @@ pub async fn drop_view( view_name: &str, schema: Option<&str>, ) -> Result<(), String> { - let sql = format!("DROP VIEW IF EXISTS {}", qualify(schema, view_name)); + let sql = build_drop_view_sql(view_name, schema); let mut conn = acquire(params).await?; conn.simple_query(sql) .await @@ -424,16 +436,7 @@ pub async fn insert_record( .map(|id| columns.iter().any(|c| c.eq_ignore_ascii_case(id))) .unwrap_or(false); - let qualified = helpers::qualify(schema, table); - let sql = helpers::build_insert_sql( - &qualified, - &columns, - if needs_identity_insert { - Some(qualified.as_str()) - } else { - None - }, - ); + let sql = helpers::build_insert_sql(schema, table, &columns, needs_identity_insert); // Map each JSON value to a typed SQL parameter. Owned boxes live // for the duration of the call so the borrowed `&dyn ToSql` slice is @@ -633,17 +636,21 @@ pub fn get_create_foreign_key_sql( ) } +pub(crate) fn build_drop_index_sql(table: &str, index_name: &str, schema: Option<&str>) -> String { + format!( + "DROP INDEX {} ON {}", + bracket_quote(index_name), + qualify(schema, table), + ) +} + pub async fn drop_index( params: &ConnectionParams, table: &str, index_name: &str, schema: Option<&str>, ) -> Result<(), String> { - let sql = format!( - "DROP INDEX {} ON {}", - bracket_quote(index_name), - qualify(schema, table), - ); + let sql = build_drop_index_sql(table, index_name, schema); let mut conn = acquire(params).await?; conn.execute(sql, &[]) .await @@ -651,17 +658,25 @@ pub async fn drop_index( Ok(()) } -pub async fn drop_foreign_key( - params: &ConnectionParams, +pub(crate) fn build_drop_foreign_key_sql( table: &str, fk_name: &str, schema: Option<&str>, -) -> Result<(), String> { - let sql = format!( +) -> String { + format!( "ALTER TABLE {} DROP CONSTRAINT {}", qualify(schema, table), bracket_quote(fk_name), - ); + ) +} + +pub async fn drop_foreign_key( + params: &ConnectionParams, + table: &str, + fk_name: &str, + schema: Option<&str>, +) -> Result<(), String> { + let sql = build_drop_foreign_key_sql(table, fk_name, schema); let mut conn = acquire(params).await?; conn.execute(sql, &[]) .await diff --git a/src/driver/sql_audit_tests.rs b/src/driver/sql_audit_tests.rs new file mode 100644 index 0000000..d1c9786 --- /dev/null +++ b/src/driver/sql_audit_tests.rs @@ -0,0 +1,276 @@ +//! Cross-module regression tests for the SQL construction audit. +//! +//! These tests deliberately use identifiers that are reserved words, start +//! with a digit, contain Unicode, quotes, and closing brackets. Each public +//! pure SQL builder is exercised here; live JSON-RPC coverage proves the same +//! quoting survives execution against SQL Server. + +use crate::driver::{ddl, helpers, ops, routines, triggers, users}; +use crate::models::{ColumnDefinition, RoutineCallArg, RoutineParameter}; + +const SCHEMA: &str = "9schéma]'"; +const TABLE: &str = "[weird\"name]]"; +const COLUMN: &str = "order"; +const OTHER_COLUMN: &str = "Δ\"value]"; + +fn column(name: &str, data_type: &str) -> ColumnDefinition { + ColumnDefinition { + name: name.to_string(), + data_type: data_type.to_string(), + is_nullable: false, + is_pk: false, + is_auto_increment: false, + default_value: None, + } +} + +#[test] +fn identifier_and_crud_builders_quote_every_hostile_identifier() { + let qualified = "[9schéma]]'].[[weird\"name]]]]]"; + assert_eq!(helpers::bracket_quote(TABLE), "[[weird\"name]]]]]"); + assert_eq!(helpers::quote_identifier("Δ\"value]"), "\"Δ\"\"value]\""); + assert_eq!(helpers::qualify(Some(SCHEMA), TABLE), qualified); + assert_eq!(helpers::escape_single_quoted("a'b"), "a''b"); + + let columns = vec![COLUMN.to_string(), OTHER_COLUMN.to_string()]; + let insert = helpers::build_insert_sql(Some(SCHEMA), TABLE, &columns, true); + assert!(insert.contains(&format!( + "INSERT INTO {qualified} ([order], [Δ\"value]]]) VALUES (@P1, @P2)" + ))); + assert_eq!( + insert + .matches(&format!("SET IDENTITY_INSERT {qualified} OFF")) + .count(), + 2 + ); + + assert_eq!( + helpers::build_pk_where_clause(&columns, 2).unwrap(), + "[order] = @P2 AND [Δ\"value]]] = @P3" + ); + assert_eq!( + helpers::build_update_composite_sql(Some(SCHEMA), TABLE, OTHER_COLUMN, &[COLUMN.into()]) + .unwrap(), + format!("UPDATE {qualified} SET [Δ\"value]]] = @P1 WHERE [order] = @P2") + ); + assert_eq!( + helpers::build_delete_composite_sql(Some(SCHEMA), TABLE, &[COLUMN.into()]).unwrap(), + format!("DELETE FROM {qualified} WHERE [order] = @P1") + ); + + let wrapped = helpers::wrap_dml_with_rowcount("UPDATE [safe] SET [value] = @P1"); + assert!(wrapped.ends_with("SELECT CAST(@@ROWCOUNT AS BIGINT) AS [__tabularis_affected_rows];")); + assert!(helpers::build_paginated_query("SELECT 1", 10, 1) + .ends_with("OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY")); +} + +#[test] +fn column_and_ddl_builders_quote_every_identifier() { + let qualified = helpers::qualify(Some(SCHEMA), TABLE); + let mut key = column(COLUMN, "INT"); + key.is_pk = true; + key.is_auto_increment = true; + key.default_value = Some("(7)".to_string()); + assert_eq!( + helpers::render_column_definition(&key, true), + "[order] INT IDENTITY(1,1) NOT NULL DEFAULT (7) PRIMARY KEY" + ); + + let create_table = ops::get_create_table_sql(TABLE, vec![key.clone()], Some(SCHEMA)).unwrap(); + assert!(create_table[0].starts_with(&format!("CREATE TABLE {qualified}"))); + assert!(create_table[0].contains("PRIMARY KEY ([order])")); + + let add = + ops::get_add_column_sql(TABLE, column(OTHER_COLUMN, "NVARCHAR(20)"), Some(SCHEMA)).unwrap(); + assert_eq!( + add[0], + format!("ALTER TABLE {qualified} ADD [Δ\"value]]] NVARCHAR(20) NOT NULL") + ); + + let index_name = "9índex\"]"; + let index = ops::get_create_index_sql( + TABLE, + index_name, + vec![COLUMN.into(), OTHER_COLUMN.into()], + true, + Some(SCHEMA), + ) + .unwrap(); + assert_eq!( + index[0], + format!("CREATE UNIQUE INDEX [9índex\"]]] ON {qualified} ([order], [Δ\"value]]])") + ); + + let fk_name = "9fk\"]"; + let referenced = "9référence\"]"; + let direct_fk = ddl::create_foreign_key_sql( + TABLE, + fk_name, + COLUMN, + referenced, + OTHER_COLUMN, + Some("cascade"), + Some("set null"), + Some(SCHEMA), + ) + .unwrap(); + let rpc_fk = ops::get_create_foreign_key_sql( + TABLE, + fk_name, + COLUMN, + referenced, + OTHER_COLUMN, + Some("cascade"), + Some("set null"), + Some(SCHEMA), + ) + .unwrap(); + assert_eq!(direct_fk, rpc_fk); + assert!(direct_fk[0].contains("CONSTRAINT [9fk\"]]] FOREIGN KEY ([order])")); + assert!(direct_fk[0].contains("REFERENCES [9schéma]]'].[9référence\"]]] ([Δ\"value]]])")); +} + +#[test] +fn alter_column_quotes_multipart_names_and_escapes_metadata_literals() { + let old = column("old'name]", "INT"); + let mut new = column(COLUMN, "BIGINT"); + new.is_nullable = true; + new.default_value = Some("(42)".to_string()); + + let direct = ddl::alter_column_sql(TABLE, &old, &new, Some(SCHEMA)).unwrap(); + let rpc = ops::get_alter_column_sql(TABLE, old, new, Some(SCHEMA)).unwrap(); + assert_eq!(direct, rpc); + assert!(direct[0].contains("[9schéma]]''].[[weird\"name]]]]].[old''name]]]")); + assert!(direct[0].contains(", N'order', N'COLUMN'")); + assert_eq!( + direct[1], + "ALTER TABLE [9schéma]]'].[[weird\"name]]]]] ALTER COLUMN [order] BIGINT NULL" + ); + assert!(direct[2].contains("OBJECT_ID(N'[9schéma]]''].[[weird\"name]]]]]')")); + assert!(direct[2].contains("c.[name] = N'order'")); + assert!(direct[3].contains("ADD CONSTRAINT [DF_[weird\"name]]]]_order]")); +} + +#[test] +fn directly_executed_drop_and_view_builders_quote_identifiers() { + let qualified = helpers::qualify(Some(SCHEMA), TABLE); + let definition = "SELECT CAST(1 AS INT) AS [order]"; + assert_eq!( + ops::build_create_view_sql(TABLE, definition, Some(SCHEMA)), + format!("CREATE VIEW {qualified} AS {definition}") + ); + assert_eq!( + ops::build_alter_view_sql(TABLE, definition, Some(SCHEMA)), + format!("ALTER VIEW {qualified} AS {definition}") + ); + assert_eq!( + ops::build_drop_view_sql(TABLE, Some(SCHEMA)), + format!("DROP VIEW IF EXISTS {qualified}") + ); + assert_eq!( + ops::build_drop_index_sql(TABLE, "9índex\"]", Some(SCHEMA)), + format!("DROP INDEX [9índex\"]]] ON {qualified}") + ); + assert_eq!( + ops::build_drop_foreign_key_sql(TABLE, "9fk\"]", Some(SCHEMA)), + format!("ALTER TABLE {qualified} DROP CONSTRAINT [9fk\"]]]") + ); + assert_eq!( + triggers::drop_trigger_sql(TABLE, Some(SCHEMA)), + format!("DROP TRIGGER {qualified}") + ); +} + +#[test] +fn routine_builders_escape_literals_and_preserve_only_explicit_raw_expressions() { + let args = [ + RoutineCallArg { + name: "order".to_string(), + mode: "IN".to_string(), + value: Some("x'); DROP TABLE victims;--".to_string()), + is_raw: false, + }, + RoutineCallArg { + name: "raw_value".to_string(), + mode: "IN".to_string(), + value: Some("DATEADD(day, 1, SYSDATETIME())".to_string()), + is_raw: true, + }, + RoutineCallArg { + name: "out_value".to_string(), + mode: "OUT".to_string(), + value: None, + is_raw: false, + }, + ]; + let parameters = [RoutineParameter { + name: "@out_value".to_string(), + data_type: "NVARCHAR(20)".to_string(), + mode: "OUT".to_string(), + ordinal_position: 3, + }]; + let sql = + routines::routine_call_sql(TABLE, "PROCEDURE", &args, ¶meters, false, Some(SCHEMA)) + .unwrap(); + assert!(sql.contains("@order = N'x''); DROP TABLE victims;--'")); + assert!(sql.contains("@raw_value = DATEADD(day, 1, SYSDATETIME())")); + assert!(sql.contains("EXEC [9schéma]]'].[[weird\"name]]]]]")); + assert!(sql.contains("@tabularis_output_2 AS [out_value]")); + + assert!(routines::routine_create_template("FUNCTION", Some(SCHEMA)) + .starts_with("CREATE FUNCTION [9schéma]]'].[my_function]")); + assert_eq!( + routines::routine_edit_script("CREATE PROCEDURE [safe].[p] AS SELECT 1").unwrap(), + "ALTER PROCEDURE [safe].[p] AS SELECT 1" + ); + assert_eq!( + routines::drop_routine_sql(TABLE, "FUNCTION", Some(SCHEMA)), + "DROP FUNCTION [9schéma]]'].[[weird\"name]]]]]" + ); +} + +#[test] +fn user_management_builders_quote_names_and_escape_unbindable_password_literals() { + let user = "9usér\"]"; + let login = "9lógin\"]"; + let password = "S3cret'); DROP LOGIN victim;--"; + + assert_eq!( + users::build_create_user_sql(user, login), + "CREATE USER [9usér\"]]] FOR LOGIN [9lógin\"]]]" + ); + assert_eq!(users::build_drop_user_sql(user), "DROP USER [9usér\"]]]"); + assert_eq!( + users::build_drop_login_sql(login), + "DROP LOGIN [9lógin\"]]]" + ); + + let create = users::build_create_login_sql(login, password); + let alter = users::build_set_password_sql(login, password); + for sql in [create, alter] { + assert!(sql.contains("[9lógin\"]]]")); + assert!(sql.contains("PASSWORD = N'S3cret''); DROP LOGIN victim;--'")); + } + + assert_eq!( + users::build_permission_change_sql( + "9database\"]", + user, + Some(SCHEMA), + Some(TABLE), + "select", + true, + ) + .unwrap(), + "GRANT SELECT ON OBJECT::[9schéma]]'].[[weird\"name]]]]] TO [9usér\"]]]" + ); + assert!(users::build_permission_change_sql( + "database", + user, + Some(SCHEMA), + Some(TABLE), + "SELECT; DROP TABLE victims", + true, + ) + .is_err()); +} diff --git a/src/driver/users.rs b/src/driver/users.rs index 59fc315..1b867a9 100644 --- a/src/driver/users.rs +++ b/src/driver/users.rs @@ -282,6 +282,38 @@ fn password_literal(password: &str) -> String { format!("N'{}'", password.replace('\'', "''")) } +pub(crate) fn build_create_login_sql(login: &str, password: &str) -> String { + format!( + "CREATE LOGIN {} WITH PASSWORD = {}, CHECK_POLICY = ON, CHECK_EXPIRATION = OFF", + bracket_quote(login), + password_literal(password) + ) +} + +pub(crate) fn build_create_user_sql(user: &str, login: &str) -> String { + format!( + "CREATE USER {} FOR LOGIN {}", + bracket_quote(user), + bracket_quote(login) + ) +} + +pub(crate) fn build_drop_user_sql(user: &str) -> String { + format!("DROP USER {}", bracket_quote(user)) +} + +pub(crate) fn build_drop_login_sql(login: &str) -> String { + format!("DROP LOGIN {}", bracket_quote(login)) +} + +pub(crate) fn build_set_password_sql(login: &str, password: &str) -> String { + format!( + "ALTER LOGIN {} WITH PASSWORD = {}", + bracket_quote(login), + password_literal(password) + ) +} + fn redact_password(mut message: String, password: &str) -> String { if !password.is_empty() { message = message.replace(password, "[REDACTED]"); @@ -408,11 +440,7 @@ pub async fn create_user( )); } - let create_login = format!( - "CREATE LOGIN {} WITH PASSWORD = {}, CHECK_POLICY = ON, CHECK_EXPIRATION = OFF", - bracket_quote(login), - password_literal(password) - ); + let create_login = build_create_login_sql(login, password); conn.simple_query(create_login) .await .map_err(|error| { @@ -426,15 +454,9 @@ pub async fn create_user( })? .into_results(); - let create_database_user = format!( - "CREATE USER {} FOR LOGIN {}", - bracket_quote(user), - bracket_quote(login) - ); + let create_database_user = build_create_user_sql(user, login); if let Err(error) = conn.simple_query(create_database_user).await { - let cleanup = conn - .simple_query(format!("DROP LOGIN {}", bracket_quote(login))) - .await; + let cleanup = conn.simple_query(build_drop_login_sql(login)).await; let cleanup_note = cleanup .err() .map(|cleanup_error| format!("; login cleanup also failed: {cleanup_error}")) @@ -453,8 +475,7 @@ pub async fn create_user( pub async fn drop_user(conn: &mut BridgeConnection, user: &str, login: &str) -> Result<(), String> { ensure_account(conn, user, login).await?; - conn.simple_query(format!("DROP USER {}", bracket_quote(user))) - .await + conn.simple_query(build_drop_user_sql(user)).await .map_err(|error| { format!( "Failed to drop database user {}. SQL Server may be protecting a schema or object owned by this user: {error}", @@ -462,8 +483,7 @@ pub async fn drop_user(conn: &mut BridgeConnection, user: &str, login: &str) -> ) })? .into_results(); - conn.simple_query(format!("DROP LOGIN {}", bracket_quote(login))) - .await + conn.simple_query(build_drop_login_sql(login)).await .map_err(|error| { format!( "Database user {} was dropped, but its SQL Server login {} could not be dropped: {error}", @@ -482,11 +502,7 @@ pub async fn set_password( password: &str, ) -> Result<(), String> { ensure_account(conn, user, login).await?; - let sql = format!( - "ALTER LOGIN {} WITH PASSWORD = {}", - bracket_quote(login), - password_literal(password) - ); + let sql = build_set_password_sql(login, password); conn.simple_query(sql) .await .map_err(|error| { @@ -670,6 +686,28 @@ fn canonical_privileges( Ok(canonical) } +pub(crate) fn build_permission_change_sql( + database_name: &str, + user: &str, + database: Option<&str>, + table: Option<&str>, + privilege: &str, + grant: bool, +) -> Result { + let scope = RequestedScope::from_wire(database, table)?; + let privilege = canonical_privileges(&scope, &[privilege.to_string()])? + .into_iter() + .next() + .expect("one validated privilege"); + let verb = if grant { "GRANT" } else { "REVOKE" }; + let preposition = if grant { "TO" } else { "FROM" }; + Ok(format!( + "{verb} {privilege} ON {} {preposition} {}", + scope.target_sql(database_name), + bracket_quote(user) + )) +} + #[allow(clippy::too_many_arguments)] pub async fn apply_privileges( conn: &mut BridgeConnection, @@ -706,13 +744,14 @@ pub async fn apply_privileges( if already_granted == grant { continue; } - let verb = if grant { "GRANT" } else { "REVOKE" }; - let preposition = if grant { "TO" } else { "FROM" }; - statements.push(format!( - "{verb} {privilege} ON {} {preposition} {}", - scope.target_sql(database_name), - bracket_quote(user) - )); + statements.push(build_permission_change_sql( + database_name, + user, + database, + table, + &privilege, + grant, + )?); } if statements.is_empty() { diff --git a/tests/live_db.rs b/tests/live_db.rs index 727f117..c01425f 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -465,6 +465,76 @@ fn crud_insert_update_and_delete_support_single_and_composite_primary_keys() { ); } +#[test] +fn hostile_identifiers_survive_ddl_and_crud_round_trip() { + const TABLE: &str = "[weird\"name]]"; + const KEY_COLUMN: &str = "order"; + const VALUE_COLUMN: &str = "9Δ\"value]"; + + let mut plugin = Plugin::with_scratch_database(); + let table_ref = format!("{}.{}", bracket_quote(TEST_SCHEMA), bracket_quote(TABLE)); + plugin.execute(format!("DROP TABLE IF EXISTS {table_ref}")); + + let create = generated_create_table_sql( + &mut plugin, + TABLE, + json!([ + { + "name": KEY_COLUMN, "data_type": "INT", "is_nullable": false, + "is_pk": true, "is_auto_increment": false, "default_value": null + }, + { + "name": VALUE_COLUMN, "data_type": "NVARCHAR(100)", "is_nullable": false, + "is_pk": false, "is_auto_increment": false, "default_value": null + } + ]), + ); + for statement in create { + plugin.execute(statement); + } + + assert_eq!( + plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": TABLE, + "data": { "order": 7, "9Δ\"value]": "before" } + }), + ), + json!(1) + ); + assert_eq!( + plugin.call_ok( + "update_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": TABLE, + "pk_map": { "order": 7 }, "col_name": VALUE_COLUMN, "new_val": "after" + }), + ), + json!(1) + ); + + let selected = plugin.execute(format!( + "SELECT {} FROM {table_ref} WHERE {} = 7", + bracket_quote(VALUE_COLUMN), + bracket_quote(KEY_COLUMN), + )); + assert_eq!(selected["columns"], json!([VALUE_COLUMN])); + assert_eq!(selected["rows"], json!([["after"]])); + + assert_eq!( + plugin.call_ok( + "delete_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": TABLE, + "pk_map": { "order": 7 } + }), + ), + json!(1) + ); + plugin.execute(format!("DROP TABLE {table_ref}")); +} + #[test] fn zero_row_select_preserves_column_headers() { let mut plugin = Plugin::with_scratch_database(); From 20102f57e12db3a9a04d248e8ed55f924bf708aa Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 22:13:18 +0000 Subject: [PATCH 16/23] fix(query): harden pagination semantics --- README.md | 33 +++++++ src/driver/helpers.rs | 179 +++++++++--------------------------- src/driver/helpers/tests.rs | 155 ++++++++++++++++++++----------- tests/live_db.rs | 80 +++++++++++++--- 4 files changed, 246 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index 55d3cde..5162641 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ This plugin enables Tabularis to connect to SQL Server instances, providing sche - [Screenshots](#screenshots) - [Connection Configuration](#connection-configuration) - [Plugin Settings](#plugin-settings) +- [Query Execution Semantics](#query-execution-semantics) - [Supported Data Types](#supported-data-types) - [Database Users and Privileges](#database-users-and-privileges) - [Installation](#installation) @@ -151,6 +152,38 @@ connection after the plugin is restarted, not on live pooled sessions. certificates in a verifying TLS mode. The `prefer` and `require` modes already accept the server certificate as described above. +## Query Execution Semantics + +Tabularis sends `limit` and `page` when result paging is enabled. The plugin +adds pagination only to one top-level `SELECT` or `VALUES` statement, including +a CTE whose final operation is a `SELECT`. DML, `EXEC`, `SELECT ... INTO`, and +multi-statement SQL run without pagination metadata. `execute_query` and every +statement in `execute_query_batch` use this same classification and execution +path. + +For a paginated query the plugin requests `page_size + 1` rows. It returns at +most `page_size`, sets `has_more` when the lookahead row exists, and sets +`truncated` to the same value because that lookahead row was omitted. +`pagination.total_rows` remains `null`: normal page fetches never run a hidden +count query. The Tabularis **Count rows** action obtains a total separately by +running a `SELECT COUNT(*)` wrapper with pagination disabled. That count can +scan the full query result on a large table, but its cost is incurred only when +the host explicitly requests it. + +SQL Server requires `ORDER BY` with `OFFSET ... FETCH`. If the query has no +top-level `ORDER BY`, the plugin injects `ORDER BY (SELECT NULL)` so paging +still works. This deliberately does **not** promise stable page boundaries: +rows can move between or repeat across pages because SQL Server may choose any +order. Add a deterministic `ORDER BY`, ideally ending in a unique key, whenever +page-to-page stability matters. An `ORDER BY` inside a subquery or `OVER(...)` +does not order the outer result and therefore does not prevent this injection. + +When one SQL statement produces multiple result sets, the first occupies the +normal `columns` and `rows` fields and only real subsequent result sets appear +in `additional_results`. Batch-RPC statements remain separate batch entries. +The private `@@ROWCOUNT` result set used to recover SQL Server DML affected-row +counts is always removed and never appears in `additional_results`. + ## Supported Data Types All common SQL Server types are supported for column creation and value extraction, including exact/approximate numerics (`TINYINT` … `BIGINT`, `DECIMAL`, `MONEY`, `FLOAT`), strings (`CHAR`/`VARCHAR`/`NVARCHAR` incl. `MAX`, `TEXT`/`NTEXT`), binary (`BINARY`/`VARBINARY`/`IMAGE`), date/time (`DATE`, `TIME`, `DATETIME`, `DATETIME2`, `SMALLDATETIME`, `DATETIMEOFFSET`), `BIT`, `UNIQUEIDENTIFIER`, `XML`, `SQL_VARIANT`, `ROWVERSION`, `HIERARCHYID`, and spatial (`GEOGRAPHY`, `GEOMETRY`). diff --git a/src/driver/helpers.rs b/src/driver/helpers.rs index 0684d15..31c1e13 100644 --- a/src/driver/helpers.rs +++ b/src/driver/helpers.rs @@ -268,8 +268,6 @@ pub fn build_update_composite_sql( )) } -/// Apply SQL Server `OFFSET … FETCH` pagination, requesting one extra row so -/// callers can determine whether another page exists. pub fn render_column_definition(column: &ColumnDefinition, inline_primary_key: bool) -> String { let mut definition = format!("{} {}", bracket_quote(&column.name), column.data_type); if column.is_auto_increment { @@ -308,30 +306,18 @@ pub fn query_reports_affected_rows(query: &str) -> bool { .last() .map(|statement| { let words = top_level_words(statement); - let operation = if words.first().map(String::as_str) == Some("WITH") { - words.iter().skip(1).find(|word| { - matches!( - word.as_str(), - "SELECT" - | "VALUES" - | "EXEC" - | "EXECUTE" - | "INSERT" - | "UPDATE" - | "DELETE" - | "MERGE" - ) - }) - } else { - words.first() - }; - operation.is_some_and(|word| { - matches!(word.as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE") + statement_operation(&words).is_some_and(|(operation_index, operation)| { + matches!(operation, "INSERT" | "UPDATE" | "DELETE" | "MERGE") + || (operation == "SELECT" && select_has_top_level_into(&words, operation_index)) }) }) .unwrap_or(false) } +/// Apply SQL Server `OFFSET … FETCH` pagination, requesting one extra row so +/// callers can determine whether another page exists. SQL Server requires an +/// `ORDER BY`; for an unordered query the synthetic order keeps the host's +/// pagination contract available but cannot make page boundaries stable. pub fn build_paginated_query(query: &str, page_size: u32, page: u32) -> String { let normalized = query.trim().trim_end_matches(';').trim_end(); let offset = page.saturating_sub(1).saturating_mul(page_size); @@ -348,47 +334,49 @@ pub fn build_paginated_query(query: &str, page_size: u32, page: u32) -> String { fn statement_can_be_paginated(statement: &str) -> bool { let words = top_level_words(statement); - match words.first().map(String::as_str) { - Some("SELECT" | "VALUES") => true, - Some("WITH") => words - .iter() - .skip(1) - .find_map(|word| match word.as_str() { - "SELECT" | "VALUES" => Some(true), - "INSERT" | "UPDATE" | "DELETE" | "MERGE" | "EXEC" | "EXECUTE" => Some(false), - _ => None, - }) - .unwrap_or(false), + statement_operation(&words).is_some_and(|(operation_index, operation)| match operation { + "SELECT" => !select_has_top_level_into(&words, operation_index), + "VALUES" => true, _ => false, - } + }) } fn statement_returns_result_set(statement: &str) -> bool { let words = top_level_words(statement); - let Some(first) = words.first().map(String::as_str) else { + let Some((operation_index, operation)) = statement_operation(&words) else { return false; }; - let dml_returns_rows = - |start: usize| words[start..].iter().any(|word| word.as_str() == "OUTPUT"); + let dml_returns_rows = words[operation_index + 1..] + .iter() + .any(|word| word == "OUTPUT"); + match operation { + "SELECT" => !select_has_top_level_into(&words, operation_index), + "VALUES" | "EXEC" | "EXECUTE" => true, + "INSERT" | "UPDATE" | "DELETE" | "MERGE" => dml_returns_rows, + _ => crate::common::returns_result_set(statement), + } +} + +fn statement_operation(words: &[String]) -> Option<(usize, &str)> { + let first = words.first()?; if first != "WITH" { - return match first { - "EXEC" | "EXECUTE" => true, - "INSERT" | "UPDATE" | "DELETE" | "MERGE" => dml_returns_rows(1), - _ => crate::common::returns_result_set(statement), - }; + return Some((0, first.as_str())); } - words + words.iter().enumerate().skip(1).find_map(|(index, word)| { + matches!( + word.as_str(), + "SELECT" | "VALUES" | "EXEC" | "EXECUTE" | "INSERT" | "UPDATE" | "DELETE" | "MERGE" + ) + .then_some((index, word.as_str())) + }) +} + +fn select_has_top_level_into(words: &[String], operation_index: usize) -> bool { + words[operation_index + 1..] .iter() - .enumerate() - .skip(1) - .find_map(|(index, word)| match word.as_str() { - "SELECT" | "VALUES" | "EXEC" | "EXECUTE" => Some(true), - "INSERT" | "UPDATE" | "DELETE" | "MERGE" => Some(dml_returns_rows(index + 1)), - _ => None, - }) - == Some(true) + .any(|word| word == "INTO") } fn top_level_words(statement: &str) -> Vec { @@ -532,93 +520,10 @@ fn code_mask(query: &str) -> String { } fn contains_top_level_order_by(query: &str) -> bool { - #[derive(Clone, Copy, PartialEq, Eq)] - enum State { - Normal, - SingleQuote, - DoubleQuote, - Bracket, - LineComment, - BlockComment, - } - - let upper = query.to_ascii_uppercase(); - let characters: Vec<(usize, char)> = upper.char_indices().collect(); - let mut state = State::Normal; - let mut depth = 0_u32; - let mut position = 0; - - while position < characters.len() { - let (byte_index, character) = characters[position]; - let next = characters.get(position + 1).map(|(_, value)| *value); - match state { - State::Normal => match (character, next) { - ('\'', _) => state = State::SingleQuote, - ('"', _) => state = State::DoubleQuote, - ('[', _) => state = State::Bracket, - ('-', Some('-')) => { - state = State::LineComment; - position += 1; - } - ('/', Some('*')) => { - state = State::BlockComment; - position += 1; - } - ('(', _) => depth = depth.saturating_add(1), - (')', _) => depth = depth.saturating_sub(1), - _ if depth == 0 && token_at(&upper, byte_index, "ORDER BY") => return true, - _ => {} - }, - State::SingleQuote if character == '\'' => { - if next == Some('\'') { - position += 1; - } else { - state = State::Normal; - } - } - State::DoubleQuote if character == '"' => { - if next == Some('"') { - position += 1; - } else { - state = State::Normal; - } - } - State::Bracket if character == ']' => { - if next == Some(']') { - position += 1; - } else { - state = State::Normal; - } - } - State::LineComment if matches!(character, '\n' | '\r') => state = State::Normal, - State::BlockComment if character == '*' && next == Some('/') => { - state = State::Normal; - position += 1; - } - _ => {} - } - position += 1; - } - false -} - -fn token_at(haystack: &str, index: usize, needle: &str) -> bool { - if !haystack[index..].starts_with(needle) { - return false; - } - let is_identifier = |character: char| character.is_alphanumeric() || character == '_'; - let left_is_clear = index == 0 - || !haystack[..index] - .chars() - .next_back() - .map(is_identifier) - .unwrap_or(false); - let right_is_clear = haystack[index + needle.len()..] - .chars() - .next() - .map(|character| !is_identifier(character)) - .unwrap_or(true); - left_is_clear && right_is_clear + let words = top_level_words(&code_mask(query)); + words + .windows(2) + .any(|pair| pair[0] == "ORDER" && pair[1] == "BY") } #[cfg(test)] diff --git a/src/driver/helpers/tests.rs b/src/driver/helpers/tests.rs index 8280e96..6cbf7aa 100644 --- a/src/driver/helpers/tests.rs +++ b/src/driver/helpers/tests.rs @@ -297,42 +297,74 @@ fn render_column_definition_handles_identity_default_and_pk() { } #[test] -fn result_set_classification_handles_cte_dml_and_mixed_batches() { - assert!(query_returns_result_set( - "WITH cte AS (SELECT 1 AS id) SELECT id FROM cte" - )); - assert!(!query_returns_result_set( - "WITH cte AS (SELECT 1 AS id) UPDATE users SET active = 1 FROM users JOIN cte ON users.id = cte.id" - )); - assert!(query_returns_result_set( - "INSERT INTO audit(message) VALUES ('x'); SELECT SCOPE_IDENTITY()" - )); - assert!(query_returns_result_set( - "SELECT 1; UPDATE users SET active = 1" - )); - assert!(query_returns_result_set( - "EXEC sp_executesql N'SELECT 1 AS value'" - )); - assert!(query_returns_result_set( - "UPDATE users SET active = 1 OUTPUT INSERTED.id WHERE id = 7" - )); - assert!(query_returns_result_set( - "WITH target AS (SELECT id FROM users) DELETE FROM target OUTPUT DELETED.id" - )); - assert!(!query_returns_result_set( - "UPDATE users SET active = 1 WHERE id = 7" - )); - assert!(query_can_be_paginated( - "WITH cte AS (SELECT 1 AS id) SELECT id FROM cte" - )); - assert!(!query_can_be_paginated("SELECT 1; SELECT 2")); - assert!(!query_can_be_paginated("EXEC sp_executesql N'SELECT 1'")); - assert!(!query_can_be_paginated( - "UPDATE users SET active = 1 OUTPUT INSERTED.id" - )); - assert!(!query_can_be_paginated( - "WITH cte AS (SELECT 1 AS id) DELETE FROM users WHERE id IN (SELECT id FROM cte)" - )); +fn classifier_handles_cte_followed_by_select() { + let query = "WITH cte AS (SELECT 1 AS id) SELECT id FROM cte"; + assert!(query_returns_result_set(query)); + assert!(query_can_be_paginated(query)); + assert!(!query_reports_affected_rows(query)); +} + +#[test] +fn classifier_handles_cte_followed_by_insert() { + let query = "WITH source AS (SELECT 1 AS id) INSERT INTO audit(id) SELECT id FROM source"; + assert!(!query_returns_result_set(query)); + assert!(!query_can_be_paginated(query)); + assert!(query_reports_affected_rows(query)); +} + +#[test] +fn classifier_treats_select_into_as_affected_rows_not_a_result_set() { + for query in [ + "SELECT id INTO #selected FROM users", + "WITH source AS (SELECT id FROM users) SELECT id INTO #selected FROM source", + ] { + assert!( + !query_returns_result_set(query), + "got result set for {query}" + ); + assert!(!query_can_be_paginated(query), "paginated {query}"); + assert!( + query_reports_affected_rows(query), + "lost row count for {query}" + ); + } +} + +#[test] +fn classifier_handles_merge_and_exec() { + let merge = "MERGE users AS target USING source ON target.id = source.id WHEN MATCHED THEN UPDATE SET target.active = 1"; + assert!(!query_returns_result_set(merge)); + assert!(!query_can_be_paginated(merge)); + assert!(query_reports_affected_rows(merge)); + assert!(query_returns_result_set(&format!( + "{merge} OUTPUT INSERTED.id" + ))); + + let exec = "EXEC sp_executesql N'SELECT 1 AS value'"; + assert!(query_returns_result_set(exec)); + assert!(!query_can_be_paginated(exec)); + assert!(!query_reports_affected_rows(exec)); +} + +#[test] +fn classifier_handles_select_from_temp_table() { + let query = "SELECT id FROM #selected ORDER BY id"; + assert!(query_returns_result_set(query)); + assert!(query_can_be_paginated(query)); + assert!(!query_reports_affected_rows(query)); +} + +#[test] +fn classifier_handles_mixed_batches() { + let ending_in_select = "UPDATE users SET active = 1; SELECT id FROM users"; + assert!(query_returns_result_set(ending_in_select)); + assert!(!query_can_be_paginated(ending_in_select)); + assert!(!query_reports_affected_rows(ending_in_select)); + + let ending_in_dml = "SELECT id FROM users; DELETE FROM users WHERE active = 0"; + assert!(query_returns_result_set(ending_in_dml)); + assert!(!query_can_be_paginated(ending_in_dml)); + assert!(query_reports_affected_rows(ending_in_dml)); } #[test] @@ -340,6 +372,9 @@ fn result_set_classification_ignores_literals_comments_and_identifiers() { assert!(!query_returns_result_set( "UPDATE [SELECT] SET [value] = '; SELECT 1' -- ; SELECT 2" )); + assert!(!query_returns_result_set( + "UPDATE users SET note = 'order by id' /* order by id; SELECT 1 */" + )); } #[test] @@ -372,31 +407,45 @@ fn paginated_query_adds_order_when_missing() { } #[test] -fn paginated_query_preserves_existing_order() { - assert_eq!( - build_paginated_query("SELECT * FROM [users] ORDER BY [id]", 10, 1), - "SELECT * FROM [users] ORDER BY [id] OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY" - ); +fn paginated_query_preserves_top_level_order_with_sql_whitespace() { + for query in [ + "SELECT * FROM [users] ORDER BY [id]", + "SELECT * FROM [users] ORDER\nBY [id]", + "SELECT * FROM [users] ORDER /* stable key */ BY [id]", + ] { + let paginated = build_paginated_query(query, 10, 1); + assert!( + !paginated.contains("ORDER BY (SELECT NULL)"), + "got {paginated}" + ); + assert!( + paginated.ends_with("OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY"), + "got {paginated}" + ); + } } #[test] -fn paginated_query_ignores_nested_order() { - assert_eq!( - build_paginated_query( - "SELECT * FROM (SELECT TOP 5 * FROM [users] ORDER BY [id]) AS [recent]", - 10, - 1, - ), - "SELECT * FROM (SELECT TOP 5 * FROM [users] ORDER BY [id]) AS [recent] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY" - ); +fn paginated_query_ignores_nested_order_in_subquery_and_window() { + for query in [ + "SELECT * FROM (SELECT TOP 5 * FROM [users] ORDER BY [id]) AS [recent]", + "SELECT ROW_NUMBER() OVER (ORDER BY [id]) AS [position] FROM [users]", + ] { + let paginated = build_paginated_query(query, 10, 1); + assert!( + paginated.contains("ORDER BY (SELECT NULL) OFFSET"), + "got {paginated}" + ); + } } #[test] -fn paginated_query_ignores_order_by_in_literals_and_comments() { +fn paginated_query_ignores_order_words_in_literals_and_comments() { for query in [ + "SELECT 'order' AS [label]", "SELECT 'ORDER BY' AS [label]", - "SELECT 1 -- ORDER BY [id]", - "SELECT 1 /* ORDER BY [id] */", + "SELECT 1 -- order by [id]", + "SELECT 1 /* the word order and ORDER BY [id] */", "SELECT N'città ORDER BY nome'", ] { let paginated = build_paginated_query(query, 10, 1); diff --git a/tests/live_db.rs b/tests/live_db.rs index c01425f..989aa8a 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -656,32 +656,90 @@ fn identity_insert_succeeds_and_failure_restores_session_state() { } #[test] -fn pagination_returns_ordered_pages_has_more_and_explicit_unknown_total() { +fn pagination_and_batch_semantics_cover_ordered_unordered_cte_and_dml() { let mut plugin = Plugin::with_scratch_database(); - plugin.reset_table("pagination", "id INT PRIMARY KEY"); + plugin.reset_table( + "pagination", + "id INT PRIMARY KEY, touched BIT NOT NULL DEFAULT 0", + ); plugin.execute(format!( - "INSERT INTO [{TEST_SCHEMA}].[pagination] VALUES (1), (2), (3), (4), (5)" + "INSERT INTO [{TEST_SCHEMA}].[pagination] (id) VALUES (1), (2), (3), (4), (5)" )); - let query = format!("SELECT id FROM [{TEST_SCHEMA}].[pagination] ORDER BY id"); + let ordered_query = format!("SELECT id FROM [{TEST_SCHEMA}].[pagination] ORDER BY id"); let page_one = plugin.call_ok( "execute_query", - json!({ "params": connection_params(), "query": query, "limit": 2, "page": 1 }), + json!({ "params": connection_params(), "query": ordered_query, "limit": 2, "page": 1 }), ); assert_eq!(page_one["rows"], json!([[1], [2]])); assert_eq!(page_one["pagination"]["page"], 1); assert_eq!(page_one["pagination"]["page_size"], 2); assert_eq!(page_one["pagination"]["has_more"], true); assert_eq!(page_one["pagination"]["total_rows"], Value::Null); + assert_eq!(page_one["truncated"], true); + assert!(page_one.get("additional_results").is_none()); + + let final_page = plugin.call_ok( + "execute_query", + json!({ "params": connection_params(), "query": ordered_query, "limit": 2, "page": 3 }), + ); + assert_eq!(final_page["rows"], json!([[5]])); + assert_eq!(final_page["pagination"]["has_more"], false); + assert_eq!(final_page["truncated"], false); + + let unordered = plugin.call_ok( + "execute_query", + json!({ + "params": connection_params(), + "query": format!("SELECT id FROM [{TEST_SCHEMA}].[pagination]"), + "limit": 2, + "page": 1 + }), + ); + assert_eq!(result_rows(&unordered).len(), 2); + assert_eq!(unordered["pagination"]["has_more"], true); + assert_eq!(unordered["pagination"]["total_rows"], Value::Null); - let page_two = plugin.call_ok( + let cte = plugin.call_ok( "execute_query", - json!({ "params": connection_params(), "query": query, "limit": 2, "page": 2 }), + json!({ + "params": connection_params(), + "query": format!( + "WITH source AS (SELECT id FROM [{TEST_SCHEMA}].[pagination] WHERE id >= 2) \ + SELECT id FROM source ORDER BY id DESC" + ), + "limit": 2, + "page": 2 + }), + ); + assert_eq!(cte["rows"], json!([[3], [2]])); + assert_eq!(cte["pagination"]["has_more"], false); + + let batch = plugin.call_ok( + "execute_query_batch", + json!({ + "params": connection_params(), + "queries": [ + format!("UPDATE [{TEST_SCHEMA}].[pagination] SET touched = 1 WHERE id = 1"), + ordered_query, + format!( + "SELECT id INTO #ss041_selected FROM [{TEST_SCHEMA}].[pagination] WHERE id <= 3" + ), + "SELECT id FROM #ss041_selected ORDER BY id" + ], + "limit": 2, + "page": 1 + }), ); - assert_eq!(page_two["rows"], json!([[3], [4]])); - assert_eq!(page_two["pagination"]["page"], 2); - assert_eq!(page_two["pagination"]["has_more"], true); - assert_eq!(page_two["pagination"]["total_rows"], Value::Null); + assert_eq!(batch[0]["result"]["affected_rows"], 1); + assert!(batch[0]["result"].get("additional_results").is_none()); + assert_eq!(batch[1]["result"]["rows"], json!([[1], [2]])); + assert_eq!(batch[1]["result"]["pagination"]["has_more"], true); + assert_eq!(batch[2]["result"]["affected_rows"], 3); + assert_eq!(batch[2]["result"]["rows"], json!([])); + assert!(batch[2]["result"].get("additional_results").is_none()); + assert_eq!(batch[3]["result"]["rows"], json!([[1], [2]])); + assert_eq!(batch[3]["result"]["pagination"]["has_more"], true); } #[test] From 52f321f41cb89fc886dda06858572de7e6b625e1 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 22:26:57 +0000 Subject: [PATCH 17/23] fix(types): harden SQL Server round trips --- Cargo.lock | 1 - Cargo.toml | 1 - README.md | 23 +- src/driver/blob.rs | 31 ++ src/driver/blob/tests.rs | 12 + src/driver/extract/mod.rs | 146 +++++-- src/driver/extract/temporal.rs | 13 + src/driver/extract/temporal/tests.rs | 16 + src/driver/extract/tests.rs | 26 +- src/driver/helpers.rs | 71 +++- src/driver/helpers/tests.rs | 37 ++ src/driver/ops.rs | 64 ++- tests/live_db.rs | 561 +++++++++++++++++++++++++++ 13 files changed, 942 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73a13f5..40d6957 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1275,7 +1275,6 @@ dependencies = [ "mssql-tds-preview", "mssql-tiberius-bridge", "once_cell", - "rust_decimal", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 3d52e11..e66b2ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,6 @@ mssql-tiberius-bridge = "=0.1.0-preview.3" # authentication is disabled because this plugin supports SQL auth only. mssql-tds-preview = { version = "=0.1.0-preview.1", default-features = false } once_cell = "1" -rust_decimal = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md index 5162641..b56da4f 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,28 @@ Generic DDL types emitted by Tabularis map to SQL Server-native spellings. In particular, generic `TIMESTAMP` maps to `DATETIME2`; SQL Server's own `TIMESTAMP` type remains a deprecated `ROWVERSION` synonym, not a date/time. -`BIGINT` values outside JavaScript's safe integer range are delivered as strings so they round-trip without precision loss. +`BIGINT` values outside JavaScript's safe integer range and all exact +`DECIMAL`, `NUMERIC`, `MONEY`, and `SMALLMONEY` values are delivered as +strings so they round-trip without precision loss. `TIME(7)`, `DATETIME2(7)`, +and `DATETIMEOFFSET(7)` preserve 100-nanosecond precision; legacy `DATETIME` +is rendered at SQL Server's `.000`, `.003`, or `.007` second granularity. +`SQL_VARIANT` is emitted using the JSON representation of its contained value. + +Binary values in query grids use the host BLOB wire shape +`BLOB:::` and the same shape is accepted by insert and +update. SQL Server CLR UDTs (`HIERARCHYID`, `GEOGRAPHY`, and `GEOMETRY`) are +losslessly displayed in that opaque binary shape because the TDS bridge does +not expose their type-specific value APIs. Protocol clients can write those +columns with the explicit raw row-edit shape +`{"value":"","is_raw":true}`; ordinary values remain bound +parameters. `ROWVERSION` and its deprecated `TIMESTAMP` synonym are read-only, +server-generated eight-byte values: omit them on insert and do not update +them. + +The pinned client can decode the newer native TDS `JSON` and `VECTOR` wire +types, and unit tests protect those paths. They are not advertised for column +creation while SQL Server 2022 is the plugin's live-test and release baseline; +JSON documents remain supported through `NVARCHAR(MAX)` on that server. ### Binary export and preview diff --git a/src/driver/blob.rs b/src/driver/blob.rs index 7ab9498..2b13386 100644 --- a/src/driver/blob.rs +++ b/src/driver/blob.rs @@ -113,6 +113,37 @@ pub fn encode_blob_full(data: &[u8], max_preview_size: u64) -> Result Result>, String> { + if !value.starts_with("BLOB:") { + return Ok(None); + } + let mut fields = value.splitn(4, ':'); + let _prefix = fields.next(); + let declared_size = fields + .next() + .and_then(|size| size.parse::().ok()) + .ok_or_else(|| "SQL Server BLOB value has an invalid size".to_string())?; + let _mime_type = fields + .next() + .filter(|mime| !mime.is_empty()) + .ok_or_else(|| "SQL Server BLOB value has an empty MIME type".to_string())?; + let payload = fields + .next() + .ok_or_else(|| "SQL Server BLOB value has no base64 payload".to_string())?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload) + .map_err(|error| format!("SQL Server BLOB value has invalid base64: {error}"))?; + if bytes.len() != declared_size { + return Err(format!( + "SQL Server BLOB value declares {declared_size} bytes but contains {} bytes", + bytes.len() + )); + } + Ok(Some(bytes)) +} + pub fn validate_writable_file_path(file_path: &str) -> Result<(), String> { if file_path.trim().is_empty() { return Err("file_path must not be empty".to_string()); diff --git a/src/driver/blob/tests.rs b/src/driver/blob/tests.rs index bda9681..bab110c 100644 --- a/src/driver/blob/tests.rs +++ b/src/driver/blob/tests.rs @@ -9,6 +9,18 @@ fn wire_format_contains_size_mime_and_base64() { ); } +#[test] +fn wire_format_decodes_for_row_editing_and_checks_declared_size() { + assert_eq!( + decode_blob_wire("BLOB:4:application/octet-stream:yv66vg==").unwrap(), + Some(vec![0xCA, 0xFE, 0xBA, 0xBE]) + ); + assert_eq!(decode_blob_wire("ordinary text").unwrap(), None); + assert!(decode_blob_wire("BLOB:3:application/octet-stream:yv66vg==") + .unwrap_err() + .contains("declares 3 bytes")); +} + #[test] fn wire_format_sniffs_png_magic_bytes() { let png_signature = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; diff --git a/src/driver/extract/mod.rs b/src/driver/extract/mod.rs index 06545df..fb04f7d 100644 --- a/src/driver/extract/mod.rs +++ b/src/driver/extract/mod.rs @@ -15,7 +15,6 @@ use mssql_tds::datatypes::sql_json::SqlJson; use mssql_tds::datatypes::sql_vector::SqlVector; use mssql_tds::datatypes::sqldatatypes::TdsDataType; use mssql_tiberius_bridge::{ColumnType, Row}; -use rust_decimal::Decimal; use serde_json::Value; use uuid::Uuid; @@ -30,6 +29,11 @@ pub fn normalized_column_type(tds_type: TdsDataType, byte_length: usize) -> Colu TdsDataType::BigChar => ColumnType::Char, TdsDataType::BigBinary => ColumnType::Binary, TdsDataType::DateTimeN if byte_length == 4 => ColumnType::Datetime4, + // SQL Server's hierarchyid, geography, and geometry arrive as UDT + // payloads. The bridge decodes those payloads into ColumnValues::Bytes + // but has no UDT ColumnType, so expose their lossless serialized form + // through the same BLOB wire representation as binary columns. + TdsDataType::Udt => ColumnType::BigVarBin, other => ColumnType::from_tds_with_length(other, byte_length), } } @@ -89,12 +93,23 @@ pub fn extract_value_as(row: &Row, idx: usize, column_type: ColumnType) -> Resul }, // Temporal - ColumnType::Datetime | ColumnType::Datetime4 => { - match row.try_get::(idx) { - Ok(Some(v)) => Value::String(temporal::format_datetime(&v)), - _ => Value::Null, + ColumnType::Datetime => match row.raw_value(idx) { + Some(ColumnValues::DateTime(value)) => { + temporal::format_legacy_datetime(value.days, value.time) + .map(Value::String) + .ok_or_else(|| "SQL Server returned an invalid DATETIME value".to_string())? } - } + Some(ColumnValues::Null) | None => Value::Null, + Some(other) => { + return Err(format!( + "SQL Server returned {other:?} for a DATETIME column" + )) + } + }, + ColumnType::Datetime4 => match row.try_get::(idx) { + Ok(Some(v)) => Value::String(temporal::format_datetime(&v)), + _ => Value::Null, + }, ColumnType::Datetime2 => match row.try_get::(idx) { Ok(Some(v)) => Value::String(temporal::format_datetime(&v)), _ => Value::Null, @@ -136,8 +151,8 @@ pub fn extract_value_as(row: &Row, idx: usize, column_type: ColumnType) -> Resul _ => Value::Null, }, - // sql_variant remains a best-effort textual fallback. - ColumnType::Ssvariant => read_string(row, idx), + // sql_variant carries the contained value's concrete TDS variant. + ColumnType::Ssvariant => return read_variant(row, idx), }; Ok(value) } @@ -166,21 +181,15 @@ fn read_numeric_as_string(row: &Row, idx: usize) -> Result { } fn numeric_value_to_json(value: &ColumnValues) -> Result { - let decimal = match value { + let raw = match value { ColumnValues::Null => return Ok(Value::Null), - ColumnValues::Decimal(parts) | ColumnValues::Numeric(parts) => { - let raw = parts.to_string(); - raw.parse::().map_err(|error| { - format!( - "SQL Server decimal({}, {}) value {raw} exceeds rust_decimal's exact range: {error}", - parts.precision, parts.scale - ) - })? - } - ColumnValues::SmallMoney(money) => Decimal::new(i64::from(money.int_val), 4), + // DecimalParts already preserves all 38 SQL Server digits. Parsing it + // through rust_decimal first imposed an unrelated 29-digit ceiling. + ColumnValues::Decimal(parts) | ColumnValues::Numeric(parts) => parts.to_string(), + ColumnValues::SmallMoney(money) => fixed_scale_4(i64::from(money.int_val)), ColumnValues::Money(money) => { let raw = (i64::from(money.msb_part) << 32) | i64::from(money.lsb_part as u32); - Decimal::new(raw, 4) + fixed_scale_4(raw) } other => { return Err(format!( @@ -188,9 +197,84 @@ fn numeric_value_to_json(value: &ColumnValues) -> Result { )) } }; - Ok(Value::String(normalize_decimal_string( - &decimal.to_string(), - ))) + Ok(Value::String(normalize_decimal_string(&raw))) +} + +fn fixed_scale_4(raw: i64) -> String { + let raw = i128::from(raw); + let sign = if raw < 0 { "-" } else { "" }; + let magnitude = raw.abs(); + format!("{sign}{}.{:04}", magnitude / 10_000, magnitude % 10_000) +} + +fn read_variant(row: &Row, idx: usize) -> Result { + let value = row + .raw_value(idx) + .ok_or_else(|| format!("SQL Server sql_variant column index {idx} is out of bounds"))?; + let json = match value { + ColumnValues::Null => Value::Null, + ColumnValues::TinyInt(value) => Value::from(*value), + ColumnValues::SmallInt(value) => Value::from(*value), + ColumnValues::Int(value) => Value::from(*value), + ColumnValues::BigInt(value) => i64_to_json(*value), + ColumnValues::Real(value) => f64_to_json(f64::from(*value)), + ColumnValues::Float(value) => f64_to_json(*value), + ColumnValues::Decimal(_) + | ColumnValues::Numeric(_) + | ColumnValues::SmallMoney(_) + | ColumnValues::Money(_) => return numeric_value_to_json(value), + ColumnValues::Bit(value) => Value::Bool(*value), + ColumnValues::String(value) => Value::String(value.to_utf8_string()), + ColumnValues::Bytes(value) => binary_to_json(value), + ColumnValues::Uuid(value) => Value::String(value.to_string()), + ColumnValues::Xml(value) => Value::String(value.as_string()), + ColumnValues::Json(value) => json_to_json(value)?, + ColumnValues::Vector(value) => vector_to_json(value), + ColumnValues::DateTime(value) => temporal::format_legacy_datetime(value.days, value.time) + .map(Value::String) + .ok_or_else(|| "SQL Server returned an invalid sql_variant DATETIME".to_string())?, + ColumnValues::SmallDateTime(_) => match row.try_get::(idx) { + Ok(Some(value)) => Value::String(temporal::format_datetime(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant smalldatetime: {error:?}" + )) + } + }, + ColumnValues::DateTime2(_) => match row.try_get::(idx) { + Ok(Some(value)) => Value::String(temporal::format_datetime(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant datetime2: {error:?}" + )) + } + }, + ColumnValues::DateTimeOffset(_) => match row.try_get::, _>(idx) { + Ok(Some(value)) => Value::String(temporal::format_datetime_offset(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant datetimeoffset: {error:?}" + )) + } + }, + ColumnValues::Date(_) => match row.try_get::(idx) { + Ok(Some(value)) => Value::String(temporal::format_date(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant date: {error:?}" + )) + } + }, + ColumnValues::Time(_) => match row.try_get::(idx) { + Ok(Some(value)) => Value::String(temporal::format_time(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant time: {error:?}" + )) + } + }, + }; + Ok(json) } fn json_to_json(json: &SqlJson) -> Result { @@ -211,16 +295,22 @@ fn vector_to_json(vector: &SqlVector) -> Value { } fn read_binary_as_base64(row: &Row, idx: usize) -> Value { - use base64::Engine as _; match row.try_get::<&[u8], _>(idx) { - Ok(Some(bytes)) => Value::String(format!( - "base64:{}", - base64::engine::general_purpose::STANDARD.encode(bytes) - )), + Ok(Some(bytes)) => binary_to_json(bytes), _ => Value::Null, } } +fn binary_to_json(bytes: &[u8]) -> Value { + // Query grids and the dedicated preview RPC share the host's BLOB wire + // shape. The grid path has already received the bytes from SQL Server, so + // it has no useful pre-transfer size limit to apply here. + Value::String( + crate::driver::blob::encode_blob_full(bytes, u64::MAX) + .expect("u64::MAX cannot reject an in-memory BLOB"), + ) +} + // --- Pure helpers (testable) --------------------------------------------- /// Convert a `f64` to a JSON number, falling back to string for non-finite diff --git a/src/driver/extract/temporal.rs b/src/driver/extract/temporal.rs index f77ba74..920d36a 100644 --- a/src/driver/extract/temporal.rs +++ b/src/driver/extract/temporal.rs @@ -45,6 +45,19 @@ pub fn format_datetime(dt: &NaiveDateTime) -> String { } } +/// Decode SQL Server's legacy DATETIME ticks using its documented display +/// granularity (.000, .003, or .007 seconds). The bridge truncates 1/300 +/// second ticks to milliseconds, which incorrectly renders .006 and .996. +pub fn format_legacy_datetime(days: i32, ticks: u32) -> Option { + let base = NaiveDate::from_ymd_opt(1900, 1, 1)?; + let date = base.checked_add_signed(chrono::Duration::days(i64::from(days)))?; + let total_millis = (u64::from(ticks) * 10 + 1) / 3; + let seconds = u32::try_from(total_millis / 1_000).ok()?; + let nanos = u32::try_from(total_millis % 1_000).ok()? * 1_000_000; + let time = NaiveTime::from_num_seconds_from_midnight_opt(seconds, nanos)?; + Some(format_datetime(&NaiveDateTime::new(date, time))) +} + /// Format a `DateTime` as RFC3339 with fractional seconds when /// present. `datetimeoffset` is the only SQL Server temporal type that /// carries a zone; we keep the zone explicit so round-tripping is safe. diff --git a/src/driver/extract/temporal/tests.rs b/src/driver/extract/temporal/tests.rs index e87bd09..8df196d 100644 --- a/src/driver/extract/temporal/tests.rs +++ b/src/driver/extract/temporal/tests.rs @@ -63,6 +63,22 @@ fn datetime_with_fraction_trims_trailing_zeros() { ); } +#[test] +fn legacy_datetime_rounds_three_hundredth_second_ticks() { + assert_eq!( + format_legacy_datetime(0, 1).as_deref(), + Some("1900-01-01 00:00:00.003") + ); + assert_eq!( + format_legacy_datetime(0, 2).as_deref(), + Some("1900-01-01 00:00:00.007") + ); + assert_eq!( + format_legacy_datetime(0, 3).as_deref(), + Some("1900-01-01 00:00:00.01") + ); +} + #[test] fn datetime_epoch_value() { assert_eq!( diff --git a/src/driver/extract/tests.rs b/src/driver/extract/tests.rs index 66ac1a3..9445584 100644 --- a/src/driver/extract/tests.rs +++ b/src/driver/extract/tests.rs @@ -56,9 +56,9 @@ fn column_type_normalization_matches_the_replaced_tiberius_dispatch() { (TdsDataType::Image, ColumnType::Image), (TdsDataType::Xml, ColumnType::Xml), (TdsDataType::SsVariant, ColumnType::Ssvariant), - // The bridge has no UDT variant; the old best-effort string decode - // also produced null for the binary CLR payloads we receive. - (TdsDataType::Udt, ColumnType::Null), + // The bridge has no UDT ColumnType, but it decodes CLR payloads as + // bytes, so the plugin exposes them losslessly as BLOB values. + (TdsDataType::Udt, ColumnType::BigVarBin), (TdsDataType::None, ColumnType::Null), ]; @@ -86,7 +86,7 @@ fn column_type_normalization_covers_fixed_and_big_wire_names() { } #[test] -fn exact_decimal_within_rust_decimal_range_is_preserved() { +fn exact_decimal_is_preserved() { let parts = DecimalParts::from_string("123.4500", 10, 4).unwrap(); let value = numeric_value_to_json(&ColumnValues::Decimal(parts)).unwrap(); @@ -94,12 +94,14 @@ fn exact_decimal_within_rust_decimal_range_is_preserved() { } #[test] -fn decimal_beyond_rust_decimal_range_fails_loudly() { +fn decimal_38_preserves_every_digit() { let parts = DecimalParts::from_string("99999999999999999999999999999999999999", 38, 0).unwrap(); - let error = numeric_value_to_json(&ColumnValues::Numeric(parts)).unwrap_err(); + let value = numeric_value_to_json(&ColumnValues::Numeric(parts)).unwrap(); - assert!(error.contains("decimal(38, 0)")); - assert!(error.contains("exceeds rust_decimal's exact range")); + assert_eq!( + value, + Value::String("99999999999999999999999999999999999999".into()) + ); } #[test] @@ -118,6 +120,14 @@ fn money_and_smallmoney_are_exact_decimal_strings() { assert_eq!(money, Value::String("123.4567".into())); } +#[test] +fn binary_values_use_the_host_blob_wire_format() { + assert_eq!( + binary_to_json(&[0xde, 0xad, 0xbe, 0xef]), + Value::String("BLOB:4:application/octet-stream:3q2+7w==".into()) + ); +} + #[test] fn json_values_have_a_defined_text_representation() { let json = SqlJson::new(br#"{"ok":true}"#.to_vec()); diff --git a/src/driver/helpers.rs b/src/driver/helpers.rs index 31c1e13..acb72dd 100644 --- a/src/driver/helpers.rs +++ b/src/driver/helpers.rs @@ -104,19 +104,32 @@ pub fn build_insert_sql( columns: &[String], wrap_identity_insert: bool, ) -> String { + let expressions: Vec = (1..=columns.len()).map(|i| format!("@P{i}")).collect(); + build_insert_sql_with_expressions(schema, table, columns, &expressions, wrap_identity_insert) +} + +/// Build an INSERT whose value expressions have already been classified. +/// Most entries are positional parameters; an explicitly marked `is_raw` +/// edit may supply a SQL expression instead. +pub fn build_insert_sql_with_expressions( + schema: Option<&str>, + table: &str, + columns: &[String], + expressions: &[String], + wrap_identity_insert: bool, +) -> String { + debug_assert_eq!(columns.len(), expressions.len()); let target = qualify(schema, table); let col_list = columns .iter() .map(|c| bracket_quote(c)) .collect::>() .join(", "); - let placeholders = (1..=columns.len()) - .map(|i| format!("@P{}", i)) - .collect::>() - .join(", "); let insert = format!( "INSERT INTO {} ({}) VALUES ({})", - target, col_list, placeholders + target, + col_list, + expressions.join(", ") ); if wrap_identity_insert { @@ -197,13 +210,43 @@ pub fn value_to_sql_param( .ok_or_else(|| format!("Invalid SQL Server numeric value: {number}")) } } - serde_json::Value::String(value) => Ok(Box::new(value.clone())), + serde_json::Value::String(value) => match crate::driver::blob::decode_blob_wire(value)? { + Some(bytes) => Ok(Box::new(bytes)), + None => Ok(Box::new(value.clone())), + }, serde_json::Value::Array(_) | serde_json::Value::Object(_) => { Ok(Box::new(value.to_string())) } } } +/// Return the SQL expression from the explicit row-edit raw-value shape. +/// Ordinary JSON objects remain bindable JSON values; only `is_raw: true` +/// opts into expression insertion. +pub fn raw_sql_expression(value: &serde_json::Value) -> Result, String> { + // An untyped SQL NULL is assignable to every nullable SQL Server type; + // the bridge's fallback NVARCHAR NULL parameter is not (notably binary + // and CLR UDT columns reject that implicit conversion). + if value.is_null() { + return Ok(Some("NULL")); + } + let serde_json::Value::Object(object) = value else { + return Ok(None); + }; + if object.get("is_raw") != Some(&serde_json::Value::Bool(true)) { + return Ok(None); + } + let expression = object + .get("value") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|expression| !expression.is_empty()) + .ok_or_else(|| { + "SQL Server raw row-edit values require a non-empty string 'value'".to_string() + })?; + Ok(Some(expression)) +} + /// Build a parameterised `WHERE` clause for a composite primary key. /// /// `pk_cols` are bracket-quoted; each column is bound to an ordinal marker @@ -259,11 +302,23 @@ pub fn build_update_composite_sql( col_name: &str, pk_cols: &[String], ) -> Option { - let where_clause = build_pk_where_clause(pk_cols, 2)?; + build_update_composite_sql_with_expression(schema, table, col_name, "@P1", pk_cols, 2) +} + +pub fn build_update_composite_sql_with_expression( + schema: Option<&str>, + table: &str, + col_name: &str, + value_expression: &str, + pk_cols: &[String], + first_pk_marker: usize, +) -> Option { + let where_clause = build_pk_where_clause(pk_cols, first_pk_marker)?; Some(format!( - "UPDATE {} SET {} = @P1 WHERE {}", + "UPDATE {} SET {} = {} WHERE {}", qualify(schema, table), bracket_quote(col_name), + value_expression, where_clause )) } diff --git a/src/driver/helpers/tests.rs b/src/driver/helpers/tests.rs index 6cbf7aa..c57f09e 100644 --- a/src/driver/helpers/tests.rs +++ b/src/driver/helpers/tests.rs @@ -166,6 +166,43 @@ fn value_to_sql_param_accepts_supported_json_variants() { } } +#[test] +fn raw_row_edit_shape_is_explicit_and_null_is_untyped() { + let raw = serde_json::json!({ + "value": "geometry::STGeomFromText('POINT (1 2)', 0)", + "is_raw": true + }); + assert_eq!( + raw_sql_expression(&raw).unwrap(), + Some("geometry::STGeomFromText('POINT (1 2)', 0)") + ); + assert_eq!( + raw_sql_expression(&serde_json::Value::Null).unwrap(), + Some("NULL") + ); + assert_eq!( + raw_sql_expression(&serde_json::json!({"value": "still JSON"})).unwrap(), + None + ); + assert!(raw_sql_expression(&serde_json::json!({"value": 1, "is_raw": true})).is_err()); +} + +#[test] +fn explicit_insert_expressions_do_not_consume_parameter_markers() { + let sql = build_insert_sql_with_expressions( + Some("dbo"), + "spatial", + &["id".into(), "shape".into(), "note".into()], + &[ + "@P1".into(), + "geometry::STGeomFromText('POINT (1 2)', 0)".into(), + "@P2".into(), + ], + false, + ); + assert!(sql.contains("VALUES (@P1, geometry::STGeomFromText('POINT (1 2)', 0), @P2)")); +} + // --- composite PK SQL builders (issue #145) ---------------------------- #[test] diff --git a/src/driver/ops.rs b/src/driver/ops.rs index cece9dc..abec3b5 100644 --- a/src/driver/ops.rs +++ b/src/driver/ops.rs @@ -6,7 +6,8 @@ use std::collections::HashMap; use mssql_tiberius_bridge::ToSql; use crate::driver::helpers::{ - bracket_quote, build_delete_composite_sql, build_update_composite_sql, qualify, + bracket_quote, build_delete_composite_sql, build_insert_sql_with_expressions, + build_update_composite_sql_with_expression, qualify, raw_sql_expression, }; use crate::driver::{ acquire, blob, ddl, execute_on_connection, explain, helpers, introspection, routines, triggers, @@ -436,15 +437,35 @@ pub async fn insert_record( .map(|id| columns.iter().any(|c| c.eq_ignore_ascii_case(id))) .unwrap_or(false); - let sql = helpers::build_insert_sql(schema, table, &columns, needs_identity_insert); - - // Map each JSON value to a typed SQL parameter. Owned boxes live - // for the duration of the call so the borrowed `&dyn ToSql` slice is - // valid. - let owned_params: Vec> = columns + // Most values become parameters. The host may explicitly mark a value as + // raw SQL (`{ "value": "...", "is_raw": true }`) for server-side type + // constructors such as hierarchyid::Parse; raw values consume no marker. + let mut owned_params: Vec> = Vec::new(); + let mut expressions = Vec::with_capacity(columns.len()); + for column in &columns { + let value = &data[column]; + if let Some(expression) = raw_sql_expression(value)? { + expressions.push(expression.to_string()); + } else { + owned_params.push(helpers::value_to_sql_param(value)?); + expressions.push(format!("@P{}", owned_params.len())); + } + } + let sql = if expressions .iter() - .map(|column| helpers::value_to_sql_param(&data[column])) - .collect::>()?; + .enumerate() + .all(|(index, expression)| expression == &format!("@P{}", index + 1)) + { + helpers::build_insert_sql(schema, table, &columns, needs_identity_insert) + } else { + build_insert_sql_with_expressions( + schema, + table, + &columns, + &expressions, + needs_identity_insert, + ) + }; let params_slice: Vec<&dyn mssql_tiberius_bridge::ToSql> = owned_params.iter().map(|b| b.as_ref()).collect(); @@ -469,11 +490,28 @@ pub async fn update_record( .iter() .map(|(column, _)| (*column).clone()) .collect(); - let sql = build_update_composite_sql(schema, table, col_name, &pk_columns) - .ok_or_else(|| "SQL Server: UPDATE requires at least one primary-key column".to_string())?; - let mut owned_params = Vec::with_capacity(primary_keys.len() + 1); - owned_params.push(helpers::value_to_sql_param(&new_val)?); + let value_expression = if let Some(expression) = raw_sql_expression(&new_val)? { + expression.to_string() + } else { + owned_params.push(helpers::value_to_sql_param(&new_val)?); + "@P1".to_string() + }; + let first_pk_marker = owned_params.len() + 1; + let sql = if value_expression == "@P1" { + helpers::build_update_composite_sql(schema, table, col_name, &pk_columns) + } else { + build_update_composite_sql_with_expression( + schema, + table, + col_name, + &value_expression, + &pk_columns, + first_pk_marker, + ) + } + .ok_or_else(|| "SQL Server: UPDATE requires at least one primary-key column".to_string())?; + for (_, value) in primary_keys { owned_params.push(helpers::value_to_sql_param(value)?); } diff --git a/tests/live_db.rs b/tests/live_db.rs index 989aa8a..57c4887 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -219,6 +219,128 @@ fn result_rows(result: &Value) -> &Vec { .expect("query result must contain a rows array") } +fn blob_wire(bytes: &[u8]) -> Value { + json!(format!( + "BLOB:{}:application/octet-stream:{}", + bytes.len(), + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +fn raw_sql(expression: &str) -> Value { + json!({ "value": expression, "is_raw": true }) +} + +#[derive(Clone)] +enum ExpectedCell { + Exact(Value), + Approx(f64), + Blob { exact_size: Option }, +} + +struct TypeCase { + advertised_name: &'static str, + ddl: &'static str, + insert: Value, + inserted: ExpectedCell, + boundary: Value, + bounded: ExpectedCell, + semantic_check: Option<(&'static str, Value)>, +} + +impl TypeCase { + fn exact( + advertised_name: &'static str, + ddl: &'static str, + insert: Value, + inserted: Value, + boundary: Value, + bounded: Value, + ) -> Self { + Self { + advertised_name, + ddl, + insert, + inserted: ExpectedCell::Exact(inserted), + boundary, + bounded: ExpectedCell::Exact(bounded), + semantic_check: None, + } + } +} + +fn assert_cell(case: &TypeCase, label: &str, actual: &Value, expected: &ExpectedCell) { + assert_ne!( + actual, + &Value::Null, + "{} {label} silently decoded as null", + case.advertised_name + ); + match expected { + ExpectedCell::Exact(expected) => assert_eq!( + actual, expected, + "{} {label} representation", + case.advertised_name + ), + ExpectedCell::Approx(expected) => { + let actual = actual + .as_f64() + .unwrap_or_else(|| panic!("{} {label} must be numeric", case.advertised_name)); + let relative_error = ((actual - expected) / expected).abs(); + assert!( + relative_error <= f64::from(f32::EPSILON), + "{} {label}: expected approximately {expected}, got {actual}", + case.advertised_name + ); + } + ExpectedCell::Blob { exact_size } => { + let wire = actual.as_str().unwrap_or_else(|| { + panic!("{} {label} must be a BLOB string", case.advertised_name) + }); + let mut fields = wire.splitn(4, ':'); + assert_eq!( + fields.next(), + Some("BLOB"), + "{} {label}", + case.advertised_name + ); + let size = fields + .next() + .and_then(|size| size.parse::().ok()) + .unwrap_or_else(|| { + panic!("{} {label} has invalid BLOB size", case.advertised_name) + }); + assert!( + size > 0, + "{} {label} BLOB must not be empty", + case.advertised_name + ); + assert_eq!( + fields.next(), + Some("application/octet-stream"), + "{} {label} MIME type", + case.advertised_name + ); + let decoded = base64::engine::general_purpose::STANDARD + .decode(fields.next().expect("BLOB payload")) + .expect("BLOB base64"); + assert_eq!( + decoded.len(), + size, + "{} {label} byte count", + case.advertised_name + ); + if let Some(expected_size) = exact_size { + assert_eq!( + size, *expected_size, + "{} {label} size", + case.advertised_name + ); + } + } + } +} + fn generated_create_table_sql( plugin: &mut Plugin, table_name: &str, @@ -378,6 +500,445 @@ fn ddl_creates_identity_composite_and_all_data_type_categories() { assert_eq!(names.len(), 7); } +fn advertised_type_cases() -> Vec { + vec![ + TypeCase::exact( + "TINYINT", + "TINYINT", + json!(42), + json!(42), + json!(255), + json!(255), + ), + TypeCase::exact( + "SMALLINT", + "SMALLINT", + json!(-123), + json!(-123), + json!(-32768), + json!(-32768), + ), + TypeCase::exact( + "INT", + "INT", + json!(123456), + json!(123456), + json!(2147483647), + json!(2147483647), + ), + TypeCase::exact( + "BIGINT", + "BIGINT", + json!("9007199254740992"), + json!("9007199254740992"), + json!("-9223372036854775808"), + json!("-9223372036854775808"), + ), + TypeCase::exact( + "DECIMAL", + "DECIMAL(38,10)", + json!("1234567890123456789012345678.1234567890"), + json!("1234567890123456789012345678.123456789"), + json!("-9999999999999999999999999999.9999999999"), + json!("-9999999999999999999999999999.9999999999"), + ), + TypeCase::exact( + "NUMERIC", + "NUMERIC(38,0)", + json!("90071992547409931234567890123456789012"), + json!("90071992547409931234567890123456789012"), + json!("99999999999999999999999999999999999999"), + json!("99999999999999999999999999999999999999"), + ), + TypeCase::exact( + "SMALLMONEY", + "SMALLMONEY", + json!("12.3456"), + json!("12.3456"), + json!("-214748.3648"), + json!("-214748.3648"), + ), + TypeCase::exact( + "MONEY", + "MONEY", + json!("-12.3400"), + json!("-12.34"), + json!("922337203685477.5807"), + json!("922337203685477.5807"), + ), + TypeCase::exact( + "FLOAT", + "FLOAT", + json!(1.25), + json!(1.25), + json!(1.7976931348623157e308), + json!(1.7976931348623157e308), + ), + TypeCase { + advertised_name: "REAL", + ddl: "REAL", + insert: json!(1.25), + inserted: ExpectedCell::Exact(json!(1.25)), + boundary: json!(3.4028235e38), + bounded: ExpectedCell::Approx(3.4028235e38), + semantic_check: None, + }, + TypeCase::exact( + "CHAR", + "CHAR(5)", + json!("abc"), + json!("abc "), + json!("12345"), + json!("12345"), + ), + TypeCase::exact( + "VARCHAR", + "VARCHAR(8)", + json!("plain"), + json!("plain"), + json!("edge'123"), + json!("edge'123"), + ), + TypeCase::exact( + "VARCHAR(MAX)", + "VARCHAR(MAX)", + json!("max text"), + json!("max text"), + json!("boundary text"), + json!("boundary text"), + ), + TypeCase::exact( + "TEXT", + "TEXT", + json!("legacy text"), + json!("legacy text"), + json!("legacy boundary"), + json!("legacy boundary"), + ), + TypeCase::exact( + "NCHAR", + "NCHAR(4)", + json!("猫"), + json!("猫 "), + json!("猫犬鳥魚"), + json!("猫犬鳥魚"), + ), + TypeCase::exact( + "NVARCHAR", + "NVARCHAR(16)", + json!("Grüße 🦀"), + json!("Grüße 🦀"), + json!("東京"), + json!("東京"), + ), + TypeCase::exact( + "NVARCHAR(MAX)", + "NVARCHAR(MAX)", + json!("Unicode Ω"), + json!("Unicode Ω"), + json!("boundary 🦀"), + json!("boundary 🦀"), + ), + TypeCase::exact( + "NTEXT", + "NTEXT", + json!("legacy Ω"), + json!("legacy Ω"), + json!("旧式"), + json!("旧式"), + ), + TypeCase::exact( + "BINARY", + "BINARY(4)", + blob_wire(&[1, 2]), + blob_wire(&[1, 2, 0, 0]), + blob_wire(&[0xde, 0xad, 0xbe, 0xef]), + blob_wire(&[0xde, 0xad, 0xbe, 0xef]), + ), + TypeCase::exact( + "VARBINARY", + "VARBINARY(8)", + blob_wire(&[1, 2, 3]), + blob_wire(&[1, 2, 3]), + blob_wire(&[0, 1, 2, 3, 4, 5, 6, 7]), + blob_wire(&[0, 1, 2, 3, 4, 5, 6, 7]), + ), + TypeCase::exact( + "VARBINARY(MAX)", + "VARBINARY(MAX)", + blob_wire(&[0xca, 0xfe]), + blob_wire(&[0xca, 0xfe]), + blob_wire(&[0xde, 0xad, 0xbe, 0xef]), + blob_wire(&[0xde, 0xad, 0xbe, 0xef]), + ), + TypeCase::exact( + "IMAGE", + "IMAGE", + blob_wire(&[9, 8, 7]), + blob_wire(&[9, 8, 7]), + blob_wire(&[6, 5, 4, 3]), + blob_wire(&[6, 5, 4, 3]), + ), + TypeCase::exact( + "DATE", + "DATE", + json!("2024-02-29"), + json!("2024-02-29"), + json!("0001-01-01"), + json!("0001-01-01"), + ), + TypeCase::exact( + "TIME", + "TIME(7)", + json!("12:34:56.1234567"), + json!("12:34:56.1234567"), + json!("23:59:59.9999999"), + json!("23:59:59.9999999"), + ), + TypeCase::exact( + "DATETIME", + "DATETIME", + json!("2024-01-02 03:04:05.006"), + json!("2024-01-02 03:04:05.007"), + json!("9999-12-31 23:59:59.997"), + json!("9999-12-31 23:59:59.997"), + ), + TypeCase::exact( + "DATETIME2", + "DATETIME2(7)", + json!("2024-01-02 03:04:05.1234567"), + json!("2024-01-02 03:04:05.1234567"), + json!("9999-12-31 23:59:59.9999999"), + json!("9999-12-31 23:59:59.9999999"), + ), + TypeCase::exact( + "SMALLDATETIME", + "SMALLDATETIME", + json!("2024-01-02 12:34:31"), + json!("2024-01-02 12:35:00"), + json!("1900-01-01 00:00:00"), + json!("1900-01-01 00:00:00"), + ), + TypeCase::exact( + "DATETIMEOFFSET", + "DATETIMEOFFSET(7)", + json!("2024-01-02 03:04:05.1234567 +05:30"), + json!("2024-01-02T03:04:05.123456700+05:30"), + json!("9999-12-31 23:59:59.9999999 +14:00"), + json!("9999-12-31T23:59:59.999999900+14:00"), + ), + TypeCase::exact( + "BIT", + "BIT", + json!(true), + json!(true), + json!(false), + json!(false), + ), + TypeCase::exact( + "UNIQUEIDENTIFIER", + "UNIQUEIDENTIFIER", + json!("00112233-4455-6677-8899-aabbccddeeff"), + json!("00112233-4455-6677-8899-aabbccddeeff"), + json!("ffffffff-ffff-ffff-ffff-ffffffffffff"), + json!("ffffffff-ffff-ffff-ffff-ffffffffffff"), + ), + TypeCase::exact( + "XML", + "XML", + json!("text"), + json!("text"), + json!(""), + json!(""), + ), + TypeCase::exact( + "SQL_VARIANT", + "SQL_VARIANT", + json!("variant text"), + json!("variant text"), + raw_sql("CAST(2147483647 AS INT)"), + json!(2147483647), + ), + TypeCase { + advertised_name: "HIERARCHYID", + ddl: "HIERARCHYID", + insert: raw_sql("hierarchyid::Parse('/1/3/')"), + inserted: ExpectedCell::Blob { exact_size: None }, + boundary: raw_sql("hierarchyid::Parse('/9/')"), + bounded: ExpectedCell::Blob { exact_size: None }, + semantic_check: Some(("value.ToString()", json!("/9/"))), + }, + TypeCase { + advertised_name: "GEOGRAPHY", + ddl: "GEOGRAPHY", + insert: raw_sql("geography::STGeomFromText('POINT (-122.35 47.65)', 4326)"), + inserted: ExpectedCell::Blob { exact_size: None }, + boundary: raw_sql("geography::STGeomFromText('POINT (180 90)', 4326)"), + bounded: ExpectedCell::Blob { exact_size: None }, + semantic_check: Some(("value.STSrid", json!(4326))), + }, + TypeCase { + advertised_name: "GEOMETRY", + ddl: "GEOMETRY", + insert: raw_sql("geometry::STGeomFromText('LINESTRING (0 0, 3 4)', 0)"), + inserted: ExpectedCell::Blob { exact_size: None }, + boundary: raw_sql("geometry::STGeomFromText('POINT (1 2)', 0)"), + bounded: ExpectedCell::Blob { exact_size: None }, + semantic_check: Some(("value.ToString()", json!("POINT (1 2)"))), + }, + ] +} + +#[test] +fn advertised_types_round_trip_through_query_insert_update_and_null() { + let manifest: Value = serde_json::from_str(include_str!("../.tabularium")) + .expect(".tabularium must be valid JSON"); + let advertised: BTreeSet = manifest["data_types"] + .as_array() + .expect("manifest data_types") + .iter() + .map(|data_type| data_type["name"].as_str().expect("type name").to_string()) + .collect(); + let cases = advertised_type_cases(); + let covered: BTreeSet = cases + .iter() + .map(|case| case.advertised_name.to_string()) + .chain(["ROWVERSION".to_string(), "TIMESTAMP".to_string()]) + .collect(); + assert_eq!( + covered, advertised, + "live matrix must cover every advertised type" + ); + assert!( + !advertised.contains("JSON") && !advertised.contains("VECTOR"), + "native JSON and VECTOR require a SQL Server version newer than the 2022 release baseline" + ); + + let mut plugin = Plugin::with_scratch_database(); + for (index, case) in cases.iter().enumerate() { + let table = format!("type_{index:02}"); + plugin.reset_table( + &table, + &format!("id INT PRIMARY KEY, value {} NULL", case.ddl), + ); + for (id, value) in [ + (1, case.insert.clone()), + (2, Value::Null), + (3, case.insert.clone()), + ] { + assert_eq!( + plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "data": { "id": id, "value": value } + }), + ), + json!(1), + "{} insert id {id}", + case.advertised_name + ); + } + assert_eq!( + plugin.call_ok( + "update_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "pk_map": { "id": 3 }, "col_name": "value", + "new_val": case.boundary.clone() + }), + ), + json!(1), + "{} update", + case.advertised_name + ); + + let result = plugin.execute(format!( + "SELECT value FROM [{TEST_SCHEMA}].[{table}] ORDER BY id" + )); + let rows = result_rows(&result); + assert_eq!(rows.len(), 3, "{} row count", case.advertised_name); + assert_cell(case, "representative", &rows[0][0], &case.inserted); + assert_eq!( + rows[1][0], + Value::Null, + "{} SQL NULL representation", + case.advertised_name + ); + assert_cell(case, "boundary", &rows[2][0], &case.bounded); + + if let Some((expression, expected)) = &case.semantic_check { + let semantic = plugin.execute(format!( + "SELECT {expression} FROM [{TEST_SCHEMA}].[{table}] WHERE id = 3" + )); + assert_eq!( + semantic["rows"][0][0], *expected, + "{} raw-expression write semantics", + case.advertised_name + ); + } + } + + // ROWVERSION and its TIMESTAMP synonym are generated concurrency tokens: + // they have a defined eight-byte read representation but deliberately no + // NULL, insert-value, or update-value direction. + for (index, type_name) in ["ROWVERSION", "TIMESTAMP"].iter().enumerate() { + let table = format!("type_readonly_{index}"); + plugin.reset_table(&table, &format!("id INT PRIMARY KEY, value {type_name}")); + for id in [1, 2] { + plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "data": { "id": id } + }), + ); + } + let result = plugin.execute(format!( + "SELECT value FROM [{TEST_SCHEMA}].[{table}] ORDER BY id" + )); + for row in result_rows(&result) { + let case = TypeCase { + advertised_name: type_name, + ddl: type_name, + insert: Value::Null, + inserted: ExpectedCell::Blob { + exact_size: Some(8), + }, + boundary: Value::Null, + bounded: ExpectedCell::Blob { + exact_size: Some(8), + }, + semantic_check: None, + }; + assert_cell(&case, "generated value", &row[0], &case.inserted); + } + let insert_error = plugin.call_error( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "data": { "id": 3, "value": blob_wire(&[0; 8]) } + }), + ); + assert!( + insert_error.to_ascii_lowercase().contains("timestamp"), + "{type_name} must reject explicit host inserts: {insert_error}" + ); + let update_error = plugin.call_error( + "update_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "pk_map": { "id": 1 }, "col_name": "value", + "new_val": blob_wire(&[0; 8]) + }), + ); + assert!( + update_error.to_ascii_lowercase().contains("timestamp"), + "{type_name} must reject host updates: {update_error}" + ); + } +} + #[test] fn crud_insert_update_and_delete_support_single_and_composite_primary_keys() { let mut plugin = Plugin::with_scratch_database(); From c98bd902a4b7612546fdf72244509a5f1450809e Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 23:03:36 +0000 Subject: [PATCH 18/23] fix(errors): harden mapping and session recovery --- src/connection.rs | 40 +++- src/driver/error.rs | 259 ++++++++++++++++++++++++ src/driver/mod.rs | 27 ++- src/driver/pool.rs | 92 +++++++-- src/driver/pool/tests.rs | 4 +- src/pool_manager.rs | 13 ++ src/rpc.rs | 11 +- tests/live_db.rs | 423 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 826 insertions(+), 43 deletions(-) create mode 100644 src/driver/error.rs diff --git a/src/connection.rs b/src/connection.rs index 96573eb..8667c77 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -160,7 +160,9 @@ impl ParsedConnectionString { }); parsed.username = Some(percent_decode(username, false)?); if let Some(password) = password { - parsed.password = Some(percent_decode(password, false)?); + parsed.password = Some(percent_decode(password, false).map_err(|_| { + "invalid percent encoding in SQL Server URL password".to_string() + })?); } host_port } else { @@ -228,7 +230,7 @@ impl ParsedConnectionString { "userid" | "uid" | "user" | "username" => { set_string(&mut self.username, value, "username")?; } - "password" | "pwd" => set_string(&mut self.password, value, "password")?, + "password" | "pwd" => set_sensitive_string(&mut self.password, value, "password")?, "encrypt" => { let encrypt = parse_encrypt(&value)?; set_value(&mut self.encrypt, encrypt, "Encrypt")?; @@ -568,6 +570,22 @@ fn set_string(slot: &mut Option, value: String, field: &str) -> Result<( Ok(()) } +fn set_sensitive_string( + slot: &mut Option, + value: String, + field: &str, +) -> Result<(), String> { + if slot.as_ref().is_some_and(|existing| existing != &value) { + return Err(format!( + "SQL Server connection string specifies conflicting {field} values '' and ''" + )); + } + if slot.is_none() { + *slot = Some(value); + } + Ok(()) +} + fn set_value(slot: &mut Option, value: T, field: &str) -> Result<(), String> where T: Copy + PartialEq + std::fmt::Debug, @@ -745,6 +763,24 @@ mod tests { assert!(error.contains("from-string")); } + #[test] + fn password_parse_errors_never_echo_credentials() { + for (connection_string, secret) in [ + ( + "Server=localhost;Password=FirstSecret!;Password=SecondSecret!", + "FirstSecret!", + ), + ( + "sqlserver://sa:Malformed%ZZSecret@localhost/master", + "Malformed", + ), + ] { + let error = resolve_connection_params(¶ms(connection_string)).unwrap_err(); + assert!(!error.contains(secret), "{error}"); + assert!(!error.contains(connection_string), "{error}"); + } + } + #[test] fn malformed_connection_strings_are_rejected() { for connection_string in [ diff --git a/src/driver/error.rs b/src/driver/error.rs new file mode 100644 index 0000000..a68eac2 --- /dev/null +++ b/src/driver/error.rs @@ -0,0 +1,259 @@ +//! User-facing SQL Server error formatting and credential redaction. + +use deadpool::managed::{PoolError, TimeoutType}; +use mssql_tds::error::{Error as TdsError, SqlErrorInfo}; +use mssql_tiberius_bridge::Error as BridgeError; + +use crate::models::ConnectionParams; + +/// Format a bridge error without losing SQL Server's structured error token. +pub fn format_bridge_error(error: &BridgeError, ssl_mode: Option<&str>) -> String { + match error { + BridgeError::Tds(error) => format_tds_error(error, ssl_mode), + BridgeError::Conversion(message) + if message + .trim_start() + .to_ascii_lowercase() + .starts_with("query timed out") => + { + format!("SQL Server timeout: {message}") + } + BridgeError::ColumnNotFound(_) + | BridgeError::ColumnIndexOutOfBounds { .. } + | BridgeError::Conversion(_) => format!("SQL Server data conversion failure: {error}"), + BridgeError::Pool(message) => format!("SQL Server connection-pool failure: {message}"), + } +} + +/// Format errors from the underlying Microsoft TDS client. +pub fn format_tds_error(error: &TdsError, ssl_mode: Option<&str>) -> String { + match error { + TdsError::SqlServerError { errors } => format_server_errors(errors), + TdsError::TlsError(_) + | TdsError::TlsHandshakeError { .. } + | TdsError::CertificateNotFound { .. } + | TdsError::InvalidCertificateFormat { .. } + | TdsError::CertificateExpired + | TdsError::CertificateMismatch + | TdsError::CertificateFileIoError { .. } + | TdsError::NoServerCertificate => format_tls_error(error, ssl_mode), + TdsError::TimeoutError(_) => format!("SQL Server timeout: {error}"), + TdsError::Io(_) + | TdsError::ConnectionError(_) + | TdsError::ConnectionClosed(_) + | TdsError::Redirection { .. } + | TdsError::SessionRecoveryFailed { .. } + | TdsError::SessionNotRecoverable(_) + | TdsError::ReconnectionValidationFailed(_) => { + format!("SQL Server connection failure: {error}") + } + TdsError::Security(_) => format!("SQL Server authentication failure: {error}"), + TdsError::ProtocolError(_) + | TdsError::OperationCancelledError(_) + | TdsError::UsageError(_) + | TdsError::ImplementationError(_) + | TdsError::UnimplementedFeature { .. } + | TdsError::TypeConversionError(_) + | TdsError::UnsupportedEncoding { .. } + | TdsError::BulkCopyError(_) => format!("SQL Server driver failure: {error}"), + } +} + +/// Format a deadpool checkout failure, preserving backend error structure. +pub fn format_pool_error(error: &PoolError, ssl_mode: Option<&str>) -> String { + match error { + PoolError::Backend(error) => format_bridge_error(error, ssl_mode), + PoolError::Timeout(kind) => { + let operation = match kind { + TimeoutType::Wait => "waiting for a pooled connection", + TimeoutType::Create => "opening a connection", + TimeoutType::Recycle => "resetting a pooled connection", + }; + format!("SQL Server timeout while {operation}") + } + PoolError::Closed => "SQL Server connection-pool failure: pool is closed".to_string(), + PoolError::NoRuntimeSpecified => { + "SQL Server connection-pool failure: no async runtime is configured".to_string() + } + PoolError::PostCreateHook(_) => { + "SQL Server connection-pool failure: post-create validation failed".to_string() + } + } +} + +/// Remove connection secrets defensively before an error crosses JSON-RPC. +pub fn redact_connection_secrets(message: String, params: &ConnectionParams) -> String { + let mut redacted = message; + for secret in [ + params.password.as_deref(), + params.connection_string.as_deref(), + ] + .into_iter() + .flatten() + .filter(|secret| !secret.is_empty()) + { + redacted = redacted.replace(secret, ""); + } + redacted +} + +/// A server, timeout, or transport failure can leave unread TDS packets +/// behind. Such a connection must be discarded instead of reset and reused. +pub fn bridge_error_requires_discard(error: &BridgeError) -> bool { + match error { + BridgeError::Conversion(message) => message + .trim_start() + .to_ascii_lowercase() + .starts_with("query timed out"), + BridgeError::Tds(error) => matches!( + error, + TdsError::Io(_) + | TdsError::ConnectionError(_) + | TdsError::SqlServerError { .. } + | TdsError::ConnectionClosed(_) + | TdsError::ProtocolError(_) + | TdsError::TlsError(_) + | TdsError::TlsHandshakeError { .. } + | TdsError::TimeoutError(_) + | TdsError::OperationCancelledError(_) + | TdsError::SessionRecoveryFailed { .. } + | TdsError::SessionNotRecoverable(_) + | TdsError::ReconnectionValidationFailed(_) + ), + _ => false, + } +} + +fn format_server_errors(errors: &[SqlErrorInfo]) -> String { + if errors.is_empty() { + return "SQL Server error: no server error details were returned".to_string(); + } + errors + .iter() + .map(format_server_error) + .collect::>() + .join("; ") +} + +fn format_server_error(error: &SqlErrorInfo) -> String { + let mut details = vec![ + server_error_category(error.number).to_string(), + format!("severity {}", error.class), + format!("state {}", error.state), + ]; + if let Some(procedure) = error.proc_name.as_deref().filter(|name| !name.is_empty()) { + details.push(format!("procedure '{procedure}'")); + } + if let Some(line) = error.line_number.filter(|line| *line > 0) { + details.push(format!("line {line}")); + } + format!( + "SQL Server error {}: {} [{}]", + error.number, + error.message.trim(), + details.join("; ") + ) +} + +fn server_error_category(number: u32) -> &'static str { + match number { + 1205 => "deadlock victim", + 18452 | 18456 | 4060 => "authentication failure", + 229 | 230 | 262 | 300 | 916 => "permission denial", + 515 | 547 | 1505 | 2601 | 2627 => "constraint violation", + 1222 => "timeout", + 102 | 105 | 156 => "syntax error", + _ => "statement failure", + } +} + +fn format_tls_error(error: &TdsError, ssl_mode: Option<&str>) -> String { + let ssl_mode = ssl_mode.unwrap_or("prefer"); + let advice = match ssl_mode { + "verify-full" | "verify_identity" => { + "install a certificate trusted by the system trust store, or try ssl_mode 'require' for a self-signed development server" + } + "disable" | "disabled" => { + "try ssl_mode 'require' if the server requires encryption" + } + _ => { + "check the server TLS configuration, or try ssl_mode 'disable' only on a trusted development network" + } + }; + format!( + "SQL Server TLS negotiation failure with ssl_mode '{ssl_mode}': {error}. To recover, {advice}." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::DatabaseSelection; + + fn server_error(number: u32, line: i32) -> BridgeError { + BridgeError::Tds(TdsError::from_sql_error(SqlErrorInfo { + message: "fixture message".into(), + state: 2, + class: 16, + number, + server_name: Some("fixture-server".into()), + proc_name: Some("fixture_proc".into()), + line_number: Some(line), + })) + } + + #[test] + fn structured_server_error_leads_with_number_text_and_keeps_details() { + let message = format_bridge_error(&server_error(102, 7), None); + assert!(message.starts_with("SQL Server error 102: fixture message")); + assert!(message.contains("syntax error")); + assert!(message.contains("severity 16")); + assert!(message.contains("state 2")); + assert!(message.contains("procedure 'fixture_proc'")); + assert!(message.contains("line 7")); + } + + #[test] + fn server_error_categories_are_distinct() { + for (number, category) in [ + (18456, "authentication failure"), + (229, "permission denial"), + (2627, "constraint violation"), + (1205, "deadlock victim"), + (1222, "timeout"), + (102, "syntax error"), + ] { + assert!( + format_bridge_error(&server_error(number, 1), None).contains(category), + "error {number}" + ); + } + } + + #[test] + fn tls_error_names_mode_and_action() { + let error = BridgeError::Tds(TdsError::CertificateMismatch); + let message = format_bridge_error(&error, Some("verify-full")); + assert!(message.contains("TLS negotiation failure")); + assert!(message.contains("ssl_mode 'verify-full'")); + assert!(message.contains("ssl_mode 'require'")); + } + + #[test] + fn redaction_removes_password_and_connection_string() { + let connection_string = "Server=db;User Id=sa;Password=NeverExposeThis!;Encrypt=true"; + let params = ConnectionParams { + password: Some("NeverExposeThis!".into()), + connection_string: Some(connection_string.into()), + database: DatabaseSelection::Single("master".into()), + ..Default::default() + }; + let message = redact_connection_secrets( + format!("failed with NeverExposeThis! from {connection_string}"), + ¶ms, + ); + assert!(!message.contains("NeverExposeThis!")); + assert!(!message.contains(connection_string)); + assert!(message.contains("")); + } +} diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 2ffc21c..8072429 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -6,6 +6,7 @@ pub mod blob; pub mod ddl; +pub mod error; pub mod explain; pub mod extract; pub mod helpers; @@ -23,14 +24,25 @@ use mssql_tiberius_bridge::row::RowSchema; use mssql_tiberius_bridge::Row; use crate::models::{ConnectionParams, Pagination, QueryResult}; -use crate::pool_manager::get_sqlserver_pool; +use crate::pool_manager::{self, get_sqlserver_pool}; /// Acquire a pooled client from the pool manager. pub async fn acquire( params: &ConnectionParams, ) -> Result, String> { - let pool = get_sqlserver_pool(params).await?; - pool.get().await.map_err(|e| e.to_string()) + let pool = get_sqlserver_pool(params) + .await + .map_err(|message| error::redact_connection_secrets(message, params))?; + match pool.get().await { + Ok(connection) => Ok(connection), + Err(pool_error) => { + // A pool whose manager cannot connect must not pin stale host, + // credential, or TLS configuration under a stable connection id. + pool_manager::remove_sqlserver_pool(params).await; + let message = error::format_pool_error(&pool_error, params.ssl_mode.as_deref()); + Err(error::redact_connection_secrets(message, params)) + } + } } fn empty_query_result(columns: Vec) -> QueryResult { @@ -58,17 +70,18 @@ async fn run_query_collecting( query: &str, ) -> Result, String> { let query_timeout_seconds = conn.query_timeout_seconds(); + let ssl_mode = conn.ssl_mode().to_owned(); let client = conn.inner_mut(); // Drain any leftover state from a prior query / dropped stream so we // don't hit "open batch" errors when re-using the client. client .close_query() .await - .map_err(|error| error.to_string())?; + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))?; client .execute(query.to_string(), query_timeout_seconds, None) .await - .map_err(|error| error.to_string())?; + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))?; let mut results = Vec::new(); while let Some(result_set) = client.get_current_resultset() { @@ -83,7 +96,7 @@ async fn run_query_collecting( while let Some(values) = result_set .next_row() .await - .map_err(|error| error.to_string())? + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))? { let row = Row::from_schema(schema.clone(), values); current.rows.push( @@ -104,7 +117,7 @@ async fn run_query_collecting( if !client .move_to_next() .await - .map_err(|error| error.to_string())? + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))? { break; } diff --git a/src/driver/pool.rs b/src/driver/pool.rs index a165011..350233d 100644 --- a/src/driver/pool.rs +++ b/src/driver/pool.rs @@ -14,6 +14,7 @@ use std::future::Future; use std::time::Duration; use crate::connection::{custom_ca_error, resolve_connection_params}; +use crate::driver::error::{bridge_error_requires_discard, format_bridge_error}; use crate::models::ConnectionParams; use crate::settings::PluginSettings; use deadpool::managed::{Manager, Metrics, RecycleError, RecycleResult}; @@ -27,6 +28,8 @@ use tokio::time::timeout; pub struct BridgeConnection { client: Client, query_timeout_seconds: Option, + ssl_mode: String, + recyclable: bool, } impl BridgeConnection { @@ -44,27 +47,57 @@ impl BridgeConnection { } } - pub async fn simple_query(&mut self, sql: impl Into) -> Result { + async fn simple_query_raw(&mut self, sql: impl Into) -> Result { let operation = self.client.simple_query(sql.into()); Self::with_query_timeout(self.query_timeout_seconds, operation).await } + pub async fn simple_query(&mut self, sql: impl Into) -> Result { + let result = self.simple_query_raw(sql).await; + self.discard_if_transaction_open(); + self.format_result(result) + } + pub async fn query( &mut self, sql: impl Into, params: &[&dyn ToSql], - ) -> Result { + ) -> Result { let operation = self.client.query(sql.into(), params); - Self::with_query_timeout(self.query_timeout_seconds, operation).await + let result = Self::with_query_timeout(self.query_timeout_seconds, operation).await; + self.discard_if_transaction_open(); + self.format_result(result) } pub async fn execute( &mut self, sql: impl Into, params: &[&dyn ToSql], - ) -> Result { + ) -> Result { let operation = self.client.execute(sql.into(), params); - Self::with_query_timeout(self.query_timeout_seconds, operation).await + let result = Self::with_query_timeout(self.query_timeout_seconds, operation).await; + self.discard_if_transaction_open(); + self.format_result(result) + } + + fn discard_if_transaction_open(&mut self) { + if self.client.inner_mut().has_active_transaction() { + self.recyclable = false; + } + } + + fn format_result(&mut self, result: Result) -> Result { + result.map_err(|error| { + if bridge_error_requires_discard(&error) + || self.client.inner_mut().has_active_transaction() + { + // Dropping the socket lets SQL Server roll back the + // transaction and release temp/session state without trying + // to issue reset commands into an errored transaction stream. + self.recyclable = false; + } + format_bridge_error(&error, Some(&self.ssl_mode)) + }) } pub fn inner_mut(&mut self) -> &mut TdsClient { @@ -74,6 +107,10 @@ impl BridgeConnection { pub fn query_timeout_seconds(&self) -> Option { self.query_timeout_seconds } + + pub fn ssl_mode(&self) -> &str { + &self.ssl_mode + } } /// Deadpool `Manager` for bridge connections. @@ -81,15 +118,22 @@ impl BridgeConnection { pub struct BridgeManager { config: Config, startup_script: Option, + ssl_mode: String, connect_timeout: Duration, query_timeout_seconds: Option, } impl BridgeManager { - pub fn new(config: Config, startup_script: Option, settings: &PluginSettings) -> Self { + pub fn new( + config: Config, + startup_script: Option, + ssl_mode: &str, + settings: &PluginSettings, + ) -> Self { Self { config, startup_script, + ssl_mode: ssl_mode.to_owned(), connect_timeout: Duration::from_secs(u64::from(settings.connect_timeout_seconds)), query_timeout_seconds: settings .query_timeout() @@ -99,19 +143,12 @@ impl BridgeManager { async fn apply_startup_script(&self, conn: &mut BridgeConnection) -> Result<(), Error> { if let Some(script) = self.startup_script.as_deref() { - conn.simple_query(script) - .await - .map_err(startup_script_error)? - .into_results(); + conn.simple_query_raw(script).await?.into_results(); } Ok(()) } } -fn startup_script_error(error: Error) -> Error { - Error::Conversion(format!("Startup script failed: {error}")) -} - impl Manager for BridgeManager { type Type = BridgeConnection; @@ -129,16 +166,35 @@ impl Manager for BridgeManager { let mut connection = BridgeConnection { client, query_timeout_seconds: self.query_timeout_seconds, + ssl_mode: self.ssl_mode.clone(), + recyclable: true, }; self.apply_startup_script(&mut connection).await?; Ok(connection) } async fn recycle(&self, conn: &mut Self::Type, _: &Metrics) -> RecycleResult { - // Reset transaction, temporary-object, and SET state before another - // caller receives this physical session, then restore its configured - // startup script. - conn.simple_query("EXEC sp_reset_connection") + if !conn.recyclable || conn.client.inner_mut().has_active_transaction() { + return Err(RecycleError::message( + "connection was discarded after an error, timeout, transport failure, or open transaction", + )); + } + + // SHOWPLAN must be disabled in its own batches: while SHOWPLAN_XML is + // active SQL Server plans later statements instead of executing them, + // including sp_reset_connection. Open transactions are discarded + // above; for reusable sessions the reset drops local temp tables, + // disables IDENTITY_INSERT and restores SET options before the startup + // script is reapplied. + conn.simple_query_raw("SET SHOWPLAN_XML OFF") + .await + .map_err(RecycleError::Backend)? + .into_results(); + conn.simple_query_raw("SET STATISTICS XML OFF") + .await + .map_err(RecycleError::Backend)? + .into_results(); + conn.simple_query_raw("EXEC sp_reset_connection") .await .map_err(RecycleError::Backend)? .into_results(); diff --git a/src/driver/pool/tests.rs b/src/driver/pool/tests.rs index 2d08b62..4d2b6e6 100644 --- a/src/driver/pool/tests.rs +++ b/src/driver/pool/tests.rs @@ -70,7 +70,7 @@ fn manager_new_stores_config() { &settings, ) .expect("config builds"); - let mgr = BridgeManager::new(cfg, Some("SET NOCOUNT ON".into()), &settings); + let mgr = BridgeManager::new(cfg, Some("SET NOCOUNT ON".into()), "prefer", &settings); let cloned = mgr.clone(); let original = format!("{:?}", mgr); let cloned_dbg = format!("{:?}", cloned); @@ -107,7 +107,7 @@ fn manager_snapshots_timeout_settings() { &settings, ) .expect("config builds"); - let manager = BridgeManager::new(cfg, None, &settings); + let manager = BridgeManager::new(cfg, None, "prefer", &settings); assert_eq!(manager.connect_timeout, Duration::from_secs(8)); assert_eq!(manager.query_timeout_seconds, Some(42)); diff --git a/src/pool_manager.rs b/src/pool_manager.rs index bcb94e9..23b6cc2 100644 --- a/src/pool_manager.rs +++ b/src/pool_manager.rs @@ -69,6 +69,7 @@ pub async fn get_sqlserver_pool(params: &ConnectionParams) -> Result Result(id: Value, outcome: Result) -> Value { /// Deserialize the nested `params.params` connection object every RPC method /// receives. pub fn conn_params(params: &Value) -> Result { - let params = serde_json::from_value(params.get("params").cloned().unwrap_or(Value::Null)) - .map_err(|err| format!("invalid connection params: {err}"))?; - resolve_connection_params(¶ms) - .map_err(|error| format!("invalid connection params: {error}")) + let params: ConnectionParams = + serde_json::from_value(params.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|err| format!("invalid connection params: {err}"))?; + resolve_connection_params(¶ms).map_err(|error| { + redact_connection_secrets(format!("invalid connection params: {error}"), ¶ms) + }) } pub fn opt_str<'a>(params: &'a Value, key: &str) -> Option<&'a str> { diff --git a/tests/live_db.rs b/tests/live_db.rs index 57c4887..8e20619 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -133,7 +133,7 @@ impl Plugin { plugin } - fn call(&mut self, method: &str, params: Value) -> Value { + fn send(&mut self, method: &str, params: Value) -> u64 { let id = self.next_id; self.next_id += 1; let request = json!({ @@ -148,7 +148,10 @@ impl Plugin { .write_all(line.as_bytes()) .expect("write request to plugin stdin"); self.stdin.flush().expect("flush plugin stdin"); + id + } + fn read_response(&mut self) -> Value { let mut response_line = String::new(); self.stdout .read_line(&mut response_line) @@ -157,8 +160,12 @@ impl Plugin { !response_line.is_empty(), "plugin exited without a response" ); - let response: Value = - serde_json::from_str(response_line.trim()).expect("parse JSON-RPC response"); + serde_json::from_str(response_line.trim()).expect("parse JSON-RPC response") + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.send(method, params); + let response = self.read_response(); assert_eq!( response.get("id").and_then(Value::as_u64), Some(id), @@ -190,13 +197,17 @@ impl Plugin { .to_string() } - fn execute(&mut self, query: impl Into) -> Value { + fn execute_with(&mut self, params: &Value, query: impl Into) -> Value { self.call_ok( "execute_query", - json!({ "params": connection_params(), "query": query.into() }), + json!({ "params": params, "query": query.into() }), ) } + fn execute(&mut self, query: impl Into) -> Value { + self.execute_with(&connection_params(), query) + } + fn reset_table(&mut self, table: &str, definition: &str) { self.execute(format!( "DROP TABLE IF EXISTS [{TEST_SCHEMA}].[{table}]; \ @@ -1304,7 +1315,7 @@ fn pagination_and_batch_semantics_cover_ordered_unordered_cte_and_dml() { } #[test] -fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { +fn syntax_and_constraint_errors_keep_server_details_and_pool_recovery() { let mut plugin = Plugin::with_scratch_database(); plugin.reset_table( "errors", @@ -1316,9 +1327,17 @@ fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { let syntax_error = plugin.call_error( "execute_query", - json!({ "params": connection_params(), "query": "SELEC definitely_invalid" }), + json!({ + "params": connection_params(), + "query": "\nSELECT 1 +" + }), + ); + assert!( + syntax_error.starts_with("SQL Server error 102:"), + "{syntax_error}" ); - assert!(!syntax_error.is_empty()); + assert!(syntax_error.contains("syntax error"), "{syntax_error}"); + assert!(syntax_error.contains("line 2"), "{syntax_error}"); let after_syntax = plugin.execute("SELECT CAST(1 AS INT) AS connection_ok"); assert_eq!(after_syntax["rows"], json!([[1]])); @@ -1326,16 +1345,400 @@ fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { "execute_query", json!({ "params": connection_params(), - "query": format!("INSERT INTO [{TEST_SCHEMA}].[errors] VALUES (2, 10)") + "query": format!( + "EXEC(N'INSERT INTO [{TEST_SCHEMA}].[errors] VALUES (2, 10)')" + ) }), ); - assert!(!constraint_error.is_empty()); + assert!( + constraint_error.starts_with("SQL Server error 2627:"), + "{constraint_error}" + ); + assert!( + constraint_error.contains("constraint violation"), + "{constraint_error}" + ); let after_constraint = plugin.execute(format!( "SELECT COUNT(*) AS row_count FROM [{TEST_SCHEMA}].[errors]" )); assert_eq!(after_constraint["rows"], json!([[1]])); } +#[test] +fn connection_authentication_and_tls_errors_are_actionable_and_redacted() { + let mut plugin = Plugin::with_scratch_database(); + let valid = connection_params(); + let username = valid["username"].as_str().expect("username"); + let password = valid["password"].as_str().expect("password"); + let host = valid["host"].as_str().expect("host"); + let port = valid["port"].as_u64().expect("port"); + let database = valid["database"].as_str().expect("database"); + + let connection_secret = "Ss043!ConnectionSecret9"; + let failed_connection_string = format!( + "sqlserver://{}:{}@{}:1/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(connection_secret), + host, + url_encode_component(database), + ); + let connection_error = plugin.call_error( + "test_connection", + json!({ + "params": { + "connection_string": failed_connection_string, + "connection_id": "ss043-connection-recovery" + } + }), + ); + assert!( + connection_error.contains("SQL Server connection failure"), + "{connection_error}" + ); + assert!(!connection_error.contains(connection_secret)); + assert!(!connection_error.contains(password)); + assert!(!connection_error.contains(&failed_connection_string)); + + let mut recovered_connection = valid.clone(); + recovered_connection["connection_id"] = json!("ss043-connection-recovery"); + assert_eq!( + plugin.call_ok("test_connection", json!({ "params": recovered_connection })), + json!({ "success": true }) + ); + + let authentication_secret = "Ss043!WrongPassword9"; + let failed_auth_string = format!( + "sqlserver://{}:{}@{}:{}/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(authentication_secret), + host, + port, + url_encode_component(database), + ); + let authentication_error = plugin.call_error( + "test_connection", + json!({ + "params": { + "connection_string": failed_auth_string, + "connection_id": "ss043-auth-recovery" + } + }), + ); + assert!( + authentication_error.starts_with("SQL Server error 18456:"), + "{authentication_error}" + ); + assert!( + authentication_error.contains("authentication failure"), + "{authentication_error}" + ); + assert!(!authentication_error.contains(authentication_secret)); + assert!(!authentication_error.contains(password)); + assert!(!authentication_error.contains(&failed_auth_string)); + + let mut recovered_auth = valid.clone(); + recovered_auth["connection_id"] = json!("ss043-auth-recovery"); + assert_eq!( + plugin.call_ok("test_connection", json!({ "params": recovered_auth })), + json!({ "success": true }) + ); + + let mut verify_full = valid; + verify_full["connection_id"] = json!("ss043-tls"); + verify_full["ssl_mode"] = json!("verify-full"); + let tls_error = plugin.call_error("test_connection", json!({ "params": verify_full })); + assert!( + tls_error.contains("SQL Server TLS negotiation failure"), + "{tls_error}" + ); + assert!(tls_error.contains("ssl_mode 'verify-full'"), "{tls_error}"); + assert!(tls_error.contains("ssl_mode 'require'"), "{tls_error}"); + + // A setup failure has no physical session to recycle, but it must not + // poison healthy pools in the same plugin process. + let after_tls = plugin.execute("SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(after_tls["rows"], json!([[1]])); +} + +#[test] +fn permission_denial_keeps_number_and_pool_recovers() { + const LOGIN: &str = "ss043_denied_login"; + const USER: &str = "ss043_denied_user"; + const PASSWORD: &str = "Ss043!DeniedPassword9"; + + let mut plugin = Plugin::with_scratch_database(); + plugin.execute(format!( + "IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; \ + IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]; \ + DROP TABLE IF EXISTS [{TEST_SCHEMA}].[permission_error]; \ + CREATE TABLE [{TEST_SCHEMA}].[permission_error] (id INT PRIMARY KEY); \ + CREATE LOGIN [{LOGIN}] WITH PASSWORD = N'{PASSWORD}', CHECK_POLICY = OFF; \ + CREATE USER [{USER}] FOR LOGIN [{LOGIN}]; \ + DENY SELECT ON OBJECT::[{TEST_SCHEMA}].[permission_error] TO [{USER}]" + )); + + let mut denied_params = connection_params(); + denied_params["username"] = json!(LOGIN); + denied_params["password"] = json!(PASSWORD); + denied_params["connection_id"] = json!("ss043-permission"); + let permission_error = plugin.call_error( + "execute_query", + json!({ + "params": denied_params, + "query": format!("SELECT id FROM [{TEST_SCHEMA}].[permission_error]") + }), + ); + assert!( + permission_error.starts_with("SQL Server error 229:"), + "{permission_error}" + ); + assert!( + permission_error.contains("permission denial"), + "{permission_error}" + ); + assert!(!permission_error.contains(PASSWORD)); + let recovered = plugin.execute_with(&denied_params, "SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(recovered["rows"], json!([[1]])); + + plugin.call_ok("shutdown", json!({})); + plugin.execute(format!( + "DROP TABLE IF EXISTS [{TEST_SCHEMA}].[permission_error]; \ + IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; \ + IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]" + )); +} + +#[test] +fn timeout_is_named_and_pool_replaces_the_cancelled_session() { + let mut plugin = Plugin::with_scratch_database(); + plugin.call_ok( + "initialize", + json!({ "settings": { "query_timeout_seconds": 1 } }), + ); + let mut params = connection_params(); + params["connection_id"] = json!("ss043-timeout"); + + let timeout_error = plugin.call_error( + "execute_query", + json!({ + "params": params, + "query": "WAITFOR DELAY '00:00:03'; SELECT CAST(1 AS INT) AS too_late" + }), + ); + assert!( + timeout_error.contains("SQL Server timeout"), + "{timeout_error}" + ); + let recovered = plugin.execute_with(¶ms, "SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(recovered["rows"], json!([[1]])); +} + +#[test] +fn recycle_clears_identity_showplan_transaction_and_temp_table_state() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "recycle_identity_first", + "id INT IDENTITY(1,1) PRIMARY KEY, value INT NOT NULL", + ); + plugin.reset_table( + "recycle_identity_second", + "id INT IDENTITY(1,1) PRIMARY KEY, value INT NOT NULL", + ); + let mut params = connection_params(); + params["connection_id"] = json!("ss043-session-state"); + let before = plugin.execute_with(¶ms, "SELECT @@SPID AS session_id"); + let session_id = before["rows"][0][0].as_i64().expect("session id"); + + // Leave identity and temp-table state deliberately active after a + // successful RPC. The next checkout must run the manager's reset on the + // same physical session. + plugin.execute_with( + ¶ms, + format!( + "SET IDENTITY_INSERT [{TEST_SCHEMA}].[recycle_identity_first] ON; \ + CREATE TABLE #ss043_temp (id INT)" + ), + ); + let reset_state = plugin.execute_with( + ¶ms, + "SELECT @@SPID AS session_id, @@TRANCOUNT AS transaction_count, \ + CASE WHEN OBJECT_ID('tempdb..#ss043_temp') IS NULL THEN 0 ELSE 1 END AS temp_exists", + ); + assert_eq!(reset_state["rows"], json!([[session_id, 0, 0]])); + let identity_recovered = plugin.call_ok( + "insert_record", + json!({ + "params": params, "schema": TEST_SCHEMA, "table": "recycle_identity_second", + "data": { "id": 43, "value": 1 } + }), + ); + assert_eq!(identity_recovered, json!(1)); + + plugin.execute_with(¶ms, "SET SHOWPLAN_XML ON"); + let reset_showplan = plugin.execute_with( + ¶ms, + "SELECT @@SPID AS session_id, CAST(1 AS INT) AS connection_ok", + ); + assert_eq!(reset_showplan["rows"], json!([[session_id, 1]])); + + // Open transactions are discarded without issuing commands into their + // session. Closing the socket rolls back and drops local temp objects. + plugin.execute_with( + ¶ms, + "BEGIN TRANSACTION; CREATE TABLE #ss043_open_transaction_temp (id INT)", + ); + let reset_transaction = plugin.execute_with( + ¶ms, + "SELECT @@TRANCOUNT AS transaction_count, \ + CASE WHEN OBJECT_ID('tempdb..#ss043_open_transaction_temp') IS NULL THEN 0 ELSE 1 END AS temp_exists", + ); + assert_eq!(reset_transaction["rows"], json!([[0, 0]])); + + // Server errors are conservatively discarded because an error token can + // leave unread protocol state. Replacement must still be immediate and + // must roll back the transaction and remove its temp table. + let identity_error = plugin.call_error( + "execute_query", + json!({ + "params": params, + "query": format!( + "SET IDENTITY_INSERT [{TEST_SCHEMA}].[recycle_identity_first] ON; \ + THROW 50043, 'identity cleanup fixture', 1" + ) + }), + ); + assert!(identity_error.starts_with("SQL Server error 50043:")); + let after_identity_error = plugin.call_ok( + "insert_record", + json!({ + "params": params, "schema": TEST_SCHEMA, "table": "recycle_identity_second", + "data": { "id": 44, "value": 1 } + }), + ); + assert_eq!(after_identity_error, json!(1)); + + let state_error = plugin.call_error( + "execute_query", + json!({ + "params": params, + "query": "BEGIN TRANSACTION; CREATE TABLE #ss043_error_temp (id INT); SELECT 1 / 0" + }), + ); + assert!( + state_error.starts_with("SQL Server error 8134:"), + "{state_error}" + ); + let state = plugin.execute_with( + ¶ms, + "SELECT @@TRANCOUNT AS transaction_count, \ + CASE WHEN OBJECT_ID('tempdb..#ss043_error_temp') IS NULL THEN 0 ELSE 1 END AS temp_exists", + ); + assert_eq!(state["rows"], json!([[0, 0]])); + + let showplan_error = plugin.call_error( + "explain_query", + json!({ + "params": params, + "query": "SELECT missing_column FROM definitely_missing_table", + "analyze": false + }), + ); + assert!( + showplan_error.starts_with("SQL Server error 208:"), + "{showplan_error}" + ); + let after_showplan = plugin.execute_with(¶ms, "SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(after_showplan["rows"], json!([[1]])); +} + +#[test] +fn deadlock_victim_is_named_and_its_pool_recovers() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table("deadlock_error", "id INT PRIMARY KEY, value INT NOT NULL"); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[deadlock_error] VALUES (1, 0), (2, 0)" + )); + + let mut params_a = connection_params(); + params_a["connection_id"] = json!("ss043-deadlock-a"); + let mut params_b = connection_params(); + params_b["connection_id"] = json!("ss043-deadlock-b"); + let query_a = format!( + "SET DEADLOCK_PRIORITY LOW; BEGIN TRANSACTION; \ + UPDATE [{TEST_SCHEMA}].[deadlock_error] SET value = value + 1 WHERE id = 1; \ + WAITFOR DELAY '00:00:01'; \ + UPDATE [{TEST_SCHEMA}].[deadlock_error] SET value = value + 1 WHERE id = 2; COMMIT" + ); + let query_b = format!( + "BEGIN TRANSACTION; \ + UPDATE [{TEST_SCHEMA}].[deadlock_error] SET value = value + 1 WHERE id = 2; \ + WAITFOR DELAY '00:00:01'; \ + UPDATE [{TEST_SCHEMA}].[deadlock_error] SET value = value + 1 WHERE id = 1; COMMIT" + ); + let id_a = plugin.send( + "execute_query", + json!({ "params": params_a, "query": query_a }), + ); + let id_b = plugin.send( + "execute_query", + json!({ "params": params_b, "query": query_b }), + ); + let first = plugin.read_response(); + let second = plugin.read_response(); + let responses = [first, second]; + let (victim_id, deadlock_error) = responses + .iter() + .find_map(|response| { + let message = response["error"]["message"].as_str()?; + message + .starts_with("SQL Server error 1205:") + .then(|| (response["id"].as_u64().expect("response id"), message)) + }) + .expect("one concurrent transaction must be the deadlock victim"); + assert!( + deadlock_error.starts_with("SQL Server error 1205:"), + "{deadlock_error}" + ); + assert!( + deadlock_error.contains("deadlock victim"), + "{deadlock_error}" + ); + assert_eq!(responses.len(), 2); + + let victim_params = if victim_id == id_a { + ¶ms_a + } else { + ¶ms_b + }; + assert!(victim_id == id_a || victim_id == id_b); + let recovered = plugin.execute_with( + victim_params, + "SELECT @@TRANCOUNT AS transaction_count, CAST(1 AS INT) AS connection_ok", + ); + assert_eq!(recovered["rows"], json!([[0, 1]])); +} + +#[test] +fn killed_pooled_connection_is_detected_and_replaced() { + let mut plugin = Plugin::with_scratch_database(); + let mut victim_params = connection_params(); + victim_params["connection_id"] = json!("ss043-killed-victim"); + let mut killer_params = connection_params(); + killer_params["connection_id"] = json!("ss043-killer"); + + let before = plugin.execute_with(&victim_params, "SELECT @@SPID AS session_id"); + let killed_session = before["rows"][0][0].as_i64().expect("session id"); + plugin.execute_with(&killer_params, format!("KILL {killed_session}")); + + let after = plugin.execute_with( + &victim_params, + "SELECT @@SPID AS session_id, @@TRANCOUNT AS transaction_count, \ + CAST(1 AS INT) AS connection_ok", + ); + assert_eq!(after["rows"][0][1], json!(0)); + assert_eq!(after["rows"][0][2], json!(1)); +} + #[test] fn explain_query_returns_raw_showplan_xml_for_estimate_and_analyze() { let mut plugin = Plugin::with_scratch_database(); From 6f0f55b7f96c240f40f2831e33ce2c1766f336db Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 23:16:06 +0000 Subject: [PATCH 19/23] test(conformance): verify host response models --- .github/workflows/ci.yml | 7 +- docs/completeness.md | 21 +- justfile | 7 +- src/driver/introspection.rs | 13 +- src/driver/introspection/tests.rs | 22 +- src/models.rs | 4 + tests/capture_conformance.py | 508 ++++++++++++++++++ tests/conformance.rs | 431 +++++++++++++++ tests/fixtures/conformance/alter_view.json | 5 + .../conformance/apply_db_user_privileges.json | 5 + .../conformance/build_routine_call_sql.json | 5 + .../fixtures/conformance/create_db_user.json | 5 + .../fixtures/conformance/create_trigger.json | 5 + tests/fixtures/conformance/create_view.json | 5 + tests/fixtures/conformance/delete_record.json | 5 + tests/fixtures/conformance/drop_db_user.json | 5 + .../conformance/drop_foreign_key.json | 5 + tests/fixtures/conformance/drop_index.json | 5 + tests/fixtures/conformance/drop_routine.json | 5 + tests/fixtures/conformance/drop_trigger.json | 5 + tests/fixtures/conformance/drop_view.json | 5 + tests/fixtures/conformance/execute_query.json | 41 ++ .../conformance/execute_query_batch.json | 33 ++ tests/fixtures/conformance/explain_query.json | 10 + .../conformance/fetch_blob_as_data_url.json | 5 + .../conformance/get_add_column_sql.json | 7 + .../conformance/get_ai_schema_context.json | 111 ++++ .../conformance/get_all_columns_batch.json | 115 ++++ .../get_all_foreign_keys_batch.json | 26 + .../conformance/get_alter_column_sql.json | 7 + tests/fixtures/conformance/get_columns.json | 48 ++ .../get_create_foreign_key_sql.json | 7 + .../conformance/get_create_index_sql.json | 7 + .../conformance/get_create_table_sql.json | 7 + tests/fixtures/conformance/get_databases.json | 7 + .../conformance/get_db_privilege_catalog.json | 52 ++ .../conformance/get_db_user_grants.json | 8 + .../conformance/get_db_user_privileges.json | 20 + tests/fixtures/conformance/get_db_users.json | 11 + .../conformance/get_foreign_keys.json | 14 + tests/fixtures/conformance/get_indexes.json | 30 ++ .../conformance/get_routine_definition.json | 5 + .../conformance/get_routine_edit_script.json | 5 + .../conformance/get_routine_parameters.json | 18 + tests/fixtures/conformance/get_routines.json | 16 + .../conformance/get_schema_snapshot.json | 153 ++++++ tests/fixtures/conformance/get_schemas.json | 9 + tests/fixtures/conformance/get_tables.json | 21 + .../conformance/get_trigger_definition.json | 5 + tests/fixtures/conformance/get_triggers.json | 20 + .../conformance/get_view_columns.json | 23 + .../conformance/get_view_definition.json | 5 + tests/fixtures/conformance/get_views.json | 10 + tests/fixtures/conformance/initialize.json | 5 + tests/fixtures/conformance/insert_record.json | 5 + tests/fixtures/conformance/ping.json | 5 + .../conformance/routine_create_template.json | 5 + .../conformance/save_blob_to_file.json | 5 + .../conformance/set_db_user_password.json | 5 + tests/fixtures/conformance/shutdown.json | 5 + .../fixtures/conformance/test_connection.json | 7 + tests/fixtures/conformance/update_record.json | 5 + 62 files changed, 1970 insertions(+), 11 deletions(-) create mode 100644 tests/capture_conformance.py create mode 100644 tests/conformance.rs create mode 100644 tests/fixtures/conformance/alter_view.json create mode 100644 tests/fixtures/conformance/apply_db_user_privileges.json create mode 100644 tests/fixtures/conformance/build_routine_call_sql.json create mode 100644 tests/fixtures/conformance/create_db_user.json create mode 100644 tests/fixtures/conformance/create_trigger.json create mode 100644 tests/fixtures/conformance/create_view.json create mode 100644 tests/fixtures/conformance/delete_record.json create mode 100644 tests/fixtures/conformance/drop_db_user.json create mode 100644 tests/fixtures/conformance/drop_foreign_key.json create mode 100644 tests/fixtures/conformance/drop_index.json create mode 100644 tests/fixtures/conformance/drop_routine.json create mode 100644 tests/fixtures/conformance/drop_trigger.json create mode 100644 tests/fixtures/conformance/drop_view.json create mode 100644 tests/fixtures/conformance/execute_query.json create mode 100644 tests/fixtures/conformance/execute_query_batch.json create mode 100644 tests/fixtures/conformance/explain_query.json create mode 100644 tests/fixtures/conformance/fetch_blob_as_data_url.json create mode 100644 tests/fixtures/conformance/get_add_column_sql.json create mode 100644 tests/fixtures/conformance/get_ai_schema_context.json create mode 100644 tests/fixtures/conformance/get_all_columns_batch.json create mode 100644 tests/fixtures/conformance/get_all_foreign_keys_batch.json create mode 100644 tests/fixtures/conformance/get_alter_column_sql.json create mode 100644 tests/fixtures/conformance/get_columns.json create mode 100644 tests/fixtures/conformance/get_create_foreign_key_sql.json create mode 100644 tests/fixtures/conformance/get_create_index_sql.json create mode 100644 tests/fixtures/conformance/get_create_table_sql.json create mode 100644 tests/fixtures/conformance/get_databases.json create mode 100644 tests/fixtures/conformance/get_db_privilege_catalog.json create mode 100644 tests/fixtures/conformance/get_db_user_grants.json create mode 100644 tests/fixtures/conformance/get_db_user_privileges.json create mode 100644 tests/fixtures/conformance/get_db_users.json create mode 100644 tests/fixtures/conformance/get_foreign_keys.json create mode 100644 tests/fixtures/conformance/get_indexes.json create mode 100644 tests/fixtures/conformance/get_routine_definition.json create mode 100644 tests/fixtures/conformance/get_routine_edit_script.json create mode 100644 tests/fixtures/conformance/get_routine_parameters.json create mode 100644 tests/fixtures/conformance/get_routines.json create mode 100644 tests/fixtures/conformance/get_schema_snapshot.json create mode 100644 tests/fixtures/conformance/get_schemas.json create mode 100644 tests/fixtures/conformance/get_tables.json create mode 100644 tests/fixtures/conformance/get_trigger_definition.json create mode 100644 tests/fixtures/conformance/get_triggers.json create mode 100644 tests/fixtures/conformance/get_view_columns.json create mode 100644 tests/fixtures/conformance/get_view_definition.json create mode 100644 tests/fixtures/conformance/get_views.json create mode 100644 tests/fixtures/conformance/initialize.json create mode 100644 tests/fixtures/conformance/insert_record.json create mode 100644 tests/fixtures/conformance/ping.json create mode 100644 tests/fixtures/conformance/routine_create_template.json create mode 100644 tests/fixtures/conformance/save_blob_to_file.json create mode 100644 tests/fixtures/conformance/set_db_user_password.json create mode 100644 tests/fixtures/conformance/shutdown.json create mode 100644 tests/fixtures/conformance/test_connection.json create mode 100644 tests/fixtures/conformance/update_record.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d6c7de..44ae9a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,10 @@ jobs: run: cargo build - name: Run tests - # This crate has no library target, so --lib would fail. --bins keeps - # the live SQL Server test in its dedicated job below. - run: cargo test --bins + # This crate has no library target, so --lib would fail. Select the + # fixture-only conformance target while keeping live_db in its + # dedicated SQL Server job below. + run: cargo test --bins --test conformance - name: Clippy run: cargo clippy --all-targets -- -D warnings diff --git a/docs/completeness.md b/docs/completeness.md index 3835294..5a2a929 100644 --- a/docs/completeness.md +++ b/docs/completeness.md @@ -31,9 +31,24 @@ remaining protocol gaps are: - A host-method coverage test snapshots every RPC sent by the host and requires each one to be dispatched or included in the reasoned `NOT_IMPLEMENTED` table. Unknown methods also return `-32601` naming the method and plugin. -- `explain_query` currently returns an in-process parsed plan. `SS-035` will - return raw `sqlserver-showplan-xml` after the plugin-owned parser contract - and host support are available. +- `explain_query` returns raw `sqlserver-showplan-xml` for the plugin-owned + TypeScript parser registered by the host. + +## Host model conformance + +`tests/conformance.rs` carries verbatim response-model definitions from +Tabularis host commit `ba0463d3b861ec8fad110126c67e3fc12bac9839` and checks a +live-captured fixture for every implemented RPC. Regenerate all 54 responses +with `python3 tests/capture_conformance.py` whenever the host models or RPC +surface changes. + +The first conformance sync found two additive host fields missing from the +plugin wire models. SQL Server column introspection now emits `is_generated` +from `sys.columns.is_computed`, including the schema snapshot and batch path, +and index metadata emits `is_expression: false` because SQL Server does not +support arbitrary expression index keys. Parameterized character types now +retain `character_maximum_length`; absent lengths and defaults remain omitted +and deserialize through the host's optional fields. ## BLOB policy diff --git a/justfile b/justfile index b6fb6dc..4a788aa 100644 --- a/justfile +++ b/justfile @@ -46,10 +46,11 @@ test-explain: pnpm --dir explain typecheck pnpm --dir explain test -# Run unit tests only. This crate is binary-only, so --lib would fail; --bins -# also keeps tests/live_db.rs out of the default run. +# Run unit tests and recorded host-model conformance. This crate is binary-only, +# so --lib would fail; selecting conformance explicitly keeps tests/live_db.rs +# out of the default run. test: - cargo test --bins + cargo test --bins --test conformance # Launch the local REPL that simulates Tabularis JSON-RPC calls over stdio. repl: diff --git a/src/driver/introspection.rs b/src/driver/introspection.rs index f97e4c6..d0b3149 100644 --- a/src/driver/introspection.rs +++ b/src/driver/introspection.rs @@ -39,6 +39,7 @@ SELECT \ END AS data_type, \ c.is_nullable AS is_nullable, \ c.is_identity AS is_identity, \ + c.is_computed AS is_generated, \ CAST(c.max_length AS INT) AS max_length, \ CAST(ISNULL(( \ SELECT TOP 1 1 \ @@ -193,6 +194,7 @@ SELECT \ END AS data_type, \ c.is_nullable AS is_nullable, \ c.is_identity AS is_identity, \ + c.is_computed AS is_generated, \ CAST(c.max_length AS INT) AS max_length, \ CAST(ISNULL(( \ SELECT TOP 1 1 \ @@ -357,11 +359,13 @@ pub fn normalize_routine_type(raw: Option<&str>) -> String { /// by the `sys.*` introspection queries. Extracted out of the async paths so /// the field-by-field mapping — including the non-obvious /// `character_maximum_length` policy — stays unit-testable. +#[allow(clippy::too_many_arguments)] pub fn build_table_column( name: String, data_type: String, is_nullable: bool, is_identity: bool, + is_generated: bool, max_length_bytes: i32, is_pk: bool, default_value: Option, @@ -377,6 +381,7 @@ pub fn build_table_column( is_pk, is_nullable, is_auto_increment: is_identity, + is_generated, default_value, character_maximum_length, } @@ -420,8 +425,10 @@ pub fn split_agg_columns(raw: &str) -> Vec { /// Whether a given SQL Server type name is a string-like type that should /// advertise `character_maximum_length` to the UI. pub fn is_string_type(data_type: &str) -> bool { + let normalized = data_type.to_ascii_lowercase(); + let base_type = normalized.split('(').next().unwrap_or(normalized.as_str()); matches!( - data_type.to_ascii_lowercase().as_str(), + base_type, "char" | "varchar" | "nchar" @@ -494,6 +501,7 @@ pub async fn get_columns( row_str(&r, "data_type"), row_bool(&r, "is_nullable"), row_bool(&r, "is_identity"), + row_bool(&r, "is_generated"), row_i32(&r, "max_length"), row_bool(&r, "is_pk"), row_str_opt(&r, "default_value"), @@ -585,6 +593,7 @@ pub async fn get_all_columns_batch( row_str(&r, "data_type"), row_bool(&r, "is_nullable"), row_bool(&r, "is_identity"), + row_bool(&r, "is_generated"), row_i32(&r, "max_length"), row_bool(&r, "is_pk"), row_str_opt(&r, "default_value"), @@ -822,6 +831,8 @@ pub async fn get_indexes( is_unique: row_bool(&r, "is_unique"), is_primary: row_bool(&r, "is_primary"), seq_in_index: row_i32(&r, "seq_in_index"), + // SQL Server indexes cannot contain arbitrary expressions. + is_expression: false, }) .collect()) } diff --git a/src/driver/introspection/tests.rs b/src/driver/introspection/tests.rs index 233da00..66baa63 100644 --- a/src/driver/introspection/tests.rs +++ b/src/driver/introspection/tests.rs @@ -18,6 +18,7 @@ fn q_get_columns_joins_sys_types_and_reports_pk() { assert!(Q_GET_COLUMNS.contains("sys.indexes")); assert!(Q_GET_COLUMNS.contains("is_primary_key")); assert!(Q_GET_COLUMNS.contains("sys.default_constraints")); + assert!(Q_GET_COLUMNS.contains("c.is_computed AS is_generated")); assert!(Q_GET_COLUMNS.contains("OBJECT_ID(@P1)")); assert!(Q_GET_COLUMNS.contains("ORDER BY c.column_id")); } @@ -189,6 +190,7 @@ fn build_table_column_populates_string_length() { "nvarchar".into(), true, false, + false, 40, false, None, @@ -204,7 +206,7 @@ fn build_table_column_populates_string_length() { #[test] fn build_table_column_leaves_length_none_for_numeric() { - let col = build_table_column("id".into(), "int".into(), false, true, 4, true, None); + let col = build_table_column("id".into(), "int".into(), false, true, false, 4, true, None); assert_eq!(col.character_maximum_length, None); assert!(col.is_pk); assert!(col.is_auto_increment); @@ -218,6 +220,7 @@ fn build_table_column_honours_max_as_none() { "varbinary".into(), true, false, + false, -1, false, None, @@ -232,6 +235,7 @@ fn build_table_column_carries_default_value() { "datetime2".into(), false, false, + false, 8, false, Some("(getdate())".into()), @@ -240,6 +244,22 @@ fn build_table_column_carries_default_value() { assert_eq!(col.character_maximum_length, None); } +#[test] +fn build_table_column_reports_generated_and_parameterized_lengths() { + let col = build_table_column( + "summary".into(), + "nvarchar(42)".into(), + true, + false, + true, + 84, + false, + None, + ); + assert!(col.is_generated); + assert_eq!(col.character_maximum_length, Some(42)); +} + // --- build_foreign_keys ---------------------------------------------- #[test] diff --git a/src/models.rs b/src/models.rs index a8027a4..a2c6b30 100644 --- a/src/models.rs +++ b/src/models.rs @@ -76,6 +76,8 @@ pub struct TableColumn { pub is_pk: bool, pub is_nullable: bool, pub is_auto_increment: bool, + #[serde(default)] + pub is_generated: bool, #[serde(skip_serializing_if = "Option::is_none")] pub default_value: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -99,6 +101,8 @@ pub struct Index { pub is_unique: bool, pub is_primary: bool, pub seq_in_index: i32, + #[serde(default)] + pub is_expression: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/tests/capture_conformance.py b/tests/capture_conformance.py new file mode 100644 index 0000000..a2983af --- /dev/null +++ b/tests/capture_conformance.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 +"""Capture one successful JSON-RPC response for every implemented plugin RPC. + +Run against the SQL Server container started by `just run-sqlserver`: + + cargo build + python3 tests/capture_conformance.py + +Connection and binary paths use the same SQLSERVER_TEST_* and +SQLSERVER_PLUGIN_BIN overrides as tests/live_db.rs. Fixtures never contain +requests, so credentials are not written to the repository. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE_DIR = ROOT / "tests" / "fixtures" / "conformance" +SCHEMA = "ss044" +USER = "ss044_user" +LOGIN = "ss044_login" +PASSWORD = "Ss044!Conformance9" +NEW_PASSWORD = "Ss044!Conformance10" + + +class Plugin: + def __init__(self) -> None: + binary = os.environ.get( + "SQLSERVER_PLUGIN_BIN", str(ROOT / "target" / "debug" / "sqlserver-plugin") + ) + self.process = subprocess.Popen( + [binary], + cwd=ROOT, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + ) + + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + assert self.process.stdin is not None + assert self.process.stdout is not None + request = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + } + self.process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + if not line: + raise RuntimeError(f"plugin exited while handling {method}") + response = json.loads(line) + if "error" in response: + raise RuntimeError(f"{method} failed: {response['error']}") + return response + + def close(self) -> None: + if self.process.poll() is None: + self.process.terminate() + self.process.wait(timeout=5) + + +def connection_params() -> dict[str, Any]: + return { + "driver": "sqlserver", + "host": os.environ.get("SQLSERVER_TEST_HOST", "127.0.0.1"), + "port": int(os.environ.get("SQLSERVER_TEST_PORT", "1433")), + "username": os.environ.get("SQLSERVER_TEST_USER", "sa"), + "password": os.environ.get("SQLSERVER_TEST_PASSWORD", "Str0ng!Passw0rd"), + "database": os.environ.get("SQLSERVER_TEST_DATABASE", "tabularis_test"), + "ssl_mode": "require", + "connection_id": "ss044-conformance-capture", + } + + +def main() -> None: + plugin = Plugin() + params = connection_params() + FIXTURE_DIR.parent.mkdir(parents=True, exist_ok=True) + staging = Path( + tempfile.mkdtemp(prefix=".ss044-conformance-", dir=FIXTURE_DIR.parent) + ) + captured: set[str] = set() + + def rpc_params(**values: Any) -> dict[str, Any]: + return {"params": params, **values} + + def execute(sql: str) -> Any: + return plugin.call("execute_query", rpc_params(query=sql))["result"] + + def capture(method: str, values: dict[str, Any]) -> Any: + if method in captured: + raise RuntimeError(f"duplicate fixture for {method}") + response = plugin.call(method, values) + (staging / f"{method}.json").write_text( + json.dumps(response, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + captured.add(method) + return response["result"] + + try: + capture( + "initialize", + { + "settings": { + "application_name": "Tabularis SS-044 conformance capture", + "query_timeout_seconds": 30, + } + }, + ) + + execute( + f"IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; " + f"IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]; " + f"DROP VIEW IF EXISTS [{SCHEMA}].[base_view]; " + f"DROP VIEW IF EXISTS [{SCHEMA}].[created_view]; " + f"DROP PROCEDURE IF EXISTS [{SCHEMA}].[sample_proc]; " + f"DROP PROCEDURE IF EXISTS [{SCHEMA}].[drop_proc]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[drop_fk_child]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[crud_rows]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[blob_rows]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[child]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[parent]; " + f"IF SCHEMA_ID(N'{SCHEMA}') IS NULL EXEC(N'CREATE SCHEMA [{SCHEMA}]')" + ) + execute( + f"CREATE TABLE [{SCHEMA}].[parent] (id INT PRIMARY KEY); " + f"CREATE TABLE [{SCHEMA}].[child] (" + "id INT IDENTITY(1,1) PRIMARY KEY, " + "parent_id INT NULL, " + "label NVARCHAR(42) NOT NULL CONSTRAINT [df_ss044_label] DEFAULT N'pending', " + "note NVARCHAR(MAX) NULL, " + "generated_value AS (id + 1), " + f"CONSTRAINT [fk_child_parent] FOREIGN KEY (parent_id) REFERENCES [{SCHEMA}].[parent](id) ON DELETE SET NULL); " + f"CREATE UNIQUE INDEX [ix_child_label] ON [{SCHEMA}].[child] (label); " + f"CREATE INDEX [ix_drop] ON [{SCHEMA}].[child] (parent_id); " + f"CREATE TABLE [{SCHEMA}].[drop_fk_child] (id INT PRIMARY KEY, parent_id INT NULL, " + f"CONSTRAINT [fk_drop] FOREIGN KEY (parent_id) REFERENCES [{SCHEMA}].[parent](id)); " + f"CREATE TABLE [{SCHEMA}].[crud_rows] (id INT PRIMARY KEY, value NVARCHAR(20) NOT NULL); " + f"CREATE TABLE [{SCHEMA}].[blob_rows] (id INT PRIMARY KEY, payload VARBINARY(MAX) NOT NULL); " + f"INSERT INTO [{SCHEMA}].[parent] VALUES (1), (2); " + f"INSERT INTO [{SCHEMA}].[child] (parent_id, label, note) VALUES (1, N'alpha', NULL), (2, N'beta', N'note'); " + f"INSERT INTO [{SCHEMA}].[blob_rows] VALUES (1, 0x89504E470D0A1A0A0000000D49484452)" + ) + execute(f"CREATE VIEW [{SCHEMA}].[base_view] AS SELECT id, label FROM [{SCHEMA}].[child]") + execute( + f"CREATE PROCEDURE [{SCHEMA}].[sample_proc] " + "@input INT, @output NVARCHAR(20) OUTPUT AS BEGIN SET NOCOUNT ON; " + "SET @output = CONCAT(N'value-', @input); SELECT @input AS input_value; END" + ) + execute(f"CREATE PROCEDURE [{SCHEMA}].[drop_proc] AS SELECT 1 AS value") + execute( + f"CREATE TRIGGER [{SCHEMA}].[base_after] ON [{SCHEMA}].[child] " + "AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; END" + ) + execute( + f"CREATE TRIGGER [{SCHEMA}].[base_instead] ON [{SCHEMA}].[child] " + "INSTEAD OF DELETE AS BEGIN SET NOCOUNT ON; END" + ) + + capture("ping", rpc_params()) + capture("test_connection", rpc_params()) + capture("get_databases", rpc_params()) + capture("get_schemas", rpc_params()) + capture("get_tables", rpc_params(schema=SCHEMA)) + capture("get_columns", rpc_params(schema=SCHEMA, table="child")) + capture("get_foreign_keys", rpc_params(schema=SCHEMA, table="child")) + capture("get_indexes", rpc_params(schema=SCHEMA, table="child")) + capture("get_schema_snapshot", rpc_params(schema=SCHEMA)) + capture("get_all_columns_batch", rpc_params(schema=SCHEMA)) + capture("get_all_foreign_keys_batch", rpc_params(schema=SCHEMA)) + capture("get_ai_schema_context", rpc_params(schema=SCHEMA, max_tables=3)) + + capture("get_views", rpc_params(schema=SCHEMA)) + capture( + "get_view_definition", + rpc_params(schema=SCHEMA, view_name="base_view"), + ) + capture("get_view_columns", rpc_params(schema=SCHEMA, view_name="base_view")) + capture( + "create_view", + rpc_params( + schema=SCHEMA, + view_name="created_view", + definition=f"SELECT id FROM [{SCHEMA}].[parent]", + ), + ) + capture( + "alter_view", + rpc_params( + schema=SCHEMA, + view_name="created_view", + definition=f"SELECT id FROM [{SCHEMA}].[parent] WHERE id > 0", + ), + ) + capture("drop_view", rpc_params(schema=SCHEMA, view_name="created_view")) + + capture("get_routines", rpc_params(schema=SCHEMA)) + capture( + "get_routine_parameters", + rpc_params(schema=SCHEMA, routine_name="sample_proc"), + ) + capture( + "get_routine_definition", + rpc_params( + schema=SCHEMA, + routine_name="sample_proc", + routine_type="PROCEDURE", + ), + ) + capture( + "build_routine_call_sql", + rpc_params( + schema=SCHEMA, + routine_name="sample_proc", + routine_type="PROCEDURE", + args=[ + {"name": "@input", "mode": "IN", "value": "7", "is_raw": True}, + { + "name": "@output", + "mode": "INOUT", + "value": None, + "is_raw": False, + }, + ], + ), + ) + capture("routine_create_template", {"schema": SCHEMA, "routine_type": "FUNCTION"}) + capture( + "get_routine_edit_script", + rpc_params( + schema=SCHEMA, + routine_name="sample_proc", + routine_type="PROCEDURE", + ), + ) + capture( + "drop_routine", + rpc_params(schema=SCHEMA, routine_name="drop_proc", routine_type="PROCEDURE"), + ) + + capture("get_triggers", rpc_params(schema=SCHEMA)) + capture( + "get_trigger_definition", + rpc_params(schema=SCHEMA, trigger_name="base_after", table_name="child"), + ) + capture( + "create_trigger", + rpc_params( + schema=SCHEMA, + trigger_sql=f"CREATE TRIGGER [{SCHEMA}].[created_trigger] ON [{SCHEMA}].[parent] AFTER UPDATE AS BEGIN SET NOCOUNT ON; END", + ), + ) + capture( + "drop_trigger", + rpc_params(schema=SCHEMA, trigger_name="created_trigger", table_name="parent"), + ) + + capture("get_db_privilege_catalog", {}) + capture( + "create_db_user", + rpc_params(user=USER, host=LOGIN, password=PASSWORD), + ) + capture( + "set_db_user_password", + rpc_params(user=USER, host=LOGIN, password=NEW_PASSWORD), + ) + capture( + "apply_db_user_privileges", + rpc_params( + user=USER, + host=LOGIN, + database=SCHEMA, + table="parent", + privileges=["SELECT"], + grant=True, + ), + ) + capture("get_db_users", rpc_params()) + capture("get_db_user_grants", rpc_params(user=USER, host=LOGIN)) + capture("get_db_user_privileges", rpc_params(user=USER, host=LOGIN)) + capture("drop_db_user", rpc_params(user=USER, host=LOGIN)) + + capture( + "execute_query", + rpc_params( + query=( + f"SELECT id, label FROM [{SCHEMA}].[child] ORDER BY id; " + f"SELECT parent_id FROM [{SCHEMA}].[child] ORDER BY id" + ) + ), + ) + capture( + "execute_query_batch", + rpc_params( + queries=[ + f"SELECT id FROM [{SCHEMA}].[child] ORDER BY id", + f"SELECT * FROM [{SCHEMA}].[missing_table]", + ], + limit=1, + page=1, + ), + ) + capture( + "explain_query", + rpc_params( + query=f"SELECT label FROM [{SCHEMA}].[child] WHERE id = 1", + analyze=False, + ), + ) + + capture( + "insert_record", + rpc_params(schema=SCHEMA, table="crud_rows", data={"id": 1, "value": "before"}), + ) + capture( + "update_record", + rpc_params( + schema=SCHEMA, + table="crud_rows", + pk_map={"id": 1}, + col_name="value", + new_val="after", + ), + ) + capture( + "delete_record", + rpc_params(schema=SCHEMA, table="crud_rows", pk_map={"id": 1}), + ) + + blob_path = Path(tempfile.gettempdir()) / "ss044-conformance-blob.bin" + blob_path.unlink(missing_ok=True) + capture( + "save_blob_to_file", + rpc_params( + schema=SCHEMA, + table="blob_rows", + col_name="payload", + pk_map={"id": 1}, + file_path=str(blob_path), + ), + ) + blob_path.unlink(missing_ok=True) + capture( + "fetch_blob_as_data_url", + rpc_params( + schema=SCHEMA, + table="blob_rows", + col_name="payload", + pk_map={"id": 1}, + max_blob_size=1024, + ), + ) + + column = { + "name": "value", + "data_type": "NVARCHAR(40)", + "is_nullable": True, + "is_pk": False, + "is_auto_increment": False, + "default_value": None, + } + capture( + "get_create_table_sql", + { + "schema": SCHEMA, + "table_name": "generated_table", + "columns": [ + { + **column, + "name": "id", + "data_type": "INT", + "is_nullable": False, + "is_pk": True, + }, + column, + ], + }, + ) + capture( + "get_add_column_sql", + {"schema": SCHEMA, "table": "generated_table", "column": column}, + ) + capture( + "get_alter_column_sql", + { + "schema": SCHEMA, + "table": "generated_table", + "old_column": column, + "new_column": {**column, "data_type": "NVARCHAR(80)", "is_nullable": False}, + }, + ) + capture( + "get_create_index_sql", + { + "schema": SCHEMA, + "table": "generated_table", + "index_name": "ix_generated_value", + "columns": ["value"], + "is_unique": True, + }, + ) + capture( + "get_create_foreign_key_sql", + { + "params": params, + "schema": SCHEMA, + "table": "generated_table", + "fk_name": "fk_generated_parent", + "column": "id", + "ref_table": "parent", + "ref_column": "id", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + }, + ) + capture( + "drop_index", + rpc_params(schema=SCHEMA, table="child", index_name="ix_drop"), + ) + capture( + "drop_foreign_key", + rpc_params(schema=SCHEMA, table="drop_fk_child", fk_name="fk_drop"), + ) + + capture("shutdown", {}) + + expected = { + "initialize", + "ping", + "test_connection", + "shutdown", + "get_databases", + "get_schemas", + "get_tables", + "get_columns", + "get_foreign_keys", + "get_indexes", + "get_schema_snapshot", + "get_all_columns_batch", + "get_all_foreign_keys_batch", + "get_ai_schema_context", + "get_views", + "get_view_definition", + "get_view_columns", + "create_view", + "alter_view", + "drop_view", + "get_routines", + "get_routine_parameters", + "get_routine_definition", + "build_routine_call_sql", + "routine_create_template", + "get_routine_edit_script", + "drop_routine", + "get_triggers", + "get_trigger_definition", + "create_trigger", + "drop_trigger", + "get_db_privilege_catalog", + "get_db_users", + "create_db_user", + "drop_db_user", + "set_db_user_password", + "get_db_user_grants", + "get_db_user_privileges", + "apply_db_user_privileges", + "execute_query", + "execute_query_batch", + "explain_query", + "insert_record", + "update_record", + "delete_record", + "save_blob_to_file", + "fetch_blob_as_data_url", + "get_create_table_sql", + "get_add_column_sql", + "get_alter_column_sql", + "get_create_index_sql", + "get_create_foreign_key_sql", + "drop_index", + "drop_foreign_key", + } + if captured != expected: + raise RuntimeError( + f"fixture inventory mismatch; missing={expected - captured}, extra={captured - expected}" + ) + + if FIXTURE_DIR.exists(): + shutil.rmtree(FIXTURE_DIR) + staging.rename(FIXTURE_DIR) + print(f"captured {len(captured)} responses in {FIXTURE_DIR}") + finally: + if staging.exists(): + shutil.rmtree(staging) + plugin.close() + + +if __name__ == "__main__": + main() diff --git a/tests/conformance.rs b/tests/conformance.rs new file mode 100644 index 0000000..d0db5b4 --- /dev/null +++ b/tests/conformance.rs @@ -0,0 +1,431 @@ +//! Recorded JSON-RPC response conformance against Tabularis host models. +//! +//! The model definitions below are copied verbatim from +//! `tabularis/src-tauri/src/models.rs` at host commit +//! `ba0463d3b861ec8fad110126c67e3fc12bac9839`. Re-sync them and regenerate +//! `tests/fixtures/conformance/` with `python3 tests/capture_conformance.py` +//! whenever the host models or plugin RPC surface changes. + +#![allow(dead_code)] + +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::PathBuf; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; + +// BEGIN verbatim host model definitions. + +#[derive(Debug, Serialize, Deserialize)] +pub struct TableInfo { + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TableColumn { + pub name: String, + pub data_type: String, + pub is_pk: bool, + pub is_nullable: bool, + pub is_auto_increment: bool, + #[serde(default)] + pub is_generated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub character_maximum_length: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ForeignKey { + pub name: String, + pub column_name: String, + pub ref_table: String, + pub ref_column: String, + pub on_delete: Option, + pub on_update: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Index { + pub name: String, + pub column_name: String, + pub is_unique: bool, + pub is_primary: bool, + pub seq_in_index: i32, + #[serde(default)] + pub is_expression: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Pagination { + pub page: u32, + pub page_size: u32, + pub total_rows: Option, + pub has_more: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QueryResult { + pub columns: Vec, + pub rows: Vec>, + pub affected_rows: u64, + #[serde(default)] + pub truncated: bool, + pub pagination: Option, + /// Extra result sets produced by a single statement beyond the first one, + /// e.g. a MySQL `CALL` to a stored procedure containing multiple `SELECT`s. + /// The first result set stays in `columns` / `rows` so consumers unaware + /// of multi-result statements keep working unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_results: Option>, +} + +/// One statement's outcome within an `execute_batch` call. Exactly one of +/// `result` / `error` is `Some` — kept as separate optionals (not a tagged +/// enum) so the TypeScript side can do `if (item.error) ... else ... item.result` +/// without a discriminated-union helper. Use [`BatchStatementResult::from_outcome`] +/// to construct so the invariant is enforced. +/// +/// `execution_time_ms` is measured server-side because a batch is one +/// Tauri round-trip but the history UI wants per-statement timings. +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchStatementResult { + pub result: Option, + pub error: Option, + pub execution_time_ms: Option, +} + +/// Raw EXPLAIN output produced by a built-in driver. +/// +/// Parsing lives in the `@tabularis/explain` TypeScript package +/// (`parseRawExplain`): a driver's job ends at handing over the payload it +/// obtained — text, a JSON document, or decoded rows re-serialised as a JSON +/// array — plus the format tag naming what it is. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RawExplainOutput { + /// Driver id of the engine that produced the payload ("postgres", …). + pub engine: String, + /// Wire format tag understood by `@tabularis/explain`: + /// `postgres-json`, `mysql-json`, `mysql-analyze-text`, + /// `mysql-tabular-rows` or `sqlite-eqp-rows`. + pub format: String, + /// The untouched payload: text, a JSON document, or rows as a JSON array. + pub payload: String, + pub original_query: String, +} + +/// What `explain_query` hands to the frontend: a raw payload for a registered +/// parser, or a plan a plugin driver already parsed. Both plugin result shapes +/// remain supported for backwards compatibility. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum ExplainQueryOutput { + Raw { raw: RawExplainOutput }, + Plan { plan: serde_json::Value }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TableSchema { + pub name: String, + pub columns: Vec, + pub foreign_keys: Vec, +} + +/// Bounded schema metadata prepared by a database driver for AI features. +/// The host remains responsible for rendering this structured data into a +/// provider-agnostic prompt. +#[derive(Debug, Serialize, Deserialize)] +pub struct AiSchemaContext { + pub tables: Vec, + pub total_table_count: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoutineInfo { + pub name: String, + pub routine_type: String, // "PROCEDURE" | "FUNCTION" + pub definition: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoutineParameter { + pub name: String, + pub data_type: String, + pub mode: String, // "IN", "OUT", "INOUT" + pub ordinal_position: i32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViewInfo { + pub name: String, + pub definition: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TriggerInfo { + pub name: String, + pub table_name: String, + pub event: String, // e.g. "INSERT", "UPDATE", "DELETE", "INSERT OR UPDATE" + pub timing: String, // "BEFORE", "AFTER", "INSTEAD OF" + pub definition: Option, +} + +/// One database account as listed by the server (MySQL/MariaDB: +/// `mysql.user` rows, identified by the `user`@`host` pair). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DbUserInfo { + pub user: String, + pub host: String, + /// Account is locked (`ALTER USER ... ACCOUNT LOCK`); `false` when the + /// server does not expose the flag. + pub locked: bool, +} + +/// The privilege keywords a driver accepts in `apply_db_user_privileges`, +/// split by scope. Sent to the frontend so the privilege editor renders the +/// dialect's own catalog instead of hardcoding one. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct DbPrivilegeCatalog { + /// Privileges valid at the database scope (and also globally). + pub database: Vec, + /// Privileges valid only at the global scope. + pub global: Vec, + /// Privileges valid at the table scope. + pub table: Vec, +} + +/// One account's privileges on one scope, parsed from the server's grant +/// metadata (MySQL: one `SHOW GRANTS` line). `database == None` is the +/// global scope; `table` is only ever `Some` when `database` is. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DbUserGrantSet { + pub database: Option, + pub table: Option, + /// Canonical privilege keywords, `GRANT OPTION` included as an entry. + pub privileges: Vec, +} + +// END verbatim host model definitions. + +#[derive(Debug, Deserialize)] +struct RpcResponse { + jsonrpc: String, + result: Value, + id: u64, +} + +const DELIBERATELY_UNSUPPORTED: &[&str] = &[ + "get_materialized_views", + "get_materialized_view_columns", + "get_materialized_view_definition", + "refresh_materialized_view", +]; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/conformance") +} + +fn fixture_methods() -> BTreeSet { + fs::read_dir(fixture_dir()) + .expect("read conformance fixture directory") + .filter_map(|entry| { + let path = entry.expect("read fixture entry").path(); + (path.extension().and_then(|extension| extension.to_str()) == Some("json")).then(|| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .expect("fixture names must be UTF-8") + .to_string() + }) + }) + .collect() +} + +fn implemented_methods() -> BTreeSet { + let unsupported: BTreeSet<_> = DELIBERATELY_UNSUPPORTED.iter().copied().collect(); + include_str!("../src/rpc.rs") + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix('"')? + .split_once("\" =>") + .map(|(method, _)| method) + }) + .filter(|method| !unsupported.contains(method)) + .map(str::to_string) + .collect() +} + +fn fixture_result(method: &str) -> Value { + let path = fixture_dir().join(format!("{method}.json")); + let fixture: RpcResponse = serde_json::from_str( + &fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())), + ) + .unwrap_or_else(|error| panic!("deserialize {}: {error}", path.display())); + assert_eq!(fixture.jsonrpc, "2.0", "{method} JSON-RPC version"); + assert_eq!(fixture.id, 1, "{method} JSON-RPC id"); + fixture.result +} + +fn assert_deserializes(methods: &[&str], checked: &mut BTreeSet) { + for method in methods { + serde_json::from_value::(fixture_result(method)) + .unwrap_or_else(|error| panic!("{method} does not match the host target: {error}")); + assert!(checked.insert((*method).to_string()), "duplicate {method}"); + } +} + +#[test] +fn fixtures_cover_every_implemented_rpc() { + assert_eq!(fixture_methods(), implemented_methods()); +} + +#[test] +fn every_recorded_result_deserializes_into_the_host_target() { + let mut checked = BTreeSet::new(); + + assert_deserializes::(&["test_connection"], &mut checked); + assert_deserializes::<()>( + &[ + "initialize", + "ping", + "shutdown", + "create_view", + "alter_view", + "drop_view", + "drop_routine", + "create_trigger", + "drop_trigger", + "create_db_user", + "drop_db_user", + "set_db_user_password", + "apply_db_user_privileges", + "save_blob_to_file", + "drop_index", + "drop_foreign_key", + ], + &mut checked, + ); + assert_deserializes::( + &[ + "get_view_definition", + "get_routine_definition", + "build_routine_call_sql", + "routine_create_template", + "get_routine_edit_script", + "get_trigger_definition", + "fetch_blob_as_data_url", + ], + &mut checked, + ); + assert_deserializes::( + &["insert_record", "update_record", "delete_record"], + &mut checked, + ); + assert_deserializes::>( + &[ + "get_databases", + "get_schemas", + "get_db_user_grants", + "get_create_table_sql", + "get_add_column_sql", + "get_alter_column_sql", + "get_create_index_sql", + "get_create_foreign_key_sql", + ], + &mut checked, + ); + assert_deserializes::>(&["get_tables"], &mut checked); + assert_deserializes::>(&["get_columns", "get_view_columns"], &mut checked); + assert_deserializes::>(&["get_foreign_keys"], &mut checked); + assert_deserializes::>(&["get_indexes"], &mut checked); + assert_deserializes::>(&["get_schema_snapshot"], &mut checked); + assert_deserializes::>>( + &["get_all_columns_batch"], + &mut checked, + ); + assert_deserializes::>>( + &["get_all_foreign_keys_batch"], + &mut checked, + ); + assert_deserializes::(&["get_ai_schema_context"], &mut checked); + assert_deserializes::>(&["get_views"], &mut checked); + assert_deserializes::>(&["get_routines"], &mut checked); + assert_deserializes::>(&["get_routine_parameters"], &mut checked); + assert_deserializes::>(&["get_triggers"], &mut checked); + assert_deserializes::(&["get_db_privilege_catalog"], &mut checked); + assert_deserializes::>(&["get_db_users"], &mut checked); + assert_deserializes::>(&["get_db_user_privileges"], &mut checked); + assert_deserializes::(&["execute_query"], &mut checked); + assert_deserializes::>(&["execute_query_batch"], &mut checked); + assert_deserializes::(&["explain_query"], &mut checked); + + assert_eq!(checked, implemented_methods()); +} + +#[test] +fn drift_prone_wire_fields_are_exercised() { + let query: QueryResult = serde_json::from_value(fixture_result("execute_query")).unwrap(); + assert!(query.additional_results.is_some()); + let batch: Vec = + serde_json::from_value(fixture_result("execute_query_batch")).unwrap(); + assert!(batch[0] + .result + .as_ref() + .and_then(|result| result.pagination.as_ref()) + .is_some()); + assert!(batch[1].error.is_some()); + + let columns: Vec = serde_json::from_value(fixture_result("get_columns")).unwrap(); + let label = columns + .iter() + .find(|column| column.name == "label") + .unwrap(); + assert_eq!(label.character_maximum_length, Some(42)); + assert_eq!(label.default_value.as_deref(), Some("(N'pending')")); + let parent = columns + .iter() + .find(|column| column.name == "parent_id") + .unwrap(); + assert_eq!(parent.character_maximum_length, None); + assert_eq!(parent.default_value, None); + assert!(columns.iter().any(|column| column.is_generated)); + + let foreign_keys: Vec = + serde_json::from_value(fixture_result("get_foreign_keys")).unwrap(); + assert_eq!(foreign_keys[0].on_delete.as_deref(), Some("SET NULL")); + assert_eq!(foreign_keys[0].on_update.as_deref(), Some("NO ACTION")); + let mut nullable_foreign_keys = fixture_result("get_foreign_keys"); + nullable_foreign_keys[0]["on_delete"] = Value::Null; + nullable_foreign_keys[0]["on_update"] = Value::Null; + let nullable: Vec = serde_json::from_value(nullable_foreign_keys).unwrap(); + assert!(nullable[0].on_delete.is_none() && nullable[0].on_update.is_none()); + + let triggers: Vec = + serde_json::from_value(fixture_result("get_triggers")).unwrap(); + let vocabulary: BTreeSet<_> = triggers + .iter() + .map(|trigger| (trigger.timing.as_str(), trigger.event.as_str())) + .collect(); + assert!(vocabulary.contains(&("AFTER", "INSERT OR UPDATE"))); + assert!(vocabulary.contains(&("INSTEAD OF", "DELETE"))); + + let parameters: Vec = + serde_json::from_value(fixture_result("get_routine_parameters")).unwrap(); + assert_eq!(parameters[0].mode, "IN"); + assert_eq!(parameters[1].mode, "INOUT"); + + let context: AiSchemaContext = + serde_json::from_value(fixture_result("get_ai_schema_context")).unwrap(); + assert_eq!(context.tables.len(), 3); + assert_eq!(context.total_table_count, 5); + + let raw: RawExplainOutput = serde_json::from_value(fixture_result("explain_query")).unwrap(); + assert_eq!(raw.engine, "sqlserver"); + assert_eq!(raw.format, "sqlserver-showplan-xml"); + assert!(raw.payload.contains("" + } +} diff --git a/tests/fixtures/conformance/fetch_blob_as_data_url.json b/tests/fixtures/conformance/fetch_blob_as_data_url.json new file mode 100644 index 0000000..4896b35 --- /dev/null +++ b/tests/fixtures/conformance/fetch_blob_as_data_url.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "BLOB:16:image/png:iVBORw0KGgoAAAANSUhEUg==" +} diff --git a/tests/fixtures/conformance/get_add_column_sql.json b/tests/fixtures/conformance/get_add_column_sql.json new file mode 100644 index 0000000..b868b39 --- /dev/null +++ b/tests/fixtures/conformance/get_add_column_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "ALTER TABLE [ss044].[generated_table] ADD [value] NVARCHAR(40) NULL" + ] +} diff --git a/tests/fixtures/conformance/get_ai_schema_context.json b/tests/fixtures/conformance/get_ai_schema_context.json new file mode 100644 index 0000000..1777bf0 --- /dev/null +++ b/tests/fixtures/conformance/get_ai_schema_context.json @@ -0,0 +1,111 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "tables": [ + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "varbinary(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "payload" + } + ], + "foreign_keys": [], + "name": "blob_rows" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "default_value": "(N'pending')", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + }, + { + "data_type": "nvarchar(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "note" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": true, + "is_nullable": true, + "is_pk": false, + "name": "generated_value" + } + ], + "foreign_keys": [ + { + "column_name": "parent_id", + "name": "fk_child_parent", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ], + "name": "child" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "character_maximum_length": 20, + "data_type": "nvarchar(20)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "value" + } + ], + "foreign_keys": [], + "name": "crud_rows" + } + ], + "total_table_count": 5 + } +} diff --git a/tests/fixtures/conformance/get_all_columns_batch.json b/tests/fixtures/conformance/get_all_columns_batch.json new file mode 100644 index 0000000..24970fc --- /dev/null +++ b/tests/fixtures/conformance/get_all_columns_batch.json @@ -0,0 +1,115 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blob_rows": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "varbinary(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "payload" + } + ], + "child": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "default_value": "(N'pending')", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + }, + { + "data_type": "nvarchar(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "note" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": true, + "is_nullable": true, + "is_pk": false, + "name": "generated_value" + } + ], + "crud_rows": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "character_maximum_length": 20, + "data_type": "nvarchar(20)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "value" + } + ], + "drop_fk_child": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + } + ], + "parent": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + } + ] + } +} diff --git a/tests/fixtures/conformance/get_all_foreign_keys_batch.json b/tests/fixtures/conformance/get_all_foreign_keys_batch.json new file mode 100644 index 0000000..84769e2 --- /dev/null +++ b/tests/fixtures/conformance/get_all_foreign_keys_batch.json @@ -0,0 +1,26 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "child": [ + { + "column_name": "parent_id", + "name": "fk_child_parent", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ], + "drop_fk_child": [ + { + "column_name": "parent_id", + "name": "fk_drop", + "on_delete": "NO ACTION", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ] + } +} diff --git a/tests/fixtures/conformance/get_alter_column_sql.json b/tests/fixtures/conformance/get_alter_column_sql.json new file mode 100644 index 0000000..6b21697 --- /dev/null +++ b/tests/fixtures/conformance/get_alter_column_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "ALTER TABLE [ss044].[generated_table] ALTER COLUMN [value] NVARCHAR(80) NOT NULL" + ] +} diff --git a/tests/fixtures/conformance/get_columns.json b/tests/fixtures/conformance/get_columns.json new file mode 100644 index 0000000..46ca8bd --- /dev/null +++ b/tests/fixtures/conformance/get_columns.json @@ -0,0 +1,48 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "default_value": "(N'pending')", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + }, + { + "data_type": "nvarchar(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "note" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": true, + "is_nullable": true, + "is_pk": false, + "name": "generated_value" + } + ] +} diff --git a/tests/fixtures/conformance/get_create_foreign_key_sql.json b/tests/fixtures/conformance/get_create_foreign_key_sql.json new file mode 100644 index 0000000..b4cbba0 --- /dev/null +++ b/tests/fixtures/conformance/get_create_foreign_key_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "ALTER TABLE [ss044].[generated_table] ADD CONSTRAINT [fk_generated_parent] FOREIGN KEY ([id]) REFERENCES [ss044].[parent] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION" + ] +} diff --git a/tests/fixtures/conformance/get_create_index_sql.json b/tests/fixtures/conformance/get_create_index_sql.json new file mode 100644 index 0000000..268ac07 --- /dev/null +++ b/tests/fixtures/conformance/get_create_index_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "CREATE UNIQUE INDEX [ix_generated_value] ON [ss044].[generated_table] ([value])" + ] +} diff --git a/tests/fixtures/conformance/get_create_table_sql.json b/tests/fixtures/conformance/get_create_table_sql.json new file mode 100644 index 0000000..d65915b --- /dev/null +++ b/tests/fixtures/conformance/get_create_table_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "CREATE TABLE [ss044].[generated_table] (\n [id] INT NOT NULL,\n [value] NVARCHAR(40) NULL,\n PRIMARY KEY ([id])\n)" + ] +} diff --git a/tests/fixtures/conformance/get_databases.json b/tests/fixtures/conformance/get_databases.json new file mode 100644 index 0000000..e9017ec --- /dev/null +++ b/tests/fixtures/conformance/get_databases.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "tabularis_test" + ] +} diff --git a/tests/fixtures/conformance/get_db_privilege_catalog.json b/tests/fixtures/conformance/get_db_privilege_catalog.json new file mode 100644 index 0000000..be7a21b --- /dev/null +++ b/tests/fixtures/conformance/get_db_privilege_catalog.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "database": [ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION" + ], + "global": [ + "AUTHENTICATE", + "BACKUP DATABASE", + "BACKUP LOG", + "CHECKPOINT", + "CONNECT", + "CREATE FUNCTION", + "CREATE PROCEDURE", + "CREATE ROLE", + "CREATE SCHEMA", + "CREATE SYNONYM", + "CREATE TABLE", + "CREATE TYPE", + "CREATE VIEW", + "SHOWPLAN", + "SUBSCRIBE QUERY NOTIFICATIONS", + "UNMASK", + "VIEW DATABASE STATE" + ], + "table": [ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "RECEIVE", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION" + ] + } +} diff --git a/tests/fixtures/conformance/get_db_user_grants.json b/tests/fixtures/conformance/get_db_user_grants.json new file mode 100644 index 0000000..7a87345 --- /dev/null +++ b/tests/fixtures/conformance/get_db_user_grants.json @@ -0,0 +1,8 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "GRANT CONNECT ON DATABASE::[tabularis_test] TO [ss044_user]", + "GRANT SELECT ON OBJECT::[ss044].[parent] TO [ss044_user]" + ] +} diff --git a/tests/fixtures/conformance/get_db_user_privileges.json b/tests/fixtures/conformance/get_db_user_privileges.json new file mode 100644 index 0000000..ec94f1e --- /dev/null +++ b/tests/fixtures/conformance/get_db_user_privileges.json @@ -0,0 +1,20 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "database": null, + "privileges": [ + "CONNECT" + ], + "table": null + }, + { + "database": "ss044", + "privileges": [ + "SELECT" + ], + "table": "parent" + } + ] +} diff --git a/tests/fixtures/conformance/get_db_users.json b/tests/fixtures/conformance/get_db_users.json new file mode 100644 index 0000000..87c61b2 --- /dev/null +++ b/tests/fixtures/conformance/get_db_users.json @@ -0,0 +1,11 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "host": "ss044_login", + "locked": false, + "user": "ss044_user" + } + ] +} diff --git a/tests/fixtures/conformance/get_foreign_keys.json b/tests/fixtures/conformance/get_foreign_keys.json new file mode 100644 index 0000000..5dab610 --- /dev/null +++ b/tests/fixtures/conformance/get_foreign_keys.json @@ -0,0 +1,14 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "column_name": "parent_id", + "name": "fk_child_parent", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ] +} diff --git a/tests/fixtures/conformance/get_indexes.json b/tests/fixtures/conformance/get_indexes.json new file mode 100644 index 0000000..307bf80 --- /dev/null +++ b/tests/fixtures/conformance/get_indexes.json @@ -0,0 +1,30 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "column_name": "label", + "is_expression": false, + "is_primary": false, + "is_unique": true, + "name": "ix_child_label", + "seq_in_index": 1 + }, + { + "column_name": "parent_id", + "is_expression": false, + "is_primary": false, + "is_unique": false, + "name": "ix_drop", + "seq_in_index": 1 + }, + { + "column_name": "id", + "is_expression": false, + "is_primary": true, + "is_unique": true, + "name": "PK__child__3213E83FF87841BF", + "seq_in_index": 1 + } + ] +} diff --git a/tests/fixtures/conformance/get_routine_definition.json b/tests/fixtures/conformance/get_routine_definition.json new file mode 100644 index 0000000..5e57e3f --- /dev/null +++ b/tests/fixtures/conformance/get_routine_definition.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "CREATE PROCEDURE [ss044].[sample_proc] @input INT, @output NVARCHAR(20) OUTPUT AS BEGIN SET NOCOUNT ON; SET @output = CONCAT(N'value-', @input); SELECT @input AS input_value; END" +} diff --git a/tests/fixtures/conformance/get_routine_edit_script.json b/tests/fixtures/conformance/get_routine_edit_script.json new file mode 100644 index 0000000..389d9b4 --- /dev/null +++ b/tests/fixtures/conformance/get_routine_edit_script.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "ALTER PROCEDURE [ss044].[sample_proc] @input INT, @output NVARCHAR(20) OUTPUT AS BEGIN SET NOCOUNT ON; SET @output = CONCAT(N'value-', @input); SELECT @input AS input_value; END" +} diff --git a/tests/fixtures/conformance/get_routine_parameters.json b/tests/fixtures/conformance/get_routine_parameters.json new file mode 100644 index 0000000..8eb48bd --- /dev/null +++ b/tests/fixtures/conformance/get_routine_parameters.json @@ -0,0 +1,18 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "data_type": "int", + "mode": "IN", + "name": "@input", + "ordinal_position": 1 + }, + { + "data_type": "nvarchar(20)", + "mode": "INOUT", + "name": "@output", + "ordinal_position": 2 + } + ] +} diff --git a/tests/fixtures/conformance/get_routines.json b/tests/fixtures/conformance/get_routines.json new file mode 100644 index 0000000..09d3b8c --- /dev/null +++ b/tests/fixtures/conformance/get_routines.json @@ -0,0 +1,16 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "definition": null, + "name": "drop_proc", + "routine_type": "PROCEDURE" + }, + { + "definition": null, + "name": "sample_proc", + "routine_type": "PROCEDURE" + } + ] +} diff --git a/tests/fixtures/conformance/get_schema_snapshot.json b/tests/fixtures/conformance/get_schema_snapshot.json new file mode 100644 index 0000000..14203ff --- /dev/null +++ b/tests/fixtures/conformance/get_schema_snapshot.json @@ -0,0 +1,153 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "varbinary(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "payload" + } + ], + "foreign_keys": [], + "name": "blob_rows" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "default_value": "(N'pending')", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + }, + { + "data_type": "nvarchar(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "note" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": true, + "is_nullable": true, + "is_pk": false, + "name": "generated_value" + } + ], + "foreign_keys": [ + { + "column_name": "parent_id", + "name": "fk_child_parent", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ], + "name": "child" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "character_maximum_length": 20, + "data_type": "nvarchar(20)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "value" + } + ], + "foreign_keys": [], + "name": "crud_rows" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + } + ], + "foreign_keys": [ + { + "column_name": "parent_id", + "name": "fk_drop", + "on_delete": "NO ACTION", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ], + "name": "drop_fk_child" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + } + ], + "foreign_keys": [], + "name": "parent" + } + ] +} diff --git a/tests/fixtures/conformance/get_schemas.json b/tests/fixtures/conformance/get_schemas.json new file mode 100644 index 0000000..f2ea0a6 --- /dev/null +++ b/tests/fixtures/conformance/get_schemas.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "dbo", + "ss003", + "ss044" + ] +} diff --git a/tests/fixtures/conformance/get_tables.json b/tests/fixtures/conformance/get_tables.json new file mode 100644 index 0000000..29764d5 --- /dev/null +++ b/tests/fixtures/conformance/get_tables.json @@ -0,0 +1,21 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "name": "blob_rows" + }, + { + "name": "child" + }, + { + "name": "crud_rows" + }, + { + "name": "drop_fk_child" + }, + { + "name": "parent" + } + ] +} diff --git a/tests/fixtures/conformance/get_trigger_definition.json b/tests/fixtures/conformance/get_trigger_definition.json new file mode 100644 index 0000000..eabb03c --- /dev/null +++ b/tests/fixtures/conformance/get_trigger_definition.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "CREATE TRIGGER [ss044].[base_after] ON [ss044].[child] AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; END" +} diff --git a/tests/fixtures/conformance/get_triggers.json b/tests/fixtures/conformance/get_triggers.json new file mode 100644 index 0000000..67e53c4 --- /dev/null +++ b/tests/fixtures/conformance/get_triggers.json @@ -0,0 +1,20 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "definition": "CREATE TRIGGER [ss044].[base_after] ON [ss044].[child] AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; END", + "event": "INSERT OR UPDATE", + "name": "base_after", + "table_name": "child", + "timing": "AFTER" + }, + { + "definition": "CREATE TRIGGER [ss044].[base_instead] ON [ss044].[child] INSTEAD OF DELETE AS BEGIN SET NOCOUNT ON; END", + "event": "DELETE", + "name": "base_instead", + "table_name": "child", + "timing": "INSTEAD OF" + } + ] +} diff --git a/tests/fixtures/conformance/get_view_columns.json b/tests/fixtures/conformance/get_view_columns.json new file mode 100644 index 0000000..f3a9e8f --- /dev/null +++ b/tests/fixtures/conformance/get_view_columns.json @@ -0,0 +1,23 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + } + ] +} diff --git a/tests/fixtures/conformance/get_view_definition.json b/tests/fixtures/conformance/get_view_definition.json new file mode 100644 index 0000000..6f07573 --- /dev/null +++ b/tests/fixtures/conformance/get_view_definition.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "CREATE VIEW [ss044].[base_view] AS SELECT id, label FROM [ss044].[child]" +} diff --git a/tests/fixtures/conformance/get_views.json b/tests/fixtures/conformance/get_views.json new file mode 100644 index 0000000..649e0e2 --- /dev/null +++ b/tests/fixtures/conformance/get_views.json @@ -0,0 +1,10 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "definition": null, + "name": "base_view" + } + ] +} diff --git a/tests/fixtures/conformance/initialize.json b/tests/fixtures/conformance/initialize.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/initialize.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/insert_record.json b/tests/fixtures/conformance/insert_record.json new file mode 100644 index 0000000..fcbcb5d --- /dev/null +++ b/tests/fixtures/conformance/insert_record.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": 1 +} diff --git a/tests/fixtures/conformance/ping.json b/tests/fixtures/conformance/ping.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/ping.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/routine_create_template.json b/tests/fixtures/conformance/routine_create_template.json new file mode 100644 index 0000000..acc943f --- /dev/null +++ b/tests/fixtures/conformance/routine_create_template.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "CREATE FUNCTION [ss044].[my_function] (@value INT)\nRETURNS INT\nAS\nBEGIN\n RETURN @value;\nEND" +} diff --git a/tests/fixtures/conformance/save_blob_to_file.json b/tests/fixtures/conformance/save_blob_to_file.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/save_blob_to_file.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/set_db_user_password.json b/tests/fixtures/conformance/set_db_user_password.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/set_db_user_password.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/shutdown.json b/tests/fixtures/conformance/shutdown.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/shutdown.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/test_connection.json b/tests/fixtures/conformance/test_connection.json new file mode 100644 index 0000000..2a28900 --- /dev/null +++ b/tests/fixtures/conformance/test_connection.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "success": true + } +} diff --git a/tests/fixtures/conformance/update_record.json b/tests/fixtures/conformance/update_record.json new file mode 100644 index 0000000..fcbcb5d --- /dev/null +++ b/tests/fixtures/conformance/update_record.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": 1 +} From 45a90b935e2067db3d6e3f921a14a58f99fd8367 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 23:33:29 +0000 Subject: [PATCH 20/23] perf(runtime): bound pool and query memory --- README.md | 10 ++- docs/performance.md | 170 ++++++++++++++++++++++++++++++++++++++++++++ src/driver/mod.rs | 64 +++++++++++++++-- src/main.rs | 28 ++++---- src/pool_manager.rs | 29 +++++++- tests/live_db.rs | 98 +++++++++++++++++++++++++ 6 files changed, 375 insertions(+), 24 deletions(-) create mode 100644 docs/performance.md diff --git a/README.md b/README.md index b56da4f..a5eff7c 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,13 @@ multi-statement SQL run without pagination metadata. `execute_query` and every statement in `execute_query_batch` use this same classification and execution path. -For a paginated query the plugin requests `page_size + 1` rows. It returns at -most `page_size`, sets `has_more` when the lookahead row exists, and sets -`truncated` to the same value because that lookahead row was omitted. +For a paginated query the plugin requests `page_size + 1` rows. It normally +returns at most `page_size`, sets `has_more` when the lookahead row exists, and +sets `truncated` to the same value because that lookahead row was omitted. A +statement-wide safety ceiling retains at most 10,000 rows across all result +sets, even when `limit` is absent or larger; crossing it also sets `truncated` +and, for paginated queries, `has_more`. This bounds the plugin's single-line +JSON response instead of buffering arbitrary row counts in process memory. `pagination.total_rows` remains `null`: normal page fetches never run a hidden count query. The Tabularis **Count rows** action obtains a total separately by running a `SELECT COUNT(*)` wrapper with pagination disabled. That count can diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..2b1b922 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,170 @@ +# Performance baseline + +This is a diagnostic baseline, not a performance target or guarantee. Results +will vary with SQL Server placement, TLS, schema size, query shape, and host +load. Repeat the same procedure when changing the TDS bridge, pool, runtime, or +worker topology. + +## Environment and method + +Measurements were taken on 2026-09-02 from commit `6f0f55b` plus the SS-045 +working change, using an optimized `cargo build --release` binary and a local +Docker container over `127.0.0.1`: + +- Linux 5.4.0 x86-64, 8 logical CPUs, 31.2 GiB RAM; +- Rust 1.98.0; +- SQL Server 2022 Developer, 16.0.4265.3 (CU26); +- `mcr.microsoft.com/mssql/server:2022-latest`, image + `sha256:90488d58c6a5c19f24ff716e14330b85b5b26ee54b44a36ea24e6206533e7edd`; +- TLS required with the development certificate trusted; +- default pool size 10, four JSON-RPC workers, and a 16 MiB Tokio thread stack. + +The harness drove newline-delimited JSON-RPC over the plugin's real stdin and +stdout. Timings use a monotonic clock around complete request/response pairs. +RSS and virtual size came from `/proc//status`. Cold latency is 20 fresh +processes. Steady latency is 100 sequential `SELECT CAST(1 AS INT)` calls after +one warm-up. Worker comparisons rebuild only `WORKER_POOL_SIZE`, warm the +available worker and pool paths with one request group, then report 20 groups +of eight simultaneous `get_tables` calls. Reported p95 values use the nearest +observed sample rather than interpolation. + +## Results + +### Query latency + +| Measurement | Median | p95 | +| --- | ---: | ---: | +| First query with a cold process and pool | 15.07 ms | 18.80 ms | +| Steady-state query on a warm pool | 6.42 ms | 6.95 ms | + +The steady path includes deadpool checkout and the session reset performed on +reuse. It is therefore a plugin round-trip baseline, not raw server execution +time. + +### Concurrent metadata and worker count + +| JSON-RPC workers | Eight `get_tables` calls median | p95 | +| ---: | ---: | ---: | +| 1 | 70.59 ms | 74.94 ms | +| 2 | 41.55 ms | 44.83 ms | +| **4 — default** | **26.45 ms** | **29.63 ms** | +| 8 | 19.21 ms | 21.02 ms | + +Four workers reduced the median group time by 63% relative to one. Eight +workers improved another 27%, but can create twice as many simultaneous TDS +sessions and showed diminishing absolute returns. Four remains the default; +SQL connection concurrency remains independently configurable through +`max_pool_size`. + +### Idle pools and eviction + +A release process used 5,868 KiB RSS before opening a connection and 15,180 +KiB after one idle connection in each of `master` and three scratch databases: +a 9,312 KiB high-water increase for four pools. A separate run measured 5,260 +KiB from process start to one pool and roughly 1.3 MiB for each of the next +three. These figures include lazily touched runtime, TLS, and allocator pages, +so they are not a per-session allocation formula. Virtual size remained +829,648 KiB before and after opening pools; most of it is reserved address +space, including runtime stacks, rather than resident memory. + +Eviction was checked with `pool_idle_eviction_minutes` set to 1 and a unique +`application_name`. Four pools were opened against four databases and released +back to deadpool. This server-side query initially returned 4 and returned 0 +at 60.0 seconds without another plugin request: + +```sql +SELECT COUNT(*) +FROM sys.dm_exec_sessions +WHERE program_name = N'Tabularis SS-045 eviction'; +``` + +The cleanup path now explicitly closes every fully idle deadpool before +removing it from the cache. Checked-out pools survive that pass and are +considered again on the next interval. + +### Pool identity + +Unit and live tests verified all three key properties using pool identity and +SQL Server `@@SPID` values: + +- repeated identical parameters reused the same pool and physical session; +- changing only `database` produced a different pool and session, even with + the same `connection_id`; +- URL connection-string parameters reused the same session as equivalent + discrete parameters. + +Connection strings are resolved to canonical fields before key construction, +so syntax does not create duplicate pools. + +### Large results + +JSON-RPC returns one JSON line and cannot stream rows incrementally. The old +collector retained every row when `limit` was absent, allowing a million-row +query to grow until the process or host ran out of memory. SS-045 adds a hard +budget of 10,000 retained rows across a statement's result sets. On the next +row it sets `truncated: true`, closes the remaining TDS stream, and keeps the +pooled session reusable. An explicit page limit cannot bypass this safety +budget. Result-bearing DML drains excess `OUTPUT` rows without retaining them +so it can still read the trailing affected-row sentinel. + +The million-row live fixture now returned 10,000 rows with `truncated: true` in +379.10 ms. The response was 69,017 bytes and plugin RSS rose by 5,340 KiB at +peak from a warm baseline. A follow-up query on the same pool succeeded. The +ceiling bounds row accumulation; an individual SQL value can still be large, +so callers should continue to paginate and use the dedicated bounded BLOB +preview RPC where applicable. + +### Queue backpressure and responsiveness + +Both the request and response channels now hold at most 64 messages. Including +four active workers and the reader and writer payloads, at most 134 payloads +are queued or active inside the dispatcher. Bounding only the input queue was +insufficient: a slow stdout consumer could previously move all completed +responses into an unbounded output channel. + +In the release measurement, a two-second `WAITFOR` followed by 200 `ping` +requests produced a ping response after 8.55 ms rather than waiting for the +slow query. All 201 responses arrived with matching ids. RSS rose from 11,588 +to a sampled peak of 19,304 KiB while sessions and worker paths warmed, a +7,716 KiB increase. The live regression test repeats the 200-request burst; +the fixed channel capacities provide the actual memory backpressure rather +than relying on that one observed RSS value. + +### Worker stack + +The earlier debug probe overflowed Tokio's 2 MiB default stack and completed +at 4 MiB. For this task, the optimized binary was rebuilt with a 4 MiB stack +and the full 25-test live suite passed in 21.88 seconds, including type +extraction, metadata, DDL, errors, EXPLAIN, BLOBs, million-row cancellation, +and concurrent requests. This establishes that 4 MiB worked for this Linux +release build; it does not prove safety for debug binaries or every target. + +The configured 16 MiB stack is retained as a four-times margin over the +smallest observed successful size. Tokio reserves this virtual address range +per runtime thread but commits resident pages on demand. Reduce it only after +the preview TDS client changes or equivalent debug and release stress coverage +is green on all release platforms. + +## Reproduction checks + +The committed automated checks are: + +```bash +cargo test --bins pool_manager::tests -- --test-threads=1 +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ + cargo test --test live_db \ + million_row_query_is_bounded_and_marks_truncation -- --test-threads=1 +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ + cargo test --test live_db \ + request_burst_is_bounded_and_slow_query_does_not_block_ping \ + -- --test-threads=1 +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ + cargo test --test live_db \ + pool_keys_reuse_identical_and_equivalent_forms_but_separate_databases \ + -- --test-threads=1 +``` + +The one-minute DMV eviction timing and worker-count comparison are intentionally +recorded measurements rather than always-on CI tests. Making every CI run wait +for a wall-clock eviction interval would add a minute while testing timer +accuracy more than cleanup behavior. diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 8072429..5239ecd 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -26,6 +26,24 @@ use mssql_tiberius_bridge::Row; use crate::models::{ConnectionParams, Pagination, QueryResult}; use crate::pool_manager::{self, get_sqlserver_pool}; +/// Maximum rows retained across all result sets from one statement. +/// +/// JSON-RPC responses are single JSON lines, so they cannot be streamed to the +/// host incrementally. This ceiling bounds response memory even when callers +/// omit `limit` or request an impractically large page. SQL Server may produce +/// one additional row so the collector can set `truncated` accurately. +pub const MAX_RESULT_ROWS: usize = 10_000; + +#[derive(Clone, Copy)] +enum OverflowPolicy { + /// Cancel the remaining TDS result stream as soon as the ceiling is known + /// to have been exceeded. + Stop, + /// Drain without retaining ordinary rows. Result-bearing DML needs its + /// trailing affected-row sentinel, even when OUTPUT exceeds the ceiling. + DrainForAffectedRows, +} + /// Acquire a pooled client from the pool manager. pub async fn acquire( params: &ConnectionParams, @@ -56,7 +74,7 @@ fn empty_query_result(columns: Vec) -> QueryResult { } } -/// Run `query` as a simple batch and collect every result set. +/// Run `query` as a simple batch and collect its bounded result sets. /// /// Goes through the bridge's `inner_mut()` escape hatch instead of /// `simple_query().into_results()`: the bridge derives columns from rows, so @@ -68,6 +86,7 @@ fn empty_query_result(columns: Vec) -> QueryResult { async fn run_query_collecting( conn: &mut pool::BridgeConnection, query: &str, + overflow_policy: OverflowPolicy, ) -> Result, String> { let query_timeout_seconds = conn.query_timeout_seconds(); let ssl_mode = conn.ssl_mode().to_owned(); @@ -84,9 +103,13 @@ async fn run_query_collecting( .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))?; let mut results = Vec::new(); - while let Some(result_set) = client.get_current_resultset() { + let mut retained_rows = 0usize; + let mut stopped_early = false; + 'result_sets: while let Some(result_set) = client.get_current_resultset() { let metadata = result_set.get_metadata().clone(); let schema = RowSchema::from_metadata(&metadata); + let is_affected_rows_sentinel = + metadata.len() == 1 && metadata[0].column_name == helpers::AFFECTED_ROWS_COLUMN; let mut current = empty_query_result( metadata .iter() @@ -98,6 +121,16 @@ async fn run_query_collecting( .await .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))? { + if retained_rows >= MAX_RESULT_ROWS && !is_affected_rows_sentinel { + current.truncated = true; + if matches!(overflow_policy, OverflowPolicy::Stop) { + results.push(current); + stopped_early = true; + break 'result_sets; + } + continue; + } + let row = Row::from_schema(schema.clone(), values); current.rows.push( metadata @@ -112,6 +145,9 @@ async fn run_query_collecting( }) .collect::, _>>()?, ); + if !is_affected_rows_sentinel { + retained_rows += 1; + } } results.push(current); if !client @@ -122,6 +158,20 @@ async fn run_query_collecting( break; } } + if stopped_early { + client + .close_query() + .await + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))?; + } + if results.iter().any(|result| result.truncated) { + // `truncated` on the primary result is the statement-level signal the + // host already consumes. Keep it true even if the budget was crossed + // in an additional result set. + if let Some(first) = results.first_mut() { + first.truncated = true; + } + } Ok(results) } @@ -175,7 +225,9 @@ async fn execute_result_bearing_dml( query: &str, ) -> Result { let wrapped = helpers::wrap_dml_with_rowcount(query); - finish_result_bearing_dml(run_query_collecting(conn, &wrapped).await?) + finish_result_bearing_dml( + run_query_collecting(conn, &wrapped, OverflowPolicy::DrainForAffectedRows).await?, + ) } pub async fn execute_on_connection( @@ -216,7 +268,7 @@ pub async fn execute_on_connection( Some(page_size) => helpers::build_paginated_query(query, page_size, page), None => query.to_string(), }; - let mut results = run_query_collecting(conn, &final_query).await?; + let mut results = run_query_collecting(conn, &final_query, OverflowPolicy::Stop).await?; let mut first = if results.is_empty() { empty_query_result(Vec::new()) } else { @@ -224,8 +276,8 @@ pub async fn execute_on_connection( }; if let Some(ref mut pagination) = pagination { - pagination.has_more = first.rows.len() > pagination.page_size as usize; - if pagination.has_more { + pagination.has_more = first.truncated || first.rows.len() > pagination.page_size as usize; + if first.rows.len() > pagination.page_size as usize { first.rows.truncate(pagination.page_size as usize); first.truncated = true; } diff --git a/src/main.rs b/src/main.rs index 416a112..616bf2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,16 +26,19 @@ mod settings; // connection concurrency, which is configured separately by max_pool_size. const WORKER_POOL_SIZE: usize = 4; -// Bounded so a burst of requests applies backpressure to the stdin reader -// instead of buffering unboundedly in memory. +// Both sides are bounded so backpressure reaches stdin even when the host is +// slow to consume responses. With four in-flight handlers this caps a burst at +// 134 queued or active payloads instead of moving it into an unbounded output +// queue (64 requests + 64 responses + 4 workers + reader + writer). const REQUEST_QUEUE_CAPACITY: usize = 64; +const RESPONSE_QUEUE_CAPACITY: usize = 64; // The TDS client's async call chains produce large futures (especially in -// debug builds). A local SQL Server 2022 execute_query probe overflowed -// tokio's default 2 MiB stack while 4 MiB completed; 16 MiB is therefore a -// deliberate 4x safety margin, not a measured minimum. Keep the margin until -// the preview client flattens those polling chains or equivalent CI stress -// coverage proves a smaller stack across platforms. +// debug builds). A local SQL Server 2022 debug execute_query probe overflowed +// tokio's default 2 MiB stack while 4 MiB completed. The full release live +// suite also passed at 4 MiB in SS-045, but that does not remove the debug or +// cross-platform risk. Keep 16 MiB as a deliberate 4x margin until the preview +// client flattens those polling chains or CI stress proves less everywhere. const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024; fn main() { @@ -55,7 +58,7 @@ async fn run() { let (req_tx, req_rx) = mpsc::channel::(REQUEST_QUEUE_CAPACITY); let req_rx = Arc::new(Mutex::new(req_rx)); - let (resp_tx, resp_rx) = mpsc::unbounded_channel::(); + let (resp_tx, resp_rx) = mpsc::channel::(RESPONSE_QUEUE_CAPACITY); let writer_handle = tokio::spawn(run_writer(resp_rx)); let worker_handles: Vec<_> = (0..WORKER_POOL_SIZE) @@ -116,10 +119,7 @@ async fn run_reader(req_tx: mpsc::Sender) { } } -async fn run_worker( - req_rx: Arc>>, - resp_tx: mpsc::UnboundedSender, -) { +async fn run_worker(req_rx: Arc>>, resp_tx: mpsc::Sender) { loop { let line = { let mut rx = req_rx.lock().await; @@ -138,13 +138,13 @@ async fn run_worker( ), }; - if resp_tx.send(body).is_err() { + if resp_tx.send(body).await.is_err() { break; } } } -async fn run_writer(mut resp_rx: mpsc::UnboundedReceiver) { +async fn run_writer(mut resp_rx: mpsc::Receiver) { let mut stdout = tokio::io::stdout(); while let Some(mut body) = resp_rx.recv().await { body.push('\n'); diff --git a/src/pool_manager.rs b/src/pool_manager.rs index 23b6cc2..40c2a32 100644 --- a/src/pool_manager.rs +++ b/src/pool_manager.rs @@ -96,7 +96,17 @@ pub async fn remove_sqlserver_pool(params: &ConnectionParams) { /// periodically so long-idle sessions don't linger for the plugin's lifetime. pub async fn cleanup_idle_pools() { let mut pools = SQLSERVER_POOLS.write().await; - pools.retain(|_, pool| pool.status().size > pool.status().available); + pools.retain(|_, pool| { + let status = pool.status(); + let has_checked_out_connection = status.size > status.available; + if !has_checked_out_connection { + // Explicit close makes the server-side sessions disappear now; + // removing the last registry handle alone would rely on drop + // timing inside deadpool. + pool.close(); + } + has_checked_out_connection + }); } /// Close every cached pool and remove it from the process-wide registry. @@ -147,6 +157,23 @@ mod tests { assert_eq!(key, "sqlserver:localhost:1433:sa:master:ssl:require"); } + #[test] + fn database_is_part_of_the_key_and_identical_params_are_stable() { + let master = params(Some("ss045-key")); + let identical = master.clone(); + let mut other_database = master.clone(); + other_database.database = crate::models::DatabaseSelection::Single("tempdb".into()); + + assert_eq!( + build_connection_key(&master), + build_connection_key(&identical) + ); + assert_ne!( + build_connection_key(&master), + build_connection_key(&other_database) + ); + } + #[test] fn equivalent_discrete_and_connection_string_params_share_a_key() { let mut discrete = params(None); diff --git a/tests/live_db.rs b/tests/live_db.rs index 8e20619..5fa4c70 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -1314,6 +1314,63 @@ fn pagination_and_batch_semantics_cover_ordered_unordered_cte_and_dml() { assert_eq!(batch[3]["result"]["pagination"]["has_more"], true); } +#[test] +fn million_row_query_is_bounded_and_marks_truncation() { + let mut plugin = Plugin::with_scratch_database(); + let result = plugin.execute( + "SELECT TOP (1000000) \ + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS INT) AS row_number \ + FROM sys.all_objects AS left_source \ + CROSS JOIN sys.all_objects AS right_source", + ); + + assert_eq!(result_rows(&result).len(), 10_000); + assert_eq!(result["truncated"], true); + assert_eq!(result["pagination"], Value::Null); + + // Cancelling the remainder of the TDS stream must leave the pooled + // connection immediately reusable. + let recovered = plugin.execute("SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(recovered["rows"], json!([[1]])); +} + +#[test] +fn request_burst_is_bounded_and_slow_query_does_not_block_ping() { + let mut plugin = Plugin::with_scratch_database(); + let params = connection_params(); + let started = std::time::Instant::now(); + let slow_id = plugin.send( + "execute_query", + json!({ + "params": params, + "query": "WAITFOR DELAY '00:00:02'; SELECT CAST(1 AS INT) AS finished" + }), + ); + let ping_ids: BTreeSet = (0..200) + .map(|_| plugin.send("ping", json!({ "params": params }))) + .collect(); + + let first = plugin.read_response(); + let first_id = first["id"].as_u64().expect("response id"); + assert!(ping_ids.contains(&first_id), "a ping must finish first"); + assert!( + started.elapsed() < std::time::Duration::from_secs(2), + "the slow query blocked all JSON-RPC workers" + ); + + let mut seen = BTreeSet::from([first_id]); + for _ in 0..200 { + let response = plugin.read_response(); + assert!( + response.get("error").is_none(), + "burst response: {response}" + ); + seen.insert(response["id"].as_u64().expect("response id")); + } + assert!(seen.contains(&slow_id)); + assert!(ping_ids.iter().all(|id| seen.contains(id))); +} + #[test] fn syntax_and_constraint_errors_keep_server_details_and_pool_recovery() { let mut plugin = Plugin::with_scratch_database(); @@ -1966,6 +2023,47 @@ fn connection_string_only_connects_for_url_and_keyword_syntaxes() { } } +#[test] +fn pool_keys_reuse_identical_and_equivalent_forms_but_separate_databases() { + let mut plugin = Plugin::with_scratch_database(); + + let identical = connection_params_for(&test_database(), "ss045-identical"); + let first = plugin.execute_with(&identical, "SELECT @@SPID AS session_id"); + let second = plugin.execute_with(&identical, "SELECT @@SPID AS session_id"); + assert_eq!(first["rows"][0][0], second["rows"][0][0]); + + let master = connection_params_for("master", "ss045-database-key"); + let selected = connection_params_for(&test_database(), "ss045-database-key"); + let master_session = plugin.execute_with(&master, "SELECT @@SPID AS session_id"); + let selected_session = plugin.execute_with(&selected, "SELECT @@SPID AS session_id"); + assert_ne!(master_session["rows"][0][0], selected_session["rows"][0][0]); + + let mut discrete = connection_params(); + discrete + .as_object_mut() + .expect("connection params object") + .remove("connection_id"); + let username = discrete["username"].as_str().expect("username"); + let password = discrete["password"].as_str().expect("password"); + let host = discrete["host"].as_str().expect("host"); + let port = discrete["port"].as_u64().expect("port"); + let database = discrete["database"].as_str().expect("database"); + let connection_string = format!( + "sqlserver://{}:{}@{}:{}/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(password), + host, + port, + url_encode_component(database), + ); + let from_discrete = plugin.execute_with(&discrete, "SELECT @@SPID AS session_id"); + let from_string = plugin.execute_with( + &json!({ "connection_string": connection_string }), + "SELECT @@SPID AS session_id", + ); + assert_eq!(from_discrete["rows"][0][0], from_string["rows"][0][0]); +} + #[test] fn database_user_lifecycle_privilege_diff_roles_and_ownership_guard() { const LOGIN: &str = "ss014_login"; From bcf9f0e2db2f2ae863f64465c6fa986d6f3caa73 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 2 Sep 2026 23:50:22 +0000 Subject: [PATCH 21/23] chore(release): close out release candidate --- .github/workflows/ci.yml | 55 ++++++++--- .github/workflows/publish-explain.yml | 3 +- .github/workflows/release.yml | 7 +- CHANGELOG.md | 55 ++++++++--- CLAUDE.md | 11 ++- README.md | 34 +++++-- docs/completeness.md | 137 +++++++++++++------------- docs/dependencies.md | 21 ++-- docs/explain-architecture.md | 17 ++++ docs/performance.md | 11 ++- docs/sql-audit.md | 62 ++++++------ explain/README.md | 14 ++- 12 files changed, 266 insertions(+), 161 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44ae9a6..72c7e9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,8 @@ jobs: - name: Setup Node uses: actions/setup-node@v5 with: - node-version: "20" + # pnpm 11.23 requires Node 22.13 or newer. + node-version: "22.13" - name: Enable pnpm run: corepack enable @@ -74,8 +75,36 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Validate against the live registry schema - run: npx --yes @tabularium/cli validate .tabularium --registry https://registry.tabularis.dev --kind driver + # The live schema does not yet include core PR #688's additive + # explain_parsers field. Keep every deployed field under the real + # validator and validate that one pending extension exactly until the + # schema deployment tracked in plugin issue #4. + - name: Validate deployed manifest fields against the live registry schema + run: | + jq 'del(.explain_parsers)' .tabularium > /tmp/sqlserver.tabularium + npx --yes @tabularium/cli validate /tmp/sqlserver.tabularium \ + --registry https://registry.tabularis.dev --kind driver + + - name: Validate pending EXPLAIN parser declaration + run: | + python3 - <<'PY' + import json + from pathlib import Path + + manifest = json.loads(Path(".tabularium").read_text()) + parsers = manifest.get("explain_parsers") + assert isinstance(parsers, list) and parsers, "explain_parsers must be a non-empty array" + allowed = {"engine", "format", "label", "module"} + required = {"engine", "format", "module"} + for parser in parsers: + assert isinstance(parser, dict), "each explain parser must be an object" + assert not parser.keys() - allowed, "unknown explain parser property" + assert required <= parser.keys(), "missing required explain parser property" + for key in required: + assert isinstance(parser[key], str) and parser[key].strip(), f"{key} must be a non-empty string" + if "label" in parser: + assert isinstance(parser["label"], str) and parser["label"].strip(), "label must be a non-empty string" + PY live-db-integration: name: Live SQL Server integration @@ -165,12 +194,12 @@ jobs: fi case "$TYPE" in - feat) CLASS=minor ;; - fix|refactor|perf) CLASS=patch ;; - docs|style|chore|test|ci|build) CLASS=none ;; - *) CLASS=none ;; + feat) CLASS="minor" ;; + fix|refactor|perf) CLASS="patch" ;; + docs|style|chore|test|ci|build) CLASS="none" ;; + *) CLASS="none" ;; esac - [ "$BREAKING" = true ] && CLASS=major + [ "$BREAKING" = true ] && CLASS="major" CHANNEL=$(echo "$PR_LABELS" | jq -r '[.[] | select(.name | startswith("prerelease:")) | .name][0] // ""' | sed 's/^prerelease://') if [ -z "$CHANNEL" ]; then @@ -182,10 +211,12 @@ jobs: *) echo "::error::Unrecognized prerelease label value '$CHANNEL' — expected alpha, beta, rc, or stable."; exit 1 ;; esac - echo "type=$TYPE" >> "$GITHUB_OUTPUT" - echo "breaking=$BREAKING" >> "$GITHUB_OUTPUT" - echo "class=$CLASS" >> "$GITHUB_OUTPUT" - echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" + { + echo "type=$TYPE" + echo "breaking=$BREAKING" + echo "class=$CLASS" + echo "channel=$CHANNEL" + } >> "$GITHUB_OUTPUT" - name: Resolve baseline version id: baseline diff --git a/.github/workflows/publish-explain.yml b/.github/workflows/publish-explain.yml index be54415..652f19d 100644 --- a/.github/workflows/publish-explain.yml +++ b/.github/workflows/publish-explain.yml @@ -19,7 +19,8 @@ jobs: - name: Setup Node uses: actions/setup-node@v5 with: - node-version: "20" + # pnpm 11.23 requires Node 22.13 or newer. + node-version: "22.13" registry-url: "https://registry.npmjs.org" - name: Enable pnpm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 495603d..90a55dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,7 +89,8 @@ jobs: if: steps.ui.outputs.present == 'true' || steps.explain.outputs.present == 'true' uses: actions/setup-node@v5 with: - node-version: "20" + # pnpm 11.23 requires Node 22.13 or newer. + node-version: "22.13" - name: Build UI if: steps.ui.outputs.present == 'true' @@ -190,8 +191,10 @@ jobs: - name: Detect prerelease from tag id: meta + env: + TAG_NAME: ${{ github.ref_name }} run: | - if [[ "${{ github.ref_name }}" == *-* ]]; then + if [[ "$TAG_NAME" == *-* ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" else echo "prerelease=false" >> "$GITHUB_OUTPUT" diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e2212..55b497e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### BREAKING +The release candidate version is `1.0.0-beta.1`; it has not yet been tagged or +published. Publication is tracked in +[issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). + +### Breaking - Visual EXPLAIN now returns raw `sqlserver-showplan-xml` for the plugin-owned TypeScript parser instead of parsing SHOWPLAN in the Rust process. This - requires Tabularis 0.23.0 or later, the first runtime with raw plugin - EXPLAIN output and plugin parser-bundle loading. - -## [1.0.0-beta.1] - 2026-08-30 + requires Tabularis 0.23.0 or later, the first intended runtime with raw + plugin EXPLAIN output and plugin parser-bundle loading. ### Changed @@ -26,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 of retaining the scaffold's `0.1.0` version. Pull-request `prerelease:alpha`, `prerelease:beta`, `prerelease:rc`, and `prerelease:stable` labels drive version suggestions. +- Aligned paging with host lookahead semantics, made totals explicitly + on-demand, and capped each statement at 10,000 retained rows across result + sets. ### Added @@ -37,18 +42,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 query timeouts, TDS application identity, certificate trust, and idle pool eviction. - SQL-authenticated login and database-user lifecycle management, privilege - catalogs, direct and inherited grant reporting, and transactional - privilege changes. + catalogs, direct and inherited grant reporting, and transactional privilege + changes. - Registry-grade manifest metadata, SQL Server branding and screenshots, native type mappings, synchronized data-type declarations, and release - archives for five desktop platforms. + workflows for five desktop platforms. +- A browser-safe SQL Server SHOWPLAN parser built as both the plugin IIFE and + the independently publishable `@tabularis/explain-sqlserver` package. - CI checks for formatting, Clippy, unit and live SQL Server 2022 tests, manifest and Markdown validation, Conventional Commit pull-request titles, - version suggestions, dependency updates, RustSec advisories, and release - tag/version agreement. -- Schema and object introspection, query and batch execution, CRUD, DDL, - views, routines, triggers, visual execution plans, JavaScript-safe integer - extraction, and broad SQL Server type handling. - -[Unreleased]: https://github.com/TabularisDB/tabularis-sqlserver-plugin/compare/v1.0.0-beta.1...HEAD -[1.0.0-beta.1]: https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/tag/v1.0.0-beta.1 + version suggestions, dependency updates, RustSec advisories, release + tag/version agreement, and the TypeScript parser package. +- Host-model conformance fixtures for every implemented RPC and a live type + matrix for all 37 manifest-advertised SQL Server types. + +### Fixed + +- Hardened identifier quoting and separated identifier, bound-value and + explicit raw-SQL boundaries throughout CRUD, DDL, routine, trigger and user + management. +- Preserved exact numeric, temporal, BLOB, UDT and `SQL_VARIANT` values across + reads and row edits; concurrency-token types remain read-only. +- Added structured SQL Server error categories, credential redaction and safe + replacement of timed-out, failed, transactional or dead pooled sessions. +- Reset SHOWPLAN, `IDENTITY_INSERT`, startup-script and temporary-table state + before pooled session reuse. + +### Performance + +- Added bounded request and response queues, explicit idle-pool closing and + regression coverage for pool identity, million-row truncation and concurrent + responsiveness. + +[Unreleased]: https://github.com/TabularisDB/tabularis-sqlserver-plugin/compare/main...HEAD diff --git a/CLAUDE.md b/CLAUDE.md index 8f23b80..145f2cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,13 +35,16 @@ src/ rpc.rs # JSON-RPC dispatch + response/param helpers models.rs # serde shapes mirroring the Tabularis host's models common.rs # query classification + JS-safe integer helpers - pool_manager.rs # per-connection-key deadpool cache + connection.rs # URL/keyword connection strings and canonical reconciliation + settings.rs # forgiving process settings received by initialize + pool_manager.rs # canonical-key deadpool cache and idle eviction handlers/ # thin JSON adapters, one module per RPC area driver/ # SQL Server logic ops.rs # one free function per host RPC method - pool.rs # Microsoft mssql-tds client via bridge + deadpool Manager (TLS modes, startup scripts) - introspection.rs, helpers.rs, ddl/, routines/, triggers/, types.rs, version.rs - extract/ # row → JSON value extraction (incl. temporal types) + pool.rs # Microsoft mssql-tds bridge + deadpool Manager, TLS and reset + error.rs # categorized errors, discard policy and credential redaction + blob.rs, users.rs, introspection.rs, helpers.rs, ddl/, routines/, triggers/, types.rs + extract/ # row → JSON value extraction, including temporal types explain.rs # raw SHOWPLAN_XML / STATISTICS XML capture explain/ # TypeScript SHOWPLAN parser, plugin IIFE and npm package ``` diff --git a/README.md b/README.md index a5eff7c..eda5087 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ A [Microsoft SQL Server](https://www.microsoft.com/sql-server) plugin for [Tabul This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, BLOB handling, database-user management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. > **Requires Tabularis v0.23.0 or later.** This plugin relies on raw plugin -> EXPLAIN output and plugin-provided parser bundle loading from that release. +> EXPLAIN output and plugin-provided parser bundle loading targeted for that +> release. Do not publish this candidate before a compatible host is available. **Discord** — [Join our Discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. @@ -32,6 +33,7 @@ This plugin enables Tabularis to connect to SQL Server instances, providing sche - [Query Execution Semantics](#query-execution-semantics) - [Supported Data Types](#supported-data-types) - [Database Users and Privileges](#database-users-and-privileges) +- [Visual EXPLAIN](#visual-explain) - [Installation](#installation) - [How It Works](#how-it-works) - [Supported Operations](#supported-operations) @@ -56,7 +58,7 @@ This plugin enables Tabularis to connect to SQL Server instances, providing sche - Procedure/function management, typed `OUT`/`INOUT` variables, and table-valued functions - Static and runtime execution plans through `SHOWPLAN_XML` / `STATISTICS XML`, rendered in Tabularis's Visual EXPLAIN - JavaScript-safe `BIGINT` extraction and broad SQL Server type handling -- Pre-built release targets for Linux x86_64, macOS x86_64 and Apple Silicon, and Windows x86_64 +- Release workflow targets for Linux x86_64 and ARM64, macOS x86_64 and Apple Silicon, and Windows x86_64 ## Screenshots @@ -267,19 +269,39 @@ if they were direct grants. Because SQL Server `DENY` overrides `GRANT`, the plugin refuses to alter a denied permission; remove that `DENY` explicitly in SQL before managing the permission through Tabularis. +## Visual EXPLAIN + +The Rust process safely captures estimated `SHOWPLAN_XML` or runtime +`STATISTICS XML`, restores the session option, and returns the untouched XML as +raw format `sqlserver-showplan-xml`. It does not parse plan trees itself. +Tabularis v0.23.0 or later reads `explain/dist/index.iife.js` from the installed +plugin and registers that isolated TypeScript parser with +`@tabularis/explain`. + +The same source builds the independently versioned +`@tabularis/explain-sqlserver` ESM package for browser and Node consumers such +as [explain.tabularis.dev](https://explain.tabularis.dev). This keeps SQL Server +semantics owned by this plugin while letting renderer improvements apply +without another Rust implementation. The complete parser and wire contract is +in [`docs/explain-architecture.md`](docs/explain-architecture.md). + ## Installation ### Automatic (via Tabularis) -Open **Settings → Plugins** in Tabularis and install *SQL Server* from the plugin registry. +After the first release is published and registered, open **Settings → Plugins** +in Tabularis and install *SQL Server* from the plugin registry. Publication +status is tracked in [issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). ### Manual Installation +Once release assets are available: + 1. Download the ZIP for your platform from the [releases page](https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases). 2. Extract it into the Tabularis plugins directory: - **Linux:** `~/.local/share/tabularis/plugins/sqlserver/` - - **macOS:** `~/Library/Application Support/com.debba.tabularis/plugins/sqlserver/` - - **Windows:** `%APPDATA%\debba\tabularis\data\plugins\sqlserver\` + - **macOS:** `~/Library/Application Support/tabularis/plugins/sqlserver/` + - **Windows:** `%APPDATA%\tabularis\plugins\sqlserver\` 3. On Linux/macOS, make the binary executable: `chmod +x sqlserver-plugin` 4. Restart Tabularis — *SQL Server* appears in the connection picker. @@ -328,7 +350,7 @@ remaining pools. ### Prerequisites - Rust (stable, see `rust-toolchain.toml`) -- Node.js 20 or newer and pnpm for the bundled Visual EXPLAIN parser +- Node.js 22.13 or newer and pnpm 11 for building the bundled Visual EXPLAIN parser - [`just`](https://github.com/casey/just) (optional, wraps the common build and test commands) ### Build diff --git a/docs/completeness.md b/docs/completeness.md index 5a2a929..a6ae18c 100644 --- a/docs/completeness.md +++ b/docs/completeness.md @@ -1,15 +1,15 @@ # Plugin completeness -This document records the remaining gaps between the SQL Server plugin and the -Tabularis host protocol and plugin registry. It is the repository-local -checklist for the completeness work; task identifiers refer to the project -plan used to deliver that work. +This document records the SQL Server plugin's release-candidate state against +the Tabularis host protocol and registry. The implementation work is complete; +publication and cross-repository rollout remain tracked in +[issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). ## Host protocol -The plugin already supports connection testing, schema introspection, query -execution, CRUD, DDL, views, routines, triggers, and visual EXPLAIN. The -remaining protocol gaps are: +The plugin supports connection testing, schema introspection, query execution, +CRUD, DDL, views, routines, triggers, BLOB handling, database users and +privileges, and Visual EXPLAIN. In particular: - `initialize` applies forgiving process settings for pool sizing, connection and query timeouts, the TDS application name, certificate trust, and idle @@ -21,18 +21,18 @@ remaining protocol gaps are: - All eight database-user and privilege methods manage mapped SQL login/database-user pairs, direct and inherited grants, and DENY-safe privilege changes. -- The host currently terminates plugin processes directly and never sends an - RPC `shutdown`; the courtesy method is implemented anyway and closes and - removes every cached pool before replying `null`. -- The four materialized-view methods deliberately return a reasoned `-32601`. - SQL Server indexed views are synchronously maintained views with clustered - indexes, not refreshable materialized views, so mapping between them would - misrepresent both lifecycle and semantics. -- A host-method coverage test snapshots every RPC sent by the host and requires - each one to be dispatched or included in the reasoned `NOT_IMPLEMENTED` - table. Unknown methods also return `-32601` naming the method and plugin. -- `explain_query` returns raw `sqlserver-showplan-xml` for the plugin-owned - TypeScript parser registered by the host. +- `shutdown` is implemented as a courtesy method that closes and removes every + cached pool, although the current host normally terminates the subprocess. +- `explain_query` returns raw `sqlserver-showplan-xml`; Tabularis loads the + plugin-owned TypeScript parser declared in `.tabularium`. + +The only deliberate protocol exclusions are the four materialized-view +methods. SQL Server indexed views are synchronously maintained views with +clustered indexes, not refreshable materialized views, so those methods return +a reasoned `-32601` instead of misrepresenting their lifecycle. A host-method +coverage test requires every host RPC to be dispatched or included in that +reasoned table. Truly unknown methods return `-32601` naming the method and +plugin. ## Host model conformance @@ -42,13 +42,13 @@ live-captured fixture for every implemented RPC. Regenerate all 54 responses with `python3 tests/capture_conformance.py` whenever the host models or RPC surface changes. -The first conformance sync found two additive host fields missing from the -plugin wire models. SQL Server column introspection now emits `is_generated` -from `sys.columns.is_computed`, including the schema snapshot and batch path, -and index metadata emits `is_expression: false` because SQL Server does not -support arbitrary expression index keys. Parameterized character types now -retain `character_maximum_length`; absent lengths and defaults remain omitted -and deserialize through the host's optional fields. +The conformance sync found two additive host fields that had been missing from +the plugin wire models. SQL Server column introspection now emits +`is_generated` from `sys.columns.is_computed`, including the schema snapshot +and batch path. Index metadata emits `is_expression: false` because SQL Server +does not support arbitrary expression index keys. Parameterized character +types retain `character_maximum_length`; absent lengths and defaults remain +omitted and deserialize through the host's optional fields. ## BLOB policy @@ -64,52 +64,49 @@ from the SQL result when it exceeds the ceiling, so an oversized `VARBINARY(MAX)` is neither transferred over TDS nor base64-encoded into the JSON-RPC line. The error reports the actual and configured sizes and suggests file export, which remains unbounded. For compatibility with hosts that omit -the field, the plugin uses Tabularis' 100 MiB default. +the field, the plugin uses Tabularis's 100 MiB default. ## Connection parameters -The manifest advertises connection-string support, but `ConnectionParams` -does not contain `connection_string`; only discrete host, port, username, -password, and database fields work. `SS-011` will make the declared capability -functional. +Discrete host, port, username, password and database fields work alongside URL +and ADO.NET/ODBC `connection_string` forms. Explicit string values are +reconciled against discrete fields, passwords are redacted in conflicts, and +equivalent forms normalize to the same pool key. TLS modes, startup scripts and +connection ids are included in pool identity where they affect sessions. SQL authentication remains the supported authentication mechanism. Azure AD, -Windows Integrated Authentication, custom CA files, and client certificates -are outside the completion scope. - -## Manifest and settings - -The `.tabularium` file validates against the live registry driver schema. It -now carries the registry metadata and links, SQL Server branding, runtime -floor, generic-to-native type mappings, and capabilities verified against the -implemented RPC surface. A unit test keeps its `data_types` list synchronized -with `driver/types.rs`; the registry-compatible `string` and `date` categories -replace the scaffold's unsupported `text` and `datetime` labels. - -The manifest now links the scalable SQL Server icon and the complete eight-image -registry screenshot set. Remaining work is limited to the plugin-owned -`explain_parsers` declaration and corresponding runtime-version bump in -`SS-030`, `SS-034`, and `SS-035`. - -## CI and release packaging - -The Rust build, test, Clippy, formatting, live SQL Server integration, -registry manifest validation, and scheduled security-audit checks exist. -Release readiness still requires: - -- Conventional Commit pull-request title checks; -- version-suggestion and Markdown lint jobs; -- Dependabot configuration; -- release validation that the tag and manifest versions agree; -- build and test coverage for the future `explain/` package; -- corrected developer install recipes; and -- registry assets and per-platform release archives. - -These gaps are covered by `SS-022` through `SS-024` and `SS-034`. - -## Distribution - -There is no SQL Server entry in the Tabularis plugin registry, no published -GitHub release containing per-platform archives and a manifest asset, and no -published `@tabularis/explain-sqlserver` package. `SS-024` and `SS-034` add -those distribution paths after their prerequisites land. +Windows Integrated Authentication, custom CA files and client certificates are +outside the completion scope and documented as limitations. + +## Manifest, settings and packaging + +`.tabularium` carries registry metadata, SQL Server branding, the runtime +floor, generic-to-native type mappings, process settings, capabilities and the +`explain_parsers` declaration from core PR #688. The currently deployed live +registry schema predates that additive field and rejects it; schema deployment +and a clean live validation are publication prerequisites in issue #4. A unit +test keeps all 37 `data_types` synchronized with +`driver/types.rs::get_data_types()`. + +The release workflow enforces tag/version equality and builds five platform +archives. Each archive stages the binary, manifest, screenshots and +`explain/dist/index.iife.js`; `.tabularium` is also staged as a standalone +release asset. The independent `explain-v*` workflow validates and publishes +`@tabularis/explain-sqlserver`. + +CI covers Rust build, tests, Clippy and formatting; SQL Server 2022 integration; +manifest and Markdown validation; the TypeScript parser package; Conventional +Commit titles and version suggestions; and RustSec advisories. Until the live +schema is deployed, CI validates deployed fields with the live endpoint and the +pending `explain_parsers` declaration against its exact frozen contract. +Dependabot tracks Cargo, Actions and npm dependencies. + +## Distribution status + +The code and workflows are release-ready, but no plugin tag, GitHub release or +npm package has been published yet. The core parser-loader PR and standalone +site PR are also still open. Consequently no registry PR has been opened and +issue #2 remains open. Issue #4 contains the ordered publication, artifact +inspection, real-desktop validation, registry submission, site deployment and +issue-close checklist; it prevents incomplete or 404-backed registry metadata +from being submitted. diff --git a/docs/dependencies.md b/docs/dependencies.md index 561abff..e8bc1f1 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -20,8 +20,10 @@ release-critical dependency because both crates are preview releases. [published manifest](https://docs.rs/crate/mssql-tiberius-bridge/0.1.0-preview.3/source/Cargo.toml.orig). MIT is compatible with this plugin's Apache-2.0 licence. The upstream repository and published crate do not currently include a standalone licence - text, so the declaration is the licence evidence and the omission should be - corrected upstream before a public binary release. + text, so the declaration is the licence evidence. Resolving that omission + and the release archive's third-party-notice policy before public binary + distribution is tracked in + [issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). - **Release cadence:** all five published previews arrived in a ten-day burst: preview.1 on 2026-05-08, preview.2 and preview.3 on 2026-05-10, preview.4 later on 2026-05-10, and @@ -117,8 +119,10 @@ date above. also preserves headers for zero-row result sets. - [Bridge #88](https://github.com/saurabh500/mssql-tiberius-bridge/issues/88) says cancellation safety under `tokio::time::timeout` has not been audited. - The plugin currently does not cancel in-flight client futures with a Tokio - timeout. This must be resolved before adding such a timeout. + The plugin now applies its configured query timeout with Tokio and marks the + connection non-recyclable on timeout, so no later request receives a stream + with unread packets. The live suite verifies timeout categorization and + replacement-session recovery. Re-audit this boundary on every bridge update. - [Bridge #89](https://github.com/saurabh500/mssql-tiberius-bridge/issues/89) tracks the unverified encryption-off handshake. It is relevant to the plugin's `ssl_mode=disable` mapping and must be included in TLS live tests. @@ -159,9 +163,10 @@ When considering preview.4 or any later release: If the bridge is abandoned or develops a blocking correctness, security, or reliability bug that cannot be fixed promptly, the fallback is the stable -`tiberius 0.12` implementation that this branch replaced. It remains one -revert away in the parent of client-swap commit -[`f2afb7b`](https://github.com/TabularisDB/tabularis-sqlserver-plugin/commit/f2afb7b). +`tiberius 0.12` implementation that this branch replaced. It remains +available in the parent of client-swap commit +[`f2afb7b`](https://github.com/TabularisDB/tabularis-sqlserver-plugin/commit/f2afb7b) +and can be restored with an explicit rollback. Restore that implementation rather than carrying an indefinite private fork of both preview crates. @@ -204,7 +209,7 @@ is selected, not LGPL. This inventory is based on package manifests as resolved by Cargo, including platform-specific and lockfile-only entries. Release packaging must retain the applicable third-party notices; this review does not replace that packaging -step. +step. The unresolved archive-policy work is tracked in issue #4. ## RustSec audit diff --git a/docs/explain-architecture.md b/docs/explain-architecture.md index 7047c23..f6772f9 100644 --- a/docs/explain-architecture.md +++ b/docs/explain-architecture.md @@ -9,6 +9,23 @@ Statements in §1 describe that baseline and include source anchors. The later sections are normative decisions for the implementation tasks; they do not claim that the code exists at the frozen commits. +## Implementation status + +The contract is implemented on the release-candidate branches. Core PR +[TabularisDB/tabularis#688](https://github.com/TabularisDB/tabularis/pull/688) +contains the registry, raw plugin protocol, manifest plumbing, author guide and +isolated desktop loader. This repository contains the TypeScript parser, ESM +package, IIFE and raw Rust handoff; `.tabularium` requires Tabularis 0.23.0. +The standalone site integration is in +[TabularisDB/explain-plan#2](https://github.com/TabularisDB/explain-plan/pull/2). + +The cross-repository PRs are not merged, Tabularis 0.23.0 and +`@tabularis/explain` 0.2.0 are not published, and the SQL Server npm package is +not published. Those distribution prerequisites and the required real-desktop +check are tracked in +[issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). +This status note does not alter the frozen normative contract below. + ## 1. Verified baseline The current split is real, but several details in the initial design needed diff --git a/docs/performance.md b/docs/performance.md index 2b1b922..9b5d9be 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -7,9 +7,9 @@ worker topology. ## Environment and method -Measurements were taken on 2026-09-02 from commit `6f0f55b` plus the SS-045 -working change, using an optimized `cargo build --release` binary and a local -Docker container over `127.0.0.1`: +Measurements were taken on 2026-09-02 from release-candidate commit +`45a90b9`, using an optimized `cargo build --release` binary and a local Docker +container over `127.0.0.1`: - Linux 5.4.0 x86-64, 8 logical CPUs, 31.2 GiB RAM; - Rust 1.98.0; @@ -100,8 +100,9 @@ so syntax does not create duplicate pools. JSON-RPC returns one JSON line and cannot stream rows incrementally. The old collector retained every row when `limit` was absent, allowing a million-row -query to grow until the process or host ran out of memory. SS-045 adds a hard -budget of 10,000 retained rows across a statement's result sets. On the next +query to grow until the process or host ran out of memory. The release +candidate applies a hard budget of 10,000 retained rows across a statement's +result sets. On the next row it sets `truncated: true`, closes the remaining TDS stream, and keeps the pooled session reusable. An explicit page limit cannot bypass this safety budget. Result-bearing DML drains excess `OUTPUT` rows without retaining them diff --git a/docs/sql-audit.md b/docs/sql-audit.md index 9899622..8148830 100644 --- a/docs/sql-audit.md +++ b/docs/sql-audit.md @@ -1,12 +1,14 @@ # SQL construction and identifier audit -This audit covers `src/driver/` at `SS-040`. It classifies every production -`format!` call that emits SQL or an SQL fragment. The audit found one unsafe -API boundary: `build_insert_sql` accepted an already-rendered table target. -It now accepts `schema` and `table` separately and applies `qualify` itself. -The directly executed view, index, foreign-key, user, and login statements now -also have pure builders so their quoting can be regression-tested without a -database connection. +This audit covers the release-candidate `src/driver/` tree and was refreshed +during `SS-046`. It classifies every production `format!` call that emits SQL +or an SQL fragment. The original audit found one unsafe API boundary: +`build_insert_sql` accepted an already-rendered table target. It now accepts +`schema` and `table` separately and applies `qualify` itself. The directly +executed view, index, foreign-key, user, and login statements also have pure +builders so their quoting can be regression-tested without a database +connection. Later raw row-edit support is included below as an explicit SQL +expression boundary rather than being mistaken for an ordinary value. ## Classification rules @@ -34,16 +36,16 @@ encoding, and tests) is excluded. | `helpers.rs:62` | Multipart object reference | schema I via `bracket_quote`; object I via `bracket_quote` | Safe | | `helpers.rs:74` | Affected-row `SELECT` | expression K from two private constants; result alias I/K constant | Safe | | `helpers.rs:80` | DML plus row-count sentinel | sql S from query execution; sentinel K | Intentional SQL boundary | -| `helpers.rs:114` | `@Pn` marker | ordinal K integer | Safe | -| `helpers.rs:117` | `INSERT` | target I from `qualify`; columns I from `bracket_quote`; markers K | Safe after SS-040 API fix | -| `helpers.rs:133` | Identity-insert batch | target I from `qualify`; insert and row-count select K built internally | Safe | -| `helpers.rs:149` | Plain insert plus row-count select | insert and select K built internally | Safe | -| `helpers.rs:231` | Primary-key predicate | column I via `bracket_quote`; ordinal K integer | Safe; values are bound | -| `helpers.rs:245` | Composite-key `DELETE` | table I via `qualify`; predicate internally built | Safe; values are bound | -| `helpers.rs:263` | Composite-key `UPDATE` | table I via `qualify`; column I via `bracket_quote`; predicate internally built | Safe; values are bound | -| `helpers.rs:274` | Column definition head | column I via `bracket_quote`; data type S from the DDL editor/model | Intentional reviewed DDL source | -| `helpers.rs:284` | Column default | default S from the DDL editor/model | Intentional reviewed DDL source | -| `helpers.rs:346` | Paginated query | query S; optional order clause K; offset/fetch K integers | Intentional query boundary | +| `helpers.rs:107` | `@Pn` marker | ordinal K integer | Safe | +| `helpers.rs:128` | `INSERT` | target I from `qualify`; columns I from `bracket_quote`; expressions are bound-marker K or explicitly marked S | Safe identifiers; intentional raw row-edit boundary | +| `helpers.rs:146` | Identity-insert batch | target I; insert may contain explicit S; row-count select K | Safe within documented raw boundary | +| `helpers.rs:162` | Plain insert plus row-count select | insert may contain explicit S; select K | Safe within documented raw boundary | +| `helpers.rs:274` | Primary-key predicate | column I via `bracket_quote`; ordinal K integer | Safe; values are bound | +| `helpers.rs:288` | Composite-key `DELETE` | table I via `qualify`; predicate internally built | Safe; values are bound | +| `helpers.rs:317` | Composite-key `UPDATE` | table and column I; value expression is bound-marker K or explicitly marked S | Safe within documented raw boundary | +| `helpers.rs:327` | Column definition head | column I via `bracket_quote`; data type S from the DDL editor/model | Intentional reviewed DDL source | +| `helpers.rs:337` | Column default | default S from the DDL editor/model | Intentional reviewed DDL source | +| `helpers.rs:387` | Paginated query | query S; optional order clause K; offset/fetch K integers | Intentional query boundary | ### `ddl/mod.rs` @@ -72,15 +74,16 @@ encoding, and tests) is excluded. | Source | SQL produced | Interpolations | Result | | --- | --- | --- | --- | -| `ops.rs:116` | `CREATE VIEW` | view I via `qualify`; definition S from view editor | Intentional SQL-definition boundary | -| `ops.rs:124` | `ALTER VIEW` | view I via `qualify`; definition S from view editor | Intentional SQL-definition boundary | -| `ops.rs:128` | `DROP VIEW` | view I via `qualify` | Safe; executed directly | -| `ops.rs:568` | Table primary-key clause | columns I, each already bracket-quoted | Safe | -| `ops.rs:572` | `CREATE TABLE` | table I via `qualify`; definitions internally built | Safe identifiers; reviewed type/default source | -| `ops.rs:584` | `ADD COLUMN` | table I via `qualify`; definition internally built | Safe identifiers; reviewed type/default source | -| `ops.rs:616` | `CREATE INDEX` | uniqueness K boolean; index, table, columns I via quoting helpers | Safe; returned for review | -| `ops.rs:640` | `DROP INDEX` | index I via `bracket_quote`; table I via `qualify` | Safe; executed directly | -| `ops.rs:666` | Drop foreign-key constraint | table I via `qualify`; constraint I via `bracket_quote` | Safe; executed directly | +| `ops.rs:117` | `CREATE VIEW` | view I via `qualify`; definition S from view editor | Intentional SQL-definition boundary | +| `ops.rs:125` | `ALTER VIEW` | view I via `qualify`; definition S from view editor | Intentional SQL-definition boundary | +| `ops.rs:129` | `DROP VIEW` | view I via `qualify` | Safe; executed directly | +| `ops.rs:451` | Insert expression marker | ordinal K from the bound-parameter count | Safe | +| `ops.rs:606` | Table primary-key clause | columns I, each already bracket-quoted | Safe | +| `ops.rs:610` | `CREATE TABLE` | table I via `qualify`; definitions internally built | Safe identifiers; reviewed type/default source | +| `ops.rs:622` | `ADD COLUMN` | table I via `qualify`; definition internally built | Safe identifiers; reviewed type/default source | +| `ops.rs:654` | `CREATE INDEX` | uniqueness K boolean; index, table, columns I via quoting helpers | Safe; returned for review | +| `ops.rs:678` | `DROP INDEX` | index I via `bracket_quote`; table I via `qualify` | Safe; executed directly | +| `ops.rs:704` | Drop foreign-key constraint | table I via `qualify`; constraint I via `bracket_quote` | Safe; executed directly | ### `routines/mod.rs` @@ -103,7 +106,7 @@ encoding, and tests) is excluded. | `routines/mod.rs:104` | Convert create definition to alter | definition S accepted only with the `CREATE` prefix | Intentional routine-editor boundary | | `routines/mod.rs:115` | Drop routine | object kind K from function/procedure branch; routine I via `qualify` | Safe; executed directly | -`RoutineCallArg.is_raw` is the only argument-value bypass. `false` renders a +`RoutineCallArg.is_raw` is the routine argument-value bypass. `false` renders a Unicode string literal with embedded quotes doubled; `None` renders fixed `NULL`. `true` preserves the value as an SQL expression so callers can enter values such as `SYSDATETIME()` or `DEFAULT`. This method returns editable SQL @@ -170,8 +173,9 @@ SQL Server does not permit parameter markers in identifier positions. Complete SQL text is accepted only by APIs whose purpose is editing or running SQL: `execute_query`, batch execution, view definitions, trigger definitions, routine edit scripts, startup scripts, EXPLAIN's input query, column type and -default expressions, and routine arguments explicitly marked `is_raw`. These -boundaries do not weaken identifier handling elsewhere. +default expressions, routine arguments explicitly marked `is_raw`, and row +edits explicitly shaped as `{ "value": "", "is_raw": true }`. +These boundaries do not weaken identifier handling elsewhere. ## Regression coverage diff --git a/explain/README.md b/explain/README.md index a4e7ff0..008a546 100644 --- a/explain/README.md +++ b/explain/README.md @@ -63,11 +63,9 @@ irrelevant to SHOWPLAN element names. This keeps the same source usable in a browser tab, the desktop IIFE, and server-side JavaScript. The golden fixtures under `tests/fixtures/` were captured from SQL Server 2022. -Their expected plans are generated by the plugin's Rust parser with: - -```bash -cargo test write_showplan_fixture_goldens -- --ignored -``` - -They cover a scan, an index seek with key lookup, a parallel hash join, -`STATISTICS XML`, a missing-index recommendation, and a multi-statement batch. +Their expected plans preserve the output of the former Rust parser at the +TypeScript handoff boundary; the Rust parser was removed after parity was +verified. They cover a scan, an index seek with key lookup, a parallel hash +join, `STATISTICS XML`, a missing-index recommendation, and a multi-statement +batch. Update an expected plan only with a separately reviewed parser-semantic +change. From c84a811b7011150dd1bdbff8656a60d70b16445b Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 3 Sep 2026 09:39:39 +0200 Subject: [PATCH 22/23] ci: install pnpm with pnpm/action-setup instead of Corepack The Corepack copy bundled with Node 22.13 ships stale npm registry signing keys, so "corepack enable" followed by "pnpm install" fails with "Cannot find matching keyid" before any dependency is fetched. Resolve the pinned pnpm version from explain/package.json with pnpm/action-setup in the CI, npm publish and release workflows instead. --- .github/workflows/ci.yml | 12 +++++++++--- .github/workflows/publish-explain.yml | 10 +++++++--- .github/workflows/release.yml | 13 ++++++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72c7e9d..21a4a6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,15 +44,21 @@ jobs: steps: - uses: actions/checkout@v7 + # Installs the pnpm version pinned by "packageManager" in + # explain/package.json. Corepack is not used because the copy bundled + # with Node 22.13 carries stale npm registry signing keys and fails + # with "Cannot find matching keyid" when it resolves pnpm. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: explain/package.json + - name: Setup Node uses: actions/setup-node@v5 with: # pnpm 11.23 requires Node 22.13 or newer. node-version: "22.13" - - name: Enable pnpm - run: corepack enable - - name: Install dependencies working-directory: explain run: pnpm install --frozen-lockfile diff --git a/.github/workflows/publish-explain.yml b/.github/workflows/publish-explain.yml index 652f19d..6f050ef 100644 --- a/.github/workflows/publish-explain.yml +++ b/.github/workflows/publish-explain.yml @@ -16,6 +16,13 @@ jobs: steps: - uses: actions/checkout@v7 + # See ci.yml: pnpm comes from the pinned "packageManager" field, not + # from the Corepack copy bundled with Node. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: explain/package.json + - name: Setup Node uses: actions/setup-node@v5 with: @@ -23,9 +30,6 @@ jobs: node-version: "22.13" registry-url: "https://registry.npmjs.org" - - name: Enable pnpm - run: corepack enable - - name: Check tag matches package and plugin versions run: | TAG_VERSION="${GITHUB_REF_NAME#explain-v}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90a55dc..63a0a7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,14 @@ jobs: echo "present=false" >> "$GITHUB_OUTPUT" fi + # See ci.yml: pnpm comes from the pinned "packageManager" field, not + # from the Corepack copy bundled with Node. + - name: Setup pnpm + if: steps.explain.outputs.present == 'true' + uses: pnpm/action-setup@v4 + with: + package_json_file: explain/package.json + - name: Setup Node if: steps.ui.outputs.present == 'true' || steps.explain.outputs.present == 'true' uses: actions/setup-node@v5 @@ -100,11 +108,6 @@ jobs: npm install --no-audit --no-fund npm run build - - name: Enable pnpm - if: steps.explain.outputs.present == 'true' - shell: bash - run: corepack enable - - name: Build EXPLAIN parser if: steps.explain.outputs.present == 'true' shell: bash From 3c5ef7862c892c594caf4e6a5616b9b458e75d2f Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 3 Sep 2026 12:56:03 +0200 Subject: [PATCH 23/23] ci: validate the full manifest against the live registry schema The driver-kind schema on registry.tabularis.dev now includes the additive explain_parsers field, so the manifest no longer needs to be split into a deployed part and a locally checked pending part. --- .github/workflows/ci.yml | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21a4a6b..5c6d4e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,37 +81,14 @@ jobs: steps: - uses: actions/checkout@v7 - # The live schema does not yet include core PR #688's additive - # explain_parsers field. Keep every deployed field under the real - # validator and validate that one pending extension exactly until the - # schema deployment tracked in plugin issue #4. - - name: Validate deployed manifest fields against the live registry schema + # registry.tabularis.dev serves the driver-kind schema including the + # additive explain_parsers field from core PR #688, so the whole + # manifest is validated against the live validator. + - name: Validate manifest against the live registry schema run: | - jq 'del(.explain_parsers)' .tabularium > /tmp/sqlserver.tabularium - npx --yes @tabularium/cli validate /tmp/sqlserver.tabularium \ + npx --yes @tabularium/cli validate .tabularium \ --registry https://registry.tabularis.dev --kind driver - - name: Validate pending EXPLAIN parser declaration - run: | - python3 - <<'PY' - import json - from pathlib import Path - - manifest = json.loads(Path(".tabularium").read_text()) - parsers = manifest.get("explain_parsers") - assert isinstance(parsers, list) and parsers, "explain_parsers must be a non-empty array" - allowed = {"engine", "format", "label", "module"} - required = {"engine", "format", "module"} - for parser in parsers: - assert isinstance(parser, dict), "each explain parser must be an object" - assert not parser.keys() - allowed, "unknown explain parser property" - assert required <= parser.keys(), "missing required explain parser property" - for key in required: - assert isinstance(parser[key], str) and parser[key].strip(), f"{key} must be a non-empty string" - if "label" in parser: - assert isinstance(parser["label"], str) and parser["label"].strip(), "label must be a non-empty string" - PY - live-db-integration: name: Live SQL Server integration runs-on: ubuntu-latest