1use std::collections::HashMap;
12use std::pin::Pin;
13
14use arrow::datatypes::{DataType, SchemaRef};
15use async_trait::async_trait;
16use faucet_common_delta::convert::record_batch_to_json;
17use faucet_core::{FaucetError, Stream, StreamPage};
18use futures::StreamExt;
19use object_store::path::Path as ObjPath;
20use parquet::arrow::ProjectionMask;
21use parquet::arrow::async_reader::{ParquetObjectReader, ParquetRecordBatchStreamBuilder};
22use serde_json::Value;
23
24use crate::config::DeltaSourceConfig;
25
26pub struct DeltaSource {
28 config: DeltaSourceConfig,
29}
30
31struct DataFile {
33 path: ObjPath,
34 partitions: HashMap<String, Value>,
37}
38
39impl DeltaSource {
40 pub async fn new(config: DeltaSourceConfig) -> Result<Self, FaucetError> {
43 config
44 .validate()
45 .map_err(|e| FaucetError::Config(format!("invalid delta source config: {e}")))?;
46 config.connection.register_handlers();
47 Ok(Self { config })
48 }
49
50 async fn open(&self) -> Result<deltalake::DeltaTable, FaucetError> {
52 match (self.config.version, &self.config.timestamp) {
53 (Some(v), _) => self.config.connection.open_at_version(v).await,
54 (None, Some(ts)) => self.config.connection.open_at_timestamp(ts).await,
55 (None, None) => self.config.connection.open().await,
56 }
57 }
58
59 async fn resolve(
62 &self,
63 table: &deltalake::DeltaTable,
64 ) -> Result<(Vec<DataFile>, SchemaRef, Vec<String>), FaucetError> {
65 let state = table
66 .snapshot()
67 .map_err(|e| FaucetError::Source(format!("delta: table has no snapshot: {e}")))?;
68 let arrow_schema = state.snapshot().arrow_schema();
69 let partition_cols = state.metadata().partition_columns().to_vec();
70
71 let paths = table
72 .get_files_by_partitions(&[])
73 .await
74 .map_err(|e| FaucetError::Source(format!("delta: could not list table files: {e}")))?;
75
76 let files = paths
77 .into_iter()
78 .map(|path| {
79 let partitions =
80 parse_partition_values(path.as_ref(), &partition_cols, &arrow_schema);
81 DataFile { path, partitions }
82 })
83 .collect();
84 Ok((files, arrow_schema, partition_cols))
85 }
86
87 fn data_projection(&self, partition_cols: &[String]) -> Option<Vec<String>> {
91 if self.config.columns.is_empty() {
92 return None;
93 }
94 Some(
95 self.config
96 .columns
97 .iter()
98 .filter(|c| !partition_cols.contains(c))
99 .cloned()
100 .collect(),
101 )
102 }
103}
104
105#[async_trait]
106impl faucet_core::Source for DeltaSource {
107 fn config_schema(&self) -> Value {
108 serde_json::to_value(faucet_core::schema_for!(DeltaSourceConfig))
109 .expect("schema serialization")
110 }
111
112 fn connector_name(&self) -> &'static str {
113 "delta"
114 }
115
116 fn dataset_uri(&self) -> String {
117 self.config.connection.redacted_uri()
118 }
119
120 async fn check(
121 &self,
122 ctx: &faucet_core::check::CheckContext,
123 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
124 use faucet_core::check::{CheckReport, Probe};
125 let started = std::time::Instant::now();
126 let probe =
129 match tokio::time::timeout(ctx.timeout, self.config.connection.open_optional()).await {
130 Ok(Ok(Some(_))) => Probe::pass("table", started.elapsed()),
131 Ok(Ok(None)) => Probe::fail_hint(
132 "table",
133 started.elapsed(),
134 format!(
135 "delta source: no Delta table at '{}'",
136 self.config.connection.redacted_uri()
137 ),
138 "Verify table_uri points at an existing Delta table.",
139 ),
140 Ok(Err(e)) => Probe::fail_hint(
141 "table",
142 started.elapsed(),
143 format!("delta source probe failed: {e}"),
144 "Verify table_uri, credentials, and object-store reachability.",
145 ),
146 Err(_) => Probe::fail_hint(
147 "table",
148 started.elapsed(),
149 format!("delta source probe timed out after {:?}", ctx.timeout),
150 "Check object-store network reachability.",
151 ),
152 };
153 Ok(CheckReport::single(probe))
154 }
155
156 async fn fetch_with_context(
157 &self,
158 _context: &HashMap<String, Value>,
159 ) -> Result<Vec<Value>, FaucetError> {
160 let mut out = Vec::new();
161 let mut stream = self.stream_pages(_context, self.config.batch_size);
162 while let Some(page) = stream.next().await {
163 out.extend(page?.records);
164 }
165 Ok(out)
166 }
167
168 fn stream_pages<'a>(
169 &'a self,
170 _context: &'a HashMap<String, Value>,
171 _batch_size: usize,
172 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
173 Box::pin(async_stream::try_stream! {
174 let table = self.open().await?;
175 let (files, _schema, partition_cols) = self.resolve(&table).await?;
176 let store = table.object_store();
177 let data_projection = self.data_projection(&partition_cols);
178 let requested: Option<&[String]> =
179 if self.config.columns.is_empty() { None } else { Some(&self.config.columns) };
180
181 tracing::info!(
182 files = files.len(),
183 uri = %self.config.connection.redacted_uri(),
184 "delta source resolved active files",
185 );
186
187 for file in &files {
188 let reader = ParquetObjectReader::new(store.clone(), file.path.clone());
189 let mut builder = ParquetRecordBatchStreamBuilder::new(reader).await.map_err(|e| {
190 FaucetError::Source(format!(
191 "delta: could not open data file '{}': {e}",
192 file.path
193 ))
194 })?;
195
196 if self.config.batch_size > 0 {
197 builder = builder.with_batch_size(self.config.batch_size);
198 }
199 if let Some(cols) = &data_projection {
200 let pq = builder.parquet_schema();
204 let present: Vec<&str> = cols
205 .iter()
206 .filter(|c| pq.columns().iter().any(|col| col.name() == c.as_str()))
207 .map(String::as_str)
208 .collect();
209 let mask = ProjectionMask::columns(pq, present.iter().copied());
210 builder = builder.with_projection(mask);
211 }
212
213 let mut batches = builder.build().map_err(|e| {
214 FaucetError::Source(format!(
215 "delta: could not build reader for '{}': {e}",
216 file.path
217 ))
218 })?;
219
220 while let Some(batch) = batches.next().await {
221 let batch = batch.map_err(|e| {
222 FaucetError::Source(format!("delta: read error in '{}': {e}", file.path))
223 })?;
224 let mut rows = record_batch_to_json(&batch)?;
225 if !rows.is_empty() {
226 for row in &mut rows {
227 merge_partitions(row, &file.partitions, requested);
228 }
229 yield StreamPage { records: rows, bookmark: None };
230 }
231 }
232 }
233 })
234 }
235}
236
237fn parse_partition_values(
241 path: &str,
242 partition_cols: &[String],
243 schema: &SchemaRef,
244) -> HashMap<String, Value> {
245 let mut out = HashMap::new();
246 if partition_cols.is_empty() {
247 return out;
248 }
249 for segment in path.split('/') {
250 if let Some((k, v)) = segment.split_once('=')
251 && partition_cols.iter().any(|c| c == k)
252 {
253 let decoded = percent_decode(v);
254 let dt = schema
255 .field_with_name(k)
256 .ok()
257 .map(|f| f.data_type().clone())
258 .unwrap_or(DataType::Utf8);
259 out.insert(k.to_string(), coerce_partition_value(&decoded, &dt));
260 }
261 }
262 out
263}
264
265const HIVE_NULL: &str = "__HIVE_DEFAULT_PARTITION__";
268
269fn coerce_partition_value(raw: &str, dt: &DataType) -> Value {
271 if raw == HIVE_NULL || raw.is_empty() {
272 return Value::Null;
273 }
274 match dt {
275 DataType::Boolean => match raw {
276 "true" => Value::Bool(true),
277 "false" => Value::Bool(false),
278 _ => Value::String(raw.to_string()),
279 },
280 DataType::Int8
281 | DataType::Int16
282 | DataType::Int32
283 | DataType::Int64
284 | DataType::UInt8
285 | DataType::UInt16
286 | DataType::UInt32
287 | DataType::UInt64 => raw
288 .parse::<i64>()
289 .map(|n| Value::Number(n.into()))
290 .unwrap_or_else(|_| Value::String(raw.to_string())),
291 DataType::Float32 | DataType::Float64 => {
292 serde_json::Number::from_f64(raw.parse::<f64>().unwrap_or(f64::NAN))
293 .map(Value::Number)
294 .unwrap_or_else(|| Value::String(raw.to_string()))
295 }
296 _ => Value::String(raw.to_string()),
298 }
299}
300
301fn merge_partitions(
305 row: &mut Value,
306 partitions: &HashMap<String, Value>,
307 requested: Option<&[String]>,
308) {
309 if let Value::Object(map) = row {
310 for (k, v) in partitions {
311 match requested {
312 Some(cols) if !cols.iter().any(|c| c == k) => continue,
313 _ => {
314 map.entry(k.clone()).or_insert_with(|| v.clone());
315 }
316 }
317 }
318 if let Some(cols) = requested {
319 map.retain(|k, _| cols.iter().any(|c| c == k));
320 }
321 }
322}
323
324fn percent_decode(s: &str) -> String {
327 if !s.contains('%') {
328 return s.to_string();
329 }
330 let bytes = s.as_bytes();
331 let mut out = Vec::with_capacity(bytes.len());
332 let mut i = 0;
333 while i < bytes.len() {
334 if bytes[i] == b'%' && i + 2 < bytes.len() {
335 let hi = (bytes[i + 1] as char).to_digit(16);
336 let lo = (bytes[i + 2] as char).to_digit(16);
337 if let (Some(h), Some(l)) = (hi, lo) {
338 out.push((h * 16 + l) as u8);
339 i += 3;
340 continue;
341 }
342 }
343 out.push(bytes[i]);
344 i += 1;
345 }
346 String::from_utf8_lossy(&out).into_owned()
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use arrow::datatypes::{Field, Schema};
353 use serde_json::json;
354 use std::sync::Arc;
355
356 fn schema() -> SchemaRef {
357 Arc::new(Schema::new(vec![
358 Field::new("id", DataType::Int64, true),
359 Field::new("dt", DataType::Utf8, true),
360 Field::new("region", DataType::Utf8, true),
361 Field::new("part", DataType::Int64, true),
362 ]))
363 }
364
365 #[test]
366 fn parses_typed_partition_values() {
367 let s = schema();
368 let cols = vec!["dt".to_string(), "part".to_string()];
369 let m = parse_partition_values("t/dt=2026-01-01/part=7/file.parquet", &cols, &s);
370 assert_eq!(m["dt"], json!("2026-01-01"));
371 assert_eq!(m["part"], json!(7));
372 }
373
374 #[test]
375 fn hive_null_becomes_json_null() {
376 let s = schema();
377 let cols = vec!["region".to_string()];
378 let m = parse_partition_values("t/region=__HIVE_DEFAULT_PARTITION__/f.parquet", &cols, &s);
379 assert_eq!(m["region"], Value::Null);
380 }
381
382 #[test]
383 fn percent_decoding_of_partition_values() {
384 let s = schema();
385 let cols = vec!["region".to_string()];
386 let m = parse_partition_values("t/region=a%2Fb/f.parquet", &cols, &s);
387 assert_eq!(m["region"], json!("a/b"));
388 }
389
390 #[test]
391 fn no_partition_columns_is_empty() {
392 let s = schema();
393 assert!(parse_partition_values("t/f.parquet", &[], &s).is_empty());
394 }
395
396 #[test]
397 fn merge_injects_and_projects() {
398 let mut row = json!({"id": 1});
399 let mut parts = HashMap::new();
400 parts.insert("dt".to_string(), json!("2026-01-01"));
401 merge_partitions(&mut row, &parts, None);
402 assert_eq!(row["dt"], json!("2026-01-01"));
403 assert_eq!(row["id"], json!(1));
404
405 let mut row2 = json!({"id": 1, "name": "x"});
407 let cols = vec!["id".to_string(), "dt".to_string()];
408 merge_partitions(&mut row2, &parts, Some(&cols));
409 assert_eq!(row2["id"], json!(1));
410 assert_eq!(row2["dt"], json!("2026-01-01"));
411 assert!(row2.get("name").is_none());
412 }
413
414 #[test]
415 fn coerce_bool_and_float() {
416 assert_eq!(
417 coerce_partition_value("true", &DataType::Boolean),
418 json!(true)
419 );
420 assert_eq!(
421 coerce_partition_value("1.5", &DataType::Float64),
422 json!(1.5)
423 );
424 assert_eq!(coerce_partition_value("x", &DataType::Int64), json!("x"));
425 assert_eq!(
427 coerce_partition_value("maybe", &DataType::Boolean),
428 json!("maybe")
429 );
430 assert_eq!(
431 coerce_partition_value("nan-ish", &DataType::Float32),
432 json!("nan-ish")
433 );
434 assert_eq!(coerce_partition_value("", &DataType::Utf8), Value::Null);
436 assert_eq!(
437 coerce_partition_value(HIVE_NULL, &DataType::Int64),
438 Value::Null
439 );
440 assert_eq!(
442 coerce_partition_value("2026-01-01", &DataType::Date32),
443 json!("2026-01-01")
444 );
445 }
446
447 #[tokio::test]
448 async fn source_trait_metadata_methods() {
449 use faucet_core::Source;
450 let src = DeltaSource::new(DeltaSourceConfig::new("file:///tmp/delta_src_meta"))
451 .await
452 .unwrap();
453 assert_eq!(src.connector_name(), "delta");
454 assert_eq!(src.dataset_uri(), "file:///tmp/delta_src_meta");
455 assert!(src.config_schema().is_object());
456 }
457
458 #[tokio::test]
459 async fn fetch_missing_table_errors() {
460 use faucet_core::Source;
461 let dir = tempfile::tempdir().unwrap();
462 let uri = dir
463 .path()
464 .join("no_such_table")
465 .to_string_lossy()
466 .into_owned();
467 let src = DeltaSource::new(DeltaSourceConfig::new(&uri))
468 .await
469 .unwrap();
470 let err = src.fetch_with_context(&HashMap::new()).await.unwrap_err();
472 assert!(matches!(err, FaucetError::Source(_)), "{err}");
473 }
474}