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 #[cfg(feature = "arrow")]
240 fn supports_columnar(&self) -> bool {
241 true
242 }
243
244 #[cfg(feature = "arrow")]
250 fn stream_batches<'a>(
251 &'a self,
252 _context: &'a HashMap<String, Value>,
253 _batch_size: usize,
254 ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::ColumnarPage, FaucetError>> + Send + 'a>>
255 {
256 Box::pin(async_stream::try_stream! {
257 let table = self.open().await?;
258 let (files, schema, partition_cols) = self.resolve(&table).await?;
259 let store = table.object_store();
260 let data_projection = self.data_projection(&partition_cols);
261 let requested: Option<&[String]> =
262 if self.config.columns.is_empty() { None } else { Some(&self.config.columns) };
263
264 for file in &files {
265 let reader = ParquetObjectReader::new(store.clone(), file.path.clone());
266 let mut builder = ParquetRecordBatchStreamBuilder::new(reader).await.map_err(|e| {
267 FaucetError::Source(format!("delta: could not open data file '{}': {e}", file.path))
268 })?;
269 if self.config.batch_size > 0 {
270 builder = builder.with_batch_size(self.config.batch_size);
271 }
272 if let Some(cols) = &data_projection {
273 let pq = builder.parquet_schema();
274 let present: Vec<&str> = cols
275 .iter()
276 .filter(|c| pq.columns().iter().any(|col| col.name() == c.as_str()))
277 .map(String::as_str)
278 .collect();
279 let mask = ProjectionMask::columns(pq, present.iter().copied());
280 builder = builder.with_projection(mask);
281 }
282 let mut batches = builder.build().map_err(|e| {
283 FaucetError::Source(format!("delta: could not build reader for '{}': {e}", file.path))
284 })?;
285 while let Some(batch) = batches.next().await {
286 let batch = batch.map_err(|e| {
287 FaucetError::Source(format!("delta: read error in '{}': {e}", file.path))
288 })?;
289 if batch.num_rows() == 0 {
290 continue;
291 }
292 let batch = append_partition_columns(batch, &file.partitions, &schema, requested)?;
293 yield faucet_core::ColumnarPage { batch, bookmark: None };
294 }
295 }
296 })
297 }
298}
299
300#[cfg(feature = "arrow")]
307fn append_partition_columns(
308 batch: arrow::array::RecordBatch,
309 partitions: &HashMap<String, Value>,
310 table_schema: &SchemaRef,
311 requested: Option<&[String]>,
312) -> Result<arrow::array::RecordBatch, FaucetError> {
313 use arrow::datatypes::{Field, Schema};
314 use std::sync::Arc;
315
316 if partitions.is_empty() {
317 return Ok(batch);
318 }
319 let in_schema = batch.schema();
320 let n = batch.num_rows();
321 let mut fields: Vec<Arc<Field>> = in_schema.fields().iter().cloned().collect();
322 let mut columns = batch.columns().to_vec();
323
324 let mut keys: Vec<&String> = partitions.keys().collect();
326 keys.sort();
327 for k in keys {
328 if let Some(cols) = requested
329 && !cols.iter().any(|c| c == k)
330 {
331 continue;
332 }
333 if in_schema.field_with_name(k).is_ok() {
334 continue; }
336 let field = table_schema
337 .field_with_name(k)
338 .cloned()
339 .unwrap_or_else(|_| Field::new(k, arrow::datatypes::DataType::Utf8, true));
340 let one_schema = Arc::new(Schema::new(vec![field.clone().with_nullable(true)]));
341 let mut obj = serde_json::Map::new();
342 obj.insert(k.clone(), partitions[k].clone());
343 let rows = vec![Value::Object(obj); n];
344 let col_batch = faucet_core::values_to_record_batch(&rows, one_schema)?;
345 fields.push(Arc::new(field));
346 columns.push(col_batch.column(0).clone());
347 }
348
349 arrow::array::RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
350 .map_err(|e| FaucetError::Source(format!("delta: assembling columnar batch failed: {e}")))
351}
352
353fn parse_partition_values(
357 path: &str,
358 partition_cols: &[String],
359 schema: &SchemaRef,
360) -> HashMap<String, Value> {
361 let mut out = HashMap::new();
362 if partition_cols.is_empty() {
363 return out;
364 }
365 for segment in path.split('/') {
366 if let Some((k, v)) = segment.split_once('=')
367 && partition_cols.iter().any(|c| c == k)
368 {
369 let decoded = percent_decode(v);
370 let dt = schema
371 .field_with_name(k)
372 .ok()
373 .map(|f| f.data_type().clone())
374 .unwrap_or(DataType::Utf8);
375 out.insert(k.to_string(), coerce_partition_value(&decoded, &dt));
376 }
377 }
378 out
379}
380
381const HIVE_NULL: &str = "__HIVE_DEFAULT_PARTITION__";
384
385fn coerce_partition_value(raw: &str, dt: &DataType) -> Value {
387 if raw == HIVE_NULL || raw.is_empty() {
388 return Value::Null;
389 }
390 match dt {
391 DataType::Boolean => match raw {
392 "true" => Value::Bool(true),
393 "false" => Value::Bool(false),
394 _ => Value::String(raw.to_string()),
395 },
396 DataType::Int8
397 | DataType::Int16
398 | DataType::Int32
399 | DataType::Int64
400 | DataType::UInt8
401 | DataType::UInt16
402 | DataType::UInt32
403 | DataType::UInt64 => raw
404 .parse::<i64>()
405 .map(|n| Value::Number(n.into()))
406 .unwrap_or_else(|_| Value::String(raw.to_string())),
407 DataType::Float32 | DataType::Float64 => {
408 serde_json::Number::from_f64(raw.parse::<f64>().unwrap_or(f64::NAN))
409 .map(Value::Number)
410 .unwrap_or_else(|| Value::String(raw.to_string()))
411 }
412 _ => Value::String(raw.to_string()),
414 }
415}
416
417fn merge_partitions(
421 row: &mut Value,
422 partitions: &HashMap<String, Value>,
423 requested: Option<&[String]>,
424) {
425 if let Value::Object(map) = row {
426 for (k, v) in partitions {
427 match requested {
428 Some(cols) if !cols.iter().any(|c| c == k) => continue,
429 _ => {
430 map.entry(k.clone()).or_insert_with(|| v.clone());
431 }
432 }
433 }
434 if let Some(cols) = requested {
435 map.retain(|k, _| cols.iter().any(|c| c == k));
436 }
437 }
438}
439
440fn percent_decode(s: &str) -> String {
443 if !s.contains('%') {
444 return s.to_string();
445 }
446 let bytes = s.as_bytes();
447 let mut out = Vec::with_capacity(bytes.len());
448 let mut i = 0;
449 while i < bytes.len() {
450 if bytes[i] == b'%' && i + 2 < bytes.len() {
451 let hi = (bytes[i + 1] as char).to_digit(16);
452 let lo = (bytes[i + 2] as char).to_digit(16);
453 if let (Some(h), Some(l)) = (hi, lo) {
454 out.push((h * 16 + l) as u8);
455 i += 3;
456 continue;
457 }
458 }
459 out.push(bytes[i]);
460 i += 1;
461 }
462 String::from_utf8_lossy(&out).into_owned()
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use arrow::datatypes::{Field, Schema};
469 use serde_json::json;
470 use std::sync::Arc;
471
472 fn schema() -> SchemaRef {
473 Arc::new(Schema::new(vec![
474 Field::new("id", DataType::Int64, true),
475 Field::new("dt", DataType::Utf8, true),
476 Field::new("region", DataType::Utf8, true),
477 Field::new("part", DataType::Int64, true),
478 ]))
479 }
480
481 #[test]
482 fn parses_typed_partition_values() {
483 let s = schema();
484 let cols = vec!["dt".to_string(), "part".to_string()];
485 let m = parse_partition_values("t/dt=2026-01-01/part=7/file.parquet", &cols, &s);
486 assert_eq!(m["dt"], json!("2026-01-01"));
487 assert_eq!(m["part"], json!(7));
488 }
489
490 #[test]
491 fn hive_null_becomes_json_null() {
492 let s = schema();
493 let cols = vec!["region".to_string()];
494 let m = parse_partition_values("t/region=__HIVE_DEFAULT_PARTITION__/f.parquet", &cols, &s);
495 assert_eq!(m["region"], Value::Null);
496 }
497
498 #[test]
499 fn percent_decoding_of_partition_values() {
500 let s = schema();
501 let cols = vec!["region".to_string()];
502 let m = parse_partition_values("t/region=a%2Fb/f.parquet", &cols, &s);
503 assert_eq!(m["region"], json!("a/b"));
504 }
505
506 #[test]
507 fn no_partition_columns_is_empty() {
508 let s = schema();
509 assert!(parse_partition_values("t/f.parquet", &[], &s).is_empty());
510 }
511
512 #[test]
513 fn merge_injects_and_projects() {
514 let mut row = json!({"id": 1});
515 let mut parts = HashMap::new();
516 parts.insert("dt".to_string(), json!("2026-01-01"));
517 merge_partitions(&mut row, &parts, None);
518 assert_eq!(row["dt"], json!("2026-01-01"));
519 assert_eq!(row["id"], json!(1));
520
521 let mut row2 = json!({"id": 1, "name": "x"});
523 let cols = vec!["id".to_string(), "dt".to_string()];
524 merge_partitions(&mut row2, &parts, Some(&cols));
525 assert_eq!(row2["id"], json!(1));
526 assert_eq!(row2["dt"], json!("2026-01-01"));
527 assert!(row2.get("name").is_none());
528 }
529
530 #[test]
531 fn coerce_bool_and_float() {
532 assert_eq!(
533 coerce_partition_value("true", &DataType::Boolean),
534 json!(true)
535 );
536 assert_eq!(
537 coerce_partition_value("1.5", &DataType::Float64),
538 json!(1.5)
539 );
540 assert_eq!(coerce_partition_value("x", &DataType::Int64), json!("x"));
541 assert_eq!(
543 coerce_partition_value("maybe", &DataType::Boolean),
544 json!("maybe")
545 );
546 assert_eq!(
547 coerce_partition_value("nan-ish", &DataType::Float32),
548 json!("nan-ish")
549 );
550 assert_eq!(coerce_partition_value("", &DataType::Utf8), Value::Null);
552 assert_eq!(
553 coerce_partition_value(HIVE_NULL, &DataType::Int64),
554 Value::Null
555 );
556 assert_eq!(
558 coerce_partition_value("2026-01-01", &DataType::Date32),
559 json!("2026-01-01")
560 );
561 }
562
563 #[tokio::test]
564 async fn source_trait_metadata_methods() {
565 use faucet_core::Source;
566 let src = DeltaSource::new(DeltaSourceConfig::new("file:///tmp/delta_src_meta"))
567 .await
568 .unwrap();
569 assert_eq!(src.connector_name(), "delta");
570 assert_eq!(src.dataset_uri(), "file:///tmp/delta_src_meta");
571 assert!(src.config_schema().is_object());
572 }
573
574 #[tokio::test]
575 async fn fetch_missing_table_errors() {
576 use faucet_core::Source;
577 let dir = tempfile::tempdir().unwrap();
578 let uri = dir
579 .path()
580 .join("no_such_table")
581 .to_string_lossy()
582 .into_owned();
583 let src = DeltaSource::new(DeltaSourceConfig::new(&uri))
584 .await
585 .unwrap();
586 let err = src.fetch_with_context(&HashMap::new()).await.unwrap_err();
588 assert!(matches!(err, FaucetError::Source(_)), "{err}");
589 }
590}