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 95d0e110 Complete Java-compatible schema evolution for native reads
(#1128)
95d0e110 is described below
commit 95d0e110abb230920f44e330be73d5bf4c8cb1ae
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 15 10:16:59 2026 +0800
Complete Java-compatible schema evolution for native reads (#1128)
---
Cargo.lock | 4 +-
DEPENDENCIES.rust.tsv | 2 +-
benchmarks/tpcds/DEPENDENCIES.rust.tsv | 2 +-
bindings/c/DEPENDENCIES.rust.tsv | 2 +-
bindings/go/DEPENDENCIES.rust.tsv | 2 +-
bindings/python/DEPENDENCIES.rust.tsv | 2 +-
bindings/python/python/pypaimon_rust/data.pyi | 14 +
bindings/python/src/evolution.rs | 83 +++
bindings/python/src/lib.rs | 2 +
crates/integration_tests/DEPENDENCIES.rust.tsv | 2 +-
.../integrations/datafusion/DEPENDENCIES.rust.tsv | 2 +-
crates/integrations/datafusion/src/table/mod.rs | 24 +-
.../datafusion/src/variant_pushdown.rs | 2 +-
crates/paimon-rest-server/DEPENDENCIES.rust.tsv | 2 +-
crates/paimon/DEPENDENCIES.rust.tsv | 2 +-
.../paimon/src/arrow/cast_completion_java_test.rs | 90 ++-
crates/paimon/src/arrow/cast_executor.rs | 8 +-
crates/paimon/src/arrow/evolution_cast.rs | 6 +-
crates/paimon/src/arrow/evolution_temporal.rs | 15 +-
crates/paimon/src/arrow/mod.rs | 85 +++
crates/paimon/src/arrow/nested_evolution.rs | 738 ++++++++++++++++++---
crates/paimon/src/arrow/schema_evolution.rs | 14 +
crates/paimon/src/spec/types.rs | 62 +-
crates/paimon/src/table/audit_log_table/merge.rs | 3 +-
crates/paimon/src/table/audit_log_table/read.rs | 3 +-
crates/paimon/src/table/data_evolution_reader.rs | 13 +-
.../table/data_evolution_reader/blob_fallback.rs | 3 +-
crates/paimon/src/table/data_file_reader.rs | 5 +-
crates/paimon/src/table/format_table_read.rs | 4 +-
crates/paimon/src/table/kv_file_reader.rs | 13 +-
crates/paimon/src/table/mod.rs | 1 +
crates/paimon/src/table/query_auth/rules.rs | 17 +-
crates/paimon/src/table/table_read.rs | 5 +-
crates/paimon/src/table/write_batch_normalize.rs | 4 +-
34 files changed, 1079 insertions(+), 157 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 69911e8b..22ea40a7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6046,9 +6046,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.45"
+version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"once_cell",
diff --git a/DEPENDENCIES.rust.tsv b/DEPENDENCIES.rust.tsv
index f266a0a4..2313f8f4 100644
--- a/DEPENDENCIES.rust.tsv
+++ b/DEPENDENCIES.rust.tsv
@@ -504,7 +504,7 @@ [email protected] X
X
[email protected] X
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/benchmarks/tpcds/DEPENDENCIES.rust.tsv
b/benchmarks/tpcds/DEPENDENCIES.rust.tsv
index 6e030d48..24915be4 100644
--- a/benchmarks/tpcds/DEPENDENCIES.rust.tsv
+++ b/benchmarks/tpcds/DEPENDENCIES.rust.tsv
@@ -328,7 +328,7 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/bindings/c/DEPENDENCIES.rust.tsv b/bindings/c/DEPENDENCIES.rust.tsv
index 8b8f6cea..d3a70ef8 100644
--- a/bindings/c/DEPENDENCIES.rust.tsv
+++ b/bindings/c/DEPENDENCIES.rust.tsv
@@ -287,7 +287,7 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/bindings/go/DEPENDENCIES.rust.tsv
b/bindings/go/DEPENDENCIES.rust.tsv
index 8b8f6cea..d3a70ef8 100644
--- a/bindings/go/DEPENDENCIES.rust.tsv
+++ b/bindings/go/DEPENDENCIES.rust.tsv
@@ -287,7 +287,7 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/bindings/python/DEPENDENCIES.rust.tsv
b/bindings/python/DEPENDENCIES.rust.tsv
index 9c4513eb..563899bf 100644
--- a/bindings/python/DEPENDENCIES.rust.tsv
+++ b/bindings/python/DEPENDENCIES.rust.tsv
@@ -428,7 +428,7 @@ [email protected] X
X
[email protected] X
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/bindings/python/python/pypaimon_rust/data.pyi
b/bindings/python/python/pypaimon_rust/data.pyi
index c46aa2bb..9b50d350 100644
--- a/bindings/python/python/pypaimon_rust/data.pyi
+++ b/bindings/python/python/pypaimon_rust/data.pyi
@@ -19,6 +19,20 @@ from typing import Sequence
import pyarrow
+def read_arrow_schema(fields: str) -> pyarrow.Schema:
+ """Build the runtime read schema from Paimon fields (JSON)."""
+ ...
+
+def normalize_array_for_write(column: pyarrow.Array, target_type:
pyarrow.DataType) -> pyarrow.Array:
+ """Normalize a write layout and validate visible NOT NULL children in
core."""
+ ...
+
+def cast_array_for_schema_evolution(
+ column: pyarrow.Array, source_type: str, target_type: str
+) -> pyarrow.Array:
+ """Cast historical Paimon types (JSON) using the core table-reader
rules."""
+ ...
+
def variant_get_numeric_fields(
column: pyarrow.Array, fields: Sequence[str]
) -> pyarrow.FixedSizeListArray:
diff --git a/bindings/python/src/evolution.rs b/bindings/python/src/evolution.rs
new file mode 100644
index 00000000..547c14cb
--- /dev/null
+++ b/bindings/python/src/evolution.rs
@@ -0,0 +1,83 @@
+// 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.
+
+use arrow::array::{make_array, Array, ArrayData};
+use arrow::pyarrow::{FromPyArrow, ToPyArrow};
+use paimon::spec::{DataField, DataType};
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+
+use crate::error::to_py_err;
+
+/// Arrow and logical-type transport only; evolution belongs to Rust core.
+#[pyfunction]
+fn cast_array_for_schema_evolution(
+ py: Python<'_>,
+ column: &Bound<'_, PyAny>,
+ source_type: &str,
+ target_type: &str,
+) -> PyResult<Py<PyAny>> {
+ let parse = |value| -> PyResult<DataType> {
+ serde_json::from_str(value)
+ .map_err(|error| PyValueError::new_err(format!("Invalid Paimon
type: {error}")))
+ };
+ let source = parse(source_type)?;
+ let target = parse(target_type)?;
+ let data = ArrayData::from_pyarrow_bound(column)?;
+ data.validate_full().map_err(|error| {
+ PyValueError::new_err(format!("Invalid Arrow evolution input:
{error}"))
+ })?;
+ let column = make_array(data);
+ let output = py
+ .detach(|| paimon::arrow::cast_array_for_schema_evolution(&column,
&source, &target))
+ .map_err(to_py_err)?;
+ Ok(output.to_data().to_pyarrow(py)?.unbind())
+}
+
+/// Transport the core runtime schema; Python does not duplicate read
nullability.
+#[pyfunction]
+fn read_arrow_schema(py: Python<'_>, fields: &str) -> PyResult<Py<PyAny>> {
+ let fields: Vec<DataField> = serde_json::from_str(fields)
+ .map_err(|error| PyValueError::new_err(format!("Invalid Paimon fields:
{error}")))?;
+ let schema =
paimon::arrow::build_read_arrow_schema(&fields).map_err(to_py_err)?;
+ Ok(schema.as_ref().to_pyarrow(py)?.unbind())
+}
+
+/// Reuse the writer's existing normalization and value-sensitive constraints.
+#[pyfunction]
+fn normalize_array_for_write(
+ py: Python<'_>,
+ column: &Bound<'_, PyAny>,
+ target_type: &Bound<'_, PyAny>,
+) -> PyResult<Py<PyAny>> {
+ let target = arrow::datatypes::DataType::from_pyarrow_bound(target_type)?;
+ let data = ArrayData::from_pyarrow_bound(column)?;
+ data.validate_full()
+ .map_err(|error| PyValueError::new_err(format!("Invalid Arrow write
input: {error}")))?;
+ let column = make_array(data);
+ let output = py
+ .detach(|| paimon::table::normalize_write_array(&column, &target))
+ .map_err(to_py_err)?;
+ Ok(output.to_data().to_pyarrow(py)?.unbind())
+}
+
+pub(crate) fn register_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
+ let data = m.getattr("data")?.cast_into::<PyModule>()?;
+ data.add_function(wrap_pyfunction!(cast_array_for_schema_evolution,
&data)?)?;
+ data.add_function(wrap_pyfunction!(read_arrow_schema, &data)?)?;
+ data.add_function(wrap_pyfunction!(normalize_array_for_write, &data)?)
+}
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 6b2fcc54..a15c7600 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -22,6 +22,7 @@ mod blob_uri_reader;
mod commit_callback;
mod context;
mod error;
+mod evolution;
mod full_text_search;
mod hybrid_search;
mod index_build;
@@ -47,5 +48,6 @@ fn pypaimon_rust(py: Python<'_>, m: &Bound<'_, PyModule>) ->
PyResult<()> {
error::register_module(py, m)?;
context::register_module(py, m)?;
variant::register_module(py, m)?;
+ evolution::register_module(m)?;
Ok(())
}
diff --git a/crates/integration_tests/DEPENDENCIES.rust.tsv
b/crates/integration_tests/DEPENDENCIES.rust.tsv
index c8f095a7..3c350db3 100644
--- a/crates/integration_tests/DEPENDENCIES.rust.tsv
+++ b/crates/integration_tests/DEPENDENCIES.rust.tsv
@@ -257,7 +257,7 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
b/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
index 88bc626b..091e6a23 100644
--- a/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
+++ b/crates/integrations/datafusion/DEPENDENCIES.rust.tsv
@@ -423,7 +423,7 @@ [email protected] X
X
[email protected] X
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/crates/integrations/datafusion/src/table/mod.rs
b/crates/integrations/datafusion/src/table/mod.rs
index ad74a594..9a4727c5 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -68,7 +68,7 @@ pub(crate) fn datafusion_arrow_schema(
schema_force_view_types: bool,
) -> DFResult<ArrowSchemaRef> {
let paimon_schema =
-
paimon::arrow::build_target_arrow_schema(fields).map_err(to_datafusion_error)?;
+
paimon::arrow::build_read_arrow_schema(fields).map_err(to_datafusion_error)?;
let fields = paimon_schema
.fields()
.iter()
@@ -902,6 +902,28 @@ mod tests {
assert_eq!(row_fields[0].data_type(), &ArrowDataType::Utf8);
}
+ #[test]
+ fn test_datafusion_read_schema_keeps_cast_nulls_visible_to_optimizers() {
+ let decimal =
+ DataType::Decimal(paimon::spec::DecimalType::with_nullable(false,
3, 2).unwrap());
+ let fields = vec![
+ DataField::new(0, "value".into(), decimal.clone()),
+ DataField::new(1, "values".into(),
DataType::Array(ArrayType::new(decimal))),
+ ];
+ let schema = datafusion_arrow_schema(&fields, false).unwrap();
+ assert!(schema.field(0).is_nullable());
+ let ArrowDataType::List(child) = schema.field(1).data_type() else {
+ panic!()
+ };
+ assert!(child.is_nullable());
+ let write_schema =
paimon::arrow::build_target_arrow_schema(&fields).unwrap();
+ assert!(!write_schema.field(0).is_nullable());
+ let ArrowDataType::List(child) = write_schema.field(1).data_type()
else {
+ panic!()
+ };
+ assert!(!child.is_nullable());
+ }
+
fn planned_file_names(scan: &PaimonTableScan) -> Vec<String> {
let mut names = scan
.planned_partitions()
diff --git a/crates/integrations/datafusion/src/variant_pushdown.rs
b/crates/integrations/datafusion/src/variant_pushdown.rs
index 6013c504..4955b302 100644
--- a/crates/integrations/datafusion/src/variant_pushdown.rs
+++ b/crates/integrations/datafusion/src/variant_pushdown.rs
@@ -389,7 +389,7 @@ fn build_projection_rewrite(
.transpose()?;
let arrow_schema =
-
paimon::arrow::build_target_arrow_schema(&read_type).map_err(to_datafusion_error)?;
+
paimon::arrow::build_read_arrow_schema(&read_type).map_err(to_datafusion_error)?;
let schema = Arc::new(DFSchema::try_from_qualified_schema(
scan.table_name.clone(),
arrow_schema.as_ref(),
diff --git a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
index aa748ee1..55d7caf6 100644
--- a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
+++ b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
@@ -261,7 +261,7 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/crates/paimon/DEPENDENCIES.rust.tsv
b/crates/paimon/DEPENDENCIES.rust.tsv
index 94caabea..3a0678c2 100644
--- a/crates/paimon/DEPENDENCIES.rust.tsv
+++ b/crates/paimon/DEPENDENCIES.rust.tsv
@@ -420,7 +420,7 @@ [email protected] X
X
[email protected] X
X
[email protected] X
X
[email protected] X X
X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X X
[email protected] X
X
[email protected] X
X
diff --git a/crates/paimon/src/arrow/cast_completion_java_test.rs
b/crates/paimon/src/arrow/cast_completion_java_test.rs
index dee9fb83..56422f19 100644
--- a/crates/paimon/src/arrow/cast_completion_java_test.rs
+++ b/crates/paimon/src/arrow/cast_completion_java_test.rs
@@ -297,6 +297,94 @@ fn unrepresentable(expected: &Value, target: &ArrowType)
-> bool {
full % divisor != 0 || i64::try_from(full / divisor).is_err()
}
+#[test]
+fn schema_evolution_scalar_casts_match_actual_java() {
+ let zone = jiff::tz::TimeZone::system();
+ let zone = match zone.iana_name() {
+ Some("Asia/Shanghai") => "Asia/Shanghai",
+ Some("America/New_York") => "America/New_York",
+ Some("UTC" | "Etc/UTC" | "Etc/GMT" | "GMT") => "UTC",
+ _ => return,
+ };
+ let mut tested = 0;
+ let mut failures = Vec::new();
+ for line in CASES.lines() {
+ let case: Value = serde_json::from_str(line).unwrap();
+ let from: DataType =
serde_json::from_value(case["fields"][0]["type"].clone()).unwrap();
+ let original: DataType =
+
serde_json::from_value(case["transform"]["fieldRef"]["type"].clone()).unwrap();
+ if from != original {
+ continue;
+ }
+ let target: DataType =
serde_json::from_value(case["transform"]["type"].clone()).unwrap();
+ let target_arrow = paimon_type_to_arrow(&target).unwrap();
+ for (row, expected) in case["rows"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .zip(case["expected"][zone]["results"].as_array().unwrap())
+ {
+ let Some(source) = source(&row[0], &from) else {
+ continue;
+ };
+ // These outputs cannot be represented by Arrow, independently of
+ // Java's executor. Storage-boundary behavior has dedicated tests.
+ if unrepresentable(expected,
&paimon_type_to_arrow(&target).unwrap()) {
+ continue;
+ }
+ if matches!(source.data_type(), ArrowType::Binary)
+ && row[0].as_array().is_some_and(|bytes| {
+ std::str::from_utf8(
+ &bytes
+ .iter()
+ .map(|v| v.as_u64().unwrap() as u8)
+ .collect::<Vec<_>>(),
+ )
+ .is_err()
+ })
+ {
+ continue;
+ }
+ let actual = super::nested_evolution::evolve_column(&source,
&from, &target);
+ tested += 1;
+ if expected.get("error").is_some() {
+ if actual.is_ok() {
+ failures.push(format!(
+ "{} input {} expected an error, got {:?}",
+ case["name"],
+ row[0],
+ actual.map(|v| external(&v))
+ ));
+ }
+ } else if !actual.as_ref().is_ok_and(|value| {
+ let value = external(value);
+ if expected.is_number()
+ && matches!(target_arrow, ArrowType::Float32 |
ArrowType::Float64)
+ {
+ value.as_f64() == expected.as_f64()
+ } else {
+ value == *expected
+ }
+ }) {
+ failures.push(format!(
+ "{} input {} expected {}, got {:?}",
+ case["name"],
+ row[0],
+ expected,
+ actual.map(|v| external(&v))
+ ));
+ }
+ }
+ }
+ assert!(tested > 500, "too few Java cases: {tested}");
+ assert!(
+ failures.is_empty(),
+ "{} mismatches in {tested} Java cases: {:#?}",
+ failures.len(),
+ &failures[..failures.len().min(30)]
+ );
+}
+
#[test]
fn cast_completion_matches_actual_java_in_three_zones() {
let mut tested = 0;
@@ -389,7 +477,7 @@ fn existing_python_java_capability_matrix_is_complete() {
"01111110111000000000",
"11111111111000000000",
"11111111111000000000",
- "11111111111000000000",
+ "11111111111000001111",
"11111111111000001111",
"01111111111000000000",
"01111111111000000000",
diff --git a/crates/paimon/src/arrow/cast_executor.rs
b/crates/paimon/src/arrow/cast_executor.rs
index 4ce174b3..4d5adbf7 100644
--- a/crates/paimon/src/arrow/cast_executor.rs
+++ b/crates/paimon/src/arrow/cast_executor.rs
@@ -96,7 +96,10 @@ impl CastExecutor {
to,
DataType::Float(_) | DataType::Double(_) |
DataType::Date(_)
) || timestamp(to))
- || matches!(from, DataType::BigInt(_) | DataType::Date(_)) &&
timestamp(to)
+ || matches!(
+ from,
+ DataType::Int(_) | DataType::BigInt(_) | DataType::Date(_)
+ ) && timestamp(to)
|| timestamp(from)
&& (timestamp(to)
|| matches!(
@@ -277,7 +280,8 @@ impl CastExecutor {
.collect::<crate::Result<Vec<_>>>()?;
strict_cast(&(Arc::new(Int64Array::from(values)) as ArrayRef),
&target)?
}
- } else if matches!(self.from, DataType::BigInt(_)) &&
timestamp(&self.to) {
+ } else if matches!(self.from, DataType::Int(_) | DataType::BigInt(_))
&& timestamp(&self.to)
+ {
self.require(number(&self.current))?;
let (source, _) = self.number_input(&source)?;
let long = super::evolution_cast::numeric_to_integer(&source,
&ArrowType::Int64)?;
diff --git a/crates/paimon/src/arrow/evolution_cast.rs
b/crates/paimon/src/arrow/evolution_cast.rs
index c0bd85d1..5e85e110 100644
--- a/crates/paimon/src/arrow/evolution_cast.rs
+++ b/crates/paimon/src/arrow/evolution_cast.rs
@@ -342,7 +342,11 @@ pub(super) fn timestamp_precision(
value.and_then(|value| {
let nanos = i128::from(value) * nanos_per_tick(source_unit);
let truncated = nanos.div_euclid(quantum) * quantum;
- i64::try_from(truncated / nanos_per_tick(target_unit)).ok()
+ // Java Timestamp keeps signed-long milliseconds. Flooring below
+ // i64::MIN wraps that millisecond value before Arrow unit scaling.
+ let millis = truncated.div_euclid(1_000_000) as i64;
+ let nanos = i128::from(millis) * 1_000_000 +
truncated.rem_euclid(1_000_000);
+ i64::try_from(nanos / nanos_per_tick(target_unit)).ok()
})
}));
Ok(Some(
diff --git a/crates/paimon/src/arrow/evolution_temporal.rs
b/crates/paimon/src/arrow/evolution_temporal.rs
index a4349099..cd99a702 100644
--- a/crates/paimon/src/arrow/evolution_temporal.rs
+++ b/crates/paimon/src/arrow/evolution_temporal.rs
@@ -44,7 +44,7 @@ pub(super) fn cast_temporal(
DataType::LocalZonedTimestamp(_),
DataType::Timestamp(_) | DataType::Date(_) | DataType::Time(_)
) | (
- DataType::Date(_) | DataType::BigInt(_),
+ DataType::Date(_) | DataType::Int(_) | DataType::BigInt(_),
DataType::LocalZonedTimestamp(_)
)
) {
@@ -96,12 +96,15 @@ fn cast_in_zone_with_storage(
);
let numeric = matches!(
(from, to),
- (DataType::BigInt(_), DataType::LocalZonedTimestamp(_))
+ (
+ DataType::Int(_) | DataType::BigInt(_),
+ DataType::LocalZonedTimestamp(_)
+ )
);
- if !cross
- && !date
- && !numeric
- && !(timestamp && matches!(to, DataType::Date(_) | DataType::Time(_)))
+ if !(cross
+ || date
+ || numeric
+ || timestamp && matches!(to, DataType::Date(_) | DataType::Time(_)))
{
return Ok(None);
}
diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index 72101876..a7119809 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -39,6 +39,7 @@ mod row_filter;
mod string_transform;
pub(crate) use string_transform::evaluate_string_transform;
pub(crate) mod schema_evolution;
+pub use schema_evolution::cast_array_for_schema_evolution;
pub(crate) mod shredding;
mod variant;
@@ -380,6 +381,90 @@ pub fn build_target_arrow_schema(fields: &[DataField]) ->
crate::Result<Arc<Arro
Ok(Arc::new(ArrowSchema::new(arrow_fields)))
}
+/// Read results can contain NULL after a Java cast overflows or a historical
+/// file lacks a requested field, even when the current logical type is NOT
NULL.
+/// Keep those runtime values representable without relaxing the writer schema.
+pub(crate) fn paimon_type_to_arrow_for_read(dt: &PaimonDataType) ->
crate::Result<ArrowDataType> {
+ Ok(match dt {
+ PaimonDataType::Row(row) => ArrowDataType::Struct(
+ row.fields()
+ .iter()
+ .map(|field| {
+ Ok(ArrowField::new(
+ field.name(),
+ paimon_type_to_arrow_for_read(field.data_type())?,
+ true,
+ ))
+ })
+ .collect::<crate::Result<Vec<_>>>()?
+ .into(),
+ ),
+ PaimonDataType::Array(array) =>
ArrowDataType::List(Arc::new(ArrowField::new(
+ "element",
+ paimon_type_to_arrow_for_read(array.element_type())?,
+ true,
+ ))),
+ PaimonDataType::Map(map) => ArrowDataType::Map(
+ Arc::new(ArrowField::new(
+ "entries",
+ ArrowDataType::Struct(
+ vec![
+ ArrowField::new(
+ "key",
+ paimon_type_to_arrow_for_read(map.key_type())?,
+ false,
+ ),
+ ArrowField::new(
+ "value",
+ paimon_type_to_arrow_for_read(map.value_type())?,
+ true,
+ ),
+ ]
+ .into(),
+ ),
+ false,
+ )),
+ false,
+ ),
+ PaimonDataType::Multiset(multiset) => ArrowDataType::Map(
+ Arc::new(ArrowField::new(
+ "entries",
+ ArrowDataType::Struct(
+ vec![
+ ArrowField::new(
+ "key",
+
paimon_type_to_arrow_for_read(multiset.element_type())?,
+ multiset.element_type().is_nullable(),
+ ),
+ ArrowField::new("value", ArrowDataType::Int32, false),
+ ]
+ .into(),
+ ),
+ false,
+ )),
+ false,
+ ),
+ _ => paimon_type_to_arrow(dt)?,
+ })
+}
+
+/// Build the nullable runtime schema used by table readers. Logical table
+/// constraints and the schema used to validate/write new records are
unchanged.
+pub fn build_read_arrow_schema(fields: &[DataField]) ->
crate::Result<Arc<ArrowSchema>> {
+ let fields = fields
+ .iter()
+ .map(|field| {
+ Ok(arrow_field_with_paimon_id(
+ field.name(),
+ paimon_type_to_arrow_for_read(field.data_type())?,
+ true,
+ field.id(),
+ ))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ Ok(Arc::new(ArrowSchema::new(fields)))
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/paimon/src/arrow/nested_evolution.rs
b/crates/paimon/src/arrow/nested_evolution.rs
index 09fc1288..2e511139 100644
--- a/crates/paimon/src/arrow/nested_evolution.rs
+++ b/crates/paimon/src/arrow/nested_evolution.rs
@@ -34,12 +34,14 @@
use std::sync::Arc;
use arrow_array::{
- new_null_array, Array, ArrayRef, ListArray, MapArray, StringArray,
StructArray, UInt32Array,
+ new_null_array, Array, ArrayRef, BooleanArray, GenericListArray,
ListArray, MapArray,
+ OffsetSizeTrait, StringArray, StructArray, UInt32Array,
};
+use arrow_buffer::{NullBuffer, OffsetBuffer};
use arrow_cast::cast;
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Fields};
-use crate::arrow::paimon_type_to_arrow;
+use crate::arrow::paimon_type_to_arrow_for_read;
use crate::spec::{
is_variant_extraction_row_type, map_selected_keys, DataField, DataType,
MapType, RowType,
};
@@ -62,13 +64,26 @@ pub(crate) fn evolve_field(
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());
+ if source
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .is_some_and(|rows| {
+ rows.column_names()
+ == row
+ .fields()
+ .iter()
+ .map(|field| field.name())
+ .collect::<Vec<_>>()
+ })
+ {
+ return evolve_column(source, target.data_type(),
target.data_type());
}
- return
crate::arrow::shredding::variant::assemble_variant_extraction_array(
- source.as_ref(),
- row,
- );
+ let assembled =
+
crate::arrow::shredding::variant::assemble_variant_extraction_array(
+ source.as_ref(),
+ row,
+ )?;
+ return evolve_column(&assembled, target.data_type(),
target.data_type());
}
}
}
@@ -89,8 +104,20 @@ pub(crate) fn selected_map_to_row(
message: "Selected MAP input must be a MapArray".into(),
source: None,
})?;
- let names = map
- .keys()
+ if !matches!(
+ map_type.key_type(),
+ DataType::Char(_) | DataType::VarChar(_)
+ ) {
+ return Err(crate::Error::Unsupported {
+ message: "Selected MAP keys must have a character string
type".into(),
+ });
+ }
+ let normalized_keys = evolve_column(
+ map.keys(),
+ map_type.key_type(),
+ &DataType::VarChar(crate::spec::VarCharType::string_type()),
+ )?;
+ let names = normalized_keys
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| crate::Error::DataInvalid {
@@ -123,7 +150,8 @@ pub(crate) fn selected_map_to_row(
field.data_type(),
)?);
}
- let ArrowDataType::Struct(fields) =
paimon_type_to_arrow(&DataType::Row(row_type.clone()))?
+ let ArrowDataType::Struct(fields) =
+ paimon_type_to_arrow_for_read(&DataType::Row(row_type.clone()))?
else {
unreachable!()
};
@@ -141,14 +169,35 @@ pub(crate) fn selected_map_to_row(
/// 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.
+/// is handled at any depth. Leaf conversions use the Java-compatible scalar
+/// executors also used by top-level old-file reads.
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)?;
+ // DECIMAL has at most 38 digits in Paimon, but Arrow metadata may use the
+ // wider Decimal256 storage for the same logical type. Normalize storage
+ // before dispatching Java numeric executors, preserving precision/scale.
+ if matches!(source_type, DataType::Decimal(_))
+ && matches!(source.data_type(), ArrowDataType::Decimal256(_, _))
+ {
+ let source_arrow = paimon_type_to_arrow_for_read(source_type)?;
+ let normalized = arrow_cast::cast_with_options(
+ source,
+ &source_arrow,
+ &arrow_cast::CastOptions {
+ safe: false,
+ ..Default::default()
+ },
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: format!("failed to normalize DECIMAL storage: {error}"),
+ source: None,
+ })?;
+ return evolve_column(&normalized, source_type, target_type);
+ }
+ let target_arrow = paimon_type_to_arrow_for_read(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
@@ -160,6 +209,25 @@ pub(crate) fn evolve_column(
if source_type.equals_ignore_nullable(target_type) && source.data_type()
== &target_arrow {
return Ok(source.clone());
}
+ if source_type.equals_ignore_nullable(target_type)
+ && !matches!(
+ source_type,
+ DataType::Row(_) | DataType::Array(_) | DataType::Map(_) |
DataType::Multiset(_)
+ )
+ {
+ return arrow_cast::cast_with_options(
+ source,
+ &target_arrow,
+ &arrow_cast::CastOptions {
+ safe: false,
+ ..Default::default()
+ },
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: format!("failed to normalize schema-evolution storage:
{error}"),
+ source: None,
+ });
+ }
if matches!(target_type, DataType::VarChar(_) | DataType::Char(_))
&& matches!(
@@ -180,7 +248,21 @@ pub(crate) fn evolve_column(
// 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) => {}
+ || is_variant_extraction_row_type(source_type) =>
+ {
+ return arrow_cast::cast_with_options(
+ source,
+ &target_arrow,
+ &arrow_cast::CastOptions {
+ safe: false,
+ ..Default::default()
+ },
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: format!("failed to normalize variant-extraction ROW
storage: {error}"),
+ source: None,
+ });
+ }
(DataType::Row(target_row), DataType::Row(source_row)) => {
return evolve_struct(source, source_row, target_row)
}
@@ -245,13 +327,21 @@ pub(crate) fn evolve_column(
return Ok(result);
}
- cast(source, &target_arrow).map_err(|e| crate::Error::UnexpectedError {
+ // Old-file reads and authorization use the same Java executor for lexical
+ // parsing, numeric text and character/binary widths. Arrow's default cast
+ // can otherwise silently replace invalid strings with NULL.
+ if super::CastExecutor::supports(source_type, target_type) {
+ return super::CastExecutor::new(
+ source_type.clone(),
+ source_type.clone(),
+ target_type.clone(),
+ )?
+ .cast(source);
+ }
+ Err(crate::Error::Unsupported {
message: format!(
- "failed to cast nested value from {:?} to {:?} during schema
evolution",
- source.data_type(),
- target_arrow
+ "Cannot cast from type {source_type} to type {target_type} during
schema evolution"
),
- source: Some(Box::new(e)),
})
}
@@ -273,8 +363,18 @@ fn render_string_values(
let children = row_type
.fields()
.iter()
- .enumerate()
- .map(|(index, field)| render_string_values(rows.column(index),
field.data_type()))
+ .map(|field| {
+ let column =
rows.column_by_name(field.name()).ok_or_else(|| {
+ crate::Error::DataInvalid {
+ message: format!(
+ "missing historical ROW field '{}' during
string conversion",
+ field.name()
+ ),
+ source: None,
+ }
+ })?;
+ render_string_values(&visible_struct_child(column, rows)?,
field.data_type())
+ })
.collect::<crate::Result<Vec<_>>>()?;
Ok((0..rows.len())
.map(|index| {
@@ -292,12 +392,7 @@ fn render_string_values(
.collect())
}
DataType::Array(array_type) => {
- let rows =
source.as_any().downcast_ref::<ListArray>().ok_or_else(|| {
- crate::Error::DataInvalid {
- message: format!("expected ARRAY array, got {:?}",
source.data_type()),
- source: None,
- }
- })?;
+ let rows = visible_list(source)?;
let values = render_string_values(rows.values(),
array_type.element_type())?;
let offsets = rows.value_offsets();
Ok((0..rows.len())
@@ -316,12 +411,7 @@ fn render_string_values(
.collect())
}
DataType::Map(map_type) => {
- let rows =
source.as_any().downcast_ref::<MapArray>().ok_or_else(|| {
- crate::Error::DataInvalid {
- message: format!("expected MAP array, got {:?}",
source.data_type()),
- source: None,
- }
- })?;
+ let rows = visible_map(as_map_array(source, "MAP")?)?;
let keys = render_string_values(rows.keys(), map_type.key_type())?;
let values = render_string_values(rows.values(),
map_type.value_type())?;
let offsets = rows.value_offsets();
@@ -396,11 +486,11 @@ fn evolve_struct(
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())?;
+ let target_arrow =
paimon_type_to_arrow_for_read(target_field.data_type())?;
fields.push(Arc::new(ArrowField::new(
target_field.name(),
target_arrow.clone(),
- target_field.data_type().is_nullable(),
+ true,
)));
// Pair by id (Java: `createIndexMapping` over the ROW's fields).
@@ -431,7 +521,11 @@ fn evolve_struct(
),
source: None,
})?;
- arrays.push(evolve_field(column, source_field, target_field)?)
+ arrays.push(evolve_field(
+ &visible_struct_child(column, source_struct)?,
+ source_field,
+ target_field,
+ )?)
}
}
}
@@ -452,45 +546,13 @@ fn evolve_list(
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 list = visible_list(source)?;
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(),
+ paimon_type_to_arrow_for_read(target_element)?,
+ true,
));
let evolved = ListArray::try_new(
element_field,
@@ -505,6 +567,166 @@ fn evolve_list(
Ok(Arc::new(evolved))
}
+/// Normalize physical offsets without casting children before field-id
evolution.
+/// Arrow metadata can store logical ARRAYs as LargeList or FixedSizeList.
Their
+/// children still describe the historical file schema and must remain intact.
+fn normalize_list(source: &ArrayRef) -> crate::Result<ListArray> {
+ if let Some(list) = source.as_any().downcast_ref::<ListArray>() {
+ return Ok(list.clone());
+ }
+ let child = match source.data_type() {
+ ArrowDataType::LargeList(child) | ArrowDataType::FixedSizeList(child,
_) => child,
+ _ => {
+ return Err(crate::Error::DataInvalid {
+ message: format!("expected ARRAY array, got {:?}",
source.data_type()),
+ source: None,
+ });
+ }
+ };
+ let normalized = cast(source,
&ArrowDataType::List(child.clone())).map_err(|e| {
+ crate::Error::DataInvalid {
+ message: format!("failed to normalize ARRAY offsets: {e}"),
+ source: None,
+ }
+ })?;
+ normalized
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .cloned()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "normalizing ARRAY offsets did not yield a
ListArray".into(),
+ source: None,
+ })
+}
+
+fn visible_struct_child(column: &ArrayRef, rows: &StructArray) ->
crate::Result<ArrayRef> {
+ if rows.null_count() == 0 {
+ return Ok(column.clone());
+ }
+ let hidden = BooleanArray::from(
+ (0..rows.len())
+ .map(|row| rows.is_null(row))
+ .collect::<Vec<_>>(),
+ );
+ arrow_select::nullif::nullif(column.as_ref(), &hidden).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!("failed to mask a NULL ROW child: {error}"),
+ source: None,
+ }
+ })
+}
+
+/// Only convert elements the visible containers can observe. Slices may retain
+/// unrelated prefix/suffix values; NULL containers can also own physical
slots.
+/// Java's array/map getters never evaluate those slots.
+fn visible_entries<O: OffsetSizeTrait>(
+ offsets: &[O],
+ nulls: Option<&NullBuffer>,
+ values: &ArrayRef,
+) -> crate::Result<(OffsetBuffer<i32>, ArrayRef)> {
+ let start = offsets[0].as_usize();
+ let end = offsets[offsets.len() - 1].as_usize();
+ let has_hidden_entries = (0..offsets.len() - 1).any(|row| {
+ nulls.is_some_and(|nulls| nulls.is_null(row)) && offsets[row] !=
offsets[row + 1]
+ });
+ let mut result_offsets = Vec::with_capacity(offsets.len());
+ result_offsets.push(0);
+ let selected = if has_hidden_entries {
+ let mut pieces = Vec::new();
+ let mut count = 0usize;
+ for row in 0..offsets.len() - 1 {
+ let from = offsets[row].as_usize();
+ let to = offsets[row + 1].as_usize();
+ if nulls.is_none_or(|nulls| nulls.is_valid(row)) && from != to {
+ pieces.push(values.slice(from, to - from));
+ count += to - from;
+ }
+ result_offsets.push(i32::try_from(count).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!(
+ "ARRAY/MAP visible offsets exceed Arrow List storage:
{error}"
+ ),
+ source: None,
+ }
+ })?);
+ }
+ if pieces.is_empty() {
+ values.slice(start, 0)
+ } else {
+ arrow_select::concat::concat(
+ &pieces.iter().map(|part| part.as_ref()).collect::<Vec<_>>(),
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: format!("failed to gather visible ARRAY/MAP entries:
{error}"),
+ source: None,
+ })?
+ }
+ } else {
+ for offset in &offsets[1..] {
+ result_offsets.push(i32::try_from(offset.as_usize() -
start).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!(
+ "ARRAY/MAP visible offsets exceed Arrow List storage:
{error}"
+ ),
+ source: None,
+ }
+ })?);
+ }
+ values.slice(start, end - start)
+ };
+ Ok((OffsetBuffer::new(result_offsets.into()), selected))
+}
+
+fn visible_generic_list<O: OffsetSizeTrait>(
+ list: &GenericListArray<O>,
+) -> crate::Result<ListArray> {
+ let (offsets, values) = visible_entries(list.value_offsets(),
list.nulls(), list.values())?;
+ let child = match list.data_type() {
+ ArrowDataType::List(child) | ArrowDataType::LargeList(child) =>
child.clone(),
+ _ => unreachable!(),
+ };
+ ListArray::try_new(child, offsets, values,
list.nulls().cloned()).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!("failed to build visible ARRAY: {error}"),
+ source: None,
+ }
+ })
+}
+
+fn visible_list(source: &ArrayRef) -> crate::Result<ListArray> {
+ if let Some(list) = source
+ .as_any()
+ .downcast_ref::<arrow_array::LargeListArray>()
+ {
+ return visible_generic_list(list);
+ }
+ visible_generic_list(&normalize_list(source)?)
+}
+
+fn visible_map(map: &MapArray) -> crate::Result<MapArray> {
+ let entries: ArrayRef = Arc::new(map.entries().clone());
+ let (offsets, entries) = visible_entries(map.value_offsets(), map.nulls(),
&entries)?;
+ let entries = entries
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap()
+ .clone();
+ let ArrowDataType::Map(field, sorted) = map.data_type() else {
+ unreachable!()
+ };
+ MapArray::try_new(
+ field.clone(),
+ offsets,
+ entries,
+ map.nulls().cloned(),
+ *sorted,
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: format!("failed to build visible MAP: {error}"),
+ source: None,
+ })
+}
+
/// Rebuild a MAP whose key or value type evolved, reusing the source offsets
and
/// validity. Mirrors Java `SchemaEvolutionUtil.createMapCastExecutor`.
fn evolve_map(
@@ -512,7 +734,7 @@ fn evolve_map(
source_map: &MapType,
target_map: &MapType,
) -> crate::Result<ArrayRef> {
- let map = as_map_array(source, "MAP")?;
+ let map = visible_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(),
@@ -520,14 +742,18 @@ fn evolve_map(
target_map.value_type(),
)?;
rebuild_map(
- map,
+ &map,
keys,
values,
- ArrowField::new("key", paimon_type_to_arrow(target_map.key_type())?,
false),
+ ArrowField::new(
+ "key",
+ paimon_type_to_arrow_for_read(target_map.key_type())?,
+ false,
+ ),
ArrowField::new(
"value",
- paimon_type_to_arrow(target_map.value_type())?,
- target_map.value_type().is_nullable(),
+ paimon_type_to_arrow_for_read(target_map.value_type())?,
+ true,
),
)
}
@@ -546,16 +772,16 @@ fn evolve_multiset(
source_element: &DataType,
target_element: &DataType,
) -> crate::Result<ArrayRef> {
- let map = as_map_array(source, "MULTISET")?;
+ let map = visible_map(as_map_array(source, "MULTISET")?)?;
let elements = evolve_column(map.keys(), source_element, target_element)?;
let counts = map.values().clone();
rebuild_map(
- map,
+ &map,
elements,
counts,
ArrowField::new(
"key",
- paimon_type_to_arrow(target_element)?,
+ paimon_type_to_arrow_for_read(target_element)?,
target_element.is_nullable(),
),
ArrowField::new("value", ArrowDataType::Int32, false),
@@ -718,6 +944,42 @@ mod tests {
assert_eq!(output.column(2).null_count(), 3);
}
+ #[test]
+ fn selected_map_normalizes_large_string_keys_in_sliced_input() {
+ use arrow_array::builder::{Int32Builder, LargeStringBuilder,
MapBuilder};
+ let mut builder = MapBuilder::new(None, LargeStringBuilder::new(),
Int32Builder::new());
+ for (key, value) in [("prefix", 99), ("key", 7), ("key", 9),
("suffix", 88)] {
+ builder.keys().append_value(key);
+ builder.values().append_value(value);
+ if key != "key" || value == 9 {
+ 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(), "prefix".into()])
+ .unwrap();
+ let output = evolve_field(&source.slice(1, 1), &source_field,
&requested).unwrap();
+ let output = output.as_any().downcast_ref::<StructArray>().unwrap();
+ assert_eq!(
+ output
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap(),
+ &Int32Array::from(vec![7])
+ );
+ assert_eq!(output.column(1).null_count(), 1);
+ }
+
fn field(id: i32, name: &str, dt: DataType) -> DataField {
DataField::new(id, name.to_string(), dt)
}
@@ -784,7 +1046,10 @@ mod tests {
// 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());
+ assert_eq!(
+ out.data_type(),
+ &paimon_type_to_arrow_for_read(&target).unwrap()
+ );
let evolved = as_struct(&out);
assert_eq!(
evolved.column_names(),
@@ -901,7 +1166,10 @@ mod tests {
let ts6 = DataType::Timestamp(TimestampType::new(6).unwrap());
let micros = evolve_column(&source, &source_type, &ts6).unwrap();
- assert_eq!(micros.data_type(), &paimon_type_to_arrow(&ts6).unwrap());
+ assert_eq!(
+ micros.data_type(),
+ &paimon_type_to_arrow_for_read(&ts6).unwrap()
+ );
let micros = micros
.as_any()
.downcast_ref::<TimestampMicrosecondArray>()
@@ -947,7 +1215,7 @@ mod tests {
Some((i64::from(i32::MAX) + 3) * ticks),
None,
]));
- let source = cast(&raw,
&paimon_type_to_arrow(&source_type).unwrap())
+ let source = cast(&raw,
&paimon_type_to_arrow_for_read(&source_type).unwrap())
.unwrap()
.slice(1, 6);
let out =
@@ -1034,7 +1302,7 @@ mod tests {
};
let raw: ArrayRef =
Arc::new(Int64Array::from(vec![Some(i64::MAX), Some(i64::MIN),
None]));
- let source = cast(&raw,
&paimon_type_to_arrow(&source_type).unwrap()).unwrap();
+ let source = cast(&raw,
&paimon_type_to_arrow_for_read(&source_type).unwrap()).unwrap();
let output =
evolve_column(&source, &source_type,
&DataType::BigInt(BigIntType::new())).unwrap();
assert_eq!(
@@ -1117,7 +1385,7 @@ mod tests {
)
};
let raw: ArrayRef = Arc::new(Int64Array::from(raw));
- let leaf = cast(&raw,
&paimon_type_to_arrow(&source_type).unwrap()).unwrap();
+ let leaf = cast(&raw,
&paimon_type_to_arrow_for_read(&source_type).unwrap()).unwrap();
let validity = Some(NullBuffer::from(vec![true, false, true]));
let values = |array: &ArrayRef| {
cast(array, &ArrowDataType::Int64)
@@ -1131,7 +1399,8 @@ mod tests {
let source_row = row(vec![field(11, "v", source_type.clone())]);
let target_row = row(vec![field(11, "v", target_type.clone())]);
- let ArrowDataType::Struct(fields) =
paimon_type_to_arrow(&source_row).unwrap() else {
+ let ArrowDataType::Struct(fields) =
paimon_type_to_arrow_for_read(&source_row).unwrap()
+ else {
panic!()
};
let source: ArrayRef = Arc::new(
@@ -1140,7 +1409,11 @@ mod tests {
let out = evolve_column(&source, &source_row,
&target_row).unwrap();
let out = as_struct(&out);
assert_eq!(out.nulls(), source.nulls());
- assert_eq!(values(out.column(0)), expected, "ROW
reverse={reverse}");
+ assert_eq!(
+ values(out.column(0)),
+ vec![expected[0], None, expected[2]],
+ "ROW reverse={reverse}"
+ );
let source: ArrayRef = Arc::new(
ListArray::try_new(
@@ -1483,7 +1756,7 @@ mod tests {
assert_eq!(
out.data_type(),
- &paimon_type_to_arrow(&target_type).unwrap()
+ &paimon_type_to_arrow_for_read(&target_type).unwrap()
);
let list = out
.as_any()
@@ -1551,7 +1824,7 @@ mod tests {
assert_eq!(
out.data_type(),
- &paimon_type_to_arrow(&target_type).unwrap()
+ &paimon_type_to_arrow_for_read(&target_type).unwrap()
);
let map = out
.as_any()
@@ -1685,6 +1958,289 @@ mod tests {
);
}
+ #[test]
+ fn casts_only_visible_list_elements_including_null_container_slots() {
+ let values: ArrayRef = Arc::new(StringArray::from(vec![
+ "bad-prefix",
+ "1.9",
+ "bad-null",
+ "-2.5",
+ "bad-suffix",
+ ]));
+ let array: ArrayRef = Arc::new(
+ ListArray::try_new(
+ Arc::new(ArrowField::new("item", ArrowDataType::Utf8, true)),
+ OffsetBuffer::new(vec![0i32, 1, 2, 3, 4, 5].into()),
+ values,
+ Some(NullBuffer::from(vec![true, true, false, true, true])),
+ )
+ .unwrap(),
+ );
+ let source_type = DataType::Array(ArrayType::new(DataType::VarChar(
+ VarCharType::string_type(),
+ )));
+ let target_type =
DataType::Array(ArrayType::new(DataType::Int(IntType::new())));
+ let output = evolve_column(&array.slice(1, 3), &source_type,
&target_type).unwrap();
+ let rows = output.as_any().downcast_ref::<ListArray>().unwrap();
+ assert_eq!(rows.value_offsets(), &[0, 1, 1, 2]);
+ assert_eq!(
+ rows.values().as_any().downcast_ref::<Int32Array>().unwrap(),
+ &Int32Array::from(vec![1, -2])
+ );
+ assert!(rows.is_null(1));
+ assert!(evolve_column(&array.slice(0, 1), &source_type,
&target_type).is_err());
+ }
+
+ #[test]
+ fn casts_only_children_of_non_null_rows() {
+ let child: ArrayRef = Arc::new(StringArray::from(vec!["1.9", "bad",
"-2.5"]));
+ let array: ArrayRef = Arc::new(
+ StructArray::try_new(
+ Fields::from(vec![ArrowField::new("old", ArrowDataType::Utf8,
true)]),
+ vec![child],
+ Some(NullBuffer::from(vec![true, false, true])),
+ )
+ .unwrap(),
+ );
+ let output = evolve_column(
+ &array,
+ &row(vec![field(1, "old", string_type())]),
+ &row(vec![field(1, "renamed", DataType::Int(IntType::new()))]),
+ )
+ .unwrap();
+ let rows = as_struct(&output);
+ assert_eq!(
+ rows.column_by_name("renamed")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap(),
+ &Int32Array::from(vec![Some(1), None, Some(-2)])
+ );
+ assert!(rows.is_null(1));
+ }
+
+ #[test]
+ fn read_schema_allows_decimal_overflow_nulls_without_relaxing_writes() {
+ let source_type = DataType::Array(ArrayType::new(DataType::Decimal(
+ DecimalType::with_nullable(false, 6, 3).unwrap(),
+ )));
+ let target_type = DataType::Array(ArrayType::new(DataType::Decimal(
+ DecimalType::with_nullable(false, 3, 2).unwrap(),
+ )));
+ let source: ArrayRef = Arc::new(
+ ListArray::try_new(
+ Arc::new(ArrowField::new(
+ "element",
+ ArrowDataType::Decimal128(6, 3),
+ false,
+ )),
+ OffsetBuffer::new(vec![0, 2].into()),
+ Arc::new(
+ Decimal128Array::from(vec![9995, 1000])
+ .with_precision_and_scale(6, 3)
+ .unwrap(),
+ ),
+ None,
+ )
+ .unwrap(),
+ );
+ let output = evolve_column(&source, &source_type,
&target_type).unwrap();
+ output.to_data().validate_full().unwrap();
+ let list = output.as_any().downcast_ref::<ListArray>().unwrap();
+ assert_eq!(
+ list.values()
+ .as_any()
+ .downcast_ref::<Decimal128Array>()
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>(),
+ vec![None, Some(100)]
+ );
+ let fields = vec![field(1, "value", target_type)];
+ let read_schema =
crate::arrow::build_read_arrow_schema(&fields).unwrap();
+ arrow_array::RecordBatch::try_new(read_schema, vec![output]).unwrap();
+ let write_schema =
crate::arrow::build_target_arrow_schema(&fields).unwrap();
+ let ArrowDataType::List(child) = write_schema.field(0).data_type()
else {
+ panic!()
+ };
+ assert!(!child.is_nullable());
+ }
+
+ #[test]
+ fn
assembled_variant_rows_normalize_runtime_nullability_and_large_strings() {
+ let row = crate::spec::variant_extraction_row(
+ true,
+ vec![
+ (
+ DataType::Int(IntType::with_nullable(false)),
+ "$.x".into(),
+ false,
+ "UTC".into(),
+ ),
+ (
+ DataType::VarChar(VarCharType::string_type()),
+ "$.s".into(),
+ false,
+ "UTC".into(),
+ ),
+ ],
+ )
+ .unwrap();
+ let target = field(1, "payload", DataType::Row(row));
+ let source_field = field(
+ 1,
+ "payload",
+ DataType::Variant(crate::spec::VariantType::new()),
+ );
+ let source: ArrayRef = Arc::new(
+ StructArray::try_new(
+ vec![
+ ArrowField::new("0", ArrowDataType::Int32, false),
+ ArrowField::new("1", ArrowDataType::LargeUtf8, true),
+ ]
+ .into(),
+ vec![
+ Arc::new(Int32Array::from(vec![7])),
+
Arc::new(arrow_array::LargeStringArray::from(vec!["text"])),
+ ],
+ None,
+ )
+ .unwrap(),
+ );
+ for output in [
+ evolve_column(&source, target.data_type(),
target.data_type()).unwrap(),
+ evolve_field(&source, &source_field, &target).unwrap(),
+ ] {
+ assert_eq!(
+ output.data_type(),
+ &paimon_type_to_arrow_for_read(target.data_type()).unwrap()
+ );
+ let rows = as_struct(&output);
+ assert_eq!(
+ rows.column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 7
+ );
+ assert_eq!(
+ rows.column(1)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap()
+ .value(0),
+ "text"
+ );
+ }
+ let variant =
crate::variant::GenericVariant::parse_json(r#"{"x":7,"s":"text"}"#).unwrap();
+ let ArrowDataType::Struct(fields) = crate::arrow::variant_arrow_type()
else {
+ panic!()
+ };
+ let raw: ArrayRef = Arc::new(
+ StructArray::try_new(
+ fields,
+ vec![
+
Arc::new(arrow_array::BinaryArray::from(vec![Some(variant.value())])),
+ Arc::new(arrow_array::BinaryArray::from(vec![Some(
+ variant.metadata(),
+ )])),
+ ],
+ None,
+ )
+ .unwrap(),
+ );
+ let fresh = evolve_field(&raw, &source_field, &target).unwrap();
+ assert_eq!(
+ fresh.data_type(),
+ &paimon_type_to_arrow_for_read(target.data_type()).unwrap()
+ );
+ }
+
+ #[test]
+ fn casts_only_entries_of_non_null_maps() {
+ let fields = Fields::from(vec![
+ ArrowField::new("key", ArrowDataType::Utf8, false),
+ ArrowField::new("value", ArrowDataType::Utf8, true),
+ ]);
+ let entries = StructArray::new(
+ fields.clone(),
+ vec![
+ Arc::new(StringArray::from(vec!["one", "hidden", "two"])),
+ Arc::new(StringArray::from(vec!["1.9", "bad", "-2.5"])),
+ ],
+ None,
+ );
+ let array: ArrayRef = Arc::new(
+ MapArray::try_new(
+ Arc::new(ArrowField::new(
+ "entries",
+ ArrowDataType::Struct(fields),
+ false,
+ )),
+ OffsetBuffer::new(vec![0i32, 1, 2, 3].into()),
+ entries,
+ Some(NullBuffer::from(vec![true, false, true])),
+ false,
+ )
+ .unwrap(),
+ );
+ let from = DataType::Map(MapType::new(string_type(), string_type()));
+ let to = DataType::Map(MapType::new(string_type(),
DataType::Int(IntType::new())));
+ let output = evolve_column(&array, &from, &to).unwrap();
+ let rows = output.as_any().downcast_ref::<MapArray>().unwrap();
+ assert_eq!(rows.value_offsets(), &[0, 1, 1, 2]);
+ assert_eq!(
+ rows.values().as_any().downcast_ref::<Int32Array>().unwrap(),
+ &Int32Array::from(vec![1, -2])
+ );
+ assert!(rows.is_null(1));
+ }
+
+ #[test]
+ fn renders_large_list_strings_in_sliced_containers() {
+ let array: ArrayRef = Arc::new(
+ arrow_array::LargeListArray::try_new(
+ Arc::new(ArrowField::new("item", ArrowDataType::Int32, true)),
+ arrow_buffer::OffsetBuffer::new(vec![0i64, 1, 3, 3, 4,
5].into()),
+ Arc::new(Int32Array::from(vec![
+ Some(9),
+ Some(1),
+ None,
+ Some(7),
+ Some(8),
+ ])),
+ Some(arrow_buffer::NullBuffer::from(vec![
+ true, true, true, false, true,
+ ])),
+ )
+ .unwrap(),
+ );
+ let array_type =
DataType::Array(ArrayType::new(DataType::Int(IntType::new())));
+ let output = evolve_column(&array.slice(1, 3), &array_type,
&string_type()).unwrap();
+ assert_eq!(
+ strings(&output),
+ StringArray::from(vec![Some("[1, null]"), Some("[]"), None])
+ );
+
+ let source: ArrayRef = Arc::new(StructArray::new(
+ Fields::from(vec![ArrowField::new("a", array.data_type().clone(),
true)]),
+ vec![array],
+ None,
+ ));
+ let output = evolve_column(
+ &source.slice(1, 3),
+ &row(vec![field(4, "a", array_type)]),
+ &string_type(),
+ )
+ .unwrap();
+ assert_eq!(
+ strings(&output),
+ StringArray::from(vec!["{[1, null]}", "{[]}", "{null}"])
+ );
+ }
+
#[test]
fn evolves_a_large_list_element() {
// A format table's embedded Arrow schema can decode a Paimon ARRAY as
a
@@ -1721,7 +2277,7 @@ mod tests {
assert_eq!(
out.data_type(),
- &paimon_type_to_arrow(&target_type).unwrap()
+ &paimon_type_to_arrow_for_read(&target_type).unwrap()
);
let list = out
.as_any()
@@ -1761,7 +2317,7 @@ mod tests {
assert_eq!(
out.data_type(),
- &paimon_type_to_arrow(&source_type).unwrap(),
+ &paimon_type_to_arrow_for_read(&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/arrow/schema_evolution.rs
b/crates/paimon/src/arrow/schema_evolution.rs
index ea9450b3..5a6cff05 100644
--- a/crates/paimon/src/arrow/schema_evolution.rs
+++ b/crates/paimon/src/arrow/schema_evolution.rs
@@ -22,6 +22,20 @@
use crate::spec::DataField;
use std::collections::HashMap;
+/// Cast an Arrow column from its historical Paimon type into a requested type.
+/// Nested fields are paired by field ID; scalar conversions use Java rules.
+/// Physical Arrow layouts, NULL containers and sliced values are reconciled
+/// by the same implementation used by the table readers.
+/// Nested output fields permit runtime NULLs from casts or absent historical
+/// fields; the logical target type remains unchanged.
+pub fn cast_array_for_schema_evolution(
+ array: &arrow_array::ArrayRef,
+ source_type: &crate::spec::DataType,
+ target_type: &crate::spec::DataType,
+) -> crate::Result<arrow_array::ArrayRef> {
+ super::nested_evolution::evolve_column(array, source_type, target_type)
+}
+
/// Sentinel value indicating a field does not exist in the data schema.
pub const NULL_FIELD_INDEX: i32 = -1;
diff --git a/crates/paimon/src/spec/types.rs b/crates/paimon/src/spec/types.rs
index 0ff00663..2fdee4dd 100644
--- a/crates/paimon/src/spec/types.rs
+++ b/crates/paimon/src/spec/types.rs
@@ -119,15 +119,10 @@ fn type_base_name(s: &str) -> &str {
/// Nullability of a plain keyword type (`NAME` or `NAME NOT NULL`), `None`
when `s` is neither.
fn plain_type_nullable(s: &str, name: &str) -> Option<bool> {
- let (base, rest) = s.split_once(' ').unwrap_or((s, ""));
- if base != name {
- None
- } else if rest.is_empty() {
- Some(true)
- } else if rest.contains("NOT NULL") {
- Some(false)
- } else {
- None
+ match s.strip_prefix(name)?.trim() {
+ "" => Some(true),
+ "NOT NULL" => Some(false),
+ _ => None,
}
}
@@ -199,6 +194,7 @@ impl DataType {
"TINYINT" =>
DataType::TinyInt(TinyIntType::with_nullable(plain("TINYINT")?)),
"SMALLINT" =>
DataType::SmallInt(SmallIntType::with_nullable(plain("SMALLINT")?)),
"INT" => DataType::Int(IntType::with_nullable(plain("INT")?)),
+ "INTEGER" =>
DataType::Int(IntType::with_nullable(plain("INTEGER")?)),
"BIGINT" =>
DataType::BigInt(BigIntType::with_nullable(plain("BIGINT")?)),
"FLOAT" =>
DataType::Float(FloatType::with_nullable(plain("FLOAT")?)),
"DOUBLE" =>
DataType::Double(DoubleType::with_nullable(plain("DOUBLE")?)),
@@ -1014,6 +1010,9 @@ impl FromStr for DecimalType {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
+ if let Some(nullable) = plain_type_nullable(s, "DECIMAL") {
+ return Self::with_nullable(nullable, Self::DEFAULT_PRECISION,
Self::DEFAULT_SCALE);
+ }
if !s.starts_with(serde_utils::DECIMAL::NAME) {
return DataTypeInvalidSnafu {
message: "Invalid DECIMAL type. Expected string to start with
'DECIMAL'.",
@@ -1289,6 +1288,11 @@ impl FromStr for LocalZonedTimestampType {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
+ if let Some(nullable) = plain_type_nullable(s, "TIMESTAMP_LTZ")
+ .or_else(|| plain_type_nullable(s, "TIMESTAMP WITH LOCAL TIME
ZONE"))
+ {
+ return Self::with_nullable(nullable, Self::DEFAULT_PRECISION);
+ }
// PyPaimon persists the TIMESTAMP_LTZ(p) alias for this type.
let alias = s.starts_with("TIMESTAMP_LTZ(");
if !alias && (!s.starts_with(serde_utils::LocalZonedTimestamp::NAME)
|| !s.contains("WITH"))
@@ -1442,6 +1446,9 @@ impl FromStr for TimeType {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
+ if let Some(nullable) = plain_type_nullable(s, "TIME") {
+ return Self::with_nullable(nullable, Self::DEFAULT_PRECISION);
+ }
if !s.starts_with(serde_utils::TIME::NAME) || s.contains("STAMP") {
return DataTypeInvalidSnafu {
message: "Invalid TIME type. Expected string to start with
'TIME'.",
@@ -1539,6 +1546,9 @@ impl FromStr for TimestampType {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
+ if let Some(nullable) = plain_type_nullable(s, "TIMESTAMP") {
+ return Self::with_nullable(nullable, Self::DEFAULT_PRECISION);
+ }
if !s.starts_with(serde_utils::TIMESTAMP::NAME)
|| s.starts_with("TIMESTAMP_LTZ")
|| s.contains("WITH LOCAL TIME ZONE")
@@ -2921,6 +2931,40 @@ mod tests {
}
}
+ #[test]
+ fn test_atomic_defaults_and_integer_alias_match_java() {
+ let cases = [
+ ("INTEGER", DataType::Int(IntType::new())),
+ ("DECIMAL", DataType::Decimal(DecimalType::default())),
+ ("TIME", DataType::Time(TimeType::default())),
+ ("TIMESTAMP", DataType::Timestamp(TimestampType::default())),
+ (
+ "TIMESTAMP_LTZ",
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::default()),
+ ),
+ (
+ "TIMESTAMP WITH LOCAL TIME ZONE",
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::default()),
+ ),
+ ];
+ for (name, expected) in cases {
+ for nullable in [true, false] {
+ let input = format!("{name}{}", if nullable { "" } else { "
NOT NULL" });
+ let parsed: DataType =
serde_json::from_value(serde_json::json!(input)).unwrap();
+ assert_eq!(
+ parsed,
+ expected.copy_with_nullable(nullable).unwrap(),
+ "{input}"
+ );
+ assert_eq!(
+
serde_json::from_str::<DataType>(&serde_json::to_string(&parsed).unwrap())
+ .unwrap(),
+ parsed
+ );
+ }
+ }
+ }
+
#[test]
fn test_string_type_serializes_like_java() {
let nullable = DataType::VarChar(VarCharType::string_type());
diff --git a/crates/paimon/src/table/audit_log_table/merge.rs
b/crates/paimon/src/table/audit_log_table/merge.rs
index 801fa5cc..b036124c 100644
--- a/crates/paimon/src/table/audit_log_table/merge.rs
+++ b/crates/paimon/src/table/audit_log_table/merge.rs
@@ -21,7 +21,6 @@ use super::super::sort_merge::{
compare_sequence_order, AggregateMergeFunction, BufferedBatch,
MergeFunction, MergeResult,
MergeRow, PartialUpdateMergeFunction,
};
-use crate::arrow::build_target_arrow_schema;
use crate::spec::{
CoreOptions, DataField, MergeEngine, RowKind, SEQUENCE_NUMBER_FIELD_ID,
VALUE_KIND_FIELD_ID,
};
@@ -76,7 +75,7 @@ pub(in crate::table) fn new_merge_function(
Ok(Box::new(AuditValueMergeFunction {
inner,
value_projection,
- value_schema: build_target_arrow_schema(&value_fields)?,
+ value_schema: crate::arrow::build_read_arrow_schema(&value_fields)?,
fields: fields.to_vec(),
}))
}
diff --git a/crates/paimon/src/table/audit_log_table/read.rs
b/crates/paimon/src/table/audit_log_table/read.rs
index 6efce91a..c4bf56b1 100644
--- a/crates/paimon/src/table/audit_log_table/read.rs
+++ b/crates/paimon/src/table/audit_log_table/read.rs
@@ -21,7 +21,6 @@ use super::{
audit_sequence_number_enabled, rowkind_array_from_column, PaimonTableRead,
TableRead,
TableReadKind, MAX_MERGE_INPUT_STREAMS,
};
-use crate::arrow::build_target_arrow_schema;
use crate::spec::{
DataField, DataType, MergeEngine, TinyIntType, ROW_KIND_FIELD_ID,
SEQUENCE_NUMBER_FIELD_ID,
VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
@@ -79,7 +78,7 @@ impl<'a> AuditLogRead<'a> {
source: None,
});
}
- let audit_schema = build_target_arrow_schema(&output_read_type)?;
+ let audit_schema =
crate::arrow::build_read_arrow_schema(&output_read_type)?;
let mut read_type: Vec<_> = output_read_type
.iter()
.filter(|field| field.id() != ROW_KIND_FIELD_ID)
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index a29c9241..411bcb57 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -26,7 +26,7 @@ use super::read_limit::take_limited_batch;
use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
use crate::arrow::format::FilePredicates;
use crate::arrow::format::MosaicPrefetchOptions;
-use crate::arrow::{build_target_arrow_schema, ReadBudget};
+use crate::arrow::ReadBudget;
use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
use crate::file_index::evaluator::evaluate_file_index;
use crate::file_index::file_index_result::FileIndexResult;
@@ -163,7 +163,7 @@ impl DataEvolutionReader {
.filter(|f| f.name() != ROW_ID_FIELD_NAME)
.cloned()
.collect();
- let output_schema = build_target_arrow_schema(&read_type)?;
+ let output_schema = crate::arrow::build_read_arrow_schema(&read_type)?;
// Widen the file read set with predicate columns not already projected
// so the residual filter can evaluate every leaf. Extras land strictly
@@ -197,7 +197,7 @@ impl DataEvolutionReader {
}
None => None,
};
- let wide_output_schema = build_target_arrow_schema(&wide_read_type)?;
+ let wide_output_schema =
crate::arrow::build_read_arrow_schema(&wide_read_type)?;
Ok(Self {
file_io,
@@ -996,7 +996,7 @@ impl DataEvolutionReader {
// Match the input cap so a LIMIT cannot resolve payloads for rows that
// are only going to be discarded from the merge output.
let merge_batch_size = batch_size.unwrap_or(1024).clamp(1, 1024);
- let target_schema = build_target_arrow_schema(&read_type)?;
+ let target_schema = crate::arrow::build_read_arrow_schema(&read_type)?;
Ok(try_stream! {
let (file_infos, file_schema_nested_enabled) = load_file_infos(
@@ -1931,7 +1931,7 @@ fn read_vector_bunch_with_null_gaps_stream(
}
}
- let target_schema = build_target_arrow_schema(read_fields)?;
+ let target_schema = crate::arrow::build_read_arrow_schema(read_fields)?;
let null_batch_size = batch_size.unwrap_or(1024).max(1);
let split = split.clone();
@@ -7744,7 +7744,8 @@ mod tests {
None,
);
- let row_schema =
build_target_arrow_schema(std::slice::from_ref(&id_field)).unwrap();
+ let row_schema =
+
crate::arrow::build_target_arrow_schema(std::slice::from_ref(&id_field)).unwrap();
let batch = RecordBatch::try_new(
row_schema.clone(),
vec![Arc::new(Int32Array::from(vec![1, 2]))],
diff --git a/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
b/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
index a650ab20..5b71a91d 100644
--- a/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
+++ b/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
@@ -19,6 +19,7 @@ use super::{
blob_file_row_range, row_range_overlaps_any,
selected_absolute_row_ranges_for_file, BlobBunch,
DeletionVectorContext,
};
+#[cfg(test)]
use crate::arrow::build_target_arrow_schema;
use crate::arrow::format::blob::{
build_blob_array_batch, build_blob_batch, build_blob_map_batch,
BlobFieldKind, BlobReadValue,
@@ -164,7 +165,7 @@ pub(super) fn read(
});
}
- let target_schema = build_target_arrow_schema(&read_fields)?;
+ let target_schema = crate::arrow::build_read_arrow_schema(&read_fields)?;
let field_kind = match read_fields[0].data_type() {
DataType::Blob(_) => BlobFieldKind::Scalar,
DataType::Array(_) => BlobFieldKind::Array,
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index d1425d16..b97f051a 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+#[cfg(test)]
use crate::arrow::build_target_arrow_schema;
use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
use crate::arrow::format::{
@@ -617,7 +618,7 @@ impl DataFileReader {
let mosaic_prefetch = self.mosaic_prefetch;
let read_timing = self.read_timing.clone();
- let target_schema = build_target_arrow_schema(&read_type)?;
+ let target_schema = crate::arrow::build_read_arrow_schema(&read_type)?;
let file_fields = data_fields.clone().unwrap_or_else(||
table_fields.clone());
let data_schema_fields = data_schema_fields_for_file(
&file_fields,
@@ -954,7 +955,7 @@ impl DataFileReader {
let table_options = Arc::clone(&self.table_options);
let mosaic_prefetch = self.mosaic_prefetch;
- let target_schema = build_target_arrow_schema(&read_type)?;
+ let target_schema = crate::arrow::build_read_arrow_schema(&read_type)?;
let file_fields = data_fields.clone().unwrap_or_else(||
table_fields.clone());
let data_schema_fields =
data_schema_fields_for_file(&file_fields,
file_meta.write_cols.as_deref(), None)?;
diff --git a/crates/paimon/src/table/format_table_read.rs
b/crates/paimon/src/table/format_table_read.rs
index 3eb95274..e8a0fea4 100644
--- a/crates/paimon/src/table/format_table_read.rs
+++ b/crates/paimon/src/table/format_table_read.rs
@@ -23,7 +23,7 @@ use super::table_read::{configured_mosaic_prefetch,
configured_parquet_read_budg
use super::{ArrowRecordBatchStream, Table};
use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
use crate::arrow::partition::partition_array;
-use crate::arrow::{build_target_arrow_schema, ReadBudget};
+use crate::arrow::ReadBudget;
use crate::resource::ResourceContext;
use crate::spec::{DataField, Predicate};
use crate::{DataSplit, Error};
@@ -132,7 +132,7 @@ impl<'a> FormatTableRead<'a> {
// `TableRead` is public and can be constructed and filtered directly.
super::row_id_predicate::reject_row_id_filter(&self.data_predicates,
"format tables")?;
let read_type = self.read_type.clone();
- let output_schema = build_target_arrow_schema(&read_type)?;
+ let output_schema = crate::arrow::build_read_arrow_schema(&read_type)?;
let partition_keys = self.table.schema().partition_keys().to_vec();
let partition_fields = self.table.schema().partition_fields();
let (data_read_type, data_columns, partition_columns) =
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index f50585d6..44c9a07a 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -32,7 +32,7 @@ use super::sort_merge::{
};
use crate::arrow::format::MosaicPrefetchOptions;
use crate::arrow::shredding::variant::assemble_variant_extraction_array;
-use crate::arrow::{build_target_arrow_schema, ReadBudget};
+use crate::arrow::ReadBudget;
use crate::deletion_vector::DeletionVectorFactory;
use crate::file_index::evaluator::evaluate_file_index;
use crate::file_index::file_index_result::FileIndexResult;
@@ -524,10 +524,10 @@ impl KeyValueFileReader {
internal_read_type.push(value_kind_field);
internal_read_type.extend(user_fields.clone());
- let internal_schema = build_target_arrow_schema(&internal_read_type)?;
+ let internal_schema =
crate::arrow::build_read_arrow_schema(&internal_read_type)?;
// Output schema: user's read_type order
- let output_schema = build_target_arrow_schema(&self.config.read_type)?;
+ let output_schema =
crate::arrow::build_read_arrow_schema(&self.config.read_type)?;
// Indices within internal_schema (offset 2 for _SEQ and _VK).
let seq_index = 0;
@@ -628,7 +628,7 @@ impl KeyValueFileReader {
let mut merge_output_fields: Vec<DataField> = Vec::new();
merge_output_fields.extend(key_fields);
merge_output_fields.extend(value_fields);
- let merge_output_schema =
build_target_arrow_schema(&merge_output_fields)?;
+ let merge_output_schema =
crate::arrow::build_read_arrow_schema(&merge_output_fields)?;
Ok(try_stream! {
for split_group in &split_groups {
@@ -838,7 +838,10 @@ impl KeyValueFileReader {
match config.read_type[out_idx].data_type() {
PaimonDataType::Row(row) if
is_variant_extraction_row(row) =>
{
-
assemble_variant_extraction_array(column.as_ref(), row)
+ let assembled =
assemble_variant_extraction_array(column.as_ref(), row)?;
+
crate::arrow::nested_evolution::evolve_column(
+ &assembled,
config.read_type[out_idx].data_type(), config.read_type[out_idx].data_type(),
+ )
}
_ => {
let source =
merge_read_type.iter().find(|field| field.id() ==
config.read_type[out_idx].id()).unwrap();
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index bd8f2cd5..92d5b7c9 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -175,6 +175,7 @@ pub(crate) mod vector_search_result;
mod vector_search_test_utils;
mod vindex_index_build_builder;
mod write_batch_normalize;
+pub use write_batch_normalize::normalize_write_array;
mod write_builder;
use crate::Result;
diff --git a/crates/paimon/src/table/query_auth/rules.rs
b/crates/paimon/src/table/query_auth/rules.rs
index 98187099..83387330 100644
--- a/crates/paimon/src/table/query_auth/rules.rs
+++ b/crates/paimon/src/table/query_auth/rules.rs
@@ -1475,18 +1475,15 @@ mod tests {
field(4, "b",
DataType::VarBinary(VarBinaryType::new(10).unwrap())),
field(5, "i", DataType::Int(IntType::new())),
];
- for (target, mask) in [
- ("ts", cast("i", "TIMESTAMP(3)")),
- ("d", cast("b", "DOUBLE")),
- ] {
- let message = refusal(parse(&[], &[(target, &mask)], &fields));
- assert!(
- message.contains("existing Python/Java scalar"),
- "{mask}: {message}"
- );
- }
+ let mask = cast("b", "DOUBLE");
+ let message = refusal(parse(&[], &[("d", &mask)], &fields));
+ assert!(
+ message.contains("existing Python/Java scalar"),
+ "{mask}: {message}"
+ );
for (target, mask) in [
("n", cast("s", "BIGINT")),
+ ("ts", cast("i", "TIMESTAMP(3)")),
("ts", cast("n", "TIMESTAMP(3)")),
("s", cast("d", "VARCHAR(10)")),
("s", cast("b", "VARCHAR(10)")),
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 04d74400..6242a905 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -22,7 +22,6 @@ use super::incremental_scan::{IncrementalPlan,
IncrementalScanMode, IncrementalS
use super::kv_file_reader::{KeyValueFileReader, KeyValueReadConfig};
use super::read_builder::split_scan_predicates;
use super::{ArrowRecordBatchStream, Table};
-use crate::arrow::build_target_arrow_schema;
use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
use crate::arrow::format::MosaicPrefetchOptions;
use crate::arrow::ReadBudget;
@@ -852,7 +851,7 @@ impl<'a> PaimonTableRead<'a> {
let key_indices = primary_key_indices(self.table, &diff_read_type)?;
ensure_diff_supported_read_type(&diff_read_type, &key_indices)?;
let value_indices = value_indices_for_diff(self.table,
&diff_read_type);
- let output_schema = build_target_arrow_schema(&self.read_type)?;
+ let output_schema =
crate::arrow::build_read_arrow_schema(&self.read_type)?;
let output_col_indices = self
.read_type
.iter()
@@ -1762,7 +1761,7 @@ fn audit_schema_for_read_type(
));
}
fields.extend(read_type.iter().cloned());
- build_target_arrow_schema(&fields)
+ crate::arrow::build_read_arrow_schema(&fields)
}
fn audit_sequence_number_enabled(table: &Table) -> bool {
diff --git a/crates/paimon/src/table/write_batch_normalize.rs
b/crates/paimon/src/table/write_batch_normalize.rs
index 57fce025..ad5966cc 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -32,7 +32,9 @@ use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType, Field};
use std::sync::Arc;
-pub(super) fn normalize_write_array(array: &ArrayRef, expected: &DataType) ->
Result<ArrayRef> {
+/// Normalize equivalent Arrow layouts for writing, validating NOT NULL
children
+/// only in values visible through non-NULL parents.
+pub fn normalize_write_array(array: &ArrayRef, expected: &DataType) ->
Result<ArrayRef> {
normalize_visible_array(array, expected, None)
}