Skip to main content

faucet_source_databricks/
stream.rs

1//! Databricks SQL query source over the Statement Execution API.
2//!
3//! Submits `sql` to `POST /api/2.0/sql/statements`, polls
4//! `GET /api/2.0/sql/statements/{id}` until the statement is terminal, then
5//! streams the result chunks (INLINE + JSON_ARRAY) as typed JSON rows,
6//! following `result.next_chunk_internal_link` across chunks. No SDK — plain
7//! `reqwest` over the shared-auth bearer token.
8
9use 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_common_databricks::{
18    ErrorSide, StatementClient, StatementOptions, resolve_authorization, value_to_param_string,
19};
20use faucet_core::replication::{filter_incremental, max_value};
21use faucet_core::{FaucetError, SharedAuthProvider, Source, Stream, StreamPage};
22use reqwest::Client;
23use serde::Deserialize;
24use serde_json::{Value, json};
25
26use crate::config::{DatabricksReplication, DatabricksSourceConfig, shared_auth};
27use crate::convert::{ColumnInfo, row_to_json};
28
29/// Databricks SQL query source.
30pub struct DatabricksSource {
31    config: DatabricksSourceConfig,
32    client: Client,
33    /// Base URL override (up to but not including `/api/2.0/...`). `None` uses
34    /// `config.workspace_url`. Set by tests to point at a mock server.
35    endpoint_base: Option<String>,
36    /// Shared auth provider; when set, supplies the bearer token (takes
37    /// precedence over inline auth).
38    auth_provider: Option<SharedAuthProvider>,
39    /// Bookmark applied via [`Source::apply_start_bookmark`].
40    start_bookmark: Mutex<Option<Value>>,
41}
42
43#[derive(Debug, Deserialize)]
44struct ResultChunk {
45    #[serde(default)]
46    data_array: Option<Vec<Vec<Value>>>,
47    #[serde(default)]
48    next_chunk_internal_link: Option<String>,
49    /// Present under `EXTERNAL_LINKS` disposition (ARROW_STREAM): each entry
50    /// carries a presigned URL to an Arrow IPC chunk plus its own
51    /// next-chunk link. Only consumed by the `arrow` columnar path.
52    #[cfg(feature = "arrow")]
53    #[serde(default)]
54    external_links: Option<Vec<ExternalLink>>,
55}
56
57/// One `result.external_links[]` entry (EXTERNAL_LINKS disposition).
58#[cfg(feature = "arrow")]
59#[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
67/// The internal link to the next result chunk, checked at both the result
68/// level and (for EXTERNAL_LINKS) the first external-link level.
69#[cfg(feature = "arrow")]
70fn next_chunk_link(chunk: &ResultChunk) -> Option<String> {
71    chunk.next_chunk_internal_link.clone().or_else(|| {
72        chunk
73            .external_links
74            .as_ref()
75            .and_then(|l| l.first())
76            .and_then(|e| e.next_chunk_internal_link.clone())
77    })
78}
79
80/// A terminal statement: its result columns and first chunk.
81struct Executed {
82    columns: Vec<ColumnInfo>,
83    result: Option<ResultChunk>,
84}
85
86/// Client-side incremental filter context.
87struct IncrementalCtx {
88    column: String,
89    start: Value,
90}
91
92impl DatabricksSource {
93    /// Create a new source. Validates config; does no I/O.
94    pub fn new(config: DatabricksSourceConfig) -> Result<Self, FaucetError> {
95        config.validate()?;
96        Ok(Self {
97            config,
98            client: Client::new(),
99            endpoint_base: None,
100            auth_provider: None,
101            start_bookmark: Mutex::new(None),
102        })
103    }
104
105    /// Attach a shared auth provider (yields the bearer token). Takes
106    /// precedence over inline auth — lets several connectors share one token.
107    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
108        self.auth_provider = Some(provider);
109        self
110    }
111
112    /// Override the base URL (e.g. a wiremock server). Requests go to
113    /// `{base}/api/2.0/sql/statements`.
114    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    /// Resolve the `Authorization` header value: shared provider first, else
131    /// inline auth.
132    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    /// A Statement Execution API client for this source's warehouse.
137    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                ..StatementOptions::default()
148            },
149            ErrorSide::Source,
150        )
151    }
152
153    /// The effective incremental start bookmark (persisted bookmark, else the
154    /// configured `initial_value`).
155    fn incremental_ctx(&self) -> Option<IncrementalCtx> {
156        match &self.config.replication {
157            DatabricksReplication::Full => None,
158            DatabricksReplication::Incremental {
159                column,
160                initial_value,
161            } => {
162                let start = self
163                    .start_bookmark
164                    .lock()
165                    .expect("start_bookmark mutex poisoned")
166                    .clone()
167                    .unwrap_or_else(|| initial_value.clone());
168                Some(IncrementalCtx {
169                    column: column.clone(),
170                    start,
171                })
172            }
173        }
174    }
175
176    /// Build the request body: the SQL (with `${bookmark}` and `{ctx}` tokens
177    /// rewritten to named params) plus the `parameters` array.
178    fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
179        let mut sql = self.config.sql.clone();
180        // Named parameters: start with the user's static ones.
181        let mut params: Vec<Value> = self
182            .config
183            .parameters
184            .iter()
185            .map(|p| {
186                json!({
187                    "name": p.name,
188                    "value": value_to_param_string(&p.value),
189                    "type": p.param_type.clone().unwrap_or_else(|| "STRING".into()),
190                })
191            })
192            .collect();
193
194        // Parent-context `{key}` tokens → `:_faucet_ctN` named params.
195        if !context.is_empty() {
196            let (rewritten, ctx_values) =
197                faucet_core::util::substitute_context_bind_params(&sql, context, 0, |i| {
198                    format!(":_faucet_ct{i}")
199                });
200            sql = rewritten;
201            for (i, v) in ctx_values.into_iter().enumerate() {
202                params.push(json!({
203                    "name": format!("_faucet_ct{i}"),
204                    "value": value_to_param_string(&v),
205                }));
206            }
207        }
208
209        // Incremental `${bookmark}` token → `:_faucet_bookmark` named param.
210        if let Some(ctx) = incr
211            && sql.contains("${bookmark}")
212        {
213            sql = sql.replace("${bookmark}", ":_faucet_bookmark");
214            params.push(json!({
215                "name": "_faucet_bookmark",
216                "value": value_to_param_string(&ctx.start),
217            }));
218        }
219
220        // ARROW_STREAM is only valid with EXTERNAL_LINKS disposition; JSON_ARRAY
221        // stays INLINE. `arrow_native` is validated to require the `arrow`
222        // feature at config load, so requesting Arrow here is always decodable.
223        let (disposition, format) = if self.config.arrow_native {
224            ("EXTERNAL_LINKS", "ARROW_STREAM")
225        } else {
226            ("INLINE", "JSON_ARRAY")
227        };
228        let mut body = json!({
229            "statement": sql,
230            "warehouse_id": self.config.warehouse_id,
231            "wait_timeout": format!("{}s", self.config.wait_timeout_secs),
232            "on_wait_timeout": "CONTINUE",
233            "disposition": disposition,
234            "format": format,
235        });
236        if let Some(c) = &self.config.catalog {
237            body["catalog"] = json!(c);
238        }
239        if let Some(s) = &self.config.schema {
240            body["schema"] = json!(s);
241        }
242        if !params.is_empty() {
243            body["parameters"] = Value::Array(params);
244        }
245        body
246    }
247
248    /// Submit the statement and poll until it reaches a terminal state.
249    async fn run_statement(
250        &self,
251        context: &HashMap<String, Value>,
252        incr: Option<&IncrementalCtx>,
253    ) -> Result<Executed, FaucetError> {
254        let body = self.build_body(context, incr);
255        let resp = self.statement_client().execute_body(&body).await?;
256        let columns = resp.columns().to_vec();
257        let result = resp.result.map(decode_chunk).transpose()?;
258        Ok(Executed { columns, result })
259    }
260
261    /// Fetch a follow-up chunk by its `next_chunk_internal_link` (a full API path).
262    async fn fetch_chunk(&self, link: &str) -> Result<ResultChunk, FaucetError> {
263        decode_chunk(self.statement_client().fetch_chunk(link).await?)
264    }
265
266    /// Fetch a presigned external link and decode its body as an Arrow IPC
267    /// stream. The link is a pre-signed cloud-storage URL, so it is fetched
268    /// **without** an `Authorization` header (adding one breaks the signature).
269    #[cfg(feature = "arrow")]
270    async fn fetch_arrow_link(
271        &self,
272        url: &str,
273    ) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
274        let resp = self.client.get(url).send().await.map_err(|e| {
275            FaucetError::Source(format!("databricks: external-link request failed: {e}"))
276        })?;
277        let status = resp.status();
278        if !status.is_success() {
279            let body = resp.text().await.unwrap_or_default();
280            return Err(FaucetError::Source(format!(
281                "databricks: external link HTTP {status}: {body}"
282            )));
283        }
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
297/// Decode an Arrow IPC **stream** (the ARROW_STREAM chunk body) into its
298/// `RecordBatch`es. Synchronous (runs inside `spawn_blocking`).
299#[cfg(feature = "arrow")]
300fn decode_arrow_ipc(data: bytes::Bytes) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
301    use arrow::ipc::reader::StreamReader;
302
303    let reader = StreamReader::try_new(std::io::Cursor::new(data), None).map_err(|e| {
304        FaucetError::Source(format!("databricks: arrow IPC reader init failed: {e}"))
305    })?;
306    let mut batches = Vec::new();
307    for batch in reader {
308        batches.push(batch.map_err(|e| {
309            FaucetError::Source(format!("databricks: arrow IPC decode failed: {e}"))
310        })?);
311    }
312    Ok(batches)
313}
314
315/// Derive a stable state key from the workspace, warehouse, and query.
316fn default_state_key(config: &DatabricksSourceConfig) -> String {
317    let mut h = DefaultHasher::new();
318    config.workspace_url.hash(&mut h);
319    config.warehouse_id.hash(&mut h);
320    config.sql.hash(&mut h);
321    format!("databricks:{:016x}", h.finish())
322}
323
324/// Decode a raw result chunk.
325fn decode_chunk(v: Value) -> Result<ResultChunk, FaucetError> {
326    serde_json::from_value(v)
327        .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
328}
329
330#[async_trait]
331impl Source for DatabricksSource {
332    fn config_schema(&self) -> Value {
333        serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
334            .expect("schema serialization")
335    }
336
337    fn connector_name(&self) -> &'static str {
338        "databricks"
339    }
340
341    fn dataset_uri(&self) -> String {
342        format!(
343            "databricks://{}/warehouses/{}",
344            self.config
345                .workspace_url
346                .trim_start_matches("https://")
347                .trim_end_matches('/'),
348            self.config.warehouse_id
349        )
350    }
351
352    fn state_key(&self) -> Option<String> {
353        match &self.config.replication {
354            DatabricksReplication::Full => None,
355            DatabricksReplication::Incremental { .. } => Some(
356                self.config
357                    .state_key
358                    .clone()
359                    .unwrap_or_else(|| default_state_key(&self.config)),
360            ),
361        }
362    }
363
364    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
365        *self
366            .start_bookmark
367            .lock()
368            .expect("start_bookmark mutex poisoned") = Some(bookmark);
369        Ok(())
370    }
371
372    /// The Databricks source advertises the columnar fast path only when
373    /// [`arrow_native`](DatabricksSourceConfig::arrow_native) is set — i.e. the
374    /// statement is fetched as `ARROW_STREAM` (RFC 0002 / #375).
375    #[cfg(feature = "arrow")]
376    fn supports_columnar(&self) -> bool {
377        self.config.arrow_native
378    }
379
380    /// Stream the statement's `ARROW_STREAM` result chunks as Arrow
381    /// `RecordBatch`es — one [`ColumnarPage`](faucet_core::columnar::ColumnarPage)
382    /// per batch — so a `databricks → parquet`/`delta`/`sql` chain never
383    /// materializes `serde_json::Value`. `arrow_native` is Full-replication
384    /// only, so every page carries `bookmark: None`. Empty batches are skipped.
385    #[cfg(feature = "arrow")]
386    fn stream_batches<'a>(
387        &'a self,
388        context: &'a HashMap<String, Value>,
389        _batch_size: usize,
390    ) -> Pin<
391        Box<
392            dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
393        >,
394    > {
395        Box::pin(async_stream::try_stream! {
396            if !self.config.arrow_native {
397                Err(FaucetError::Source(
398                    "databricks: stream_batches requires `arrow_native: true`".into(),
399                ))?;
400            }
401            let resp = self.run_statement(context, None).await?;
402            let mut chunk = resp.result;
403            let mut total_records = 0usize;
404            let mut total_pages = 0usize;
405            while let Some(c) = chunk {
406                if let Some(links) = c.external_links.as_ref() {
407                    for link in links {
408                        if let Some(url) = link.external_link.as_deref() {
409                            let batches = self.fetch_arrow_link(url).await?;
410                            for batch in batches {
411                                if batch.num_rows() == 0 {
412                                    continue;
413                                }
414                                total_records += batch.num_rows();
415                                total_pages += 1;
416                                yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
417                            }
418                        }
419                    }
420                }
421                chunk = match next_chunk_link(&c) {
422                    Some(link) => Some(self.fetch_chunk(&link).await?),
423                    None => None,
424                };
425            }
426            tracing::info!(
427                pages = total_pages,
428                total_records,
429                "databricks columnar stream complete",
430            );
431        })
432    }
433
434    fn stream_pages<'a>(
435        &'a self,
436        context: &'a HashMap<String, Value>,
437        _batch_size: usize,
438    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
439        Box::pin(async_stream::try_stream! {
440            // Arrow-native row path: fetch ARROW_STREAM chunks and decode each
441            // RecordBatch to JSON rows (for a non-columnar sink). `arrow_native`
442            // is Full-replication only (enforced by config validation), so no
443            // incremental filter runs here.
444            #[cfg(feature = "arrow")]
445            if self.config.arrow_native {
446                let resp = self.run_statement(context, None).await?;
447                let cap = if self.config.batch_size == 0 { 1024 } else { self.config.batch_size };
448                let mut buffer: Vec<Value> = Vec::with_capacity(cap);
449                let mut chunk = resp.result;
450                while let Some(c) = chunk {
451                    if let Some(links) = c.external_links.as_ref() {
452                        for link in links {
453                            if let Some(url) = link.external_link.as_deref() {
454                                let batches = self.fetch_arrow_link(url).await?;
455                                for batch in &batches {
456                                    for row in faucet_core::columnar::record_batch_to_values(batch)? {
457                                        buffer.push(row);
458                                        if self.config.batch_size != 0
459                                            && buffer.len() >= self.config.batch_size
460                                        {
461                                            let page = std::mem::replace(
462                                                &mut buffer,
463                                                Vec::with_capacity(cap),
464                                            );
465                                            yield StreamPage { records: page, bookmark: None };
466                                        }
467                                    }
468                                }
469                            }
470                        }
471                    }
472                    chunk = match next_chunk_link(&c) {
473                        Some(link) => Some(self.fetch_chunk(&link).await?),
474                        None => None,
475                    };
476                }
477                if !buffer.is_empty() {
478                    yield StreamPage { records: buffer, bookmark: None };
479                }
480                return;
481            }
482
483            let incr = self.incremental_ctx();
484            let resp = self.run_statement(context, incr.as_ref()).await?;
485
486            let columns: Vec<ColumnInfo> = resp.columns;
487
488            let batch = self.config.batch_size;
489            let cap = if batch == 0 { 1024 } else { batch };
490            let mut buffer: Vec<Value> = Vec::with_capacity(cap);
491            let mut running_max: Option<Value> = None;
492
493            // Walk chunks: the initial `result`, then follow next_chunk_internal_link.
494            let mut chunk = resp.result;
495            while let Some(c) = chunk {
496                if let Some(data) = c.data_array {
497                    for row in &data {
498                        let obj = row_to_json(row, &columns);
499                        // Track the running max BEFORE the client-side filter so
500                        // the persisted bookmark reflects the full scan.
501                        if let Some(ic) = &incr
502                            && let Some(v) = obj.get(&ic.column)
503                        {
504                            running_max = Some(match running_max.take() {
505                                Some(m) => max_value(m, v.clone()),
506                                None => v.clone(),
507                            });
508                        }
509                        buffer.push(obj);
510                        if batch != 0 && buffer.len() >= batch {
511                            let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
512                            let kept = apply_incr_filter(page, incr.as_ref());
513                            if !kept.is_empty() {
514                                yield StreamPage { records: kept, bookmark: None };
515                            }
516                        }
517                    }
518                }
519                chunk = match c.next_chunk_internal_link {
520                    Some(link) => Some(self.fetch_chunk(&link).await?),
521                    None => None,
522                };
523            }
524
525            // Final page carries the new bookmark (incremental only).
526            let kept = apply_incr_filter(buffer, incr.as_ref());
527            let bookmark = if incr.is_some() { running_max } else { None };
528            if !kept.is_empty() || bookmark.is_some() {
529                yield StreamPage { records: kept, bookmark };
530            }
531        })
532    }
533
534    async fn fetch_with_context(
535        &self,
536        context: &HashMap<String, Value>,
537    ) -> Result<Vec<Value>, FaucetError> {
538        use futures::StreamExt;
539        let mut out = Vec::new();
540        let mut s = self.stream_pages(context, self.config.batch_size);
541        while let Some(page) = s.next().await {
542            out.extend(page?.records);
543        }
544        Ok(out)
545    }
546
547    async fn check(
548        &self,
549        ctx: &faucet_core::check::CheckContext,
550    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
551        use faucet_core::check::{CheckReport, Probe};
552        let started = std::time::Instant::now();
553        // Non-scanning probe: run `SELECT 1` on the warehouse.
554        let auth = match self.auth_header().await {
555            Ok(a) => a,
556            Err(e) => {
557                return Ok(CheckReport::single(Probe::fail(
558                    "auth",
559                    started.elapsed(),
560                    e.to_string(),
561                )));
562            }
563        };
564        let body = json!({
565            "statement": "SELECT 1",
566            "warehouse_id": self.config.warehouse_id,
567            "wait_timeout": "50s",
568            "disposition": "INLINE",
569            "format": "JSON_ARRAY",
570        });
571        let fut = self
572            .client
573            .post(self.statements_url())
574            .header("Authorization", &auth)
575            .header("Content-Type", "application/json")
576            .json(&body)
577            .send();
578        let probe = match tokio::time::timeout(ctx.timeout, fut).await {
579            Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
580            Ok(Ok(r)) => Probe::fail_hint(
581                "warehouse",
582                started.elapsed(),
583                format!("databricks probe returned HTTP {}", r.status()),
584                "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
585            ),
586            Ok(Err(e)) => Probe::fail_hint(
587                "warehouse",
588                started.elapsed(),
589                format!("databricks probe request failed: {e}"),
590                "Verify workspace_url and network reachability.",
591            ),
592            Err(_) => Probe::fail_hint(
593                "warehouse",
594                started.elapsed(),
595                format!("databricks probe timed out after {:?}", ctx.timeout),
596                "Check warehouse availability and network reachability.",
597            ),
598        };
599        Ok(CheckReport::single(probe))
600    }
601}
602
603/// Apply the client-side incremental filter to a page (no-op for full runs).
604fn apply_incr_filter(page: Vec<Value>, incr: Option<&IncrementalCtx>) -> Vec<Value> {
605    match incr {
606        Some(ic) => filter_incremental(page, &ic.column, &ic.start),
607        None => page,
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::config::{DatabricksAuth, DatabricksParam};
615    use faucet_core::AuthSpec;
616
617    fn cfg() -> DatabricksSourceConfig {
618        DatabricksSourceConfig {
619            workspace_url: "https://x.cloud.databricks.com".into(),
620            warehouse_id: "wh1".into(),
621            sql: "SELECT * FROM t WHERE ts > ${bookmark}".into(),
622            auth: AuthSpec::Inline(DatabricksAuth::Pat {
623                token: "tok".into(),
624            }),
625            catalog: Some("main".into()),
626            schema: Some("s".into()),
627            parameters: vec![DatabricksParam {
628                name: "min".into(),
629                value: json!(10),
630                param_type: Some("INT".into()),
631            }],
632            wait_timeout_secs: 50,
633            poll_interval_secs: 1,
634            batch_size: 1000,
635            arrow_native: false,
636            replication: DatabricksReplication::Incremental {
637                column: "ts".into(),
638                initial_value: json!("2026-01-01"),
639            },
640            state_key: None,
641        }
642    }
643
644    fn source(c: DatabricksSourceConfig) -> DatabricksSource {
645        DatabricksSource::new(c).unwrap()
646    }
647
648    #[test]
649    fn body_has_required_fields_and_params() {
650        let s = source(cfg());
651        let incr = s.incremental_ctx();
652        let body = s.build_body(&HashMap::new(), incr.as_ref());
653        assert_eq!(body["warehouse_id"], json!("wh1"));
654        assert_eq!(body["catalog"], json!("main"));
655        assert_eq!(body["disposition"], json!("INLINE"));
656        assert_eq!(body["format"], json!("JSON_ARRAY"));
657        assert_eq!(body["wait_timeout"], json!("50s"));
658        // ${bookmark} rewritten to the named param marker.
659        assert!(
660            body["statement"]
661                .as_str()
662                .unwrap()
663                .contains(":_faucet_bookmark")
664        );
665        assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
666        let params = body["parameters"].as_array().unwrap();
667        // static `min` (typed) + the bookmark param.
668        assert!(
669            params
670                .iter()
671                .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
672        );
673        let bm = params
674            .iter()
675            .find(|p| p["name"] == json!("_faucet_bookmark"))
676            .unwrap();
677        assert_eq!(bm["value"], json!("2026-01-01"));
678    }
679
680    #[test]
681    fn full_mode_has_no_state_key_or_bookmark_param() {
682        let mut c = cfg();
683        c.replication = DatabricksReplication::Full;
684        c.sql = "SELECT 1".into();
685        let s = source(c);
686        assert!(s.state_key().is_none());
687        let body = s.build_body(&HashMap::new(), None);
688        assert!(
689            body.get("parameters").is_none()
690                || body["parameters"]
691                    .as_array()
692                    .unwrap()
693                    .iter()
694                    .all(|p| p["name"] != json!("_faucet_bookmark"))
695        );
696    }
697
698    #[test]
699    fn incremental_state_key_derived_and_stable() {
700        let s = source(cfg());
701        let k1 = s.state_key().unwrap();
702        let k2 = source(cfg()).state_key().unwrap();
703        assert_eq!(k1, k2);
704        assert!(k1.starts_with("databricks:"));
705    }
706
707    #[tokio::test]
708    async fn explicit_state_key_wins() {
709        let mut c = cfg();
710        c.state_key = Some("my-key".into());
711        let s = source(c);
712        assert_eq!(s.state_key().as_deref(), Some("my-key"));
713        // apply_start_bookmark overrides initial_value in the incr ctx.
714        s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
715        assert_eq!(s.incremental_ctx().unwrap().start, json!("2026-06-01"));
716    }
717
718    #[test]
719    fn value_to_param_string_stringifies() {
720        assert_eq!(value_to_param_string(&json!(5)), json!("5"));
721        assert_eq!(value_to_param_string(&json!(true)), json!("true"));
722        assert_eq!(value_to_param_string(&json!("x")), json!("x"));
723        assert_eq!(value_to_param_string(&Value::Null), Value::Null);
724    }
725
726    #[test]
727    fn decode_chunk_rejects_non_objects() {
728        assert!(decode_chunk(json!({"data_array": [["1"]]})).is_ok());
729        let err = decode_chunk(json!(5)).unwrap_err().to_string();
730        assert!(err.contains("could not parse"), "{err}");
731    }
732
733    #[test]
734    fn dataset_uri_redacts_scheme() {
735        let s = source(cfg());
736        assert_eq!(
737            s.dataset_uri(),
738            "databricks://x.cloud.databricks.com/warehouses/wh1"
739        );
740    }
741}