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 #[schemars(extend("x-faucet-sql" = true))]
159 pub sql: String,
160 pub auth: AuthSpec<DatabricksAuth>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub catalog: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub schema: Option<String>,
168 #[serde(default)]
170 pub parameters: Vec<DatabricksParam>,
171 #[serde(default = "default_wait_timeout")]
174 pub wait_timeout_secs: u64,
175 #[serde(default = "default_poll_interval")]
177 pub poll_interval_secs: u64,
178 #[serde(default = "default_statement_timeout")]
181 pub statement_timeout_secs: u64,
182 #[serde(default = "default_batch_size")]
190 pub batch_size: usize,
191 #[serde(default)]
203 pub arrow_native: bool,
204 #[serde(default)]
210 pub result_disposition: ResultDisposition,
211 #[serde(default)]
213 pub replication: DatabricksReplication,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub state_key: Option<String>,
218}
219
220impl DatabricksSourceConfig {
221 pub fn validate(&self) -> Result<(), FaucetError> {
223 if self.workspace_url.trim().is_empty() {
224 return Err(FaucetError::Config(
225 "databricks: `workspace_url` must not be empty".into(),
226 ));
227 }
228 if self.warehouse_id.trim().is_empty() {
229 return Err(FaucetError::Config(
230 "databricks: `warehouse_id` must not be empty".into(),
231 ));
232 }
233 if self.sql.trim().is_empty() {
234 return Err(FaucetError::Config(
235 "databricks: `sql` must not be empty".into(),
236 ));
237 }
238 if self.wait_timeout_secs != 0 && !(5..=50).contains(&self.wait_timeout_secs) {
240 return Err(FaucetError::Config(format!(
241 "databricks: `wait_timeout_secs` must be 0 or between 5 and 50 (got {})",
242 self.wait_timeout_secs
243 )));
244 }
245 faucet_core::validate_batch_size(self.batch_size)?;
246 if let DatabricksReplication::Incremental {
247 column,
248 initial_value,
249 } = &self.replication
250 {
251 if column.trim().is_empty() {
252 return Err(FaucetError::Config(
253 "databricks: incremental replication `column` must not be empty".into(),
254 ));
255 }
256 if initial_value.is_null() {
257 return Err(FaucetError::Config(
258 "databricks: incremental replication requires a non-null `initial_value`"
259 .into(),
260 ));
261 }
262 if faucet_core::replication::cursor::strict_comparison(&self.sql, BOOKMARK_TOKEN) {
263 return Err(FaucetError::Config(format!(
264 "databricks: compare `{BOOKMARK_TOKEN}` with `>=`, not `>`: the source \
265 re-reads the bookmark value and drops the rows it already emitted, and a \
266 strict comparison skips rows that share it"
267 )));
268 }
269 }
270 if self.arrow_native {
271 if !cfg!(feature = "arrow") {
272 return Err(FaucetError::Config(
273 "databricks: `arrow_native` requires the crate-local `arrow` feature to be \
274 enabled"
275 .into(),
276 ));
277 }
278 if !matches!(self.replication, DatabricksReplication::Full) {
279 return Err(FaucetError::Config(
280 "databricks: `arrow_native` currently supports only `replication: full` — the \
281 columnar path does not track the cursor bookmark"
282 .into(),
283 ));
284 }
285 }
286 Ok(())
287 }
288}
289
290pub const BOOKMARK_TOKEN: &str = "${bookmark}";
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use serde_json::json;
298
299 fn base() -> DatabricksSourceConfig {
300 DatabricksSourceConfig {
301 workspace_url: "https://x.cloud.databricks.com".into(),
302 warehouse_id: "wh1".into(),
303 sql: "SELECT 1".into(),
304 auth: AuthSpec::Inline(DatabricksAuth::Pat { token: "t".into() }),
305 catalog: None,
306 schema: None,
307 parameters: Vec::new(),
308 wait_timeout_secs: default_wait_timeout(),
309 poll_interval_secs: default_poll_interval(),
310 statement_timeout_secs: default_statement_timeout(),
311 batch_size: DEFAULT_BATCH_SIZE,
312 arrow_native: false,
313 result_disposition: ResultDisposition::default(),
314 replication: DatabricksReplication::Full,
315 state_key: None,
316 }
317 }
318
319 #[test]
320 fn valid_config_passes() {
321 base().validate().unwrap();
322 }
323
324 #[cfg(feature = "arrow")]
325 #[test]
326 fn arrow_native_full_passes_but_incremental_rejected() {
327 let mut c = base();
328 c.arrow_native = true;
329 c.validate().unwrap();
330 c.replication = DatabricksReplication::Incremental {
331 column: "ts".into(),
332 initial_value: json!("2026-01-01"),
333 };
334 let err = c.validate().unwrap_err();
335 assert!(err.to_string().contains("arrow_native"));
336 }
337
338 #[cfg(not(feature = "arrow"))]
339 #[test]
340 fn arrow_native_requires_feature() {
341 let mut c = base();
342 c.arrow_native = true;
343 let err = c.validate().unwrap_err();
344 assert!(err.to_string().contains("arrow"));
345 }
346
347 #[test]
348 fn auth_is_bearer_for_both_variants() {
349 assert_eq!(
350 DatabricksAuth::Pat {
351 token: "abc".into()
352 }
353 .authorization_value(),
354 "Bearer abc"
355 );
356 assert_eq!(
357 DatabricksAuth::Token {
358 token: "xyz".into()
359 }
360 .authorization_value(),
361 "Bearer xyz"
362 );
363 let dbg = format!(
364 "{:?}",
365 DatabricksAuth::Token {
366 token: "xyz".into()
367 }
368 );
369 assert!(!dbg.contains("xyz"));
370 let r = shared_auth(&AuthSpec::Reference(faucet_core::AuthReference {
371 name: "p".into(),
372 }));
373 assert!(matches!(r, AuthSpec::Reference(_)));
374 }
375
376 #[test]
377 fn rejects_empty_required_fields() {
378 let mut c = base();
379 c.workspace_url = " ".into();
380 assert!(c.validate().is_err());
381 let mut c = base();
382 c.warehouse_id = "".into();
383 assert!(c.validate().is_err());
384 let mut c = base();
385 c.sql = "".into();
386 assert!(c.validate().is_err());
387 }
388
389 #[test]
390 fn rejects_bad_wait_timeout() {
391 let mut c = base();
392 c.wait_timeout_secs = 3; assert!(c.validate().is_err());
394 c.wait_timeout_secs = 51;
395 assert!(c.validate().is_err());
396 c.wait_timeout_secs = 0; assert!(c.validate().is_ok());
398 }
399
400 #[test]
401 fn rejects_bad_incremental_settings() {
402 for (sql, column, initial, needle) in [
403 ("SELECT * FROM t", " ", json!(0), "column"),
404 ("SELECT * FROM t", "ts", json!(null), "initial_value"),
405 (
406 "SELECT * FROM t WHERE ts > ${bookmark}",
407 "ts",
408 json!(0),
409 ">=",
410 ),
411 ] {
412 let mut c = base();
413 c.sql = sql.into();
414 c.replication = DatabricksReplication::Incremental {
415 column: column.into(),
416 initial_value: initial,
417 };
418 let err = c.validate().unwrap_err().to_string();
419 assert!(err.contains(needle), "{err}");
420 }
421 }
422
423 #[test]
424 fn rejects_oversized_batch() {
425 let mut c = base();
426 c.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
427 assert!(c.validate().is_err());
428 }
429
430 #[test]
431 fn deserializes_full_shape() {
432 let v = json!({
433 "workspace_url": "https://x.cloud.databricks.com",
434 "warehouse_id": "wh1",
435 "sql": "SELECT * FROM t WHERE id > :min",
436 "auth": { "type": "pat", "config": { "token": "tok" } },
437 "catalog": "main",
438 "schema": "sales",
439 "parameters": [{ "name": "min", "value": 10, "type": "INT" }],
440 "wait_timeout_secs": 30,
441 "batch_size": 500
442 });
443 let c: DatabricksSourceConfig = serde_json::from_value(v).unwrap();
444 assert_eq!(c.warehouse_id, "wh1");
445 assert_eq!(c.catalog.as_deref(), Some("main"));
446 assert_eq!(c.parameters.len(), 1);
447 assert_eq!(c.parameters[0].name, "min");
448 assert_eq!(c.parameters[0].param_type.as_deref(), Some("INT"));
449 c.validate().unwrap();
450 }
451}