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 381a1ad9 fix(read): null-fill nested fields a data file predates (#805)
381a1ad9 is described below
commit 381a1ad97a8b0a840a453f1f60984588f5327b08
Author: Junrui Lee <[email protected]>
AuthorDate: Fri Sep 11 13:34:56 2026 +0800
fix(read): null-fill nested fields a data file predates (#805)
---
crates/paimon/src/arrow/mod.rs | 1 +
crates/paimon/src/arrow/nested_evolution.rs | 901 +++++++++++++++++++++
crates/paimon/src/spec/types.rs | 15 +
crates/paimon/src/table/data_file_reader.rs | 254 +++---
.../paimon/tests/nested_schema_evolution_test.rs | 237 ++++++
5 files changed, 1315 insertions(+), 93 deletions(-)
diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index 2fe1a6e2..0995bd45 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -17,6 +17,7 @@
pub(crate) mod filtering;
pub(crate) mod format;
+pub(crate) mod nested_evolution;
mod parquet_read_budget;
pub(crate) mod residual;
mod row_filter;
diff --git a/crates/paimon/src/arrow/nested_evolution.rs
b/crates/paimon/src/arrow/nested_evolution.rs
new file mode 100644
index 00000000..dde19e22
--- /dev/null
+++ b/crates/paimon/src/arrow/nested_evolution.rs
@@ -0,0 +1,901 @@
+// 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-side reconciliation of one decoded column against the read (table)
+//! schema, for the cases Arrow's own `cast` cannot express:
+//!
+//! - a ROW child present in the read schema but **absent from the data file**
+//! (an `ALTER TABLE ... ADD COLUMN parent.child` that landed after the file
+//! was written) is filled with NULLs;
+//! - a ROW child the read schema does not ask for is dropped (nested
+//! projection);
+//! - children are paired by **field id**, so a renamed nested column still
+//! resolves, and a leaf whose type was promoted is cast.
+//!
+//! Mirrors Java `SchemaEvolutionUtil.createRowCastExecutor`
+//!
(paimon-core/src/main/java/org/apache/paimon/schema/SchemaEvolutionUtil.java),
+//! which builds the same id-based index mapping per ROW level and yields NULL
+//! for a target child with no source counterpart.
+
+use std::sync::Arc;
+
+use arrow_array::{new_null_array, Array, ArrayRef, ListArray, MapArray,
StructArray};
+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};
+
+/// Reconcile `source` (as described by `source_type`, the type the data file
+/// actually holds) with `target_type` (the type the read schema wants).
+///
+/// Recurses through ROW so an added, dropped, renamed or promoted nested field
+/// is handled at any depth. Non-nested mismatches fall back to Arrow `cast`,
+/// mirroring the top-level promotion path.
+pub(crate) fn evolve_column(
+ source: &ArrayRef,
+ source_type: &DataType,
+ target_type: &DataType,
+) -> crate::Result<ArrayRef> {
+ let target_arrow = paimon_type_to_arrow(target_type)?;
+ // Arrow equality alone is NOT enough to skip the walk:
`paimon_type_to_arrow`
+ // drops field ids, so a nested child that was dropped and re-added under
the
+ // same name and type produces an identical Arrow struct while carrying a
+ // different id. Serving the old column there would return the dropped
+ // field's values instead of NULL. Gate the fast path on the Paimon types
+ // instead, as Java `SchemaEvolutionUtil.createCastExecutor` does with
+ // `equalsIgnoreNullable`; the Arrow check stays so a decoded array that
does
+ // not actually match the target still goes through the cast below.
+ if source_type.equals_ignore_nullable(target_type) && source.data_type()
== &target_arrow {
+ return Ok(source.clone());
+ }
+
+ match (target_type, source_type) {
+ // A variant-extraction ROW is synthetic: its fields are numbered by
+ // position, not by schema field id, so pairing them by id would mix
+ // columns up. `prune_data_type` keeps such a row verbatim, so there is
+ // nothing to evolve — leave it to the cast below, as before.
+ (DataType::Row(_), DataType::Row(_))
+ if is_variant_extraction_row_type(target_type)
+ || is_variant_extraction_row_type(source_type) => {}
+ (DataType::Row(target_row), DataType::Row(source_row)) => {
+ return evolve_struct(source, source_row, target_row)
+ }
+ (DataType::Array(target_array), DataType::Array(source_array)) => {
+ return evolve_list(
+ source,
+ source_array.element_type(),
+ target_array.element_type(),
+ )
+ }
+ (DataType::Map(target_map), DataType::Map(source_map)) => {
+ return evolve_map(source, source_map, target_map)
+ }
+ (DataType::Multiset(target_multiset),
DataType::Multiset(source_multiset)) => {
+ return evolve_multiset(
+ source,
+ source_multiset.element_type(),
+ target_multiset.element_type(),
+ )
+ }
+ _ => {}
+ }
+
+ cast(source, &target_arrow).map_err(|e| crate::Error::UnexpectedError {
+ message: format!(
+ "failed to cast nested value from {:?} to {:?} during schema
evolution",
+ source.data_type(),
+ target_arrow
+ ),
+ source: Some(Box::new(e)),
+ })
+}
+
+/// Rebuild a struct array to `target_row`: pair children by field id, recurse,
+/// NULL-fill target children the source does not have, and preserve the
+/// source's row-level validity buffer.
+fn evolve_struct(
+ source: &ArrayRef,
+ source_row: &RowType,
+ target_row: &RowType,
+) -> crate::Result<ArrayRef> {
+ let source_struct = source
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "expected a struct array for ROW schema evolution, got {:?}",
+ source.data_type()
+ ),
+ source: None,
+ })?;
+
+ let mut fields: Vec<Arc<ArrowField>> =
Vec::with_capacity(target_row.fields().len());
+ let mut arrays: Vec<ArrayRef> =
Vec::with_capacity(target_row.fields().len());
+ for target_field in target_row.fields() {
+ let target_arrow = paimon_type_to_arrow(target_field.data_type())?;
+ fields.push(Arc::new(ArrowField::new(
+ target_field.name(),
+ target_arrow.clone(),
+ target_field.data_type().is_nullable(),
+ )));
+
+ // Pair by id (Java: `createIndexMapping` over the ROW's fields).
+ let source_field = source_row
+ .fields()
+ .iter()
+ .find(|source_field| source_field.id() == target_field.id());
+
+ match source_field {
+ // Not in the data file's schema: the column was added to the ROW
+ // after this file was written.
+ None => arrays.push(new_null_array(&target_arrow,
source_struct.len())),
+ Some(source_field) => {
+ // In the file's schema, so the decoder must have produced it —
+ // under the source field's own name, which is what the file
+ // labels the child with. A gap here means the file, its schema
+ // or the format reader's projection disagree; NULL-filling it
+ // would pass that off as legitimately absent data.
+ let column = source_struct
+ .column_by_name(source_field.name())
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "nested field '{}' (id {}) is declared by the data
file's schema \
+ but missing from the decoded struct {:?}",
+ source_field.name(),
+ source_field.id(),
+ source_struct.data_type()
+ ),
+ source: None,
+ })?;
+ arrays.push(evolve_column(
+ column,
+ source_field.data_type(),
+ target_field.data_type(),
+ )?)
+ }
+ }
+ }
+
+ let evolved = StructArray::try_new(fields.into(), arrays,
source_struct.nulls().cloned())
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("failed to build schema-evolved struct: {e}"),
+ source: None,
+ })?;
+ Ok(Arc::new(evolved))
+}
+
+/// Rebuild an ARRAY whose element type evolved, reusing the source offsets and
+/// validity so only the element values are reconciled. Mirrors Java
+/// `SchemaEvolutionUtil.createArrayCastExecutor`.
+fn evolve_list(
+ source: &ArrayRef,
+ source_element: &DataType,
+ target_element: &DataType,
+) -> crate::Result<ArrayRef> {
+ // A Paimon ARRAY converts to an Arrow `List`, but a decoded column can
arrive
+ // in another list layout — an external Parquet file's embedded Arrow
schema
+ // may yield `LargeList` or `FixedSizeList`. Normalize the layout with
Arrow's
+ // own cast (which range-checks large offsets) before reconciling the
element,
+ // so those files stay readable.
+ let normalized;
+ let list = match source.as_any().downcast_ref::<ListArray>() {
+ Some(list) => list,
+ None => {
+ let source_layout = ArrowDataType::List(Arc::new(ArrowField::new(
+ "element",
+ paimon_type_to_arrow(source_element)?,
+ source_element.is_nullable(),
+ )));
+ normalized = cast(source, &source_layout).map_err(|e|
crate::Error::DataInvalid {
+ message: format!(
+ "expected a list array for ARRAY schema evolution, got
{:?}, which does not convert to {source_layout:?}: {e}",
+ source.data_type()
+ ),
+ source: None,
+ })?;
+ normalized
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "normalizing {:?} did not yield a list array",
+ source.data_type()
+ ),
+ source: None,
+ })?
+ }
+ };
+
+ let values = evolve_column(list.values(), source_element, target_element)?;
+ let element_field = Arc::new(ArrowField::new(
+ "element",
+ paimon_type_to_arrow(target_element)?,
+ target_element.is_nullable(),
+ ));
+ let evolved = ListArray::try_new(
+ element_field,
+ list.offsets().clone(),
+ values,
+ list.nulls().cloned(),
+ )
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("failed to build schema-evolved list: {e}"),
+ source: None,
+ })?;
+ Ok(Arc::new(evolved))
+}
+
+/// Rebuild a MAP whose key or value type evolved, reusing the source offsets
and
+/// validity. Mirrors Java `SchemaEvolutionUtil.createMapCastExecutor`.
+fn evolve_map(
+ source: &ArrayRef,
+ source_map: &MapType,
+ target_map: &MapType,
+) -> crate::Result<ArrayRef> {
+ let map = as_map_array(source, "MAP")?;
+ let keys = evolve_column(map.keys(), source_map.key_type(),
target_map.key_type())?;
+ let values = evolve_column(
+ map.values(),
+ source_map.value_type(),
+ target_map.value_type(),
+ )?;
+ rebuild_map(
+ map,
+ keys,
+ values,
+ ArrowField::new("key", paimon_type_to_arrow(target_map.key_type())?,
false),
+ ArrowField::new(
+ "value",
+ paimon_type_to_arrow(target_map.value_type())?,
+ target_map.value_type().is_nullable(),
+ ),
+ )
+}
+
+/// Rebuild a MULTISET whose element type evolved. Arrow renders a MULTISET as
a
+/// map of element -> count, so only the key side is reconciled and the counts
+/// carry over untouched.
+///
+/// Java's read-side cast dispatcher has no MULTISET branch and raises "Cannot
+/// cast from type ... to type ..." for such a schema change; going through the
+/// element here keeps the same shapes Java would have accepted and, unlike
+/// falling through to Arrow's `cast`, cannot hand back a dropped field's
values
+/// under a re-added field's id.
+fn evolve_multiset(
+ source: &ArrayRef,
+ source_element: &DataType,
+ target_element: &DataType,
+) -> crate::Result<ArrayRef> {
+ let map = as_map_array(source, "MULTISET")?;
+ let elements = evolve_column(map.keys(), source_element, target_element)?;
+ let counts = map.values().clone();
+ rebuild_map(
+ map,
+ elements,
+ counts,
+ ArrowField::new(
+ "key",
+ paimon_type_to_arrow(target_element)?,
+ target_element.is_nullable(),
+ ),
+ ArrowField::new("value", ArrowDataType::Int32, false),
+ )
+}
+
+fn as_map_array<'a>(source: &'a ArrayRef, kind: &str) -> crate::Result<&'a
MapArray> {
+ source
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "expected a map array for {kind} schema evolution, got {:?}",
+ source.data_type()
+ ),
+ source: None,
+ })
+}
+
+/// Reassemble a map array around reconciled key/value children, keeping the
+/// source offsets and validity and the entries layout `paimon_type_to_arrow`
+/// produces, so the rebuilt array's type matches the read schema exactly.
+fn rebuild_map(
+ map: &MapArray,
+ keys: ArrayRef,
+ values: ArrayRef,
+ key_field: ArrowField,
+ value_field: ArrowField,
+) -> crate::Result<ArrayRef> {
+ let entry_fields = Fields::from(vec![key_field, value_field]);
+ let entries = StructArray::try_new(
+ entry_fields.clone(),
+ vec![keys, values],
+ map.entries().nulls().cloned(),
+ )
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("failed to build schema-evolved map entries: {e}"),
+ source: None,
+ })?;
+ let evolved = MapArray::try_new(
+ Arc::new(ArrowField::new(
+ "entries",
+ ArrowDataType::Struct(entry_fields),
+ false,
+ )),
+ map.offsets().clone(),
+ entries,
+ map.nulls().cloned(),
+ false,
+ )
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("failed to build schema-evolved map: {e}"),
+ source: None,
+ })?;
+ Ok(Arc::new(evolved))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{BigIntType, DataField, IntType, VarCharType};
+ use arrow_array::{Int32Array, Int64Array, StringArray};
+ use arrow_buffer::NullBuffer;
+ use arrow_schema::{DataType as ArrowDataType, Fields};
+
+ fn field(id: i32, name: &str, dt: DataType) -> DataField {
+ DataField::new(id, name.to_string(), dt)
+ }
+
+ fn string_type() -> DataType {
+ DataType::VarChar(VarCharType::new(50).unwrap())
+ }
+
+ fn row(fields: Vec<DataField>) -> DataType {
+ DataType::Row(RowType::new(fields))
+ }
+
+ /// Arrow struct `{codec: Utf8, width: Int32}` — the shape a file written
+ /// under the older schema holds.
+ fn source_struct() -> ArrayRef {
+ let codec: ArrayRef = Arc::new(StringArray::from(vec![Some("h264"),
Some("h265")]));
+ let width: ArrayRef = Arc::new(Int32Array::from(vec![Some(1920),
Some(3840)]));
+ Arc::new(StructArray::from(vec![
+ (
+ Arc::new(ArrowField::new("codec", ArrowDataType::Utf8, true)),
+ codec,
+ ),
+ (
+ Arc::new(ArrowField::new("width", ArrowDataType::Int32, true)),
+ width,
+ ),
+ ]))
+ }
+
+ fn source_row() -> DataType {
+ row(vec![
+ field(1, "codec", string_type()),
+ field(2, "width", DataType::Int(IntType::new())),
+ ])
+ }
+
+ fn as_struct(array: &ArrayRef) -> StructArray {
+ array
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .expect("struct array")
+ .clone()
+ }
+
+ fn strings(array: &ArrayRef) -> StringArray {
+ array
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .expect("string array")
+ .clone()
+ }
+
+ #[test]
+ fn fills_null_for_child_added_after_the_file_was_written() {
+ // Read type gained `color_transfer` (id 3); the file's struct has no
+ // such child.
+ let target = row(vec![
+ field(1, "codec", string_type()),
+ field(2, "width", DataType::Int(IntType::new())),
+ field(3, "color_transfer", string_type()),
+ ]);
+
+ let out = evolve_column(&source_struct(), &source_row(),
&target).unwrap();
+
+ // The rebuilt struct must match the type the read schema declares, or
+ // RecordBatch assembly would reject it.
+ assert_eq!(out.data_type(), &paimon_type_to_arrow(&target).unwrap());
+ let evolved = as_struct(&out);
+ assert_eq!(
+ evolved.column_names(),
+ vec!["codec", "width", "color_transfer"]
+ );
+ let added = evolved.column_by_name("color_transfer").unwrap();
+ assert_eq!(added.len(), 2);
+ assert_eq!(added.null_count(), 2);
+ // Existing children keep their values.
+ assert_eq!(
+ strings(evolved.column_by_name("codec").unwrap()).value(0),
+ "h264"
+ );
+ }
+
+ #[test]
+ fn pairs_children_by_id_not_by_name() {
+ // The nested column was renamed `codec` -> `codec_name`; the file
still
+ // holds it under the old name, and the ids match.
+ let target = row(vec![
+ field(1, "codec_name", string_type()),
+ field(2, "width", DataType::Int(IntType::new())),
+ ]);
+
+ let out = evolve_column(&source_struct(), &source_row(),
&target).unwrap();
+
+ let evolved = as_struct(&out);
+ assert_eq!(evolved.column_names(), vec!["codec_name", "width"]);
+ let renamed = strings(evolved.column_by_name("codec_name").unwrap());
+ assert_eq!(renamed.value(0), "h264");
+ assert_eq!(renamed.value(1), "h265");
+ }
+
+ #[test]
+ fn drops_children_the_read_type_does_not_ask_for() {
+ // Nested projection: only `width` is requested.
+ let target = row(vec![field(2, "width",
DataType::Int(IntType::new()))]);
+
+ let out = evolve_column(&source_struct(), &source_row(),
&target).unwrap();
+
+ let evolved = as_struct(&out);
+ assert_eq!(evolved.column_names(), vec!["width"]);
+ assert_eq!(evolved.len(), 2);
+ let widths = evolved
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .clone();
+ assert_eq!(widths.value(0), 1920);
+ }
+
+ #[test]
+ fn recurses_into_a_nested_row() {
+ // source: Struct{ id: Int32, inner: Struct{ a: Utf8 } }
+ let inner: ArrayRef = Arc::new(StructArray::from(vec![(
+ Arc::new(ArrowField::new("a", ArrowDataType::Utf8, true)),
+ Arc::new(StringArray::from(vec![Some("x"), Some("y")])) as
ArrayRef,
+ )]));
+ let id: ArrayRef = Arc::new(Int32Array::from(vec![Some(1), Some(2)]));
+ let source: ArrayRef = Arc::new(StructArray::from(vec![
+ (
+ Arc::new(ArrowField::new("id", ArrowDataType::Int32, true)),
+ id,
+ ),
+ (
+ Arc::new(ArrowField::new(
+ "inner",
+ ArrowDataType::Struct(Fields::from(vec![ArrowField::new(
+ "a",
+ ArrowDataType::Utf8,
+ true,
+ )])),
+ true,
+ )),
+ inner,
+ ),
+ ]));
+ let source_type = row(vec![
+ field(1, "id", DataType::Int(IntType::new())),
+ field(2, "inner", row(vec![field(3, "a", string_type())])),
+ ]);
+ // The inner ROW gained `b` two levels down.
+ let target = row(vec![field(
+ 2,
+ "inner",
+ row(vec![
+ field(3, "a", string_type()),
+ field(4, "b", string_type()),
+ ]),
+ )]);
+
+ let out = evolve_column(&source, &source_type, &target).unwrap();
+
+ let evolved = as_struct(&out);
+ assert_eq!(evolved.column_names(), vec!["inner"]);
+ let inner_out = as_struct(evolved.column_by_name("inner").unwrap());
+ assert_eq!(inner_out.column_names(), vec!["a", "b"]);
+ assert_eq!(
+ strings(inner_out.column_by_name("a").unwrap()).value(1),
+ "y"
+ );
+ assert_eq!(inner_out.column_by_name("b").unwrap().null_count(), 2);
+ }
+
+ #[test]
+ fn preserves_null_rows() {
+ let codec: ArrayRef = Arc::new(StringArray::from(vec![Some("h264"),
None]));
+ let fields = Fields::from(vec![ArrowField::new("codec",
ArrowDataType::Utf8, true)]);
+ let nulls = NullBuffer::from(vec![true, false]);
+ let source: ArrayRef =
+ Arc::new(StructArray::try_new(fields, vec![codec],
Some(nulls)).unwrap());
+ let source_type = row(vec![field(1, "codec", string_type())]);
+ let target = row(vec![
+ field(1, "codec", string_type()),
+ field(3, "color_transfer", string_type()),
+ ]);
+
+ let out = evolve_column(&source, &source_type, &target).unwrap();
+
+ assert_eq!(out.len(), 2);
+ assert_eq!(out.null_count(), 1);
+ assert!(out.is_valid(0));
+ assert!(out.is_null(1));
+ }
+
+ #[test]
+ fn casts_a_promoted_nested_leaf() {
+ // `width` was promoted INT -> BIGINT.
+ let target = row(vec![
+ field(1, "codec", string_type()),
+ field(2, "width", DataType::BigInt(BigIntType::new())),
+ ]);
+
+ let out = evolve_column(&source_struct(), &source_row(),
&target).unwrap();
+
+ let evolved = as_struct(&out);
+ let widths = evolved
+ .column_by_name("width")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .clone();
+ assert_eq!(widths.value(0), 1920);
+ assert_eq!(widths.value(1), 3840);
+ }
+
+ #[test]
+ fn returns_source_untouched_when_types_already_match() {
+ let out = evolve_column(&source_struct(), &source_row(),
&source_row()).unwrap();
+ assert_eq!(out.data_type(), source_struct().data_type());
+ assert_eq!(as_struct(&out).column_names(), vec!["codec", "width"]);
+ }
+
+ #[test]
+ fn fills_null_for_a_child_added_inside_an_array_element() {
+ // source: ARRAY<ROW<codec>>, two elements in one list row.
+ let codec: ArrayRef = Arc::new(StringArray::from(vec![Some("h264"),
Some("h265")]));
+ let element_fields =
+ Fields::from(vec![ArrowField::new("codec", ArrowDataType::Utf8,
true)]);
+ let elements: ArrayRef =
+ Arc::new(StructArray::try_new(element_fields.clone(), vec![codec],
None).unwrap());
+ let offsets = arrow_buffer::OffsetBuffer::new(vec![0, 2].into());
+ let source: ArrayRef = Arc::new(
+ arrow_array::ListArray::try_new(
+ Arc::new(ArrowField::new(
+ "element",
+ ArrowDataType::Struct(element_fields),
+ true,
+ )),
+ offsets,
+ elements,
+ None,
+ )
+ .unwrap(),
+ );
+
+ let source_type =
DataType::Array(crate::spec::ArrayType::new(row(vec![field(
+ 1,
+ "codec",
+ string_type(),
+ )])));
+ let target_type = DataType::Array(crate::spec::ArrayType::new(row(vec![
+ field(1, "codec", string_type()),
+ field(3, "color_transfer", string_type()),
+ ])));
+
+ let out = evolve_column(&source, &source_type, &target_type).unwrap();
+
+ assert_eq!(
+ out.data_type(),
+ &paimon_type_to_arrow(&target_type).unwrap()
+ );
+ let list = out
+ .as_any()
+ .downcast_ref::<arrow_array::ListArray>()
+ .unwrap()
+ .clone();
+ assert_eq!(list.len(), 1);
+ let evolved = as_struct(&list.values().clone());
+ assert_eq!(evolved.column_names(), vec!["codec", "color_transfer"]);
+ assert_eq!(
+ strings(evolved.column_by_name("codec").unwrap()).value(1),
+ "h265"
+ );
+ assert_eq!(
+ evolved
+ .column_by_name("color_transfer")
+ .unwrap()
+ .null_count(),
+ 2
+ );
+ }
+
+ #[test]
+ fn fills_null_for_a_child_added_inside_a_map_value() {
+ // source: MAP<STRING, ROW<codec>> with one entry.
+ let keys: ArrayRef = Arc::new(StringArray::from(vec![Some("k")]));
+ let codec: ArrayRef = Arc::new(StringArray::from(vec![Some("h264")]));
+ let value_fields = Fields::from(vec![ArrowField::new("codec",
ArrowDataType::Utf8, true)]);
+ let values: ArrayRef =
+ Arc::new(StructArray::try_new(value_fields.clone(), vec![codec],
None).unwrap());
+ let entry_fields = Fields::from(vec![
+ ArrowField::new("key", ArrowDataType::Utf8, false),
+ ArrowField::new("value",
ArrowDataType::Struct(value_fields.clone()), true),
+ ]);
+ let entries = StructArray::try_new(entry_fields.clone(), vec![keys,
values], None).unwrap();
+ let offsets = arrow_buffer::OffsetBuffer::new(vec![0, 1].into());
+ let source: ArrayRef = Arc::new(
+ arrow_array::MapArray::try_new(
+ Arc::new(ArrowField::new(
+ "entries",
+ ArrowDataType::Struct(entry_fields),
+ false,
+ )),
+ offsets,
+ entries,
+ None,
+ false,
+ )
+ .unwrap(),
+ );
+
+ let source_type = DataType::Map(crate::spec::MapType::new(
+ string_type(),
+ row(vec![field(1, "codec", string_type())]),
+ ));
+ let target_type = DataType::Map(crate::spec::MapType::new(
+ string_type(),
+ row(vec![
+ field(1, "codec", string_type()),
+ field(3, "color_transfer", string_type()),
+ ]),
+ ));
+
+ let out = evolve_column(&source, &source_type, &target_type).unwrap();
+
+ assert_eq!(
+ out.data_type(),
+ &paimon_type_to_arrow(&target_type).unwrap()
+ );
+ let map = out
+ .as_any()
+ .downcast_ref::<arrow_array::MapArray>()
+ .unwrap()
+ .clone();
+ assert_eq!(map.len(), 1);
+ let evolved = as_struct(&map.values().clone());
+ assert_eq!(evolved.column_names(), vec!["codec", "color_transfer"]);
+ assert_eq!(
+ evolved
+ .column_by_name("color_transfer")
+ .unwrap()
+ .null_count(),
+ 1
+ );
+ }
+
+ #[test]
+ fn
readded_child_with_the_same_name_does_not_serve_the_dropped_field_values() {
+ // `media.x` (id 5) was dropped and re-added as a new field (id 9) with
+ // the same name and type. The Arrow types are identical, but the old
+ // file holds id 5, not id 9 — the read must not surface those values.
+ let codec: ArrayRef = Arc::new(StringArray::from(vec![Some("h264"),
Some("h265")]));
+ let old_x: ArrayRef = Arc::new(StringArray::from(vec![Some("old1"),
Some("old2")]));
+ let source: ArrayRef = Arc::new(StructArray::from(vec![
+ (
+ Arc::new(ArrowField::new("codec", ArrowDataType::Utf8, true)),
+ codec,
+ ),
+ (
+ Arc::new(ArrowField::new("x", ArrowDataType::Utf8, true)),
+ old_x,
+ ),
+ ]));
+ let source_type = row(vec![
+ field(1, "codec", string_type()),
+ field(5, "x", string_type()),
+ ]);
+ let target = row(vec![
+ field(1, "codec", string_type()),
+ field(9, "x", string_type()),
+ ]);
+
+ let out = evolve_column(&source, &source_type, &target).unwrap();
+
+ let evolved = as_struct(&out);
+ let x = evolved.column_by_name("x").unwrap();
+ assert_eq!(
+ x.null_count(),
+ 2,
+ "re-added field must read as NULL, got {:?}",
+ strings(x)
+ );
+ }
+
+ #[test]
+ fn a_child_the_source_schema_declares_but_the_decoder_dropped_fails_loud()
{
+ // The file's schema says `media` has `codec` and `width`, but the
decoded
+ // struct only carries `codec`. That is not "the field was added
later",
+ // so it must not read back as NULL.
+ let codec: ArrayRef = Arc::new(StringArray::from(vec![Some("h264"),
Some("h265")]));
+ let source: ArrayRef = Arc::new(StructArray::from(vec![(
+ Arc::new(ArrowField::new("codec", ArrowDataType::Utf8, true)),
+ codec,
+ )]));
+
+ let err = evolve_column(&source, &source_row(),
&source_row()).unwrap_err();
+
+ assert!(
+ matches!(&err, crate::Error::DataInvalid { message, .. } if
message.contains("width")),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[test]
+ fn
readded_child_inside_a_multiset_does_not_serve_the_dropped_field_values() {
+ // MULTISET<ROW<x>> where `x` was dropped and re-added with a new id.
+ // Arrow renders a MULTISET as a Map of element -> count, so the source
+ // and target Arrow types are identical and only the field ids differ.
+ let x: ArrayRef = Arc::new(StringArray::from(vec![Some("old")]));
+ let element_fields = Fields::from(vec![ArrowField::new("x",
ArrowDataType::Utf8, true)]);
+ let elements: ArrayRef =
+ Arc::new(StructArray::try_new(element_fields.clone(), vec![x],
None).unwrap());
+ let counts: ArrayRef = Arc::new(Int32Array::from(vec![Some(1)]));
+ let entry_fields = Fields::from(vec![
+ ArrowField::new("key",
ArrowDataType::Struct(element_fields.clone()), true),
+ ArrowField::new("value", ArrowDataType::Int32, false),
+ ]);
+ let entries =
+ StructArray::try_new(entry_fields.clone(), vec![elements, counts],
None).unwrap();
+ let offsets = arrow_buffer::OffsetBuffer::new(vec![0, 1].into());
+ let source: ArrayRef = Arc::new(
+ arrow_array::MapArray::try_new(
+ Arc::new(ArrowField::new(
+ "entries",
+ ArrowDataType::Struct(entry_fields),
+ false,
+ )),
+ offsets,
+ entries,
+ None,
+ false,
+ )
+ .unwrap(),
+ );
+
+ let source_type =
DataType::Multiset(crate::spec::MultisetType::new(row(vec![field(
+ 5,
+ "x",
+ string_type(),
+ )])));
+ let target_type =
DataType::Multiset(crate::spec::MultisetType::new(row(vec![field(
+ 9,
+ "x",
+ string_type(),
+ )])));
+
+ let out = evolve_column(&source, &source_type, &target_type).unwrap();
+
+ let map = out
+ .as_any()
+ .downcast_ref::<arrow_array::MapArray>()
+ .unwrap()
+ .clone();
+ let element = as_struct(&map.keys().clone());
+ assert_eq!(
+ element.column_by_name("x").unwrap().null_count(),
+ 1,
+ "a re-added element field must read as NULL"
+ );
+ }
+
+ #[test]
+ fn evolves_a_large_list_element() {
+ // A format table's embedded Arrow schema can decode a Paimon ARRAY as
a
+ // LargeList; the logical types still have to reconcile.
+ let codec: ArrayRef = Arc::new(StringArray::from(vec![Some("h264"),
Some("h265")]));
+ let element_fields =
+ Fields::from(vec![ArrowField::new("codec", ArrowDataType::Utf8,
true)]);
+ let elements: ArrayRef =
+ Arc::new(StructArray::try_new(element_fields.clone(), vec![codec],
None).unwrap());
+ let source: ArrayRef = Arc::new(
+ arrow_array::LargeListArray::try_new(
+ Arc::new(ArrowField::new(
+ "element",
+ ArrowDataType::Struct(element_fields),
+ true,
+ )),
+ arrow_buffer::OffsetBuffer::new(vec![0i64, 2].into()),
+ elements,
+ None,
+ )
+ .unwrap(),
+ );
+ let source_type =
DataType::Array(crate::spec::ArrayType::new(row(vec![field(
+ 1,
+ "codec",
+ string_type(),
+ )])));
+ let target_type = DataType::Array(crate::spec::ArrayType::new(row(vec![
+ field(1, "codec", string_type()),
+ field(3, "color_transfer", string_type()),
+ ])));
+
+ let out = evolve_column(&source, &source_type, &target_type).unwrap();
+
+ assert_eq!(
+ out.data_type(),
+ &paimon_type_to_arrow(&target_type).unwrap()
+ );
+ let list = out
+ .as_any()
+ .downcast_ref::<arrow_array::ListArray>()
+ .unwrap()
+ .clone();
+ let evolved = as_struct(&list.values().clone());
+ assert_eq!(
+ strings(evolved.column_by_name("codec").unwrap()).value(1),
+ "h265"
+ );
+ assert_eq!(
+ evolved
+ .column_by_name("color_transfer")
+ .unwrap()
+ .null_count(),
+ 2
+ );
+ }
+
+ #[test]
+ fn reconciles_a_fixed_size_list_layout() {
+ let values: ArrayRef = Arc::new(Int32Array::from(vec![Some(1),
Some(2), Some(3), Some(4)]));
+ let source: ArrayRef = Arc::new(
+ arrow_array::FixedSizeListArray::try_new(
+ Arc::new(ArrowField::new("item", ArrowDataType::Int32, true)),
+ 2,
+ values,
+ None,
+ )
+ .unwrap(),
+ );
+ let source_type =
+
DataType::Array(crate::spec::ArrayType::new(DataType::Int(IntType::new())));
+
+ let out = evolve_column(&source, &source_type, &source_type).unwrap();
+
+ assert_eq!(
+ out.data_type(),
+ &paimon_type_to_arrow(&source_type).unwrap(),
+ "the layout must be normalized to the Paimon ARRAY's Arrow type"
+ );
+ assert_eq!(out.len(), 2);
+ }
+}
diff --git a/crates/paimon/src/spec/types.rs b/crates/paimon/src/spec/types.rs
index 4835ce1d..20f71dd4 100644
--- a/crates/paimon/src/spec/types.rs
+++ b/crates/paimon/src/spec/types.rs
@@ -169,6 +169,21 @@ impl DataType {
/// Returns a copy of this type with the given nullability (top-level
only).
/// Corresponds to Java `DataType.copy(boolean nullable)`.
+ /// Compare two types ignoring their top-level nullability.
+ ///
+ /// Mirrors Java `DataType.equalsIgnoreNullable`, which compares
+ /// `this.copy(true)` with `o.copy(true)`: only the outermost nullable
flag is
+ /// normalized, so nested fields (ids included) still have to match.
+ pub fn equals_ignore_nullable(&self, other: &DataType) -> bool {
+ match (
+ self.copy_with_nullable(true),
+ other.copy_with_nullable(true),
+ ) {
+ (Ok(a), Ok(b)) => a == b,
+ _ => false,
+ }
+ }
+
pub fn copy_with_nullable(&self, nullable: bool) -> Result<Self> {
Ok(match self {
DataType::Boolean(_) =>
DataType::Boolean(BooleanType::with_nullable(nullable)),
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index a6314a5e..e6221cc0 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -31,7 +31,6 @@ use crate::table::ArrowRecordBatchStream;
use crate::table::RowRange;
use crate::{DataSplit, Error};
use arrow_array::{Array, Int64Array, RecordBatch};
-use arrow_cast::cast;
use async_stream::try_stream;
use futures::StreamExt;
@@ -457,26 +456,32 @@ impl DataFileReader {
let file_fields = data_fields.clone().unwrap_or_else(||
table_fields.clone());
let is_row_file = is_row_file(&file_meta);
- // Compute index mapping and determine which columns to read from the
file.
- let (projected_read_fields, index_mapping) = if let Some(ref df) =
data_fields {
- let mapping = create_index_mapping(&read_type, df);
- let fields_to_read = read_data_fields(df, &read_type)?;
- (fields_to_read, mapping)
+ // What the reader is asked for.
+ let projected_read_fields: Vec<DataField> = if let Some(ref df) =
data_fields {
+ read_data_fields(df, &read_type)?
} else {
- (
- read_type
- .iter()
- .filter(|field| field.name() != ROW_ID_FIELD_NAME)
- .cloned()
- .collect(),
- None,
- )
+ read_type
+ .iter()
+ .filter(|field| field.name() != ROW_ID_FIELD_NAME)
+ .cloned()
+ .collect()
};
let format_read_fields = if is_row_file {
file_fields.clone()
} else {
projected_read_fields
};
+ // 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() {
+ (
+ 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 = {
@@ -580,49 +585,40 @@ impl DataFileReader {
continue;
}
- let source_col = if let Some(ref idx_map) = index_mapping {
+ // The field the decoded column comes from: through the
+ // field-ID mapping under schema evolution, else by id
against
+ // the table schema (`read_type` may be a nested
projection of
+ // it; system fields stand for themselves).
+ let source_field: Option<&DataField> = if let Some(ref
idx_map) = index_mapping
+ {
let data_idx = idx_map[i];
if data_idx == NULL_FIELD_INDEX {
None
} else {
- let data_field =
&data_fields.as_ref().unwrap()[data_idx as usize];
- batch_schema
- .index_of(data_field.name())
- .ok()
- .map(|col_idx| batch.column(col_idx))
+ Some(&source_fields.as_ref().unwrap()[data_idx as
usize])
}
- } else if let Some(ref df) = data_fields {
- batch_schema
- .index_of(df[i].name())
- .ok()
- .map(|col_idx| batch.column(col_idx))
+ } else if let Some(ref df) = source_fields {
+ Some(&df[i])
} else {
+ Some(
+ table_fields
+ .iter()
+ .find(|f| f.id() == read_type[i].id())
+ .unwrap_or(&read_type[i]),
+ )
+ };
+ let source_col = source_field.and_then(|f| {
batch_schema
- .index_of(target_field.name())
+ .index_of(f.name())
.ok()
.map(|col_idx| batch.column(col_idx))
- };
+ });
- match source_col {
- Some(col) => {
- if col.data_type() == target_field.data_type() {
- columns.push(col.clone());
- } else {
- let casted = cast(col,
target_field.data_type()).map_err(|e| {
- Error::UnexpectedError {
- message: format!(
- "Failed to cast column '{}' from
{:?} to {:?}: {e}",
- target_field.name(),
- col.data_type(),
- target_field.data_type()
- ),
- source: Some(Box::new(e)),
- }
- })?;
- columns.push(casted);
- }
+ match (source_col, source_field) {
+ (Some(col), Some(source_field)) => {
+ columns.push(reconcile_column(col, source_field,
&read_type[i])?);
}
- None => {
+ _ => {
let null_array =
arrow_array::new_null_array(target_field.data_type(), num_rows);
columns.push(null_array);
}
@@ -726,26 +722,32 @@ impl DataFileReader {
let file_fields = data_fields.clone().unwrap_or_else(||
table_fields.clone());
let is_row_file = is_row_file(&file_meta);
- // Compute index mapping and determine which columns to read from the
file.
- let (projected_read_fields, index_mapping) = if let Some(ref df) =
data_fields {
- let mapping = create_index_mapping(&read_type, df);
- let fields_to_read = read_data_fields(df, &read_type)?;
- (fields_to_read, mapping)
+ // What the reader is asked for.
+ let projected_read_fields: Vec<DataField> = if let Some(ref df) =
data_fields {
+ read_data_fields(df, &read_type)?
} else {
- (
- read_type
- .iter()
- .filter(|field| field.name() != ROW_ID_FIELD_NAME)
- .cloned()
- .collect(),
- None,
- )
+ read_type
+ .iter()
+ .filter(|field| field.name() != ROW_ID_FIELD_NAME)
+ .cloned()
+ .collect()
};
let format_read_fields = if is_row_file {
file_fields.clone()
} else {
projected_read_fields
};
+ // 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() {
+ (
+ 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 = {
@@ -799,8 +801,10 @@ impl DataFileReader {
let result = project_file_batch(
&batch,
&target_schema,
+ &read_type,
+ &table_fields,
index_mapping.as_deref(),
- data_fields.as_deref(),
+ source_fields.as_deref(),
)?;
yield result;
}
@@ -809,6 +813,32 @@ impl DataFileReader {
}
}
+/// Reconcile one decoded column with the read schema's type for it.
+///
+/// Delegates to [`crate::arrow::nested_evolution::evolve_column`], which
returns
+/// the column untouched when the types already agree, walks ROW / ARRAY / MAP
to
+/// NULL-fill nested fields the data file predates, and otherwise casts.
+fn reconcile_column(
+ col: &Arc<dyn Array>,
+ 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)),
+ })
+}
+
/// Project one decoded file `batch` onto `target_schema`, resolving each
target
/// column through the field-ID `index_mapping` (or by name when the file
schema
/// matches the table schema), casting on type mismatch and null-filling absent
@@ -818,56 +848,44 @@ impl DataFileReader {
fn project_file_batch(
batch: &RecordBatch,
target_schema: &Arc<arrow_schema::Schema>,
+ read_type: &[DataField],
+ table_fields: &[DataField],
index_mapping: Option<&[i32]>,
- data_fields: Option<&[DataField]>,
+ source_fields: Option<&[DataField]>,
) -> crate::Result<RecordBatch> {
let num_rows = batch.num_rows();
let batch_schema = batch.schema();
let mut columns: Vec<Arc<dyn Array>> =
Vec::with_capacity(target_schema.fields().len());
for (i, target_field) in target_schema.fields().iter().enumerate() {
- let source_col = if let Some(idx_map) = index_mapping {
+ let source_field: Option<&DataField> = if let Some(idx_map) =
index_mapping {
let data_idx = idx_map[i];
if data_idx == NULL_FIELD_INDEX {
None
} else {
- let data_field = &data_fields.unwrap()[data_idx as usize];
- batch_schema
- .index_of(data_field.name())
- .ok()
- .map(|col_idx| batch.column(col_idx))
+ Some(&source_fields.unwrap()[data_idx as usize])
}
- } else if let Some(df) = data_fields {
- batch_schema
- .index_of(df[i].name())
- .ok()
- .map(|col_idx| batch.column(col_idx))
+ } else if let Some(df) = source_fields {
+ Some(&df[i])
} else {
+ Some(
+ table_fields
+ .iter()
+ .find(|f| f.id() == read_type[i].id())
+ .unwrap_or(&read_type[i]),
+ )
+ };
+ let source_col = source_field.and_then(|f| {
batch_schema
- .index_of(target_field.name())
+ .index_of(f.name())
.ok()
.map(|col_idx| batch.column(col_idx))
- };
+ });
- match source_col {
- Some(col) => {
- if col.data_type() == target_field.data_type() {
- columns.push(col.clone());
- } else {
- let casted = cast(col,
target_field.data_type()).map_err(|e| {
- Error::UnexpectedError {
- message: format!(
- "Failed to cast column '{}' from {:?} to {:?}:
{e}",
- target_field.name(),
- col.data_type(),
- target_field.data_type()
- ),
- source: Some(Box::new(e)),
- }
- })?;
- columns.push(casted);
- }
+ match (source_col, source_field) {
+ (Some(col), Some(source_field)) => {
+ columns.push(reconcile_column(col, source_field,
&read_type[i])?);
}
- None => {
+ _ => {
columns.push(arrow_array::new_null_array(
target_field.data_type(),
num_rows,
@@ -943,6 +961,13 @@ fn prune_data_type(read_type: &DataType, data_type:
&DataType) -> crate::Result<
))))
}
}
+ // ARRAY and MAP are deliberately NOT descended, even though Java's
+ // `pruneDataType` does: the pruned type is also what the Vortex
reader is
+ // asked for, and it projects top-level columns only, then
reinterprets a
+ // decoded nested struct against the requested Arrow type
positionally, so
+ // a container pruned into read order would silently relabel its
children.
+ // Nested containers are reconciled after decoding instead, by
+ // `arrow::nested_evolution::evolve_column`, which pairs by field id.
_ => Ok(Some(data_type.clone())),
}
}
@@ -2862,3 +2887,46 @@ mod vector_parquet_tests {
assert_eq!(floats2.values(), &[3.0, 4.0]);
}
}
+
+#[cfg(test)]
+mod prune_container_tests {
+ use super::*;
+ use crate::spec::{ArrayType, DataType, MapType, RowType, VarCharType};
+
+ fn f(id: i32, name: &str, dt: DataType) -> DataField {
+ DataField::new(id, name.to_string(), dt)
+ }
+
+ fn str_t() -> DataType {
+ DataType::VarChar(VarCharType::new(50).unwrap())
+ }
+
+ fn row(fields: Vec<DataField>) -> DataType {
+ DataType::Row(RowType::new(fields))
+ }
+
+ #[test]
+ fn keeps_an_array_element_type_verbatim() {
+ // Not descended on purpose — see the comment in `prune_data_type`: the
+ // Vortex reader would relabel a reordered container positionally. The
+ // element is reconciled after decoding instead.
+ let data = DataType::Array(ArrayType::new(row(vec![
+ f(2, "name", str_t()),
+ f(3, "lang", str_t()),
+ ])));
+ let read = DataType::Array(ArrayType::new(row(vec![f(3, "lang",
str_t())])));
+
+ assert_eq!(prune_data_type(&read, &data).unwrap().unwrap(), data);
+ }
+
+ #[test]
+ fn keeps_a_map_value_type_verbatim() {
+ let data = DataType::Map(MapType::new(
+ str_t(),
+ row(vec![f(5, "v", str_t()), f(6, "unit", str_t())]),
+ ));
+ let read = DataType::Map(MapType::new(str_t(), row(vec![f(5, "v",
str_t())])));
+
+ assert_eq!(prune_data_type(&read, &data).unwrap().unwrap(), data);
+ }
+}
diff --git a/crates/paimon/tests/nested_schema_evolution_test.rs
b/crates/paimon/tests/nested_schema_evolution_test.rs
new file mode 100644
index 00000000..2b3a4f2e
--- /dev/null
+++ b/crates/paimon/tests/nested_schema_evolution_test.rs
@@ -0,0 +1,237 @@
+// 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.
+
+//! Regression coverage for schema evolution *inside* a ROW column.
+//!
+//! Mirrors a primary-key table whose `ALTER TABLE ... ADD COLUMN
+//! media.color_transfer` landed as a new schema version: data files written
+//! before the change carry a struct with fewer children than the table schema
+//! declares. Reading them must fill the added child with NULL instead of
+//! failing, which is what Java does via
+//! `SchemaEvolutionUtil.createRowCastExecutor`.
+
+// Gated off Windows: the fixture table location is a `file://` URL built from
a
+// temp dir path, which `FileIO` cannot derive on Windows (see #397). The
sibling
+// `pk_vector_baseline_test` / `rest_catalog_test` gate their `file://` tempdir
+// tests the same way.
+#![cfg(not(windows))]
+
+use arrow_array::{Array, ArrayRef, Int32Array, RecordBatch, StringArray,
StructArray};
+use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as
ArrowSchema};
+use bytes::Bytes;
+use futures::TryStreamExt;
+use paimon::catalog::Identifier;
+use paimon::io::{FileIO, FileIOBuilder};
+use paimon::table::{SchemaManager, Table, TableCommit};
+use std::sync::Arc;
+
+/// schema-0: `id INT NOT NULL` (pk) + `media ROW<codec STRING>`.
+fn schema_v0() -> serde_json::Value {
+ serde_json::json!({
+ "version": 3,
+ "id": 0,
+ "fields": [
+ {"id": 0, "name": "id", "type": "INT NOT NULL"},
+ {"id": 1, "name": "media", "type": {
+ "type": "ROW",
+ "fields": [{"id": 2, "name": "codec", "type": "STRING"}]
+ }}
+ ],
+ "highestFieldId": 2,
+ "partitionKeys": [],
+ "primaryKeys": ["id"],
+ "options": {"bucket": "1"},
+ "timeMillis": 1_700_000_000_000i64
+ })
+}
+
+/// schema-1: adds `media.color_transfer STRING` (field id 3) inside the ROW.
+fn schema_v1() -> serde_json::Value {
+ serde_json::json!({
+ "version": 3,
+ "id": 1,
+ "fields": [
+ {"id": 0, "name": "id", "type": "INT NOT NULL"},
+ {"id": 1, "name": "media", "type": {
+ "type": "ROW",
+ "fields": [
+ {"id": 2, "name": "codec", "type": "STRING"},
+ {"id": 3, "name": "color_transfer", "type": "STRING"}
+ ]
+ }}
+ ],
+ "highestFieldId": 3,
+ "partitionKeys": [],
+ "primaryKeys": ["id"],
+ "options": {"bucket": "1"},
+ "timeMillis": 1_700_000_001_000i64
+ })
+}
+
+async fn write_schema(file_io: &FileIO, location: &str, schema:
serde_json::Value) {
+ let id = schema.get("id").and_then(|v| v.as_i64()).unwrap();
+ file_io
+ .new_output(&format!("{location}/schema/schema-{id}"))
+ .unwrap()
+ .write(Bytes::from(serde_json::to_vec(&schema).unwrap()))
+ .await
+ .unwrap();
+}
+
+async fn open_table(file_io: &FileIO, location: &str) -> Table {
+ let schema = SchemaManager::new(file_io.clone(), location.to_string())
+ .latest()
+ .await
+ .expect("failed to list schemas")
+ .expect("table has no schema");
+ Table::new(
+ file_io.clone(),
+ Identifier::new("default", "nested_evo"),
+ location.to_string(),
+ (*schema).clone(),
+ None,
+ )
+}
+
+/// One row whose `media` struct has only `codec` (the schema-0 shape).
+fn batch_v0(id: i32, codec: &str) -> RecordBatch {
+ let media_fields = vec![ArrowField::new("codec", ArrowDataType::Utf8,
true)];
+ let media: ArrayRef = Arc::new(StructArray::from(vec![(
+ Arc::new(media_fields[0].clone()),
+ Arc::new(StringArray::from(vec![Some(codec)])) as ArrayRef,
+ )]));
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("media", ArrowDataType::Struct(media_fields.into()),
true),
+ ]));
+ RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![id])),
media]).unwrap()
+}
+
+/// One row whose `media` struct has both children (the schema-1 shape).
+fn batch_v1(id: i32, codec: &str, color_transfer: &str) -> RecordBatch {
+ let media_fields = vec![
+ ArrowField::new("codec", ArrowDataType::Utf8, true),
+ ArrowField::new("color_transfer", ArrowDataType::Utf8, true),
+ ];
+ let media: ArrayRef = Arc::new(StructArray::from(vec![
+ (
+ Arc::new(media_fields[0].clone()),
+ Arc::new(StringArray::from(vec![Some(codec)])) as ArrayRef,
+ ),
+ (
+ Arc::new(media_fields[1].clone()),
+ Arc::new(StringArray::from(vec![Some(color_transfer)])) as
ArrayRef,
+ ),
+ ]));
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("media", ArrowDataType::Struct(media_fields.into()),
true),
+ ]));
+ RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![id])),
media]).unwrap()
+}
+
+async fn commit(table: &Table, batch: &RecordBatch) {
+ let write_builder = table.new_write_builder();
+ let mut writer = write_builder.new_write().unwrap();
+ writer.write_arrow_batch(batch).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "nested-evo".to_string())
+ .commit(messages)
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn added_nested_field_reads_as_null_in_older_files() {
+ let tmp = tempfile::tempdir().unwrap();
+ let location = format!("file://{}", tmp.path().display());
+ let file_io = FileIOBuilder::new("file").build().unwrap();
+ for dir in ["schema", "snapshot", "manifest"] {
+ file_io.mkdirs(&format!("{location}/{dir}")).await.unwrap();
+ }
+
+ // Snapshot 1 under schema-0: `media` has only `codec`.
+ write_schema(&file_io, &location, schema_v0()).await;
+ let table_v0 = open_table(&file_io, &location).await;
+ assert_eq!(table_v0.schema().id(), 0);
+ commit(&table_v0, &batch_v0(1, "h264")).await;
+
+ // ALTER TABLE ... ADD COLUMN media.color_transfer -> schema-1, then a
second
+ // snapshot whose file carries the wider struct.
+ write_schema(&file_io, &location, schema_v1()).await;
+ let table_v1 = open_table(&file_io, &location).await;
+ assert_eq!(table_v1.schema().id(), 1);
+ commit(&table_v1, &batch_v1(2, "h265", "bt709")).await;
+
+ // Full read across both files.
+ let read_builder = table_v1.new_read_builder();
+ let scan = read_builder.new_scan();
+ let plan = scan.plan().await.unwrap();
+ let read = read_builder.new_read().unwrap();
+ let stream = read.to_arrow(plan.splits()).unwrap();
+ let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
+
+ let mut rows: Vec<(i32, String, Option<String>)> = Vec::new();
+ for batch in &batches {
+ let ids = batch
+ .column(batch.schema().index_of("id").unwrap())
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .clone();
+ let media = batch
+ .column(batch.schema().index_of("media").unwrap())
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap()
+ .clone();
+ let codec = media
+ .column_by_name("codec")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap()
+ .clone();
+ let color = media
+ .column_by_name("color_transfer")
+ .expect("color_transfer must be present in the read output")
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap()
+ .clone();
+ for i in 0..batch.num_rows() {
+ rows.push((
+ ids.value(i),
+ codec.value(i).to_string(),
+ if color.is_null(i) {
+ None
+ } else {
+ Some(color.value(i).to_string())
+ },
+ ));
+ }
+ }
+ rows.sort_by_key(|r| r.0);
+
+ assert_eq!(
+ rows,
+ vec![
+ (1, "h264".to_string(), None),
+ (2, "h265".to_string(), Some("bt709".to_string())),
+ ]
+ );
+}