From 3a1e47d8723a284bd640e5373d6e6218e0ff5725 Mon Sep 17 00:00:00 2001 From: DIMAS FEBRIYANTO <62858781+dimassfeb-09@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:50:19 +0700 Subject: [PATCH] feat(db): add Microsoft SQL Server driver support (#276) The database tool window can connect to Microsoft SQL Server (tiberius over rustls with a bb8 pool): browse schema objects, edit table data, run queries and view plans; SQL Server dialect, object dialogs and icons in the UI. Values are rendered by column type: exact decimal/numeric, money with four decimals, tinyint, uniqueidentifier, date/time/datetime/datetime2/smalldatetime/datetimeoffset in ISO form, xml and binary. Multi-statement edits roll back on any failure and discard the connection rather than returning it to the pool mid-transaction; connect-time errors (login, database) are reported; result pagination reports more rows; cancelling a query stops reading and drops the stream. The sqlx drivers keep the shared with_pool! path. Co-authored-by: dimassfeb-09 <62858781+dimassfeb-09@users.noreply.github.com> --- Cargo.lock | 194 +++++++++- Cargo.toml | 3 + crates/ide-db/Cargo.toml | 3 + crates/ide-db/src/conn.rs | 398 ++++++++++++++++++-- crates/ide-db/src/lib.rs | 8 + crates/ide-db/src/plan.rs | 3 + crates/ide-db/src/source.rs | 12 + crates/ide-db/src/sql.rs | 108 +++++- crates/ide-db/tests/sqlserver.rs | 97 +++++ src/bindings.ts | 4 +- src/features/database/ObjectDialogs.tsx | 19 + src/features/database/SourceDialog.tsx | 4 + src/features/database/database.test.ts | 2 +- src/features/database/objects.ts | 6 +- src/features/database/sql/dialect.ts | 37 ++ src/features/database/vendors.ts | 10 + src/platform/locales/text/zh.json | 2 + src/ui/icons.tsx | 1 + src/ui/openidea/database/sqlserver.svg | 53 +++ src/ui/openidea/database/sqlserver_dark.svg | 53 +++ 20 files changed, 974 insertions(+), 43 deletions(-) create mode 100644 crates/ide-db/tests/sqlserver.rs create mode 100644 src/ui/openidea/database/sqlserver.svg create mode 100644 src/ui/openidea/database/sqlserver_dark.svg diff --git a/Cargo.lock b/Cargo.lock index eae1a9675..ed0c4491a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,19 @@ dependencies = [ "syn 3.0.9", ] +[[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 = "atk" version = "0.18.2" @@ -388,6 +401,31 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bb8" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" +dependencies = [ + "futures-util", + "parking_lot", + "portable-atomic", + "tokio", +] + +[[package]] +name = "bb8-tiberius" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a5023b318f47a294d02b46ce0d82ac8e7df0afce2257f041ba7316566d6cd56" +dependencies = [ + "bb8", + "thiserror 1.0.69", + "tiberius", + "tokio", + "tokio-util", +] + [[package]] name = "bcrypt-pbkdf" version = "0.11.0" @@ -959,6 +997,12 @@ 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 = "console" version = "0.16.6" @@ -3817,9 +3861,9 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.46", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.6", "tower-service", ] @@ -4080,6 +4124,8 @@ dependencies = [ name = "ide-db" version = "0.1.25" dependencies = [ + "bb8", + "bb8-tiberius", "dirs 6.0.0", "futures", "ide-core", @@ -4090,6 +4136,7 @@ dependencies = [ "specta", "sqlx", "tempfile", + "tiberius", "tokio", "toml 0.9.12+spec-1.1.0", "tracing", @@ -4182,7 +4229,7 @@ dependencies = [ "ide-core", "parking_lot", "reqwest", - "rustls", + "rustls 0.23.46", "rustls-platform-verifier", "serde", "serde_json", @@ -6050,6 +6097,12 @@ dependencies = [ "tracing-subscriber", ] +[[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" @@ -6526,6 +6579,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + [[package]] name = "primefield" version = "0.14.0" @@ -6920,14 +6979,14 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.23.46", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.6", "tokio-util", "tower", "tower-http", @@ -7189,6 +7248,18 @@ dependencies = [ "rustix", ] +[[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.46" @@ -7199,21 +7270,42 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.16", "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.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" 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]] @@ -7236,11 +7328,11 @@ dependencies = [ "jni 0.22.4", "log", "once_cell", - "rustls", - "rustls-native-certs", + "rustls 0.23.46", + "rustls-native-certs 0.8.4", "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", + "rustls-webpki 0.103.16", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -7252,6 +7344,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.16" @@ -7378,6 +7480,16 @@ dependencies = [ "sha2 0.11.1", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sec1" version = "0.8.1" @@ -7392,6 +7504,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 2.13.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -7988,7 +8113,7 @@ dependencies = [ "log", "memchr", "percent-encoding", - "rustls", + "rustls 0.23.46", "serde", "serde_json", "sha2 0.10.9", @@ -8693,7 +8818,7 @@ dependencies = [ "osakit", "percent-encoding", "reqwest", - "rustls", + "rustls 0.23.46", "semver", "serde", "serde_json", @@ -8935,6 +9060,34 @@ 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", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rustls-native-certs 0.6.3", + "rustls-pemfile", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.24.1", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "time" version = "0.3.55" @@ -9019,13 +9172,23 @@ dependencies = [ "syn 3.0.9", ] +[[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.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ - "rustls", + "rustls 0.23.46", "tokio", ] @@ -9060,6 +9223,7 @@ checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "libc", diff --git a/Cargo.toml b/Cargo.toml index 48139dd5c..2dbda21a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,9 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } rustls-platform-verifier = "0.7" sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] } +tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] } +bb8 = "0.9" +bb8-tiberius = { version = "0.16", default-features = false, features = ["tds73", "tls", "with-tokio", "sql-browser"] } uuid = { version = "1", features = ["v4"] } # testing diff --git a/crates/ide-db/Cargo.toml b/crates/ide-db/Cargo.toml index 4d384b809..b1329a9ce 100644 --- a/crates/ide-db/Cargo.toml +++ b/crates/ide-db/Cargo.toml @@ -16,6 +16,9 @@ tracing.workspace = true parking_lot.workspace = true futures.workspace = true sqlx.workspace = true +tiberius.workspace = true +bb8.workspace = true +bb8-tiberius.workspace = true dirs.workspace = true ide-ssh.workspace = true diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs index 93c8ee6ba..97e554f67 100644 --- a/crates/ide-db/src/conn.rs +++ b/crates/ide-db/src/conn.rs @@ -5,6 +5,9 @@ //! whatever its type. use crate::source::{DataSource, DbDriver, DbSslMode}; +use bb8::ManageConnection; +use bb8::Pool as Bb8Pool; +use futures::StreamExt; use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -44,10 +47,47 @@ pub struct QueryResult { pub execution_ms: f64, } +// bb8-tiberius never marks connections broken; keep an empty slot when discarded. +pub struct MssqlConnectionManager(bb8_tiberius::ConnectionManager); + +pub struct MssqlConnection(Option); + +impl std::ops::Deref for MssqlConnection { + type Target = bb8_tiberius::rt::Client; + fn deref(&self) -> &Self::Target { + self.0.as_ref().expect("live SQL Server connection") + } +} + +impl std::ops::DerefMut for MssqlConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + self.0.as_mut().expect("live SQL Server connection") + } +} + +impl ManageConnection for MssqlConnectionManager { + type Connection = MssqlConnection; + type Error = bb8_tiberius::Error; + + async fn connect(&self) -> Result { + self.0.connect().await.map(|c| MssqlConnection(Some(c))) + } + + async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> { + conn.simple_query("SELECT 1").await?.into_results().await?; + Ok(()) + } + + fn has_broken(&self, conn: &mut Self::Connection) -> bool { + conn.0.is_none() + } +} + pub enum Pool { Sqlite(SqlitePool), Postgres(PgPool), Mysql(MySqlPool), + SqlServer(Bb8Pool), } /// Runs `$body` with `$p` bound to the concrete pool of each driver. @@ -57,6 +97,7 @@ macro_rules! with_pool { Pool::Sqlite($p) => $body, Pool::Postgres($p) => $body, Pool::Mysql($p) => $body, + Pool::SqlServer(_) => unreachable!(), } }; } @@ -79,6 +120,132 @@ pub(crate) fn db_err(e: sqlx::Error) -> IdeError { } } +pub(crate) fn mssql_err(e: impl Into) -> IdeError { + match e.into() { + bb8_tiberius::Error::Tiberius(e) => match &e { + tiberius::error::Error::Server(d) => { + let code = d.code(); + // Wrong login: SQL Server 18456. + let auth = matches!(code, 18452 | 18456 | 18470); + IdeError::new( + if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput }, + format!("[{code}] {}", d.message()), + ) + } + tiberius::error::Error::Io { .. } | tiberius::error::Error::Tls(_) => { + IdeError::new(ErrorKind::Io, e.to_string()) + } + _ => IdeError::new(ErrorKind::Protocol, e.to_string()), + }, + bb8_tiberius::Error::Io(e) => IdeError::new(ErrorKind::Io, e.to_string()), + } +} + +fn mssql_pool_err(e: bb8::RunError) -> IdeError { + match e { + bb8::RunError::User(e) => mssql_err(e), + bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"), + } +} + +const DAYS_1900_01_01: i64 = 693_595; + +/// Formats a proleptic Gregorian date given days since `0001-01-01`. +fn format_date(days: i64) -> String { + let z = days + 306; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if m <= 2 { y + 1 } else { y }; + format!("{year:04}-{m:02}-{d:02}") +} + +fn format_hms(secs: u64, frac: u64, scale: u8) -> String { + let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60); + if scale == 0 || frac == 0 { + format!("{h:02}:{m:02}:{s:02}") + } else { + let frac = format!("{frac:0width$}", width = usize::from(scale)); + format!("{h:02}:{m:02}:{s:02}.{}", frac.trim_end_matches('0')) + } +} + +fn format_time(t: tiberius::time::Time) -> String { + let scale = t.scale().min(7); + let div = 10u64.pow(u32::from(scale)); + format_hms(t.increments() / div, t.increments() % div, scale) +} + +fn format_numeric(n: tiberius::numeric::Numeric) -> String { + let scale = u32::from(n.scale()); + if scale == 0 { + return n.value().to_string(); + } + let sign = if n.value() < 0 { "-" } else { "" }; + let abs = n.value().unsigned_abs(); + let div = 10u128.pow(scale); + format!("{sign}{}.{:0width$}", abs / div, abs % div, width = scale as usize) +} + +fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> Option { + use tiberius::ColumnData as C; + match cell { + C::U8(v) => v.map(|v| v.to_string()), + C::I16(v) => v.map(|v| v.to_string()), + C::I32(v) => v.map(|v| v.to_string()), + C::I64(v) => v.map(|v| v.to_string()), + C::F32(v) => v.map(|v| v.to_string()), + C::F64(v) if matches!(ty, tiberius::ColumnType::Money | tiberius::ColumnType::Money4) => { + // Beyond ~9e11, money precision is limited by the driver's f64 decoding. + v.map(|v| format!("{:.4}", v)) + } + C::F64(v) => v.map(|v| v.to_string()), + C::Bit(v) => v.map(|v| v.to_string()), + C::String(v) => v.as_deref().map(str::to_owned), + C::Guid(v) => v.map(|v| v.to_string()), + C::Binary(v) => v.as_deref().map(hex), + C::Numeric(v) => v.map(format_numeric), + C::Xml(v) => v.as_deref().map(|x| x.as_ref().to_owned()), + C::Date(v) => v.map(|d| format_date(i64::from(d.days()))), + C::Time(v) => v.map(format_time), + C::DateTime(v) => v.map(|dt| { + let ms = (u64::from(dt.seconds_fragments()) * 1000 + 150) / 300; + format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(ms / 1000, ms % 1000, 3)) + }), + C::SmallDateTime(v) => v.map(|dt| { + let secs = u64::from(dt.seconds_fragments()) * 60; + format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(secs, 0, 0)) + }), + C::DateTime2(v) => { + v.map(|dt| format!("{} {}", format_date(i64::from(dt.date().days())), format_time(dt.time()))) + } + C::DateTimeOffset(v) => v.map(|dto| { + let dt = dto.datetime2(); + let t = dt.time(); + let scale = t.scale().min(7); + let div = 10u64.pow(u32::from(scale)); + let utc_secs = i64::from(dt.date().days()) * 86_400 + (t.increments() / div) as i64; + let local_secs = utc_secs + i64::from(dto.offset()) * 60; + let days = local_secs.div_euclid(86_400); + let secs = local_secs.rem_euclid(86_400) as u64; + let sign = if dto.offset() < 0 { '-' } else { '+' }; + let off = dto.offset().unsigned_abs(); + format!( + "{} {}{sign}{:02}:{:02}", + format_date(days), + format_hms(secs, t.increments() % div, scale), + off / 60, + off % 60 + ) + }), + } +} + fn hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(2 + bytes.len() * 2); s.push_str("0x"); @@ -197,6 +364,37 @@ macro_rules! fetch { /// Columns, rows, rows affected, more rows beyond the limit, time of the first answer. type Fetched = (Vec, Vec>>, Option, bool, Option); +async fn mssql_command(conn: &mut bb8_tiberius::rt::Client, sql: &str) -> IdeResult<()> { + // Transaction control must be a batch: sp_executesql rejects a changed transaction count. + conn.simple_query(sql).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?; + Ok(()) +} + +async fn mssql_fetch(conn: &mut bb8_tiberius::rt::Client, sql: &str, max: usize) -> IdeResult { + let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?; + let first = Some(Instant::now()); + let columns = stream + .columns() + .await + .map_err(mssql_err)? + .unwrap_or_default() + .iter() + .map(|col| ResultColumn { name: col.name().to_owned(), type_name: format!("{:?}", col.column_type()) }) + .collect(); + let mut rows = Vec::new(); + let mut has_more = false; + while let Some(item) = stream.next().await { + if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? { + if rows.len() >= max { + has_more = true; + break; + } + rows.push(row.cells().map(|(col, cell)| tiberius_cell(cell, col.column_type())).collect()); + } + } + Ok((columns, rows, None, has_more, first)) +} + /// The server-side id of a connection (to cancel its statement from another one). macro_rules! backend_id { ($conn:expr, $sql:expr) => {{ @@ -313,6 +511,7 @@ impl Pool { Pool::Sqlite(_) => DbDriver::Sqlite, Pool::Postgres(_) => DbDriver::Postgres, Pool::Mysql(_) => DbDriver::Mysql, + Pool::SqlServer(_) => DbDriver::SqlServer, } } @@ -453,20 +652,60 @@ impl Pool { .map_err(db_err)?; Ok(Pool::Mysql(pool)) } + DbDriver::SqlServer => { + use tiberius::Config; + let mut config = Config::new(); + config.host(&source.host); + config.port(source.port.unwrap_or(1433)); + if !source.user.is_empty() { + config.authentication(tiberius::AuthMethod::sql_server( + source.user.clone(), + password.unwrap_or_default().to_owned(), + )); + } + if !source.database.is_empty() { + config.database(&source.database); + } + if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) { + config.trust_cert(); + } + let manager = MssqlConnectionManager(bb8_tiberius::ConnectionManager::new(config)); + let pool = bb8::Pool::builder() + .max_size(MAX) + .connection_timeout(timeout) + .build(manager) + .await + .map_err(mssql_err)?; + let mut conn = pool.get_owned().await.map_err(mssql_pool_err)?; + conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?; + Ok(Pool::SqlServer(pool)) + } } } pub async fn close(&self) { - with_pool!(self, p => p.close().await) + match self { + Pool::SqlServer(_) => {} + _ => with_pool!(self, p => p.close().await), + } } pub fn is_closed(&self) -> bool { - with_pool!(self, p => p.is_closed()) + match self { + Pool::SqlServer(_) => false, + _ => with_pool!(self, p => p.is_closed()), + } } /// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]). pub async fn rows(&self, sql: &str, max: usize) -> IdeResult>>> { - let fetched: IdeResult = with_pool!(self, p => fetch!(p, sql, max, Cells::Text)); + let fetched: IdeResult = match self { + Pool::SqlServer(p) => { + let mut conn = p.get_owned().await.map_err(mssql_pool_err)?; + mssql_fetch(&mut conn, sql, max).await + } + _ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)), + }; Ok(fetched?.1) } @@ -485,21 +724,47 @@ impl Pool { /// Runs `statements` in one transaction; each must change `expect_one` /// row when flagged (table editor submits). pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult { - with_pool!(self, p => { - let mut tx = p.begin().await.map_err(db_err)?; - let mut total = 0; - for (sql, expect_one) in statements { - let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?; - let n = done.rows_affected(); - // MySQL reports 0 for an UPDATE that leaves the row unchanged. - if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) { - return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved"))); + match self { + Pool::SqlServer(p) => { + let mut conn = p.get_owned().await.map_err(mssql_pool_err)?; + let result = async { + mssql_command(&mut conn, "BEGIN TRAN").await?; + let mut total = 0; + for (sql, expect_one) in statements { + let n = conn.execute(sql, &[]).await.map_err(mssql_err)?.total(); + if *expect_one && n != 1 { + return Err(IdeError::invalid(format!( + "{sql}: expected to change 1 row, changed {n}; nothing was saved" + ))); + } + total += n; + } + mssql_command(&mut conn, "COMMIT TRAN").await?; + Ok(total) } - total += n; + .await; + if result.is_err() && mssql_command(&mut conn, "IF @@TRANCOUNT > 0 ROLLBACK TRAN").await.is_err() { + // A failed rollback must never return an open transaction to the pool. + conn.0.take(); + } + result } - tx.commit().await.map_err(db_err)?; - Ok(total) - }) + _ => with_pool!(self, p => { + let mut tx = p.begin().await.map_err(db_err)?; + let mut total = 0; + for (sql, expect_one) in statements { + let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?; + let n = done.rows_affected(); + // MySQL reports 0 for an UPDATE that leaves the row unchanged. + if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) { + return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved"))); + } + total += n; + } + tx.commit().await.map_err(db_err)?; + Ok(total) + }), + } } } @@ -509,6 +774,7 @@ pub enum Held { Sqlite(PoolConnection), Postgres(PoolConnection), Mysql(PoolConnection), + SqlServer(Box>), } impl Held { @@ -517,18 +783,25 @@ impl Held { Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?), Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?), Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?), + Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)), }) } /// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`). pub async fn command(&mut self, sql: &str) -> IdeResult<()> { - let sql = sqlx::AssertSqlSafe(sql.to_owned()); match self { - Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), - Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), - Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), + Held::SqlServer(c) => mssql_command(c, sql).await, + other => { + let sql = sqlx::AssertSqlSafe(sql.to_owned()); + match other { + Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), + Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), + Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), + Held::SqlServer(_) => unreachable!(), + } + .map_err(db_err) + } } - .map_err(db_err) } /// Closes the connection instead of returning it to the pool (its statement was @@ -542,6 +815,9 @@ impl Held { Held::Mysql(c) => { let _ = c.detach().close().await; } + Held::SqlServer(mut c) => { + c.0.take(); + } } } @@ -589,6 +865,13 @@ impl Held { run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})")) } Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")), + Held::SqlServer(c) => { + // Tiberius has no attention/cancel API; stop reading and discard the connection. + tokio::select! { + r = mssql_fetch(c, sql, max_rows) => r?, + () = cancel.cancelled() => return Err(IdeError::cancelled()), + } + } }; let (columns, rows, affected, has_more, first) = fetched; let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; @@ -608,6 +891,7 @@ impl Held { pub fn begin_statement(driver: DbDriver) -> &'static str { match driver { DbDriver::Mysql => "START TRANSACTION", + DbDriver::SqlServer => "BEGIN TRAN", DbDriver::Sqlite | DbDriver::Postgres => "BEGIN", } } @@ -624,6 +908,78 @@ mod tests { use super::*; use crate::source::{DbOptions, DbProperty}; + #[test] + fn sqlserver_cells_preserve_values_and_nulls() { + use std::borrow::Cow; + use tiberius::time::{Date, DateTime, DateTime2, DateTimeOffset, SmallDateTime, Time}; + use tiberius::{ColumnData as C, ColumnType as T, Uuid, numeric::Numeric}; + let dt = DateTime2::new(Date::new(693_595), Time::new(1234567, 7)); + let cases = [ + (C::U8(Some(255)), "255"), + (C::I16(Some(-123)), "-123"), + (C::I32(Some(-456)), "-456"), + (C::I64(Some(i64::MAX)), "9223372036854775807"), + (C::F32(Some(1.25)), "1.25"), + (C::F64(Some(1.25)), "1.25"), + (C::Bit(Some(true)), "true"), + (C::String(Some(Cow::Borrowed("hello"))), "hello"), + (C::Binary(Some(Cow::Borrowed(&[0, 0xAB, 0xFF]))), "0x00ABFF"), + ( + C::Guid(Some(Uuid::parse_str("ABCDEF01-2345-6789-ABCD-EF0123456789").unwrap())), + "abcdef01-2345-6789-abcd-ef0123456789", + ), + (C::Numeric(Some(Numeric::new_with_scale(-123, 5))), "-0.00123"), + (C::Numeric(Some(Numeric::new_with_scale(12300, 4))), "1.2300"), + ( + C::Numeric(Some(Numeric::new_with_scale(10i128.pow(38) - 1, 0))), + "99999999999999999999999999999999999999", + ), + (C::Xml(Some(Cow::Owned(tiberius::xml::XmlData::new("")))), ""), + (C::Date(Some(Date::new(0))), "0001-01-01"), + (C::Date(Some(Date::new(3_652_058))), "9999-12-31"), + (C::Time(Some(Time::new(452961234567, 7))), "12:34:56.1234567"), + (C::DateTime(Some(DateTime::new(-1, 1))), "1899-12-31 00:00:00.003"), + (C::DateTime(Some(DateTime::new(0, 2))), "1900-01-01 00:00:00.007"), + (C::SmallDateTime(Some(SmallDateTime::new(0, 754))), "1900-01-01 12:34:00"), + (C::DateTime2(Some(dt)), "1900-01-01 00:00:00.1234567"), + (C::DateTimeOffset(Some(DateTimeOffset::new(dt, -60))), "1899-12-31 23:00:00.1234567-01:00"), + (C::DateTimeOffset(Some(DateTimeOffset::new(dt, 330))), "1900-01-01 05:30:00.1234567+05:30"), + ]; + for (cell, expected) in cases { + assert_eq!(tiberius_cell(&cell, T::Null).as_deref(), Some(expected), "{cell:?}"); + } + for cell in [ + C::U8(None), + C::I16(None), + C::I32(None), + C::I64(None), + C::F32(None), + C::F64(None), + C::Bit(None), + C::String(None), + C::Guid(None), + C::Binary(None), + C::Numeric(None), + C::Xml(None), + C::Date(None), + C::Time(None), + C::DateTime(None), + C::SmallDateTime(None), + C::DateTime2(None), + C::DateTimeOffset(None), + ] { + assert_eq!(tiberius_cell(&cell, T::Null), None); + } + for ty in [T::Money, T::Money4] { + for (v, expected) in + [(0.0, "0.0000"), (1.2, "1.2000"), (-123.4567, "-123.4567"), (214748.3647, "214748.3647")] + { + assert_eq!(tiberius_cell(&C::F64(Some(v)), ty).as_deref(), Some(expected)); + } + assert_eq!(tiberius_cell(&C::F64(None), ty), None); + } + } + fn prop(name: &str, value: &str) -> DbProperty { DbProperty { name: name.into(), value: value.into() } } diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index 806499fd5..5f7034c5e 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -549,6 +549,7 @@ impl DbManager { DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()", DbDriver::Postgres => "SELECT version()", DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())", + DbDriver::SqlServer => "SELECT @@VERSION", }; let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?); pool.close().await; @@ -821,6 +822,13 @@ impl DbManager { let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next(); ddl.map(|d| format!("{d};\n")).ok_or_else(missing) } + DbDriver::SqlServer => { + let obj = format!("{}.{}", schema, name).replace('\'', "''"); + let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0) + .into_iter() + .next(); + ddl.map(|d| format!("{d};\n")).ok_or_else(missing) + } DbDriver::Postgres => { let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next(); let (oid, kind) = match rel.as_deref() { diff --git a/crates/ide-db/src/plan.rs b/crates/ide-db/src/plan.rs index 6f0022d69..af3789137 100644 --- a/crates/ide-db/src/plan.rs +++ b/crates/ide-db/src/plan.rs @@ -36,6 +36,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String { DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"), DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"), DbDriver::Mysql => format!("EXPLAIN {s}"), + DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"), } } @@ -59,6 +60,7 @@ pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult> let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); Ok(mysql(&names, &result.rows)) } + DbDriver::SqlServer => Ok(Vec::new()), } } @@ -197,6 +199,7 @@ mod tests { assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1"); assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1"); assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1"); + assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1"); } #[test] diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index c580c596c..a3590ae6e 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -16,6 +16,8 @@ pub enum DbDriver { Postgres, /// MySQL and MariaDB. Mysql, + /// Microsoft SQL Server. + SqlServer, } impl DbDriver { @@ -24,6 +26,7 @@ impl DbDriver { DbDriver::Sqlite => None, DbDriver::Postgres => Some(5432), DbDriver::Mysql => Some(3306), + DbDriver::SqlServer => Some(1433), } } } @@ -248,6 +251,7 @@ pub fn scheme_driver(scheme: &str) -> Option { "postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb" | "timescaledb" | "greenplum" => Some(DbDriver::Postgres), "mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql), + "sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer), "sqlite" | "sqlite3" => Some(DbDriver::Sqlite), _ => None, } @@ -472,6 +476,14 @@ mod tests { assert_eq!(load(tmp.path()).unwrap(), vec![s]); } + #[test] + fn sqlserver_defaults() { + assert_eq!(DbDriver::SqlServer.default_port(), Some(1433)); + assert_eq!(scheme_driver("sqlserver"), Some(DbDriver::SqlServer)); + assert_eq!(scheme_driver("mssql"), Some(DbDriver::SqlServer)); + assert_eq!(scheme_driver("mssqlserver"), Some(DbDriver::SqlServer)); + } + #[test] fn parses_urls() { let u = parse_url("jdbc:postgresql://ann:s%40cret@db.local:5433/shop?sslmode=require&search_path=app").unwrap(); diff --git a/crates/ide-db/src/sql.rs b/crates/ide-db/src/sql.rs index 0f57823ae..79d6c1ec5 100644 --- a/crates/ide-db/src/sql.rs +++ b/crates/ide-db/src/sql.rs @@ -8,6 +8,7 @@ use specta::Type; pub fn quote_ident(driver: DbDriver, name: &str) -> String { match driver { DbDriver::Mysql => format!("`{}`", name.replace('`', "``")), + DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")), _ => format!("\"{}\"", name.replace('"', "\"\"")), } } @@ -52,6 +53,7 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str { WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1" } DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1", + DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1", DbDriver::Postgres => { "SELECT schema_name FROM information_schema.schemata \ WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \ @@ -61,6 +63,10 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str { "SELECT schema_name FROM information_schema.schemata \ WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY 1" } + DbDriver::SqlServer => { + "SELECT schema_name FROM information_schema.schemata \ + WHERE schema_name NOT IN ('sys', 'information_schema', 'guest') ORDER BY 1" + } } } @@ -83,6 +89,11 @@ pub fn tables(driver: DbDriver, schema: &str) -> String { FROM information_schema.tables WHERE table_schema = {} ORDER BY 1", quote_literal(driver, schema) ), + DbDriver::SqlServer => format!( + "SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \ + FROM information_schema.tables WHERE table_schema = {} ORDER BY 1", + quote_literal(driver, schema) + ), } } @@ -110,6 +121,16 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String { FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} \ ORDER BY ordinal_position" ), + DbDriver::SqlServer => format!( + "SELECT column_name, data_type, is_nullable, column_default, \ + CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \ + AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \ + WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = {s} \ + AND tc.table_name = {t} AND k.column_name = c.column_name) THEN 1 ELSE 0 END \ + FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \ + ORDER BY c.ordinal_position" + ), } } @@ -134,6 +155,13 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String { FROM information_schema.statistics WHERE table_schema = {s} AND table_name = {t} \ ORDER BY index_name, seq_in_index" ), + DbDriver::SqlServer => format!( + "SELECT i.name, CASE WHEN i.is_unique = 1 THEN 1 ELSE 0 END, c.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 \ + WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \ + ORDER BY i.name, ic.key_ordinal" + ), } } @@ -171,6 +199,20 @@ pub fn completion_columns(driver: DbDriver) -> &'static str { WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \ ORDER BY c.table_schema, c.table_name, c.ordinal_position LIMIT 50000" } + DbDriver::SqlServer => { + "SELECT c.table_schema, c.table_name, CASE WHEN t.table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END, \ + c.column_name, c.data_type, c.is_nullable, \ + CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \ + AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \ + WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = c.table_schema \ + AND tc.table_name = c.table_name AND k.column_name = c.column_name) THEN 1 ELSE 0 END, \ + CASE WHEN c.column_default IS NOT NULL THEN 1 ELSE 0 END \ + FROM information_schema.columns c JOIN information_schema.tables t \ + ON t.table_schema = c.table_schema AND t.table_name = c.table_name \ + WHERE c.table_schema NOT IN ('sys', 'information_schema') \ + ORDER BY c.table_schema, c.table_name, c.ordinal_position" + } } } @@ -202,6 +244,13 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str { AND table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \ ORDER BY table_schema, table_name, constraint_name, ordinal_position LIMIT 20000" } + DbDriver::SqlServer => { + "SELECT table_schema, table_name, constraint_name, column_name, \ + referenced_table_schema, referenced_table_name, referenced_column_name \ + FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL \ + AND table_schema NOT IN ('sys', 'information_schema') \ + ORDER BY table_schema, table_name, constraint_name, ordinal_position" + } } } @@ -218,6 +267,9 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String DbDriver::Mysql => format!( "SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position" ), + DbDriver::SqlServer => format!( + "SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position" + ), } } @@ -227,6 +279,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> { DbDriver::Sqlite => None, DbDriver::Postgres => Some("SELECT current_schema()"), DbDriver::Mysql => Some("SELECT DATABASE()"), + DbDriver::SqlServer => Some("SELECT DB_NAME()"), } } @@ -250,8 +303,14 @@ pub fn table_page( ) -> String { let mut sql = format!("SELECT * FROM {}", qualified(driver, schema, table)); if let Some(f) = filter.map(str::trim).filter(|f| !f.is_empty()) { - let f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f); - sql.push_str(&format!(" WHERE {f}")); + let mut f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f); + if let Some(i) = f.find(';') { + f = &f[..i]; + } + f = f.trim(); + if !f.is_empty() { + sql.push_str(&format!(" WHERE {f}")); + } } if !sort.is_empty() { let keys: Vec = sort @@ -260,7 +319,14 @@ pub fn table_page( .collect(); sql.push_str(&format!(" ORDER BY {}", keys.join(", "))); } - sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1)); + if driver == DbDriver::SqlServer { + if sort.is_empty() { + sql.push_str(" ORDER BY (SELECT 1)"); + } + sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1)); + } else { + sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1)); + } sql } @@ -400,6 +466,10 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String { "SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \ WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1" ), + DbDriver::SqlServer => format!( + "SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \ + WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1" + ), } } @@ -438,6 +508,10 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option { AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \ FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1" )), + DbDriver::SqlServer => Some(format!( + "SELECT r.routine_name, lower(r.routine_type), '', r.data_type \ + FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1" + )), } } @@ -559,6 +633,10 @@ mod tests { "SELECT * FROM \"public\".\"users\" WHERE age > 3 ORDER BY \"id\" DESC LIMIT 51 OFFSET 100" ); assert_eq!(table_page(DbDriver::Mysql, "", "t", Some(" "), &[], 0, 10), "SELECT * FROM `t` LIMIT 11 OFFSET 0"); + assert_eq!( + table_page(DbDriver::SqlServer, "dbo", "users", Some("id = 1;"), &[], 0, 500), + "SELECT * FROM [dbo].[users] WHERE id = 1 ORDER BY (SELECT 1) OFFSET 0 ROWS FETCH NEXT 501 ROWS ONLY" + ); } #[test] @@ -586,6 +664,30 @@ mod tests { assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err()); } + #[test] + fn sqlserver_quoting_and_schema() { + assert_eq!(quote_ident(DbDriver::SqlServer, "a]b"), "[a]]b]"); + assert_eq!(qualified(DbDriver::SqlServer, "dbo", "t"), "[dbo].[t]"); + assert_eq!(qualified(DbDriver::SqlServer, "", "t"), "[t]"); + assert_eq!(current_schema(DbDriver::SqlServer), Some("SELECT DB_NAME()")); + assert_eq!(crate::conn::begin_statement(DbDriver::SqlServer), "BEGIN TRAN"); + assert_eq!( + schemas(DbDriver::SqlServer, true), + "SELECT schema_name FROM information_schema.schemata ORDER BY 1" + ); + assert!(schemas(DbDriver::SqlServer, false).contains("NOT IN ('sys', 'information_schema', 'guest')")); + } + + #[test] + fn sqlserver_introspection_queries() { + assert!(tables(DbDriver::SqlServer, "dbo").contains("information_schema.tables")); + assert!(columns(DbDriver::SqlServer, "dbo", "t").contains("information_schema.columns")); + assert!(indexes(DbDriver::SqlServer, "dbo", "t").contains("sys.indexes")); + assert!(foreign_keys(DbDriver::SqlServer).contains("referenced_table_name IS NOT NULL")); + assert!(table_foreign_keys(DbDriver::SqlServer, "dbo", "t").contains("table_schema = 'dbo'")); + assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns")); + } + #[test] fn sqlite_trigger_events() { let ev = sqlite_trigger_event; diff --git a/crates/ide-db/tests/sqlserver.rs b/crates/ide-db/tests/sqlserver.rs new file mode 100644 index 000000000..a02b9ce35 --- /dev/null +++ b/crates/ide-db/tests/sqlserver.rs @@ -0,0 +1,97 @@ +//! Live SQL Server checks, skipped unless OPENIDEA_TEST_SQLSERVER=host:port:user:password +//! names a throwaway server. + +use ide_core::CancellationToken; +use ide_db::{DataSource, DbDriver, DbSslMode, Held, Pool}; +use std::time::Duration; + +#[tokio::test] +async fn sqlserver_values_paging_transactions_and_cancellation() { + let Ok(server) = std::env::var("OPENIDEA_TEST_SQLSERVER") else { return }; + let mut parts = server.splitn(4, ':'); + let source = DataSource { + driver: DbDriver::SqlServer, + host: parts.next().unwrap().into(), + port: Some(parts.next().unwrap().parse().unwrap()), + user: parts.next().unwrap().into(), + database: "tempdb".into(), + ssl: DbSslMode::Disable, + ..Default::default() + }; + let pool = Pool::connect(&source, parts.next(), None).await.unwrap(); + let cancel = CancellationToken::new(); + let mut held = Held::acquire(&pool).await.unwrap(); + let table = format!("tempdb.dbo.oi_pr276_values_{}", std::process::id()); + held.command(&format!("CREATE TABLE {table} (id int PRIMARY KEY, d decimal(38, 7), dt datetime2(7), guid uniqueidentifier, m money, tiny tinyint, x xml)")).await.unwrap(); + held.command(&format!("INSERT INTO {table} VALUES (1, 12345678901234567890.1234567, '2024-02-29T12:34:56.1234567', 'abcdef01-2345-6789-abcd-ef0123456789', 12.3400, 255, ''), (2, NULL, NULL, NULL, NULL, NULL, NULL)")).await.unwrap(); + let r = held.execute(&pool, &format!("SELECT * FROM {table} ORDER BY id"), 1, &cancel).await.unwrap(); + assert!(r.has_more); + assert_eq!( + r.rows[0], + [ + "1", + "12345678901234567890.1234567", + "2024-02-29 12:34:56.1234567", + "abcdef01-2345-6789-abcd-ef0123456789", + "12.3400", + "255", + "" + ] + .map(|s| Some(s.to_owned())) + ); + let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 2"), 5, &cancel).await.unwrap(); + assert!(!r.has_more); + assert!(r.rows[0][1..].iter().all(Option::is_none)); + let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 3"), 5, &cancel).await.unwrap(); + assert_eq!(r.columns.len(), 7); + assert!(r.rows.is_empty()); + let r = held.execute(&pool, + "SELECT CAST('2024-02-29T00:15:00.1234567+05:30' AS datetimeoffset(7)), CAST('2024-02-29' AS date), CAST('12:34:56.1234567' AS time(7)), CAST('2024-02-29T12:34:56.007' AS datetime), CAST('2024-02-29T12:34:00' AS smalldatetime), CAST(-12.3400 AS smallmoney)", + 1, &cancel).await.unwrap(); + assert_eq!( + r.rows[0], + [ + "2024-02-29 00:15:00.1234567+05:30", + "2024-02-29", + "12:34:56.1234567", + "2024-02-29 12:34:56.007", + "2024-02-29 12:34:00", + "-12.3400" + ] + .map(|s| Some(s.to_owned())) + ); + held.command(&format!("DROP TABLE {table}")).await.unwrap(); + drop(held); + + // A unique permanent table lets transaction calls acquire any pooled connection. + let table = format!("tempdb.dbo.oi_pr276_{}", std::process::id()); + pool.execute(&format!("CREATE TABLE {table} (id int PRIMARY KEY, v int)"), 1, &cancel).await.unwrap(); + pool.transaction(&[(format!("INSERT INTO {table} VALUES (1, 10)"), true)]).await.unwrap(); + for failing in [format!("INSERT INTO {table} VALUES (1, 20)"), format!("UPDATE {table} SET v = 30 WHERE id = 999")] + { + assert!( + pool.transaction(&[(format!("UPDATE {table} SET v = 99 WHERE id = 1"), true), (failing, true)]) + .await + .is_err() + ); + assert_eq!(pool.rows(&format!("SELECT v FROM {table}"), 1).await.unwrap()[0][0].as_deref(), Some("10")); + assert_eq!(pool.rows("SELECT @@TRANCOUNT", 1).await.unwrap()[0][0].as_deref(), Some("0")); + } + assert_eq!(pool.transaction(&[(format!("UPDATE {table} SET v = 11 WHERE id = 1"), true)]).await.unwrap(), 1); + pool.execute(&format!("DROP TABLE {table}"), 1, &cancel).await.unwrap(); + + let cancel = CancellationToken::new(); + let stop = cancel.clone(); + let task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + stop.cancel(); + }); + assert!( + tokio::time::timeout(Duration::from_secs(2), pool.execute("WAITFOR DELAY '00:00:10'; SELECT 1", 1, &cancel)) + .await + .unwrap() + .is_err() + ); + task.await.unwrap(); + assert_eq!(pool.rows("SELECT 1", 1).await.unwrap()[0][0].as_deref(), Some("1")); +} diff --git a/src/bindings.ts b/src/bindings.ts index f6eeaa3d1..60c7596f2 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2039,7 +2039,9 @@ export type DbDatabases = { export type DbDriver = "sqlite" | "postgres" | /** MySQL and MariaDB. */ -"mysql"; +"mysql" | +/** Microsoft SQL Server. */ +"sqlServer"; /** `schema.table (columns) REFERENCES ref_schema.ref_table (ref_columns)`. */ export type DbForeignKey = { diff --git a/src/features/database/ObjectDialogs.tsx b/src/features/database/ObjectDialogs.tsx index 30121bac2..08fb56d4d 100644 --- a/src/features/database/ObjectDialogs.tsx +++ b/src/features/database/ObjectDialogs.tsx @@ -250,6 +250,25 @@ const TYPES: Record = { "json", "blob", ], + sqlServer: [ + "int", + "bigint", + "smallint", + "tinyint", + "decimal(10,2)", + "float", + "real", + "varchar(255)", + "nvarchar(255)", + "text", + "ntext", + "date", + "datetime", + "datetime2", + "uniqueidentifier", + "bit", + "varbinary(max)", + ], }; interface ModifyProps { diff --git a/src/features/database/SourceDialog.tsx b/src/features/database/SourceDialog.tsx index 0e77b840e..8bc85a994 100644 --- a/src/features/database/SourceDialog.tsx +++ b/src/features/database/SourceDialog.tsx @@ -93,6 +93,10 @@ const PROPERTIES: Record = { names: ["foreign_keys", "busy_timeout", "cache_size", "journal_mode", "synchronous"], hint: "Applied as pragmas when opening the file (PRAGMA name = value).", }, + sqlServer: { + names: ["connect_timeout"], + hint: "Connection settings for SQL Server.", + }, }; const blankSsh = (): DbSsh => ({ diff --git a/src/features/database/database.test.ts b/src/features/database/database.test.ts index 8310f593a..72c4cb60a 100644 --- a/src/features/database/database.test.ts +++ b/src/features/database/database.test.ts @@ -184,7 +184,7 @@ describe("data source types", () => { }); expect(vendorPreset(vendorById("postgres")!)).toEqual({ driver: "postgres", vendor: "", port: 5432 }); expect(vendorPreset(vendorById("supabase")!).ssl).toBe("require"); - expect(VENDORS.every((v) => ["postgres", "mysql", "sqlite"].includes(v.driver))).toBe(true); + expect(VENDORS.every((v) => ["postgres", "mysql", "sqlite", "sqlServer"].includes(v.driver))).toBe(true); const labels = VENDORS.map((v) => v.label); expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b))); }); diff --git a/src/features/database/objects.ts b/src/features/database/objects.ts index 3ed304383..6b0b98206 100644 --- a/src/features/database/objects.ts +++ b/src/features/database/objects.ts @@ -6,7 +6,9 @@ import type { DbColumnInfo, DbDriver } from "@/platform/ipc"; export function quoteIdent(driver: DbDriver, name: string): string { - return driver === "mysql" ? `\`${name.replace(/`/g, "``")}\`` : `"${name.replace(/"/g, '""')}"`; + if (driver === "mysql") return `\`${name.replace(/`/g, "``")}\``; + if (driver === "sqlServer") return `[${name.replace(/]/g, "]]")}]`; + return `"${name.replace(/"/g, '""')}"`; } /** Quotes only when needed: lower-case (PostgreSQL) / plain words stay bare, like IDEA's generated SQL. */ @@ -132,7 +134,7 @@ export function truncateSql( ): string { const q = qualifiedName(driver, schema, table); if (driver === "sqlite") return `delete from ${q};`; - if (driver === "mysql") return `truncate table ${q};`; + if (driver === "mysql" || driver === "sqlServer") return `truncate table ${q};`; return `truncate table ${q}${opts.restartIdentity ? " restart identity" : ""}${opts.cascade ? " cascade" : ""};`; } diff --git a/src/features/database/sql/dialect.ts b/src/features/database/sql/dialect.ts index bf02cbf53..e66afaaf0 100644 --- a/src/features/database/sql/dialect.ts +++ b/src/features/database/sql/dialect.ts @@ -154,6 +154,29 @@ export const DATA_TYPES: Record = { "BOOLEAN", "ENUM", ], + sqlServer: [ + "INT", + "BIGINT", + "SMALLINT", + "TINYINT", + "VARCHAR", + "NVARCHAR", + "CHAR", + "NCHAR", + "TEXT", + "NTEXT", + "DECIMAL", + "FLOAT", + "REAL", + "DATE", + "DATETIME", + "DATETIME2", + "TIME", + "UNIQUEIDENTIFIER", + "BIT", + "VARBINARY", + "XML", + ], }; const f = (name: string, params: string, returns?: string, aggregate?: boolean): SqlFunction => ({ @@ -260,6 +283,20 @@ const FUNCTIONS: Record = { f("row_number", "", "bigint"), f("rank", "", "bigint"), ], + sqlServer: [ + ...COMMON, + f("getdate", "", "datetime"), + f("getutcdate", "", "datetime"), + f("dateadd", "part, n, date", "datetime"), + f("datediff", "part, a, b", "int"), + f("isnull", "a, b"), + f("iif", "cond, then, else"), + f("concat", "value, ...", "text"), + f("format", "value, format", "text"), + f("newid", "", "uniqueidentifier"), + f("row_number", "", "bigint"), + f("rank", "", "bigint"), + ], }; export function functions(driver: DbDriver): SqlFunction[] { diff --git a/src/features/database/vendors.ts b/src/features/database/vendors.ts index a1e769d78..a5cd56afd 100644 --- a/src/features/database/vendors.ts +++ b/src/features/database/vendors.ts @@ -38,6 +38,14 @@ export const VENDORS: readonly DbVendor[] = [ }, { id: "greenplum", label: "Greenplum", driver: "postgres", port: 5432, icon: "db-greenplum", scheme: "postgresql" }, { id: "mariadb", label: "MariaDB", driver: "mysql", port: 3306, icon: "db-mariadb", scheme: "mariadb" }, + { + id: "sqlServer", + label: "Microsoft SQL Server", + driver: "sqlServer", + port: 1433, + icon: "db-sqlserver", + scheme: "sqlserver", + }, { id: "mysql", label: "MySQL", driver: "mysql", port: 3306, icon: "db-mysql", scheme: "mysql" }, { id: "neon", label: "Neon", driver: "postgres", port: 5432, icon: "db-neon", scheme: "postgresql", ssl: "require" }, { @@ -113,6 +121,8 @@ const SCHEMES: Record = { singlestore: "singlestore", memsql: "singlestore", oceanbase: "oceanbase", + sqlserver: "sqlServer", + mssql: "sqlServer", }; export const vendorOfScheme = (scheme: string): DbVendor | undefined => BY_ID.get(SCHEMES[scheme.toLowerCase()] ?? ""); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b0c813da4..3f89ea982 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4737,6 +4737,8 @@ "Interpreter Settings…": "解释器设置…", "Cannot select Python interpreter": "无法选择 Python 解释器", "Python Interpreter: {label}": "Python 解释器:{label}", + "Connection settings for SQL Server.": "SQL Server 的连接设置。", + "Microsoft SQL Server": "微软 SQL Server", "Add to .gitignore": "添加到 .gitignore", "Add to .git/info/exclude": "添加到 .git/info/exclude", "Delete Agent Tasks": "删除 Agent 任务", diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 1723f2ba1..44914015b 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -280,6 +280,7 @@ const ICONS = { "db-sqlite": "openidea/database/sqlite", "db-postgres": "openidea/database/postgres", "db-mysql": "openidea/database/mysql", + "db-sqlserver": "openidea/database/sqlserver", "db-mariadb": "openidea/database/mariadb", "db-aurora": "openidea/database/aurora", "db-redshift": "openidea/database/redshift", diff --git a/src/ui/openidea/database/sqlserver.svg b/src/ui/openidea/database/sqlserver.svg new file mode 100644 index 000000000..d5d7f8ea5 --- /dev/null +++ b/src/ui/openidea/database/sqlserver.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ui/openidea/database/sqlserver_dark.svg b/src/ui/openidea/database/sqlserver_dark.svg new file mode 100644 index 000000000..27235f61b --- /dev/null +++ b/src/ui/openidea/database/sqlserver_dark.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file