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::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
26pub struct MssqlSource {
28 config: MssqlSourceConfig,
29 pool: MssqlPool,
30 start_bookmark: Mutex<Option<Value>>,
33 applied_shard: Mutex<Option<PkShardBounds>>,
37}
38
39impl MssqlSource {
40 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 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
77fn bracket_quote(name: &str) -> String {
81 format!("[{}]", name.replace(']', "]]"))
82}
83
84#[derive(Debug, Clone, PartialEq)]
86struct IncrementalCtx {
87 column: String,
88 start: Value,
89}
90
91fn build_query_and_params(
98 config: &MssqlSourceConfig,
99 context: &HashMap<String, Value>,
100 start_bookmark: Option<&Value>,
101) -> (String, Vec<Value>, Option<IncrementalCtx>) {
102 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 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
143enum 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 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
183type CatalogRow = (String, String, String, String, bool);
186
187fn descriptors_from_catalog(
193 rows: Vec<CatalogRow>,
194 estimates: &HashMap<(String, String), u64>,
195 quote: fn(&str) -> String,
196) -> Vec<faucet_core::DatasetDescriptor> {
197 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
240fn 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 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 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 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 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 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 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 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 fn is_shardable(&self) -> bool {
550 self.config.shard.is_some()
551 }
552
553 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 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
630 let bounds = parse_pk_shard(shard, "mssql")?;
631 if let Some(b) = &bounds {
632 quote_ident_mssql(&b.key)?;
635 }
636 *self.applied_shard.lock().expect("shard mutex poisoned") = bounds;
637 Ok(())
638 }
639}
640
641impl MssqlSource {
642 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
692fn 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 let big = u64::MAX; 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 match OwnedParam::from_value(&json!(42u64)) {
737 OwnedParam::I64(v) => assert_eq!(v, 42),
738 _ => panic!("small u64 should bind as i64"),
739 }
740 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 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 #[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 #[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 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 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 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}