Skip to main content

hudi_datafusion/
lib.rs

1/*
2 * Licensed to the Apache Software Foundation (ASF) under one
3 * or more contributor license agreements.  See the NOTICE file
4 * distributed with this work for additional information
5 * regarding copyright ownership.  The ASF licenses this file
6 * to you under the Apache License, Version 2.0 (the
7 * "License"); you may not use this file except in compliance
8 * with the License.  You may obtain a copy of the License at
9 *
10 *   http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing,
13 * software distributed under the License is distributed on an
14 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15 * KIND, either express or implied.  See the License for the
16 * specific language governing permissions and limitations
17 * under the License.
18 */
19
20//! [Apache DataFusion](https://datafusion.apache.org) integration for Apache Hudi tables.
21//!
22//! [`HudiDataSource`] is a DataFusion `TableProvider` to register with a `SessionContext`;
23//! [`HudiTableFactory`] serves `CREATE EXTERNAL TABLE ... STORED AS HUDI`. Both read through
24//! `hudi-core` and accept the same table options.
25//!
26//! Applications usually reach this crate through the [`hudi`](https://docs.rs/hudi) crate's
27//! `datafusion` feature. The [Python and Rust quick start guide][quick start guide] shows
28//! DataFusion queries on a hudi-rs table, and the [`hudi`](https://docs.rs/hudi) crate docs list
29//! every resource (API references, quick starts, examples, specs).
30//!
31//! [quick start guide]: https://hudi.apache.org/docs/python-rust-quick-start-guide
32
33#![warn(missing_docs)]
34
35pub(crate) mod hudi_exec;
36pub(crate) mod util;
37
38use std::collections::HashMap;
39use std::error::Error;
40use std::fmt::Debug;
41use std::sync::Arc;
42
43use arrow_schema::{Schema, SchemaRef};
44use async_trait::async_trait;
45use datafusion::catalog::{Session, TableProviderFactory};
46use datafusion::datasource::TableProvider;
47use datafusion::datasource::listing::PartitionedFile;
48use datafusion::datasource::object_store::ObjectStoreUrl;
49use datafusion::datasource::physical_plan::FileGroup;
50use datafusion::datasource::physical_plan::FileScanConfigBuilder;
51use datafusion::datasource::physical_plan::parquet::source::ParquetSource;
52use datafusion::datasource::source::DataSourceExec;
53use datafusion::error::Result;
54use datafusion::logical_expr::Operator;
55use datafusion::physical_plan::ExecutionPlan;
56use datafusion_common::DFSchema;
57use datafusion_common::DataFusionError::Execution;
58use datafusion_common::config::TableParquetOptions;
59use datafusion_common::stats::Precision;
60use datafusion_common::{DataFusionError, Statistics};
61use datafusion_expr::utils::split_conjunction;
62use datafusion_expr::{CreateExternalTable, Expr, TableProviderFilterPushDown, TableType};
63use log::warn;
64
65use crate::hudi_exec::HudiScanExec;
66use crate::util::expr::exprs_to_filters;
67use hudi_core::config::read::HudiReadConfig::{
68    FileSliceReadConcurrency, InputPartitions, UseReadOptimizedMode,
69};
70use hudi_core::config::table::{BaseFileFormatValue, HudiTableConfig};
71use hudi_core::config::util::empty_options;
72use hudi_core::config::{ConfigParser, HudiConfigs};
73use hudi_core::file_group::file_slice::FileSlice;
74use hudi_core::storage::util::{get_scheme_authority, join_url_segments};
75use hudi_core::table::{ReadOptions, Table as HudiTable};
76
77fn default_file_slice_read_concurrency() -> usize {
78    match FileSliceReadConcurrency.default_value() {
79        Some(value) => value.into(),
80        None => unreachable!("FileSliceReadConcurrency has a default value defined in hudi-core"),
81    }
82}
83
84pub(crate) fn inexact_usize_from_u64(value: u64) -> Precision<usize> {
85    match usize::try_from(value) {
86        Ok(value) => Precision::Inexact(value),
87        Err(_) => Precision::Absent,
88    }
89}
90
91pub(crate) fn external_error<E>(context: impl Into<String>, error: E) -> DataFusionError
92where
93    E: Error + Send + Sync + 'static,
94{
95    DataFusionError::External(Box::new(error)).context(context)
96}
97
98fn filter_field_matches_partition_column(filter_field: &str, partition_column: &str) -> bool {
99    filter_field == partition_column
100        || filter_field
101            .rsplit_once('.')
102            .is_some_and(|(_, name)| name == partition_column)
103}
104
105/// A DataFusion table provider for Apache Hudi tables.
106///
107/// The provider reads the table as of its creation, including its schema and statistics; create
108/// a new provider to see later commits.
109///
110/// # Examples
111///
112/// ```rust,no_run
113/// use std::sync::Arc;
114///
115/// use datafusion::error::Result;
116/// use datafusion::prelude::{DataFrame, SessionContext};
117/// use hudi_datafusion::HudiDataSource;
118///
119/// #[tokio::main]
120/// async fn main() -> Result<()> {
121///     // Initialize a new DataFusion session context
122///     let ctx = SessionContext::new();
123///
124///     // Create a new HudiDataSource with specific read options
125///     let hudi = HudiDataSource::new_with_options(
126///         "/tmp/trips_table",
127///         [("hoodie.read.input.partitions", "5")]).await?;
128///
129///     // Register the Hudi table with the session context
130///     ctx.register_table("trips_table", Arc::new(hudi))?;
131///     let df: DataFrame = ctx.sql("SELECT * from trips_table where city = 'san_francisco'").await?;
132///     df.show().await?;
133///     Ok(())
134/// }
135/// ```
136#[derive(Clone)]
137pub struct HudiDataSource {
138    table: Arc<HudiTable>,
139    options: HashMap<String, String>,
140    /// Cached table schema (with meta fields) for synchronous access in `TableProvider::schema()`.
141    /// This provider is a construction-time metadata snapshot; create a new
142    /// provider to observe schema/stat changes from later commits.
143    schema: SchemaRef,
144    /// Cached partition schema for determining partition columns.
145    /// This is cached at construction since partition schema rarely changes
146    /// and is needed synchronously in `supports_filters_pushdown`.
147    partition_schema: Schema,
148    /// Cached table-level statistics for join ordering and broadcast decisions.
149    ///
150    /// Consumed in two places:
151    /// 1. `TableProvider::statistics()` - returned to the optimizer (note:
152    ///    DataFusion's main planner does not currently consult this method;
153    ///    see the trait docs).
154    /// 2. `scan_parquet` - passed to `FileScanConfigBuilder::with_statistics(...)`,
155    ///    which IS consumed by the optimizer for join planning on the
156    ///    Parquet/COW fast path.
157    ///
158    /// The `HudiScanExec` path (Lance and MOR snapshot) derives statistics
159    /// per-execution from `FileSlice` metadata via `aggregate_partitions`
160    /// rather than reusing this table-level cache, since per-partition stats
161    /// are more useful for that path.
162    cached_stats: Option<Statistics>,
163    /// Number of input partitions for scan planning, extracted from read options.
164    input_partitions: usize,
165    /// Read-optimized mode requested when constructing the provider.
166    read_optimized_mode: bool,
167    /// Maximum number of file-slice streams polled concurrently within a scan partition.
168    file_slice_read_concurrency: usize,
169    /// Explicit base file format from table config, if present.
170    base_file_format: Option<BaseFileFormatValue>,
171}
172
173impl std::fmt::Debug for HudiDataSource {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        f.debug_struct("HudiDataSource")
176            .field("table", &self.table)
177            .field(
178                "partition_columns",
179                &self
180                    .partition_schema
181                    .fields()
182                    .iter()
183                    .map(|field| field.name())
184                    .collect::<Vec<_>>(),
185            )
186            .finish()
187    }
188}
189
190impl HudiDataSource {
191    /// Creates a provider for the table at `base_uri` with default options.
192    pub async fn new(base_uri: &str) -> Result<Self> {
193        Self::new_with_options(base_uri, empty_options()).await
194    }
195
196    /// Creates a provider for the table at `base_uri` with options, which are passed on to
197    /// [`Table::new_with_options`](hudi_core::table::Table::new_with_options).
198    ///
199    /// The provider also reads `hoodie.read.input.partitions` (default `0`, which defers to
200    /// DataFusion's `target_partitions`), `hoodie.read.use.read_optimized.mode`, and
201    /// `hoodie.read.file.slice.read.concurrency` from these options.
202    ///
203    /// # Errors
204    ///
205    /// Fails when one of those options has an invalid value, the table cannot be opened, or the
206    /// table's base file format is HFile.
207    pub async fn new_with_options<I, K, V>(base_uri: &str, options: I) -> Result<Self>
208    where
209        I: IntoIterator<Item = (K, V)>,
210        K: AsRef<str>,
211        V: Into<String>,
212    {
213        let all_options: Vec<(String, String)> = options
214            .into_iter()
215            .map(|(k, v)| (k.as_ref().to_string(), v.into()))
216            .collect();
217        let input_partitions: usize = match all_options
218            .iter()
219            .find(|(k, _)| k == InputPartitions.as_ref())
220        {
221            Some((_, v)) => v.parse().map_err(|_| {
222                Execution(format!(
223                    "Invalid value '{v}' for {}: expected a non-negative integer",
224                    InputPartitions.as_ref()
225                ))
226            })?,
227            None => 0,
228        };
229        let read_optimized_mode: bool = match all_options
230            .iter()
231            .find(|(k, _)| k == UseReadOptimizedMode.as_ref())
232        {
233            Some((_, v)) => v.parse().map_err(|e| {
234                Execution(format!(
235                    "Invalid value '{v}' for {}: {e}",
236                    UseReadOptimizedMode.as_ref()
237                ))
238            })?,
239            None => false,
240        };
241        let file_slice_read_concurrency: usize = match all_options
242            .iter()
243            .find(|(k, _)| k == FileSliceReadConcurrency.as_ref())
244        {
245            Some((_, v)) => {
246                let parsed = v.parse().map_err(|_| {
247                    Execution(format!(
248                        "Invalid value '{v}' for {}: expected a positive integer",
249                        FileSliceReadConcurrency.as_ref()
250                    ))
251                })?;
252                if parsed == 0 {
253                    return Err(Execution(format!(
254                        "Invalid value '0' for {}: expected a positive integer",
255                        FileSliceReadConcurrency.as_ref()
256                    )));
257                }
258                parsed
259            }
260            None => default_file_slice_read_concurrency(),
261        };
262        let options = all_options.iter().cloned().collect();
263        let table = HudiTable::new_with_options(base_uri, all_options)
264            .await
265            .map_err(|e| external_error("Failed to create Hudi table", e))?;
266
267        let base_file_format =
268            BaseFileFormatValue::from_configs(&table.hudi_configs).map_err(|e| {
269                external_error(
270                    format!(
271                        "Invalid {} config",
272                        HudiTableConfig::BaseFileFormat.as_ref()
273                    ),
274                    e,
275                )
276            })?;
277        if matches!(base_file_format, Some(BaseFileFormatValue::HFile)) {
278            return Err(Execution(
279                "HFile is only supported for Hudi metadata tables, not regular DataFusion scans"
280                    .to_string(),
281            ));
282        }
283
284        // Cache schema with meta fields at construction for synchronous access
285        let schema = table
286            .get_schema_with_meta_fields()
287            .await
288            .map(SchemaRef::from)
289            .unwrap_or_else(|e| {
290                warn!("Failed to get table schema, using empty schema: {e}");
291                SchemaRef::from(Schema::empty())
292            });
293
294        // Cache partition schema at construction for use in supports_filters_pushdown
295        let partition_schema = match table.get_partition_schema().await {
296            Ok(s) => s,
297            Err(e) => {
298                warn!("Failed to get partition schema, using empty schema: {e}");
299                Schema::empty()
300            }
301        };
302
303        // Compute table-level statistics for join ordering and broadcast decisions.
304        // Uses MDT files partition for base-file sizes and, for Parquet tables, one
305        // sampled footer to infer row counts and byte sizes without loading all file groups.
306        // Falls back to None if statistics cannot be derived.
307        let cached_stats = match table.compute_table_stats(None).await {
308            Some((num_rows, total_byte_size)) => {
309                let num_fields = schema.fields().len();
310                Some(Statistics {
311                    num_rows: inexact_usize_from_u64(num_rows),
312                    total_byte_size: inexact_usize_from_u64(total_byte_size),
313                    column_statistics: vec![
314                        datafusion_common::ColumnStatistics::new_unknown();
315                        num_fields
316                    ],
317                })
318            }
319            None => None,
320        };
321
322        Ok(Self {
323            table: Arc::new(table),
324            options,
325            schema,
326            partition_schema,
327            cached_stats,
328            input_partitions,
329            read_optimized_mode,
330            file_slice_read_concurrency,
331            base_file_format,
332        })
333    }
334
335    /// Returns the table location needed to reconstruct this provider.
336    pub fn base_uri(&self) -> String {
337        self.table.base_url().to_string()
338    }
339
340    /// Returns the caller-supplied options needed to reconstruct this provider.
341    pub fn options(&self) -> &HashMap<String, String> {
342        &self.options
343    }
344
345    fn get_input_partitions(&self) -> usize {
346        self.input_partitions
347    }
348
349    #[cfg(test)]
350    fn get_file_slice_read_concurrency(&self) -> usize {
351        self.file_slice_read_concurrency
352    }
353
354    /// Returns the effective `UseReadOptimizedMode` value cached from the
355    /// table options at construction time. Used by [`Self::use_parquet_source`]
356    /// to decide whether MOR snapshot semantics are required.
357    fn effective_read_optimized(&self) -> bool {
358        self.read_optimized_mode
359    }
360
361    fn scan_read_options(
362        &self,
363        pushdown_filters: Vec<(String, String, String)>,
364        read_optimized: bool,
365    ) -> Result<ReadOptions> {
366        let mut read_options = ReadOptions::new()
367            .with_filters(pushdown_filters)
368            .map_err(|e| external_error("Invalid pushdown filter", e))?;
369        if read_optimized {
370            read_options = read_options.with_hudi_option(UseReadOptimizedMode.as_ref(), "true");
371        }
372        Ok(read_options)
373    }
374
375    /// Build the [`ReadOptions`] passed to `HudiScanExec` for per-slice reads.
376    ///
377    /// Hudi table-level planning has already applied partition filters. When
378    /// partition fields are dropped from data files, passing those filters to
379    /// `FileGroupReader` would fail its strict batch-schema validation.
380    fn read_options_for_hudi_exec(
381        hudi_configs: &HudiConfigs,
382        options: &ReadOptions,
383    ) -> ReadOptions {
384        let drops_partition_columns: bool = hudi_configs
385            .get_or_default(HudiTableConfig::DropsPartitionFields)
386            .into();
387        if !drops_partition_columns || options.filters.is_empty() {
388            return options.clone();
389        }
390
391        let partition_columns: Vec<String> = hudi_configs
392            .get_or_default(HudiTableConfig::PartitionFields)
393            .into();
394        let mut applicable = options.clone();
395        applicable.filters = options
396            .filters
397            .iter()
398            .filter(|filter| {
399                !partition_columns
400                    .iter()
401                    .any(|p| filter_field_matches_partition_column(&filter.field, p))
402            })
403            .cloned()
404            .collect();
405        applicable
406    }
407
408    /// Returns true iff every file slice has a `.parquet` base file.
409    /// Empty input returns `false` so the scan is routed to `HudiScanExec`,
410    /// which handles the empty case via `RecordBatchStreamAdapter::new(.., empty())`.
411    fn file_slices_are_parquet(file_slices: &[FileSlice]) -> Result<bool> {
412        if file_slices.is_empty() {
413            return Ok(false);
414        }
415        for file_slice in file_slices {
416            let relative_path = file_slice.base_file_relative_path().map_err(|e| {
417                external_error(
418                    format!("Failed to get base file relative path for {file_slice:?}"),
419                    e,
420                )
421            })?;
422            // No base file: nothing with a parquet footer to prune on.
423            let Some(relative_path) = relative_path else {
424                return Ok(true);
425            };
426            if !BaseFileFormatValue::Parquet.matches_extension(&relative_path) {
427                return Ok(false);
428            }
429        }
430        Ok(true)
431    }
432
433    /// Decides whether a scan can be served by DataFusion's native
434    /// `ParquetSource` (cheap row-group / page pruning) or must go through
435    /// [`HudiScanExec`].
436    ///
437    /// Routing matrix:
438    /// - Parquet COW → `ParquetSource`
439    /// - Parquet MOR read-optimized → `ParquetSource`
440    /// - Parquet MOR snapshot → `HudiScanExec` (base + log merging)
441    /// - Lance and any other base format → `HudiScanExec`
442    fn use_parquet_source(
443        &self,
444        read_options: &ReadOptions,
445        file_slices: &[FileSlice],
446    ) -> Result<bool> {
447        let parquet_base_files = match &self.base_file_format {
448            Some(format) => matches!(format, BaseFileFormatValue::Parquet),
449            None => Self::file_slices_are_parquet(file_slices)?,
450        };
451        if !parquet_base_files || !Self::file_slices_have_sizes(file_slices) {
452            return Ok(false);
453        }
454        if !self.table.is_mor() {
455            return Ok(true);
456        }
457        read_options
458            .is_read_optimized()
459            .map_err(|e| external_error("Invalid read-optimized option", e))
460    }
461
462    /// The scan's routing once the file slices are known: a table whose
463    /// persisted format already settled on parquet still needs every base file
464    /// sized, and a table without a persisted format decides from the slices.
465    fn scan_uses_parquet_source(
466        &self,
467        format_decision: Option<bool>,
468        read_options: &ReadOptions,
469        file_slices: &[FileSlice],
470    ) -> Result<bool> {
471        match format_decision {
472            Some(false) => Ok(false),
473            Some(true) => Ok(Self::file_slices_have_sizes(file_slices)),
474            None => self.use_parquet_source(read_options, file_slices),
475        }
476    }
477
478    /// Whether every base file carries its size. The parquet source needs it:
479    /// DataFusion reads the footer at the size it is given, and its file-group
480    /// repartitioner assigns a zero-length file to no partition at all, so a
481    /// slice without one goes through `HudiScanExec`, which looks the size up.
482    fn file_slices_have_sizes(file_slices: &[FileSlice]) -> bool {
483        file_slices.iter().all(|slice| {
484            slice
485                .base_file
486                .as_ref()
487                .is_none_or(|base_file| base_file.file_metadata.is_some())
488        })
489    }
490
491    /// Check if the given expression can be pushed down to the Hudi table.
492    ///
493    /// The expression can be pushed down if it is:
494    /// - A binary expression with a supported operator and operands
495    /// - A NOT expression wrapping a pushable expression
496    /// - An AND compound expression where at least one side can be pushed down
497    /// - A BETWEEN expression with column and literals
498    fn can_push_down_expr(schema: &Schema, expr: &Expr) -> bool {
499        match expr {
500            Expr::BinaryExpr(binary_expr) => {
501                let left = &binary_expr.left;
502                let op = &binary_expr.op;
503                let right = &binary_expr.right;
504
505                match op {
506                    Operator::And => {
507                        // AND is pushable if at least one side is pushable
508                        Self::can_push_down_expr(schema, left)
509                            || Self::can_push_down_expr(schema, right)
510                    }
511                    Operator::Or => {
512                        // OR cannot be pushed down with current filter model
513                        false
514                    }
515                    _ => {
516                        Self::is_supported_operator(op)
517                            && Self::is_supported_operand(schema, left)
518                            && Self::is_supported_operand(schema, right)
519                    }
520                }
521            }
522            Expr::Not(inner_expr) => {
523                // Recursively check if the inner expression can be pushed down
524                Self::can_push_down_expr(schema, inner_expr)
525            }
526            Expr::Between(between) => {
527                // BETWEEN can be pushed if expr is a column and bounds are literals
528                !between.negated
529                    && matches!(&*between.expr, Expr::Column(col) if schema.column_with_name(&col.name).is_some())
530                    && matches!(&*between.low, Expr::Literal(..))
531                    && matches!(&*between.high, Expr::Literal(..))
532            }
533            Expr::InList(in_list) => {
534                !in_list.list.is_empty()
535                    && matches!(in_list.expr.as_ref(), Expr::Column(col) if schema.column_with_name(&col.name).is_some())
536                    && in_list
537                        .list
538                        .iter()
539                        .all(|expr| matches!(expr, Expr::Literal(..)))
540            }
541            _ => false,
542        }
543    }
544
545    fn is_supported_operator(op: &Operator) -> bool {
546        matches!(
547            op,
548            Operator::Eq
549                | Operator::NotEq
550                | Operator::Gt
551                | Operator::Lt
552                | Operator::GtEq
553                | Operator::LtEq
554        )
555    }
556
557    fn is_supported_operand(schema: &Schema, expr: &Expr) -> bool {
558        match expr {
559            Expr::Column(col) => schema.column_with_name(&col.name).is_some(),
560            Expr::Literal(..) => true,
561            _ => false,
562        }
563    }
564
565    /// Returns partition column names from partition schema.
566    fn get_partition_columns(&self) -> Vec<String> {
567        self.partition_schema
568            .fields()
569            .iter()
570            .map(|f| f.name().clone())
571            .collect()
572    }
573
574    /// Checks if expression filters only on a partition column.
575    ///
576    /// Partition filters are safe to push into Hudi file listing for every
577    /// scan path. For Parquet scans, non-partition predicates are left to
578    /// DataFusion's `ParquetSource` so Hudi doesn't read Parquet footers for
579    /// stats pruning before DataFusion reads the same files.
580    fn is_partition_column_filter(expr: &Expr, partition_cols: &[String]) -> bool {
581        match expr {
582            Expr::BinaryExpr(binary_expr) => match binary_expr.op {
583                Operator::And => {
584                    Self::is_partition_column_filter(&binary_expr.left, partition_cols)
585                        && Self::is_partition_column_filter(&binary_expr.right, partition_cols)
586                }
587                Operator::Or => false,
588                _ => match (&*binary_expr.left, &*binary_expr.right) {
589                    (Expr::Column(col), Expr::Literal(..))
590                    | (Expr::Literal(..), Expr::Column(col)) => partition_cols.contains(&col.name),
591                    _ => false,
592                },
593            },
594            Expr::Not(inner) => Self::is_partition_column_filter(inner, partition_cols),
595            Expr::Between(between) => {
596                !between.negated
597                    && matches!(&*between.expr, Expr::Column(col) if partition_cols.contains(&col.name))
598                    && matches!(&*between.low, Expr::Literal(..))
599                    && matches!(&*between.high, Expr::Literal(..))
600            }
601            Expr::InList(in_list) => {
602                !in_list.list.is_empty()
603                    && matches!(in_list.expr.as_ref(), Expr::Column(col) if partition_cols.contains(&col.name))
604                    && in_list
605                        .list
606                        .iter()
607                        .all(|expr| matches!(expr, Expr::Literal(..)))
608            }
609            _ => false,
610        }
611    }
612
613    fn is_exact_partition_equality_filter(expr: &Expr, partition_cols: &[String]) -> bool {
614        match expr {
615            Expr::BinaryExpr(binary_expr) if binary_expr.op == Operator::Eq => {
616                match (&*binary_expr.left, &*binary_expr.right) {
617                    (Expr::Column(col), Expr::Literal(..))
618                    | (Expr::Literal(..), Expr::Column(col)) => partition_cols.contains(&col.name),
619                    _ => false,
620                }
621            }
622            _ => false,
623        }
624    }
625
626    fn filter_pushdown_support(
627        table_schema: &Schema,
628        partition_cols: &[String],
629        expr: &Expr,
630    ) -> TableProviderFilterPushDown {
631        let conjuncts = split_conjunction(expr);
632        let has_pushable_conjunct = conjuncts
633            .iter()
634            .any(|conjunct| Self::can_push_down_expr(table_schema, conjunct));
635
636        if !has_pushable_conjunct {
637            return TableProviderFilterPushDown::Unsupported;
638        }
639
640        let all_conjuncts_are_exact_partition_eq = conjuncts.iter().all(|conjunct| {
641            Self::can_push_down_expr(table_schema, conjunct)
642                && Self::is_exact_partition_equality_filter(conjunct, partition_cols)
643        });
644
645        if all_conjuncts_are_exact_partition_eq {
646            TableProviderFilterPushDown::Exact
647        } else {
648            TableProviderFilterPushDown::Inexact
649        }
650    }
651
652    /// Returns `(partition_pushdown_exprs, all_pushdown_exprs)`.
653    fn split_scan_pushdown_exprs(&self, filters: &[Expr]) -> (Vec<Expr>, Vec<Expr>) {
654        let partition_cols = self.get_partition_columns();
655        Self::split_scan_pushdown_exprs_for_schema(self.schema.as_ref(), &partition_cols, filters)
656    }
657
658    fn split_scan_pushdown_exprs_for_schema(
659        table_schema: &Schema,
660        partition_cols: &[String],
661        filters: &[Expr],
662    ) -> (Vec<Expr>, Vec<Expr>) {
663        let all_pushdown_exprs: Vec<Expr> = filters
664            .iter()
665            .flat_map(|expr| split_conjunction(expr).into_iter())
666            .filter(|expr| Self::can_push_down_expr(table_schema, expr))
667            .cloned()
668            .collect();
669        let partition_pushdown_exprs = all_pushdown_exprs
670            .iter()
671            .filter(|expr| Self::is_partition_column_filter(expr, partition_cols))
672            .cloned()
673            .collect();
674
675        (partition_pushdown_exprs, all_pushdown_exprs)
676    }
677
678    fn use_parquet_source_without_file_slices(
679        &self,
680        read_options: &ReadOptions,
681    ) -> Result<Option<bool>> {
682        if self.table.is_mor()
683            && !read_options
684                .is_read_optimized()
685                .map_err(|e| external_error("Invalid read-optimized option", e))?
686        {
687            return Ok(Some(false));
688        }
689
690        match &self.base_file_format {
691            Some(BaseFileFormatValue::Parquet) => Ok(Some(true)),
692            Some(_) => Ok(Some(false)),
693            None => Ok(None),
694        }
695    }
696
697    #[allow(clippy::too_many_arguments)]
698    async fn scan_parquet(
699        &self,
700        state: &dyn Session,
701        projection: Option<&Vec<usize>>,
702        filters: &[Expr],
703        limit: Option<usize>,
704        flat_slices: Vec<FileSlice>,
705    ) -> Result<Arc<dyn ExecutionPlan>> {
706        let input_partitions = self.get_input_partitions_for_scan(state);
707        let file_slices =
708            hudi_core::util::collection::split_into_chunks(flat_slices, input_partitions);
709        let base_url = self.table.base_url();
710        let mut parquet_file_groups: Vec<Vec<PartitionedFile>> = Vec::new();
711        for file_slice_vec in file_slices {
712            let mut parquet_file_group_vec = Vec::new();
713            for f in file_slice_vec {
714                let relative_path = f.base_file_relative_path().map_err(|e| {
715                    external_error(
716                        format!("Failed to get base file relative path for {f:?}"),
717                        e,
718                    )
719                })?;
720                // A slice with no base file contributes no parquet file here;
721                // its records come from log files, which this path does not read.
722                let Some(relative_path) = relative_path else {
723                    continue;
724                };
725                let url = join_url_segments(&base_url, &[relative_path.as_str()])
726                    .map_err(|e| external_error("Failed to join URL segments", e))?;
727                let size = f
728                    .base_file
729                    .as_ref()
730                    .and_then(|b| b.file_metadata.as_ref())
731                    .map(|m| m.size)
732                    .ok_or_else(|| {
733                        DataFusionError::Internal(format!(
734                            "Base file {relative_path} has no known size"
735                        ))
736                    })?;
737                let partitioned_file = PartitionedFile::new(url.path(), size);
738                parquet_file_group_vec.push(partitioned_file);
739            }
740            parquet_file_groups.push(parquet_file_group_vec)
741        }
742
743        let url = ObjectStoreUrl::parse(get_scheme_authority(&base_url))?;
744        let parquet_opts = TableParquetOptions {
745            global: state.config_options().execution.parquet.clone(),
746            column_specific_options: Default::default(),
747            key_value_metadata: Default::default(),
748            crypto: Default::default(),
749        };
750        let table_schema = self.schema();
751        let mut parquet_source = ParquetSource::new(table_schema.clone())
752            .with_table_parquet_options(parquet_opts)
753            .with_pushdown_filters(true)
754            .with_reorder_filters(true)
755            .with_enable_page_index(true);
756        let filter = filters.iter().cloned().reduce(|acc, new| acc.and(new));
757        if let Some(expr) = filter {
758            let df_schema = DFSchema::try_from(table_schema.clone())?;
759            let predicate = state.create_physical_expr(expr, &df_schema)?;
760            parquet_source = parquet_source.with_predicate(predicate)
761        }
762
763        let file_groups: Vec<FileGroup> = parquet_file_groups
764            .into_iter()
765            .map(FileGroup::from)
766            .collect();
767
768        let mut fsc_builder = FileScanConfigBuilder::new(url, Arc::new(parquet_source))
769            .with_file_groups(file_groups)
770            .with_projection_indices(projection.cloned())?
771            .with_limit(limit);
772
773        if let Some(stats) = &self.cached_stats {
774            // DataFusion's FileScanConfig stores unprojected table statistics
775            // and applies the source projection inside partition_statistics().
776            fsc_builder = fsc_builder.with_statistics(stats.clone());
777        }
778
779        let fsc = fsc_builder.build();
780        Ok(Arc::new(DataSourceExec::new(Arc::new(fsc))))
781    }
782
783    async fn scan_hudi(
784        &self,
785        projection: Option<&Vec<usize>>,
786        limit: Option<usize>,
787        input_partitions: usize,
788        flat_slices: Vec<FileSlice>,
789        read_options: ReadOptions,
790    ) -> Result<Arc<dyn ExecutionPlan>> {
791        // Derive the fan-out from the scan's memory budget when one is set. The
792        // concurrency knob alone cannot express the peak: it multiplies by the
793        // partitions running at once, and nothing multiplies them. Sharing one
794        // budget across partitions is what keeps the product inside it.
795        let slice_log_bytes: Vec<Option<u64>> =
796            flat_slices.iter().map(FileSlice::log_size_bytes).collect();
797        let file_slice_read_concurrency = hudi_core::file_group::admission::slices_in_flight(
798            self.scan_max_memory_size(),
799            input_partitions,
800            &slice_log_bytes,
801            self.file_slice_read_concurrency,
802        );
803        let file_slices =
804            hudi_core::util::collection::split_into_chunks(flat_slices, input_partitions);
805
806        // The reader is built with the caller's full options so table-level
807        // setup sees the same query context. Dropped partition filters are only
808        // stripped from the per-slice options passed into HudiScanExec below,
809        // before FileGroupReader validates base-file batch schemas.
810        let file_group_reader = Arc::new(
811            self.table
812                .create_file_group_reader_with_options(Some(&read_options), empty_options())
813                .await
814                .map_err(|e| external_error("Failed to create FileGroupReader", e))?,
815        );
816
817        let mut hudi_read_options =
818            Self::read_options_for_hudi_exec(&self.table.hudi_configs, &read_options);
819        if let Some(proj) = projection {
820            let col_names: Vec<String> = proj
821                .iter()
822                .map(|&i| self.schema.field(i).name().clone())
823                .collect();
824            hudi_read_options = hudi_read_options.with_projection(col_names);
825        }
826
827        Ok(Arc::new(HudiScanExec::new(
828            file_slices,
829            file_group_reader,
830            hudi_read_options,
831            input_partitions,
832            file_slice_read_concurrency,
833            self.schema.clone(),
834            projection.cloned(),
835            limit,
836        )))
837    }
838
839    /// The scan's memory budget, when the table or read set one.
840    ///
841    /// Absent means the concurrency knob stands on its own, which is the
842    /// behaviour every existing table keeps.
843    fn scan_max_memory_size(&self) -> Option<u64> {
844        self.table
845            .hudi_configs
846            .try_get(hudi_core::config::read::HudiReadConfig::ScanMaxMemorySize)
847            .ok()
848            .flatten()
849            .map(|v| -> usize { v.into() })
850            .map(|v| v as u64)
851    }
852
853    fn get_input_partitions_for_scan(&self, state: &dyn Session) -> usize {
854        match self.get_input_partitions() {
855            0 => state.config_options().execution.target_partitions,
856            n => n,
857        }
858    }
859}
860
861#[async_trait]
862impl TableProvider for HudiDataSource {
863    fn schema(&self) -> SchemaRef {
864        self.schema.clone()
865    }
866
867    fn table_type(&self) -> TableType {
868        TableType::Base
869    }
870
871    fn statistics(&self) -> Option<Statistics> {
872        self.cached_stats.clone()
873    }
874
875    /// Builds the scan `ExecutionPlan` for this Hudi table.
876    ///
877    /// Per DataFusion's [custom table provider guide], `scan()` should avoid
878    /// data-reading I/O (work proportional to row count). Catalog I/O — the
879    /// listing needed to decide what files exist and which ones the query
880    /// touches — is expected; it is what DataFusion's own `ListingTable` does.
881    ///
882    /// `Table::get_file_slices(&read_options)` is catalog I/O: it loads the
883    /// timeline, resolves file groups, and applies partition pruning. Its cost
884    /// is proportional to partition count, not row count. When the metadata
885    /// table is enabled, partition discovery reads a few HFile blocks instead
886    /// of doing recursive object-store listing, so MDT is strictly cheaper
887    /// than the generic `ListingTable` path.
888    ///
889    /// Callers that issue many `scan()` calls per session against the same
890    /// table (e.g. ad-hoc SQL across the same dataset) may still benefit from
891    /// caching the resolved file-slice set keyed by
892    /// `(filters, as_of_timestamp)`. Tracked as future work.
893    ///
894    /// We implement the legacy `scan()` rather than `scan_with_args()`. The
895    /// default `scan_with_args()` impl delegates to `scan()`, so today's
896    /// behavior is identical. Revisit if a future DataFusion version adds
897    /// optimization hints to `scan_with_args()` that we'd want to consume.
898    ///
899    /// [custom table provider guide]: https://datafusion.apache.org/library-user-guide/custom-table-providers.html
900    async fn scan(
901        &self,
902        state: &dyn Session,
903        projection: Option<&Vec<usize>>,
904        filters: &[Expr],
905        limit: Option<usize>,
906    ) -> Result<Arc<dyn ExecutionPlan>> {
907        // Idempotent: registers our object store with the session's runtime
908        // so the Parquet path (DataSourceExec) can resolve `s3://`/`gs://`/`az://`
909        // URIs. Per-scan invocation is intentional: `TableProvider` has no
910        // registration hook called by the SessionContext, and direct callers
911        // (`HudiDataSource::new`) wouldn't have a chance to call it otherwise.
912        self.table.register_storage(state.runtime_env().clone());
913
914        let input_partitions = self.get_input_partitions_for_scan(state);
915
916        let (partition_pushdown_exprs, all_pushdown_exprs) =
917            self.split_scan_pushdown_exprs(filters);
918        let partition_pushdown_filters = exprs_to_filters(&partition_pushdown_exprs);
919        let all_pushdown_filters = exprs_to_filters(&all_pushdown_exprs);
920        let all_filters_are_partition_filters = all_pushdown_filters == partition_pushdown_filters;
921
922        let read_optimized = self.effective_read_optimized();
923        let partition_read_options =
924            self.scan_read_options(partition_pushdown_filters.clone(), read_optimized)?;
925        let all_read_options = if all_filters_are_partition_filters {
926            partition_read_options.clone()
927        } else {
928            self.scan_read_options(all_pushdown_filters, read_optimized)?
929        };
930
931        let format_decision =
932            self.use_parquet_source_without_file_slices(&partition_read_options)?;
933        match format_decision {
934            Some(false) => {
935                let flat_slices = self
936                    .table
937                    .get_file_slices(&all_read_options)
938                    .await
939                    .map_err(|e| external_error("Failed to get file slices from Hudi table", e))?;
940                self.scan_hudi(
941                    projection,
942                    limit,
943                    input_partitions,
944                    flat_slices,
945                    all_read_options,
946                )
947                .await
948            }
949            _ => {
950                let partition_flat_slices = self
951                    .table
952                    .get_file_slices(&partition_read_options)
953                    .await
954                    .map_err(|e| external_error("Failed to get file slices from Hudi table", e))?;
955
956                if self.scan_uses_parquet_source(
957                    format_decision,
958                    &partition_read_options,
959                    &partition_flat_slices,
960                )? {
961                    self.scan_parquet(state, projection, filters, limit, partition_flat_slices)
962                        .await
963                } else {
964                    let flat_slices = if all_filters_are_partition_filters {
965                        partition_flat_slices
966                    } else {
967                        self.table
968                            .get_file_slices(&all_read_options)
969                            .await
970                            .map_err(|e| {
971                                external_error("Failed to get file slices from Hudi table", e)
972                            })?
973                    };
974                    self.scan_hudi(
975                        projection,
976                        limit,
977                        input_partitions,
978                        flat_slices,
979                        all_read_options,
980                    )
981                    .await
982                }
983            }
984        }
985    }
986
987    /// Reports partition equality predicates as `Exact`; all other pushed
988    /// filters are `Inexact` so DataFusion retains a residual `FilterExec`.
989    /// `scan()` splits conjunctions before converting them to Hudi filters, so
990    /// pushable atoms inside mixed `AND` predicates still help pruning.
991    fn supports_filters_pushdown(
992        &self,
993        filters: &[&Expr],
994    ) -> Result<Vec<TableProviderFilterPushDown>> {
995        let partition_cols = self.get_partition_columns();
996
997        filters
998            .iter()
999            .map(|expr| {
1000                Ok(Self::filter_pushdown_support(
1001                    self.schema.as_ref(),
1002                    &partition_cols,
1003                    expr,
1004                ))
1005            })
1006            .collect()
1007    }
1008}
1009
1010/// `HudiTableFactory` is responsible for creating and configuring Hudi tables.
1011///
1012/// This factory handles the initialization of Hudi tables by creating configuration
1013/// options from both session state and table creation commands.
1014///
1015/// # Examples
1016///
1017/// Creating a new `HudiTableFactory` instance:
1018///
1019/// ```rust,no_run
1020/// use datafusion::prelude::SessionContext;
1021/// use datafusion::catalog::TableProviderFactory;
1022/// use datafusion::sql::parser::CreateExternalTable;
1023/// use hudi_datafusion::HudiTableFactory;
1024///
1025/// #[tokio::main]
1026/// async fn main() -> datafusion::error::Result<()> {
1027///     // Initialize a new HudiTableFactory
1028///     let factory = HudiTableFactory::new();
1029///     
1030///     // Initialize a new DataFusion session context
1031///     let ctx = SessionContext::new();
1032///     
1033///     // Register table using SQL command
1034///     let create_table_sql =
1035///         "CREATE EXTERNAL TABLE trips_table STORED AS HUDI LOCATION '/tmp/trips_table'";
1036///     ctx.sql(create_table_sql).await?;
1037///     
1038///     Ok(())
1039/// }
1040/// ```
1041#[derive(Debug)]
1042pub struct HudiTableFactory {}
1043
1044impl HudiTableFactory {
1045    /// Creates a factory.
1046    pub fn new() -> Self {
1047        Self {}
1048    }
1049
1050    fn resolve_options(
1051        state: &dyn Session,
1052        cmd: &CreateExternalTable,
1053    ) -> Result<HashMap<String, String>> {
1054        let mut options: HashMap<_, _> = state
1055            .config_options()
1056            .entries()
1057            .iter()
1058            .filter_map(|e| {
1059                let value = e.value.as_ref().filter(|v| !v.is_empty())?;
1060                Some((e.key.clone(), value.clone()))
1061            })
1062            .collect();
1063
1064        // options from the command take precedence
1065        options.extend(cmd.options.iter().map(|(k, v)| (k.clone(), v.clone())));
1066
1067        Ok(options)
1068    }
1069}
1070
1071impl Default for HudiTableFactory {
1072    fn default() -> Self {
1073        Self::new()
1074    }
1075}
1076
1077#[async_trait]
1078impl TableProviderFactory for HudiTableFactory {
1079    async fn create(
1080        &self,
1081        state: &dyn Session,
1082        cmd: &CreateExternalTable,
1083    ) -> Result<Arc<dyn TableProvider>> {
1084        let options = HudiTableFactory::resolve_options(state, cmd)?;
1085        let base_uri = match cmd.locations.as_slice() {
1086            [location] => location.as_str(),
1087            locations => {
1088                return Err(DataFusionError::Plan(format!(
1089                    "A Hudi table has exactly one location, got {}",
1090                    locations.len()
1091                )));
1092            }
1093        };
1094        let table_provider = HudiDataSource::new_with_options(base_uri, options).await?;
1095        Ok(Arc::new(table_provider))
1096    }
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102    use arrow_schema::{DataType, Field};
1103    use datafusion_common::{Column, ScalarValue};
1104    use hudi_core::config::internal::HudiInternalConfig;
1105    use hudi_core::config::table::{BaseFileFormatValue, HudiTableConfig};
1106    use std::fs::canonicalize;
1107    use std::path::Path;
1108    use url::Url;
1109
1110    use datafusion::logical_expr::BinaryExpr;
1111    use datafusion::prelude::SessionContext;
1112    use hudi_test::QuickstartTripsTable;
1113    use hudi_test::SampleTable::{V6Nonpartitioned, V6SimplekeygenNonhivestyle, V9LanceTxnsSimple};
1114
1115    use crate::HudiDataSource;
1116
1117    #[tokio::test]
1118    async fn get_default_input_partitions() {
1119        let base_url =
1120            Url::from_file_path(canonicalize(Path::new("tests/data/table_props_valid")).unwrap())
1121                .unwrap();
1122        let hudi = HudiDataSource::new(base_url.as_str()).await.unwrap();
1123        assert_eq!(hudi.get_input_partitions(), 0);
1124        assert_eq!(
1125            hudi.get_file_slice_read_concurrency(),
1126            default_file_slice_read_concurrency()
1127        );
1128        assert_eq!(hudi.table_type(), TableType::Base);
1129        assert_eq!(hudi.statistics(), None);
1130    }
1131
1132    #[tokio::test]
1133    async fn test_new_with_options_sets_file_slice_read_concurrency() {
1134        let hudi = HudiDataSource::new_with_options(
1135            V6Nonpartitioned.path_to_cow().as_str(),
1136            [(FileSliceReadConcurrency.as_ref(), "2")],
1137        )
1138        .await
1139        .unwrap();
1140
1141        assert_eq!(hudi.get_file_slice_read_concurrency(), 2);
1142    }
1143
1144    #[tokio::test]
1145    async fn test_new_with_options_rejects_invalid_file_slice_read_concurrency() {
1146        for invalid in ["0", "abc"] {
1147            let result = HudiDataSource::new_with_options(
1148                V6Nonpartitioned.path_to_cow().as_str(),
1149                [(FileSliceReadConcurrency.as_ref(), invalid)],
1150            )
1151            .await;
1152
1153            assert!(result.is_err());
1154            let error = result.unwrap_err().to_string();
1155            assert!(error.contains(FileSliceReadConcurrency.as_ref()));
1156            assert!(error.contains(invalid));
1157        }
1158    }
1159
1160    #[test]
1161    fn test_file_slices_are_parquet_empty_is_false() {
1162        assert!(!HudiDataSource::file_slices_are_parquet(&[]).unwrap());
1163    }
1164
1165    #[test]
1166    fn test_file_slices_have_sizes_requires_metadata_on_every_base_file() {
1167        use hudi_core::file_group::base_file::BaseFile;
1168        use hudi_core::storage::file_metadata::FileMetadata;
1169        use std::str::FromStr;
1170
1171        let name = "0832997e-30d8-4a42-a52d-e2c540816593-0_0-43-78_20261016221427272.parquet";
1172        let sized = {
1173            let mut base_file = BaseFile::from_str(name).unwrap();
1174            base_file.file_metadata = Some(FileMetadata::new(name.to_string(), 4096));
1175            FileSlice::new(base_file, "city=sf".to_string())
1176        };
1177        let without_size = FileSlice::new(BaseFile::from_str(name).unwrap(), "city=sf".to_string());
1178
1179        assert!(HudiDataSource::file_slices_have_sizes(&[]));
1180        assert!(HudiDataSource::file_slices_have_sizes(
1181            std::slice::from_ref(&sized)
1182        ));
1183        assert!(!HudiDataSource::file_slices_have_sizes(&[
1184            sized,
1185            without_size
1186        ]));
1187    }
1188
1189    #[tokio::test]
1190    async fn test_scan_routing_requires_sizes_even_when_the_table_persists_parquet() {
1191        // This fixture persists `hoodie.table.base.file.format=PARQUET`, so a
1192        // read-optimized read settles on the parquet source before any slice is
1193        // read.
1194        let base_uri = QuickstartTripsTable::MorDeleteOrdInt.path_to_mor_avro();
1195        let hudi = HudiDataSource::new_with_options(base_uri.as_str(), empty_options())
1196            .await
1197            .unwrap();
1198        let read_options = hudi.scan_read_options(Vec::new(), true).unwrap();
1199        let decision = hudi
1200            .use_parquet_source_without_file_slices(&read_options)
1201            .unwrap();
1202        assert_eq!(
1203            decision,
1204            Some(true),
1205            "the persisted format settles on parquet"
1206        );
1207
1208        let mut slices = hudi.table.get_file_slices(&read_options).await.unwrap();
1209        assert!(!slices.is_empty());
1210        assert!(
1211            hudi.scan_uses_parquet_source(decision, &read_options, &slices)
1212                .unwrap()
1213        );
1214
1215        slices[0].base_file.as_mut().unwrap().file_metadata = None;
1216        assert!(
1217            !hudi
1218                .scan_uses_parquet_source(decision, &read_options, &slices)
1219                .unwrap(),
1220            "a base file without a size must leave the parquet source"
1221        );
1222    }
1223
1224    #[tokio::test]
1225    async fn test_scan_parquet_rejects_a_base_file_without_size() {
1226        let base_uri = V6Nonpartitioned.path_to_cow();
1227        let hudi = HudiDataSource::new_with_options(base_uri.as_str(), empty_options())
1228            .await
1229            .unwrap();
1230        let read_options = hudi.scan_read_options(Vec::new(), false).unwrap();
1231        let mut slices = hudi.table.get_file_slices(&read_options).await.unwrap();
1232        assert!(!slices.is_empty());
1233        slices[0].base_file.as_mut().unwrap().file_metadata = None;
1234
1235        let ctx = SessionContext::new();
1236        let state = ctx.state();
1237        let err = hudi
1238            .scan_parquet(&state, None, &[], None, slices)
1239            .await
1240            .expect_err("a sizeless base file must not reach DataFusion as zero bytes");
1241        assert!(err.to_string().contains("has no known size"), "{err}");
1242    }
1243
1244    #[tokio::test]
1245    async fn test_new_with_options_rejects_hfile_format_for_regular_scan() {
1246        let result = HudiDataSource::new_with_options(
1247            V6Nonpartitioned.path_to_cow().as_str(),
1248            [
1249                (
1250                    HudiTableConfig::BaseFileFormat.as_ref(),
1251                    BaseFileFormatValue::HFile.as_ref(),
1252                ),
1253                (HudiInternalConfig::SkipConfigValidation.as_ref(), "true"),
1254            ],
1255        )
1256        .await;
1257
1258        assert!(
1259            result.is_err(),
1260            "HFile format should be rejected for regular DataFusion scans"
1261        );
1262        assert!(result.unwrap_err().to_string().contains("HFile"));
1263    }
1264
1265    #[tokio::test]
1266    async fn test_new_with_options_rejects_invalid_base_file_format_config() {
1267        let result = HudiDataSource::new_with_options(
1268            V6Nonpartitioned.path_to_cow().as_str(),
1269            [
1270                (HudiTableConfig::BaseFileFormat.as_ref(), "orc"),
1271                (HudiInternalConfig::SkipConfigValidation.as_ref(), "true"),
1272            ],
1273        )
1274        .await;
1275
1276        assert!(result.is_err());
1277        assert!(result.unwrap_err().to_string().contains("orc"));
1278    }
1279
1280    fn pushdown_test_schema() -> Schema {
1281        Schema::new(vec![
1282            Field::new("byteField", DataType::Int8, false),
1283            Field::new("name", DataType::Utf8, true),
1284            Field::new("intField", DataType::Int32, true),
1285        ])
1286    }
1287
1288    fn partition_cols() -> Vec<String> {
1289        vec!["byteField".to_string()]
1290    }
1291
1292    fn col_lit(name: &str, op: Operator, lit: ScalarValue) -> Expr {
1293        Expr::BinaryExpr(BinaryExpr {
1294            left: Box::new(Expr::Column(Column::from_name(name.to_string()))),
1295            op,
1296            right: Box::new(Expr::Literal(lit, None)),
1297        })
1298    }
1299
1300    fn pushdown_support(
1301        schema: &Schema,
1302        partition_cols: &[String],
1303        expr: &Expr,
1304    ) -> TableProviderFilterPushDown {
1305        HudiDataSource::filter_pushdown_support(schema, partition_cols, expr)
1306    }
1307
1308    async fn scan_hudi_exec_filter_triplets(
1309        hudi: &HudiDataSource,
1310        filters: &[Expr],
1311    ) -> Vec<(String, String, Vec<String>)> {
1312        let ctx = SessionContext::new();
1313        let state = ctx.state();
1314        let plan = hudi.scan(&state, None, filters, None).await.unwrap();
1315        let exec = plan
1316            .downcast_ref::<HudiScanExec>()
1317            .expect("scan should route to HudiScanExec");
1318
1319        exec.read_options()
1320            .filters
1321            .iter()
1322            .map(|filter| {
1323                (
1324                    filter.field.clone(),
1325                    filter.operator.to_string(),
1326                    filter.values.clone(),
1327                )
1328            })
1329            .collect()
1330    }
1331
1332    fn assert_scan_filter(
1333        filters: &[(String, String, Vec<String>)],
1334        field: &str,
1335        operator: &str,
1336        values: &[&str],
1337    ) {
1338        assert!(
1339            filters
1340                .iter()
1341                .any(|(actual_field, actual_operator, actual_values)| {
1342                    actual_field == field
1343                        && actual_operator == operator
1344                        && actual_values
1345                            .iter()
1346                            .map(String::as_str)
1347                            .eq(values.iter().copied())
1348                }),
1349            "expected filter ({field}, {operator}, {values:?}) in {filters:?}"
1350        );
1351    }
1352
1353    #[test]
1354    fn test_filter_pushdown_support_for_non_partitioned_schema() {
1355        let schema = pushdown_test_schema();
1356        let partition_cols = vec![];
1357        let filters = [
1358            col_lit(
1359                "name",
1360                Operator::Eq,
1361                ScalarValue::Utf8(Some("Alice".to_string())),
1362            ),
1363            col_lit("intField", Operator::Gt, ScalarValue::Int32(Some(20000))),
1364            col_lit(
1365                "nonexistent_column",
1366                Operator::Eq,
1367                ScalarValue::Int32(Some(1)),
1368            ),
1369            col_lit(
1370                "name",
1371                Operator::NotEq,
1372                ScalarValue::Utf8(Some("Diana".to_string())),
1373            ),
1374            Expr::Literal(ScalarValue::Int32(Some(10)), None),
1375            Expr::Not(Box::new(col_lit(
1376                "intField",
1377                Operator::Gt,
1378                ScalarValue::Int32(Some(20000)),
1379            ))),
1380        ];
1381
1382        let result = filters
1383            .iter()
1384            .map(|expr| pushdown_support(&schema, &partition_cols, expr))
1385            .collect::<Vec<_>>();
1386
1387        assert_eq!(
1388            result,
1389            vec![
1390                TableProviderFilterPushDown::Inexact,
1391                TableProviderFilterPushDown::Inexact,
1392                TableProviderFilterPushDown::Unsupported,
1393                TableProviderFilterPushDown::Inexact,
1394                TableProviderFilterPushDown::Unsupported,
1395                TableProviderFilterPushDown::Inexact,
1396            ]
1397        );
1398    }
1399
1400    #[test]
1401    fn test_filter_pushdown_exact_only_for_partition_equality() {
1402        let schema = pushdown_test_schema();
1403        let partition_cols = partition_cols();
1404
1405        let partition_eq = col_lit("byteField", Operator::Eq, ScalarValue::Int8(Some(1)));
1406        let partition_gt = col_lit("byteField", Operator::Gt, ScalarValue::Int8(Some(1)));
1407        let non_partition_eq = col_lit(
1408            "name",
1409            Operator::Eq,
1410            ScalarValue::Utf8(Some("Alice".to_string())),
1411        );
1412
1413        assert_eq!(
1414            pushdown_support(&schema, &partition_cols, &partition_eq),
1415            TableProviderFilterPushDown::Exact
1416        );
1417        assert_eq!(
1418            pushdown_support(&schema, &partition_cols, &partition_gt),
1419            TableProviderFilterPushDown::Inexact
1420        );
1421        assert_eq!(
1422            pushdown_support(&schema, &partition_cols, &non_partition_eq),
1423            TableProviderFilterPushDown::Inexact
1424        );
1425    }
1426
1427    #[test]
1428    fn test_filter_pushdown_splits_conjunctions_for_classification() {
1429        let schema = pushdown_test_schema();
1430        let partition_cols = partition_cols();
1431
1432        let partition_eq = col_lit("byteField", Operator::Eq, ScalarValue::Int8(Some(1)));
1433        let second_partition_eq = col_lit("byteField", Operator::Eq, ScalarValue::Int8(Some(2)));
1434        let non_partition_eq = col_lit(
1435            "name",
1436            Operator::Eq,
1437            ScalarValue::Utf8(Some("Alice".to_string())),
1438        );
1439        let unsupported = Expr::Literal(ScalarValue::Boolean(Some(true)), None);
1440
1441        assert_eq!(
1442            pushdown_support(
1443                &schema,
1444                &partition_cols,
1445                &partition_eq.clone().and(second_partition_eq)
1446            ),
1447            TableProviderFilterPushDown::Exact
1448        );
1449        assert_eq!(
1450            pushdown_support(
1451                &schema,
1452                &partition_cols,
1453                &partition_eq.clone().and(non_partition_eq)
1454            ),
1455            TableProviderFilterPushDown::Inexact
1456        );
1457        assert_eq!(
1458            pushdown_support(&schema, &partition_cols, &partition_eq.and(unsupported)),
1459            TableProviderFilterPushDown::Inexact
1460        );
1461    }
1462
1463    #[test]
1464    fn test_filter_pushdown_between_in_list_and_or() {
1465        let schema = pushdown_test_schema();
1466        let partition_cols = partition_cols();
1467
1468        let partition_between = Expr::Between(datafusion_expr::Between::new(
1469            Box::new(Expr::Column(Column::from_name("byteField".to_string()))),
1470            false,
1471            Box::new(Expr::Literal(ScalarValue::Int8(Some(1)), None)),
1472            Box::new(Expr::Literal(ScalarValue::Int8(Some(3)), None)),
1473        ));
1474        let partition_in = Expr::InList(datafusion_expr::expr::InList::new(
1475            Box::new(Expr::Column(Column::from_name("byteField".to_string()))),
1476            vec![
1477                Expr::Literal(ScalarValue::Int8(Some(1)), None),
1478                Expr::Literal(ScalarValue::Int8(Some(2)), None),
1479            ],
1480            false,
1481        ));
1482        let or_expr = col_lit(
1483            "name",
1484            Operator::Eq,
1485            ScalarValue::Utf8(Some("Alice".to_string())),
1486        )
1487        .or(col_lit(
1488            "name",
1489            Operator::Eq,
1490            ScalarValue::Utf8(Some("Bob".to_string())),
1491        ));
1492
1493        assert_eq!(
1494            pushdown_support(&schema, &partition_cols, &partition_between),
1495            TableProviderFilterPushDown::Inexact
1496        );
1497        assert_eq!(
1498            pushdown_support(&schema, &partition_cols, &partition_in),
1499            TableProviderFilterPushDown::Inexact
1500        );
1501        assert_eq!(
1502            pushdown_support(&schema, &partition_cols, &or_expr),
1503            TableProviderFilterPushDown::Unsupported
1504        );
1505    }
1506
1507    #[test]
1508    fn test_scan_pushdown_exprs_separates_partition_filters_for_listing() {
1509        let schema = pushdown_test_schema();
1510        let partition_cols = partition_cols();
1511        let partition_eq = col_lit("byteField", Operator::Eq, ScalarValue::Int8(Some(1)));
1512        let non_partition_eq = col_lit(
1513            "name",
1514            Operator::Eq,
1515            ScalarValue::Utf8(Some("Alice".to_string())),
1516        );
1517        let partition_in = Expr::InList(datafusion_expr::expr::InList::new(
1518            Box::new(Expr::Column(Column::from_name("byteField".to_string()))),
1519            vec![
1520                Expr::Literal(ScalarValue::Int8(Some(1)), None),
1521                Expr::Literal(ScalarValue::Int8(Some(2)), None),
1522            ],
1523            false,
1524        ));
1525
1526        let (partition_exprs, all_exprs) = HudiDataSource::split_scan_pushdown_exprs_for_schema(
1527            &schema,
1528            &partition_cols,
1529            &[partition_eq.and(non_partition_eq), partition_in],
1530        );
1531        let partition_filters = exprs_to_filters(&partition_exprs);
1532        let all_filters = exprs_to_filters(&all_exprs);
1533
1534        let partition_fields = partition_filters
1535            .iter()
1536            .map(|(field, _, _)| field.as_str())
1537            .collect::<Vec<_>>();
1538        let all_fields = all_filters
1539            .iter()
1540            .map(|(field, _, _)| field.as_str())
1541            .collect::<Vec<_>>();
1542
1543        assert_eq!(partition_fields, ["byteField", "byteField"]);
1544        assert_eq!(all_fields, ["byteField", "name", "byteField"]);
1545    }
1546
1547    #[test]
1548    fn test_read_options_for_hudi_exec_strips_dropped_partition_filters() {
1549        let hudi_configs = HudiConfigs::new([
1550            (HudiTableConfig::DropsPartitionFields, "true"),
1551            (HudiTableConfig::PartitionFields, "region,country"),
1552        ]);
1553        let read_options = ReadOptions::new()
1554            .with_filters([
1555                ("region", "=", "us"),
1556                ("amount", ">", "10"),
1557                ("txns.country", "=", "ca"),
1558            ])
1559            .unwrap()
1560            .with_projection(["txn_id", "amount"]);
1561
1562        let actual = HudiDataSource::read_options_for_hudi_exec(&hudi_configs, &read_options);
1563
1564        assert_eq!(actual.filters.len(), 1);
1565        assert_eq!(actual.filters[0].field, "amount");
1566        assert_eq!(actual.projection, read_options.projection);
1567        assert_eq!(actual.hudi_options, read_options.hudi_options);
1568    }
1569
1570    #[tokio::test]
1571    async fn test_scan_hudi_keeps_inexact_non_partition_filters() {
1572        let hudi = HudiDataSource::new(V6SimplekeygenNonhivestyle.url_to_mor_parquet().as_str())
1573            .await
1574            .unwrap();
1575        let read_options = hudi
1576            .scan_read_options(
1577                vec![("id".to_string(), ">".to_string(), "1".to_string())],
1578                false,
1579            )
1580            .unwrap();
1581        let flat_slices = hudi.table.get_file_slices(&read_options).await.unwrap();
1582
1583        let plan = hudi
1584            .scan_hudi(None, None, 1, flat_slices, read_options)
1585            .await
1586            .unwrap();
1587        let exec = plan
1588            .downcast_ref::<HudiScanExec>()
1589            .expect("MOR snapshot scan should use HudiScanExec");
1590
1591        assert_eq!(exec.read_options().filters.len(), 1);
1592        let filter = &exec.read_options().filters[0];
1593        assert_eq!(filter.field, "id");
1594        assert_eq!(filter.values, vec!["1".to_string()]);
1595    }
1596
1597    /// A scan memory budget lowers the plan's slice concurrency; without one the
1598    /// configured ceiling stands.
1599    ///
1600    /// This asserts the wiring, not the arithmetic — `slices_in_flight` has its
1601    /// own tests. The wiring is what needs a test of its own: it is one call in
1602    /// `scan_hudi`, it compiles fine when absent, and the derivation it feeds
1603    /// looks correct in isolation either way. It went missing once already.
1604    ///
1605    /// `HudiScanExec`'s verbose display carries the value, so this reads the
1606    /// plan the planner actually built rather than a field.
1607    #[tokio::test]
1608    async fn a_scan_memory_budget_lowers_the_planned_slice_concurrency() {
1609        use datafusion::physical_plan::displayable;
1610
1611        async fn planned_concurrency(options: Vec<(&str, &str)>) -> String {
1612            let hudi = HudiDataSource::new_with_options(
1613                V6SimplekeygenNonhivestyle.url_to_mor_parquet().as_str(),
1614                options,
1615            )
1616            .await
1617            .unwrap();
1618            let ctx = SessionContext::new();
1619            let state = ctx.state();
1620            let plan = hudi.scan(&state, None, &[], None).await.unwrap();
1621            displayable(plan.as_ref())
1622                .set_show_schema(false)
1623                .indent(true)
1624                .to_string()
1625        }
1626
1627        let unbounded = planned_concurrency(vec![]).await;
1628        assert!(
1629            unbounded.contains("file_slice_read_concurrency=4"),
1630            "the default ceiling should stand with no budget: {unbounded}"
1631        );
1632
1633        // 1 MiB cannot fit even one slice's estimate, so admission floors at 1.
1634        let bounded =
1635            planned_concurrency(vec![("hoodie.read.scan.max.memory.size", "1048576")]).await;
1636        assert!(
1637            bounded.contains("file_slice_read_concurrency=1"),
1638            "a 1 MiB budget must lower the fan-out to 1: {bounded}"
1639        );
1640    }
1641
1642    #[tokio::test]
1643    async fn test_scan_mor_snapshot_keeps_partition_and_non_partition_filters_for_hudi_exec() {
1644        let hudi = HudiDataSource::new(V6SimplekeygenNonhivestyle.url_to_mor_parquet().as_str())
1645            .await
1646            .unwrap();
1647        let partition_filter = col_lit("byteField", Operator::Eq, ScalarValue::Int32(Some(10)));
1648        let non_partition_filter = col_lit("id", Operator::Gt, ScalarValue::Int32(Some(1)));
1649
1650        let filters =
1651            scan_hudi_exec_filter_triplets(&hudi, &[partition_filter, non_partition_filter]).await;
1652
1653        assert_scan_filter(&filters, "byteField", "=", &["10"]);
1654        assert_scan_filter(&filters, "id", ">", &["1"]);
1655    }
1656
1657    #[tokio::test]
1658    async fn test_scan_lance_keeps_partition_and_non_partition_filters_for_hudi_exec() {
1659        let hudi = HudiDataSource::new(V9LanceTxnsSimple.url_to_cow().as_str())
1660            .await
1661            .unwrap();
1662        let partition_filter = col_lit(
1663            "region",
1664            Operator::Eq,
1665            ScalarValue::Utf8(Some("us".to_string())),
1666        );
1667        let non_partition_filter = col_lit(
1668            "txn_id",
1669            Operator::Eq,
1670            ScalarValue::Utf8(Some("TXN-001".to_string())),
1671        );
1672
1673        let filters =
1674            scan_hudi_exec_filter_triplets(&hudi, &[partition_filter, non_partition_filter]).await;
1675
1676        assert_scan_filter(&filters, "region", "=", &["us"]);
1677        assert_scan_filter(&filters, "txn_id", "=", &["TXN-001"]);
1678    }
1679}