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 976e408 [arrow] Read inline VECTOR columns via Arrow FixedSizeList
(#412)
976e408 is described below
commit 976e4082a06611944d46af85bec4f1603b69c5c1
Author: Junrui Lee <[email protected]>
AuthorDate: Mon Jun 29 19:21:44 2026 +0800
[arrow] Read inline VECTOR columns via Arrow FixedSizeList (#412)
---
crates/paimon/src/arrow/format/mod.rs | 3 +
crates/paimon/src/arrow/format/parquet.rs | 61 ++++++++++
crates/paimon/src/arrow/mod.rs | 108 +++++++++++++++++-
crates/paimon/src/spec/types.rs | 8 +-
crates/paimon/src/table/data_file_reader.rs | 165 ++++++++++++++++++++++++++++
5 files changed, 337 insertions(+), 8 deletions(-)
diff --git a/crates/paimon/src/arrow/format/mod.rs
b/crates/paimon/src/arrow/format/mod.rs
index 1593f5a..cec1be9 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -25,6 +25,9 @@ mod row;
#[cfg(feature = "vortex")]
mod vortex;
+#[cfg(test)]
+pub(crate) use parquet::ParquetFormatWriter;
+
use crate::io::{FileRead, OutputFile};
use crate::spec::{DataField, Predicate};
use crate::table::{ArrowRecordBatchStream, RowRange};
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index bec9466..d7bfc5b 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -1410,4 +1410,65 @@ mod tests {
let total_rows: usize = reader.into_iter().map(|r|
r.unwrap().num_rows()).sum();
assert_eq!(total_rows, 5);
}
+
+ #[tokio::test]
+ async fn test_parquet_inline_fixed_size_list_roundtrip() {
+ use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
+ use arrow_array::{Array, FixedSizeListArray, Float32Array};
+
+ // Build a FixedSizeList<Float32, 2> column: row 0 = [1.0, 2.0], row 1
= null.
+ let mut builder = FixedSizeListBuilder::new(Float32Builder::new(),
2).with_field(Arc::new(
+ ArrowField::new("element", ArrowDataType::Float32, true),
+ ));
+ builder.values().append_value(1.0);
+ builder.values().append_value(2.0);
+ builder.append(true);
+ builder.values().append_value(0.0);
+ builder.values().append_value(0.0);
+ builder.append(false); // null vector row
+ let vec_array = builder.finish();
+
+ let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
+ "embedding",
+ ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new("element", ArrowDataType::Float32,
true)),
+ 2,
+ ),
+ true,
+ )]));
+ let batch = RecordBatch::try_new(schema.clone(),
vec![Arc::new(vec_array)]).unwrap();
+
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/test_parquet_inline_vector.parquet";
+ let output = file_io.new_output(path).unwrap();
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ ParquetFormatWriter::new(&output, schema.clone(), "zstd", 1)
+ .await
+ .unwrap(),
+ );
+ writer.write(&batch).await.unwrap();
+ writer.close().await.unwrap();
+
+ let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
+ let reader =
+
parquet::arrow::arrow_reader::ParquetRecordBatchReader::try_new(bytes,
1024).unwrap();
+ let batches: Vec<RecordBatch> = reader.into_iter().map(|r|
r.unwrap()).collect();
+ assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
+
+ let col = batches[0].column(0);
+ let fsl = col
+ .as_any()
+ .downcast_ref::<FixedSizeListArray>()
+ .expect("column should be FixedSizeListArray");
+ assert_eq!(fsl.value_length(), 2);
+ assert!(fsl.is_valid(0));
+ assert!(fsl.is_null(1)); // null vector row preserved
+
+ let row0 = fsl.value(0);
+ let floats = row0
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .expect("child should be Float32Array");
+ assert_eq!(floats.values(), &[1.0, 2.0]);
+ }
}
diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index fc6451f..b897c8c 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -22,7 +22,7 @@ pub(crate) mod schema_evolution;
use crate::spec::{
ArrayType, BigIntType, BooleanType, DataField, DataType as PaimonDataType,
DateType,
DecimalType, DoubleType, FloatType, IntType, LocalZonedTimestampType,
MapType, RowType,
- SmallIntType, TimeType, TimestampType, TinyIntType, VarBinaryType,
VarCharType,
+ SmallIntType, TimeType, TimestampType, TinyIntType, VarBinaryType,
VarCharType, VectorType,
};
use arrow_schema::DataType as ArrowDataType;
use arrow_schema::{Field as ArrowField, Schema as ArrowSchema, TimeUnit};
@@ -117,10 +117,19 @@ pub fn paimon_type_to_arrow(dt: &PaimonDataType) ->
crate::Result<ArrowDataType>
.collect::<crate::Result<Vec<_>>>()?;
ArrowDataType::Struct(fields.into())
}
- PaimonDataType::Vector(_) => {
- return Err(crate::Error::Unsupported {
- message: "VectorType is not yet supported in arrow
conversion".to_string(),
- })
+ PaimonDataType::Vector(v) => {
+ let element_type = paimon_type_to_arrow(v.element_type())?;
+ // VectorType::MAX_LENGTH is i32::MAX as u32 (validated at
construction),
+ // so the length always fits in the i32 Arrow FixedSizeList size.
+ let length = v.length() as i32;
+ ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new(
+ "element",
+ element_type,
+ v.element_type().is_nullable(),
+ )),
+ length,
+ )
}
})
}
@@ -226,6 +235,17 @@ pub fn arrow_to_paimon_type(
paimon_fields,
)))
}
+ ArrowDataType::FixedSizeList(field, size) => {
+ let element = arrow_to_paimon_type(field.data_type(),
field.is_nullable())?;
+ // FixedSizeList size is i32; reject non-positive sizes with a
clear error
+ // rather than casting a negative into a huge u32.
+ let length = u32::try_from(*size).map_err(|_|
crate::Error::DataTypeInvalid {
+ message: format!("Invalid vector (FixedSizeList) length:
{size}"),
+ })?;
+ Ok(PaimonDataType::Vector(VectorType::try_new(
+ nullable, length, element,
+ )?))
+ }
_ => Err(crate::Error::Unsupported {
message: format!("Unsupported Arrow type for Paimon conversion:
{arrow_type:?}"),
}),
@@ -465,6 +485,84 @@ mod tests {
assert!(result.is_err());
}
+ #[test]
+ fn test_vector_to_arrow_nullable() {
+ let paimon = PaimonDataType::Vector(
+ VectorType::try_new(true, 128,
PaimonDataType::Float(FloatType::new())).unwrap(),
+ );
+ let expected = ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new("element", ArrowDataType::Float32, true)),
+ 128,
+ );
+ assert_paimon_to_arrow(&paimon, &expected);
+ }
+
+ #[test]
+ fn test_vector_to_arrow_not_null_vector_has_float64_child() {
+ // The vector's own non-nullability is not represented in the
ArrowDataType;
+ // only the child element type and length are.
+ let paimon = PaimonDataType::Vector(
+ VectorType::try_new(false, 2,
PaimonDataType::Double(DoubleType::new())).unwrap(),
+ );
+ let arrow = paimon_type_to_arrow(&paimon).unwrap();
+ match arrow {
+ ArrowDataType::FixedSizeList(field, size) => {
+ assert_eq!(size, 2);
+ assert_eq!(field.data_type(), &ArrowDataType::Float64);
+ }
+ other => panic!("expected FixedSizeList, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_arrow_fixed_size_list_to_vector() {
+ let arrow = ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new("element", ArrowDataType::Float32, true)),
+ 4,
+ );
+ let paimon = arrow_to_paimon_type(&arrow, true).unwrap();
+ match paimon {
+ PaimonDataType::Vector(v) => {
+ assert_eq!(v.length(), 4);
+ assert_eq!(v.element_type(),
&PaimonDataType::Float(FloatType::new()));
+ }
+ other => panic!("expected Vector, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_arrow_fixed_size_list_invalid_element_rejected() {
+ let arrow = ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new("element", ArrowDataType::Utf8, true)),
+ 4,
+ );
+ let err = arrow_to_paimon_type(&arrow, true);
+ assert!(matches!(err, Err(crate::Error::DataTypeInvalid { .. })));
+ }
+
+ #[test]
+ fn test_arrow_fixed_size_list_zero_length_rejected() {
+ let arrow = ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new("element", ArrowDataType::Float32, true)),
+ 0,
+ );
+ let err = arrow_to_paimon_type(&arrow, true);
+ assert!(matches!(err, Err(crate::Error::DataTypeInvalid { .. })));
+ }
+
+ #[test]
+ fn test_arrow_fixed_size_list_negative_length_rejected() {
+ // A negative FixedSizeList size IS directly constructible in Arrow,
so it
+ // exercises the `u32::try_from(*size)` negative branch in the
conversion
+ // (distinct from the zero case, which `VectorType::try_new` rejects).
+ let arrow = ArrowDataType::FixedSizeList(
+ Arc::new(ArrowField::new("element", ArrowDataType::Float32, true)),
+ -1,
+ );
+ let err = arrow_to_paimon_type(&arrow, true);
+ assert!(matches!(err, Err(crate::Error::DataTypeInvalid { .. })));
+ }
+
#[test]
fn test_arrow_fields_to_paimon_ids() {
let fields = vec![
diff --git a/crates/paimon/src/spec/types.rs b/crates/paimon/src/spec/types.rs
index 4dbe8e3..7269aff 100644
--- a/crates/paimon/src/spec/types.rs
+++ b/crates/paimon/src/spec/types.rs
@@ -2623,11 +2623,13 @@ mod tests {
}
#[test]
- fn test_datatype_vector_arrow_unsupported() {
+ fn test_datatype_vector_arrow_conversion() {
+ // PR 2 lifted the PR 1 boundary: Vector now converts to an Arrow
+ // FixedSizeList (see arrow/mod.rs for the full conversion test
matrix).
let dt = DataType::Vector(
VectorType::try_new(true, 4,
DataType::Float(FloatType::new())).unwrap(),
);
- let err = crate::arrow::paimon_type_to_arrow(&dt);
- assert!(matches!(err, Err(crate::Error::Unsupported { .. })));
+ let arrow = crate::arrow::paimon_type_to_arrow(&dt).unwrap();
+ assert!(matches!(arrow, arrow_schema::DataType::FixedSizeList(_, 4)));
}
}
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index a780446..111e23e 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -1066,3 +1066,168 @@ mod tests {
assert_eq!(collect_ids(&batches), vec![11]);
}
}
+
+/// Parquet-only end-to-end tests for the inline VECTOR (`FixedSizeList`) read
path.
+///
+/// This module is deliberately NOT gated behind the `mosaic` feature: the
vector
+/// read capability is core parquet support, so these tests must run under a
plain
+/// `cargo test -p paimon`.
+#[cfg(test)]
+mod vector_parquet_tests {
+ use super::*;
+ use crate::arrow::format::FormatFileWriter;
+ use crate::arrow::format::ParquetFormatWriter;
+ use crate::io::FileIOBuilder;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{DataFileMeta, DataType, FloatType, VectorType};
+ use crate::table::source::DataSplitBuilder;
+ use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
+ use arrow_array::{FixedSizeListArray, Float32Array, RecordBatch};
+ use arrow_schema::{DataType as ArrowDataType, Field as ArrowField};
+ use futures::TryStreamExt;
+
+ fn data_file(file_name: &str, file_size: i64, row_count: i64, schema_id:
i64) -> DataFileMeta {
+ DataFileMeta {
+ file_name: file_name.to_string(),
+ file_size,
+ row_count,
+ min_key: Vec::new(),
+ max_key: Vec::new(),
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id,
+ level: 0,
+ extra_files: Vec::new(),
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: None,
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: None,
+ write_cols: None,
+ }
+ }
+
+ /// TRUE end-to-end: write a parquet data file containing a
`FixedSizeList<Float32, 2>`
+ /// column, then read it back through `DataFileReader` using a Paimon
`read_type` whose
+ /// field is `DataType::Vector`. This exercises
`build_target_arrow_schema`, the parquet
+ /// format dispatch (by `.parquet` extension), and the read path's
pass-through/cast
+ /// logic — not just a raw Arrow/parquet round-trip.
+ #[tokio::test]
+ async fn test_datafilereader_inline_vector_column_e2e() {
+ // Paimon read schema: a single nullable VECTOR<FLOAT> column of
length 2.
+ let vector_type = VectorType::try_new(true, 2,
DataType::Float(FloatType::new())).unwrap();
+ let read_fields = vec![DataField::new(
+ 0,
+ "embedding".to_string(),
+ DataType::Vector(vector_type),
+ )];
+
+ // Build the physical Arrow data via the Paimon -> Arrow conversion
under test,
+ // so the parquet file matches what the read path expects to
materialize.
+ let arrow_schema = build_target_arrow_schema(&read_fields).unwrap();
+
+ // Build a FixedSizeList<Float32, 2> column:
+ // row 0 = [1.0, 2.0] (non-null)
+ // row 1 = null (null vector row)
+ // row 2 = [3.0, 4.0] (non-null)
+ let mut builder = FixedSizeListBuilder::new(Float32Builder::new(),
2).with_field(Arc::new(
+ ArrowField::new("element", ArrowDataType::Float32, true),
+ ));
+ builder.values().append_value(1.0);
+ builder.values().append_value(2.0);
+ builder.append(true);
+ builder.values().append_value(0.0);
+ builder.values().append_value(0.0);
+ builder.append(false); // null vector row
+ builder.values().append_value(3.0);
+ builder.values().append_value(4.0);
+ builder.append(true);
+ let vec_array = builder.finish();
+ let batch = RecordBatch::try_new(arrow_schema.clone(),
vec![Arc::new(vec_array)]).unwrap();
+
+ // Write the data file as parquet into the split's bucket path.
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/vector_inline_e2e";
+ let bucket_path = format!("{table_path}/bucket-0");
+ let file_name = "part-0.parquet";
+ let file_path = format!("{bucket_path}/{file_name}");
+ let output = file_io.new_output(&file_path).unwrap();
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ ParquetFormatWriter::new(&output, arrow_schema.clone(), "zstd", 1)
+ .await
+ .unwrap(),
+ );
+ writer.write(&batch).await.unwrap();
+ let file_size = writer.close().await.unwrap();
+
+ // Build a split whose data file's schema_id matches the table
schema_id, so the
+ // read path uses `read_type` directly (no SchemaManager lookup
needed).
+ let table_schema_id = 1;
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(bucket_path)
+ .with_total_buckets(1)
+ .with_data_files(vec![data_file(
+ file_name,
+ file_size as i64,
+ 3,
+ table_schema_id,
+ )])
+ .build()
+ .unwrap();
+
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ let reader = DataFileReader::new(
+ file_io,
+ schema_manager,
+ table_schema_id,
+ read_fields.clone(),
+ read_fields.clone(),
+ Vec::new(),
+ );
+ let batches = reader
+ .read(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+ assert_eq!(total_rows, 3);
+ let result = &batches[0];
+ assert_eq!(result.num_columns(), 1);
+ assert_eq!(result.schema().field(0).name(), "embedding");
+
+ // The materialized column must be a FixedSizeListArray with the right
length,
+ // child Float32 values, and null bitmap (one non-null and one null
row).
+ let fsl = result
+ .column(0)
+ .as_any()
+ .downcast_ref::<FixedSizeListArray>()
+ .expect("column should materialize as FixedSizeListArray");
+ assert_eq!(fsl.value_length(), 2);
+ assert!(fsl.is_valid(0));
+ assert!(fsl.is_null(1)); // null vector row preserved through the read
path
+ assert!(fsl.is_valid(2));
+
+ let row0 = fsl.value(0);
+ let floats0 = row0
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .expect("child should be Float32Array");
+ assert_eq!(floats0.values(), &[1.0, 2.0]);
+
+ let row2 = fsl.value(2);
+ let floats2 = row2
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .expect("child should be Float32Array");
+ assert_eq!(floats2.values(), &[3.0, 4.0]);
+ }
+}