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 dd862f55 fix(table): honor physical schemas in raw evolution reads 
(#636)
dd862f55 is described below

commit dd862f550ecb3b5230ee07f4983432cba4286edb
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Aug 3 11:42:59 2026 +0800

    fix(table): honor physical schemas in raw evolution reads (#636)
---
 crates/paimon/src/table/data_evolution_reader.rs | 222 +++++++++++++++++++++--
 1 file changed, 208 insertions(+), 14 deletions(-)

diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index b86cf213..2aa6d485 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -210,16 +210,29 @@ impl DataEvolutionReader {
             let filter_before_blob_resolution =
                 
self.can_filter_before_blob_resolution(blob_view_lookup.is_some(), 
&descriptor_fields);
 
-            // The exact residual runs after schema evolution and `_ROW_ID`
-            // attachment, so the nested file reader must not receive data
-            // predicates.
-            let file_reader = DataFileReader::new(
+            // A raw-convertible split consists only of independent files: 
every
+            // row-id segment has a single column provider, so predicates can 
be
+            // pushed into its file reader. Keep the exact residual below as a
+            // format-independent backstop.
+            //
+            // Positional `_ROW_ID` attachment needs the unfiltered physical 
row
+            // stream, and predicates on resolved BLOB/BLOB-view values must 
run
+            // after that transformation. Both cases deliberately stay on the
+            // residual-only path.
+            let push_down_raw_predicates = !self.predicates.is_empty()
+                && self.row_id_index.is_none()
+                && filter_before_blob_resolution;
+            let raw_file_reader = DataFileReader::new(
                 self.file_io.clone(),
                 self.schema_manager.clone(),
                 self.table_schema_id,
                 self.table_fields.clone(),
                 self.wide_file_read_type.clone(),
-                Vec::new(),
+                if push_down_raw_predicates {
+                    self.predicates.clone()
+                } else {
+                    Vec::new()
+                },
             )
             .with_batch_size(self.batch_size)
             .with_parquet_read_budget(self.parquet_read_budget.clone());
@@ -235,14 +248,13 @@ impl DataEvolutionReader {
                             &file_meta,
                         )
                         .await?;
-                        let data_fields: Option<Vec<DataField>> =
-                            if file_meta.schema_id != self.table_schema_id {
-                                let data_schema =
-                                    
self.schema_manager.schema(file_meta.schema_id).await?;
-                                Some(data_schema.fields().to_vec())
-                            } else {
-                                None
-                            };
+                        let data_fields = raw_file_physical_fields(
+                            &self.schema_manager,
+                            self.table_schema_id,
+                            &self.table_fields,
+                            &file_meta,
+                        )
+                        .await?;
 
                         let has_row_id = file_meta.first_row_id.is_some();
                         let effective_row_ranges = if has_row_id { 
row_ranges.clone() } else { None };
@@ -268,7 +280,7 @@ impl DataEvolutionReader {
                         let mut row_id_cursor = file_base_row_id;
                         let mut row_id_offset: usize = 0;
 
-                        let mut stream = file_reader.read_single_file_stream(
+                        let mut stream = 
raw_file_reader.read_single_file_stream(
                             &split,
                             file_meta,
                             data_fields,
@@ -782,6 +794,52 @@ impl DataEvolutionReader {
     }
 }
 
+/// Resolve the schema that a raw-convertible file physically stores.
+///
+/// Partial-column files omit fields listed outside `write_cols`; returning 
only
+/// their physical fields lets `DataFileReader` apply field-id mapping and
+/// all-NULL semantics consistently, with or without predicate pushdown.
+async fn raw_file_physical_fields(
+    schema_manager: &SchemaManager,
+    table_schema_id: i64,
+    table_fields: &[DataField],
+    file: &DataFileMeta,
+) -> crate::Result<Option<Vec<DataField>>> {
+    let schema_fields = if file.schema_id == table_schema_id {
+        None
+    } else {
+        Some(
+            schema_manager
+                .schema(file.schema_id)
+                .await?
+                .fields()
+                .to_vec(),
+        )
+    };
+
+    let Some(write_cols) = file.write_cols.as_ref() else {
+        return Ok(schema_fields);
+    };
+    let fields = schema_fields.as_deref().unwrap_or(table_fields);
+    let written_fields = write_cols
+        .iter()
+        .map(|name| {
+            fields
+                .iter()
+                .find(|field| field.name() == name)
+                .cloned()
+                .ok_or_else(|| Error::DataInvalid {
+                    message: format!(
+                        "Failed to resolve write column '{}' in 
raw-convertible file '{}'",
+                        name, file.file_name
+                    ),
+                    source: None,
+                })
+        })
+        .collect::<crate::Result<Vec<_>>>()?;
+    Ok(Some(written_fields))
+}
+
 async fn resolve_descriptor_columns(
     batch: RecordBatch,
     blob_descriptor_fields: &HashSet<String>,
@@ -6450,6 +6508,71 @@ mod tests {
         )
     }
 
+    /// A positional `.row` file must be decoded with its physical 
`write_cols`,
+    /// not the full table schema. With BLOB first in the table schema, 
decoding
+    /// an id-only file as `[payload, id]` reads the id bytes as a BLOB offset 
and
+    /// either corrupts alignment or runs past the row payload.
+    #[tokio::test]
+    async fn test_raw_row_file_uses_write_cols_when_blob_precedes_projection() 
{
+        use crate::arrow::format::create_format_writer;
+
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let table_path = "memory:/raw-row-write-cols";
+        let bucket_path = format!("{table_path}/bucket-0");
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("payload", DataType::Blob(BlobType::new()))
+                .column("id", DataType::Int(IntType::new()))
+                .option("data-evolution.enabled", "true")
+                .build()
+                .unwrap(),
+        );
+        let id_field = table_schema.fields()[1].clone();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "raw_row_write_cols_t"),
+            table_path.to_string(),
+            table_schema,
+            None,
+        );
+
+        let row_schema = 
build_target_arrow_schema(std::slice::from_ref(&id_field)).unwrap();
+        let batch = RecordBatch::try_new(
+            row_schema.clone(),
+            vec![Arc::new(Int32Array::from(vec![1, 2]))],
+        )
+        .unwrap();
+        let output = file_io
+            .new_output(&format!("{bucket_path}/data.row"))
+            .unwrap();
+        let mut writer = create_format_writer(&output, row_schema, "zstd", 1, 
None, None, None)
+            .await
+            .unwrap();
+        writer.write(&batch).await.unwrap();
+        let file_size = writer.close().await.unwrap().file_size as i64;
+
+        let mut file_meta = data_file("data.row", 0, 2, 1, Some(vec!["id"]));
+        file_meta.file_size = file_size;
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(bucket_path)
+            .with_total_buckets(1)
+            .with_data_files(vec![file_meta])
+            .build()
+            .unwrap();
+
+        let batches = TableRead::new(&table, vec![id_field], Vec::new())
+            .to_arrow(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        assert_eq!(collect_int_values(&batches, "id"), vec![1, 2]);
+    }
+
     /// Raw-convertible branch: a leaf predicate is applied exactly through the
     /// public ReadBuilder -> TableRead -> to_arrow path.
     #[tokio::test]
@@ -6501,6 +6624,77 @@ mod tests {
         assert_eq!(collect_int_values(&batches, "value"), vec![20, 30, 40]);
     }
 
+    /// Multiple non-overlapping, single-file row-id segments may share one
+    /// raw-convertible split. Each file can still receive the predicate
+    /// independently because no column-wise merge is required.
+    #[tokio::test]
+    async fn 
test_evolution_read_pushes_predicate_to_single_file_row_id_segments() {
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+
+        let first_path = bucket_dir.join("first.parquet");
+        write_int_parquet_file(
+            &first_path,
+            vec![("id", vec![1, 2]), ("value", vec![5, 20])],
+            None,
+        );
+        let second_path = bucket_dir.join("second.parquet");
+        write_int_parquet_file(
+            &second_path,
+            vec![("id", vec![3, 4]), ("value", vec![30, 10])],
+            None,
+        );
+
+        let table = two_col_evolution_table(table_path);
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![
+                data_file_meta_with_path(
+                    "first.parquet",
+                    0,
+                    2,
+                    1,
+                    first_path.metadata().unwrap().len() as i64,
+                    Some(vec!["id", "value"]),
+                ),
+                data_file_meta_with_path(
+                    "second.parquet",
+                    2,
+                    2,
+                    1,
+                    second_path.metadata().unwrap().len() as i64,
+                    Some(vec!["id", "value"]),
+                ),
+            ])
+            .build()
+            .unwrap();
+
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .greater_or_equal("value", Datum::Int(20))
+            .unwrap();
+        let mut builder = table.new_read_builder();
+        builder
+            .with_projection(&["id"])
+            .unwrap()
+            .with_filter(predicate);
+        let batches = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        assert_eq!(collect_int_values(&batches, "id"), vec![2, 3]);
+    }
+
     /// Raw-convertible branch: a compound `Or` predicate referencing a
     /// NON-projected column filters exactly, and the widened predicate column
     /// does not leak into the output schema.

Reply via email to