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