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::{filter_incremental, max_value};
19use faucet_core::{FaucetError, SharedAuthProvider, Source, Stream, StreamPage};
20use reqwest::Client;
21use serde::Deserialize;
22use serde_json::{Value, json};
23
24use crate::config::{
25 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<Value>>,
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: Value,
89}
90
91impl DatabricksSource {
92 pub fn new(config: DatabricksSourceConfig) -> Result<Self, FaucetError> {
94 config.validate()?;
95 Ok(Self {
96 config,
97 client: faucet_common_databricks::http_client()?,
98 endpoint_base: None,
99 auth_provider: None,
100 start_bookmark: Mutex::new(None),
101 calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
102 })
103 }
104
105 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
108 self.auth_provider = Some(provider);
109 self
110 }
111
112 pub fn with_endpoint_base(mut self, base: impl Into<String>) -> Self {
115 self.endpoint_base = Some(base.into());
116 self
117 }
118
119 fn base_url(&self) -> String {
120 match &self.endpoint_base {
121 Some(b) => b.trim_end_matches('/').to_owned(),
122 None => self.config.workspace_url.trim_end_matches('/').to_owned(),
123 }
124 }
125
126 fn statements_url(&self) -> String {
127 format!("{}/api/2.0/sql/statements", self.base_url())
128 }
129
130 async fn auth_header(&self) -> Result<String, FaucetError> {
133 resolve_authorization(&shared_auth(&self.config.auth), self.auth_provider.as_ref()).await
134 }
135
136 fn statement_client(&self) -> StatementClient {
138 StatementClient::new(
139 self.client.clone(),
140 self.base_url(),
141 self.config.warehouse_id.clone(),
142 shared_auth(&self.config.auth),
143 self.auth_provider.clone(),
144 StatementOptions {
145 wait_timeout_secs: self.config.wait_timeout_secs,
146 poll_interval: Duration::from_secs(self.config.poll_interval_secs.max(1)),
147 statement_timeout: (self.config.statement_timeout_secs > 0)
148 .then(|| Duration::from_secs(self.config.statement_timeout_secs)),
149 ..StatementOptions::default()
150 },
151 ErrorSide::Source,
152 )
153 .with_call_context(self.calls.get())
154 }
155
156 fn incremental_ctx(&self) -> Option<IncrementalCtx> {
159 match &self.config.replication {
160 DatabricksReplication::Full => None,
161 DatabricksReplication::Incremental {
162 column,
163 initial_value,
164 } => {
165 let start = self
166 .start_bookmark
167 .lock()
168 .expect("start_bookmark mutex poisoned")
169 .clone()
170 .unwrap_or_else(|| initial_value.clone());
171 Some(IncrementalCtx {
172 column: column.clone(),
173 start,
174 })
175 }
176 }
177 }
178
179 fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
182 let mut sql = self.config.sql.clone();
183 let mut params: Vec<Value> = self
185 .config
186 .parameters
187 .iter()
188 .map(|p| {
189 json!({
190 "name": p.name,
191 "value": value_to_param_string(&p.value),
192 "type": p.param_type.clone().unwrap_or_else(|| "STRING".into()),
193 })
194 })
195 .collect();
196
197 if !context.is_empty() {
199 let (rewritten, ctx_values) =
200 faucet_core::util::substitute_context_bind_params(&sql, context, 0, |i| {
201 format!(":_faucet_ct{i}")
202 });
203 sql = rewritten;
204 for (i, v) in ctx_values.into_iter().enumerate() {
205 params.push(json!({
206 "name": format!("_faucet_ct{i}"),
207 "value": value_to_param_string(&v),
208 }));
209 }
210 }
211
212 if let Some(ctx) = incr
214 && sql.contains("${bookmark}")
215 {
216 sql = sql.replace("${bookmark}", ":_faucet_bookmark");
217 params.push(json!({
218 "name": "_faucet_bookmark",
219 "value": value_to_param_string(&ctx.start),
220 }));
221 }
222
223 let (disposition, format) = if self.config.arrow_native {
229 ("EXTERNAL_LINKS", "ARROW_STREAM")
230 } else {
231 match self.config.result_disposition {
232 ResultDisposition::ExternalLinks => ("EXTERNAL_LINKS", "JSON_ARRAY"),
233 ResultDisposition::Inline => ("INLINE", "JSON_ARRAY"),
234 }
235 };
236 let mut body = json!({
237 "statement": sql,
238 "warehouse_id": self.config.warehouse_id,
239 "wait_timeout": format!("{}s", self.config.wait_timeout_secs),
240 "on_wait_timeout": "CONTINUE",
241 "disposition": disposition,
242 "format": format,
243 });
244 if let Some(c) = &self.config.catalog {
245 body["catalog"] = json!(c);
246 }
247 if let Some(s) = &self.config.schema {
248 body["schema"] = json!(s);
249 }
250 if !params.is_empty() {
251 body["parameters"] = Value::Array(params);
252 }
253 body
254 }
255
256 async fn run_statement(
258 &self,
259 context: &HashMap<String, Value>,
260 incr: Option<&IncrementalCtx>,
261 ) -> Result<Executed, FaucetError> {
262 let body = self.build_body(context, incr);
263 let resp = self.statement_client().execute_body(&body).await?;
264 let columns = resp.columns().to_vec();
265 crate::convert::check_unique_columns(&columns)?;
266 let result = resp.result.map(decode_chunk).transpose()?;
267 Ok(Executed { columns, result })
268 }
269
270 async fn fetch_chunk(&self, link: &str) -> Result<ResultChunk, FaucetError> {
272 decode_chunk(self.statement_client().fetch_chunk(link).await?)
273 }
274
275 #[cfg(feature = "arrow")]
279 async fn fetch_arrow_link(
280 &self,
281 url: &str,
282 ) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
283 let resp = self.get_external(url).await?;
284 let data = resp.bytes().await.map_err(|e| {
285 FaucetError::Source(format!(
286 "databricks: reading external-link body failed: {e}"
287 ))
288 })?;
289 tokio::task::spawn_blocking(move || decode_arrow_ipc(data))
290 .await
291 .map_err(|e| {
292 FaucetError::Source(format!("databricks: arrow decode task panicked: {e}"))
293 })?
294 }
295}
296
297impl DatabricksSource {
298 async fn get_external(&self, url: &str) -> Result<reqwest::Response, FaucetError> {
302 let resp = self
303 .calls
304 .call("chunk", || async {
305 self.client.get(url).send().await.map_err(|e| {
306 FaucetError::Source(format!("databricks: external-link request failed: {e}"))
307 })
308 })
309 .await?;
310 let status = resp.status();
311 if !status.is_success() {
312 let body = resp.text().await.unwrap_or_default();
313 return Err(FaucetError::Source(format!(
314 "databricks: external link HTTP {status}: {body}"
315 )));
316 }
317 Ok(resp)
318 }
319
320 async fn fetch_json_link(&self, url: &str) -> Result<Vec<Vec<Value>>, FaucetError> {
322 self.get_external(url).await?.json().await.map_err(|e| {
323 FaucetError::Source(format!(
324 "databricks: could not parse an external-link chunk: {e}"
325 ))
326 })
327 }
328
329 async fn chunk_rows(&self, chunk: &mut ResultChunk) -> Result<Vec<Vec<Value>>, FaucetError> {
332 let mut rows = chunk.data_array.take().unwrap_or_default();
333 for link in chunk.external_links.iter().flatten() {
334 if let Some(url) = link.external_link.as_deref() {
335 rows.extend(self.fetch_json_link(url).await?);
336 }
337 }
338 Ok(rows)
339 }
340}
341
342#[cfg(feature = "arrow")]
345fn decode_arrow_ipc(data: bytes::Bytes) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
346 use arrow::ipc::reader::StreamReader;
347
348 let reader = StreamReader::try_new(std::io::Cursor::new(data), None).map_err(|e| {
349 FaucetError::Source(format!("databricks: arrow IPC reader init failed: {e}"))
350 })?;
351 let mut batches = Vec::new();
352 for batch in reader {
353 batches.push(batch.map_err(|e| {
354 FaucetError::Source(format!("databricks: arrow IPC decode failed: {e}"))
355 })?);
356 }
357 Ok(batches)
358}
359
360fn default_state_key(config: &DatabricksSourceConfig) -> String {
362 let material = format!(
364 "{}\u{1f}{}\u{1f}{}",
365 config.workspace_url, config.warehouse_id, config.sql
366 );
367 format!(
368 "databricks:{:016x}",
369 faucet_core::shard::shard_hash(&material)
370 )
371}
372
373fn decode_chunk(v: Value) -> Result<ResultChunk, FaucetError> {
375 serde_json::from_value(v)
376 .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
377}
378
379#[async_trait]
380impl Source for DatabricksSource {
381 fn config_schema(&self) -> Value {
382 serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
383 .expect("schema serialization")
384 }
385
386 fn connector_name(&self) -> &'static str {
387 "databricks"
388 }
389
390 fn set_call_context(&self, ctx: faucet_core::io::CallContext) {
391 self.calls.install(ctx);
392 }
393
394 fn set_roundtrip_recorder(
395 &self,
396 recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
397 ) {
398 self.calls.install_recorder(recorder);
399 }
400
401 fn dataset_uri(&self) -> String {
402 format!(
403 "databricks://{}/warehouses/{}",
404 self.config
405 .workspace_url
406 .trim_start_matches("https://")
407 .trim_end_matches('/'),
408 self.config.warehouse_id
409 )
410 }
411
412 fn state_key(&self) -> Option<String> {
413 match &self.config.replication {
414 DatabricksReplication::Full => None,
415 DatabricksReplication::Incremental { .. } => Some(
416 self.config
417 .state_key
418 .clone()
419 .unwrap_or_else(|| default_state_key(&self.config)),
420 ),
421 }
422 }
423
424 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
425 *self
426 .start_bookmark
427 .lock()
428 .expect("start_bookmark mutex poisoned") = Some(bookmark);
429 Ok(())
430 }
431
432 #[cfg(feature = "arrow")]
436 fn supports_columnar(&self) -> bool {
437 self.config.arrow_native
438 }
439
440 #[cfg(feature = "arrow")]
446 fn stream_batches<'a>(
447 &'a self,
448 context: &'a HashMap<String, Value>,
449 _batch_size: usize,
450 ) -> Pin<
451 Box<
452 dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
453 >,
454 > {
455 Box::pin(async_stream::try_stream! {
456 if !self.config.arrow_native {
457 Err(FaucetError::Source(
458 "databricks: stream_batches requires `arrow_native: true`".into(),
459 ))?;
460 }
461 let resp = self.run_statement(context, None).await?;
462 let mut chunk = resp.result;
463 let mut total_records = 0usize;
464 let mut total_pages = 0usize;
465 while let Some(c) = chunk {
466 if let Some(links) = c.external_links.as_ref() {
467 for link in links {
468 if let Some(url) = link.external_link.as_deref() {
469 let batches = self.fetch_arrow_link(url).await?;
470 for batch in batches {
471 if batch.num_rows() == 0 {
472 continue;
473 }
474 total_records += batch.num_rows();
475 total_pages += 1;
476 yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
477 }
478 }
479 }
480 }
481 chunk = match next_chunk_link(&c) {
482 Some(link) => Some(self.fetch_chunk(&link).await?),
483 None => None,
484 };
485 }
486 tracing::info!(
487 pages = total_pages,
488 total_records,
489 "databricks columnar stream complete",
490 );
491 })
492 }
493
494 fn stream_pages<'a>(
495 &'a self,
496 context: &'a HashMap<String, Value>,
497 _batch_size: usize,
498 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
499 Box::pin(async_stream::try_stream! {
500 #[cfg(feature = "arrow")]
505 if self.config.arrow_native {
506 let resp = self.run_statement(context, None).await?;
507 let cap = if self.config.batch_size == 0 { 1024 } else { self.config.batch_size };
508 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
509 let mut chunk = resp.result;
510 while let Some(c) = chunk {
511 if let Some(links) = c.external_links.as_ref() {
512 for link in links {
513 if let Some(url) = link.external_link.as_deref() {
514 let batches = self.fetch_arrow_link(url).await?;
515 for batch in &batches {
516 for row in faucet_core::columnar::record_batch_to_values(batch)? {
517 buffer.push(row);
518 if self.config.batch_size != 0
519 && buffer.len() >= self.config.batch_size
520 {
521 let page = std::mem::replace(
522 &mut buffer,
523 Vec::with_capacity(cap),
524 );
525 yield StreamPage { records: page, bookmark: None };
526 }
527 }
528 }
529 }
530 }
531 }
532 chunk = match next_chunk_link(&c) {
533 Some(link) => Some(self.fetch_chunk(&link).await?),
534 None => None,
535 };
536 }
537 if !buffer.is_empty() {
538 yield StreamPage { records: buffer, bookmark: None };
539 }
540 return;
541 }
542
543 let incr = self.incremental_ctx();
544 let resp = self.run_statement(context, incr.as_ref()).await?;
545
546 let columns: Vec<ColumnInfo> = resp.columns;
547
548 let batch = self.config.batch_size;
549 let cap = if batch == 0 { 1024 } else { batch };
550 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
551 let mut running_max: Option<Value> = None;
552
553 let mut chunk = resp.result;
555 while let Some(mut c) = chunk {
556 let data = self.chunk_rows(&mut c).await?;
557 {
558 for row in &data {
559 let obj = row_to_json(row, &columns);
560 if let Some(ic) = &incr
563 && let Some(v) = obj.get(&ic.column)
564 {
565 running_max = Some(match running_max.take() {
566 Some(m) => max_value(m, v.clone()),
567 None => v.clone(),
568 });
569 }
570 buffer.push(obj);
571 if batch != 0 && buffer.len() >= batch {
572 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
573 let kept = apply_incr_filter(page, incr.as_ref());
574 if !kept.is_empty() {
575 yield StreamPage { records: kept, bookmark: None };
576 }
577 }
578 }
579 }
580 chunk = match next_chunk_link(&c) {
581 Some(link) => Some(self.fetch_chunk(&link).await?),
582 None => None,
583 };
584 }
585
586 let kept = apply_incr_filter(buffer, incr.as_ref());
588 let bookmark = if incr.is_some() { running_max } else { None };
589 if !kept.is_empty() || bookmark.is_some() {
590 yield StreamPage { records: kept, bookmark };
591 }
592 })
593 }
594
595 async fn fetch_with_context(
596 &self,
597 context: &HashMap<String, Value>,
598 ) -> Result<Vec<Value>, FaucetError> {
599 use futures::StreamExt;
600 let mut out = Vec::new();
601 let mut s = self.stream_pages(context, self.config.batch_size);
602 while let Some(page) = s.next().await {
603 out.extend(page?.records);
604 }
605 Ok(out)
606 }
607
608 async fn check(
609 &self,
610 ctx: &faucet_core::check::CheckContext,
611 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
612 use faucet_core::check::{CheckReport, Probe};
613 let started = std::time::Instant::now();
614 let auth = match self.auth_header().await {
616 Ok(a) => a,
617 Err(e) => {
618 return Ok(CheckReport::single(Probe::fail(
619 "auth",
620 started.elapsed(),
621 e.to_string(),
622 )));
623 }
624 };
625 let body = json!({
626 "statement": "SELECT 1",
627 "warehouse_id": self.config.warehouse_id,
628 "wait_timeout": "50s",
629 "disposition": "INLINE",
630 "format": "JSON_ARRAY",
631 });
632 let calls = self.calls.get().with_timeout(ctx.timeout);
633 let fut = calls.call("submit", || async {
634 self.client
635 .post(self.statements_url())
636 .header("Authorization", &auth)
637 .header("Content-Type", "application/json")
638 .json(&body)
639 .send()
640 .await
641 .map_err(FaucetError::from)
642 });
643 let probe = match tokio::time::timeout(ctx.timeout, fut).await {
644 Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
645 Ok(Ok(r)) => Probe::fail_hint(
646 "warehouse",
647 started.elapsed(),
648 format!("databricks probe returned HTTP {}", r.status()),
649 "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
650 ),
651 Ok(Err(e)) => Probe::fail_hint(
652 "warehouse",
653 started.elapsed(),
654 format!("databricks probe request failed: {e}"),
655 "Verify workspace_url and network reachability.",
656 ),
657 Err(_) => Probe::fail_hint(
658 "warehouse",
659 started.elapsed(),
660 format!("databricks probe timed out after {:?}", ctx.timeout),
661 "Check warehouse availability and network reachability.",
662 ),
663 };
664 Ok(CheckReport::single(probe))
665 }
666}
667
668fn apply_incr_filter(page: Vec<Value>, incr: Option<&IncrementalCtx>) -> Vec<Value> {
670 match incr {
671 Some(ic) => filter_incremental(page, &ic.column, &ic.start),
672 None => page,
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use crate::config::{DatabricksAuth, DatabricksParam};
680 use faucet_core::AuthSpec;
681
682 fn cfg() -> DatabricksSourceConfig {
683 DatabricksSourceConfig {
684 workspace_url: "https://x.cloud.databricks.com".into(),
685 warehouse_id: "wh1".into(),
686 sql: "SELECT * FROM t WHERE ts > ${bookmark}".into(),
687 auth: AuthSpec::Inline(DatabricksAuth::Pat {
688 token: "tok".into(),
689 }),
690 catalog: Some("main".into()),
691 schema: Some("s".into()),
692 parameters: vec![DatabricksParam {
693 name: "min".into(),
694 value: json!(10),
695 param_type: Some("INT".into()),
696 }],
697 wait_timeout_secs: 50,
698 poll_interval_secs: 1,
699 statement_timeout_secs: 3600,
700 batch_size: 1000,
701 arrow_native: false,
702 result_disposition: ResultDisposition::default(),
703 replication: DatabricksReplication::Incremental {
704 column: "ts".into(),
705 initial_value: json!("2026-01-01"),
706 },
707 state_key: None,
708 }
709 }
710
711 fn source(c: DatabricksSourceConfig) -> DatabricksSource {
712 DatabricksSource::new(c).unwrap()
713 }
714
715 #[test]
716 fn body_has_required_fields_and_params() {
717 let s = source(cfg());
718 let incr = s.incremental_ctx();
719 let body = s.build_body(&HashMap::new(), incr.as_ref());
720 assert_eq!(body["warehouse_id"], json!("wh1"));
721 assert_eq!(body["catalog"], json!("main"));
722 assert_eq!(body["disposition"], json!("EXTERNAL_LINKS"));
724 assert_eq!(body["format"], json!("JSON_ARRAY"));
725 assert_eq!(body["wait_timeout"], json!("50s"));
726 let mut inline = cfg();
727 inline.result_disposition = ResultDisposition::Inline;
728 let inline_body = source(inline).build_body(&HashMap::new(), incr.as_ref());
729 assert_eq!(inline_body["disposition"], json!("INLINE"));
730 assert_eq!(inline_body["format"], json!("JSON_ARRAY"));
731 assert!(
733 body["statement"]
734 .as_str()
735 .unwrap()
736 .contains(":_faucet_bookmark")
737 );
738 assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
739 let params = body["parameters"].as_array().unwrap();
740 assert!(
742 params
743 .iter()
744 .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
745 );
746 let bm = params
747 .iter()
748 .find(|p| p["name"] == json!("_faucet_bookmark"))
749 .unwrap();
750 assert_eq!(bm["value"], json!("2026-01-01"));
751 }
752
753 #[test]
754 fn full_mode_has_no_state_key_or_bookmark_param() {
755 let mut c = cfg();
756 c.replication = DatabricksReplication::Full;
757 c.sql = "SELECT 1".into();
758 let s = source(c);
759 assert!(s.state_key().is_none());
760 let body = s.build_body(&HashMap::new(), None);
761 assert!(
762 body.get("parameters").is_none()
763 || body["parameters"]
764 .as_array()
765 .unwrap()
766 .iter()
767 .all(|p| p["name"] != json!("_faucet_bookmark"))
768 );
769 }
770
771 #[test]
772 fn incremental_state_key_derived_and_stable() {
773 let s = source(cfg());
774 let k1 = s.state_key().unwrap();
775 let k2 = source(cfg()).state_key().unwrap();
776 assert_eq!(k1, k2);
777 assert!(k1.starts_with("databricks:"));
778 let c = cfg();
779 let material = format!("{}\u{1f}{}\u{1f}{}", c.workspace_url, c.warehouse_id, c.sql);
780 assert_eq!(
781 k1,
782 format!(
783 "databricks:{:016x}",
784 faucet_core::shard::shard_hash(&material)
785 )
786 );
787 }
788
789 #[tokio::test]
790 async fn explicit_state_key_wins() {
791 let mut c = cfg();
792 c.state_key = Some("my-key".into());
793 let s = source(c);
794 assert_eq!(s.state_key().as_deref(), Some("my-key"));
795 s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
797 assert_eq!(s.incremental_ctx().unwrap().start, json!("2026-06-01"));
798 }
799
800 #[test]
801 fn value_to_param_string_stringifies() {
802 assert_eq!(value_to_param_string(&json!(5)), json!("5"));
803 assert_eq!(value_to_param_string(&json!(true)), json!("true"));
804 assert_eq!(value_to_param_string(&json!("x")), json!("x"));
805 assert_eq!(value_to_param_string(&Value::Null), Value::Null);
806 }
807
808 #[test]
809 fn decode_chunk_rejects_non_objects() {
810 assert!(decode_chunk(json!({"data_array": [["1"]]})).is_ok());
811 let err = decode_chunk(json!(5)).unwrap_err().to_string();
812 assert!(err.contains("could not parse"), "{err}");
813 }
814
815 #[test]
816 fn dataset_uri_redacts_scheme() {
817 let s = source(cfg());
818 assert_eq!(
819 s.dataset_uri(),
820 "databricks://x.cloud.databricks.com/warehouses/wh1"
821 );
822 }
823}