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            Value::Number(n) if n.is_u64() => OwnedParam::I64(n.as_u64().unwrap() as i64),
134            Value::Number(n) => OwnedParam::F64(n.as_f64().unwrap_or(0.0)),
135            Value::Bool(b) => OwnedParam::Bool(*b),
136            Value::Null => OwnedParam::Null(None),
137            other => OwnedParam::Str(other.to_string()),
138        }
139    }
140
141    fn as_tosql(&self) -> &dyn ToSql {
142        match self {
143            OwnedParam::I64(v) => v,
144            OwnedParam::F64(v) => v,
145            OwnedParam::Bool(v) => v,
146            OwnedParam::Str(v) => v,
147            OwnedParam::Null(v) => v,
148        }
149    }
150}
151
152/// Derive a default state-store key from the connection host + a query
153/// fingerprint, stable across runs.
154fn default_state_key(config: &MssqlSourceConfig) -> String {
155    let host = config
156        .connection
157        .connection_url
158        .as_deref()
159        .and_then(|u| url::Url::parse(u).ok())
160        .and_then(|u| u.host_str().map(|h| h.to_string()))
161        .unwrap_or_else(|| "mssql".to_string());
162
163    let mut hasher = std::collections::hash_map::DefaultHasher::new();
164    config.query.hash(&mut hasher);
165    let fingerprint = hasher.finish();
166    // Host may contain dots (allowed mid-key); sanitise anything else.
167    let host: String = host
168        .chars()
169        .map(|c| {
170            if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
171                c
172            } else {
173                '_'
174            }
175        })
176        .collect();
177    format!("mssql:{host}:{fingerprint:016x}")
178}
179
180#[async_trait]
181impl Source for MssqlSource {
182    async fn fetch_with_context(
183        &self,
184        context: &HashMap<String, Value>,
185    ) -> Result<Vec<Value>, FaucetError> {
186        Ok(self.collect_all(context).await?.0)
187    }
188
189    async fn fetch_with_context_incremental(
190        &self,
191        context: &HashMap<String, Value>,
192    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
193        self.collect_all(context).await
194    }
195
196    fn stream_pages<'a>(
197        &'a self,
198        context: &'a HashMap<String, Value>,
199        _batch_size: usize,
200    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
201        let batch_size = self.config.batch_size;
202        let chunk = if batch_size == 0 {
203            usize::MAX
204        } else {
205            batch_size
206        };
207        let cap = if batch_size == 0 { 1024 } else { batch_size };
208        let start = self.current_start();
209        let (query, values, incr) = build_query_and_params(&self.config, context, start.as_ref());
210
211        Box::pin(async_stream::try_stream! {
212            let mut conn = self
213                .pool
214                .get()
215                .await
216                .map_err(|e| FaucetError::Source(format!("MSSQL pool checkout failed: {e}")))?;
217
218            // Scope the borrowed param slice to the query() call — the
219            // QueryStream borrows the connection, not the params.
220            let mut stream = {
221                let owned: Vec<OwnedParam> = values.iter().map(OwnedParam::from_value).collect();
222                let refs: Vec<&dyn ToSql> = owned.iter().map(OwnedParam::as_tosql).collect();
223                let query_fut = conn.query(&query, &refs);
224                match self.timeout() {
225                    Some(t) => {
226                        with_statement_timeout(t, async {
227                            query_fut.await.map_err(|e| {
228                                FaucetError::Source(format!("MSSQL query failed: {e}"))
229                            })
230                        }, || FaucetError::Source("MSSQL query timed out".into()))
231                        .await?
232                    }
233                    None => query_fut
234                        .await
235                        .map_err(|e| FaucetError::Source(format!("MSSQL query failed: {e}")))?,
236                }
237            };
238
239            let mut buffer: Vec<Value> = Vec::with_capacity(cap);
240            let mut running_max: Option<Value> = None;
241            let mut total = 0usize;
242
243            while let Some(item) = stream
244                .try_next()
245                .await
246                .map_err(|e| FaucetError::Source(format!("MSSQL row stream failed: {e}")))?
247            {
248                let QueryItem::Row(row) = item else { continue };
249                buffer.push(row_to_json(&row)?);
250                if buffer.len() >= chunk {
251                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
252                    let kept = apply_incremental(page, incr.as_ref(), &mut running_max);
253                    total += kept.len();
254                    if !kept.is_empty() {
255                        yield StreamPage { records: kept, bookmark: None };
256                    }
257                }
258            }
259
260            // Final page carries the bookmark so the pipeline persists only
261            // after everything before it has been written.
262            let kept = apply_incremental(buffer, incr.as_ref(), &mut running_max);
263            total += kept.len();
264            let bookmark = if incr.is_some() { running_max.clone() } else { None };
265            if !kept.is_empty() || bookmark.is_some() {
266                yield StreamPage { records: kept, bookmark };
267            }
268
269            tracing::info!(rows = total, query = %self.config.query, "MSSQL source stream complete");
270        })
271    }
272
273    fn config_schema(&self) -> Value {
274        serde_json::to_value(faucet_core::schema_for!(MssqlSourceConfig))
275            .expect("schema serialization")
276    }
277
278    fn connector_name(&self) -> &'static str {
279        "mssql"
280    }
281
282    fn dataset_uri(&self) -> String {
283        let conn = self
284            .config
285            .connection
286            .connection_url
287            .as_deref()
288            .or(self.config.connection.connection_string.as_deref())
289            .unwrap_or("");
290        format!(
291            "{}?query={}",
292            faucet_core::redact_uri_credentials(conn),
293            self.config.query
294        )
295    }
296
297    fn state_key(&self) -> Option<String> {
298        match &self.config.replication {
299            MssqlReplication::Full => None,
300            MssqlReplication::Incremental { .. } => Some(
301                self.config
302                    .state_key
303                    .clone()
304                    .unwrap_or_else(|| default_state_key(&self.config)),
305            ),
306        }
307    }
308
309    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
310        *self
311            .start_bookmark
312            .lock()
313            .expect("start_bookmark mutex poisoned") = Some(bookmark);
314        Ok(())
315    }
316
317    async fn check(&self, ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
318        let started = std::time::Instant::now();
319        let probe = match tokio::time::timeout(ctx.timeout, self.pool.get()).await {
320            Ok(Ok(_conn)) => Probe::pass("connect", started.elapsed()),
321            Ok(Err(e)) => Probe::fail_hint(
322                "connect",
323                started.elapsed(),
324                e.to_string(),
325                "check connection_url / credentials / TLS / that the server is reachable",
326            ),
327            Err(_) => Probe::fail_hint(
328                "connect",
329                started.elapsed(),
330                "timed out",
331                "check connection_url / credentials / TLS / that the server is reachable",
332            ),
333        };
334        Ok(CheckReport::single(probe))
335    }
336}
337
338impl MssqlSource {
339    /// Run the query and return all decoded rows plus (for incremental) the new
340    /// bookmark. Used by the non-streaming convenience methods.
341    async fn collect_all(
342        &self,
343        context: &HashMap<String, Value>,
344    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
345        let start = self.current_start();
346        let (query, values, incr) = build_query_and_params(&self.config, context, start.as_ref());
347
348        let mut conn = self
349            .pool
350            .get()
351            .await
352            .map_err(|e| FaucetError::Source(format!("MSSQL pool checkout failed: {e}")))?;
353
354        let rows = {
355            let owned: Vec<OwnedParam> = values.iter().map(OwnedParam::from_value).collect();
356            let refs: Vec<&dyn ToSql> = owned.iter().map(OwnedParam::as_tosql).collect();
357            let run = async {
358                conn.query(&query, &refs)
359                    .await
360                    .map_err(|e| FaucetError::Source(format!("MSSQL query failed: {e}")))?
361                    .into_first_result()
362                    .await
363                    .map_err(|e| FaucetError::Source(format!("MSSQL result read failed: {e}")))
364            };
365            match self.timeout() {
366                Some(t) => {
367                    with_statement_timeout(t, run, || {
368                        FaucetError::Source("MSSQL query timed out".into())
369                    })
370                    .await?
371                }
372                None => run.await?,
373            }
374        };
375
376        let mut records = Vec::with_capacity(rows.len());
377        for row in &rows {
378            records.push(row_to_json(row)?);
379        }
380
381        let mut running_max: Option<Value> = None;
382        let records = apply_incremental(records, incr.as_ref(), &mut running_max);
383        let bookmark = if incr.is_some() { running_max } else { None };
384        Ok((records, bookmark))
385    }
386}
387
388/// Filter a page for incremental replication and advance `running_max`.
389/// For full replication the page passes through unchanged.
390fn apply_incremental(
391    page: Vec<Value>,
392    incr: Option<&IncrementalCtx>,
393    running_max: &mut Option<Value>,
394) -> Vec<Value> {
395    match incr {
396        None => page,
397        Some(ctx) => {
398            let kept = filter_incremental(page, &ctx.column, &ctx.start);
399            if let Some(m) = max_replication_value(&kept, &ctx.column) {
400                let m = m.clone();
401                *running_max = Some(match running_max.take() {
402                    Some(prev) => max_value(prev, m),
403                    None => m,
404                });
405            }
406            kept
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use serde_json::json;
415
416    fn full_cfg() -> MssqlSourceConfig {
417        MssqlSourceConfig::new("mssql://sa:pw@db.example.com:1433/sales", "SELECT * FROM t")
418    }
419
420    #[test]
421    fn build_full_returns_query_and_params_unchanged() {
422        let mut cfg = full_cfg();
423        cfg.params = vec![json!(1), json!("x")];
424        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
425        assert_eq!(q, "SELECT * FROM t");
426        assert_eq!(v, vec![json!(1), json!("x")]);
427        assert!(incr.is_none());
428    }
429
430    #[test]
431    fn build_incremental_binds_bookmark_token() {
432        let cfg = MssqlSourceConfig {
433            query: "SELECT * FROM t WHERE updated_at > @bookmark".into(),
434            replication: MssqlReplication::Incremental {
435                column: "updated_at".into(),
436                initial_value: json!("1970-01-01"),
437            },
438            ..full_cfg()
439        };
440        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
441        assert_eq!(q, "SELECT * FROM t WHERE updated_at > @P1");
442        assert_eq!(v, vec![json!("1970-01-01")]);
443        assert_eq!(
444            incr,
445            Some(IncrementalCtx {
446                column: "updated_at".into(),
447                start: json!("1970-01-01")
448            })
449        );
450    }
451
452    #[test]
453    fn build_incremental_uses_stored_bookmark_over_initial() {
454        let cfg = MssqlSourceConfig {
455            query: "SELECT * FROM t WHERE c > @bookmark".into(),
456            params: vec![json!("p0")],
457            replication: MssqlReplication::Incremental {
458                column: "c".into(),
459                initial_value: json!(0),
460            },
461            ..full_cfg()
462        };
463        let stored = json!(500);
464        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), Some(&stored));
465        // bookmark bound after the one configured param → @P2
466        assert_eq!(q, "SELECT * FROM t WHERE c > @P2");
467        assert_eq!(v, vec![json!("p0"), json!(500)]);
468        assert_eq!(incr.unwrap().start, json!(500));
469    }
470
471    #[test]
472    fn build_incremental_without_token_still_returns_filter_ctx() {
473        let cfg = MssqlSourceConfig {
474            query: "SELECT * FROM t".into(),
475            replication: MssqlReplication::Incremental {
476                column: "c".into(),
477                initial_value: json!(0),
478            },
479            ..full_cfg()
480        };
481        let (q, v, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
482        assert_eq!(q, "SELECT * FROM t");
483        assert!(v.is_empty());
484        assert!(incr.is_some(), "client-side filter must still run");
485    }
486
487    #[test]
488    fn owned_param_classifies_json() {
489        assert!(matches!(
490            OwnedParam::from_value(&json!("s")),
491            OwnedParam::Str(_)
492        ));
493        assert!(matches!(
494            OwnedParam::from_value(&json!(7)),
495            OwnedParam::I64(7)
496        ));
497        assert!(matches!(
498            OwnedParam::from_value(&json!(1.5)),
499            OwnedParam::F64(_)
500        ));
501        assert!(matches!(
502            OwnedParam::from_value(&json!(true)),
503            OwnedParam::Bool(true)
504        ));
505        assert!(matches!(
506            OwnedParam::from_value(&Value::Null),
507            OwnedParam::Null(None)
508        ));
509        assert!(matches!(
510            OwnedParam::from_value(&json!({"a":1})),
511            OwnedParam::Str(_)
512        ));
513    }
514
515    #[test]
516    fn apply_incremental_filters_and_tracks_max() {
517        let ctx = IncrementalCtx {
518            column: "c".into(),
519            start: json!(10),
520        };
521        let mut running = None;
522        let page = vec![json!({"c": 5}), json!({"c": 15}), json!({"c": 20})];
523        let kept = apply_incremental(page, Some(&ctx), &mut running);
524        assert_eq!(kept.len(), 2);
525        assert_eq!(running, Some(json!(20)));
526    }
527
528    #[test]
529    fn apply_incremental_full_passes_through() {
530        let mut running = None;
531        let page = vec![json!({"c": 1}), json!({"c": 2})];
532        let kept = apply_incremental(page, None, &mut running);
533        assert_eq!(kept.len(), 2);
534        assert_eq!(running, None);
535    }
536
537    #[test]
538    fn default_state_key_is_stable_and_valid() {
539        let cfg = full_cfg();
540        let k1 = default_state_key(&cfg);
541        let k2 = default_state_key(&cfg);
542        assert_eq!(k1, k2);
543        assert!(k1.starts_with("mssql:db.example.com:"));
544        faucet_core::state::validate_state_key(&k1).expect("derived key must be valid");
545    }
546
547    // dataset_uri is a pure-config method; the source requires a live SQL Server
548    // pool to construct so we verify the logic directly.
549    #[test]
550    fn dataset_uri_redacts_connection_url() {
551        let cfg = full_cfg();
552        let conn = cfg
553            .connection
554            .connection_url
555            .as_deref()
556            .or(cfg.connection.connection_string.as_deref())
557            .unwrap_or("");
558        let uri = format!(
559            "{}?query={}",
560            faucet_core::redact_uri_credentials(conn),
561            cfg.query
562        );
563        assert_eq!(
564            uri,
565            "mssql://db.example.com:1433/sales?query=SELECT * FROM t"
566        );
567    }
568}