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