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    /// Backend-call context (#880) every request runs through.
42    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    /// Present under `EXTERNAL_LINKS` disposition: each entry carries a
52    /// presigned URL to a chunk (a JSON array of rows, or an Arrow IPC stream
53    /// under `arrow_native`) plus its own next-chunk link.
54    #[serde(default)]
55    external_links: Option<Vec<ExternalLink>>,
56}
57
58/// One `result.external_links[]` entry (EXTERNAL_LINKS disposition).
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.
69fn 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
79/// A terminal statement: its result columns and first chunk.
80struct Executed {
81    columns: Vec<ColumnInfo>,
82    result: Option<ResultChunk>,
83}
84
85/// Client-side incremental filter context.
86struct IncrementalCtx {
87    column: String,
88    start: Value,
89}
90
91impl DatabricksSource {
92    /// Create a new source. Validates config; does no I/O.
93    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    /// 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                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    /// The effective incremental start bookmark (persisted bookmark, else the
157    /// configured `initial_value`).
158    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    /// Build the request body: the SQL (with `${bookmark}` and `{ctx}` tokens
180    /// rewritten to named params) plus the `parameters` array.
181    fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
182        let mut sql = self.config.sql.clone();
183        // Named parameters: start with the user's static ones.
184        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        // Parent-context `{key}` tokens → `:_faucet_ctN` named params.
198        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        // Incremental `${bookmark}` token → `:_faucet_bookmark` named param.
213        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        // ARROW_STREAM is only valid with EXTERNAL_LINKS disposition; JSON_ARRAY
224        // stays INLINE. `arrow_native` is validated to require the `arrow`
225        // feature at config load, so requesting Arrow here is always decodable.
226        // INLINE results are capped at 25 MiB server-side, so the row path
227        // reads presigned JSON chunks by default (SQL-45).
228        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    /// Submit the statement and poll until it reaches a terminal state.
257    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    /// Fetch a follow-up chunk by its `next_chunk_internal_link` (a full API path).
271    async fn fetch_chunk(&self, link: &str) -> Result<ResultChunk, FaucetError> {
272        decode_chunk(self.statement_client().fetch_chunk(link).await?)
273    }
274
275    /// Fetch a presigned external link and decode its body as an Arrow IPC
276    /// stream. The link is a pre-signed cloud-storage URL, so it is fetched
277    /// **without** an `Authorization` header (adding one breaks the signature).
278    #[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    /// GET a presigned external link. It is signed for the storage service, so
299    /// it is fetched **without** an `Authorization` header (adding one breaks
300    /// the signature).
301    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    /// Fetch a presigned `JSON_ARRAY` chunk: a JSON array of row arrays.
321    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    /// Every row of one result chunk: its inline `data_array` plus the rows of
330    /// each presigned external link.
331    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/// Decode an Arrow IPC **stream** (the ARROW_STREAM chunk body) into its
343/// `RecordBatch`es. Synchronous (runs inside `spawn_blocking`).
344#[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
360/// Derive a stable state key from the workspace, warehouse, and query.
361fn default_state_key(config: &DatabricksSourceConfig) -> String {
362    // Fixed FNV-1a: the key is durable and `DefaultHasher` is not stable across Rust releases.
363    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
373/// Decode a raw result chunk.
374fn 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    /// The Databricks source advertises the columnar fast path only when
433    /// [`arrow_native`](DatabricksSourceConfig::arrow_native) is set — i.e. the
434    /// statement is fetched as `ARROW_STREAM` (RFC 0002 / #375).
435    #[cfg(feature = "arrow")]
436    fn supports_columnar(&self) -> bool {
437        self.config.arrow_native
438    }
439
440    /// Stream the statement's `ARROW_STREAM` result chunks as Arrow
441    /// `RecordBatch`es — one [`ColumnarPage`](faucet_core::columnar::ColumnarPage)
442    /// per batch — so a `databricks → parquet`/`delta`/`sql` chain never
443    /// materializes `serde_json::Value`. `arrow_native` is Full-replication
444    /// only, so every page carries `bookmark: None`. Empty batches are skipped.
445    #[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            // Arrow-native row path: fetch ARROW_STREAM chunks and decode each
501            // RecordBatch to JSON rows (for a non-columnar sink). `arrow_native`
502            // is Full-replication only (enforced by config validation), so no
503            // incremental filter runs here.
504            #[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            // Walk chunks: the initial `result`, then follow next_chunk_internal_link.
554            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                        // Track the running max BEFORE the client-side filter so
561                        // the persisted bookmark reflects the full scan.
562                        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            // Final page carries the new bookmark (incremental only).
587            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        // Non-scanning probe: run `SELECT 1` on the warehouse.
615        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
668/// Apply the client-side incremental filter to a page (no-op for full runs).
669fn 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        // SQL-45: the row path no longer requests INLINE (capped at 25 MiB).
723        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        // ${bookmark} rewritten to the named param marker.
732        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        // static `min` (typed) + the bookmark param.
741        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        // apply_start_bookmark overrides initial_value in the incr ctx.
796        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}