1use 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
23pub struct MssqlSource {
25 config: MssqlSourceConfig,
26 pool: MssqlPool,
27 start_bookmark: Mutex<Option<Value>>,
30}
31
32impl MssqlSource {
33 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#[derive(Debug, Clone, PartialEq)]
61struct IncrementalCtx {
62 column: String,
63 start: Value,
64}
65
66fn build_query_and_params(
73 config: &MssqlSourceConfig,
74 context: &HashMap<String, Value>,
75 start_bookmark: Option<&Value>,
76) -> (String, Vec<Value>, Option<IncrementalCtx>) {
77 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 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
118enum 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::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
158fn 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 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 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 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 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
394fn 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 let big = u64::MAX; 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 match OwnedParam::from_value(&json!(42u64)) {
438 OwnedParam::I64(v) => assert_eq!(v, 42),
439 _ => panic!("small u64 should bind as i64"),
440 }
441 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 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 #[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}