Skip to main content

rivet/source/mssql/
mod.rs

1//! **Layer: Execution** — MSSQL / SQL Server source engine.
2//!
3//! Third SQL engine after PostgreSQL and MySQL. The `tiberius` driver is
4//! async (tokio); the `Source` trait is sync `&mut self` (ADR-0011), so each
5//! `MssqlSource` owns a current-thread `tokio` runtime and `block_on`s every
6//! driver call — no async leaks into the runner.
7//!
8//! Dialect deltas vs PG/MySQL (routed through the shared seams):
9//! - identifier quoting `[col]` (`sql::quote_ident`)
10//! - cursor literal `N'…'` with `''` escaping (`query::cursor_rhs`)
11//! - introspection via `sys.*` catalog views
12//!
13//! Supported today: snapshot / incremental / chunked (range + dense) and keyset
14//! (seek) export, `check --type-report`, `doctor`, chunked-mode planning. The
15//! keyset page builder emits a dialect-correct
16//! `OFFSET 0 ROWS FETCH NEXT n ROWS ONLY` clause (T-SQL has no `LIMIT`).
17
18mod arrow_convert;
19pub(crate) mod cdc;
20mod proxy;
21
22pub use proxy::MssqlProxyKind;
23
24use std::collections::HashMap;
25use std::sync::Arc;
26
27use arrow::datatypes::SchemaRef;
28use tiberius::{AuthMethod, Client, Config, EncryptionLevel};
29use tokio::net::TcpStream;
30use tokio::runtime::Runtime;
31use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
32
33use proxy::{detect_mssql_proxy_kind, warn_proxy_kind};
34
35use crate::config::{TlsConfig, TlsMode};
36use crate::error::Result;
37use crate::source::batch_controller::{
38    AdaptiveBatchController, DEFAULT_BATCH_TARGET_MB, PROBE_BATCH_SIZE,
39};
40use crate::source::query::build_export_query;
41use crate::source::{BatchSink, ExportRequest, Source, TableIntrospection};
42use crate::types::{ColumnOverrides, TypeMapping};
43
44type MssqlClient = Client<Compat<TcpStream>>;
45
46/// SQL Server source. Owns the async driver + the runtime that drives it.
47///
48/// `pub` (not `pub(crate)`) so integration tests can reach `proxy_kind()` the
49/// same way they reach `MysqlSource::proxy_kind()`; the rest of the type
50/// carries the same "no external API contract" disclaimer as `MysqlSource`.
51pub struct MssqlSource {
52    rt: Runtime,
53    client: MssqlClient,
54    /// Pooler/gateway classification, sampled once at connect time.
55    proxy_kind: MssqlProxyKind,
56    /// Whether the export issued `SET LOCK_TIMEOUT` on this connection, so the
57    /// `Drop` teardown knows to reset it (Epic 18 B2 — pooler-safe session).
58    lock_timeout_applied: bool,
59}
60
61impl Drop for MssqlSource {
62    /// Pooler-safe session teardown (Epic 18 B2). rivet never opens a
63    /// transaction on this connection — every read is an autocommit `SELECT`,
64    /// so there is no transaction to leave dangling across the `block_on`
65    /// bridge (ADR-0011). The only session state the export mutates is
66    /// `SET LOCK_TIMEOUT`; reset it to the SQL Server default (`-1`, wait
67    /// indefinitely) before the connection closes so a *multiplexed* pooler
68    /// that keeps the backend connection alive cannot hand our non-default
69    /// `LOCK_TIMEOUT` to the next session that reuses it.
70    ///
71    /// Best-effort and time-boxed: after a failed read the stream is
72    /// half-drained and the connection is dying anyway, so the reset (and the
73    /// physical connection) just goes away; the 2 s cap guarantees `Drop`
74    /// can never hang on a wedged connection.
75    fn drop(&mut self) {
76        if !self.lock_timeout_applied {
77            return;
78        }
79        let Self { rt, client, .. } = self;
80        let _ = rt.block_on(async {
81            tokio::time::timeout(
82                std::time::Duration::from_secs(2),
83                client.execute("SET LOCK_TIMEOUT -1", &[]),
84            )
85            .await
86        });
87    }
88}
89
90/// Parsed `sqlserver://user[:password]@host[:port]/db` connection parts.
91pub(crate) struct MssqlUrl {
92    pub host: String,
93    pub port: u16,
94    pub user: String,
95    pub password: String,
96    pub database: String,
97}
98
99/// Percent-decode a URL userinfo component (lossy on invalid UTF-8, which a
100/// well-formed URL never produces). Mirrors the driver-side decode PG/MySQL/
101/// Mongo get for free, so the round-trip with `build_url_from_fields`' encoding
102/// is lossless.
103fn pct_decode(s: &str) -> String {
104    percent_encoding::percent_decode_str(s)
105        .decode_utf8_lossy()
106        .into_owned()
107}
108
109pub(crate) fn parse_mssql_url(url: &str) -> Result<MssqlUrl> {
110    let rest = url
111        .strip_prefix("sqlserver://")
112        .or_else(|| url.strip_prefix("mssql://"))
113        .ok_or_else(|| anyhow::anyhow!("mssql url must start with sqlserver:// — got {url}"))?;
114    // userinfo @ host:port / db   (rsplit the last '@' so a '@' in a password
115    // is tolerated; '/' splits host from db).
116    let (userinfo, hostpart) = rest
117        .rsplit_once('@')
118        .ok_or_else(|| anyhow::anyhow!("mssql url missing user@host: {url}"))?;
119    // Percent-DECODE the userinfo. `build_url_from_fields` (config/source.rs)
120    // percent-ENCODES user/password for every engine so a credential's
121    // `/ @ : ? #` can't break URL parsing or defeat redaction. PG/MySQL/Mongo
122    // hand the URL to a driver parser that decodes per RFC 3986; this hand-rolled
123    // MSSQL parser is the one that must decode itself, or tiberius receives the
124    // literal `%40`/`%2F` bytes and auth fails for any password outside the
125    // unreserved set — exactly the special chars SQL Server's complexity policy
126    // encourages (regression guarded by `mssql_url_percent_decodes_userinfo`).
127    let (user, password) = match userinfo.split_once(':') {
128        Some((u, p)) => (pct_decode(u), pct_decode(p)),
129        None => (pct_decode(userinfo), String::new()),
130    };
131    let (hostport, database) = hostpart
132        .split_once('/')
133        .map(|(h, d)| (h, d.to_string()))
134        .unwrap_or((hostpart, String::new()));
135    let (host, port) = match hostport.rsplit_once(':') {
136        Some((h, p)) => (
137            h.to_string(),
138            p.parse::<u16>()
139                .map_err(|_| anyhow::anyhow!("mssql url port not a number: {p}"))?,
140        ),
141        None => (hostport.to_string(), 1433),
142    };
143    if database.is_empty() {
144        anyhow::bail!("mssql url must include a database: sqlserver://user:pass@host:port/<db>");
145    }
146    Ok(MssqlUrl {
147        host,
148        port,
149        user,
150        password,
151        database,
152    })
153}
154
155impl MssqlSource {
156    /// Connect to SQL Server, honouring the shared `TlsConfig`. `url` is the
157    /// resolved `sqlserver://user:pass@host:port/db` form. A successful return
158    /// has completed a TLS login handshake and a `SELECT 1` round-trip.
159    pub fn connect_with_tls(url: &str, tls: Option<&TlsConfig>) -> Result<Self> {
160        // Refuse trust-any-cert to a remote host with no `tls:` block before any
161        // dial (CWE-295): SQL Server always encrypts the login handshake, but
162        // with `trust_cert` that handshake is unauthenticated, so a MITM is not
163        // detected. Loopback keeps trust-cert (dev); a remote host must opt in
164        // explicitly via `tls: { mode: ... }`.
165        crate::source::require_tls_or_loopback(url, tls)?;
166        let parts = parse_mssql_url(url)?;
167        let mut config = Config::new();
168        config.host(&parts.host);
169        config.port(parts.port);
170        config.database(&parts.database);
171        config.authentication(AuthMethod::sql_server(&parts.user, &parts.password));
172
173        // SQL Server forces TLS on the login handshake regardless; map the
174        // shared TlsConfig onto tiberius' cert-trust knobs. A private CA goes
175        // through `trust_cert_ca`; otherwise dev self-signed certs need
176        // `trust_cert` (accept-invalid). Default keeps full verification.
177        config.encryption(EncryptionLevel::Required);
178        match tls {
179            // `mode: disable` is the operator's explicit opt-in to an
180            // unauthenticated (trust-any-cert) connection — the SQL Server
181            // analogue of PG/MySQL remote plaintext. It is the documented way
182            // to keep trust-cert against a remote host the gate above would
183            // otherwise have refused.
184            Some(cfg) if cfg.mode == TlsMode::Disable || cfg.accept_invalid_certs => {
185                config.trust_cert()
186            }
187            Some(cfg) => {
188                // Strict cert validation is ON here (mode verify-ca/verify-full,
189                // no accept_invalid_certs). The TLS backend is OpenSSL
190                // (vendored-openssl via tiberius — see Cargo.toml), NOT
191                // rustls-webpki, so there is no CA name-constraint advisory to
192                // warn about: OpenSSL validates the chain against the trusted CA
193                // (and, for verify-full, the hostname) and rejects a certificate
194                // that does not chain to it (verified against a private-CA-
195                // configured SQL Server: the correct CA connects; a wrong CA is
196                // refused with OpenSSL `certificate verify failed`).
197                if let Some(ca) = cfg.ca_file.as_deref() {
198                    config.trust_cert_ca(ca);
199                }
200            }
201            None => {
202                // Reached only for a LOOPBACK host (the gate above refuses a
203                // remote host with no `tls:` block). On loopback, tiberius
204                // trusts the server certificate without verifying issuer or
205                // hostname: the handshake is encrypted but unauthenticated. That
206                // is safe here because the bytes never leave the box, and it
207                // keeps dev / self-signed docker setups working without opt-in.
208                // Warn once, naming the config key that turns on strict
209                // validation.
210                static WARNED: std::sync::Once = std::sync::Once::new();
211                WARNED.call_once(|| {
212                    log::warn!(
213                        "mssql: connecting with TLS certificate validation disabled \
214                         (no `source.tls:` block) — the connection is encrypted but the \
215                         server certificate is not verified (MITM not detected). Add \
216                         `source.tls: {{ mode: verify-full, ca_file: <ca.pem> }}` to enable \
217                         strict validation (or `mode: verify-ca` to skip only hostname checks)."
218                    );
219                });
220                config.trust_cert();
221            }
222        }
223
224        let rt = tokio::runtime::Builder::new_current_thread()
225            .enable_all()
226            .build()
227            .map_err(|e| anyhow::anyhow!("mssql: tokio runtime build failed: {e}"))?;
228
229        let client = rt.block_on(async {
230            let tcp = TcpStream::connect(config.get_addr())
231                .await
232                .map_err(|e| anyhow::anyhow!("mssql: TCP connect failed: {e}"))?;
233            tcp.set_nodelay(true).ok();
234            Client::connect(config, tcp.compat_write())
235                .await
236                .map_err(|e| anyhow::anyhow!("mssql: login failed: {e}"))
237        })?;
238
239        let mut src = Self {
240            rt,
241            client,
242            proxy_kind: MssqlProxyKind::Direct,
243            lock_timeout_applied: false,
244        };
245        // Health round-trip — surfaces auth/permission errors at connect time
246        // (doctor relies on this).
247        src.query_scalar("SELECT 1")?;
248        // Best-effort pooler/gateway detection (mirrors PG `pg_backend_pid`
249        // drift and MySQL `CONNECTION_ID()` drift): one warning at connect
250        // time, never breaks the export. Disjoint borrows of `rt` (&) and
251        // `client` (&mut).
252        let kind = detect_mssql_proxy_kind(&src.rt, &mut src.client);
253        warn_proxy_kind(kind);
254        src.proxy_kind = kind;
255        Ok(src)
256    }
257
258    /// Expose the proxy classification for diagnostics (preflight, integration
259    /// tests). Not part of the `Source` trait — same internal-may-change
260    /// contract as the rest of `rivet::source::mssql::*`.
261    #[allow(dead_code)]
262    pub fn proxy_kind(&self) -> MssqlProxyKind {
263        self.proxy_kind
264    }
265
266    /// Declared `(precision, scale)` per decimal/numeric column, read from
267    /// `sys.columns`, for a simple single-table `SELECT … FROM [schema.]table`.
268    /// `None` for any query the FROM parser does not handle (joins, comma lists,
269    /// subqueries) or when the lookup fails — the schema builder then falls back
270    /// to data-inference, today's behaviour. Never fails the export: a lookup
271    /// error is logged (mirrors `pg_numeric_catalog_hints_opt`) and downgraded
272    /// to `None`.
273    fn mssql_decimal_catalog_hints_opt(
274        &mut self,
275        query: &str,
276    ) -> Option<HashMap<String, (u8, i8)>> {
277        let (schema, table) = parse_mssql_simple_from_table(query)?;
278        match self.fetch_mssql_decimal_catalog_hints(&schema, &table) {
279            Ok(m) => m,
280            Err(e) => {
281                // The parser identified a single-table query but the catalog
282                // lookup itself failed (permissions, gateway). Surface it —
283                // otherwise a downstream decimal scale-0 freeze on an all-NULL
284                // first batch looks like a config problem when the real cause is
285                // a missing `sys.columns` read here.
286                log::warn!(
287                    "mssql decimal catalog lookup failed for {schema}.{table} — decimal scale \
288                     will fall back to first-batch inference (declare it with a `columns:` \
289                     override if an all-NULL first batch truncates it): {e}"
290                );
291                None
292            }
293        }
294    }
295
296    /// Probe `sys.columns` for each `decimal`/`numeric` column's declared
297    /// `(precision, scale)`. Joined through `sys.schemas`/`sys.objects` so the
298    /// `(schema, table)` pair resolves the exact base table the export reads.
299    fn fetch_mssql_decimal_catalog_hints(
300        &mut self,
301        schema: &str,
302        table: &str,
303    ) -> Result<Option<HashMap<String, (u8, i8)>>> {
304        // `decimal` and `numeric` are synonyms in SQL Server and share one
305        // `sys.types` entry per scale; filter on the base type name so only
306        // fixed-point columns (not money / int / float) carry a hint.
307        let sql = format!(
308            "SELECT c.name, c.precision, c.scale \
309             FROM sys.columns c \
310             JOIN sys.types t ON t.user_type_id = c.user_type_id \
311             JOIN sys.objects o ON o.object_id = c.object_id \
312             JOIN sys.schemas s ON s.schema_id = o.schema_id \
313             WHERE s.name = N'{}' AND o.name = N'{}' \
314             AND t.name IN ('decimal', 'numeric')",
315            schema.replace('\'', "''"),
316            table.replace('\'', "''")
317        );
318        let Self { rt, client, .. } = self;
319        let rows = rt.block_on(async {
320            client
321                .query(sql.as_str(), &[])
322                .await
323                .map_err(|e| anyhow::anyhow!("mssql: sys.columns probe failed: {e}"))?
324                .into_first_result()
325                .await
326                .map_err(|e| anyhow::anyhow!("mssql: reading sys.columns rows failed: {e}"))
327        })?;
328
329        let mut map = HashMap::new();
330        for row in &rows {
331            // sys.columns: name = sysname (nvarchar), precision/scale = tinyint.
332            // `try_get` (not `get`) so an unexpected cell type downgrades to a
333            // skipped hint rather than panicking the export.
334            let name: Option<&str> = row.try_get(0).ok().flatten();
335            let precision: Option<u8> = row.try_get(1).ok().flatten();
336            let scale: Option<u8> = row.try_get(2).ok().flatten();
337            if let (Some(name), Some(p), Some(s)) = (name, precision, scale)
338                && let Some(pair) = catalog_decimal_to_params(p, s)
339            {
340                map.insert(name.to_string(), pair);
341            }
342        }
343
344        if map.is_empty() {
345            Ok(None)
346        } else {
347            log::debug!(
348                "mssql decimal catalog: resolved {} DECIMAL/NUMERIC column(s) for {schema}.{table}",
349                map.len(),
350            );
351            Ok(Some(map))
352        }
353    }
354}
355
356/// Convert `sys.columns` `(precision, scale)` into Rivet `decimal(p, s)`
357/// parameters, rejecting anything outside the bounds the YAML overrides accept.
358/// SQL Server caps precision at 38 and scale ≤ precision, so a well-formed
359/// catalog row always passes; the guard defends against a degenerate row.
360fn catalog_decimal_to_params(precision: u8, scale: u8) -> Option<(u8, i8)> {
361    if precision == 0 || precision > 38 {
362        return None;
363    }
364    if scale > precision || scale > i8::MAX as u8 {
365        return None;
366    }
367    Some((precision, scale as i8))
368}
369
370/// Extract the `(schema, table)` of a simple single-table T-SQL
371/// `SELECT … FROM [schema.]table` (no joins, no comma list, no subquery in
372/// `FROM`). Returns `None` for anything more complex — the caller falls back to
373/// data-inference rather than guessing. Schema defaults to `dbo` when the table
374/// is unqualified. Handles `[bracketed]` and bare identifiers; pure `&str`
375/// work, so it is unit-testable without a live server.
376fn parse_mssql_simple_from_table(query: &str) -> Option<(String, String)> {
377    let from_idx = mssql_find_outer_from_keyword(query)?;
378    let tail = trim_sql_ws(query.get(from_idx + 4..)?);
379    let (first, after1) = parse_mssql_ident_piece(tail)?;
380    let after1 = trim_sql_ws(after1);
381    // `schema.table` (optionally `db.schema.table` → take the last two parts).
382    let (schema, table, after) = if after1.starts_with('.') {
383        let (second, after2) = parse_mssql_ident_piece(trim_sql_ws(after1.get(1..)?))?;
384        let after2 = trim_sql_ws(after2);
385        if after2.starts_with('.') {
386            // db.schema.table — `first` is the database, drop it.
387            let (third, after3) = parse_mssql_ident_piece(trim_sql_ws(after2.get(1..)?))?;
388            (second, third, trim_sql_ws(after3))
389        } else {
390            (first, second, after2)
391        }
392    } else {
393        ("dbo".to_string(), first, after1)
394    };
395    // Reject joins / comma-lists / a trailing dotted continuation we didn't
396    // consume; only a clause boundary (WHERE/ORDER/…/end) or an alias may follow.
397    let after = skip_mssql_optional_alias(after)?;
398    if mssql_joins_or_comma(after) {
399        return None;
400    }
401    Some((schema, table))
402}
403
404fn trim_sql_ws(s: &str) -> &str {
405    s.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\n' | '\r'))
406}
407
408fn is_sql_ident_byte(b: u8) -> bool {
409    b.is_ascii_alphanumeric() || b == b'_'
410}
411
412/// Case-insensitive keyword match at byte `idx` with identifier-boundary checks
413/// on both sides (so `from_x` does not match `from`).
414fn sql_keyword_at(haystack: &[u8], idx: usize, kw_lower: &[u8]) -> bool {
415    let n = kw_lower.len();
416    if idx + n > haystack.len() || !haystack[idx..idx + n].eq_ignore_ascii_case(kw_lower) {
417        return false;
418    }
419    let before_ok = idx == 0 || !is_sql_ident_byte(haystack[idx - 1]);
420    let after_ok = idx + n >= haystack.len() || !is_sql_ident_byte(haystack[idx + n]);
421    before_ok && after_ok
422}
423
424/// Byte offset of the top-level `FROM`, skipping nested parentheses
425/// (subqueries) and `'…'` string literals (with `''` escapes).
426fn mssql_find_outer_from_keyword(sql: &str) -> Option<usize> {
427    let b = sql.as_bytes();
428    let mut i = 0usize;
429    let mut depth = 0usize;
430    let mut in_quote = false;
431    while i < b.len() {
432        if in_quote {
433            if b[i] == b'\'' {
434                if i + 1 < b.len() && b[i + 1] == b'\'' {
435                    i += 2;
436                } else {
437                    in_quote = false;
438                    i += 1;
439                }
440                continue;
441            }
442            i += 1;
443            continue;
444        }
445        match b[i] {
446            b'\'' => in_quote = true,
447            b'(' => depth += 1,
448            b')' => depth = depth.saturating_sub(1),
449            _ if depth == 0 && sql_keyword_at(b, i, b"from") => return Some(i),
450            _ => {}
451        }
452        i += 1;
453    }
454    None
455}
456
457/// Parse one T-SQL identifier piece: `[bracketed name]` (with `]]` escapes) or
458/// a bare `ident`. Returns the unquoted name and the remaining tail.
459fn parse_mssql_ident_piece(rest: &str) -> Option<(String, &str)> {
460    let rest = trim_sql_ws(rest);
461    if let Some(after_open) = rest.strip_prefix('[') {
462        let mut out = String::new();
463        let mut chars = after_open.chars();
464        while let Some(ch) = chars.next() {
465            if ch == ']' {
466                if chars.as_str().starts_with(']') {
467                    chars.next();
468                    out.push(']');
469                    continue;
470                }
471                return Some((out, chars.as_str()));
472            }
473            out.push(ch);
474        }
475        return None; // unterminated bracket
476    }
477    let bytes = rest.as_bytes();
478    if bytes.is_empty() || (!bytes[0].is_ascii_alphabetic() && bytes[0] != b'_') {
479        return None;
480    }
481    let mut i = 1usize;
482    while i < bytes.len() && is_sql_ident_byte(bytes[i]) {
483        i += 1;
484    }
485    Some((rest.get(0..i)?.to_string(), rest.get(i..)?))
486}
487
488/// `true` when a join / comma-list follows the relation — the parser rejects
489/// these (catalog hints only resolve for a single base table).
490fn mssql_joins_or_comma(rest: &str) -> bool {
491    let r = trim_sql_ws(rest);
492    if r.starts_with(',') || r.starts_with('.') {
493        return true;
494    }
495    let b = r.as_bytes();
496    ["inner", "left", "right", "full", "cross", "join"]
497        .iter()
498        .any(|kw| sql_keyword_at(b, 0, kw.as_bytes()))
499}
500
501/// Consume an optional table alias (`[AS] alias`) after the relation, stopping
502/// at a clause boundary. Returns the tail after the alias, or `None` if what
503/// follows is a join/comma (so the caller rejects the query).
504fn skip_mssql_optional_alias(rest: &str) -> Option<&str> {
505    let rest = trim_sql_ws(rest);
506    if rest.is_empty() || mssql_starts_clause_boundary(rest) || mssql_joins_or_comma(rest) {
507        return Some(rest);
508    }
509    let mut rest = rest;
510    if sql_keyword_at(rest.as_bytes(), 0, b"as") {
511        rest = trim_sql_ws(rest.get(2..)?);
512    }
513    let (_, tail) = parse_mssql_ident_piece(rest)?;
514    Some(trim_sql_ws(tail))
515}
516
517fn mssql_starts_clause_boundary(rest: &str) -> bool {
518    let r = trim_sql_ws(rest);
519    if r.is_empty() {
520        return true;
521    }
522    const KWS: &[&[u8]] = &[
523        b"where",
524        b"group",
525        b"having",
526        b"order",
527        b"union",
528        b"except",
529        b"intersect",
530        b"for",
531        b"option",
532        b"offset",
533    ];
534    let b = r.as_bytes();
535    KWS.iter().any(|kw| sql_keyword_at(b, 0, kw))
536}
537
538impl Source for MssqlSource {
539    fn export(&mut self, request: &ExportRequest<'_>, sink: &mut dyn BatchSink) -> Result<()> {
540        // Keyset (seek) pages build a dialect-correct
541        // `OFFSET 0 ROWS FETCH NEXT n ROWS ONLY` clause (T-SQL has no `LIMIT`).
542        let built = build_export_query(request, crate::config::SourceType::Mssql);
543        let sql = built.sql.clone();
544        let overrides = request.column_overrides.clone();
545        // Stream the result one Arrow batch at a time (peak RSS ≈ one batch,
546        // independent of `chunk_size`) through the shared `AdaptiveBatchController`
547        // — it starts at a probe size and caps the batch to a memory target once
548        // the real row width is known (the cap is computed in the loop). The SQL
549        // Server analogue of the PostgreSQL cursor's `FETCH N`. (`adaptive` resize
550        // is a no-op here: a single streaming connection can't sample DB pressure
551        // mid-stream; the OPT-2 concurrency governor handles that at the chunk
552        // layer instead.)
553        let mut ctl =
554            AdaptiveBatchController::new(request.tuning, request.tuning.batch_size.max(1));
555        let mut cap_applied = false;
556        // Source-safety knobs (parity with the PG/MySQL export loops):
557        //  - lock_timeout → server-side `SET LOCK_TIMEOUT` so a blocked read
558        //    fails fast instead of waiting on a writer's lock indefinitely.
559        //  - statement_timeout → enforced client-side: SQL Server has no
560        //    statement-duration `SET` (unlike PG's `statement_timeout` / MySQL's
561        //    `max_execution_time`), so we stop pulling and error out once the
562        //    wall-clock budget is spent. The half-drained stream is dropped with
563        //    the (errored) source, so nothing leaks.
564        //  - throttle_ms → applied by the controller between batches.
565        let lock_timeout_ms = request.tuning.lock_timeout_s.saturating_mul(1000);
566        let stmt_timeout = (request.tuning.statement_timeout_s > 0)
567            .then(|| std::time::Duration::from_secs(request.tuning.statement_timeout_s));
568
569        // Resolve declared decimal precision/scale from `sys.columns` for the
570        // *unwrapped* base query (the chunk/keyset wrapper hides the source
571        // table from the FROM parser, so resolve from the base — same restriction
572        // as PG's catalog hints). `None` ⇒ not a simple single-table SELECT, so
573        // the schema builder falls back to data-inference, today's behaviour.
574        let hint_query = request.catalog_hint_query.unwrap_or(request.query);
575        let decimal_hints = self.mssql_decimal_catalog_hints_opt(hint_query);
576
577        // Record that we are about to mutate session state so `Drop` resets it
578        // (Epic 18 B2). Set before the disjoint-borrow destructure below.
579        if lock_timeout_ms > 0 {
580            self.lock_timeout_applied = true;
581        }
582
583        let Self { rt, client, .. } = self;
584        rt.block_on(async {
585            use futures_util::stream::TryStreamExt;
586            use tiberius::QueryItem;
587
588            if lock_timeout_ms > 0 {
589                client
590                    .execute(format!("SET LOCK_TIMEOUT {lock_timeout_ms}"), &[])
591                    .await
592                    .map_err(|e| anyhow::anyhow!("mssql: SET LOCK_TIMEOUT failed: {e}"))?;
593            }
594
595            let started = std::time::Instant::now();
596            let mut stream = client
597                .query(sql.as_str(), &[])
598                .await
599                .map_err(|e| anyhow::anyhow!("mssql: query failed: {e}"))?;
600
601            let mut columns: Vec<tiberius::Column> = Vec::new();
602            let mut buf: Vec<tiberius::Row> = Vec::with_capacity(ctl.target());
603            let mut schema: Option<SchemaRef> = None;
604            // Per-value ceiling (MB→bytes; `0`/None disables), enforced
605            // pre-allocation inside the batch builder so an oversized cell bails
606            // before Arrow reserves the buffer. Same source of truth as the sink.
607            let max_value_bytes = request.tuning.max_value_bytes();
608
609            while let Some(item) = stream
610                .try_next()
611                .await
612                .map_err(|e| anyhow::anyhow!("mssql: streaming rows failed: {e}"))?
613            {
614                if let Some(budget) = stmt_timeout
615                    && started.elapsed() > budget
616                {
617                    // Typed marker (not a bare string): the retry classifier
618                    // downcasts the TYPE → permanent, so a reworded message can
619                    // never silently make this deterministic timeout retryable.
620                    // Its Display carries the same actionable hint for the user.
621                    return Err(crate::source::StatementDurationTimeout::mssql(
622                        budget.as_secs(),
623                    )
624                    .into());
625                }
626                match item {
627                    // A single SELECT yields one metadata token (the column
628                    // shape) ahead of its rows.
629                    QueryItem::Metadata(meta) if columns.is_empty() => {
630                        columns = meta.columns().to_vec();
631                        // First moment the column shape is known. SQL Server
632                        // can't seed the controller from `effective_batch_size`
633                        // up-front (the schema isn't known until now), so raise
634                        // the ceiling here — otherwise it stays pinned at the
635                        // static `batch_size` and the post-probe memory cap
636                        // (shrink-only) can never grow a narrow table's batch
637                        // past it, the way the PG/MySQL loops do. A provisional
638                        // schema (no rows) is enough for the row-byte estimate;
639                        // the real, decimal-scale-correct schema is still built
640                        // per batch in `emit_mssql_batch`.
641                        if let Ok((provisional, _)) = arrow_convert::mssql_columns_to_schema(
642                            &columns,
643                            &overrides,
644                            &[],
645                            decimal_hints.as_ref(),
646                        ) {
647                            let eff = request
648                                .tuning
649                                .effective_batch_size(Some(&Arc::new(provisional)));
650                            ctl.raise_configured_ceiling(eff);
651                        }
652                    }
653                    QueryItem::Metadata(_) => {}
654                    QueryItem::Row(row) => {
655                        buf.push(row);
656                        if buf.len() >= ctl.target() {
657                            let arrow_bytes = emit_mssql_batch(
658                                &columns,
659                                &overrides,
660                                decimal_hints.as_ref(),
661                                &mut schema,
662                                &buf,
663                                sink,
664                                max_value_bytes,
665                            )?;
666                            let n = buf.len();
667                            buf.clear();
668                            // First batch: cap to a memory target now that the
669                            // real Arrow width is known (same probe→cap the
670                            // PG/MySQL loops do, clamped to the configured
671                            // batch_size by the controller).
672                            if !cap_applied && n > 0 {
673                                let arrow_per_row = (arrow_bytes / n).max(64);
674                                let target_mb = request
675                                    .tuning
676                                    .batch_size_memory_mb
677                                    .unwrap_or(DEFAULT_BATCH_TARGET_MB);
678                                let safe = ((target_mb * 1024 * 1024) / arrow_per_row)
679                                    .max(PROBE_BATCH_SIZE);
680                                if let Some(new) = ctl.apply_memory_cap(safe) {
681                                    log::info!(
682                                        "MSSQL batch cap: arrow≈{} B/row, target={} MB → batch_size → {}",
683                                        arrow_per_row,
684                                        target_mb,
685                                        new
686                                    );
687                                    buf.reserve(new.saturating_sub(buf.capacity()));
688                                }
689                                cap_applied = true;
690                            }
691                            // adaptive no-op mid-stream (sample → None); throttle.
692                            ctl.after_batch(|| None);
693                            ctl.throttle(n);
694                        }
695                    }
696                }
697            }
698            // Final partial batch — or, for an empty result set, a single call
699            // that still emits the (empty) schema so the sink writes a
700            // correctly-typed empty output. Rows arrive in the query's
701            // `ORDER BY` order, so the last batch's last row carries the max
702            // cursor the sink extracts.
703            if !buf.is_empty() || schema.is_none() {
704                emit_mssql_batch(
705                    &columns,
706                    &overrides,
707                    decimal_hints.as_ref(),
708                    &mut schema,
709                    &buf,
710                    sink,
711                    max_value_bytes,
712                )?;
713            }
714            Ok::<_, anyhow::Error>(())
715        })?;
716        Ok(())
717    }
718
719    fn query_scalar(&mut self, sql: &str) -> Result<Option<String>> {
720        let Self { rt, client, .. } = self;
721        rt.block_on(async {
722            let row = client
723                .query(sql, &[])
724                .await
725                .map_err(|e| anyhow::anyhow!("mssql: scalar query failed: {e}"))?
726                .into_row()
727                .await
728                .map_err(|e| anyhow::anyhow!("mssql: reading scalar row failed: {e}"))?;
729            Ok(row.and_then(|r| scalar_to_string(&r)))
730        })
731    }
732
733    fn type_mappings(
734        &mut self,
735        query: &str,
736        column_overrides: &ColumnOverrides,
737    ) -> Result<Vec<TypeMapping>> {
738        // Recover declared decimal precision/scale from `sys.columns` — the same
739        // catalog hint the full-export path applies — so a scan-free probe (CDC
740        // resolve, `rivet check`) resolves decimals identically to a batch export.
741        let decimal_hints = self.mssql_decimal_catalog_hints_opt(query);
742        // Zero-row wrapper so the server returns column metadata without a scan.
743        let wrapped = format!("SELECT * FROM ({query}) AS _rivet_q WHERE 1 = 0");
744        let overrides = column_overrides.clone();
745        let Self { rt, client, .. } = self;
746        rt.block_on(async {
747            let mut stream = client
748                .query(wrapped.as_str(), &[])
749                .await
750                .map_err(|e| anyhow::anyhow!("mssql: type-probe query failed: {e}"))?;
751            let columns = stream
752                .columns()
753                .await
754                .map_err(|e| anyhow::anyhow!("mssql: type-probe metadata failed: {e}"))?
755                .map(<[_]>::to_vec)
756                .unwrap_or_default();
757            // Drain so the connection is reusable.
758            let _ = stream.into_first_result().await;
759            Ok(arrow_convert::mssql_type_mappings(
760                &columns,
761                &overrides,
762                decimal_hints.as_ref(),
763            ))
764        })
765    }
766
767    fn sample_pressure(&mut self) -> Option<u64> {
768        let Self { rt, client, .. } = self;
769        // Extraction-pressure proxy (Epic 18 C2): cumulative `Workfiles Created`
770        // + `Worktables Created` (SQLServer:Access Methods). A workfile /
771        // worktable is created when a sort or hash spills to tempdb — the SQL
772        // Server analogue of PG `temp_bytes` / MySQL `Created_tmp_disk_tables`.
773        // The `cntr_value` of these `*/sec`-named perfmon counters is the raw
774        // cumulative count, so their sum is monotonic — exactly what the governor
775        // compares deltas of. Replaces `Log Flush Waits`, which is redo-**write**
776        // pressure and barely moves during a read-only export. Instance-level
777        // (no per-database `instance_name`), so no parameter is bound.
778        let sql = "SELECT SUM(cntr_value) FROM sys.dm_os_performance_counters \
779                   WHERE counter_name IN ('Workfiles Created/sec', 'Worktables Created/sec')";
780        rt.block_on(async {
781            let row = client.query(sql, &[]).await.ok()?.into_row().await.ok()??;
782            row.get::<i64, _>(0).map(|v| v.max(0) as u64)
783        })
784    }
785
786    fn server_context(&mut self) -> Option<String> {
787        // SERVERPROPERTY + @@ globals — cheap, no elevated permission. SQL Server's
788        // query timeout is CLIENT-side (there is no server "ERROR 3024"), so the
789        // load-bearing forensics here are version/edition plus the lock timeout and
790        // connection cap. Best-effort per field.
791        let version = self
792            .query_scalar("SELECT CAST(SERVERPROPERTY('ProductVersion') AS NVARCHAR(128))")
793            .ok()
794            .flatten();
795        let edition = self
796            .query_scalar("SELECT CAST(SERVERPROPERTY('Edition') AS NVARCHAR(128))")
797            .ok()
798            .flatten();
799        let lock_timeout = self
800            .query_scalar("SELECT CAST(@@LOCK_TIMEOUT AS NVARCHAR(32))")
801            .ok()
802            .flatten();
803        let max_conns = self
804            .query_scalar("SELECT CAST(@@MAX_CONNECTIONS AS NVARCHAR(32))")
805            .ok()
806            .flatten();
807        let collation = self
808            .query_scalar("SELECT CAST(SERVERPROPERTY('Collation') AS NVARCHAR(128))")
809            .ok()
810            .flatten();
811        Some(
812            serde_json::json!({
813                "engine": "mssql",
814                "version": version,
815                "edition": edition,
816                "lock_timeout_ms": lock_timeout,
817                "max_connections": max_conns,
818                "collation": collation,
819            })
820            .to_string(),
821        )
822    }
823}
824
825impl MssqlSource {
826    /// Snapshot lock-wait counters from `sys.dm_os_wait_stats` (LCK_* waits) —
827    /// the SQL Server contention signal the 0.12 harm A/B tracked. This is a
828    /// server-scoped DMV: it needs `VIEW SERVER STATE`; a missing grant (or any
829    /// query error) yields `None`, so the metric is simply skipped, never failing
830    /// the export. Cumulative since server start; the pipeline deltas it around
831    /// the run.
832    pub(crate) fn harm_counters(&mut self) -> Option<Vec<(String, i64)>> {
833        let Self { rt, client, .. } = self;
834        let sql = "SELECT SUM(waiting_tasks_count), SUM(wait_time_ms) \
835                   FROM sys.dm_os_wait_stats WHERE wait_type LIKE 'LCK%'";
836        rt.block_on(async {
837            let row = client.query(sql, &[]).await.ok()?.into_row().await.ok()??;
838            let waits = row.get::<i64, _>(0).unwrap_or(0);
839            let wait_ms = row.get::<i64, _>(1).unwrap_or(0);
840            Some(vec![
841                ("mssql_lock_waits".to_string(), waits),
842                ("mssql_lock_wait_ms".to_string(), wait_ms),
843            ])
844        })
845    }
846
847    /// Does the current login hold `VIEW SERVER STATE` — the permission
848    /// [`harm_counters`] needs? `Some(true/false)` via `HAS_PERMS_BY_NAME`
849    /// (callable by any login for its own permissions, so this probe itself
850    /// never needs a grant); `None` only if even that round-trip fails.
851    pub(crate) fn has_view_server_state(&mut self) -> Option<bool> {
852        let Self { rt, client, .. } = self;
853        rt.block_on(async {
854            let row = client
855                .query(
856                    "SELECT HAS_PERMS_BY_NAME(NULL, NULL, 'VIEW SERVER STATE')",
857                    &[],
858                )
859                .await
860                .ok()?
861                .into_row()
862                .await
863                .ok()??;
864            row.get::<i32, _>(0).map(|v| v == 1)
865        })
866    }
867
868    /// One-shot CDC health probe for `rivet doctor` (see
869    /// `preflight::cdc_health`): is CDC enabled on the database, does the
870    /// capture instance exist (its min LSN), and is the Agent service running.
871    /// The Agent query needs `VIEW SERVER STATE` — a permission failure yields
872    /// `agent_running: None` (report "could not verify"), never an error.
873    pub(crate) fn cdc_health(&mut self, capture_instance: Option<&str>) -> Result<MssqlCdcProbe> {
874        let Self { rt, client, .. } = self;
875        rt.block_on(async {
876            // max LSN: NULL ⇒ sp_cdc_enable_db has not run.
877            let max: Option<String> = client
878                .query(
879                    "SELECT CONVERT(varchar(24), sys.fn_cdc_get_max_lsn(), 1)",
880                    &[],
881                )
882                .await?
883                .into_row()
884                .await?
885                .and_then(|r| r.get::<&str, _>(0).map(|s| s.to_string()));
886
887            // min LSN of the capture instance: NULL ⇒ the instance is unknown.
888            let min: Option<String> = match capture_instance {
889                None => None,
890                Some(ci) => client
891                    .query(
892                        "SELECT CONVERT(varchar(24), sys.fn_cdc_get_min_lsn(@P1), 1)",
893                        &[&ci],
894                    )
895                    .await?
896                    .into_row()
897                    .await?
898                    .and_then(|r| r.get::<&str, _>(0).map(|s| s.to_string()))
899                    // an all-zero min LSN is the same "no such instance" signal.
900                    .filter(|s| s.trim_start_matches("0x").chars().any(|c| c != '0')),
901            };
902
903            // Agent service state — permission-gated, so failures are `None`.
904            let agent: Option<bool> = async {
905                let row = client
906                    .query(
907                        "SELECT status_desc FROM sys.dm_server_services \
908                         WHERE servicename LIKE 'SQL Server Agent%'",
909                        &[],
910                    )
911                    .await
912                    .ok()?
913                    .into_row()
914                    .await
915                    .ok()??;
916                row.get::<&str, _>(0)
917                    .map(|s| s.eq_ignore_ascii_case("Running"))
918            }
919            .await;
920
921            Ok(MssqlCdcProbe {
922                cdc_enabled: max.is_some(),
923                max_lsn_hex: max.clone(),
924                instance_min_lsn: min,
925                agent_running: agent,
926            })
927        })
928    }
929}
930
931/// Result of [`MssqlSource::cdc_health`].
932pub(crate) struct MssqlCdcProbe {
933    pub cdc_enabled: bool,
934    /// The database's current max LSN (`0x…` hex) — the `initial: snapshot`
935    /// anchor position. `None` ⇔ CDC not enabled.
936    pub max_lsn_hex: Option<String>,
937    /// Hex LSN (`0x…`) of the capture instance's retained minimum; `None` ⇒
938    /// the instance does not exist (or none was configured).
939    pub instance_min_lsn: Option<String>,
940    /// `None` ⇒ could not verify (no `VIEW SERVER STATE`).
941    pub agent_running: Option<bool>,
942}
943
944/// Connect and snapshot MSSQL harm counters; see [`MssqlSource::harm_counters`].
945/// `None` on connect failure or a missing `VIEW SERVER STATE` grant.
946pub(crate) fn sample_harm_counters(
947    url: &str,
948    tls: Option<&TlsConfig>,
949) -> Option<Vec<(String, i64)>> {
950    let mut src = MssqlSource::connect_with_tls(url, tls).ok()?;
951    src.harm_counters()
952}
953
954/// Connect and check whether the login has `VIEW SERVER STATE` — used by
955/// `rivet doctor` to *advise* (never block) that source-harm metrics will be
956/// skipped without it. `None` on connect failure, in which case doctor stays
957/// silent rather than guess.
958pub(crate) fn sample_view_server_state(url: &str, tls: Option<&TlsConfig>) -> Option<bool> {
959    let mut src = MssqlSource::connect_with_tls(url, tls).ok()?;
960    src.has_view_server_state()
961}
962
963/// Emit one Arrow batch from `rows`, building (and emitting) the schema on the
964/// first call and reusing it thereafter. tiberius drops a decimal column's
965/// declared precision/scale, so the scale is recovered from the `decimal_hints`
966/// catalog lookup (the upstream, lossless source that survives an all-NULL
967/// first batch); only an expression/computed column with no catalog entry falls
968/// back to inferring the scale from the first batch's data.
969///
970/// Returns the emitted batch's Arrow memory footprint (bytes), so the export
971/// loop can size the memory cap from the real row width; `0` for an empty batch.
972fn emit_mssql_batch(
973    columns: &[tiberius::Column],
974    overrides: &ColumnOverrides,
975    decimal_hints: Option<&HashMap<String, (u8, i8)>>,
976    schema: &mut Option<SchemaRef>,
977    rows: &[tiberius::Row],
978    sink: &mut dyn BatchSink,
979    max_value_bytes: Option<usize>,
980) -> Result<usize> {
981    let schema_ref = match schema {
982        Some(s) => s.clone(),
983        None => {
984            let (built, _decoders) =
985                arrow_convert::mssql_columns_to_schema(columns, overrides, rows, decimal_hints)?;
986            let s: SchemaRef = Arc::new(built);
987            sink.on_schema(s.clone())?;
988            *schema = Some(s.clone());
989            s
990        }
991    };
992    if !rows.is_empty() {
993        let batch = arrow_convert::mssql_rows_to_record_batch(&schema_ref, rows, max_value_bytes)?;
994        let bytes = crate::tuning::SourceTuning::batch_memory_bytes(&batch);
995        sink.on_batch(&batch)?;
996        return Ok(bytes);
997    }
998    Ok(0)
999}
1000
1001/// Render a T-SQL `NUMERIC` as exact decimal text: the unscaled value with a
1002/// decimal point inserted at `scale` (zero-padded so `(5, scale 3)` is
1003/// `"0.005"`, not `".5"`). Extracted from the `ColumnData::Numeric` arm so the
1004/// digit arithmetic is unit-testable — `tiberius::Row` cannot be constructed
1005/// outside the driver, and the W4 mutation run showed the split/pad arithmetic
1006/// unguarded.
1007fn numeric_to_display(raw: i128, scale: usize) -> String {
1008    if scale == 0 {
1009        raw.to_string()
1010    } else {
1011        let neg = raw < 0;
1012        let digits = raw.unsigned_abs().to_string();
1013        let digits = format!("{digits:0>width$}", width = scale + 1);
1014        let (int, frac) = digits.split_at(digits.len() - scale);
1015        format!("{}{int}.{frac}", if neg { "-" } else { "" })
1016    }
1017}
1018
1019/// Render a row's first column as a display string for `query_scalar`
1020/// (min/max bounds, COUNT(*), SELECT 1). Covers the scalar shapes the planner
1021/// asks for; richer typing flows through the export path, not here.
1022fn scalar_to_string(row: &tiberius::Row) -> Option<String> {
1023    use tiberius::ColumnData;
1024    let cell = row.cells().next().map(|(_, d)| d)?;
1025    match cell {
1026        ColumnData::U8(v) => v.map(|x| x.to_string()),
1027        ColumnData::I16(v) => v.map(|x| x.to_string()),
1028        ColumnData::I32(v) => v.map(|x| x.to_string()),
1029        ColumnData::I64(v) => v.map(|x| x.to_string()),
1030        ColumnData::F32(v) => v.map(|x| x.to_string()),
1031        ColumnData::F64(v) => v.map(|x| x.to_string()),
1032        ColumnData::Bit(v) => v.map(|x| x.to_string()),
1033        ColumnData::String(v) => v.as_ref().map(|s| s.to_string()),
1034        ColumnData::Numeric(v) => v.map(|n| numeric_to_display(n.value(), n.scale() as usize)),
1035        ColumnData::Guid(v) => v.map(|g| g.to_string()),
1036        // Date/time: the Debug fallback rendered these as `Date(Some(Date(738520)))`,
1037        // which `scalar::parse_date_flexible` (chunk_by_days / date-keyset min/max)
1038        // cannot read — so date-window chunking on an MSSQL DATE key failed the run.
1039        // Decode via tiberius' chrono `FromSql` (`try_get`, the same path
1040        // arrow_convert uses) and render ISO: `YYYY-MM-DD` / `YYYY-MM-DD HH:MM:SS`.
1041        ColumnData::Date(_) => row
1042            .try_get::<chrono::NaiveDate, _>(0)
1043            .ok()
1044            .flatten()
1045            .map(|d| d.format("%Y-%m-%d").to_string()),
1046        ColumnData::DateTime(_) | ColumnData::DateTime2(_) | ColumnData::SmallDateTime(_) => row
1047            .try_get::<chrono::NaiveDateTime, _>(0)
1048            .ok()
1049            .flatten()
1050            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()),
1051        other => Some(format!("{other:?}")),
1052    }
1053}
1054
1055/// Probe `sys.*` for the stats chunked-mode planning needs (ADR-0015 seam).
1056/// Mirrors `introspect_pg_table_for_chunking` / `introspect_mysql_table_for_chunking`.
1057pub(crate) fn introspect_mssql_table_for_chunking(
1058    url: &str,
1059    tls: Option<&TlsConfig>,
1060    qualified_table: &str,
1061) -> Result<TableIntrospection> {
1062    let (schema, table) = match qualified_table.split_once('.') {
1063        Some((s, t)) => (s.to_string(), t.to_string()),
1064        None => ("dbo".to_string(), qualified_table.to_string()),
1065    };
1066    let mut src = MssqlSource::connect_with_tls(url, tls)?;
1067
1068    // Row estimate from `sys.dm_db_partition_stats` (rows in the heap/clustered
1069    // index, index_id 0/1).
1070    let count_sql = format!(
1071        "SELECT SUM(p.row_count) FROM sys.dm_db_partition_stats p \
1072         JOIN sys.objects o ON o.object_id = p.object_id \
1073         JOIN sys.schemas s ON s.schema_id = o.schema_id \
1074         WHERE s.name = N'{}' AND o.name = N'{}' AND p.index_id IN (0,1)",
1075        schema.replace('\'', "''"),
1076        table.replace('\'', "''")
1077    );
1078    let row_estimate = src
1079        .query_scalar(&count_sql)?
1080        .and_then(|s| s.parse::<i64>().ok())
1081        .unwrap_or(0);
1082
1083    // Single-column integer PK → range chunking. `sys.indexes (is_primary_key)`
1084    // + one `index_columns` row + an integer base type.
1085    let pk_sql = format!(
1086        "SELECT TOP 1 c.name, t.name FROM sys.indexes i \
1087         JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \
1088         JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \
1089         JOIN sys.types t ON t.user_type_id = c.user_type_id \
1090         JOIN sys.objects o ON o.object_id = i.object_id \
1091         JOIN sys.schemas s ON s.schema_id = o.schema_id \
1092         WHERE i.is_primary_key = 1 AND s.name = N'{}' AND o.name = N'{}' \
1093         GROUP BY c.name, t.name HAVING COUNT(*) = 1",
1094        schema.replace('\'', "''"),
1095        table.replace('\'', "''")
1096    );
1097    // Keyset keys (OPT-4) — parity with `postgres/mod.rs:314-340`: every
1098    // single-column, NOT NULL, UNIQUE index (the PK *plus* any unique
1099    // constraint/index), PK-first and de-duplicated, not just the PK. SQL
1100    // Server: `sys.indexes.is_unique = 1`, exactly one key column
1101    // (`ic.key_ordinal > 0` + `HAVING COUNT(*) = 1`), and the column is NOT NULL
1102    // — so `ORDER BY key LIMIT n` is an index range scan and `WHERE key > last`
1103    // never skips dup keys. Aggregated with a `CHAR(31)` (unit-separator)
1104    // delimiter because the introspection seam only exposes `query_scalar`; that
1105    // byte cannot appear in a real identifier, so the split is unambiguous.
1106    let keyset_sql = format!(
1107        "SELECT STRING_AGG(CONVERT(nvarchar(max), col), CHAR(31)) WITHIN GROUP (ORDER BY is_pk DESC, col) FROM ( \
1108           SELECT col, MAX(is_pk) AS is_pk FROM ( \
1109             SELECT MIN(c.name) AS col, MAX(CONVERT(int, i.is_primary_key)) AS is_pk \
1110             FROM sys.indexes i \
1111             JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal > 0 \
1112             JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \
1113             JOIN sys.types kt ON kt.user_type_id = c.user_type_id \
1114             JOIN sys.objects o ON o.object_id = i.object_id \
1115             JOIN sys.schemas s ON s.schema_id = o.schema_id \
1116             WHERE i.is_unique = 1 AND c.is_nullable = 0 AND s.name = N'{}' AND o.name = N'{}' \
1117               AND kt.name NOT IN ('decimal', 'numeric') \
1118             GROUP BY i.object_id, i.index_id HAVING COUNT(*) = 1 \
1119           ) per_index GROUP BY col \
1120         ) deduped",
1121        schema.replace('\'', "''"),
1122        table.replace('\'', "''")
1123    );
1124    let keyset_keys: Vec<String> = src
1125        .query_scalar(&keyset_sql)?
1126        .map(|s| {
1127            s.split('\u{1f}')
1128                .filter(|c| !c.is_empty())
1129                .map(str::to_string)
1130                .collect()
1131        })
1132        .unwrap_or_default();
1133
1134    // Single-column integer PK → range chunking. Its own probe (the keyset list
1135    // above doesn't carry the type, and range-chunk eligibility needs it).
1136    let mut single_int_pk = None;
1137    if let Some(pk_col) = src.query_scalar(&pk_sql)? {
1138        // The scalar query returns only the column name; re-probe the type to
1139        // decide range-chunk eligibility.
1140        let type_sql = format!(
1141            "SELECT t.name FROM sys.columns c \
1142             JOIN sys.types t ON t.user_type_id = c.user_type_id \
1143             JOIN sys.objects o ON o.object_id = c.object_id \
1144             JOIN sys.schemas s ON s.schema_id = o.schema_id \
1145             WHERE s.name = N'{}' AND o.name = N'{}' AND c.name = N'{}'",
1146            schema.replace('\'', "''"),
1147            table.replace('\'', "''"),
1148            pk_col.replace('\'', "''")
1149        );
1150        if let Some(ty) = src.query_scalar(&type_sql)?
1151            && matches!(ty.as_str(), "tinyint" | "smallint" | "int" | "bigint")
1152        {
1153            single_int_pk = Some(pk_col);
1154        }
1155    }
1156
1157    // Integer-family columns — the safety set for an explicit `chunk_column`.
1158    // Same CHAR(31)-delimited STRING_AGG pattern as the keyset probe above.
1159    // CONVERT(nvarchar(max), ..) because STRING_AGG over a non-max input caps its
1160    // result at 8000 bytes and raises Msg 9829 past it — a wide table (~160 int
1161    // columns of 30-char names) then failed EVERY chunked plan (#21 bughunt).
1162    let int_sql = format!(
1163        "SELECT STRING_AGG(CONVERT(nvarchar(max), c.name), CHAR(31)) FROM sys.columns c \
1164         JOIN sys.types t ON t.user_type_id = c.user_type_id \
1165         JOIN sys.objects o ON o.object_id = c.object_id \
1166         JOIN sys.schemas s ON s.schema_id = o.schema_id \
1167         WHERE s.name = N'{}' AND o.name = N'{}' \
1168           AND t.name IN ('tinyint', 'smallint', 'int', 'bigint')",
1169        schema.replace('\'', "''"),
1170        table.replace('\'', "''")
1171    );
1172    let int_columns: Vec<String> = src
1173        .query_scalar(&int_sql)?
1174        .map(|s| {
1175            s.split('\u{1f}')
1176                .filter(|c| !c.is_empty())
1177                .map(str::to_string)
1178                .collect()
1179        })
1180        .unwrap_or_default();
1181
1182    Ok(TableIntrospection {
1183        single_int_pk,
1184        keyset_keys,
1185        row_estimate,
1186        avg_row_bytes: None,
1187        int_columns,
1188    })
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::{
1194        catalog_decimal_to_params, mssql_find_outer_from_keyword, parse_mssql_simple_from_table,
1195        parse_mssql_url, sql_keyword_at,
1196    };
1197
1198    // Regression (round-2 audit #1): the shared `build_url_from_fields` percent-
1199    // ENCODES userinfo for every engine, but this hand-rolled parser must
1200    // percent-DECODE it back (PG/MySQL/Mongo get that from their driver's URL
1201    // parser). Without the decode, tiberius receives the literal `%40`/`%2F`
1202    // bytes and auth FAILS for any password outside the RFC-3986 unreserved set —
1203    // exactly the special chars SQL Server's complexity policy encourages. RED
1204    // against the pre-fix `.to_string()` (no decode).
1205    #[test]
1206    fn mssql_url_percent_decodes_userinfo() {
1207        // `P@ss/w0rd!` → encoded `P%40ss%2Fw0rd%21`, and the user `dom\svc` →
1208        // `dom%5Csvc` (backslash is a legit SQL Server login char).
1209        let parsed =
1210            parse_mssql_url("sqlserver://dom%5Csvc:P%40ss%2Fw0rd%21@db.internal:1433/rivet")
1211                .expect("well-formed encoded url must parse");
1212        assert_eq!(parsed.user, r"dom\svc", "user must be percent-decoded");
1213        assert_eq!(
1214            parsed.password, "P@ss/w0rd!",
1215            "password must be percent-decoded, not passed as literal %-bytes"
1216        );
1217        assert_eq!(parsed.host, "db.internal");
1218        assert_eq!(parsed.port, 1433);
1219        assert_eq!(parsed.database, "rivet");
1220    }
1221
1222    fn parse(q: &str) -> Option<(String, String)> {
1223        parse_mssql_simple_from_table(q)
1224    }
1225
1226    // ── mutation-W4 gap closure ──────────────────────────────────────────────
1227    // 18 mutants survived in the FROM-finder (quote escapes, paren depth,
1228    // identifier boundaries) — no direct test existed. A mis-found FROM feeds
1229    // the table extraction for catalog lookups; golden byte OFFSETS pin the
1230    // arithmetic, tricky shapes pin the state machine.
1231    #[test]
1232    fn outer_from_finder_golden_offsets() {
1233        let f = mssql_find_outer_from_keyword;
1234        // Exact offset (kills index arithmetic): "SELECT a " = 9 bytes.
1235        assert_eq!(f("SELECT a FROM t"), Some(9));
1236        // Case-insensitive.
1237        assert_eq!(f("select a frOm t"), Some(9));
1238        // Subquery FROM is nested — only the outer one counts.
1239        let q = "SELECT (SELECT x FROM i) FROM t";
1240        assert_eq!(f(q), Some(q.rfind("FROM").unwrap()));
1241        // 'from' inside a string literal is skipped…
1242        let q = "SELECT 'from' FROM t";
1243        assert_eq!(f(q), Some(q.rfind("FROM").unwrap()));
1244        // …including with a doubled-quote escape swallowing a fake closer.
1245        let q = "SELECT 'it''s from x' FROM t";
1246        assert_eq!(f(q), Some(q.rfind("FROM").unwrap()));
1247        // Escape-skip arithmetic: with a long prefix the escape index i has
1248        // 2*i PAST the string end, so an `i += 2` -> `i *= 2` mutant runs off
1249        // the buffer and returns None (short fixtures let 2*i coincidentally
1250        // land back on the closing quote — the fixture must break the
1251        // coincidence, not rely on it).
1252        let q = "SELECT aaaaaaaaaaaaaaaaaaaaaaaaaaaa, 'it''s' FROM t";
1253        assert!(2 * q.find("''").unwrap() > q.len(), "fixture invariant");
1254        assert_eq!(f(q), Some(q.rfind("FROM").unwrap()));
1255        // Identifier boundaries: neither `a_from` nor `fromage` match.
1256        let q = "SELECT a_from, fromage FROM t";
1257        assert_eq!(f(q), Some(q.rfind("FROM").unwrap()));
1258        // Unbalanced-close before FROM must not underflow depth below zero and
1259        // hide the keyword.
1260        assert_eq!(f(") FROM t"), Some(2));
1261        // No FROM at all.
1262        assert_eq!(f("SELECT 1"), None);
1263        // FROM stuck inside parens only -> nested, not outer.
1264        assert_eq!(f("SELECT (x FROM t)"), None);
1265    }
1266
1267    #[test]
1268    fn numeric_display_inserts_the_point_exactly() {
1269        // W4: the split/pad arithmetic (digits.len() - scale, width scale+1)
1270        // had no direct test. Goldens over the tricky shapes: sub-1 values
1271        // needing zero-pad, negatives, scale 0.
1272        use super::numeric_to_display as n;
1273        assert_eq!(n(12345, 2), "123.45");
1274        assert_eq!(n(5, 3), "0.005", "zero-pad below 1");
1275        assert_eq!(n(-12345, 2), "-123.45");
1276        assert_eq!(n(-5, 3), "-0.005");
1277        assert_eq!(n(42, 0), "42", "scale 0 is the bare integer");
1278        assert_eq!(n(0, 2), "0.00");
1279        assert_eq!(n(10, 1), "1.0");
1280    }
1281
1282    #[test]
1283    fn keyword_at_checks_both_identifier_boundaries() {
1284        let k = |h: &str, i: usize| sql_keyword_at(h.as_bytes(), i, b"from");
1285        assert!(k("from t", 0), "start of string is a boundary");
1286        assert!(k("x from", 2), "end of string is a boundary");
1287        assert!(!k("xfrom t", 1), "preceded by an identifier byte");
1288        assert!(!k("from_x", 0), "followed by an identifier byte");
1289        assert!(!k("fro", 0), "keyword longer than the remaining haystack");
1290        assert!(k("x FROM y", 2), "case-insensitive");
1291    }
1292
1293    #[test]
1294    fn parse_unqualified_table_defaults_to_dbo() {
1295        assert_eq!(
1296            parse("SELECT id, amount FROM transactions ORDER BY id"),
1297            Some(("dbo".into(), "transactions".into()))
1298        );
1299    }
1300
1301    #[test]
1302    fn parse_schema_qualified() {
1303        assert_eq!(
1304            parse("SELECT id FROM sales.orders WHERE id > 1"),
1305            Some(("sales".into(), "orders".into()))
1306        );
1307    }
1308
1309    #[test]
1310    fn parse_db_schema_table_takes_last_two() {
1311        assert_eq!(
1312            parse("SELECT * FROM mydb.sales.orders"),
1313            Some(("sales".into(), "orders".into()))
1314        );
1315    }
1316
1317    #[test]
1318    fn parse_bracketed_identifiers() {
1319        assert_eq!(
1320            parse("SELECT * FROM [my schema].[order items]"),
1321            Some(("my schema".into(), "order items".into()))
1322        );
1323    }
1324
1325    #[test]
1326    fn parse_table_with_alias() {
1327        assert_eq!(
1328            parse("SELECT t.id FROM transactions AS t WHERE t.x = 1"),
1329            Some(("dbo".into(), "transactions".into()))
1330        );
1331        assert_eq!(
1332            parse("SELECT t.id FROM transactions t ORDER BY t.id"),
1333            Some(("dbo".into(), "transactions".into()))
1334        );
1335    }
1336
1337    #[test]
1338    fn parse_rejects_join() {
1339        assert_eq!(parse("SELECT * FROM a INNER JOIN b ON a.id = b.id"), None);
1340        assert_eq!(parse("SELECT * FROM a JOIN b ON a.id = b.id"), None);
1341    }
1342
1343    #[test]
1344    fn parse_rejects_comma_list() {
1345        assert_eq!(parse("SELECT * FROM a, b WHERE a.id = b.id"), None);
1346    }
1347
1348    #[test]
1349    fn parse_rejects_subquery_from() {
1350        assert_eq!(parse("SELECT * FROM (SELECT * FROM t) AS s"), None);
1351    }
1352
1353    #[test]
1354    fn parse_ignores_from_inside_string_literal() {
1355        // The first top-level FROM is the real one, not the literal's bytes.
1356        assert_eq!(
1357            parse("SELECT 'from x', amount FROM ledger WHERE note = 'paid from cash'"),
1358            Some(("dbo".into(), "ledger".into()))
1359        );
1360    }
1361
1362    #[test]
1363    fn catalog_bounds_accept_well_formed_and_reject_degenerate() {
1364        // DECIMAL(10,2) — the bug's column — rides through losslessly.
1365        assert_eq!(catalog_decimal_to_params(10, 2), Some((10, 2)));
1366        // SQL Server max precision.
1367        assert_eq!(catalog_decimal_to_params(38, 0), Some((38, 0)));
1368        assert_eq!(catalog_decimal_to_params(38, 38), Some((38, 38)));
1369        // Degenerate rows are rejected (defends against a corrupt catalog row),
1370        // so the builder falls back to data-inference rather than emitting a
1371        // nonsensical decimal type.
1372        assert_eq!(catalog_decimal_to_params(0, 0), None);
1373        assert_eq!(catalog_decimal_to_params(39, 0), None);
1374        assert_eq!(catalog_decimal_to_params(10, 11), None);
1375    }
1376}