diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 01a44b1af..a1270c316 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -201,6 +201,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "asynchronous-codec" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + [[package]] name = "atoi" version = "2.0.0" @@ -414,8 +427,8 @@ dependencies = [ "num", "pin-project-lite", "rand 0.9.4", - "rustls", - "rustls-native-certs", + "rustls 0.23.39", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_derive", @@ -656,12 +669,28 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -997,6 +1026,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1922,9 +1960,9 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.39", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -2689,6 +2727,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3011,6 +3055,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + [[package]] name = "prettyplease" version = "0.2.37" @@ -3535,6 +3585,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.39" @@ -3545,21 +3607,42 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", ] [[package]] @@ -3571,6 +3654,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -3653,6 +3746,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "seahash" version = "4.1.0" @@ -3683,6 +3786,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3690,7 +3806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4024,7 +4140,7 @@ dependencies = [ "once_cell", "percent-encoding", "rust_decimal", - "rustls", + "rustls 0.23.39", "serde", "serde_json", "sha2", @@ -4340,6 +4456,7 @@ dependencies = [ "sourceview5", "sqlformat", "tablepro-core", + "tablepro-driver-mssql", "tablepro-driver-mysql", "tablepro-driver-postgres", "tablepro-driver-sqlite", @@ -4368,6 +4485,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "tablepro-driver-mssql" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "futures", + "rust_decimal", + "secrecy", + "serde_json", + "tablepro-core", + "testcontainers", + "testcontainers-modules", + "tiberius", + "tokio", + "tokio-util", + "uuid", +] + [[package]] name = "tablepro-driver-mysql" version = "0.1.0" @@ -4576,6 +4712,35 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiberius" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +dependencies = [ + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "chrono", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rust_decimal", + "rustls-native-certs 0.6.3", + "rustls-pemfile", + "thiserror 1.0.69", + "tokio-rustls 0.24.1", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "time" version = "0.3.47" @@ -4681,13 +4846,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.39", "tokio", ] @@ -4710,6 +4885,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -4992,7 +5168,7 @@ dependencies = [ "base64 0.22.1", "log", "percent-encoding", - "rustls", + "rustls 0.23.39", "rustls-pki-types", "ureq-proto", "utf8-zero", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 76d31755c..27aa71c5e 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/core", "crates/ssh", "crates/storage", + "crates/drivers/mssql", "crates/drivers/mysql", "crates/drivers/postgres", "crates/drivers/sqlite", @@ -29,6 +30,7 @@ secrecy = { version = "0.10", features = ["serde"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "chrono", "rust_decimal", "uuid", "json"] } +tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } rust_decimal = { version = "1", default-features = false, features = ["serde", "std"] } futures = "0.3" diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index f7e749d50..19f972c45 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -233,7 +233,7 @@ This is where the project sits today. The app is interesting but **not productio ### Additional drivers (parallelizable, ~1 week each) - [ ] ClickHouse via `clickhouse-arrow` -- [ ] MSSQL via `tiberius` +- [x] MSSQL via `tiberius` - [ ] Oracle via `oracle` crate (ODPI-C) - [ ] Redis via `fred` - [ ] MongoDB via official `mongodb` crate diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 31a32af8a..39db1d6a4 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] tablepro-core = { path = "../core" } +tablepro-driver-mssql = { path = "../drivers/mssql" } tablepro-driver-mysql = { path = "../drivers/mysql" } tablepro-driver-postgres = { path = "../drivers/postgres" } tablepro-driver-sqlite = { path = "../drivers/sqlite" } diff --git a/linux/crates/app/src/main.rs b/linux/crates/app/src/main.rs index a28e59045..4d6d59af0 100644 --- a/linux/crates/app/src/main.rs +++ b/linux/crates/app/src/main.rs @@ -70,6 +70,7 @@ fn main() { fn build_registry() -> DriverRegistry { let mut r = DriverRegistry::new(); + r.register(Arc::new(drivers_mssql::MssqlDriver)); r.register(Arc::new(drivers_mysql::MysqlDriver)); r.register(Arc::new(drivers_postgres::PgDriver)); r.register(Arc::new(drivers_sqlite::SqliteDriver)); diff --git a/linux/crates/app/src/ui/app/browse.rs b/linux/crates/app/src/ui/app/browse.rs index f7912086c..d6362b69c 100644 --- a/linux/crates/app/src/ui/app/browse.rs +++ b/linux/crates/app/src/ui/app/browse.rs @@ -25,7 +25,9 @@ impl App { /// Fire the SELECT * query for a specific browse tab. Result goes to /// the same tab via `AppMsg::RowsLoaded(tab_id, ...)`. Composes the /// SELECT from the tab's current sort + filter + pagination state. - /// Filter and sort are server-side; pagination uses LIMIT/OFFSET. + /// Filter and sort are server-side; the row window is rendered by + /// `sql_dialect::build_order_and_pagination` because the syntax is + /// dialect-specific. pub(super) fn fetch_browse_page(&self, tab_id: Uuid, sender: ComponentSender) { let (schema, table, offset, limit, sort, filter, columns, driver_id) = { let tabs = self.workspace_tabs.borrow(); @@ -94,11 +96,12 @@ impl App { sql.push_str(" WHERE "); sql.push_str(w); } - if let Some(order) = &order_by { - sql.push_str(" ORDER BY "); - sql.push_str(order); - } - sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}")); + sql.push_str(&tablepro_core::sql_dialect::build_order_and_pagination( + &driver_id, + order_by.as_deref(), + limit, + offset, + )); conn.query_params(&sql, ¶ms).await }; match result { diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index a9485d1e4..3923445b2 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -12,11 +12,12 @@ use relm4::adw::prelude::*; use relm4::{ComponentController, ComponentSender, adw}; use uuid::Uuid; -use tablepro_core::ColumnInfo; +use tablepro_core::{ColumnInfo, Value}; use crate::services::database_service; use crate::services::structure_tracker; use crate::ui::app::{App, AppMsg, WorkspaceTab}; +use crate::ui::error_text; use crate::ui::structure_tab::{StructureMode, StructureTabInput}; impl App { @@ -222,11 +223,12 @@ impl App { } } - /// Structure tab Save → run ordered DDL statements. Postgres wraps - /// in BEGIN / COMMIT for atomicity (DDL is transactional in PG); - /// MySQL / SQLite execute sequentially because their DDL is - /// auto-committed per-statement and a wrapping transaction would - /// either be ignored or error out on the first DDL. + /// Structure tab Save → run ordered DDL statements. Engines whose + /// DDL is transactional go through `execute_in_transaction` so a + /// mid-batch failure rolls the whole save back; the driver owns the + /// transaction-control statements because their spelling and their + /// wire encoding are engine-specific. The rest execute + /// sequentially. pub(super) fn on_execute_structure_transaction( &mut self, tab_id: Uuid, @@ -246,6 +248,10 @@ impl App { sender.input(AppMsg::StructureSaveFailed(tab_id, crate::tr!("No active connection."))); return; }; + let ddl_is_transactional = self + .registry + .get(&driver_id) + .is_some_and(|driver| driver.ddl_is_transactional()); let mode = { let tabs = self.workspace_tabs.borrow(); let Some(slot) = tabs.get(&tab_id) else { @@ -292,29 +298,21 @@ impl App { sender_for_cmd.input(AppMsg::StructureSaveFailed(tab_id, crate::tr!("No active connection."))); return; }; - let wrap_tx = driver_id == "postgres"; - if wrap_tx && let Err(e) = conn.execute("BEGIN").await { - sender_for_cmd.input(AppMsg::StructureSaveFailed(tab_id, format!("{e}"))); - return; - } - for sql in &statements { - if let Err(e) = conn.execute(sql).await { - if wrap_tx { - let _ = conn.execute("ROLLBACK").await; - } - sender_for_cmd.input(AppMsg::StructureSaveFailed(tab_id, format!("{e}"))); + if ddl_is_transactional { + let batch: Vec<(String, Vec)> = + statements.iter().map(|sql| (sql.clone(), Vec::new())).collect(); + if let Err(e) = conn.execute_in_transaction(&batch).await { + sender_for_cmd.input(AppMsg::StructureSaveFailed(tab_id, error_text::driver_message(&e))); return; } - } - if wrap_tx && let Err(e) = conn.execute("COMMIT").await { - // COMMIT failure leaves the connection in an - // open transaction. Fire ROLLBACK so the - // pooled connection is reusable; ignore its - // result because the user already has the - // commit-failure to surface. - let _ = conn.execute("ROLLBACK").await; - sender_for_cmd.input(AppMsg::StructureSaveFailed(tab_id, format!("{e}"))); - return; + } else { + for sql in &statements { + if let Err(e) = conn.execute(sql).await { + sender_for_cmd + .input(AppMsg::StructureSaveFailed(tab_id, error_text::driver_message(&e))); + return; + } + } } sender_for_cmd.input(AppMsg::StructureSaveCompleted { tab_id, diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index a0af32f78..9dfe2d4ed 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -61,6 +61,26 @@ pub(super) fn driver_types(driver_id: &str) -> &'static [&'static str] { "sqlite" => &[ "INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME", "DATE", ], + "mssql" => &[ + "int", + "bigint", + "smallint", + "tinyint", + "bit", + "nvarchar(255)", + "nvarchar(max)", + "varchar(255)", + "decimal(18, 2)", + "float", + "real", + "money", + "date", + "time", + "datetime2", + "datetimeoffset", + "uniqueidentifier", + "varbinary(max)", + ], _ => &["TEXT"], } } @@ -85,6 +105,7 @@ pub(super) fn default_type_for(driver_id: &str) -> String { "postgres" => "text".into(), "mysql" => "VARCHAR(255)".into(), "sqlite" => "TEXT".into(), + "mssql" => "nvarchar(255)".into(), _ => "TEXT".into(), } } diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index e1efc73b6..2078cc388 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -396,10 +396,12 @@ impl StructureTab { let columns_for_dialog = self.columns.clone(); let sender_for_add = sender.clone(); let parent_box = self.fks_box.clone(); + let driver_id_for_dialog = driver_id.clone(); append_add_button(&list, &crate::tr!("Add Foreign Key…"), move || { present_fk_dialog( parent_box.upcast_ref(), &columns_for_dialog.borrow(), + &driver_id_for_dialog, sender_for_add.clone(), ); }); diff --git a/linux/crates/app/src/ui/structure_tab_dialogs.rs b/linux/crates/app/src/ui/structure_tab_dialogs.rs index 6365c0da0..24c716f22 100644 --- a/linux/crates/app/src/ui/structure_tab_dialogs.rs +++ b/linux/crates/app/src/ui/structure_tab_dialogs.rs @@ -26,11 +26,6 @@ use tablepro_core::{ForeignKeyInfo, IndexInfo}; use super::structure_tab::{StructureTab, StructureTabInput, StructureTabOutput}; -/// Foreign-key referential action choices, in the same order they -/// appear in the `AdwComboRow`s. The index returned by -/// `selected()` is mapped back to a string via this slice. -const FK_ACTIONS: &[&str] = &["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"]; - /// `(column name, checkbox)` pairs, shared between the dialog body /// and the submit handler so the latter can collect which columns the /// user ticked. Pulled out as an alias so `build_column_checklist`'s @@ -182,7 +177,13 @@ pub(super) fn present_index_dialog( dialog.present(Some(parent)); } -pub(super) fn present_fk_dialog(parent: >k::Widget, columns: &[DraftColumn], sender: ComponentSender) { +pub(super) fn present_fk_dialog( + parent: >k::Widget, + columns: &[DraftColumn], + driver_id: &str, + sender: ComponentSender, +) { + let fk_actions = tablepro_core::sql_ddl::supported_fk_actions(driver_id); let (dialog, body, submit_btn) = build_form_dialog(&crate::tr!("Add Foreign Key"), &crate::tr!("Add")); // Name in its own AdwPreferencesGroup at the top — matches the @@ -206,12 +207,12 @@ pub(super) fn present_fk_dialog(parent: >k::Widget, columns: &[DraftColumn], s ref_group.add(&ref_cols_row); let on_delete_row = adw::ComboRow::builder() .title(crate::tr!("On delete")) - .model(>k::StringList::new(FK_ACTIONS)) + .model(>k::StringList::new(fk_actions)) .build(); ref_group.add(&on_delete_row); let on_update_row = adw::ComboRow::builder() .title(crate::tr!("On update")) - .model(>k::StringList::new(FK_ACTIONS)) + .model(>k::StringList::new(fk_actions)) .build(); ref_group.add(&on_update_row); body.append(&ref_group); @@ -275,7 +276,7 @@ pub(super) fn present_fk_dialog(parent: >k::Widget, columns: &[DraftColumn], s // driver-returned-unknown case so the SQL emitter can choose // sensibly per dialect (MySQL implicit RESTRICT vs Postgres // implicit NO ACTION). - let action_at = |idx: u32| -> Option { FK_ACTIONS.get(idx as usize).map(|s| (*s).to_string()) }; + let action_at = |idx: u32| -> Option { fk_actions.get(idx as usize).map(|s| (*s).to_string()) }; sender_for_resp.input(StructureTabInput::AddForeignKey(ForeignKeyInfo { name, columns: cols, diff --git a/linux/crates/core/src/driver.rs b/linux/crates/core/src/driver.rs index a807dc939..c98e26383 100644 --- a/linux/crates/core/src/driver.rs +++ b/linux/crates/core/src/driver.rs @@ -13,5 +13,16 @@ pub trait DatabaseDriver: Send + Sync { false } + /// Whether a multi-statement DDL batch can roll back as a unit, so + /// the structure editor's Save runs through + /// `Connection::execute_in_transaction` instead of statement by + /// statement. MySQL commits implicitly on every DDL statement: the + /// transaction would end after the first one and a later failure + /// would leave the earlier statements applied, which is worse than + /// not opening one at all. + fn ddl_is_transactional(&self) -> bool { + false + } + async fn connect(&self, opts: ConnectOptions) -> Result, DriverError>; } diff --git a/linux/crates/core/src/sql_ddl.rs b/linux/crates/core/src/sql_ddl.rs index 5342ef926..b4e0b99ca 100644 --- a/linux/crates/core/src/sql_ddl.rs +++ b/linux/crates/core/src/sql_ddl.rs @@ -115,12 +115,26 @@ fn validate_safe_default(s: &str) -> Result<(), BuildDdlError> { const FK_ACTIONS: &[&str] = &["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"]; -/// FK actions are a closed enum in the SQL standard. Allow-list rather -/// than escape; case-insensitive match against the canonical strings +/// T-SQL's `ON DELETE` / `ON UPDATE` grammar has no `RESTRICT`; the +/// engine spells that behaviour `NO ACTION`. Emitting `RESTRICT` is a +/// syntax error, so it is not an option the UI may offer either. +const FK_ACTIONS_MSSQL: &[&str] = &["NO ACTION", "CASCADE", "SET NULL", "SET DEFAULT"]; + +/// Referential actions the engine accepts, in the order the UI should +/// present them. The first entry is the SQL default. +pub fn supported_fk_actions(driver_id: &str) -> &'static [&'static str] { + match driver_id { + "mssql" => FK_ACTIONS_MSSQL, + _ => FK_ACTIONS, + } +} + +/// FK actions are a closed enum per dialect. Allow-list rather than +/// escape; case-insensitive match against the canonical strings /// returned in upper case for emission. -fn validate_fk_action(s: &str) -> Result<&'static str, BuildDdlError> { +fn validate_fk_action(driver_id: &str, s: &str) -> Result<&'static str, BuildDdlError> { let upper = s.trim().to_ascii_uppercase(); - FK_ACTIONS + supported_fk_actions(driver_id) .iter() .copied() .find(|canon| *canon == upper.as_str()) @@ -259,6 +273,33 @@ fn qualified_table(driver_id: &str, schema: Option<&str>, table: &str) -> String } } +/// Escape a value for inclusion in a SQL string literal. Bracket +/// quoting escapes `]`, not `'`, so any identifier that travels as a +/// literal (`sp_rename`'s arguments, an `OBJECT_ID()` lookup) needs +/// this on top of, or instead of, `quote_ident`. +fn sql_literal(value: &str) -> String { + value.replace('\'', "''") +} + +/// Drop the default constraint bound to `column`, if any. SQL Server +/// generates the constraint name, and `DROP CONSTRAINT` does not accept +/// a variable, so the name is resolved from `sys.default_constraints` +/// and applied through `EXEC`. Emitted as one batch because the +/// variable does not outlive it. +fn mssql_drop_default_constraint(driver_id: &str, schema: Option<&str>, table: &str, column: &str) -> String { + let qualified = qualified_table(driver_id, schema, table); + let table_literal = sql_literal(&qualified); + let column_literal = sql_literal(column.trim()); + format!( + "DECLARE @default_constraint sysname = (\ +SELECT dc.name FROM sys.default_constraints dc \ +JOIN sys.columns c ON c.object_id = dc.parent_object_id AND c.column_id = dc.parent_column_id \ +WHERE dc.parent_object_id = OBJECT_ID('{table_literal}') AND c.name = '{column_literal}'); \ +IF @default_constraint IS NOT NULL \ +EXEC('ALTER TABLE {table_literal} DROP CONSTRAINT [' + @default_constraint + ']')" + ) +} + fn validate_table(table: &str) -> Result<(), BuildDdlError> { if table.trim().is_empty() { return Err(BuildDdlError::EmptyTableName); @@ -336,6 +377,21 @@ fn render_column_definition(driver_id: &str, column: &DraftColumn, inline_pk: bo return Ok(parts.join(" ")); } + // MSSQL IDENTITY(1,1) auto-increment. An identity column cannot + // carry a DEFAULT, so this bypasses the generic tail entirely — + // unlike Postgres SERIAL, MSSQL identity still honors the user's + // NOT NULL choice instead of forcing one implicitly. + if driver_id == "mssql" && column.auto_increment { + parts.push("IDENTITY(1,1)".into()); + if !column.nullable { + parts.push("NOT NULL".into()); + } + if inline_pk && column.primary_key { + parts.push("PRIMARY KEY".into()); + } + return Ok(parts.join(" ")); + } + if !column.nullable { parts.push("NOT NULL".into()); } @@ -447,6 +503,13 @@ pub fn build_rename_table( ) -> Result { validate_table(old_name)?; validate_table(new_name)?; + if driver_id == "mssql" { + // sp_rename's arguments are SQL string literals, not + // identifiers, and the bare @newname isn't quoted at all. + let old_qualified = sql_literal(&qualified_table(driver_id, schema, old_name)); + let new_bare = sql_literal(new_name.trim()); + return Ok(format!("EXEC sp_rename '{}', '{}'", old_qualified, new_bare)); + } Ok(format!( "ALTER TABLE {} RENAME TO {}", qualified_table(driver_id, schema, old_name), @@ -469,9 +532,11 @@ pub fn build_add_column( // error before sending the statement to the driver. return Err(BuildDdlError::SqliteNotSupported("ADD COLUMN NOT NULL without DEFAULT")); } + let keyword = if driver_id == "mssql" { "ADD" } else { "ADD COLUMN" }; Ok(format!( - "ALTER TABLE {} ADD COLUMN {}", + "ALTER TABLE {} {} {}", qualified_table(driver_id, schema, table), + keyword, column_def )) } @@ -508,6 +573,16 @@ pub fn build_rename_column( validate_table(table)?; validate_column_name(old_name)?; validate_column_name(new_name)?; + if driver_id == "mssql" { + // Same string-literal escaping requirement as build_rename_table. + let object_name = sql_literal(&format!( + "{}.{}", + qualified_table(driver_id, schema, table), + quote_ident(driver_id, old_name) + )); + let new_bare = sql_literal(new_name.trim()); + return Ok(format!("EXEC sp_rename '{}', '{}', 'COLUMN'", object_name, new_bare)); + } Ok(format!( "ALTER TABLE {} RENAME COLUMN {} TO {}", qualified_table(driver_id, schema, table), @@ -611,6 +686,49 @@ pub fn build_alter_column( } Ok(stmts) } + "mssql" => { + let original = column.original.as_ref(); + let type_changed = original.map(|o| o.data_type != column.data_type).unwrap_or(true); + let nullable_changed = original.map(|o| o.nullable != column.nullable).unwrap_or(false); + let default_changed = original + .map(|o| o.default_value.as_deref() != column.default_value.as_deref()) + .unwrap_or(column.default_value.is_some()); + let mut stmts: Vec = Vec::new(); + // ALTER COLUMN carries type and nullability together: T-SQL + // reads an omitted NULL / NOT NULL as NULL, so a type-only + // change has to restate the nullability or it would silently + // drop NOT NULL. + if type_changed || nullable_changed { + validate_safe_type(&column.data_type)?; + let nullability = if column.nullable { "NULL" } else { "NOT NULL" }; + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} {} {}", + qualified, + quote_ident(driver_id, &column.name), + column.data_type, + nullability + )); + } + if default_changed { + // A default is a separate named constraint here, not a + // column attribute, so changing one is drop-then-add. + // The existing constraint's name is generated by the + // server, so the drop resolves it from the catalog. + stmts.push(mssql_drop_default_constraint(driver_id, schema, table, &column.name)); + if let Some(default) = validated_default(column.default_value.as_deref())? { + stmts.push(format!( + "ALTER TABLE {} ADD DEFAULT ({}) FOR {}", + qualified, + default, + quote_ident(driver_id, &column.name) + )); + } + } + if stmts.is_empty() { + return Err(BuildDdlError::NoChange); + } + Ok(stmts) + } "sqlite" => Err(BuildDdlError::SqliteNotSupported( "ALTER COLUMN (type / nullable / default change)", )), @@ -692,6 +810,16 @@ pub fn build_drop_index( quote_ident(driver_id, index_name) )); } + if driver_id == "mssql" { + validate_table(table)?; + // MSSQL indexes are not standalone schema objects: DROP INDEX + // must always state the owning table via ON . + return Ok(format!( + "DROP INDEX IF EXISTS {} ON {}", + quote_ident(driver_id, index_name), + qualified_table(driver_id, schema, table) + )); + } // Postgres + SQLite: schema-qualified index name, no table ref. let qualified_index = match schema { Some(s) if !s.is_empty() => format!("{}.{}", quote_ident(driver_id, s), quote_ident(driver_id, index_name)), @@ -725,11 +853,11 @@ pub fn build_add_foreign_key( ref_cols.join(", "), )]; if let Some(raw) = fk.on_delete.as_deref().filter(|a| !a.is_empty()) { - let action = validate_fk_action(raw)?; + let action = validate_fk_action(driver_id, raw)?; clauses.push(format!("ON DELETE {action}")); } if let Some(raw) = fk.on_update.as_deref().filter(|a| !a.is_empty()) { - let action = validate_fk_action(raw)?; + let action = validate_fk_action(driver_id, raw)?; clauses.push(format!("ON UPDATE {action}")); } Ok(clauses.join(" ")) @@ -752,7 +880,7 @@ pub fn build_drop_foreign_key( qualified, quote_ident(driver_id, fk_name) )), - "postgres" => Ok(format!( + "postgres" | "mssql" => Ok(format!( "ALTER TABLE {} DROP CONSTRAINT {}", qualified, quote_ident(driver_id, fk_name) @@ -1051,6 +1179,18 @@ mod tests { assert!(stmts[0].contains("\"email\" TEXT NOT NULL")); } + #[test] + fn create_table_simple_mssql() { + let mut cols = vec![ai(dc("id", "INT")), nn(dc("email", "VARCHAR(255)"))]; + cols[0].primary_key = true; + let stmts = build_create_table("mssql", None, "users", &cols, &[], &[]).unwrap(); + assert_eq!(stmts.len(), 1); + assert!(stmts[0].contains("[id] INT IDENTITY(1,1) PRIMARY KEY")); + assert!(!stmts[0].contains("DEFAULT")); + assert!(stmts[0].contains("[email] VARCHAR(255) NOT NULL")); + assert!(stmts[0].starts_with("CREATE TABLE [users]")); + } + #[test] fn create_table_postgres_bigserial() { let cols = vec![pk(ai(dc("id", "bigint")))]; @@ -1188,6 +1328,18 @@ mod tests { ); } + #[test] + fn drop_table_mssql_no_cascade() { + assert_eq!( + build_drop_table("mssql", None, "users", false, false).unwrap(), + "DROP TABLE [users]" + ); + assert_eq!( + build_drop_table("mssql", Some("dbo"), "users", true, true).unwrap(), + "DROP TABLE IF EXISTS [dbo].[users]" + ); + } + #[test] fn rename_table_each_driver() { assert_eq!( @@ -1204,6 +1356,27 @@ mod tests { ); } + #[test] + fn rename_table_mssql() { + assert_eq!( + build_rename_table("mssql", None, "old", "new").unwrap(), + "EXEC sp_rename '[old]', 'new'" + ); + assert_eq!( + build_rename_table("mssql", Some("dbo"), "old", "new").unwrap(), + "EXEC sp_rename '[dbo].[old]', 'new'" + ); + } + + #[test] + fn rename_table_mssql_escapes_embedded_quote() { + // sp_rename's arguments are SQL string literals; an embedded + // `'` in a name must be doubled or it would close the literal + // early and splice the remainder in as a second statement. + let sql = build_rename_table("mssql", None, "o'brien", "new'table").unwrap(); + assert_eq!(sql, "EXEC sp_rename '[o''brien]', 'new''table'"); + } + #[test] fn add_column_basic() { let col = nn(def(dc("created_at", "timestamp"), "now()")); @@ -1227,6 +1400,17 @@ mod tests { assert!(sql.starts_with("ALTER TABLE \"t\" ADD COLUMN")); } + #[test] + fn add_column_mssql_no_column_keyword() { + let col = nn(def(dc("created_at", "DATETIME2"), "SYSUTCDATETIME()")); + let sql = build_add_column("mssql", None, "users", &col).unwrap(); + assert_eq!( + sql, + "ALTER TABLE [users] ADD [created_at] DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()" + ); + assert!(!sql.contains("ADD COLUMN")); + } + #[test] fn drop_column_each_driver() { assert_eq!( @@ -1243,6 +1427,14 @@ mod tests { ); } + #[test] + fn drop_column_mssql() { + assert_eq!( + build_drop_column("mssql", None, "users", "email").unwrap(), + "ALTER TABLE [users] DROP COLUMN [email]" + ); + } + #[test] fn rename_column_each_driver() { assert_eq!( @@ -1255,6 +1447,24 @@ mod tests { ); } + #[test] + fn rename_column_mssql() { + assert_eq!( + build_rename_column("mssql", None, "t", "old", "new").unwrap(), + "EXEC sp_rename '[t].[old]', 'new', 'COLUMN'" + ); + assert_eq!( + build_rename_column("mssql", Some("dbo"), "t", "old", "new").unwrap(), + "EXEC sp_rename '[dbo].[t].[old]', 'new', 'COLUMN'" + ); + } + + #[test] + fn rename_column_mssql_escapes_embedded_quote() { + let sql = build_rename_column("mssql", None, "t", "o'brien", "new'name").unwrap(); + assert_eq!(sql, "EXEC sp_rename '[t].[o''brien]', 'new''name', 'COLUMN'"); + } + #[test] fn alter_column_postgres_type_change() { let col = DraftColumn { @@ -1373,6 +1583,156 @@ mod tests { assert!(matches!(err, BuildDdlError::SqliteNotSupported(_))); } + #[test] + fn alter_column_mssql_type_and_nullable_change() { + let col = DraftColumn { + original: Some(ColumnInfo { + name: "x".into(), + data_type: "int".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: None, + is_generated: false, + }), + name: "x".into(), + data_type: "int".into(), + nullable: false, + primary_key: false, + auto_increment: false, + default_value: None, + }; + let stmts = build_alter_column("mssql", None, "t", &col).unwrap(); + assert_eq!(stmts.len(), 1); + assert_eq!(stmts[0], "ALTER TABLE [t] ALTER COLUMN [x] int NOT NULL"); + } + + #[test] + fn alter_column_mssql_default_only_replaces_the_constraint() { + let col = DraftColumn { + original: Some(ColumnInfo { + name: "x".into(), + data_type: "text".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: None, + is_generated: false, + }), + name: "x".into(), + data_type: "text".into(), + nullable: true, + primary_key: false, + auto_increment: false, + default_value: Some("'pending'".into()), + }; + let stmts = build_alter_column("mssql", None, "t", &col).unwrap(); + assert_eq!(stmts.len(), 2); + assert!(!stmts[0].contains("ALTER COLUMN")); + assert!(stmts[0].contains("sys.default_constraints")); + assert!(stmts[0].contains("OBJECT_ID('[t]')")); + assert!(stmts[0].contains("c.name = 'x'")); + assert_eq!(stmts[1], "ALTER TABLE [t] ADD DEFAULT ('pending') FOR [x]"); + } + + #[test] + fn alter_column_mssql_clearing_a_default_only_drops() { + let col = DraftColumn { + original: Some(ColumnInfo { + name: "x".into(), + data_type: "text".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: Some("'pending'".into()), + is_generated: false, + }), + name: "x".into(), + data_type: "text".into(), + nullable: true, + primary_key: false, + auto_increment: false, + default_value: None, + }; + let stmts = build_alter_column("mssql", None, "t", &col).unwrap(); + assert_eq!(stmts.len(), 1); + assert!(stmts[0].contains("DROP CONSTRAINT")); + assert!(!stmts[0].contains("ADD DEFAULT")); + } + + #[test] + fn alter_column_mssql_applies_default_alongside_type_change() { + let col = DraftColumn { + original: Some(ColumnInfo { + name: "x".into(), + data_type: "int".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: None, + is_generated: false, + }), + name: "x".into(), + data_type: "bigint".into(), + nullable: true, + primary_key: false, + auto_increment: false, + default_value: Some("0".into()), + }; + let stmts = build_alter_column("mssql", None, "t", &col).unwrap(); + assert_eq!(stmts.len(), 3); + assert_eq!(stmts[0], "ALTER TABLE [t] ALTER COLUMN [x] bigint NULL"); + assert!(stmts[1].contains("DROP CONSTRAINT")); + assert_eq!(stmts[2], "ALTER TABLE [t] ADD DEFAULT (0) FOR [x]"); + } + + #[test] + fn alter_column_mssql_unchanged_is_no_change() { + let col = DraftColumn { + original: Some(ColumnInfo { + name: "x".into(), + data_type: "int".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: None, + is_generated: false, + }), + name: "x".into(), + data_type: "int".into(), + nullable: true, + primary_key: false, + auto_increment: false, + default_value: None, + }; + let err = build_alter_column("mssql", None, "t", &col).unwrap_err(); + assert!(matches!(err, BuildDdlError::NoChange)); + } + + #[test] + fn alter_column_mssql_drop_default_escapes_literals() { + let col = DraftColumn { + original: Some(ColumnInfo { + name: "o'brien".into(), + data_type: "int".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: Some("0".into()), + is_generated: false, + }), + name: "o'brien".into(), + data_type: "int".into(), + nullable: true, + primary_key: false, + auto_increment: false, + default_value: None, + }; + let stmts = build_alter_column("mssql", Some("s'x"), "t'q", &col).unwrap(); + assert!(stmts[0].contains("OBJECT_ID('[s''x].[t''q]')")); + assert!(stmts[0].contains("c.name = 'o''brien'")); + } + #[test] fn reorder_column_mysql() { let col = nn(dc("status", "VARCHAR(64)")); @@ -1442,6 +1802,18 @@ mod tests { assert!(matches!(err, BuildDdlError::EmptyIndexName)); } + #[test] + fn create_index_mssql() { + let idx = IndexInfo { + name: "idx_a_b".into(), + columns: vec!["a".into(), "b".into()], + unique: true, + primary: false, + }; + let sql = build_create_index("mssql", None, "t", &idx).unwrap(); + assert_eq!(sql, "CREATE UNIQUE INDEX [idx_a_b] ON [t] ([a], [b])"); + } + #[test] fn drop_index_postgres() { assert_eq!( @@ -1466,6 +1838,14 @@ mod tests { ); } + #[test] + fn drop_index_mssql() { + assert_eq!( + build_drop_index("mssql", Some("schema"), "t", "ix").unwrap(), + "DROP INDEX IF EXISTS [ix] ON [schema].[t]" + ); + } + fn fk_basic() -> ForeignKeyInfo { ForeignKeyInfo { name: "fk_user".into(), @@ -1505,6 +1885,29 @@ mod tests { assert!(!sql.contains("ON UPDATE")); } + #[test] + fn add_foreign_key_mssql() { + let mut fk = fk_basic(); + fk.on_update = Some("NO ACTION".into()); + let sql = build_add_foreign_key("mssql", None, "orders", &fk).unwrap(); + assert!(sql.contains("ADD CONSTRAINT [fk_user]")); + assert!(sql.contains("FOREIGN KEY ([user_id])")); + assert!(sql.contains("REFERENCES [users] ([id])")); + assert!(sql.contains("ON DELETE CASCADE")); + assert!(sql.contains("ON UPDATE NO ACTION")); + } + + #[test] + fn add_foreign_key_mssql_rejects_restrict() { + // T-SQL has no RESTRICT. Emitting it produces a syntax error at + // Save time, so the builder refuses it up front and the dialog + // never offers it. + let err = build_add_foreign_key("mssql", None, "orders", &fk_basic()).unwrap_err(); + assert!(matches!(err, BuildDdlError::InvalidFkAction(a) if a == "RESTRICT")); + assert!(!supported_fk_actions("mssql").contains(&"RESTRICT")); + assert!(supported_fk_actions("postgres").contains(&"RESTRICT")); + } + #[test] fn drop_foreign_key_postgres() { assert_eq!( @@ -1527,6 +1930,14 @@ mod tests { assert!(matches!(err, BuildDdlError::SqliteNotSupported(_))); } + #[test] + fn drop_foreign_key_mssql() { + assert_eq!( + build_drop_foreign_key("mssql", None, "orders", "fk_user").unwrap(), + "ALTER TABLE [orders] DROP CONSTRAINT [fk_user]" + ); + } + #[test] fn rejects_injection_via_data_type() { let mut col = dc("x", "INT; DROP TABLE users; --"); @@ -1629,4 +2040,47 @@ mod tests { let sql = build_drop_table("postgres", Some("a\"b"), "c\"d", false, false).unwrap(); assert!(sql.contains("\"a\"\"b\".\"c\"\"d\"")); } + + #[test] + fn materialize_ops_mssql_orders_rename_alter_then_add() { + let ops = vec![ + StructureOp::RenameTable { + schema: None, + old_name: "old_t".into(), + new_name: "new_t".into(), + }, + StructureOp::AlterColumn { + schema: None, + table: "new_t".into(), + column: DraftColumn { + original: Some(ColumnInfo { + name: "x".into(), + data_type: "text".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: None, + is_generated: false, + }), + name: "x".into(), + data_type: "text".into(), + nullable: true, + primary_key: false, + auto_increment: false, + default_value: Some("'x'".into()), + }, + }, + StructureOp::AddColumn { + schema: None, + table: "new_t".into(), + column: nn(dc("flag", "BIT")), + }, + ]; + let stmts = materialize_ops(&ops, "mssql").unwrap(); + assert_eq!(stmts.len(), 4); + assert_eq!(stmts[0], "EXEC sp_rename '[old_t]', 'new_t'"); + assert!(stmts[1].contains("DROP CONSTRAINT")); + assert_eq!(stmts[2], "ALTER TABLE [new_t] ADD DEFAULT ('x') FOR [x]"); + assert_eq!(stmts[3], "ALTER TABLE [new_t] ADD [flag] BIT NOT NULL"); + } } diff --git a/linux/crates/core/src/sql_dialect.rs b/linux/crates/core/src/sql_dialect.rs index 8dd0500d9..4591e4d60 100644 --- a/linux/crates/core/src/sql_dialect.rs +++ b/linux/crates/core/src/sql_dialect.rs @@ -15,18 +15,39 @@ pub enum BuildSqlError { } pub fn quote_ident(driver_id: &str, name: &str) -> String { - if driver_id == "mysql" { - format!("`{}`", name.replace('`', "``")) - } else { - format!("\"{}\"", name.replace('"', "\"\"")) + match driver_id { + "mysql" => format!("`{}`", name.replace('`', "``")), + "mssql" => format!("[{}]", name.replace(']', "]]")), + _ => format!("\"{}\"", name.replace('"', "\"\"")), } } pub fn placeholder_for(driver_id: &str, index: usize) -> String { - if driver_id == "postgres" { - format!("${}", index + 1) - } else { - "?".to_string() + match driver_id { + "postgres" => format!("${}", index + 1), + "mssql" => format!("@P{}", index + 1), + _ => "?".to_string(), + } +} + +/// Render the `ORDER BY` and row-window tail of a paged `SELECT`, +/// including the leading space. The two clauses are built together +/// because SQL Server couples them: `OFFSET … FETCH` is defined as a +/// suffix of `ORDER BY`, so a paged query with no user sort still +/// needs one. `(SELECT NULL)` is the no-op ordering that satisfies the +/// parser without imposing a sort the user did not ask for. +/// +/// `order_by` is pre-quoted SQL (`"name" ASC, "id" DESC`), not an +/// identifier. +pub fn build_order_and_pagination(driver_id: &str, order_by: Option<&str>, limit: u64, offset: u64) -> String { + let order_by = order_by.map(str::trim).filter(|o| !o.is_empty()); + if driver_id == "mssql" { + let order = order_by.unwrap_or("(SELECT NULL)"); + return format!(" ORDER BY {order} OFFSET {offset} ROWS FETCH NEXT {limit} ROWS ONLY"); + } + match order_by { + Some(order) => format!(" ORDER BY {order} LIMIT {limit} OFFSET {offset}"), + None => format!(" LIMIT {limit} OFFSET {offset}"), } } @@ -256,6 +277,12 @@ mod tests { assert_eq!(quote_ident("mysql", "foo`bar"), "`foo``bar`"); } + #[test] + fn quote_ident_mssql() { + assert_eq!(quote_ident("mssql", "users"), "[users]"); + assert_eq!(quote_ident("mssql", "a]b"), "[a]]b]"); + } + #[test] fn placeholder_dialect() { assert_eq!(placeholder_for("postgres", 0), "$1"); @@ -264,6 +291,56 @@ mod tests { assert_eq!(placeholder_for("mysql", 5), "?"); } + #[test] + fn placeholder_mssql() { + assert_eq!(placeholder_for("mssql", 0), "@P1"); + assert_eq!(placeholder_for("mssql", 2), "@P3"); + } + + #[test] + fn pagination_limit_offset_dialects() { + assert_eq!( + build_order_and_pagination("postgres", None, 50, 100), + " LIMIT 50 OFFSET 100" + ); + assert_eq!( + build_order_and_pagination("mysql", Some("`a` ASC"), 50, 100), + " ORDER BY `a` ASC LIMIT 50 OFFSET 100" + ); + assert_eq!( + build_order_and_pagination("sqlite", Some("\"a\" DESC"), 10, 0), + " ORDER BY \"a\" DESC LIMIT 10 OFFSET 0" + ); + } + + #[test] + fn pagination_mssql_uses_offset_fetch() { + assert_eq!( + build_order_and_pagination("mssql", Some("[a] ASC"), 50, 100), + " ORDER BY [a] ASC OFFSET 100 ROWS FETCH NEXT 50 ROWS ONLY" + ); + } + + #[test] + fn pagination_mssql_synthesizes_order_by_when_unsorted() { + // OFFSET / FETCH is a suffix of ORDER BY in T-SQL, so an + // unsorted page still needs one to parse at all. + let sql = build_order_and_pagination("mssql", None, 50, 0); + assert_eq!(sql, " ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY"); + } + + #[test] + fn pagination_treats_blank_order_by_as_absent() { + assert_eq!( + build_order_and_pagination("postgres", Some(" "), 5, 0), + " LIMIT 5 OFFSET 0" + ); + assert_eq!( + build_order_and_pagination("mssql", Some(" "), 5, 0), + " ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY" + ); + } + #[test] fn single_cell_update_postgres() { let columns = vec![col("id", true), col("name", false)]; @@ -293,6 +370,16 @@ mod tests { assert_eq!(sql, "UPDATE \"t\" SET \"v\" = ? WHERE \"id\" = ?"); } + #[test] + fn single_cell_update_mssql() { + let columns = vec![col("id", true), col("name", false)]; + let original = vec![Value::Int(7), Value::Text("alice".into())]; + let (sql, params) = + build_single_cell_update("mssql", "u", &columns, &original, 1, Value::Text("bob".into())).unwrap(); + assert_eq!(sql, "UPDATE [u] SET [name] = @P1 WHERE [id] = @P2"); + assert_eq!(params, vec![Value::Text("bob".into()), Value::Int(7)]); + } + #[test] fn single_cell_update_no_pk() { let columns = vec![col("a", false), col("b", false)]; @@ -385,6 +472,15 @@ mod tests { assert_eq!(params, vec![Value::Int(1), Value::Int(2)]); } + #[test] + fn insert_from_draft_mssql() { + let columns = vec![col_auto("id"), col("name", false)]; + let values = vec![Value::Null, Value::Text("alice".into())]; + let (sql, params) = build_insert_from_draft("mssql", None, "users", &columns, &values).unwrap(); + assert_eq!(sql, "INSERT INTO [users] ([name]) VALUES (@P1)"); + assert_eq!(params, vec![Value::Text("alice".into())]); + } + #[test] fn insert_from_draft_omits_null_when_default_exists() { // Cell is NULL and column has a server default (e.g., now()) → diff --git a/linux/crates/drivers/mssql/Cargo.toml b/linux/crates/drivers/mssql/Cargo.toml new file mode 100644 index 000000000..7ee26aa61 --- /dev/null +++ b/linux/crates/drivers/mssql/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "tablepro-driver-mssql" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +publish = false + +[lib] +name = "drivers_mssql" +path = "src/lib.rs" + +[dependencies] +tablepro-core = { path = "../../core" } +async-trait.workspace = true +chrono.workspace = true +futures.workspace = true +rust_decimal.workspace = true +secrecy.workspace = true +serde_json.workspace = true +tiberius.workspace = true +tokio.workspace = true +tokio-util = { workspace = true, features = ["compat"] } +uuid.workspace = true + +[dev-dependencies] +secrecy.workspace = true +testcontainers.workspace = true +testcontainers-modules = { workspace = true, features = ["mssql_server"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/linux/crates/drivers/mssql/src/lib.rs b/linux/crates/drivers/mssql/src/lib.rs new file mode 100644 index 000000000..a518d5a9a --- /dev/null +++ b/linux/crates/drivers/mssql/src/lib.rs @@ -0,0 +1,742 @@ +use async_trait::async_trait; +use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; +use futures::TryStreamExt; +use rust_decimal::Decimal; +use secrecy::ExposeSecret; +use tiberius::{ + AuthMethod, Client, Column, ColumnData, ColumnType, Config, EncryptionLevel, FromSql, QueryItem, ToSql, +}; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; + +use tablepro_core::sql_dialect::build_order_and_pagination; +use tablepro_core::{ + ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, ForeignKeyInfo, IndexInfo, + MAX_QUERY_ROWS, QueryResult, TableInfo, Value, +}; + +type MssqlClient = Client>; + +/// Matches the `acquire_timeout` the sqlx-backed drivers give their +/// pools, so a dead host fails at the same speed on every engine. +const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +pub struct MssqlDriver; + +#[async_trait] +impl DatabaseDriver for MssqlDriver { + fn id(&self) -> &'static str { + "mssql" + } + + fn display_name(&self) -> &'static str { + "SQL Server" + } + + fn default_port(&self) -> u16 { + 1433 + } + + fn ddl_is_transactional(&self) -> bool { + true + } + + async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { + let mut config = Config::new(); + config.host(&opts.host); + config.port(opts.port); + config.database(&opts.database); + config.authentication(AuthMethod::sql_server(&opts.username, opts.password.expose_secret())); + // SQL Server always encrypts the login exchange; `Off` keeps the + // post-login stream in the clear, `Required` encrypts everything. + // No cert-path UI exists, so the server certificate is trusted + // without verification, matching the sqlx drivers' Require / + // Required modes. + config.encryption(if opts.use_tls { + EncryptionLevel::Required + } else { + EncryptionLevel::Off + }); + config.trust_cert(); + + // Neither the TCP dial nor the TDS login has its own deadline, + // and an unreachable host would otherwise hang the connect + // dialog for the OS SYN timeout. The budget covers both so the + // failure arrives on the same scale as the sqlx drivers' + // acquire_timeout. + tokio::time::timeout(CONNECT_TIMEOUT, async { + let tcp = TcpStream::connect(config.get_addr()).await.map_err(map_io_error)?; + tcp.set_nodelay(true).map_err(map_io_error)?; + Client::connect(config, tcp.compat_write()) + .await + .map_err(map_tiberius_error) + }) + .await + .map_err(|_| DriverError::ConnectionRefused)? + .map(|client| { + Box::new(MssqlConnection { + client: Mutex::new(client), + }) as Box + }) + } +} + +struct MssqlConnection { + client: Mutex, +} + +#[async_trait] +impl Connection for MssqlConnection { + async fn list_tables(&self) -> Result, DriverError> { + let sql = "SELECT s.name AS schema_name, t.name AS table_name \ + FROM sys.tables t \ + JOIN sys.schemas s ON t.schema_id = s.schema_id \ + ORDER BY s.name, t.name"; + let mut client = self.client.lock().await; + let result = run_query(&mut client, sql, &[], MAX_QUERY_ROWS).await?; + Ok(result + .rows + .iter() + .filter_map(|row| { + let name = as_text(row.get(1))?; + Some(TableInfo { + schema: as_text(row.first()), + name, + }) + }) + .collect()) + } + + async fn fetch_columns(&self, schema: Option<&str>, table: &str) -> Result, DriverError> { + // sys catalog is authoritative: is_identity/is_computed are exact + // flags, sys.default_constraints.definition carries the DEFAULT + // expression, and the primary-key join flags PK members. Type text + // is rebuilt from sys.types + length/precision so it reads like the + // user's CREATE TABLE (e.g. `nvarchar(255)`, `decimal(18,2)`). + let sql = "SELECT \ + c.name AS col_name, \ + ty.name AS type_name, \ + c.max_length, \ + c.precision, \ + c.scale, \ + c.is_nullable, \ + c.is_identity, \ + c.is_computed, \ + dc.definition AS default_def, \ + CASE WHEN pk.column_id IS NOT NULL THEN 1 ELSE 0 END AS is_pk \ + FROM sys.columns c \ + JOIN sys.objects o ON c.object_id = o.object_id \ + JOIN sys.schemas sc ON o.schema_id = sc.schema_id \ + JOIN sys.types ty ON c.user_type_id = ty.user_type_id \ + LEFT JOIN sys.default_constraints dc ON dc.object_id = c.default_object_id \ + LEFT JOIN ( \ + SELECT ic.object_id, ic.column_id \ + FROM sys.index_columns ic \ + JOIN sys.indexes i ON i.object_id = ic.object_id AND i.index_id = ic.index_id \ + WHERE i.is_primary_key = 1 \ + ) pk ON pk.object_id = c.object_id AND pk.column_id = c.column_id \ + WHERE o.name = @P1 AND sc.name = COALESCE(@P2, SCHEMA_NAME()) \ + ORDER BY c.column_id"; + let mut client = self.client.lock().await; + let result = run_query( + &mut client, + sql, + &[text_param(table), schema_param(schema)], + MAX_QUERY_ROWS, + ) + .await?; + Ok(result.rows.iter().map(|r| row_to_column_info(r.as_slice())).collect()) + } + + async fn fetch_rows( + &self, + schema: Option<&str>, + table: &str, + offset: u64, + limit: u64, + ) -> Result { + let sql = format!( + "SELECT * FROM {}{}", + qualified(schema, table), + build_order_and_pagination("mssql", None, limit, offset) + ); + let mut client = self.client.lock().await; + run_query(&mut client, &sql, &[], limit as usize).await + } + + async fn query(&self, sql: &str) -> Result { + let mut client = self.client.lock().await; + run_query(&mut client, sql, &[], MAX_QUERY_ROWS).await + } + + async fn query_params(&self, sql: &str, params: &[Value]) -> Result { + let mut client = self.client.lock().await; + run_query(&mut client, sql, params, MAX_QUERY_ROWS).await + } + + async fn execute(&self, sql: &str) -> Result { + let mut client = self.client.lock().await; + let rows_affected = run_execute(&mut client, sql, &[]).await?; + Ok(ExecResult { rows_affected }) + } + + async fn execute_params(&self, sql: &str, params: &[Value]) -> Result { + let mut client = self.client.lock().await; + let rows_affected = run_execute(&mut client, sql, params).await?; + Ok(ExecResult { rows_affected }) + } + + async fn execute_in_transaction(&self, statements: &[(String, Vec)]) -> Result, DriverError> { + let mut client = self.client.lock().await; + exec_simple(&mut client, "BEGIN TRANSACTION").await?; + let mut affected = Vec::with_capacity(statements.len()); + for (idx, (sql, params)) in statements.iter().enumerate() { + let boxes = boxed_params(params); + let refs: Vec<&dyn ToSql> = boxes.iter().map(|b| &**b as &dyn ToSql).collect(); + match client.execute(sql.as_str(), &refs).await { + Ok(res) => affected.push(res.total()), + Err(e) => { + let _ = exec_simple(&mut client, "ROLLBACK").await; + return Err(DriverError::Transaction { + statement_index: idx, + source: Box::new(map_tiberius_error(e)), + }); + } + } + } + exec_simple(&mut client, "COMMIT").await?; + Ok(affected) + } + + async fn fetch_indexes(&self, schema: Option<&str>, table: &str) -> Result, DriverError> { + // Flat rows (one per index column) ordered by key_ordinal; grouped + // into IndexInfo below since TDS has no array aggregation. + // INCLUDE columns are not part of the key and carry key_ordinal + // 0, so leaving them in would both list them as key columns and + // sort them ahead of the real ones. + let sql = "SELECT i.name AS index_name, i.is_unique, i.is_primary_key, c.name AS col_name \ + FROM sys.indexes i \ + JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \ + JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \ + JOIN sys.objects o ON o.object_id = i.object_id \ + JOIN sys.schemas s ON s.schema_id = o.schema_id \ + WHERE o.name = @P1 AND s.name = COALESCE(@P2, SCHEMA_NAME()) \ + AND i.name IS NOT NULL AND i.type > 0 \ + AND ic.is_included_column = 0 \ + ORDER BY i.name, ic.key_ordinal"; + let mut client = self.client.lock().await; + let result = run_query( + &mut client, + sql, + &[text_param(table), schema_param(schema)], + MAX_QUERY_ROWS, + ) + .await?; + let mut out: Vec = Vec::new(); + for row in &result.rows { + let Some(name) = as_text(row.first()) else { + continue; + }; + let unique = as_bool(row.get(1)).unwrap_or(false); + let primary = as_bool(row.get(2)).unwrap_or(false); + let col = as_text(row.get(3)).unwrap_or_default(); + match out.iter_mut().find(|ix| ix.name == name) { + Some(ix) => ix.columns.push(col), + None => out.push(IndexInfo { + name, + columns: vec![col], + unique, + primary, + }), + } + } + Ok(out) + } + + async fn fetch_foreign_keys(&self, schema: Option<&str>, table: &str) -> Result, DriverError> { + let sql = "SELECT fk.name AS fk_name, \ + cpar.name AS col_name, \ + rs.name AS ref_schema, \ + rt.name AS ref_table, \ + cref.name AS ref_col, \ + fk.delete_referential_action_desc, \ + fk.update_referential_action_desc \ + FROM sys.foreign_keys fk \ + JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id \ + JOIN sys.objects o ON o.object_id = fk.parent_object_id \ + JOIN sys.schemas s ON s.schema_id = o.schema_id \ + JOIN sys.columns cpar ON cpar.object_id = fk.parent_object_id AND cpar.column_id = fkc.parent_column_id \ + JOIN sys.objects rt ON rt.object_id = fk.referenced_object_id \ + JOIN sys.schemas rs ON rs.schema_id = rt.schema_id \ + JOIN sys.columns cref ON cref.object_id = fk.referenced_object_id AND cref.column_id = fkc.referenced_column_id \ + WHERE o.name = @P1 AND s.name = COALESCE(@P2, SCHEMA_NAME()) \ + ORDER BY fk.name, fkc.constraint_column_id"; + let mut client = self.client.lock().await; + let result = run_query( + &mut client, + sql, + &[text_param(table), schema_param(schema)], + MAX_QUERY_ROWS, + ) + .await?; + let mut out: Vec = Vec::new(); + for row in &result.rows { + let Some(name) = as_text(row.first()) else { + continue; + }; + let col = as_text(row.get(1)).unwrap_or_default(); + let ref_col = as_text(row.get(4)).unwrap_or_default(); + match out.iter_mut().find(|fk| fk.name == name) { + Some(fk) => { + fk.columns.push(col); + fk.ref_columns.push(ref_col); + } + None => out.push(ForeignKeyInfo { + name, + columns: vec![col], + ref_schema: as_text(row.get(2)), + ref_table: as_text(row.get(3)).unwrap_or_default(), + ref_columns: vec![ref_col], + on_delete: as_text(row.get(5)).and_then(|d| map_referential_action(&d)), + on_update: as_text(row.get(6)).and_then(|d| map_referential_action(&d)), + }), + } + } + Ok(out) + } + + async fn ping(&self) -> Result<(), DriverError> { + let mut client = self.client.lock().await; + exec_simple(&mut client, "SELECT 1").await + } + + async fn close(self: Box) -> Result<(), DriverError> { + self.client.into_inner().close().await.map_err(map_tiberius_error) + } +} + +async fn run_query( + client: &mut MssqlClient, + sql: &str, + params: &[Value], + limit: usize, +) -> Result { + let boxes = boxed_params(params); + let refs: Vec<&dyn ToSql> = boxes.iter().map(|b| &**b as &dyn ToSql).collect(); + let mut stream = client.query(sql, &refs).await.map_err(map_tiberius_error)?; + let mut columns: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + let mut truncated = false; + let mut seen_result_set = false; + while let Some(item) = stream.try_next().await.map_err(map_tiberius_error)? { + match item { + // Metadata arrives before the rows it describes, so a + // result set with no rows still reports its columns. A + // second one means the batch produced another result set; + // the grid renders a single column list, so stop rather + // than file the next set's rows under these headers. + QueryItem::Metadata(meta) => { + if seen_result_set { + break; + } + seen_result_set = true; + columns = meta.columns().iter().map(col_to_info).collect(); + } + QueryItem::Row(row) => { + if rows.len() >= limit { + truncated = true; + break; + } + rows.push(row.into_iter().map(|cd| column_data_to_value(&cd)).collect()); + } + } + } + Ok(QueryResult { + columns, + rows, + truncated, + }) +} + +async fn run_execute(client: &mut MssqlClient, sql: &str, params: &[Value]) -> Result { + let boxes = boxed_params(params); + let refs: Vec<&dyn ToSql> = boxes.iter().map(|b| &**b as &dyn ToSql).collect(); + let res = client.execute(sql, &refs).await.map_err(map_tiberius_error)?; + Ok(res.total()) +} + +/// Run a statement whose result set we don't consume (transaction control, +/// `SELECT 1` liveness). The stream must be drained so the DONE token is +/// read and the connection is left ready for the next command. +async fn exec_simple(client: &mut MssqlClient, sql: &str) -> Result<(), DriverError> { + let stream = client.simple_query(sql).await.map_err(map_tiberius_error)?; + stream.into_results().await.map_err(map_tiberius_error)?; + Ok(()) +} + +fn boxed_params(params: &[Value]) -> Vec> { + params + .iter() + .map(|p| -> Box { + match p { + // Type NULL as nvarchar: a typed-int NULL breaks COALESCE / + // comparisons against string columns (e.g. the introspection + // `COALESCE(@P, SCHEMA_NAME())`), while NULL always converts + // cleanly into any target column type on INSERT/UPDATE. + Value::Null => Box::new(Option::::None), + Value::Bool(b) => Box::new(*b), + Value::Int(i) => Box::new(*i), + Value::Float(f) => Box::new(*f), + Value::Text(s) => Box::new(s.clone()), + Value::Bytes(b) => Box::new(b.clone()), + Value::Date(d) => Box::new(*d), + Value::Time(t) => Box::new(*t), + Value::DateTime(dt) => Box::new(*dt), + Value::TimestampTz(ts) => Box::new(*ts), + Value::Decimal(d) => Box::new(*d), + Value::Uuid(u) => Box::new(*u), + // TDS has no JSON type; SQL Server stores JSON as nvarchar. + Value::Json(j) => Box::new(serde_json::to_string(j).unwrap_or_default()), + } + }) + .collect() +} + +fn col_to_info(c: &Column) -> ColumnInfo { + ColumnInfo { + name: c.name().to_string(), + data_type: column_type_to_string(c.column_type()), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: None, + is_generated: false, + } +} + +fn column_data_to_value(cd: &ColumnData<'static>) -> Value { + match cd { + ColumnData::Bit(v) => (*v).map(Value::Bool).unwrap_or(Value::Null), + ColumnData::U8(v) => (*v).map(|n| Value::Int(i64::from(n))).unwrap_or(Value::Null), + ColumnData::I16(v) => (*v).map(|n| Value::Int(i64::from(n))).unwrap_or(Value::Null), + ColumnData::I32(v) => (*v).map(|n| Value::Int(i64::from(n))).unwrap_or(Value::Null), + ColumnData::I64(v) => (*v).map(Value::Int).unwrap_or(Value::Null), + ColumnData::F32(v) => (*v).map(|n| Value::Float(f64::from(n))).unwrap_or(Value::Null), + ColumnData::F64(v) => (*v).map(Value::Float).unwrap_or(Value::Null), + ColumnData::String(v) => v.as_ref().map(|s| Value::Text(s.to_string())).unwrap_or(Value::Null), + ColumnData::Binary(v) => v.as_ref().map(|b| Value::Bytes(b.to_vec())).unwrap_or(Value::Null), + ColumnData::Guid(v) => (*v).map(Value::Uuid).unwrap_or(Value::Null), + ColumnData::Numeric(_) => Decimal::from_sql(cd) + .ok() + .flatten() + .map(Value::Decimal) + .unwrap_or(Value::Null), + ColumnData::Date(_) => NaiveDate::from_sql(cd) + .ok() + .flatten() + .map(Value::Date) + .unwrap_or(Value::Null), + ColumnData::Time(_) => NaiveTime::from_sql(cd) + .ok() + .flatten() + .map(Value::Time) + .unwrap_or(Value::Null), + ColumnData::DateTime(_) | ColumnData::SmallDateTime(_) | ColumnData::DateTime2(_) => { + NaiveDateTime::from_sql(cd) + .ok() + .flatten() + .map(Value::DateTime) + .unwrap_or(Value::Null) + } + ColumnData::DateTimeOffset(_) => DateTime::::from_sql(cd) + .ok() + .flatten() + .map(Value::TimestampTz) + .unwrap_or(Value::Null), + ColumnData::Xml(v) => v.as_ref().map(|x| Value::Text(x.to_string())).unwrap_or(Value::Null), + } +} + +fn column_type_to_string(ct: ColumnType) -> String { + let name = match ct { + ColumnType::Null => "null", + ColumnType::Bit | ColumnType::Bitn => "bit", + ColumnType::Int1 => "tinyint", + ColumnType::Int2 => "smallint", + ColumnType::Int4 => "int", + ColumnType::Int8 => "bigint", + ColumnType::Intn => "int", + ColumnType::Float4 => "real", + ColumnType::Float8 | ColumnType::Floatn => "float", + ColumnType::Decimaln | ColumnType::Numericn => "decimal", + ColumnType::Money | ColumnType::Money4 => "money", + ColumnType::Datetime | ColumnType::Datetime4 | ColumnType::Datetimen => "datetime", + ColumnType::Datetime2 => "datetime2", + ColumnType::DatetimeOffsetn => "datetimeoffset", + ColumnType::Daten => "date", + ColumnType::Timen => "time", + ColumnType::Guid => "uniqueidentifier", + ColumnType::BigChar | ColumnType::BigVarChar => "varchar", + ColumnType::NChar | ColumnType::NVarchar => "nvarchar", + ColumnType::Text => "text", + ColumnType::NText => "ntext", + ColumnType::BigBinary | ColumnType::BigVarBin => "varbinary", + ColumnType::Image => "image", + ColumnType::Xml => "xml", + ColumnType::Udt => "udt", + ColumnType::SSVariant => "sql_variant", + }; + name.to_string() +} + +fn row_to_column_info(row: &[Value]) -> ColumnInfo { + let type_name = as_text(row.get(1)).unwrap_or_default(); + let max_length = as_i64(row.get(2)).unwrap_or(0); + let precision = as_i64(row.get(3)).unwrap_or(0); + let scale = as_i64(row.get(4)).unwrap_or(0); + let is_identity = as_bool(row.get(6)).unwrap_or(false); + let default_raw = as_text(row.get(8)); + ColumnInfo { + name: as_text(row.first()).unwrap_or_default(), + data_type: format_mssql_type(&type_name, max_length, precision, scale), + nullable: as_bool(row.get(5)).unwrap_or(true), + primary_key: as_bool(row.get(9)).unwrap_or(false), + is_auto_increment: is_identity, + // IDENTITY columns carry an internal seed/increment, not a user + // DEFAULT — suppress so the inline-insert UI treats them as auto. + default_value: if is_identity { + None + } else { + default_raw.map(|d| normalize_mssql_default(&d)) + }, + is_generated: as_bool(row.get(7)).unwrap_or(false), + } +} + +/// Rebuild a user-facing type string from `sys.types` metadata. `nvarchar` +/// / `nchar` store `max_length` in bytes (two per character); `-1` is the +/// `(max)` sentinel. Numeric types carry precision/scale. +fn format_mssql_type(type_name: &str, max_length: i64, precision: i64, scale: i64) -> String { + match type_name.to_ascii_lowercase().as_str() { + "varchar" | "char" | "varbinary" | "binary" => { + if max_length < 0 { + format!("{type_name}(max)") + } else { + format!("{type_name}({max_length})") + } + } + "nvarchar" | "nchar" => { + if max_length < 0 { + format!("{type_name}(max)") + } else { + format!("{type_name}({})", max_length / 2) + } + } + "decimal" | "numeric" => format!("{type_name}({precision},{scale})"), + _ => type_name.to_string(), + } +} + +/// `sys.default_constraints.definition` wraps the expression in parentheses +/// (sometimes doubled) and quotes string literals: `((0))`, `('pending')`, +/// `(getdate())`. Peel balanced outer parens, then outer single quotes, so +/// the value reads like the user typed it — matching the other drivers. +fn normalize_mssql_default(raw: &str) -> String { + let mut s = raw.trim(); + while outer_parens_wrap(s) { + s = s[1..s.len() - 1].trim(); + } + strip_outer_single_quotes(s) +} + +fn outer_parens_wrap(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.len() < 2 || bytes[0] != b'(' || bytes[bytes.len() - 1] != b')' { + return false; + } + let mut depth = 0i32; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'(' => depth += 1, + b')' => { + depth -= 1; + // A close that returns to depth 0 before the final byte + // means the leading `(` does not wrap the whole string + // (e.g. `(a)+(b)`); don't strip. + if depth == 0 && i != bytes.len() - 1 { + return false; + } + } + _ => {} + } + } + depth == 0 +} + +fn strip_outer_single_quotes(raw: &str) -> String { + let bytes = raw.as_bytes(); + if bytes.len() >= 2 && bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'' { + return raw[1..raw.len() - 1].replace("''", "'"); + } + raw.to_string() +} + +/// Map SQL Server's `*_referential_action_desc` text to the canonical SQL +/// keyword the FK/DDL layer expects. `NO_ACTION` returns `None` so the DDL +/// builder omits the redundant clause. +fn map_referential_action(desc: &str) -> Option { + match desc.to_ascii_uppercase().as_str() { + "CASCADE" => Some("CASCADE".into()), + "SET_NULL" => Some("SET NULL".into()), + "SET_DEFAULT" => Some("SET DEFAULT".into()), + _ => None, + } +} + +fn quote_ident(name: &str) -> String { + format!("[{}]", name.replace(']', "]]")) +} + +fn qualified(schema: Option<&str>, table: &str) -> String { + match schema { + Some(s) => format!("{}.{}", quote_ident(s), quote_ident(table)), + None => quote_ident(table), + } +} + +fn text_param(s: &str) -> Value { + Value::Text(s.to_string()) +} + +fn schema_param(schema: Option<&str>) -> Value { + match schema { + Some(s) => Value::Text(s.to_string()), + None => Value::Null, + } +} + +fn as_text(v: Option<&Value>) -> Option { + match v { + Some(Value::Text(s)) => Some(s.clone()), + _ => None, + } +} + +fn as_i64(v: Option<&Value>) -> Option { + match v { + Some(Value::Int(i)) => Some(*i), + _ => None, + } +} + +fn as_bool(v: Option<&Value>) -> Option { + match v { + Some(Value::Bool(b)) => Some(*b), + // sys catalog bit columns and the CASE-derived is_pk flag can arrive + // as an integer depending on the shape of the projection. + Some(Value::Int(i)) => Some(*i != 0), + _ => None, + } +} + +fn map_io_error(e: std::io::Error) -> DriverError { + if e.kind() == std::io::ErrorKind::ConnectionRefused { + DriverError::ConnectionRefused + } else { + DriverError::Internal(e.to_string()) + } +} + +fn map_tiberius_error(err: tiberius::error::Error) -> DriverError { + use tiberius::error::Error as E; + match err { + E::Io { .. } => DriverError::Disconnected, + E::Tls(msg) => DriverError::Tls(msg), + E::Server(token) => { + // 18456 = "Login failed for user"; surface as an auth failure so + // the UI shows the right remediation instead of a raw SQL error. + if token.code() == 18456 { + DriverError::AuthFailed + } else { + DriverError::Query { + message: token.message().to_string(), + sqlstate: Some(token.state().to_string()), + } + } + } + E::Routing { host, port } => DriverError::Internal(format!("server requested routing to {host}:{port}")), + other => DriverError::Internal(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn driver_metadata() { + let d = MssqlDriver; + assert_eq!(d.id(), "mssql"); + assert_eq!(d.display_name(), "SQL Server"); + assert_eq!(d.default_port(), 1433); + assert!(!d.is_file_based()); + } + + #[test] + fn quote_ident_brackets_and_escapes() { + assert_eq!(quote_ident("users"), "[users]"); + assert_eq!(quote_ident("My Table"), "[My Table]"); + assert_eq!(quote_ident("weird]name"), "[weird]]name]"); + } + + #[test] + fn qualified_uses_bracket_quoting() { + assert_eq!(qualified(Some("dbo"), "users"), "[dbo].[users]"); + assert_eq!(qualified(None, "users"), "[users]"); + } + + #[test] + fn format_type_lengths_and_precision() { + assert_eq!(format_mssql_type("int", 4, 10, 0), "int"); + assert_eq!(format_mssql_type("varchar", 255, 0, 0), "varchar(255)"); + assert_eq!(format_mssql_type("varchar", -1, 0, 0), "varchar(max)"); + // nvarchar max_length is in bytes: 510 bytes -> 255 chars. + assert_eq!(format_mssql_type("nvarchar", 510, 0, 0), "nvarchar(255)"); + assert_eq!(format_mssql_type("nvarchar", -1, 0, 0), "nvarchar(max)"); + assert_eq!(format_mssql_type("decimal", 9, 18, 2), "decimal(18,2)"); + } + + #[test] + fn normalize_default_peels_parens_and_quotes() { + assert_eq!(normalize_mssql_default("((0))"), "0"); + assert_eq!(normalize_mssql_default("('pending')"), "pending"); + assert_eq!(normalize_mssql_default("(getdate())"), "getdate()"); + assert_eq!(normalize_mssql_default("(N'x')"), "N'x'"); + assert_eq!(normalize_mssql_default("('it''s')"), "it's"); + } + + #[test] + fn normalize_default_leaves_unbalanced_alone() { + // A leading `(` that doesn't wrap the whole expression must not be + // stripped, or the value would be corrupted. + assert_eq!(normalize_mssql_default("(a)+(b)"), "(a)+(b)"); + } + + #[test] + fn referential_actions_map_to_keywords() { + assert_eq!(map_referential_action("CASCADE"), Some("CASCADE".to_string())); + assert_eq!(map_referential_action("SET_NULL"), Some("SET NULL".to_string())); + assert_eq!(map_referential_action("SET_DEFAULT"), Some("SET DEFAULT".to_string())); + assert_eq!(map_referential_action("NO_ACTION"), None); + } + + #[test] + fn column_type_names_are_human_readable() { + assert_eq!(column_type_to_string(ColumnType::Int4), "int"); + assert_eq!(column_type_to_string(ColumnType::NVarchar), "nvarchar"); + assert_eq!(column_type_to_string(ColumnType::Datetime2), "datetime2"); + assert_eq!(column_type_to_string(ColumnType::Guid), "uniqueidentifier"); + assert_eq!(column_type_to_string(ColumnType::Bit), "bit"); + } +} diff --git a/linux/crates/drivers/mssql/tests/integration.rs b/linux/crates/drivers/mssql/tests/integration.rs new file mode 100644 index 000000000..a919410aa --- /dev/null +++ b/linux/crates/drivers/mssql/tests/integration.rs @@ -0,0 +1,368 @@ +use std::str::FromStr; + +use chrono::{NaiveDate, NaiveTime}; +use rust_decimal::Decimal; +use secrecy::SecretString; + +use drivers_mssql::MssqlDriver; +use tablepro_core::{ConnectOptions, Connection, DatabaseDriver, Value}; +use testcontainers::ContainerAsync; +use testcontainers_modules::mssql_server::MssqlServer; +use testcontainers_modules::testcontainers::runners::AsyncRunner; + +async fn start_mssql() -> (ContainerAsync, ConnectOptions) { + let container = MssqlServer::default() + .with_accept_eula() + .start() + .await + .expect("start mssql container"); + let host = container.get_host().await.expect("host").to_string(); + let port = container.get_host_port_ipv4(1433).await.expect("port"); + let opts = ConnectOptions { + host, + port, + database: "master".into(), + username: "sa".into(), + password: SecretString::new(MssqlServer::DEFAULT_SA_PASSWORD.to_string().into()), + use_tls: false, + }; + (container, opts) +} + +async fn connect(opts: ConnectOptions) -> Box { + MssqlDriver.connect(opts).await.expect("connect") +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn connect_list_tables_pk_and_identity() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + conn.execute( + "CREATE TABLE pk_demo ( + id int IDENTITY(1,1) PRIMARY KEY, + name nvarchar(255) NOT NULL, + note nvarchar(max) NULL + )", + ) + .await + .unwrap(); + conn.execute("INSERT INTO pk_demo (name, note) VALUES (N'a', NULL), (N'b', N'second')") + .await + .unwrap(); + + let tables = conn.list_tables().await.unwrap(); + assert!(tables.iter().any(|t| t.name == "pk_demo")); + + let cols = conn.fetch_columns(None, "pk_demo").await.unwrap(); + assert_eq!(cols.len(), 3); + let id_col = cols.iter().find(|c| c.name == "id").unwrap(); + assert!(id_col.primary_key, "id must be detected as primary key"); + assert!(id_col.is_auto_increment, "IDENTITY must flag auto-increment"); + assert!(!id_col.nullable); + let note_col = cols.iter().find(|c| c.name == "note").unwrap(); + assert!(!note_col.primary_key); + assert!(note_col.nullable); + + let result = conn.fetch_rows(None, "pk_demo", 0, 100).await.unwrap(); + assert_eq!(result.rows.len(), 2); + assert!(!result.truncated); +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn value_roundtrip_representative_types() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + conn.execute( + "CREATE TABLE types_demo ( + b bit, + i int, + big bigint, + f float, + dec decimal(18,4), + s nvarchar(100), + bin varbinary(16), + d date, + t time, + dt2 datetime2, + uid uniqueidentifier + )", + ) + .await + .unwrap(); + + let uid = uuid::Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788); + let dec = Decimal::from_str("1234.5678").unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(); + let time = NaiveTime::from_hms_opt(10, 30, 0).unwrap(); + let dt2 = date.and_hms_opt(10, 30, 0).unwrap(); + let params = vec![ + Value::Bool(true), + Value::Int(42), + Value::Int(9_000_000_000), + Value::Float(2.5), + Value::Decimal(dec), + Value::Text("héllo".into()), + Value::Bytes(vec![1, 2, 3, 4]), + Value::Date(date), + Value::Time(time), + Value::DateTime(dt2), + Value::Uuid(uid), + ]; + conn.execute_params( + "INSERT INTO types_demo (b, i, big, f, dec, s, bin, d, t, dt2, uid) \ + VALUES (@P1, @P2, @P3, @P4, @P5, @P6, @P7, @P8, @P9, @P10, @P11)", + ¶ms, + ) + .await + .unwrap(); + + let result = conn + .query("SELECT b, i, big, f, dec, s, bin, d, t, dt2, uid FROM types_demo") + .await + .unwrap(); + assert_eq!(result.rows.len(), 1); + let row = &result.rows[0]; + assert_eq!(row[0], Value::Bool(true)); + assert_eq!(row[1], Value::Int(42)); + assert_eq!(row[2], Value::Int(9_000_000_000)); + assert_eq!(row[3], Value::Float(2.5)); + assert_eq!(row[4], Value::Decimal(dec)); + assert_eq!(row[5], Value::Text("héllo".into())); + assert_eq!(row[6], Value::Bytes(vec![1, 2, 3, 4])); + assert_eq!(row[7], Value::Date(date)); + assert_eq!(row[8], Value::Time(time)); + assert_eq!(row[9], Value::DateTime(dt2)); + assert_eq!(row[10], Value::Uuid(uid)); +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn pagination_and_truncated_flag() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + conn.execute("CREATE TABLE big (i int PRIMARY KEY)").await.unwrap(); + let mut sql = String::from("INSERT INTO big (i) VALUES "); + for i in 0..50 { + if i > 0 { + sql.push(','); + } + sql.push_str(&format!("({i})")); + } + conn.execute(&sql).await.unwrap(); + + // fetch_rows pages with OFFSET/FETCH; order is not guaranteed, so assert + // the page size only. + let page = conn.fetch_rows(None, "big", 10, 5).await.unwrap(); + assert_eq!(page.rows.len(), 5); + assert!(!page.truncated); + + // Ordered query for deterministic value assertions. + let q = conn + .query("SELECT i FROM big ORDER BY i OFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY") + .await + .unwrap(); + let firsts: Vec = q + .rows + .iter() + .map(|r| match r[0] { + Value::Int(i) => i, + _ => panic!("expected int"), + }) + .collect(); + assert_eq!(firsts, vec![10, 11, 12, 13, 14]); + + let all = conn.query("SELECT i FROM big ORDER BY i").await.unwrap(); + assert_eq!(all.rows.len(), 50); + assert!(!all.truncated); +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn bad_sql_returns_query_error() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + let err = conn.query("SELECT * FROM no_such_table").await.unwrap_err(); + let msg = format!("{err}").to_lowercase(); + assert!( + msg.contains("no_such_table") || msg.contains("invalid object") || msg.contains("object name"), + "expected error to mention the missing object, got: {msg}" + ); +} + +/// The structure editor's Save path. Transaction control has to travel +/// as a SQL batch: tiberius routes `query` / `execute` through +/// `sp_executesql`, and SQL Server rejects a stored procedure that +/// returns with a different `@@TRANCOUNT` than it entered with (Msg +/// 266), leaving the transaction open on the connection. +#[tokio::test] +#[ignore = "requires docker"] +async fn ddl_batch_commits_and_rolls_back_as_a_unit() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + let committed = conn + .execute_in_transaction(&[ + ("CREATE TABLE tx_demo (id int NOT NULL)".to_string(), Vec::new()), + ("ALTER TABLE tx_demo ADD name nvarchar(50) NULL".to_string(), Vec::new()), + ]) + .await + .unwrap(); + assert_eq!(committed.len(), 2); + assert_eq!(conn.fetch_columns(None, "tx_demo").await.unwrap().len(), 2); + + let err = conn + .execute_in_transaction(&[ + ("ALTER TABLE tx_demo ADD extra int NULL".to_string(), Vec::new()), + ("ALTER TABLE tx_demo ADD extra int NULL".to_string(), Vec::new()), + ]) + .await + .unwrap_err(); + assert!(matches!( + err, + tablepro_core::DriverError::Transaction { statement_index: 1, .. } + )); + let cols = conn.fetch_columns(None, "tx_demo").await.unwrap(); + assert_eq!(cols.len(), 2, "the failed batch must roll back the first statement"); + assert!(!cols.iter().any(|c| c.name == "extra")); + + // The connection is still usable, which it would not be if a + // half-open transaction were left behind holding schema locks. + conn.execute("CREATE TABLE tx_demo_after (id int)").await.unwrap(); +} + +/// Default constraints are separate objects here, so a default change +/// is drop-then-add against a server-generated constraint name. +#[tokio::test] +#[ignore = "requires docker"] +async fn alter_column_default_round_trips() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + conn.execute("CREATE TABLE def_demo (id int NOT NULL, status nvarchar(20) NULL)") + .await + .unwrap(); + + let mut column = tablepro_core::sql_ddl::DraftColumn { + original: Some( + conn.fetch_columns(None, "def_demo") + .await + .unwrap() + .into_iter() + .find(|c| c.name == "status") + .unwrap(), + ), + name: "status".into(), + data_type: "nvarchar(20)".into(), + nullable: true, + primary_key: false, + auto_increment: false, + default_value: Some("'pending'".into()), + }; + for sql in tablepro_core::sql_ddl::build_alter_column("mssql", None, "def_demo", &column).unwrap() { + conn.execute(&sql).await.unwrap(); + } + let status = |cols: Vec| cols.into_iter().find(|c| c.name == "status").unwrap(); + let after_add = status(conn.fetch_columns(None, "def_demo").await.unwrap()); + assert_eq!(after_add.default_value.as_deref(), Some("pending")); + + conn.execute("INSERT INTO def_demo (id) VALUES (1)").await.unwrap(); + let rows = conn.query("SELECT status FROM def_demo").await.unwrap(); + assert_eq!(rows.rows[0][0], Value::Text("pending".into())); + + column.original = Some(after_add); + column.default_value = None; + for sql in tablepro_core::sql_ddl::build_alter_column("mssql", None, "def_demo", &column).unwrap() { + conn.execute(&sql).await.unwrap(); + } + assert!( + status(conn.fetch_columns(None, "def_demo").await.unwrap()) + .default_value + .is_none() + ); +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn foreign_key_actions_round_trip() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + conn.execute("CREATE TABLE fk_parent (id int NOT NULL PRIMARY KEY)") + .await + .unwrap(); + conn.execute("CREATE TABLE fk_child (id int NOT NULL PRIMARY KEY, parent_id int NULL)") + .await + .unwrap(); + + let fk = tablepro_core::ForeignKeyInfo { + name: "fk_child_parent".into(), + columns: vec!["parent_id".into()], + ref_schema: None, + ref_table: "fk_parent".into(), + ref_columns: vec!["id".into()], + on_delete: Some("CASCADE".into()), + on_update: Some("NO ACTION".into()), + }; + let sql = tablepro_core::sql_ddl::build_add_foreign_key("mssql", None, "fk_child", &fk).unwrap(); + conn.execute(&sql).await.unwrap(); + + let fks = conn.fetch_foreign_keys(None, "fk_child").await.unwrap(); + assert_eq!(fks.len(), 1); + assert_eq!(fks[0].columns, vec!["parent_id".to_string()]); + assert_eq!(fks[0].ref_table, "fk_parent"); + assert_eq!(fks[0].on_delete.as_deref(), Some("CASCADE")); + + // RESTRICT is not in the T-SQL grammar, so the builder refuses it + // rather than handing the server a syntax error. + let mut restricted = fk.clone(); + restricted.name = "fk_restrict".into(); + restricted.on_delete = Some("RESTRICT".into()); + assert!(tablepro_core::sql_ddl::build_add_foreign_key("mssql", None, "fk_child", &restricted).is_err()); +} + +/// An index's INCLUDE columns are not part of its key and carry +/// key_ordinal 0, so leaving them in the catalog query would both list +/// them as key columns and sort them ahead of the real ones. +#[tokio::test] +#[ignore = "requires docker"] +async fn index_columns_exclude_included_columns() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + conn.execute("CREATE TABLE ix_demo (a int NOT NULL, b int NOT NULL, c int NULL, d int NULL)") + .await + .unwrap(); + conn.execute("CREATE INDEX ix_demo_ab ON ix_demo (a, b) INCLUDE (c, d)") + .await + .unwrap(); + + let indexes = conn.fetch_indexes(None, "ix_demo").await.unwrap(); + let ix = indexes.iter().find(|i| i.name == "ix_demo_ab").unwrap(); + assert_eq!(ix.columns, vec!["a".to_string(), "b".to_string()]); +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn empty_result_set_still_reports_columns() { + let (_c, opts) = start_mssql().await; + let conn = connect(opts).await; + + conn.execute("CREATE TABLE empty_demo (id int NOT NULL, label nvarchar(10) NULL)") + .await + .unwrap(); + + let result = conn.query("SELECT id, label FROM empty_demo").await.unwrap(); + assert!(result.rows.is_empty()); + let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["id", "label"]); + + let paged = conn.fetch_rows(None, "empty_demo", 0, 50).await.unwrap(); + assert!(paged.rows.is_empty()); + assert_eq!(paged.columns.len(), 2); +} diff --git a/linux/crates/drivers/postgres/src/lib.rs b/linux/crates/drivers/postgres/src/lib.rs index 3c3a20bc2..56064ae86 100644 --- a/linux/crates/drivers/postgres/src/lib.rs +++ b/linux/crates/drivers/postgres/src/lib.rs @@ -28,6 +28,10 @@ impl DatabaseDriver for PgDriver { 5432 } + fn ddl_is_transactional(&self) -> bool { + true + } + async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { let pg_opts = PgConnectOptions::new() .host(&opts.host) diff --git a/linux/crates/drivers/sqlite/src/lib.rs b/linux/crates/drivers/sqlite/src/lib.rs index e0b9eb547..34440778a 100644 --- a/linux/crates/drivers/sqlite/src/lib.rs +++ b/linux/crates/drivers/sqlite/src/lib.rs @@ -32,6 +32,10 @@ impl DatabaseDriver for SqliteDriver { true } + fn ddl_is_transactional(&self) -> bool { + true + } + async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { let url = if opts.database.is_empty() || opts.database == ":memory:" { "sqlite::memory:".to_string()