1use std::collections::HashMap;
10use std::pin::Pin;
11use std::sync::Mutex;
12use std::time::Duration;
13
14use async_trait::async_trait;
15use faucet_common_databricks::{
16 ErrorSide, StatementClient, StatementOptions, resolve_authorization, value_to_param_string,
17};
18use faucet_core::replication::cursor::{CursorBookmark, CursorTracker};
19use faucet_core::{FaucetError, SharedAuthProvider, Source, Stream, StreamPage};
20use reqwest::Client;
21use serde::Deserialize;
22use serde_json::{Value, json};
23
24use crate::config::{
25 BOOKMARK_TOKEN, DatabricksReplication, DatabricksSourceConfig, ResultDisposition, shared_auth,
26};
27use crate::convert::{ColumnInfo, row_to_json};
28
29pub struct DatabricksSource {
31 config: DatabricksSourceConfig,
32 client: Client,
33 endpoint_base: Option<String>,
36 auth_provider: Option<SharedAuthProvider>,
39 start_bookmark: Mutex<Option<CursorBookmark>>,
41 calls: faucet_core::CallSlot,
43}
44
45#[derive(Debug, Deserialize)]
46struct ResultChunk {
47 #[serde(default)]
48 data_array: Option<Vec<Vec<Value>>>,
49 #[serde(default)]
50 next_chunk_internal_link: Option<String>,
51 #[serde(default)]
55 external_links: Option<Vec<ExternalLink>>,
56}
57
58#[derive(Debug, Deserialize)]
60struct ExternalLink {
61 #[serde(default)]
62 external_link: Option<String>,
63 #[serde(default)]
64 next_chunk_internal_link: Option<String>,
65}
66
67fn next_chunk_link(chunk: &ResultChunk) -> Option<String> {
70 chunk.next_chunk_internal_link.clone().or_else(|| {
71 chunk
72 .external_links
73 .as_ref()
74 .and_then(|l| l.last())
75 .and_then(|e| e.next_chunk_internal_link.clone())
76 })
77}
78
79struct Executed {
81 columns: Vec<ColumnInfo>,
82 result: Option<ResultChunk>,
83}
84
85struct IncrementalCtx {
87 column: String,
88 start: CursorBookmark,
89}
90
91fn quote_ident(name: &str) -> String {
93 format!("`{}`", name.replace('`', "``"))
94}
95
96impl DatabricksSource {
97 pub fn new(config: DatabricksSourceConfig) -> Result<Self, FaucetError> {
99 config.validate()?;
100 Ok(Self {
101 config,
102 client: faucet_common_databricks::http_client()?,
103 endpoint_base: None,
104 auth_provider: None,
105 start_bookmark: Mutex::new(None),
106 calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
107 })
108 }
109
110 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
113 self.auth_provider = Some(provider);
114 self
115 }
116
117 pub fn with_endpoint_base(mut self, base: impl Into<String>) -> Self {
120 self.endpoint_base = Some(base.into());
121 self
122 }
123
124 fn base_url(&self) -> String {
125 match &self.endpoint_base {
126 Some(b) => b.trim_end_matches('/').to_owned(),
127 None => self.config.workspace_url.trim_end_matches('/').to_owned(),
128 }
129 }
130
131 fn statements_url(&self) -> String {
132 format!("{}/api/2.0/sql/statements", self.base_url())
133 }
134
135 async fn auth_header(&self) -> Result<String, FaucetError> {
138 resolve_authorization(&shared_auth(&self.config.auth), self.auth_provider.as_ref()).await
139 }
140
141 fn statement_client(&self) -> StatementClient {
143 StatementClient::new(
144 self.client.clone(),
145 self.base_url(),
146 self.config.warehouse_id.clone(),
147 shared_auth(&self.config.auth),
148 self.auth_provider.clone(),
149 StatementOptions {
150 wait_timeout_secs: self.config.wait_timeout_secs,
151 poll_interval: Duration::from_secs(self.config.poll_interval_secs.max(1)),
152 statement_timeout: (self.config.statement_timeout_secs > 0)
153 .then(|| Duration::from_secs(self.config.statement_timeout_secs)),
154 ..StatementOptions::default()
155 },
156 ErrorSide::Source,
157 )
158 .with_call_context(self.calls.get())
159 }
160
161 fn incremental_ctx(&self) -> Option<IncrementalCtx> {
164 match &self.config.replication {
165 DatabricksReplication::Full => None,
166 DatabricksReplication::Incremental {
167 column,
168 initial_value,
169 } => {
170 let start = self
171 .start_bookmark
172 .lock()
173 .expect("start_bookmark mutex poisoned")
174 .clone()
175 .unwrap_or_else(|| CursorBookmark::initial(initial_value.clone()));
176 Some(IncrementalCtx {
177 column: column.clone(),
178 start,
179 })
180 }
181 }
182 }
183
184 fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
189 let mut sql = self.config.sql.clone();
190 let mut params: Vec<Value> = self
192 .config
193 .parameters
194 .iter()
195 .map(|p| {
196 json!({
197 "name": p.name,
198 "value": value_to_param_string(&p.value),
199 "type": p.param_type.clone().unwrap_or_else(|| "STRING".into()),
200 })
201 })
202 .collect();
203
204 if !context.is_empty() {
206 let (rewritten, ctx_values) =
207 faucet_core::util::substitute_context_bind_params(&sql, context, 0, |i| {
208 format!(":_faucet_ct{i}")
209 });
210 sql = rewritten;
211 for (i, v) in ctx_values.into_iter().enumerate() {
212 params.push(json!({
213 "name": format!("_faucet_ct{i}"),
214 "value": value_to_param_string(&v),
215 }));
216 }
217 }
218
219 if let Some(ctx) = incr {
222 sql = sql.replace(BOOKMARK_TOKEN, ":_faucet_bookmark");
223 let c = quote_ident(&ctx.column);
224 let inner = sql.trim_end_matches(|ch: char| ch.is_whitespace() || ch == ';');
225 sql = format!(
226 "SELECT * FROM ({inner}\n) AS q WHERE q.{c} >= :_faucet_bookmark ORDER BY q.{c}"
227 );
228 params.push(json!({
229 "name": "_faucet_bookmark",
230 "value": value_to_param_string(&ctx.start.value),
231 }));
232 }
233
234 let (disposition, format) = if self.config.arrow_native {
240 ("EXTERNAL_LINKS", "ARROW_STREAM")
241 } else {
242 match self.config.result_disposition {
243 ResultDisposition::ExternalLinks => ("EXTERNAL_LINKS", "JSON_ARRAY"),
244 ResultDisposition::Inline => ("INLINE", "JSON_ARRAY"),
245 }
246 };
247 let mut body = json!({
248 "statement": sql,
249 "warehouse_id": self.config.warehouse_id,
250 "wait_timeout": format!("{}s", self.config.wait_timeout_secs),
251 "on_wait_timeout": "CONTINUE",
252 "disposition": disposition,
253 "format": format,
254 });
255 if let Some(c) = &self.config.catalog {
256 body["catalog"] = json!(c);
257 }
258 if let Some(s) = &self.config.schema {
259 body["schema"] = json!(s);
260 }
261 if !params.is_empty() {
262 body["parameters"] = Value::Array(params);
263 }
264 body
265 }
266
267 async fn run_statement(
269 &self,
270 context: &HashMap<String, Value>,
271 incr: Option<&IncrementalCtx>,
272 ) -> Result<Executed, FaucetError> {
273 let body = self.build_body(context, incr);
274 let resp = self.statement_client().execute_body(&body).await?;
275 let columns = resp.columns().to_vec();
276 crate::convert::check_unique_columns(&columns)?;
277 let result = resp.result.map(decode_chunk).transpose()?;
278 Ok(Executed { columns, result })
279 }
280
281 async fn fetch_chunk(&self, link: &str) -> Result<ResultChunk, FaucetError> {
283 decode_chunk(self.statement_client().fetch_chunk(link).await?)
284 }
285
286 #[cfg(feature = "arrow")]
290 async fn fetch_arrow_link(
291 &self,
292 url: &str,
293 ) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
294 let data = bytes::Bytes::from(self.get_external(url).await?);
295 tokio::task::spawn_blocking(move || decode_arrow_ipc(data))
296 .await
297 .map_err(|e| {
298 FaucetError::Source(format!("databricks: arrow decode task panicked: {e}"))
299 })?
300 }
301}
302
303impl DatabricksSource {
304 async fn get_external(&self, url: &str) -> Result<Vec<u8>, FaucetError> {
308 self.calls
309 .call("chunk", || async {
310 let resp = self.client.get(url).send().await.map_err(|e| {
311 FaucetError::Source(format!("databricks: external-link request failed: {e}"))
312 })?;
313 let status = resp.status();
314 if !status.is_success() {
315 let body = faucet_core::http::read_body_limited(
316 resp,
317 Some(faucet_core::util::DEFAULT_ERROR_BODY_MAX_LEN as u64),
318 )
319 .await
320 .map(|b| String::from_utf8_lossy(&b).into_owned())
321 .unwrap_or_default();
322 return Err(FaucetError::Source(format!(
323 "databricks: external link HTTP {status}: {body}"
324 )));
325 }
326 faucet_core::http::read_body_limited(
327 resp,
328 Some(faucet_core::http::DEFAULT_MAX_RESPONSE_BYTES),
329 )
330 .await
331 .map_err(|e| {
332 FaucetError::Source(format!(
333 "databricks: reading external-link body failed: {e}"
334 ))
335 })
336 })
337 .await
338 }
339
340 async fn fetch_json_link(&self, url: &str) -> Result<Vec<Vec<Value>>, FaucetError> {
342 let body = self.get_external(url).await?;
343 serde_json::from_slice(&body).map_err(|e| {
344 FaucetError::Source(format!(
345 "databricks: could not parse an external-link chunk: {e}"
346 ))
347 })
348 }
349
350 async fn chunk_rows(&self, chunk: &mut ResultChunk) -> Result<Vec<Vec<Value>>, FaucetError> {
353 let mut rows = chunk.data_array.take().unwrap_or_default();
354 for link in chunk.external_links.iter().flatten() {
355 if let Some(url) = link.external_link.as_deref() {
356 rows.extend(self.fetch_json_link(url).await?);
357 }
358 }
359 Ok(rows)
360 }
361}
362
363#[cfg(feature = "arrow")]
366fn decode_arrow_ipc(data: bytes::Bytes) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
367 use arrow::ipc::reader::StreamReader;
368
369 let reader = StreamReader::try_new(std::io::Cursor::new(data), None).map_err(|e| {
370 FaucetError::Source(format!("databricks: arrow IPC reader init failed: {e}"))
371 })?;
372 let mut batches = Vec::new();
373 for batch in reader {
374 batches.push(batch.map_err(|e| {
375 FaucetError::Source(format!("databricks: arrow IPC decode failed: {e}"))
376 })?);
377 }
378 Ok(batches)
379}
380
381fn default_state_key(config: &DatabricksSourceConfig) -> String {
383 let material = format!(
385 "{}\u{1f}{}\u{1f}{}",
386 config.workspace_url, config.warehouse_id, config.sql
387 );
388 format!(
389 "databricks:{:016x}",
390 faucet_core::shard::shard_hash(&material)
391 )
392}
393
394fn decode_chunk(v: Value) -> Result<ResultChunk, FaucetError> {
396 serde_json::from_value(v)
397 .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
398}
399
400#[async_trait]
401impl Source for DatabricksSource {
402 fn config_schema(&self) -> Value {
403 serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
404 .expect("schema serialization")
405 }
406
407 fn connector_name(&self) -> &'static str {
408 "databricks"
409 }
410
411 fn set_call_context(&self, ctx: faucet_core::io::CallContext) {
412 self.calls.install(ctx);
413 }
414
415 fn set_roundtrip_recorder(
416 &self,
417 recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
418 ) {
419 self.calls.install_recorder(recorder);
420 }
421
422 fn dataset_uri(&self) -> String {
423 format!(
424 "databricks://{}/warehouses/{}",
425 self.config
426 .workspace_url
427 .trim_start_matches("https://")
428 .trim_end_matches('/'),
429 self.config.warehouse_id
430 )
431 }
432
433 fn state_key(&self) -> Option<String> {
434 match &self.config.replication {
435 DatabricksReplication::Full => None,
436 DatabricksReplication::Incremental { .. } => Some(
437 self.config
438 .state_key
439 .clone()
440 .unwrap_or_else(|| default_state_key(&self.config)),
441 ),
442 }
443 }
444
445 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
446 let start = CursorBookmark::from_state(bookmark)?;
447 *self
448 .start_bookmark
449 .lock()
450 .expect("start_bookmark mutex poisoned") = Some(start);
451 Ok(())
452 }
453
454 #[cfg(feature = "arrow")]
458 fn supports_columnar(&self) -> bool {
459 self.config.arrow_native
460 }
461
462 #[cfg(feature = "arrow")]
468 fn stream_batches<'a>(
469 &'a self,
470 context: &'a HashMap<String, Value>,
471 _batch_size: usize,
472 ) -> Pin<
473 Box<
474 dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
475 >,
476 > {
477 Box::pin(async_stream::try_stream! {
478 if !self.config.arrow_native {
479 Err(FaucetError::Source(
480 "databricks: stream_batches requires `arrow_native: true`".into(),
481 ))?;
482 }
483 let resp = self.run_statement(context, None).await?;
484 let mut chunk = resp.result;
485 let mut total_records = 0usize;
486 let mut total_pages = 0usize;
487 while let Some(c) = chunk {
488 if let Some(links) = c.external_links.as_ref() {
489 for link in links {
490 if let Some(url) = link.external_link.as_deref() {
491 let batches = self.fetch_arrow_link(url).await?;
492 for batch in batches {
493 if batch.num_rows() == 0 {
494 continue;
495 }
496 total_records += batch.num_rows();
497 total_pages += 1;
498 yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
499 }
500 }
501 }
502 }
503 chunk = match next_chunk_link(&c) {
504 Some(link) => Some(self.fetch_chunk(&link).await?),
505 None => None,
506 };
507 }
508 tracing::info!(
509 pages = total_pages,
510 total_records,
511 "databricks columnar stream complete",
512 );
513 })
514 }
515
516 fn stream_pages<'a>(
517 &'a self,
518 context: &'a HashMap<String, Value>,
519 _batch_size: usize,
520 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
521 Box::pin(async_stream::try_stream! {
522 #[cfg(feature = "arrow")]
527 if self.config.arrow_native {
528 let resp = self.run_statement(context, None).await?;
529 let cap = if self.config.batch_size == 0 { 1024 } else { self.config.batch_size };
530 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
531 let mut chunk = resp.result;
532 while let Some(c) = chunk {
533 if let Some(links) = c.external_links.as_ref() {
534 for link in links {
535 if let Some(url) = link.external_link.as_deref() {
536 let batches = self.fetch_arrow_link(url).await?;
537 for batch in &batches {
538 for row in faucet_core::columnar::record_batch_to_values(batch)? {
539 buffer.push(row);
540 if self.config.batch_size != 0
541 && buffer.len() >= self.config.batch_size
542 {
543 let page = std::mem::replace(
544 &mut buffer,
545 Vec::with_capacity(cap),
546 );
547 yield StreamPage { records: page, bookmark: None };
548 }
549 }
550 }
551 }
552 }
553 }
554 chunk = match next_chunk_link(&c) {
555 Some(link) => Some(self.fetch_chunk(&link).await?),
556 None => None,
557 };
558 }
559 if !buffer.is_empty() {
560 yield StreamPage { records: buffer, bookmark: None };
561 }
562 return;
563 }
564
565 let incr = self.incremental_ctx();
566 let resp = self.run_statement(context, incr.as_ref()).await?;
567
568 let columns: Vec<ColumnInfo> = resp.columns;
569
570 let batch = self.config.batch_size;
571 let cap = if batch == 0 { 1024 } else { batch };
572 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
573 let mut tracker = incr
574 .as_ref()
575 .map(|ic| CursorTracker::new(ic.column.clone(), ic.start.clone()));
576
577 let mut chunk = resp.result;
579 while let Some(mut c) = chunk {
580 let data = self.chunk_rows(&mut c).await?;
581 {
582 for row in &data {
583 let obj = row_to_json(row, &columns);
584 if tracker.as_mut().is_none_or(|t| t.admit(&obj)) {
585 buffer.push(obj);
586 }
587 if batch != 0 && buffer.len() >= batch {
588 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
589 let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
590 yield StreamPage { records: page, bookmark };
591 }
592 }
593 }
594 chunk = match next_chunk_link(&c) {
595 Some(link) => Some(self.fetch_chunk(&link).await?),
596 None => None,
597 };
598 }
599
600 if !buffer.is_empty() || tracker.is_some() {
603 let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
604 yield StreamPage { records: buffer, bookmark };
605 }
606 if let Some(missing) = tracker.as_ref().map(CursorTracker::missing).filter(|n| *n > 0) {
607 tracing::warn!(
608 missing,
609 "databricks incremental read: rows without a cursor value were emitted \
610 without moving the bookmark"
611 );
612 }
613 })
614 }
615
616 async fn fetch_with_context(
617 &self,
618 context: &HashMap<String, Value>,
619 ) -> Result<Vec<Value>, FaucetError> {
620 Ok(self.fetch_with_context_incremental(context).await?.0)
621 }
622
623 async fn fetch_with_context_incremental(
624 &self,
625 context: &HashMap<String, Value>,
626 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
627 use futures::StreamExt;
628 let mut records = Vec::new();
629 let mut bookmark = None;
630 let mut s = self.stream_pages(context, self.config.batch_size);
631 while let Some(page) = s.next().await {
632 let page = page?;
633 records.extend(page.records);
634 bookmark = page.bookmark.or(bookmark);
635 }
636 Ok((records, bookmark))
637 }
638
639 async fn check(
640 &self,
641 ctx: &faucet_core::check::CheckContext,
642 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
643 use faucet_core::check::{CheckReport, Probe};
644 let started = std::time::Instant::now();
645 let auth = match self.auth_header().await {
647 Ok(a) => a,
648 Err(e) => {
649 return Ok(CheckReport::single(Probe::fail(
650 "auth",
651 started.elapsed(),
652 e.to_string(),
653 )));
654 }
655 };
656 let body = json!({
657 "statement": "SELECT 1",
658 "warehouse_id": self.config.warehouse_id,
659 "wait_timeout": "50s",
660 "disposition": "INLINE",
661 "format": "JSON_ARRAY",
662 });
663 let calls = self.calls.get().with_timeout(ctx.timeout);
664 let fut = calls.call("submit", || async {
665 self.client
666 .post(self.statements_url())
667 .header("Authorization", &auth)
668 .header("Content-Type", "application/json")
669 .json(&body)
670 .send()
671 .await
672 .map_err(FaucetError::from)
673 });
674 let probe = match tokio::time::timeout(ctx.timeout, fut).await {
675 Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
676 Ok(Ok(r)) => Probe::fail_hint(
677 "warehouse",
678 started.elapsed(),
679 format!("databricks probe returned HTTP {}", r.status()),
680 "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
681 ),
682 Ok(Err(e)) => Probe::fail_hint(
683 "warehouse",
684 started.elapsed(),
685 format!("databricks probe request failed: {e}"),
686 "Verify workspace_url and network reachability.",
687 ),
688 Err(_) => Probe::fail_hint(
689 "warehouse",
690 started.elapsed(),
691 format!("databricks probe timed out after {:?}", ctx.timeout),
692 "Check warehouse availability and network reachability.",
693 ),
694 };
695 Ok(CheckReport::single(probe))
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use crate::config::{DatabricksAuth, DatabricksParam};
703 use faucet_core::AuthSpec;
704
705 fn cfg() -> DatabricksSourceConfig {
706 DatabricksSourceConfig {
707 workspace_url: "https://x.cloud.databricks.com".into(),
708 warehouse_id: "wh1".into(),
709 sql: "SELECT * FROM t WHERE ts >= ${bookmark}".into(),
710 auth: AuthSpec::Inline(DatabricksAuth::Pat {
711 token: "tok".into(),
712 }),
713 catalog: Some("main".into()),
714 schema: Some("s".into()),
715 parameters: vec![DatabricksParam {
716 name: "min".into(),
717 value: json!(10),
718 param_type: Some("INT".into()),
719 }],
720 wait_timeout_secs: 50,
721 poll_interval_secs: 1,
722 statement_timeout_secs: 3600,
723 batch_size: 1000,
724 arrow_native: false,
725 result_disposition: ResultDisposition::default(),
726 replication: DatabricksReplication::Incremental {
727 column: "ts".into(),
728 initial_value: json!("2026-01-01"),
729 },
730 state_key: None,
731 }
732 }
733
734 fn source(c: DatabricksSourceConfig) -> DatabricksSource {
735 DatabricksSource::new(c).unwrap()
736 }
737
738 #[test]
739 fn body_has_required_fields_and_params() {
740 let s = source(cfg());
741 let incr = s.incremental_ctx();
742 let body = s.build_body(&HashMap::new(), incr.as_ref());
743 assert_eq!(body["warehouse_id"], json!("wh1"));
744 assert_eq!(body["catalog"], json!("main"));
745 assert_eq!(body["disposition"], json!("EXTERNAL_LINKS"));
747 assert_eq!(body["format"], json!("JSON_ARRAY"));
748 assert_eq!(body["wait_timeout"], json!("50s"));
749 let mut inline = cfg();
750 inline.result_disposition = ResultDisposition::Inline;
751 let inline_body = source(inline).build_body(&HashMap::new(), incr.as_ref());
752 assert_eq!(inline_body["disposition"], json!("INLINE"));
753 assert_eq!(inline_body["format"], json!("JSON_ARRAY"));
754 assert!(
756 body["statement"]
757 .as_str()
758 .unwrap()
759 .contains(":_faucet_bookmark")
760 );
761 assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
762 assert_eq!(
763 body["statement"],
764 json!(
765 "SELECT * FROM (SELECT * FROM t WHERE ts >= :_faucet_bookmark\n) AS q \
766 WHERE q.`ts` >= :_faucet_bookmark ORDER BY q.`ts`"
767 )
768 );
769 assert_eq!(quote_ident("a`b"), "`a``b`");
770 let params = body["parameters"].as_array().unwrap();
771 assert!(
773 params
774 .iter()
775 .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
776 );
777 let bm = params
778 .iter()
779 .find(|p| p["name"] == json!("_faucet_bookmark"))
780 .unwrap();
781 assert_eq!(bm["value"], json!("2026-01-01"));
782 }
783
784 #[test]
785 fn full_mode_has_no_state_key_or_bookmark_param() {
786 let mut c = cfg();
787 c.replication = DatabricksReplication::Full;
788 c.sql = "SELECT 1".into();
789 let s = source(c);
790 assert!(s.state_key().is_none());
791 let body = s.build_body(&HashMap::new(), None);
792 assert!(
793 body.get("parameters").is_none()
794 || body["parameters"]
795 .as_array()
796 .unwrap()
797 .iter()
798 .all(|p| p["name"] != json!("_faucet_bookmark"))
799 );
800 }
801
802 #[test]
803 fn incremental_state_key_derived_and_stable() {
804 let s = source(cfg());
805 let k1 = s.state_key().unwrap();
806 let k2 = source(cfg()).state_key().unwrap();
807 assert_eq!(k1, k2);
808 assert!(k1.starts_with("databricks:"));
809 let c = cfg();
810 let material = format!("{}\u{1f}{}\u{1f}{}", c.workspace_url, c.warehouse_id, c.sql);
811 assert_eq!(
812 k1,
813 format!(
814 "databricks:{:016x}",
815 faucet_core::shard::shard_hash(&material)
816 )
817 );
818 }
819
820 #[tokio::test]
821 async fn explicit_state_key_wins() {
822 let mut c = cfg();
823 c.state_key = Some("my-key".into());
824 let s = source(c);
825 assert_eq!(s.state_key().as_deref(), Some("my-key"));
826 s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
828 assert_eq!(
829 s.incremental_ctx().unwrap().start.value,
830 json!("2026-06-01")
831 );
832 assert!(
833 s.apply_start_bookmark(json!({"value": null}))
834 .await
835 .is_err()
836 );
837 }
838
839 #[test]
840 fn value_to_param_string_stringifies() {
841 assert_eq!(value_to_param_string(&json!(5)), json!("5"));
842 assert_eq!(value_to_param_string(&json!(true)), json!("true"));
843 assert_eq!(value_to_param_string(&json!("x")), json!("x"));
844 assert_eq!(value_to_param_string(&Value::Null), Value::Null);
845 }
846
847 #[test]
848 fn decode_chunk_rejects_non_objects() {
849 assert!(decode_chunk(json!({"data_array": [["1"]]})).is_ok());
850 let err = decode_chunk(json!(5)).unwrap_err().to_string();
851 assert!(err.contains("could not parse"), "{err}");
852 }
853
854 #[test]
855 fn dataset_uri_redacts_scheme() {
856 let s = source(cfg());
857 assert_eq!(
858 s.dataset_uri(),
859 "databricks://x.cloud.databricks.com/warehouses/wh1"
860 );
861 }
862}