From f2afb7bb17559962e18e1f4d42ba9dfd6f91c706 Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:00:56 +0200 Subject: [PATCH 1/6] feat: replace tiberius with mssql-tiberius-bridge (mssql-tds) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the TDS client to Microsoft's mssql-tds protocol implementation via its tiberius-compatible bridge API, as adopted in Tabularis' feat/sql-server branch. The SQL surface (introspection, DDL, routines, triggers, extraction, showplan parsing) is unchanged. Client differences handled in the port: - Connection: Client::connect(&Config) manages its own transport — no manual TcpStream / tokio-util compat layer. - Result collection goes through the inner_mut() TdsClient escape hatch, reading result-set metadata directly: the bridge derives columns from rows, which would lose the headers of zero-row result sets. - Affected rows: the bridge's execute().total() counts rows returned, not rows affected, so every DML batch now captures @@ROWCOUNT itself and selects it as a trailing single-cell result set — including the IDENTITY_INSERT insert path, where the count is captured into a variable before SET IDENTITY_INSERT resets it. - The IDENTITY_INSERT batch no longer opens an explicit transaction: the TDS client rejects BEGIN TRAN / COMMIT inside an sp_executesql RPC batch (error 3981), and a single INSERT is atomic without one. The TRY/CATCH recovery that clears the session-scoped setting stays. - ColumnType is normalized by the bridge (Intn/Floatn/Bitn and the BigVarChar-style names collapse into width-resolved variants; Json and Vector are new); the extraction dispatcher is remapped accordingly and decimals now decode through rust_decimal only. - The TDS client's async call chains produce large futures that overflow tokio's default 2 MiB worker stack in debug builds: workers now get a 16 MiB stack and the per-request dispatch future is boxed. Validated against a live SQL Server (Azure SQL Edge) over TLS: DDL, CRUD with identity and composite keys, multi-statement result sets, empty-result column headers, pagination, DML OUTPUT, error recovery, and SHOWPLAN_XML parsing. --- CHANGELOG.md | 2 +- CLAUDE.md | 4 +- Cargo.lock | 849 ++++++++++++++++++++++++------ Cargo.toml | 11 +- README.md | 4 +- src/driver/explain.rs | 24 +- src/driver/extract/mod.rs | 57 +- src/driver/extract/temporal.rs | 2 +- src/driver/helpers.rs | 65 ++- src/driver/helpers/tests.rs | 20 +- src/driver/introspection.rs | 66 +-- src/driver/introspection/tests.rs | 2 +- src/driver/mod.rs | 153 +++--- src/driver/ops.rs | 47 +- src/driver/pool.rs | 53 +- src/driver/triggers/mod.rs | 4 +- src/main.rs | 21 +- 17 files changed, 943 insertions(+), 441 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a6556..a9bace3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial SQL Server driver: `tiberius` + `deadpool` pooling, TLS modes, session reset, startup scripts +- Initial SQL Server driver: `mssql-tiberius-bridge` + `deadpool` pooling, TLS modes, session reset, startup scripts - Schema, table, column, PK/FK, index, view, routine, and trigger introspection - Query execution with pagination, CTE/DML classification, multiple result sets, and accurate affected rows (incl. DML `OUTPUT`) - INSERT/UPDATE/DELETE with composite primary keys and safe `IDENTITY_INSERT` recovery diff --git a/CLAUDE.md b/CLAUDE.md index 8f6a147..8862233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,9 +39,9 @@ src/ handlers/ # thin JSON adapters, one module per RPC area driver/ # SQL Server logic ops.rs # one free function per host RPC method - pool.rs # tiberius Config + deadpool Manager (TLS modes, startup scripts) + pool.rs # mssql-tiberius-bridge Config + deadpool Manager (TLS modes, startup scripts) introspection.rs, helpers.rs, ddl/, routines/, triggers/, types.rs, version.rs - extract/ # tiberius row → JSON value extraction (incl. temporal types) + extract/ # row → JSON value extraction (incl. temporal types) explain.rs # SHOWPLAN_XML / STATISTICS XML capture showplan.rs # SHOWPLAN XML → visual-plan JSON (plugins return parsed plans) ``` diff --git a/Cargo.lock b/Cargo.lock index 40b0c41..97b441c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom", + "getrandom 0.2.17", "once_cell", "version_check", ] @@ -29,29 +29,77 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] -name = "async-trait" -version = "0.1.91" +name = "asn1-rs" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.119", + "synstructure", ] [[package]] -name = "asynchronous-codec" -version = "0.6.2" +name = "asn1-rs-impl" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ - "bytes", - "futures-sink", - "futures-util", - "memchr", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", "pin-project-lite", ] +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -60,15 +108,22 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "base64" -version = "0.22.1" +name = "bigdecimal" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] [[package]] name = "bitflags" @@ -174,6 +229,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -187,17 +253,11 @@ dependencies = [ "windows-link", ] -[[package]] -name = "connection-string" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" - [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -209,6 +269,21 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deadpool" version = "0.12.3" @@ -228,32 +303,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "der-parser" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "cfg-if", + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", ] [[package]] -name = "enumflags2" -version = "0.7.12" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] -name = "enumflags2_derive" -version = "0.7.12" +name = "displaydoc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", +] + +[[package]] +name = "dns-lookup" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5597a4b7fe5275fc9dcf88ce26326bc8e4cb87d0130f33752d4c5f717793cf" +dependencies = [ + "cfg-if", + "libc", + "socket2 0.6.5", + "windows-sys 0.60.2", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", ] [[package]] @@ -272,12 +370,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "funty" version = "2.0.0" @@ -383,6 +502,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -392,6 +523,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + [[package]] name = "hashbrown" version = "0.17.1" @@ -404,6 +541,17 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -416,7 +564,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -467,6 +615,18 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -488,6 +648,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.2" @@ -499,6 +665,107 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mssql-tds-preview" +version = "0.1.0-preview.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "454957bed59024ea56acb311e8c0b74369182f1a3256d9e036d4b46f687d0462" +dependencies = [ + "async-trait", + "bigdecimal", + "bitflags", + "byteorder", + "bytes", + "dns-lookup", + "encoding_rs", + "futures", + "hostname", + "libc", + "native-tls", + "pretty-hex", + "socket2 0.5.10", + "thiserror", + "tokio", + "tokio-native-tls", + "tokio-util", + "tracing", + "uuid", + "winapi", + "windows", + "x509-parser", +] + +[[package]] +name = "mssql-tiberius-bridge" +version = "0.1.0-preview.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa62f1fe8f53595471d6ad62c347be6009f7bc9b799f2715b1f593a434c16588" +dependencies = [ + "async-stream", + "async-trait", + "chrono", + "deadpool", + "futures-core", + "mssql-tds-preview", + "rust_decimal", + "serde_json", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -518,17 +785,63 @@ dependencies = [ "libc", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] [[package]] name = "parking_lot" @@ -559,6 +872,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -570,9 +895,9 @@ dependencies = [ [[package]] name = "pretty-hex" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" +checksum = "9a65843dfefbafd3c879c683306959a6de478443ffe9c9adf02f5976432402d7" [[package]] name = "proc-macro-crate" @@ -621,6 +946,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -635,7 +966,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -645,7 +987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -654,9 +996,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -675,20 +1023,6 @@ dependencies = [ "bytecheck", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rkyv" version = "0.7.46" @@ -734,7 +1068,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.7", "rkyv", "serde", "serde_json", @@ -742,46 +1076,25 @@ dependencies = [ ] [[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki", - "sct", -] - -[[package]] -name = "rustls-native-certs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" -dependencies = [ - "openssl-probe", - "rustls-pemfile", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rusticata-macros" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "base64 0.21.7", + "nom", ] [[package]] -name = "rustls-webpki" -version = "0.101.7" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "ring", - "untrusted", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] @@ -805,16 +1118,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "seahash" version = "4.1.0" @@ -823,9 +1126,9 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation", @@ -921,6 +1224,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.5" @@ -935,18 +1248,17 @@ dependencies = [ name = "sqlserver-plugin" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "deadpool", - "futures", + "mssql-tds-preview", + "mssql-tiberius-bridge", "once_cell", "roxmltree", "rust_decimal", "serde", "serde_json", - "tiberius", "tokio", - "tokio-util", "uuid", ] @@ -983,60 +1295,84 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] -name = "tiberius" -version = "0.12.3" +name = "time" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" 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", - "rustls-pemfile", - "thiserror", - "tokio", - "tokio-rustls", - "tokio-util", - "tracing", - "uuid", + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -1066,7 +1402,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -1083,12 +1419,12 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" -version = "0.24.1" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ - "rustls", + "native-tls", "tokio", ] @@ -1102,7 +1438,11 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", + "hashbrown 0.15.5", + "libc", "pin-project-lite", + "slab", "tokio", ] @@ -1174,22 +1514,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "uuid" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", + "rand 0.10.2", "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1248,17 +1590,73 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1272,6 +1670,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -1289,6 +1698,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1298,6 +1716,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -1313,7 +1741,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -1331,14 +1768,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1347,48 +1801,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.4" @@ -1407,6 +1909,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "zerocopy" version = "0.8.55" diff --git a/Cargo.toml b/Cargo.toml index 26dc7c1..a5ffc06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,16 +10,19 @@ publish = false base64 = "0.22" chrono = "0.4" deadpool = "0.12" -futures = "0.3.31" +# SQL Server driver — Microsoft's mssql-tds protocol implementation behind a +# tiberius-compatible API, pooled with deadpool. +mssql-tiberius-bridge = "=0.1.0-preview.3" +# Direct dependency on the underlying protocol crate (lib name `mssql_tds`): +# the result-set traits used through `Client::inner_mut()` are not re-exported +# by the bridge. +mssql-tds-preview = "=0.1.0-preview.1" once_cell = "1" roxmltree = "0.20" rust_decimal = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -# SQL Server driver — stable Tiberius client pooled with deadpool. -tiberius = { version = "0.12", default-features = false, features = ["rustls", "tds73", "chrono", "rust_decimal", "sql-browser-tokio"] } tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7", features = ["compat"] } uuid = "1" [profile.release] diff --git a/README.md b/README.md index a4ae821..bb549d3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A [Microsoft SQL Server](https://www.microsoft.com/sql-server) plugin for [Tabularis](https://github.com/TabularisDB/tabularis), the lightweight database management tool. -This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of [`tiberius`](https://crates.io/crates/tiberius) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. +This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. **Discord** - [Join our discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. @@ -33,7 +33,7 @@ This plugin enables Tabularis to connect to SQL Server instances, providing sche ## Features -- Stable `tiberius` + `deadpool` connection pooling with session reset (`sp_reset_connection`), startup scripts, and pool lifecycle handling +- Microsoft `mssql-tds` (`mssql-tiberius-bridge`) + `deadpool` connection pooling with session reset (`sp_reset_connection`), startup scripts, and pool lifecycle handling - Schema, table, column, PK/FK, index, view, routine, and trigger introspection - Query execution with pagination, CTE/DML classification, multiple result sets, and session-preserving batches - Accurate affected rows, including multi-statement DML and DML `OUTPUT` diff --git a/src/driver/explain.rs b/src/driver/explain.rs index c29658d..533dbbe 100644 --- a/src/driver/explain.rs +++ b/src/driver/explain.rs @@ -21,24 +21,20 @@ pub async fn explain_showplan_xml( conn.simple_query(format!("SET {option} ON")) .await .map_err(|error| error.to_string())? - .into_results() - .await - .map_err(|error| error.to_string())?; + .into_results(); - let query_result = match conn.simple_query(query).await { - Ok(stream) => stream - .into_results() - .await - .map_err(|error| error.to_string()), - Err(error) => Err(error.to_string()), - }; + let query_result = conn + .simple_query(query) + .await + .map(|result| result.into_results()) + .map_err(|error| error.to_string()); let disable_result = conn .simple_query(format!("SET {option} OFF")) .await - .map_err(|error| error.to_string())? - .into_results() - .await - .map_err(|error| error.to_string()); + .map_err(|error| error.to_string()) + .map(|result| { + result.into_results(); + }); let result_sets = query_result?; disable_result?; diff --git a/src/driver/extract/mod.rs b/src/driver/extract/mod.rs index b96acfc..5b81223 100644 --- a/src/driver/extract/mod.rs +++ b/src/driver/extract/mod.rs @@ -1,6 +1,6 @@ //! Row-level value extraction for SQL Server. //! -//! The dispatcher inspects the column's `ColumnType` (provided by tiberius) +//! The dispatcher inspects the column's `ColumnType` (provided by the client) //! and calls `Row::try_get::(idx)` with the right `T`. Conversions that //! need string formatting (dates, decimals, UUIDs, binary) are delegated to //! pure helpers in sibling modules so they stay unit-testable without a live @@ -10,9 +10,9 @@ pub mod temporal; use crate::common::i64_to_json; use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime}; +use mssql_tiberius_bridge::{ColumnType, Row}; use rust_decimal::Decimal; use serde_json::Value; -use tiberius::{numeric::Numeric, ColumnType, Row}; use uuid::Uuid; /// Extract a single cell into the Tabularis wire-level `serde_json::Value`. @@ -33,7 +33,7 @@ pub fn extract_value(row: &Row, idx: usize) -> Value { match ct { ColumnType::Null => Value::Null, - ColumnType::Bit | ColumnType::Bitn => read_bool(row, idx), + ColumnType::Bit => read_bool(row, idx), ColumnType::Int1 => match row.try_get::(idx) { Ok(Some(v)) => Value::from(v), @@ -51,13 +51,11 @@ pub fn extract_value(row: &Row, idx: usize) -> Value { Ok(Some(v)) => i64_to_json(v), _ => Value::Null, }, - ColumnType::Intn => read_intn(row, idx), - ColumnType::Float4 => match row.try_get::(idx) { Ok(Some(v)) => f64_to_json(v as f64), _ => Value::Null, }, - ColumnType::Float8 | ColumnType::Floatn => match row.try_get::(idx) { + ColumnType::Float8 => match row.try_get::(idx) { Ok(Some(v)) => f64_to_json(v), _ => Value::Null, }, @@ -71,7 +69,7 @@ pub fn extract_value(row: &Row, idx: usize) -> Value { }, // Temporal - ColumnType::Datetime | ColumnType::Datetime4 | ColumnType::Datetimen => { + ColumnType::Datetime | ColumnType::Datetime4 => { match row.try_get::(idx) { Ok(Some(v)) => Value::String(temporal::format_datetime(&v)), _ => Value::Null, @@ -81,15 +79,15 @@ pub fn extract_value(row: &Row, idx: usize) -> Value { Ok(Some(v)) => Value::String(temporal::format_datetime(&v)), _ => Value::Null, }, - ColumnType::DatetimeOffsetn => match row.try_get::, _>(idx) { + ColumnType::DatetimeOffset => match row.try_get::, _>(idx) { Ok(Some(v)) => Value::String(temporal::format_datetime_offset(&v)), _ => Value::Null, }, - ColumnType::Daten => match row.try_get::(idx) { + ColumnType::Date => match row.try_get::(idx) { Ok(Some(v)) => Value::String(temporal::format_date(&v)), _ => Value::Null, }, - ColumnType::Timen => match row.try_get::(idx) { + ColumnType::Time => match row.try_get::(idx) { Ok(Some(v)) => Value::String(temporal::format_time(&v)), _ => Value::Null, }, @@ -97,19 +95,20 @@ pub fn extract_value(row: &Row, idx: usize) -> Value { // Strings ColumnType::Text | ColumnType::NText - | ColumnType::BigVarChar - | ColumnType::BigChar + | ColumnType::Varchar + | ColumnType::Char | ColumnType::NVarchar | ColumnType::NChar - | ColumnType::Xml => read_string(row, idx), + | ColumnType::Xml + | ColumnType::Json => read_string(row, idx), // Binary - ColumnType::Image | ColumnType::BigBinary | ColumnType::BigVarBin => { + ColumnType::Image | ColumnType::Binary | ColumnType::VarBinary | ColumnType::BigVarBin => { read_binary_as_base64(row, idx) } - // Fallbacks: SSVariant and UDT → best-effort string - ColumnType::SSVariant | ColumnType::Udt => read_string(row, idx), + // Fallbacks: sql_variant and vector → best-effort string + ColumnType::Ssvariant | ColumnType::Vector => read_string(row, idx), } } @@ -122,24 +121,6 @@ fn read_bool(row: &Row, idx: usize) -> Value { } } -fn read_intn(row: &Row, idx: usize) -> Value { - // tiberius returns the "natural" Rust integer width based on the column - // length. Try widest to narrowest; the first successful decode wins. - if let Ok(Some(v)) = row.try_get::(idx) { - return i64_to_json(v); - } - if let Ok(Some(v)) = row.try_get::(idx) { - return Value::from(v); - } - if let Ok(Some(v)) = row.try_get::(idx) { - return Value::from(v); - } - if let Ok(Some(v)) = row.try_get::(idx) { - return Value::from(v); - } - Value::Null -} - fn read_string(row: &Row, idx: usize) -> Value { match row.try_get::<&str, _>(idx) { Ok(Some(s)) => Value::String(s.to_string()), @@ -148,15 +129,11 @@ fn read_string(row: &Row, idx: usize) -> Value { } fn read_numeric_as_string(row: &Row, idx: usize) -> Value { - // Prefer `rust_decimal::Decimal` (exact) when the feature exposes it; - // fall back to tiberius' own `Numeric` (lossless integer + scale) for - // values outside rust_decimal's 96-bit range (NUMERIC(38, ...)). + // Prefer `rust_decimal::Decimal` (exact); fall back to `f64` for values + // that fail to decode as a decimal. if let Ok(Some(d)) = row.try_get::(idx) { return Value::String(normalize_decimal_string(&d.to_string())); } - if let Ok(Some(n)) = row.try_get::(idx) { - return Value::String(normalize_decimal_string(&n.to_string())); - } if let Ok(Some(f)) = row.try_get::(idx) { return f64_to_json(f); } diff --git a/src/driver/extract/temporal.rs b/src/driver/extract/temporal.rs index 737bd48..f77ba74 100644 --- a/src/driver/extract/temporal.rs +++ b/src/driver/extract/temporal.rs @@ -1,7 +1,7 @@ //! Pure formatters for SQL Server temporal types. //! //! All functions here take a `chrono` value and return a `String`. They do -//! **not** touch tiberius — the row-level extraction lives in +//! **not** touch the database client — the row-level extraction lives in //! [`super::extract_value`], which calls into these helpers after pulling the //! right chrono type out of the row. //! diff --git a/src/driver/helpers.rs b/src/driver/helpers.rs index 735a443..2115ead 100644 --- a/src/driver/helpers.rs +++ b/src/driver/helpers.rs @@ -1,6 +1,6 @@ //! Pure SQL Server identifier / literal helpers and parameter-binding adapters. //! -//! The string utilities are deliberately kept free of any tiberius or async +//! The string utilities are deliberately kept free of any client or async //! dependency so they can be unit-tested trivially and reused by multiple //! modules (introspection, DDL, explain). @@ -62,6 +62,24 @@ pub fn qualify(schema: Option<&str>, object: &str) -> String { format!("{}.{}", bracket_quote(schema), bracket_quote(object)) } +/// Result-set column used to carry `@@ROWCOUNT` back to the driver for DML +/// statements. The TDS client reports rows *returned*, not rows *affected*, +/// so every DML batch captures the count itself and selects it as the final +/// result set under this name. +pub const AFFECTED_ROWS_COLUMN: &str = "__tabularis_affected_rows"; + +/// The trailing `SELECT` that surfaces `@@ROWCOUNT` (or a variable holding +/// it) as the batch's final single-cell result set. +fn select_affected_rows(expression: &str) -> String { + format!("SELECT CAST({expression} AS BIGINT) AS [{AFFECTED_ROWS_COLUMN}];") +} + +/// Append the `@@ROWCOUNT` capture to a single-statement DML so the affected +/// count comes back as a final result set (see [`AFFECTED_ROWS_COLUMN`]). +pub fn wrap_dml_with_rowcount(sql: &str) -> String { + format!("{sql}\n; {}", select_affected_rows("@@ROWCOUNT")) +} + /// Build a parameterized SQL Server `INSERT` statement. /// /// `qualified` is expected to already be a `[schema].[table]` produced by @@ -75,6 +93,11 @@ pub fn qualify(schema: Option<&str>, object: &str) -> String { /// reference (typically the same as `qualified`); accepting it as a parameter /// keeps the helper pure and easy to unit-test. /// +/// The batch always ends by selecting the insert's `@@ROWCOUNT` as +/// [`AFFECTED_ROWS_COLUMN`]. In the identity-wrapped variant the count is +/// captured into a variable right after the `INSERT` — `SET IDENTITY_INSERT` +/// resets `@@ROWCOUNT`, so it cannot be read at the end of the batch. +/// /// Returns the SQL batch. The number of placeholders always matches /// `columns.len()`. pub fn build_insert_sql( @@ -97,35 +120,39 @@ pub fn build_insert_sql( ); match wrap_identity_insert { - None => format!("{};", insert), + None => format!("{};\n{}", insert, select_affected_rows("@@ROWCOUNT")), Some(target) => { - // SET IDENTITY_INSERT is session-scoped and is *not* reverted by - // ROLLBACK, so the CATCH block must explicitly turn it OFF before - // re-raising. Setting OFF on a table that is already OFF is a - // no-op in SQL Server, so this is safe even if the failure occurs - // before the ON statement executes. + // SET IDENTITY_INSERT is session-scoped and is *not* transactional, + // so the CATCH block must explicitly turn it OFF before re-raising. + // Setting OFF on a table that is already OFF is a no-op in SQL + // Server, so this is safe even if the failure occurs before the ON + // statement executes. No explicit transaction is needed — a single + // INSERT is atomic on its own, and the TDS client rejects + // BEGIN TRAN / COMMIT inside an `sp_executesql` RPC batch + // (error 3981). format!( - "BEGIN TRY\n\ - BEGIN TRAN;\n\ + "DECLARE @tabularis_affected BIGINT = 0;\n\ + BEGIN TRY\n\ SET IDENTITY_INSERT {target} ON;\n\ {insert};\n\ + SET @tabularis_affected = @@ROWCOUNT;\n\ SET IDENTITY_INSERT {target} OFF;\n\ - COMMIT TRAN;\n\ END TRY\n\ BEGIN CATCH\n\ - IF @@TRANCOUNT > 0 ROLLBACK TRAN;\n\ SET IDENTITY_INSERT {target} OFF;\n\ THROW;\n\ - END CATCH;", + END CATCH;\n\ + {select}", target = target, insert = insert, + select = select_affected_rows("@tabularis_affected"), ) } } } /// Escape a single-quoted string literal by doubling embedded single quotes. -/// **Do not use this for parameterised values** — prefer tiberius parameter +/// **Do not use this for parameterised values** — prefer positional parameter /// binding (`@P1` / `conn.query(sql, &[&value])`). This helper is only for /// metadata queries where the value is also the searchable key (e.g. when /// embedding a schema name into a diagnostic comment). @@ -133,10 +160,10 @@ pub fn escape_single_quoted(value: &str) -> String { value.replace('\'', "''") } -/// Map a [`serde_json::Value`] to a Tiberius parameter with the corresponding +/// Map a [`serde_json::Value`] to a SQL parameter with the corresponding /// SQL Server type instead of coercing every value through a string. /// -/// This helper dispatches on the JSON variant and hands Tiberius a +/// This helper dispatches on the JSON variant and hands the client a /// natively-typed primitive, leaning on its existing /// `ToSql for bool / i64 / f64 / String / Option` implementations: /// @@ -151,7 +178,9 @@ pub fn escape_single_quoted(value: &str) -> String { /// site: the caller collects owned boxes once, then borrows from them when /// building the `&[&dyn ToSql]` slice required by `Client::execute` / /// `Client::query`. -pub fn value_to_sql_param(value: &serde_json::Value) -> Result, String> { +pub fn value_to_sql_param( + value: &serde_json::Value, +) -> Result, String> { match value { serde_json::Value::Null => Ok(Box::new(None::)), serde_json::Value::Bool(value) => Ok(Box::new(*value)), @@ -165,7 +194,7 @@ pub fn value_to_sql_param(value: &serde_json::Value) -> Result) + .map(|value| Box::new(value) as Box) .ok_or_else(|| format!("Invalid SQL Server numeric value: {number}")) } } @@ -180,7 +209,7 @@ pub fn value_to_sql_param(value: &serde_json::Value) -> Result 0 ROLLBACK TRAN;")); assert!(sql.contains("THROW;")); + // No BEGIN TRAN/COMMIT: the TDS client rejects transaction statements + // inside an sp_executesql RPC batch (error 3981), and a single INSERT + // is atomic without one. + assert!(!sql.contains("TRAN")); // The OFF guard must appear both on success and in CATCH so the // session-scoped setting cannot leak when an insert fails. let off_count = sql .matches("SET IDENTITY_INSERT [dbo].[Users] OFF;") .count(); assert_eq!(off_count, 2); + // @@ROWCOUNT must be captured immediately after the INSERT (the later + // SET IDENTITY_INSERT resets it) and selected at the end of the batch. + assert!(sql.contains("SET @tabularis_affected = @@ROWCOUNT;")); + assert!( + sql.contains("SELECT CAST(@tabularis_affected AS BIGINT) AS [__tabularis_affected_rows];") + ); } #[test] @@ -153,9 +161,7 @@ fn value_to_sql_param_accepts_supported_json_variants() { #[test] fn value_to_sql_param_rejects_unsigned_bigint_overflow() { let value = serde_json::json!(u64::MAX); - let error = value_to_sql_param(&value) - .err() - .expect("overflow must be rejected"); + let error = value_to_sql_param(&value).expect_err("overflow must be rejected"); assert!(error.contains("BIGINT range")); } diff --git a/src/driver/introspection.rs b/src/driver/introspection.rs index 692903e..f97e4c6 100644 --- a/src/driver/introspection.rs +++ b/src/driver/introspection.rs @@ -2,10 +2,10 @@ //! //! The SQL strings are exposed as `pub const` so they can be asserted on in //! unit tests (clean-room, no smoke-testing against a live server at compile -//! time). Async helpers execute each query via tiberius and normalise the +//! time). Async helpers execute each query via the bridge client and normalise the //! result into the public Tabularis models (`TableInfo`, `TableColumn`, ...). //! -//! All queries qualify objects with `@P1` / `@P2` tiberius parameter markers; +//! All queries qualify objects with `@P1` / `@P2` positional parameter markers; //! we never interpolate user input. use crate::driver::helpers::qualify; @@ -438,19 +438,19 @@ pub fn is_string_type(data_type: &str) -> bool { // --- Async query helpers -------------------------------------------------- -fn row_str(row: &tiberius::Row, col: &str) -> String { +fn row_str(row: &mssql_tiberius_bridge::Row, col: &str) -> String { row.get::<&str, _>(col).unwrap_or("").to_string() } -fn row_str_opt(row: &tiberius::Row, col: &str) -> Option { +fn row_str_opt(row: &mssql_tiberius_bridge::Row, col: &str) -> Option { row.get::<&str, _>(col).map(|s| s.to_string()) } -fn row_bool(row: &tiberius::Row, col: &str) -> bool { +fn row_bool(row: &mssql_tiberius_bridge::Row, col: &str) -> bool { row.get::(col).unwrap_or(false) } -fn row_i32(row: &tiberius::Row, col: &str) -> i32 { +fn row_i32(row: &mssql_tiberius_bridge::Row, col: &str) -> i32 { row.get::(col).unwrap_or(0) } @@ -462,9 +462,7 @@ pub async fn get_tables( .query(Q_GET_TABLES, &[&schema]) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(rows .into_iter() @@ -486,9 +484,7 @@ pub async fn get_columns( .query(Q_GET_COLUMNS, &[&qualified]) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(rows .into_iter() @@ -522,9 +518,7 @@ pub async fn detect_identity_column( .query(Q_GET_IDENTITY_COLUMN, &[&qualified]) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(rows .into_iter() @@ -555,9 +549,7 @@ pub async fn get_foreign_keys( .query(query, &[&schema, &table]) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(rows .into_iter() @@ -583,9 +575,7 @@ pub async fn get_all_columns_batch( .query(Q_GET_ALL_COLUMNS_BATCH, &[&schema]) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); let mut out: HashMap> = HashMap::new(); for r in rows { @@ -618,9 +608,7 @@ pub async fn get_all_foreign_keys_batch( .query(query, &[&schema]) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); let mut out: HashMap> = HashMap::new(); for r in rows { @@ -657,7 +645,8 @@ pub async fn detect_server_version( ) .await { - if let Ok(rows) = result.into_first_result().await { + { + let rows = result.into_first_result(); if let Some(r) = rows.first() { let raw = row_str(r, "v"); if !raw.trim().is_empty() { @@ -670,7 +659,8 @@ pub async fn detect_server_version( // Fall back to @@VERSION banner. if let Ok(result) = conn.query("SELECT @@VERSION AS v", &[]).await { - if let Ok(rows) = result.into_first_result().await { + { + let rows = result.into_first_result(); if let Some(r) = rows.first() { let raw = row_str(r, "v"); if !raw.trim().is_empty() { @@ -713,9 +703,7 @@ pub async fn get_views(conn: &mut BridgeConnection, schema: &str) -> Result(0)) @@ -807,9 +789,7 @@ pub async fn get_routine_parameters( .query(Q_GET_ROUTINE_PARAMETERS, &[&schema, &routine_name]) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(rows .into_iter() @@ -832,9 +812,7 @@ pub async fn get_indexes( .query(Q_GET_INDEXES, &[&qualified]) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(rows .into_iter() diff --git a/src/driver/introspection/tests.rs b/src/driver/introspection/tests.rs index 41cff03..233da00 100644 --- a/src/driver/introspection/tests.rs +++ b/src/driver/introspection/tests.rs @@ -137,7 +137,7 @@ fn is_string_type_covers_all_text_family() { "sysname", ] { assert!(is_string_type(t), "{} should be string-like", t); - // Case-insensitive — tiberius gives us lowercase, but sys.types + // Case-insensitive — the driver gives us lowercase, but sys.types // occasionally echoes mixed case via sysname aliases. assert!(is_string_type(&t.to_ascii_uppercase())); } diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 86d927f..403d289 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -17,12 +17,14 @@ pub mod triggers; pub mod types; pub mod version; -use futures::TryStreamExt; +use mssql_tds::connection::tds_client::{ResultSet, ResultSetClient}; +use mssql_tiberius_bridge::row::RowSchema; +use mssql_tiberius_bridge::Row; use crate::models::{ConnectionParams, Pagination, QueryResult}; use crate::pool_manager::get_sqlserver_pool; -/// Acquire a Tiberius client from the pool. +/// Acquire a pooled client from the pool manager. pub async fn acquire( params: &ConnectionParams, ) -> Result, String> { @@ -41,63 +43,84 @@ fn empty_query_result(columns: Vec) -> QueryResult { } } -async fn collect_query_results( - mut stream: tiberius::QueryStream<'_>, +/// Run `query` as a simple batch and collect every result set. +/// +/// Goes through the bridge's `inner_mut()` escape hatch instead of +/// `simple_query().into_results()`: the bridge derives columns from rows, so +/// a result set with zero rows would lose its column headers. Reading the +/// result-set metadata directly preserves them, matching the behaviour the +/// UI expects for empty SELECTs. +async fn run_query_collecting( + conn: &mut pool::BridgeConnection, + query: &str, ) -> Result, String> { - let mut results = Vec::new(); - let mut current: Option = None; + let client = conn.inner_mut(); + // Drain any leftover state from a prior query / dropped stream so we + // don't hit "open batch" errors when re-using the client. + client + .close_query() + .await + .map_err(|error| error.to_string())?; + client + .execute(query.to_string(), None, None) + .await + .map_err(|error| error.to_string())?; - while let Some(item) = stream.try_next().await.map_err(|error| error.to_string())? { - match item { - tiberius::QueryItem::Metadata(metadata) => { - if let Some(previous) = current.take() { - results.push(previous); - } - current = Some(empty_query_result( - metadata - .columns() - .iter() - .map(|column| column.name().to_string()) - .collect(), - )); - } - tiberius::QueryItem::Row(row) => { - let result = current.get_or_insert_with(|| { - empty_query_result( - row.columns() - .iter() - .map(|column| column.name().to_string()) - .collect(), - ) - }); - result.rows.push( - (0..row.columns().len()) - .map(|index| extract::extract_value(&row, index)) - .collect(), - ); - } + let mut results = Vec::new(); + while let Some(result_set) = client.get_current_resultset() { + let metadata = result_set.get_metadata().clone(); + let schema = RowSchema::from_metadata(&metadata); + let mut current = empty_query_result( + metadata + .iter() + .map(|column| column.column_name.clone()) + .collect(), + ); + while let Some(values) = result_set + .next_row() + .await + .map_err(|error| error.to_string())? + { + let row = Row::from_schema(schema.clone(), values); + current.rows.push( + (0..row.columns().len()) + .map(|index| extract::extract_value(&row, index)) + .collect(), + ); + } + results.push(current); + if !client + .move_to_next() + .await + .map_err(|error| error.to_string())? + { + break; } - } - if let Some(result) = current { - results.push(result); } Ok(results) } +/// Pull the trailing [`helpers::AFFECTED_ROWS_COLUMN`] result set produced by +/// a parameterized DML batch and return its count. +pub fn affected_rows_from_query(result: mssql_tiberius_bridge::QueryResult) -> Result { + result + .into_results() + .last() + .and_then(|rows| rows.first()) + .and_then(|row| row.get::(0)) + .and_then(|value| u64::try_from(value).ok()) + .ok_or_else(|| "SQL Server did not return affected rows for DML".to_string()) +} + async fn execute_result_bearing_dml( conn: &mut pool::BridgeConnection, query: &str, ) -> Result { - const AFFECTED_COLUMN: &str = "__tabularis_affected_rows"; - let wrapped = format!("{query}\n; SELECT CAST(@@ROWCOUNT AS BIGINT) AS [{AFFECTED_COLUMN}]"); - let stream = conn - .simple_query(wrapped) - .await - .map_err(|error| error.to_string())?; - let mut results = collect_query_results(stream).await?; + let wrapped = helpers::wrap_dml_with_rowcount(query); + let mut results = run_query_collecting(conn, &wrapped).await?; let affected = results .last() - .filter(|result| result.columns == [AFFECTED_COLUMN]) + .filter(|result| result.columns == [helpers::AFFECTED_ROWS_COLUMN]) .and_then(|result| result.rows.first()) .and_then(|row| row.first()) .and_then(serde_json::Value::as_i64) @@ -126,32 +149,22 @@ pub async fn execute_on_connection( page: u32, ) -> Result { let returns_result_set = helpers::query_returns_result_set(query); - if returns_result_set && helpers::query_reports_affected_rows(query) { - return execute_result_bearing_dml(conn, query).await; + if helpers::query_reports_affected_rows(query) { + // The TDS client reports rows returned, not rows affected, so every + // DML goes through the @@ROWCOUNT-capturing batch — result-bearing + // (OUTPUT clauses) or not. + let mut result = execute_result_bearing_dml(conn, query).await?; + if !returns_result_set { + result.columns = Vec::new(); + result.rows = Vec::new(); + } + return Ok(result); } if !returns_result_set { - if helpers::query_reports_affected_rows(query) { - let affected_rows = conn - .execute(query, &[]) - .await - .map_err(|error| error.to_string())? - .total(); - return Ok(QueryResult { - columns: Vec::new(), - rows: Vec::new(), - affected_rows, - truncated: false, - pagination: None, - additional_results: None, - }); - } - conn.simple_query(query) .await .map_err(|error| error.to_string())? - .into_results() - .await - .map_err(|error| error.to_string())?; + .into_results(); return Ok(empty_query_result(Vec::new())); } @@ -166,11 +179,7 @@ pub async fn execute_on_connection( Some(page_size) => helpers::build_paginated_query(query, page_size, page), None => query.to_string(), }; - let stream = conn - .simple_query(final_query) - .await - .map_err(|error| error.to_string())?; - let mut results = collect_query_results(stream).await?; + let mut results = run_query_collecting(conn, &final_query).await?; let mut first = if results.is_empty() { empty_query_result(Vec::new()) } else { diff --git a/src/driver/ops.rs b/src/driver/ops.rs index c2b30d4..b0f8d6a 100644 --- a/src/driver/ops.rs +++ b/src/driver/ops.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; -use tiberius::ToSql; +use mssql_tiberius_bridge::ToSql; use crate::driver::helpers::{ bracket_quote, build_delete_composite_sql, build_update_composite_sql, qualify, @@ -22,9 +22,7 @@ pub async fn test_connection(params: &ConnectionParams) -> Result<(), String> { conn.simple_query("SELECT 1") .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(()) } @@ -35,9 +33,7 @@ pub async fn get_databases(params: &ConnectionParams) -> Result, Str .simple_query("SELECT name FROM sys.databases WHERE database_id > 4 ORDER BY name") .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); let mut out = Vec::with_capacity(rows.len()); for row in rows { @@ -61,9 +57,7 @@ pub async fn get_schemas(params: &ConnectionParams) -> Result, Strin ) .await .map_err(|e| e.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); let mut out = Vec::with_capacity(rows.len()); for row in rows { @@ -157,9 +151,7 @@ pub async fn create_view( conn.simple_query(sql) .await .map_err(|error| format!("Failed to create view: {error}"))? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(()) } @@ -178,9 +170,7 @@ pub async fn alter_view( conn.simple_query(sql) .await .map_err(|error| format!("Failed to alter view: {error}"))? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(()) } @@ -194,9 +184,7 @@ pub async fn drop_view( conn.simple_query(sql) .await .map_err(|error| format!("Failed to drop view: {error}"))? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(()) } @@ -361,20 +349,21 @@ pub async fn insert_record( }, ); - // Map each JSON value to a typed Tiberius parameter. Owned boxes live + // Map each JSON value to a typed SQL parameter. Owned boxes live // for the duration of the call so the borrowed `&dyn ToSql` slice is // valid. - let owned_params: Vec> = columns + let owned_params: Vec> = columns .iter() .map(|column| helpers::value_to_sql_param(&data[column])) .collect::>()?; - let params_slice: Vec<&dyn tiberius::ToSql> = owned_params.iter().map(|b| b.as_ref()).collect(); + let params_slice: Vec<&dyn mssql_tiberius_bridge::ToSql> = + owned_params.iter().map(|b| b.as_ref()).collect(); - let exec = conn - .execute(&sql, ¶ms_slice) + let result = conn + .query(&sql, ¶ms_slice) .await .map_err(|e| e.to_string())?; - Ok(exec.total()) + crate::driver::affected_rows_from_query(result) } pub async fn update_record( @@ -403,10 +392,10 @@ pub async fn update_record( let mut conn = acquire(params).await?; let result = conn - .execute(sql, &bound) + .query(helpers::wrap_dml_with_rowcount(&sql), &bound) .await .map_err(|error| error.to_string())?; - Ok(result.total()) + crate::driver::affected_rows_from_query(result) } pub async fn delete_record( @@ -432,10 +421,10 @@ pub async fn delete_record( let mut conn = acquire(params).await?; let result = conn - .execute(sql, &bound) + .query(helpers::wrap_dml_with_rowcount(&sql), &bound) .await .map_err(|error| error.to_string())?; - Ok(result.total()) + crate::driver::affected_rows_from_query(result) } // --- DDL generation ----------------------------------------------------- diff --git a/src/driver/pool.rs b/src/driver/pool.rs index 5dda7d7..f191d22 100644 --- a/src/driver/pool.rs +++ b/src/driver/pool.rs @@ -1,7 +1,8 @@ -//! tiberius connection pool primitives. +//! mssql-tds connection pool primitives. //! -//! Pools `tiberius::Client` objects via a custom deadpool manager over a -//! Tokio TCP stream adapted to the futures I/O traits Tiberius expects. +//! Pools `mssql_tiberius_bridge::Client` objects (Microsoft's `mssql-tds` +//! protocol implementation behind a tiberius-compatible API) via a custom +//! deadpool manager. //! //! Current authentication support is SQL Server username/password. TLS uses //! Tabularis' shared `ssl_mode`: `disable` turns encryption off, @@ -11,14 +12,12 @@ use crate::models::ConnectionParams; use deadpool::managed::{Manager, Metrics, RecycleError, RecycleResult}; -use tiberius::{AuthMethod, Client, Config, EncryptionLevel}; -use tokio::net::TcpStream; -use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; +use mssql_tiberius_bridge::{AuthMethod, Client, Config, EncryptionLevel, Error}; -/// A live Tiberius client. `deadpool` hands one of these out per checkout. -pub type BridgeConnection = Client>; +/// A live bridge client. `deadpool` hands one of these out per checkout. +pub type BridgeConnection = Client; -/// Deadpool `Manager` for tiberius connections. +/// Deadpool `Manager` for bridge connections. #[derive(Debug, Clone)] pub struct BridgeManager { config: Config, @@ -33,42 +32,28 @@ impl BridgeManager { } } - async fn apply_startup_script( - &self, - conn: &mut BridgeConnection, - ) -> Result<(), tiberius::error::Error> { + async fn apply_startup_script(&self, conn: &mut BridgeConnection) -> Result<(), Error> { if let Some(script) = self.startup_script.as_deref() { conn.simple_query(script) .await .map_err(startup_script_error)? - .into_results() - .await - .map_err(startup_script_error)?; + .into_results(); } Ok(()) } } -fn startup_script_error(error: tiberius::error::Error) -> tiberius::error::Error { - tiberius::error::Error::Io { - kind: std::io::ErrorKind::Other, - message: format!("Startup script failed: {error}"), - } +fn startup_script_error(error: Error) -> Error { + Error::Conversion(format!("Startup script failed: {error}")) } impl Manager for BridgeManager { type Type = BridgeConnection; - type Error = tiberius::error::Error; + + type Error = Error; async fn create(&self) -> Result { - let tcp = TcpStream::connect(self.config.get_addr()) - .await - .map_err(|error| tiberius::error::Error::Io { - kind: error.kind(), - message: error.to_string(), - })?; - let _ = tcp.set_nodelay(true); - let mut client = Client::connect(self.config.clone(), tcp.compat_write()).await?; + let mut client = Client::connect(&self.config).await?; self.apply_startup_script(&mut client).await?; Ok(client) } @@ -80,9 +65,7 @@ impl Manager for BridgeManager { conn.simple_query("EXEC sp_reset_connection") .await .map_err(RecycleError::Backend)? - .into_results() - .await - .map_err(RecycleError::Backend)?; + .into_results(); self.apply_startup_script(conn) .await .map_err(RecycleError::Backend)?; @@ -90,11 +73,11 @@ impl Manager for BridgeManager { } } -/// Build a `tiberius::Config` from Tabularis `ConnectionParams`. +/// Build a `mssql_tiberius_bridge::Config` from Tabularis `ConnectionParams`. /// /// Consumes the shared connection fields used by current Tabularis drivers. /// SQL Server authentication is currently username/password only. TLS maps -/// the standard `ssl_mode` values onto the Tiberius encryption policy. +/// the standard `ssl_mode` values onto the bridge's encryption policy. pub fn build_config(params: &ConnectionParams) -> Result { let mut cfg = Config::new(); cfg.host(params.host.as_deref().unwrap_or("localhost")); diff --git a/src/driver/triggers/mod.rs b/src/driver/triggers/mod.rs index 9b2f56e..8c2a691 100644 --- a/src/driver/triggers/mod.rs +++ b/src/driver/triggers/mod.rs @@ -30,9 +30,7 @@ pub async fn get_triggers( .query(LIST_TRIGGERS, &[&schema]) .await .map_err(|error| error.to_string())? - .into_first_result() - .await - .map_err(|error| error.to_string())?; + .into_first_result(); Ok(rows .into_iter() diff --git a/src/main.rs b/src/main.rs index d30b401..84aba1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,8 +28,21 @@ const REQUEST_QUEUE_CAPACITY: usize = 64; const POOL_CLEANUP_INTERVAL: Duration = Duration::from_secs(600); // 10 minutes -#[tokio::main] -async fn main() { +// The TDS client's async call chains produce large futures (especially in +// debug builds); tokio's default 2 MiB worker stack overflows while polling +// them, so give workers a wider stack. +const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024; + +fn main() { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(WORKER_STACK_SIZE) + .build() + .expect("failed to build tokio runtime") + .block_on(run()); +} + +async fn run() { let (shutdown_tx, shutdown_rx) = watch::channel(false); let cleanup_handle = tokio::spawn(run_pool_cleanup(shutdown_rx)); @@ -102,7 +115,9 @@ async fn run_worker( }; let Some(line) = line else { break }; - let response = rpc::handle_line(&line).await; + // Boxed: the dispatch future embeds every handler's state machine, + // so keep it on the heap rather than the worker stack. + let response = Box::pin(rpc::handle_line(&line)).await; let body = match serde_json::to_string(&response) { Ok(s) => s, Err(err) => format!( From c5c0604ab0b6856b5baf4a1dd7ccfb0d06d76e90 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 10:22:21 +0000 Subject: [PATCH 2/6] docs: record PR 1 baseline --- docs/pr1-baseline.md | 119 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/pr1-baseline.md diff --git a/docs/pr1-baseline.md b/docs/pr1-baseline.md new file mode 100644 index 0000000..a1257ff --- /dev/null +++ b/docs/pr1-baseline.md @@ -0,0 +1,119 @@ +# PR #1 baseline + +Measured on 2026-08-30 against the fetched PR head, before making any source +changes. + +## Revisions and mergeability + +- PR head: `f2afb7bb17559962e18e1f4d42ba9dfd6f91c706` +- `origin/feat/mssql-tds-bridge`: the same revision; the local branch was + neither ahead nor behind. +- Fetched `origin/main`: `1a398810e7bc94064203442193482834f0367d81` +- Merge base: `1a398810e7bc94064203442193482834f0367d81` +- `git rev-list --left-right --count origin/main...HEAD`: `0 1` + +The merge base is exactly `origin/main`, so PR #1 remains mergeable as a +fast-forward with no conflicting main-branch commits. + +## Measurement environment + +- Linux `5.4.0-216-generic` x86-64, Ubuntu 20.04 +- `rustc 1.98.0 (88d9e12ae 2026-08-18)` +- `cargo 1.98.0 (797e8a9bc 2026-08-05)` +- Docker `26.1.3` + +The machine did not have `pkg-config` or OpenSSL development headers installed, +so the first plain `cargo build` stopped in `openssl-sys` with exit status 101. +`sudo` required an unavailable password. To distinguish that machine +prerequisite from branch health, Ubuntu's `pkg-config` and `libssl-dev` +packages were downloaded and extracted under `/tmp`; the Rust commands below +were then run with `OPENSSL_INCLUDE_DIR` and `OPENSSL_LIB_DIR` pointed at those +extracted files. No repository files or dependency selections were changed. + +## Cargo gate + +| Command | Result | +| --- | --- | +| `cargo build` | Passed; dev profile completed | +| `cargo test` | Passed; 134 unit tests passed, 0 failed, plus 0 tests in `test_plugin` | +| `cargo clippy --all-targets -- -D warnings` | Passed with no warnings | +| `cargo fmt --check` | Passed with no diff | + +The measured test count confirms 134 passing tests at the PR head. + +## Bridge dependency + +`cargo tree -p mssql-tiberius-bridge` resolved the root package as +`mssql-tiberius-bridge v0.1.0-preview.3`; its direct TDS implementation resolved +as `mssql-tds-preview v0.1.0-preview.1`. + +The maximum depth printed by that command is **6 dependency edges** (**7 +levels including the bridge root**). One deepest path is: + +```text +mssql-tiberius-bridge +└── mssql-tds-preview + └── x509-parser + └── asn1-rs + └── asn1-rs-derive + └── synstructure + └── syn +``` + +Depth was measured from the four-character indentation levels in the actual +`cargo tree` output, with the bridge root at depth zero. + +## Release binary + +`cargo build --release` passed. The resulting Linux x86-64 binary was: + +```text +path: target/release/sqlserver-plugin +size: 3,023,312 bytes (2.9 MiB as reported by du -h) +format: ELF 64-bit, dynamically linked, stripped +``` + +## Files changed by the measured PR head + +`git diff --name-status origin/main...HEAD` reported 17 files, all modified: + +```text +M CHANGELOG.md +M CLAUDE.md +M Cargo.lock +M Cargo.toml +M README.md +M src/driver/explain.rs +M src/driver/extract/mod.rs +M src/driver/extract/temporal.rs +M src/driver/helpers.rs +M src/driver/helpers/tests.rs +M src/driver/introspection.rs +M src/driver/introspection/tests.rs +M src/driver/mod.rs +M src/driver/ops.rs +M src/driver/pool.rs +M src/driver/triggers/mod.rs +M src/main.rs +``` + +This list describes the client-swap commit under measurement; this baseline +document is the subsequent SS-000 addition. + +## Live SQL Server smoke test + +The existing `sqlserver-dev` container was running the required +`mcr.microsoft.com/mssql/server:2022-latest` image. Its measured image ID was +`sha256:90488d58c6a5c19f24ff716e14330b85b5b26ee54b44a36ea24e6206533e7edd`, +and SQL Server reported `16.0.4265.3 RTM Developer Edition (64-bit)`. +`just seed-sqlserver` completed successfully and selected `tabularis_test`. + +A hand-written JSON-RPC request was piped as one line into +`target/debug/sqlserver-plugin`: + +```text +> {"jsonrpc":"2.0","id":1,"method":"test_connection","params":{"params":{"driver":"sqlserver","host":"127.0.0.1","port":1433,"username":"sa","password":"Str0ng!Passw0rd","database":"tabularis_test","ssl_mode":"disable"}}} +< {"id":1,"jsonrpc":"2.0","result":{"success":true}} +``` + +The process exited after stdin closed and emitted nothing on stderr. From 9bc9bf2f25653efbc92a636796f8e022cb01b850 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 10:37:00 +0000 Subject: [PATCH 3/6] fix: harden TDS bridge result handling --- src/driver/explain.rs | 22 +++--- src/driver/extract/mod.rs | 130 ++++++++++++++++++++++++-------- src/driver/extract/tests.rs | 144 ++++++++++++++++++++++++++++++++++++ src/driver/helpers.rs | 4 +- src/driver/helpers/tests.rs | 25 ++++++- src/driver/mod.rs | 124 +++++++++++++++++++++++++++---- src/main.rs | 12 ++- 7 files changed, 400 insertions(+), 61 deletions(-) diff --git a/src/driver/explain.rs b/src/driver/explain.rs index 533dbbe..5621551 100644 --- a/src/driver/explain.rs +++ b/src/driver/explain.rs @@ -38,15 +38,15 @@ pub async fn explain_showplan_xml( let result_sets = query_result?; disable_result?; - result_sets - .iter() - .flat_map(|rows| rows.iter()) - .flat_map(|row| (0..row.columns().len()).map(move |index| extract_value(row, index))) - .find_map(|value| { - value - .as_str() - .filter(|text| text.contains("ShowPlanXML")) - .map(str::to_string) - }) - .ok_or_else(|| "SQL Server did not return a SHOWPLAN_XML document".to_string()) + for rows in &result_sets { + for row in rows { + for index in 0..row.columns().len() { + let value = extract_value(row, index)?; + if let Some(xml) = value.as_str().filter(|text| text.contains("ShowPlanXML")) { + return Ok(xml.to_string()); + } + } + } + } + Err("SQL Server did not return a SHOWPLAN_XML document".to_string()) } diff --git a/src/driver/extract/mod.rs b/src/driver/extract/mod.rs index 5b81223..06545df 100644 --- a/src/driver/extract/mod.rs +++ b/src/driver/extract/mod.rs @@ -10,27 +10,46 @@ pub mod temporal; use crate::common::i64_to_json; use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime}; +use mssql_tds::datatypes::column_values::ColumnValues; +use mssql_tds::datatypes::sql_json::SqlJson; +use mssql_tds::datatypes::sql_vector::SqlVector; +use mssql_tds::datatypes::sqldatatypes::TdsDataType; use mssql_tiberius_bridge::{ColumnType, Row}; use rust_decimal::Decimal; use serde_json::Value; use uuid::Uuid; -/// Extract a single cell into the Tabularis wire-level `serde_json::Value`. +/// Resolve wire types before dispatching extraction. /// -/// Returns `Value::Null` for: -/// - NULL SQL values -/// - columns whose `ColumnType` is `Null` (untyped) -/// - values that couldn't be decoded as any expected type -/// -/// The function never panics; decoding errors log at debug level and fall -/// back to `Value::Null` so one malformed row doesn't break the whole query. -pub fn extract_value(row: &Row, idx: usize) -> Value { - let Some(col) = row.columns().get(idx) else { - return Value::Null; +/// The bridge's preview.3 normalizer handles nullable numeric widths but omits +/// the fixed `smallmoney`, `char(n)`, and `binary(n)` wire variants. Keep those +/// corrections local until the pinned bridge can be upgraded deliberately. +pub fn normalized_column_type(tds_type: TdsDataType, byte_length: usize) -> ColumnType { + match tds_type { + TdsDataType::Money4 => ColumnType::Money4, + TdsDataType::BigChar => ColumnType::Char, + TdsDataType::BigBinary => ColumnType::Binary, + TdsDataType::DateTimeN if byte_length == 4 => ColumnType::Datetime4, + other => ColumnType::from_tds_with_length(other, byte_length), + } +} + +/// Extract a single cell using the type exposed by the bridge row. +pub fn extract_value(row: &Row, idx: usize) -> Result { + let Some(column) = row.columns().get(idx) else { + return Ok(Value::Null); }; - let ct = col.column_type(); + extract_value_as(row, idx, column.column_type()) +} - match ct { +/// Extract a single cell using already-normalized result-set metadata. +/// +/// Most decode mismatches retain the driver's historical `null` fallback. +/// Exact numerics are different: silently replacing an out-of-range +/// `decimal(38, s)` with null or an approximate float would corrupt data, so +/// those conversions return an error that aborts the query result instead. +pub fn extract_value_as(row: &Row, idx: usize, column_type: ColumnType) -> Result { + let value = match column_type { ColumnType::Null => Value::Null, ColumnType::Bit => read_bool(row, idx), @@ -60,8 +79,9 @@ pub fn extract_value(row: &Row, idx: usize) -> Value { _ => Value::Null, }, - ColumnType::Money | ColumnType::Money4 => read_numeric_as_string(row, idx), - ColumnType::Decimaln | ColumnType::Numericn => read_numeric_as_string(row, idx), + ColumnType::Money | ColumnType::Money4 | ColumnType::Decimaln | ColumnType::Numericn => { + return read_numeric_as_string(row, idx) + } ColumnType::Guid => match row.try_get::(idx) { Ok(Some(u)) => Value::String(u.to_string()), @@ -99,17 +119,27 @@ pub fn extract_value(row: &Row, idx: usize) -> Value { | ColumnType::Char | ColumnType::NVarchar | ColumnType::NChar - | ColumnType::Xml - | ColumnType::Json => read_string(row, idx), + | ColumnType::Xml => read_string(row, idx), + + ColumnType::Json => match row.raw_value(idx) { + Some(ColumnValues::Json(json)) => json_to_json(json)?, + _ => Value::Null, + }, // Binary ColumnType::Image | ColumnType::Binary | ColumnType::VarBinary | ColumnType::BigVarBin => { read_binary_as_base64(row, idx) } - // Fallbacks: sql_variant and vector → best-effort string - ColumnType::Ssvariant | ColumnType::Vector => read_string(row, idx), - } + ColumnType::Vector => match row.raw_value(idx) { + Some(ColumnValues::Vector(vector)) => vector_to_json(vector), + _ => Value::Null, + }, + + // sql_variant remains a best-effort textual fallback. + ColumnType::Ssvariant => read_string(row, idx), + }; + Ok(value) } // --- Primitive readers --------------------------------------------------- @@ -128,16 +158,56 @@ fn read_string(row: &Row, idx: usize) -> Value { } } -fn read_numeric_as_string(row: &Row, idx: usize) -> Value { - // Prefer `rust_decimal::Decimal` (exact); fall back to `f64` for values - // that fail to decode as a decimal. - if let Ok(Some(d)) = row.try_get::(idx) { - return Value::String(normalize_decimal_string(&d.to_string())); - } - if let Ok(Some(f)) = row.try_get::(idx) { - return f64_to_json(f); - } - Value::Null +fn read_numeric_as_string(row: &Row, idx: usize) -> Result { + let value = row + .raw_value(idx) + .ok_or_else(|| format!("SQL Server numeric column index {idx} is out of bounds"))?; + numeric_value_to_json(value) +} + +fn numeric_value_to_json(value: &ColumnValues) -> Result { + let decimal = match value { + ColumnValues::Null => return Ok(Value::Null), + ColumnValues::Decimal(parts) | ColumnValues::Numeric(parts) => { + let raw = parts.to_string(); + raw.parse::().map_err(|error| { + format!( + "SQL Server decimal({}, {}) value {raw} exceeds rust_decimal's exact range: {error}", + parts.precision, parts.scale + ) + })? + } + ColumnValues::SmallMoney(money) => Decimal::new(i64::from(money.int_val), 4), + ColumnValues::Money(money) => { + let raw = (i64::from(money.msb_part) << 32) | i64::from(money.lsb_part as u32); + Decimal::new(raw, 4) + } + other => { + return Err(format!( + "SQL Server returned a non-numeric value for an exact numeric column: {other:?}" + )) + } + }; + Ok(Value::String(normalize_decimal_string( + &decimal.to_string(), + ))) +} + +fn json_to_json(json: &SqlJson) -> Result { + String::from_utf8(json.bytes.clone()) + .map(Value::String) + .map_err(|error| format!("SQL Server returned invalid UTF-8 for a JSON column: {error}")) +} + +fn vector_to_json(vector: &SqlVector) -> Value { + Value::Array( + vector + .as_f32() + .unwrap_or_default() + .iter() + .map(|dimension| f64_to_json(f64::from(*dimension))) + .collect(), + ) } fn read_binary_as_base64(row: &Row, idx: usize) -> Value { diff --git a/src/driver/extract/tests.rs b/src/driver/extract/tests.rs index c27a770..66ac1a3 100644 --- a/src/driver/extract/tests.rs +++ b/src/driver/extract/tests.rs @@ -1,4 +1,148 @@ use super::*; +use mssql_tds::datatypes::column_values::{SqlMoney, SqlSmallMoney}; +use mssql_tiberius_bridge::DecimalParts; + +#[test] +fn column_type_normalization_resolves_nullable_widths() { + let cases = [ + (TdsDataType::IntN, 1, ColumnType::Int1), + (TdsDataType::IntN, 2, ColumnType::Int2), + (TdsDataType::IntN, 4, ColumnType::Int4), + (TdsDataType::IntN, 8, ColumnType::Int8), + (TdsDataType::FltN, 4, ColumnType::Float4), + (TdsDataType::FltN, 8, ColumnType::Float8), + (TdsDataType::MoneyN, 4, ColumnType::Money4), + (TdsDataType::MoneyN, 8, ColumnType::Money), + (TdsDataType::DateTimeN, 4, ColumnType::Datetime4), + (TdsDataType::DateTimeN, 8, ColumnType::Datetime), + ]; + + for (tds_type, length, expected) in cases { + assert_eq!(normalized_column_type(tds_type, length), expected); + } +} + +#[test] +fn column_type_normalization_matches_the_replaced_tiberius_dispatch() { + let cases = [ + (TdsDataType::Void, ColumnType::Null), + (TdsDataType::Bit, ColumnType::Bit), + (TdsDataType::Int1, ColumnType::Int1), + (TdsDataType::Int2, ColumnType::Int2), + (TdsDataType::Int4, ColumnType::Int4), + (TdsDataType::Int8, ColumnType::Int8), + (TdsDataType::Flt4, ColumnType::Float4), + (TdsDataType::Flt8, ColumnType::Float8), + (TdsDataType::DateTime, ColumnType::Datetime), + (TdsDataType::DateTime2N, ColumnType::Datetime2), + (TdsDataType::DateTim4, ColumnType::Datetime4), + (TdsDataType::DateTimeOffsetN, ColumnType::DatetimeOffset), + (TdsDataType::DateN, ColumnType::Date), + (TdsDataType::TimeN, ColumnType::Time), + (TdsDataType::Decimal, ColumnType::Decimaln), + (TdsDataType::DecimalN, ColumnType::Decimaln), + (TdsDataType::Numeric, ColumnType::Numericn), + (TdsDataType::NumericN, ColumnType::Numericn), + (TdsDataType::Money, ColumnType::Money), + (TdsDataType::Guid, ColumnType::Guid), + (TdsDataType::NVarChar, ColumnType::NVarchar), + (TdsDataType::VarChar, ColumnType::Varchar), + (TdsDataType::NChar, ColumnType::NChar), + (TdsDataType::Char, ColumnType::Char), + (TdsDataType::NText, ColumnType::NText), + (TdsDataType::Text, ColumnType::Text), + (TdsDataType::Binary, ColumnType::Binary), + (TdsDataType::VarBinary, ColumnType::VarBinary), + (TdsDataType::Image, ColumnType::Image), + (TdsDataType::Xml, ColumnType::Xml), + (TdsDataType::SsVariant, ColumnType::Ssvariant), + // The bridge has no UDT variant; the old best-effort string decode + // also produced null for the binary CLR payloads we receive. + (TdsDataType::Udt, ColumnType::Null), + (TdsDataType::None, ColumnType::Null), + ]; + + for (tds_type, expected) in cases { + assert_eq!(normalized_column_type(tds_type, 8), expected); + } +} + +#[test] +fn column_type_normalization_covers_fixed_and_big_wire_names() { + let cases = [ + (TdsDataType::BitN, ColumnType::Bit), + (TdsDataType::Money4, ColumnType::Money4), + (TdsDataType::BigVarChar, ColumnType::Varchar), + (TdsDataType::BigChar, ColumnType::Char), + (TdsDataType::BigVarBinary, ColumnType::VarBinary), + (TdsDataType::BigBinary, ColumnType::Binary), + (TdsDataType::Json, ColumnType::Json), + (TdsDataType::Vector, ColumnType::Vector), + ]; + + for (tds_type, expected) in cases { + assert_eq!(normalized_column_type(tds_type, 8), expected); + } +} + +#[test] +fn exact_decimal_within_rust_decimal_range_is_preserved() { + let parts = DecimalParts::from_string("123.4500", 10, 4).unwrap(); + let value = numeric_value_to_json(&ColumnValues::Decimal(parts)).unwrap(); + + assert_eq!(value, Value::String("123.45".into())); +} + +#[test] +fn decimal_beyond_rust_decimal_range_fails_loudly() { + let parts = DecimalParts::from_string("99999999999999999999999999999999999999", 38, 0).unwrap(); + let error = numeric_value_to_json(&ColumnValues::Numeric(parts)).unwrap_err(); + + assert!(error.contains("decimal(38, 0)")); + assert!(error.contains("exceeds rust_decimal's exact range")); +} + +#[test] +fn money_and_smallmoney_are_exact_decimal_strings() { + let smallmoney = numeric_value_to_json(&ColumnValues::SmallMoney(SqlSmallMoney { + int_val: -123_456, + })) + .unwrap(); + let money = numeric_value_to_json(&ColumnValues::Money(SqlMoney { + msb_part: 0, + lsb_part: 1_234_567, + })) + .unwrap(); + + assert_eq!(smallmoney, Value::String("-12.3456".into())); + assert_eq!(money, Value::String("123.4567".into())); +} + +#[test] +fn json_values_have_a_defined_text_representation() { + let json = SqlJson::new(br#"{"ok":true}"#.to_vec()); + + assert_eq!( + json_to_json(&json).unwrap(), + Value::String(r#"{"ok":true}"#.into()) + ); +} + +#[test] +fn malformed_json_utf8_fails_loudly() { + let json = SqlJson::new(vec![0xff]); + let error = json_to_json(&json).unwrap_err(); + + assert!(error.contains("invalid UTF-8")); +} + +#[test] +fn vector_values_have_a_defined_json_array_representation() { + let vector = SqlVector::try_from_f32(vec![1.0, -2.5, 3.25]).unwrap(); + let value = vector_to_json(&vector); + + assert_eq!(value, serde_json::json!([1.0, -2.5, 3.25])); +} // --- f64_to_json ------------------------------------------------------ diff --git a/src/driver/helpers.rs b/src/driver/helpers.rs index 2115ead..9aecb5d 100644 --- a/src/driver/helpers.rs +++ b/src/driver/helpers.rs @@ -126,7 +126,9 @@ pub fn build_insert_sql( // so the CATCH block must explicitly turn it OFF before re-raising. // Setting OFF on a table that is already OFF is a no-op in SQL // Server, so this is safe even if the failure occurs before the ON - // statement executes. No explicit transaction is needed — a single + // statement executes. The success and CATCH paths both turn it + // OFF; SS-003 verifies a failed insert does not poison the reused + // pooled session. No explicit transaction is needed — a single // INSERT is atomic on its own, and the TDS client rejects // BEGIN TRAN / COMMIT inside an `sp_executesql` RPC batch // (error 3981). diff --git a/src/driver/helpers/tests.rs b/src/driver/helpers/tests.rs index bd66d43..cf11895 100644 --- a/src/driver/helpers/tests.rs +++ b/src/driver/helpers/tests.rs @@ -89,6 +89,16 @@ fn build_insert_sql_plain_emits_positional_placeholders() { ); } +#[test] +fn wrap_dml_with_rowcount_keeps_multi_statement_batch_and_single_sentinel() { + let batch = "SET NOCOUNT ON; SELECT 1; UPDATE [dbo].[Users] SET [active] = 1"; + let sql = wrap_dml_with_rowcount(batch); + + assert!(sql.starts_with(batch)); + assert_eq!(sql.matches(AFFECTED_ROWS_COLUMN).count(), 1); + assert!(sql.ends_with("SELECT CAST(@@ROWCOUNT AS BIGINT) AS [__tabularis_affected_rows];")); +} + #[test] fn build_insert_sql_plain_quotes_column_identifiers() { let sql = build_insert_sql( @@ -125,7 +135,11 @@ fn build_insert_sql_with_identity_wraps_in_try_catch() { assert_eq!(off_count, 2); // @@ROWCOUNT must be captured immediately after the INSERT (the later // SET IDENTITY_INSERT resets it) and selected at the end of the batch. - assert!(sql.contains("SET @tabularis_affected = @@ROWCOUNT;")); + assert!(sql.contains( + "INSERT INTO [dbo].[Users] ([id], [name]) VALUES (@P1, @P2);\n\ + SET @tabularis_affected = @@ROWCOUNT;\n\ + SET IDENTITY_INSERT [dbo].[Users] OFF;" + )); assert!( sql.contains("SELECT CAST(@tabularis_affected AS BIGINT) AS [__tabularis_affected_rows];") ); @@ -335,9 +349,18 @@ fn result_set_classification_ignores_literals_comments_and_identifiers() { #[test] fn affected_rows_are_only_reported_for_final_dml_statement() { assert!(query_reports_affected_rows("UPDATE users SET active = 1")); + assert!(query_reports_affected_rows( + "UPDATE users SET active = 1 OUTPUT INSERTED.id" + )); + assert!(query_reports_affected_rows( + "SELECT 1; UPDATE users SET active = 1" + )); assert!(query_reports_affected_rows( "SET NOCOUNT ON; WITH target AS (SELECT id FROM users) DELETE FROM target" )); + assert!(!query_reports_affected_rows( + "UPDATE users SET active = 1; SELECT 1" + )); assert!(!query_reports_affected_rows( "CREATE PROCEDURE dbo.p AS SELECT 1" )); diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 403d289..f0f649c 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -49,7 +49,9 @@ fn empty_query_result(columns: Vec) -> QueryResult { /// `simple_query().into_results()`: the bridge derives columns from rows, so /// a result set with zero rows would lose its column headers. Reading the /// result-set metadata directly preserves them, matching the behaviour the -/// UI expects for empty SELECTs. +/// UI expects for empty SELECTs. This cannot be simulated faithfully without +/// a TDS stream; SS-003 exercises zero-row headers here through simple, +/// multi-result, batch-RPC, and paginated JSON-RPC calls. async fn run_query_collecting( conn: &mut pool::BridgeConnection, query: &str, @@ -83,9 +85,17 @@ async fn run_query_collecting( { let row = Row::from_schema(schema.clone(), values); current.rows.push( - (0..row.columns().len()) - .map(|index| extract::extract_value(&row, index)) - .collect(), + metadata + .iter() + .enumerate() + .map(|(index, column)| { + let column_type = extract::normalized_column_type( + column.data_type, + column.type_info.length, + ); + extract::extract_value_as(&row, index, column_type) + }) + .collect::, _>>()?, ); } results.push(current); @@ -112,22 +122,25 @@ pub fn affected_rows_from_query(result: mssql_tiberius_bridge::QueryResult) -> R .ok_or_else(|| "SQL Server did not return affected rows for DML".to_string()) } -async fn execute_result_bearing_dml( - conn: &mut pool::BridgeConnection, - query: &str, -) -> Result { - let wrapped = helpers::wrap_dml_with_rowcount(query); - let mut results = run_query_collecting(conn, &wrapped).await?; +fn affected_rows_value(value: &serde_json::Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok())) + .or_else(|| value.as_str().and_then(|value| value.parse().ok())) +} + +fn finish_result_bearing_dml(mut results: Vec) -> Result { let affected = results .last() .filter(|result| result.columns == [helpers::AFFECTED_ROWS_COLUMN]) .and_then(|result| result.rows.first()) .and_then(|row| row.first()) - .and_then(serde_json::Value::as_i64) - .and_then(|value| u64::try_from(value).ok()) + .and_then(affected_rows_value) .ok_or_else(|| { "SQL Server did not return affected rows for result-bearing DML".to_string() })?; + // The sentinel is an implementation detail. Removing it here guarantees + // OUTPUT and mixed batches never expose a stray one-cell grid to the host. results.pop(); let mut first = if results.is_empty() { @@ -142,6 +155,14 @@ async fn execute_result_bearing_dml( Ok(first) } +async fn execute_result_bearing_dml( + conn: &mut pool::BridgeConnection, + query: &str, +) -> Result { + let wrapped = helpers::wrap_dml_with_rowcount(query); + finish_result_bearing_dml(run_query_collecting(conn, &wrapped).await?) +} + pub async fn execute_on_connection( conn: &mut pool::BridgeConnection, query: &str, @@ -151,8 +172,9 @@ pub async fn execute_on_connection( let returns_result_set = helpers::query_returns_result_set(query); if helpers::query_reports_affected_rows(query) { // The TDS client reports rows returned, not rows affected, so every - // DML goes through the @@ROWCOUNT-capturing batch — result-bearing - // (OUTPUT clauses) or not. + // final DML statement goes through the @@ROWCOUNT-capturing batch — + // result-bearing (OUTPUT clauses) or not. SS-003 verifies plain, + // multi-statement, and OUTPUT cases against SQL Server. let mut result = execute_result_bearing_dml(conn, query).await?; if !returns_result_set { result.columns = Vec::new(); @@ -199,3 +221,77 @@ pub async fn execute_on_connection( } Ok(first) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn result(columns: &[&str], rows: Vec>) -> QueryResult { + QueryResult { + columns: columns.iter().map(|column| (*column).to_string()).collect(), + rows, + ..empty_query_result(Vec::new()) + } + } + + #[test] + fn dml_sentinel_is_removed_from_output_result() { + let output = result(&["id"], vec![vec![json!(7)]]); + let sentinel = result(&[helpers::AFFECTED_ROWS_COLUMN], vec![vec![json!(1)]]); + + let actual = finish_result_bearing_dml(vec![output, sentinel]).unwrap(); + + assert_eq!(actual.columns, ["id"]); + assert_eq!(actual.rows, vec![vec![json!(7)]]); + assert_eq!(actual.affected_rows, 1); + assert!(actual.additional_results.is_none()); + } + + #[test] + fn dml_sentinel_preserves_additional_result_sets() { + let first = result(&["before"], vec![vec![json!("first")]]); + let output = result(&["id"], vec![vec![json!(9)]]); + let sentinel = result(&[helpers::AFFECTED_ROWS_COLUMN], vec![vec![json!(3)]]); + + let actual = finish_result_bearing_dml(vec![first, output, sentinel]).unwrap(); + + assert_eq!(actual.affected_rows, 3); + let additional = actual.additional_results.unwrap(); + assert_eq!(additional.len(), 1); + assert_eq!(additional[0].columns, ["id"]); + assert_eq!(additional[0].rows, vec![vec![json!(9)]]); + } + + #[test] + fn dml_without_output_returns_only_affected_count() { + let sentinel = result(&[helpers::AFFECTED_ROWS_COLUMN], vec![vec![json!(2)]]); + + let actual = finish_result_bearing_dml(vec![sentinel]).unwrap(); + + assert!(actual.columns.is_empty()); + assert!(actual.rows.is_empty()); + assert_eq!(actual.affected_rows, 2); + assert!(actual.additional_results.is_none()); + } + + #[test] + fn dml_affected_count_accepts_js_unsafe_integer_string() { + let sentinel = result( + &[helpers::AFFECTED_ROWS_COLUMN], + vec![vec![json!("9007199254740992")]], + ); + + let actual = finish_result_bearing_dml(vec![sentinel]).unwrap(); + + assert_eq!(actual.affected_rows, 9_007_199_254_740_992); + } + + #[test] + fn dml_requires_a_well_formed_trailing_sentinel() { + let output = result(&["id"], vec![vec![json!(7)]]); + let error = finish_result_bearing_dml(vec![output]).unwrap_err(); + + assert!(error.contains("did not return affected rows")); + } +} diff --git a/src/main.rs b/src/main.rs index 84aba1b..64b6139 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,8 +29,11 @@ const REQUEST_QUEUE_CAPACITY: usize = 64; const POOL_CLEANUP_INTERVAL: Duration = Duration::from_secs(600); // 10 minutes // The TDS client's async call chains produce large futures (especially in -// debug builds); tokio's default 2 MiB worker stack overflows while polling -// them, so give workers a wider stack. +// debug builds). A local SQL Server 2022 execute_query probe overflowed +// tokio's default 2 MiB stack while 4 MiB completed; 16 MiB is therefore a +// deliberate 4x safety margin, not a measured minimum. Keep the margin until +// the preview client flattens those polling chains or equivalent CI stress +// coverage proves a smaller stack across platforms. const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024; fn main() { @@ -115,8 +118,9 @@ async fn run_worker( }; let Some(line) = line else { break }; - // Boxed: the dispatch future embeds every handler's state machine, - // so keep it on the heap rather than the worker stack. + // Box the dispatch future itself: it embeds every handler's state + // machine, so constructing only a boxed handler result later would + // still leave the large dispatch enum on the worker stack. let response = Box::pin(rpc::handle_line(&line)).await; let body = match serde_json::to_string(&response) { Ok(s) => s, From 29495a43dbc99596ed599f9f4a09825d5d1e6eb6 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 10:52:15 +0000 Subject: [PATCH 4/6] chore: audit TDS bridge dependencies --- .github/workflows/ci.yml | 25 +++++ Cargo.lock | 4 +- Cargo.toml | 10 +- docs/dependencies.md | 228 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 docs/dependencies.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8af1e24..ff43dc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: branches: [main] + schedule: + # Weekly cargo-audit sweep for advisories disclosed after dependencies land. + # Use an off-peak minute rather than :00 or :30. + - cron: "23 5 * * 2" jobs: test: @@ -27,3 +31,24 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + + audit: + name: Security audit + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + # Scheduled audit-check runs can file or update tracking issues. + issues: write + steps: + - uses: actions/checkout@v7 + + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + # RUSTSEC-2026-0235 affects rkyv 0.7.46, present only because + # rust_decimal declares an optional rkyv feature. This plugin does + # not enable it (`cargo tree -i rkyv` has no path), so no rkyv code + # is compiled into the shipped binary. Re-check whenever + # rust_decimal is upgraded or its enabled features change. + ignore: RUSTSEC-2026-0235 diff --git a/Cargo.lock b/Cargo.lock index 97b441c..fc61a48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,9 +231,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", diff --git a/Cargo.toml b/Cargo.toml index a5ffc06..fbf12ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,16 @@ base64 = "0.22" chrono = "0.4" deadpool = "0.12" # SQL Server driver — Microsoft's mssql-tds protocol implementation behind a -# tiberius-compatible API, pooled with deadpool. +# tiberius-compatible API, pooled with deadpool. Keep the exact preview pin: +# preview releases may change bridge semantics without a stable-version signal. +# To upgrade, review both changelogs and dependency diffs, update both pins, +# then run the full unit, live-database, clippy, audit, and release-build gates. mssql-tiberius-bridge = "=0.1.0-preview.3" # Direct dependency on the underlying protocol crate (lib name `mssql_tds`): # the result-set traits used through `Client::inner_mut()` are not re-exported -# by the bridge. -mssql-tds-preview = "=0.1.0-preview.1" +# by the bridge. It is pinned in lockstep with the bridge; default integrated +# authentication is disabled because this plugin supports SQL auth only. +mssql-tds-preview = { version = "=0.1.0-preview.1", default-features = false } once_cell = "1" roxmltree = "0.20" rust_decimal = "1" diff --git a/docs/dependencies.md b/docs/dependencies.md new file mode 100644 index 0000000..561abff --- /dev/null +++ b/docs/dependencies.md @@ -0,0 +1,228 @@ +# Dependency and supply-chain review + +This review was performed on 2026-08-30 against the versions locked in +`Cargo.lock`. The SQL Server transport is intentionally treated as a +release-critical dependency because both crates are preview releases. + +## TDS dependency provenance + +### `mssql-tiberius-bridge` + +- **Resolved version:** + [`0.1.0-preview.3`](https://crates.io/crates/mssql-tiberius-bridge/0.1.0-preview.3), + source commit + [`fcd9008`](https://github.com/saurabh500/mssql-tiberius-bridge/commit/fcd9008e6ee38e6098c01a3b7547125b84b5e54a). +- **Upstream:** + [`saurabh500/mssql-tiberius-bridge`](https://github.com/saurabh500/mssql-tiberius-bridge), + maintained separately from Microsoft. It provides a Tiberius-compatible API + over `mssql-tds` and pins the protocol crate itself. +- **Licence:** MIT as declared by the + [published manifest](https://docs.rs/crate/mssql-tiberius-bridge/0.1.0-preview.3/source/Cargo.toml.orig). + MIT is compatible with this plugin's Apache-2.0 licence. The upstream + repository and published crate do not currently include a standalone licence + text, so the declaration is the licence evidence and the omission should be + corrected upstream before a public binary release. +- **Release cadence:** all five published previews arrived in a ten-day burst: + preview.1 on 2026-05-08, preview.2 and preview.3 on 2026-05-10, preview.4 + later on 2026-05-10, and + [preview.5](https://github.com/saurabh500/mssql-tiberius-bridge/releases/tag/v0.1.0-preview.5) + on 2026-05-18. There has been no release after preview.5, and the repository's + [latest commit](https://github.com/saurabh500/mssql-tiberius-bridge/commit/9a3d10a5b810a02cc0a59dc7cc91b2ef8835f4b0) + was 2026-05-21. +- **Maintenance status:** the repository is public and not archived, but it is + a young, single-owner project. The initial development burst has not been + followed by a commit in more than three months, the generated + [preview.6 pull request](https://github.com/saurabh500/mssql-tiberius-bridge/pull/97) + remains open, and a recent reliability report has no response yet. Treat it + as active-but-unproven rather than as a stable, regularly maintained client. + +Preview.4 and preview.5 already exist, but this plugin stays on the exact +preview.3 build that was reviewed and smoke-tested. A caret requirement on a +`0.1.0-preview.*` compatibility layer would permit an unreviewed API or wire +behaviour change when the lockfile is refreshed. The `=` pin makes an upgrade +an explicit change with its own dependency diff and live SQL Server evidence. + +### `mssql-tds-preview` + +- **Resolved version:** + [`0.1.0-preview.1`](https://crates.io/crates/mssql-tds-preview/0.1.0-preview.1), + source commit + [`d43bdcc`](https://github.com/saurabh500/mssql-rs/commit/d43bdcc2f2d6f16155a259e2db7240365e6271df). +- **Published upstream:** + [`saurabh500/mssql-rs`](https://github.com/saurabh500/mssql-rs), a publishable + fork of Microsoft's + [`microsoft/mssql-rs`](https://github.com/microsoft/mssql-rs) until Microsoft + publishes the official crate. The plugin therefore consumes a third-party + crates.io package even though the protocol implementation originated at + Microsoft. +- **Licence:** MIT in the + [crate manifest](https://docs.rs/crate/mssql-tds-preview/0.1.0-preview.1/source/Cargo.toml.orig) + and the + [fork licence](https://github.com/saurabh500/mssql-rs/blob/main/LICENSE). + MIT is compatible with Apache-2.0. +- **Maintenance status:** the publishing fork had a commit on 2026-08-21 and + Microsoft's source remains active. The published preview line has moved to + preview.9, but the bridge preview.3 requires protocol preview.1 exactly. + Advancing either dependency independently is not supported. + +The plugin has a direct dependency on `mssql-tds-preview` because the bridge +does not re-export the result-set traits used through `Client::inner_mut()`. +Its default integrated-authentication feature is disabled: the plugin ships +SQL authentication only. + +## Protocol crate dependency surface + +The exact preview.1 +[manifest](https://docs.rs/crate/mssql-tds-preview/0.1.0-preview.1/source/Cargo.toml.orig) +declares these runtime dependencies: + +- async/runtime and I/O: `async-trait`, `tokio` with `full`, `tokio-util` with + `full`, `futures`, `bytes`, `byteorder`, `socket2`, and `tracing`; +- TLS and certificate parsing: `native-tls` with ALPN, `tokio-native-tls`, and + `x509-parser`; +- SQL values and text: `bigdecimal`, `uuid` with v4 and fast RNG, + `encoding_rs`, and `bitflags`; +- networking and support: `dns-lookup`, `hostname`, `pretty-hex`, and + `thiserror`; +- platform dependencies: `libc` on Unix and `winapi` plus `windows` on + Windows. + +On Linux, `native-tls` means the release environment must provide OpenSSL. The +bridge disables the protocol crate's default features, and this plugin now does +so on its direct edge as well, preventing the unused `integrated-auth` feature +from being unified back into the build. + +## Open upstream issues relevant to this plugin + +The following open issues touch code paths the plugin uses. They are reviewed +on every bridge upgrade; issue links and status are current as of the review +date above. + +- [Bridge #104](https://github.com/saurabh500/mssql-tiberius-bridge/issues/104) + reports pooled connections intermittently returning no rows after 5–15 + minutes. This directly affects the deadpool usage here and is not yet + explained. Pool recycling calls `sp_reset_connection`, but that has not been + demonstrated to prevent this report. +- [Bridge #1](https://github.com/saurabh500/mssql-tiberius-bridge/issues/1) + reports `execute()` returning zero affected rows for DML. The plugin does not + trust that API: its raw TDS batch appends a `@@ROWCOUNT` sentinel and parses + the exact count. +- [Bridge #52](https://github.com/saurabh500/mssql-tiberius-bridge/issues/52) + tracks a missing session-reset API. The pool explicitly executes + `sp_reset_connection` and then reapplies the configured startup script on + every recycle. +- [Bridge #63](https://github.com/saurabh500/mssql-tiberius-bridge/issues/63) + tracks incomplete column metadata in the compatibility API. The query path + uses `inner_mut()` and reads `mssql-tds` result-set metadata directly, which + also preserves headers for zero-row result sets. +- [Bridge #88](https://github.com/saurabh500/mssql-tiberius-bridge/issues/88) + says cancellation safety under `tokio::time::timeout` has not been audited. + The plugin currently does not cancel in-flight client futures with a Tokio + timeout. This must be resolved before adding such a timeout. +- [Bridge #89](https://github.com/saurabh500/mssql-tiberius-bridge/issues/89) + tracks the unverified encryption-off handshake. It is relevant to the + plugin's `ssl_mode=disable` mapping and must be included in TLS live tests. +- [Bridge #90](https://github.com/saurabh500/mssql-tiberius-bridge/issues/90) + tracks missing malformed UTF-16 regression coverage. The underlying decoder + is expected to substitute U+FFFD, but the plugin has no independent wire + fixture for that case. + +The publishing fork's open issues concern metadata test constructors and +`sp_prepare`; the plugin uses neither prepared statements nor those private +constructors. Microsoft's current repository has newer issues, but they do not +necessarily describe the immutable preview.1 source. Any candidate upgrade +must triage the issues for its exact source commit rather than assuming fixes +or regressions carry across forks. + +## Upgrade procedure + +When considering preview.4 or any later release: + +1. Read the bridge and protocol changelogs and compare both source tags against + the currently recorded commits. Triage the issues above and all new issues + touching pooling, TLS, query draining, metadata, values, or DML counts. +2. Inspect the candidate bridge manifest and update + `mssql-tiberius-bridge` and the direct `mssql-tds-preview` pin together to + the exact protocol version it requires. Keep `default-features = false` on + the protocol edge. +3. Run `cargo update` only for those packages, review the complete + `Cargo.lock` and `cargo tree -p mssql-tiberius-bridge` diffs, and repeat the + licence inventory below for every newly resolved package. +4. Run `cargo audit`, unit tests, clippy, formatting, a release build, and the + live SQL Server integration suite. The live suite must cover zero-row + metadata, DML row counts, `IDENTITY_INSERT`, pagination, error recovery, + pool reuse, TLS modes, and SHOWPLAN capture. +5. Land the upgrade as an explicit dependency change. Never relax the exact + pin merely because upstream labels two previews API-compatible. + +## Fallback plan + +If the bridge is abandoned or develops a blocking correctness, security, or +reliability bug that cannot be fixed promptly, the fallback is the stable +`tiberius 0.12` implementation that this branch replaced. It remains one +revert away in the parent of client-swap commit +[`f2afb7b`](https://github.com/TabularisDB/tabularis-sqlserver-plugin/commit/f2afb7b). +Restore that implementation rather than carrying an indefinite private fork of +both preview crates. + +The rollback must restore `Cargo.toml` and `Cargo.lock`, then move the client +API adaptations back in `src/main.rs` and these driver files: + +- `src/driver/mod.rs`, `ops.rs`, `pool.rs`, and `helpers.rs`; +- `src/driver/explain.rs`, `introspection.rs`, and `triggers/mod.rs`; +- `src/driver/extract/mod.rs` and `extract/temporal.rs`; +- the corresponding helper, introspection, and extraction tests. + +`README.md`, `CHANGELOG.md`, and `CLAUDE.md` must again name Tiberius. Preserve +JSON-RPC behaviour and the post-swap correctness tests where their semantics +apply, then run the same unit, live-database, audit, clippy, formatting, and +release gates before publishing the rollback. + +## Licence inventory + +`cargo metadata --locked` was compared with `main` by package name and +version. The bridge swap introduces or upgrades the following 74 package +identities. Every SPDX expression offers MIT, Apache-2.0, or both; those +choices are compatible with an Apache-2.0 binary. Historical +`MIT/Apache-2.0` metadata means dual-licensed. For `r-efi`, the MIT alternative +is selected, not LGPL. + + + +| Declared licence | New or upgraded packages in the lock graph | +| --- | --- | +| `MIT OR Apache-2.0` | `asn1-rs 0.7.2`, `asn1-rs-derive 0.6.0`, `chacha20 0.10.2`, `core-foundation 0.10.1`, `cpufeatures 0.3.0`, `der-parser 10.0.0`, `deranged 0.5.8`, `displaydoc 0.2.7`, `getrandom 0.4.3`, `hashbrown 0.15.5`, `native-tls 0.2.18`, `num-bigint 0.4.8`, `num-conv 0.2.2`, `num-integer 0.1.46`, `oid-registry 0.8.1`, `openssl-probe 0.2.1`, `pkg-config 0.3.33`, `powerfmt 0.2.0`, `rand 0.10.2`, `rand_core 0.10.1`, `security-framework 3.7.0`, `socket2 0.5.10`, `tempfile 3.27.0`, `thiserror 2.0.19`, `thiserror-impl 2.0.19`, `time 0.3.54`, `time-core 0.1.9`, `time-macros 0.2.32`, `windows 0.58.0`, `windows-core 0.58.0`, `windows-implement 0.58.0`, `windows-interface 0.58.0`, `windows-result 0.2.0`, `windows-strings 0.1.0`, `windows-sys 0.60.2`, `windows-targets 0.53.5`, `windows_aarch64_gnullvm 0.53.1`, `windows_aarch64_msvc 0.53.1`, `windows_i686_gnu 0.53.1`, `windows_i686_gnullvm 0.53.1`, `windows_i686_msvc 0.53.1`, `windows_x86_64_gnu 0.53.1`, `windows_x86_64_gnullvm 0.53.1`, `windows_x86_64_msvc 0.53.1`, `x509-parser 0.18.1` | +| `MIT/Apache-2.0` | `asn1-rs-impl 0.2.0`, `bigdecimal 0.4.10`, `dns-lookup 2.1.1`, `foreign-types 0.3.2`, `foreign-types-shared 0.1.1`, `minimal-lexical 0.2.1`, `openssl-macros 0.1.1`, `rusticata-macros 4.1.0`, `vcpkg 0.2.15`, `winapi 0.3.9`, `winapi-i686-pc-windows-gnu 0.4.0`, `winapi-x86_64-pc-windows-gnu 0.4.0` | +| `MIT` | `async-stream 0.3.6`, `async-stream-impl 0.3.6`, `data-encoding 2.11.0`, `hostname 0.4.2`, `libm 0.2.16`, `mssql-tds-preview 0.1.0-preview.1`, `mssql-tiberius-bridge 0.1.0-preview.3`, `nom 7.1.3`, `openssl-sys 0.9.117`, `pretty-hex 0.4.2`, `synstructure 0.13.2`, `tokio-native-tls 0.3.1` | +| `Apache-2.0 OR MIT` | `fastrand 2.5.0` | +| `Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT` | `linux-raw-sys 0.12.1`, `rustix 1.1.4` | +| `Apache-2.0` | `openssl 0.10.81` | +| `MIT OR Apache-2.0 OR LGPL-2.1-or-later` | `r-efi 6.0.0` | + + + +This inventory is based on package manifests as resolved by Cargo, including +platform-specific and lockfile-only entries. Release packaging must retain the +applicable third-party notices; this review does not replace that packaging +step. + +## RustSec audit + +`cargo audit 0.22.2` scanned 208 locked dependencies against 1,226 advisories. +It found: + +- **RUSTSEC-2026-0235 (`rkyv 0.7.46`):** `rkyv` is present only because + `rust_decimal` declares it as an optional feature. The plugin does not enable + that feature and `cargo tree -i rkyv` reports no dependency path, so the + vulnerable code is not compiled or reachable in the shipped binary. CI + ignores this one advisory with an inline rationale. Re-check the ignore on + every `rust_decimal` upgrade or feature change. +- **Yanked `chacha20 0.10.1`:** resolved by updating the lockfile to the + non-yanked compatible `0.10.2`. It enters through + `mssql-tds-preview -> uuid -> rand`. + +After that lockfile update, +`cargo audit --ignore RUSTSEC-2026-0235` passes with no other vulnerability or +yank finding. CI runs the same audit on pushes and pull requests and on a +weekly off-peak schedule; scheduled runs have permission to file tracking +issues for new informational findings. From cb5a243efb79662e071928676ad106d55b8f0559 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 10:59:49 +0000 Subject: [PATCH 5/6] test: add live SQL Server integration coverage --- .github/workflows/ci.yml | 43 ++- justfile | 5 +- tests/live_db.rs | 695 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 740 insertions(+), 3 deletions(-) create mode 100644 tests/live_db.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff43dc7..125dc9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,9 @@ jobs: run: cargo build - name: Run tests - run: cargo test + # This crate has no library target, so --lib would fail. --bins keeps + # the live SQL Server test in its dedicated job below. + run: cargo test --bins - name: Clippy run: cargo clippy --all-targets -- -D warnings @@ -32,6 +34,45 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + live-db-integration: + name: Live SQL Server integration + runs-on: ubuntu-latest + # SQL Server needs more startup time and memory than PostgreSQL. GitHub's + # hosted runner has sufficient memory; generous health retries wait for + # readiness without a fixed sleep. + services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + ports: + - 1433:1433 + env: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: "Str0ng!Passw0rd" + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Str0ng!Passw0rd' -C -Q 'SELECT 1' -b" + --health-interval 10s + --health-timeout 5s + --health-start-period 20s + --health-retries 30 + steps: + - uses: actions/checkout@v7 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build + run: cargo build + + - name: Run live-database integration test + env: + SQLSERVER_PLUGIN_BIN: ${{ github.workspace }}/target/debug/sqlserver-plugin + SQLSERVER_TEST_HOST: 127.0.0.1 + SQLSERVER_TEST_PORT: 1433 + SQLSERVER_TEST_USER: sa + SQLSERVER_TEST_PASSWORD: "Str0ng!Passw0rd" + SQLSERVER_TEST_DATABASE: tabularis_test + run: cargo test --test live_db -- --test-threads=1 + audit: name: Security audit runs-on: ubuntu-latest diff --git a/justfile b/justfile index 53cc925..c8df0de 100644 --- a/justfile +++ b/justfile @@ -30,9 +30,10 @@ build: build-ui release: build-ui cargo build --release -# Run unit tests. +# Run unit tests only. This crate is binary-only, so --lib would fail; --bins +# also keeps tests/live_db.rs out of the default run. test: - cargo test + cargo test --bins # Launch the local REPL that simulates Tabularis JSON-RPC calls over stdio. repl: diff --git a/tests/live_db.rs b/tests/live_db.rs new file mode 100644 index 0000000..4680c78 --- /dev/null +++ b/tests/live_db.rs @@ -0,0 +1,695 @@ +//! Live SQL Server integration coverage for the plugin's JSON-RPC boundary. +//! +//! Every call in this file is sent to the compiled plugin over stdin/stdout, +//! exactly as Tabularis sends it. The suite creates its own database, schema, +//! and scratch tables, so it does not depend on `just seed-sqlserver`. +//! +//! Run against the container started by `just run-sqlserver`: +//! +//! ```bash +//! cargo test --test live_db -- --test-threads=1 +//! ``` +//! +//! `SQLSERVER_TEST_HOST`, `SQLSERVER_TEST_PORT`, `SQLSERVER_TEST_USER`, +//! `SQLSERVER_TEST_PASSWORD`, `SQLSERVER_TEST_DATABASE`, and +//! `SQLSERVER_PLUGIN_BIN` override the local defaults. + +use std::collections::BTreeSet; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; + +use serde_json::{json, Value}; + +const TEST_SCHEMA: &str = "ss003"; + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn test_database() -> String { + env_or("SQLSERVER_TEST_DATABASE", "tabularis_test") +} + +fn connection_params_for(database: &str, connection_id: &str) -> Value { + json!({ + "driver": "sqlserver", + "host": env_or("SQLSERVER_TEST_HOST", "127.0.0.1"), + "port": env_or("SQLSERVER_TEST_PORT", "1433") + .parse::() + .expect("SQLSERVER_TEST_PORT must be a valid port"), + "username": env_or("SQLSERVER_TEST_USER", "sa"), + "password": env_or("SQLSERVER_TEST_PASSWORD", "Str0ng!Passw0rd"), + "database": database, + "ssl_mode": "require", + "connection_id": connection_id, + }) +} + +fn connection_params() -> Value { + connection_params_for(&test_database(), "ss003-live") +} + +fn bracket_quote(identifier: &str) -> String { + format!("[{}]", identifier.replace(']', "]]")) +} + +fn string_literal(value: &str) -> String { + value.replace('\'', "''") +} + +/// A running plugin process driven through real newline-delimited JSON-RPC. +struct Plugin { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Plugin { + fn spawn() -> Self { + let bin = std::env::var("SQLSERVER_PLUGIN_BIN") + .unwrap_or_else(|_| env!("CARGO_BIN_EXE_sqlserver-plugin").to_string()); + let mut child = Command::new(bin) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("failed to spawn plugin binary"); + let stdin = child.stdin.take().expect("plugin stdin was not piped"); + let stdout = BufReader::new(child.stdout.take().expect("plugin stdout was not piped")); + Self { + child, + stdin, + stdout, + next_id: 1, + } + } + + fn with_scratch_database() -> Self { + let mut plugin = Self::spawn(); + let database = test_database(); + + if !database.eq_ignore_ascii_case("master") { + let create_database = format!( + "IF DB_ID(N'{}') IS NULL EXEC(N'CREATE DATABASE {}')", + string_literal(&database), + bracket_quote(&database), + ); + plugin.call_ok( + "execute_query", + json!({ + "params": connection_params_for("master", "ss003-master"), + "query": create_database, + }), + ); + } + + plugin.call_ok( + "execute_query", + json!({ + "params": connection_params(), + "query": format!( + "IF SCHEMA_ID(N'{TEST_SCHEMA}') IS NULL EXEC(N'CREATE SCHEMA [{TEST_SCHEMA}]')" + ), + }), + ); + plugin + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let request = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": id, + }); + let mut line = serde_json::to_string(&request).expect("serialize JSON-RPC request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .expect("write request to plugin stdin"); + self.stdin.flush().expect("flush plugin stdin"); + + let mut response_line = String::new(); + self.stdout + .read_line(&mut response_line) + .expect("read response from plugin stdout"); + assert!( + !response_line.is_empty(), + "plugin exited without a response" + ); + let response: Value = + serde_json::from_str(response_line.trim()).expect("parse JSON-RPC response"); + assert_eq!( + response.get("id").and_then(Value::as_u64), + Some(id), + "response id must match its request" + ); + response + } + + fn call_ok(&mut self, method: &str, params: Value) -> Value { + let response = self.call(method, params); + assert!( + response.get("error").is_none(), + "{method} returned an error: {:?}", + response.get("error") + ); + response + .get("result") + .cloned() + .unwrap_or_else(|| panic!("{method} returned neither result nor error")) + } + + fn call_error(&mut self, method: &str, params: Value) -> String { + let response = self.call(method, params); + response + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("{method} unexpectedly succeeded: {response}")) + .to_string() + } + + fn execute(&mut self, query: impl Into) -> Value { + self.call_ok( + "execute_query", + json!({ "params": connection_params(), "query": query.into() }), + ) + } + + fn reset_table(&mut self, table: &str, definition: &str) { + self.execute(format!( + "DROP TABLE IF EXISTS [{TEST_SCHEMA}].[{table}]; \ + CREATE TABLE [{TEST_SCHEMA}].[{table}] ({definition})" + )); + } +} + +impl Drop for Plugin { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn result_rows(result: &Value) -> &Vec { + result + .get("rows") + .and_then(Value::as_array) + .expect("query result must contain a rows array") +} + +fn generated_create_table_sql( + plugin: &mut Plugin, + table_name: &str, + columns: Value, +) -> Vec { + plugin + .call_ok( + "get_create_table_sql", + json!({ "table_name": table_name, "schema": TEST_SCHEMA, "columns": columns }), + ) + .as_array() + .expect("DDL result must be an array") + .iter() + .map(|statement| { + statement + .as_str() + .expect("DDL statement must be a string") + .to_string() + }) + .collect() +} + +#[test] +fn test_connection_and_ping_succeed_with_tls_required() { + let mut plugin = Plugin::with_scratch_database(); + let test_result = plugin.call_ok("test_connection", json!({ "params": connection_params() })); + assert_eq!(test_result, json!({ "success": true })); + + let ping_result = plugin.call_ok("ping", json!({ "params": connection_params() })); + assert_eq!(ping_result, Value::Null); +} + +#[test] +fn ddl_creates_identity_composite_and_all_data_type_categories() { + let mut plugin = Plugin::with_scratch_database(); + for table in ["ddl_identity", "ddl_composite", "ddl_categories"] { + plugin.execute(format!("DROP TABLE IF EXISTS [{TEST_SCHEMA}].[{table}]")); + } + + let identity_sql = generated_create_table_sql( + &mut plugin, + "ddl_identity", + json!([ + { + "name": "id", "data_type": "INT", "is_nullable": false, + "is_pk": true, "is_auto_increment": true, "default_value": null + }, + { + "name": "name", "data_type": "NVARCHAR(100)", "is_nullable": false, + "is_pk": false, "is_auto_increment": false, "default_value": null + } + ]), + ); + for statement in identity_sql { + plugin.execute(statement); + } + + let composite_sql = generated_create_table_sql( + &mut plugin, + "ddl_composite", + json!([ + { + "name": "tenant_id", "data_type": "INT", "is_nullable": false, + "is_pk": true, "is_auto_increment": false, "default_value": null + }, + { + "name": "record_id", "data_type": "INT", "is_nullable": false, + "is_pk": true, "is_auto_increment": false, "default_value": null + }, + { + "name": "value", "data_type": "NVARCHAR(100)", "is_nullable": true, + "is_pk": false, "is_auto_increment": false, "default_value": null + } + ]), + ); + for statement in composite_sql { + plugin.execute(statement); + } + + // One representative from every manifest category: numeric, text, + // binary, datetime, boolean, other, and spatial. + let categories_sql = generated_create_table_sql( + &mut plugin, + "ddl_categories", + json!([ + { + "name": "numeric_value", "data_type": "DECIMAL(18,2)", "is_nullable": true, + "is_pk": false, "is_auto_increment": false, "default_value": null + }, + { + "name": "text_value", "data_type": "NVARCHAR(100)", "is_nullable": true, + "is_pk": false, "is_auto_increment": false, "default_value": null + }, + { + "name": "binary_value", "data_type": "VARBINARY(100)", "is_nullable": true, + "is_pk": false, "is_auto_increment": false, "default_value": null + }, + { + "name": "datetime_value", "data_type": "DATETIME2", "is_nullable": true, + "is_pk": false, "is_auto_increment": false, "default_value": null + }, + { + "name": "boolean_value", "data_type": "BIT", "is_nullable": true, + "is_pk": false, "is_auto_increment": false, "default_value": null + }, + { + "name": "other_value", "data_type": "UNIQUEIDENTIFIER", "is_nullable": true, + "is_pk": false, "is_auto_increment": false, "default_value": null + }, + { + "name": "spatial_value", "data_type": "GEOGRAPHY", "is_nullable": true, + "is_pk": false, "is_auto_increment": false, "default_value": null + } + ]), + ); + for statement in categories_sql { + plugin.execute(statement); + } + + let identity_columns = plugin.call_ok( + "get_columns", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "ddl_identity" + }), + ); + assert_eq!(identity_columns[0]["is_pk"], true); + assert_eq!(identity_columns[0]["is_auto_increment"], true); + + let composite_columns = plugin.call_ok( + "get_columns", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "ddl_composite" + }), + ); + assert_eq!( + composite_columns + .as_array() + .expect("columns array") + .iter() + .filter(|column| column["is_pk"] == true) + .count(), + 2 + ); + + let category_columns = plugin.call_ok( + "get_columns", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "ddl_categories" + }), + ); + let names: BTreeSet<&str> = category_columns + .as_array() + .expect("columns array") + .iter() + .filter_map(|column| column["name"].as_str()) + .collect(); + assert_eq!(names.len(), 7); +} + +#[test] +fn crud_insert_update_and_delete_support_single_and_composite_primary_keys() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "crud_single", + "id INT IDENTITY(1,1) PRIMARY KEY, value NVARCHAR(100) NOT NULL", + ); + plugin.reset_table( + "crud_composite", + "tenant_id INT NOT NULL, record_id INT NOT NULL, value NVARCHAR(100) NOT NULL, \ + PRIMARY KEY (tenant_id, record_id)", + ); + + let inserted = plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "crud_single", + "data": { "value": "before" } + }), + ); + assert_eq!(inserted, json!(1)); + let single_row = plugin.execute(format!( + "SELECT id, value FROM [{TEST_SCHEMA}].[crud_single]" + )); + let single_id = single_row["rows"][0][0].as_i64().expect("identity id"); + + assert_eq!( + plugin.call_ok( + "update_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "crud_single", + "pk_map": { "id": single_id }, "col_name": "value", "new_val": "after" + }), + ), + json!(1) + ); + assert_eq!( + plugin.call_ok( + "delete_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "crud_single", + "pk_map": { "id": single_id } + }), + ), + json!(1) + ); + + assert_eq!( + plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "crud_composite", + "data": { "tenant_id": 7, "record_id": 9, "value": "before" } + }), + ), + json!(1) + ); + assert_eq!( + plugin.call_ok( + "update_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "crud_composite", + "pk_map": { "record_id": 9, "tenant_id": 7 }, + "col_name": "value", "new_val": "after" + }), + ), + json!(1) + ); + let updated = plugin.execute(format!( + "SELECT value FROM [{TEST_SCHEMA}].[crud_composite] \ + WHERE tenant_id = 7 AND record_id = 9" + )); + assert_eq!(updated["rows"][0], json!(["after"])); + assert_eq!( + plugin.call_ok( + "delete_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "crud_composite", "pk_map": { "tenant_id": 7, "record_id": 9 } + }), + ), + json!(1) + ); +} + +#[test] +fn zero_row_select_preserves_column_headers() { + let mut plugin = Plugin::with_scratch_database(); + let result = plugin + .execute("SELECT CAST(1 AS INT) AS id, CAST(N'x' AS NVARCHAR(10)) AS label WHERE 1 = 0"); + assert_eq!(result["columns"], json!(["id", "label"])); + assert!(result_rows(&result).is_empty()); +} + +#[test] +fn multi_statement_and_batch_rpc_preserve_result_sets_and_temp_table_session() { + let mut plugin = Plugin::with_scratch_database(); + let multi = plugin.execute( + "SELECT CAST(1 AS INT) AS first_value; \ + CREATE TABLE #ss003_multi (value INT NOT NULL); \ + INSERT INTO #ss003_multi VALUES (2), (3); \ + SELECT value FROM #ss003_multi ORDER BY value", + ); + assert_eq!(multi["columns"], json!(["first_value"])); + assert_eq!(multi["rows"], json!([[1]])); + assert_eq!(multi["additional_results"][0]["columns"], json!(["value"])); + assert_eq!(multi["additional_results"][0]["rows"], json!([[2], [3]])); + + let batch = plugin.call_ok( + "execute_query_batch", + json!({ + "params": connection_params(), + "queries": [ + "CREATE TABLE #ss003_batch (value INT NOT NULL)", + "INSERT INTO #ss003_batch VALUES (10), (20)", + "SELECT value FROM #ss003_batch ORDER BY value" + ] + }), + ); + assert_eq!(batch[1]["result"]["affected_rows"], 2); + assert_eq!(batch[2]["result"]["columns"], json!(["value"])); + assert_eq!(batch[2]["result"]["rows"], json!([[10], [20]])); +} + +#[test] +fn affected_rows_cover_plain_multi_statement_and_output_dml() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table("affected_rows", "id INT PRIMARY KEY, value INT NOT NULL"); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[affected_rows] VALUES (1, 0), (2, 0), (3, 0)" + )); + + let plain = plugin.execute(format!( + "UPDATE [{TEST_SCHEMA}].[affected_rows] SET value = 1 WHERE id <= 2" + )); + assert_eq!(plain["affected_rows"], 2); + assert_eq!(plain["rows"], json!([])); + + let multi = plugin.execute(format!( + "UPDATE [{TEST_SCHEMA}].[affected_rows] SET value = 2; \ + DELETE FROM [{TEST_SCHEMA}].[affected_rows] WHERE id = 3" + )); + assert_eq!( + multi["affected_rows"], 1, + "a batch reports the final DML statement's @@ROWCOUNT" + ); + + let output = plugin.execute(format!( + "UPDATE [{TEST_SCHEMA}].[affected_rows] SET value = 3 \ + OUTPUT inserted.id WHERE id <= 2" + )); + assert_eq!(output["affected_rows"], 2); + assert_eq!(output["columns"], json!(["id"])); + let mut ids: Vec = result_rows(&output) + .iter() + .map(|row| row[0].as_i64().expect("OUTPUT id must be an integer")) + .collect(); + ids.sort_unstable(); + assert_eq!(ids, [1, 2]); + assert!(output.get("additional_results").is_none()); +} + +#[test] +fn identity_insert_succeeds_and_failure_restores_session_state() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "identity_first", + "id INT IDENTITY(1,1) PRIMARY KEY, value NVARCHAR(100) NOT NULL", + ); + plugin.reset_table( + "identity_second", + "id INT IDENTITY(1,1) PRIMARY KEY, value NVARCHAR(100) NOT NULL", + ); + + let explicit = plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "identity_first", + "data": { "id": 100, "value": "explicit" } + }), + ); + assert_eq!(explicit, json!(1)); + + let duplicate_error = plugin.call_error( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "identity_first", + "data": { "id": 100, "value": "duplicate" } + }), + ); + assert!(!duplicate_error.is_empty()); + + // SQL Server permits IDENTITY_INSERT ON for only one table per session. + // An explicit insert into a second table therefore proves the failed + // first-table batch turned the session-scoped setting back off. + let recovered = plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": "identity_second", + "data": { "id": 200, "value": "session-recovered" } + }), + ); + assert_eq!(recovered, json!(1)); +} + +#[test] +fn pagination_returns_ordered_pages_has_more_and_explicit_unknown_total() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table("pagination", "id INT PRIMARY KEY"); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[pagination] VALUES (1), (2), (3), (4), (5)" + )); + let query = format!("SELECT id FROM [{TEST_SCHEMA}].[pagination] ORDER BY id"); + + let page_one = plugin.call_ok( + "execute_query", + json!({ "params": connection_params(), "query": query, "limit": 2, "page": 1 }), + ); + assert_eq!(page_one["rows"], json!([[1], [2]])); + assert_eq!(page_one["pagination"]["page"], 1); + assert_eq!(page_one["pagination"]["page_size"], 2); + assert_eq!(page_one["pagination"]["has_more"], true); + assert_eq!(page_one["pagination"]["total_rows"], Value::Null); + + let page_two = plugin.call_ok( + "execute_query", + json!({ "params": connection_params(), "query": query, "limit": 2, "page": 2 }), + ); + assert_eq!(page_two["rows"], json!([[3], [4]])); + assert_eq!(page_two["pagination"]["page"], 2); + assert_eq!(page_two["pagination"]["has_more"], true); + assert_eq!(page_two["pagination"]["total_rows"], Value::Null); +} + +#[test] +fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "errors", + "id INT PRIMARY KEY, unique_value INT NOT NULL UNIQUE", + ); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[errors] VALUES (1, 10)" + )); + + let syntax_error = plugin.call_error( + "execute_query", + json!({ "params": connection_params(), "query": "SELEC definitely_invalid" }), + ); + assert!(!syntax_error.is_empty()); + let after_syntax = plugin.execute("SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(after_syntax["rows"], json!([[1]])); + + let constraint_error = plugin.call_error( + "execute_query", + json!({ + "params": connection_params(), + "query": format!("INSERT INTO [{TEST_SCHEMA}].[errors] VALUES (2, 10)") + }), + ); + assert!(!constraint_error.is_empty()); + let after_constraint = plugin.execute(format!( + "SELECT COUNT(*) AS row_count FROM [{TEST_SCHEMA}].[errors]" + )); + assert_eq!(after_constraint["rows"], json!([[1]])); +} + +#[test] +fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table("explain", "id INT PRIMARY KEY, value INT NOT NULL"); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[explain] VALUES (1, 10), (2, 20)" + )); + let query = format!("SELECT value FROM [{TEST_SCHEMA}].[explain] WHERE id = 1"); + + for analyze in [false, true] { + let plan = plugin.call_ok( + "explain_query", + json!({ + "params": connection_params(), + "query": query, + "analyze": analyze + }), + ); + let raw = plan["raw_output"] + .as_str() + .expect("parsed plan must retain its raw SHOWPLAN XML"); + assert!(raw.contains("ShowPlanXML"), "analyze={analyze}: {raw}"); + assert_eq!(plan["driver"], "sqlserver"); + } +} + +#[test] +fn startup_script_runs_on_pooled_connections() { + let mut plugin = Plugin::with_scratch_database(); + let mut params = connection_params(); + params["connection_id"] = json!("ss003-startup-script"); + params["startup_script"] = json!("SET DATEFIRST 3"); + + plugin.call_ok("test_connection", json!({ "params": params })); + let result = plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "SELECT @@DATEFIRST AS date_first" }), + ); + assert_eq!(result["rows"], json!([[3]])); +} + +#[test] +fn connection_string_only_is_rejected_until_ss_011() { + let mut plugin = Plugin::with_scratch_database(); + let params = connection_params(); + let connection_string = format!( + "sqlserver://{}:{}@{}:{}/{}", + params["username"].as_str().expect("username"), + params["password"].as_str().expect("password"), + params["host"].as_str().expect("host"), + params["port"].as_u64().expect("port"), + params["database"].as_str().expect("database"), + ); + + // TODO(SS-011): change this to call_ok once ConnectionParams accepts and + // parses connection_string. Today serde ignores the field and the plugin + // attempts its empty/default discrete connection, which must fail. + let error = plugin.call_error( + "test_connection", + json!({ "params": { "connection_string": connection_string } }), + ); + assert!(!error.is_empty()); +} From 11b4302340e4a8c26527d00e5d0b4d9ec9cdb331 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sun, 30 Aug 2026 11:03:25 +0000 Subject: [PATCH 6/6] docs: close out mssql-tds bridge PR Document the client transition and automated live validation, and record the closing test-count and release-size comparison for PR 1. Co-authored-by: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> --- CHANGELOG.md | 7 ++++++- CLAUDE.md | 2 +- README.md | 4 +++- docs/pr1-baseline.md | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9bace3..b23c9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Replaced the `tiberius` TDS client with Microsoft's `mssql-tds` implementation through `mssql-tiberius-bridge`, preserving the plugin's user-facing connection and query behaviour + ### Added -- Initial SQL Server driver: `mssql-tiberius-bridge` + `deadpool` pooling, TLS modes, session reset, startup scripts +- Automated live SQL Server 2022 JSON-RPC integration tests for TLS, DDL, CRUD, result-set metadata, affected rows, identity recovery, pagination, error recovery, execution plans, and startup scripts +- Initial SQL Server driver with `deadpool` pooling, TLS modes, session reset, and startup scripts - Schema, table, column, PK/FK, index, view, routine, and trigger introspection - Query execution with pagination, CTE/DML classification, multiple result sets, and accurate affected rows (incl. DML `OUTPUT`) - INSERT/UPDATE/DELETE with composite primary keys and safe `IDENTITY_INSERT` recovery diff --git a/CLAUDE.md b/CLAUDE.md index 8862233..63b6ab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ src/ handlers/ # thin JSON adapters, one module per RPC area driver/ # SQL Server logic ops.rs # one free function per host RPC method - pool.rs # mssql-tiberius-bridge Config + deadpool Manager (TLS modes, startup scripts) + pool.rs # Microsoft mssql-tds client via bridge + deadpool Manager (TLS modes, startup scripts) introspection.rs, helpers.rs, ddl/, routines/, triggers/, types.rs, version.rs extract/ # row → JSON value extraction (incl. temporal types) explain.rs # SHOWPLAN_XML / STATISTICS XML capture diff --git a/README.md b/README.md index bb549d3..d6cf602 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ A [Microsoft SQL Server](https://www.microsoft.com/sql-server) plugin for [Tabul This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. +The client was swapped to Microsoft's protocol implementation to align the plugin with the actively developed upstream SQL Server stack while the bridge preserves the API the driver uses. This is an internal transport change: connection settings and user-facing behaviour are unchanged, and existing users do not need to migrate anything. + **Discord** - [Join our discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. ## Table of Contents @@ -33,7 +35,7 @@ This plugin enables Tabularis to connect to SQL Server instances, providing sche ## Features -- Microsoft `mssql-tds` (`mssql-tiberius-bridge`) + `deadpool` connection pooling with session reset (`sp_reset_connection`), startup scripts, and pool lifecycle handling +- Microsoft's `mssql-tds` protocol implementation through `mssql-tiberius-bridge`, with `deadpool` connection pooling, session reset (`sp_reset_connection`), startup scripts, and pool lifecycle handling - Schema, table, column, PK/FK, index, view, routine, and trigger introspection - Query execution with pagination, CTE/DML classification, multiple result sets, and session-preserving batches - Accurate affected rows, including multi-statement DML and DML `OUTPUT` diff --git a/docs/pr1-baseline.md b/docs/pr1-baseline.md index a1257ff..16df420 100644 --- a/docs/pr1-baseline.md +++ b/docs/pr1-baseline.md @@ -117,3 +117,20 @@ A hand-written JSON-RPC request was piped as one line into ``` The process exited after stdin closed and emitted nothing on stderr. + +## Closing comparison + +Measured on 2026-08-30 at `cb5a243`, after the review, dependency audit, and +live-suite tasks and before the close-out documentation commit. + +| Measure | Opening | Closing | Difference | +| --- | ---: | ---: | ---: | +| Unit tests | 134 | 149 | +15 | +| Automated live SQL Server tests | 0 | 12 | +12 | +| Release binary | 3,023,312 bytes | 6,064,496 bytes | +3,041,184 bytes (+100.6%) | +| Release binary (`du -h`) | 2.9 MiB | 5.8 MiB | +2.9 MiB | + +The closing unit count comes from `cargo test --bins`; the live count comes +from `cargo test --test live_db -- --test-threads=1`; and the release size was +measured after `cargo build --release` with `stat -c %s` and `du -h`. All 149 +unit tests and all 12 live tests passed.