1use std::collections::HashMap;
10use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12use std::pin::Pin;
13use std::sync::Mutex;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use faucet_core::replication::{filter_incremental, max_value};
18use faucet_core::{AuthSpec, FaucetError, SharedAuthProvider, Source, Stream, StreamPage};
19use reqwest::Client;
20use serde::Deserialize;
21use serde_json::{Value, json};
22
23use crate::config::{DatabricksReplication, DatabricksSourceConfig};
24use crate::convert::{ColumnInfo, row_to_json};
25
26pub struct DatabricksSource {
28 config: DatabricksSourceConfig,
29 client: Client,
30 endpoint_base: Option<String>,
33 auth_provider: Option<SharedAuthProvider>,
36 start_bookmark: Mutex<Option<Value>>,
38}
39
40#[derive(Debug, Deserialize)]
42struct StatementResponse {
43 #[serde(default)]
44 statement_id: Option<String>,
45 #[serde(default)]
46 status: Option<StatusInfo>,
47 #[serde(default)]
48 manifest: Option<Manifest>,
49 #[serde(default)]
50 result: Option<ResultChunk>,
51}
52
53#[derive(Debug, Deserialize)]
54struct StatusInfo {
55 #[serde(default)]
56 state: String,
57 #[serde(default)]
58 error: Option<ErrorInfo>,
59}
60
61#[derive(Debug, Deserialize)]
62struct ErrorInfo {
63 #[serde(default)]
64 error_code: Option<String>,
65 #[serde(default)]
66 message: Option<String>,
67}
68
69#[derive(Debug, Deserialize)]
70struct Manifest {
71 #[serde(default)]
72 schema: Option<SchemaInfo>,
73}
74
75#[derive(Debug, Deserialize)]
76struct SchemaInfo {
77 #[serde(default)]
78 columns: Vec<ColumnInfo>,
79}
80
81#[derive(Debug, Deserialize)]
82struct ResultChunk {
83 #[serde(default)]
84 data_array: Option<Vec<Vec<Value>>>,
85 #[serde(default)]
86 next_chunk_internal_link: Option<String>,
87 #[cfg(feature = "arrow")]
91 #[serde(default)]
92 external_links: Option<Vec<ExternalLink>>,
93}
94
95#[cfg(feature = "arrow")]
97#[derive(Debug, Deserialize)]
98struct ExternalLink {
99 #[serde(default)]
100 external_link: Option<String>,
101 #[serde(default)]
102 next_chunk_internal_link: Option<String>,
103}
104
105#[cfg(feature = "arrow")]
108fn next_chunk_link(chunk: &ResultChunk) -> Option<String> {
109 chunk.next_chunk_internal_link.clone().or_else(|| {
110 chunk
111 .external_links
112 .as_ref()
113 .and_then(|l| l.first())
114 .and_then(|e| e.next_chunk_internal_link.clone())
115 })
116}
117
118struct IncrementalCtx {
120 column: String,
121 start: Value,
122}
123
124impl DatabricksSource {
125 pub fn new(config: DatabricksSourceConfig) -> Result<Self, FaucetError> {
127 config.validate()?;
128 Ok(Self {
129 config,
130 client: Client::new(),
131 endpoint_base: None,
132 auth_provider: None,
133 start_bookmark: Mutex::new(None),
134 })
135 }
136
137 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
140 self.auth_provider = Some(provider);
141 self
142 }
143
144 pub fn with_endpoint_base(mut self, base: impl Into<String>) -> Self {
147 self.endpoint_base = Some(base.into());
148 self
149 }
150
151 fn base_url(&self) -> String {
152 match &self.endpoint_base {
153 Some(b) => b.trim_end_matches('/').to_owned(),
154 None => self.config.workspace_url.trim_end_matches('/').to_owned(),
155 }
156 }
157
158 fn statements_url(&self) -> String {
159 format!("{}/api/2.0/sql/statements", self.base_url())
160 }
161
162 async fn auth_header(&self) -> Result<String, FaucetError> {
165 if let Some(p) = &self.auth_provider {
166 let cred = p.credential().await?;
167 return cred.authorization_value().ok_or_else(|| {
168 FaucetError::Auth("databricks: shared provider yielded no bearer credential".into())
169 });
170 }
171 match &self.config.auth {
172 AuthSpec::Inline(a) => Ok(a.authorization_value()),
173 AuthSpec::Reference(r) => Err(FaucetError::Auth(format!(
174 "databricks: auth references provider '{}' but none was supplied",
175 r.name
176 ))),
177 }
178 }
179
180 fn incremental_ctx(&self) -> Option<IncrementalCtx> {
183 match &self.config.replication {
184 DatabricksReplication::Full => None,
185 DatabricksReplication::Incremental {
186 column,
187 initial_value,
188 } => {
189 let start = self
190 .start_bookmark
191 .lock()
192 .expect("start_bookmark mutex poisoned")
193 .clone()
194 .unwrap_or_else(|| initial_value.clone());
195 Some(IncrementalCtx {
196 column: column.clone(),
197 start,
198 })
199 }
200 }
201 }
202
203 fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
206 let mut sql = self.config.sql.clone();
207 let mut params: Vec<Value> = self
209 .config
210 .parameters
211 .iter()
212 .map(|p| {
213 json!({
214 "name": p.name,
215 "value": value_to_param_string(&p.value),
216 "type": p.param_type.clone().unwrap_or_else(|| "STRING".into()),
217 })
218 })
219 .collect();
220
221 if !context.is_empty() {
223 let (rewritten, ctx_values) =
224 faucet_core::util::substitute_context_bind_params(&sql, context, 0, |i| {
225 format!(":_faucet_ct{i}")
226 });
227 sql = rewritten;
228 for (i, v) in ctx_values.into_iter().enumerate() {
229 params.push(json!({
230 "name": format!("_faucet_ct{i}"),
231 "value": value_to_param_string(&v),
232 }));
233 }
234 }
235
236 if let Some(ctx) = incr
238 && sql.contains("${bookmark}")
239 {
240 sql = sql.replace("${bookmark}", ":_faucet_bookmark");
241 params.push(json!({
242 "name": "_faucet_bookmark",
243 "value": value_to_param_string(&ctx.start),
244 }));
245 }
246
247 let (disposition, format) = if self.config.arrow_native {
251 ("EXTERNAL_LINKS", "ARROW_STREAM")
252 } else {
253 ("INLINE", "JSON_ARRAY")
254 };
255 let mut body = json!({
256 "statement": sql,
257 "warehouse_id": self.config.warehouse_id,
258 "wait_timeout": format!("{}s", self.config.wait_timeout_secs),
259 "on_wait_timeout": "CONTINUE",
260 "disposition": disposition,
261 "format": format,
262 });
263 if let Some(c) = &self.config.catalog {
264 body["catalog"] = json!(c);
265 }
266 if let Some(s) = &self.config.schema {
267 body["schema"] = json!(s);
268 }
269 if !params.is_empty() {
270 body["parameters"] = Value::Array(params);
271 }
272 body
273 }
274
275 async fn run_statement(
277 &self,
278 context: &HashMap<String, Value>,
279 incr: Option<&IncrementalCtx>,
280 ) -> Result<StatementResponse, FaucetError> {
281 let auth = self.auth_header().await?;
282 let body = self.build_body(context, incr);
283 let resp = self
284 .client
285 .post(self.statements_url())
286 .header("Authorization", &auth)
287 .header("Content-Type", "application/json")
288 .json(&body)
289 .send()
290 .await
291 .map_err(|e| FaucetError::Source(format!("databricks: submit request failed: {e}")))?;
292 let parsed = parse_http(resp).await?;
293 self.poll_until_terminal(parsed, &auth).await
294 }
295
296 async fn poll_until_terminal(
299 &self,
300 first: StatementResponse,
301 auth: &str,
302 ) -> Result<StatementResponse, FaucetError> {
303 let mut current = first;
304 loop {
305 let state = current
306 .status
307 .as_ref()
308 .map(|s| s.state.as_str())
309 .unwrap_or("");
310 match state {
311 "SUCCEEDED" => return Ok(current),
312 "FAILED" | "CANCELED" | "CLOSED" => {
313 return Err(statement_error(state, current.status.as_ref()));
314 }
315 "PENDING" | "RUNNING" => {
316 let id = current.statement_id.clone().ok_or_else(|| {
317 FaucetError::Source(
318 "databricks: pending statement without a statement_id to poll".into(),
319 )
320 })?;
321 tokio::time::sleep(Duration::from_secs(self.config.poll_interval_secs.max(1)))
322 .await;
323 let url = format!("{}/{}", self.statements_url(), id);
324 let resp = self
325 .client
326 .get(&url)
327 .header("Authorization", auth)
328 .send()
329 .await
330 .map_err(|e| {
331 FaucetError::Source(format!("databricks: poll request failed: {e}"))
332 })?;
333 current = parse_http(resp).await?;
334 }
335 other => {
336 return Err(FaucetError::Source(format!(
337 "databricks: unexpected statement state '{other}'"
338 )));
339 }
340 }
341 }
342 }
343
344 async fn fetch_chunk(&self, link: &str, auth: &str) -> Result<ResultChunk, FaucetError> {
346 let url = format!("{}{}", self.base_url(), link);
347 let resp = self
348 .client
349 .get(&url)
350 .header("Authorization", auth)
351 .send()
352 .await
353 .map_err(|e| FaucetError::Source(format!("databricks: chunk request failed: {e}")))?;
354 let parsed = parse_http::<ResultChunk>(resp).await?;
355 Ok(parsed)
356 }
357
358 #[cfg(feature = "arrow")]
362 async fn fetch_arrow_link(
363 &self,
364 url: &str,
365 ) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
366 let resp = self.client.get(url).send().await.map_err(|e| {
367 FaucetError::Source(format!("databricks: external-link request failed: {e}"))
368 })?;
369 let status = resp.status();
370 if !status.is_success() {
371 let body = resp.text().await.unwrap_or_default();
372 return Err(FaucetError::Source(format!(
373 "databricks: external link HTTP {status}: {body}"
374 )));
375 }
376 let data = resp.bytes().await.map_err(|e| {
377 FaucetError::Source(format!(
378 "databricks: reading external-link body failed: {e}"
379 ))
380 })?;
381 tokio::task::spawn_blocking(move || decode_arrow_ipc(data))
382 .await
383 .map_err(|e| {
384 FaucetError::Source(format!("databricks: arrow decode task panicked: {e}"))
385 })?
386 }
387}
388
389#[cfg(feature = "arrow")]
392fn decode_arrow_ipc(data: bytes::Bytes) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
393 use arrow::ipc::reader::StreamReader;
394
395 let reader = StreamReader::try_new(std::io::Cursor::new(data), None).map_err(|e| {
396 FaucetError::Source(format!("databricks: arrow IPC reader init failed: {e}"))
397 })?;
398 let mut batches = Vec::new();
399 for batch in reader {
400 batches.push(batch.map_err(|e| {
401 FaucetError::Source(format!("databricks: arrow IPC decode failed: {e}"))
402 })?);
403 }
404 Ok(batches)
405}
406
407fn default_state_key(config: &DatabricksSourceConfig) -> String {
409 let mut h = DefaultHasher::new();
410 config.workspace_url.hash(&mut h);
411 config.warehouse_id.hash(&mut h);
412 config.sql.hash(&mut h);
413 format!("databricks:{:016x}", h.finish())
414}
415
416fn value_to_param_string(v: &Value) -> Value {
419 match v {
420 Value::Null => Value::Null,
421 Value::String(s) => Value::String(s.clone()),
422 Value::Bool(b) => Value::String(b.to_string()),
423 Value::Number(n) => Value::String(n.to_string()),
424 other => Value::String(other.to_string()),
425 }
426}
427
428fn statement_error(state: &str, status: Option<&StatusInfo>) -> FaucetError {
430 let detail = status.and_then(|s| s.error.as_ref()).map(|e| {
431 format!(
432 " [{}] {}",
433 e.error_code.as_deref().unwrap_or("UNKNOWN"),
434 e.message.as_deref().unwrap_or("")
435 )
436 });
437 FaucetError::Source(format!(
438 "databricks: statement {state}{}",
439 detail.unwrap_or_default()
440 ))
441}
442
443async fn parse_http<T: for<'de> Deserialize<'de>>(
446 resp: reqwest::Response,
447) -> Result<T, FaucetError> {
448 let status = resp.status();
449 if !status.is_success() {
450 let body = resp.text().await.unwrap_or_default();
451 return Err(FaucetError::Source(format!(
452 "databricks: HTTP {status}: {body}"
453 )));
454 }
455 resp.json::<T>()
456 .await
457 .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
458}
459
460#[async_trait]
461impl Source for DatabricksSource {
462 fn config_schema(&self) -> Value {
463 serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
464 .expect("schema serialization")
465 }
466
467 fn connector_name(&self) -> &'static str {
468 "databricks"
469 }
470
471 fn dataset_uri(&self) -> String {
472 format!(
473 "databricks://{}/warehouses/{}",
474 self.config
475 .workspace_url
476 .trim_start_matches("https://")
477 .trim_end_matches('/'),
478 self.config.warehouse_id
479 )
480 }
481
482 fn state_key(&self) -> Option<String> {
483 match &self.config.replication {
484 DatabricksReplication::Full => None,
485 DatabricksReplication::Incremental { .. } => Some(
486 self.config
487 .state_key
488 .clone()
489 .unwrap_or_else(|| default_state_key(&self.config)),
490 ),
491 }
492 }
493
494 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
495 *self
496 .start_bookmark
497 .lock()
498 .expect("start_bookmark mutex poisoned") = Some(bookmark);
499 Ok(())
500 }
501
502 #[cfg(feature = "arrow")]
506 fn supports_columnar(&self) -> bool {
507 self.config.arrow_native
508 }
509
510 #[cfg(feature = "arrow")]
516 fn stream_batches<'a>(
517 &'a self,
518 context: &'a HashMap<String, Value>,
519 _batch_size: usize,
520 ) -> Pin<
521 Box<
522 dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
523 >,
524 > {
525 Box::pin(async_stream::try_stream! {
526 if !self.config.arrow_native {
527 Err(FaucetError::Source(
528 "databricks: stream_batches requires `arrow_native: true`".into(),
529 ))?;
530 }
531 let auth = self.auth_header().await?;
532 let resp = self.run_statement(context, None).await?;
533 let mut chunk = resp.result;
534 let mut total_records = 0usize;
535 let mut total_pages = 0usize;
536 while let Some(c) = chunk {
537 if let Some(links) = c.external_links.as_ref() {
538 for link in links {
539 if let Some(url) = link.external_link.as_deref() {
540 let batches = self.fetch_arrow_link(url).await?;
541 for batch in batches {
542 if batch.num_rows() == 0 {
543 continue;
544 }
545 total_records += batch.num_rows();
546 total_pages += 1;
547 yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
548 }
549 }
550 }
551 }
552 chunk = match next_chunk_link(&c) {
553 Some(link) => Some(self.fetch_chunk(&link, &auth).await?),
554 None => None,
555 };
556 }
557 tracing::info!(
558 pages = total_pages,
559 total_records,
560 "databricks columnar stream complete",
561 );
562 })
563 }
564
565 fn stream_pages<'a>(
566 &'a self,
567 context: &'a HashMap<String, Value>,
568 _batch_size: usize,
569 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
570 Box::pin(async_stream::try_stream! {
571 let auth = self.auth_header().await?;
572
573 #[cfg(feature = "arrow")]
578 if self.config.arrow_native {
579 let resp = self.run_statement(context, None).await?;
580 let cap = if self.config.batch_size == 0 { 1024 } else { self.config.batch_size };
581 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
582 let mut chunk = resp.result;
583 while let Some(c) = chunk {
584 if let Some(links) = c.external_links.as_ref() {
585 for link in links {
586 if let Some(url) = link.external_link.as_deref() {
587 let batches = self.fetch_arrow_link(url).await?;
588 for batch in &batches {
589 for row in faucet_core::columnar::record_batch_to_values(batch)? {
590 buffer.push(row);
591 if self.config.batch_size != 0
592 && buffer.len() >= self.config.batch_size
593 {
594 let page = std::mem::replace(
595 &mut buffer,
596 Vec::with_capacity(cap),
597 );
598 yield StreamPage { records: page, bookmark: None };
599 }
600 }
601 }
602 }
603 }
604 }
605 chunk = match next_chunk_link(&c) {
606 Some(link) => Some(self.fetch_chunk(&link, &auth).await?),
607 None => None,
608 };
609 }
610 if !buffer.is_empty() {
611 yield StreamPage { records: buffer, bookmark: None };
612 }
613 return;
614 }
615
616 let incr = self.incremental_ctx();
617 let resp = self.run_statement(context, incr.as_ref()).await?;
618
619 let columns: Vec<ColumnInfo> = resp
620 .manifest
621 .and_then(|m| m.schema)
622 .map(|s| s.columns)
623 .unwrap_or_default();
624
625 let batch = self.config.batch_size;
626 let cap = if batch == 0 { 1024 } else { batch };
627 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
628 let mut running_max: Option<Value> = None;
629
630 let mut chunk = resp.result;
632 while let Some(c) = chunk {
633 if let Some(data) = c.data_array {
634 for row in &data {
635 let obj = row_to_json(row, &columns);
636 if let Some(ic) = &incr
639 && let Some(v) = obj.get(&ic.column)
640 {
641 running_max = Some(match running_max.take() {
642 Some(m) => max_value(m, v.clone()),
643 None => v.clone(),
644 });
645 }
646 buffer.push(obj);
647 if batch != 0 && buffer.len() >= batch {
648 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
649 let kept = apply_incr_filter(page, incr.as_ref());
650 if !kept.is_empty() {
651 yield StreamPage { records: kept, bookmark: None };
652 }
653 }
654 }
655 }
656 chunk = match c.next_chunk_internal_link {
657 Some(link) => Some(self.fetch_chunk(&link, &auth).await?),
658 None => None,
659 };
660 }
661
662 let kept = apply_incr_filter(buffer, incr.as_ref());
664 let bookmark = if incr.is_some() { running_max } else { None };
665 if !kept.is_empty() || bookmark.is_some() {
666 yield StreamPage { records: kept, bookmark };
667 }
668 })
669 }
670
671 async fn fetch_with_context(
672 &self,
673 context: &HashMap<String, Value>,
674 ) -> Result<Vec<Value>, FaucetError> {
675 use futures::StreamExt;
676 let mut out = Vec::new();
677 let mut s = self.stream_pages(context, self.config.batch_size);
678 while let Some(page) = s.next().await {
679 out.extend(page?.records);
680 }
681 Ok(out)
682 }
683
684 async fn check(
685 &self,
686 ctx: &faucet_core::check::CheckContext,
687 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
688 use faucet_core::check::{CheckReport, Probe};
689 let started = std::time::Instant::now();
690 let auth = match self.auth_header().await {
692 Ok(a) => a,
693 Err(e) => {
694 return Ok(CheckReport::single(Probe::fail(
695 "auth",
696 started.elapsed(),
697 e.to_string(),
698 )));
699 }
700 };
701 let body = json!({
702 "statement": "SELECT 1",
703 "warehouse_id": self.config.warehouse_id,
704 "wait_timeout": "50s",
705 "disposition": "INLINE",
706 "format": "JSON_ARRAY",
707 });
708 let fut = self
709 .client
710 .post(self.statements_url())
711 .header("Authorization", &auth)
712 .header("Content-Type", "application/json")
713 .json(&body)
714 .send();
715 let probe = match tokio::time::timeout(ctx.timeout, fut).await {
716 Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
717 Ok(Ok(r)) => Probe::fail_hint(
718 "warehouse",
719 started.elapsed(),
720 format!("databricks probe returned HTTP {}", r.status()),
721 "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
722 ),
723 Ok(Err(e)) => Probe::fail_hint(
724 "warehouse",
725 started.elapsed(),
726 format!("databricks probe request failed: {e}"),
727 "Verify workspace_url and network reachability.",
728 ),
729 Err(_) => Probe::fail_hint(
730 "warehouse",
731 started.elapsed(),
732 format!("databricks probe timed out after {:?}", ctx.timeout),
733 "Check warehouse availability and network reachability.",
734 ),
735 };
736 Ok(CheckReport::single(probe))
737 }
738}
739
740fn apply_incr_filter(page: Vec<Value>, incr: Option<&IncrementalCtx>) -> Vec<Value> {
742 match incr {
743 Some(ic) => filter_incremental(page, &ic.column, &ic.start),
744 None => page,
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use crate::config::{DatabricksAuth, DatabricksParam};
752
753 fn cfg() -> DatabricksSourceConfig {
754 DatabricksSourceConfig {
755 workspace_url: "https://x.cloud.databricks.com".into(),
756 warehouse_id: "wh1".into(),
757 sql: "SELECT * FROM t WHERE ts > ${bookmark}".into(),
758 auth: AuthSpec::Inline(DatabricksAuth::Pat {
759 token: "tok".into(),
760 }),
761 catalog: Some("main".into()),
762 schema: Some("s".into()),
763 parameters: vec![DatabricksParam {
764 name: "min".into(),
765 value: json!(10),
766 param_type: Some("INT".into()),
767 }],
768 wait_timeout_secs: 50,
769 poll_interval_secs: 1,
770 batch_size: 1000,
771 arrow_native: false,
772 replication: DatabricksReplication::Incremental {
773 column: "ts".into(),
774 initial_value: json!("2026-01-01"),
775 },
776 state_key: None,
777 }
778 }
779
780 fn source(c: DatabricksSourceConfig) -> DatabricksSource {
781 DatabricksSource::new(c).unwrap()
782 }
783
784 #[test]
785 fn body_has_required_fields_and_params() {
786 let s = source(cfg());
787 let incr = s.incremental_ctx();
788 let body = s.build_body(&HashMap::new(), incr.as_ref());
789 assert_eq!(body["warehouse_id"], json!("wh1"));
790 assert_eq!(body["catalog"], json!("main"));
791 assert_eq!(body["disposition"], json!("INLINE"));
792 assert_eq!(body["format"], json!("JSON_ARRAY"));
793 assert_eq!(body["wait_timeout"], json!("50s"));
794 assert!(
796 body["statement"]
797 .as_str()
798 .unwrap()
799 .contains(":_faucet_bookmark")
800 );
801 assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
802 let params = body["parameters"].as_array().unwrap();
803 assert!(
805 params
806 .iter()
807 .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
808 );
809 let bm = params
810 .iter()
811 .find(|p| p["name"] == json!("_faucet_bookmark"))
812 .unwrap();
813 assert_eq!(bm["value"], json!("2026-01-01"));
814 }
815
816 #[test]
817 fn full_mode_has_no_state_key_or_bookmark_param() {
818 let mut c = cfg();
819 c.replication = DatabricksReplication::Full;
820 c.sql = "SELECT 1".into();
821 let s = source(c);
822 assert!(s.state_key().is_none());
823 let body = s.build_body(&HashMap::new(), None);
824 assert!(
825 body.get("parameters").is_none()
826 || body["parameters"]
827 .as_array()
828 .unwrap()
829 .iter()
830 .all(|p| p["name"] != json!("_faucet_bookmark"))
831 );
832 }
833
834 #[test]
835 fn incremental_state_key_derived_and_stable() {
836 let s = source(cfg());
837 let k1 = s.state_key().unwrap();
838 let k2 = source(cfg()).state_key().unwrap();
839 assert_eq!(k1, k2);
840 assert!(k1.starts_with("databricks:"));
841 }
842
843 #[tokio::test]
844 async fn explicit_state_key_wins() {
845 let mut c = cfg();
846 c.state_key = Some("my-key".into());
847 let s = source(c);
848 assert_eq!(s.state_key().as_deref(), Some("my-key"));
849 s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
851 assert_eq!(s.incremental_ctx().unwrap().start, json!("2026-06-01"));
852 }
853
854 #[test]
855 fn value_to_param_string_stringifies() {
856 assert_eq!(value_to_param_string(&json!(5)), json!("5"));
857 assert_eq!(value_to_param_string(&json!(true)), json!("true"));
858 assert_eq!(value_to_param_string(&json!("x")), json!("x"));
859 assert_eq!(value_to_param_string(&Value::Null), Value::Null);
860 }
861
862 #[test]
863 fn dataset_uri_redacts_scheme() {
864 let s = source(cfg());
865 assert_eq!(
866 s.dataset_uri(),
867 "databricks://x.cloud.databricks.com/warehouses/wh1"
868 );
869 }
870}