1use std::{collections::HashSet, fmt, sync::Arc};
4
5use arrow::datatypes::{DataType, Schema, SchemaRef};
6use async_trait::async_trait;
7use datafusion::{
8 catalog::Session,
9 common::{DataFusionError, Result as DataFusionResult},
10 datasource::{TableProvider, TableType, physical_plan::wrap_partition_type_in_dict},
11 execution::context::SessionContext,
12 logical_expr::{Expr, TableProviderFilterPushDown},
13 physical_plan::ExecutionPlan,
14};
15
16use crate::{
17 DeltaReaderBackend, DeltaReaderError, DeltaReaderExecutionOptions, DeltaTable,
18 datafusion_execution::create_datafusion_execution_plan,
19 datafusion_planning::{
20 DataFusionFilterCapabilities, plan_datafusion_filters, plan_datafusion_scan,
21 },
22 kernel::delta_predicate_to_kernel_pruning,
23 planning::{
24 DeltaScanPartitionTargetOptions, plan_row_predicate, plan_scan, validate_backend_available,
25 },
26 transform::schema_with_view_types,
27};
28
29const TRACING_TARGET: &str = "delta_arrow_reader::datafusion";
30
31#[derive(Debug, Clone, Default)]
33pub struct DeltaDataFusionScanOptions {
34 pub execution_options: DeltaReaderExecutionOptions,
36 pub target_partitions: Option<usize>,
38}
39
40#[derive(Clone)]
60pub struct DeltaTableProvider {
61 table: DeltaTable,
62 schema: SchemaRef,
63 options: DeltaDataFusionScanOptions,
64 source_name: Option<String>,
65}
66
67impl DeltaTableProvider {
68 pub fn try_new(
70 table: DeltaTable,
71 options: DeltaDataFusionScanOptions,
72 ) -> Result<Self, DeltaReaderError> {
73 Self::try_new_with_source_name(table, options, None)
74 }
75
76 fn try_new_with_source_name(
77 table: DeltaTable,
78 options: DeltaDataFusionScanOptions,
79 source_name: Option<String>,
80 ) -> Result<Self, DeltaReaderError> {
81 options.execution_options.validate()?;
82 validate_backend_available(options.execution_options)?;
83 if options.target_partitions == Some(0) {
84 return Err(DeltaReaderError::InvalidConfiguration {
85 reason: "scan_partition_target_must_be_positive",
86 });
87 }
88 table.validate_protocol()?;
89 let partition_columns = table.partition_columns().iter().cloned().collect();
90 let schema = datafusion_schema(table.schema(), &partition_columns);
91 Ok(Self {
92 table,
93 schema,
94 options,
95 source_name,
96 })
97 }
98
99 fn plan(
100 &self,
101 state: &dyn Session,
102 projection: Option<&[usize]>,
103 filters: &[Expr],
104 ) -> Result<(Arc<dyn ExecutionPlan>, usize), DeltaReaderError> {
105 let _planning = tracing::debug_span!(
106 target: "delta_arrow_reader::profile",
107 "Delta scan planning"
108 )
109 .entered();
110 let partition_columns = self
111 .table
112 .partition_columns()
113 .iter()
114 .cloned()
115 .collect::<HashSet<_>>();
116 let filter_refs = filters.iter().collect::<Vec<_>>();
117 let mut planning = plan_datafusion_scan(
118 self.table.schema(),
119 &partition_columns,
120 projection,
121 &filter_refs,
122 DataFusionFilterCapabilities {
123 exact_predicate_evaluation: self.options.execution_options.reader_backend()
124 == DeltaReaderBackend::NativeAsync,
125 },
126 )?;
127 if planning
128 .filters
129 .decisions
130 .iter()
131 .any(|decision| decision.pushdown == TableProviderFilterPushDown::Unsupported)
132 {
133 return Err(DeltaReaderError::UnsupportedPredicate {
134 reason: "datafusion_scan_contains_unsupported_filter",
135 });
136 }
137 let physical_projection = planning.projection.physical_projection.clone();
138 let hidden_columns = planning.projection.hidden_columns.clone();
139 let kernel_predicate = planning
140 .filters
141 .predicate
142 .as_ref()
143 .map(|predicate| {
144 delta_predicate_to_kernel_pruning(predicate).ok_or(
145 DeltaReaderError::UnsupportedPredicate {
146 reason: "datafusion_predicate_not_kernel_safe",
147 },
148 )
149 })
150 .transpose()?;
151 let row_predicate = planning
152 .filters
153 .row_predicate
154 .as_ref()
155 .map(|predicate| {
156 delta_predicate_to_kernel_pruning(predicate).ok_or(
157 DeltaReaderError::UnsupportedPredicate {
158 reason: "exact_row_predicate_not_kernel_safe",
159 },
160 )
161 })
162 .transpose()?;
163 let row_predicate = plan_row_predicate(
164 self.table.snapshot(),
165 physical_projection.as_deref(),
166 &hidden_columns,
167 row_predicate,
168 )?;
169 let mut core = plan_scan(
170 self.table.snapshot(),
171 physical_projection.as_deref(),
172 &hidden_columns,
173 kernel_predicate,
174 planning.filters.requires_statistics,
175 self.options.execution_options,
176 DeltaScanPartitionTargetOptions {
177 explicit_target_partitions: self.options.target_partitions,
178 caller_target_partitions: Some(state.config().target_partitions()),
179 },
180 )?;
181 core.logical_schema = datafusion_schema(&core.logical_schema, &partition_columns);
182 core.physical_schema = datafusion_schema(&core.physical_schema, &partition_columns);
183 core.projected_schema = datafusion_schema(&core.projected_schema, &partition_columns);
184 planning.projection.output_schema =
185 datafusion_schema(&planning.projection.output_schema, &partition_columns);
186 let partition_count = core.partitions.len();
187 let plan = {
188 let _setup = tracing::debug_span!(
189 target: "delta_arrow_reader::profile",
190 "Delta scan execution setup"
191 )
192 .entered();
193 create_datafusion_execution_plan(
194 core,
195 planning,
196 row_predicate,
197 self.source_name.clone(),
198 )
199 };
200 Ok((plan, partition_count))
201 }
202}
203
204fn datafusion_schema(schema: &Schema, partition_columns: &HashSet<String>) -> SchemaRef {
205 let view_schema = schema_with_view_types(schema);
206 Arc::new(Schema::new_with_metadata(
207 schema
208 .fields()
209 .iter()
210 .zip(view_schema.fields())
211 .map(|(logical, view)| {
212 if partition_columns.contains(logical.name())
213 && matches!(
214 logical.data_type(),
215 DataType::Utf8
216 | DataType::LargeUtf8
217 | DataType::Binary
218 | DataType::LargeBinary
219 )
220 {
221 Arc::new(
222 logical
223 .as_ref()
224 .clone()
225 .with_data_type(wrap_partition_type_in_dict(
226 logical.data_type().clone(),
227 )),
228 )
229 } else {
230 Arc::clone(view)
231 }
232 })
233 .collect::<Vec<_>>(),
234 schema.metadata().clone(),
235 ))
236}
237
238impl fmt::Debug for DeltaTableProvider {
239 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
240 formatter
241 .debug_struct("DeltaTableProvider")
242 .field("snapshot_version", &self.table.version())
243 .finish_non_exhaustive()
244 }
245}
246
247#[async_trait]
248impl TableProvider for DeltaTableProvider {
249 fn schema(&self) -> SchemaRef {
250 Arc::clone(&self.schema)
251 }
252
253 fn table_type(&self) -> TableType {
254 TableType::Base
255 }
256
257 async fn scan(
258 &self,
259 state: &dyn Session,
260 projection: Option<&Vec<usize>>,
261 filters: &[Expr],
262 _limit: Option<usize>,
263 ) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
264 match self.plan(state, projection.map(Vec::as_slice), filters) {
265 Ok((plan, partition_count)) => {
266 tracing::debug!(
267 target: TRACING_TARGET,
268 event = "provider_scan.planned",
269 snapshot_version = self.table.version(),
270 partition_count,
271 backend = ?self.options.execution_options.reader_backend(),
272 outcome = "planned"
273 );
274 Ok(plan)
275 }
276 Err(error) => {
277 trace_failure(
278 "provider_scan.failed",
279 self.table.version(),
280 self.options.execution_options.reader_backend(),
281 &error,
282 );
283 Err(DataFusionError::External(Box::new(error)))
284 }
285 }
286 }
287
288 fn supports_filters_pushdown(
289 &self,
290 filters: &[&Expr],
291 ) -> DataFusionResult<Vec<TableProviderFilterPushDown>> {
292 let partition_columns = self
293 .table
294 .partition_columns()
295 .iter()
296 .cloned()
297 .collect::<HashSet<_>>();
298 let planning = plan_datafusion_filters(
299 self.table.schema(),
300 &partition_columns,
301 filters,
302 DataFusionFilterCapabilities {
303 exact_predicate_evaluation: self.options.execution_options.reader_backend()
304 == DeltaReaderBackend::NativeAsync,
305 },
306 );
307 Ok(planning
308 .decisions
309 .iter()
310 .map(|decision| decision.pushdown.clone())
311 .collect())
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct RegisteredDeltaTable {
318 pub name: String,
320 pub version: u64,
322}
323
324pub fn register_delta_table(
349 context: &SessionContext,
350 name: impl Into<String>,
351 table: DeltaTable,
352 options: DeltaDataFusionScanOptions,
353) -> Result<RegisteredDeltaTable, DeltaReaderError> {
354 let name = name.into();
355 let version = table.version();
356 let backend = options.execution_options.reader_backend();
357 let result = (|| {
358 validate_registration_name(&name)?;
359 let provider =
360 DeltaTableProvider::try_new_with_source_name(table, options, Some(name.clone()))?;
361 context
362 .register_table(name.as_str(), Arc::new(provider))
363 .map_err(|source| DeltaReaderError::DataFusionAdapter {
364 reason: "table_registration_failed",
365 source: Box::new(source),
366 })?;
367 Ok(RegisteredDeltaTable { name, version })
368 })();
369 match result {
370 Ok(registered) => {
371 tracing::debug!(
372 target: TRACING_TARGET,
373 event = "provider_registration.registered",
374 snapshot_version = version,
375 partition_count = tracing::field::Empty,
376 backend = ?backend,
377 outcome = "registered"
378 );
379 Ok(registered)
380 }
381 Err(error) => {
382 trace_failure("provider_registration.failed", version, backend, &error);
383 Err(error)
384 }
385 }
386}
387
388fn validate_registration_name(name: &str) -> Result<(), DeltaReaderError> {
389 let mut chars = name.chars();
390 let valid = chars
391 .next()
392 .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
393 && chars.all(|value| value == '_' || value.is_ascii_alphanumeric());
394 if !valid || is_reserved_sql_keyword(name) {
395 let reason = if name.is_empty() {
396 "table_registration_name_empty"
397 } else {
398 "table_registration_name_invalid"
399 };
400 return Err(DeltaReaderError::DataFusionAdapter {
401 reason,
402 source: Box::new(DataFusionError::Plan(reason.to_owned())),
403 });
404 }
405 Ok(())
406}
407
408fn is_reserved_sql_keyword(name: &str) -> bool {
409 const KEYWORDS: &[&str] = &[
410 "all",
411 "alter",
412 "analyze",
413 "and",
414 "anti",
415 "as",
416 "asof",
417 "by",
418 "case",
419 "connect",
420 "cross",
421 "delete",
422 "distinct",
423 "distribute",
424 "drop",
425 "else",
426 "end",
427 "except",
428 "exists",
429 "explain",
430 "false",
431 "fetch",
432 "for",
433 "format",
434 "from",
435 "full",
436 "global",
437 "group",
438 "having",
439 "in",
440 "inner",
441 "insert",
442 "intersect",
443 "into",
444 "is",
445 "join",
446 "lateral",
447 "left",
448 "like",
449 "limit",
450 "minus",
451 "natural",
452 "not",
453 "null",
454 "offset",
455 "on",
456 "open",
457 "or",
458 "order",
459 "outer",
460 "partition",
461 "pivot",
462 "prewhere",
463 "qualify",
464 "returning",
465 "right",
466 "sample",
467 "select",
468 "semi",
469 "set",
470 "settings",
471 "sort",
472 "start",
473 "table",
474 "tablesample",
475 "then",
476 "top",
477 "true",
478 "union",
479 "unpivot",
480 "update",
481 "using",
482 "values",
483 "view",
484 "when",
485 "where",
486 "window",
487 "with",
488 ];
489 KEYWORDS
490 .iter()
491 .any(|keyword| name.eq_ignore_ascii_case(keyword))
492}
493
494fn trace_failure(
495 event: &'static str,
496 snapshot_version: u64,
497 backend: DeltaReaderBackend,
498 error: &DeltaReaderError,
499) {
500 tracing::debug!(
501 target: TRACING_TARGET,
502 event,
503 snapshot_version,
504 partition_count = tracing::field::Empty,
505 backend = ?backend,
506 outcome = "failed",
507 error_variant = error.as_str(),
508 error_phase = error.phase().as_str()
509 );
510}
511
512#[cfg(test)]
513mod tests {
514 use std::collections::{HashMap, HashSet};
515
516 use arrow::datatypes::{DataType, Field, Schema};
517
518 use super::{datafusion_schema, validate_registration_name};
519
520 #[test]
521 fn registration_names_preserve_the_frozen_unquoted_identifier_boundary() {
522 for name in ["orders", "_customers", "Regions_2026", "line_items"] {
523 assert!(validate_registration_name(name).is_ok(), "{name}");
524 }
525
526 for name in [
527 "",
528 "2026_orders",
529 "orders.latest",
530 "line-items",
531 "line items",
532 "\"orders\"",
533 "orders$",
534 "ordérs",
535 "select",
536 "FROM",
537 "Join",
538 "where",
539 "table",
540 ] {
541 assert!(validate_registration_name(name).is_err(), "{name}");
542 }
543 }
544
545 #[test]
546 fn datafusion_schema_uses_views_except_for_dictionary_partitions() {
547 let field_metadata = HashMap::from([("field-key".to_owned(), "field-value".to_owned())]);
548 let schema_metadata = HashMap::from([("schema-key".to_owned(), "schema-value".to_owned())]);
549 let schema = Schema::new_with_metadata(
550 vec![
551 Field::new("text", DataType::Utf8, true).with_metadata(field_metadata.clone()),
552 Field::new("payload", DataType::Binary, true),
553 Field::new("region", DataType::Utf8, true),
554 Field::new("partition_payload", DataType::LargeBinary, true),
555 Field::new("id", DataType::Int32, false),
556 ],
557 schema_metadata.clone(),
558 );
559 let partitions = HashSet::from(["region".to_owned(), "partition_payload".to_owned()]);
560
561 let mapped = datafusion_schema(&schema, &partitions);
562
563 assert_eq!(
564 mapped.as_ref(),
565 &Schema::new_with_metadata(
566 vec![
567 Field::new("text", DataType::Utf8View, true).with_metadata(field_metadata),
568 Field::new("payload", DataType::BinaryView, true),
569 Field::new(
570 "region",
571 DataType::Dictionary(Box::new(DataType::UInt16), Box::new(DataType::Utf8)),
572 true,
573 ),
574 Field::new(
575 "partition_payload",
576 DataType::Dictionary(
577 Box::new(DataType::UInt16),
578 Box::new(DataType::LargeBinary),
579 ),
580 true,
581 ),
582 Field::new("id", DataType::Int32, false),
583 ],
584 schema_metadata,
585 )
586 );
587 }
588}