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.first())
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 resp = self.get_external(url).await?;
295 let data = resp.bytes().await.map_err(|e| {
296 FaucetError::Source(format!(
297 "databricks: reading external-link body failed: {e}"
298 ))
299 })?;
300 tokio::task::spawn_blocking(move || decode_arrow_ipc(data))
301 .await
302 .map_err(|e| {
303 FaucetError::Source(format!("databricks: arrow decode task panicked: {e}"))
304 })?
305 }
306}
307
308impl DatabricksSource {
309 async fn get_external(&self, url: &str) -> Result<reqwest::Response, FaucetError> {
313 let resp = self
314 .calls
315 .call("chunk", || async {
316 self.client.get(url).send().await.map_err(|e| {
317 FaucetError::Source(format!("databricks: external-link request failed: {e}"))
318 })
319 })
320 .await?;
321 let status = resp.status();
322 if !status.is_success() {
323 let body = resp.text().await.unwrap_or_default();
324 return Err(FaucetError::Source(format!(
325 "databricks: external link HTTP {status}: {body}"
326 )));
327 }
328 Ok(resp)
329 }
330
331 async fn fetch_json_link(&self, url: &str) -> Result<Vec<Vec<Value>>, FaucetError> {
333 self.get_external(url).await?.json().await.map_err(|e| {
334 FaucetError::Source(format!(
335 "databricks: could not parse an external-link chunk: {e}"
336 ))
337 })
338 }
339
340 async fn chunk_rows(&self, chunk: &mut ResultChunk) -> Result<Vec<Vec<Value>>, FaucetError> {
343 let mut rows = chunk.data_array.take().unwrap_or_default();
344 for link in chunk.external_links.iter().flatten() {
345 if let Some(url) = link.external_link.as_deref() {
346 rows.extend(self.fetch_json_link(url).await?);
347 }
348 }
349 Ok(rows)
350 }
351}
352
353#[cfg(feature = "arrow")]
356fn decode_arrow_ipc(data: bytes::Bytes) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
357 use arrow::ipc::reader::StreamReader;
358
359 let reader = StreamReader::try_new(std::io::Cursor::new(data), None).map_err(|e| {
360 FaucetError::Source(format!("databricks: arrow IPC reader init failed: {e}"))
361 })?;
362 let mut batches = Vec::new();
363 for batch in reader {
364 batches.push(batch.map_err(|e| {
365 FaucetError::Source(format!("databricks: arrow IPC decode failed: {e}"))
366 })?);
367 }
368 Ok(batches)
369}
370
371fn default_state_key(config: &DatabricksSourceConfig) -> String {
373 let material = format!(
375 "{}\u{1f}{}\u{1f}{}",
376 config.workspace_url, config.warehouse_id, config.sql
377 );
378 format!(
379 "databricks:{:016x}",
380 faucet_core::shard::shard_hash(&material)
381 )
382}
383
384fn decode_chunk(v: Value) -> Result<ResultChunk, FaucetError> {
386 serde_json::from_value(v)
387 .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
388}
389
390#[async_trait]
391impl Source for DatabricksSource {
392 fn config_schema(&self) -> Value {
393 serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
394 .expect("schema serialization")
395 }
396
397 fn connector_name(&self) -> &'static str {
398 "databricks"
399 }
400
401 fn set_call_context(&self, ctx: faucet_core::io::CallContext) {
402 self.calls.install(ctx);
403 }
404
405 fn set_roundtrip_recorder(
406 &self,
407 recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
408 ) {
409 self.calls.install_recorder(recorder);
410 }
411
412 fn dataset_uri(&self) -> String {
413 format!(
414 "databricks://{}/warehouses/{}",
415 self.config
416 .workspace_url
417 .trim_start_matches("https://")
418 .trim_end_matches('/'),
419 self.config.warehouse_id
420 )
421 }
422
423 fn state_key(&self) -> Option<String> {
424 match &self.config.replication {
425 DatabricksReplication::Full => None,
426 DatabricksReplication::Incremental { .. } => Some(
427 self.config
428 .state_key
429 .clone()
430 .unwrap_or_else(|| default_state_key(&self.config)),
431 ),
432 }
433 }
434
435 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
436 let start = CursorBookmark::from_state(bookmark)?;
437 *self
438 .start_bookmark
439 .lock()
440 .expect("start_bookmark mutex poisoned") = Some(start);
441 Ok(())
442 }
443
444 #[cfg(feature = "arrow")]
448 fn supports_columnar(&self) -> bool {
449 self.config.arrow_native
450 }
451
452 #[cfg(feature = "arrow")]
458 fn stream_batches<'a>(
459 &'a self,
460 context: &'a HashMap<String, Value>,
461 _batch_size: usize,
462 ) -> Pin<
463 Box<
464 dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
465 >,
466 > {
467 Box::pin(async_stream::try_stream! {
468 if !self.config.arrow_native {
469 Err(FaucetError::Source(
470 "databricks: stream_batches requires `arrow_native: true`".into(),
471 ))?;
472 }
473 let resp = self.run_statement(context, None).await?;
474 let mut chunk = resp.result;
475 let mut total_records = 0usize;
476 let mut total_pages = 0usize;
477 while let Some(c) = chunk {
478 if let Some(links) = c.external_links.as_ref() {
479 for link in links {
480 if let Some(url) = link.external_link.as_deref() {
481 let batches = self.fetch_arrow_link(url).await?;
482 for batch in batches {
483 if batch.num_rows() == 0 {
484 continue;
485 }
486 total_records += batch.num_rows();
487 total_pages += 1;
488 yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
489 }
490 }
491 }
492 }
493 chunk = match next_chunk_link(&c) {
494 Some(link) => Some(self.fetch_chunk(&link).await?),
495 None => None,
496 };
497 }
498 tracing::info!(
499 pages = total_pages,
500 total_records,
501 "databricks columnar stream complete",
502 );
503 })
504 }
505
506 fn stream_pages<'a>(
507 &'a self,
508 context: &'a HashMap<String, Value>,
509 _batch_size: usize,
510 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
511 Box::pin(async_stream::try_stream! {
512 #[cfg(feature = "arrow")]
517 if self.config.arrow_native {
518 let resp = self.run_statement(context, None).await?;
519 let cap = if self.config.batch_size == 0 { 1024 } else { self.config.batch_size };
520 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
521 let mut chunk = resp.result;
522 while let Some(c) = chunk {
523 if let Some(links) = c.external_links.as_ref() {
524 for link in links {
525 if let Some(url) = link.external_link.as_deref() {
526 let batches = self.fetch_arrow_link(url).await?;
527 for batch in &batches {
528 for row in faucet_core::columnar::record_batch_to_values(batch)? {
529 buffer.push(row);
530 if self.config.batch_size != 0
531 && buffer.len() >= self.config.batch_size
532 {
533 let page = std::mem::replace(
534 &mut buffer,
535 Vec::with_capacity(cap),
536 );
537 yield StreamPage { records: page, bookmark: None };
538 }
539 }
540 }
541 }
542 }
543 }
544 chunk = match next_chunk_link(&c) {
545 Some(link) => Some(self.fetch_chunk(&link).await?),
546 None => None,
547 };
548 }
549 if !buffer.is_empty() {
550 yield StreamPage { records: buffer, bookmark: None };
551 }
552 return;
553 }
554
555 let incr = self.incremental_ctx();
556 let resp = self.run_statement(context, incr.as_ref()).await?;
557
558 let columns: Vec<ColumnInfo> = resp.columns;
559
560 let batch = self.config.batch_size;
561 let cap = if batch == 0 { 1024 } else { batch };
562 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
563 let mut tracker = incr
564 .as_ref()
565 .map(|ic| CursorTracker::new(ic.column.clone(), ic.start.clone()));
566
567 let mut chunk = resp.result;
569 while let Some(mut c) = chunk {
570 let data = self.chunk_rows(&mut c).await?;
571 {
572 for row in &data {
573 let obj = row_to_json(row, &columns);
574 if tracker.as_mut().is_none_or(|t| t.admit(&obj)) {
575 buffer.push(obj);
576 }
577 if batch != 0 && buffer.len() >= batch {
578 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
579 let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
580 yield StreamPage { records: page, bookmark };
581 }
582 }
583 }
584 chunk = match next_chunk_link(&c) {
585 Some(link) => Some(self.fetch_chunk(&link).await?),
586 None => None,
587 };
588 }
589
590 if !buffer.is_empty() || tracker.is_some() {
593 let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
594 yield StreamPage { records: buffer, bookmark };
595 }
596 if let Some(missing) = tracker.as_ref().map(CursorTracker::missing).filter(|n| *n > 0) {
597 tracing::warn!(
598 missing,
599 "databricks incremental read: rows without a cursor value were emitted \
600 without moving the bookmark"
601 );
602 }
603 })
604 }
605
606 async fn fetch_with_context(
607 &self,
608 context: &HashMap<String, Value>,
609 ) -> Result<Vec<Value>, FaucetError> {
610 Ok(self.fetch_with_context_incremental(context).await?.0)
611 }
612
613 async fn fetch_with_context_incremental(
614 &self,
615 context: &HashMap<String, Value>,
616 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
617 use futures::StreamExt;
618 let mut records = Vec::new();
619 let mut bookmark = None;
620 let mut s = self.stream_pages(context, self.config.batch_size);
621 while let Some(page) = s.next().await {
622 let page = page?;
623 records.extend(page.records);
624 bookmark = page.bookmark.or(bookmark);
625 }
626 Ok((records, bookmark))
627 }
628
629 async fn check(
630 &self,
631 ctx: &faucet_core::check::CheckContext,
632 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
633 use faucet_core::check::{CheckReport, Probe};
634 let started = std::time::Instant::now();
635 let auth = match self.auth_header().await {
637 Ok(a) => a,
638 Err(e) => {
639 return Ok(CheckReport::single(Probe::fail(
640 "auth",
641 started.elapsed(),
642 e.to_string(),
643 )));
644 }
645 };
646 let body = json!({
647 "statement": "SELECT 1",
648 "warehouse_id": self.config.warehouse_id,
649 "wait_timeout": "50s",
650 "disposition": "INLINE",
651 "format": "JSON_ARRAY",
652 });
653 let calls = self.calls.get().with_timeout(ctx.timeout);
654 let fut = calls.call("submit", || async {
655 self.client
656 .post(self.statements_url())
657 .header("Authorization", &auth)
658 .header("Content-Type", "application/json")
659 .json(&body)
660 .send()
661 .await
662 .map_err(FaucetError::from)
663 });
664 let probe = match tokio::time::timeout(ctx.timeout, fut).await {
665 Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
666 Ok(Ok(r)) => Probe::fail_hint(
667 "warehouse",
668 started.elapsed(),
669 format!("databricks probe returned HTTP {}", r.status()),
670 "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
671 ),
672 Ok(Err(e)) => Probe::fail_hint(
673 "warehouse",
674 started.elapsed(),
675 format!("databricks probe request failed: {e}"),
676 "Verify workspace_url and network reachability.",
677 ),
678 Err(_) => Probe::fail_hint(
679 "warehouse",
680 started.elapsed(),
681 format!("databricks probe timed out after {:?}", ctx.timeout),
682 "Check warehouse availability and network reachability.",
683 ),
684 };
685 Ok(CheckReport::single(probe))
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::config::{DatabricksAuth, DatabricksParam};
693 use faucet_core::AuthSpec;
694
695 fn cfg() -> DatabricksSourceConfig {
696 DatabricksSourceConfig {
697 workspace_url: "https://x.cloud.databricks.com".into(),
698 warehouse_id: "wh1".into(),
699 sql: "SELECT * FROM t WHERE ts >= ${bookmark}".into(),
700 auth: AuthSpec::Inline(DatabricksAuth::Pat {
701 token: "tok".into(),
702 }),
703 catalog: Some("main".into()),
704 schema: Some("s".into()),
705 parameters: vec![DatabricksParam {
706 name: "min".into(),
707 value: json!(10),
708 param_type: Some("INT".into()),
709 }],
710 wait_timeout_secs: 50,
711 poll_interval_secs: 1,
712 statement_timeout_secs: 3600,
713 batch_size: 1000,
714 arrow_native: false,
715 result_disposition: ResultDisposition::default(),
716 replication: DatabricksReplication::Incremental {
717 column: "ts".into(),
718 initial_value: json!("2026-01-01"),
719 },
720 state_key: None,
721 }
722 }
723
724 fn source(c: DatabricksSourceConfig) -> DatabricksSource {
725 DatabricksSource::new(c).unwrap()
726 }
727
728 #[test]
729 fn body_has_required_fields_and_params() {
730 let s = source(cfg());
731 let incr = s.incremental_ctx();
732 let body = s.build_body(&HashMap::new(), incr.as_ref());
733 assert_eq!(body["warehouse_id"], json!("wh1"));
734 assert_eq!(body["catalog"], json!("main"));
735 assert_eq!(body["disposition"], json!("EXTERNAL_LINKS"));
737 assert_eq!(body["format"], json!("JSON_ARRAY"));
738 assert_eq!(body["wait_timeout"], json!("50s"));
739 let mut inline = cfg();
740 inline.result_disposition = ResultDisposition::Inline;
741 let inline_body = source(inline).build_body(&HashMap::new(), incr.as_ref());
742 assert_eq!(inline_body["disposition"], json!("INLINE"));
743 assert_eq!(inline_body["format"], json!("JSON_ARRAY"));
744 assert!(
746 body["statement"]
747 .as_str()
748 .unwrap()
749 .contains(":_faucet_bookmark")
750 );
751 assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
752 assert_eq!(
753 body["statement"],
754 json!(
755 "SELECT * FROM (SELECT * FROM t WHERE ts >= :_faucet_bookmark\n) AS q \
756 WHERE q.`ts` >= :_faucet_bookmark ORDER BY q.`ts`"
757 )
758 );
759 assert_eq!(quote_ident("a`b"), "`a``b`");
760 let params = body["parameters"].as_array().unwrap();
761 assert!(
763 params
764 .iter()
765 .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
766 );
767 let bm = params
768 .iter()
769 .find(|p| p["name"] == json!("_faucet_bookmark"))
770 .unwrap();
771 assert_eq!(bm["value"], json!("2026-01-01"));
772 }
773
774 #[test]
775 fn full_mode_has_no_state_key_or_bookmark_param() {
776 let mut c = cfg();
777 c.replication = DatabricksReplication::Full;
778 c.sql = "SELECT 1".into();
779 let s = source(c);
780 assert!(s.state_key().is_none());
781 let body = s.build_body(&HashMap::new(), None);
782 assert!(
783 body.get("parameters").is_none()
784 || body["parameters"]
785 .as_array()
786 .unwrap()
787 .iter()
788 .all(|p| p["name"] != json!("_faucet_bookmark"))
789 );
790 }
791
792 #[test]
793 fn incremental_state_key_derived_and_stable() {
794 let s = source(cfg());
795 let k1 = s.state_key().unwrap();
796 let k2 = source(cfg()).state_key().unwrap();
797 assert_eq!(k1, k2);
798 assert!(k1.starts_with("databricks:"));
799 let c = cfg();
800 let material = format!("{}\u{1f}{}\u{1f}{}", c.workspace_url, c.warehouse_id, c.sql);
801 assert_eq!(
802 k1,
803 format!(
804 "databricks:{:016x}",
805 faucet_core::shard::shard_hash(&material)
806 )
807 );
808 }
809
810 #[tokio::test]
811 async fn explicit_state_key_wins() {
812 let mut c = cfg();
813 c.state_key = Some("my-key".into());
814 let s = source(c);
815 assert_eq!(s.state_key().as_deref(), Some("my-key"));
816 s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
818 assert_eq!(
819 s.incremental_ctx().unwrap().start.value,
820 json!("2026-06-01")
821 );
822 assert!(
823 s.apply_start_bookmark(json!({"value": null}))
824 .await
825 .is_err()
826 );
827 }
828
829 #[test]
830 fn value_to_param_string_stringifies() {
831 assert_eq!(value_to_param_string(&json!(5)), json!("5"));
832 assert_eq!(value_to_param_string(&json!(true)), json!("true"));
833 assert_eq!(value_to_param_string(&json!("x")), json!("x"));
834 assert_eq!(value_to_param_string(&Value::Null), Value::Null);
835 }
836
837 #[test]
838 fn decode_chunk_rejects_non_objects() {
839 assert!(decode_chunk(json!({"data_array": [["1"]]})).is_ok());
840 let err = decode_chunk(json!(5)).unwrap_err().to_string();
841 assert!(err.contains("could not parse"), "{err}");
842 }
843
844 #[test]
845 fn dataset_uri_redacts_scheme() {
846 let s = source(cfg());
847 assert_eq!(
848 s.dataset_uri(),
849 "databricks://x.cloud.databricks.com/warehouses/wh1"
850 );
851 }
852}