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::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
152fn 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 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 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 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 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
388fn 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 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 #[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}