This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new c58be1f6 fix(scan): prune primary-key files with key stats (#698)
c58be1f6 is described below

commit c58be1f699d393eea3406733c217658677d8c706
Author: QuakeWang <[email protected]>
AuthorDate: Mon Aug 10 10:43:32 2026 +0800

    fix(scan): prune primary-key files with key stats (#698)
---
 crates/paimon/src/table/pk_vector_scan.rs |  43 ++++---
 crates/paimon/src/table/stats_filter.rs   | 165 +++++++++++++++++++--------
 crates/paimon/src/table/table_scan.rs     | 180 ++++++++++++++++++++++++++++--
 3 files changed, 312 insertions(+), 76 deletions(-)

diff --git a/crates/paimon/src/table/pk_vector_scan.rs 
b/crates/paimon/src/table/pk_vector_scan.rs
index d9681d1c..00bec032 100644
--- a/crates/paimon/src/table/pk_vector_scan.rs
+++ b/crates/paimon/src/table/pk_vector_scan.rs
@@ -752,17 +752,11 @@ mod tests {
             )
         }
 
-        /// Build a real single-file primary-key table via the public write 
path, in a
-        /// fresh temp dir. Persists the schema and writes one data batch, 
then commits
-        /// the written data file with real `value_stats` for the `id`/`score` 
columns.
+        /// Build a real single-file primary-key table via the public write 
path.
         ///
-        /// The stats injection mirrors the meta-modification the baseline 
fixture uses
-        /// for `level`/`file_source`: the Rust key-value (primary-key) writer 
records
-        /// column stats in `key_stats` and leaves `value_stats` empty, but 
scan-time
-        /// file pruning reads `value_stats`. Java primary-key writers 
populate value
-        /// stats, so committing them here makes the file prunable exactly as 
it would be
-        /// in a table written by the Java engine. Returns the temp dir (kept 
alive by
-        /// the caller) and the opened table.
+        /// The Rust key-value writer records primary-key stats in 
`key_stats`. For the
+        /// deletion-vector test, also populate `value_stats` so its non-key 
predicate
+        /// has the same metadata a Java primary-key writer produces.
         async fn build_pruning_test_table(
             with_deletion_vectors: bool,
         ) -> (tempfile::TempDir, Table) {
@@ -793,19 +787,24 @@ mod tests {
             let base_meta = written.new_files[0].clone();
             let bucket = written.bucket;
             let partition = written.partition.clone();
-
-            // Real value stats over the `id` (col 0) and `score` (col 1) 
columns, so a
-            // predicate outside the written [0, PRUNE_ROWS) range can prune 
the file.
-            let int = DataType::Int(IntType::new());
-            let value_stats: BinaryTableStats =
-                compute_column_stats(&batch, &[0, 1], &[int.clone(), 
int]).unwrap();
-            let indexed_meta = DataFileMeta {
-                value_stats,
-                value_stats_cols: Some(vec!["id".to_string(), 
"score".to_string()]),
-                ..base_meta
+            assert_eq!(base_meta.key_stats.null_counts(), &vec![Some(0)]);
+            assert!(base_meta.value_stats.null_counts().is_empty());
+            assert_eq!(base_meta.value_stats_cols, Some(vec![]));
+
+            let file_meta = if with_deletion_vectors {
+                let int = DataType::Int(IntType::new());
+                let value_stats: BinaryTableStats =
+                    compute_column_stats(&batch, &[0, 1], &[int.clone(), 
int]).unwrap();
+                DataFileMeta {
+                    value_stats,
+                    value_stats_cols: Some(vec!["id".to_string(), 
"score".to_string()]),
+                    ..base_meta
+                }
+            } else {
+                base_meta
             };
 
-            let message = CommitMessage::new(partition, bucket, 
vec![indexed_meta]);
+            let message = CommitMessage::new(partition, bucket, 
vec![file_meta]);
             TableCommit::new(table.clone(), "pkvector-prune".to_string())
                 .commit(vec![message])
                 .await
@@ -831,7 +830,7 @@ mod tests {
         }
 
         #[tokio::test]
-        async fn plan_prunes_file_when_pk_predicate_excludes_it() {
+        async fn plan_prunes_rust_pk_file_using_key_stats() {
             // Real PK table, one data file with id in [0, PRUNE_ROWS). A 
predicate
             // `id = OUT_OF_RANGE` cannot match the file's id stats, so the 
scan drops
             // the file and plan() returns no splits. Control (no filter) 
returns one.
diff --git a/crates/paimon/src/table/stats_filter.rs 
b/crates/paimon/src/table/stats_filter.rs
index 7de875c5..8e2f06b0 100644
--- a/crates/paimon/src/table/stats_filter.rs
+++ b/crates/paimon/src/table/stats_filter.rs
@@ -37,11 +37,26 @@ pub(super) struct FileStatsRows {
     null_counts: Vec<Option<i64>>,
     supports_in_min_max_pruning: bool,
     /// Maps schema field index → stats index. `None` means identity mapping
-    /// (stats cover all schema fields in order). `Some` is used when
-    /// `value_stats_cols` or `write_cols` is present (dense mode).
+    /// (stats cover all schema fields in order). `Some` maps dense stats such
+    /// as `value_stats_cols`, `write_cols`, or trimmed primary-key stats.
     stats_col_mapping: Option<Vec<Option<usize>>>,
 }
 
+fn dense_stats_col_mapping(
+    schema_fields: &[DataField],
+    stats_columns: &[String],
+) -> Vec<Option<usize>> {
+    let col_index: HashMap<&str, usize> = stats_columns
+        .iter()
+        .enumerate()
+        .map(|(i, c)| (c.as_str(), i))
+        .collect();
+    schema_fields
+        .iter()
+        .map(|field| col_index.get(field.name()).copied())
+        .collect()
+}
+
 impl FileStatsRows {
     /// Build a `FileStatsRows` for manifest-level partition stats (no column 
mapping).
     pub(super) fn for_manifest_partition(
@@ -66,31 +81,11 @@ impl FileStatsRows {
     /// When `value_stats_cols` is `Some`, stats are in dense mode — only 
covering those
     /// columns, and the mapping from schema field index to stats index is 
built by name.
     pub(super) fn from_data_file(file: &DataFileMeta, schema_fields: 
&[DataField]) -> Self {
-        let stats_col_mapping = if let Some(cols) = &file.value_stats_cols {
-            let col_index: HashMap<&str, usize> = cols
-                .iter()
-                .enumerate()
-                .map(|(i, c)| (c.as_str(), i))
-                .collect();
-            let mapping: Vec<Option<usize>> = schema_fields
-                .iter()
-                .map(|field| col_index.get(field.name()).copied())
-                .collect();
-            Some(mapping)
-        } else if let Some(cols) = &file.write_cols {
-            let col_index: HashMap<&str, usize> = cols
-                .iter()
-                .enumerate()
-                .map(|(i, c)| (c.as_str(), i))
-                .collect();
-            let mapping: Vec<Option<usize>> = schema_fields
-                .iter()
-                .map(|field| col_index.get(field.name()).copied())
-                .collect();
-            Some(mapping)
-        } else {
-            None
-        };
+        let stats_col_mapping = file
+            .value_stats_cols
+            .as_ref()
+            .or(file.write_cols.as_ref())
+            .map(|cols| dense_stats_col_mapping(schema_fields, cols));
 
         Self {
             row_count: file.row_count,
@@ -102,6 +97,25 @@ impl FileStatsRows {
         }
     }
 
+    /// Build file stats from `_KEY_STATS`.
+    ///
+    /// Key stats are dense and follow the table's trimmed primary-key order
+    /// (primary-key columns with partition columns removed).
+    pub(super) fn from_key_stats(
+        file: &DataFileMeta,
+        schema_fields: &[DataField],
+        key_fields: &[String],
+    ) -> Self {
+        Self {
+            row_count: file.row_count,
+            min_values: 
BinaryRow::from_serialized_bytes(file.key_stats.min_values()).ok(),
+            max_values: 
BinaryRow::from_serialized_bytes(file.key_stats.max_values()).ok(),
+            null_counts: file.key_stats.null_counts().clone(),
+            supports_in_min_max_pruning: true,
+            stats_col_mapping: Some(dense_stats_col_mapping(schema_fields, 
key_fields)),
+        }
+    }
+
     /// Resolve a schema field index to the corresponding stats index.
     fn stats_index(&self, schema_index: usize) -> Option<usize> {
         match &self.stats_col_mapping {
@@ -148,6 +162,7 @@ impl StatsAccessor for FileStatsRows {
 pub(super) struct ResolvedStatsSchema {
     file_fields: Vec<DataField>,
     field_mapping: Vec<Option<usize>>,
+    key_fields: Vec<String>,
 }
 
 fn identity_field_mapping(num_fields: usize) -> Vec<Option<usize>> {
@@ -165,41 +180,89 @@ fn normalize_field_mapping(mapping: Option<Vec<i32>>, 
num_fields: usize) -> Vec<
         .unwrap_or_else(|| identity_field_mapping(num_fields))
 }
 
+fn has_always_false(predicates: &[Predicate], key_predicates: &[Predicate]) -> 
bool {
+    predicates
+        .iter()
+        .chain(key_predicates)
+        .any(|p| matches!(p, Predicate::AlwaysFalse))
+}
+
+fn matches_file_stats(
+    file: &DataFileMeta,
+    predicates: &[Predicate],
+    key_predicates: &[Predicate],
+    field_mapping: &[Option<usize>],
+    schema_fields: &[DataField],
+    key_fields: &[String],
+) -> bool {
+    let value_stats = FileStatsRows::from_data_file(file, schema_fields);
+    if !predicates_may_match_with_schema(predicates, &value_stats, 
field_mapping, schema_fields) {
+        return false;
+    }
+
+    if key_predicates.is_empty() {
+        return true;
+    }
+
+    let key_stats = FileStatsRows::from_key_stats(file, schema_fields, 
key_fields);
+    predicates_may_match_with_schema(key_predicates, &key_stats, 
field_mapping, schema_fields)
+}
+
 /// Check whether a data file *may* contain rows matching all `predicates`.
 ///
+/// Value predicates are evaluated against `_VALUE_STATS`; primary-key
+/// predicates are additionally evaluated against `_KEY_STATS`. Both sources
+/// must report that the file may match.
+///
 /// Pruning is evaluated per file and fails open when stats cannot be
 /// interpreted safely, including schema mismatches, incompatible stats arity,
 /// and missing or corrupted stats.
-pub(super) fn data_file_matches_predicates(
+pub(super) fn data_file_matches_predicates_with_key_stats(
     file: &DataFileMeta,
     predicates: &[Predicate],
+    key_predicates: &[Predicate],
     current_schema_id: i64,
     schema_fields: &[DataField],
+    key_fields: &[String],
 ) -> bool {
-    if predicates.is_empty() {
+    if predicates.is_empty() && key_predicates.is_empty() {
         return true;
     }
 
-    if predicates
-        .iter()
-        .any(|p| matches!(p, Predicate::AlwaysFalse))
-    {
+    if has_always_false(predicates, key_predicates) {
         return false;
     }
-    if predicates
-        .iter()
-        .all(|p| matches!(p, Predicate::AlwaysTrue))
-    {
-        return true;
-    }
 
     if file.schema_id != current_schema_id {
         return true;
     }
 
-    let stats = FileStatsRows::from_data_file(file, schema_fields);
     let field_mapping = identity_field_mapping(schema_fields.len());
-    predicates_may_match_with_schema(predicates, &stats, &field_mapping, 
schema_fields)
+    matches_file_stats(
+        file,
+        predicates,
+        key_predicates,
+        &field_mapping,
+        schema_fields,
+        key_fields,
+    )
+}
+
+#[cfg(test)]
+pub(super) fn data_file_matches_predicates(
+    file: &DataFileMeta,
+    predicates: &[Predicate],
+    current_schema_id: i64,
+    schema_fields: &[DataField],
+) -> bool {
+    data_file_matches_predicates_with_key_stats(
+        file,
+        predicates,
+        &[],
+        current_schema_id,
+        schema_fields,
+        &[],
+    )
 }
 
 async fn resolve_stats_schema(
@@ -217,6 +280,7 @@ async fn resolve_stats_schema(
         Some(Arc::new(ResolvedStatsSchema {
             file_fields: current_fields.to_vec(),
             field_mapping: identity_field_mapping(current_fields.len()),
+            key_fields: table_schema.trimmed_primary_keys(),
         }))
     } else {
         let file_schema = 
table.schema_manager().schema(file_schema_id).await.ok()?;
@@ -227,6 +291,7 @@ async fn resolve_stats_schema(
                 current_fields.len(),
             ),
             file_fields,
+            key_fields: file_schema.trimmed_primary_keys(),
         }))
     };
 
@@ -238,18 +303,25 @@ pub(super) async fn 
data_file_matches_predicates_for_table(
     table: &Table,
     file: &DataFileMeta,
     predicates: &[Predicate],
+    key_predicates: &[Predicate],
     schema_cache: &mut HashMap<i64, Option<Arc<ResolvedStatsSchema>>>,
 ) -> bool {
-    if predicates.is_empty() {
+    if predicates.is_empty() && key_predicates.is_empty() {
         return true;
     }
 
+    if has_always_false(predicates, key_predicates) {
+        return false;
+    }
+
     if file.schema_id == table.schema().id() {
-        return data_file_matches_predicates(
+        return data_file_matches_predicates_with_key_stats(
             file,
             predicates,
+            key_predicates,
             table.schema().id(),
             table.schema().fields(),
+            &table.schema().trimmed_primary_keys(),
         );
     }
 
@@ -257,12 +329,13 @@ pub(super) async fn 
data_file_matches_predicates_for_table(
         return true;
     };
 
-    let stats = FileStatsRows::from_data_file(file, &resolved.file_fields);
-    predicates_may_match_with_schema(
+    matches_file_stats(
+        file,
         predicates,
-        &stats,
+        key_predicates,
         &resolved.field_mapping,
         &resolved.file_fields,
+        &resolved.key_fields,
     )
 }
 
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 7712eb82..eb79f95f 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -27,8 +27,8 @@ use 
super::global_index_types::normalize_sorted_global_index_type;
 use super::kv_file_reader::retain_primary_key_conjuncts;
 use super::partition_filter::PartitionFilter;
 use super::stats_filter::{
-    data_evolution_group_matches_predicates, data_file_matches_predicates,
-    data_file_matches_predicates_for_table, group_by_overlapping_row_id, 
FileStatsRows,
+    data_evolution_group_matches_predicates, 
data_file_matches_predicates_for_table,
+    data_file_matches_predicates_with_key_stats, group_by_overlapping_row_id, 
FileStatsRows,
     ResolvedStatsSchema,
 };
 use super::Table;
@@ -123,8 +123,10 @@ async fn read_all_manifest_entries(
     partition_filter: Option<&PartitionFilter>,
     partition_fields: &[DataField],
     data_predicates: &[Predicate],
+    key_predicates: &[Predicate],
     current_schema_id: i64,
     schema_fields: &[DataField],
+    key_fields: &[String],
     bucket_predicate: Option<&Predicate>,
     bucket_key_fields: &[DataField],
     bucket_function_type: BucketFunctionType,
@@ -239,12 +241,14 @@ async fn read_all_manifest_entries(
                         counters.pruned_by_level += 1;
                         continue;
                     }
-                    if !data_predicates.is_empty()
-                        && !data_file_matches_predicates(
+                    if (!data_predicates.is_empty() || 
!key_predicates.is_empty())
+                        && !data_file_matches_predicates_with_key_stats(
                             entry.file(),
                             data_predicates,
+                            key_predicates,
                             current_schema_id,
                             schema_fields,
+                            key_fields,
                         )
                     {
                         counters.pruned_by_data_stats += 1;
@@ -1163,6 +1167,8 @@ impl<'a> PaimonTableScan<'a> {
         } else {
             self.stats_pruning_predicates()
         };
+        let key_fields = self.table.schema().trimmed_primary_keys();
+        let pushdown_key_predicates = 
self.key_stats_predicates(&pushdown_data_predicates);
 
         let bucket_key_fields: Vec<DataField> = if 
self.bucket_predicate.is_none() {
             Vec::new()
@@ -1198,8 +1204,10 @@ impl<'a> PaimonTableScan<'a> {
             self.partition_filter.as_ref(),
             &partition_fields,
             &pushdown_data_predicates,
+            &pushdown_key_predicates,
             self.table.schema().id(),
             self.table.schema().fields(),
+            &key_fields,
             self.bucket_predicate.as_ref(),
             &bucket_key_fields,
             bucket_function_type,
@@ -1373,6 +1381,16 @@ impl<'a> PaimonTableScan<'a> {
         }
     }
 
+    /// Project file-safe predicates onto trimmed primary-key columns while
+    /// preserving table-schema field indices.
+    fn key_stats_predicates(&self, predicates: &[Predicate]) -> Vec<Predicate> 
{
+        let key_fields = self.table.schema().trimmed_primary_keys();
+        if key_fields.is_empty() {
+            return Vec::new();
+        }
+        retain_primary_key_conjuncts(predicates, self.table.schema().fields(), 
&key_fields)
+    }
+
     /// Plan data splits from a snapshot's delta manifest list (APPEND deltas).
     ///
     /// Reuses the same split-building path as a full snapshot plan, but only
@@ -1747,6 +1765,7 @@ impl<'a> PaimonTableScan<'a> {
         // For non-data-evolution tables, cross-schema files were kept 
(fail-open)
         // by the pushdown. Apply the full schema-aware filter for those files.
         let stats_pruning_predicates = self.stats_pruning_predicates();
+        let key_stats_predicates = 
self.key_stats_predicates(&stats_pruning_predicates);
         let entries = if stats_pruning_predicates.is_empty() || 
data_evolution_enabled {
             entries
         } else {
@@ -1770,6 +1789,7 @@ impl<'a> PaimonTableScan<'a> {
                             self.table,
                             entry.file(),
                             &stats_pruning_predicates,
+                            &key_stats_predicates,
                             &mut schema_cache,
                         )
                         .await
@@ -2065,16 +2085,17 @@ mod tests {
     use crate::io::FileIOBuilder;
     use crate::spec::{
         stats::BinaryTableStats, ArrayType, BinaryRow, BinaryRowBuilder, 
BucketFunctionType,
-        CommitKind, DataField, DataFileMeta, DataType, Datum, 
DeletionVectorMeta, FileKind,
-        IndexFileMeta, IndexManifestEntry, IntType, ManifestEntry, 
ManifestFileMeta, Predicate,
-        PredicateBuilder, PredicateOperator, Schema as PaimonSchema, Snapshot, 
TableSchema,
-        VarCharType,
+        ColumnMove, CommitKind, DataField, DataFileMeta, DataType, Datum, 
DeletionVectorMeta,
+        FileKind, IndexFileMeta, IndexManifestEntry, IntType, ManifestEntry, 
ManifestFileMeta,
+        Predicate, PredicateBuilder, PredicateOperator, Schema as 
PaimonSchema, SchemaChange,
+        Snapshot, TableSchema, VarCharType,
     };
     use crate::table::bucket_filter::{compute_target_buckets, 
extract_predicate_for_keys};
     use crate::table::partition_filter::PartitionFilter;
     use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile, 
RowRange};
     use crate::table::stats_filter::{
         data_evolution_group_matches_predicates, data_file_matches_predicates,
+        data_file_matches_predicates_for_table, 
data_file_matches_predicates_with_key_stats,
         group_by_overlapping_row_id,
     };
     use crate::table::{CommitMessage, Table, TableCommit};
@@ -3221,6 +3242,149 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn test_data_file_matches_composite_key_stats_in_primary_key_order() {
+        let fields = vec![
+            DataField::new(0, "key_a".to_string(), 
DataType::Int(IntType::new())),
+            DataField::new(1, "payload".to_string(), 
DataType::Int(IntType::new())),
+            DataField::new(2, "key_b".to_string(), 
DataType::Int(IntType::new())),
+        ];
+        let empty_stats = BinaryTableStats::empty();
+        let mut file = test_data_file_meta(
+            empty_stats.min_values().to_vec(),
+            empty_stats.max_values().to_vec(),
+            Vec::new(),
+            5,
+        );
+        file.value_stats_cols = Some(Vec::new());
+        // key_stats order is [key_b, key_a], independent of table field order.
+        file.key_stats = BinaryTableStats::new(
+            two_int_stats_row(Some(100), Some(10)),
+            two_int_stats_row(Some(200), Some(20)),
+            vec![Some(0), Some(0)],
+        );
+        let key_fields = vec!["key_b".to_string(), "key_a".to_string()];
+        let pb = PredicateBuilder::new(&fields);
+
+        let key_a_out_of_range = pb.equal("key_a", Datum::Int(150)).unwrap();
+        assert!(!data_file_matches_predicates_with_key_stats(
+            &file,
+            std::slice::from_ref(&key_a_out_of_range),
+            std::slice::from_ref(&key_a_out_of_range),
+            TEST_SCHEMA_ID,
+            &fields,
+            &key_fields,
+        ));
+
+        let key_b_in_range = pb.equal("key_b", Datum::Int(150)).unwrap();
+        assert!(data_file_matches_predicates_with_key_stats(
+            &file,
+            std::slice::from_ref(&key_b_in_range),
+            std::slice::from_ref(&key_b_in_range),
+            TEST_SCHEMA_ID,
+            &fields,
+            &key_fields,
+        ));
+    }
+
+    #[tokio::test]
+    async fn 
test_data_file_matches_key_stats_across_renamed_reordered_schema() {
+        let old_schema = TableSchema::new(
+            0,
+            &PaimonSchema::builder()
+                .column("payload", DataType::Int(IntType::new()))
+                .column("old_b", DataType::Int(IntType::new()))
+                .column("old_a", DataType::Int(IntType::new()))
+                .primary_key(["old_b", "old_a"])
+                .build()
+                .unwrap(),
+        );
+        let current_schema = old_schema
+            .apply_changes(vec![
+                SchemaChange::rename_column("old_b".to_string(), 
"new_b".to_string()),
+                SchemaChange::rename_column("old_a".to_string(), 
"new_a".to_string()),
+                
SchemaChange::update_column_position(ColumnMove::move_first("new_a".to_string())),
+            ])
+            .unwrap();
+        let table = Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("test_db", "cross_schema_key_stats"),
+            "memory:/cross_schema_key_stats".to_string(),
+            current_schema,
+            None,
+        );
+        write_schema_file(&table, &old_schema).await;
+
+        let empty_stats = BinaryTableStats::empty();
+        let mut file = test_data_file_meta_with_schema(
+            empty_stats.min_values().to_vec(),
+            empty_stats.max_values().to_vec(),
+            Vec::new(),
+            5,
+            old_schema.id(),
+        );
+        file.value_stats_cols = Some(Vec::new());
+        // Old key_stats order is [old_b, old_a]. Current fields are reordered 
to
+        // [new_a, payload, new_b], while field IDs remain [2, 0, 1].
+        file.key_stats = BinaryTableStats::new(
+            two_int_stats_row(Some(100), Some(10)),
+            two_int_stats_row(Some(200), Some(20)),
+            vec![Some(0), Some(0)],
+        );
+
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let new_a_out_of_range = pb.equal("new_a", Datum::Int(150)).unwrap();
+        let new_b_in_range = pb.equal("new_b", Datum::Int(150)).unwrap();
+        let mut schema_cache = HashMap::new();
+
+        assert!(
+            !data_file_matches_predicates_for_table(
+                &table,
+                &file,
+                std::slice::from_ref(&new_a_out_of_range),
+                std::slice::from_ref(&new_a_out_of_range),
+                &mut schema_cache,
+            )
+            .await
+        );
+        assert!(
+            data_file_matches_predicates_for_table(
+                &table,
+                &file,
+                std::slice::from_ref(&new_b_in_range),
+                std::slice::from_ref(&new_b_in_range),
+                &mut schema_cache,
+            )
+            .await
+        );
+    }
+
+    #[test]
+    fn test_data_file_matches_corrupt_key_stats_fails_open() {
+        let fields = int_field();
+        let empty_stats = BinaryTableStats::empty();
+        let mut file = test_data_file_meta(
+            empty_stats.min_values().to_vec(),
+            empty_stats.max_values().to_vec(),
+            Vec::new(),
+            5,
+        );
+        file.value_stats_cols = Some(Vec::new());
+        file.key_stats = BinaryTableStats::new(vec![0], vec![0], 
vec![Some(0)]);
+        let predicate = PredicateBuilder::new(&fields)
+            .equal("id", Datum::Int(30))
+            .unwrap();
+
+        assert!(data_file_matches_predicates_with_key_stats(
+            &file,
+            std::slice::from_ref(&predicate),
+            std::slice::from_ref(&predicate),
+            TEST_SCHEMA_ID,
+            &fields,
+            &["id".to_string()],
+        ));
+    }
+
     #[tokio::test]
     async fn test_plan_with_trace_records_between_data_stats_pruning() {
         let table_path = 
"memory:/test_plan_with_trace_records_between_data_stats_pruning";

Reply via email to