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::{FaucetError, Source, StreamPage};
14use futures::{Stream, TryStreamExt};
15use serde_json::Value;
16use tiberius::{QueryItem, ToSql};
17
18use faucet_common_mssql::{MssqlPool, build_pool, with_statement_timeout};
19
20use crate::config::{MssqlReplication, MssqlSourceConfig};
21use crate::convert::row_to_json;
22
23/// Microsoft SQL Server query source.
24pub struct MssqlSource {
25    config: MssqlSourceConfig,
26    pool: MssqlPool,
27    /// Bookmark loaded via [`Source::apply_start_bookmark`]; overrides the
28    /// configured `initial_value` for incremental runs.
29    start_bookmark: Mutex<Option<Value>>,
30}
31
32impl MssqlSource {
33    /// Connect, validate the config, and build the connection pool.
34    pub async fn new(config: MssqlSourceConfig) -> Result<Self, FaucetError> {
35        config.validate()?;
36        let pool = build_pool(&config.connection, config.max_connections).await?;
37        Ok(Self {
38            config,
39            pool,
40            start_bookmark: Mutex::new(None),
41        })
42    }
43
44    fn timeout(&self) -> Option<Duration> {
45        match self.config.statement_timeout_secs {
46            0 => None,
47            secs => Some(Duration::from_secs(secs)),
48        }
49    }
50
51    fn current_start(&self) -> Option<Value> {
52        self.start_bookmark
53            .lock()
54            .expect("start_bookmark mutex poisoned")
55            .clone()
56    }
57}
58
59/// Incremental-replication context resolved for one run.
60#[derive(Debug, Clone, PartialEq)]
61struct IncrementalCtx {
62    column: String,
63    start: Value,
64}
65
66/// Build the final query string, the ordered bind values, and (for incremental
67/// runs) the client-side filter context.
68///
69/// Pure function (no pool) so it is unit-testable. Param order is:
70/// `config.params` → context-substituted values → the incremental bookmark
71/// (only when the query contains the `@bookmark` token).
72fn build_query_and_params(
73    config: &MssqlSourceConfig,
74    context: &HashMap<String, Value>,
75    start_bookmark: Option<&Value>,
76) -> (String, Vec<Value>, Option<IncrementalCtx>) {
77    // Resolve parent-context placeholders to positional @P markers.
78    let (mut query, mut values) = if context.is_empty() {
79        (config.query.clone(), config.params.clone())
80    } else {
81        let (q, ctx_values) = faucet_core::util::substitute_context_bind_params(
82            &config.query,
83            context,
84            config.params.len() + 1,
85            |i| format!("@P{i}"),
86        );
87        let mut v = config.params.clone();
88        v.extend(ctx_values);
89        (q, v)
90    };
91
92    let incremental = match &config.replication {
93        MssqlReplication::Full => None,
94        MssqlReplication::Incremental {
95            column,
96            initial_value,
97        } => {
98            let start = start_bookmark
99                .cloned()
100                .unwrap_or_else(|| initial_value.clone());
101            // Server-side pushdown: bind the cursor where the user wrote
102            // `@bookmark`. If absent, only the client-side filter applies.
103            if query.contains("@bookmark") {
104                let idx = values.len() + 1;
105                query = query.replace("@bookmark", &format!("@P{idx}"));
106                values.push(start.clone());
107            }
108            Some(IncrementalCtx {
109                column: column.clone(),
110                start,
111            })
112        }
113    };
114
115    (query, values, incremental)
116}
117
118/// Owned bind parameter, so the borrowed `&dyn ToSql` slice handed to
119/// `tiberius` outlives nothing it shouldn't.
120enum OwnedParam {
121    I64(i64),
122    F64(f64),
123    Bool(bool),
124    Str(String),
125    Null(Option<i32>),
126}
127
128impl OwnedParam {
129    fn from_value(v: &Value) -> Self {
130        match v {
131            Value::String(s) => OwnedParam::Str(s.clone()),
132            Value::Number(n) if n.is_i64() => OwnedParam::I64(n.as_i64().unwrap()),
133            // A `u64` above `i64::MAX` cannot be represented as a tiberius `i64`
134            // bind value (SQL Server has no unsigned 64-bit type). The previous
135            // `as i64` wrapped it to a negative number (e.g. u64::MAX -> -1),
136            // silently matching the wrong rows. Bind the exact decimal digits as
137            // a string instead — SQL Server implicitly converts it to the
138            // column's numeric type for comparison, preserving the value (F41).
139            Value::Number(n) if n.is_u64() => OwnedParam::Str(n.as_u64().unwrap().to_string()),
140            Value::Number(n) => OwnedParam::F64(n.as_f64().unwrap_or(0.0)),
141            Value::Bool(b) => OwnedParam::Bool(*b),
142            Value::Null => OwnedParam::Null(None),
143            other => OwnedParam::Str(other.to_string()),
144        }
145    }
146
147    fn as_tosql(&self) -> &dyn ToSql {
148        match self {
149            OwnedParam::I64(v) => v,
150            OwnedParam::F64(v) => v,
151            OwnedParam::Bool(v) => v,
152            OwnedParam::Str(v) => v,
153            OwnedParam::Null(v) => v,
154        }
155    }
156}
157
158/// Derive a default state-store key from the connection host + a query
159/// fingerprint, stable across runs.
160fn default_state_key(config: &MssqlSourceConfig) -> String {
161    let host = config
162        .connection
163        .connection_url
164        .as_deref()
165        .and_then(|u| url::Url::parse(u).ok())
166        .and_then(|u| u.host_str().map(|h| h.to_string()))
167        .unwrap_or_else(|| "mssql".to_string());
168
169    let mut hasher = std::collections::hash_map::DefaultHasher::new();
170    config.query.hash(&mut hasher);
171    let fingerprint = hasher.finish();
172    // Host may contain dots (allowed mid-key); sanitise anything else.
173    let host: String = host
174        .chars()
175        .map(|c| {
176            if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
177                c
178            } else {
179                '_'
180            }
181        })
182        .collect();
183    format!("mssql:{host}:{fingerprint:016x}")
184}
185
186#[async_trait]
187impl Source for MssqlSource {
188    async fn fetch_with_context(
189        &self,
190        context: &HashMap<String, Value>,
191    ) -> Result<Vec<Value>, FaucetError> {
192        Ok(self.collect_all(context).await?.0)
193    }
194
195    async fn fetch_with_context_incremental(
196        &self,
197        context: &HashMap<String, Value>,
198    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
199        self.collect_all(context).await
200    }
201
202    fn stream_pages<'a>(
203        &'a self,
204        context: &'a HashMap<String, Value>,
205        _batch_size: usize,
206    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
207        let batch_size = self.config.batch_size;
208        let chunk = if batch_size == 0 {
209            usize::MAX
210        } else {
211            batch_size
212        };
213        let cap = if batch_size == 0 { 1024 } else { batch_size };
214        let start = self.current_start();
215        let (query, values, incr) = build_query_and_params(&self.config, context, start.as_ref());
216
217        Box::pin(async_stream::try_stream! {
218            let mut conn = self
219                .pool
220                .get()
221                .await
222                .map_err(|e| FaucetError::Source(format!("MSSQL pool checkout failed: {e}")))?;
223
224            // Scope the borrowed param slice to the query() call — the
225            // QueryStream borrows the connection, not the params.
226            let mut stream = {
227                let owned: Vec<OwnedParam> = values.iter().map(OwnedParam::from_value).collect();
228                let refs: Vec<&dyn ToSql> = owned.iter().map(OwnedParam::as_tosql).collect();
229                let query_fut = conn.query(&query, &refs);
230                match self.timeout() {
231                    Some(t) => {
232                        with_statement_timeout(t, async {
233                            query_fut.await.map_err(|e| {
234                                FaucetError::Source(format!("MSSQL query failed: {e}"))
235                            })
236                        }, || FaucetError::Source("MSSQL query timed out".into()))
237                        .await?
238                    }
239                    None => query_fut
240                        .await
241                        .map_err(|e| FaucetError::Source(format!("MSSQL query failed: {e}")))?,
242                }
243            };
244
245            let mut buffer: Vec<Value> = Vec::with_capacity(cap);
246            let mut running_max: Option<Value> = None;
247            let mut total = 0usize;
248
249            while let Some(item) = stream
250                .try_next()
251                .await
252                .map_err(|e| FaucetError::Source(format!("MSSQL row stream failed: {e}")))?
253            {
254                let QueryItem::Row(row) = item else { continue };
255                buffer.push(row_to_json(&row)?);
256                if buffer.len() >= chunk {
257                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
258                    let kept = apply_incremental(page, incr.as_ref(), &mut running_max);
259                    total += kept.len();
260                    if !kept.is_empty() {
261                        yield StreamPage { records: kept, bookmark: None };
262                    }
263                }
264            }
265
266            // Final page carries the bookmark so the pipeline persists only
267            // after everything before it has been written.
268            let kept = apply_incremental(buffer, incr.as_ref(), &mut running_max);
269            total += kept.len();
270            let bookmark = if incr.is_some() { running_max.clone() } else { None };
271            if !kept.is_empty() || bookmark.is_some() {
272                yield StreamPage { records: kept, bookmark };
273            }
274
275            tracing::info!(rows = total, query = %self.config.query, "MSSQL source stream complete");
276        })
277    }
278
279    fn config_schema(&self) -> Value {
280        serde_json::to_value(faucet_core::schema_for!(MssqlSourceConfig))
281            .expect("schema serialization")
282    }
283
284    fn connector_name(&self) -> &'static str {
285        "mssql"
286    }
287
288    fn dataset_uri(&self) -> String {
289        let conn = self
290            .config
291            .connection
292            .connection_url
293            .as_deref()
294            .or(self.config.connection.connection_string.as_deref())
295            .unwrap_or("");
296        format!(
297            "{}?query={}",
298            faucet_core::redact_uri_credentials(conn),
299            self.config.query
300        )
301    }
302
303    fn state_key(&self) -> Option<String> {
304        match &self.config.replication {
305            MssqlReplication::Full => None,
306            MssqlReplication::Incremental { .. } => Some(
307                self.config
308                    .state_key
309                    .clone()
310                    .unwrap_or_else(|| default_state_key(&self.config)),
311            ),
312        }
313    }
314
315    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
316        *self
317            .start_bookmark
318            .lock()
319            .expect("start_bookmark mutex poisoned") = Some(bookmark);
320        Ok(())
321    }
322
323    async fn check(&self, ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
324        let started = std::time::Instant::now();
325        let probe = match tokio::time::timeout(ctx.timeout, self.pool.get()).await {
326            Ok(Ok(_conn)) => Probe::pass("connect", started.elapsed()),
327            Ok(Err(e)) => Probe::fail_hint(
328                "connect",
329                started.elapsed(),
330                e.to_string(),
331                "check connection_url / credentials / TLS / that the server is reachable",
332            ),
333            Err(_) => Probe::fail_hint(
334                "connect",
335                started.elapsed(),
336                "timed out",
337                "check connection_url / credentials / TLS / that the server is reachable",
338            ),
339        };
340        Ok(CheckReport::single(probe))
341    }
342}
343
344impl MssqlSource {
345    /// Run the query and return all decoded rows plus (for incremental) the new
346    /// bookmark. Used by the non-streaming convenience methods.
347    async fn collect_all(
348        &self,
349        context: &HashMap<String, Value>,
350    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
351        let start = self.current_start();
352        let (query, values, incr) = build_query_and_params(&self.config, context, start.as_ref());
353
354        let mut conn = self
355            .pool
356            .get()
357            .await
358            .map_err(|e| FaucetError::Source(format!("MSSQL pool checkout failed: {e}")))?;
359
360        let rows = {
361            let owned: Vec<OwnedParam> = values.iter().map(OwnedParam::from_value).collect();
362            let refs: Vec<&dyn ToSql> = owned.iter().map(OwnedParam::as_tosql).collect();
363            let run = async {
364                conn.query(&query, &refs)
365                    .await
366                    .map_err(|e| FaucetError::Source(format!("MSSQL query failed: {e}")))?
367                    .into_first_result()
368                    .await
369                    .map_err(|e| FaucetError::Source(format!("MSSQL result read failed: {e}")))
370            };
371            match self.timeout() {
372                Some(t) => {
373                    with_statement_timeout(t, run, || {
374                        FaucetError::Source("MSSQL query timed out".into())
375                    })
376                    .await?
377                }
378                None => run.await?,
379            }
380        };
381
382        let mut records = Vec::with_capacity(rows.len());
383        for row in &rows {
384            records.push(row_to_json(row)?);
385        }
386
387        let mut running_max: Option<Value> = None;
388        let records = apply_incremental(records, incr.as_ref(), &mut running_max);
389        let bookmark = if incr.is_some() { running_max } else { None };
390        Ok((records, bookmark))
391    }
392}
393
394/// Filter a page for incremental replication and advance `running_max`.
395/// For full replication the page passes through unchanged.
396fn apply_incremental(
397    page: Vec<Value>,
398    incr: Option<&IncrementalCtx>,
399    running_max: &mut Option<Value>,
400) -> Vec<Value> {
401    match incr {
402        None => page,
403        Some(ctx) => {
404            let kept = filter_incremental(page, &ctx.column, &ctx.start);
405            if let Some(m) = max_replication_value(&kept, &ctx.column) {
406                let m = m.clone();
407                *running_max = Some(match running_max.take() {
408                    Some(prev) => max_value(prev, m),
409                    None => m,
410                });
411            }
412            kept
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use serde_json::json;
421
422    fn full_cfg() -> MssqlSourceConfig {
423        MssqlSourceConfig::new("mssql://sa:pw@db.example.com:1433/sales", "SELECT * FROM t")
424    }
425
426    #[test]
427    fn owned_param_binds_large_u64_as_exact_string_not_wrapped_i64() {
428        // F41: a u64 above i64::MAX must keep its exact value, not wrap to a
429        // negative i64 (`u64::MAX as i64 == -1`).
430        let big = u64::MAX; // 18446744073709551615 > i64::MAX
431        match OwnedParam::from_value(&json!(big)) {
432            OwnedParam::Str(s) => assert_eq!(s, big.to_string()),
433            OwnedParam::I64(v) => panic!("u64::MAX bound as wrapped i64 {v}"),
434            _ => panic!("expected a string-bound param for a large u64"),
435        }
436        // A u64 that still fits i64 keeps the native integer binding.
437        match OwnedParam::from_value(&json!(42u64)) {
438            OwnedParam::I64(v) => assert_eq!(v, 42),
439            _ => panic!("small u64 should bind as i64"),
440        }
441        // i64::MAX itself is representable as i64.
442        match OwnedParam::from_value(&json!(i64::MAX)) {
443            OwnedParam::I64(v) => assert_eq!(v, i64::MAX),
444            _ => panic!("i64::MAX should bind as i64"),
445        }
446    }
447
448    #[test]
449    fn build_full_returns_query_and_params_unchanged() {
450        let mut cfg = full_cfg();
451        cfg.params = vec![json!(1), json!("x")];
452        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
453        assert_eq!(q, "SELECT * FROM t");
454        assert_eq!(v, vec![json!(1), json!("x")]);
455        assert!(incr.is_none());
456    }
457
458    #[test]
459    fn build_incremental_binds_bookmark_token() {
460        let cfg = MssqlSourceConfig {
461            query: "SELECT * FROM t WHERE updated_at > @bookmark".into(),
462            replication: MssqlReplication::Incremental {
463                column: "updated_at".into(),
464                initial_value: json!("1970-01-01"),
465            },
466            ..full_cfg()
467        };
468        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
469        assert_eq!(q, "SELECT * FROM t WHERE updated_at > @P1");
470        assert_eq!(v, vec![json!("1970-01-01")]);
471        assert_eq!(
472            incr,
473            Some(IncrementalCtx {
474                column: "updated_at".into(),
475                start: json!("1970-01-01")
476            })
477        );
478    }
479
480    #[test]
481    fn build_incremental_uses_stored_bookmark_over_initial() {
482        let cfg = MssqlSourceConfig {
483            query: "SELECT * FROM t WHERE c > @bookmark".into(),
484            params: vec![json!("p0")],
485            replication: MssqlReplication::Incremental {
486                column: "c".into(),
487                initial_value: json!(0),
488            },
489            ..full_cfg()
490        };
491        let stored = json!(500);
492        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), Some(&stored));
493        // bookmark bound after the one configured param → @P2
494        assert_eq!(q, "SELECT * FROM t WHERE c > @P2");
495        assert_eq!(v, vec![json!("p0"), json!(500)]);
496        assert_eq!(incr.unwrap().start, json!(500));
497    }
498
499    #[test]
500    fn build_incremental_without_token_still_returns_filter_ctx() {
501        let cfg = MssqlSourceConfig {
502            query: "SELECT * FROM t".into(),
503            replication: MssqlReplication::Incremental {
504                column: "c".into(),
505                initial_value: json!(0),
506            },
507            ..full_cfg()
508        };
509        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
510        assert_eq!(q, "SELECT * FROM t");
511        assert!(v.is_empty());
512        assert!(incr.is_some(), "client-side filter must still run");
513    }
514
515    #[test]
516    fn owned_param_classifies_json() {
517        assert!(matches!(
518            OwnedParam::from_value(&json!("s")),
519            OwnedParam::Str(_)
520        ));
521        assert!(matches!(
522            OwnedParam::from_value(&json!(7)),
523            OwnedParam::I64(7)
524        ));
525        assert!(matches!(
526            OwnedParam::from_value(&json!(1.5)),
527            OwnedParam::F64(_)
528        ));
529        assert!(matches!(
530            OwnedParam::from_value(&json!(true)),
531            OwnedParam::Bool(true)
532        ));
533        assert!(matches!(
534            OwnedParam::from_value(&Value::Null),
535            OwnedParam::Null(None)
536        ));
537        assert!(matches!(
538            OwnedParam::from_value(&json!({"a":1})),
539            OwnedParam::Str(_)
540        ));
541    }
542
543    #[test]
544    fn apply_incremental_filters_and_tracks_max() {
545        let ctx = IncrementalCtx {
546            column: "c".into(),
547            start: json!(10),
548        };
549        let mut running = None;
550        let page = vec![json!({"c": 5}), json!({"c": 15}), json!({"c": 20})];
551        let kept = apply_incremental(page, Some(&ctx), &mut running);
552        assert_eq!(kept.len(), 2);
553        assert_eq!(running, Some(json!(20)));
554    }
555
556    #[test]
557    fn apply_incremental_full_passes_through() {
558        let mut running = None;
559        let page = vec![json!({"c": 1}), json!({"c": 2})];
560        let kept = apply_incremental(page, None, &mut running);
561        assert_eq!(kept.len(), 2);
562        assert_eq!(running, None);
563    }
564
565    #[test]
566    fn default_state_key_is_stable_and_valid() {
567        let cfg = full_cfg();
568        let k1 = default_state_key(&cfg);
569        let k2 = default_state_key(&cfg);
570        assert_eq!(k1, k2);
571        assert!(k1.starts_with("mssql:db.example.com:"));
572        faucet_core::state::validate_state_key(&k1).expect("derived key must be valid");
573    }
574
575    // dataset_uri is a pure-config method; the source requires a live SQL Server
576    // pool to construct so we verify the logic directly.
577    #[test]
578    fn dataset_uri_redacts_connection_url() {
579        let cfg = full_cfg();
580        let conn = cfg
581            .connection
582            .connection_url
583            .as_deref()
584            .or(cfg.connection.connection_string.as_deref())
585            .unwrap_or("");
586        let uri = format!(
587            "{}?query={}",
588            faucet_core::redact_uri_credentials(conn),
589            cfg.query
590        );
591        assert_eq!(
592            uri,
593            "mssql://db.example.com:1433/sales?query=SELECT * FROM t"
594        );
595    }
596}