Skip to main content

faucet_source_mssql/
stream.rs

1//! The MSSQL [`Source`] implementation — connection pool, query execution,
2//! streaming, and incremental-replication bookkeeping.
3
4use std::collections::HashMap;
5use std::hash::{Hash, Hasher};
6use std::pin::Pin;
7use std::sync::Mutex;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use faucet_core::check::{CheckContext, CheckReport, Probe};
12use faucet_core::replication::{filter_incremental, max_replication_value, max_value};
13use faucet_core::shard::{
14    PkShardBounds, ShardSpec, parse_pk_shard, pk_bounds_query, pk_shards_from_bounds,
15};
16use faucet_core::{FaucetError, Source, StreamPage};
17use futures::{Stream, TryStreamExt};
18use serde_json::Value;
19use tiberius::{QueryItem, ToSql};
20
21use faucet_common_mssql::{MssqlPool, build_pool, quote_ident_mssql, with_statement_timeout};
22
23use crate::config::{MssqlReplication, MssqlSourceConfig};
24use crate::convert::row_to_json;
25
26/// Microsoft SQL Server query source.
27pub struct MssqlSource {
28    config: MssqlSourceConfig,
29    pool: MssqlPool,
30    /// Bookmark loaded via [`Source::apply_start_bookmark`]; overrides the
31    /// configured `initial_value` for incremental runs.
32    start_bookmark: Mutex<Option<Value>>,
33    /// Shard applied by the cluster coordinator (Mode B), if any. `None` (or the
34    /// whole-dataset shard) means the full query is streamed. Stored behind a
35    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
36    applied_shard: Mutex<Option<PkShardBounds>>,
37}
38
39impl MssqlSource {
40    /// Connect, validate the config, and build the connection pool.
41    pub async fn new(config: MssqlSourceConfig) -> Result<Self, FaucetError> {
42        config.validate()?;
43        let pool = build_pool(&config.connection, config.max_connections).await?;
44        Ok(Self {
45            config,
46            pool,
47            start_bookmark: Mutex::new(None),
48            applied_shard: Mutex::new(None),
49        })
50    }
51
52    fn timeout(&self) -> Option<Duration> {
53        match self.config.statement_timeout_secs {
54            0 => None,
55            secs => Some(Duration::from_secs(secs)),
56        }
57    }
58
59    fn current_start(&self) -> Option<Value> {
60        self.start_bookmark
61            .lock()
62            .expect("start_bookmark mutex poisoned")
63            .clone()
64    }
65
66    /// Apply the currently-set shard (if any) to a resolved query string. The
67    /// positional `@Pn` bind markers inside the wrapped subquery are unaffected
68    /// (they bind by name, not by position in the text).
69    fn shard_wrap(&self, query: String) -> String {
70        match &*self.applied_shard.lock().expect("shard mutex poisoned") {
71            Some(bounds) => bounds.wrap(&query, bracket_quote),
72            None => query,
73        }
74    }
75}
76
77/// Infallible bracket quoting for a shard key whose NUL-freeness was already
78/// validated (via [`quote_ident_mssql`]) when the shard was applied/enumerated.
79/// Interior `]` are doubled per T-SQL rules, preventing identifier injection.
80fn bracket_quote(name: &str) -> String {
81    format!("[{}]", name.replace(']', "]]"))
82}
83
84/// Incremental-replication context resolved for one run.
85#[derive(Debug, Clone, PartialEq)]
86struct IncrementalCtx {
87    column: String,
88    start: Value,
89}
90
91/// Build the final query string, the ordered bind values, and (for incremental
92/// runs) the client-side filter context.
93///
94/// Pure function (no pool) so it is unit-testable. Param order is:
95/// `config.params` → context-substituted values → the incremental bookmark
96/// (only when the query contains the `@bookmark` token).
97fn build_query_and_params(
98    config: &MssqlSourceConfig,
99    context: &HashMap<String, Value>,
100    start_bookmark: Option<&Value>,
101) -> (String, Vec<Value>, Option<IncrementalCtx>) {
102    // Resolve parent-context placeholders to positional @P markers.
103    let (mut query, mut values) = if context.is_empty() {
104        (config.query.clone(), config.params.clone())
105    } else {
106        let (q, ctx_values) = faucet_core::util::substitute_context_bind_params(
107            &config.query,
108            context,
109            config.params.len() + 1,
110            |i| format!("@P{i}"),
111        );
112        let mut v = config.params.clone();
113        v.extend(ctx_values);
114        (q, v)
115    };
116
117    let incremental = match &config.replication {
118        MssqlReplication::Full => None,
119        MssqlReplication::Incremental {
120            column,
121            initial_value,
122        } => {
123            let start = start_bookmark
124                .cloned()
125                .unwrap_or_else(|| initial_value.clone());
126            // Server-side pushdown: bind the cursor where the user wrote
127            // `@bookmark`. If absent, only the client-side filter applies.
128            if query.contains("@bookmark") {
129                let idx = values.len() + 1;
130                query = query.replace("@bookmark", &format!("@P{idx}"));
131                values.push(start.clone());
132            }
133            Some(IncrementalCtx {
134                column: column.clone(),
135                start,
136            })
137        }
138    };
139
140    (query, values, incremental)
141}
142
143/// Owned bind parameter, so the borrowed `&dyn ToSql` slice handed to
144/// `tiberius` outlives nothing it shouldn't.
145enum OwnedParam {
146    I64(i64),
147    F64(f64),
148    Bool(bool),
149    Str(String),
150    Null(Option<i32>),
151}
152
153impl OwnedParam {
154    fn from_value(v: &Value) -> Self {
155        match v {
156            Value::String(s) => OwnedParam::Str(s.clone()),
157            Value::Number(n) if n.is_i64() => OwnedParam::I64(n.as_i64().unwrap()),
158            // A `u64` above `i64::MAX` cannot be represented as a tiberius `i64`
159            // bind value (SQL Server has no unsigned 64-bit type). The previous
160            // `as i64` wrapped it to a negative number (e.g. u64::MAX -> -1),
161            // silently matching the wrong rows. Bind the exact decimal digits as
162            // a string instead — SQL Server implicitly converts it to the
163            // column's numeric type for comparison, preserving the value (F41).
164            Value::Number(n) if n.is_u64() => OwnedParam::Str(n.as_u64().unwrap().to_string()),
165            Value::Number(n) => OwnedParam::F64(n.as_f64().unwrap_or(0.0)),
166            Value::Bool(b) => OwnedParam::Bool(*b),
167            Value::Null => OwnedParam::Null(None),
168            other => OwnedParam::Str(other.to_string()),
169        }
170    }
171
172    fn as_tosql(&self) -> &dyn ToSql {
173        match self {
174            OwnedParam::I64(v) => v,
175            OwnedParam::F64(v) => v,
176            OwnedParam::Bool(v) => v,
177            OwnedParam::Str(v) => v,
178            OwnedParam::Null(v) => v,
179        }
180    }
181}
182
183/// One flattened `INFORMATION_SCHEMA.COLUMNS` row used by
184/// [`Source::discover`]: `(schema, table, column, data_type, is_nullable)`.
185type CatalogRow = (String, String, String, String, bool);
186
187/// Group flattened catalog rows (ordered by schema, table, ordinal position)
188/// into one [`DatasetDescriptor`](faucet_core::DatasetDescriptor) per table,
189/// merging in per-table row estimates keyed by `(schema, table)`. Pure —
190/// unit-testable without a live server. `quote` is the dialect's identifier
191/// quoter ([`bracket_quote`] for T-SQL).
192fn descriptors_from_catalog(
193    rows: Vec<CatalogRow>,
194    estimates: &HashMap<(String, String), u64>,
195    quote: fn(&str) -> String,
196) -> Vec<faucet_core::DatasetDescriptor> {
197    /// In-progress per-table accumulator: `(schema, table, columns)`.
198    type PendingTable = (String, String, Vec<(String, Value)>);
199
200    let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
201    let mut current: Option<PendingTable> = None;
202
203    let flush = |cur: Option<PendingTable>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
204        if let Some((schema, table, cols)) = cur {
205            let est = estimates.get(&(schema.clone(), table.clone())).copied();
206            let query = format!("SELECT * FROM {}.{}", quote(&schema), quote(&table));
207            let mut d = faucet_core::DatasetDescriptor::new(
208                format!("{schema}.{table}"),
209                "table",
210                serde_json::json!({ "query": query }),
211            )
212            .with_schema(faucet_core::columns_to_schema(cols));
213            if let Some(n) = est {
214                d = d.with_estimated_rows(n);
215            }
216            out.push(d);
217        }
218    };
219
220    for (schema, table, column, data_type, is_nullable) in rows {
221        let same = current
222            .as_ref()
223            .is_some_and(|(s, t, _)| *s == schema && *t == table);
224        if !same {
225            flush(current.take(), &mut out);
226            current = Some((schema, table, Vec::new()));
227        }
228        let mut fragment = faucet_core::sql_type_to_json_schema(&data_type);
229        if is_nullable {
230            fragment = faucet_core::nullable_type(fragment);
231        }
232        if let Some((_, _, cols)) = current.as_mut() {
233            cols.push((column, fragment));
234        }
235    }
236    flush(current, &mut out);
237    out
238}
239
240/// Derive a default state-store key from the connection host + a query
241/// fingerprint, stable across runs.
242fn default_state_key(config: &MssqlSourceConfig) -> String {
243    let host = config
244        .connection
245        .connection_url
246        .as_deref()
247        .and_then(|u| url::Url::parse(u).ok())
248        .and_then(|u| u.host_str().map(|h| h.to_string()))
249        .unwrap_or_else(|| "mssql".to_string());
250
251    let mut hasher = std::collections::hash_map::DefaultHasher::new();
252    config.query.hash(&mut hasher);
253    let fingerprint = hasher.finish();
254    // Host may contain dots (allowed mid-key); sanitise anything else.
255    let host: String = host
256        .chars()
257        .map(|c| {
258            if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
259                c
260            } else {
261                '_'
262            }
263        })
264        .collect();
265    format!("mssql:{host}:{fingerprint:016x}")
266}
267
268#[async_trait]
269impl Source for MssqlSource {
270    async fn fetch_with_context(
271        &self,
272        context: &HashMap<String, Value>,
273    ) -> Result<Vec<Value>, FaucetError> {
274        Ok(self.collect_all(context).await?.0)
275    }
276
277    async fn fetch_with_context_incremental(
278        &self,
279        context: &HashMap<String, Value>,
280    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
281        self.collect_all(context).await
282    }
283
284    fn stream_pages<'a>(
285        &'a self,
286        context: &'a HashMap<String, Value>,
287        _batch_size: usize,
288    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
289        let batch_size = self.config.batch_size;
290        let chunk = if batch_size == 0 {
291            usize::MAX
292        } else {
293            batch_size
294        };
295        let cap = if batch_size == 0 { 1024 } else { batch_size };
296        let start = self.current_start();
297        let (query, values, incr) = build_query_and_params(&self.config, context, start.as_ref());
298        let query = self.shard_wrap(query);
299
300        Box::pin(async_stream::try_stream! {
301            let mut conn = self
302                .pool
303                .get()
304                .await
305                .map_err(|e| FaucetError::Source(format!("MSSQL pool checkout failed: {e}")))?;
306
307            // Scope the borrowed param slice to the query() call — the
308            // QueryStream borrows the connection, not the params.
309            let mut stream = {
310                let owned: Vec<OwnedParam> = values.iter().map(OwnedParam::from_value).collect();
311                let refs: Vec<&dyn ToSql> = owned.iter().map(OwnedParam::as_tosql).collect();
312                let query_fut = conn.query(&query, &refs);
313                match self.timeout() {
314                    Some(t) => {
315                        with_statement_timeout(t, async {
316                            query_fut.await.map_err(|e| {
317                                FaucetError::Source(format!("MSSQL query failed: {e}"))
318                            })
319                        }, || FaucetError::Source("MSSQL query timed out".into()))
320                        .await?
321                    }
322                    None => query_fut
323                        .await
324                        .map_err(|e| FaucetError::Source(format!("MSSQL query failed: {e}")))?,
325                }
326            };
327
328            let mut buffer: Vec<Value> = Vec::with_capacity(cap);
329            let mut running_max: Option<Value> = None;
330            let mut total = 0usize;
331
332            while let Some(item) = stream
333                .try_next()
334                .await
335                .map_err(|e| FaucetError::Source(format!("MSSQL row stream failed: {e}")))?
336            {
337                let QueryItem::Row(row) = item else { continue };
338                buffer.push(row_to_json(&row)?);
339                if buffer.len() >= chunk {
340                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
341                    let kept = apply_incremental(page, incr.as_ref(), &mut running_max);
342                    total += kept.len();
343                    if !kept.is_empty() {
344                        yield StreamPage { records: kept, bookmark: None };
345                    }
346                }
347            }
348
349            // Final page carries the bookmark so the pipeline persists only
350            // after everything before it has been written.
351            let kept = apply_incremental(buffer, incr.as_ref(), &mut running_max);
352            total += kept.len();
353            let bookmark = if incr.is_some() { running_max.clone() } else { None };
354            if !kept.is_empty() || bookmark.is_some() {
355                yield StreamPage { records: kept, bookmark };
356            }
357
358            tracing::info!(rows = total, query = %self.config.query, "MSSQL source stream complete");
359        })
360    }
361
362    fn config_schema(&self) -> Value {
363        serde_json::to_value(faucet_core::schema_for!(MssqlSourceConfig))
364            .expect("schema serialization")
365    }
366
367    fn connector_name(&self) -> &'static str {
368        "mssql"
369    }
370
371    fn dataset_uri(&self) -> String {
372        let conn = self
373            .config
374            .connection
375            .connection_url
376            .as_deref()
377            .or(self.config.connection.connection_string.as_deref())
378            .unwrap_or("");
379        format!(
380            "{}?query={}",
381            faucet_core::redact_uri_credentials(conn),
382            self.config.query
383        )
384    }
385
386    fn state_key(&self) -> Option<String> {
387        match &self.config.replication {
388            MssqlReplication::Full => None,
389            MssqlReplication::Incremental { .. } => Some(
390                self.config
391                    .state_key
392                    .clone()
393                    .unwrap_or_else(|| default_state_key(&self.config)),
394            ),
395        }
396    }
397
398    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
399        *self
400            .start_bookmark
401            .lock()
402            .expect("start_bookmark mutex poisoned") = Some(bookmark);
403        Ok(())
404    }
405
406    async fn check(&self, ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
407        let started = std::time::Instant::now();
408        let probe = match tokio::time::timeout(ctx.timeout, self.pool.get()).await {
409            Ok(Ok(_conn)) => Probe::pass("connect", started.elapsed()),
410            Ok(Err(e)) => Probe::fail_hint(
411                "connect",
412                started.elapsed(),
413                e.to_string(),
414                "check connection_url / credentials / TLS / that the server is reachable",
415            ),
416            Err(_) => Probe::fail_hint(
417                "connect",
418                started.elapsed(),
419                "timed out",
420                "check connection_url / credentials / TLS / that the server is reachable",
421            ),
422        };
423        Ok(CheckReport::single(probe))
424    }
425
426    fn supports_discover(&self) -> bool {
427        true
428    }
429
430    /// Enumerate every base table visible to the connection, with column
431    /// types from `INFORMATION_SCHEMA.COLUMNS` and a row estimate from
432    /// `sys.partitions` (catalog metadata only — no data scan). The estimate
433    /// query degrades gracefully: when the `sys.*` views aren't readable
434    /// (permissions), tables are returned without estimates rather than
435    /// failing discovery.
436    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
437        const CATALOG_SQL: &str = "SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME, \
438                c.DATA_TYPE, c.IS_NULLABLE \
439           FROM INFORMATION_SCHEMA.COLUMNS c \
440           JOIN INFORMATION_SCHEMA.TABLES t \
441             ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME \
442          WHERE t.TABLE_TYPE = 'BASE TABLE' \
443          ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION";
444        // Heap (index_id 0) or clustered-index (index_id 1) partitions carry
445        // the table's row count; secondary indexes would double-count.
446        const ESTIMATE_SQL: &str = "SELECT s.name AS sch, o.name AS tbl, SUM(p.rows) AS est \
447           FROM sys.objects o \
448           JOIN sys.schemas s ON s.schema_id = o.schema_id \
449           JOIN sys.partitions p ON p.object_id = o.object_id AND p.index_id IN (0, 1) \
450          WHERE o.type = 'U' \
451          GROUP BY s.name, o.name";
452
453        let map_err = |e: tiberius::error::Error| {
454            FaucetError::Source(format!("mssql: catalog discovery failed: {e}"))
455        };
456
457        let mut conn =
458            self.pool.get().await.map_err(|e| {
459                FaucetError::Source(format!("mssql: catalog discovery failed: {e}"))
460            })?;
461
462        let rows = {
463            let run = async {
464                conn.query(CATALOG_SQL, &[])
465                    .await
466                    .map_err(map_err)?
467                    .into_first_result()
468                    .await
469                    .map_err(map_err)
470            };
471            match self.timeout() {
472                Some(t) => {
473                    with_statement_timeout(t, run, || {
474                        FaucetError::Source("mssql: catalog discovery timed out".into())
475                    })
476                    .await?
477                }
478                None => run.await?,
479            }
480        };
481
482        let mut catalog: Vec<CatalogRow> = Vec::with_capacity(rows.len());
483        for row in &rows {
484            let v = row_to_json(row)?;
485            let decode = |col: &str| -> Result<String, FaucetError> {
486                v.get(col)
487                    .and_then(Value::as_str)
488                    .map(str::to_string)
489                    .ok_or_else(|| {
490                        FaucetError::Source(format!("mssql: catalog decode failed ({col})"))
491                    })
492            };
493            catalog.push((
494                decode("TABLE_SCHEMA")?,
495                decode("TABLE_NAME")?,
496                decode("COLUMN_NAME")?,
497                decode("DATA_TYPE")?,
498                // Absent/odd IS_NULLABLE over-approximates to nullable.
499                decode("IS_NULLABLE")
500                    .map(|s| s.eq_ignore_ascii_case("YES"))
501                    .unwrap_or(true),
502            ));
503        }
504
505        let mut estimates: HashMap<(String, String), u64> = HashMap::new();
506        let est_rows = {
507            let run = async {
508                conn.query(ESTIMATE_SQL, &[])
509                    .await
510                    .map_err(map_err)?
511                    .into_first_result()
512                    .await
513                    .map_err(map_err)
514            };
515            match self.timeout() {
516                Some(t) => {
517                    with_statement_timeout(t, run, || {
518                        FaucetError::Source("mssql: catalog discovery timed out".into())
519                    })
520                    .await
521                }
522                None => run.await,
523            }
524        };
525        match est_rows {
526            Ok(rows) => {
527                for row in &rows {
528                    if let Ok(v) = row_to_json(row)
529                        && let (Some(sch), Some(tbl)) = (v["sch"].as_str(), v["tbl"].as_str())
530                        && let Some(est) = v["est"].as_i64()
531                        && est >= 0
532                    {
533                        estimates.insert((sch.to_string(), tbl.to_string()), est as u64);
534                    }
535                }
536            }
537            // Permissions on sys.* vary by principal — estimates are
538            // best-effort metadata, never worth failing discovery over.
539            Err(e) => tracing::debug!(
540                error = %e,
541                "mssql: row-estimate query failed; discovery continues without estimates"
542            ),
543        }
544
545        Ok(descriptors_from_catalog(catalog, &estimates, bracket_quote))
546    }
547
548    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
549    fn is_shardable(&self) -> bool {
550        self.config.shard.is_some()
551    }
552
553    /// Enumerate contiguous primary-key range shards by computing the `key`
554    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
555    /// range into ~`target` slices. Returns a single whole-dataset shard when no
556    /// `shard` config is set or the result set is empty.
557    ///
558    /// The base query is resolved through the same query builder as a normal
559    /// fetch first, so a `@bookmark` token (incremental replication) is bound
560    /// rather than left dangling — bounds are then computed over the
561    /// not-yet-synced slice, which is exactly the data the shards will read.
562    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
563        let Some(shard_cfg) = &self.config.shard else {
564            return Ok(vec![ShardSpec::whole()]);
565        };
566
567        let start = self.current_start();
568        let (inner, values, _incr) =
569            build_query_and_params(&self.config, &HashMap::new(), start.as_ref());
570        let key = quote_ident_mssql(&shard_cfg.key)?;
571        let bounds_sql = pk_bounds_query(&inner, &key, "BIGINT");
572
573        let mut conn = self
574            .pool
575            .get()
576            .await
577            .map_err(|e| FaucetError::Source(format!("MSSQL pool checkout failed: {e}")))?;
578
579        let rows = {
580            let owned: Vec<OwnedParam> = values.iter().map(OwnedParam::from_value).collect();
581            let refs: Vec<&dyn ToSql> = owned.iter().map(OwnedParam::as_tosql).collect();
582            let run = async {
583                conn.query(&bounds_sql, &refs)
584                    .await
585                    .map_err(|e| {
586                        FaucetError::Source(format!(
587                            "mssql: failed to compute shard bounds for key {:?} \
588                             (it must be an integer-typed column, and the query must \
589                             not end in a top-level ORDER BY): {e}",
590                            shard_cfg.key
591                        ))
592                    })?
593                    .into_first_result()
594                    .await
595                    .map_err(|e| {
596                        FaucetError::Source(format!(
597                            "mssql: failed to compute shard bounds for key {:?} \
598                             (it must be an integer-typed column, and the query must \
599                             not end in a top-level ORDER BY): {e}",
600                            shard_cfg.key
601                        ))
602                    })
603            };
604            match self.timeout() {
605                Some(t) => {
606                    with_statement_timeout(t, run, || {
607                        FaucetError::Source("MSSQL shard-bounds query timed out".into())
608                    })
609                    .await?
610                }
611                None => run.await?,
612            }
613        };
614
615        let Some(row) = rows.first() else {
616            return Ok(vec![ShardSpec::whole()]);
617        };
618        let decoded = row_to_json(row)?;
619        Ok(pk_shards_from_bounds(
620            &shard_cfg.key,
621            decoded["lo"].as_i64(),
622            decoded["hi"].as_i64(),
623            target,
624        ))
625    }
626
627    /// Narrow this source to a single PK-range shard. The whole-dataset shard
628    /// clears any applied range (streams the full query).
629    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
630        let bounds = parse_pk_shard(shard, "mssql")?;
631        if let Some(b) = &bounds {
632            // Validate the key now (NUL check) so `bracket_quote` in the hot
633            // wrap path stays infallible.
634            quote_ident_mssql(&b.key)?;
635        }
636        *self.applied_shard.lock().expect("shard mutex poisoned") = bounds;
637        Ok(())
638    }
639}
640
641impl MssqlSource {
642    /// Run the query and return all decoded rows plus (for incremental) the new
643    /// bookmark. Used by the non-streaming convenience methods.
644    async fn collect_all(
645        &self,
646        context: &HashMap<String, Value>,
647    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
648        let start = self.current_start();
649        let (query, values, incr) = build_query_and_params(&self.config, context, start.as_ref());
650        let query = self.shard_wrap(query);
651
652        let mut conn = self
653            .pool
654            .get()
655            .await
656            .map_err(|e| FaucetError::Source(format!("MSSQL pool checkout failed: {e}")))?;
657
658        let rows = {
659            let owned: Vec<OwnedParam> = values.iter().map(OwnedParam::from_value).collect();
660            let refs: Vec<&dyn ToSql> = owned.iter().map(OwnedParam::as_tosql).collect();
661            let run = async {
662                conn.query(&query, &refs)
663                    .await
664                    .map_err(|e| FaucetError::Source(format!("MSSQL query failed: {e}")))?
665                    .into_first_result()
666                    .await
667                    .map_err(|e| FaucetError::Source(format!("MSSQL result read failed: {e}")))
668            };
669            match self.timeout() {
670                Some(t) => {
671                    with_statement_timeout(t, run, || {
672                        FaucetError::Source("MSSQL query timed out".into())
673                    })
674                    .await?
675                }
676                None => run.await?,
677            }
678        };
679
680        let mut records = Vec::with_capacity(rows.len());
681        for row in &rows {
682            records.push(row_to_json(row)?);
683        }
684
685        let mut running_max: Option<Value> = None;
686        let records = apply_incremental(records, incr.as_ref(), &mut running_max);
687        let bookmark = if incr.is_some() { running_max } else { None };
688        Ok((records, bookmark))
689    }
690}
691
692/// Filter a page for incremental replication and advance `running_max`.
693/// For full replication the page passes through unchanged.
694fn apply_incremental(
695    page: Vec<Value>,
696    incr: Option<&IncrementalCtx>,
697    running_max: &mut Option<Value>,
698) -> Vec<Value> {
699    match incr {
700        None => page,
701        Some(ctx) => {
702            let kept = filter_incremental(page, &ctx.column, &ctx.start);
703            if let Some(m) = max_replication_value(&kept, &ctx.column) {
704                let m = m.clone();
705                *running_max = Some(match running_max.take() {
706                    Some(prev) => max_value(prev, m),
707                    None => m,
708                });
709            }
710            kept
711        }
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use faucet_core::shard::plan_pk_shards;
719    use serde_json::json;
720
721    fn full_cfg() -> MssqlSourceConfig {
722        MssqlSourceConfig::new("mssql://sa:pw@db.example.com:1433/sales", "SELECT * FROM t")
723    }
724
725    #[test]
726    fn owned_param_binds_large_u64_as_exact_string_not_wrapped_i64() {
727        // F41: a u64 above i64::MAX must keep its exact value, not wrap to a
728        // negative i64 (`u64::MAX as i64 == -1`).
729        let big = u64::MAX; // 18446744073709551615 > i64::MAX
730        match OwnedParam::from_value(&json!(big)) {
731            OwnedParam::Str(s) => assert_eq!(s, big.to_string()),
732            OwnedParam::I64(v) => panic!("u64::MAX bound as wrapped i64 {v}"),
733            _ => panic!("expected a string-bound param for a large u64"),
734        }
735        // A u64 that still fits i64 keeps the native integer binding.
736        match OwnedParam::from_value(&json!(42u64)) {
737            OwnedParam::I64(v) => assert_eq!(v, 42),
738            _ => panic!("small u64 should bind as i64"),
739        }
740        // i64::MAX itself is representable as i64.
741        match OwnedParam::from_value(&json!(i64::MAX)) {
742            OwnedParam::I64(v) => assert_eq!(v, i64::MAX),
743            _ => panic!("i64::MAX should bind as i64"),
744        }
745    }
746
747    #[test]
748    fn build_full_returns_query_and_params_unchanged() {
749        let mut cfg = full_cfg();
750        cfg.params = vec![json!(1), json!("x")];
751        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
752        assert_eq!(q, "SELECT * FROM t");
753        assert_eq!(v, vec![json!(1), json!("x")]);
754        assert!(incr.is_none());
755    }
756
757    #[test]
758    fn build_incremental_binds_bookmark_token() {
759        let cfg = MssqlSourceConfig {
760            query: "SELECT * FROM t WHERE updated_at > @bookmark".into(),
761            replication: MssqlReplication::Incremental {
762                column: "updated_at".into(),
763                initial_value: json!("1970-01-01"),
764            },
765            ..full_cfg()
766        };
767        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
768        assert_eq!(q, "SELECT * FROM t WHERE updated_at > @P1");
769        assert_eq!(v, vec![json!("1970-01-01")]);
770        assert_eq!(
771            incr,
772            Some(IncrementalCtx {
773                column: "updated_at".into(),
774                start: json!("1970-01-01")
775            })
776        );
777    }
778
779    #[test]
780    fn build_incremental_uses_stored_bookmark_over_initial() {
781        let cfg = MssqlSourceConfig {
782            query: "SELECT * FROM t WHERE c > @bookmark".into(),
783            params: vec![json!("p0")],
784            replication: MssqlReplication::Incremental {
785                column: "c".into(),
786                initial_value: json!(0),
787            },
788            ..full_cfg()
789        };
790        let stored = json!(500);
791        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), Some(&stored));
792        // bookmark bound after the one configured param → @P2
793        assert_eq!(q, "SELECT * FROM t WHERE c > @P2");
794        assert_eq!(v, vec![json!("p0"), json!(500)]);
795        assert_eq!(incr.unwrap().start, json!(500));
796    }
797
798    #[test]
799    fn build_incremental_without_token_still_returns_filter_ctx() {
800        let cfg = MssqlSourceConfig {
801            query: "SELECT * FROM t".into(),
802            replication: MssqlReplication::Incremental {
803                column: "c".into(),
804                initial_value: json!(0),
805            },
806            ..full_cfg()
807        };
808        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
809        assert_eq!(q, "SELECT * FROM t");
810        assert!(v.is_empty());
811        assert!(incr.is_some(), "client-side filter must still run");
812    }
813
814    #[test]
815    fn owned_param_classifies_json() {
816        assert!(matches!(
817            OwnedParam::from_value(&json!("s")),
818            OwnedParam::Str(_)
819        ));
820        assert!(matches!(
821            OwnedParam::from_value(&json!(7)),
822            OwnedParam::I64(7)
823        ));
824        assert!(matches!(
825            OwnedParam::from_value(&json!(1.5)),
826            OwnedParam::F64(_)
827        ));
828        assert!(matches!(
829            OwnedParam::from_value(&json!(true)),
830            OwnedParam::Bool(true)
831        ));
832        assert!(matches!(
833            OwnedParam::from_value(&Value::Null),
834            OwnedParam::Null(None)
835        ));
836        assert!(matches!(
837            OwnedParam::from_value(&json!({"a":1})),
838            OwnedParam::Str(_)
839        ));
840    }
841
842    #[test]
843    fn apply_incremental_filters_and_tracks_max() {
844        let ctx = IncrementalCtx {
845            column: "c".into(),
846            start: json!(10),
847        };
848        let mut running = None;
849        let page = vec![json!({"c": 5}), json!({"c": 15}), json!({"c": 20})];
850        let kept = apply_incremental(page, Some(&ctx), &mut running);
851        assert_eq!(kept.len(), 2);
852        assert_eq!(running, Some(json!(20)));
853    }
854
855    #[test]
856    fn apply_incremental_full_passes_through() {
857        let mut running = None;
858        let page = vec![json!({"c": 1}), json!({"c": 2})];
859        let kept = apply_incremental(page, None, &mut running);
860        assert_eq!(kept.len(), 2);
861        assert_eq!(running, None);
862    }
863
864    #[test]
865    fn default_state_key_is_stable_and_valid() {
866        let cfg = full_cfg();
867        let k1 = default_state_key(&cfg);
868        let k2 = default_state_key(&cfg);
869        assert_eq!(k1, k2);
870        assert!(k1.starts_with("mssql:db.example.com:"));
871        faucet_core::state::validate_state_key(&k1).expect("derived key must be valid");
872    }
873
874    // dataset_uri is a pure-config method; the source requires a live SQL Server
875    // pool to construct so we verify the logic directly.
876    #[test]
877    fn dataset_uri_redacts_connection_url() {
878        let cfg = full_cfg();
879        let conn = cfg
880            .connection
881            .connection_url
882            .as_deref()
883            .or(cfg.connection.connection_string.as_deref())
884            .unwrap_or("");
885        let uri = format!(
886            "{}?query={}",
887            faucet_core::redact_uri_credentials(conn),
888            cfg.query
889        );
890        assert_eq!(
891            uri,
892            "mssql://db.example.com:1433/sales?query=SELECT * FROM t"
893        );
894    }
895
896    // ── PK-range sharding (Mode B, #262) ─────────────────────────────────────
897
898    #[test]
899    fn shard_wrap_uses_bracket_quoting() {
900        let spec = faucet_core::ShardSpec::new(
901            "1",
902            json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
903        );
904        let bounds = PkShardBounds::from_spec(&spec).unwrap();
905        let sql = bounds.wrap("SELECT * FROM t", bracket_quote);
906        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"), "{sql}");
907        assert!(sql.contains("[id] >= 100"), "bracket-quoted key: {sql}");
908        assert!(sql.contains("[id] < 200"), "half-open upper bound: {sql}");
909    }
910
911    #[test]
912    fn last_shard_wrap_covers_null_keys() {
913        let shards = plan_pk_shards("id", 0, 99, 3);
914        let last = PkShardBounds::from_spec(shards.last().unwrap()).unwrap();
915        let sql = last.wrap("SELECT * FROM t", bracket_quote);
916        assert!(
917            sql.contains("[id] IS NULL"),
918            "last shard must match NULL keys: {sql}"
919        );
920    }
921
922    #[test]
923    fn shard_wrap_preserves_positional_bind_markers() {
924        // The @Pn markers of a resolved incremental query survive the wrap —
925        // tiberius binds them by name, not by textual position.
926        let cfg = MssqlSourceConfig {
927            query: "SELECT * FROM t WHERE c > @bookmark".into(),
928            replication: MssqlReplication::Incremental {
929                column: "c".into(),
930                initial_value: json!(0),
931            },
932            ..full_cfg()
933        };
934        let (q, v, _incr) = build_query_and_params(&cfg, &HashMap::new(), None);
935        let spec = faucet_core::ShardSpec::new(
936            "0",
937            json!({"key": "id", "lo": 0, "hi": 10, "lo_unbounded": false, "hi_unbounded": false}),
938        );
939        let wrapped = PkShardBounds::from_spec(&spec)
940            .unwrap()
941            .wrap(&q, bracket_quote);
942        assert!(wrapped.contains("@P1"), "bind marker survives: {wrapped}");
943        assert_eq!(v.len(), 1, "one bound value for the bookmark");
944    }
945
946    // ── discover: pure catalog-row grouping (#211) ───────────────────────────
947
948    fn cat_row(s: &str, t: &str, c: &str, dt: &str, nullable: bool) -> CatalogRow {
949        (s.into(), t.into(), c.into(), dt.into(), nullable)
950    }
951
952    #[test]
953    fn descriptors_group_catalog_rows_per_table() {
954        let rows = vec![
955            cat_row("dbo", "orders", "id", "int", false),
956            cat_row("dbo", "orders", "note", "nvarchar", true),
957            cat_row("sales", "orders", "total", "decimal", false),
958        ];
959        let mut estimates = HashMap::new();
960        estimates.insert(("dbo".to_string(), "orders".to_string()), 120u64);
961        let ds = descriptors_from_catalog(rows, &estimates, bracket_quote);
962        assert_eq!(ds.len(), 2, "same table name in two schemas = two datasets");
963
964        assert_eq!(ds[0].name, "dbo.orders");
965        assert_eq!(ds[0].kind, "table");
966        assert_eq!(ds[0].estimated_rows, Some(120));
967        assert_eq!(ds[0].config_patch["query"], "SELECT * FROM [dbo].[orders]");
968        let schema = ds[0].schema.as_ref().unwrap();
969        assert_eq!(schema["type"], "object");
970        assert_eq!(schema["properties"]["id"]["type"], "integer");
971        assert_eq!(
972            schema["properties"]["note"]["type"],
973            json!(["string", "null"]),
974            "nullable column"
975        );
976
977        // Estimate-merge miss: sales.orders has no sys.partitions entry.
978        assert_eq!(ds[1].name, "sales.orders");
979        assert_eq!(ds[1].estimated_rows, None, "missing estimate = no field");
980        assert_eq!(
981            ds[1].schema.as_ref().unwrap()["properties"]["total"]["type"],
982            "number"
983        );
984    }
985
986    #[test]
987    fn descriptors_quote_hostile_identifiers() {
988        let rows = vec![cat_row("dbo", "wei]rd", "id", "int", false)];
989        let ds = descriptors_from_catalog(rows, &HashMap::new(), bracket_quote);
990        let q = ds[0].config_patch["query"].as_str().unwrap();
991        assert_eq!(
992            q, "SELECT * FROM [dbo].[wei]]rd]",
993            "interior ] must be doubled"
994        );
995    }
996
997    #[test]
998    fn descriptors_empty_catalog_is_empty() {
999        assert!(descriptors_from_catalog(Vec::new(), &HashMap::new(), bracket_quote).is_empty());
1000    }
1001}