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