1#![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#[derive(Clone)]
137pub struct HudiDataSource {
138 table: Arc<HudiTable>,
139 options: HashMap<String, String>,
140 schema: SchemaRef,
144 partition_schema: Schema,
148 cached_stats: Option<Statistics>,
163 input_partitions: usize,
165 read_optimized_mode: bool,
167 file_slice_read_concurrency: usize,
169 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 pub async fn new(base_uri: &str) -> Result<Self> {
193 Self::new_with_options(base_uri, empty_options()).await
194 }
195
196 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 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 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 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 pub fn base_uri(&self) -> String {
337 self.table.base_url().to_string()
338 }
339
340 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 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 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 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 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 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 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 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 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 Self::can_push_down_expr(schema, left)
509 || Self::can_push_down_expr(schema, right)
510 }
511 Operator::Or => {
512 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 Self::can_push_down_expr(schema, inner_expr)
525 }
526 Expr::Between(between) => {
527 !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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug)]
1042pub struct HudiTableFactory {}
1043
1044impl HudiTableFactory {
1045 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.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 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 #[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 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}