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 1fe06d2  Apply exact predicate filtering on the primary-key merge read 
path (#463)
1fe06d2 is described below

commit 1fe06d2a886f859bdd29852a8ceb7c76bb68955e
Author: Junrui Lee <[email protected]>
AuthorDate: Tue Jul 7 21:07:03 2026 +0800

    Apply exact predicate filtering on the primary-key merge read path (#463)
---
 crates/paimon/src/table/kv_file_reader.rs | 852 +++++++++++++++++++++++++++++-
 crates/paimon/src/table/read_builder.rs   |   9 +-
 crates/paimon/src/table/table_read.rs     |   6 +-
 crates/paimon/src/table/table_scan.rs     | 215 +++++++-
 4 files changed, 1041 insertions(+), 41 deletions(-)

diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index 6d3e4e5..2a6224b 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -20,6 +20,8 @@
 //! Each data file in a split is read as a separate sorted stream. The streams
 //! are merged by primary key using a LoserTree, and rows with the same key are
 //! deduplicated by keeping the one with the highest `_SEQUENCE_NUMBER`.
+//! Non-primary-key predicate conjuncts are enforced by an exact post-merge
+//! residual filter; only primary-key conjuncts are pushed below the merge.
 //!
 //! Reference: Java Paimon `SortMergeReaderWithMinHeap`.
 
@@ -48,6 +50,11 @@ use std::collections::HashMap;
 pub(crate) struct KeyValueFileReader {
     file_io: FileIO,
     config: KeyValueReadConfig,
+    /// PK-only conjuncts pushed down to the per-file readers before merge.
+    /// Non-PK conjuncts must not run pre-merge (they can change which version
+    /// of a key survives); they are enforced by the post-merge residual
+    /// filter using the full `config.predicates` instead.
+    pushdown_predicates: Vec<Predicate>,
 }
 
 /// Configuration for [`KeyValueFileReader`], grouping table schema and
@@ -65,37 +72,49 @@ pub(crate) struct KeyValueReadConfig {
     pub sequence_fields: Vec<String>,
 }
 
+/// Keep only the conjuncts of `predicates` that reference primary-key columns,
+/// preserving table-schema field indices. Mixed `AND`s keep their PK children;
+/// `OR`/`NOT` require every child to be PK-only (see
+/// [`Predicate::project_field_index_inclusive`]).
+///
+/// Used for pre-merge pushdown in [`KeyValueFileReader`] and for per-file
+/// stats pruning of primary-key tables in scan planning: a key's versions all
+/// share the key columns, so key conjuncts can never drop one version of a
+/// key while keeping another — non-key conjuncts can, which corrupts merge.
+pub(super) fn retain_primary_key_conjuncts(
+    predicates: &[Predicate],
+    table_fields: &[DataField],
+    primary_keys: &[String],
+) -> Vec<Predicate> {
+    let pk_set: std::collections::HashSet<&str> = primary_keys.iter().map(|s| 
s.as_str()).collect();
+    let mapping: Vec<Option<usize>> = table_fields
+        .iter()
+        .enumerate()
+        .map(|(i, f)| {
+            if pk_set.contains(f.name()) {
+                Some(i)
+            } else {
+                None
+            }
+        })
+        .collect();
+    predicates
+        .iter()
+        .filter_map(|p| p.project_field_index_inclusive(&mapping))
+        .collect()
+}
+
 impl KeyValueFileReader {
     pub(crate) fn new(file_io: FileIO, config: KeyValueReadConfig) -> Self {
-        // Only keep predicates that reference primary key columns.
-        // Non-PK predicates applied before merge can cause incorrect results.
-        // Use project_field_index_inclusive: AND keeps PK children, OR 
requires all PK.
-        let pk_set: std::collections::HashSet<&str> =
-            config.primary_keys.iter().map(|s| s.as_str()).collect();
-        let mapping: Vec<Option<usize>> = config
-            .table_fields
-            .iter()
-            .enumerate()
-            .map(|(i, f)| {
-                if pk_set.contains(f.name()) {
-                    Some(i)
-                } else {
-                    None
-                }
-            })
-            .collect();
-        let pk_predicates = config
-            .predicates
-            .into_iter()
-            .filter_map(|p| p.project_field_index_inclusive(&mapping))
-            .collect();
-
+        let pushdown_predicates = retain_primary_key_conjuncts(
+            &config.predicates,
+            &config.table_fields,
+            &config.primary_keys,
+        );
         Self {
             file_io,
-            config: KeyValueReadConfig {
-                predicates: pk_predicates,
-                ..config
-            },
+            config,
+            pushdown_predicates,
         }
     }
 
@@ -193,6 +212,22 @@ impl KeyValueFileReader {
             }
         }
 
+        // Widen with predicate columns not already read so the post-merge
+        // residual filter can evaluate every leaf (predicate leaf indices are
+        // table-schema positions). Extras ride through the merge as ordinary
+        // value columns — partial-update/aggregation apply their configured
+        // per-field semantics to them, so the residual sees properly MERGED
+        // values — and the read_type reorder below drops them from the output.
+        let residual_file_predicates =
+            (!self.config.predicates.is_empty()).then(|| 
crate::arrow::format::FilePredicates {
+                predicates: self.config.predicates.clone(),
+                file_fields: self.config.table_fields.clone(),
+            });
+        let user_fields = crate::arrow::residual::widen_scan_fields(
+            &user_fields,
+            residual_file_predicates.as_ref(),
+        );
+
         // Internal read type: [_SEQ, _VK, user_fields...]
         let mut internal_read_type: Vec<DataField> = Vec::new();
         internal_read_type.push(seq_field);
@@ -274,7 +309,8 @@ impl KeyValueFileReader {
         let table_fields = self.config.table_fields;
         let table_name = self.config.table_name;
         let table_options = self.config.table_options;
-        let predicates = self.config.predicates;
+        let pushdown_predicates = self.pushdown_predicates;
+        let residual_predicates = self.config.predicates;
         let primary_keys = self.config.primary_keys;
         let sequence_fields = self.config.sequence_fields;
 
@@ -313,7 +349,7 @@ impl KeyValueFileReader {
                         table_schema_id,
                         table_fields.clone(),
                         internal_read_type.clone(),
-                        predicates.clone(),
+                        pushdown_predicates.clone(),
                     );
 
                     let stream = reader.read_single_file_stream(
@@ -355,6 +391,36 @@ impl KeyValueFileReader {
 
                 while let Some(batch) = merge_stream.next().await {
                     let batch = batch?;
+                    // Post-merge residual: enforce the FULL data predicate on
+                    // merged rows. PK conjuncts are also in this set (they 
were
+                    // already pushed down pre-merge); re-evaluating them on
+                    // already-matching rows is a no-op and keeps one shared
+                    // evaluator instead of deriving a non-PK subset. Runs on
+                    // the merge-output batch (keys + values, including widened
+                    // predicate columns); the reorder below projects the
+                    // output back to read_type.
+                    let batch = if residual_predicates.is_empty() {
+                        batch
+                    } else {
+                        match crate::arrow::residual::evaluate_predicates_mask(
+                            &batch,
+                            &residual_predicates,
+                            &table_fields,
+                            &merge_output_fields,
+                        )? {
+                            Some(mask) => {
+                                
arrow_select::filter::filter_record_batch(&batch, &mask).map_err(
+                                    |e| Error::DataInvalid {
+                                        message: format!(
+                                            "Failed to filter merged batch by 
predicates: {e}"
+                                        ),
+                                        source: Some(Box::new(e)),
+                                    },
+                                )?
+                            }
+                            None => batch,
+                        }
+                    };
                     // Reorder columns from [keys..., values...] to read_type 
order.
                     let columns: Vec<_> = reorder_map
                         .iter()
@@ -377,3 +443,731 @@ impl KeyValueFileReader {
         .boxed())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::catalog::Identifier;
+    use crate::io::FileIOBuilder;
+    use crate::spec::{DataType, Datum, IntType, PredicateBuilder, Schema, 
TableSchema};
+    use crate::table::table_commit::TableCommit;
+    use crate::table::{Table, TableWrite};
+    use arrow_array::{Array, Int32Array};
+    use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema 
as ArrowSchema};
+    use std::sync::Arc;
+
+    fn test_file_io() -> FileIO {
+        FileIOBuilder::new("memory").build().unwrap()
+    }
+
+    fn pk_table(file_io: &FileIO, table_path: &str, options: &[(&str, &str)]) 
-> Table {
+        let mut builder = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("bucket", "1");
+        for (key, value) in options {
+            builder = builder.option(*key, *value);
+        }
+        Table::new(
+            file_io.clone(),
+            Identifier::new("default", "kv_residual_t"),
+            table_path.to_string(),
+            TableSchema::new(0, &builder.build().unwrap()),
+            None,
+        )
+    }
+
+    async fn setup_dirs(file_io: &FileIO, table_path: &str) {
+        file_io
+            .mkdirs(&format!("{table_path}/snapshot/"))
+            .await
+            .unwrap();
+        file_io
+            .mkdirs(&format!("{table_path}/manifest/"))
+            .await
+            .unwrap();
+    }
+
+    fn int_batch(ids: Vec<i32>, values: Vec<Option<i32>>) -> RecordBatch {
+        let schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, false),
+            ArrowField::new("value", ArrowDataType::Int32, true),
+        ]));
+        RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(Int32Array::from(ids)),
+                Arc::new(Int32Array::from(values)),
+            ],
+        )
+        .unwrap()
+    }
+
+    fn evo_batch(ids: Vec<i32>, values: Vec<Option<i32>>, scores: 
Vec<Option<i32>>) -> RecordBatch {
+        let schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, false),
+            ArrowField::new("value", ArrowDataType::Int32, true),
+            ArrowField::new("score", ArrowDataType::Int32, true),
+        ]));
+        RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(Int32Array::from(ids)),
+                Arc::new(Int32Array::from(values)),
+                Arc::new(Int32Array::from(scores)),
+            ],
+        )
+        .unwrap()
+    }
+
+    /// User schema for the evolution fixture: `id INT pk, value INT` at
+    /// version 0, plus `score INT` (new field id 2) at version 1. Field ids
+    /// line up across versions exactly as a real ADD COLUMN produces.
+    fn evo_user_schema(with_score: bool) -> Schema {
+        let mut builder = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()));
+        if with_score {
+            builder = builder.column("score", DataType::Int(IntType::new()));
+        }
+        builder
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .build()
+            .unwrap()
+    }
+
+    /// Persist a schema version as `{table_path}/schema/schema-{id}` JSON so
+    /// `SchemaManager::schema` can resolve old-file schemas at read time. The
+    /// write path only stamps `DataFileMeta.schema_id`; schema files are
+    /// normally written by the catalog, which these fixtures bypass. Follows
+    /// the `write_schema_file` pattern from the table_scan tests.
+    async fn write_schema_file(table: &Table, schema: &TableSchema) {
+        let path = table.schema_manager().schema_path(schema.id());
+        let dir = path.rsplit_once('/').map(|(dir, _)| dir).unwrap();
+        table.file_io().mkdirs(dir).await.unwrap();
+        let json = serde_json::to_vec(schema).unwrap();
+        table
+            .file_io()
+            .new_output(&path)
+            .unwrap()
+            .write(bytes::Bytes::from(json))
+            .await
+            .unwrap();
+    }
+
+    async fn write_commit(table: &Table, batch: &RecordBatch) {
+        let mut tw = TableWrite::new(table, "test-user".to_string()).unwrap();
+        tw.write_arrow_batch(batch).await.unwrap();
+        let msgs = tw.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(msgs)
+            .await
+            .unwrap();
+    }
+
+    async fn read_rows(
+        table: &Table,
+        projection: Option<&[&str]>,
+        filter: Option<Predicate>,
+    ) -> Vec<RecordBatch> {
+        let mut rb = table.new_read_builder();
+        if let Some(cols) = projection {
+            rb.with_projection(cols).unwrap();
+        }
+        if let Some(f) = filter {
+            rb.with_filter(f);
+        }
+        let plan = rb.new_scan().plan().await.unwrap();
+        let read = rb.new_read().unwrap();
+        
futures::TryStreamExt::try_collect(read.to_arrow(plan.splits()).unwrap())
+            .await
+            .unwrap()
+    }
+
+    fn int_column(batches: &[RecordBatch], name: &str) -> Vec<i32> {
+        batches
+            .iter()
+            .flat_map(|b| {
+                let idx = b.schema().index_of(name).unwrap();
+                let arr = 
b.column(idx).as_any().downcast_ref::<Int32Array>().unwrap();
+                (0..arr.len()).map(|i| arr.value(i)).collect::<Vec<_>>()
+            })
+            .collect()
+    }
+
+    #[test]
+    fn retain_primary_key_conjuncts_semantics() {
+        let fields = vec![
+            DataField::new(0, "id".to_string(), 
PaimonDataType::Int(IntType::new())),
+            DataField::new(1, "value".to_string(), 
PaimonDataType::Int(IntType::new())),
+        ];
+        let pks = vec!["id".to_string()];
+        let pb = PredicateBuilder::new(&fields);
+
+        // Plain PK leaf: kept. Plain non-PK leaf: dropped.
+        let kept =
+            retain_primary_key_conjuncts(&[pb.equal("id", 
Datum::Int(1)).unwrap()], &fields, &pks);
+        assert_eq!(kept.len(), 1);
+        let dropped = retain_primary_key_conjuncts(
+            &[pb.equal("value", Datum::Int(1)).unwrap()],
+            &fields,
+            &pks,
+        );
+        assert!(dropped.is_empty());
+
+        // Mixed AND keeps the PK child only.
+        let mixed = Predicate::and(vec![
+            pb.equal("id", Datum::Int(1)).unwrap(),
+            pb.equal("value", Datum::Int(2)).unwrap(),
+        ]);
+        let kept = retain_primary_key_conjuncts(&[mixed], &fields, &pks);
+        assert_eq!(kept.len(), 1);
+        assert!(matches!(&kept[0], Predicate::Leaf { index: 0, .. }));
+
+        // OR with a non-PK child: dropped entirely (cannot be tightened).
+        let or = Predicate::or(vec![
+            pb.equal("id", Datum::Int(1)).unwrap(),
+            pb.equal("value", Datum::Int(2)).unwrap(),
+        ]);
+        assert!(retain_primary_key_conjuncts(&[or], &fields, &pks).is_empty());
+
+        // Constant predicates reference no columns and must survive the PK
+        // trim verbatim. The post-merge residual (full predicate set) would
+        // still mask every row to false if AlwaysFalse were dropped here, but
+        // the scan/pushdown layers would lose their prune-everything fast
+        // path (stats_filter treats any AlwaysFalse as prune-all).
+        let kept = retain_primary_key_conjuncts(&[Predicate::AlwaysFalse], 
&fields, &pks);
+        assert_eq!(kept.len(), 1);
+        assert!(matches!(&kept[0], Predicate::AlwaysFalse));
+        let kept = retain_primary_key_conjuncts(&[Predicate::AlwaysTrue], 
&fields, &pks);
+        assert_eq!(kept.len(), 1);
+        assert!(matches!(&kept[0], Predicate::AlwaysTrue));
+    }
+
+    /// Non-PK equality filter on a dedup PK table read through the sort-merge
+    /// path must return only matching rows. Before the post-merge residual,
+    /// the non-PK conjunct was silently dropped and all rows came back.
+    #[tokio::test]
+    async fn kv_read_applies_non_pk_filter_exactly() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_eq";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        // Overlapping keys across two commits -> split is not raw convertible
+        // -> forced through KeyValueFileReader.
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let fields = table.schema().fields().to_vec();
+        let filter = PredicateBuilder::new(&fields)
+            .equal("value", Datum::Int(21))
+            .unwrap();
+        let batches = read_rows(&table, None, Some(filter)).await;
+
+        assert_eq!(int_column(&batches, "id"), vec![2]);
+        assert_eq!(int_column(&batches, "value"), vec![21]);
+    }
+
+    /// Gap-A: the predicate column is NOT in the projection. The merge read
+    /// must widen internally, filter, then project back — output schema must
+    /// contain only the projected column.
+    #[tokio::test]
+    async fn kv_read_filters_on_unprojected_column() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_gap_a";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let fields = table.schema().fields().to_vec();
+        let filter = PredicateBuilder::new(&fields)
+            .equal("value", Datum::Int(21))
+            .unwrap();
+        let batches = read_rows(&table, Some(&["id"]), Some(filter)).await;
+
+        assert_eq!(int_column(&batches, "id"), vec![2]);
+        for batch in &batches {
+            assert_eq!(
+                batch.num_columns(),
+                1,
+                "widened predicate column must not leak into the output"
+            );
+            assert_eq!(batch.schema().field(0).name(), "id");
+        }
+    }
+
+    /// Regression: PK-column filters were already exact (pushed down pre-merge
+    /// AND now re-checked in the residual). Must stay exact.
+    #[tokio::test]
+    async fn kv_read_pk_filter_still_exact() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_pk";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let fields = table.schema().fields().to_vec();
+        let filter = PredicateBuilder::new(&fields)
+            .equal("id", Datum::Int(2))
+            .unwrap();
+        let batches = read_rows(&table, None, Some(filter)).await;
+
+        assert_eq!(int_column(&batches, "id"), vec![2]);
+        assert_eq!(int_column(&batches, "value"), vec![21]);
+    }
+
+    /// A filter matching only a superseded version must return nothing: the
+    /// newer version wins the merge first, THEN the filter runs. If the full
+    /// predicate leaked below the merge, the stale (2, 20) row would survive
+    /// its file's scan, win against nothing, and leak into the output.
+    #[tokio::test]
+    async fn kv_read_filter_on_superseded_value_returns_nothing() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_superseded";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let fields = table.schema().fields().to_vec();
+        let filter = PredicateBuilder::new(&fields)
+            .equal("value", Datum::Int(20))
+            .unwrap();
+        let batches = read_rows(&table, None, Some(filter)).await;
+
+        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
+        assert_eq!(
+            total, 0,
+            "superseded value must not resurrect through the filter"
+        );
+    }
+
+    /// Compound residual `value > 15 AND value < 25` on merged values.
+    #[tokio::test]
+    async fn kv_read_applies_compound_range_filter() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_range";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let fields = table.schema().fields().to_vec();
+        let pb = PredicateBuilder::new(&fields);
+        let filter = Predicate::and(vec![
+            pb.greater_than("value", Datum::Int(15)).unwrap(),
+            pb.less_than("value", Datum::Int(25)).unwrap(),
+        ]);
+        let batches = read_rows(&table, None, Some(filter)).await;
+
+        assert_eq!(int_column(&batches, "id"), vec![2]);
+        assert_eq!(int_column(&batches, "value"), vec![21]);
+    }
+
+    /// COUNT(*)-style read: empty projection + non-PK filter. The residual
+    /// runs on the pre-reorder merge batch (which still has columns), and the
+    /// zero-column output batch must carry the filtered row count.
+    #[tokio::test]
+    async fn kv_read_empty_projection_with_filter_keeps_row_count() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_count";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let fields = table.schema().fields().to_vec();
+        let filter = PredicateBuilder::new(&fields)
+            .greater_than("value", Datum::Int(15))
+            .unwrap();
+        let batches = read_rows(&table, Some(&[] as &[&str]), 
Some(filter)).await;
+
+        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
+        assert_eq!(total, 2, "only merged rows with value > 15 (21, 31) 
count");
+        for batch in &batches {
+            assert_eq!(batch.num_columns(), 0);
+        }
+    }
+
+    /// String residual op (starts_with) on a value column — exercises the
+    /// residual string kernel on the KV path.
+    #[tokio::test]
+    async fn kv_read_applies_string_starts_with_filter() {
+        use crate::spec::VarCharType;
+        use arrow_array::StringArray;
+
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_string";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("name", DataType::VarChar(VarCharType::string_type()))
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "kv_residual_string_t"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, false),
+            ArrowField::new("name", ArrowDataType::Utf8, true),
+        ]));
+        let make = |ids: Vec<i32>, names: Vec<&str>| {
+            RecordBatch::try_new(
+                arrow_schema.clone(),
+                vec![
+                    Arc::new(Int32Array::from(ids)),
+                    Arc::new(StringArray::from(names)),
+                ],
+            )
+            .unwrap()
+        };
+
+        write_commit(
+            &table,
+            &make(vec![1, 2, 3], vec!["apple", "banana", "apricot"]),
+        )
+        .await;
+        write_commit(&table, &make(vec![2], vec!["avocado"])).await;
+
+        let fields = table.schema().fields().to_vec();
+        let filter = PredicateBuilder::new(&fields)
+            .starts_with("name", Datum::String("a".to_string()))
+            .unwrap();
+        let batches = read_rows(&table, None, Some(filter)).await;
+
+        // Merged rows: (1, apple), (2, avocado), (3, apricot) — all start 
with 'a'.
+        // The overwritten (2, banana) must not resurrect; if the filter ran
+        // pre-merge it would also be wrong the other way (banana dropped, but
+        // then avocado wins anyway — so also assert the merged VALUE).
+        let mut ids = int_column(&batches, "id");
+        ids.sort_unstable();
+        assert_eq!(ids, vec![1, 2, 3]);
+        let names: Vec<String> = batches
+            .iter()
+            .flat_map(|b| {
+                let idx = b.schema().index_of("name").unwrap();
+                let arr = b
+                    .column(idx)
+                    .as_any()
+                    .downcast_ref::<StringArray>()
+                    .unwrap();
+                (0..arr.len())
+                    .map(|i| arr.value(i).to_string())
+                    .collect::<Vec<_>>()
+            })
+            .collect();
+        assert!(names.contains(&"avocado".to_string()));
+        assert!(!names.contains(&"banana".to_string()));
+    }
+
+    /// Aggregation (sum): inputs 10 + 20 merge to 30. `value = 30` must match
+    /// the merged row (a pre-merge filter would drop both inputs);
+    /// `value = 10` must match nothing (a pre-merge filter would keep the
+    /// 10-input and leak it).
+    #[tokio::test]
+    async fn kv_read_aggregation_filters_on_merged_value() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_agg";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(
+            &file_io,
+            table_path,
+            &[
+                ("merge-engine", "aggregation"),
+                ("fields.value.aggregate-function", "sum"),
+            ],
+        );
+
+        write_commit(&table, &int_batch(vec![1], vec![Some(10)])).await;
+        write_commit(&table, &int_batch(vec![1], vec![Some(20)])).await;
+
+        let fields = table.schema().fields().to_vec();
+
+        let match_merged = PredicateBuilder::new(&fields)
+            .equal("value", Datum::Int(30))
+            .unwrap();
+        let batches = read_rows(&table, None, Some(match_merged)).await;
+        assert_eq!(int_column(&batches, "id"), vec![1]);
+        assert_eq!(int_column(&batches, "value"), vec![30]);
+
+        let match_input = PredicateBuilder::new(&fields)
+            .equal("value", Datum::Int(10))
+            .unwrap();
+        let batches = read_rows(&table, None, Some(match_input)).await;
+        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
+        assert_eq!(total, 0, "pre-merge input value must not leak through");
+    }
+
+    /// Aggregation + Gap-A: the aggregated predicate column is unprojected.
+    /// The widened column must be aggregated with its configured function
+    /// (sum), not treated as a plain latest-value column.
+    #[tokio::test]
+    async fn kv_read_aggregation_filters_merged_value_unprojected() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_agg_gap_a";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(
+            &file_io,
+            table_path,
+            &[
+                ("merge-engine", "aggregation"),
+                ("fields.value.aggregate-function", "sum"),
+            ],
+        );
+
+        write_commit(&table, &int_batch(vec![1], vec![Some(10)])).await;
+        write_commit(&table, &int_batch(vec![1], vec![Some(20)])).await;
+
+        let fields = table.schema().fields().to_vec();
+        let filter = PredicateBuilder::new(&fields)
+            .equal("value", Datum::Int(30))
+            .unwrap();
+        let batches = read_rows(&table, Some(&["id"]), Some(filter)).await;
+        assert_eq!(int_column(&batches, "id"), vec![1]);
+    }
+
+    /// Partial-update: (1, a=5, b=NULL) then (1, a=NULL, b=7) merge to
+    /// (1, 5, 7). A conjunction over both columns only matches the MERGED row
+    /// — no single input row satisfies it.
+    #[tokio::test]
+    async fn kv_read_partial_update_filters_on_merged_row() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_pu";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("a", DataType::Int(IntType::new()))
+            .column("b", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .option("merge-engine", "partial-update")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "kv_residual_pu_t"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, false),
+            ArrowField::new("a", ArrowDataType::Int32, true),
+            ArrowField::new("b", ArrowDataType::Int32, true),
+        ]));
+        let make = |ids: Vec<i32>, a: Vec<Option<i32>>, b: Vec<Option<i32>>| {
+            RecordBatch::try_new(
+                arrow_schema.clone(),
+                vec![
+                    Arc::new(Int32Array::from(ids)),
+                    Arc::new(Int32Array::from(a)),
+                    Arc::new(Int32Array::from(b)),
+                ],
+            )
+            .unwrap()
+        };
+
+        write_commit(&table, &make(vec![1], vec![Some(5)], vec![None])).await;
+        write_commit(&table, &make(vec![1], vec![None], vec![Some(7)])).await;
+
+        let fields = table.schema().fields().to_vec();
+        let pb = PredicateBuilder::new(&fields);
+        let filter = Predicate::and(vec![
+            pb.equal("a", Datum::Int(5)).unwrap(),
+            pb.equal("b", Datum::Int(7)).unwrap(),
+        ]);
+        let batches = read_rows(&table, None, Some(filter)).await;
+
+        assert_eq!(int_column(&batches, "id"), vec![1]);
+        assert_eq!(int_column(&batches, "a"), vec![5]);
+        assert_eq!(int_column(&batches, "b"), vec![7]);
+    }
+
+    /// An AlwaysFalse filter on a PK table must return nothing, end to end.
+    /// Two layers enforce it: scan-side stats pruning treats AlwaysFalse as
+    /// prune-everything (plans no files), and the post-merge residual masks
+    /// every row to false. This locks the composed contract regardless of
+    /// which layer short-circuits first.
+    #[tokio::test]
+    async fn kv_read_always_false_filter_returns_nothing() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_always_false";
+        setup_dirs(&file_io, table_path).await;
+        let table = pk_table(&file_io, table_path, &[]);
+
+        // Overlapping keys across two commits -> split is not raw convertible
+        // -> forced through KeyValueFileReader.
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table,
+            &int_batch(vec![1, 2, 3], vec![Some(11), Some(21), Some(31)]),
+        )
+        .await;
+
+        let batches = read_rows(&table, None, 
Some(Predicate::AlwaysFalse)).await;
+
+        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
+        assert_eq!(total, 0, "AlwaysFalse must return no rows on a PK table");
+    }
+
+    /// Schema evolution on the KV residual path: a predicate column that is
+    /// MISSING from an old-schema file is null-filled pre-merge by
+    /// DataFileReader; the post-merge residual must treat those NULLs as
+    /// non-matching (comparison mask NULL -> false), and `is_null` must match
+    /// exactly them. Locks the null-fill -> merge -> residual composition; the
+    /// shared evaluator's semantics are already locked on the data-evolution
+    /// path (`test_evolution_read_null_filled_predicate_column_semantics`).
+    ///
+    /// Setup: commit 1 goes through a Table at schema 0 (id, value), stamping
+    /// schema_id 0 into its files; commit 2 goes through a Table at the same
+    /// path at schema 1 (id, value, score — new field id). Both schema JSONs
+    /// are persisted so `SchemaManager::schema(0)` resolves at read time. Keys
+    /// overlap across commits so the split is not raw convertible and routes
+    /// through the KV merge reader.
+    #[tokio::test]
+    async fn kv_read_schema_evolution_null_filled_predicate_semantics() {
+        let file_io = test_file_io();
+        let table_path = "memory:/kv_residual_schema_evolution";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema0 = TableSchema::new(0, &evo_user_schema(false));
+        let schema1 = TableSchema::new(1, &evo_user_schema(true));
+        let table_v0 = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "kv_residual_evo_t"),
+            table_path.to_string(),
+            schema0.clone(),
+            None,
+        );
+        let table_v1 = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "kv_residual_evo_t"),
+            table_path.to_string(),
+            schema1.clone(),
+            None,
+        );
+        write_schema_file(&table_v1, &schema0).await;
+        write_schema_file(&table_v1, &schema1).await;
+
+        // Commit 1 at schema 0: files carry schema_id 0. Commit 2 at schema 1
+        // overwrites key 3, so file_meta.schema_id != table_schema_id holds 
for
+        // the old files when reading through table_v1, forcing the null-fill
+        // remap in read() -> DataFileReader::read_single_file_stream.
+        write_commit(
+            &table_v0,
+            &int_batch(vec![1, 2, 3], vec![Some(10), Some(20), Some(30)]),
+        )
+        .await;
+        write_commit(
+            &table_v1,
+            &evo_batch(vec![3], vec![Some(31)], vec![Some(300)]),
+        )
+        .await;
+
+        // Merged rows: (1, 10, NULL), (2, 20, NULL), (3, 31, 300).
+        let fields = table_v1.schema().fields().to_vec();
+        let pb = PredicateBuilder::new(&fields);
+
+        // Comparison: score = 300 matches only id 3; old rows' null-filled
+        // score must collapse to false, not match or error.
+        let filter = pb.equal("score", Datum::Int(300)).unwrap();
+        let batches = read_rows(&table_v1, None, Some(filter)).await;
+        assert_eq!(int_column(&batches, "id"), vec![3]);
+        assert_eq!(int_column(&batches, "value"), vec![31]);
+        assert_eq!(int_column(&batches, "score"), vec![300]);
+
+        // IS NULL: matches exactly the null-filled old rows (ids 1, 2).
+        let filter = pb.is_null("score").unwrap();
+        let batches = read_rows(&table_v1, None, Some(filter)).await;
+        let mut ids = int_column(&batches, "id");
+        ids.sort_unstable();
+        assert_eq!(ids, vec![1, 2]);
+
+        // Gap-A on an evolution column: score is filtered but not projected.
+        // The merge read must widen internally (null-filling score for the old
+        // files), filter, then project back to just "id".
+        let filter = pb.equal("score", Datum::Int(300)).unwrap();
+        let batches = read_rows(&table_v1, Some(&["id"]), Some(filter)).await;
+        assert_eq!(int_column(&batches, "id"), vec![3]);
+        for batch in &batches {
+            assert_eq!(
+                batch.num_columns(),
+                1,
+                "widened evolution column must not leak into the output"
+            );
+            assert_eq!(batch.schema().field(0).name(), "id");
+        }
+    }
+}
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 1d54b8d..c3c2789 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -155,11 +155,12 @@ impl<'a> ReadBuilder<'a> {
     ///
     /// [`TableRead`] may use supported non-partition data predicates on 
formats
     /// with reader pruning for conservative row-group pruning. Parquet may 
also
-    /// use native row filtering. Row-level exactness is enforced on append and
-    /// data-evolution read paths: format readers apply an exact residual 
filter
+    /// use native row filtering. Row-level exactness is enforced on all read
+    /// paths: format readers apply an exact residual filter on append reads
     /// (see `FormatFileReader::read_batch_stream` for per-format exceptions),
-    /// and data-evolution reads filter batches exactly before yielding.
-    /// Primary-key merge reads currently apply only primary-key conjuncts.
+    /// data-evolution reads filter batches exactly before yielding, and
+    /// primary-key merge reads push key conjuncts below the merge and enforce
+    /// the full predicate with an exact post-merge residual filter.
     pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
         self.filter = normalize_filter(self.table, filter);
         self.try_extract_row_id_ranges();
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index c2f9627..216fbd6 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -63,10 +63,10 @@ impl<'a> TableRead<'a> {
     }
 
     /// Set a filter predicate. Used conservatively for read-side pruning and
-    /// enforced exactly by the residual filter on append and data-evolution
-    /// read paths (see 
[`ReadBuilder::with_filter`](crate::table::ReadBuilder::with_filter)
+    /// enforced exactly by residual filtering on append, data-evolution, and
+    /// primary-key merge read paths (see
+    /// [`ReadBuilder::with_filter`](crate::table::ReadBuilder::with_filter)
     /// for per-format exceptions).
-    /// Primary-key merge reads currently apply only primary-key conjuncts.
     pub fn with_filter(mut self, filter: Predicate) -> Self {
         let (_, data_predicates) = split_scan_predicates(self.table, filter);
         // Keep the FULL data predicate (including `And`/`Or`/`Not`). Native
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index b69097c..115fb01 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -21,6 +21,7 @@
 //! and 
[FullStartingScanner](https://github.com/apache/paimon/blob/release-1.3/paimon-python/pypaimon/read/scanner/full_starting_scanner.py).
 
 use super::bucket_filter::compute_target_buckets;
+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,
@@ -856,9 +857,9 @@ impl<'a> TableScan<'a> {
         let partition_fields = self.table.schema().partition_fields();
 
         let pushdown_data_predicates = if data_evolution_enabled {
-            &[][..]
+            Vec::new()
         } else {
-            self.data_predicates.as_slice()
+            self.stats_pruning_predicates()
         };
 
         let bucket_key_fields: Vec<DataField> = if 
self.bucket_predicate.is_none() {
@@ -894,7 +895,7 @@ impl<'a> TableScan<'a> {
             has_primary_keys,
             self.partition_filter.as_ref(),
             &partition_fields,
-            pushdown_data_predicates,
+            &pushdown_data_predicates,
             self.table.schema().id(),
             self.table.schema().fields(),
             self.bucket_predicate.as_ref(),
@@ -914,6 +915,44 @@ impl<'a> TableScan<'a> {
         can_push_down_limit_hint_for_scan(&self.data_predicates, row_ranges)
     }
 
+    /// The predicate set that may prune WHOLE FILES by their stats.
+    ///
+    /// For primary-key tables read by merging, only key conjuncts are safe: a
+    /// key's versions agree on the key columns but not on value columns, so a
+    /// value conjunct could prune the file holding the newest version and
+    /// resurrect an older one from a surviving file. The dropped conjuncts
+    /// are still enforced exactly by the post-merge residual filter in
+    /// `KeyValueFileReader`.
+    ///
+    /// Exempt (full predicates kept):
+    /// - Deletion-vector tables: they read raw with per-row masks, stats are
+    ///   a superset of live rows, full pruning stays safe.
+    /// - `merge-engine=first-row`: planned with `skip_level_zero` and read
+    ///   via `DataFileReader` (see `TableRead::to_arrow`), no merge on the
+    ///   read path — pruning a file drops exactly the rows the raw path's
+    ///   exact residual filter would drop anyway. If first-row ever gains a
+    ///   merge read path, this exemption must be revisited.
+    fn stats_pruning_predicates(&self) -> Vec<Predicate> {
+        let has_primary_keys = !self.table.schema().primary_keys().is_empty();
+        let core_options = CoreOptions::new(self.table.schema().options());
+        let deletion_vectors_enabled = core_options.deletion_vectors_enabled();
+        // An unknown merge engine stays conservative (key-only pruning); the
+        // read side fails on it anyway before returning rows.
+        let first_row = matches!(
+            core_options.merge_engine(),
+            Ok(crate::spec::MergeEngine::FirstRow)
+        );
+        if has_primary_keys && !deletion_vectors_enabled && !first_row {
+            retain_primary_key_conjuncts(
+                &self.data_predicates,
+                self.table.schema().fields(),
+                &self.table.schema().trimmed_primary_keys(),
+            )
+        } else {
+            self.data_predicates.clone()
+        }
+    }
+
     async fn plan_snapshot(
         &self,
         snapshot: Snapshot,
@@ -944,7 +983,8 @@ impl<'a> TableScan<'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 entries = if self.data_predicates.is_empty() || 
data_evolution_enabled {
+        let stats_pruning_predicates = self.stats_pruning_predicates();
+        let entries = if stats_pruning_predicates.is_empty() || 
data_evolution_enabled {
             entries
         } else {
             let current_schema_id = self.table.schema().id();
@@ -966,7 +1006,7 @@ impl<'a> TableScan<'a> {
                         || data_file_matches_predicates_for_table(
                             self.table,
                             entry.file(),
-                            &self.data_predicates,
+                            &stats_pruning_predicates,
                             &mut schema_cache,
                         )
                         .await
@@ -2152,6 +2192,171 @@ mod tests {
         );
     }
 
+    fn pk_stats_gate_table(table_path: &str) -> Table {
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let schema = PaimonSchema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .build()
+            .unwrap();
+        Table::new(
+            file_io,
+            Identifier::new("test_db", "pk_stats_gate"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        )
+    }
+
+    fn two_int_stats_row(id: Option<i32>, value: Option<i32>) -> Vec<u8> {
+        let mut builder = BinaryRowBuilder::new(2);
+        match id {
+            Some(id) => builder.write_int(0, id),
+            None => builder.set_null_at(0),
+        }
+        match value {
+            Some(value) => builder.write_int(1, value),
+            None => builder.set_null_at(1),
+        }
+        builder.build_serialized()
+    }
+
+    fn pk_stats_file(name: &str, id_range: (i32, i32), value_range: (i32, 
i32)) -> DataFileMeta {
+        let mut file = test_data_file_meta(
+            two_int_stats_row(Some(id_range.0), Some(value_range.0)),
+            two_int_stats_row(Some(id_range.1), Some(value_range.1)),
+            vec![Some(0), Some(0)],
+            2,
+        );
+        file.file_name = name.to_string();
+        file
+    }
+
+    /// Merge reads combine versions of a key across files, so scan planning
+    /// must not prune a PK table's files by NON-key conjuncts: dropping the
+    /// file that holds the newest version resurrects an older version from a
+    /// surviving file — an error no post-merge residual can repair. Key
+    /// conjuncts stay safe (every version of a key shares the key columns)
+    /// and must still prune.
+    #[tokio::test]
+    async fn test_pk_table_stats_pruning_ignores_non_key_conjuncts() {
+        let table_path = "memory:/test_pk_stats_gate";
+        let table = pk_stats_gate_table(table_path);
+        setup_scan_trace_dirs(&table).await;
+
+        // Both files cover key id=1; the newer version's value (50) falls
+        // outside the value predicate while the older one (150) matches.
+        TableCommit::new(table.clone(), "pk-gate-test".to_string())
+            .commit(vec![CommitMessage::new(
+                BinaryRowBuilder::new(0).build_serialized(),
+                0,
+                vec![
+                    pk_stats_file("old-version.parquet", (1, 5), (100, 200)),
+                    pk_stats_file("new-version.parquet", (1, 5), (10, 60)),
+                ],
+            )])
+            .await
+            .unwrap();
+
+        let fields = vec![
+            DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+            DataField::new(1, "value".to_string(), 
DataType::Int(IntType::new())),
+        ];
+        let pb = PredicateBuilder::new(&fields);
+
+        // Non-key conjunct: must NOT prune any file of a PK table.
+        let value_filter = pb.greater_than("value", Datum::Int(90)).unwrap();
+        let mut reader = table.new_read_builder();
+        reader.with_filter(value_filter);
+        let (plan, trace) = reader.new_scan().plan_with_trace().await.unwrap();
+        assert_eq!(
+            trace.manifest_entries_pruned_by_data_stats, 0,
+            "non-key conjuncts must not file-prune a PK table: {trace:?}"
+        );
+        let planned_files: usize = plan.splits().iter().map(|s| 
s.data_files().len()).sum();
+        assert_eq!(
+            planned_files, 2,
+            "both versions must reach the merge reader"
+        );
+
+        // Key conjunct: still prunes (id=9 outside both files' key range).
+        let key_filter = pb.equal("id", Datum::Int(9)).unwrap();
+        let mut reader = table.new_read_builder();
+        reader.with_filter(key_filter);
+        let (_plan, trace) = 
reader.new_scan().plan_with_trace().await.unwrap();
+        assert!(
+            trace.manifest_entries_pruned_by_data_stats >= 2,
+            "key conjuncts must still prune PK-table files: {trace:?}"
+        );
+    }
+
+    /// `merge-engine=first-row` PK tables read raw (no merge on the read
+    /// path: planned with `skip_level_zero`, read via `DataFileReader`), so
+    /// pruning a file by a non-key conjunct cannot resurrect anything — it
+    /// drops exactly the rows the raw path's exact residual filter would
+    /// drop. The key-only gate must exempt first-row and keep full-predicate
+    /// stats pruning, matching the split-generation path.
+    #[tokio::test]
+    async fn test_first_row_table_stats_pruning_keeps_non_key_conjuncts() {
+        let table_path = "memory:/test_first_row_stats_gate";
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let schema = PaimonSchema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .option("merge-engine", "first-row")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io,
+            Identifier::new("test_db", "first_row_stats_gate"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        setup_scan_trace_dirs(&table).await;
+
+        // Compacted (level 1) files: first-row planning skips level 0, so the
+        // fixture files must sit above it to be planned at all. Distinct key
+        // ranges; only file A's value range can match `value > 90`.
+        TableCommit::new(table.clone(), "first-row-gate-test".to_string())
+            .commit(vec![CommitMessage::new(
+                BinaryRowBuilder::new(0).build_serialized(),
+                0,
+                vec![
+                    pk_stats_file("file-a.parquet", (1, 5), (100, 200)),
+                    pk_stats_file("file-b.parquet", (6, 9), (10, 60)),
+                ],
+            )])
+            .await
+            .unwrap();
+
+        let fields = vec![
+            DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+            DataField::new(1, "value".to_string(), 
DataType::Int(IntType::new())),
+        ];
+        let pb = PredicateBuilder::new(&fields);
+
+        // Non-key conjunct: first-row reads raw, so full-predicate pruning
+        // stays enabled — file-b (value stats [10, 60]) must be pruned.
+        let value_filter = pb.greater_than("value", Datum::Int(90)).unwrap();
+        let mut reader = table.new_read_builder();
+        reader.with_filter(value_filter);
+        let (plan, trace) = reader.new_scan().plan_with_trace().await.unwrap();
+        assert!(
+            trace.manifest_entries_pruned_by_data_stats >= 1,
+            "first-row tables must keep full-predicate stats pruning: 
{trace:?}"
+        );
+        let planned_files: usize = plan.splits().iter().map(|s| 
s.data_files().len()).sum();
+        assert_eq!(
+            planned_files, 1,
+            "only the value-matching file should be planned on first-row"
+        );
+    }
+
     #[tokio::test]
     async fn 
test_plan_with_trace_records_limit_early_stop_during_split_construction() {
         let table_path =

Reply via email to