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 283f1f93 Drive nested, MAP and VARIANT projections from structured
read types (#1041)
283f1f93 is described below
commit 283f1f938e77633ca33529e45e92f45fc6b9053e
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 5 22:03:41 2026 +0800
Drive nested, MAP and VARIANT projections from structured read types (#1041)
---
.../python/python/pypaimon_rust/datafusion.pyi | 10 +-
bindings/python/src/read.rs | 233 +--------------
bindings/python/tests/test_read.py | 5 +-
crates/paimon/src/arrow/format/mod.rs | 1 +
crates/paimon/src/arrow/format/parquet.rs | 328 ++++++++++++++++++---
.../paimon/src/arrow/format/variant_projection.rs | 297 +++++++++++++++++++
crates/paimon/src/arrow/nested_evolution.rs | 173 ++++++++++-
crates/paimon/src/arrow/shredding/variant.rs | 110 ++++---
crates/paimon/src/spec/map_selected_keys.rs | 120 ++++++++
crates/paimon/src/spec/mod.rs | 6 +
crates/paimon/src/spec/read_type.rs | 238 +++++++++++++++
crates/paimon/src/table/data_evolution_nested.rs | 114 ++++++-
crates/paimon/src/table/data_evolution_reader.rs | 50 +++-
crates/paimon/src/table/data_file_reader.rs | 231 ++++++++++-----
crates/paimon/src/table/kv_file_reader.rs | 50 ++--
crates/paimon/src/table/query_auth.rs | 84 ++++--
crates/paimon/src/table/read_builder.rs | 2 +
crates/paimon/src/variant.rs | 120 +++++++-
18 files changed, 1739 insertions(+), 433 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 31e4120f..2953e1c5 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -89,13 +89,13 @@ class TableRead:
class ReadBuilder:
def with_read_type(self, read_type_json: str) -> "ReadBuilder":
- """Set a complete Paimon ROW read type serialized as schema JSON."""
+ """Set the complete structured reader type as Paimon ROW schema JSON.
+ Preserve source field IDs for nested ROW pruning. Field descriptions
+ carry VARIANT extraction and selected-key MAP metadata. Results retain
+ the requested structure; output aliases and flattening belong to
callers.
+ """
...
def with_projection(self, columns: List[str]) -> "ReadBuilder": ...
- def with_nested_projection(self, paths: List[List[str]]) -> "ReadBuilder":
- """Project top-level fields or nested ROW leaves by exact name paths.
A MAP
- path keeps the complete MAP so the caller can extract literal keys."""
- ...
def with_case_sensitive(self, case_sensitive: bool) -> "ReadBuilder":
"""
Set whether column-name matching (projection and predicate column
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 53e88384..8cbaf515 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -33,9 +33,6 @@ use tokio::sync::Notify;
use crate::error::to_py_err;
use crate::predicate::dict_to_table_predicate;
-const MAP_SELECTED_KEYS_PREFIX: &str = "__PAIMON_MAP_SELECTED_KEYS:";
-const MAP_SELECTED_KEYS_DELIMITER: char = ';';
-
struct PyReadProjection<'a> {
columns: &'a Option<Vec<String>>,
read_type: &'a Option<Vec<DataField>>,
@@ -99,106 +96,6 @@ 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
@@ -338,39 +235,7 @@ impl PyReadBuilder {
Ok(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)
- }
-
+ /// Limit the number of visible rows.
fn with_limit(mut slf: PyRefMut<'_, Self>, limit: usize) -> PyRefMut<'_,
Self> {
slf.limit = Some(limit);
slf
@@ -953,102 +818,6 @@ 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/bindings/python/tests/test_read.py
b/bindings/python/tests/test_read.py
index 6d72e482..27642786 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -213,11 +213,12 @@ def
test_with_read_type_rejects_unsupported_variant_target():
table.new_read_builder().with_read_type(json.dumps(read_type))
-def test_with_nested_projection_replaces_read_type():
+def test_with_read_type_replaces_variant_extraction():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_variant_table_with_data(warehouse)
builder = table.new_read_builder().with_read_type(_variant_read_type(
- table, ["payload"], ["$.age"])).with_nested_projection([["id"]])
+ table, ["payload"], ["$.age"])).with_read_type(json.dumps({"type":
"ROW", "fields": [{
+ "id": table.schema().fields()[0].id(), "name": "id", "type":
"INT"}]}))
plan = builder.new_scan().plan()
result = pa.Table.from_batches(builder.new_read().read(plan.splits()))
assert result.schema.names == ["id"]
diff --git a/crates/paimon/src/arrow/format/mod.rs
b/crates/paimon/src/arrow/format/mod.rs
index b85fad7e..a400b067 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -26,6 +26,7 @@ pub(crate) mod parquet;
mod row;
mod shredding;
pub(crate) mod text;
+mod variant_projection;
#[cfg(feature = "vortex")]
mod vortex;
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index 213f93af..81b9f15b 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -686,28 +686,18 @@ impl FormatFileReader for ParquetFormatReader {
crate::arrow::residual::widen_scan_fields(read_fields, predicates)
};
- let root_schema = parquet_schema.root_schema();
- let root_indices: Vec<usize> = scan_fields
- .iter()
- .filter_map(|f| {
- root_schema
- .get_fields()
- .iter()
- .position(|pf| pf.name() == f.name())
- })
- .collect();
-
// 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)
- };
+ let mask = read_type_projection_mask(
+ &parquet_schema,
+ &scan_fields,
+ map_read_plan.as_deref(),
+ preds,
+ );
batch_stream_builder =
batch_stream_builder.with_projection(mask.clone());
let mut decoder_predicates = build_parquet_row_filter(&parquet_schema,
preds, file_fields)?
@@ -1060,29 +1050,90 @@ impl FormatFileReader for ParquetFormatReader {
}
}
-fn map_shredding_projection_mask(
+fn read_type_projection_mask(
parquet_schema: &parquet::schema::types::SchemaDescriptor,
- selected_roots: &[usize],
- plan: &MapShreddingReadPlan,
+ read_fields: &[DataField],
+ map_plan: Option<&MapShreddingReadPlan>,
+ predicates: &[Predicate],
) -> 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;
+ fn requested_paths(
+ field: &DataField,
+ physical: &parquet::schema::types::Type,
+ path: &mut Vec<String>,
+ output: &mut std::collections::HashSet<Vec<String>>,
+ ) {
+ if crate::spec::is_map_selected_keys_field(field) {
+ // Selected-key ROW children name logical MAP keys, not physical
+ // Parquet children. Shared MAPs use their read plan above; other
+ // MAPs must remain complete for the core's key-to-ROW assembly.
+ super::variant_projection::all_leaves(physical, path, output);
+ return;
}
- let root_name = root_fields[root_index].name();
- let Some(children) = plan.projected_physical_children(root_name) else {
- return true;
+ let data_type = field.data_type();
+ if let DataType::Row(row) = data_type {
+ if crate::spec::is_variant_extraction_row_type(data_type) {
+ output.extend(
+ super::variant_projection::projected_paths(row, physical)
+ .into_iter()
+ .map(|parts| {
+ let mut full = path.clone();
+ full.extend(parts);
+ full
+ }),
+ );
+ return;
+ }
+ if !physical.is_primitive() {
+ for field in row.fields() {
+ if let Some(child) = physical
+ .get_fields()
+ .iter()
+ .find(|child| child.name() == field.name())
+ {
+ path.push(child.name().to_string());
+ requested_paths(field, child, path, output);
+ path.pop();
+ }
+ }
+ return;
+ }
+ }
+ super::variant_projection::all_leaves(physical, path, output);
+ }
+ let mut predicate_refs = Vec::new();
+ for predicate in predicates {
+ crate::arrow::residual::collect_predicate_leaf_refs(predicate, &mut
predicate_refs);
+ }
+ let roots = parquet_schema.root_schema().get_fields();
+ let mut selected = std::collections::HashSet::new();
+ for field in read_fields {
+ let Some(root) = roots.iter().find(|root| root.name() == field.name())
else {
+ continue;
};
- parquet_schema
- .column(*leaf_index)
- .path()
- .parts()
- .get(1)
- .is_some_and(|child| children.contains(child))
- });
+ let mut path = vec![root.name().to_string()];
+ if predicate_refs
+ .iter()
+ .any(|(column, _)| *column == field.name())
+ {
+ super::variant_projection::all_leaves(root, &mut path, &mut
selected);
+ } else if let Some(children) =
+ map_plan.and_then(|plan|
plan.projected_physical_children(field.name()))
+ {
+ for child in root
+ .get_fields()
+ .iter()
+ .filter(|child| children.contains(child.name()))
+ {
+ path.push(child.name().to_string());
+ super::variant_projection::all_leaves(child, &mut path, &mut
selected);
+ path.pop();
+ }
+ } else {
+ requested_paths(field, root, &mut path, &mut selected);
+ }
+ }
+ let leaves = (0..parquet_schema.num_columns())
+ .filter(|&leaf|
selected.contains(parquet_schema.column(leaf).path().parts()));
ProjectionMask::leaves(parquet_schema, leaves)
}
@@ -2935,6 +2986,92 @@ mod tests {
use std::time::Duration;
use tokio::sync::mpsc;
+ #[test]
+ fn selected_map_rows_keep_all_map_leaves_without_a_shredding_plan() {
+ use crate::spec::RowType;
+ let descriptor = SchemaDescriptor::new(Arc::new(
+ parse_message_type(
+ "message schema {
+ optional group attrs (MAP) { repeated group key_value {
+ required binary key (STRING); optional int32 value;
+ } }
+ optional group profile {
+ optional group attrs (MAP) { repeated group key_value {
+ required binary key (STRING); optional int32 value;
+ } }
+ optional int32 unused;
+ }
+ optional int32 unused;
+ }",
+ )
+ .unwrap(),
+ ));
+ let map_type = DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::new()),
+ ));
+ let top = crate::spec::map_selected_keys_field(
+ &DataField::new(10, "attrs".into(), map_type.clone()),
+ &["wanted".into()],
+ )
+ .unwrap();
+ // A literal key can even collide with a Parquet layout field name.
+ let nested = crate::spec::map_selected_keys_field(
+ &DataField::new(21, "attrs".into(), map_type),
+ &["key_value".into()],
+ )
+ .unwrap();
+ let fields = vec![
+ top,
+ DataField::new(
+ 20,
+ "profile".into(),
+ DataType::Row(RowType::new(vec![nested])),
+ ),
+ ];
+ let mask = super::read_type_projection_mask(&descriptor, &fields,
None, &[]);
+ for index in 0..4 {
+ assert!(mask.leaf_included(index), "Missing MAP leaf {index}");
+ }
+ assert!(!mask.leaf_included(4));
+ assert!(!mask.leaf_included(5));
+ }
+
+ #[test]
+ fn nested_read_type_masks_physical_leaves_and_keeps_predicate_inputs() {
+ use crate::spec::RowType;
+ let descriptor = SchemaDescriptor::new(Arc::new(parse_message_type(
+ "message schema { optional group profile { optional int32 score;
optional binary unused; } optional int32 id; }"
+ ).unwrap()));
+ let fields = vec![
+ DataField::new(
+ 10,
+ "profile".into(),
+ DataType::Row(RowType::new(vec![
+ DataField::new(11, "score".into(),
DataType::Int(IntType::new())),
+ DataField::new(
+ 12,
+ "unused".into(),
+ DataType::VarChar(VarCharType::string_type()),
+ ),
+ ])),
+ ),
+ DataField::new(20, "id".into(), DataType::Int(IntType::new())),
+ ];
+ let selected =
+ crate::spec::project_read_type(&fields, &[vec!["profile".into(),
"score".into()]])
+ .unwrap();
+ let mask = super::read_type_projection_mask(&descriptor, &selected,
None, &[]);
+ assert!(mask.leaf_included(0));
+ assert!(!mask.leaf_included(1));
+ assert!(!mask.leaf_included(2));
+ let predicate =
PredicateBuilder::new(&fields).is_null("profile").unwrap();
+ let widened = super::read_type_projection_mask(&descriptor, &selected,
None, &[predicate]);
+ assert!(widened.leaf_included(0));
+ assert!(widened.leaf_included(1));
+ assert!(!widened.leaf_included(2));
+ }
+
fn test_fields() -> Vec<DataField> {
vec![
DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
@@ -6537,6 +6674,127 @@ mod tests {
(path, file_io, file_size, fields)
}
+ #[tokio::test]
+ async fn selected_map_rows_decode_complete_maps_before_core_assembly() {
+ use crate::spec::RowType;
+ let map_type = map_shredding_fields()[1].data_type().clone();
+ let fields = vec![
+ DataField::new(10, "attrs".into(), map_type.clone()),
+ DataField::new(
+ 20,
+ "profile".into(),
+ DataType::Row(RowType::new(vec![
+ DataField::new(21, "attrs".into(), map_type),
+ DataField::new(22, "unused".into(),
DataType::Int(IntType::new())),
+ ])),
+ ),
+ ];
+ let rows = vec![
+ Some(vec![("wanted", Some(10)), ("key_value", Some(11))]),
+ None,
+ Some(vec![]),
+ Some(vec![
+ ("wanted", Some(40)),
+ ("wanted", Some(50)),
+ ("key_value", Some(41)),
+ ]),
+ ];
+ let schema = build_target_arrow_schema(&fields).unwrap();
+ let ArrowDataType::Struct(profile_fields) =
schema.field(1).data_type() else {
+ panic!("profile must be ROW");
+ };
+ let profile = StructArray::new(
+ profile_fields.clone(),
+ vec![
+ Arc::new(build_int64_map_array(&rows)),
+ Arc::new(Int32Array::from(vec![1, 2, 3, 4])),
+ ],
+ Some(NullBuffer::from(vec![true, false, true, true])),
+ );
+ let batch = RecordBatch::try_new(
+ schema.clone(),
+ vec![Arc::new(build_int64_map_array(&rows)), Arc::new(profile)],
+ )
+ .unwrap();
+ let requested = crate::spec::project_read_type(
+ &fields,
+ &[
+ vec!["attrs".into(), "wanted".into()],
+ vec!["attrs".into(), "missing".into()],
+ vec!["profile".into(), "attrs".into(), "key_value".into()],
+ ],
+ )
+ .unwrap();
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/selected_map_complete_fallback.parquet";
+ let output = file_io.new_output(path).unwrap();
+ let mut writer =
+ create_format_writer(&output, schema, "zstd", 1, None,
Some(&fields), None)
+ .await
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ let file_size = writer.close().await.unwrap().file_size;
+ let batches = create_format_reader(path, false, &requested)
+ .unwrap()
+ .read_batch_stream(
+
Box::new(file_io.new_input(path).unwrap().reader().await.unwrap()),
+ file_size,
+ &requested,
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(batches.len(), 1);
+ assert_int64_map_rows(
+ batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .unwrap(),
+ &rows,
+ );
+ let top = crate::arrow::nested_evolution::evolve_field(
+ batches[0].column(0),
+ &fields[0],
+ &requested[0],
+ )
+ .unwrap();
+ let top = top.as_any().downcast_ref::<StructArray>().unwrap();
+ assert!(top.is_null(1));
+ assert_eq!(
+ top.column(0).as_any().downcast_ref::<Int64Array>().unwrap(),
+ &Int64Array::from(vec![Some(10), None, None, Some(40)]),
+ );
+ assert_eq!(top.column(1).null_count(), 4);
+ let profile = crate::arrow::nested_evolution::evolve_field(
+ batches[0].column(1),
+ &fields[1],
+ &requested[1],
+ )
+ .unwrap();
+ let profile = profile.as_any().downcast_ref::<StructArray>().unwrap();
+ assert!(profile.is_null(1));
+ assert_eq!(profile.num_columns(), 1);
+ let attrs = profile
+ .column(0)
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap();
+ assert_eq!(
+ attrs
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap(),
+ &Int64Array::from(vec![Some(11), None, None, Some(41)]),
+ );
+ }
+
#[tokio::test]
async fn test_parquet_map_shredding_write_read_roundtrip() {
let rows: Vec<Option<Vec<(&str, Option<i64>)>>> = vec![
diff --git a/crates/paimon/src/arrow/format/variant_projection.rs
b/crates/paimon/src/arrow/format/variant_projection.rs
new file mode 100644
index 00000000..1af09d2a
--- /dev/null
+++ b/crates/paimon/src/arrow/format/variant_projection.rs
@@ -0,0 +1,297 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Read-type-driven Parquet clipping, mirroring VariantShreddingTypePruner.
+
+use crate::spec::{parse_variant_metadata, RowType};
+use crate::variant::{parse_path, PathSegment};
+use parquet::basic::{LogicalType, Repetition};
+use parquet::schema::types::Type;
+use std::collections::{HashMap, HashSet};
+
+#[derive(Default)]
+struct PathNode {
+ children: HashMap<String, PathNode>,
+ element: Option<Box<PathNode>>,
+ keep_all: bool,
+}
+
+/// The requested physical leaves, relative to the Variant column. Invalid
+/// paths retain the full input so strict/try extraction handles the error at
+/// the same point as an unshredded read.
+pub(super) fn projected_paths(row: &RowType, physical: &Type) ->
HashSet<Vec<String>> {
+ let mut root = PathNode::default();
+ for field in row.fields() {
+ let segments = field.description().and_then(|description| {
+ parse_variant_metadata(description)
+ .ok()
+ .and_then(|metadata| parse_path(metadata.path()).ok())
+ });
+ let Some(segments) = segments else {
+ root.keep_all = true;
+ break;
+ };
+ let mut node = &mut root;
+ for segment in segments {
+ node = match segment {
+ PathSegment::Key(key) => node.children.entry(key).or_default(),
+ PathSegment::Index(_) =>
node.element.get_or_insert_with(Default::default),
+ };
+ }
+ node.keep_all = true;
+ }
+ let mut result = HashSet::new();
+ clip(physical, &root, &mut Vec::new(), &mut result);
+ result
+}
+
+pub(super) fn all_leaves(
+ physical: &Type,
+ path: &mut Vec<String>,
+ output: &mut HashSet<Vec<String>>,
+) {
+ if physical.is_primitive() {
+ output.insert(path.clone());
+ } else {
+ for child in physical.get_fields() {
+ path.push(child.name().to_string());
+ all_leaves(child, path, output);
+ path.pop();
+ }
+ }
+}
+
+fn keep_child(child: &Type, path: &mut Vec<String>, output: &mut
HashSet<Vec<String>>) {
+ path.push(child.name().to_string());
+ all_leaves(child, path, output);
+ path.pop();
+}
+
+/// Mirror Java's ParquetListLayoutResolver.isCanonicalList. Other LIST
+/// layouts must stay whole instead of assuming these physical levels.
+fn canonical_list(physical: &Type) -> Option<(&Type, &Type)> {
+ if physical.is_primitive()
+ || !matches!(
+ physical.get_basic_info().logical_type_ref(),
+ Some(LogicalType::List)
+ )
+ || physical.get_fields().len() != 1
+ {
+ return None;
+ }
+ let repeated = &physical.get_fields()[0];
+ if repeated.is_primitive()
+ || repeated.name() != "list"
+ || repeated.get_basic_info().repetition() != Repetition::REPEATED
+ || repeated.get_fields().len() != 1
+ {
+ return None;
+ }
+ let element = &repeated.get_fields()[0];
+ (element.name() == "element" && element.get_basic_info().repetition() !=
Repetition::REPEATED)
+ .then_some((repeated.as_ref(), element.as_ref()))
+}
+
+fn clip(
+ physical: &Type,
+ node: &PathNode,
+ path: &mut Vec<String>,
+ output: &mut HashSet<Vec<String>>,
+) {
+ if physical.is_primitive() || node.keep_all {
+ all_leaves(physical, path, output);
+ return;
+ }
+ let fields = physical.get_fields();
+ let Some(typed) = fields.iter().find(|child| child.name() ==
"typed_value") else {
+ all_leaves(physical, path, output);
+ return;
+ };
+ let logical = typed.get_basic_info().logical_type_ref();
+ let object = !typed.is_primitive() && logical.is_none();
+ let list_layout = canonical_list(typed);
+ let list = list_layout.is_some() && node.element.is_some();
+ if (!object || node.element.is_some()) && !list {
+ all_leaves(physical, path, output);
+ return;
+ }
+ if list && node.element.as_ref().unwrap().keep_all {
+ all_leaves(physical, path, output);
+ return;
+ }
+ if let Some(metadata) = fields.iter().find(|child| child.name() ==
"metadata") {
+ keep_child(metadata, path, output);
+ }
+ if object && node.element.is_none() {
+ let missing = node
+ .children
+ .keys()
+ .any(|key| !typed.get_fields().iter().any(|field| field.name() ==
key));
+ if missing {
+ if let Some(value) = fields.iter().find(|child| child.name() ==
"value") {
+ keep_child(value, path, output);
+ }
+ }
+ path.push("typed_value".into());
+ for child in typed.get_fields() {
+ if let Some(selected) = node.children.get(child.name()) {
+ path.push(child.name().to_string());
+ clip(child, selected, path, output);
+ path.pop();
+ }
+ }
+ path.pop();
+ } else {
+ // Canonical Parquet LIST: typed_value / list / element. Array
+ // indices cannot prune rows, only fields within every element.
+ let (repeated, element) = list_layout.expect("canonical layout was
checked above");
+ if !node.children.is_empty() {
+ if let Some(value) = fields.iter().find(|child| child.name() ==
"value") {
+ keep_child(value, path, output);
+ }
+ }
+ path.extend([
+ typed.name().into(),
+ repeated.name().into(),
+ element.name().into(),
+ ]);
+ clip(element, node.element.as_ref().unwrap(), path, output);
+ path.truncate(path.len() - 3);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{variant_extraction_row, DataType, FloatType};
+ use parquet::schema::parser::parse_message_type;
+
+ fn paths(requests: &[&str]) -> HashSet<Vec<String>> {
+ let physical = parse_message_type(r#"message schema { optional group v
{
+ optional binary metadata; optional binary value;
+ optional group typed_value {
+ optional group obj { optional binary value; optional group
typed_value {
+ optional group wanted { optional binary value; optional
float typed_value; }
+ optional group unused { optional binary value; optional
float typed_value; }
+ } }
+ optional group arr { optional binary value; optional group
typed_value (LIST) {
+ repeated group list { optional group element {
+ optional binary value; optional group typed_value {
+ optional group wanted { optional binary value;
optional float typed_value; }
+ optional group unused { optional binary value;
optional float typed_value; }
+ }
+ } }
+ } }
+ }
+ } }"#).unwrap();
+ let row = variant_extraction_row(
+ true,
+ requests.iter().map(|path| {
+ (
+ DataType::Float(FloatType::new()),
+ (*path).into(),
+ false,
+ "UTC".into(),
+ )
+ }),
+ )
+ .unwrap();
+ projected_paths(&row, &physical.get_fields()[0])
+ }
+
+ fn includes(paths: &HashSet<Vec<String>>, path: &str) -> bool {
+
paths.contains(&path.split('.').map(str::to_string).collect::<Vec<_>>())
+ }
+
+ #[test]
+ fn object_array_and_missing_paths_prune_unrequested_leaves() {
+ let selected = paths(&["$.obj.wanted", "$.arr[1].wanted",
"$.missing"]);
+ assert!(includes(&selected, "metadata"));
+ assert!(includes(&selected, "value"));
+ assert!(includes(
+ &selected,
+ "typed_value.obj.typed_value.wanted.typed_value"
+ ));
+ assert!(includes(
+ &selected,
+
"typed_value.arr.typed_value.list.element.typed_value.wanted.typed_value"
+ ));
+ assert!(!selected
+ .iter()
+ .any(|parts| parts.iter().any(|part| part == "unused")));
+ let known = paths(&["$.obj.wanted"]);
+ assert!(!includes(&known, "value"));
+ let wrong_case = paths(&["$.Obj.wanted"]);
+ assert!(includes(&wrong_case, "value"));
+ assert_eq!(wrong_case.len(), 2);
+ }
+
+ #[test]
+ fn noncanonical_variant_lists_keep_the_whole_shredded_value() {
+ let row = variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$[0].wanted".into(),
+ false,
+ "UTC".into(),
+ )],
+ )
+ .unwrap();
+ for (middle, middle_repetition, element, element_repetition) in [
+ ("array", "repeated", "element", "optional"),
+ ("typed_value_tuple", "repeated", "element", "optional"),
+ ("bag", "repeated", "element", "optional"),
+ ("list", "repeated", "item", "optional"),
+ ("list", "optional", "element", "optional"),
+ ("list", "repeated", "element", "repeated"),
+ ] {
+ let physical = parse_message_type(&format!(r#"message schema {{
optional group v {{
+ optional binary metadata; optional binary value;
+ optional group typed_value (LIST) {{
+ {middle_repetition} group {middle} {{
+ {element_repetition} group {element} {{
+ optional binary value; optional group typed_value
{{
+ optional group wanted {{ optional binary
value; optional float typed_value; }}
+ optional group unused {{ optional binary
value; optional float typed_value; }}
+ }}
+ }}
+ }}
+ }}
+ }} }}"#)).unwrap();
+ let variant = &physical.get_fields()[0];
+ let mut complete = HashSet::new();
+ all_leaves(variant, &mut Vec::new(), &mut complete);
+ assert_eq!(
+ projected_paths(&row, variant),
+ complete,
+ "Must keep {middle_repetition} {middle} / {element_repetition}
{element} whole",
+ );
+ }
+ }
+
+ #[test]
+ fn whole_values_and_invalid_paths_retain_the_required_shape() {
+ let whole = paths(&["$"]);
+ assert_eq!(paths(&["invalid"]), whole);
+ assert!(paths(&["$.arr[0]"])
+ .iter()
+ .any(|parts| parts.iter().any(|part| part == "unused")));
+ let mixed = paths(&["$.arr[0].wanted", "$.arr.key"]);
+ assert!(includes(&mixed, "typed_value.arr.value"));
+ }
+}
diff --git a/crates/paimon/src/arrow/nested_evolution.rs
b/crates/paimon/src/arrow/nested_evolution.rs
index df71d925..ffcc7bae 100644
--- a/crates/paimon/src/arrow/nested_evolution.rs
+++ b/crates/paimon/src/arrow/nested_evolution.rs
@@ -35,12 +35,108 @@ use std::sync::Arc;
use arrow_array::{
new_null_array, Array, ArrayRef, Int64Array, ListArray, MapArray,
StringArray, StructArray,
+ UInt32Array,
};
use arrow_cast::cast;
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Fields};
use crate::arrow::paimon_type_to_arrow;
-use crate::spec::{is_variant_extraction_row_type, DataType, MapType, RowType};
+use crate::spec::{
+ is_variant_extraction_row_type, map_selected_keys, DataField, DataType,
MapType, RowType,
+};
+
+/// Field-aware projection for synthetic read types and nested schema
evolution.
+pub(crate) fn evolve_field(
+ source: &ArrayRef,
+ source_field: &DataField,
+ target: &DataField,
+) -> crate::Result<ArrayRef> {
+ if let (DataType::Map(map_type), DataType::Row(row_type), Some(keys)) = (
+ source_field.data_type(),
+ target.data_type(),
+ map_selected_keys(target)?,
+ ) {
+ return selected_map_to_row(source, map_type, row_type, &keys);
+ }
+ if matches!(source_field.data_type(), DataType::Variant(_)) {
+ if let DataType::Row(row) = target.data_type() {
+ if is_variant_extraction_row_type(target.data_type()) {
+ // A format read plan may already have assembled the synthetic
+ // ROW while its field context still describes the VARIANT.
+ if source.data_type() ==
&paimon_type_to_arrow(target.data_type())? {
+ return Ok(source.clone());
+ }
+ return
crate::arrow::shredding::variant::assemble_variant_extraction_array(
+ source.as_ref(),
+ row,
+ );
+ }
+ }
+ }
+ evolve_column(source, source_field.data_type(), target.data_type())
+}
+
+pub(crate) fn selected_map_to_row(
+ source: &ArrayRef,
+ map_type: &MapType,
+ row_type: &RowType,
+ keys: &[String],
+) -> crate::Result<ArrayRef> {
+ let map =
+ source
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Selected MAP input must be a MapArray".into(),
+ source: None,
+ })?;
+ let names = map
+ .keys()
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Selected MAP keys must be STRING".into(),
+ source: None,
+ })?;
+ let mut children = Vec::new();
+ for (key, field) in keys.iter().zip(row_type.fields()) {
+ let indices: UInt32Array = (0..map.len())
+ .map(|row| {
+ if map.is_null(row) {
+ return None;
+ }
+ let offsets = map.value_offsets();
+ (offsets[row] as usize..offsets[row + 1] as usize)
+ .find(|&index| names.is_valid(index) && names.value(index)
== key)
+ .map(|index| index as u32)
+ })
+ .collect();
+ let values =
+ arrow_select::take::take(map.values().as_ref(), &indices,
None).map_err(|e| {
+ crate::Error::UnexpectedError {
+ message: "Failed to gather selected MAP values".into(),
+ source: Some(Box::new(e)),
+ }
+ })?;
+ children.push(evolve_column(
+ &values,
+ map_type.value_type(),
+ field.data_type(),
+ )?);
+ }
+ let ArrowDataType::Struct(fields) =
paimon_type_to_arrow(&DataType::Row(row_type.clone()))?
+ else {
+ unreachable!()
+ };
+ Ok(Arc::new(
+ StructArray::try_new(fields, children,
map.nulls().cloned()).map_err(|e| {
+ crate::Error::DataInvalid {
+ message: format!("Failed to build selected-key ROW: {e}"),
+ source: Some(Box::new(e)),
+ }
+ })?,
+ ))
+}
/// Reconcile `source` (as described by `source_type`, the type the data file
/// actually holds) with `target_type` (the type the read schema wants).
@@ -351,11 +447,7 @@ fn evolve_struct(
),
source: None,
})?;
- arrays.push(evolve_column(
- column,
- source_field.data_type(),
- target_field.data_type(),
- )?)
+ arrays.push(evolve_field(column, source_field, target_field)?)
}
}
}
@@ -550,6 +642,75 @@ mod tests {
use arrow_buffer::NullBuffer;
use arrow_schema::{DataType as ArrowDataType, Fields};
+ #[test]
+ fn selected_map_read_type_uses_key_metadata_not_child_names() {
+ use arrow_array::builder::{Int32Builder, MapBuilder, StringBuilder};
+ let mut builder = MapBuilder::new(None, StringBuilder::new(),
Int32Builder::new());
+ for (key, value) in [("key", 7), ("key", 9), ("", 3)] {
+ builder.keys().append_value(key);
+ builder.values().append_value(value);
+ }
+ builder.append(true).unwrap();
+ builder.append(false).unwrap();
+ builder.append(true).unwrap();
+ let source: ArrayRef = Arc::new(builder.finish());
+ let source_field = field(
+ 10,
+ "attrs",
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::new()),
+ )),
+ );
+ let requested = crate::spec::map_selected_keys_field(
+ &source_field,
+ &["key".into(), "".into(), "missing".into()],
+ )
+ .unwrap();
+ let DataType::Row(row) = requested.data_type() else {
+ panic!()
+ };
+ let target = DataField::new(
+ requested.id(),
+ requested.name().into(),
+ DataType::Row(RowType::new(
+ row.fields()
+ .iter()
+ .enumerate()
+ .map(|(index, child)| {
+ DataField::new(
+ child.id(),
+ format!("output_{index}"),
+ DataType::BigInt(BigIntType::new()),
+ )
+ })
+ .collect(),
+ )),
+ )
+ .with_description(requested.description().map(str::to_string));
+ let output = evolve_field(&source, &source_field, &target).unwrap();
+ let output = output.as_any().downcast_ref::<StructArray>().unwrap();
+ assert!(output.is_null(1));
+ assert!(output.is_valid(2));
+ let first = output
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ assert_eq!(first.iter().collect::<Vec<_>>(), vec![Some(7), None,
None]);
+ assert_eq!(
+ output
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>(),
+ vec![Some(3), None, None]
+ );
+ assert_eq!(output.column(2).null_count(), 3);
+ }
+
fn field(id: i32, name: &str, dt: DataType) -> DataField {
DataField::new(id, name.to_string(), dt)
}
diff --git a/crates/paimon/src/arrow/shredding/variant.rs
b/crates/paimon/src/arrow/shredding/variant.rs
index 9f75d406..83e90ca4 100644
--- a/crates/paimon/src/arrow/shredding/variant.rs
+++ b/crates/paimon/src/arrow/shredding/variant.rs
@@ -817,7 +817,6 @@ pub(crate) fn assemble_shredded_variant_batch(
let mut changed = false;
let mut columns = Vec::with_capacity(batch.num_columns());
let mut output_fields = Vec::with_capacity(batch.num_columns());
- let logical_schema = build_target_arrow_schema(read_fields)?;
for (idx, arrow_field) in schema.fields().iter().enumerate() {
let column = batch.column(idx);
@@ -829,8 +828,13 @@ pub(crate) fn assemble_shredded_variant_batch(
if let Some(assembled) =
assemble_array_to_logical(column.as_ref(),
read_fields[field_idx].data_type())?
{
+ output_fields.push(
+ arrow_field
+ .as_ref()
+ .clone()
+ .with_data_type(assembled.data_type().clone()),
+ );
columns.push(assembled);
- output_fields.push(logical_schema.field(field_idx).clone());
changed = true;
continue;
}
@@ -901,6 +905,15 @@ pub(crate) fn assemble_variant_extraction_array(
return Ok(projected);
}
+ let shredded_type = if is_shredded_variant_array(input) {
+ let DataType::Row(row) = arrow_to_paimon_type(input.data_type(),
true)? else {
+ unreachable!()
+ };
+ let schema = build_variant_schema(&row)?;
+ Some((row, schema))
+ } else {
+ None
+ };
let mut values_by_field = vec![Vec::with_capacity(input.len());
fields.len()];
let mut validities = Vec::with_capacity(input.len());
for row in 0..input.len() {
@@ -913,24 +926,40 @@ pub(crate) fn assemble_variant_extraction_array(
}
validities.push(true);
- let variant = variant_from_storage_row(input, row)?;
+ let shredded = shredded_type
+ .as_ref()
+ .map(|(row_type, _)| struct_row_at(input, row, row_type))
+ .transpose()?
+ .flatten();
+ let variant = if shredded_type.is_none() {
+ variant_from_storage_row(input, row)?
+ } else {
+ None
+ };
for (field_idx, field) in fields.iter().enumerate() {
let field_metadata = &metadata[field_idx];
- let value = match &variant {
- Some(variant) => match variant.get_path(field_metadata.path())
{
- Ok(Some(extracted)) => cast_variant_to_shredded_value(
- extracted,
- field.data_type(),
- field_metadata.fail_on_error(),
- )?,
- Ok(None) => None,
- Err(e) if !field_metadata.fail_on_error() => {
- let _ = e;
- None
- }
- Err(e) => return Err(e),
+ let extracted = match (&shredded, &shredded_type) {
+ (Some(shredded), Some((_, schema))) => {
+ crate::variant::extract_shredded_path(shredded, schema,
field_metadata.path())
+ }
+ _ => match &variant {
+ Some(variant) => variant
+ .get_path(field_metadata.path())
+ .and_then(|selected| {
+ selected.map(|value|
value.to_owned_variant()).transpose()
+ }),
+ None => Ok(None),
},
- None => None,
+ };
+ let value = match extracted {
+ Ok(Some(extracted)) => cast_variant_to_shredded_value(
+ extracted.as_ref()?,
+ field.data_type(),
+ field_metadata.fail_on_error(),
+ )?,
+ Ok(None) => None,
+ Err(_) if !field_metadata.fail_on_error() => None,
+ Err(error) => return Err(error),
};
values_by_field[field_idx].push(value);
}
@@ -1078,39 +1107,38 @@ fn assemble_row_array_to_logical(
message: "Variant assembled ROW column must be
StructArray".to_string(),
source: None,
})?;
- if input.num_columns() != row_type.fields().len() {
- return Ok(None);
- }
-
let mut changed = false;
let mut columns = Vec::with_capacity(input.num_columns());
- for (idx, field) in row_type.fields().iter().enumerate() {
- if let Some(assembled) =
- assemble_array_to_logical(input.column(idx).as_ref(),
field.data_type())?
- {
+ let mut fields = Vec::with_capacity(input.num_columns());
+ // Parquet leaf clipping preserves file order. Resolve each logical child
+ // by its physical name and retain that order for field-ID reconciliation.
+ for (physical, column) in input.fields().iter().zip(input.columns()) {
+ let logical = row_type
+ .fields()
+ .iter()
+ .find(|field| field.name() == physical.name());
+ let assembled = logical
+ .map(|field| assemble_array_to_logical(column.as_ref(),
field.data_type()))
+ .transpose()?
+ .flatten();
+ if let Some(assembled) = assembled {
+ fields.push(Arc::new(
+ physical
+ .as_ref()
+ .clone()
+ .with_data_type(assembled.data_type().clone()),
+ ));
columns.push(assembled);
changed = true;
} else {
- columns.push(input.column(idx).clone());
+ fields.push(Arc::clone(physical));
+ columns.push(Arc::clone(column));
}
}
-
if !changed {
return Ok(None);
}
-
- let fields: Fields = row_type
- .fields()
- .iter()
- .map(|field| {
- Ok(ArrowField::new(
- field.name(),
- paimon_type_to_arrow(field.data_type())?,
- field.data_type().is_nullable(),
- ))
- })
- .collect::<Result<Vec<_>>>()?
- .into();
+ let fields: Fields = fields.into();
if fields.is_empty() {
Ok(Some(Arc::new(StructArray::new_empty_fields(
input.len(),
@@ -1137,7 +1165,7 @@ fn is_variant_storage_array(array: &dyn Array) -> bool {
.iter()
.any(|field| field.name() == name && field.data_type() ==
&ArrowDataType::Binary)
};
- has_binary(VARIANT_VALUE_FIELD_NAME)
+ (has_binary(VARIANT_VALUE_FIELD_NAME) || is_shredded_variant_array(array))
&& has_binary(VARIANT_METADATA_FIELD_NAME)
&& (!is_variant_arrow_fields(fields) ||
is_shredded_variant_array(array))
}
diff --git a/crates/paimon/src/spec/map_selected_keys.rs
b/crates/paimon/src/spec/map_selected_keys.rs
new file mode 100644
index 00000000..3abf7dac
--- /dev/null
+++ b/crates/paimon/src/spec/map_selected_keys.rs
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Java's temporary selected-key MAP ROW read type.
+
+use crate::spec::{DataField, DataType, RowType};
+use crate::{Error, Result};
+use std::collections::HashSet;
+
+/// Java field-description prefix for a temporary selected-key MAP ROW.
+pub const MAP_SELECTED_KEYS_METADATA_KEY: &str = "__PAIMON_MAP_SELECTED_KEYS:";
+
+/// Whether the field is a synthetic ROW carrying selected MAP key metadata.
+pub fn is_map_selected_keys_field(field: &DataField) -> bool {
+ matches!(field.data_type(), DataType::Row(_))
+ && field
+ .description()
+ .is_some_and(|value|
value.starts_with(MAP_SELECTED_KEYS_METADATA_KEY))
+}
+
+/// Decode keys in child ordinal order, validating arity and uniqueness.
+/// Ordinary fields return `None`.
+pub fn map_selected_keys(field: &DataField) -> Result<Option<Vec<String>>> {
+ if !is_map_selected_keys_field(field) {
+ return Ok(None);
+ }
+ let DataType::Row(row) = field.data_type() else {
+ unreachable!()
+ };
+ let encoded = field
+ .description()
+ .unwrap()
+ .strip_prefix(MAP_SELECTED_KEYS_METADATA_KEY)
+ .unwrap();
+ let keys: Vec<String> = encoded.split(';').map(str::to_string).collect();
+ if row.fields().is_empty()
+ || keys
+ .iter()
+ .any(|key| key.starts_with(MAP_SELECTED_KEYS_METADATA_KEY))
+ || keys.len() != row.fields().len()
+ || keys.iter().collect::<HashSet<_>>().len() != keys.len()
+ {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Selected-key metadata for '{}' must match the ROW fields and
contain unique keys",
+ field.name()
+ ),
+ source: None,
+ });
+ }
+ Ok(Some(keys))
+}
+
+/// Build Java's selected-key ROW for a STRING-keyed MAP, preserving the parent
+/// field ID and the complete value type. Each selected value is nullable.
+pub fn map_selected_keys_field(field: &DataField, keys: &[String]) ->
Result<DataField> {
+ let DataType::Map(map) = field.data_type() else {
+ return Err(Error::DataInvalid {
+ message: "Selected-key projection requires a MAP field".into(),
+ source: None,
+ });
+ };
+ if !matches!(map.key_type(), DataType::VarChar(_)) {
+ return Err(Error::Unsupported {
+ message: "Selected-key projection requires STRING map keys".into(),
+ });
+ }
+ let mut seen = HashSet::new();
+ if keys.is_empty()
+ || keys.iter().any(|key| {
+ key.contains(';')
+ || key.starts_with(MAP_SELECTED_KEYS_METADATA_KEY)
+ || !seen.insert(key)
+ })
+ {
+ return Err(Error::DataInvalid {
+ message: "Selected MAP keys must be nonempty, unique, and
encodable with Java metadata"
+ .into(),
+ source: None,
+ });
+ }
+ let children = keys
+ .iter()
+ .enumerate()
+ .map(|(index, key)| {
+ Ok(DataField::new(
+ index as i32,
+ key.clone(),
+ map.value_type().copy_with_nullable(true)?,
+ ))
+ })
+ .collect::<Result<Vec<_>>>()?;
+ Ok(DataField::new(
+ field.id(),
+ field.name().into(),
+ DataType::Row(RowType::with_nullable(
+ field.data_type().is_nullable(),
+ children,
+ )),
+ )
+ .with_description(Some(format!(
+ "{MAP_SELECTED_KEYS_METADATA_KEY}{}",
+ keys.join(";")
+ )))
+ .with_default_value(field.default_value().map(str::to_string)))
+}
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index e7b2a9af..9c64f6fd 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -57,6 +57,12 @@ pub use schema::*;
mod schema_change;
pub use schema_change::*;
+mod read_type;
+pub use read_type::*;
+
+mod map_selected_keys;
+pub use map_selected_keys::*;
+
mod variant_metadata;
pub use variant_metadata::*;
diff --git a/crates/paimon/src/spec/read_type.rs
b/crates/paimon/src/spec/read_type.rs
new file mode 100644
index 00000000..17d3c69e
--- /dev/null
+++ b/crates/paimon/src/spec/read_type.rs
@@ -0,0 +1,238 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Resolve nested reader requests while preserving ROW structure and field
IDs.
+
+use crate::spec::{
+ map_selected_keys_field, DataField, DataType, RowType,
MAP_SELECTED_KEYS_METADATA_KEY,
+};
+use crate::{Error, Result};
+const MAP_KEYS_DELIMITER: char = ';';
+fn invalid(message: impl Into<String>) -> Error {
+ Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+/// Resolve flat output paths to the authoritative nested read type used by the
+/// core reader. ROW children are pruned recursively. Selected MAP keys use
+/// Java's temporary ROW field metadata so shared-shredding Parquet files can
+/// prune physical columns.
+pub fn project_read_type(fields: &[DataField], paths: &[Vec<String>]) ->
Result<Vec<DataField>> {
+ if paths.iter().any(Vec::is_empty) {
+ return Err(invalid("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(|| {
+ invalid(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,
+) -> Result<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_read_type(row.fields(),
&child_paths).map_err(|_| {
+ invalid(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))
+ .with_default_value(field.default_value().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_KEYS_DELIMITER)
+ || key.starts_with(MAP_SELECTED_KEYS_METADATA_KEY)
+ {
+ // 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());
+ }
+ }
+ map_selected_keys_field(field, &keys)
+ }
+ _ => Err(invalid(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())
+ ))),
+ }
+}
+
+/// Validate synthetic MAP readers against the current logical schema, before
+/// historical physical value types are reconciled. Java selected children
+/// retain the MAP value type (apart from nullability), including all ROW IDs.
+pub(crate) fn validate_selected_map_fields(
+ source: &[DataField],
+ requested: &[DataField],
+) -> Result<()> {
+ for target in requested {
+ let Some(field) = source.iter().find(|field| field.id() ==
target.id()) else {
+ continue;
+ };
+ if crate::spec::is_map_selected_keys_field(target) {
+ let (DataType::Map(map), DataType::Row(row)) = (field.data_type(),
target.data_type())
+ else {
+ return Err(invalid("Selected-key ROW requires a MAP source
field"));
+ };
+ crate::spec::map_selected_keys(target)?;
+ if !matches!(map.key_type(), DataType::VarChar(_))
+ || row
+ .fields()
+ .iter()
+ .any(|child|
!map.value_type().equals_ignore_nullable(child.data_type()))
+ {
+ return Err(invalid(format!(
+ "Selected-key ROW '{}' must retain the STRING MAP's
complete value type",
+ target.name()
+ )));
+ }
+ } else if let (DataType::Row(source_row), DataType::Row(target_row)) =
+ (field.data_type(), target.data_type())
+ {
+ validate_selected_map_fields(source_row.fields(),
target_row.fields())?;
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{IntType, MapType, VarCharType};
+
+ fn fields() -> Vec<DataField> {
+ vec![
+ DataField::new(
+ 10,
+ "profile".into(),
+ DataType::Row(RowType::new(vec![
+ DataField::new(11, "score".into(),
DataType::Int(IntType::new()))
+ .with_default_value(Some("7".into())),
+ DataField::new(
+ 12,
+ "attrs".into(),
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::new()),
+ )),
+ ),
+ ])),
+ )
+ .with_description(Some("profile description".into())),
+ DataField::new(20, "id".into(), DataType::Int(IntType::new())),
+ ]
+ }
+
+ fn path(parts: &[&str]) -> Vec<String> {
+ parts.iter().map(|part| (*part).into()).collect()
+ }
+
+ #[test]
+ fn structured_projection_preserves_ids_metadata_and_key_order() {
+ let read = project_read_type(
+ &fields(),
+ &[
+ path(&["profile", "attrs", ""]),
+ path(&["profile", "score"]),
+ path(&["profile", "attrs", "missing"]),
+ path(&["profile", "attrs", ""]),
+ path(&["id"]),
+ ],
+ )
+ .unwrap();
+ assert_eq!(read[0].id(), 10);
+ assert_eq!(read[0].description(), Some("profile description"));
+ let DataType::Row(row) = read[0].data_type() else {
+ panic!()
+ };
+ assert_eq!(row.fields()[0].id(), 12);
+ assert_eq!(
+ crate::spec::map_selected_keys(&row.fields()[0]).unwrap(),
+ Some(vec!["".into(), "missing".into()])
+ );
+ assert_eq!(row.fields()[1].id(), 11);
+ assert_eq!(row.fields()[1].default_value(), Some("7"));
+ assert_eq!(read[1].id(), 20);
+ }
+
+ #[test]
+ fn whole_fields_win_and_unencodable_keys_read_complete_map() {
+ let fields = fields();
+ assert_eq!(
+ project_read_type(&fields, &[path(&["profile", "score"]),
path(&["profile"])]).unwrap(),
+ fields[..1]
+ );
+ let read = project_read_type(&fields, &[path(&["profile", "attrs",
"a;b"])]).unwrap();
+ let DataType::Row(row) = read[0].data_type() else {
+ panic!()
+ };
+ assert!(matches!(row.fields()[0].data_type(), DataType::Map(_)));
+ assert!(project_read_type(&fields, &[Vec::new()]).is_err());
+ assert!(project_read_type(&fields, &[path(&["profile",
"unknown"])]).is_err());
+ assert!(project_read_type(&fields, &[]).unwrap().is_empty());
+ }
+}
diff --git a/crates/paimon/src/table/data_evolution_nested.rs
b/crates/paimon/src/table/data_evolution_nested.rs
index 6d65be42..c75203f6 100644
--- a/crates/paimon/src/table/data_evolution_nested.rs
+++ b/crates/paimon/src/table/data_evolution_nested.rs
@@ -154,7 +154,12 @@ pub(super) fn select_nested_row(
fn collect_leaf_ids(fields: &[DataField], output: &mut Vec<i32>) {
for field in fields {
match field.data_type() {
- DataType::Row(row) => collect_leaf_ids(row.fields(), output),
+ DataType::Row(row)
+ if
!crate::spec::is_variant_extraction_row_type(field.data_type())
+ && !crate::spec::is_map_selected_keys_field(field) =>
+ {
+ collect_leaf_ids(row.fields(), output)
+ }
_ => output.push(field.id()),
}
}
@@ -223,7 +228,14 @@ pub(super) fn source_read_field(
.fields()
.iter()
.filter(|child| selected_ids.contains(&child.id()))
- .cloned()
+ .map(|child| {
+ requested_row
+ .fields()
+ .iter()
+ .find(|requested| requested.id() == child.id())
+ .map(|requested| source_child_field(child, requested))
+ .unwrap_or_else(|| child.clone())
+ })
.collect::<Vec<_>>();
if children.is_empty() {
let physical = source_fields
@@ -267,6 +279,38 @@ pub(super) fn source_read_field(
))
}
+fn source_child_field(current: &DataField, requested: &DataField) -> DataField
{
+ if crate::spec::is_variant_extraction_row_type(requested.data_type())
+ || crate::spec::is_map_selected_keys_field(requested)
+ {
+ return requested.clone();
+ }
+ if let (DataType::Row(current_row), DataType::Row(requested_row)) =
+ (current.data_type(), requested.data_type())
+ {
+ let children = current_row
+ .fields()
+ .iter()
+ .map(|child| {
+ requested_row
+ .fields()
+ .iter()
+ .find(|requested| requested.id() == child.id())
+ .map(|requested| source_child_field(child, requested))
+ .unwrap_or_else(|| child.clone())
+ })
+ .collect();
+ return super::data_evolution_fields::field_with_type(
+ current,
+ DataType::Row(RowType::with_nullable(
+ current.data_type().is_nullable(),
+ children,
+ )),
+ );
+ }
+ current.clone()
+}
+
pub(super) fn assemble_nested_row(
plan: &NestedFieldPlan,
target_type: &ArrowDataType,
@@ -435,6 +479,72 @@ mod tests {
field(1, "profile", DataType::Row(RowType::new(children)))
}
+ #[test]
+ fn synthetic_children_bind_to_the_original_leaf_id_and_keep_metadata() {
+ use crate::spec::{variant_extraction_row, FloatType, MapType,
VariantType};
+ let variant = field(30, "payload",
DataType::Variant(VariantType::new()));
+ let attrs = field(
+ 40,
+ "attrs",
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::new()),
+ )),
+ );
+ let extraction = field(
+ 30,
+ "payload",
+ DataType::Row(
+ variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$.ratio".into(),
+ true,
+ "UTC".into(),
+ )],
+ )
+ .unwrap(),
+ ),
+ );
+ let selected = crate::spec::map_selected_keys_field(&attrs,
&["wanted".into()]).unwrap();
+ let current = row(vec![variant.clone(), attrs.clone(), age()]);
+ let requested = row(vec![extraction.clone(), selected.clone()]);
+ let whole = vec![current.clone()];
+ let sources = [PhysicalRowSource {
+ source_index: 0,
+ fields: &whole,
+ }];
+ assert_eq!(
+ select_nested_row(&requested, &sources)
+ .unwrap()
+ .unwrap()
+ .whole_source,
+ Some(0)
+ );
+
+ let newer = vec![row(vec![age()])];
+ let older = vec![row(vec![variant, attrs])];
+ let sources = [
+ PhysicalRowSource {
+ source_index: 0,
+ fields: &newer,
+ },
+ PhysicalRowSource {
+ source_index: 1,
+ fields: &older,
+ },
+ ];
+ let selection = select_nested_row(&requested,
&sources).unwrap().unwrap();
+ assert_eq!(selection.children, vec![Some(1), Some(1)]);
+ assert_eq!(selection.whole_source, None);
+ let old_request = source_read_field(&requested, ¤t, 1, &older,
&selection).unwrap();
+ let DataType::Row(old_row) = old_request.data_type() else {
+ panic!()
+ };
+ assert_eq!(old_row.fields(), &[extraction, selected]);
+ }
+
#[test]
fn latest_leaf_provider_wins_across_partial_files() {
let newer = vec![row(vec![age()])];
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index 3e648ca4..88b0bd46 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -1173,6 +1173,15 @@ impl DataEvolutionReader {
.unwrap_or_else(|| {
arrow_array::new_null_array(target_field.data_type(), rows_to_emit)
});
+ let array = if let Some((source_idx, field_offset)) =
provider {
+ crate::arrow::nested_evolution::evolve_field(
+ &array,
+
&source_plan.sources[*source_idx].read_fields()[*field_offset],
+ &read_type[idx],
+ )?
+ } else {
+ array
+ };
columns.push(array);
}
@@ -2499,7 +2508,13 @@ fn build_source_plan_with_row_id_pushdown(
}
}
}
- let source_idx = if field.data_type().is_blob_file_field()
+ let source_field = table_fields
+ .iter()
+ .find(|source| source.id() == field.id())
+ .filter(|_| crate::spec::is_map_selected_keys_field(field))
+ .filter(|source| source.data_type().is_blob_file_field())
+ .unwrap_or(field);
+ let source_idx = if source_field.data_type().is_blob_file_field()
&& !blob_descriptor_fields.contains(field.name())
{
blob_source_indices.get(&field.id()).copied()
@@ -2516,7 +2531,7 @@ fn build_source_plan_with_row_id_pushdown(
};
if let Some(source_idx) = source_idx {
- let field_offset =
sources[source_idx].add_read_field(field.clone());
+ let field_offset =
sources[source_idx].add_read_field(source_field.clone());
column_plan.push(Some((source_idx, field_offset)));
nested_plan.push(None);
} else if !field.data_type().is_nullable() {
@@ -4140,6 +4155,37 @@ mod tests {
);
}
+ #[test]
+ fn selected_map_blob_uses_the_blob_provider_and_original_map_type() {
+ let map = DataField::new(
+ 2,
+ "payload".into(),
+ DataType::Map(crate::spec::MapType::new(
+ DataType::VarChar(crate::spec::VarCharType::string_type()),
+ DataType::Blob(BlobType::new()),
+ )),
+ );
+ let selected = crate::spec::map_selected_keys_field(&map,
&["key".into()]).unwrap();
+ let files = vec![
+ data_file("others.parquet", 0, 3, 1, Some(vec!["id"])),
+ data_file("payload.blob", 0, 3, 1, Some(vec!["payload"])),
+ ];
+ let group = PreparedMergeGroup::new(&files).unwrap();
+ let plan = build_source_plan_with_row_id_pushdown(
+ &group,
+ &[resolved_info(vec![1]), resolved_info(vec![2])],
+ &[selected],
+ std::slice::from_ref(&map),
+ &HashSet::new(),
+ false,
+ false,
+ )
+ .unwrap();
+ assert_eq!(plan.column_plan, vec![Some((1, 0))]);
+ assert!(matches!(plan.sources[1], FieldSource::BlobBunch { .. }));
+ assert_eq!(plan.sources[1].read_fields(), &[map]);
+ }
+
#[test]
fn test_build_source_plan_groups_multiple_blob_columns() {
let files = vec![
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 8d9c5eb0..82092a58 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -548,6 +548,8 @@ impl DataFileReader {
let read_timing = self.read_timing.clone();
let target_schema = build_target_arrow_schema(&read_type)?;
+ let row_tracking_enabled =
+
crate::spec::CoreOptions::new(&table_options).row_tracking_enabled();
let file_fields = data_fields.clone().unwrap_or_else(||
table_fields.clone());
let data_schema_fields = data_schema_fields_for_file(
&file_fields,
@@ -555,16 +557,18 @@ impl DataFileReader {
data_schema_fields.as_deref(),
)?;
// What the reader is asked for.
- let projected_read_fields: Vec<DataField> =
- if data_fields.is_some() || file_meta.write_cols.is_some() {
- read_data_fields(&data_schema_fields, &read_type,
self.nested_field_enabled)?
- } else {
- read_type
- .iter()
- .filter(|field| field.name() != ROW_ID_FIELD_NAME)
- .cloned()
- .collect()
- };
+ let projected_read_fields: Vec<DataField> = if data_fields.is_some()
+ || file_meta.write_cols.is_some()
+ || read_type.iter().any(contains_selected_map)
+ {
+ read_data_fields(&data_schema_fields, &read_type,
self.nested_field_enabled)?
+ } else {
+ read_type
+ .iter()
+ .filter(|field| field.name() != ROW_ID_FIELD_NAME)
+ .cloned()
+ .collect()
+ };
let path_to_read = split.data_file_path(&file_meta);
let configured_reader = create_format_reader_with_budget(
&path_to_read,
@@ -583,15 +587,17 @@ impl DataFileReader {
// The decoded batch is described by `format_read_fields`, so map
// `read_type` onto *that* list: its entries carry the types the
columns
// actually come back as, which is what reconciling them needs.
- let (index_mapping, source_fields) =
- if data_fields.is_some() || file_meta.write_cols.is_some() {
- (
- create_index_mapping(&read_type, &format_read_fields),
- Some(format_read_fields.clone()),
- )
- } else {
- (None, None)
- };
+ let (index_mapping, source_fields) = if data_fields.is_some()
+ || file_meta.write_cols.is_some()
+ || read_type.iter().any(contains_selected_map)
+ {
+ (
+ create_index_mapping(&read_type, &format_read_fields),
+ Some(format_read_fields.clone()),
+ )
+ } else {
+ (None, None)
+ };
// Remap predicates from table-level to file-level indices.
let file_predicates = if row_id_residual {
@@ -719,6 +725,18 @@ impl DataFileReader {
.map(|col_idx| batch.column(col_idx))
});
+ if row_tracking_enabled
+ && read_type[i].id() ==
crate::spec::SEQUENCE_NUMBER_FIELD_ID
+ && target_field.name() ==
crate::spec::SEQUENCE_NUMBER_FIELD_NAME
+ {
+ columns.push(row_tracking_sequence_column(
+ source_col,
+ file_meta.max_sequence_number,
+ num_rows,
+ )?);
+ continue;
+ }
+
match (source_col, source_field) {
(Some(col), Some(source_field)) => {
columns.push(reconcile_column(col, source_field,
&read_type[i])?);
@@ -850,16 +868,18 @@ impl DataFileReader {
let data_schema_fields =
data_schema_fields_for_file(&file_fields,
file_meta.write_cols.as_deref(), None)?;
// What the reader is asked for.
- let projected_read_fields: Vec<DataField> =
- if data_fields.is_some() || file_meta.write_cols.is_some() {
- read_data_fields(&data_schema_fields, &read_type,
self.nested_field_enabled)?
- } else {
- read_type
- .iter()
- .filter(|field| field.name() != ROW_ID_FIELD_NAME)
- .cloned()
- .collect()
- };
+ let projected_read_fields: Vec<DataField> = if data_fields.is_some()
+ || file_meta.write_cols.is_some()
+ || read_type.iter().any(contains_selected_map)
+ {
+ read_data_fields(&data_schema_fields, &read_type,
self.nested_field_enabled)?
+ } else {
+ read_type
+ .iter()
+ .filter(|field| field.name() != ROW_ID_FIELD_NAME)
+ .cloned()
+ .collect()
+ };
let path_to_read = split.data_file_path(&file_meta);
let configured_reader = create_format_reader_with_budget(
&path_to_read,
@@ -878,15 +898,17 @@ impl DataFileReader {
// The decoded batch is described by `format_read_fields`, so map
// `read_type` onto *that* list: its entries carry the types the
columns
// actually come back as, which is what reconciling them needs.
- let (index_mapping, source_fields) =
- if data_fields.is_some() || file_meta.write_cols.is_some() {
- (
- create_index_mapping(&read_type, &format_read_fields),
- Some(format_read_fields.clone()),
- )
- } else {
- (None, None)
- };
+ let (index_mapping, source_fields) = if data_fields.is_some()
+ || file_meta.write_cols.is_some()
+ || read_type.iter().any(contains_selected_map)
+ {
+ (
+ create_index_mapping(&read_type, &format_read_fields),
+ Some(format_read_fields.clone()),
+ )
+ } else {
+ (None, None)
+ };
// Remap predicates from table-level to file-level indices.
let file_predicates = {
@@ -955,19 +977,16 @@ fn reconcile_column(
source_field: &DataField,
target_field: &DataField,
) -> crate::Result<Arc<dyn Array>> {
- crate::arrow::nested_evolution::evolve_column(
- col,
- source_field.data_type(),
- target_field.data_type(),
- )
- .map_err(|e| Error::UnexpectedError {
- message: format!(
- "Failed to reconcile column '{}' read as {:?} with read type {:?}",
- target_field.name(),
- col.data_type(),
- target_field.data_type()
- ),
- source: Some(Box::new(e)),
+ crate::arrow::nested_evolution::evolve_field(col, source_field,
target_field).map_err(|e| {
+ Error::UnexpectedError {
+ message: format!(
+ "Failed to reconcile column '{}' read as {:?} with read type
{:?}",
+ target_field.name(),
+ col.data_type(),
+ target_field.data_type()
+ ),
+ source: Some(Box::new(e)),
+ }
})
}
@@ -1041,6 +1060,36 @@ fn project_file_batch(
})
}
+fn contains_selected_map(field: &DataField) -> bool {
+ crate::spec::is_map_selected_keys_field(field)
+ || matches!(field.data_type(), DataType::Row(row) if
row.fields().iter().any(contains_selected_map))
+}
+
+fn row_tracking_sequence_column(
+ physical: Option<&Arc<dyn Array>>,
+ max_sequence_number: i64,
+ num_rows: usize,
+) -> crate::Result<Arc<dyn Array>> {
+ let physical = physical
+ .map(|array| {
+ array
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Row-tracking sequence column must be
BIGINT".into(),
+ source: None,
+ })
+ })
+ .transpose()?;
+ Ok(Arc::new(Int64Array::from_iter_values((0..num_rows).map(
+ |index| {
+ physical
+ .filter(|array| !array.is_null(index))
+ .map_or(max_sequence_number, |array| array.value(index))
+ },
+ ))))
+}
+
fn read_data_fields(
all_data_fields: &[DataField],
expected_fields: &[DataField],
@@ -1057,10 +1106,24 @@ fn read_data_fields(
data_field.data_type(),
nested_field_enabled,
)? {
- read_fields.push(data_field_with_type(data_field,
pruned_type));
+ let mut field = data_field_with_type(data_field, pruned_type);
+ if crate::spec::is_map_selected_keys_field(expected) {
+ field =
field.with_description(expected.description().map(str::to_string));
+ }
+ read_fields.push(field);
}
}
}
+ for expected in expected_fields {
+ if expected.id() == crate::spec::SEQUENCE_NUMBER_FIELD_ID
+ && expected.name() == crate::spec::SEQUENCE_NUMBER_FIELD_NAME
+ && !read_fields.iter().any(|field| field.id() == expected.id())
+ {
+ // Row tracking stores this physical system column outside the
+ // logical table schema, including alongside selected MAP keys.
+ read_fields.push(expected.clone());
+ }
+ }
Ok(read_fields)
}
@@ -1077,22 +1140,8 @@ fn prune_data_type(
let DataType::Row(data_row) = data_type else {
return Ok(Some(data_type.clone()));
};
- let mut fields = Vec::new();
- for read_field in read_row.fields() {
- if let Some(data_field) = data_row
- .fields()
- .iter()
- .find(|field| field.id() == read_field.id())
- {
- if let Some(pruned_type) = prune_data_type(
- read_field.data_type(),
- data_field.data_type(),
- nested_field_enabled,
- )? {
- fields.push(data_field_with_type(data_field,
pruned_type));
- }
- }
- }
+ let mut fields =
+ read_data_fields(data_row.fields(), read_row.fields(),
nested_field_enabled)?;
if fields.is_empty() {
if !nested_field_enabled {
return Ok(None);
@@ -1515,6 +1564,52 @@ mod row_tests {
}
}
+ #[test]
+ fn row_tracking_sequence_fills_only_missing_values_from_file_metadata() {
+ let physical: Arc<dyn Array> = Arc::new(Int64Array::from(vec![Some(3),
None, Some(5)]));
+ let filled = row_tracking_sequence_column(Some(&physical), 9,
3).unwrap();
+ assert_eq!(
+ filled.as_any().downcast_ref::<Int64Array>().unwrap(),
+ &Int64Array::from(vec![3, 9, 5]),
+ );
+ let absent = row_tracking_sequence_column(None, 9, 3).unwrap();
+ assert_eq!(
+ absent.as_any().downcast_ref::<Int64Array>().unwrap(),
+ &Int64Array::from(vec![9, 9, 9]),
+ );
+ }
+
+ #[test]
+ fn selected_map_read_preserves_physical_sequence_number() {
+ let map = field(
+ 1,
+ "attrs",
+ DataType::Map(crate::spec::MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::new()),
+ )),
+ );
+ let selected = crate::spec::map_selected_keys_field(&map,
&["key".into()]).unwrap();
+ let sequence = field(
+ crate::spec::SEQUENCE_NUMBER_FIELD_ID,
+ crate::spec::SEQUENCE_NUMBER_FIELD_NAME,
+ DataType::BigInt(BigIntType::with_nullable(false)),
+ );
+ let read = read_data_fields(
+ std::slice::from_ref(&map),
+ &[selected, sequence.clone()],
+ false,
+ )
+ .unwrap();
+ assert_eq!(read.len(), 2);
+ assert_eq!(read[0].data_type(), map.data_type());
+ assert_eq!(
+ read[0].description(),
+ Some("__PAIMON_MAP_SELECTED_KEYS:key")
+ );
+ assert_eq!(read[1], sequence);
+ }
+
#[test]
fn read_data_fields_preserves_variant_extraction_row_type() {
let configured = DataType::Row(RowType::new(vec![field(
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index e36ed774..0e67eae3 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -38,9 +38,9 @@ use crate::file_index::evaluator::evaluate_file_index;
use crate::file_index::file_index_result::FileIndexResult;
use crate::io::FileIO;
use crate::spec::{
- is_variant_extraction_row, is_variant_extraction_row_type, BigIntType,
CoreOptions, DataField,
- DataFileMeta, DataType as PaimonDataType, MergeEngine,
PartialUpdateConfig, Predicate,
- TinyIntType, SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME,
VALUE_KIND_FIELD_ID,
+ is_variant_extraction_row, BigIntType, CoreOptions, DataField,
DataFileMeta,
+ DataType as PaimonDataType, MergeEngine, PartialUpdateConfig, Predicate,
TinyIntType,
+ SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME, VALUE_KIND_FIELD_ID,
VALUE_KIND_FIELD_NAME,
};
use crate::table::schema_manager::SchemaManager;
@@ -126,6 +126,11 @@ pub(super) fn retain_primary_key_conjuncts(
.collect()
}
+fn contains_variant_extraction(field: &DataField) -> bool {
+ crate::spec::is_variant_extraction_row_type(field.data_type())
+ || matches!(field.data_type(), PaimonDataType::Row(row) if
row.fields().iter().any(contains_variant_extraction))
+}
+
fn widen_partial_update_sequence_group_fields(
merge_engine: MergeEngine,
table_options: &HashMap<String, String>,
@@ -397,27 +402,33 @@ impl KeyValueFileReader {
// File readers run before PK deduplication. Keep Variant values in
their
// logical storage type until the visible row has been selected: a
strict
// extraction must not fail on an older, overwritten version.
+ let aggregate_fields = if self.config.merge_engine ==
MergeEngine::PartialUpdate {
+
PartialUpdateConfig::new(&self.config.table_options).validated_aggregate_functions(
+ &self.config.table_fields,
+ &self.config.table_primary_keys,
+ )?
+ } else {
+ HashMap::new()
+ };
let merge_read_type: Vec<DataField> = self
.config
.read_type
.iter()
.map(|field| {
- if !is_variant_extraction_row_type(field.data_type()) {
- return Ok(field.clone());
- }
- self.config
+ let source = self
+ .config
.table_fields
.iter()
- .find(|source| source.id() == field.id())
- .filter(|source| matches!(source.data_type(),
PaimonDataType::Variant(_)))
- .cloned()
- .ok_or_else(|| Error::DataInvalid {
- message: format!(
- "Variant extraction field '{}' has no matching
VARIANT table field",
- field.name()
- ),
- source: None,
- })
+ .find(|source| source.id() == field.id());
+ if let Some(source) = source {
+ let aggregate = self.config.merge_engine ==
MergeEngine::Aggregation
+ || aggregate_fields.contains_key(source.name());
+ let has_variant = contains_variant_extraction(field);
+ if field.data_type() != source.data_type() && (has_variant
|| aggregate) {
+ return Ok(source.clone());
+ }
+ }
+ Ok(field.clone())
})
.collect::<crate::Result<_>>()?;
@@ -818,7 +829,10 @@ impl KeyValueFileReader {
{
assemble_variant_extraction_array(column.as_ref(), row)
}
- _ => Ok(column.clone()),
+ _ => {
+ let source =
merge_read_type.iter().find(|field| field.id() ==
config.read_type[out_idx].id()).unwrap();
+
crate::arrow::nested_evolution::evolve_field(column, source,
&config.read_type[out_idx])
+ },
}
})
.collect::<crate::Result<Vec<_>>>()?;
diff --git a/crates/paimon/src/table/query_auth.rs
b/crates/paimon/src/table/query_auth.rs
index fb24d3ba..15d03d6d 100644
--- a/crates/paimon/src/table/query_auth.rs
+++ b/crates/paimon/src/table/query_auth.rs
@@ -77,11 +77,10 @@ pub(crate) async fn reject_unauthorized_stats(
}
let older = schemas.schema(file.schema_id).await?;
if let Some(gone) = older.fields().iter().find(|f| {
- !current.fields().iter().any(|c| {
- c.id() == f.id()
- && c.name() == f.name()
- && contains(c.data_type(), f.data_type())
- })
+ !current
+ .fields()
+ .iter()
+ .any(|c| c.id() == f.id() && c.name() == f.name() &&
contains_field(c, f))
}) {
return refuse(gone.name());
}
@@ -90,17 +89,29 @@ pub(crate) async fn reject_unauthorized_stats(
Ok(())
}
+fn contains_field(wide: &crate::spec::DataField, narrow:
&crate::spec::DataField) -> bool {
+ if let (crate::spec::DataType::Map(map), crate::spec::DataType::Row(row)) =
+ (wide.data_type(), narrow.data_type())
+ {
+ return matches!(map.key_type(), crate::spec::DataType::VarChar(_))
+ && crate::spec::map_selected_keys(narrow).is_ok_and(|keys|
keys.is_some())
+ && row
+ .fields()
+ .iter()
+ .all(|child|
map.value_type().equals_ignore_nullable(child.data_type()));
+ }
+ contains(wide.data_type(), narrow.data_type())
+}
+
/// Whether `narrow` reads nothing `wide` lacks: nested children match by id
/// and name, so a `ROW` projection passes and a re-added child does not.
fn contains(wide: &crate::spec::DataType, narrow: &crate::spec::DataType) ->
bool {
use crate::spec::DataType;
match (wide, narrow) {
(DataType::Row(w), DataType::Row(n)) => n.fields().iter().all(|nf| {
- w.fields().iter().any(|wf| {
- wf.id() == nf.id()
- && wf.name() == nf.name()
- && contains(wf.data_type(), nf.data_type())
- })
+ w.fields()
+ .iter()
+ .any(|wf| wf.id() == nf.id() && wf.name() == nf.name() &&
contains_field(wf, nf))
}),
(DataType::Array(w), DataType::Array(n)) => contains(w.element_type(),
n.element_type()),
(DataType::Multiset(w), DataType::Multiset(n)) => {
@@ -110,7 +121,9 @@ fn contains(wide: &crate::spec::DataType, narrow:
&crate::spec::DataType) -> boo
contains(w.key_type(), n.key_type()) && contains(w.value_type(),
n.value_type())
}
// A `variant_get` pushdown reads a `VARIANT` column as a `ROW` of
paths.
- (DataType::Variant(_), DataType::Row(_)) => true,
+ (DataType::Variant(_), DataType::Row(_)) => {
+ crate::spec::is_variant_extraction_row_type(narrow)
+ }
(w, n) => w == n,
}
}
@@ -151,11 +164,9 @@ pub(crate) fn reject_noncanonical_fields(
}
// The whole shape: an older field can keep `(id, name)` and carry an
// extra nested child.
- let canonical = schema_fields.iter().any(|f| {
- f.id() == field.id()
- && f.name() == field.name()
- && contains(f.data_type(), field.data_type())
- });
+ let canonical = schema_fields
+ .iter()
+ .any(|f| f.id() == field.id() && f.name() == field.name() &&
contains_field(f, field));
if !canonical {
return Err(unsupported(&format!(
"'{}' (field id {}) is not a column of the current schema,
which is what the \
@@ -173,6 +184,40 @@ mod tests {
use super::reject_system_columns;
use crate::table::{query_auth_table, rest_query_auth_table};
+ #[test]
+ fn authorized_map_allows_only_valid_selected_value_types() {
+ use crate::spec::*;
+ let source = DataField::new(
+ 1,
+ "attrs".into(),
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::new()),
+ )),
+ );
+ let selected = map_selected_keys_field(&source,
&["key".into()]).unwrap();
+ super::reject_noncanonical_fields(
+ std::slice::from_ref(&selected),
+ std::slice::from_ref(&source),
+ )
+ .unwrap();
+ let forged = selected.clone().with_description(None);
+ assert!(
+ super::reject_noncanonical_fields(&[forged],
std::slice::from_ref(&source)).is_err()
+ );
+ let changed = DataField::new(
+ selected.id(),
+ selected.name().into(),
+ DataType::Row(RowType::new(vec![DataField::new(
+ 0,
+ "key".into(),
+ DataType::BigInt(BigIntType::new()),
+ )])),
+ )
+ .with_description(selected.description().map(str::to_string));
+ assert!(super::reject_noncanonical_fields(&[changed],
&[source]).is_err());
+ }
+
#[tokio::test]
async fn test_a_grant_is_pinned_to_the_handle_that_obtained_it() {
let a = crate::table::rest_query_auth_table().await;
@@ -470,7 +515,12 @@ mod tests {
fn test_a_variant_extraction_is_the_one_shape_change_allowed() {
use crate::spec::{DataField, DataType, IntType, RowType, VariantType};
let int = || DataType::Int(IntType::new());
- let row = || DataType::Row(RowType::new(vec![DataField::new(0,
"p".into(), int())]));
+ let row = || {
+ DataType::Row(RowType::new(vec![DataField::new(0, "p".into(),
int())
+ .with_description(Some(
+ crate::spec::build_variant_metadata("$.p", true,
"UTC").unwrap(),
+ ))]))
+ };
let schema = vec![
DataField::new(1, "v".into(),
DataType::Variant(VariantType::new())),
DataField::new(2, "n".into(), int()),
diff --git a/crates/paimon/src/table/read_builder.rs
b/crates/paimon/src/table/read_builder.rs
index 5b8295e0..b3fd6730 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -572,6 +572,8 @@ impl<'a> PaimonReadBuilder<'a> {
Some(fields) => fields,
};
+ crate::spec::validate_selected_map_fields(self.table.schema.fields(),
&read_type)?;
+
// Pass the FULL data predicate through (including `And`/`Or`/`Not`).
// Pushdown/stats skip compound nodes; the residual pass enforces the
full
// predicate exactly. Pruning here would drop compound predicates.
diff --git a/crates/paimon/src/variant.rs b/crates/paimon/src/variant.rs
index 829e9e95..d9616315 100644
--- a/crates/paimon/src/variant.rs
+++ b/crates/paimon/src/variant.rs
@@ -169,17 +169,21 @@ impl<'a> VariantRef<'a> {
}
pub fn get_path(&self, path: &str) -> Result<Option<VariantRef<'a>>> {
+ self.get_path_segments(&parse_path(path)?)
+ }
+
+ fn get_path_segments(&self, segments: &[PathSegment]) ->
Result<Option<VariantRef<'a>>> {
let mut current = *self;
- for segment in parse_path(path)? {
+ for segment in segments {
match (segment, current.kind()?) {
(PathSegment::Key(key), VariantKind::Object) => {
- let Some(next) = current.get_field_by_key(&key)? else {
+ let Some(next) = current.get_field_by_key(key)? else {
return Ok(None);
};
current = next;
}
(PathSegment::Index(index), VariantKind::Array) => {
- let Some(next) = current.get_element_at_index(index)? else
{
+ let Some(next) = current.get_element_at_index(*index)?
else {
return Ok(None);
};
current = next;
@@ -1230,12 +1234,12 @@ fn write_json(value: &[u8], metadata: &[u8], pos:
usize, out: &mut String) -> Re
}
#[derive(Clone, Debug, PartialEq, Eq)]
-enum PathSegment {
+pub(crate) enum PathSegment {
Key(String),
Index(usize),
}
-fn parse_path(path: &str) -> Result<Vec<PathSegment>> {
+pub(crate) fn parse_path(path: &str) -> Result<Vec<PathSegment>> {
let bytes = path.as_bytes();
if !bytes.starts_with(b"$") {
return data_invalid(format!("Invalid Variant path: {path}"));
@@ -2682,6 +2686,81 @@ pub(crate) fn rebuild_shredded(
builder.result()
}
+/// Resolve a path directly against a clipped shredded schema. Unrequested
+/// physical siblings and a pruned parent `value` are not needed to rebuild
+/// the selected subtree. Whole-value rebuilding remains strict.
+pub(crate) fn extract_shredded_path(
+ row: &ShreddedRow,
+ schema: &VariantSchema,
+ path: &str,
+) -> Result<Option<GenericVariant>> {
+ let metadata = binary_field(row, schema.top_level_metadata_idx)?;
+ if !shredded_row_has_value(row, schema) && schema.variant_idx.is_some() {
+ return data_invalid("Malformed shredded Variant: missing value");
+ }
+ extract_shredded_segments(row, schema, metadata, &parse_path(path)?)
+}
+
+fn extract_shredded_segments(
+ row: &ShreddedRow,
+ schema: &VariantSchema,
+ metadata: &[u8],
+ segments: &[PathSegment],
+) -> Result<Option<GenericVariant>> {
+ let Some((segment, remaining)) = segments.split_first() else {
+ if !shredded_row_has_value(row, schema) {
+ return Ok(None);
+ }
+ let mut builder = VariantBuilder::new();
+ rebuild_shredded_into(row, metadata, schema, &mut builder)?;
+ return builder.result().map(Some);
+ };
+ if let Some(typed) = schema.typed_idx.and_then(|index| row.field(index)) {
+ match (segment, typed) {
+ (PathSegment::Key(key), ShreddedValue::Row(object))
+ if schema.object_schema.is_some() =>
+ {
+ if let Some(index) = schema.object_field_index(key) {
+ let Some(ShreddedValue::Row(child)) = object.field(index)
else {
+ return data_invalid("Malformed shredded Variant object
field");
+ };
+ return extract_shredded_segments(
+ child,
+ &schema.object_schema.as_ref().unwrap()[index].schema,
+ metadata,
+ remaining,
+ );
+ }
+ }
+ (PathSegment::Index(index), ShreddedValue::List(elements))
+ if schema.array_schema.is_some() =>
+ {
+ return match elements.get(*index) {
+ Some(element) => extract_shredded_segments(
+ element,
+ schema.array_schema.as_ref().unwrap(),
+ metadata,
+ remaining,
+ ),
+ None => Ok(None),
+ };
+ }
+ _ => {}
+ }
+ }
+ match optional_binary_field(row, schema.variant_idx)? {
+ Some(value) => {
+ let variant = GenericVariant::from_parts(value.to_vec(),
metadata.to_vec())?;
+ variant
+ .as_ref()?
+ .get_path_segments(segments)?
+ .map(|selected| selected.to_owned_variant())
+ .transpose()
+ }
+ None => Ok(None),
+ }
+}
+
pub(crate) fn cast_variant_to_shredded_value(
variant: VariantRef<'_>,
data_type: &DataType,
@@ -3307,6 +3386,37 @@ mod tests {
build_variant_schema(&physical).unwrap()
}
+ #[test]
+ fn clipped_shredded_paths_do_not_rebuild_unrequested_parent_values() {
+ let full = simple_object_shredding_schema();
+ let mut clipped = full.clone();
+ clipped.variant_idx = None;
+ for (json, expected) in [
+ (r#"{"age":7,"city":"x","extra":true}"#, Some("7")),
+ ("42", None),
+ ("null", None),
+ ("[]", None),
+ ("{}", None),
+ ] {
+ let variant = GenericVariant::parse_json(json).unwrap();
+ let shredded = cast_shredded(&variant, &full).unwrap();
+ let actual = extract_shredded_path(&shredded, &clipped,
"$.age").unwrap();
+ assert_eq!(
+ actual.map(|value| value.to_json().unwrap()).as_deref(),
+ expected,
+ "{json}"
+ );
+ }
+ let malformed = ShreddedRow::new(full.num_fields);
+ assert!(extract_shredded_path(&malformed, &full, "$.age").is_err());
+ let scalar = GenericVariant::parse_json("42").unwrap();
+ let row = cast_shredded(&scalar, &full).unwrap();
+ assert!(
+ rebuild_shredded(&row, &clipped).is_err(),
+ "whole values still require the pruned fallback"
+ );
+ }
+
#[test]
fn variant_shredding_schema_matches_java_shape() {
let configured = DataType::Row(RowType::new(vec![