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 30c171d3 feat: expand Python native reads with nested projection and
row kinds (#887)
30c171d3 is described below
commit 30c171d3d5c86837ecfc05f9d32d039162407c39
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Sep 20 19:00:11 2026 +0800
feat: expand Python native reads with nested projection and row kinds (#887)
---
bindings/python/src/read.rs | 267 +++++++++++++++++++++++++++-
crates/paimon/src/arrow/format/parquet.rs | 251 ++++++++++++++++++++++++--
crates/paimon/src/arrow/shredding/map.rs | 166 ++++++++++++++---
crates/paimon/src/table/table_read.rs | 120 ++++++++++++-
crates/paimon/tests/audit_log_table_test.rs | 31 ++++
5 files changed, 795 insertions(+), 40 deletions(-)
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index df706c0b..f73a8ded 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex};
use arrow::pyarrow::ToPyArrow;
use arrow::record_batch::RecordBatch;
use futures::TryStreamExt;
-use paimon::spec::Predicate;
+use paimon::spec::{DataField, DataType, Predicate, RowType};
use paimon::table::{ArrowRecordBatchStream, DataSplit, IncrementalScanMode,
RowRange, Table};
use paimon_datafusion::runtime::runtime;
use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
@@ -33,6 +33,9 @@ use tokio::sync::Notify;
use crate::error::to_py_err;
use crate::predicate::dict_to_predicate;
+const MAP_SELECTED_KEYS_PREFIX: &str = "__PAIMON_MAP_SELECTED_KEYS:";
+const MAP_SELECTED_KEYS_DELIMITER: char = ';';
+
/// Time-travel selector option names, in the core's resolution priority order.
const TIME_TRAVEL_SELECTORS: [&str; 5] = [
"scan.timestamp-millis",
@@ -70,12 +73,15 @@ fn find_time_travel_selector(opts: &HashMap<String,
String>) -> Option<(&str, &s
fn apply_read_config(
builder: &mut paimon::table::ReadBuilder<'_>,
projection: &Option<Vec<String>>,
+ read_type: &Option<Vec<DataField>>,
limit: Option<usize>,
filter: &Option<Predicate>,
case_sensitive: bool,
) -> PyResult<()> {
builder.with_case_sensitive(case_sensitive);
- if let Some(projection) = projection {
+ if let Some(read_type) = read_type {
+ builder.with_read_type(read_type.clone());
+ } else if let Some(projection) = projection {
let cols: Vec<&str> = projection.iter().map(String::as_str).collect();
builder.with_projection(&cols).map_err(to_py_err)?;
}
@@ -88,6 +94,106 @@ fn apply_read_config(
Ok(())
}
+/// Resolve flat output paths to the authoritative nested read type used by the
+/// core reader. ROW children are pruned recursively. MAP values remain MAPs,
+/// but their requested string keys are carried in the temporary field
+/// description so shared-shredding Parquet files can prune physical columns.
+fn project_nested_read_type(
+ fields: &[DataField],
+ paths: &[Vec<String>],
+) -> PyResult<Vec<DataField>> {
+ if paths.iter().any(Vec::is_empty) {
+ return Err(PyValueError::new_err(
+ "nested projection paths must not be empty",
+ ));
+ }
+
+ let mut result = Vec::new();
+ let mut seen = std::collections::HashSet::new();
+ for path in paths {
+ let name = &path[0];
+ if !seen.insert(name.clone()) {
+ continue;
+ }
+ let field = fields
+ .iter()
+ .find(|field| field.name() == name)
+ .ok_or_else(|| {
+ PyValueError::new_err(format!(
+ "nested projection field '{}' does not exist",
+ path.join(".")
+ ))
+ })?;
+ let matching: Vec<&[String]> = paths
+ .iter()
+ .filter(|candidate| candidate.first() == Some(name))
+ .map(|candidate| candidate[1..].as_ref())
+ .collect();
+ result.push(project_nested_field(field, &matching, name)?);
+ }
+ Ok(result)
+}
+
+fn project_nested_field(
+ field: &DataField,
+ tails: &[&[String]],
+ full_name: &str,
+) -> PyResult<DataField> {
+ if tails.iter().any(|tail| tail.is_empty()) {
+ return Ok(field.clone());
+ }
+ match field.data_type() {
+ DataType::Row(row) => {
+ let child_paths: Vec<Vec<String>> = tails.iter().map(|tail|
tail.to_vec()).collect();
+ let children = project_nested_read_type(row.fields(),
&child_paths).map_err(|_| {
+ PyValueError::new_err(format!(
+ "nested projection field '{}' does not exist",
+ tails
+ .first()
+ .map(|tail| format!("{full_name}.{}", tail.join(".")))
+ .unwrap_or_else(|| full_name.to_string())
+ ))
+ })?;
+ Ok(DataField::new(
+ field.id(),
+ field.name().to_string(),
+ DataType::Row(RowType::with_nullable(
+ field.data_type().is_nullable(),
+ children,
+ )),
+ )
+ .with_description(field.description().map(str::to_string)))
+ }
+ DataType::Map(_) if tails.iter().all(|tail| tail.len() == 1) => {
+ let mut keys = Vec::new();
+ for tail in tails {
+ let key = &tail[0];
+ if key.contains(MAP_SELECTED_KEYS_DELIMITER)
+ || key.starts_with(MAP_SELECTED_KEYS_PREFIX)
+ {
+ // Keep the complete MAP for keys which cannot be encoded
+ // by the cross-language selected-key convention.
+ return Ok(field.clone());
+ }
+ if !keys.contains(key) {
+ keys.push(key.clone());
+ }
+ }
+ Ok(field.clone().with_description(Some(format!(
+ "{MAP_SELECTED_KEYS_PREFIX}{}",
+ keys.join(&MAP_SELECTED_KEYS_DELIMITER.to_string())
+ ))))
+ }
+ _ => Err(PyValueError::new_err(format!(
+ "nested projection field '{}' is not a ROW or MAP",
+ tails
+ .first()
+ .map(|tail| format!("{full_name}.{}", tail.join(".")))
+ .unwrap_or_else(|| full_name.to_string())
+ ))),
+ }
+}
+
/// Extract a sequence of Python `Split` objects into core `DataSplit`s.
Accepts
/// any iterable (list/tuple/generator). Runs under the GIL since it touches
/// Python objects. A non-iterable argument or a non-`Split` element raises
@@ -111,11 +217,13 @@ fn extract_splits(splits: &Bound<'_, PyAny>) ->
PyResult<Vec<DataSplit>> {
pub struct PyReadBuilder {
table: Arc<Table>,
projection: Option<Vec<String>>,
+ read_type: Option<Vec<DataField>>,
limit: Option<usize>,
filter: Option<Predicate>,
row_ranges: Option<Vec<RowRange>>,
case_sensitive: bool,
blob_parallelism: Option<usize>,
+ include_row_kind: bool,
}
impl PyReadBuilder {
@@ -123,11 +231,13 @@ impl PyReadBuilder {
Self {
table,
projection: None,
+ read_type: None,
limit: None,
filter: None,
row_ranges: None,
case_sensitive: true,
blob_parallelism: None,
+ include_row_kind: false,
}
}
@@ -169,11 +279,13 @@ impl PyReadBuilder {
Ok(Self {
table: Arc::new(traveled),
projection: None,
+ read_type: None,
limit: None,
filter: None,
row_ranges: None,
case_sensitive: true,
blob_parallelism: None,
+ include_row_kind: false,
})
}
}
@@ -182,14 +294,55 @@ impl PyReadBuilder {
impl PyReadBuilder {
fn with_projection(mut slf: PyRefMut<'_, Self>, columns: Vec<String>) ->
PyRefMut<'_, Self> {
slf.projection = Some(columns);
+ slf.read_type = None;
slf
}
+ /// Project top-level fields or nested ROW leaves by their exact name
paths.
+ /// MAP paths keep the complete MAP so the caller can extract literal keys.
+ fn with_nested_projection(
+ mut slf: PyRefMut<'_, Self>,
+ paths: Vec<Vec<String>>,
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ let schema = slf.table.schema();
+ let mut read_type = project_nested_read_type(schema.fields(), &paths)?;
+ let options = schema.options();
+ let has_default_aggregation =
options.contains_key("fields.default-aggregate-function");
+ for projected in &mut read_type {
+ let selected_map = matches!(projected.data_type(),
DataType::Map(_))
+ && projected
+ .description()
+ .is_some_and(|description|
description.starts_with(MAP_SELECTED_KEYS_PREFIX));
+ let has_field_aggregation =
+ options.contains_key(&format!("fields.{}.aggregate-function",
projected.name()));
+ if selected_map && (has_default_aggregation ||
has_field_aggregation) {
+ // Merge engines may need the complete MAP to calculate the
+ // projected key. Match PyPaimon's complete-MAP fallback.
+ *projected = schema
+ .fields()
+ .iter()
+ .find(|field| field.name() == projected.name())
+ .expect("nested projection came from this schema")
+ .clone();
+ }
+ }
+ slf.read_type = Some(read_type);
+ slf.projection = None;
+ Ok(slf)
+ }
+
fn with_limit(mut slf: PyRefMut<'_, Self>, limit: usize) -> PyRefMut<'_,
Self> {
slf.limit = Some(limit);
slf
}
+ /// Include a leading `rowkind` string column. Streaming primary-key
+ /// splits preserve their physical change kinds; snapshot rows are `+I`.
+ fn with_include_row_kind(mut slf: PyRefMut<'_, Self>, include: bool) ->
PyRefMut<'_, Self> {
+ slf.include_row_kind = include;
+ slf
+ }
+
/// Set the maximum number of concurrent BLOB range reads for this read.
fn with_blob_parallelism(
mut slf: PyRefMut<'_, Self>,
@@ -251,6 +404,7 @@ impl PyReadBuilder {
PyTableScan {
table: Arc::clone(&self.table),
projection: self.projection.clone(),
+ read_type: self.read_type.clone(),
limit: self.limit,
filter: self.filter.clone(),
row_ranges: self.row_ranges.clone(),
@@ -273,10 +427,12 @@ impl PyReadBuilder {
PyTableRead {
table: Arc::clone(&self.table),
projection: self.projection.clone(),
+ read_type: self.read_type.clone(),
limit: self.limit,
filter: self.filter.clone(),
case_sensitive: self.case_sensitive,
blob_parallelism: self.blob_parallelism,
+ include_row_kind: self.include_row_kind,
}
}
}
@@ -285,6 +441,7 @@ impl PyReadBuilder {
pub struct PyTableScan {
table: Arc<Table>,
projection: Option<Vec<String>>,
+ read_type: Option<Vec<DataField>>,
limit: Option<usize>,
filter: Option<Predicate>,
row_ranges: Option<Vec<RowRange>>,
@@ -336,6 +493,7 @@ impl PyTableScan {
apply_read_config(
&mut builder,
&self.projection,
+ &self.read_type,
self.limit,
&self.filter,
self.case_sensitive,
@@ -396,10 +554,12 @@ impl PyTableScan {
pub struct PyTableRead {
table: Arc<Table>,
projection: Option<Vec<String>>,
+ read_type: Option<Vec<DataField>>,
limit: Option<usize>,
filter: Option<Predicate>,
case_sensitive: bool,
blob_parallelism: Option<usize>,
+ include_row_kind: bool,
}
impl PyTableRead {
@@ -414,6 +574,7 @@ impl PyTableRead {
apply_read_config(
&mut builder,
&self.projection,
+ &self.read_type,
self.limit,
&self.filter,
self.case_sensitive,
@@ -427,7 +588,11 @@ impl PyTableRead {
// path so an invalid projection fails consistently regardless of
// how many splits are passed.
let read = builder.new_read().map_err(to_py_err)?;
- read.to_arrow(&splits).map_err(to_py_err)
+ if self.include_row_kind {
+ read.to_arrow_with_row_kind(&splits).map_err(to_py_err)
+ } else {
+ read.to_arrow(&splits).map_err(to_py_err)
+ }
})
}
}
@@ -639,6 +804,102 @@ mod tests {
use super::*;
+ use paimon::spec::{DataField, DataType, IntType, MapType, RowType,
VarCharType};
+
+ #[test]
+ fn nested_projection_prunes_rows_and_marks_selected_map_keys() {
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "payload".to_string(),
+ DataType::Row(RowType::new(vec![
+ DataField::new(2, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(
+ 3,
+ "details".to_string(),
+ DataType::Row(RowType::new(vec![
+ DataField::new(
+ 4,
+ "name".to_string(),
+ DataType::VarChar(VarCharType::string_type()),
+ ),
+ DataField::new(5, "score".to_string(),
DataType::Int(IntType::new())),
+ ])),
+ ),
+ ])),
+ ),
+ DataField::new(
+ 6,
+ "attrs".to_string(),
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::new()),
+ )),
+ ),
+ ];
+
+ let projected = project_nested_read_type(
+ &fields,
+ &[
+ vec![
+ "payload".to_string(),
+ "details".to_string(),
+ "score".to_string(),
+ ],
+ vec!["id".to_string()],
+ vec!["attrs".to_string(), "selected".to_string()],
+ ],
+ )
+ .unwrap();
+
+ assert_eq!(
+ projected.iter().map(DataField::name).collect::<Vec<_>>(),
+ vec!["payload", "id", "attrs"]
+ );
+ let DataType::Row(payload) = projected[0].data_type() else {
+ panic!("payload must remain a row");
+ };
+ assert_eq!(payload.fields().len(), 1);
+ assert_eq!(payload.fields()[0].name(), "details");
+ let DataType::Row(details) = payload.fields()[0].data_type() else {
+ panic!("details must remain a row");
+ };
+ assert_eq!(
+ details
+ .fields()
+ .iter()
+ .map(DataField::name)
+ .collect::<Vec<_>>(),
+ vec!["score"]
+ );
+ assert_eq!(projected[2].data_type(), fields[2].data_type());
+ assert_eq!(
+ projected[2].description(),
+ Some("__PAIMON_MAP_SELECTED_KEYS:selected")
+ );
+ }
+
+ #[test]
+ fn nested_projection_rejects_an_invalid_struct_path() {
+ let fields = vec![DataField::new(
+ 1,
+ "payload".to_string(),
+ DataType::Row(RowType::new(vec![DataField::new(
+ 2,
+ "version".to_string(),
+ DataType::Int(IntType::new()),
+ )])),
+ )];
+
+ let error = project_nested_read_type(
+ &fields,
+ &[vec!["payload".to_string(), "missing".to_string()]],
+ )
+ .unwrap_err();
+ assert!(error.to_string().contains("payload.missing"));
+ }
+
#[test]
fn record_batch_reader_close_interrupts_pending_next() {
let (polled_tx, polled_rx) = mpsc::channel();
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index a8ddca4b..bc7403a3 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -519,7 +519,17 @@ impl FormatFileReader for ParquetFormatReader {
})
.collect();
- let mask = ProjectionMask::roots(&parquet_schema, root_indices);
+ // Build the MAP plan before the projection mask: a selected-key MAP
+ // can then decode only __field_mapping plus the direct/overflow
+ // children which may contain the requested keys.
+ let map_read_plan =
+ MapShreddingReadPlan::create(&scan_fields,
batch_stream_builder.schema())?
+ .map(Arc::new);
+ let mask = if let Some(plan) = map_read_plan.as_deref() {
+ map_shredding_projection_mask(&parquet_schema, &root_indices, plan)
+ } else {
+ ProjectionMask::roots(&parquet_schema, root_indices)
+ };
batch_stream_builder =
batch_stream_builder.with_projection(mask.clone());
let mut decoder_predicates = build_parquet_row_filter(&parquet_schema,
preds, file_fields)?
@@ -614,14 +624,6 @@ impl FormatFileReader for ParquetFormatReader {
batch_stream_builder = batch_stream_builder.with_batch_size(size);
}
- // MAP shared-shredding read plan, built from the per-field metadata
- // committed into the file footer at write time. `None` when no scanned
- // field is shared-shredded. Assembly must happen before any residual
- // predicate evaluation so predicates see logical MAP columns.
- let map_read_plan =
- MapShreddingReadPlan::create(&scan_fields,
batch_stream_builder.schema())?
- .map(Arc::new);
-
// A normal Parquet stream fetches and decodes row groups one by one.
// For remote object stores, a full scan of a compacted file can
// therefore serialize dozens of independent range requests behind one
@@ -784,6 +786,32 @@ impl FormatFileReader for ParquetFormatReader {
}
}
+fn map_shredding_projection_mask(
+ parquet_schema: &parquet::schema::types::SchemaDescriptor,
+ selected_roots: &[usize],
+ plan: &MapShreddingReadPlan,
+) -> ProjectionMask {
+ let selected_roots: std::collections::HashSet<usize> =
selected_roots.iter().copied().collect();
+ let root_fields = parquet_schema.root_schema().get_fields();
+ let leaves = (0..parquet_schema.num_columns()).filter(|leaf_index| {
+ let root_index = parquet_schema.get_column_root_idx(*leaf_index);
+ if !selected_roots.contains(&root_index) {
+ return false;
+ }
+ let root_name = root_fields[root_index].name();
+ let Some(children) = plan.projected_physical_children(root_name) else {
+ return true;
+ };
+ parquet_schema
+ .column(*leaf_index)
+ .path()
+ .parts()
+ .get(1)
+ .is_some_and(|child| children.contains(child))
+ });
+ ProjectionMask::leaves(parquet_schema, leaves)
+}
+
fn projected_row_group_bytes(row_group: &RowGroupMetaData, projection:
&ProjectionMask) -> u64 {
row_group
.columns()
@@ -4931,6 +4959,15 @@ mod tests {
rows: &[Option<Vec<(&str, Option<i64>)>>],
ids: &[i32],
max_columns: usize,
+ ) -> (String, crate::io::FileIO, u64, Vec<DataField>) {
+ write_map_shredding_file_with_compression(rows, ids, max_columns,
"zstd").await
+ }
+
+ async fn write_map_shredding_file_with_compression(
+ rows: &[Option<Vec<(&str, Option<i64>)>>],
+ ids: &[i32],
+ max_columns: usize,
+ compression: &str,
) -> (String, crate::io::FileIO, u64, Vec<DataField>) {
let fields = map_shredding_fields();
let options = HashMap::from([
@@ -4959,7 +4996,7 @@ mod tests {
let mut writer = create_format_writer(
&output,
logical_schema,
- "zstd",
+ compression,
1,
None,
Some(&fields),
@@ -5050,6 +5087,200 @@ mod tests {
assert_int64_map_rows(tags, &rows);
}
+ #[tokio::test]
+ async fn test_parquet_map_shredding_selected_key_prunes_and_assembles() {
+ let rows: Vec<Option<Vec<(&str, Option<i64>)>>> = vec![
+ Some(vec![("a", Some(10)), ("b", None), ("c", Some(30))]),
+ None,
+ Some(vec![]),
+ Some(vec![("b", Some(40)), ("a", Some(50))]),
+ ];
+ let ids = vec![1, 2, 3, 4];
+ let (path, file_io, file_size, fields) =
write_map_shredding_file(&rows, &ids, 2).await;
+ let selected_fields = vec![
+ fields[0].clone(),
+ fields[1].clone().with_description(Some(format!(
+ "{}c",
+ crate::arrow::shredding::map::SELECTED_KEYS_PREFIX
+ ))),
+ ];
+
+ let input = file_io.new_input(&path).unwrap();
+ let file_reader = input.reader().await.unwrap();
+ let reader = create_format_reader(&path, false,
&selected_fields).unwrap();
+ let batches = reader
+ .read_batch_stream(
+ Box::new(file_reader),
+ file_size,
+ &selected_fields,
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ let tags = batches[0]
+ .column_by_name("tags")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .unwrap();
+ assert_int64_map_rows(
+ tags,
+ &[
+ Some(vec![("c", Some(30))]),
+ None,
+ Some(vec![]),
+ Some(vec![]),
+ ],
+ );
+
+ let missing_fields =
vec![fields[1].clone().with_description(Some(format!(
+ "{}missing",
+ crate::arrow::shredding::map::SELECTED_KEYS_PREFIX
+ )))];
+ let missing_reader =
file_io.new_input(&path).unwrap().reader().await.unwrap();
+ let missing = create_format_reader(&path, false, &missing_fields)
+ .unwrap()
+ .read_batch_stream(
+ Box::new(missing_reader),
+ file_size,
+ &missing_fields,
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_int64_map_rows(
+ missing[0]
+ .column_by_name("tags")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .unwrap(),
+ &[Some(vec![]), None, Some(vec![]), Some(vec![])],
+ );
+
+ // An empty encoded suffix is the selected-key protocol's encoding for
+ // the valid empty-string key, not an empty key set.
+ let empty_key_rows = vec![Some(vec![("", Some(10)), ("other",
Some(11))])];
+ let (empty_path, empty_file_io, empty_file_size, empty_fields) =
+ write_map_shredding_file(&empty_key_rows, &[1], 2).await;
+ let selected_empty_fields =
vec![empty_fields[1].clone().with_description(Some(
+ crate::arrow::shredding::map::SELECTED_KEYS_PREFIX.to_string(),
+ ))];
+ let empty_key_reader = empty_file_io
+ .new_input(&empty_path)
+ .unwrap()
+ .reader()
+ .await
+ .unwrap();
+ let empty_key = create_format_reader(&empty_path, false,
&selected_empty_fields)
+ .unwrap()
+ .read_batch_stream(
+ Box::new(empty_key_reader),
+ empty_file_size,
+ &selected_empty_fields,
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_int64_map_rows(
+ empty_key[0]
+ .column_by_name("tags")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .unwrap(),
+ &[Some(vec![("", Some(10))])],
+ );
+ }
+
+ #[tokio::test]
+ async fn test_parquet_map_shredding_selected_key_reduces_data_io() {
+ const ROWS: usize = 100_000;
+ let rows = (0..ROWS)
+ .map(|row| {
+ Some(vec![
+ ("a", Some((row as i64).wrapping_mul(982_451_653))),
+ ("b", Some((row as i64).wrapping_mul(961_748_941))),
+ ])
+ })
+ .collect::<Vec<_>>();
+ let ids = (0..ROWS as i32).collect::<Vec<_>>();
+ let (path, file_io, file_size, fields) =
+ write_map_shredding_file_with_compression(&rows, &ids, 2,
"none").await;
+ let data = file_io.new_input(&path).unwrap().read().await.unwrap();
+
+ let selected_fields =
vec![fields[1].clone().with_description(Some(format!(
+ "{}a",
+ crate::arrow::shredding::map::SELECTED_KEYS_PREFIX
+ )))];
+ let selected_reader = TrackingFileRead::new(data.clone());
+ let selected_tracker = selected_reader.clone();
+ let selected_stream = ParquetFormatReader::default()
+ .read_batch_stream(
+ Box::new(selected_reader),
+ file_size,
+ &selected_fields,
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+ selected_tracker.reset();
+ let selected_rows = selected_stream
+ .try_fold(0usize, |count, batch| async move {
+ Ok(count + batch.num_rows())
+ })
+ .await
+ .unwrap();
+ let selected_bytes = selected_tracker.bytes_read();
+
+ let full_reader = TrackingFileRead::new(data);
+ let full_tracker = full_reader.clone();
+ let full_stream = ParquetFormatReader::default()
+ .read_batch_stream(
+ Box::new(full_reader),
+ file_size,
+ &fields[1..],
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+ full_tracker.reset();
+ let full_rows = full_stream
+ .try_fold(0usize, |count, batch| async move {
+ Ok(count + batch.num_rows())
+ })
+ .await
+ .unwrap();
+ let full_bytes = full_tracker.bytes_read();
+
+ assert_eq!(selected_rows, ROWS);
+ assert_eq!(full_rows, ROWS);
+ assert!(
+ selected_bytes * 5 < full_bytes * 4,
+ "selected-key read used {selected_bytes} bytes; full MAP read used
{full_bytes} bytes"
+ );
+ }
+
#[tokio::test]
async fn
test_parquet_map_shredding_residual_path_assembles_before_filter() {
// An `Or` predicate is not fully enforced by the parquet row filter,
so
diff --git a/crates/paimon/src/arrow/shredding/map.rs
b/crates/paimon/src/arrow/shredding/map.rs
index b887324c..b4e9bdac 100644
--- a/crates/paimon/src/arrow/shredding/map.rs
+++ b/crates/paimon/src/arrow/shredding/map.rs
@@ -37,7 +37,7 @@ use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType as ArrowDataType, Fields, Schema as ArrowSchema};
use arrow_select::interleave::interleave;
use arrow_select::take::take;
-use std::collections::{BTreeMap, BTreeSet, HashMap};
+use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::Arc;
// ---------------------------------------------------------------------------
@@ -56,6 +56,11 @@ const OVERFLOW_SET_KEY: &str =
"paimon.map.shared-shredding.overflow-set";
const NUM_COLUMNS_KEY: &str = "paimon.map.shared-shredding.num-columns";
const MAX_ROW_WIDTH_KEY: &str = "paimon.map.shared-shredding.max-row-width";
+/// Temporary read-field description used by Java/Python to request literal
+/// MAP keys without changing the logical MAP output type.
+pub(crate) const SELECTED_KEYS_PREFIX: &str = "__PAIMON_MAP_SELECTED_KEYS:";
+const SELECTED_KEYS_DELIMITER: char = ';';
+
const FIELD_MAPPING_NAME: &str = "__field_mapping";
const OVERFLOW_NAME: &str = "__overflow";
@@ -1017,6 +1022,9 @@ pub(crate) struct MapShreddingReadPlan {
struct MapReadContext {
num_columns: usize,
name_by_id: HashMap<i32, String>,
+ selected_names: Option<HashSet<String>>,
+ selected_columns: BTreeSet<usize>,
+ include_overflow: bool,
}
impl MapShreddingReadPlan {
@@ -1052,6 +1060,21 @@ impl MapShreddingReadPlan {
DataField::new(field.id(), field.name().to_string(),
physical_type)
.with_description(field.description().map(ToString::to_string)),
);
+ let selected_names = selected_map_keys(field)?;
+ let mut selected_columns = BTreeSet::new();
+ let mut include_overflow = true;
+ if let Some(names) = selected_names.as_ref() {
+ include_overflow = false;
+ for name in names {
+ let Some(field_id) =
field_meta.name_to_id.get(name) else {
+ continue;
+ };
+ if let Some(columns) =
field_meta.field_to_columns.get(field_id) {
+
selected_columns.extend(columns.iter().copied());
+ }
+ include_overflow |=
field_meta.overflow_set.contains(field_id);
+ }
+ }
contexts.insert(
index,
MapReadContext {
@@ -1061,6 +1084,9 @@ impl MapShreddingReadPlan {
.iter()
.map(|(name, &id)| (id, name.clone()))
.collect(),
+ selected_names,
+ selected_columns,
+ include_overflow,
},
);
converted = true;
@@ -1078,6 +1104,51 @@ impl MapShreddingReadPlan {
contexts,
}))
}
+
+ /// Return the physical struct children needed for one logical field.
+ /// `None` means the whole root field must be decoded.
+ pub(crate) fn projected_physical_children(&self, field_name: &str) ->
Option<HashSet<String>> {
+ let field_index = self
+ .logical_fields
+ .iter()
+ .position(|field| field.name() == field_name)?;
+ let ctx = self.contexts.get(&field_index)?;
+ ctx.selected_names.as_ref()?;
+
+ let mut children = HashSet::from([FIELD_MAPPING_NAME.to_string()]);
+ children.extend(
+ ctx.selected_columns
+ .iter()
+ .map(|index| physical_column_name(*index)),
+ );
+ if ctx.include_overflow {
+ children.insert(OVERFLOW_NAME.to_string());
+ }
+ Some(children)
+ }
+}
+
+fn selected_map_keys(field: &DataField) -> Result<Option<HashSet<String>>> {
+ let Some(description) = field.description() else {
+ return Ok(None);
+ };
+ let Some(encoded) = description.strip_prefix(SELECTED_KEYS_PREFIX) else {
+ return Ok(None);
+ };
+ let keys: HashSet<String> = encoded
+ .split(SELECTED_KEYS_DELIMITER)
+ .map(ToString::to_string)
+ .collect();
+ if keys.len() != encoded.split(SELECTED_KEYS_DELIMITER).count() {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Selected-key MAP field '{}' contains duplicate keys",
+ field.name()
+ ),
+ source: None,
+ });
+ }
+ Ok(Some(keys))
}
fn validate_physical_struct_children(
@@ -1345,19 +1416,18 @@ fn struct_array_to_logical_map(
) -> Result<ArrayRef> {
let num_rows = array.len();
let k = ctx.num_columns;
- if array.num_columns() < k + 1 {
- return Err(Error::DataInvalid {
+ let field_mapping_index = array
+ .fields()
+ .iter()
+ .position(|field| field.name() == FIELD_MAPPING_NAME)
+ .ok_or_else(|| Error::DataInvalid {
message: format!(
- "Shared-shredding physical column '{column_name}' has {}
children, expected at least {}",
- array.num_columns(),
- k + 1
+ "Shared-shredding physical column '{column_name}' is missing
{FIELD_MAPPING_NAME}"
),
source: None,
- });
- }
-
+ })?;
let field_mapping = array
- .column(0)
+ .column(field_mapping_index)
.as_any()
.downcast_ref::<ListArray>()
.ok_or_else(|| Error::DataInvalid {
@@ -1397,11 +1467,13 @@ fn struct_array_to_logical_map(
};
let logical_value_type = entry_fields[1].data_type();
- let has_overflow_column = array.num_columns() > k + 1;
- let overflow = if has_overflow_column {
- Some(
+ let overflow = array
+ .fields()
+ .iter()
+ .position(|field| field.name() == OVERFLOW_NAME)
+ .map(|index| {
array
- .column(k + 1)
+ .column(index)
.as_any()
.downcast_ref::<MapArray>()
.ok_or_else(|| Error::DataInvalid {
@@ -1409,11 +1481,9 @@ fn struct_array_to_logical_map(
"Shared-shredding __overflow of '{column_name}' must
be MapArray"
),
source: None,
- })?,
- )
- } else {
- None
- };
+ })
+ })
+ .transpose()?;
let normalize_source = |value: &dyn Array| {
arrow_cast::cast(value, logical_value_type).map_err(|e|
Error::UnexpectedError {
@@ -1427,9 +1497,31 @@ fn struct_array_to_logical_map(
// Normalize each physical source once. For metadata-only differences this
// rebuilds nested Arrow wrappers while reusing their value buffers. The
// final interleave then copies only entries referenced by the mapping.
- let mut value_sources = Vec::with_capacity(k +
usize::from(overflow.is_some()));
- for i in 0..k {
- value_sources.push(normalize_source(array.column(i + 1).as_ref())?);
+ let mut value_sources = Vec::with_capacity(array.num_columns());
+ let mut direct_source_indices = vec![None; k];
+ for (position, physical_field) in array.fields().iter().enumerate() {
+ let Some(suffix) = physical_field.name().strip_prefix("__col_") else {
+ continue;
+ };
+ let physical_index = suffix.parse::<usize>().map_err(|_|
Error::DataInvalid {
+ message: format!(
+ "Shared-shredding physical column '{column_name}' has invalid
child '{}'",
+ physical_field.name()
+ ),
+ source: None,
+ })?;
+ if physical_index >= k {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Shared-shredding physical column '{column_name}' child
'{}' exceeds metadata column count {k}",
+ physical_field.name()
+ ),
+ source: None,
+ });
+ }
+ let source_index = value_sources.len();
+ value_sources.push(normalize_source(array.column(position).as_ref())?);
+ direct_source_indices[physical_index] = Some(source_index);
}
let overflow_source_index = if let Some(overflow) = overflow {
let index = value_sources.len();
@@ -1438,6 +1530,9 @@ fn struct_array_to_logical_map(
} else {
None
};
+ if value_sources.is_empty() {
+ value_sources.push(arrow_array::new_empty_array(logical_value_type));
+ }
let mut key_builder = arrow_array::builder::StringBuilder::new();
let mut value_indices: Vec<(usize, usize)> = Vec::new();
@@ -1466,7 +1561,7 @@ fn struct_array_to_logical_map(
source: None,
});
}
- for i in 0..k {
+ for (i, direct_source_index) in
direct_source_indices.iter().enumerate() {
let field_id = if mapping_values.is_null(fm_start + i) {
-1
} else {
@@ -1478,8 +1573,21 @@ fn struct_array_to_logical_map(
let Some(name) = ctx.name_by_id.get(&field_id) else {
continue;
};
+ if ctx
+ .selected_names
+ .as_ref()
+ .is_some_and(|selected| !selected.contains(name))
+ {
+ continue;
+ }
+ let source_index = (*direct_source_index).ok_or_else(||
Error::DataInvalid {
+ message: format!(
+ "Shared-shredding physical column '{column_name}' is
missing __col_{i} required for key '{name}'"
+ ),
+ source: None,
+ })?;
key_builder.append_value(name);
- value_indices.push((i, row));
+ value_indices.push((source_index, row));
count += 1;
}
@@ -1502,6 +1610,13 @@ fn struct_array_to_logical_map(
let Some(name) = ctx.name_by_id.get(&field_id) else {
continue;
};
+ if ctx
+ .selected_names
+ .as_ref()
+ .is_some_and(|selected| !selected.contains(name))
+ {
+ continue;
+ }
key_builder.append_value(name);
value_indices.push((overflow_source_index.expect("overflow
source exists"), j));
count += 1;
@@ -1732,6 +1847,9 @@ mod tests {
&MapReadContext {
num_columns,
name_by_id,
+ selected_names: None,
+ selected_columns: BTreeSet::new(),
+ include_overflow: true,
},
&MapType::new(string_type(), value_type),
"nested",
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index d232614b..4881fc82 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -205,6 +205,25 @@ impl<'a> TableRead<'a> {
}
}
+ /// Returns rows with a leading `rowkind` column.
+ ///
+ /// Materialized snapshot rows are inserts (`+I`). Streaming primary-key
+ /// splits retain their physical `_VALUE_KIND` values so change events are
+ /// exposed as `+I`, `-U`, `+U`, or `-D` without merge reconciliation.
+ pub fn to_arrow_with_row_kind(
+ &self,
+ data_splits: &[DataSplit],
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ self.ensure_query_auth_allowed()?;
+ match &self.0 {
+ TableReadKind::Paimon(read) =>
read.to_arrow_with_row_kind(data_splits),
+ TableReadKind::Format(read) => {
+ let schema = audit_schema_for_read_type(read.read_type(),
false)?;
+ prepend_insert_row_kind_stream(read.to_arrow(data_splits)?,
schema)
+ }
+ }
+ }
+
/// Returns an [`ArrowRecordBatchStream`] for an incremental scan plan.
///
/// Delta/Changelog use [`IncrementalSplit::Data`]. Diff uses
@@ -415,16 +434,57 @@ impl<'a> PaimonTableRead<'a> {
}
}
+ fn to_arrow_with_row_kind(
+ &self,
+ data_splits: &[DataSplit],
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ let schema = audit_schema_for_read_type(&self.read_type, false)?;
+ let (streaming, materialized): (Vec<_>, Vec<_>) = data_splits
+ .iter()
+ .cloned()
+ .partition(DataSplit::is_streaming);
+ let mut streams = Vec::with_capacity(2);
+ if !materialized.is_empty() {
+ streams.push(prepend_insert_row_kind_stream(
+ self.to_arrow(&materialized)?,
+ Arc::clone(&schema),
+ )?);
+ }
+ if !streaming.is_empty() {
+ if self.table.schema().primary_keys().is_empty() {
+ streams.push(prepend_insert_row_kind_stream(
+ self.to_arrow(&streaming)?,
+ Arc::clone(&schema),
+ )?);
+ } else {
+ streams.push(self.audit_raw_data_splits(&streaming, true,
false)?);
+ }
+ }
+ Ok(Box::pin(stream::select_all(streams)))
+ }
+
fn audit_raw_stream(
&self,
plan: &IncrementalPlan,
has_value_kind: bool,
) -> crate::Result<ArrowRecordBatchStream> {
plan.validate()?;
- let core_options = self.table.schema().core_options();
let data_splits = plan.data_splits();
+ self.audit_raw_data_splits(
+ &data_splits,
+ has_value_kind,
+ audit_sequence_number_enabled(self.table),
+ )
+ }
+
+ fn audit_raw_data_splits(
+ &self,
+ data_splits: &[DataSplit],
+ has_value_kind: bool,
+ include_sequence: bool,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ let core_options = self.table.schema().core_options();
let user_read_type = self.read_type.clone();
- let include_sequence = audit_sequence_number_enabled(self.table);
let audit_schema = audit_schema_for_read_type(&user_read_type,
include_sequence)?;
let mut read_type = user_read_type.clone();
@@ -460,7 +520,7 @@ impl<'a> PaimonTableRead<'a> {
.with_parquet_read_budget(Some(self.parquet_read_budget()?))
.with_table_options(self.table.schema().options().clone())
.with_mosaic_prefetch(configured_mosaic_prefetch(self.table)?);
- let raw_stream = reader.read(&data_splits)?;
+ let raw_stream = reader.read(data_splits)?;
Ok(Box::pin(async_stream::try_stream! {
futures::pin_mut!(raw_stream);
@@ -982,6 +1042,34 @@ impl<'a> PaimonTableRead<'a> {
}
}
+fn prepend_insert_row_kind_stream(
+ stream: ArrowRecordBatchStream,
+ schema: Arc<ArrowSchema>,
+) -> crate::Result<ArrowRecordBatchStream> {
+ Ok(Box::pin(async_stream::try_stream! {
+ futures::pin_mut!(stream);
+ while let Some(batch) = stream.next().await {
+ yield prepend_insert_row_kind(batch?, Arc::clone(&schema))?;
+ }
+ }))
+}
+
+fn prepend_insert_row_kind(
+ batch: RecordBatch,
+ schema: Arc<ArrowSchema>,
+) -> crate::Result<RecordBatch> {
+ let mut columns = Vec::with_capacity(batch.num_columns() + 1);
+ columns.push(Arc::new(StringArray::from(vec!["+I"; batch.num_rows()])) as
ArrayRef);
+ columns.extend(batch.columns().iter().cloned());
+ let options =
RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
+ RecordBatch::try_new_with_options(schema, columns,
&options).map_err(|error| {
+ crate::Error::UnexpectedError {
+ message: format!("Failed to prepend row kind column: {error}"),
+ source: Some(Box::new(error)),
+ }
+ })
+}
+
fn audit_schema_for_read_type(
read_type: &[DataField],
include_sequence: bool,
@@ -1569,6 +1657,32 @@ mod tests {
use crate::table::source::DataSplitBuilder;
use futures::TryStreamExt;
+ #[test]
+ fn test_prepend_insert_row_kind_preserves_zero_column_row_count() {
+ let batch = RecordBatch::try_new_with_options(
+ Arc::new(ArrowSchema::empty()),
+ vec![],
+ &RecordBatchOptions::new().with_row_count(Some(3)),
+ )
+ .unwrap();
+
+ let actual =
+ prepend_insert_row_kind(batch, audit_schema_for_read_type(&[],
false).unwrap())
+ .unwrap();
+
+ assert_eq!(actual.num_rows(), 3);
+ assert_eq!(actual.schema().fields()[0].name(), ROW_KIND_FIELD_NAME);
+ let kinds = actual
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ assert_eq!(
+ kinds.iter().collect::<Vec<_>>(),
+ vec![Some("+I"), Some("+I"), Some("+I")]
+ );
+ }
+
pub(super) fn file(name: &str, level: i32, delete_row_count: Option<i64>)
-> DataFileMeta {
DataFileMeta {
file_name: name.to_string(),
diff --git a/crates/paimon/tests/audit_log_table_test.rs
b/crates/paimon/tests/audit_log_table_test.rs
index 94a31d43..75327aa8 100644
--- a/crates/paimon/tests/audit_log_table_test.rs
+++ b/crates/paimon/tests/audit_log_table_test.rs
@@ -237,6 +237,17 @@ async fn audit_log_delta_scan_preserves_pk_row_kinds() {
("-U".to_string(), 2, 20),
]
);
+
+ let splits = plan.data_splits();
+ assert!(splits.iter().all(|split| split.is_streaming()));
+ let read = table.new_read_builder().new_read().unwrap();
+ let direct: Vec<RecordBatch> = read
+ .to_arrow_with_row_kind(&splits)
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(collect_audit_rows(&direct), collect_audit_rows(&batches));
}
#[test]
@@ -318,6 +329,26 @@ async fn audit_log_exposes_sequence_number_when_enabled() {
let rows = collect_audit_rows_with_sequence(&batches);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|(_, seq, _, _)| *seq >= 0));
+
+ let direct: Vec<RecordBatch> = table
+ .new_read_builder()
+ .new_read()
+ .unwrap()
+ .to_arrow_with_row_kind(&plan.data_splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ direct[0]
+ .schema()
+ .fields()
+ .iter()
+ .map(|field| field.name().clone())
+ .collect::<Vec<_>>(),
+ vec!["rowkind".to_string(), "id".to_string(), "value".to_string()],
+ "the Python-compatible row-kind read must not leak the audit-only
sequence column",
+ );
}
#[tokio::test]