faucet_source_databricks/
config.rs1use faucet_core::{AuthSpec, DEFAULT_BATCH_SIZE, FaucetError};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum ResultDisposition {
13 #[default]
16 ExternalLinks,
17 Inline,
20}
21
22#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
27#[serde(tag = "type", rename_all = "snake_case")]
28pub enum DatabricksReplication {
29 #[default]
31 Full,
32 Incremental {
44 column: String,
46 initial_value: Value,
49 },
50}
51
52fn default_wait_timeout() -> u64 {
53 50
54}
55
56fn default_statement_timeout() -> u64 {
57 3600
58}
59
60fn default_poll_interval() -> u64 {
61 1
62}
63
64fn default_batch_size() -> usize {
65 DEFAULT_BATCH_SIZE
66}
67
68#[derive(Clone, Serialize, Deserialize, JsonSchema)]
81#[serde(tag = "type", content = "config", rename_all = "snake_case")]
82pub enum DatabricksAuth {
83 Pat {
85 token: String,
87 },
88 Token {
90 token: String,
92 },
93}
94
95impl std::fmt::Debug for DatabricksAuth {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 faucet_common_databricks::DatabricksAuth::from(self.clone()).fmt(f)
98 }
99}
100
101impl DatabricksAuth {
102 pub fn authorization_value(&self) -> String {
104 faucet_common_databricks::DatabricksAuth::from(self.clone()).authorization_value()
105 }
106}
107
108impl From<DatabricksAuth> for faucet_common_databricks::DatabricksAuth {
109 fn from(a: DatabricksAuth) -> Self {
110 match a {
111 DatabricksAuth::Pat { token } => Self::Pat { token },
112 DatabricksAuth::Token { token } => Self::Token { token },
113 }
114 }
115}
116
117pub(crate) fn shared_auth(
119 auth: &AuthSpec<DatabricksAuth>,
120) -> AuthSpec<faucet_common_databricks::DatabricksAuth> {
121 match auth {
122 AuthSpec::Inline(a) => AuthSpec::Inline(a.clone().into()),
123 AuthSpec::Reference(r) => AuthSpec::Reference(r.clone()),
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
129#[serde(deny_unknown_fields)]
130pub struct DatabricksParam {
131 pub name: String,
133 #[serde(default)]
136 pub value: Value,
137 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
140 pub param_type: Option<String>,
141}
142
143#[derive(Clone, Serialize, Deserialize, JsonSchema)]
148#[serde(deny_unknown_fields)]
149pub struct DatabricksSourceConfig {
150 #[schemars(extend("x-faucet-resource" = "url"))]
152 pub workspace_url: String,
153 pub warehouse_id: String,
155 pub sql: String,
159 pub auth: AuthSpec<DatabricksAuth>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub catalog: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub schema: Option<String>,
167 #[serde(default)]
169 pub parameters: Vec<DatabricksParam>,
170 #[serde(default = "default_wait_timeout")]
173 pub wait_timeout_secs: u64,
174 #[serde(default = "default_poll_interval")]
176 pub poll_interval_secs: u64,
177 #[serde(default = "default_statement_timeout")]
180 pub statement_timeout_secs: u64,
181 #[serde(default = "default_batch_size")]
189 pub batch_size: usize,
190 #[serde(default)]
202 pub arrow_native: bool,
203 #[serde(default)]
209 pub result_disposition: ResultDisposition,
210 #[serde(default)]
212 pub replication: DatabricksReplication,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub state_key: Option<String>,
217}
218
219impl DatabricksSourceConfig {
220 pub fn validate(&self) -> Result<(), FaucetError> {
222 if self.workspace_url.trim().is_empty() {
223 return Err(FaucetError::Config(
224 "databricks: `workspace_url` must not be empty".into(),
225 ));
226 }
227 if self.warehouse_id.trim().is_empty() {
228 return Err(FaucetError::Config(
229 "databricks: `warehouse_id` must not be empty".into(),
230 ));
231 }
232 if self.sql.trim().is_empty() {
233 return Err(FaucetError::Config(
234 "databricks: `sql` must not be empty".into(),
235 ));
236 }
237 if self.wait_timeout_secs != 0 && !(5..=50).contains(&self.wait_timeout_secs) {
239 return Err(FaucetError::Config(format!(
240 "databricks: `wait_timeout_secs` must be 0 or between 5 and 50 (got {})",
241 self.wait_timeout_secs
242 )));
243 }
244 faucet_core::validate_batch_size(self.batch_size)?;
245 if let DatabricksReplication::Incremental {
246 column,
247 initial_value,
248 } = &self.replication
249 {
250 if column.trim().is_empty() {
251 return Err(FaucetError::Config(
252 "databricks: incremental replication `column` must not be empty".into(),
253 ));
254 }
255 if initial_value.is_null() {
256 return Err(FaucetError::Config(
257 "databricks: incremental replication requires a non-null `initial_value`"
258 .into(),
259 ));
260 }
261 if faucet_core::replication::cursor::strict_comparison(&self.sql, BOOKMARK_TOKEN) {
262 return Err(FaucetError::Config(format!(
263 "databricks: compare `{BOOKMARK_TOKEN}` with `>=`, not `>`: the source \
264 re-reads the bookmark value and drops the rows it already emitted, and a \
265 strict comparison skips rows that share it"
266 )));
267 }
268 }
269 if self.arrow_native {
270 if !cfg!(feature = "arrow") {
271 return Err(FaucetError::Config(
272 "databricks: `arrow_native` requires the crate-local `arrow` feature to be \
273 enabled"
274 .into(),
275 ));
276 }
277 if !matches!(self.replication, DatabricksReplication::Full) {
278 return Err(FaucetError::Config(
279 "databricks: `arrow_native` currently supports only `replication: full` — the \
280 columnar path does not track the cursor bookmark"
281 .into(),
282 ));
283 }
284 }
285 Ok(())
286 }
287}
288
289pub const BOOKMARK_TOKEN: &str = "${bookmark}";
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use serde_json::json;
297
298 fn base() -> DatabricksSourceConfig {
299 DatabricksSourceConfig {
300 workspace_url: "https://x.cloud.databricks.com".into(),
301 warehouse_id: "wh1".into(),
302 sql: "SELECT 1".into(),
303 auth: AuthSpec::Inline(DatabricksAuth::Pat { token: "t".into() }),
304 catalog: None,
305 schema: None,
306 parameters: Vec::new(),
307 wait_timeout_secs: default_wait_timeout(),
308 poll_interval_secs: default_poll_interval(),
309 statement_timeout_secs: default_statement_timeout(),
310 batch_size: DEFAULT_BATCH_SIZE,
311 arrow_native: false,
312 result_disposition: ResultDisposition::default(),
313 replication: DatabricksReplication::Full,
314 state_key: None,
315 }
316 }
317
318 #[test]
319 fn valid_config_passes() {
320 base().validate().unwrap();
321 }
322
323 #[cfg(feature = "arrow")]
324 #[test]
325 fn arrow_native_full_passes_but_incremental_rejected() {
326 let mut c = base();
327 c.arrow_native = true;
328 c.validate().unwrap();
329 c.replication = DatabricksReplication::Incremental {
330 column: "ts".into(),
331 initial_value: json!("2026-01-01"),
332 };
333 let err = c.validate().unwrap_err();
334 assert!(err.to_string().contains("arrow_native"));
335 }
336
337 #[cfg(not(feature = "arrow"))]
338 #[test]
339 fn arrow_native_requires_feature() {
340 let mut c = base();
341 c.arrow_native = true;
342 let err = c.validate().unwrap_err();
343 assert!(err.to_string().contains("arrow"));
344 }
345
346 #[test]
347 fn auth_is_bearer_for_both_variants() {
348 assert_eq!(
349 DatabricksAuth::Pat {
350 token: "abc".into()
351 }
352 .authorization_value(),
353 "Bearer abc"
354 );
355 assert_eq!(
356 DatabricksAuth::Token {
357 token: "xyz".into()
358 }
359 .authorization_value(),
360 "Bearer xyz"
361 );
362 let dbg = format!(
363 "{:?}",
364 DatabricksAuth::Token {
365 token: "xyz".into()
366 }
367 );
368 assert!(!dbg.contains("xyz"));
369 let r = shared_auth(&AuthSpec::Reference(faucet_core::AuthReference {
370 name: "p".into(),
371 }));
372 assert!(matches!(r, AuthSpec::Reference(_)));
373 }
374
375 #[test]
376 fn rejects_empty_required_fields() {
377 let mut c = base();
378 c.workspace_url = " ".into();
379 assert!(c.validate().is_err());
380 let mut c = base();
381 c.warehouse_id = "".into();
382 assert!(c.validate().is_err());
383 let mut c = base();
384 c.sql = "".into();
385 assert!(c.validate().is_err());
386 }
387
388 #[test]
389 fn rejects_bad_wait_timeout() {
390 let mut c = base();
391 c.wait_timeout_secs = 3; assert!(c.validate().is_err());
393 c.wait_timeout_secs = 51;
394 assert!(c.validate().is_err());
395 c.wait_timeout_secs = 0; assert!(c.validate().is_ok());
397 }
398
399 #[test]
400 fn rejects_bad_incremental_settings() {
401 for (sql, column, initial, needle) in [
402 ("SELECT * FROM t", " ", json!(0), "column"),
403 ("SELECT * FROM t", "ts", json!(null), "initial_value"),
404 (
405 "SELECT * FROM t WHERE ts > ${bookmark}",
406 "ts",
407 json!(0),
408 ">=",
409 ),
410 ] {
411 let mut c = base();
412 c.sql = sql.into();
413 c.replication = DatabricksReplication::Incremental {
414 column: column.into(),
415 initial_value: initial,
416 };
417 let err = c.validate().unwrap_err().to_string();
418 assert!(err.contains(needle), "{err}");
419 }
420 }
421
422 #[test]
423 fn rejects_oversized_batch() {
424 let mut c = base();
425 c.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
426 assert!(c.validate().is_err());
427 }
428
429 #[test]
430 fn deserializes_full_shape() {
431 let v = json!({
432 "workspace_url": "https://x.cloud.databricks.com",
433 "warehouse_id": "wh1",
434 "sql": "SELECT * FROM t WHERE id > :min",
435 "auth": { "type": "pat", "config": { "token": "tok" } },
436 "catalog": "main",
437 "schema": "sales",
438 "parameters": [{ "name": "min", "value": 10, "type": "INT" }],
439 "wait_timeout_secs": 30,
440 "batch_size": 500
441 });
442 let c: DatabricksSourceConfig = serde_json::from_value(v).unwrap();
443 assert_eq!(c.warehouse_id, "wh1");
444 assert_eq!(c.catalog.as_deref(), Some("main"));
445 assert_eq!(c.parameters.len(), 1);
446 assert_eq!(c.parameters[0].name, "min");
447 assert_eq!(c.parameters[0].param_type.as_deref(), Some("INT"));
448 c.validate().unwrap();
449 }
450}