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 4bf77696 [python] Push batched Variant extraction into native reads
(#1009)
4bf77696 is described below
commit 4bf7769613e565e393b3b54d60a8a8eb549aef0b
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Oct 4 22:23:21 2026 +0800
[python] Push batched Variant extraction into native reads (#1009)
---
.../python/python/pypaimon_rust/datafusion.pyi | 4 +
bindings/python/src/read.rs | 63 ++++-
bindings/python/src/schema.rs | 4 +
bindings/python/tests/test_read.py | 175 ++++++++++++
.../datafusion/src/variant_pushdown.rs | 8 +-
.../datafusion/tests/variant_pushdown.rs | 42 ++-
crates/paimon/src/arrow/shredding/variant.rs | 302 ++++++++++++++++++++-
crates/paimon/src/arrow/variant.rs | 42 ++-
crates/paimon/src/spec/variant_metadata.rs | 115 ++++++--
crates/paimon/src/table/data_file_reader.rs | 19 +-
crates/paimon/src/table/kv_file_reader.rs | 270 +++++++++++++++++-
crates/paimon/src/variant.rs | 103 ++++++-
crates/paimon/src/variant/numeric.rs | 65 ++++-
13 files changed, 1138 insertions(+), 74 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 5b94ab35..31e4120f 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -28,6 +28,7 @@ InputFieldsLike: TypeAlias = Union[ArrowTypeLike,
Sequence[ArrowTypeLike]]
VolatilityLike: TypeAlias = Union[str, Any]
class DataField:
+ def id(self) -> int: ...
def name(self) -> str: ...
def field_type(self) -> str: ...
def is_nullable(self) -> bool: ...
@@ -87,6 +88,9 @@ class TableRead:
def read(self, splits: Sequence[Split]) -> List[pyarrow.RecordBatch]: ...
class ReadBuilder:
+ def with_read_type(self, read_type_json: str) -> "ReadBuilder":
+ """Set a complete Paimon ROW read type serialized as schema JSON."""
+ ...
def with_projection(self, columns: List[str]) -> "ReadBuilder": ...
def with_nested_projection(self, paths: List[List[str]]) -> "ReadBuilder":
"""Project top-level fields or nested ROW leaves by exact name paths.
A MAP
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 9a25b7fd..53e88384 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex};
use arrow::pyarrow::ToPyArrow;
use arrow::record_batch::RecordBatch;
use futures::TryStreamExt;
-use paimon::spec::{DataField, DataType, Predicate, RowType};
+use paimon::spec::{is_variant_extraction_row, DataField, DataType, Predicate,
RowType};
use paimon::table::{ArrowRecordBatchStream, DataSplit, IncrementalScanMode,
RowRange, Table};
use paimon_datafusion::runtime::runtime;
use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
@@ -36,6 +36,11 @@ use crate::predicate::dict_to_table_predicate;
const MAP_SELECTED_KEYS_PREFIX: &str = "__PAIMON_MAP_SELECTED_KEYS:";
const MAP_SELECTED_KEYS_DELIMITER: char = ';';
+struct PyReadProjection<'a> {
+ columns: &'a Option<Vec<String>>,
+ read_type: &'a Option<Vec<DataField>>,
+}
+
/// Time-travel selector option names, in the core's resolution priority order.
const TIME_TRAVEL_SELECTORS: [&str; 6] = [
"scan.timestamp-millis",
@@ -73,17 +78,16 @@ fn find_time_travel_selector(opts: &HashMap<String,
String>) -> Option<(&str, &s
/// Apply common scan/read config onto a core ReadBuilder.
fn apply_read_config(
builder: &mut paimon::table::ReadBuilder<'_>,
- projection: &Option<Vec<String>>,
- read_type: &Option<Vec<DataField>>,
+ projection: PyReadProjection<'_>,
limit: Option<usize>,
filter: &Option<Predicate>,
case_sensitive: bool,
) -> PyResult<()> {
builder.with_case_sensitive(case_sensitive);
- if let Some(read_type) = read_type {
+ if let Some(read_type) = projection.read_type {
builder.with_read_type(read_type.clone());
- } else if let Some(projection) = projection {
- let cols: Vec<&str> = projection.iter().map(String::as_str).collect();
+ } else if let Some(columns) = projection.columns {
+ let cols: Vec<&str> = columns.iter().map(String::as_str).collect();
builder.with_projection(&cols).map_err(to_py_err)?;
}
if let Some(limit) = limit {
@@ -293,12 +297,47 @@ impl PyReadBuilder {
#[pymethods]
impl PyReadBuilder {
+ /// Project top-level columns by name.
fn with_projection(mut slf: PyRefMut<'_, Self>, columns: Vec<String>) ->
PyRefMut<'_, Self> {
slf.projection = Some(columns);
slf.read_type = None;
slf
}
+ /// Set the complete Paimon ROW read type, including nested field metadata.
+ /// The JSON representation is the same as RowType in a Paimon schema.
+ fn with_read_type<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ read_type_json: &str,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ let read_type: RowType = serde_json::from_str(read_type_json)
+ .map_err(|e| PyValueError::new_err(format!("invalid Paimon read
type: {e}")))?;
+ for field in read_type.fields() {
+ let source = slf
+ .table
+ .schema()
+ .fields()
+ .iter()
+ .find(|source| source.id() == field.id());
+ if let (Some(source), DataType::Row(row)) = (source,
field.data_type()) {
+ if matches!(source.data_type(), DataType::Variant(_))
+ && is_variant_extraction_row(row)
+ && row
+ .fields()
+ .iter()
+ .any(|child| !matches!(child.data_type(),
DataType::Float(_)))
+ {
+ return Err(PyValueError::new_err(
+ "Variant extraction target type must be float32",
+ ));
+ }
+ }
+ }
+ slf.read_type = Some(read_type.fields().to_vec());
+ slf.projection = None;
+ Ok(slf)
+ }
+
/// Project top-level fields or nested ROW leaves by their exact name
paths.
/// MAP paths keep the complete MAP so the caller can extract literal keys.
fn with_nested_projection(
@@ -551,8 +590,10 @@ impl PyTableScan {
let mut builder = self.table.new_read_builder();
apply_read_config(
&mut builder,
- &self.projection,
- &self.read_type,
+ PyReadProjection {
+ columns: &self.projection,
+ read_type: &self.read_type,
+ },
self.limit,
&self.filter,
self.case_sensitive,
@@ -665,8 +706,10 @@ impl PyTableRead {
let mut builder = self.table.new_read_builder();
apply_read_config(
&mut builder,
- &self.projection,
- &self.read_type,
+ PyReadProjection {
+ columns: &self.projection,
+ read_type: &self.read_type,
+ },
self.limit,
&self.filter,
self.case_sensitive,
diff --git a/bindings/python/src/schema.rs b/bindings/python/src/schema.rs
index 3783e3a1..a40eeb06 100644
--- a/bindings/python/src/schema.rs
+++ b/bindings/python/src/schema.rs
@@ -72,6 +72,10 @@ impl PyDataField {
#[pymethods]
impl PyDataField {
+ fn id(&self) -> i32 {
+ self.inner.id()
+ }
+
fn name(&self) -> String {
self.inner.name().to_string()
}
diff --git a/bindings/python/tests/test_read.py
b/bindings/python/tests/test_read.py
index 17721de7..6d72e482 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -35,6 +35,48 @@ def _make_table_with_data(warehouse):
return catalog.get_table("rdb.t")
+def _make_variant_table_with_data(warehouse):
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.vdb")
+ ctx.sql("CREATE TABLE paimon.vdb.t (id INT, payload VARIANT)")
+ ctx.sql(
+ """
+ INSERT INTO paimon.vdb.t
+ SELECT 1, parse_json('{"age":27,"ratio":1.25}')
+ UNION ALL
+ SELECT 2, parse_json('{"age":"32","ratio":"invalid"}')
+ """
+ )
+ return PaimonCatalog({"warehouse": warehouse}).get_table("vdb.t")
+
+
+def _variant_read_type(table, columns, paths, fail_on_error=False):
+ fields = []
+ by_name = {field.name(): field for field in table.schema().fields()}
+ for name in columns:
+ field = by_name[name]
+ data_type = field.field_type()
+ if name == "payload":
+ data_type = {
+ "type": "ROW",
+ "fields": [
+ {
+ "id": index,
+ "name": str(index),
+ "type": "FLOAT",
+ "description": (
+ "__VARIANT_METADATA%s;%s;UTC"
+ % (path, str(fail_on_error).lower())
+ ),
+ }
+ for index, path in enumerate(paths)
+ ],
+ }
+ fields.append({"id": field.id(), "name": name, "type": data_type})
+ return json.dumps({"type": "ROW", "fields": fields})
+
+
def test_read_builder_chain_exists():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
@@ -59,6 +101,139 @@ def test_with_projection():
assert plan is not None
+def test_with_read_type_plain_projection():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ field = next(field for field in table.schema().fields() if
field.name() == "id")
+ read_type = json.dumps({
+ "type": "ROW",
+ "fields": [{
+ "id": field.id(), "name": field.name(),
+ "type": field.field_type(),
+ }],
+ })
+ builder = table.new_read_builder().with_read_type(read_type)
+ plan = builder.new_scan().plan()
+ result = pa.Table.from_batches(builder.new_read().read(plan.splits()))
+ assert result.schema.names == ["id"]
+ assert sorted(result.column("id").to_pylist()) == [1, 2, 3]
+
+
+def test_with_read_type_extracts_variant_fields():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_variant_table_with_data(warehouse)
+ builder = table.new_read_builder().with_read_type(_variant_read_type(
+ table, ["id", "payload"], ["$.ratio", "$.age", "$.missing"]))
+ plan = builder.new_scan().plan()
+ result =
pa.Table.from_batches(builder.new_read().read(plan.splits())).sort_by("id")
+
+ assert result.schema.names == ["id", "payload"]
+ assert result.field("payload").type == pa.struct(
+ [
+ pa.field("0", pa.float32()),
+ pa.field("1", pa.float32()),
+ pa.field("2", pa.float32()),
+ ]
+ )
+ assert result.column("payload").combine_chunks().to_pylist() == [
+ {"0": 1.25, "1": 27.0, "2": None},
+ {"0": None, "1": 32.0, "2": None},
+ ]
+
+
+def test_with_read_type_variant_fields_honors_fail_on_error():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_variant_table_with_data(warehouse)
+ builder = table.new_read_builder().with_read_type(_variant_read_type(
+ table, ["payload"], ["$.ratio"], fail_on_error=True))
+ plan = builder.new_scan().plan()
+ with pytest.raises(ValueError, match="Cannot cast Variant value to"):
+ builder.new_read().read(plan.splits())
+
+
+def test_with_read_type_variant_float_whitespace_matches_java():
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.vdb")
+ ctx.sql("CREATE TABLE paimon.vdb.t (id INT, payload VARIANT)")
+ ascii_value = json.dumps({"x": " 1.5 "})
+ nbsp_value = json.dumps({"x": "\u00a01.5\u00a0"}, ensure_ascii=False)
+ ctx.sql(
+ "INSERT INTO paimon.vdb.t "
+ "SELECT 1, parse_json('%s') UNION ALL "
+ "SELECT 2, parse_json('%s')" % (ascii_value, nbsp_value)
+ )
+ table = PaimonCatalog({"warehouse": warehouse}).get_table("vdb.t")
+
+ builder = table.new_read_builder().with_read_type(_variant_read_type(
+ table, ["id", "payload"], ["$.x"]))
+ plan = builder.new_scan().plan()
+ result = pa.Table.from_batches(
+ builder.new_read().read(plan.splits())
+ ).sort_by("id")
+ assert result.column("payload").combine_chunks().to_pylist() == [
+ {"0": 1.5}, {"0": None},
+ ]
+
+ strict = table.new_read_builder().with_read_type(_variant_read_type(
+ table, ["payload"], ["$.x"], fail_on_error=True))
+ with pytest.raises(ValueError, match="Cannot cast Variant value to"):
+ strict.new_read().read(strict.new_scan().plan().splits())
+
+
+def test_with_read_type_variant_fields_keeps_explicit_null():
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.vdb")
+ ctx.sql("CREATE TABLE paimon.vdb.t (id INT, payload VARIANT)")
+ ctx.sql("INSERT INTO paimon.vdb.t SELECT 1,
parse_json('{\"x\":null}')")
+ table = PaimonCatalog({"warehouse": warehouse}).get_table("vdb.t")
+ builder = table.new_read_builder().with_read_type(_variant_read_type(
+ table, ["payload"], ["$.x"], fail_on_error=True))
+ plan = builder.new_scan().plan()
+ result = pa.Table.from_batches(builder.new_read().read(plan.splits()))
+ assert result.column("payload").combine_chunks().to_pylist() == [{"0":
None}]
+
+
+def test_with_read_type_rejects_invalid_schema():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_variant_table_with_data(warehouse)
+ with pytest.raises(ValueError, match="invalid Paimon read type"):
+ table.new_read_builder().with_read_type("not JSON")
+
+
+def test_with_read_type_rejects_unsupported_variant_target():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_variant_table_with_data(warehouse)
+ read_type = json.loads(_variant_read_type(table, ["payload"],
["$.age"]))
+ read_type["fields"][0]["type"]["fields"][0]["type"] = "INT"
+ with pytest.raises(ValueError, match="must be float32"):
+ table.new_read_builder().with_read_type(json.dumps(read_type))
+
+
+def test_with_nested_projection_replaces_read_type():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_variant_table_with_data(warehouse)
+ builder = table.new_read_builder().with_read_type(_variant_read_type(
+ table, ["payload"], ["$.age"])).with_nested_projection([["id"]])
+ plan = builder.new_scan().plan()
+ result = pa.Table.from_batches(builder.new_read().read(plan.splits()))
+ assert result.schema.names == ["id"]
+ assert sorted(result.column("id").to_pylist()) == [1, 2]
+
+
+def test_with_projection_replaces_read_type():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_variant_table_with_data(warehouse)
+ builder = table.new_read_builder().with_read_type(_variant_read_type(
+ table, ["payload"], ["$.age"])).with_projection(["id"])
+ plan = builder.new_scan().plan()
+ result = pa.Table.from_batches(builder.new_read().read(plan.splits()))
+ assert result.schema.names == ["id"]
+
+
def test_with_limit():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
diff --git a/crates/integrations/datafusion/src/variant_pushdown.rs
b/crates/integrations/datafusion/src/variant_pushdown.rs
index ba3c7cb1..c0a79bf1 100644
--- a/crates/integrations/datafusion/src/variant_pushdown.rs
+++ b/crates/integrations/datafusion/src/variant_pushdown.rs
@@ -347,7 +347,8 @@ fn build_projection_rewrite(
"UTC".to_string(),
)
}),
- );
+ )
+ .map_err(to_datafusion_error)?;
read_type.push(
DataField::new(
field.id(),
@@ -541,6 +542,11 @@ fn parse_variant_get(expr: &Expr) ->
DFResult<VariantGetParse> {
let Some(type_name) = func.args.get(2).and_then(string_literal) else {
return Ok(VariantGetParse::FullVariant(column.clone()));
};
+ // Java's Variant field metadata uses ';' as a delimiter. Keep the full
+ // column so the original expression can handle paths containing it.
+ if path.contains(';') {
+ return Ok(VariantGetParse::FullVariant(column.clone()));
+ }
let Some(data_type) = paimon_type_for_variant_get(&type_name)? else {
return Ok(VariantGetParse::FullVariant(column.clone()));
};
diff --git a/crates/integrations/datafusion/tests/variant_pushdown.rs
b/crates/integrations/datafusion/tests/variant_pushdown.rs
index 2fb60653..bb8b50e7 100644
--- a/crates/integrations/datafusion/tests/variant_pushdown.rs
+++ b/crates/integrations/datafusion/tests/variant_pushdown.rs
@@ -334,7 +334,7 @@ async fn
try_variant_get_invalid_path_pushdown_returns_null() {
}
#[tokio::test]
-async fn variant_get_path_with_semicolon_pushes_extraction() {
+async fn variant_get_path_with_semicolon_falls_back() {
let (_tmp, sql_context) = setup_shredded_variant_table_with_rows().await;
let sql = r#"
SELECT variant_get(payload, '$.a;b', 'int') AS value
@@ -346,8 +346,8 @@ async fn
variant_get_path_with_semicolon_pushes_extraction() {
let plan = df.create_physical_plan().await.unwrap();
let plan_text = displayable(plan.as_ref()).indent(true).to_string();
assert!(
- plan_text.contains("PushedVariants=[payload=[$.a;b]]"),
- "plan should push semicolon-path variant extraction, got:\n{plan_text}"
+ !plan_text.contains("PushedVariants="),
+ "plan should not push an unencodable semicolon path, got:\n{plan_text}"
);
let batches = sql_context.sql(sql).await.unwrap().collect().await.unwrap();
@@ -360,3 +360,39 @@ async fn
variant_get_path_with_semicolon_pushes_extraction() {
.unwrap();
assert_eq!(values.values(), &[11, 22]);
}
+
+#[tokio::test]
+async fn variant_get_mixed_paths_with_semicolon_keep_full_column() {
+ let (_tmp, sql_context) = setup_shredded_variant_table_with_rows().await;
+ let sql = r#"
+ SELECT variant_get(payload, '$.age', 'int') AS age,
+ variant_get(payload, '$.a;b', 'int') AS value
+ FROM paimon.test_db.t
+ ORDER BY id
+ "#;
+
+ let df = sql_context.sql(sql).await.unwrap();
+ let plan = df.create_physical_plan().await.unwrap();
+ let plan_text = displayable(plan.as_ref()).indent(true).to_string();
+ assert!(
+ !plan_text.contains("PushedVariants="),
+ "plan should keep the full Variant column for mixed paths,
got:\n{plan_text}"
+ );
+
+ let batches = sql_context.sql(sql).await.unwrap().collect().await.unwrap();
+ assert_eq!(batches.len(), 1);
+ let ages = batches[0]
+ .column_by_name("age")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let values = batches[0]
+ .column_by_name("value")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ assert_eq!(ages.values(), &[27, 32]);
+ assert_eq!(values.values(), &[11, 22]);
+}
diff --git a/crates/paimon/src/arrow/shredding/variant.rs
b/crates/paimon/src/arrow/shredding/variant.rs
index e71774ce..9f75d406 100644
--- a/crates/paimon/src/arrow/shredding/variant.rs
+++ b/crates/paimon/src/arrow/shredding/variant.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+use super::super::variant::{extract_numeric_fields, NumericFieldMode};
use super::{
option_bool, option_f64, option_usize, FieldMetadata, ShreddingWritePlan,
ShreddingWritePlanFactory,
@@ -27,9 +28,10 @@ use crate::spec::{
};
use crate::variant::{
build_variant_schema, cast_shredded, cast_variant_to_shredded_value,
- infer_variant_shredding_schema, rebuild_shredded, variant_shredding_type,
GenericVariant,
- ShreddedRow, ShreddedValue, VariantShreddingInferConfig,
VariantShreddingInferenceSession,
- VARIANT_METADATA_FIELD_NAME, VARIANT_TYPED_VALUE_FIELD_NAME,
VARIANT_VALUE_FIELD_NAME,
+ infer_variant_shredding_schema, rebuild_shredded, top_level_path_key,
variant_shredding_type,
+ GenericVariant, ShreddedRow, ShreddedValue, VariantShreddingInferConfig,
+ VariantShreddingInferenceSession, VARIANT_METADATA_FIELD_NAME,
VARIANT_TYPED_VALUE_FIELD_NAME,
+ VARIANT_VALUE_FIELD_NAME,
};
use crate::{Error, Result};
use arrow_array::{
@@ -869,7 +871,11 @@ fn assemble_array_to_logical(
}
}
-fn assemble_variant_extraction_array(array: &dyn Array, row_type: &RowType) ->
Result<ArrayRef> {
+/// Extract projected fields from a logical Variant column after PK merge.
+pub(crate) fn assemble_variant_extraction_array(
+ array: &dyn Array,
+ row_type: &RowType,
+) -> Result<ArrayRef> {
let input = array
.as_any()
.downcast_ref::<StructArray>()
@@ -891,6 +897,10 @@ fn assemble_variant_extraction_array(array: &dyn Array,
row_type: &RowType) -> R
})
.collect::<Result<Vec<_>>>()?;
+ if let Some(projected) = assemble_plain_variant_projection(input, fields,
&metadata)? {
+ return Ok(projected);
+ }
+
let mut values_by_field = vec![Vec::with_capacity(input.len());
fields.len()];
let mut validities = Vec::with_capacity(input.len());
for row in 0..input.len() {
@@ -953,6 +963,78 @@ fn assemble_variant_extraction_array(array: &dyn Array,
row_type: &RowType) -> R
))
}
+fn assemble_plain_variant_projection(
+ input: &StructArray,
+ fields: &[DataField],
+ metadata: &[crate::spec::VariantFieldMetadata],
+) -> Result<Option<ArrayRef>> {
+ if !is_plain_variant_array(input)
+ || fields
+ .iter()
+ .any(|field| !matches!(field.data_type(), DataType::Float(_)))
+ {
+ return Ok(None);
+ }
+
+ let mut keys = Vec::with_capacity(fields.len());
+ let mut fail_on_error = Vec::with_capacity(fields.len());
+ for field_metadata in metadata {
+ let Ok(Some(key)) = top_level_path_key(field_metadata.path()) else {
+ return Ok(None);
+ };
+ keys.push(key);
+ fail_on_error.push(field_metadata.fail_on_error());
+ }
+
+ let projected = extract_numeric_fields(
+ input,
+ &keys,
+ NumericFieldMode::Cast {
+ fail_on_error: &fail_on_error,
+ },
+ )?;
+ let values = projected
+ .values()
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .expect("float32 Variant projection must contain Float32 values");
+ let width = fields.len();
+ let columns = (0..width)
+ .map(|field_index| {
+ Arc::new(Float32Array::from_iter((0..projected.len()).map(
+ |row_index| {
+ let value_index = row_index * width + field_index;
+ if values.is_null(value_index) {
+ None
+ } else {
+ Some(values.value(value_index))
+ }
+ },
+ ))) as ArrayRef
+ })
+ .collect::<Vec<_>>();
+ let arrow_fields: Fields = fields
+ .iter()
+ .map(|field| {
+ ArrowField::new(
+ field.name(),
+ ArrowDataType::Float32,
+ field.data_type().is_nullable(),
+ )
+ })
+ .collect::<Vec<_>>()
+ .into();
+
+ Ok(Some(Arc::new(
+ StructArray::try_new(arrow_fields, columns,
projected.nulls().cloned()).map_err(|e| {
+ Error::UnexpectedError {
+ message: format!("Failed to build Variant float32 projection:
{e}"),
+ source: Some(Box::new(e)),
+ }
+ })?,
+ )))
+}
+
fn variant_from_storage_row(input: &StructArray, row: usize) ->
Result<Option<GenericVariant>> {
if input.is_null(row) {
return Ok(None);
@@ -1507,7 +1589,9 @@ fn null_buffer(validities: Vec<bool>) -> NullBuffer {
mod tests {
use super::*;
use crate::arrow::variant_arrow_type;
- use crate::spec::{variant_extraction_row, BlobType, IntType, VarCharType,
VariantType};
+ use crate::spec::{
+ variant_extraction_row, BlobType, FloatType, IntType, VarCharType,
VariantType,
+ };
#[test]
fn factory_owns_activation_and_configured_schema_precedence() {
@@ -1765,7 +1849,8 @@ mod tests {
"UTC".to_string(),
),
],
- );
+ )
+ .unwrap();
let extracted = assemble_array_to_logical(array.as_ref(),
&DataType::Row(row_type))
.unwrap()
@@ -1790,6 +1875,208 @@ mod tests {
assert!(names.is_null(1));
}
+ #[test]
+ fn variant_extraction_row_batches_top_level_float32_fields() {
+ let variants = vec![
+ Some(
+ GenericVariant::parse_json(
+ r#"{"age":27,"ratio":1.25,"text":"3.5","enabled":true}"#,
+ )
+ .unwrap(),
+ ),
+ Some(
+ GenericVariant::parse_json(
+ r#"{"age":32,"ratio":null,"text":"bad","enabled":false}"#,
+ )
+ .unwrap(),
+ ),
+ None,
+ ];
+ let input = variant_array(variants).unwrap();
+ let row_type = variant_extraction_row(
+ true,
+ [
+ ("$.ratio", false),
+ ("$.age", false),
+ ("$.text", false),
+ ("$.enabled", false),
+ ("$.missing", false),
+ ]
+ .into_iter()
+ .map(|(path, fail_on_error)| {
+ (
+ DataType::Float(FloatType::new()),
+ path.to_string(),
+ fail_on_error,
+ "UTC".to_string(),
+ )
+ }),
+ )
+ .unwrap();
+
+ let projected = assemble_array_to_logical(input.as_ref(),
&DataType::Row(row_type))
+ .unwrap()
+ .expect("projected");
+ let projected =
projected.as_any().downcast_ref::<StructArray>().unwrap();
+ assert_eq!(projected.len(), 3);
+ assert!(projected.is_valid(0));
+ assert!(projected.is_valid(1));
+ assert!(projected.is_null(2));
+ let values = (0..5)
+ .map(|index| {
+ projected
+ .column(index)
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(values[0], vec![Some(1.25), None, None]);
+ assert_eq!(values[1], vec![Some(27.0), Some(32.0), None]);
+ assert_eq!(values[2], vec![Some(3.5), None, None]);
+ assert_eq!(values[3], vec![Some(1.0), Some(0.0), None]);
+ assert_eq!(values[4], vec![None, None, None]);
+ }
+
+ #[test]
+ fn variant_extraction_row_float32_fast_path_honors_fail_on_error() {
+ let variant =
GenericVariant::parse_json(r#"{"value":"bad"}"#).unwrap();
+ let input = variant_array(vec![Some(variant)]).unwrap();
+ let row_type = variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$.value".to_string(),
+ true,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap();
+
+ let error =
+ assemble_array_to_logical(input.as_ref(),
&DataType::Row(row_type)).unwrap_err();
+ assert!(matches!(error, Error::DataInvalid { ref message, .. }
+ if message.contains("Cannot cast Variant value to")));
+ }
+
+ #[test]
+ fn variant_extraction_row_preserves_null_with_fail_on_error() {
+ let variant = GenericVariant::parse_json(r#"{"value":null}"#).unwrap();
+ let input = variant_array(vec![Some(variant)]).unwrap();
+ let row_type = variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$.value".to_string(),
+ true,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap();
+
+ let projected = assemble_array_to_logical(input.as_ref(),
&DataType::Row(row_type))
+ .unwrap()
+ .unwrap();
+ let projected =
projected.as_any().downcast_ref::<StructArray>().unwrap();
+ assert!(projected.is_valid(0));
+ assert!(projected.column(0).is_null(0));
+ }
+
+ #[test]
+ fn invalid_try_path_returns_null_for_plain_and_shredded_variant() {
+ let logical_fields = vec![DataField::new(
+ 1,
+ "v".to_string(),
+ DataType::Variant(VariantType::new()),
+ )];
+ let options = HashMap::from([(
+ "variant.shreddingSchema".to_string(),
+
r#"{"type":"ROW","fields":[{"name":"v","type":{"type":"ROW","fields":[{"name":"age","type":"INT"}]}}]}"#.to_string(),
+ )]);
+ let physical_fields =
configured_variant_shredding_fields(&logical_fields, &options)
+ .unwrap()
+ .unwrap();
+ let variant = GenericVariant::parse_json(r#"{"age":27}"#).unwrap();
+ let plain = variant_array_for_test(&[variant]);
+ let row_type = variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$.".to_string(),
+ false,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap();
+
+ let plain_result =
+ assemble_array_to_logical(plain.as_ref(),
&DataType::Row(row_type.clone()))
+ .unwrap()
+ .unwrap();
+ let plain_result =
plain_result.as_any().downcast_ref::<StructArray>().unwrap();
+ assert!(plain_result.column(0).is_null(0));
+
+ let strict_row_type = variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$.".to_string(),
+ true,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap();
+ assert!(
+ assemble_array_to_logical(plain.as_ref(),
&DataType::Row(strict_row_type)).is_err()
+ );
+
+ let batch = RecordBatch::try_new(
+ build_target_arrow_schema(&logical_fields).unwrap(),
+ vec![plain],
+ )
+ .unwrap();
+ let physical =
+ batch_to_shredded_physical(&batch, &logical_fields,
&physical_fields).unwrap();
+ let read_fields = vec![DataField::new(1, "v".to_string(),
DataType::Row(row_type))];
+ let shredded_result = assemble_shredded_variant_batch(physical,
&read_fields).unwrap();
+ let shredded_result = shredded_result
+ .column_by_name("v")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap();
+ assert!(shredded_result.column(0).is_null(0));
+ }
+
+ #[test]
+ fn nested_float32_variant_extraction_keeps_general_path_semantics() {
+ let variant =
GenericVariant::parse_json(r#"{"pose":{"x":"1.5"}}"#).unwrap();
+ let input = variant_array(vec![Some(variant)]).unwrap();
+ let row_type = variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$.pose.x".to_string(),
+ true,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap();
+
+ let projected = assemble_array_to_logical(input.as_ref(),
&DataType::Row(row_type))
+ .unwrap()
+ .expect("projected");
+ let projected =
projected.as_any().downcast_ref::<StructArray>().unwrap();
+ let values = projected
+ .column(0)
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap();
+ assert_eq!(values.value(0), 1.5);
+ }
+
#[test]
fn variant_extraction_row_reads_shredded_variant_array() {
let logical_fields = vec![DataField::new(
@@ -1831,7 +2118,8 @@ mod tests {
"UTC".to_string(),
),
],
- );
+ )
+ .unwrap();
let read_fields = vec![DataField::new(
1,
"v".to_string(),
diff --git a/crates/paimon/src/arrow/variant.rs
b/crates/paimon/src/arrow/variant.rs
index 9534d86a..07619aa0 100644
--- a/crates/paimon/src/arrow/variant.rs
+++ b/crates/paimon/src/arrow/variant.rs
@@ -33,10 +33,29 @@ use crate::{Error, Result};
pub fn variant_get_numeric_fields(
column: &StructArray,
fields: &[String],
+) -> Result<FixedSizeListArray> {
+ extract_numeric_fields(column, fields, NumericFieldMode::Numeric)
+}
+
+#[derive(Clone, Copy)]
+pub(crate) enum NumericFieldMode<'a> {
+ Numeric,
+ Cast { fail_on_error: &'a [bool] },
+}
+
+pub(crate) fn extract_numeric_fields(
+ column: &StructArray,
+ fields: &[String],
+ mode: NumericFieldMode<'_>,
) -> Result<FixedSizeListArray> {
if fields.is_empty() {
return data_invalid("Variant numeric field list must not be empty");
}
+ if let NumericFieldMode::Cast { fail_on_error } = mode {
+ if fail_on_error.len() != fields.len() {
+ return data_invalid("Variant float32 projection width mismatch");
+ }
+ }
let width = i32::try_from(fields.len()).map_err(|_|
Error::ResourceExhausted {
message: "Variant numeric field count exceeds Arrow
limits".to_string(),
@@ -78,12 +97,23 @@ pub fn variant_get_numeric_fields(
projection = Some(VariantFloat32Projection::new(metadata,
fields)?);
projection_metadata = Some(metadata);
}
- projection.as_mut().unwrap().extract_float32(
- value_column.value(row),
- metadata,
- &mut offsets,
- &mut extracted,
- )?;
+ match mode {
+ NumericFieldMode::Cast { fail_on_error } => {
+ projection.as_mut().unwrap().extract_float32_cast(
+ value_column.value(row),
+ metadata,
+ fail_on_error,
+ &mut offsets,
+ &mut extracted,
+ )?
+ }
+ NumericFieldMode::Numeric =>
projection.as_mut().unwrap().extract_float32(
+ value_column.value(row),
+ metadata,
+ &mut offsets,
+ &mut extracted,
+ )?,
+ }
for value in &extracted {
builder.values().append_option(*value);
}
diff --git a/crates/paimon/src/spec/variant_metadata.rs
b/crates/paimon/src/spec/variant_metadata.rs
index b172931d..02bef4b5 100644
--- a/crates/paimon/src/spec/variant_metadata.rs
+++ b/crates/paimon/src/spec/variant_metadata.rs
@@ -58,11 +58,21 @@ impl VariantFieldMetadata {
}
}
-pub fn build_variant_metadata(path: &str, fail_on_error: bool, time_zone_id:
&str) -> String {
- let metadata = VariantFieldMetadata::new(path, fail_on_error,
time_zone_id);
- let json = serde_json::to_string(&metadata)
- .expect("Variant metadata serialization should not fail for string and
bool fields");
- format!("{VARIANT_METADATA_KEY}{json}")
+pub fn build_variant_metadata(
+ path: &str,
+ fail_on_error: bool,
+ time_zone_id: &str,
+) -> Result<String> {
+ if path.contains(VARIANT_METADATA_DELIMITER) {
+ return Err(Error::ConfigInvalid {
+ message: format!(
+ "Variant extraction path must not contain
'{VARIANT_METADATA_DELIMITER}': {path}"
+ ),
+ });
+ }
+ Ok(format!(
+
"{VARIANT_METADATA_KEY}{path}{VARIANT_METADATA_DELIMITER}{fail_on_error}{VARIANT_METADATA_DELIMITER}{time_zone_id}"
+ ))
}
pub fn parse_variant_metadata(description: &str) ->
Result<VariantFieldMetadata> {
@@ -82,10 +92,10 @@ pub fn parse_variant_metadata(description: &str) ->
Result<VariantFieldMetadata>
return Ok(metadata);
}
- parse_legacy_variant_metadata(raw)
+ parse_java_variant_metadata(raw)
}
-fn parse_legacy_variant_metadata(raw: &str) -> Result<VariantFieldMetadata> {
+fn parse_java_variant_metadata(raw: &str) -> Result<VariantFieldMetadata> {
let mut parts = raw.split(VARIANT_METADATA_DELIMITER);
let path = parts.next().unwrap_or_default();
let fail_on_error = parts.next().ok_or_else(|| Error::DataInvalid {
@@ -147,27 +157,33 @@ pub fn is_variant_extraction_row(row_type: &RowType) ->
bool {
pub fn variant_extraction_row(
nullable: bool,
extractions: impl IntoIterator<Item = (DataType, String, bool, String)>,
-) -> RowType {
+) -> Result<RowType> {
let fields = extractions
.into_iter()
.enumerate()
.map(|(idx, (data_type, path, fail_on_error, time_zone_id))| {
- DataField::new(idx as i32, idx.to_string(),
data_type).with_description(Some(
- build_variant_metadata(&path, fail_on_error, &time_zone_id),
- ))
+ Ok(
+ DataField::new(idx as i32, idx.to_string(),
data_type).with_description(Some(
+ build_variant_metadata(&path, fail_on_error,
&time_zone_id)?,
+ )),
+ )
})
- .collect();
- RowType::with_nullable(nullable, fields)
+ .collect::<Result<Vec<_>>>()?;
+ Ok(RowType::with_nullable(nullable, fields))
}
#[cfg(test)]
mod tests {
use super::*;
- use crate::spec::{DataType, IntType, VarCharType};
+ use crate::spec::{DataType, FloatType, IntType, VarCharType};
+
+ // Serialized by Java RowType.serializeJson with VariantMetadataUtils
descriptions.
+ const JAVA_READ_TYPE_JSON: &str =
r#"{"type":"ROW","fields":[{"id":7,"name":"payload","type":{"type":"ROW","fields":[{"id":0,"name":"0","type":"FLOAT","description":"__VARIANT_METADATA$.x;false;Asia/Shanghai"},{"id":1,"name":"1","type":"FLOAT","description":"__VARIANT_METADATA$.y;true;UTC"}]}}]}"#;
#[test]
fn parses_variant_metadata_description() {
- let description = build_variant_metadata("$.a", false, "UTC");
+ let description = build_variant_metadata("$.a", false, "UTC").unwrap();
+ assert_eq!(description, "__VARIANT_METADATA$.a;false;UTC");
let metadata = parse_variant_metadata(&description).unwrap();
assert_eq!(metadata.path(), "$.a");
assert!(!metadata.fail_on_error());
@@ -175,16 +191,25 @@ mod tests {
}
#[test]
- fn parses_variant_metadata_description_with_delimiters() {
- let description = build_variant_metadata("$.a;b", true, "UTC;8");
- let metadata = parse_variant_metadata(&description).unwrap();
+ fn parses_previous_json_variant_metadata_description() {
+ let description =
+
r#"__VARIANT_METADATA{"path":"$.a;b","failOnError":true,"timeZoneId":"UTC"}"#;
+ let metadata = parse_variant_metadata(description).unwrap();
assert_eq!(metadata.path(), "$.a;b");
assert!(metadata.fail_on_error());
- assert_eq!(metadata.time_zone_id(), "UTC;8");
+ assert_eq!(metadata.time_zone_id(), "UTC");
+ }
+
+ #[test]
+ fn rejects_path_with_java_delimiter() {
+ assert!(build_variant_metadata("$.a;b", true, "UTC")
+ .unwrap_err()
+ .to_string()
+ .contains("must not contain ';'"));
}
#[test]
- fn parses_legacy_variant_metadata_description() {
+ fn parses_java_variant_metadata_description() {
let description = format!("{VARIANT_METADATA_KEY}$.a;false;UTC");
let metadata = parse_variant_metadata(&description).unwrap();
assert_eq!(metadata.path(), "$.a");
@@ -192,6 +217,53 @@ mod tests {
assert_eq!(metadata.time_zone_id(), "UTC");
}
+ #[test]
+ fn java_read_type_json_round_trips_through_rust() {
+ let java_row: RowType =
serde_json::from_str(JAVA_READ_TYPE_JSON).unwrap();
+ let DataType::Row(variant_row) = java_row.fields()[0].data_type() else
{
+ panic!("expected Variant extraction row");
+ };
+ let metadata = variant_row
+ .fields()
+ .iter()
+ .map(|field|
parse_variant_metadata(field.description().unwrap()).unwrap())
+ .collect::<Vec<_>>();
+ assert_eq!(
+ metadata[0],
+ VariantFieldMetadata::new("$.x", false, "Asia/Shanghai")
+ );
+ assert_eq!(metadata[1], VariantFieldMetadata::new("$.y", true, "UTC"));
+
+ let rust_row = RowType::new(vec![DataField::new(
+ 7,
+ "payload".to_string(),
+ DataType::Row(
+ variant_extraction_row(
+ true,
+ [
+ (
+ DataType::Float(FloatType::new()),
+ "$.x".to_string(),
+ false,
+ "Asia/Shanghai".to_string(),
+ ),
+ (
+ DataType::Float(FloatType::new()),
+ "$.y".to_string(),
+ true,
+ "UTC".to_string(),
+ ),
+ ],
+ )
+ .unwrap(),
+ ),
+ )]);
+ assert_eq!(
+ serde_json::to_value(rust_row).unwrap(),
+
serde_json::from_str::<serde_json::Value>(JAVA_READ_TYPE_JSON).unwrap()
+ );
+ }
+
#[test]
fn identifies_variant_extraction_row_type() {
let row = variant_extraction_row(
@@ -210,7 +282,8 @@ mod tests {
"UTC".to_string(),
),
],
- );
+ )
+ .unwrap();
assert!(is_variant_extraction_row(&row));
assert!(is_variant_extraction_row_type(&DataType::Row(row)));
}
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 3347bb12..8d9c5eb0 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -1524,15 +1524,18 @@ mod row_tests {
)]));
let physical_type = variant_shredding_type(&configured).unwrap();
let data_field = field(1, "v", physical_type);
- let extraction_type = DataType::Row(variant_extraction_row(
- true,
- vec![(
- DataType::Int(IntType::new()),
- "$.age".to_string(),
+ let extraction_type = DataType::Row(
+ variant_extraction_row(
true,
- "UTC".to_string(),
- )],
- ));
+ vec![(
+ DataType::Int(IntType::new()),
+ "$.age".to_string(),
+ true,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap(),
+ );
let expected_field = field(1, "v", extraction_type.clone());
let read_fields = read_data_fields(&[data_field], &[expected_field],
false).unwrap();
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index 764d99cb..e36ed774 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -31,15 +31,17 @@ use super::sort_merge::{
PartialUpdateMergeFunction, SortMergeReaderBuilder,
};
use crate::arrow::format::MosaicPrefetchOptions;
+use crate::arrow::shredding::variant::assemble_variant_extraction_array;
use crate::arrow::{build_target_arrow_schema, ReadBudget};
use crate::deletion_vector::DeletionVectorFactory;
use crate::file_index::evaluator::evaluate_file_index;
use crate::file_index::file_index_result::FileIndexResult;
use crate::io::FileIO;
use crate::spec::{
- BigIntType, CoreOptions, DataField, DataFileMeta, DataType as
PaimonDataType, MergeEngine,
- PartialUpdateConfig, Predicate, TinyIntType, SEQUENCE_NUMBER_FIELD_ID,
- SEQUENCE_NUMBER_FIELD_NAME, VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
+ is_variant_extraction_row, is_variant_extraction_row_type, BigIntType,
CoreOptions, DataField,
+ DataFileMeta, DataType as PaimonDataType, MergeEngine,
PartialUpdateConfig, Predicate,
+ TinyIntType, SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME,
VALUE_KIND_FIELD_ID,
+ VALUE_KIND_FIELD_NAME,
};
use crate::table::schema_manager::SchemaManager;
use crate::table::ArrowRecordBatchStream;
@@ -392,6 +394,33 @@ impl KeyValueFileReader {
.map(|s| s.as_str())
.collect();
+ // File readers run before PK deduplication. Keep Variant values in
their
+ // logical storage type until the visible row has been selected: a
strict
+ // extraction must not fail on an older, overwritten version.
+ let merge_read_type: Vec<DataField> = self
+ .config
+ .read_type
+ .iter()
+ .map(|field| {
+ if !is_variant_extraction_row_type(field.data_type()) {
+ return Ok(field.clone());
+ }
+ self.config
+ .table_fields
+ .iter()
+ .find(|source| source.id() == field.id())
+ .filter(|source| matches!(source.data_type(),
PaimonDataType::Variant(_)))
+ .cloned()
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Variant extraction field '{}' has no matching
VARIANT table field",
+ field.name()
+ ),
+ source: None,
+ })
+ })
+ .collect::<crate::Result<_>>()?;
+
// Collect key fields from table schema.
let key_fields: Vec<DataField> = self
.config
@@ -423,10 +452,8 @@ impl KeyValueFileReader {
// + any sequence fields not already included. Physical
system
// fields are already the first two columns of every KV file.
let read_type_names: std::collections::HashSet<&str> =
- self.config.read_type.iter().map(|f| f.name()).collect();
- let mut user_fields: Vec<DataField> = self
- .config
- .read_type
+ merge_read_type.iter().map(|f| f.name()).collect();
+ let mut user_fields: Vec<DataField> = merge_read_type
.iter()
.filter(|field| !matches!(field.id(), SEQUENCE_NUMBER_FIELD_ID |
VALUE_KIND_FIELD_ID))
.cloned()
@@ -545,9 +572,11 @@ impl KeyValueFileReader {
reorder_map[out_idx] = key_pos;
} else {
// Find position in value_fields
+ // The internal field may use the current table name while the
+ // requested output retains a historical name for the same ID.
let val_pos = value_fields
.iter()
- .position(|vf| vf.name() == field.name())
+ .position(|vf| vf.id() == field.id())
.unwrap();
reorder_map[out_idx] = num_keys + val_pos;
}
@@ -781,8 +810,18 @@ impl KeyValueFileReader {
// Reorder columns from [keys..., values...] to
read_type order.
let columns: Vec<_> = reorder_map
.iter()
- .map(|&src| batch.column(src).clone())
- .collect();
+ .enumerate()
+ .map(|(out_idx, &src)| {
+ let column = batch.column(src);
+ 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)
+ }
+ _ => Ok(column.clone()),
+ }
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
// An explicit row count keeps empty projections
working
// (e.g. COUNT(*) reads no columns).
let options =
@@ -808,19 +847,22 @@ impl KeyValueFileReader {
#[cfg(test)]
mod tests {
use super::*;
- use crate::arrow::build_target_arrow_schema;
use crate::arrow::format::create_format_writer;
+ use crate::arrow::{build_target_arrow_schema, variant_arrow_type};
use crate::catalog::Identifier;
use crate::deletion_vector::DeletionVector;
use crate::io::FileIOBuilder;
use crate::spec::{
- stats::BinaryTableStats, BinaryRow, DataFileMeta, DataType, Datum,
IntType,
- PredicateBuilder, Schema, TableSchema, VarCharType,
+ stats::BinaryTableStats, variant_extraction_row, BinaryRow,
DataFileMeta, DataType, Datum,
+ FloatType, IntType, PredicateBuilder, Schema, TableSchema,
VarCharType, VariantType,
};
use crate::table::source::{DataSplitBuilder, DeletionFile};
use crate::table::table_commit::TableCommit;
use crate::table::{Table, TableWrite};
- use arrow_array::{Array, Int32Array, Int64Array, Int8Array, StringArray};
+ use arrow_array::{
+ Array, BinaryArray, Float32Array, Int32Array, Int64Array, Int8Array,
StringArray,
+ StructArray,
+ };
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
use bytes::Bytes;
use futures::TryStreamExt;
@@ -831,6 +873,206 @@ mod tests {
use std::collections::HashMap;
use std::sync::Arc;
+ #[tokio::test]
+ async fn kv_variant_projection_casts_only_visible_rows() {
+ let file_io = test_file_io();
+ let table_path = "memory:/kv_variant_projection_visible_rows";
+ setup_dirs(&file_io, table_path).await;
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Variant(VariantType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "kv_variant_projection_visible_rows"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let batch = |id, json| {
+ let variant =
crate::variant::GenericVariant::parse_json(json).unwrap();
+ let ArrowDataType::Struct(fields) = variant_arrow_type() else {
+ unreachable!()
+ };
+ let payload = StructArray::try_new(
+ fields,
+ vec![
+ Arc::new(BinaryArray::from(vec![Some(variant.value())])),
+
Arc::new(BinaryArray::from(vec![Some(variant.metadata())])),
+ ],
+ None,
+ )
+ .unwrap();
+ RecordBatch::try_new(
+ build_target_arrow_schema(table.schema().fields()).unwrap(),
+ vec![Arc::new(Int32Array::from(vec![id])), Arc::new(payload)],
+ )
+ .unwrap()
+ };
+
+ write_commit(&table, &batch(1, r#"{"x":"invalid"}"#)).await;
+ write_commit(&table, &batch(1, r#"{"x":1.5}"#)).await;
+
+ let mut read_builder = table.new_read_builder();
+ read_builder.with_read_type(vec![
+ table.schema().fields()[0].clone(),
+ DataField::new(
+ 1,
+ "payload".to_string(),
+ DataType::Row(
+ variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$.x".to_string(),
+ true,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap(),
+ ),
+ ),
+ ]);
+ let plan = read_builder.new_scan().plan().await.unwrap();
+ let batches = read_builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(int_column(&batches, "id"), vec![1]);
+ let payload = batches[0]
+ .column_by_name("payload")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap();
+ let x = payload
+ .column(0)
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap();
+ assert_eq!(x.value(0), 1.5);
+
+ // A bad value that survives the merge must still fail strict
projection.
+ write_commit(&table, &batch(1, r#"{"x":"still invalid"}"#)).await;
+ let plan = read_builder.new_scan().plan().await.unwrap();
+ let error = read_builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap_err();
+ assert!(matches!(error, Error::DataInvalid { ref message, .. }
+ if message.contains("Cannot cast Variant value")));
+ }
+
+ #[tokio::test]
+ async fn kv_variant_projection_preserves_requested_historical_field_name()
{
+ let file_io = test_file_io();
+ let table_path = "memory:/kv_variant_projection_renamed_field";
+ setup_dirs(&file_io, table_path).await;
+ let schema = |name: &str| {
+ Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(name, DataType::Variant(VariantType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .build()
+ .unwrap()
+ };
+ let old_schema = TableSchema::new(0, &schema("payload"));
+ let new_schema = TableSchema::new(1, &schema("renamed_payload"));
+ let table = |schema: TableSchema| {
+ Table::new(
+ file_io.clone(),
+ Identifier::new("default",
"kv_variant_projection_renamed_field"),
+ table_path.to_string(),
+ schema,
+ None,
+ )
+ };
+ let old_table = table(old_schema.clone());
+ let new_table = table(new_schema.clone());
+ write_schema_file(&old_table, &old_schema).await;
+ write_schema_file(&new_table, &new_schema).await;
+
+ let batch = |table: &Table, json| {
+ let variant =
crate::variant::GenericVariant::parse_json(json).unwrap();
+ let ArrowDataType::Struct(fields) = variant_arrow_type() else {
+ unreachable!()
+ };
+ let payload = StructArray::try_new(
+ fields,
+ vec![
+ Arc::new(BinaryArray::from(vec![Some(variant.value())])),
+
Arc::new(BinaryArray::from(vec![Some(variant.metadata())])),
+ ],
+ None,
+ )
+ .unwrap();
+ RecordBatch::try_new(
+ build_target_arrow_schema(table.schema().fields()).unwrap(),
+ vec![Arc::new(Int32Array::from(vec![1])), Arc::new(payload)],
+ )
+ .unwrap()
+ };
+ write_commit(&old_table, &batch(&old_table,
r#"{"x":"invalid"}"#)).await;
+ write_commit(&new_table, &batch(&new_table, r#"{"x":1.5}"#)).await;
+
+ let mut read_builder = new_table.new_read_builder();
+ read_builder.with_read_type(vec![
+ new_table.schema().fields()[0].clone(),
+ DataField::new(
+ 1,
+ "payload".to_string(),
+ DataType::Row(
+ variant_extraction_row(
+ true,
+ [(
+ DataType::Float(FloatType::new()),
+ "$.x".to_string(),
+ true,
+ "UTC".to_string(),
+ )],
+ )
+ .unwrap(),
+ ),
+ ),
+ ]);
+ let plan = read_builder.new_scan().plan().await.unwrap();
+ let batches = read_builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(int_column(&batches, "id"), vec![1]);
+ let payload = batches[0]
+ .column_by_name("payload")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap();
+ let x = payload
+ .column(0)
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap();
+ assert!(!payload.is_null(0));
+ assert!(!x.is_null(0));
+ assert_eq!(x.value(0), 1.5);
+ }
+
#[test]
fn kv_row_layout_preserves_file_schema_key_names_and_fields() {
let fields = vec![
diff --git a/crates/paimon/src/variant.rs b/crates/paimon/src/variant.rs
index a316c929..829e9e95 100644
--- a/crates/paimon/src/variant.rs
+++ b/crates/paimon/src/variant.rs
@@ -1297,6 +1297,19 @@ fn parse_path(path: &str) -> Result<Vec<PathSegment>> {
Ok(segments)
}
+/// Return the literal key selected by a one-segment object path.
+///
+/// Variant extraction read types carry SQL-style paths. The vectorized
+/// numeric projection can batch literal top-level keys, while nested paths
+/// continue through the general `variant_get` implementation.
+pub(crate) fn top_level_path_key(path: &str) -> Result<Option<String>> {
+ let segments = parse_path(path)?;
+ Ok(match segments.as_slice() {
+ [PathSegment::Key(key)] => Some(key.clone()),
+ _ => None,
+ })
+}
+
#[derive(Clone)]
struct FieldEntry {
key: String,
@@ -2708,9 +2721,7 @@ fn cast_variant_to_extraction_value(
.and_then(|value| i32::try_from(value).ok())
.map(ShreddedValue::Int32),
VariantScalarSchema::Int64 =>
cast_variant_to_i64(variant).map(ShreddedValue::Int64),
- VariantScalarSchema::Float32 => {
- cast_variant_to_f64(variant).map(|value|
ShreddedValue::Float32(value as f32))
- }
+ VariantScalarSchema::Float32 =>
cast_variant_to_f32(variant).map(ShreddedValue::Float32),
VariantScalarSchema::Float64 =>
cast_variant_to_f64(variant).map(ShreddedValue::Float64),
VariantScalarSchema::Decimal { precision, scale } => {
cast_variant_to_decimal(variant, *precision,
*scale).map(ShreddedValue::Decimal128)
@@ -2798,6 +2809,31 @@ fn cast_variant_to_f64(variant: VariantRef<'_>) ->
Option<f64> {
}
}
+pub(crate) fn cast_variant_to_f32(variant: VariantRef<'_>) -> Option<f32> {
+ match variant.kind().ok()? {
+ VariantKind::Boolean => Some(if variant.get_boolean().ok()? {
+ 1.0
+ } else {
+ 0.0
+ }),
+ VariantKind::Long => Some(variant.get_long().ok()? as f32),
+ VariantKind::Float => variant.get_float().ok(),
+ VariantKind::Double => Some(variant.get_double().ok()? as f32),
+ VariantKind::Decimal => {
+ let decimal = variant.get_decimal().ok()?;
+ Some((decimal.unscaled as f64 / 10f64.powi(decimal.scale as i32))
as f32)
+ }
+ // Java Float.parseFloat removes only leading/trailing characters <=
U+0020.
+ VariantKind::String => variant
+ .get_string()
+ .ok()?
+ .trim_matches(|c| c <= '\u{20}')
+ .parse::<f32>()
+ .ok(),
+ _ => None,
+ }
+}
+
fn cast_variant_to_string(variant: VariantRef<'_>) -> Option<String> {
match variant.kind().ok()? {
VariantKind::Object | VariantKind::Array => variant.to_json().ok(),
@@ -3637,4 +3673,65 @@ mod tests {
"2.5 truncates to 2"
);
}
+
+ #[test]
+ fn
float32_projection_casts_supported_sources_without_reinterpreting_temporal_values()
{
+ let variant = GenericVariant::parse_json(
+
r#"{"boolean":true,"integer":27,"decimal":1.25,"string":"3.5","invalid":"bad"}"#,
+ )
+ .unwrap();
+ for (path, expected) in [
+ ("$.boolean", Some(1.0)),
+ ("$.integer", Some(27.0)),
+ ("$.decimal", Some(1.25)),
+ ("$.string", Some(3.5)),
+ ("$.invalid", None),
+ ] {
+ assert_eq!(
+ cast_variant_to_f32(variant.get_path(path).unwrap().unwrap()),
+ expected
+ );
+ }
+
+ let mut date = VariantBuilder::new();
+ date.append_date(20_000);
+ assert_eq!(
+ cast_variant_to_f32(date.result().unwrap().as_ref().unwrap()),
+ None
+ );
+
+ let mut timestamp = VariantBuilder::new();
+ timestamp.append_timestamp(1_700_000_000_123_456);
+ assert_eq!(
+ cast_variant_to_f32(timestamp.result().unwrap().as_ref().unwrap()),
+ None
+ );
+ }
+
+ #[test]
+ fn float32_projection_uses_java_string_whitespace_rules() {
+ let variant = GenericVariant::parse_json(
+ r#"{"ascii":" \t1.5\n
","control":"\u00001.5\u001f","nbsp":"\u00a01.5\u00a0"}"#,
+ )
+ .unwrap();
+ let float_type = DataType::Float(crate::spec::FloatType::new());
+
+ for path in ["$.ascii", "$.control"] {
+ let value = variant.get_path(path).unwrap().unwrap();
+ assert_eq!(
+ cast_variant_to_shredded_value(value, &float_type,
true).unwrap(),
+ Some(ShreddedValue::Float32(1.5))
+ );
+ }
+
+ let nbsp = variant.get_path("$.nbsp").unwrap().unwrap();
+ assert_eq!(
+ cast_variant_to_shredded_value(nbsp, &float_type, false).unwrap(),
+ None
+ );
+ assert!(matches!(
+ cast_variant_to_shredded_value(nbsp, &float_type, true),
+ Err(Error::DataInvalid { .. })
+ ));
+ }
}
diff --git a/crates/paimon/src/variant/numeric.rs
b/crates/paimon/src/variant/numeric.rs
index 34ab0bea..8c797481 100644
--- a/crates/paimon/src/variant/numeric.rs
+++ b/crates/paimon/src/variant/numeric.rs
@@ -35,6 +35,12 @@ struct ProjectedField {
outputs: Vec<usize>,
}
+#[derive(Clone, Copy)]
+enum Float32ProjectionMode<'a> {
+ Numeric,
+ Cast { fail_on_error: &'a [bool] },
+}
+
impl VariantFloat32Projection {
pub(crate) fn new(metadata: &[u8], fields: &[String]) -> Result<Self> {
validate_metadata(metadata)?;
@@ -75,6 +81,43 @@ impl VariantFloat32Projection {
if output.len() != self.output_width {
return data_invalid("Invalid Variant numeric output width");
}
+ self.extract_float32_impl(
+ value,
+ metadata,
+ offsets,
+ output,
+ Float32ProjectionMode::Numeric,
+ )
+ }
+
+ pub(crate) fn extract_float32_cast(
+ &mut self,
+ value: &[u8],
+ metadata: &[u8],
+ fail_on_error: &[bool],
+ offsets: &mut Vec<usize>,
+ output: &mut [Option<f32>],
+ ) -> Result<()> {
+ if output.len() != self.output_width || fail_on_error.len() !=
self.output_width {
+ return data_invalid("Invalid Variant float32 projection width");
+ }
+ self.extract_float32_impl(
+ value,
+ metadata,
+ offsets,
+ output,
+ Float32ProjectionMode::Cast { fail_on_error },
+ )
+ }
+
+ fn extract_float32_impl(
+ &mut self,
+ value: &[u8],
+ metadata: &[u8],
+ offsets: &mut Vec<usize>,
+ output: &mut [Option<f32>],
+ mode: Float32ProjectionMode<'_>,
+ ) -> Result<()> {
output.fill(None);
if value_kind(value, 0)? != VariantKind::Object {
validate_payload(value, metadata)?;
@@ -139,7 +182,27 @@ impl VariantFloat32Projection {
return data_invalid("Malformed Variant child size");
}
let child = VariantRef::new_at(value, metadata, child_pos)?;
- let numeric = numeric_to_float32(child, &projected.name)?;
+ let numeric = match mode {
+ Float32ProjectionMode::Numeric => numeric_to_float32(child,
&projected.name)?,
+ Float32ProjectionMode::Cast { fail_on_error } => {
+ if child.is_null()? {
+ continue;
+ }
+ let numeric = cast_variant_to_f32(child);
+ if numeric.is_none()
+ && projected
+ .outputs
+ .iter()
+ .any(|output_index| fail_on_error[*output_index])
+ {
+ return data_invalid(format!(
+ "Cannot cast Variant value to {:?}",
+ DataType::Float(FloatType::new())
+ ));
+ }
+ numeric
+ }
+ };
for output_index in &projected.outputs {
output[*output_index] = numeric;
}