Skip to main content

delta_arrow_reader/
datafusion_provider.rs

1//! Optional DataFusion table-provider and registration surface.
2
3use 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/// DataFusion-specific scan settings for one provider.
32#[derive(Debug, Clone, Default)]
33pub struct DeltaDataFusionScanOptions {
34    /// Reader execution settings used by each provider scan.
35    pub execution_options: DeltaReaderExecutionOptions,
36    /// Optional explicit scan partition target.
37    pub target_partitions: Option<usize>,
38}
39
40/// Immutable DataFusion provider for one loaded Delta table snapshot.
41///
42/// ```no_run
43/// use std::sync::Arc;
44/// use datafusion::prelude::SessionContext;
45/// use delta_arrow_reader::{
46///     DeltaDataFusionScanOptions, DeltaTableBuilder, DeltaTableProvider,
47/// };
48///
49/// # fn build_provider() -> Result<(), Box<dyn std::error::Error>> {
50/// let table = DeltaTableBuilder::new("/tmp/example-delta-table").load()?;
51/// let provider = DeltaTableProvider::try_new(
52///     table,
53///     DeltaDataFusionScanOptions::default(),
54/// )?;
55/// SessionContext::new().register_table("orders", Arc::new(provider))?;
56/// # Ok(())
57/// # }
58/// ```
59#[derive(Clone)]
60pub struct DeltaTableProvider {
61    table: DeltaTable,
62    schema: SchemaRef,
63    options: DeltaDataFusionScanOptions,
64    source_name: Option<String>,
65}
66
67impl DeltaTableProvider {
68    /// Creates a provider after validating its options and table protocol.
69    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/// Result of registering one loaded Delta table in a DataFusion context.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct RegisteredDeltaTable {
318    /// Caller-supplied DataFusion table name.
319    pub name: String,
320    /// Loaded Delta snapshot version.
321    pub version: u64,
322}
323
324/// Registers one loaded Delta table in a DataFusion session.
325///
326/// Registration performs no scan. Existing registrations are preserved and
327/// reported through [`DeltaReaderError`].
328///
329/// ```no_run
330/// use datafusion::prelude::SessionContext;
331/// use delta_arrow_reader::{
332///     DeltaDataFusionScanOptions, DeltaTableBuilder, register_delta_table,
333/// };
334///
335/// # fn register() -> Result<(), Box<dyn std::error::Error>> {
336/// let context = SessionContext::new();
337/// let table = DeltaTableBuilder::new("/tmp/example-delta-table").load()?;
338/// let registered = register_delta_table(
339///     &context,
340///     "orders",
341///     table,
342///     DeltaDataFusionScanOptions::default(),
343/// )?;
344/// assert_eq!(registered.name, "orders");
345/// # Ok(())
346/// # }
347/// ```
348pub 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}